@getdom/studio 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +31 -0
  3. package/SKILL.md +21 -1
  4. package/package.json +10 -4
  5. package/public/llms.txt +13 -2
  6. package/src/pages/_layout/componentCatalog.generated.js +1185 -44
  7. package/src/pages/_layout/componentLoaders.js +41 -0
  8. package/src/pages/_layout/componentManager.js +38 -27
  9. package/src/pages/_layout/componentRuntimeRecords.js +3 -1
  10. package/src/pages/charts/_shared/ChartContainer.vue +67 -0
  11. package/src/pages/charts/_shared/ChartLegend.vue +22 -0
  12. package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
  13. package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
  14. package/src/pages/charts/_shared/XYChart.vue +140 -0
  15. package/src/pages/charts/_shared/XYRenderer.vue +90 -0
  16. package/src/pages/charts/_shared/chartProps.js +23 -0
  17. package/src/pages/charts/_shared/chartUtils.js +95 -0
  18. package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
  19. package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
  20. package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
  21. package/src/pages/charts/date-range-picker/dateRange.js +69 -0
  22. package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
  23. package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
  24. package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
  25. package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
  26. package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
  27. package/src/pages/components/_shared/media/useMediaInput.js +200 -0
  28. package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
  29. package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
  30. package/src/pages/components/button/DomButton.vue +10 -4
  31. package/src/pages/components/button/buttonStyles.js +5 -0
  32. package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
  33. package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
  34. package/src/pages/components/dialog/DomDialog.vue +338 -36
  35. package/src/pages/components/icon-button/DomIconButton.vue +8 -2
  36. package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
  37. package/src/pages/components/menu-stack/menuTree.js +35 -0
  38. package/src/pages/components/popover/DomPopover.vue +1 -53
  39. package/src/pages/components/popover/popover.css +50 -0
  40. package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
  41. package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
  42. package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
  43. package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
  44. package/src/pages/data/gantt/DomGantt.vue +788 -0
  45. package/src/pages/data/gantt/ganttTime.js +223 -0
  46. package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
  47. package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
  48. package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
  49. package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
  50. package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
  51. package/src/pages/designer/_shared/cssEditorContext.js +19 -0
  52. package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
  53. package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
  54. package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
  55. package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
  56. package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
  57. package/src/pages/designer/background-editor/backgroundModel.js +102 -0
  58. package/src/pages/designer/border-editor/BorderControl.vue +72 -0
  59. package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
  60. package/src/pages/designer/border-editor/borderModel.js +21 -0
  61. package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
  62. package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
  63. package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
  64. package/src/pages/designer/frame/frameLayout.js +93 -0
  65. package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
  66. package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
  67. package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
  68. package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
  69. package/src/pages/designer/stage/DesignerBounds.vue +31 -0
  70. package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
  71. package/src/pages/designer/stage/SpacingGuides.vue +33 -0
  72. package/src/pages/designer/stage/resizeGeometry.js +15 -0
  73. package/src/pages/designer/stage/snapGeometry.js +69 -0
  74. package/src/pages/designer/stage/snapPipeline.js +17 -0
  75. package/src/pages/designer/stage/spacingGeometry.js +129 -0
  76. package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
  77. package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
  78. package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
  79. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
  80. package/src/pages/forms/toggle/DomToggle.vue +1 -1
  81. package/src/pages/lib/headless/base.js +10 -5
  82. package/src/pages/lib/headless/dialog.js +25 -6
  83. package/src/pages/lib/headless/drawer.js +49 -21
  84. package/src/pages/lib/vue/index.d.ts +1161 -14
  85. package/src/pages/lib/vue/index.js +43 -0
  86. package/src/pages/mobile/_shared/interactionMath.js +46 -0
  87. package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
  88. package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
  89. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
  90. package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
  91. package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
  92. package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
  93. package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
  94. package/src/pages/mobile/shell/DomAppShell.vue +60 -9
  95. package/src/pages/mobile/stack/DomAppStack.vue +214 -55
  96. package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
  97. package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
  98. package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
  99. package/src/theme.css +13 -0
