@conduction/nextcloud-vue 2.63.0 → 2.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +154 -7
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +119 -42
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +87 -0
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +26 -24
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +61 -16
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
- package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +20 -0
- package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -2
- package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -0
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +150 -133
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +66 -14
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +23 -14
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +81 -4
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +22 -5
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
- package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +52 -2
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +6 -6
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
- package/dist/esm/l10n/en.json.js +2 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +2 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +331 -81
- package/dist/esm/utils/schemaRefSlug.js +19 -3
- package/dist/esm/utils/schemaRefSlug.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +6933 -5958
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +331 -81
- package/l10n/en.json +2 -0
- package/l10n/nl.json +2 -0
- package/package.json +1 -1
- package/src/components/CnAppNav/CnAppNav.vue +297 -11
- package/src/components/CnAppRoot/CnAppRoot.md +4 -0
- package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
- package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
- package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
- package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
- package/src/components/CnDashboardPage/CnDashboardPage.vue +43 -2
- package/src/components/CnDataTable/CnDataTable.vue +45 -1
- package/src/components/CnDetailPage/CnDetailPage.md +4 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +60 -1
- package/src/components/CnHeaderWidget/CnHeaderWidget.vue +116 -6
- package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
- package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
- package/src/components/CnStagesWidget/CnStagesWidget.vue +180 -2
- package/src/components/CnStagesWidget/stagesModel.js +5 -2
- package/src/components/CnStatWidget/CnStatWidget.vue +57 -3
- package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
- package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
- package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
- package/src/css/detail-page.css +10 -4
- package/src/css/kpi-card.css +24 -1
- package/src/css/table.css +18 -6
- package/src/schemas/app-manifest-v2.schema.json +113 -3
- package/src/utils/schemaRefSlug.js +19 -3
- package/src/utils/validateManifestV2.compiled.js +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CnStatWidget.vue3.js","sources":["../../../../src/components/CnStatWidget/CnStatWidget.vue?vue&type=template&id=9c5d29a2&lang.js"],"sourcesContent":["<!--\n SPDX-FileCopyrightText: 2026 Conduction B.V.\n SPDX-License-Identifier: EUPL-1.2\n\n CnStatWidget renders the CANONICAL KPI card (src/css/kpi-card.css). This tile\n and CnStatsBlock used to be two different-looking KPIs — a transparent\n icon-beside-number row here, a grey card with a circular icon and a large\n coloured number there — so OpenCatalogi's and dossiq's dashboards did not\n match. Both now render the same `cn-kpi-card` markup from the same\n stylesheet. The legacy `cn-stat-widget*` classes stay on the same elements so\n app CSS targeting them keeps working.\n\n Keep this comment OUT of <template>: a comment node beside the root element\n makes the component multi-root in Vue 3, and a multi-root component has no\n root for its class bindings to land on.\n-->\n<template>\n\t<component\n\t\t:is=\"linkTag\"\n\t\tclass=\"cn-kpi-card cn-stat-widget\"\n\t\t:class=\"[\n\t\t\t{ 'cn-kpi-card--clickable': isLinked, 'cn-stat-widget--linked': isLinked },\n\t\t\t'cn-kpi-card--' + cardLayout,\n\t\t\tflat ? 'cn-kpi-card--flat' : 'cn-kpi-card--filled',\n\t\t]\"\n\t\tv-bind=\"linkAttrs\">\n\t\t<div\n\t\t\tv-if=\"resolvedIcon\"\n\t\t\tclass=\"cn-kpi-card__icon cn-stat-widget__icon\"\n\t\t\t:style=\"iconCircleStyle\">\n\t\t\t<CnWidgetIcon :name=\"resolvedIcon\" :size=\"24\" />\n\t\t</div>\n\n\t\t<div class=\"cn-kpi-card__body cn-stat-widget__body\">\n\t\t\t<div v-if=\"content.label || rangePresets.length\" class=\"cn-kpi-card__title cn-stat-widget__label\">\n\t\t\t\t<span v-if=\"content.label\" :title=\"resolvedLabel\">{{ resolvedLabel }}</span>\n\n\t\t\t\t<!-- Per-tile range override. Rendered ONLY when the tile declares its\n\t\t\t\t own `content.dateRange.presets`; a tile that merely follows the\n\t\t\t\t dashboard range shows no control, because the page already has one\n\t\t\t\t and two controls for one range read as two ranges. Stops click\n\t\t\t\t propagation so choosing a range inside a linked tile does not also\n\t\t\t\t navigate. -->\n\t\t\t\t<select\n\t\t\t\t\tv-if=\"rangePresets.length\"\n\t\t\t\t\tclass=\"cn-stat-widget__range\"\n\t\t\t\t\tdata-testid=\"cn-stat-widget-range\"\n\t\t\t\t\t:aria-label=\"rangeAriaLabel\"\n\t\t\t\t\t:value=\"activeRangePreset\"\n\t\t\t\t\t@click.stop.prevent\n\t\t\t\t\t@change=\"selectRange($event.target.value)\">\n\t\t\t\t\t<option\n\t\t\t\t\t\tv-for=\"preset in rangePresets\"\n\t\t\t\t\t\t:key=\"preset.id\"\n\t\t\t\t\t\t:value=\"preset.id\">\n\t\t\t\t\t\t{{ effectiveTranslate(preset.label || preset.id) }}\n\t\t\t\t\t</option>\n\t\t\t\t</select>\n\t\t\t</div>\n\n\t\t\t<div class=\"cn-kpi-card__value-row cn-stat-widget__value-row\">\n\t\t\t\t<NcLoadingIcon v-if=\"displayLoading\" :size=\"22\" />\n\t\t\t\t<span v-else-if=\"displayError\" class=\"cn-stat-widget__error\" :title=\"displayError\">—</span>\n\t\t\t\t<template v-else>\n\t\t\t\t\t<!-- Badge mode: the value is a state name, so it reads as the\n\t\t\t\t\t library's status pill rather than as a headline number. -->\n\t\t\t\t\t<CnStatusBadge\n\t\t\t\t\t\tv-if=\"isBadge\"\n\t\t\t\t\t\tclass=\"cn-stat-widget__badge\"\n\t\t\t\t\t\tdata-testid=\"cn-stat-widget-badge\"\n\t\t\t\t\t\t:label=\"shownValue\"\n\t\t\t\t\t\t:variant=\"badgeVariant\" />\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-else\n\t\t\t\t\t\tclass=\"cn-kpi-card__value cn-stat-widget__value\"\n\t\t\t\t\t\t:data-testid=\"isCountdown ? 'cn-stat-widget-countdown' : null\"\n\t\t\t\t\t\t:class=\"{ 'cn-kpi-card__value--text': isTextValue || shownValueIsText }\"\n\t\t\t\t\t\t:title=\"shownValueIsText ? shownValue : (isTextValue ? String(displayValue) : null)\"\n\t\t\t\t\t\t:style=\"valueStyle\">\n\t\t\t\t\t\t{{ shownValue }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"formattedLimit !== ''\"\n\t\t\t\t\t\tclass=\"cn-stat-widget__limit\"\n\t\t\t\t\t\tdata-testid=\"cn-stat-widget-limit\">\n\t\t\t\t\t\t/ {{ formattedLimit }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"trendPct !== null\"\n\t\t\t\t\t\tclass=\"cn-stat-widget__trend\"\n\t\t\t\t\t\tdata-testid=\"cn-stat-widget-trend\"\n\t\t\t\t\t\t:style=\"{ color: trendColor }\">\n\t\t\t\t\t\t<component :is=\"trendIcon\" :size=\"14\" />\n\t\t\t\t\t\t{{ formattedTrend }}\n\t\t\t\t\t</span>\n\t\t\t\t</template>\n\t\t\t\t<span v-if=\"!displayLoading && !displayError && content.caption\" class=\"cn-kpi-card__label cn-stat-widget__caption\">\n\t\t\t\t\t{{ resolvedCaption }}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t</div>\n\t</component>\n</template>\n\n<script>\nimport { subscribe, unsubscribe } from '@nextcloud/event-bus'\nimport { NcLoadingIcon } from '@nextcloud/vue'\nimport { inject, ref } from 'vue'\nimport TrendingDown from 'vue-material-design-icons/TrendingDown.vue'\nimport TrendingNeutral from 'vue-material-design-icons/TrendingNeutral.vue'\nimport TrendingUp from 'vue-material-design-icons/TrendingUp.vue'\nimport CnStatusBadge from '../CnStatusBadge/CnStatusBadge.vue'\nimport CnWidgetIcon from '../CnWidgetGrid/CnWidgetIcon.vue'\nimport { fetchSharedResponse, getByPath, useEndpointSource } from '../../composables/useEndpointSource.js'\nimport widgetLink from '../../mixins/widgetLink.js'\nimport { useObjectStore } from '../../store/useObjectStore.js'\nimport { resolveObjectOpType } from '../../utils/actionsDispatcher.js'\nimport { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'\nimport { formatMetricValue, unwrapAppConfig } from '../../utils/formatMetric.js'\nimport { dropOptionalUnresolved, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'\nimport { STATUS_TEXT_COLORS } from '../../utils/statusColors.js'\nimport { evaluateVisibleWhenLocal, readVisibleWhenPath } from '../../utils/visibleWhen.js'\n\n// The canonical KPI look lives in one shared stylesheet, imported by BOTH\n// this component and CnStatsBlock, so the two cannot drift apart again.\n// Importing it here also means the look arrives without the consuming app\n// having pulled in the library's global css/index.css.\nimport '../../css/kpi-card.css'\n// Badge mode renders CnStatusBadge, whose colours live in the global badge\n// stylesheet. Imported here for the same reason as kpi-card.css above.\nimport '../../css/badge.css'\n\nconst PAGE_REFRESH_CHANNEL = 'cn:page:refresh'\n\n/**\n * Variant → CSS colour token map for the `variantWhen` threshold rules.\n * `danger` is accepted as an alias of `error` (the doriath KPI-card\n * vocabulary); `default` keeps the widget's configured colours.\n *\n * @type {Record<string, string>}\n */\n// These paint the number, so they take the text-on-background tokens.\nconst VARIANT_COLORS = {\n\tdefault: '',\n\tprimary: 'var(--color-primary-element)',\n\tsuccess: STATUS_TEXT_COLORS.success,\n\twarning: STATUS_TEXT_COLORS.warning,\n\terror: STATUS_TEXT_COLORS.error,\n\tdanger: STATUS_TEXT_COLORS.error,\n}\n\n/**\n * The variants CnStatusBadge accepts. Badge mode normalises every tile\n * variant onto this list: `danger` becomes `error`, anything unknown is\n * skipped so the next rule in line decides.\n *\n * @type {string[]}\n */\nconst BADGE_VARIANTS = ['default', 'primary', 'success', 'warning', 'error', 'info']\n\n/**\n * A bare `YYYY-MM-DD`, the shape OpenRegister stores a date property in.\n *\n * It is matched BEFORE `new Date()` sees it on purpose. `new Date('2026-09-26')`\n * is specified to parse a date-only string as UTC midnight, so west of\n * Greenwich it lands on the 25th local and every countdown built on it is a\n * day short. A date the schema calls a date has no time and no zone, so it is\n * read as the local calendar day it names.\n *\n * @type {RegExp}\n */\nconst DATE_ONLY = /^(\\d{4})-(\\d{2})-(\\d{2})$/\n\n/** Milliseconds in a day, used only between two UTC-normalised midnights. */\nconst DAY_MS = 86400000\n\n/**\n * The local calendar day a value names, as a UTC midnight timestamp.\n *\n * WHY CALENDAR DAYS AND NOT ELAPSED MILLISECONDS. A deadline is a day on a\n * calendar, not an instant. Subtracting timestamps makes a deadline at 23:00\n * today read as \"0 days\" and one at 01:00 tomorrow read as \"0 days\" as well\n * (two hours apart), while 09:00 tomorrow reads as \"1 day\" — the same calendar\n * distance rendering as two different answers because of the time somebody\n * happened to type. So both ends are collapsed to the LOCAL calendar day they\n * fall on, and the difference is taken between those days.\n *\n * The collapse goes through `Date.UTC` rather than a local `Date`, because two\n * UTC midnights are always exactly `DAY_MS` apart, while two local midnights\n * across a DST change are 23 or 25 hours apart and a plain division then\n * rounds to the wrong day twice a year.\n *\n * @param {unknown} value A date string, a `Date`, or an epoch-milliseconds number.\n * @return {number|null} The UTC-midnight timestamp of that local day, or null when there is no readable date.\n */\nfunction calendarDay(value) {\n\tif (value === null || value === undefined || value === '') {\n\t\treturn null\n\t}\n\tif (typeof value === 'string') {\n\t\tconst parts = DATE_ONLY.exec(value.trim())\n\t\tif (parts) {\n\t\t\treturn Date.UTC(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))\n\t\t}\n\t}\n\tconst isDate = value instanceof Date\n\tif (!isDate && typeof value !== 'string' && !Number.isFinite(value)) {\n\t\t// A boolean or an object is not a mis-typed date, it is not a date at\n\t\t// all; `new Date(true)` would happily answer 1 January 1970.\n\t\treturn null\n\t}\n\tconst parsed = isDate ? value : new Date(value)\n\tif (Number.isNaN(parsed.getTime())) {\n\t\treturn null\n\t}\n\treturn Date.UTC(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())\n}\n\n/**\n * CnStatWidget — an abstract, manifest-configured KPI / single-statistic tile.\n *\n * Reads ONE scalar value from OpenRegister's ad-hoc aggregation endpoint\n * (`/apps/openregister/api/objects/aggregations/{register}/{schema}/value`)\n * given a `source` config (register, schema, metric, field, filter) and renders\n * it with a configurable label, icon, value colour, and number format. Nothing\n * about the data or presentation is hard-coded — every fleet app gets the same\n * editable KPI tile, replacing per-app coded KPI components (ADR-041).\n *\n * The widget is resolved by its registry type key `stat` (see\n * `CnStatWidget/index.js`); apps reference it from a manifest placement, e.g.\n * `{ id, widgetKey: 'stat', type: 'stat', content: { label, source, ... } }`.\n *\n * Example content blob:\n * ```js\n * content: {\n * label: 'Revenue',\n * icon: 'Cash',\n * valueColor: '#0082c9',\n * format: { style: 'currency', currency: 'EUR', decimals: 0 },\n * source: { register: 'pipelinq', schema: 'lead', metric: 'sum', field: 'value', filter: { status: 'won' } },\n * }\n * ```\n *\n * ENDPOINT BINDING (Wave 2, #91) — instead of an OpenRegister `source`, the\n * tile can bind to an arbitrary app REST endpoint through the shared\n * `useEndpointSource` engine (token-resolved params, per-(url+params)\n * request dedup + short-TTL cache, `cn:page:refresh` / `cn:widget:refresh`\n * subscription). Exactly ONE of `source` | `endpointSource` may be\n * configured (validator-enforced); when both slip through, `endpointSource`\n * wins. The pipelinq analytics-KPI contract in full:\n * ```js\n * content: {\n * label: 'Revenue',\n * icon: 'CashMultiple',\n * format: { style: 'currency', currency: 'EUR', decimals: 0 },\n * endpointSource: {\n * url: '/apps/pipelinq/api/analytics/commercial',\n * params: { period: '@workspace.datePreset?' },\n * },\n * valueField: 'revenue', // dot-path into the payload\n * previousField: 'previousPeriod.revenue', // → trend sublabel (arrow + % vs previous)\n * // deltaField: 'revenueDeltaPct', // OR a server-computed delta percent\n * goodDirection: 'up', // tints the trend good/bad (default 'up')\n * variantWhen: [ // first-match threshold styling\n * { op: 'gte', value: 100000, variant: 'success' },\n * { op: 'lt', value: 10000, variant: 'warning', icon: 'AlertOutline' },\n * ],\n * clickRoute: 'leads', // whole-tile click-through (alias of route)\n * }\n * ```\n *\n * BADGE MODE. `display: 'badge'` renders the value as a `CnStatusBadge` pill,\n * for a tile whose value is a state name rather than a count. The badge colour\n * can come from the RESOLVED ROW of an `objectField` reference, not only from\n * the displayed text: `resolve.variantField` names a field on the looked-up\n * row, and `resolve.variantMap` maps its value to a variant (without a map the\n * field value itself must be a variant name). `emptyText` replaces the dash\n * when the value is empty or its reference does not resolve. `overrides` test\n * the BOUND RECORD, first match wins, and replace the label, variant and icon,\n * so a record in a special state reads as that state:\n * ```js\n * content: {\n * label: 'Status',\n * display: 'badge',\n * emptyText: 'Unknown',\n * objectField: {\n * field: 'status',\n * resolve: {\n * register: 'dossiq',\n * schema: 'statusType',\n * labelField: 'name',\n * variantField: 'isFinal',\n * variantMap: { true: 'success', false: 'info' },\n * },\n * },\n * overrides: [\n * { when: { field: 'suspended' }, label: 'Suspended', variant: 'warning' },\n * ],\n * }\n * ```\n *\n * COUNTDOWN MODE. `display: 'countdown'` reads a DATE off the record (or off an\n * endpoint payload) and renders the time remaining, so a deadline is a\n * configured KPI tile rather than a component an app writes by hand:\n * ```js\n * content: {\n * label: 'Deadline',\n * display: 'countdown',\n * objectField: 'dueDate',\n * countdown: {\n * unit: 'days', // the only unit; anything else is read as days\n * warnAt: 14, // warning at or below this many days left\n * dangerAt: 5, // error at or below, and it wins over warnAt\n * pastLabel: 'Overdue by {n} days', // a date that has passed\n * emptyText: 'No deadline', // absent or unreadable date\n * },\n * }\n * ```\n * A passed date NEVER renders as a negative number: it renders `pastLabel` with\n * `{n}` as the number of days it is past, and it is always the error colour\n * whether or not `dangerAt` is set. Today is 0, not 1 and not −1, and renders\n * `todayLabel` (\"Today\"). The thresholds are INCLUSIVE. An absent or unreadable\n * date renders `countdown.emptyText`, falling back to `content.emptyText` and\n * then to the dash, never `NaN` or `Invalid Date`. `overrides` outrank the\n * countdown's own label and colour exactly as they outrank a badge's.\n */\nexport default {\n\tname: 'CnStatWidget',\n\n\tcomponents: {\n\t\tNcLoadingIcon,\n\t\tCnWidgetIcon,\n\t\tCnStatusBadge,\n\t\tTrendingUp,\n\t\tTrendingDown,\n\t\tTrendingNeutral,\n\t},\n\n\tmixins: [widgetLink],\n\n\tinject: {\n\t\t/**\n\t\t * Detail-page object context (`{ objectId, object, register, schema }`)\n\t\t * provided by CnDetailPage — enables `@objectId` / `@object.<field>`\n\t\t * filter tokens so a detail-page KPI can be scoped to the current\n\t\t * object. Null on dashboards (tokens then pass through unresolved).\n\t\t */\n\t\tcnObjectContext: { default: null },\n\t\t/**\n\t\t * v2 slot-grid detail context holder (`{ value: { objectData, schema,\n\t\t * objectType, objectId, register, store } | null }`) provided by\n\t\t * CnPageRenderer — backfills the object token context so\n\t\t * `@objectId` / `@object.<field>` resolve on detail surfaces where\n\t\t * CnDetailPage is not an ancestor (#91 Wave 3).\n\t\t */\n\t\tcnDetailObjectContext: { default: null },\n\t\t/**\n\t\t * Page-level workspace context (a reactive `{ <key>: value }` map)\n\t\t * provided by CnDashboardPage. Drives `@page.<param>` / `@workspace.<param>`\n\t\t * tokens in an `endpoint` source's URL / params (e.g. a period selector\n\t\t * the page renders that every endpoint KPI reads). Empty `{}` when no\n\t\t * dashboard ancestor provides one.\n\t\t */\n\t\tcnWorkspaceContext: { default: () => ({}) },\n\t\t/**\n\t\t * Page-level app config (a reactive `{ <key>: value }` map) provided by\n\t\t * CnDashboardPage / CnDetailPage. Drives `@config.<key>` tokens in an\n\t\t * `endpoint` source's URL / params AND in the `format.currency` /\n\t\t * `format.suffix` / `format.prefix` strings (e.g. the reporting currency\n\t\t * the setup wizard captures). Empty `{}` when no ancestor provides one.\n\t\t */\n\t\tcnAppConfig: { default: () => ({}) },\n\t\t/**\n\t\t * Translate function provided by CnAppRoot (the host app's\n\t\t * `translate`, scoped to its app id). Applied to the manifest-authored\n\t\t * `content.label` / `content.caption` so a KPI tile renders in the\n\t\t * user's Nextcloud language instead of the raw source string. Defaults\n\t\t * to an identity function so the widget stays usable standalone.\n\t\t *\n\t\t * @type {(key: string) => string}\n\t\t */\n\t\tcnTranslate: { default: () => (key) => key },\n\t\t/**\n\t\t * Reactive date range provided by an ancestor `CnDashboardPage` when its\n\t\t * `dateRange.enabled` is true — `{ from, to, preset }`, else null. Same\n\t\t * ref `CnChartWidget` injects, so a tile and a chart on one dashboard\n\t\t * always agree on the period. A tile follows it only once it declares\n\t\t * `content.dateRange` (see the `content` prop docs for why).\n\t\t *\n\t\t * @type {{value: ({from: string, to: string, preset: string}|null)}}\n\t\t */\n\t\tcnDashboardDateRange: { default: () => ref(null) },\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * The widget's persisted configuration blob. An optional `route`\n\t\t * (vue-router location), `clickRoute` (Wave-2 alias), or `link`\n\t\t * (external href) turns the whole tile into a click-through target\n\t\t * (see the widgetLink mixin).\n\t\t * The `source` resolves the value. Besides the OpenRegister-backed kinds\n\t\t * (plain aggregate / `ratio` / `computed` / `weighted`), an\n\t\t * `{ kind: 'endpoint', url, path?, params? }` source reads an arbitrary\n\t\t * app REST endpoint and extracts the value at the dot-`path` of the\n\t\t * response (default = whole body). The `url` and any string `params`\n\t\t * value interpolate `@page.<param>` / `@workspace.<param>` tokens from the\n\t\t * page-level context (and `@objectId` / `@object.<field>` on a detail\n\t\t * page) — so a page-rendered period selector can drive every endpoint KPI.\n\t\t *\n\t\t * Wave 2 (#91): `endpointSource` (`{ url, method?, params?,\n\t\t * responsePath? }`) binds the tile to an arbitrary endpoint through the\n\t\t * shared `useEndpointSource` engine — exactly one of `source` |\n\t\t * `endpointSource`. `valueField` plucks the displayed value from the\n\t\t * payload; `previousField` (previous-period value) or `deltaField`\n\t\t * (server-computed percent) renders the trend sublabel (arrow +\n\t\t * percent-vs-previous, tinted by `goodDirection`, default `'up'`);\n\t\t * `variantWhen` (`[{ op, value, variant, icon? }]`, first match wins)\n\t\t * re-tints the value/icon by threshold.\n\t\t *\n\t\t * `limitField` (dot-path into the payload) or a static `limit` renders the\n\t\t * tile as a capacity pair — `0 / 100` — and tints it `warning` once the\n\t\t * value reaches the limit, unless a `variantWhen` rule already claims the\n\t\t * colour. Use `limitField` for a server-configured quota so the ceiling is\n\t\t * read live rather than duplicated in the manifest.\n\t\t *\n\t\t * `dateRange` opts the tile into the dashboard's period. Present and empty\n\t\t * (`{}`) = follow the ancestor `CnDashboardPage` range; add `presets`\n\t\t * (`[{ id, label?, from?, to? }]`) to render a per-tile picker that\n\t\t * overrides it. With presets and no page range, the tile starts on\n\t\t * `dateRange.default` (a preset id), else on the first preset, so the\n\t\t * picker never opens blank and the first request already carries a\n\t\t * range. The active range is exposed to `endpointSource` (and to the\n\t\t * tile's `route`) as `@range.from` / `@range.to` / `@range.preset`\n\t\t * tokens. A tile that\n\t\t * declares no `dateRange` is unaffected by the page range — that is\n\t\t * deliberate, so adding a range to a dashboard cannot silently change what\n\t\t * an existing tile requests.\n\t\t *\n\t\t * `display: 'badge'` renders the value as a `CnStatusBadge`. On an\n\t\t * `objectField` reference, `resolve.variantField` (a dot-path on the\n\t\t * looked-up row) plus `resolve.variantMap` (`{ rowValue: variant }`)\n\t\t * colour it from the row; without a map the row value must itself be a\n\t\t * variant name. `emptyText` replaces the dash for an empty value, and\n\t\t * for a reference that does not resolve. `overrides`\n\t\t * (`[{ when: { field, op?, value? }, label?, variant?, icon? }]`, first\n\t\t * match wins) test the bound record: `when` is the local `visibleWhen`\n\t\t * grammar, and a `when` with neither `op` nor `value` tests the field\n\t\t * for truthiness. A match replaces the shown label, the colour and the\n\t\t * icon. Precedence of the colour: override, `variantWhen`, the countdown\n\t\t * threshold, the at-limit warning, the resolved row, the static\n\t\t * `variant`.\n\t\t *\n\t\t * `display: 'countdown'` reads the value as a DATE and renders the days\n\t\t * remaining. `countdown.warnAt` / `countdown.dangerAt` are inclusive day\n\t\t * thresholds and `dangerAt` wins; `countdown.pastLabel` (with `{n}`)\n\t\t * words a date that has passed, which is always the error colour and is\n\t\t * never shown as a negative number; `countdown.todayLabel` words a date\n\t\t * due today, which counts as 0; `countdown.futureLabel` (with `{n}`)\n\t\t * words the ordinary case; `countdown.emptyText` words an absent or\n\t\t * unreadable date, falling back to the top-level `emptyText`. Days are\n\t\t * counted between LOCAL CALENDAR DAYS, not as elapsed milliseconds, so\n\t\t * the time of day on a deadline never moves the answer.\n\t\t *\n\t\t * @type {{label?: string, icon?: string, iconColor?: string, valueColor?: string, caption?: string, route?: (object|string), clickRoute?: (object|string), link?: string, format?: {style?: string, currency?: string, decimals?: number, prefix?: string, suffix?: string}, source?: {kind?: string, register?: string, schema?: string, metric?: string, field?: string, filter?: object, url?: string, path?: string, params?: object}, endpointSource?: {url: string, method?: string, params?: object, responsePath?: string}, valueField?: string, limitField?: string, limit?: number, dateRange?: {presets?: Array<{id: string, label?: string, from?: string, to?: string}>, default?: string}, previousField?: string, deltaField?: string, goodDirection?: ('up'|'down'), variant?: ('default'|'primary'|'success'|'warning'|'error'|'danger'), variantWhen?: Array<{op: string, value: unknown, variant: string, icon?: string}>, objectField?: (string|{field: string, resolve?: {register: string, schema: string, labelField?: string, variantField?: string, variantMap?: {[key: string]: string}}}), display?: ('text'|'badge'|'countdown'), countdown?: {unit?: 'days', warnAt?: number, dangerAt?: number, futureLabel?: string, todayLabel?: string, pastLabel?: string, emptyText?: string}, emptyText?: string, overrides?: Array<{when: {field: string, op?: string, value?: unknown}, label?: string, variant?: string, icon?: string}>}}\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 * Translate function. Falls back to the injected `cnTranslate`\n\t\t * (itself an identity function by default). Provide explicitly when\n\t\t * mounting CnStatWidget outside a CnAppRoot ancestor.\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\tsetup(props) {\n\t\t// Endpoint binding (Wave 2): the shared useEndpointSource engine owns\n\t\t// token resolution, request dedup + TTL caching, and the\n\t\t// cn:page:refresh / cn:widget:refresh subscriptions. It is a no-op\n\t\t// while `content.endpointSource` is absent, so the OpenRegister\n\t\t// `source` path below is untouched. The injects are re-read here in\n\t\t// setup — Vue 2.7 resolves them identically to the Options `inject`\n\t\t// block (same precedent as CnChartWidget's date-range ref).\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 pageRangeRaw = inject('cnDashboardDateRange', ref(null))\n\t\tconst unwrap = (v) => ((v && typeof v === 'object' && 'value' in v) ? v.value : v)\n\n\t\t// The per-tile range override. Null = follow the dashboard range; set by\n\t\t// selectRange() when the tile renders its own preset picker. It lives in\n\t\t// setup, not data(), because the ctx closure below must read it reactively\n\t\t// — a data() property would be resolved once and never refetch.\n\t\tconst tileRange = ref(null)\n\t\t// With its own presets and no page range, the tile starts on\n\t\t// `dateRange.default`, else the first preset. Without this the picker\n\t\t// opened blank (its value '' matched no option unless an app added a\n\t\t// preset with id '') and the first request carried no range. Only a\n\t\t// tile that declares presets gets a default: one that merely follows\n\t\t// the dashboard (`dateRange: {}`) keeps following it.\n\t\tconst defaultRange = () => {\n\t\t\tconst dr = props.content && props.content.dateRange\n\t\t\tconst presets = (dr && Array.isArray(dr.presets)) ? dr.presets.filter(Boolean) : []\n\t\t\tif (presets.length === 0) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst chosen = (dr.default !== undefined && presets.find((p) => p.id === dr.default)) || presets[0]\n\t\t\treturn { preset: chosen.id, from: chosen.from ?? null, to: chosen.to ?? null }\n\t\t}\n\t\tconst activeRange = () => (tileRange.value || unwrap(pageRangeRaw) || defaultRange())\n\n\t\tconst { data, loading, error, refetch } = useEndpointSource(\n\t\t\t() => (props.content && props.content.endpointSource) || null,\n\t\t\t{\n\t\t\t\tctx: () => ({\n\t\t\t\t\t...(resolveObjectTokenContext(objectCtxRaw, detailCtxRaw) || {}),\n\t\t\t\t\tworkspace: unwrap(workspaceRaw) || {},\n\t\t\t\t\tconfig: unwrap(appConfigRaw) || {},\n\t\t\t\t\t// `@range.from` / `@range.to` / `@range.preset` tokens, usable in the\n\t\t\t\t\t// endpointSource url and params exactly like `@workspace.*`.\n\t\t\t\t\trange: activeRange() || {},\n\t\t\t\t}),\n\t\t\t},\n\t\t)\n\t\treturn { epData: data, epLoading: loading, epError: error, epRefetch: refetch, tileRange, activeRange }\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\t// Resolved display label for an `objectField` that holds a reference\n\t\t\t// uuid. Null until resolved, and null forever when it cannot be.\n\t\t\treferenceLabel: null,\n\t\t\t// The looked-up row behind `referenceLabel`, read by\n\t\t\t// `resolve.variantField` to colour the tile from the row itself.\n\t\t\treferenceRow: null,\n\t\t\t// True once a configured lookup ran and found no usable label.\n\t\t\treferenceFailed: false,\n\t\t\t// True while a configured lookup is in flight. Without it, \"not\n\t\t\t// resolved yet\" and \"cannot be resolved\" were the same state, so the\n\t\t\t// tile showed the RAW UUID for the length of the request and then\n\t\t\t// replaced it with the label or the empty text. A person watching a\n\t\t\t// status badge saw `4f2b9c10-…` flash past where a state belonged.\n\t\t\treferencePending: false,\n\t\t\tvalue: null,\n\t\t\tloading: false,\n\t\t\terror: '',\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Effective translate function: the explicit `translate` prop when\n\t\t * given, else the injected `cnTranslate` (identity by default).\n\t\t *\n\t\t * @return {(key: string) => string}\n\t\t */\n\t\teffectiveTranslate() {\n\t\t\treturn this.translate ?? this.cnTranslate\n\t\t},\n\n\t\t/**\n\t\t * The tile label, run through the host translate function so a\n\t\t * manifest-authored source string localises to the user's language.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedLabel() {\n\t\t\tconst label = this.content.label\n\t\t\treturn label ? this.effectiveTranslate(label) : ''\n\t\t},\n\n\t\t/**\n\t\t * The tile caption, translated and then interpolated with the fetched\n\t\t * payload.\n\t\t *\n\t\t * `{field}` tokens are replaced from the endpoint payload by dot-path,\n\t\t * using the same {@link getByPath} that resolves `valueField` — so a\n\t\t * caption can say what the number MEANS rather than repeating it:\n\t\t *\n\t\t * caption: '{levelName} · {currentStreakDays}-day streak'\n\t\t *\n\t\t * WHY THIS EXISTS. Without it a tile needing a secondary line had to be\n\t\t * a bespoke component: it could not be expressed as config, so apps\n\t\t * wrote their own card, and that card then re-implemented fetching,\n\t\t * loading and error handling too. Every one of those re-implementations\n\t\t * observed so far swallowed its errors into a zero.\n\t\t *\n\t\t * Translation runs FIRST, so a translator sees `{levelName}` as a\n\t\t * placeholder in the source string and can move it within the sentence —\n\t\t * the same contract as Nextcloud's own `t()` parameters.\n\t\t *\n\t\t * A token with no matching field resolves to the empty string rather\n\t\t * than being left as `{levelName}` on screen: a caption is decoration,\n\t\t * and showing a reader a raw token is worse than showing them less.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedCaption() {\n\t\t\tconst caption = this.content.caption\n\t\t\tif (!caption) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst translated = this.effectiveTranslate(caption)\n\t\t\tif (translated.indexOf('{') === -1) {\n\t\t\t\treturn translated\n\t\t\t}\n\t\t\tconst payload = this.endpointMode ? this.epData : null\n\t\t\treturn translated.replace(/\\{([A-Za-z0-9_.]+)\\}/g, (whole, path) => {\n\t\t\t\tconst v = getByPath(payload, path)\n\t\t\t\treturn (v === undefined || v === null) ? '' : String(v)\n\t\t\t}).replace(/\\s{2,}/g, ' ').trim()\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped detail-page object context for token resolution, or null\n\t\t * on surfaces (dashboards) that don't provide one.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tobjectCtx() {\n\t\t\treturn resolveObjectTokenContext(this.cnObjectContext, this.cnDetailObjectContext)\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped page-level workspace context map for `@page.*` /\n\t\t * `@workspace.*` token resolution. Always an object (defaults to `{}`).\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\tpageCtx() {\n\t\t\tconst c = this.cnWorkspaceContext\n\t\t\tconst unwrapped = (c && typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t\treturn (unwrapped && typeof unwrapped === 'object') ? unwrapped : {}\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped page-level app config map for `@config.*` token\n\t\t * resolution. Always an object (defaults to `{}`).\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\tconfigCtx() {\n\t\t\treturn unwrapAppConfig(this.cnAppConfig)\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile is endpoint-bound (Wave 2): a `content.endpointSource`\n\t\t * with a `url` switches the value/loading/error surface to the shared\n\t\t * `useEndpointSource` engine. Exactly one of `source` | `endpointSource`\n\t\t * is allowed (validator-enforced); endpointSource wins when both slip\n\t\t * through.\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tendpointMode() {\n\t\t\tconst es = this.content.endpointSource\n\t\t\treturn !!(es && es.url)\n\t\t},\n\n\t\t/**\n\t\t * The raw display value: in endpoint mode, the payload value plucked at\n\t\t * `content.valueField` (dot-path; omitted = the payload itself);\n\t\t * otherwise the OpenRegister-aggregated `value`.\n\t\t *\n\t\t * @return {unknown}\n\t\t */\n\t\tdisplayValue() {\n\t\t\tif (this.objectFieldMode) {\n\t\t\t\treturn this.objectFieldValue\n\t\t\t}\n\t\t\tif (!this.endpointMode) {\n\t\t\t\treturn this.value\n\t\t\t}\n\t\t\tconst v = getByPath(this.epData, this.content.valueField)\n\t\t\treturn v === undefined ? null : v\n\t\t},\n\n\t\t/**\n\t\t * Whether the headline is text rather than a number.\n\t\t *\n\t\t * The KPI card is built around a number: it never wraps and never\n\t\t * shrinks, because in a narrow tile the decoration should give way and\n\t\t * the figure should not. A NAME needs the opposite, so it gets a variant\n\t\t * that wraps and clamps, plus a title attribute for the full string.\n\t\t *\n\t\t * @return {boolean} true when the value is not numeric.\n\t\t */\n\t\tisTextValue() {\n\t\t\tconst v = this.displayValue\n\t\t\tif (v === null || v === undefined || v === '') {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn !Number.isFinite(Number(v))\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile renders its value as a status badge.\n\t\t *\n\t\t * @return {boolean} true when `content.display` is `'badge'`.\n\t\t */\n\t\tisBadge() {\n\t\t\treturn this.content.display === 'badge'\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile renders its value as a time-remaining countdown.\n\t\t *\n\t\t * @return {boolean} true when `content.display` is `'countdown'`.\n\t\t */\n\t\tisCountdown() {\n\t\t\treturn this.content.display === 'countdown'\n\t\t},\n\n\t\t/**\n\t\t * The countdown block, or `{}`. `unit` is accepted and ignored: days is\n\t\t * the only unit this renders, and saying so is better than pretending\n\t\t * an hours or weeks mode exists.\n\t\t *\n\t\t * @return {object} The countdown config.\n\t\t */\n\t\tcountdownConfig() {\n\t\t\tconst cfg = this.content.countdown\n\t\t\treturn (cfg && typeof cfg === 'object') ? cfg : {}\n\t\t},\n\n\t\t/**\n\t\t * Whole LOCAL CALENDAR DAYS from today to the tile's value read as a\n\t\t * date. Positive is still to come, 0 is today, negative has passed.\n\t\t *\n\t\t * Zero is today because both ends are collapsed onto the calendar day\n\t\t * they fall on and then subtracted: a deadline dated today is the same\n\t\t * day as today, and the same day is a distance of nothing. Not 1 (which\n\t\t * would claim a day that is already being spent) and not −1 (which\n\t\t * would call a deadline that has not passed overdue).\n\t\t *\n\t\t * Null when the tile is not in countdown mode, or when the value holds\n\t\t * no readable date — which is what keeps `NaN` off the tile.\n\t\t *\n\t\t * @return {number|null} The signed day count, or null.\n\t\t */\n\t\tcountdownDays() {\n\t\t\tif (!this.isCountdown) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst due = calendarDay(this.displayValue)\n\t\t\tif (due === null) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst now = new Date()\n\t\t\tconst today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())\n\t\t\treturn Math.round((due - today) / DAY_MS)\n\t\t},\n\n\t\t/**\n\t\t * The countdown sentence: the past wording for a date that has gone by,\n\t\t * the today wording for today, the future wording otherwise. `{n}` is\n\t\t * the number of days, never signed — \"−3 days left\" is exactly the\n\t\t * reading this mode exists to prevent.\n\t\t *\n\t\t * The built-in wordings carry their own singular, so a tile that\n\t\t * configures nothing still reads \"1 day left\" rather than \"1 days\n\t\t * left\". A configured `futureLabel` / `pastLabel` replaces both forms,\n\t\t * because a manifest author writing their own wording is the one who\n\t\t * knows how their language counts.\n\t\t *\n\t\t * @return {string} The sentence, or '' when there is nothing to count.\n\t\t */\n\t\tcountdownText() {\n\t\t\tconst days = this.countdownDays\n\t\t\tif (days === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst cfg = this.countdownConfig\n\t\t\tif (days === 0) {\n\t\t\t\treturn this.effectiveTranslate(cfg.todayLabel || 'Today')\n\t\t\t}\n\t\t\tconst n = Math.abs(days)\n\t\t\tconst past = days < 0\n\t\t\tlet template = past ? cfg.pastLabel : cfg.futureLabel\n\t\t\tif (typeof template !== 'string' || template === '') {\n\t\t\t\tif (past) {\n\t\t\t\t\ttemplate = n === 1 ? 'Overdue by {n} day' : 'Overdue by {n} days'\n\t\t\t\t} else {\n\t\t\t\t\ttemplate = n === 1 ? '{n} day left' : '{n} days left'\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn this.effectiveTranslate(template).replace(/\\{n\\}/g, String(n))\n\t\t},\n\n\t\t/**\n\t\t * The colour the countdown itself asks for: error once the date has\n\t\t * passed or the day count is at or below `dangerAt`, warning at or\n\t\t * below `warnAt`, else ''.\n\t\t *\n\t\t * A passed date is the danger colour whether or not `dangerAt` is\n\t\t * configured: overdue is not a threshold anybody has to opt into. Both\n\t\t * thresholds are INCLUSIVE, and `dangerAt` is tested first, so exactly\n\t\t * 5 days with `dangerAt: 5` is danger and not warning even when\n\t\t * `warnAt` also matches.\n\t\t *\n\t\t * @return {string} 'error', 'warning', or ''.\n\t\t */\n\t\tcountdownVariant() {\n\t\t\tconst days = this.countdownDays\n\t\t\tif (days === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tconst cfg = this.countdownConfig\n\t\t\tif (Number.isFinite(cfg.dangerAt) && days <= cfg.dangerAt) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tif (Number.isFinite(cfg.warnAt) && days <= cfg.warnAt) {\n\t\t\t\treturn 'warning'\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * The first `content.overrides` entry whose `when` matches the BOUND\n\t\t * RECORD, or null. Needs a detail-page record: on a dashboard there is\n\t\t * nothing to test, so no override applies.\n\t\t *\n\t\t * @return {object|null} The matching override.\n\t\t */\n\t\tactiveOverride() {\n\t\t\tconst rules = this.content.overrides\n\t\t\tif (!Array.isArray(rules) || rules.length === 0) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst record = this.objectCtx?.object\n\t\t\tif (!record || typeof record !== 'object') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn rules.find((rule) => rule && this.matchesOverride(record, rule.when)) || null\n\t\t},\n\n\t\t/**\n\t\t * The translated `content.emptyText` when it applies, else ''.\n\t\t *\n\t\t * It applies to an empty value, and to an `objectField` reference whose\n\t\t * lookup found nothing. Without `emptyText` an unresolvable reference\n\t\t * keeps showing its raw value, as it always has.\n\t\t *\n\t\t * In countdown mode it applies to an UNREADABLE date as well, not only\n\t\t * to an absent one. `'not yet planned'` is a perfectly non-empty value\n\t\t * and there is no countdown to be had from it, so without this the tile\n\t\t * would print the raw string where a deadline belongs.\n\t\t *\n\t\t * @return {string} The text to show instead of the value, or ''.\n\t\t */\n\t\temptyValueText() {\n\t\t\tconst text = this.isCountdown\n\t\t\t\t? (this.countdownConfig.emptyText || this.content.emptyText)\n\t\t\t\t: this.content.emptyText\n\t\t\tif (typeof text !== 'string' || text === '') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst v = this.displayValue\n\t\t\tconst empty = v === null || v === undefined || v === ''\n\t\t\tif (empty || (this.objectFieldMode && this.referenceFailed) || (this.isCountdown && this.countdownDays === null)) {\n\t\t\t\treturn this.effectiveTranslate(text)\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * The text the tile shows: an override's label, else the countdown\n\t\t * sentence, else the empty text, else the formatted value.\n\t\t *\n\t\t * The override sits above the countdown for the same reason it sits\n\t\t * above a badge's status: a suspended case is not waiting for its\n\t\t * deadline, so it reads as suspended.\n\t\t *\n\t\t * @return {string} The shown text.\n\t\t */\n\t\tshownValue() {\n\t\t\tconst override = this.activeOverride\n\t\t\tif (override && typeof override.label === 'string' && override.label !== '') {\n\t\t\t\treturn this.effectiveTranslate(override.label)\n\t\t\t}\n\t\t\treturn this.countdownText || this.emptyValueText || this.formattedValue\n\t\t},\n\n\t\t/**\n\t\t * Whether the shown text replaced the formatted value. Such a text is a\n\t\t * word, so it takes the wrapping text style of a name.\n\t\t *\n\t\t * @return {boolean} true when an override or the empty text is shown.\n\t\t */\n\t\tshownValueIsText() {\n\t\t\treturn this.shownValue !== this.formattedValue\n\t\t},\n\n\t\t/**\n\t\t * The variant the RESOLVED ROW asks for through\n\t\t * `objectField.resolve.variantField` / `variantMap`, or ''.\n\t\t *\n\t\t * @return {string} The variant name, or ''.\n\t\t */\n\t\trowVariant() {\n\t\t\tconst cfg = this.content.objectField\n\t\t\tconst resolve = (cfg && typeof cfg === 'object') ? cfg.resolve : null\n\t\t\tif (!resolve || !resolve.variantField || !this.referenceRow) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst raw = getByPath(this.referenceRow, resolve.variantField)\n\t\t\tif (raw === undefined || raw === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst map = resolve.variantMap\n\t\t\tif (map && typeof map === 'object') {\n\t\t\t\tconst hit = map[String(raw)]\n\t\t\t\treturn typeof hit === 'string' ? hit : ''\n\t\t\t}\n\t\t\treturn typeof raw === 'string' ? raw : ''\n\t\t},\n\n\t\t/**\n\t\t * The CnStatusBadge variant in badge mode. Same precedence as the text\n\t\t * colour: override, `variantWhen`, at-limit, resolved row, static\n\t\t * `variant`. The first one that names a badge variant wins.\n\t\t *\n\t\t * @return {string} A CnStatusBadge variant.\n\t\t */\n\t\tbadgeVariant() {\n\t\t\tconst rule = this.activeVariantRule\n\t\t\tconst candidates = [\n\t\t\t\tthis.activeOverride?.variant,\n\t\t\t\trule?.variant,\n\t\t\t\tthis.atLimit ? 'warning' : '',\n\t\t\t\tthis.rowVariant,\n\t\t\t\tthis.content.variant,\n\t\t\t]\n\t\t\tfor (const candidate of candidates) {\n\t\t\t\tconst variant = candidate === 'danger' ? 'error' : candidate\n\t\t\t\tif (BADGE_VARIANTS.includes(variant)) {\n\t\t\t\t\treturn variant\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn 'default'\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile reads a field off the BOUND RECORD rather than\n\t\t * aggregating or calling an endpoint (`content.objectField`).\n\t\t *\n\t\t * The record is already loaded by the detail page, so this costs no\n\t\t * request at all. It is what lets a KPI row headline a case's type or\n\t\t * its assignee beside the counts, instead of those facts being buried\n\t\t * three rows down in the properties grid.\n\t\t *\n\t\t * @return {boolean} true in object-field mode.\n\t\t */\n\t\tobjectFieldMode() {\n\t\t\tconst of = this.content.objectField\n\t\t\treturn !!(of && (typeof of === 'string' || of.field))\n\t\t},\n\n\t\t/**\n\t\t * The bound record's value for `content.objectField`.\n\t\t *\n\t\t * A reference field holds a uuid, which is not something to show a\n\t\t * person, so `resolve` names the register/schema to look the label up in.\n\t\t * The resolved label replaces the uuid; an unresolvable id falls back to\n\t\t * the raw value rather than blanking, exactly as CnFkResolveCell does.\n\t\t *\n\t\t * @return {unknown} The field's value, or null.\n\t\t */\n\t\t/**\n\t\t * The bound record's raw value for `content.objectField`, before any\n\t\t * reference resolution.\n\t\t *\n\t\t * @return {unknown} The raw field value, or null.\n\t\t */\n\t\tobjectFieldRaw() {\n\t\t\tconst cfg = this.content.objectField\n\t\t\tconst field = typeof cfg === 'string' ? cfg : cfg?.field\n\t\t\tconst record = this.objectCtx?.object\n\t\t\tif (!record || !field) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst raw = getByPath(record, field)\n\t\t\treturn (raw === undefined || raw === '') ? null : raw\n\t\t},\n\n\t\t/**\n\t\t * @return {unknown} The field's value, or null.\n\t\t */\n\t\tobjectFieldValue() {\n\t\t\tif (this.objectFieldRaw === null) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\t// An unresolved or unresolvable reference falls back to the raw value\n\t\t\t// rather than blanking, the same way CnFkResolveCell does.\n\t\t\treturn this.referenceLabel !== null ? this.referenceLabel : this.objectFieldRaw\n\t\t},\n\n\t\t/**\n\t\t * Loading state for the active source (endpoint or OpenRegister).\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tdisplayLoading() {\n\t\t\t// A reference lookup is a request like any other, and it is the only\n\t\t\t// one the record mode makes. Leaving it out meant the tile rendered\n\t\t\t// the raw uuid as if it were the answer while the request was still\n\t\t\t// out.\n\t\t\tif (this.objectFieldMode) {\n\t\t\t\treturn this.referencePending\n\t\t\t}\n\t\t\treturn this.endpointMode ? this.epLoading : this.loading\n\t\t},\n\n\t\t/**\n\t\t * Error message for the active source ('' = none).\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tdisplayError() {\n\t\t\treturn this.endpointMode ? this.epError : this.error\n\t\t},\n\n\t\t/**\n\t\t * The previous-period value plucked at `content.previousField`\n\t\t * (endpoint mode only), or null when not configured / not numeric.\n\t\t *\n\t\t * @return {number|null}\n\t\t */\n\t\tpreviousValue() {\n\t\t\tif (!this.endpointMode || !this.content.previousField) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst v = Number(getByPath(this.epData, this.content.previousField))\n\t\t\treturn Number.isFinite(v) ? v : null\n\t\t},\n\n\t\t/**\n\t\t * Trend percent for the sublabel (the pipelinq KPI contract): a\n\t\t * server-computed percent plucked at `content.deltaField` when set,\n\t\t * else the client-computed change vs `previousField`\n\t\t * (`(current − previous) ÷ |previous| × 100`). Null when the trend is\n\t\t * not configured or not computable (previous of 0, non-numeric values).\n\t\t *\n\t\t * @return {number|null}\n\t\t */\n\t\ttrendPct() {\n\t\t\tif (!this.endpointMode) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tif (this.content.deltaField) {\n\t\t\t\tconst v = Number(getByPath(this.epData, this.content.deltaField))\n\t\t\t\treturn Number.isFinite(v) ? v : null\n\t\t\t}\n\t\t\tconst prev = this.previousValue\n\t\t\tconst cur = Number(this.displayValue)\n\t\t\tif (prev === null || prev === 0 || !Number.isFinite(cur)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn ((cur - prev) / Math.abs(prev)) * 100\n\t\t},\n\n\t\t/** The signed trend percent, e.g. \"+12.3%\". */\n\t\tformattedTrend() {\n\t\t\tif (this.trendPct === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst sign = this.trendPct > 0 ? '+' : ''\n\t\t\treturn `${sign}${this.trendPct.toFixed(1)}%`\n\t\t},\n\n\t\t/** The arrow component for the trend direction. */\n\t\ttrendIcon() {\n\t\t\tif (this.trendPct === null || Math.abs(this.trendPct) < 0.05) {\n\t\t\t\treturn 'TrendingNeutral'\n\t\t\t}\n\t\t\treturn this.trendPct > 0 ? 'TrendingUp' : 'TrendingDown'\n\t\t},\n\n\t\t/**\n\t\t * Green when the trend moves in `content.goodDirection` (default\n\t\t * 'up'), red otherwise, neutral for a ~0 change — the CnDeltaWidget\n\t\t * convention.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\ttrendColor() {\n\t\t\tif (this.trendPct === null || Math.abs(this.trendPct) < 0.05) {\n\t\t\t\treturn 'var(--color-text-maxcontrast)'\n\t\t\t}\n\t\t\tconst good = this.content.goodDirection || 'up'\n\t\t\tconst rising = this.trendPct > 0\n\t\t\tconst isGood = good === 'up' ? rising : !rising\n\t\t\treturn isGood ? STATUS_TEXT_COLORS.success : STATUS_TEXT_COLORS.error\n\t\t},\n\n\t\t/**\n\t\t * The first `content.variantWhen` rule matching the current display\n\t\t * value (first-match wins), or null. Each rule is\n\t\t * `{ op: eq|neq|gt|gte|lt|lte, value, variant, icon? }`.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tactiveVariantRule() {\n\t\t\tconst rules = this.content.variantWhen\n\t\t\tif (!Array.isArray(rules) || rules.length === 0) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst current = this.displayValue\n\t\t\tif (current === null || current === undefined) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn rules.find((r) => r && this.matchesRule(current, r)) || null\n\t\t},\n\n\t\t/**\n\t\t * The CSS colour for the matched variant rule ('' = keep the\n\t\t * configured colours).\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tvariantColor() {\n\t\t\t// An override describes the RECORD's state (a suspended case), which\n\t\t\t// outranks anything the value itself suggests.\n\t\t\tconst override = this.activeOverride\n\t\t\tif (override && override.variant) {\n\t\t\t\treturn VARIANT_COLORS[override.variant] || ''\n\t\t\t}\n\t\t\tconst rule = this.activeVariantRule\n\t\t\tif (rule && rule.variant) {\n\t\t\t\treturn VARIANT_COLORS[rule.variant] || ''\n\t\t\t}\n\t\t\t// The countdown's own threshold sits below `variantWhen`, which is\n\t\t\t// the author naming a colour outright, and above the at-limit tint,\n\t\t\t// which is the generic one. A passed date reports `error` from here\n\t\t\t// whether or not a threshold was configured.\n\t\t\tif (this.countdownVariant) {\n\t\t\t\treturn VARIANT_COLORS[this.countdownVariant] || ''\n\t\t\t}\n\t\t\t// An explicit variantWhen rule always wins: a tile that says how it\n\t\t\t// wants to be coloured is not overruled by the generic at-limit tint.\n\t\t\tif (this.atLimit) {\n\t\t\t\treturn VARIANT_COLORS.warning || ''\n\t\t\t}\n\t\t\t// The resolved row's colour sits just above the static floor. An\n\t\t\t// unknown name falls through rather than blanking the tile.\n\t\t\tif (this.rowVariant && VARIANT_COLORS[this.rowVariant]) {\n\t\t\t\treturn VARIANT_COLORS[this.rowVariant]\n\t\t\t}\n\t\t\t// A STATIC `variant` is the floor, below both of the above: it is the\n\t\t\t// tile's resting colour, not a signal about the current value, so a\n\t\t\t// threshold rule or an at-limit warning must be able to override it.\n\t\t\t//\n\t\t\t// It exists because that is how CnStatsBlock has always been coloured\n\t\t\t// (`variant=\"success\"`), and a tile migrating from a bespoke card to\n\t\t\t// this component would otherwise silently lose its colour — a change\n\t\t\t// nothing would report, on a dashboard where colour is the fastest\n\t\t\t// thing a reader takes in.\n\t\t\tif (this.content.variant) {\n\t\t\t\treturn VARIANT_COLORS[this.content.variant] || ''\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * The icon shown in the circle: a matched variant rule's `icon`\n\t\t * override, else `content.icon`.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedIcon() {\n\t\t\tconst override = this.activeOverride\n\t\t\tconst rule = this.activeVariantRule\n\t\t\treturn (override && override.icon) || (rule && rule.icon) || this.content.icon || ''\n\t\t},\n\n\t\t/**\n\t\t * Card orientation. Horizontal (icon beside the number) is the\n\t\t * canonical KPI card; `content.layout: 'vertical'` stacks the icon\n\t\t * above a centred number for a tile taller than it is wide.\n\t\t *\n\t\t * @return {'horizontal'|'vertical'}\n\t\t */\n\t\tcardLayout() {\n\t\t\treturn (this.content || {}).layout === 'vertical' ? 'vertical' : 'horizontal'\n\t\t},\n\n\t\t/**\n\t\t * Whether the card draws no box of its own. On by default: every stat\n\t\t * tile is rendered inside a CnWidgetWrapper that already draws a card,\n\t\t * so a second box is a card inside a card. Set `content.flat: false`\n\t\t * for a tile mounted somewhere with no wrapper around it.\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tflat() {\n\t\t\treturn (this.content || {}).flat !== false\n\t\t},\n\n\t\t/** Inline style for the icon circle (variant rule wins over iconColor). */\n\t\ticonCircleStyle() {\n\t\t\tconst color = this.variantColor || this.content.iconColor || this.content.valueColor || 'var(--color-primary-element)'\n\t\t\treturn { color, backgroundColor: this.tint(color) }\n\t\t},\n\n\t\t/** Inline style for the value text (variant rule wins over valueColor). */\n\t\tvalueStyle() {\n\t\t\tconst color = this.variantColor || this.content.valueColor\n\t\t\treturn color ? { color } : {}\n\t\t},\n\n\t\t/**\n\t\t * The formatted value string per the `content.format` spec. Resolves\n\t\t * `@config.<key>` tokens (e.g. `currency: '@config.currency'`) against the\n\t\t * page-level app config and guards the currency code, so an unresolved\n\t\t * token or invalid currency falls back to a safe default instead of\n\t\t * throwing. See `formatMetricValue`.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tformattedValue() {\n\t\t\treturn formatMetricValue(this.displayValue, this.content.format, this.configCtx)\n\t\t},\n\n\t\t/**\n\t\t * The capacity this tile is measured against — `content.limitField`\n\t\t * (dot-path into the endpoint payload, so a server-configured quota is\n\t\t * read live) or a static `content.limit`. Null when neither is set or\n\t\t * the resolved value is not a finite number, which is what keeps the\n\t\t * \"value / limit\" rendering off every tile that has no limit.\n\t\t *\n\t\t * @return {number|null}\n\t\t */\n\t\tlimitValue() {\n\t\t\tlet raw = this.content.limit\n\t\t\tif (this.endpointMode && this.content.limitField) {\n\t\t\t\traw = getByPath(this.epData, this.content.limitField)\n\t\t\t}\n\t\t\tconst n = Number(raw)\n\t\t\treturn Number.isFinite(n) ? n : null\n\t\t},\n\n\t\t/**\n\t\t * The limit rendered beside the value, e.g. the \"100\" in \"0 / 100\".\n\t\t * Formatted with the value's own `format` spec minus prefix/suffix —\n\t\t * a suffix belongs to the pair, not to each half, so \"0 % / 100 %\" is\n\t\t * never produced.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tformattedLimit() {\n\t\t\tif (this.limitValue === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst { prefix, suffix, ...rest } = (this.content.format || {})\n\t\t\treturn formatMetricValue(this.limitValue, rest, this.configCtx)\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile has reached or passed its limit. Drives the warning\n\t\t * tint when no explicit `variantWhen` rule already claims the colour.\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tatLimit() {\n\t\t\tif (this.limitValue === null) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tconst current = Number(this.displayValue)\n\t\t\treturn Number.isFinite(current) && current >= this.limitValue\n\t\t},\n\n\t\t/**\n\t\t * The tile's own range presets (`content.dateRange.presets`). Empty when\n\t\t * the tile has no override, which is also what hides the picker.\n\t\t *\n\t\t * @return {Array<{id: string, label?: string, from?: string, to?: string}>}\n\t\t */\n\t\trangePresets() {\n\t\t\tconst presets = this.content.dateRange?.presets\n\t\t\treturn Array.isArray(presets) ? presets.filter(Boolean) : []\n\t\t},\n\n\t\t/**\n\t\t * The preset id currently selected — the tile's own override when set,\n\t\t * else whatever the dashboard range reports, so the picker opens showing\n\t\t * the page's period rather than a stale default.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tactiveRangePreset() {\n\t\t\treturn (this.tileRange || this.activeRange() || {}).preset || ''\n\t\t},\n\n\t\t/** Accessible name for the range picker (no visible label on a compact tile). */\n\t\trangeAriaLabel() {\n\t\t\tconst label = this.resolvedLabel || this.effectiveTranslate('Date range')\n\t\t\treturn `${label} — ${this.effectiveTranslate('date range')}`\n\t\t},\n\n\t\t/** Stable signature of the data source so the watcher only refetches on real change. */\n\t\tsourceKey() {\n\t\t\treturn JSON.stringify({\n\t\t\t\ts: this.content.source || {},\n\t\t\t\to: this.objectCtx ? this.objectCtx.objectId : null,\n\t\t\t\tp: this.pageCtx,\n\t\t\t\tc: this.configCtx,\n\t\t\t})\n\t\t},\n\t},\n\n\twatch: {\n\t\tsourceKey() {\n\t\t\tthis.fetchValue()\n\t\t},\n\n\t\tobjectFieldRaw: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.resolveReference()\n\t\t\t},\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.fetchValue()\n\t\t// The `endpointSource` path refreshes through useEndpointSource; this\n\t\t// covers the `source` path, which otherwise never re-read on Refresh.\n\t\tthis._onPageRefresh = (payload) => {\n\t\t\tif (this.endpointMode) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst done = this.fetchValue(payload)\n\t\t\tpayload?.waitUntil?.(done)\n\t\t}\n\t\tsubscribe(PAGE_REFRESH_CHANNEL, this._onPageRefresh)\n\t},\n\n\tbeforeUnmount() {\n\t\tunsubscribe(PAGE_REFRESH_CHANNEL, this._onPageRefresh)\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Resolve an `objectField` that holds a reference uuid to the referenced\n\t\t * object's label, through the shared object store (per-schema caching and\n\t\t * in-flight dedup come for free).\n\t\t *\n\t\t * Does nothing unless `objectField.resolve` names a register and schema:\n\t\t * a plain scalar field needs no lookup, and guessing that a string looks\n\t\t * like a uuid would turn a legitimate identifier into a failed fetch.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync resolveReference() {\n\t\t\tthis.referenceLabel = null\n\t\t\tthis.referenceRow = null\n\t\t\tthis.referenceFailed = false\n\t\t\tthis.referencePending = false\n\t\t\tconst cfg = this.content.objectField\n\t\t\tconst resolve = (cfg && typeof cfg === 'object') ? cfg.resolve : null\n\t\t\tconst raw = this.objectFieldRaw\n\t\t\tif (!resolve || !resolve.register || !resolve.schema || !raw) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Assigned by the try below, and the catch returns rather than falling\n\t\t\t// through, so an initialiser here would never be read.\n\t\t\tlet store\n\t\t\ttry {\n\t\t\t\tstore = useObjectStore()\n\t\t\t} catch {\n\t\t\t\t// No active Pinia: the tile shows the raw value, which is correct\n\t\t\t\t// and never blank.\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!store) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst type = resolveObjectOpType(store, { register: resolve.register, schema: resolve.schema })\n\t\t\tconst id = String(raw)\n\t\t\tconst cached = store.objects && store.objects[type] && store.objects[type][id]\n\t\t\t// Only an actual request pends. A cache hit resolves in the same\n\t\t\t// tick, and flagging it would flicker a loading icon for nothing.\n\t\t\tif (!cached) {\n\t\t\t\tthis.referencePending = true\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst obj = cached || await store.fetchObject(type, id)\n\t\t\t\t// The record may have moved on while the lookup was in flight.\n\t\t\t\t// The newer call owns the state, so this one touches nothing.\n\t\t\t\tif (String(this.objectFieldRaw) !== id) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tthis.referencePending = false\n\t\t\t\tif (obj && typeof obj === 'object') {\n\t\t\t\t\tthis.referenceRow = obj\n\t\t\t\t}\n\t\t\t\tconst label = this.pickReferenceLabel(obj, resolve.labelField)\n\t\t\t\tif (label) {\n\t\t\t\t\tthis.referenceLabel = label\n\t\t\t\t} else {\n\t\t\t\t\tthis.referenceFailed = true\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Leave the raw value showing, unless `emptyText` says otherwise.\n\t\t\t\tif (String(this.objectFieldRaw) === id) {\n\t\t\t\t\tthis.referencePending = false\n\t\t\t\t\tthis.referenceFailed = true\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether an override's `when` matches the bound record. Uses the local\n\t\t * `visibleWhen` grammar (`{ field, op, value }`); a `when` with neither\n\t\t * `op` nor `value` is a truthiness test on the field.\n\t\t *\n\t\t * @param {object} record The bound record.\n\t\t * @param {{field: string, op?: string, value?: unknown}} when The condition.\n\t\t * @return {boolean} True when the override applies.\n\t\t */\n\t\tmatchesOverride(record, when) {\n\t\t\tif (!when || typeof when !== 'object' || typeof when.field !== 'string' || when.field === '') {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tconst compares = Object.hasOwn(when, 'value') || Boolean(when.op)\n\t\t\tif (!compares) {\n\t\t\t\treturn Boolean(readVisibleWhenPath(record, when.field))\n\t\t\t}\n\t\t\treturn evaluateVisibleWhenLocal(when, record)\n\t\t},\n\n\t\t/**\n\t\t * Pick a display label off a resolved reference: the configured field\n\t\t * first, then `title`, then `@self.name`. A per-language map collapses to\n\t\t * its first non-empty value.\n\t\t *\n\t\t * @param {object|null} obj The referenced object.\n\t\t * @param {string} [labelField] The preferred label property.\n\t\t * @return {string} The label, or '' when none is usable.\n\t\t */\n\t\tpickReferenceLabel(obj, labelField) {\n\t\t\tif (!obj || typeof obj !== 'object') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst candidates = [obj[labelField || 'title'], obj.title, obj.name, obj['@self'] && obj['@self'].name]\n\t\t\tfor (const value of candidates) {\n\t\t\t\tif (typeof value === 'string' && value !== '') {\n\t\t\t\t\treturn value\n\t\t\t\t}\n\t\t\t\tif (typeof value === 'number') {\n\t\t\t\t\treturn String(value)\n\t\t\t\t}\n\t\t\t\tif (value && typeof value === 'object' && !Array.isArray(value)) {\n\t\t\t\t\tconst first = Object.values(value).find((v) => typeof v === 'string' && v !== '')\n\t\t\t\t\tif (first) {\n\t\t\t\t\t\treturn first\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * Derive a faint background tint for the icon circle from a colour.\n\t\t * Falls back to the NC light primary token for CSS variables / unknowns.\n\t\t *\n\t\t * @param {string} color The base colour (hex or CSS var).\n\t\t * @return {string} A translucent or token background.\n\t\t */\n\t\t/**\n\t\t * Apply a per-tile range preset. Writes the tile override, which the\n\t\t * endpoint ctx reads, so the tile refetches on its own period while the\n\t\t * rest of the dashboard stays on the page range.\n\t\t *\n\t\t * @param {string} presetId The chosen preset's id.\n\t\t * @return {void}\n\t\t */\n\t\tselectRange(presetId) {\n\t\t\tconst preset = this.rangePresets.find((p) => p.id === presetId)\n\t\t\tif (!preset) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.tileRange = { preset: preset.id, from: preset.from ?? null, to: preset.to ?? null }\n\t\t},\n\n\t\ttint(color) {\n\t\t\tif (typeof color === 'string' && /^#([0-9a-f]{6})$/i.test(color)) {\n\t\t\t\treturn color + '1f' // ~12% alpha\n\t\t\t}\n\t\t\treturn 'var(--color-primary-element-light, rgba(0,130,201,0.1))'\n\t\t},\n\n\t\t/**\n\t\t * Whether a `variantWhen` rule matches the current value. Numeric\n\t\t * comparison when both sides coerce to numbers; `eq` / `neq` fall back\n\t\t * to strict string equality for non-numeric values.\n\t\t *\n\t\t * @param {unknown} current The current display value.\n\t\t * @param {{op: string, value: unknown}} rule The threshold rule.\n\t\t * @return {boolean} True when the rule matches.\n\t\t */\n\t\tmatchesRule(current, rule) {\n\t\t\tconst a = Number(current)\n\t\t\tconst b = Number(rule.value)\n\t\t\tconst numeric = Number.isFinite(a) && Number.isFinite(b)\n\t\t\tswitch (rule.op) {\n\t\t\t\tcase 'eq': return numeric ? a === b : String(current) === String(rule.value)\n\t\t\t\tcase 'neq': return numeric ? a !== b : String(current) !== String(rule.value)\n\t\t\t\tcase 'gt': return numeric && a > b\n\t\t\t\tcase 'gte': return numeric && a >= b\n\t\t\t\tcase 'lt': return numeric && a < b\n\t\t\t\tcase 'lte': return numeric && a <= b\n\t\t\t\tdefault: return false\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Flatten a filter map into `filter[key]=value` / `filter[key][op]=value`\n\t\t * query params (operator-aware, matching the OpenRegister vocabulary).\n\t\t *\n\t\t * @param {object} target The params object to write into.\n\t\t * @param {object} filter The filter map.\n\t\t * @return {void}\n\t\t */\n\t\tflattenFilter(target, filter) {\n\t\t\tif (!filter || typeof filter !== 'object') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Resolve `@objectId` / `@object.*` (detail page), `@workspace.*`\n\t\t\t// (page-level context — e.g. the dashboard date-range pills publish\n\t\t\t// `dateFrom` / `dateTo`) AND `@config.*` (page-level app config), then\n\t\t\t// drop any optional `@workspace.<key>?` / `@config.<key>?` that stayed\n\t\t\t// unresolved so an unset value omits the filter (show all) instead of\n\t\t\t// sending a literal token.\n\t\t\tconst ctx = { ...(this.objectCtx || {}), workspace: this.pageCtx, config: this.configCtx }\n\t\t\tfilter = dropOptionalUnresolved(resolveFilterTokens(filter, ctx))\n\t\t\tfor (const [k, v] of Object.entries(filter)) {\n\t\t\t\tif (v && typeof v === 'object') {\n\t\t\t\t\tfor (const [op, ov] of Object.entries(v)) {\n\t\t\t\t\t\ttarget[`filter[${k}][${op}]`] = ov\n\t\t\t\t\t}\n\t\t\t\t} else if (v !== '' && v !== null && v !== undefined) {\n\t\t\t\t\ttarget[`filter[${k}]`] = v\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch one scalar from the OpenRegister `/value` aggregation endpoint.\n\t\t *\n\t\t * @param {object} axios The axios instance.\n\t\t * @param {(url: string, params?: object) => string} generateUrl The router helper.\n\t\t * @param {object} s The source (register/schema).\n\t\t * @param {string} metric The aggregation metric.\n\t\t * @param {?string} field The numeric field (non-count metrics).\n\t\t * @param {object} filter The filter map.\n\t\t * @return {Promise<number|null>} The aggregated value.\n\t\t */\n\t\tasync fetchAggregate(axios, generateUrl, s, metric, field, filter) {\n\t\t\tconst url = generateUrl(\n\t\t\t\t'/apps/openregister/api/objects/aggregations/{register}/{schema}/value',\n\t\t\t\t{ register: s.register, schema: s.schema },\n\t\t\t)\n\t\t\tconst params = { metric: metric || 'count' }\n\t\t\tif (field) {\n\t\t\t\tparams.field = field\n\t\t\t}\n\t\t\tthis.flattenFilter(params, filter)\n\t\t\tconst res = await axios.get(url, { params })\n\t\t\treturn res?.data?.value ?? null\n\t\t},\n\n\t\t/**\n\t\t * Resolve the widget's value from its `source`. Supports three source\n\t\t * kinds (ADR-041): a plain `aggregate` (count/sum/avg/min/max with\n\t\t * operator filters), a `ratio` (numerator ÷ denominator × 100, e.g. a\n\t\t * win-rate), and a `weighted` sum (Σ field × weightField ÷ divisor,\n\t\t * computed client-side over the fetched objects). Lazily imports\n\t\t * axios/router (same pattern as CnFilesWidget).\n\t\t *\n\t\t * @param {object} [refresh] The page refresh event's payload, when this is one.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchValue(refresh) {\n\t\t\t// Endpoint-bound tiles are fetched by the shared useEndpointSource\n\t\t\t// engine (see setup) — the OpenRegister paths below must not fire.\n\t\t\tif (this.endpointMode) {\n\t\t\t\tthis.value = null\n\t\t\t\tthis.error = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst s = this.content.source || {}\n\t\t\t// An `endpoint` source reads an arbitrary app REST endpoint instead\n\t\t\t// of OpenRegister's per-schema aggregation, so it needs no register/schema.\n\t\t\tif (s.kind !== 'endpoint' && (!s.register || !s.schema)) {\n\t\t\t\tthis.value = null\n\t\t\t\tthis.error = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (s.kind === 'endpoint' && !s.url) {\n\t\t\t\tthis.value = null\n\t\t\t\tthis.error = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.loading = true\n\t\t\tthis.error = ''\n\t\t\ttry {\n\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\t\timport('@nextcloud/router'),\n\t\t\t\t])\n\n\t\t\t\tif (s.kind === 'endpoint') {\n\t\t\t\t\tthis.value = await this.fetchEndpoint(s, refresh)\n\t\t\t\t} else if (s.kind === 'ratio') {\n\t\t\t\t\tconst num = await this.fetchAggregate(axios, generateUrl, s, s.metric, s.field, (s.numerator && s.numerator.filter) || {})\n\t\t\t\t\tconst den = await this.fetchAggregate(axios, generateUrl, s, s.metric, s.field, (s.denominator && s.denominator.filter) || {})\n\t\t\t\t\tthis.value = (den && Number(den) !== 0) ? (Number(num) / Number(den)) * 100 : null\n\t\t\t\t} else if (s.kind === 'computed') {\n\t\t\t\t\t// Fetch each named part, then evaluate the formula over them.\n\t\t\t\t\tconst { evalFormula } = await import('../../utils/evalFormula.js')\n\t\t\t\t\tconst parts = s.parts || {}\n\t\t\t\t\tconst vars = {}\n\t\t\t\t\tfor (const [name, p] of Object.entries(parts)) {\n\t\t\t\t\t\tvars[name] = Number(await this.fetchAggregate(axios, generateUrl, s, p.metric || 'count', p.field, p.filter || {})) || 0\n\t\t\t\t\t}\n\t\t\t\t\tthis.value = evalFormula(s.formula || '', vars)\n\t\t\t\t} else if (s.kind === 'weighted') {\n\t\t\t\t\tthis.value = await this.fetchWeighted(axios, generateUrl, s)\n\t\t\t\t} else {\n\t\t\t\t\tthis.value = await this.fetchAggregate(axios, generateUrl, s, s.metric, s.field, s.filter || {})\n\t\t\t\t}\n\t\t\t} catch (e) {\n\t\t\t\tthis.error = (e && e.message) || 'error'\n\t\t\t\tthis.value = null\n\t\t\t} finally {\n\t\t\t\tthis.loading = false\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Compute a weighted sum `Σ (field × weightField) ÷ divisor` client-side\n\t\t * (no OpenRegister expression-aggregation primitive yet). Pulls the\n\t\t * matching objects (capped at `limit`, default 1000) and folds them.\n\t\t *\n\t\t * @param {object} axios The axios instance.\n\t\t * @param {(url: string, params?: object) => string} generateUrl The router helper.\n\t\t * @param {object} s The weighted source `{ field, weightField, divisor?, filter?, limit? }`.\n\t\t * @return {Promise<number|null>} The weighted sum.\n\t\t */\n\t\tasync fetchWeighted(axios, generateUrl, s) {\n\t\t\tif (!s.field || !s.weightField) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst url = generateUrl(\n\t\t\t\t'/apps/openregister/api/objects/{register}/{schema}',\n\t\t\t\t{ register: s.register, schema: s.schema },\n\t\t\t)\n\t\t\tconst params = { _limit: s.limit || 1000 }\n\t\t\tthis.flattenFilter(params, s.filter || {})\n\t\t\tconst res = await axios.get(url, { params })\n\t\t\tconst rows = (res && res.data && res.data.results) || []\n\t\t\tconst divisor = Number(s.divisor) || 1\n\t\t\tlet sum = 0\n\t\t\tfor (const r of rows) {\n\t\t\t\tconst v = Number(r[s.field])\n\t\t\t\tconst w = Number(r[s.weightField])\n\t\t\t\tif (Number.isFinite(v) && Number.isFinite(w)) {\n\t\t\t\t\tsum += (v * w) / divisor\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn sum\n\t\t},\n\n\t\t/**\n\t\t * Resolve `@page.<key>` / `@workspace.<key>` / `@config.<key>` /\n\t\t * `@objectId` / `@object.<field>` tokens inside a string against the page\n\t\t * + config + object contexts. `@page.*` is an alias for `@workspace.*`\n\t\t * (both read the page-level context); `@config.*` reads the page-level app\n\t\t * config. Unresolved tokens collapse to an empty string so a half-built\n\t\t * URL never sends a literal `@page.period`.\n\t\t *\n\t\t * @param {string} str The raw string (URL or param value).\n\t\t * @return {string} The interpolated string.\n\t\t */\n\t\tinterpolateTokens(str) {\n\t\t\tif (typeof str !== 'string') {\n\t\t\t\treturn str\n\t\t\t}\n\t\t\treturn str.replace(/@(page|workspace)\\.([A-Za-z0-9_]+)/g, (_, _ns, key) => {\n\t\t\t\tconst v = this.pageCtx[key]\n\t\t\t\treturn (v === undefined || v === null) ? '' : String(v)\n\t\t\t}).replace(/@config\\.([A-Za-z0-9_]+)/g, (_, key) => {\n\t\t\t\tconst v = this.configCtx[key]\n\t\t\t\treturn (v === undefined || v === null) ? '' : String(v)\n\t\t\t}).replace(/@objectId/g, () => {\n\t\t\t\tconst id = this.objectCtx && this.objectCtx.objectId\n\t\t\t\treturn (id === undefined || id === null) ? '' : String(id)\n\t\t\t}).replace(/@object\\.([A-Za-z0-9_]+)/g, (_, field) => {\n\t\t\t\tconst v = this.objectCtx && this.objectCtx.object && this.objectCtx.object[field]\n\t\t\t\treturn (v === undefined || v === null) ? '' : String(v)\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Read a dot-path off an object (e.g. `\"data.totalLeads\"`, `\"summary.0.count\"`).\n\t\t * Returns undefined when any segment is missing.\n\t\t *\n\t\t * @param {object} obj The source object.\n\t\t * @param {string} path The dot-path.\n\t\t * @return {unknown} The resolved value or undefined.\n\t\t */\n\t\tgetByPath(obj, path) {\n\t\t\t// Delegates to the shared useEndpointSource util so the legacy\n\t\t\t// `source.kind: 'endpoint'` path and the Wave-2 `endpointSource`\n\t\t\t// path pluck identically.\n\t\t\treturn getByPath(obj, path)\n\t\t},\n\n\t\t/**\n\t\t * Fetch a single value from an arbitrary app REST endpoint. The `url` and\n\t\t * any string `params` value are token-interpolated (`@page.*` etc.), the\n\t\t * response is read at `path` (dot-path; default = whole body), and the\n\t\t * result is coerced to a number when numeric. Lets a dashboard KPI bind\n\t\t * to a custom-aggregation endpoint (e.g. `/api/analytics/summary`) that\n\t\t * OpenRegister's per-schema aggregation can't express.\n\t\t *\n\t\t * Never served from the settled shared cache, so a tile never shows a\n\t\t * count from before a write. Tiles loading at once, or reached by one\n\t\t * page refresh, share a single request per endpoint and params.\n\t\t *\n\t\t * @param {object} s The endpoint source `{ url, path?, params?, method? }`.\n\t\t * @param {object} [refresh] The page refresh event's payload, when this is one.\n\t\t * @return {Promise<number|null>} The extracted value.\n\t\t */\n\t\tasync fetchEndpoint(s, refresh) {\n\t\t\tconst params = {}\n\t\t\tfor (const [k, v] of Object.entries(s.params || {})) {\n\t\t\t\tparams[k] = typeof v === 'string' ? this.interpolateTokens(v) : v\n\t\t\t}\n\t\t\tconst body = await fetchSharedResponse(\n\t\t\t\t{ url: this.interpolateTokens(s.url), method: 'GET', params },\n\t\t\t\trefresh ? { force: true, refresh } : { fresh: true },\n\t\t\t)\n\t\t\tconst extracted = this.getByPath(body, s.path)\n\t\t\tif (extracted === undefined || extracted === null) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst num = Number(extracted)\n\t\t\treturn Number.isFinite(num) ? num : extracted\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/*\n * The card itself — its box, body stack, icon circle, title and value\n * typography — is the CANONICAL KPI card in src/css/kpi-card.css, shared with\n * CnStatsBlock. What remains here is only what is specific to THIS widget: the\n * range select, the \"/ limit\" denominator, the trend chip and the error glyph.\n *\n * Every size below is expressed against the shared `--cn-kpi-*` scale rather\n * than as its own number, so the widget's extras re-scale with the card they\n * sit in. Resist adding card styling back, and resist restating a value the\n * scale already holds; that is what split the two KPI looks apart in the first\n * place.\n */\n\n/* The title row carries an optional range select beside the label, which the\n canonical `__title` (a plain truncating heading) does not account for. */\n.cn-stat-widget__label {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tgap: 8px;\n\twidth: 100%;\n}\n\n/* Stacked tile: the label and its range select centre over the number rather\n than spreading to the card's edges. */\n.cn-kpi-card--vertical .cn-stat-widget__label {\n\tjustify-content: center;\n\twidth: auto;\n}\n\n/* Sized down to sit inside the label row without pushing the tile taller —\n the tile's height is fixed by its grid cell (ADR-062). */\n.cn-stat-widget__range {\n\tflex: 0 0 auto;\n\tmax-width: 50%;\n\tpadding: 0 4px;\n\tborder: none;\n\tborder-radius: var(--border-radius);\n\tbackground: transparent;\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tfont-weight: normal;\n\tcursor: pointer;\n}\n\n.cn-stat-widget__range:hover,\n.cn-stat-widget__range:focus-visible {\n\tbackground: var(--color-background-hover);\n\tcolor: var(--color-main-text);\n}\n\n/* Badge mode: the pill takes the value's place in the row. Sized to the\n card's label scale so it sits in the row without pushing the tile taller. */\n.cn-stat-widget__badge {\n\tflex: 0 1 auto;\n\tmin-width: 0;\n\tmax-width: 100%;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n}\n\n/* The denominator of a \"value / limit\" pair. Deliberately quieter and smaller\n than the value: the tile's subject is what the number IS, not what it is\n allowed to reach. Shares the value's `nowrap` so the pair never breaks. */\n.cn-stat-widget__limit {\n\tflex: 0 0 auto;\n\tfont-size: var(--cn-kpi-value-size-compact, 1.25rem);\n\tfont-weight: var(--cn-kpi-title-weight, 600);\n\tline-height: var(--cn-kpi-value-line, 1.1);\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n\twhite-space: nowrap;\n}\n\n.cn-stat-widget__trend {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 2px;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tfont-weight: var(--cn-kpi-title-weight, 600);\n}\n\n/* The dash shown when the number could not be fetched. It stands where the\n value stands, so it takes the value's size and weight — CnStatsBlock's own\n error dash is literally a `cn-kpi-card__value`, and two components showing\n the same failure at two different sizes is the drift this consolidation\n exists to remove. Only the colour differs: a failure is not a number. */\n.cn-stat-widget__error {\n\tfont-size: var(--cn-kpi-value-size, 2rem);\n\tfont-weight: var(--cn-kpi-value-weight, 700);\n\tline-height: var(--cn-kpi-value-line, 1.1);\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n}\n</style>\n"],"names":["_createVNode","_createElementVNode","_openBlock","_createElementBlock","_Fragment","_renderList","_toDisplayString","_createBlock","_normalizeClass"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"CnStatWidget.vue3.js","sources":["../../../../src/components/CnStatWidget/CnStatWidget.vue?vue&type=template&id=9c5d29a2&lang.js"],"sourcesContent":["<!--\n SPDX-FileCopyrightText: 2026 Conduction B.V.\n SPDX-License-Identifier: EUPL-1.2\n\n CnStatWidget renders the CANONICAL KPI card (src/css/kpi-card.css). This tile\n and CnStatsBlock used to be two different-looking KPIs — a transparent\n icon-beside-number row here, a grey card with a circular icon and a large\n coloured number there — so OpenCatalogi's and dossiq's dashboards did not\n match. Both now render the same `cn-kpi-card` markup from the same\n stylesheet. The legacy `cn-stat-widget*` classes stay on the same elements so\n app CSS targeting them keeps working.\n\n Keep this comment OUT of <template>: a comment node beside the root element\n makes the component multi-root in Vue 3, and a multi-root component has no\n root for its class bindings to land on.\n-->\n<template>\n\t<component\n\t\t:is=\"linkTag\"\n\t\tclass=\"cn-kpi-card cn-stat-widget\"\n\t\t:class=\"[\n\t\t\t{ 'cn-kpi-card--clickable': isLinked, 'cn-stat-widget--linked': isLinked },\n\t\t\t'cn-kpi-card--' + cardLayout,\n\t\t\tflat ? 'cn-kpi-card--flat' : 'cn-kpi-card--filled',\n\t\t]\"\n\t\tv-bind=\"linkAttrs\">\n\t\t<div\n\t\t\tv-if=\"resolvedIcon\"\n\t\t\tclass=\"cn-kpi-card__icon cn-stat-widget__icon\"\n\t\t\t:style=\"iconCircleStyle\">\n\t\t\t<CnWidgetIcon :name=\"resolvedIcon\" :size=\"24\" />\n\t\t</div>\n\n\t\t<div class=\"cn-kpi-card__body cn-stat-widget__body\">\n\t\t\t<div v-if=\"content.label || rangePresets.length\" class=\"cn-kpi-card__title cn-stat-widget__label\">\n\t\t\t\t<span v-if=\"content.label\" :title=\"resolvedLabel\">{{ resolvedLabel }}</span>\n\n\t\t\t\t<!-- Per-tile range override. Rendered ONLY when the tile declares its\n\t\t\t\t own `content.dateRange.presets`; a tile that merely follows the\n\t\t\t\t dashboard range shows no control, because the page already has one\n\t\t\t\t and two controls for one range read as two ranges. Stops click\n\t\t\t\t propagation so choosing a range inside a linked tile does not also\n\t\t\t\t navigate. -->\n\t\t\t\t<select\n\t\t\t\t\tv-if=\"rangePresets.length\"\n\t\t\t\t\tclass=\"cn-stat-widget__range\"\n\t\t\t\t\tdata-testid=\"cn-stat-widget-range\"\n\t\t\t\t\t:aria-label=\"rangeAriaLabel\"\n\t\t\t\t\t:value=\"activeRangePreset\"\n\t\t\t\t\t@click.stop.prevent\n\t\t\t\t\t@change=\"selectRange($event.target.value)\">\n\t\t\t\t\t<option\n\t\t\t\t\t\tv-for=\"preset in rangePresets\"\n\t\t\t\t\t\t:key=\"preset.id\"\n\t\t\t\t\t\t:value=\"preset.id\">\n\t\t\t\t\t\t{{ effectiveTranslate(preset.label || preset.id) }}\n\t\t\t\t\t</option>\n\t\t\t\t</select>\n\t\t\t</div>\n\n\t\t\t<div class=\"cn-kpi-card__value-row cn-stat-widget__value-row\">\n\t\t\t\t<NcLoadingIcon v-if=\"displayLoading\" :size=\"22\" />\n\t\t\t\t<span v-else-if=\"displayError\" class=\"cn-stat-widget__error\" :title=\"displayError\">—</span>\n\t\t\t\t<template v-else>\n\t\t\t\t\t<!-- Badge mode: the value is a state name, so it reads as the\n\t\t\t\t\t library's status pill rather than as a headline number. -->\n\t\t\t\t\t<CnStatusBadge\n\t\t\t\t\t\tv-if=\"isBadge\"\n\t\t\t\t\t\tclass=\"cn-stat-widget__badge\"\n\t\t\t\t\t\tdata-testid=\"cn-stat-widget-badge\"\n\t\t\t\t\t\t:label=\"shownValue\"\n\t\t\t\t\t\t:variant=\"badgeVariant\" />\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-else\n\t\t\t\t\t\tclass=\"cn-kpi-card__value cn-stat-widget__value\"\n\t\t\t\t\t\t:data-testid=\"isCountdown ? 'cn-stat-widget-countdown' : null\"\n\t\t\t\t\t\t:class=\"{ 'cn-kpi-card__value--text': isTextValue || shownValueIsText }\"\n\t\t\t\t\t\t:title=\"shownValueIsText ? shownValue : (isTextValue ? String(displayValue) : null)\"\n\t\t\t\t\t\t:style=\"valueStyle\">\n\t\t\t\t\t\t{{ shownValue }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"formattedLimit !== ''\"\n\t\t\t\t\t\tclass=\"cn-stat-widget__limit\"\n\t\t\t\t\t\tdata-testid=\"cn-stat-widget-limit\">\n\t\t\t\t\t\t/ {{ formattedLimit }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"trendPct !== null\"\n\t\t\t\t\t\tclass=\"cn-stat-widget__trend\"\n\t\t\t\t\t\tdata-testid=\"cn-stat-widget-trend\"\n\t\t\t\t\t\t:style=\"{ color: trendColor }\">\n\t\t\t\t\t\t<component :is=\"trendIcon\" :size=\"14\" />\n\t\t\t\t\t\t{{ formattedTrend }}\n\t\t\t\t\t</span>\n\t\t\t\t</template>\n\t\t\t\t<span\n\t\t\t\t\tv-if=\"!displayLoading && !displayError && resolvedCaption\"\n\t\t\t\t\tclass=\"cn-kpi-card__label cn-stat-widget__caption\"\n\t\t\t\t\t:class=\"captionVariantClass\"\n\t\t\t\t\tdata-testid=\"cn-stat-widget-caption\">\n\t\t\t\t\t{{ resolvedCaption }}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t</div>\n\t</component>\n</template>\n\n<script>\nimport { subscribe, unsubscribe } from '@nextcloud/event-bus'\nimport { NcLoadingIcon } from '@nextcloud/vue'\nimport { inject, ref } from 'vue'\nimport TrendingDown from 'vue-material-design-icons/TrendingDown.vue'\nimport TrendingNeutral from 'vue-material-design-icons/TrendingNeutral.vue'\nimport TrendingUp from 'vue-material-design-icons/TrendingUp.vue'\nimport CnStatusBadge from '../CnStatusBadge/CnStatusBadge.vue'\nimport CnWidgetIcon from '../CnWidgetGrid/CnWidgetIcon.vue'\nimport { fetchSharedResponse, getByPath, useEndpointSource } from '../../composables/useEndpointSource.js'\nimport widgetLink from '../../mixins/widgetLink.js'\nimport { useObjectStore } from '../../store/useObjectStore.js'\nimport { resolveObjectOpType } from '../../utils/actionsDispatcher.js'\nimport { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'\nimport { formatMetricValue, unwrapAppConfig } from '../../utils/formatMetric.js'\nimport { dropOptionalUnresolved, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'\nimport { STATUS_TEXT_COLORS } from '../../utils/statusColors.js'\nimport { evaluateVisibleWhenLocal, readVisibleWhenPath } from '../../utils/visibleWhen.js'\n\n// The canonical KPI look lives in one shared stylesheet, imported by BOTH\n// this component and CnStatsBlock, so the two cannot drift apart again.\n// Importing it here also means the look arrives without the consuming app\n// having pulled in the library's global css/index.css.\nimport '../../css/kpi-card.css'\n// Badge mode renders CnStatusBadge, whose colours live in the global badge\n// stylesheet. Imported here for the same reason as kpi-card.css above.\nimport '../../css/badge.css'\n\nconst PAGE_REFRESH_CHANNEL = 'cn:page:refresh'\n\n/**\n * Variant → CSS colour token map for the `variantWhen` threshold rules.\n * `danger` is accepted as an alias of `error` (the doriath KPI-card\n * vocabulary); `default` keeps the widget's configured colours.\n *\n * @type {Record<string, string>}\n */\n// These paint the number, so they take the text-on-background tokens.\nconst VARIANT_COLORS = {\n\tdefault: '',\n\tprimary: 'var(--color-primary-element)',\n\tsuccess: STATUS_TEXT_COLORS.success,\n\twarning: STATUS_TEXT_COLORS.warning,\n\terror: STATUS_TEXT_COLORS.error,\n\tdanger: STATUS_TEXT_COLORS.error,\n}\n\n/**\n * The variants CnStatusBadge accepts. Badge mode normalises every tile\n * variant onto this list: `danger` becomes `error`, anything unknown is\n * skipped so the next rule in line decides.\n *\n * @type {string[]}\n */\nconst BADGE_VARIANTS = ['default', 'primary', 'success', 'warning', 'error', 'info']\n\n/**\n * A bare `YYYY-MM-DD`, the shape OpenRegister stores a date property in.\n *\n * It is matched BEFORE `new Date()` sees it on purpose. `new Date('2026-09-26')`\n * is specified to parse a date-only string as UTC midnight, so west of\n * Greenwich it lands on the 25th local and every countdown built on it is a\n * day short. A date the schema calls a date has no time and no zone, so it is\n * read as the local calendar day it names.\n *\n * @type {RegExp}\n */\nconst DATE_ONLY = /^(\\d{4})-(\\d{2})-(\\d{2})$/\n\n/** Milliseconds in a day, used only between two UTC-normalised midnights. */\nconst DAY_MS = 86400000\n\n/**\n * The local calendar day a value names, as a UTC midnight timestamp.\n *\n * WHY CALENDAR DAYS AND NOT ELAPSED MILLISECONDS. A deadline is a day on a\n * calendar, not an instant. Subtracting timestamps makes a deadline at 23:00\n * today read as \"0 days\" and one at 01:00 tomorrow read as \"0 days\" as well\n * (two hours apart), while 09:00 tomorrow reads as \"1 day\" — the same calendar\n * distance rendering as two different answers because of the time somebody\n * happened to type. So both ends are collapsed to the LOCAL calendar day they\n * fall on, and the difference is taken between those days.\n *\n * The collapse goes through `Date.UTC` rather than a local `Date`, because two\n * UTC midnights are always exactly `DAY_MS` apart, while two local midnights\n * across a DST change are 23 or 25 hours apart and a plain division then\n * rounds to the wrong day twice a year.\n *\n * @param {unknown} value A date string, a `Date`, or an epoch-milliseconds number.\n * @return {number|null} The UTC-midnight timestamp of that local day, or null when there is no readable date.\n */\nfunction calendarDay(value) {\n\tif (value === null || value === undefined || value === '') {\n\t\treturn null\n\t}\n\tif (typeof value === 'string') {\n\t\tconst parts = DATE_ONLY.exec(value.trim())\n\t\tif (parts) {\n\t\t\treturn Date.UTC(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]))\n\t\t}\n\t}\n\tconst isDate = value instanceof Date\n\tif (!isDate && typeof value !== 'string' && !Number.isFinite(value)) {\n\t\t// A boolean or an object is not a mis-typed date, it is not a date at\n\t\t// all; `new Date(true)` would happily answer 1 January 1970.\n\t\treturn null\n\t}\n\tconst parsed = isDate ? value : new Date(value)\n\tif (Number.isNaN(parsed.getTime())) {\n\t\treturn null\n\t}\n\treturn Date.UTC(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())\n}\n\n/**\n * CnStatWidget — an abstract, manifest-configured KPI / single-statistic tile.\n *\n * Reads ONE scalar value from OpenRegister's ad-hoc aggregation endpoint\n * (`/apps/openregister/api/objects/aggregations/{register}/{schema}/value`)\n * given a `source` config (register, schema, metric, field, filter) and renders\n * it with a configurable label, icon, value colour, and number format. Nothing\n * about the data or presentation is hard-coded — every fleet app gets the same\n * editable KPI tile, replacing per-app coded KPI components (ADR-041).\n *\n * The widget is resolved by its registry type key `stat` (see\n * `CnStatWidget/index.js`); apps reference it from a manifest placement, e.g.\n * `{ id, widgetKey: 'stat', type: 'stat', content: { label, source, ... } }`.\n *\n * Example content blob:\n * ```js\n * content: {\n * label: 'Revenue',\n * icon: 'Cash',\n * valueColor: '#0082c9',\n * format: { style: 'currency', currency: 'EUR', decimals: 0 },\n * source: { register: 'pipelinq', schema: 'lead', metric: 'sum', field: 'value', filter: { status: 'won' } },\n * }\n * ```\n *\n * ENDPOINT BINDING (Wave 2, #91) — instead of an OpenRegister `source`, the\n * tile can bind to an arbitrary app REST endpoint through the shared\n * `useEndpointSource` engine (token-resolved params, per-(url+params)\n * request dedup + short-TTL cache, `cn:page:refresh` / `cn:widget:refresh`\n * subscription). Exactly ONE of `source` | `endpointSource` may be\n * configured (validator-enforced); when both slip through, `endpointSource`\n * wins. The pipelinq analytics-KPI contract in full:\n * ```js\n * content: {\n * label: 'Revenue',\n * icon: 'CashMultiple',\n * format: { style: 'currency', currency: 'EUR', decimals: 0 },\n * endpointSource: {\n * url: '/apps/pipelinq/api/analytics/commercial',\n * params: { period: '@workspace.datePreset?' },\n * },\n * valueField: 'revenue', // dot-path into the payload\n * previousField: 'previousPeriod.revenue', // → trend sublabel (arrow + % vs previous)\n * // deltaField: 'revenueDeltaPct', // OR a server-computed delta percent\n * goodDirection: 'up', // tints the trend good/bad (default 'up')\n * variantWhen: [ // first-match threshold styling\n * { op: 'gte', value: 100000, variant: 'success' },\n * { op: 'lt', value: 10000, variant: 'warning', icon: 'AlertOutline' },\n * ],\n * clickRoute: 'leads', // whole-tile click-through (alias of route)\n * }\n * ```\n *\n * BADGE MODE. `display: 'badge'` renders the value as a `CnStatusBadge` pill,\n * for a tile whose value is a state name rather than a count. The badge colour\n * can come from the RESOLVED ROW of an `objectField` reference, not only from\n * the displayed text: `resolve.variantField` names a field on the looked-up\n * row, and `resolve.variantMap` maps its value to a variant (without a map the\n * field value itself must be a variant name). `emptyText` replaces the dash\n * when the value is empty or its reference does not resolve. `overrides` test\n * the BOUND RECORD, first match wins, and replace the label, variant and icon,\n * so a record in a special state reads as that state:\n * ```js\n * content: {\n * label: 'Status',\n * display: 'badge',\n * emptyText: 'Unknown',\n * objectField: {\n * field: 'status',\n * resolve: {\n * register: 'dossiq',\n * schema: 'statusType',\n * labelField: 'name',\n * variantField: 'isFinal',\n * variantMap: { true: 'success', false: 'info' },\n * },\n * },\n * overrides: [\n * { when: { field: 'suspended' }, label: 'Suspended', variant: 'warning' },\n * ],\n * }\n * ```\n *\n * COUNTDOWN MODE. `display: 'countdown'` reads a DATE off the record (or off an\n * endpoint payload) and renders the time remaining, so a deadline is a\n * configured KPI tile rather than a component an app writes by hand:\n * ```js\n * content: {\n * label: 'Deadline',\n * display: 'countdown',\n * objectField: 'dueDate',\n * countdown: {\n * unit: 'days', // the only unit; anything else is read as days\n * warnAt: 14, // warning at or below this many days left\n * dangerAt: 5, // error at or below, and it wins over warnAt\n * pastLabel: 'Overdue by {n} days', // a date that has passed\n * emptyText: 'No deadline', // absent or unreadable date\n * },\n * }\n * ```\n * A passed date NEVER renders as a negative number: it renders `pastLabel` with\n * `{n}` as the number of days it is past, and it is always the error colour\n * whether or not `dangerAt` is set. Today is 0, not 1 and not −1, and renders\n * `todayLabel` (\"Today\"). The thresholds are INCLUSIVE. An absent or unreadable\n * date renders `countdown.emptyText`, falling back to `content.emptyText` and\n * then to the dash, never `NaN` or `Invalid Date`. `overrides` outrank the\n * countdown's own label and colour exactly as they outrank a badge's.\n */\nexport default {\n\tname: 'CnStatWidget',\n\n\tcomponents: {\n\t\tNcLoadingIcon,\n\t\tCnWidgetIcon,\n\t\tCnStatusBadge,\n\t\tTrendingUp,\n\t\tTrendingDown,\n\t\tTrendingNeutral,\n\t},\n\n\tmixins: [widgetLink],\n\n\tinject: {\n\t\t/**\n\t\t * Detail-page object context (`{ objectId, object, register, schema }`)\n\t\t * provided by CnDetailPage — enables `@objectId` / `@object.<field>`\n\t\t * filter tokens so a detail-page KPI can be scoped to the current\n\t\t * object. Null on dashboards (tokens then pass through unresolved).\n\t\t */\n\t\tcnObjectContext: { default: null },\n\t\t/**\n\t\t * v2 slot-grid detail context holder (`{ value: { objectData, schema,\n\t\t * objectType, objectId, register, store } | null }`) provided by\n\t\t * CnPageRenderer — backfills the object token context so\n\t\t * `@objectId` / `@object.<field>` resolve on detail surfaces where\n\t\t * CnDetailPage is not an ancestor (#91 Wave 3).\n\t\t */\n\t\tcnDetailObjectContext: { default: null },\n\t\t/**\n\t\t * Page-level workspace context (a reactive `{ <key>: value }` map)\n\t\t * provided by CnDashboardPage. Drives `@page.<param>` / `@workspace.<param>`\n\t\t * tokens in an `endpoint` source's URL / params (e.g. a period selector\n\t\t * the page renders that every endpoint KPI reads). Empty `{}` when no\n\t\t * dashboard ancestor provides one.\n\t\t */\n\t\tcnWorkspaceContext: { default: () => ({}) },\n\t\t/**\n\t\t * Page-level app config (a reactive `{ <key>: value }` map) provided by\n\t\t * CnDashboardPage / CnDetailPage. Drives `@config.<key>` tokens in an\n\t\t * `endpoint` source's URL / params AND in the `format.currency` /\n\t\t * `format.suffix` / `format.prefix` strings (e.g. the reporting currency\n\t\t * the setup wizard captures). Empty `{}` when no ancestor provides one.\n\t\t */\n\t\tcnAppConfig: { default: () => ({}) },\n\t\t/**\n\t\t * Translate function provided by CnAppRoot (the host app's\n\t\t * `translate`, scoped to its app id). Applied to the manifest-authored\n\t\t * `content.label` / `content.caption` so a KPI tile renders in the\n\t\t * user's Nextcloud language instead of the raw source string. Defaults\n\t\t * to an identity function so the widget stays usable standalone.\n\t\t *\n\t\t * @type {(key: string) => string}\n\t\t */\n\t\tcnTranslate: { default: () => (key) => key },\n\t\t/**\n\t\t * Reactive date range provided by an ancestor `CnDashboardPage` when its\n\t\t * `dateRange.enabled` is true — `{ from, to, preset }`, else null. Same\n\t\t * ref `CnChartWidget` injects, so a tile and a chart on one dashboard\n\t\t * always agree on the period. A tile follows it only once it declares\n\t\t * `content.dateRange` (see the `content` prop docs for why).\n\t\t *\n\t\t * @type {{value: ({from: string, to: string, preset: string}|null)}}\n\t\t */\n\t\tcnDashboardDateRange: { default: () => ref(null) },\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * The widget's persisted configuration blob. An optional `route`\n\t\t * (vue-router location), `clickRoute` (Wave-2 alias), or `link`\n\t\t * (external href) turns the whole tile into a click-through target\n\t\t * (see the widgetLink mixin).\n\t\t * The `source` resolves the value. Besides the OpenRegister-backed kinds\n\t\t * (plain aggregate / `ratio` / `computed` / `weighted`), an\n\t\t * `{ kind: 'endpoint', url, path?, params? }` source reads an arbitrary\n\t\t * app REST endpoint and extracts the value at the dot-`path` of the\n\t\t * response (default = whole body). The `url` and any string `params`\n\t\t * value interpolate `@page.<param>` / `@workspace.<param>` tokens from the\n\t\t * page-level context (and `@objectId` / `@object.<field>` on a detail\n\t\t * page) — so a page-rendered period selector can drive every endpoint KPI.\n\t\t *\n\t\t * Wave 2 (#91): `endpointSource` (`{ url, method?, params?,\n\t\t * responsePath? }`) binds the tile to an arbitrary endpoint through the\n\t\t * shared `useEndpointSource` engine — exactly one of `source` |\n\t\t * `endpointSource`. `valueField` plucks the displayed value from the\n\t\t * payload; `previousField` (previous-period value) or `deltaField`\n\t\t * (server-computed percent) renders the trend sublabel (arrow +\n\t\t * percent-vs-previous, tinted by `goodDirection`, default `'up'`);\n\t\t * `variantWhen` (`[{ op, value, variant, icon? }]`, first match wins)\n\t\t * re-tints the value/icon by threshold.\n\t\t *\n\t\t * `limitField` (dot-path into the payload) or a static `limit` renders the\n\t\t * tile as a capacity pair — `0 / 100` — and tints it `warning` once the\n\t\t * value reaches the limit, unless a `variantWhen` rule already claims the\n\t\t * colour. Use `limitField` for a server-configured quota so the ceiling is\n\t\t * read live rather than duplicated in the manifest.\n\t\t *\n\t\t * `dateRange` opts the tile into the dashboard's period. Present and empty\n\t\t * (`{}`) = follow the ancestor `CnDashboardPage` range; add `presets`\n\t\t * (`[{ id, label?, from?, to? }]`) to render a per-tile picker that\n\t\t * overrides it. With presets and no page range, the tile starts on\n\t\t * `dateRange.default` (a preset id), else on the first preset, so the\n\t\t * picker never opens blank and the first request already carries a\n\t\t * range. The active range is exposed to `endpointSource` (and to the\n\t\t * tile's `route`) as `@range.from` / `@range.to` / `@range.preset`\n\t\t * tokens. A tile that\n\t\t * declares no `dateRange` is unaffected by the page range — that is\n\t\t * deliberate, so adding a range to a dashboard cannot silently change what\n\t\t * an existing tile requests.\n\t\t *\n\t\t * `display: 'badge'` renders the value as a `CnStatusBadge`. On an\n\t\t * `objectField` reference, `resolve.variantField` (a dot-path on the\n\t\t * looked-up row) plus `resolve.variantMap` (`{ rowValue: variant }`)\n\t\t * colour it from the row; without a map the row value must itself be a\n\t\t * variant name. `emptyText` replaces the dash for an empty value, and\n\t\t * for a reference that does not resolve. `overrides`\n\t\t * (`[{ when: { field, op?, value? }, label?, variant?, icon? }]`, first\n\t\t * match wins) test the bound record: `when` is the local `visibleWhen`\n\t\t * grammar, and a `when` with neither `op` nor `value` tests the field\n\t\t * for truthiness. A match replaces the shown label, the colour and the\n\t\t * icon. Precedence of the colour: override, `variantWhen`, the countdown\n\t\t * threshold, the at-limit warning, the resolved row, the static\n\t\t * `variant`.\n\t\t *\n\t\t * `display: 'countdown'` reads the value as a DATE and renders the days\n\t\t * remaining. `countdown.warnAt` / `countdown.dangerAt` are inclusive day\n\t\t * thresholds and `dangerAt` wins; `countdown.pastLabel` (with `{n}`)\n\t\t * words a date that has passed, which is always the error colour and is\n\t\t * never shown as a negative number; `countdown.todayLabel` words a date\n\t\t * due today, which counts as 0; `countdown.futureLabel` (with `{n}`)\n\t\t * words the ordinary case; `countdown.emptyText` words an absent or\n\t\t * unreadable date, falling back to the top-level `emptyText`. Days are\n\t\t * counted between LOCAL CALENDAR DAYS, not as elapsed milliseconds, so\n\t\t * the time of day on a deadline never moves the answer.\n\t\t *\n\t\t * @type {{label?: string, icon?: string, iconColor?: string, valueColor?: string, caption?: string, route?: (object|string), clickRoute?: (object|string), link?: string, format?: {style?: string, currency?: string, decimals?: number, prefix?: string, suffix?: string}, source?: {kind?: string, register?: string, schema?: string, metric?: string, field?: string, filter?: object, url?: string, path?: string, params?: object}, endpointSource?: {url: string, method?: string, params?: object, responsePath?: string}, valueField?: string, limitField?: string, limit?: number, dateRange?: {presets?: Array<{id: string, label?: string, from?: string, to?: string}>, default?: string}, previousField?: string, deltaField?: string, goodDirection?: ('up'|'down'), variant?: ('default'|'primary'|'success'|'warning'|'error'|'danger'), variantWhen?: Array<{op: string, value: unknown, variant: string, icon?: string}>, objectField?: (string|{field: string, resolve?: {register: string, schema: string, labelField?: string, variantField?: string, variantMap?: {[key: string]: string}}}), display?: ('text'|'badge'|'countdown'), countdown?: {unit?: 'days', warnAt?: number, dangerAt?: number, futureLabel?: string, todayLabel?: string, pastLabel?: string, emptyText?: string}, emptyText?: string, captionVariant?: ('default'|'primary'|'success'|'warning'|'error'|'danger'), captionVariantWhen?: Array<{op: string, value: unknown, variant: string}>, overrides?: Array<{when: {field: string, op?: string, value?: unknown}, label?: string, variant?: string, icon?: string, caption?: string, captionVariant?: string}>}}\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 * Translate function. Falls back to the injected `cnTranslate`\n\t\t * (itself an identity function by default). Provide explicitly when\n\t\t * mounting CnStatWidget outside a CnAppRoot ancestor.\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\tsetup(props) {\n\t\t// Endpoint binding (Wave 2): the shared useEndpointSource engine owns\n\t\t// token resolution, request dedup + TTL caching, and the\n\t\t// cn:page:refresh / cn:widget:refresh subscriptions. It is a no-op\n\t\t// while `content.endpointSource` is absent, so the OpenRegister\n\t\t// `source` path below is untouched. The injects are re-read here in\n\t\t// setup — Vue 2.7 resolves them identically to the Options `inject`\n\t\t// block (same precedent as CnChartWidget's date-range ref).\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 pageRangeRaw = inject('cnDashboardDateRange', ref(null))\n\t\tconst unwrap = (v) => ((v && typeof v === 'object' && 'value' in v) ? v.value : v)\n\n\t\t// The per-tile range override. Null = follow the dashboard range; set by\n\t\t// selectRange() when the tile renders its own preset picker. It lives in\n\t\t// setup, not data(), because the ctx closure below must read it reactively\n\t\t// — a data() property would be resolved once and never refetch.\n\t\tconst tileRange = ref(null)\n\t\t// With its own presets and no page range, the tile starts on\n\t\t// `dateRange.default`, else the first preset. Without this the picker\n\t\t// opened blank (its value '' matched no option unless an app added a\n\t\t// preset with id '') and the first request carried no range. Only a\n\t\t// tile that declares presets gets a default: one that merely follows\n\t\t// the dashboard (`dateRange: {}`) keeps following it.\n\t\tconst defaultRange = () => {\n\t\t\tconst dr = props.content && props.content.dateRange\n\t\t\tconst presets = (dr && Array.isArray(dr.presets)) ? dr.presets.filter(Boolean) : []\n\t\t\tif (presets.length === 0) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst chosen = (dr.default !== undefined && presets.find((p) => p.id === dr.default)) || presets[0]\n\t\t\treturn { preset: chosen.id, from: chosen.from ?? null, to: chosen.to ?? null }\n\t\t}\n\t\tconst activeRange = () => (tileRange.value || unwrap(pageRangeRaw) || defaultRange())\n\n\t\tconst { data, loading, error, refetch } = useEndpointSource(\n\t\t\t() => (props.content && props.content.endpointSource) || null,\n\t\t\t{\n\t\t\t\tctx: () => ({\n\t\t\t\t\t...(resolveObjectTokenContext(objectCtxRaw, detailCtxRaw) || {}),\n\t\t\t\t\tworkspace: unwrap(workspaceRaw) || {},\n\t\t\t\t\tconfig: unwrap(appConfigRaw) || {},\n\t\t\t\t\t// `@range.from` / `@range.to` / `@range.preset` tokens, usable in the\n\t\t\t\t\t// endpointSource url and params exactly like `@workspace.*`.\n\t\t\t\t\trange: activeRange() || {},\n\t\t\t\t}),\n\t\t\t},\n\t\t)\n\t\treturn { epData: data, epLoading: loading, epError: error, epRefetch: refetch, tileRange, activeRange }\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\t// Resolved display label for an `objectField` that holds a reference\n\t\t\t// uuid. Null until resolved, and null forever when it cannot be.\n\t\t\treferenceLabel: null,\n\t\t\t// The looked-up row behind `referenceLabel`, read by\n\t\t\t// `resolve.variantField` to colour the tile from the row itself.\n\t\t\treferenceRow: null,\n\t\t\t// True once a configured lookup ran and found no usable label.\n\t\t\treferenceFailed: false,\n\t\t\t// True while a configured lookup is in flight. Without it, \"not\n\t\t\t// resolved yet\" and \"cannot be resolved\" were the same state, so the\n\t\t\t// tile showed the RAW UUID for the length of the request and then\n\t\t\t// replaced it with the label or the empty text. A person watching a\n\t\t\t// status badge saw `4f2b9c10-…` flash past where a state belonged.\n\t\t\treferencePending: false,\n\t\t\tvalue: null,\n\t\t\tloading: false,\n\t\t\terror: '',\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Effective translate function: the explicit `translate` prop when\n\t\t * given, else the injected `cnTranslate` (identity by default).\n\t\t *\n\t\t * @return {(key: string) => string}\n\t\t */\n\t\teffectiveTranslate() {\n\t\t\treturn this.translate ?? this.cnTranslate\n\t\t},\n\n\t\t/**\n\t\t * The tile label, run through the host translate function so a\n\t\t * manifest-authored source string localises to the user's language.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedLabel() {\n\t\t\tconst label = this.content.label\n\t\t\treturn label ? this.effectiveTranslate(label) : ''\n\t\t},\n\n\t\t/**\n\t\t * The tile caption, translated and then interpolated with the fetched\n\t\t * payload.\n\t\t *\n\t\t * `{field}` tokens are replaced from the endpoint payload by dot-path,\n\t\t * using the same {@link getByPath} that resolves `valueField` — so a\n\t\t * caption can say what the number MEANS rather than repeating it:\n\t\t *\n\t\t * caption: '{levelName} · {currentStreakDays}-day streak'\n\t\t *\n\t\t * WHY THIS EXISTS. Without it a tile needing a secondary line had to be\n\t\t * a bespoke component: it could not be expressed as config, so apps\n\t\t * wrote their own card, and that card then re-implemented fetching,\n\t\t * loading and error handling too. Every one of those re-implementations\n\t\t * observed so far swallowed its errors into a zero.\n\t\t *\n\t\t * Translation runs FIRST, so a translator sees `{levelName}` as a\n\t\t * placeholder in the source string and can move it within the sentence —\n\t\t * the same contract as Nextcloud's own `t()` parameters.\n\t\t *\n\t\t * A token with no matching field resolves to the empty string rather\n\t\t * than being left as `{levelName}` on screen: a caption is decoration,\n\t\t * and showing a reader a raw token is worse than showing them less.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedCaption() {\n\t\t\tconst override = this.activeOverride\n\t\t\tconst caption = (override && typeof override.caption === 'string' && override.caption !== '')\n\t\t\t\t? override.caption\n\t\t\t\t: this.content.caption\n\t\t\tif (!caption) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst translated = this.effectiveTranslate(caption)\n\t\t\tif (translated.indexOf('{') === -1) {\n\t\t\t\treturn translated\n\t\t\t}\n\t\t\tconst payload = this.endpointMode ? this.epData : null\n\t\t\treturn translated.replace(/\\{([A-Za-z0-9_.]+)\\}/g, (whole, path) => {\n\t\t\t\t// `{value}` is the tile's own number, so a caption can read\n\t\t\t\t// \"{value} due today\" on a register-counted tile that has no\n\t\t\t\t// payload to read fields from.\n\t\t\t\tif (path === 'value' && (payload === null || getByPath(payload, path) === undefined)) {\n\t\t\t\t\treturn this.formattedValue === undefined || this.formattedValue === null ? '' : String(this.formattedValue)\n\t\t\t\t}\n\t\t\t\tconst v = getByPath(payload, path)\n\t\t\t\treturn (v === undefined || v === null) ? '' : String(v)\n\t\t\t}).replace(/\\s{2,}/g, ' ').trim()\n\t\t},\n\n\t\t/**\n\t\t * The first `content.captionVariantWhen` rule the tile's value\n\t\t * matches (`[{ op, value, variant }]`, the `variantWhen` shape), or\n\t\t * null. Colours the caption on the value without recolouring the\n\t\t * tile: \"1 due today\" turns red while the number stays ink.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stat-tile-colours-its-caption-by-rule\n\t\t * @return {object|null}\n\t\t */\n\t\tactiveCaptionRule() {\n\t\t\tconst rules = this.content.captionVariantWhen\n\t\t\tif (!Array.isArray(rules) || rules.length === 0) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst current = this.displayValue\n\t\t\tif (current === null || current === undefined) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn rules.find((r) => r && this.matchesRule(current, r)) || null\n\t\t},\n\n\t\t/**\n\t\t * The caption's colour class: the matching override's\n\t\t * `captionVariant`, else `content.captionVariant`, as\n\t\t * `cn-kpi-card__label--<variant>`. '' for `default` or an unknown\n\t\t * variant, so an uncoloured caption is what it always was.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stat-tile-colours-its-caption-by-rule\n\t\t * @return {string}\n\t\t */\n\t\tcaptionVariantClass() {\n\t\t\tconst override = this.activeOverride\n\t\t\tconst variant = (override && override.captionVariant)\n\t\t\t\t|| (this.activeCaptionRule && this.activeCaptionRule.variant)\n\t\t\t\t|| this.content.captionVariant\n\t\t\tif (!variant || variant === 'default' || !Object.hasOwn(VARIANT_COLORS, variant)) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\treturn `cn-kpi-card__label--${variant === 'danger' ? 'error' : variant}`\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped detail-page object context for token resolution, or null\n\t\t * on surfaces (dashboards) that don't provide one.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tobjectCtx() {\n\t\t\treturn resolveObjectTokenContext(this.cnObjectContext, this.cnDetailObjectContext)\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped page-level workspace context map for `@page.*` /\n\t\t * `@workspace.*` token resolution. Always an object (defaults to `{}`).\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\tpageCtx() {\n\t\t\tconst c = this.cnWorkspaceContext\n\t\t\tconst unwrapped = (c && typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t\treturn (unwrapped && typeof unwrapped === 'object') ? unwrapped : {}\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped page-level app config map for `@config.*` token\n\t\t * resolution. Always an object (defaults to `{}`).\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\tconfigCtx() {\n\t\t\treturn unwrapAppConfig(this.cnAppConfig)\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile is endpoint-bound (Wave 2): a `content.endpointSource`\n\t\t * with a `url` switches the value/loading/error surface to the shared\n\t\t * `useEndpointSource` engine. Exactly one of `source` | `endpointSource`\n\t\t * is allowed (validator-enforced); endpointSource wins when both slip\n\t\t * through.\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tendpointMode() {\n\t\t\tconst es = this.content.endpointSource\n\t\t\treturn !!(es && es.url)\n\t\t},\n\n\t\t/**\n\t\t * The raw display value: in endpoint mode, the payload value plucked at\n\t\t * `content.valueField` (dot-path; omitted = the payload itself);\n\t\t * otherwise the OpenRegister-aggregated `value`.\n\t\t *\n\t\t * @return {unknown}\n\t\t */\n\t\tdisplayValue() {\n\t\t\tif (this.objectFieldMode) {\n\t\t\t\treturn this.objectFieldValue\n\t\t\t}\n\t\t\tif (!this.endpointMode) {\n\t\t\t\treturn this.value\n\t\t\t}\n\t\t\tconst v = getByPath(this.epData, this.content.valueField)\n\t\t\treturn v === undefined ? null : v\n\t\t},\n\n\t\t/**\n\t\t * Whether the headline is text rather than a number.\n\t\t *\n\t\t * The KPI card is built around a number: it never wraps and never\n\t\t * shrinks, because in a narrow tile the decoration should give way and\n\t\t * the figure should not. A NAME needs the opposite, so it gets a variant\n\t\t * that wraps and clamps, plus a title attribute for the full string.\n\t\t *\n\t\t * @return {boolean} true when the value is not numeric.\n\t\t */\n\t\tisTextValue() {\n\t\t\tconst v = this.displayValue\n\t\t\tif (v === null || v === undefined || v === '') {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn !Number.isFinite(Number(v))\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile renders its value as a status badge.\n\t\t *\n\t\t * @return {boolean} true when `content.display` is `'badge'`.\n\t\t */\n\t\tisBadge() {\n\t\t\treturn this.content.display === 'badge'\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile renders its value as a time-remaining countdown.\n\t\t *\n\t\t * @return {boolean} true when `content.display` is `'countdown'`.\n\t\t */\n\t\tisCountdown() {\n\t\t\treturn this.content.display === 'countdown'\n\t\t},\n\n\t\t/**\n\t\t * The countdown block, or `{}`. `unit` is accepted and ignored: days is\n\t\t * the only unit this renders, and saying so is better than pretending\n\t\t * an hours or weeks mode exists.\n\t\t *\n\t\t * @return {object} The countdown config.\n\t\t */\n\t\tcountdownConfig() {\n\t\t\tconst cfg = this.content.countdown\n\t\t\treturn (cfg && typeof cfg === 'object') ? cfg : {}\n\t\t},\n\n\t\t/**\n\t\t * Whole LOCAL CALENDAR DAYS from today to the tile's value read as a\n\t\t * date. Positive is still to come, 0 is today, negative has passed.\n\t\t *\n\t\t * Zero is today because both ends are collapsed onto the calendar day\n\t\t * they fall on and then subtracted: a deadline dated today is the same\n\t\t * day as today, and the same day is a distance of nothing. Not 1 (which\n\t\t * would claim a day that is already being spent) and not −1 (which\n\t\t * would call a deadline that has not passed overdue).\n\t\t *\n\t\t * Null when the tile is not in countdown mode, or when the value holds\n\t\t * no readable date — which is what keeps `NaN` off the tile.\n\t\t *\n\t\t * @return {number|null} The signed day count, or null.\n\t\t */\n\t\tcountdownDays() {\n\t\t\tif (!this.isCountdown) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst due = calendarDay(this.displayValue)\n\t\t\tif (due === null) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst now = new Date()\n\t\t\tconst today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())\n\t\t\treturn Math.round((due - today) / DAY_MS)\n\t\t},\n\n\t\t/**\n\t\t * The countdown sentence: the past wording for a date that has gone by,\n\t\t * the today wording for today, the future wording otherwise. `{n}` is\n\t\t * the number of days, never signed — \"−3 days left\" is exactly the\n\t\t * reading this mode exists to prevent.\n\t\t *\n\t\t * The built-in wordings carry their own singular, so a tile that\n\t\t * configures nothing still reads \"1 day left\" rather than \"1 days\n\t\t * left\". A configured `futureLabel` / `pastLabel` replaces both forms,\n\t\t * because a manifest author writing their own wording is the one who\n\t\t * knows how their language counts.\n\t\t *\n\t\t * @return {string} The sentence, or '' when there is nothing to count.\n\t\t */\n\t\tcountdownText() {\n\t\t\tconst days = this.countdownDays\n\t\t\tif (days === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst cfg = this.countdownConfig\n\t\t\tif (days === 0) {\n\t\t\t\treturn this.effectiveTranslate(cfg.todayLabel || 'Today')\n\t\t\t}\n\t\t\tconst n = Math.abs(days)\n\t\t\tconst past = days < 0\n\t\t\tlet template = past ? cfg.pastLabel : cfg.futureLabel\n\t\t\tif (typeof template !== 'string' || template === '') {\n\t\t\t\tif (past) {\n\t\t\t\t\ttemplate = n === 1 ? 'Overdue by {n} day' : 'Overdue by {n} days'\n\t\t\t\t} else {\n\t\t\t\t\ttemplate = n === 1 ? '{n} day left' : '{n} days left'\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn this.effectiveTranslate(template).replace(/\\{n\\}/g, String(n))\n\t\t},\n\n\t\t/**\n\t\t * The colour the countdown itself asks for: error once the date has\n\t\t * passed or the day count is at or below `dangerAt`, warning at or\n\t\t * below `warnAt`, else ''.\n\t\t *\n\t\t * A passed date is the danger colour whether or not `dangerAt` is\n\t\t * configured: overdue is not a threshold anybody has to opt into. Both\n\t\t * thresholds are INCLUSIVE, and `dangerAt` is tested first, so exactly\n\t\t * 5 days with `dangerAt: 5` is danger and not warning even when\n\t\t * `warnAt` also matches.\n\t\t *\n\t\t * @return {string} 'error', 'warning', or ''.\n\t\t */\n\t\tcountdownVariant() {\n\t\t\tconst days = this.countdownDays\n\t\t\tif (days === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tif (days < 0) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tconst cfg = this.countdownConfig\n\t\t\tif (Number.isFinite(cfg.dangerAt) && days <= cfg.dangerAt) {\n\t\t\t\treturn 'error'\n\t\t\t}\n\t\t\tif (Number.isFinite(cfg.warnAt) && days <= cfg.warnAt) {\n\t\t\t\treturn 'warning'\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * The first `content.overrides` entry whose `when` matches the BOUND\n\t\t * RECORD, or null. Needs a detail-page record: on a dashboard there is\n\t\t * nothing to test, so no override applies.\n\t\t *\n\t\t * @return {object|null} The matching override.\n\t\t */\n\t\tactiveOverride() {\n\t\t\tconst rules = this.content.overrides\n\t\t\tif (!Array.isArray(rules) || rules.length === 0) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst record = this.objectCtx?.object\n\t\t\tif (!record || typeof record !== 'object') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn rules.find((rule) => rule && this.matchesOverride(record, rule.when)) || null\n\t\t},\n\n\t\t/**\n\t\t * The translated `content.emptyText` when it applies, else ''.\n\t\t *\n\t\t * It applies to an empty value, and to an `objectField` reference whose\n\t\t * lookup found nothing. Without `emptyText` an unresolvable reference\n\t\t * keeps showing its raw value, as it always has.\n\t\t *\n\t\t * In countdown mode it applies to an UNREADABLE date as well, not only\n\t\t * to an absent one. `'not yet planned'` is a perfectly non-empty value\n\t\t * and there is no countdown to be had from it, so without this the tile\n\t\t * would print the raw string where a deadline belongs.\n\t\t *\n\t\t * @return {string} The text to show instead of the value, or ''.\n\t\t */\n\t\temptyValueText() {\n\t\t\tconst text = this.isCountdown\n\t\t\t\t? (this.countdownConfig.emptyText || this.content.emptyText)\n\t\t\t\t: this.content.emptyText\n\t\t\tif (typeof text !== 'string' || text === '') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst v = this.displayValue\n\t\t\tconst empty = v === null || v === undefined || v === ''\n\t\t\tif (empty || (this.objectFieldMode && this.referenceFailed) || (this.isCountdown && this.countdownDays === null)) {\n\t\t\t\treturn this.effectiveTranslate(text)\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * The text the tile shows: an override's label, else the countdown\n\t\t * sentence, else the empty text, else the formatted value.\n\t\t *\n\t\t * The override sits above the countdown for the same reason it sits\n\t\t * above a badge's status: a suspended case is not waiting for its\n\t\t * deadline, so it reads as suspended.\n\t\t *\n\t\t * @return {string} The shown text.\n\t\t */\n\t\tshownValue() {\n\t\t\tconst override = this.activeOverride\n\t\t\tif (override && typeof override.label === 'string' && override.label !== '') {\n\t\t\t\treturn this.effectiveTranslate(override.label)\n\t\t\t}\n\t\t\treturn this.countdownText || this.emptyValueText || this.formattedValue\n\t\t},\n\n\t\t/**\n\t\t * Whether the shown text replaced the formatted value. Such a text is a\n\t\t * word, so it takes the wrapping text style of a name.\n\t\t *\n\t\t * @return {boolean} true when an override or the empty text is shown.\n\t\t */\n\t\tshownValueIsText() {\n\t\t\treturn this.shownValue !== this.formattedValue\n\t\t},\n\n\t\t/**\n\t\t * The variant the RESOLVED ROW asks for through\n\t\t * `objectField.resolve.variantField` / `variantMap`, or ''.\n\t\t *\n\t\t * @return {string} The variant name, or ''.\n\t\t */\n\t\trowVariant() {\n\t\t\tconst cfg = this.content.objectField\n\t\t\tconst resolve = (cfg && typeof cfg === 'object') ? cfg.resolve : null\n\t\t\tif (!resolve || !resolve.variantField || !this.referenceRow) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst raw = getByPath(this.referenceRow, resolve.variantField)\n\t\t\tif (raw === undefined || raw === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst map = resolve.variantMap\n\t\t\tif (map && typeof map === 'object') {\n\t\t\t\tconst hit = map[String(raw)]\n\t\t\t\treturn typeof hit === 'string' ? hit : ''\n\t\t\t}\n\t\t\treturn typeof raw === 'string' ? raw : ''\n\t\t},\n\n\t\t/**\n\t\t * The CnStatusBadge variant in badge mode. Same precedence as the text\n\t\t * colour: override, `variantWhen`, at-limit, resolved row, static\n\t\t * `variant`. The first one that names a badge variant wins.\n\t\t *\n\t\t * @return {string} A CnStatusBadge variant.\n\t\t */\n\t\tbadgeVariant() {\n\t\t\tconst rule = this.activeVariantRule\n\t\t\tconst candidates = [\n\t\t\t\tthis.activeOverride?.variant,\n\t\t\t\trule?.variant,\n\t\t\t\tthis.atLimit ? 'warning' : '',\n\t\t\t\tthis.rowVariant,\n\t\t\t\tthis.content.variant,\n\t\t\t]\n\t\t\tfor (const candidate of candidates) {\n\t\t\t\tconst variant = candidate === 'danger' ? 'error' : candidate\n\t\t\t\tif (BADGE_VARIANTS.includes(variant)) {\n\t\t\t\t\treturn variant\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn 'default'\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile reads a field off the BOUND RECORD rather than\n\t\t * aggregating or calling an endpoint (`content.objectField`).\n\t\t *\n\t\t * The record is already loaded by the detail page, so this costs no\n\t\t * request at all. It is what lets a KPI row headline a case's type or\n\t\t * its assignee beside the counts, instead of those facts being buried\n\t\t * three rows down in the properties grid.\n\t\t *\n\t\t * @return {boolean} true in object-field mode.\n\t\t */\n\t\tobjectFieldMode() {\n\t\t\tconst of = this.content.objectField\n\t\t\treturn !!(of && (typeof of === 'string' || of.field))\n\t\t},\n\n\t\t/**\n\t\t * The bound record's value for `content.objectField`.\n\t\t *\n\t\t * A reference field holds a uuid, which is not something to show a\n\t\t * person, so `resolve` names the register/schema to look the label up in.\n\t\t * The resolved label replaces the uuid; an unresolvable id falls back to\n\t\t * the raw value rather than blanking, exactly as CnFkResolveCell does.\n\t\t *\n\t\t * @return {unknown} The field's value, or null.\n\t\t */\n\t\t/**\n\t\t * The bound record's raw value for `content.objectField`, before any\n\t\t * reference resolution.\n\t\t *\n\t\t * @return {unknown} The raw field value, or null.\n\t\t */\n\t\tobjectFieldRaw() {\n\t\t\tconst cfg = this.content.objectField\n\t\t\tconst field = typeof cfg === 'string' ? cfg : cfg?.field\n\t\t\tconst record = this.objectCtx?.object\n\t\t\tif (!record || !field) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst raw = getByPath(record, field)\n\t\t\treturn (raw === undefined || raw === '') ? null : raw\n\t\t},\n\n\t\t/**\n\t\t * @return {unknown} The field's value, or null.\n\t\t */\n\t\tobjectFieldValue() {\n\t\t\tif (this.objectFieldRaw === null) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\t// An unresolved or unresolvable reference falls back to the raw value\n\t\t\t// rather than blanking, the same way CnFkResolveCell does.\n\t\t\treturn this.referenceLabel !== null ? this.referenceLabel : this.objectFieldRaw\n\t\t},\n\n\t\t/**\n\t\t * Loading state for the active source (endpoint or OpenRegister).\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tdisplayLoading() {\n\t\t\t// A reference lookup is a request like any other, and it is the only\n\t\t\t// one the record mode makes. Leaving it out meant the tile rendered\n\t\t\t// the raw uuid as if it were the answer while the request was still\n\t\t\t// out.\n\t\t\tif (this.objectFieldMode) {\n\t\t\t\treturn this.referencePending\n\t\t\t}\n\t\t\treturn this.endpointMode ? this.epLoading : this.loading\n\t\t},\n\n\t\t/**\n\t\t * Error message for the active source ('' = none).\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tdisplayError() {\n\t\t\treturn this.endpointMode ? this.epError : this.error\n\t\t},\n\n\t\t/**\n\t\t * The previous-period value plucked at `content.previousField`\n\t\t * (endpoint mode only), or null when not configured / not numeric.\n\t\t *\n\t\t * @return {number|null}\n\t\t */\n\t\tpreviousValue() {\n\t\t\tif (!this.endpointMode || !this.content.previousField) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst v = Number(getByPath(this.epData, this.content.previousField))\n\t\t\treturn Number.isFinite(v) ? v : null\n\t\t},\n\n\t\t/**\n\t\t * Trend percent for the sublabel (the pipelinq KPI contract): a\n\t\t * server-computed percent plucked at `content.deltaField` when set,\n\t\t * else the client-computed change vs `previousField`\n\t\t * (`(current − previous) ÷ |previous| × 100`). Null when the trend is\n\t\t * not configured or not computable (previous of 0, non-numeric values).\n\t\t *\n\t\t * @return {number|null}\n\t\t */\n\t\ttrendPct() {\n\t\t\tif (!this.endpointMode) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tif (this.content.deltaField) {\n\t\t\t\tconst v = Number(getByPath(this.epData, this.content.deltaField))\n\t\t\t\treturn Number.isFinite(v) ? v : null\n\t\t\t}\n\t\t\tconst prev = this.previousValue\n\t\t\tconst cur = Number(this.displayValue)\n\t\t\tif (prev === null || prev === 0 || !Number.isFinite(cur)) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn ((cur - prev) / Math.abs(prev)) * 100\n\t\t},\n\n\t\t/** The signed trend percent, e.g. \"+12.3%\". */\n\t\tformattedTrend() {\n\t\t\tif (this.trendPct === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst sign = this.trendPct > 0 ? '+' : ''\n\t\t\treturn `${sign}${this.trendPct.toFixed(1)}%`\n\t\t},\n\n\t\t/** The arrow component for the trend direction. */\n\t\ttrendIcon() {\n\t\t\tif (this.trendPct === null || Math.abs(this.trendPct) < 0.05) {\n\t\t\t\treturn 'TrendingNeutral'\n\t\t\t}\n\t\t\treturn this.trendPct > 0 ? 'TrendingUp' : 'TrendingDown'\n\t\t},\n\n\t\t/**\n\t\t * Green when the trend moves in `content.goodDirection` (default\n\t\t * 'up'), red otherwise, neutral for a ~0 change — the CnDeltaWidget\n\t\t * convention.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\ttrendColor() {\n\t\t\tif (this.trendPct === null || Math.abs(this.trendPct) < 0.05) {\n\t\t\t\treturn 'var(--color-text-maxcontrast)'\n\t\t\t}\n\t\t\tconst good = this.content.goodDirection || 'up'\n\t\t\tconst rising = this.trendPct > 0\n\t\t\tconst isGood = good === 'up' ? rising : !rising\n\t\t\treturn isGood ? STATUS_TEXT_COLORS.success : STATUS_TEXT_COLORS.error\n\t\t},\n\n\t\t/**\n\t\t * The first `content.variantWhen` rule matching the current display\n\t\t * value (first-match wins), or null. Each rule is\n\t\t * `{ op: eq|neq|gt|gte|lt|lte, value, variant, icon? }`.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tactiveVariantRule() {\n\t\t\tconst rules = this.content.variantWhen\n\t\t\tif (!Array.isArray(rules) || rules.length === 0) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst current = this.displayValue\n\t\t\tif (current === null || current === undefined) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn rules.find((r) => r && this.matchesRule(current, r)) || null\n\t\t},\n\n\t\t/**\n\t\t * The CSS colour for the matched variant rule ('' = keep the\n\t\t * configured colours).\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tvariantColor() {\n\t\t\t// An override describes the RECORD's state (a suspended case), which\n\t\t\t// outranks anything the value itself suggests.\n\t\t\tconst override = this.activeOverride\n\t\t\tif (override && override.variant) {\n\t\t\t\treturn VARIANT_COLORS[override.variant] || ''\n\t\t\t}\n\t\t\tconst rule = this.activeVariantRule\n\t\t\tif (rule && rule.variant) {\n\t\t\t\treturn VARIANT_COLORS[rule.variant] || ''\n\t\t\t}\n\t\t\t// The countdown's own threshold sits below `variantWhen`, which is\n\t\t\t// the author naming a colour outright, and above the at-limit tint,\n\t\t\t// which is the generic one. A passed date reports `error` from here\n\t\t\t// whether or not a threshold was configured.\n\t\t\tif (this.countdownVariant) {\n\t\t\t\treturn VARIANT_COLORS[this.countdownVariant] || ''\n\t\t\t}\n\t\t\t// An explicit variantWhen rule always wins: a tile that says how it\n\t\t\t// wants to be coloured is not overruled by the generic at-limit tint.\n\t\t\tif (this.atLimit) {\n\t\t\t\treturn VARIANT_COLORS.warning || ''\n\t\t\t}\n\t\t\t// The resolved row's colour sits just above the static floor. An\n\t\t\t// unknown name falls through rather than blanking the tile.\n\t\t\tif (this.rowVariant && VARIANT_COLORS[this.rowVariant]) {\n\t\t\t\treturn VARIANT_COLORS[this.rowVariant]\n\t\t\t}\n\t\t\t// A STATIC `variant` is the floor, below both of the above: it is the\n\t\t\t// tile's resting colour, not a signal about the current value, so a\n\t\t\t// threshold rule or an at-limit warning must be able to override it.\n\t\t\t//\n\t\t\t// It exists because that is how CnStatsBlock has always been coloured\n\t\t\t// (`variant=\"success\"`), and a tile migrating from a bespoke card to\n\t\t\t// this component would otherwise silently lose its colour — a change\n\t\t\t// nothing would report, on a dashboard where colour is the fastest\n\t\t\t// thing a reader takes in.\n\t\t\tif (this.content.variant) {\n\t\t\t\treturn VARIANT_COLORS[this.content.variant] || ''\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * The icon shown in the circle: a matched variant rule's `icon`\n\t\t * override, else `content.icon`.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedIcon() {\n\t\t\tconst override = this.activeOverride\n\t\t\tconst rule = this.activeVariantRule\n\t\t\treturn (override && override.icon) || (rule && rule.icon) || this.content.icon || ''\n\t\t},\n\n\t\t/**\n\t\t * Card orientation. Horizontal (icon beside the number) is the\n\t\t * canonical KPI card; `content.layout: 'vertical'` stacks the icon\n\t\t * above a centred number for a tile taller than it is wide.\n\t\t *\n\t\t * @return {'horizontal'|'vertical'}\n\t\t */\n\t\tcardLayout() {\n\t\t\treturn (this.content || {}).layout === 'vertical' ? 'vertical' : 'horizontal'\n\t\t},\n\n\t\t/**\n\t\t * Whether the card draws no box of its own. On by default: every stat\n\t\t * tile is rendered inside a CnWidgetWrapper that already draws a card,\n\t\t * so a second box is a card inside a card. Set `content.flat: false`\n\t\t * for a tile mounted somewhere with no wrapper around it.\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tflat() {\n\t\t\treturn (this.content || {}).flat !== false\n\t\t},\n\n\t\t/** Inline style for the icon circle (variant rule wins over iconColor). */\n\t\ticonCircleStyle() {\n\t\t\tconst color = this.variantColor || this.content.iconColor || this.content.valueColor || 'var(--color-primary-element)'\n\t\t\treturn { color, backgroundColor: this.tint(color) }\n\t\t},\n\n\t\t/** Inline style for the value text (variant rule wins over valueColor). */\n\t\tvalueStyle() {\n\t\t\tconst color = this.variantColor || this.content.valueColor\n\t\t\treturn color ? { color } : {}\n\t\t},\n\n\t\t/**\n\t\t * The formatted value string per the `content.format` spec. Resolves\n\t\t * `@config.<key>` tokens (e.g. `currency: '@config.currency'`) against the\n\t\t * page-level app config and guards the currency code, so an unresolved\n\t\t * token or invalid currency falls back to a safe default instead of\n\t\t * throwing. See `formatMetricValue`.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tformattedValue() {\n\t\t\treturn formatMetricValue(this.displayValue, this.content.format, this.configCtx)\n\t\t},\n\n\t\t/**\n\t\t * The capacity this tile is measured against — `content.limitField`\n\t\t * (dot-path into the endpoint payload, so a server-configured quota is\n\t\t * read live) or a static `content.limit`. Null when neither is set or\n\t\t * the resolved value is not a finite number, which is what keeps the\n\t\t * \"value / limit\" rendering off every tile that has no limit.\n\t\t *\n\t\t * @return {number|null}\n\t\t */\n\t\tlimitValue() {\n\t\t\tlet raw = this.content.limit\n\t\t\tif (this.endpointMode && this.content.limitField) {\n\t\t\t\traw = getByPath(this.epData, this.content.limitField)\n\t\t\t}\n\t\t\tconst n = Number(raw)\n\t\t\treturn Number.isFinite(n) ? n : null\n\t\t},\n\n\t\t/**\n\t\t * The limit rendered beside the value, e.g. the \"100\" in \"0 / 100\".\n\t\t * Formatted with the value's own `format` spec minus prefix/suffix —\n\t\t * a suffix belongs to the pair, not to each half, so \"0 % / 100 %\" is\n\t\t * never produced.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tformattedLimit() {\n\t\t\tif (this.limitValue === null) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst { prefix, suffix, ...rest } = (this.content.format || {})\n\t\t\treturn formatMetricValue(this.limitValue, rest, this.configCtx)\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile has reached or passed its limit. Drives the warning\n\t\t * tint when no explicit `variantWhen` rule already claims the colour.\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tatLimit() {\n\t\t\tif (this.limitValue === null) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tconst current = Number(this.displayValue)\n\t\t\treturn Number.isFinite(current) && current >= this.limitValue\n\t\t},\n\n\t\t/**\n\t\t * The tile's own range presets (`content.dateRange.presets`). Empty when\n\t\t * the tile has no override, which is also what hides the picker.\n\t\t *\n\t\t * @return {Array<{id: string, label?: string, from?: string, to?: string}>}\n\t\t */\n\t\trangePresets() {\n\t\t\tconst presets = this.content.dateRange?.presets\n\t\t\treturn Array.isArray(presets) ? presets.filter(Boolean) : []\n\t\t},\n\n\t\t/**\n\t\t * The preset id currently selected — the tile's own override when set,\n\t\t * else whatever the dashboard range reports, so the picker opens showing\n\t\t * the page's period rather than a stale default.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tactiveRangePreset() {\n\t\t\treturn (this.tileRange || this.activeRange() || {}).preset || ''\n\t\t},\n\n\t\t/** Accessible name for the range picker (no visible label on a compact tile). */\n\t\trangeAriaLabel() {\n\t\t\tconst label = this.resolvedLabel || this.effectiveTranslate('Date range')\n\t\t\treturn `${label} — ${this.effectiveTranslate('date range')}`\n\t\t},\n\n\t\t/** Stable signature of the data source so the watcher only refetches on real change. */\n\t\tsourceKey() {\n\t\t\treturn JSON.stringify({\n\t\t\t\ts: this.content.source || {},\n\t\t\t\to: this.objectCtx ? this.objectCtx.objectId : null,\n\t\t\t\tp: this.pageCtx,\n\t\t\t\tc: this.configCtx,\n\t\t\t})\n\t\t},\n\t},\n\n\twatch: {\n\t\tsourceKey() {\n\t\t\tthis.fetchValue()\n\t\t},\n\n\t\tobjectFieldRaw: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.resolveReference()\n\t\t\t},\n\t\t},\n\t},\n\n\tmounted() {\n\t\tthis.fetchValue()\n\t\t// The `endpointSource` path refreshes through useEndpointSource; this\n\t\t// covers the `source` path, which otherwise never re-read on Refresh.\n\t\tthis._onPageRefresh = (payload) => {\n\t\t\tif (this.endpointMode) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst done = this.fetchValue(payload)\n\t\t\tpayload?.waitUntil?.(done)\n\t\t}\n\t\tsubscribe(PAGE_REFRESH_CHANNEL, this._onPageRefresh)\n\t},\n\n\tbeforeUnmount() {\n\t\tunsubscribe(PAGE_REFRESH_CHANNEL, this._onPageRefresh)\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Resolve an `objectField` that holds a reference uuid to the referenced\n\t\t * object's label, through the shared object store (per-schema caching and\n\t\t * in-flight dedup come for free).\n\t\t *\n\t\t * Does nothing unless `objectField.resolve` names a register and schema:\n\t\t * a plain scalar field needs no lookup, and guessing that a string looks\n\t\t * like a uuid would turn a legitimate identifier into a failed fetch.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync resolveReference() {\n\t\t\tthis.referenceLabel = null\n\t\t\tthis.referenceRow = null\n\t\t\tthis.referenceFailed = false\n\t\t\tthis.referencePending = false\n\t\t\tconst cfg = this.content.objectField\n\t\t\tconst resolve = (cfg && typeof cfg === 'object') ? cfg.resolve : null\n\t\t\tconst raw = this.objectFieldRaw\n\t\t\tif (!resolve || !resolve.register || !resolve.schema || !raw) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Assigned by the try below, and the catch returns rather than falling\n\t\t\t// through, so an initialiser here would never be read.\n\t\t\tlet store\n\t\t\ttry {\n\t\t\t\tstore = useObjectStore()\n\t\t\t} catch {\n\t\t\t\t// No active Pinia: the tile shows the raw value, which is correct\n\t\t\t\t// and never blank.\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!store) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst type = resolveObjectOpType(store, { register: resolve.register, schema: resolve.schema })\n\t\t\tconst id = String(raw)\n\t\t\tconst cached = store.objects && store.objects[type] && store.objects[type][id]\n\t\t\t// Only an actual request pends. A cache hit resolves in the same\n\t\t\t// tick, and flagging it would flicker a loading icon for nothing.\n\t\t\tif (!cached) {\n\t\t\t\tthis.referencePending = true\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst obj = cached || await store.fetchObject(type, id)\n\t\t\t\t// The record may have moved on while the lookup was in flight.\n\t\t\t\t// The newer call owns the state, so this one touches nothing.\n\t\t\t\tif (String(this.objectFieldRaw) !== id) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tthis.referencePending = false\n\t\t\t\tif (obj && typeof obj === 'object') {\n\t\t\t\t\tthis.referenceRow = obj\n\t\t\t\t}\n\t\t\t\tconst label = this.pickReferenceLabel(obj, resolve.labelField)\n\t\t\t\tif (label) {\n\t\t\t\t\tthis.referenceLabel = label\n\t\t\t\t} else {\n\t\t\t\t\tthis.referenceFailed = true\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Leave the raw value showing, unless `emptyText` says otherwise.\n\t\t\t\tif (String(this.objectFieldRaw) === id) {\n\t\t\t\t\tthis.referencePending = false\n\t\t\t\t\tthis.referenceFailed = true\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether an override's `when` matches the bound record. Uses the local\n\t\t * `visibleWhen` grammar (`{ field, op, value }`); a `when` with neither\n\t\t * `op` nor `value` is a truthiness test on the field.\n\t\t *\n\t\t * @param {object} record The bound record.\n\t\t * @param {{field: string, op?: string, value?: unknown}} when The condition.\n\t\t * @return {boolean} True when the override applies.\n\t\t */\n\t\tmatchesOverride(record, when) {\n\t\t\tif (!when || typeof when !== 'object' || typeof when.field !== 'string' || when.field === '') {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tconst compares = Object.hasOwn(when, 'value') || Boolean(when.op)\n\t\t\tif (!compares) {\n\t\t\t\treturn Boolean(readVisibleWhenPath(record, when.field))\n\t\t\t}\n\t\t\treturn evaluateVisibleWhenLocal(when, record)\n\t\t},\n\n\t\t/**\n\t\t * Pick a display label off a resolved reference: the configured field\n\t\t * first, then `title`, then `@self.name`. A per-language map collapses to\n\t\t * its first non-empty value.\n\t\t *\n\t\t * @param {object|null} obj The referenced object.\n\t\t * @param {string} [labelField] The preferred label property.\n\t\t * @return {string} The label, or '' when none is usable.\n\t\t */\n\t\tpickReferenceLabel(obj, labelField) {\n\t\t\tif (!obj || typeof obj !== 'object') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst candidates = [obj[labelField || 'title'], obj.title, obj.name, obj['@self'] && obj['@self'].name]\n\t\t\tfor (const value of candidates) {\n\t\t\t\tif (typeof value === 'string' && value !== '') {\n\t\t\t\t\treturn value\n\t\t\t\t}\n\t\t\t\tif (typeof value === 'number') {\n\t\t\t\t\treturn String(value)\n\t\t\t\t}\n\t\t\t\tif (value && typeof value === 'object' && !Array.isArray(value)) {\n\t\t\t\t\tconst first = Object.values(value).find((v) => typeof v === 'string' && v !== '')\n\t\t\t\t\tif (first) {\n\t\t\t\t\t\treturn first\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn ''\n\t\t},\n\n\t\t/**\n\t\t * Derive a faint background tint for the icon circle from a colour.\n\t\t * Falls back to the NC light primary token for CSS variables / unknowns.\n\t\t *\n\t\t * @param {string} color The base colour (hex or CSS var).\n\t\t * @return {string} A translucent or token background.\n\t\t */\n\t\t/**\n\t\t * Apply a per-tile range preset. Writes the tile override, which the\n\t\t * endpoint ctx reads, so the tile refetches on its own period while the\n\t\t * rest of the dashboard stays on the page range.\n\t\t *\n\t\t * @param {string} presetId The chosen preset's id.\n\t\t * @return {void}\n\t\t */\n\t\tselectRange(presetId) {\n\t\t\tconst preset = this.rangePresets.find((p) => p.id === presetId)\n\t\t\tif (!preset) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.tileRange = { preset: preset.id, from: preset.from ?? null, to: preset.to ?? null }\n\t\t},\n\n\t\ttint(color) {\n\t\t\tif (typeof color === 'string' && /^#([0-9a-f]{6})$/i.test(color)) {\n\t\t\t\treturn color + '1f' // ~12% alpha\n\t\t\t}\n\t\t\treturn 'var(--color-primary-element-light, rgba(0,130,201,0.1))'\n\t\t},\n\n\t\t/**\n\t\t * Whether a `variantWhen` rule matches the current value. Numeric\n\t\t * comparison when both sides coerce to numbers; `eq` / `neq` fall back\n\t\t * to strict string equality for non-numeric values.\n\t\t *\n\t\t * @param {unknown} current The current display value.\n\t\t * @param {{op: string, value: unknown}} rule The threshold rule.\n\t\t * @return {boolean} True when the rule matches.\n\t\t */\n\t\tmatchesRule(current, rule) {\n\t\t\tconst a = Number(current)\n\t\t\tconst b = Number(rule.value)\n\t\t\tconst numeric = Number.isFinite(a) && Number.isFinite(b)\n\t\t\tswitch (rule.op) {\n\t\t\t\tcase 'eq': return numeric ? a === b : String(current) === String(rule.value)\n\t\t\t\tcase 'neq': return numeric ? a !== b : String(current) !== String(rule.value)\n\t\t\t\tcase 'gt': return numeric && a > b\n\t\t\t\tcase 'gte': return numeric && a >= b\n\t\t\t\tcase 'lt': return numeric && a < b\n\t\t\t\tcase 'lte': return numeric && a <= b\n\t\t\t\tdefault: return false\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Flatten a filter map into `filter[key]=value` / `filter[key][op]=value`\n\t\t * query params (operator-aware, matching the OpenRegister vocabulary).\n\t\t *\n\t\t * @param {object} target The params object to write into.\n\t\t * @param {object} filter The filter map.\n\t\t * @return {void}\n\t\t */\n\t\tflattenFilter(target, filter) {\n\t\t\tif (!filter || typeof filter !== 'object') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Resolve `@objectId` / `@object.*` (detail page), `@workspace.*`\n\t\t\t// (page-level context — e.g. the dashboard date-range pills publish\n\t\t\t// `dateFrom` / `dateTo`) AND `@config.*` (page-level app config), then\n\t\t\t// drop any optional `@workspace.<key>?` / `@config.<key>?` that stayed\n\t\t\t// unresolved so an unset value omits the filter (show all) instead of\n\t\t\t// sending a literal token.\n\t\t\tconst ctx = { ...(this.objectCtx || {}), workspace: this.pageCtx, config: this.configCtx }\n\t\t\tfilter = dropOptionalUnresolved(resolveFilterTokens(filter, ctx))\n\t\t\tfor (const [k, v] of Object.entries(filter)) {\n\t\t\t\tif (v && typeof v === 'object') {\n\t\t\t\t\tfor (const [op, ov] of Object.entries(v)) {\n\t\t\t\t\t\ttarget[`filter[${k}][${op}]`] = ov\n\t\t\t\t\t}\n\t\t\t\t} else if (v !== '' && v !== null && v !== undefined) {\n\t\t\t\t\ttarget[`filter[${k}]`] = v\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch one scalar from the OpenRegister `/value` aggregation endpoint.\n\t\t *\n\t\t * @param {object} axios The axios instance.\n\t\t * @param {(url: string, params?: object) => string} generateUrl The router helper.\n\t\t * @param {object} s The source (register/schema).\n\t\t * @param {string} metric The aggregation metric.\n\t\t * @param {?string} field The numeric field (non-count metrics).\n\t\t * @param {object} filter The filter map.\n\t\t * @return {Promise<number|null>} The aggregated value.\n\t\t */\n\t\tasync fetchAggregate(axios, generateUrl, s, metric, field, filter) {\n\t\t\tconst url = generateUrl(\n\t\t\t\t'/apps/openregister/api/objects/aggregations/{register}/{schema}/value',\n\t\t\t\t{ register: s.register, schema: s.schema },\n\t\t\t)\n\t\t\tconst params = { metric: metric || 'count' }\n\t\t\tif (field) {\n\t\t\t\tparams.field = field\n\t\t\t}\n\t\t\tthis.flattenFilter(params, filter)\n\t\t\tconst res = await axios.get(url, { params })\n\t\t\treturn res?.data?.value ?? null\n\t\t},\n\n\t\t/**\n\t\t * Resolve the widget's value from its `source`. Supports three source\n\t\t * kinds (ADR-041): a plain `aggregate` (count/sum/avg/min/max with\n\t\t * operator filters), a `ratio` (numerator ÷ denominator × 100, e.g. a\n\t\t * win-rate), and a `weighted` sum (Σ field × weightField ÷ divisor,\n\t\t * computed client-side over the fetched objects). Lazily imports\n\t\t * axios/router (same pattern as CnFilesWidget).\n\t\t *\n\t\t * @param {object} [refresh] The page refresh event's payload, when this is one.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchValue(refresh) {\n\t\t\t// Endpoint-bound tiles are fetched by the shared useEndpointSource\n\t\t\t// engine (see setup) — the OpenRegister paths below must not fire.\n\t\t\tif (this.endpointMode) {\n\t\t\t\tthis.value = null\n\t\t\t\tthis.error = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst s = this.content.source || {}\n\t\t\t// An `endpoint` source reads an arbitrary app REST endpoint instead\n\t\t\t// of OpenRegister's per-schema aggregation, so it needs no register/schema.\n\t\t\tif (s.kind !== 'endpoint' && (!s.register || !s.schema)) {\n\t\t\t\tthis.value = null\n\t\t\t\tthis.error = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (s.kind === 'endpoint' && !s.url) {\n\t\t\t\tthis.value = null\n\t\t\t\tthis.error = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.loading = true\n\t\t\tthis.error = ''\n\t\t\ttry {\n\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\t\timport('@nextcloud/router'),\n\t\t\t\t])\n\n\t\t\t\tif (s.kind === 'endpoint') {\n\t\t\t\t\tthis.value = await this.fetchEndpoint(s, refresh)\n\t\t\t\t} else if (s.kind === 'ratio') {\n\t\t\t\t\tconst num = await this.fetchAggregate(axios, generateUrl, s, s.metric, s.field, (s.numerator && s.numerator.filter) || {})\n\t\t\t\t\tconst den = await this.fetchAggregate(axios, generateUrl, s, s.metric, s.field, (s.denominator && s.denominator.filter) || {})\n\t\t\t\t\tthis.value = (den && Number(den) !== 0) ? (Number(num) / Number(den)) * 100 : null\n\t\t\t\t} else if (s.kind === 'computed') {\n\t\t\t\t\t// Fetch each named part, then evaluate the formula over them.\n\t\t\t\t\tconst { evalFormula } = await import('../../utils/evalFormula.js')\n\t\t\t\t\tconst parts = s.parts || {}\n\t\t\t\t\tconst vars = {}\n\t\t\t\t\tfor (const [name, p] of Object.entries(parts)) {\n\t\t\t\t\t\tvars[name] = Number(await this.fetchAggregate(axios, generateUrl, s, p.metric || 'count', p.field, p.filter || {})) || 0\n\t\t\t\t\t}\n\t\t\t\t\tthis.value = evalFormula(s.formula || '', vars)\n\t\t\t\t} else if (s.kind === 'weighted') {\n\t\t\t\t\tthis.value = await this.fetchWeighted(axios, generateUrl, s)\n\t\t\t\t} else {\n\t\t\t\t\tthis.value = await this.fetchAggregate(axios, generateUrl, s, s.metric, s.field, s.filter || {})\n\t\t\t\t}\n\t\t\t} catch (e) {\n\t\t\t\tthis.error = (e && e.message) || 'error'\n\t\t\t\tthis.value = null\n\t\t\t} finally {\n\t\t\t\tthis.loading = false\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Compute a weighted sum `Σ (field × weightField) ÷ divisor` client-side\n\t\t * (no OpenRegister expression-aggregation primitive yet). Pulls the\n\t\t * matching objects (capped at `limit`, default 1000) and folds them.\n\t\t *\n\t\t * @param {object} axios The axios instance.\n\t\t * @param {(url: string, params?: object) => string} generateUrl The router helper.\n\t\t * @param {object} s The weighted source `{ field, weightField, divisor?, filter?, limit? }`.\n\t\t * @return {Promise<number|null>} The weighted sum.\n\t\t */\n\t\tasync fetchWeighted(axios, generateUrl, s) {\n\t\t\tif (!s.field || !s.weightField) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst url = generateUrl(\n\t\t\t\t'/apps/openregister/api/objects/{register}/{schema}',\n\t\t\t\t{ register: s.register, schema: s.schema },\n\t\t\t)\n\t\t\tconst params = { _limit: s.limit || 1000 }\n\t\t\tthis.flattenFilter(params, s.filter || {})\n\t\t\tconst res = await axios.get(url, { params })\n\t\t\tconst rows = (res && res.data && res.data.results) || []\n\t\t\tconst divisor = Number(s.divisor) || 1\n\t\t\tlet sum = 0\n\t\t\tfor (const r of rows) {\n\t\t\t\tconst v = Number(r[s.field])\n\t\t\t\tconst w = Number(r[s.weightField])\n\t\t\t\tif (Number.isFinite(v) && Number.isFinite(w)) {\n\t\t\t\t\tsum += (v * w) / divisor\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn sum\n\t\t},\n\n\t\t/**\n\t\t * Resolve `@page.<key>` / `@workspace.<key>` / `@config.<key>` /\n\t\t * `@objectId` / `@object.<field>` tokens inside a string against the page\n\t\t * + config + object contexts. `@page.*` is an alias for `@workspace.*`\n\t\t * (both read the page-level context); `@config.*` reads the page-level app\n\t\t * config. Unresolved tokens collapse to an empty string so a half-built\n\t\t * URL never sends a literal `@page.period`.\n\t\t *\n\t\t * @param {string} str The raw string (URL or param value).\n\t\t * @return {string} The interpolated string.\n\t\t */\n\t\tinterpolateTokens(str) {\n\t\t\tif (typeof str !== 'string') {\n\t\t\t\treturn str\n\t\t\t}\n\t\t\treturn str.replace(/@(page|workspace)\\.([A-Za-z0-9_]+)/g, (_, _ns, key) => {\n\t\t\t\tconst v = this.pageCtx[key]\n\t\t\t\treturn (v === undefined || v === null) ? '' : String(v)\n\t\t\t}).replace(/@config\\.([A-Za-z0-9_]+)/g, (_, key) => {\n\t\t\t\tconst v = this.configCtx[key]\n\t\t\t\treturn (v === undefined || v === null) ? '' : String(v)\n\t\t\t}).replace(/@objectId/g, () => {\n\t\t\t\tconst id = this.objectCtx && this.objectCtx.objectId\n\t\t\t\treturn (id === undefined || id === null) ? '' : String(id)\n\t\t\t}).replace(/@object\\.([A-Za-z0-9_]+)/g, (_, field) => {\n\t\t\t\tconst v = this.objectCtx && this.objectCtx.object && this.objectCtx.object[field]\n\t\t\t\treturn (v === undefined || v === null) ? '' : String(v)\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Read a dot-path off an object (e.g. `\"data.totalLeads\"`, `\"summary.0.count\"`).\n\t\t * Returns undefined when any segment is missing.\n\t\t *\n\t\t * @param {object} obj The source object.\n\t\t * @param {string} path The dot-path.\n\t\t * @return {unknown} The resolved value or undefined.\n\t\t */\n\t\tgetByPath(obj, path) {\n\t\t\t// Delegates to the shared useEndpointSource util so the legacy\n\t\t\t// `source.kind: 'endpoint'` path and the Wave-2 `endpointSource`\n\t\t\t// path pluck identically.\n\t\t\treturn getByPath(obj, path)\n\t\t},\n\n\t\t/**\n\t\t * Fetch a single value from an arbitrary app REST endpoint. The `url` and\n\t\t * any string `params` value are token-interpolated (`@page.*` etc.), the\n\t\t * response is read at `path` (dot-path; default = whole body), and the\n\t\t * result is coerced to a number when numeric. Lets a dashboard KPI bind\n\t\t * to a custom-aggregation endpoint (e.g. `/api/analytics/summary`) that\n\t\t * OpenRegister's per-schema aggregation can't express.\n\t\t *\n\t\t * Never served from the settled shared cache, so a tile never shows a\n\t\t * count from before a write. Tiles loading at once, or reached by one\n\t\t * page refresh, share a single request per endpoint and params.\n\t\t *\n\t\t * @param {object} s The endpoint source `{ url, path?, params?, method? }`.\n\t\t * @param {object} [refresh] The page refresh event's payload, when this is one.\n\t\t * @return {Promise<number|null>} The extracted value.\n\t\t */\n\t\tasync fetchEndpoint(s, refresh) {\n\t\t\tconst params = {}\n\t\t\tfor (const [k, v] of Object.entries(s.params || {})) {\n\t\t\t\tparams[k] = typeof v === 'string' ? this.interpolateTokens(v) : v\n\t\t\t}\n\t\t\tconst body = await fetchSharedResponse(\n\t\t\t\t{ url: this.interpolateTokens(s.url), method: 'GET', params },\n\t\t\t\trefresh ? { force: true, refresh } : { fresh: true },\n\t\t\t)\n\t\t\tconst extracted = this.getByPath(body, s.path)\n\t\t\tif (extracted === undefined || extracted === null) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst num = Number(extracted)\n\t\t\treturn Number.isFinite(num) ? num : extracted\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/*\n * The card itself — its box, body stack, icon circle, title and value\n * typography — is the CANONICAL KPI card in src/css/kpi-card.css, shared with\n * CnStatsBlock. What remains here is only what is specific to THIS widget: the\n * range select, the \"/ limit\" denominator, the trend chip and the error glyph.\n *\n * Every size below is expressed against the shared `--cn-kpi-*` scale rather\n * than as its own number, so the widget's extras re-scale with the card they\n * sit in. Resist adding card styling back, and resist restating a value the\n * scale already holds; that is what split the two KPI looks apart in the first\n * place.\n */\n\n/* The title row carries an optional range select beside the label, which the\n canonical `__title` (a plain truncating heading) does not account for. */\n.cn-stat-widget__label {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tgap: 8px;\n\twidth: 100%;\n}\n\n/* Stacked tile: the label and its range select centre over the number rather\n than spreading to the card's edges. */\n.cn-kpi-card--vertical .cn-stat-widget__label {\n\tjustify-content: center;\n\twidth: auto;\n}\n\n/* Sized down to sit inside the label row without pushing the tile taller —\n the tile's height is fixed by its grid cell (ADR-062). */\n.cn-stat-widget__range {\n\tflex: 0 0 auto;\n\tmax-width: 50%;\n\tpadding: 0 4px;\n\tborder: none;\n\tborder-radius: var(--border-radius);\n\tbackground: transparent;\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tfont-weight: normal;\n\tcursor: pointer;\n}\n\n.cn-stat-widget__range:hover,\n.cn-stat-widget__range:focus-visible {\n\tbackground: var(--color-background-hover);\n\tcolor: var(--color-main-text);\n}\n\n/* Badge mode: the pill takes the value's place in the row. Sized to the\n card's label scale so it sits in the row without pushing the tile taller. */\n.cn-stat-widget__badge {\n\tflex: 0 1 auto;\n\tmin-width: 0;\n\tmax-width: 100%;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n}\n\n/* The denominator of a \"value / limit\" pair. Deliberately quieter and smaller\n than the value: the tile's subject is what the number IS, not what it is\n allowed to reach. Shares the value's `nowrap` so the pair never breaks. */\n.cn-stat-widget__limit {\n\tflex: 0 0 auto;\n\tfont-size: var(--cn-kpi-value-size-compact, 1.25rem);\n\tfont-weight: var(--cn-kpi-title-weight, 600);\n\tline-height: var(--cn-kpi-value-line, 1.1);\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n\twhite-space: nowrap;\n}\n\n.cn-stat-widget__trend {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 2px;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tfont-weight: var(--cn-kpi-title-weight, 600);\n}\n\n/* The dash shown when the number could not be fetched. It stands where the\n value stands, so it takes the value's size and weight — CnStatsBlock's own\n error dash is literally a `cn-kpi-card__value`, and two components showing\n the same failure at two different sizes is the drift this consolidation\n exists to remove. Only the colour differs: a failure is not a number. */\n.cn-stat-widget__error {\n\tfont-size: var(--cn-kpi-value-size, 2rem);\n\tfont-weight: var(--cn-kpi-value-weight, 700);\n\tline-height: var(--cn-kpi-value-line, 1.1);\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n}\n</style>\n"],"names":["_createVNode","_createElementVNode","_openBlock","_createElementBlock","_Fragment","_renderList","_toDisplayString","_createBlock","_normalizeClass"],"mappings":"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"}
|
|
@@ -149,9 +149,10 @@ var script = {
|
|
|
149
149
|
*
|
|
150
150
|
* `maxVisibleTabs` caps the strip: later tabs go under "More".
|
|
151
151
|
* `hideEmpty: true` moves a tab whose count is 0 there too.
|
|
152
|
-
* `moreLabel` names that menu.
|
|
152
|
+
* `moreLabel` names that menu. `variant: "segmented"` draws the strip
|
|
153
|
+
* as a pill switch (CnTabs' segmented variant); `line` is the default.
|
|
153
154
|
*
|
|
154
|
-
* @type {{ tabs?: Array<{widgetId: string, label?: string, icon?: string, count?: number, countField?: string, overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string }}
|
|
155
|
+
* @type {{ tabs?: Array<{widgetId: string, label?: string, icon?: string, count?: number, countField?: string, overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string, variant?: ('line'|'segmented') }}
|
|
155
156
|
*/
|
|
156
157
|
content: {
|
|
157
158
|
type: Object,
|
|
@@ -367,6 +368,17 @@ var script = {
|
|
|
367
368
|
return this.activeTab?.label || ''
|
|
368
369
|
},
|
|
369
370
|
|
|
371
|
+
/**
|
|
372
|
+
* The strip's look: `segmented` when the content asks for it, else
|
|
373
|
+
* CnTabs' default `line`. An unknown value falls back to `line`.
|
|
374
|
+
*
|
|
375
|
+
* @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-tabs-widget-renders-a-segmented-strip
|
|
376
|
+
* @return {('line'|'segmented')} The CnTabs variant.
|
|
377
|
+
*/
|
|
378
|
+
stripVariant() {
|
|
379
|
+
return this.content?.variant === 'segmented' ? 'segmented' : 'line'
|
|
380
|
+
},
|
|
381
|
+
|
|
370
382
|
/**
|
|
371
383
|
* Accessible name for the tab strip.
|
|
372
384
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CnTabsWidget.vue2.js","sources":["../../../../src/components/CnTabsWidget/CnTabsWidget.vue"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div class=\"cn-tabs-widget\">\n\t\t<CnTabs\n\t\t\t:aria-label=\"stripLabel\"\n\t\t\t:moreLabel=\"moreLabel\"\n\t\t\tclass=\"cn-tabs-widget__tabs\"\n\t\t\t@update:activeIndex=\"onTabChange\">\n\t\t\t<!-- One Actions menu for the whole widget, bound to whichever child\n\t\t\t is showing. This is the point of the component: six tabbed\n\t\t\t widgets used to mean six card headers stacked down the page. -->\n\t\t\t<template #nav-end>\n\t\t\t\t<CnActionsMenu\n\t\t\t\t\t:showRefresh=\"showRefresh\"\n\t\t\t\t\t:showRequestFeature=\"showRequestFeature\"\n\t\t\t\t\t:showDocumentation=\"showDocumentation\"\n\t\t\t\t\t:documentationUrl=\"documentationUrl\"\n\t\t\t\t\t:widgetId=\"activeWidgetId\"\n\t\t\t\t\t:title=\"activeTitle\"\n\t\t\t\t\t:surface=\"`widget:${activeWidgetId}`\"\n\t\t\t\t\trefreshChannel=\"cn:widget:refresh\"\n\t\t\t\t\ttestidBase=\"cn-tabs-widget\">\n\t\t\t\t\t<!-- The open panel's own items, below the built-in trio.\n\t\t\t\t\t A panel draws no header, so these would otherwise have\n\t\t\t\t\t nowhere to go: the data widget's Metadata and full edit\n\t\t\t\t\t dialog vanished outright when the panel stopped drawing\n\t\t\t\t\t a card. They are rendered here rather than rebuilt here\n\t\t\t\t\t because `run` closes over the widget that published it,\n\t\t\t\t\t which is what keeps the dialog's per-widget config and\n\t\t\t\t\t the widget's own save path. See utils/panelActions.js.\n\n\t\t\t\t\t Only the ACTIVE panel's items. `lazy` keeps a visited\n\t\t\t\t\t tab mounted, so several panels publish at once and an\n\t\t\t\t\t unfiltered list would offer actions for a sheet nobody\n\t\t\t\t\t is looking at. -->\n\t\t\t\t\t<template v-if=\"activePanelActions.length\" #action-items>\n\t\t\t\t\t\t<NcActionButton\n\t\t\t\t\t\t\tv-for=\"action in activePanelActions\"\n\t\t\t\t\t\t\t:key=\"action.key\"\n\t\t\t\t\t\t\t:closeAfterClick=\"true\"\n\t\t\t\t\t\t\t@click=\"action.run()\">\n\t\t\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t\t\t<CnIcon :name=\"action.icon\" :size=\"20\" />\n\t\t\t\t\t\t\t</template>\n\t\t\t\t\t\t\t{{ action.label }}\n\t\t\t\t\t\t</NcActionButton>\n\t\t\t\t\t</template>\n\t\t\t\t</CnActionsMenu>\n\t\t\t</template>\n\n\t\t\t<CnTab\n\t\t\t\tv-for=\"(entry, index) in resolvedTabs\"\n\t\t\t\t:key=\"entry.key\"\n\t\t\t\t:active=\"index === activeIndex\"\n\t\t\t\t:title=\"entry.label\"\n\t\t\t\t:count=\"entry.count\"\n\t\t\t\t:overflow=\"entry.overflow\"\n\t\t\t\tlazy\n\t\t\t\t@click=\"activeIndex = index\">\n\t\t\t\t<template #title>\n\t\t\t\t\t<span class=\"cn-tabs-widget__title\">\n\t\t\t\t\t\t<CnIcon\n\t\t\t\t\t\t\tv-if=\"entry.icon\"\n\t\t\t\t\t\t\t:name=\"entry.icon\"\n\t\t\t\t\t\t\t:size=\"18\"\n\t\t\t\t\t\t\tclass=\"cn-tabs-widget__title-icon\" />\n\t\t\t\t\t\t{{ entry.label }}\n\t\t\t\t\t</span>\n\t\t\t\t</template>\n\n\t\t\t\t<CnDetailWidgetHost\n\t\t\t\t\tv-if=\"entry.widget\"\n\t\t\t\t\t:widget=\"entry.widget\"\n\t\t\t\t\tchrome=\"bare\"\n\t\t\t\t\t:objectId=\"objectId\"\n\t\t\t\t\t:object=\"objectData\"\n\t\t\t\t\t:objectType=\"objectType\"\n\t\t\t\t\t:schemaObject=\"schemaObject\"\n\t\t\t\t\t:register=\"register\"\n\t\t\t\t\t:schema=\"schema\"\n\t\t\t\t\t:store=\"store\"\n\t\t\t\t\t:surface=\"surface\"\n\t\t\t\t\t:integrationContext=\"integrationContext\"\n\t\t\t\t\t:cnRegistry=\"cnRegistry\"\n\t\t\t\t\t@geoSaved=\"onGeoSaved\"\n\t\t\t\t\t@openIntegration=\"onOpenIntegration\" />\n\t\t\t\t<NcEmptyContent v-else :name=\"missingLabel(entry)\" />\n\t\t\t</CnTab>\n\t\t</CnTabs>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcActionButton, NcEmptyContent } from '@nextcloud/vue'\nimport CnDetailWidgetHost from '../CnDetailWidgetHost/CnDetailWidgetHost.vue'\nimport CnIcon from '../CnIcon/CnIcon.vue'\nimport CnTab from '../CnTabs/CnTab.vue'\nimport CnTabs from '../CnTabs/CnTabs.vue'\nimport { resolveTabCount } from '../../utils/detailActionModel.js'\nimport { PANEL_ACTION_SINK } from '../../utils/panelActions.js'\nimport { widgetTitleOf } from '../../utils/widgetDispatch.js'\nimport { CnActionsMenu } from '../CnActionsMenu/index.js'\n\n/**\n * CnTabsWidget — a widget that holds other widgets, one per tab.\n *\n * A detail page that carries notes, files, related records, sub-records, mail\n * and appointments renders six cards, each with its own header and its own\n * Actions menu, stacked down the page. All six say roughly the same thing about\n * one record, and only one of them is being read at a time. This widget puts\n * them behind a tab strip instead.\n *\n * ## What the tabs take over\n *\n * The strip owns the title and the card. A child renders through\n * `CnDetailWidgetHost` with `chrome=\"bare\"`, so it draws no header and no\n * border of its own, and its content fills the panel exactly as it would have\n * filled its card. The tab label is what the widget's card header used to say.\n *\n * The Actions menu moves OUT of the panels and into the bar, beside the strip\n * rather than inside it, and rebinds to whichever child is showing. So there is\n * one menu, and it always acts on what you are looking at.\n *\n * ## Configuring it\n *\n * `content.tabs[]` names the children and their labels, so a deployment can\n * relabel or reorder tabs, or drop one, without touching code:\n *\n * ```js\n * content: {\n * tabs: [\n * { widgetId: 'case-notes', label: 'Notes', icon: 'NoteTextOutline' },\n * { widgetId: 'case-files', label: 'Files and attachments' },\n * ],\n * }\n * ```\n *\n * `label` and `icon` are optional and fall back to the child widget's own\n * title and icon, so the common case is a list of `widgetId`s.\n *\n * ## Panels are lazy, and stay mounted\n *\n * Each panel is a `CnTab` with `lazy`, so a child only mounts when its tab is\n * first opened. Six eager panels that each fetch on `mounted()` would fire six\n * requests on page load to answer five questions nobody asked. Once opened, a\n * panel stays mounted, so switching back never refetches.\n *\n * ## A named tab whose widget is missing\n *\n * The tab still renders, and its panel says which widget id did not resolve.\n * Dropping the tab would be worse: `content.tabs[]` is hand-authored config,\n * and a typo that silently removes a tab is a typo nobody finds.\n */\nexport default {\n\tname: 'CnTabsWidget',\n\n\tcomponents: {\n\t\tCnActionsMenu,\n\t\tCnDetailWidgetHost,\n\t\tCnIcon,\n\t\tCnTab,\n\t\tCnTabs,\n\t\tNcActionButton,\n\t\tNcEmptyContent,\n\t},\n\n\t/**\n\t * Offer the panels a way to put their own menu items in the strip's menu.\n\t *\n\t * Re-provided one level down by `CnDetailWidgetHost`, which knows the id to\n\t * key by; see utils/panelActions.js for why the items travel up instead of\n\t * being rebuilt here.\n\t *\n\t * @return {object} The provided sink.\n\t */\n\tprovide() {\n\t\treturn {\n\t\t\t[PANEL_ACTION_SINK]: {\n\t\t\t\t/**\n\t\t\t\t * Publish a panel's items, under one SOURCE.\n\t\t\t\t *\n\t\t\t\t * A panel has two possible publishers: the host, for an action\n\t\t\t\t * the host itself would have drawn (the catalog Add), and the\n\t\t\t\t * widget inside it, for its own menu items. Keyed by source so\n\t\t\t\t * the two coexist; a single slot per widget meant whichever\n\t\t\t\t * published last silently replaced the other.\n\t\t\t\t *\n\t\t\t\t * Replaces the map rather than mutating it so the computed that\n\t\t\t\t * reads it re-evaluates on any change.\n\t\t\t\t *\n\t\t\t\t * @param {string} id The publishing widget's id.\n\t\t\t\t * @param {object[]} items Its PanelAction descriptors.\n\t\t\t\t * @param {string} [source] Who is publishing: `host` or `widget`.\n\t\t\t\t * @return {void}\n\t\t\t\t */\n\t\t\t\tset: (id, items, source = 'widget') => {\n\t\t\t\t\t// `activePanelActions` spreads what is stored, so a\n\t\t\t\t\t// non-array becomes one menu item per character.\n\t\t\t\t\tif (!id || !Array.isArray(items)) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst forId = { ...(this.panelActionsByWidget[id] || {}), [source]: items }\n\t\t\t\t\tthis.panelActionsByWidget = { ...this.panelActionsByWidget, [id]: forId }\n\t\t\t\t},\n\n\t\t\t\t/**\n\t\t\t\t * Withdraw one source's items, on unmount or when that\n\t\t\t\t * publisher's own menu comes back. The other source's items\n\t\t\t\t * stay.\n\t\t\t\t *\n\t\t\t\t * @param {string} id The publishing widget's id.\n\t\t\t\t * @param {string} [source] Who is withdrawing.\n\t\t\t\t * @return {void}\n\t\t\t\t */\n\t\t\t\tclear: (id, source = 'widget') => {\n\t\t\t\t\tconst forId = this.panelActionsByWidget[id]\n\t\t\t\t\tif (!forId || !(source in forId)) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst { [source]: _removed, ...keptSources } = forId\n\t\t\t\t\tif (Object.keys(keptSources).length) {\n\t\t\t\t\t\tthis.panelActionsByWidget = { ...this.panelActionsByWidget, [id]: keptSources }\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst { [id]: _gone, ...rest } = this.panelActionsByWidget\n\t\t\t\t\tthis.panelActionsByWidget = rest\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * The widget's config: `{ tabs, ariaLabel }`.\n\t\t *\n\t\t * `tabs[]` entries are `{ widgetId, label?, icon?, count?, countField?, overflow? }`.\n\t\t * `label` and `icon` fall back to the referenced widget's own title and\n\t\t * icon. `count` is a number shown after the label; `countField` reads\n\t\t * it off the record instead (a list counts its items). `overflow: true`\n\t\t * lists the tab under \"More\".\n\t\t *\n\t\t * `maxVisibleTabs` caps the strip: later tabs go under \"More\".\n\t\t * `hideEmpty: true` moves a tab whose count is 0 there too.\n\t\t * `moreLabel` names that menu.\n\t\t *\n\t\t * @type {{ tabs?: Array<{widgetId: string, label?: string, icon?: string, count?: number, countField?: string, overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string }}\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 * Every widget definition available on the surface, for `content.tabs[]`\n\t\t * to reference by id.\n\t\t *\n\t\t * The surface passes its whole list rather than the resolved children\n\t\t * because a tab may name a widget that does not exist, and this component\n\t\t * has to be able to say so.\n\t\t *\n\t\t * @type {object[]}\n\t\t */\n\t\tavailableWidgets: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/** The bound record's id. */\n\t\tobjectId: {\n\t\t\ttype: [String, Number],\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** The loaded record, or null while it is still being fetched. */\n\t\tobjectData: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** The resolved object-type slug. */\n\t\tobjectType: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** The resolved JSON Schema object, needed by a `data` child. */\n\t\tschemaObject: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** OpenRegister register slug of the surface. */\n\t\tregister: {\n\t\t\ttype: [String, Object],\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** OpenRegister schema slug of the surface. */\n\t\tschema: {\n\t\t\ttype: [String, Object],\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** The effective object store. */\n\t\tstore: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Rendering surface forwarded to integration children (AD-19). */\n\t\tsurface: {\n\t\t\ttype: String,\n\t\t\tdefault: 'detail-page',\n\t\t},\n\n\t\t/** Object context forwarded to integration children. */\n\t\tintegrationContext: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** The consumer's component registry, for custom child widget types. */\n\t\tcnRegistry: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/** Show the Refresh entry in the hoisted Actions menu. */\n\t\tshowRefresh: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/** Show the Request-a-feature entry in the hoisted Actions menu. */\n\t\tshowRequestFeature: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/** Show the Documentation entry in the hoisted Actions menu. */\n\t\tshowDocumentation: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/** Documentation URL for the hoisted Actions menu. */\n\t\tdocumentationUrl: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\t},\n\n\temits: ['geo-saved', 'open-integration'],\n\n\tdata() {\n\t\treturn {\n\t\t\tactiveIndex: 0,\n\t\t\t// Items published by the panels, keyed by widget id. Keyed rather\n\t\t\t// than a flat list because `lazy` keeps a visited tab mounted, so\n\t\t\t// more than one panel publishes at a time.\n\t\t\tpanelActionsByWidget: {},\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The open panel's own menu items, or an empty list.\n\t\t *\n\t\t * @return {object[]} PanelAction descriptors for the active tab.\n\t\t */\n\t\tactivePanelActions() {\n\t\t\tconst forId = this.panelActionsByWidget[this.activeWidgetId]\n\t\t\tif (!forId) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\t// Host first, then the widget's own: the catalog Add is about the\n\t\t\t// panel as a whole, the widget's items about what is in it.\n\t\t\treturn [...(forId.host || []), ...(forId.widget || [])]\n\t\t},\n\n\t\t/**\n\t\t * The configured tabs, each paired with the widget definition it names.\n\t\t *\n\t\t * A tab goes under the strip's \"More\" menu when it says `overflow`, when\n\t\t * `content.hideEmpty` is set and its count is 0, or when\n\t\t * `content.maxVisibleTabs` places are already taken. `count` is a\n\t\t * literal, or read off the record through `countField`.\n\t\t *\n\t\t * @return {object[]} `{ key, widgetId, label, icon, widget, count, overflow }` per tab.\n\t\t * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-tab-counts-and-overflow\n\t\t */\n\t\tresolvedTabs() {\n\t\t\tconst tabs = Array.isArray(this.content?.tabs) ? this.content.tabs : []\n\t\t\tconst max = Number(this.content?.maxVisibleTabs)\n\t\t\tconst hideEmpty = this.content?.hideEmpty === true\n\t\t\t// How many tabs have taken a place in the strip so far. A tab that\n\t\t\t// is already going under \"More\" does not use one up.\n\t\t\tlet placed = 0\n\t\t\treturn tabs.map((tab, index) => {\n\t\t\t\tconst widgetId = typeof tab === 'string' ? tab : tab?.widgetId\n\t\t\t\tconst widget = this.availableWidgets.find((w) => w && w.id === widgetId) || null\n\t\t\t\tconst count = resolveTabCount(typeof tab === 'object' ? tab : null, this.objectData)\n\t\t\t\tlet overflow = (tab && tab.overflow === true) || (hideEmpty && count === 0)\n\t\t\t\tif (!overflow) {\n\t\t\t\t\tif (Number.isFinite(max) && max > 0 && placed >= max) {\n\t\t\t\t\t\toverflow = true\n\t\t\t\t\t} else {\n\t\t\t\t\t\tplaced += 1\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\tkey: `${widgetId || 'tab'}-${index}`,\n\t\t\t\t\twidgetId,\n\t\t\t\t\tlabel: (tab && tab.label) || widgetTitleOf(widget) || widgetId || '',\n\t\t\t\t\ticon: (tab && tab.icon) || widget?.icon || '',\n\t\t\t\t\twidget,\n\t\t\t\t\tcount,\n\t\t\t\t\toverflow,\n\t\t\t\t}\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Name of the menu that holds the tabs that are not in the strip.\n\t\t *\n\t\t * @return {string|undefined} The configured label, or undefined for the default.\n\t\t * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-tab-counts-and-overflow\n\t\t */\n\t\tmoreLabel() {\n\t\t\tconst label = this.content?.moreLabel\n\t\t\treturn typeof label === 'string' && label !== '' ? label : undefined\n\t\t},\n\n\t\t/**\n\t\t * The tab currently showing.\n\t\t *\n\t\t * @return {object|null} The resolved tab, or null when there are none.\n\t\t */\n\t\tactiveTab() {\n\t\t\treturn this.resolvedTabs[this.activeIndex] || null\n\t\t},\n\n\t\t/**\n\t\t * The active child's id, so the hoisted Refresh reaches THAT child's\n\t\t * fetch over `cn:widget:refresh` rather than a sibling's.\n\t\t *\n\t\t * @return {string} The active widget id.\n\t\t */\n\t\tactiveWidgetId() {\n\t\t\treturn this.activeTab?.widgetId || ''\n\t\t},\n\n\t\t/**\n\t\t * The active child's label, so the Actions menu names what it acts on.\n\t\t *\n\t\t * @return {string} The active tab's label.\n\t\t */\n\t\tactiveTitle() {\n\t\t\treturn this.activeTab?.label || ''\n\t\t},\n\n\t\t/**\n\t\t * Accessible name for the tab strip.\n\t\t *\n\t\t * @return {string} The aria-label.\n\t\t */\n\t\tstripLabel() {\n\t\t\treturn this.content?.ariaLabel || t('nextcloud-vue', 'Details')\n\t\t},\n\t},\n\n\t/**\n\t * Open on the first tab that is in the strip. Tab 0 is the default, and\n\t * when tab 0 sits under \"More\" the strip would open on a tab the author\n\t * chose to tuck away.\n\t *\n\t * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-tab-counts-and-overflow\n\t */\n\tcreated() {\n\t\tconst first = this.resolvedTabs.findIndex((tab) => !tab.overflow)\n\t\tif (first > 0) {\n\t\t\tthis.activeIndex = first\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Re-emit a geo child's save so the surface can reload the record.\n\t\t *\n\t\t * @param {object} geo The saved geometry.\n\t\t * @return {void}\n\t\t */\n\t\tonGeoSaved(geo) {\n\t\t\t/**\n\t\t\t * @event geo-saved Re-emitted from a geo child that saved a geometry.\n\t\t\t * @type {object}\n\t\t\t */\n\t\t\tthis.$emit('geo-saved', geo)\n\t\t},\n\n\t\t/**\n\t\t * Re-emit a related child's request to open an integration.\n\t\t *\n\t\t * @param {string} integrationId The integration to open.\n\t\t * @return {void}\n\t\t */\n\t\tonOpenIntegration(integrationId) {\n\t\t\t/**\n\t\t\t * @event open-integration Re-emitted from a related child asking to open an integration.\n\t\t\t * @type {string}\n\t\t\t */\n\t\t\tthis.$emit('open-integration', integrationId)\n\t\t},\n\n\t\t/**\n\t\t * Follow the strip's own selection, so keyboard navigation moves the\n\t\t * hoisted Actions menu too.\n\t\t *\n\t\t * @param {number} index The newly selected tab index.\n\t\t * @return {void}\n\t\t */\n\t\tonTabChange(index) {\n\t\t\tif (typeof index === 'number' && index >= 0) {\n\t\t\t\tthis.activeIndex = index\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Empty-state text for a tab whose widget id resolves to nothing.\n\t\t *\n\t\t * @param {object} entry The resolved tab.\n\t\t * @return {string} The message.\n\t\t */\n\t\tmissingLabel(entry) {\n\t\t\treturn t('nextcloud-vue', 'No widget found for \"{id}\"', { id: entry.widgetId || '' })\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/* The panel is the card; the strip is not inside it. There is no title bar\n above the tabs either: the open tab names the panel, so a title row would\n say the same thing twice and cost a row of height on a card that is mostly\n content.\n\n The card chrome used to wrap the strip as well, which drew a border and a\n pair of rounded corners ABOVE the tabs and boxed them in. Folder tabs are\n drawn as the edge of the sheet they open, so a second edge around them\n reads as a header the widget does not have. Border, radius and background\n now live on the panel below, and the strip sits bare on the page. */\n/* The class is doubled on purpose, and the four properties below are RESET\n rather than simply omitted.\n\n Nextcloud serves every enabled app's assets on every page, each app bundles\n this library's compiled CSS, and the Vue scope id is derived from the file\n PATH, so `data-v-1dbd6122` is byte-identical across library versions. An app\n still on an older release therefore ships a rule with exactly this selector\n and the OLD declarations, and it lands on the pages of an app already on the\n new one. Measured on a dossiq case page: three stale copies of the pre-fix\n rule, one from hermiq's `companion.css` (its companion bundle loads on every\n page by design) and two inline from other apps' bundles. Same specificity,\n so source order decided, and the card border came back around the strip.\n\n Dropping a declaration cannot beat a rule that sets it, so `border` and the\n rest are explicitly zeroed. Doubling the class takes this selector to\n (0,3,0) against the stale (0,2,0), which wins on specificity rather than on\n `!important` or on load order this library does not control. The same\n technique, for the same reason, is used on `.cn-tabs__nav .cn-tabs__nav-item`\n in CnTabs to beat Nextcloud's own button margin. */\n.cn-tabs-widget.cn-tabs-widget {\n\tbackground-color: transparent;\n\tborder: 0;\n\tborder-radius: 0;\n\tdisplay: flex;\n\tflex-direction: column;\n\theight: 100%;\n\tmin-height: 0;\n\toverflow: visible;\n\tpadding: 0;\n}\n\n/* No inset, so the first tab starts at the panel's own left edge. The 8px\n here was there to clear the card's rounded top corner; that corner is gone\n from the strip now, and the inset it existed for left the first tab floating\n 8px inside the sheet it opens.\n\n The bar's own bottom rule is switched off and redrawn as a pseudo-element\n that stops one corner radius short of the right edge. CnTabs draws its rule\n across the bar's whole width, which is the sheet's top edge everywhere\n except at the far right, where the panel's top-right corner curves away\n below it: the rule ran straight on to the widget's edge and read as a\n border sticking out past a rounded corner (Ruben, 2026-09-13). Ending it\n where the curve begins makes the rule and the curve one continuous edge. */\n.cn-tabs-widget__tabs :deep(.cn-tabs__bar) {\n\tborder-bottom: none;\n\tpadding: 0;\n\tposition: relative;\n}\n\n.cn-tabs-widget__tabs :deep(.cn-tabs__bar::after) {\n\tborder-bottom: 1px solid var(--color-border);\n\tcontent: '';\n\tinset-block-end: 0;\n\tinset-inline: 0 var(--border-radius-large);\n\tpointer-events: none;\n\tposition: absolute;\n}\n\n/* The open tab erases the slice of the rule directly above the panel with its\n own background-coloured bottom edge, which only works if it paints ABOVE the\n pseudo-element: a positioned pseudo-element otherwise paints over every\n in-flow sibling, rule included. */\n.cn-tabs-widget__tabs :deep(.cn-tabs__nav-item--active) {\n\tposition: relative;\n\tz-index: 1;\n}\n\n.cn-tabs-widget__tabs {\n\tdisplay: flex;\n\tflex-direction: column;\n\t/* Grow to the widget's height, so the panel fills the grid cell the\n\t layout gave it. Without this the strip plus the open panel took only\n\t the height of the panel's content, and a cell of nine rows ended in\n\t 130px of nothing under a sheet that stopped short of its own card. */\n\tflex: 1 1 auto;\n\tmin-height: 0;\n}\n\n/* The panel area is the scroll region, so the strip stays put while a long\n child scrolls under it.\n\n Padded on all four sides. The top was once zeroed to protect the join with\n the open tab, but padding sits inside the painted box — only a margin would\n have opened that gap, and a zero top put every panel's content against the\n strip. */\n.cn-tabs-widget__tabs :deep(.cn-tabs__content) {\n\tbackground-color: var(--color-main-background);\n\t/* Three sides only: the bar's rule (redrawn above, stopping at the corner)\n\t is this sheet's top edge, and the open tab erases the slice of it\n\t directly above the panel so the two read as one surface. A border-top\n\t here would put a second line under that tab which the tab cannot paint\n\t over. */\n\tborder: 1px solid var(--color-border);\n\tborder-top: none;\n\t/* Three rounded corners. The top-left stays square because the first tab\n\t is drawn joined to the panel there, and a curve under it would curl the\n\t sheet away from that tab. The top-right has no tab above it: the strip\n\t ends where the last tab ends and the Actions menu floats free, so a\n\t square corner there read as a sheet cut off, not as a tab's edge. */\n\tborder-radius: 0 var(--border-radius-large) var(--border-radius-large) var(--border-radius-large);\n\tflex: 1 1 auto;\n\tmin-height: 0;\n\toverflow: auto;\n\tpadding: 12px;\n}\n\n.cn-tabs-widget__tabs :deep(.cn-tab) {\n\theight: 100%;\n}\n\n.cn-tabs-widget__title {\n\talign-items: center;\n\tdisplay: inline-flex;\n\tgap: 6px;\n}\n\n.cn-tabs-widget__title-icon {\n\tflex: 0 0 auto;\n}\n</style>\n"],"names":["CnActionsMenu","CnDetailWidgetHost","CnIcon","CnTab","CnTabs","t"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"CnTabsWidget.vue2.js","sources":["../../../../src/components/CnTabsWidget/CnTabsWidget.vue"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div class=\"cn-tabs-widget\">\n\t\t<CnTabs\n\t\t\t:aria-label=\"stripLabel\"\n\t\t\t:moreLabel=\"moreLabel\"\n\t\t\t:variant=\"stripVariant\"\n\t\t\tclass=\"cn-tabs-widget__tabs\"\n\t\t\t@update:activeIndex=\"onTabChange\">\n\t\t\t<!-- One Actions menu for the whole widget, bound to whichever child\n\t\t\t is showing. This is the point of the component: six tabbed\n\t\t\t widgets used to mean six card headers stacked down the page. -->\n\t\t\t<template #nav-end>\n\t\t\t\t<CnActionsMenu\n\t\t\t\t\t:showRefresh=\"showRefresh\"\n\t\t\t\t\t:showRequestFeature=\"showRequestFeature\"\n\t\t\t\t\t:showDocumentation=\"showDocumentation\"\n\t\t\t\t\t:documentationUrl=\"documentationUrl\"\n\t\t\t\t\t:widgetId=\"activeWidgetId\"\n\t\t\t\t\t:title=\"activeTitle\"\n\t\t\t\t\t:surface=\"`widget:${activeWidgetId}`\"\n\t\t\t\t\trefreshChannel=\"cn:widget:refresh\"\n\t\t\t\t\ttestidBase=\"cn-tabs-widget\">\n\t\t\t\t\t<!-- The open panel's own items, below the built-in trio.\n\t\t\t\t\t A panel draws no header, so these would otherwise have\n\t\t\t\t\t nowhere to go: the data widget's Metadata and full edit\n\t\t\t\t\t dialog vanished outright when the panel stopped drawing\n\t\t\t\t\t a card. They are rendered here rather than rebuilt here\n\t\t\t\t\t because `run` closes over the widget that published it,\n\t\t\t\t\t which is what keeps the dialog's per-widget config and\n\t\t\t\t\t the widget's own save path. See utils/panelActions.js.\n\n\t\t\t\t\t Only the ACTIVE panel's items. `lazy` keeps a visited\n\t\t\t\t\t tab mounted, so several panels publish at once and an\n\t\t\t\t\t unfiltered list would offer actions for a sheet nobody\n\t\t\t\t\t is looking at. -->\n\t\t\t\t\t<template v-if=\"activePanelActions.length\" #action-items>\n\t\t\t\t\t\t<NcActionButton\n\t\t\t\t\t\t\tv-for=\"action in activePanelActions\"\n\t\t\t\t\t\t\t:key=\"action.key\"\n\t\t\t\t\t\t\t:closeAfterClick=\"true\"\n\t\t\t\t\t\t\t@click=\"action.run()\">\n\t\t\t\t\t\t\t<template #icon>\n\t\t\t\t\t\t\t\t<CnIcon :name=\"action.icon\" :size=\"20\" />\n\t\t\t\t\t\t\t</template>\n\t\t\t\t\t\t\t{{ action.label }}\n\t\t\t\t\t\t</NcActionButton>\n\t\t\t\t\t</template>\n\t\t\t\t</CnActionsMenu>\n\t\t\t</template>\n\n\t\t\t<CnTab\n\t\t\t\tv-for=\"(entry, index) in resolvedTabs\"\n\t\t\t\t:key=\"entry.key\"\n\t\t\t\t:active=\"index === activeIndex\"\n\t\t\t\t:title=\"entry.label\"\n\t\t\t\t:count=\"entry.count\"\n\t\t\t\t:overflow=\"entry.overflow\"\n\t\t\t\tlazy\n\t\t\t\t@click=\"activeIndex = index\">\n\t\t\t\t<template #title>\n\t\t\t\t\t<span class=\"cn-tabs-widget__title\">\n\t\t\t\t\t\t<CnIcon\n\t\t\t\t\t\t\tv-if=\"entry.icon\"\n\t\t\t\t\t\t\t:name=\"entry.icon\"\n\t\t\t\t\t\t\t:size=\"18\"\n\t\t\t\t\t\t\tclass=\"cn-tabs-widget__title-icon\" />\n\t\t\t\t\t\t{{ entry.label }}\n\t\t\t\t\t</span>\n\t\t\t\t</template>\n\n\t\t\t\t<CnDetailWidgetHost\n\t\t\t\t\tv-if=\"entry.widget\"\n\t\t\t\t\t:widget=\"entry.widget\"\n\t\t\t\t\tchrome=\"bare\"\n\t\t\t\t\t:objectId=\"objectId\"\n\t\t\t\t\t:object=\"objectData\"\n\t\t\t\t\t:objectType=\"objectType\"\n\t\t\t\t\t:schemaObject=\"schemaObject\"\n\t\t\t\t\t:register=\"register\"\n\t\t\t\t\t:schema=\"schema\"\n\t\t\t\t\t:store=\"store\"\n\t\t\t\t\t:surface=\"surface\"\n\t\t\t\t\t:integrationContext=\"integrationContext\"\n\t\t\t\t\t:cnRegistry=\"cnRegistry\"\n\t\t\t\t\t@geoSaved=\"onGeoSaved\"\n\t\t\t\t\t@openIntegration=\"onOpenIntegration\" />\n\t\t\t\t<NcEmptyContent v-else :name=\"missingLabel(entry)\" />\n\t\t\t</CnTab>\n\t\t</CnTabs>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcActionButton, NcEmptyContent } from '@nextcloud/vue'\nimport CnDetailWidgetHost from '../CnDetailWidgetHost/CnDetailWidgetHost.vue'\nimport CnIcon from '../CnIcon/CnIcon.vue'\nimport CnTab from '../CnTabs/CnTab.vue'\nimport CnTabs from '../CnTabs/CnTabs.vue'\nimport { resolveTabCount } from '../../utils/detailActionModel.js'\nimport { PANEL_ACTION_SINK } from '../../utils/panelActions.js'\nimport { widgetTitleOf } from '../../utils/widgetDispatch.js'\nimport { CnActionsMenu } from '../CnActionsMenu/index.js'\n\n/**\n * CnTabsWidget — a widget that holds other widgets, one per tab.\n *\n * A detail page that carries notes, files, related records, sub-records, mail\n * and appointments renders six cards, each with its own header and its own\n * Actions menu, stacked down the page. All six say roughly the same thing about\n * one record, and only one of them is being read at a time. This widget puts\n * them behind a tab strip instead.\n *\n * ## What the tabs take over\n *\n * The strip owns the title and the card. A child renders through\n * `CnDetailWidgetHost` with `chrome=\"bare\"`, so it draws no header and no\n * border of its own, and its content fills the panel exactly as it would have\n * filled its card. The tab label is what the widget's card header used to say.\n *\n * The Actions menu moves OUT of the panels and into the bar, beside the strip\n * rather than inside it, and rebinds to whichever child is showing. So there is\n * one menu, and it always acts on what you are looking at.\n *\n * ## Configuring it\n *\n * `content.tabs[]` names the children and their labels, so a deployment can\n * relabel or reorder tabs, or drop one, without touching code:\n *\n * ```js\n * content: {\n * tabs: [\n * { widgetId: 'case-notes', label: 'Notes', icon: 'NoteTextOutline' },\n * { widgetId: 'case-files', label: 'Files and attachments' },\n * ],\n * }\n * ```\n *\n * `label` and `icon` are optional and fall back to the child widget's own\n * title and icon, so the common case is a list of `widgetId`s.\n *\n * ## Panels are lazy, and stay mounted\n *\n * Each panel is a `CnTab` with `lazy`, so a child only mounts when its tab is\n * first opened. Six eager panels that each fetch on `mounted()` would fire six\n * requests on page load to answer five questions nobody asked. Once opened, a\n * panel stays mounted, so switching back never refetches.\n *\n * ## A named tab whose widget is missing\n *\n * The tab still renders, and its panel says which widget id did not resolve.\n * Dropping the tab would be worse: `content.tabs[]` is hand-authored config,\n * and a typo that silently removes a tab is a typo nobody finds.\n */\nexport default {\n\tname: 'CnTabsWidget',\n\n\tcomponents: {\n\t\tCnActionsMenu,\n\t\tCnDetailWidgetHost,\n\t\tCnIcon,\n\t\tCnTab,\n\t\tCnTabs,\n\t\tNcActionButton,\n\t\tNcEmptyContent,\n\t},\n\n\t/**\n\t * Offer the panels a way to put their own menu items in the strip's menu.\n\t *\n\t * Re-provided one level down by `CnDetailWidgetHost`, which knows the id to\n\t * key by; see utils/panelActions.js for why the items travel up instead of\n\t * being rebuilt here.\n\t *\n\t * @return {object} The provided sink.\n\t */\n\tprovide() {\n\t\treturn {\n\t\t\t[PANEL_ACTION_SINK]: {\n\t\t\t\t/**\n\t\t\t\t * Publish a panel's items, under one SOURCE.\n\t\t\t\t *\n\t\t\t\t * A panel has two possible publishers: the host, for an action\n\t\t\t\t * the host itself would have drawn (the catalog Add), and the\n\t\t\t\t * widget inside it, for its own menu items. Keyed by source so\n\t\t\t\t * the two coexist; a single slot per widget meant whichever\n\t\t\t\t * published last silently replaced the other.\n\t\t\t\t *\n\t\t\t\t * Replaces the map rather than mutating it so the computed that\n\t\t\t\t * reads it re-evaluates on any change.\n\t\t\t\t *\n\t\t\t\t * @param {string} id The publishing widget's id.\n\t\t\t\t * @param {object[]} items Its PanelAction descriptors.\n\t\t\t\t * @param {string} [source] Who is publishing: `host` or `widget`.\n\t\t\t\t * @return {void}\n\t\t\t\t */\n\t\t\t\tset: (id, items, source = 'widget') => {\n\t\t\t\t\t// `activePanelActions` spreads what is stored, so a\n\t\t\t\t\t// non-array becomes one menu item per character.\n\t\t\t\t\tif (!id || !Array.isArray(items)) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst forId = { ...(this.panelActionsByWidget[id] || {}), [source]: items }\n\t\t\t\t\tthis.panelActionsByWidget = { ...this.panelActionsByWidget, [id]: forId }\n\t\t\t\t},\n\n\t\t\t\t/**\n\t\t\t\t * Withdraw one source's items, on unmount or when that\n\t\t\t\t * publisher's own menu comes back. The other source's items\n\t\t\t\t * stay.\n\t\t\t\t *\n\t\t\t\t * @param {string} id The publishing widget's id.\n\t\t\t\t * @param {string} [source] Who is withdrawing.\n\t\t\t\t * @return {void}\n\t\t\t\t */\n\t\t\t\tclear: (id, source = 'widget') => {\n\t\t\t\t\tconst forId = this.panelActionsByWidget[id]\n\t\t\t\t\tif (!forId || !(source in forId)) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst { [source]: _removed, ...keptSources } = forId\n\t\t\t\t\tif (Object.keys(keptSources).length) {\n\t\t\t\t\t\tthis.panelActionsByWidget = { ...this.panelActionsByWidget, [id]: keptSources }\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tconst { [id]: _gone, ...rest } = this.panelActionsByWidget\n\t\t\t\t\tthis.panelActionsByWidget = rest\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * The widget's config: `{ tabs, ariaLabel }`.\n\t\t *\n\t\t * `tabs[]` entries are `{ widgetId, label?, icon?, count?, countField?, overflow? }`.\n\t\t * `label` and `icon` fall back to the referenced widget's own title and\n\t\t * icon. `count` is a number shown after the label; `countField` reads\n\t\t * it off the record instead (a list counts its items). `overflow: true`\n\t\t * lists the tab under \"More\".\n\t\t *\n\t\t * `maxVisibleTabs` caps the strip: later tabs go under \"More\".\n\t\t * `hideEmpty: true` moves a tab whose count is 0 there too.\n\t\t * `moreLabel` names that menu. `variant: \"segmented\"` draws the strip\n\t\t * as a pill switch (CnTabs' segmented variant); `line` is the default.\n\t\t *\n\t\t * @type {{ tabs?: Array<{widgetId: string, label?: string, icon?: string, count?: number, countField?: string, overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string, variant?: ('line'|'segmented') }}\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 * Every widget definition available on the surface, for `content.tabs[]`\n\t\t * to reference by id.\n\t\t *\n\t\t * The surface passes its whole list rather than the resolved children\n\t\t * because a tab may name a widget that does not exist, and this component\n\t\t * has to be able to say so.\n\t\t *\n\t\t * @type {object[]}\n\t\t */\n\t\tavailableWidgets: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/** The bound record's id. */\n\t\tobjectId: {\n\t\t\ttype: [String, Number],\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** The loaded record, or null while it is still being fetched. */\n\t\tobjectData: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** The resolved object-type slug. */\n\t\tobjectType: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** The resolved JSON Schema object, needed by a `data` child. */\n\t\tschemaObject: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** OpenRegister register slug of the surface. */\n\t\tregister: {\n\t\t\ttype: [String, Object],\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** OpenRegister schema slug of the surface. */\n\t\tschema: {\n\t\t\ttype: [String, Object],\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** The effective object store. */\n\t\tstore: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Rendering surface forwarded to integration children (AD-19). */\n\t\tsurface: {\n\t\t\ttype: String,\n\t\t\tdefault: 'detail-page',\n\t\t},\n\n\t\t/** Object context forwarded to integration children. */\n\t\tintegrationContext: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** The consumer's component registry, for custom child widget types. */\n\t\tcnRegistry: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/** Show the Refresh entry in the hoisted Actions menu. */\n\t\tshowRefresh: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/** Show the Request-a-feature entry in the hoisted Actions menu. */\n\t\tshowRequestFeature: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/** Show the Documentation entry in the hoisted Actions menu. */\n\t\tshowDocumentation: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/** Documentation URL for the hoisted Actions menu. */\n\t\tdocumentationUrl: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\t},\n\n\temits: ['geo-saved', 'open-integration'],\n\n\tdata() {\n\t\treturn {\n\t\t\tactiveIndex: 0,\n\t\t\t// Items published by the panels, keyed by widget id. Keyed rather\n\t\t\t// than a flat list because `lazy` keeps a visited tab mounted, so\n\t\t\t// more than one panel publishes at a time.\n\t\t\tpanelActionsByWidget: {},\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The open panel's own menu items, or an empty list.\n\t\t *\n\t\t * @return {object[]} PanelAction descriptors for the active tab.\n\t\t */\n\t\tactivePanelActions() {\n\t\t\tconst forId = this.panelActionsByWidget[this.activeWidgetId]\n\t\t\tif (!forId) {\n\t\t\t\treturn []\n\t\t\t}\n\t\t\t// Host first, then the widget's own: the catalog Add is about the\n\t\t\t// panel as a whole, the widget's items about what is in it.\n\t\t\treturn [...(forId.host || []), ...(forId.widget || [])]\n\t\t},\n\n\t\t/**\n\t\t * The configured tabs, each paired with the widget definition it names.\n\t\t *\n\t\t * A tab goes under the strip's \"More\" menu when it says `overflow`, when\n\t\t * `content.hideEmpty` is set and its count is 0, or when\n\t\t * `content.maxVisibleTabs` places are already taken. `count` is a\n\t\t * literal, or read off the record through `countField`.\n\t\t *\n\t\t * @return {object[]} `{ key, widgetId, label, icon, widget, count, overflow }` per tab.\n\t\t * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-tab-counts-and-overflow\n\t\t */\n\t\tresolvedTabs() {\n\t\t\tconst tabs = Array.isArray(this.content?.tabs) ? this.content.tabs : []\n\t\t\tconst max = Number(this.content?.maxVisibleTabs)\n\t\t\tconst hideEmpty = this.content?.hideEmpty === true\n\t\t\t// How many tabs have taken a place in the strip so far. A tab that\n\t\t\t// is already going under \"More\" does not use one up.\n\t\t\tlet placed = 0\n\t\t\treturn tabs.map((tab, index) => {\n\t\t\t\tconst widgetId = typeof tab === 'string' ? tab : tab?.widgetId\n\t\t\t\tconst widget = this.availableWidgets.find((w) => w && w.id === widgetId) || null\n\t\t\t\tconst count = resolveTabCount(typeof tab === 'object' ? tab : null, this.objectData)\n\t\t\t\tlet overflow = (tab && tab.overflow === true) || (hideEmpty && count === 0)\n\t\t\t\tif (!overflow) {\n\t\t\t\t\tif (Number.isFinite(max) && max > 0 && placed >= max) {\n\t\t\t\t\t\toverflow = true\n\t\t\t\t\t} else {\n\t\t\t\t\t\tplaced += 1\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\tkey: `${widgetId || 'tab'}-${index}`,\n\t\t\t\t\twidgetId,\n\t\t\t\t\tlabel: (tab && tab.label) || widgetTitleOf(widget) || widgetId || '',\n\t\t\t\t\ticon: (tab && tab.icon) || widget?.icon || '',\n\t\t\t\t\twidget,\n\t\t\t\t\tcount,\n\t\t\t\t\toverflow,\n\t\t\t\t}\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Name of the menu that holds the tabs that are not in the strip.\n\t\t *\n\t\t * @return {string|undefined} The configured label, or undefined for the default.\n\t\t * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-tab-counts-and-overflow\n\t\t */\n\t\tmoreLabel() {\n\t\t\tconst label = this.content?.moreLabel\n\t\t\treturn typeof label === 'string' && label !== '' ? label : undefined\n\t\t},\n\n\t\t/**\n\t\t * The tab currently showing.\n\t\t *\n\t\t * @return {object|null} The resolved tab, or null when there are none.\n\t\t */\n\t\tactiveTab() {\n\t\t\treturn this.resolvedTabs[this.activeIndex] || null\n\t\t},\n\n\t\t/**\n\t\t * The active child's id, so the hoisted Refresh reaches THAT child's\n\t\t * fetch over `cn:widget:refresh` rather than a sibling's.\n\t\t *\n\t\t * @return {string} The active widget id.\n\t\t */\n\t\tactiveWidgetId() {\n\t\t\treturn this.activeTab?.widgetId || ''\n\t\t},\n\n\t\t/**\n\t\t * The active child's label, so the Actions menu names what it acts on.\n\t\t *\n\t\t * @return {string} The active tab's label.\n\t\t */\n\t\tactiveTitle() {\n\t\t\treturn this.activeTab?.label || ''\n\t\t},\n\n\t\t/**\n\t\t * The strip's look: `segmented` when the content asks for it, else\n\t\t * CnTabs' default `line`. An unknown value falls back to `line`.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-tabs-widget-renders-a-segmented-strip\n\t\t * @return {('line'|'segmented')} The CnTabs variant.\n\t\t */\n\t\tstripVariant() {\n\t\t\treturn this.content?.variant === 'segmented' ? 'segmented' : 'line'\n\t\t},\n\n\t\t/**\n\t\t * Accessible name for the tab strip.\n\t\t *\n\t\t * @return {string} The aria-label.\n\t\t */\n\t\tstripLabel() {\n\t\t\treturn this.content?.ariaLabel || t('nextcloud-vue', 'Details')\n\t\t},\n\t},\n\n\t/**\n\t * Open on the first tab that is in the strip. Tab 0 is the default, and\n\t * when tab 0 sits under \"More\" the strip would open on a tab the author\n\t * chose to tuck away.\n\t *\n\t * @spec openspec/changes/detail-action-model-and-case-surfaces/specs/detail-action-model/spec.md#requirement-tab-counts-and-overflow\n\t */\n\tcreated() {\n\t\tconst first = this.resolvedTabs.findIndex((tab) => !tab.overflow)\n\t\tif (first > 0) {\n\t\t\tthis.activeIndex = first\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Re-emit a geo child's save so the surface can reload the record.\n\t\t *\n\t\t * @param {object} geo The saved geometry.\n\t\t * @return {void}\n\t\t */\n\t\tonGeoSaved(geo) {\n\t\t\t/**\n\t\t\t * @event geo-saved Re-emitted from a geo child that saved a geometry.\n\t\t\t * @type {object}\n\t\t\t */\n\t\t\tthis.$emit('geo-saved', geo)\n\t\t},\n\n\t\t/**\n\t\t * Re-emit a related child's request to open an integration.\n\t\t *\n\t\t * @param {string} integrationId The integration to open.\n\t\t * @return {void}\n\t\t */\n\t\tonOpenIntegration(integrationId) {\n\t\t\t/**\n\t\t\t * @event open-integration Re-emitted from a related child asking to open an integration.\n\t\t\t * @type {string}\n\t\t\t */\n\t\t\tthis.$emit('open-integration', integrationId)\n\t\t},\n\n\t\t/**\n\t\t * Follow the strip's own selection, so keyboard navigation moves the\n\t\t * hoisted Actions menu too.\n\t\t *\n\t\t * @param {number} index The newly selected tab index.\n\t\t * @return {void}\n\t\t */\n\t\tonTabChange(index) {\n\t\t\tif (typeof index === 'number' && index >= 0) {\n\t\t\t\tthis.activeIndex = index\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Empty-state text for a tab whose widget id resolves to nothing.\n\t\t *\n\t\t * @param {object} entry The resolved tab.\n\t\t * @return {string} The message.\n\t\t */\n\t\tmissingLabel(entry) {\n\t\t\treturn t('nextcloud-vue', 'No widget found for \"{id}\"', { id: entry.widgetId || '' })\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/* The panel is the card; the strip is not inside it. There is no title bar\n above the tabs either: the open tab names the panel, so a title row would\n say the same thing twice and cost a row of height on a card that is mostly\n content.\n\n The card chrome used to wrap the strip as well, which drew a border and a\n pair of rounded corners ABOVE the tabs and boxed them in. Folder tabs are\n drawn as the edge of the sheet they open, so a second edge around them\n reads as a header the widget does not have. Border, radius and background\n now live on the panel below, and the strip sits bare on the page. */\n/* The class is doubled on purpose, and the four properties below are RESET\n rather than simply omitted.\n\n Nextcloud serves every enabled app's assets on every page, each app bundles\n this library's compiled CSS, and the Vue scope id is derived from the file\n PATH, so `data-v-1dbd6122` is byte-identical across library versions. An app\n still on an older release therefore ships a rule with exactly this selector\n and the OLD declarations, and it lands on the pages of an app already on the\n new one. Measured on a dossiq case page: three stale copies of the pre-fix\n rule, one from hermiq's `companion.css` (its companion bundle loads on every\n page by design) and two inline from other apps' bundles. Same specificity,\n so source order decided, and the card border came back around the strip.\n\n Dropping a declaration cannot beat a rule that sets it, so `border` and the\n rest are explicitly zeroed. Doubling the class takes this selector to\n (0,3,0) against the stale (0,2,0), which wins on specificity rather than on\n `!important` or on load order this library does not control. The same\n technique, for the same reason, is used on `.cn-tabs__nav .cn-tabs__nav-item`\n in CnTabs to beat Nextcloud's own button margin. */\n.cn-tabs-widget.cn-tabs-widget {\n\tbackground-color: transparent;\n\tborder: 0;\n\tborder-radius: 0;\n\tdisplay: flex;\n\tflex-direction: column;\n\theight: 100%;\n\tmin-height: 0;\n\toverflow: visible;\n\tpadding: 0;\n}\n\n/* No inset, so the first tab starts at the panel's own left edge. The 8px\n here was there to clear the card's rounded top corner; that corner is gone\n from the strip now, and the inset it existed for left the first tab floating\n 8px inside the sheet it opens.\n\n The bar's own bottom rule is switched off and redrawn as a pseudo-element\n that stops one corner radius short of the right edge. CnTabs draws its rule\n across the bar's whole width, which is the sheet's top edge everywhere\n except at the far right, where the panel's top-right corner curves away\n below it: the rule ran straight on to the widget's edge and read as a\n border sticking out past a rounded corner (Ruben, 2026-09-13). Ending it\n where the curve begins makes the rule and the curve one continuous edge. */\n.cn-tabs-widget__tabs :deep(.cn-tabs__bar) {\n\tborder-bottom: none;\n\tpadding: 0;\n\tposition: relative;\n}\n\n.cn-tabs-widget__tabs :deep(.cn-tabs__bar::after) {\n\tborder-bottom: 1px solid var(--color-border);\n\tcontent: '';\n\tinset-block-end: 0;\n\tinset-inline: 0 var(--border-radius-large);\n\tpointer-events: none;\n\tposition: absolute;\n}\n\n/* The open tab erases the slice of the rule directly above the panel with its\n own background-coloured bottom edge, which only works if it paints ABOVE the\n pseudo-element: a positioned pseudo-element otherwise paints over every\n in-flow sibling, rule included. */\n.cn-tabs-widget__tabs :deep(.cn-tabs__nav-item--active) {\n\tposition: relative;\n\tz-index: 1;\n}\n\n.cn-tabs-widget__tabs {\n\tdisplay: flex;\n\tflex-direction: column;\n\t/* Grow to the widget's height, so the panel fills the grid cell the\n\t layout gave it. Without this the strip plus the open panel took only\n\t the height of the panel's content, and a cell of nine rows ended in\n\t 130px of nothing under a sheet that stopped short of its own card. */\n\tflex: 1 1 auto;\n\tmin-height: 0;\n}\n\n/* The panel area is the scroll region, so the strip stays put while a long\n child scrolls under it.\n\n Padded on all four sides. The top was once zeroed to protect the join with\n the open tab, but padding sits inside the painted box — only a margin would\n have opened that gap, and a zero top put every panel's content against the\n strip. */\n.cn-tabs-widget__tabs :deep(.cn-tabs__content) {\n\tbackground-color: var(--color-main-background);\n\t/* Three sides only: the bar's rule (redrawn above, stopping at the corner)\n\t is this sheet's top edge, and the open tab erases the slice of it\n\t directly above the panel so the two read as one surface. A border-top\n\t here would put a second line under that tab which the tab cannot paint\n\t over. */\n\tborder: 1px solid var(--color-border);\n\tborder-top: none;\n\t/* Three rounded corners. The top-left stays square because the first tab\n\t is drawn joined to the panel there, and a curve under it would curl the\n\t sheet away from that tab. The top-right has no tab above it: the strip\n\t ends where the last tab ends and the Actions menu floats free, so a\n\t square corner there read as a sheet cut off, not as a tab's edge. */\n\tborder-radius: 0 var(--border-radius-large) var(--border-radius-large) var(--border-radius-large);\n\tflex: 1 1 auto;\n\tmin-height: 0;\n\toverflow: auto;\n\tpadding: 12px;\n}\n\n.cn-tabs-widget__tabs :deep(.cn-tab) {\n\theight: 100%;\n}\n\n.cn-tabs-widget__title {\n\talign-items: center;\n\tdisplay: inline-flex;\n\tgap: 6px;\n}\n\n.cn-tabs-widget__title-icon {\n\tflex: 0 0 auto;\n}\n</style>\n"],"names":["CnActionsMenu","CnDetailWidgetHost","CnIcon","CnTab","CnTabs","t"],"mappings":"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"}
|