@@ -0,0 +1,27 @@
1
+ <script setup>
2
+ import { chartProps } from '../_shared/chartProps.js';
3
+ import XYChart from '../_shared/XYChart.vue';
4
+
5
+ defineOptions({
6
+ __doc: {
7
+ name: 'Area chart',
8
+ tag: '<DomAreaChart>',
9
+ description: 'Show volume over time as overlapping or stacked areas.',
10
+ icon: 'M4 4v16h16M7 14l4-5 4 3 5-7',
11
+ events: [
12
+ { name: 'select', payload: '{ row, index }', description: 'Original application record selected by click or Enter.' },
13
+ { name: 'retry', description: 'Requests another attempt to load application data.' },
14
+ ],
15
+ keyboard: [{ keys: 'Arrow keys / Home / End', action: 'Inspect data points.' }, { keys: 'Enter', action: 'Select the inspected record.' }],
16
+ },
17
+ });
18
+ const props = defineProps({
19
+ ...chartProps,
20
+ stacked: { type: Boolean, default: false, _edit: { description: 'Stack visible series on a shared baseline.' } },
21
+ });
22
+ defineEmits(['select', 'retry']);
23
+ </script>
24
+
25
+ <template>
26
+ <XYChart v-bind="props" kind="area" @select="$emit('select', $event)" @retry="$emit('retry')" />
27
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup>
2
+ import { chartProps } from '../_shared/chartProps.js';
3
+ import XYChart from '../_shared/XYChart.vue';
4
+
5
+ defineOptions({
6
+ __doc: {
7
+ name: 'Bar chart',
8
+ tag: '<DomBarChart>',
9
+ description: 'Compare categories with vertical or horizontal bars, grouped series, and percentage stacks.',
10
+ icon: 'M4 4v16h16M7 14l4-5 4 3 5-7',
11
+ events: [
12
+ { name: 'select', payload: '{ row, index }', description: 'Original application record selected by click or Enter.' },
13
+ { name: 'retry', description: 'Requests another attempt to load application data.' },
14
+ ],
15
+ keyboard: [{ keys: 'Arrow keys / Home / End', action: 'Inspect data points.' }, { keys: 'Enter', action: 'Select the inspected record.' }],
16
+ },
17
+ });
18
+ const props = defineProps({
19
+ ...chartProps,
20
+ stacked: { type: Boolean, default: false, _edit: { description: 'Stack series within each category.' } },
21
+ percentage: { type: Boolean, default: false, _edit: { description: 'Normalize non-negative visible series to a 100% stack.' } },
22
+ horizontal: { type: Boolean, default: false, _edit: { description: 'Place category labels down the left side.' } },
23
+ });
24
+ defineEmits(['select', 'retry']);
25
+ </script>
26
+
27
+ <template>
28
+ <XYChart v-bind="props" kind="bar" @select="$emit('select', $event)" @retry="$emit('retry')" />
29
+ </template>
@@ -0,0 +1,104 @@
1
+ <script setup>
2
+ import { computed, onMounted, ref, watch } from 'vue';
3
+ import DomPopover from '../../components/popover/DomPopover.vue';
4
+ import DomButton from '../../components/button/DomButton.vue';
5
+ import DomDatePicker from '../../forms/date-picker/DomDatePicker.vue';
6
+ import DomNativeSelect from '../../forms/native-select/DomNativeSelect.vue';
7
+ import { comparisonRange, dateRangePresets, rangeError } from './dateRange.js';
8
+
9
+ defineOptions({
10
+ __doc: {
11
+ name: 'Date range picker', tag: '<DomDateRangePicker>',
12
+ description: 'Inclusive reporting dates with useful presets, explicit Apply and Cancel actions, and optional period comparisons.',
13
+ icon: 'M5 4v3M19 4v3M4 9h16M4 6h16v14H4zM8 13h8M8 16h5',
14
+ events: [
15
+ { name: 'update:modelValue', payload: '{ start, end }', description: 'Committed inclusive YYYY-MM-DD dates.' },
16
+ { name: 'update:comparison', payload: 'string', description: 'Committed comparison mode.' },
17
+ { name: 'change', payload: '{ range, comparison, comparisonRange }', description: 'Atomic reporting query change; fetching remains application-owned.' },
18
+ ],
19
+ },
20
+ });
21
+ const props = defineProps({
22
+ modelValue: { type: Object, default: () => ({ start: '', end: '' }), _edit: { description: 'Controlled inclusive { start, end } dates in YYYY-MM-DD format.' } },
23
+ comparison: { type: String, default: 'none', _edit: { options: ['none', 'previous-period', 'previous-year'] } },
24
+ showComparison: { type: Boolean, default: true },
25
+ label: { type: String, default: 'Reporting period' },
26
+ disabled: { type: Boolean, default: false },
27
+ min: { type: String, default: '' },
28
+ max: { type: String, default: '' },
29
+ anchorDate: { type: String, default: '', _edit: { description: 'Preset reference date. Defaults to browser-local today after mount; supply a reporting-timezone date for deterministic presets.' } },
30
+ locale: { type: String, default: 'en-GB' },
31
+ });
32
+ const draft = ref({ ...props.modelValue });
33
+ const draftComparison = ref(props.comparison);
34
+ const invalidStart = ref(false);
35
+ const invalidEnd = ref(false);
36
+ const draftVersion = ref(0);
37
+ const today = ref('');
38
+ const presets = computed(() => dateRangePresets(props.anchorDate || today.value));
39
+ const error = computed(() => invalidStart.value || invalidEnd.value ? 'Correct the invalid date before applying this range.' : rangeError(draft.value, props.min, props.max));
40
+ const compared = computed(() => comparisonRange(draft.value, draftComparison.value));
41
+ const triggerLabel = computed(() => rangeError(props.modelValue) ? props.label : `${formatDate(props.modelValue.start)} – ${formatDate(props.modelValue.end)}`);
42
+ const emit = defineEmits(['update:modelValue', 'update:comparison', 'change']);
43
+ const comparisonOptions = [{ value: 'none', label: 'No comparison' }, { value: 'previous-period', label: 'Previous period' }, { value: 'previous-year', label: 'Previous year' }];
44
+ onMounted(setToday);
45
+ watch(() => [props.modelValue, props.comparison], resetDraft, { deep: true });
46
+
47
+ /** @returns {void} Establishes local today only after hydration. */
48
+ function setToday() {
49
+ const date = new Date();
50
+ today.value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
51
+ }
52
+ /** @returns {void} Restores controlled values when reopening or cancelling. */
53
+ function resetDraft() {
54
+ draft.value = { ...props.modelValue };
55
+ draftComparison.value = props.comparison;
56
+ invalidStart.value = false;
57
+ invalidEnd.value = false;
58
+ draftVersion.value += 1;
59
+ }
60
+ /** @param {string} value Canonical date. @returns {string} Localized date label. */
61
+ function formatDate(value) { return new Intl.DateTimeFormat(props.locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${value}T00:00:00Z`)); }
62
+ /** @param {object} preset Inclusive dates. @returns {void} Updates only the editable draft. */
63
+ function choosePreset(preset) {
64
+ draft.value = { start: preset.start, end: preset.end };
65
+ invalidStart.value = false;
66
+ invalidEnd.value = false;
67
+ draftVersion.value += 1;
68
+ }
69
+ /** @param {Function} close Popover close helper. @returns {void} Commits the complete reporting choice. */
70
+ function apply(close) {
71
+ if (props.disabled || error.value) return;
72
+ const range = { ...draft.value };
73
+ const comparison = props.showComparison ? draftComparison.value : 'none';
74
+ emit('update:modelValue', range);
75
+ emit('update:comparison', comparison);
76
+ emit('change', { range, comparison, comparisonRange: comparisonRange(range, comparison) });
77
+ close();
78
+ }
79
+ </script>
80
+
81
+ <template>
82
+ <DomPopover width="w-[min(26rem,calc(100vw-2rem))]" :arrow="false" padding="p-4" @open="resetDraft">
83
+ <template #trigger><DomButton variant="secondary" :disabled="disabled" :aria-label="`${label}: ${triggerLabel}`">{{ triggerLabel }}</DomButton></template>
84
+ <template #default="{ close }">
85
+ <div class="space-y-4">
86
+ <p class="font-semibold">{{ label }}</p>
87
+ <div class="grid grid-cols-2 gap-2">
88
+ <DomButton v-for="preset in presets" :key="preset.label" size="sm" variant="secondary" :disabled="!!rangeError(preset, min, max)" @click="choosePreset(preset)">{{ preset.label }}</DomButton>
89
+ </div>
90
+ <div class="grid gap-3 sm:grid-cols-2">
91
+ <DomDatePicker :key="`start-${draftVersion}`" v-model="draft.start" label="Start date" :calendar-props="{ min, max }" :register-field="false" @invalid-date="invalidStart = true" @update:model-value="invalidStart = false" />
92
+ <DomDatePicker :key="`end-${draftVersion}`" v-model="draft.end" label="End date" :calendar-props="{ min, max }" :register-field="false" @invalid-date="invalidEnd = true" @update:model-value="invalidEnd = false" />
93
+ </div>
94
+ <DomNativeSelect v-if="showComparison" v-model="draftComparison" label="Compare with" :options="comparisonOptions" :register-field="false" />
95
+ <p v-if="compared && showComparison" class="text-xs leading-5 text-muted-fg">Comparison: {{ formatDate(compared.start) }} – {{ formatDate(compared.end) }}</p>
96
+ <p v-if="error" role="status" class="text-xs text-destructive">{{ error }}</p>
97
+ <div class="flex justify-end gap-2 border-t border-border pt-3">
98
+ <DomButton size="sm" variant="ghost" @click="resetDraft(); close()">Cancel</DomButton>
99
+ <DomButton size="sm" :disabled="!!error || disabled" @click="apply(close)">Apply</DomButton>
100
+ </div>
101
+ </div>
102
+ </template>
103
+ </DomPopover>
104
+ </template>
@@ -0,0 +1,69 @@
1
+ const DAY = 86_400_000;
2
+
3
+ /** @param {string} value Canonical date. @returns {number|null} UTC timestamp for a real calendar date. */
4
+ export function dateStamp(value) {
5
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return null;
6
+ const stamp = Date.parse(`${value}T00:00:00Z`);
7
+ return Number.isFinite(stamp) && new Date(stamp).toISOString().slice(0, 10) === value ? stamp : null;
8
+ }
9
+
10
+ /** @param {number} stamp UTC timestamp. @returns {string} Canonical date without a timezone conversion. */
11
+ function dateString(stamp) { return new Date(stamp).toISOString().slice(0, 10); }
12
+
13
+ /**
14
+ * Validates inclusive date ranges against optional application bounds.
15
+ * @param {{start?: string, end?: string}} range Proposed range.
16
+ * @param {string} [min] Earliest available date.
17
+ * @param {string} [max] Latest available date.
18
+ * @returns {string} Validation message, or an empty string.
19
+ */
20
+ export function rangeError(range, min = '', max = '') {
21
+ if (dateStamp(range?.start) === null || dateStamp(range?.end) === null) return 'Choose a valid start and end date.';
22
+ if (range.start > range.end) return 'End date must be on or after start date.';
23
+ if (min && range.start < min) return `Choose dates on or after ${min}.`;
24
+ if (max && range.end > max) return `Choose dates on or before ${max}.`;
25
+ return '';
26
+ }
27
+
28
+ /**
29
+ * Calculates reporting presets using calendar arithmetic independent of DST.
30
+ * @param {string} anchor Canonical date treated as today.
31
+ * @returns {Array<object>} Inclusive preset ranges.
32
+ */
33
+ export function dateRangePresets(anchor) {
34
+ const end = dateStamp(anchor);
35
+ if (end === null) return [];
36
+ const date = new Date(end);
37
+ const monthStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
38
+ return [
39
+ { label: 'Last 7 days', start: dateString(end - 6 * DAY), end: anchor },
40
+ { label: 'Last 30 days', start: dateString(end - 29 * DAY), end: anchor },
41
+ { label: 'This month', start: dateString(monthStart), end: anchor },
42
+ { label: 'Last month', start: dateString(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - 1, 1)), end: dateString(monthStart - DAY) },
43
+ ];
44
+ }
45
+
46
+ /**
47
+ * Derives an equal-length previous period or the matching dates a year earlier.
48
+ * Leap-day comparisons clamp to the final valid day of the earlier February.
49
+ * @param {{start: string, end: string}} range Inclusive reporting range.
50
+ * @param {'none'|'previous-period'|'previous-year'} comparison Comparison mode.
51
+ * @returns {{start: string, end: string}|null} Derived comparison dates.
52
+ */
53
+ export function comparisonRange(range, comparison) {
54
+ if (rangeError(range) || comparison === 'none') return null;
55
+ const start = dateStamp(range.start);
56
+ const end = dateStamp(range.end);
57
+ if (comparison === 'previous-period') return { start: dateString(start - (end - start + DAY)), end: dateString(start - DAY) };
58
+ if (comparison !== 'previous-year') return null;
59
+ return { start: previousYear(start), end: previousYear(end) };
60
+ }
61
+
62
+ /** @param {number} stamp Valid UTC date. @returns {string} Date in the preceding year, clamped at month end. */
63
+ function previousYear(stamp) {
64
+ const date = new Date(stamp);
65
+ const year = date.getUTCFullYear() - 1;
66
+ const month = date.getUTCMonth();
67
+ const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
68
+ return dateString(Date.UTC(year, month, Math.min(date.getUTCDate(), lastDay)));
69
+ }
@@ -0,0 +1,117 @@
1
+ <script setup>
2
+ import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
3
+ import { chartProps } from '../_shared/chartProps.js';
4
+ import { chartColor, chartNumber, formatChartValue } from '../_shared/chartUtils.js';
5
+ import ChartContainer from '../_shared/ChartContainer.vue';
6
+ import ChartLegend from '../_shared/ChartLegend.vue';
7
+ import ChartTooltip from '../_shared/ChartTooltip.vue';
8
+
9
+ defineOptions({
10
+ __doc: {
11
+ name: 'Donut chart', tag: '<DomDonutChart>',
12
+ description: 'Compare non-negative category shares with a centre total, selectable legend, and accessible data table.',
13
+ icon: 'M12 3v9h9A9 9 0 1 1 12 3ZM15 3.5A9 9 0 0 1 20.5 9H15z',
14
+ slots: [{ name: 'center', payload: '{ total, formattedTotal }', description: 'Replaces the centre labels with custom content, such as an audit score out of 100. Keep content small enough to fit inside the ring.' }],
15
+ events: [{ name: 'select', payload: '{ row, index }', description: 'Original record for the selected category.' }, { name: 'retry', description: 'Requests application data recovery.' }],
16
+ },
17
+ });
18
+ const props = defineProps({
19
+ rows: chartProps.rows,
20
+ title: chartProps.title,
21
+ description: chartProps.description,
22
+ height: chartProps.height,
23
+ format: chartProps.format,
24
+ currency: chartProps.currency,
25
+ locale: chartProps.locale,
26
+ valueFormatter: chartProps.valueFormatter,
27
+ loading: chartProps.loading,
28
+ error: chartProps.error,
29
+ emptyLabel: chartProps.emptyLabel,
30
+ legend: chartProps.legend,
31
+ showTable: chartProps.showTable,
32
+ labelKey: { type: String, default: 'label', _edit: { description: 'Unique category label field; supply one row per category.' } },
33
+ valueKey: { type: String, default: 'value', _edit: { description: 'Non-negative measurement field.' } },
34
+ colorKey: { type: String, default: 'color', _edit: { description: 'Optional field containing a semantic token or CSS colour.' } },
35
+ totalLabel: { type: String, default: 'Total', _edit: { description: 'Caption beneath the sum of visible categories.' } },
36
+ arcWidth: { type: Number, default: 36, _edit: { description: 'Ring thickness in pixels.' } },
37
+ });
38
+ const emit = defineEmits(['select', 'retry']);
39
+ const renderer = shallowRef(null);
40
+ const loadError = ref('');
41
+ const hiddenKeys = ref([]);
42
+ const active = ref(null);
43
+ let disposed = false;
44
+ const items = computed(getItems);
45
+ const visibleItems = computed(() => items.value.filter((item) => !hiddenKeys.value.includes(item.key)));
46
+ const total = computed(() => visibleItems.value.some((item) => item.value !== undefined) ? visibleItems.value.reduce((sum, item) => sum + (item.value || 0), 0) : null);
47
+ const validationError = computed(getValidationError);
48
+ const tooltipItems = computed(() => active.value ? [{ ...active.value, value: formatValue(active.value.value) }] : []);
49
+ onMounted(loadRenderer);
50
+ onBeforeUnmount(() => { disposed = true; });
51
+ watch(items, () => { active.value = null; });
52
+
53
+ /** @returns {Array<object>} Categories preserving source records and missing values. */
54
+ function getItems() {
55
+ return props.rows.flatMap((row, index) => {
56
+ if (!row || typeof row !== 'object') return [];
57
+ const key = String(row[props.labelKey] ?? index);
58
+ return [{ key, label: String(row[props.labelKey] ?? 'Unlabelled'), value: chartNumber(row[props.valueKey]), color: chartColor(key, row[props.colorKey]), row, index }];
59
+ });
60
+ }
61
+
62
+ /** @returns {string} Invalid data explanation, avoiding misleading category shares. */
63
+ function getValidationError() {
64
+ if (items.value.some((item) => item.value < 0)) return 'Donut charts require non-negative measurements.';
65
+ if (new Set(items.value.map((item) => item.key)).size !== items.value.length) return 'Provide one row per category; category labels must be unique.';
66
+ return '';
67
+ }
68
+
69
+ /** @returns {Promise<void>} Loads the browser-only rendering component. */
70
+ async function loadRenderer() {
71
+ loadError.value = '';
72
+ try {
73
+ const module = await import('../_shared/DonutRenderer.vue');
74
+ if (!disposed) renderer.value = module.default;
75
+ } catch {
76
+ if (!disposed) loadError.value = 'The chart could not be loaded.';
77
+ }
78
+ }
79
+
80
+ /** @returns {void} Retries the engine or delegates data recovery. */
81
+ function retry() { if (loadError.value) loadRenderer(); else emit('retry'); }
82
+ /** @param {unknown} value Measurement. @returns {string} Consistent formatted value. */
83
+ function formatValue(value) { return formatChartValue(value, props); }
84
+ /** @param {object} item Category. @returns {void} Emits the original selected record. */
85
+ function select(item) { if (item) emit('select', { row: item.row, index: item.index }); }
86
+ /** @param {string} key Category key. @returns {void} Toggles visibility while preserving one category. */
87
+ function toggle(key) {
88
+ if (hiddenKeys.value.includes(key)) hiddenKeys.value = hiddenKeys.value.filter((item) => item !== key);
89
+ else if (visibleItems.value.length > 1) hiddenKeys.value = [...hiddenKeys.value, key];
90
+ active.value = null;
91
+ }
92
+ </script>
93
+
94
+ <template>
95
+ <ChartContainer :title="title" :description="description" :height="height" :loading="loading" :error="error || validationError || loadError" :empty="total === null" :empty-label="emptyLabel" @retry="retry">
96
+ <div class="relative" :style="{ height: `${Math.max(120, height)}px` }">
97
+ <component :is="renderer" v-if="renderer" aria-hidden="true" :items="visibleItems.filter(item => item.value > 0)" :height="Math.max(120, height)" :total="$slots.center ? '' : formatValue(total)" :label="$slots.center ? '' : totalLabel" :arc-width="arcWidth" @inspect="active = $event" @select="select" />
98
+ <div v-if="$slots.center" class="pointer-events-none absolute inset-0 flex items-center justify-center text-center">
99
+ <slot name="center" :total="total" :formatted-total="formatValue(total)" />
100
+ </div>
101
+ <ChartTooltip v-if="active" class="absolute right-2 top-2 z-10" :label="active.label" :items="tooltipItems" />
102
+ <p v-if="total === 0" class="absolute inset-x-0 bottom-1 text-center text-xs text-muted-fg">No positive values for this period</p>
103
+ </div>
104
+ <template #legend><ChartLegend v-if="legend && !loading && !error" :items="items" :hidden-keys="hiddenKeys" @toggle="toggle" /></template>
105
+ <template #table>
106
+ <details v-if="showTable && items.length && !loading && !error" class="mt-3 text-xs text-muted-fg">
107
+ <summary class="w-fit cursor-pointer rounded py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">View data for {{ title }}</summary>
108
+ <div class="mt-3 overflow-auto rounded-lg border border-border">
109
+ <table class="w-full text-left tabular-nums"><caption class="sr-only">{{ title }}. Missing values are shown as a dash.</caption>
110
+ <thead class="bg-secondary text-canvas-fg"><tr><th scope="col" class="px-3 py-2">Category</th><th scope="col" class="px-3 py-2 text-right">Value</th></tr></thead>
111
+ <tbody><tr v-for="item in items" :key="item.key" class="border-t border-border"><th scope="row" class="px-3 py-2"><button type="button" class="rounded text-left font-medium underline underline-offset-4 focus-visible:ring-2 focus-visible:ring-ring" @focus="active = item" @blur="active = null" @click="select(item)">{{ item.label }}</button></th><td class="px-3 py-2 text-right">{{ formatValue(item.value) }}</td></tr></tbody>
112
+ </table>
113
+ </div>
114
+ </details>
115
+ </template>
116
+ </ChartContainer>
117
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup>
2
+ import { chartProps } from '../_shared/chartProps.js';
3
+ import XYChart from '../_shared/XYChart.vue';
4
+
5
+ defineOptions({
6
+ __doc: {
7
+ name: 'Line chart',
8
+ tag: '<DomLineChart>',
9
+ description: 'Compare trends, reporting periods, and targets with proportional time or ordered category axes.',
10
+ icon: 'M4 4v16h16M7 14l4-5 4 3 5-7',
11
+ events: [
12
+ { name: 'select', payload: '{ row, index }', description: 'Original application record selected by click or Enter.' },
13
+ { name: 'retry', description: 'Requests another attempt to load application data.' },
14
+ ],
15
+ keyboard: [{ keys: 'Arrow keys / Home / End', action: 'Inspect data points.' }, { keys: 'Enter', action: 'Select the inspected record.' }],
16
+ },
17
+ });
18
+ const props = defineProps({
19
+ ...chartProps,
20
+ });
21
+ defineEmits(['select', 'retry']);
22
+ </script>
23
+
24
+ <template>
25
+ <XYChart v-bind="props" kind="line" @select="$emit('select', $event)" @retry="$emit('retry')" />
26
+ </template>
@@ -0,0 +1,49 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import DomCard from '../../visual/card/DomCard.vue';
4
+ import DomSparkline from '../sparkline/DomSparkline.vue';
5
+ import { chartNumber, formatChartValue } from '../_shared/chartUtils.js';
6
+
7
+ defineOptions({
8
+ __doc: { name: 'Metric card', tag: '<DomMetricCard>', description: 'A formatted measurement, explicit comparison, and optional sparkline for reporting dashboards.', icon: 'M4 5h16v14H4zM7 15l3-3 3 1 4-5' },
9
+ });
10
+ const props = defineProps({
11
+ label: { type: String, default: 'Revenue', _edit: { description: 'Metric name.' } },
12
+ value: { type: Number, default: null, _edit: { description: 'Current value. Null is unavailable; zero remains zero.' } },
13
+ format: { type: String, default: 'number', _edit: { options: ['number', 'compact', 'currency', 'percent'] } },
14
+ currency: { type: String, default: 'GBP' },
15
+ locale: { type: String, default: 'en-GB' },
16
+ change: { type: Number, default: null, _edit: { description: 'Relative change as a fraction, e.g. 0.12 for +12%. Calculated by the application.' } },
17
+ comparisonLabel: { type: String, default: 'vs previous period' },
18
+ trend: { type: String, default: 'up-good', _edit: { options: ['up-good', 'down-good', 'neutral'], description: 'Whether an increase or decrease represents improvement.' } },
19
+ loading: { type: Boolean, default: false },
20
+ rows: { type: Array, default: () => [], _edit: { description: 'Optional sparkline records.' } },
21
+ xKey: { type: String, default: 'date' },
22
+ valueKey: { type: String, default: 'value' },
23
+ color: { type: String, default: 'primary' },
24
+ });
25
+ const formatted = computed(() => formatChartValue(props.value, props));
26
+ const changeLabel = computed(getChangeLabel);
27
+ const tone = computed(getTone);
28
+
29
+ /** @returns {string} Signed percentage change without inventing a comparison. */
30
+ function getChangeLabel() {
31
+ if (chartNumber(props.change) === undefined) return '';
32
+ return new Intl.NumberFormat(props.locale, { style: 'percent', maximumFractionDigits: 1, signDisplay: 'exceptZero' }).format(props.change);
33
+ }
34
+ /** @returns {string} Semantic performance colour based on metric direction. */
35
+ function getTone() {
36
+ if (props.trend === 'neutral' || !props.change) return 'text-muted-fg';
37
+ return (props.change > 0) === (props.trend === 'up-good') ? 'text-success' : 'text-destructive';
38
+ }
39
+ </script>
40
+
41
+ <template>
42
+ <DomCard padding="sm" :aria-busy="loading || undefined">
43
+ <p class="text-sm text-muted-fg">{{ label }}</p>
44
+ <p class="mt-2 text-3xl font-semibold tracking-tight tabular-nums text-canvas-fg" :class="loading && 'motion-safe:animate-pulse'">{{ loading ? '…' : formatted }}</p>
45
+ <p v-if="changeLabel && !loading" class="mt-2 flex flex-wrap items-center gap-x-2 text-xs"><span class="font-medium tabular-nums" :class="tone">{{ changeLabel }}</span><span class="text-muted-fg">{{ comparisonLabel }}</span></p>
46
+ <DomSparkline v-if="rows.length" class="mt-5" :title="`${label} trend`" :rows="rows" :x-key="xKey" :series="[{ key: valueKey, label, color }]" :format="format" :currency="currency" :locale="locale" :loading="loading" />
47
+ <slot />
48
+ </DomCard>
49
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup>
2
+ import { chartProps } from '../_shared/chartProps.js';
3
+ import XYChart from '../_shared/XYChart.vue';
4
+
5
+ defineOptions({
6
+ __doc: {
7
+ name: 'Sparkline',
8
+ tag: '<DomSparkline>',
9
+ description: 'Compact trends for metric cards, table cells, and resource lists.',
10
+ icon: 'M4 4v16h16M7 14l4-5 4 3 5-7',
11
+ events: [
12
+ { name: 'select', payload: '{ row, index }', description: 'Original application record selected by click or Enter.' },
13
+ { name: 'retry', description: 'Requests another attempt to load application data.' },
14
+ ],
15
+ keyboard: [{ keys: 'Arrow keys / Home / End', action: 'Inspect data points.' }, { keys: 'Enter', action: 'Select the inspected record.' }],
16
+ },
17
+ });
18
+ const props = defineProps({
19
+ ...chartProps,
20
+ title: { type: String, default: 'Trend' },
21
+ height: { type: Number, default: 48 },
22
+ legend: { type: Boolean, default: false },
23
+ showTable: { type: Boolean, default: false },
24
+ });
25
+ defineEmits(['select', 'retry']);
26
+ </script>
27
+
28
+ <template>
29
+ <XYChart v-bind="props" kind="line" compact @select="$emit('select', $event)" @retry="$emit('retry')" />
30
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import DomNativeSelect from '../../../forms/native-select/DomNativeSelect.vue';
4
+
5
+ const props = defineProps({
6
+ devices: { type: Array, required: true },
7
+ deviceId: { type: String, default: '' },
8
+ label: { type: String, default: 'Microphone' },
9
+ disabled: { type: Boolean, default: false },
10
+ });
11
+ const emit = defineEmits(['select']);
12
+ /** Keeps saved selections readable before permission reveals the device list. */
13
+ const options = computed(() => {
14
+ const items = props.devices.map(toOption);
15
+ if (props.deviceId && !items.some(matchesSelection)) {
16
+ items.unshift({ value: props.deviceId, label: `Selected ${props.label.toLowerCase()}` });
17
+ }
18
+ return [{ value: '', label: 'System default' }, ...items];
19
+ });
20
+ /** Tests whether discovery already includes the committed or saved input. */
21
+ function matchesSelection(option) { return option.value === props.deviceId; }
22
+ /** Converts browser input metadata into the native select's public option shape. */
23
+ function toOption(device) { return { value: device.deviceId, label: device.label }; }
24
+ </script>
25
+
26
+ <template>
27
+ <DomNativeSelect :label="label" :model-value="deviceId" :options="options" placeholder="" :disabled="disabled" @update:model-value="emit('select', $event)" />
28
+ </template>