@getdom/studio 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +53 -1
  3. package/SKILL.md +21 -1
  4. package/package.json +6 -2
  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/style.css +2 -445
  100. package/src/tailwind.css +351 -0
  101. package/src/theme.css +99 -0
@@ -0,0 +1,57 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import {
4
+ DomRangeInput,
5
+ } from '../../lib/vue';
6
+ import ControlIconButton from '../_shared/ControlIconButton.vue';
7
+ import StyleControlSection from '../_shared/StyleControlSection.vue';
8
+ import { backdropToCss } from './backdropModel.js';
9
+
10
+ const props = defineProps({
11
+ backdrop: {
12
+ type: Object,
13
+ required: true,
14
+ },
15
+ modelValue: {
16
+ type: Boolean,
17
+ default: true,
18
+ },
19
+ title: {
20
+ type: String,
21
+ default: 'Backdrop effects',
22
+ },
23
+ variables: {
24
+ type: Object,
25
+ default: () => ({}),
26
+ },
27
+ });
28
+
29
+ const emit = defineEmits(['update:modelValue']);
30
+ const backdropCss = computed(() => backdropToCss(props.backdrop));
31
+ </script>
32
+
33
+ <template>
34
+ <StyleControlSection
35
+ :model-value="modelValue"
36
+ :title="title"
37
+ :meta="backdropCss"
38
+ :muted="!backdrop.enabled"
39
+ @update:model-value="emit('update:modelValue', $event)"
40
+ >
41
+ <template #actions>
42
+ <ControlIconButton
43
+ :icon="backdrop.enabled ? 'eye' : 'eye-off'"
44
+ :label="backdrop.enabled ? 'Hide backdrop effects' : 'Show backdrop effects'"
45
+ @click="backdrop.enabled = !backdrop.enabled"
46
+ />
47
+ </template>
48
+
49
+ <div class="p-3">
50
+ <div class="grid gap-3 sm:grid-cols-2">
51
+ <DomRangeInput v-model="backdrop.blur" label="Blur" :min="0" :max="40" suffix="px" />
52
+ <DomRangeInput v-model="backdrop.saturate" label="Saturate" :min="0.5" :max="1.8" :step="0.01" />
53
+ </div>
54
+ <p v-if="variables.backdrop" class="mt-3 truncate font-mono text-[11px] text-muted-fg">{{ variables.backdrop }}</p>
55
+ </div>
56
+ </StyleControlSection>
57
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup>
2
+ import { ref, watch } from 'vue';
3
+ import BackdropControl from './BackdropControl.vue';
4
+ import { createBackdrop, backdropToCss } from './backdropModel.js';
5
+
6
+ defineOptions({ __doc: { name: 'Backdrop editor', description: 'Beta controlled CSS backdrop editor shared with the theme designer.' } });
7
+ const props = defineProps({
8
+ modelValue: { type: Object, default: () => createBackdrop() },
9
+ title: { type: String, default: 'Backdrop effects' },
10
+ });
11
+ const emit = defineEmits(['update:modelValue', 'change']);
12
+ const draft = ref(createBackdrop());
13
+ const open = ref(true);
14
+ /** Copies records so nested control edits never mutate caller-owned state. */
15
+ function clone(value) { return { ...value }; }
16
+ /** Synchronizes external state, preserving existing fill identities. */
17
+ function sync(value) {
18
+ if (JSON.stringify(value) !== JSON.stringify(draft.value)) draft.value = clone(value);
19
+ }
20
+ /** Emits independent records and ready-to-bind CSS. */
21
+ function publish(value) {
22
+ if (JSON.stringify(value) === JSON.stringify(props.modelValue)) return;
23
+ const backdrop = clone(value);
24
+ emit('update:modelValue', backdrop);
25
+ emit('change', { backdrop, css: backdropToCss(backdrop) });
26
+ }
27
+ watch(() => props.modelValue, sync, { deep: true, immediate: true });
28
+ watch(draft, publish, { deep: true });
29
+ </script>
30
+
31
+ <template>
32
+ <BackdropControl v-model="open" :backdrop="draft" :title="title" />
33
+ </template>
@@ -0,0 +1,39 @@
1
+ /** Shared theme editor helper: createBackdrop. */
2
+ export function createBackdrop(overrides = {}) {
3
+ return {
4
+ enabled: false,
5
+ open: true,
6
+ blur: 0,
7
+ saturate: 1,
8
+ ...overrides,
9
+ blur: Number.parseFloat(overrides.blur ?? 0),
10
+ saturate: Number.parseFloat(overrides.saturate ?? 1),
11
+ };
12
+ }
13
+
14
+ /** Serializes enabled blur and saturation into a backdrop-filter value. */
15
+ export function backdropToCss(backdrop) {
16
+ if (!backdrop?.enabled) return 'none';
17
+ const parts = [];
18
+ const blur = Number.parseFloat(backdrop.blur);
19
+ const saturate = Number.parseFloat(backdrop.saturate);
20
+ if (blur > 0) parts.push(`blur(${formatCssNumber(blur)}px)`);
21
+ if (Number.isFinite(saturate) && saturate !== 1) parts.push(`saturate(${formatCssNumber(saturate)})`);
22
+ return parts.length ? parts.join(' ') : 'none';
23
+ }
24
+
25
+ /** Shared theme editor helper: parseBackdropValue. */
26
+ export function parseBackdropValue(value) {
27
+ const raw = String(value || '').trim();
28
+ if (!raw || raw === 'none') return createBackdrop();
29
+ return createBackdrop({
30
+ enabled: true,
31
+ blur: Number.parseFloat(raw.match(/blur\(\s*(-?\d+(?:\.\d+)?)px\s*\)/i)?.[1] ?? 0),
32
+ saturate: Number.parseFloat(raw.match(/saturate\(\s*(-?\d+(?:\.\d+)?)\s*\)/i)?.[1] ?? 1),
33
+ });
34
+ }
35
+
36
+ /** Formats a CSS number. */
37
+ function formatCssNumber(value) {
38
+ return Number(Number.parseFloat(value || 0).toFixed(3)).toString();
39
+ }
@@ -0,0 +1,210 @@
1
+ <script setup>
2
+ import CssColorInput from '../color-swatch/DomColorSwatch.vue';
3
+ import {
4
+ DomNativeSelect,
5
+ DomPopover,
6
+ DomRangeInput,
7
+ } from '../../lib/vue';
8
+ import ControlIconButton from '../_shared/ControlIconButton.vue';
9
+ import StyleControlSection from '../_shared/StyleControlSection.vue';
10
+ import {
11
+ addFillLayer,
12
+ duplicateFillLayer,
13
+ enabledFills,
14
+ fillToCss,
15
+ fillTypeOptions,
16
+ removeFillLayer,
17
+ } from './backgroundModel.js';
18
+
19
+ const props = defineProps({
20
+ background: {
21
+ type: Object,
22
+ required: true,
23
+ },
24
+ modelValue: {
25
+ type: Boolean,
26
+ default: true,
27
+ },
28
+ title: {
29
+ type: String,
30
+ default: 'Fill',
31
+ },
32
+ variables: {
33
+ type: Object,
34
+ default: () => ({}),
35
+ },
36
+ });
37
+
38
+ const emit = defineEmits(['update:modelValue']);
39
+
40
+ /** Handles background layer interaction: addFill. */
41
+ function addFill() {
42
+ emit('update:modelValue', true);
43
+ addFillLayer(props.background);
44
+ }
45
+
46
+ /** Handles background layer interaction: duplicateFill. */
47
+ function duplicateFill(index) {
48
+ duplicateFillLayer(props.background, index);
49
+ }
50
+
51
+ /** Handles background layer interaction: removeFill. */
52
+ function removeFill(index) {
53
+ removeFillLayer(props.background, index);
54
+ }
55
+
56
+ /** Handles background layer interaction: closeOtherLayerPopovers. */
57
+ function closeOtherLayerPopovers(activeId) {
58
+ if (typeof document === 'undefined') return;
59
+ globalThis.requestAnimationFrame?.(() => {
60
+ document.querySelectorAll('dom-popover[data-layer-popover-id]').forEach((popover) => {
61
+ if (popover.dataset.layerPopoverId === activeId) return;
62
+ closeLayerPopoverElement(popover);
63
+ });
64
+ });
65
+ }
66
+
67
+ /** Handles background layer interaction: closeAllLayerPopovers. */
68
+ function closeAllLayerPopovers() {
69
+ if (typeof document === 'undefined') return;
70
+ document.querySelectorAll('dom-popover[data-layer-popover-id]').forEach(closeLayerPopoverElement);
71
+ }
72
+
73
+ /** Handles background layer interaction: closeLayerPopoverElement. */
74
+ function closeLayerPopoverElement(popover) {
75
+ popover.open = false;
76
+ popover.removeAttribute('open');
77
+ popover.querySelector('.dom-popover-panel')?.hidePopover?.();
78
+ popover.querySelector('[aria-expanded="true"]')?.setAttribute('aria-expanded', 'false');
79
+ }
80
+ </script>
81
+
82
+ <template>
83
+ <StyleControlSection
84
+ :model-value="modelValue"
85
+ :title="title"
86
+ :meta="`${enabledFills(background.fills).length} of ${background.fills.length} visible`"
87
+ :muted="!background.enabled"
88
+ @update:model-value="emit('update:modelValue', $event)"
89
+ >
90
+ <template #actions>
91
+ <ControlIconButton icon="plus" label="Add background layer" @click.stop="addFill" />
92
+ <ControlIconButton
93
+ :icon="background.enabled ? 'eye' : 'eye-off'"
94
+ :label="background.enabled ? 'Hide background' : 'Show background'"
95
+ @click="background.enabled = !background.enabled"
96
+ />
97
+ </template>
98
+
99
+ <div class="divide-y divide-border">
100
+ <p v-if="variables.bgImage" class="px-3 py-2 font-mono text-[11px] text-muted-fg">
101
+ {{ variables.bgImage }}
102
+ </p>
103
+ <article
104
+ v-for="(fill, index) in background.fills"
105
+ :key="fill.id"
106
+ class="px-3 py-2"
107
+ :class="!fill.enabled && 'opacity-60'"
108
+ >
109
+ <div class="grid grid-cols-[1fr_auto] items-center gap-2">
110
+ <DomPopover
111
+ :data-layer-popover-id="fill.id"
112
+ position="start-bottom"
113
+ width="w-[22rem] max-w-[calc(100vw-2rem)]"
114
+ padding="p-0"
115
+ :collision-padding="12"
116
+ @open="closeOtherLayerPopovers(fill.id)"
117
+ >
118
+ <template #trigger>
119
+ <button
120
+ type="button"
121
+ class="grid w-full min-w-0 grid-cols-[auto_1fr] items-center gap-2 rounded-md text-left transition hover:bg-secondary/60"
122
+ @pointerdown.capture="closeAllLayerPopovers"
123
+ >
124
+ <span
125
+ class="h-5 w-5 rounded border border-border"
126
+ :style="{ background: fillToCss(fill) }"
127
+ />
128
+ <span class="min-w-0">
129
+ <span class="block truncate text-sm font-medium">{{
130
+ fill.kind === 'solid'
131
+ ? 'Solid'
132
+ : fill.kind === 'linear'
133
+ ? 'Linear gradient'
134
+ : 'Radial gradient'
135
+ }}</span>
136
+ </span>
137
+ </button>
138
+ </template>
139
+ <div>
140
+ <div class="border-b border-border px-3 py-2">
141
+ <p class="truncate text-sm font-semibold">Fill {{ index + 1 }}</p>
142
+ </div>
143
+ <div class="grid gap-3 p-3 sm:grid-cols-2">
144
+ <DomNativeSelect
145
+ v-model="fill.kind"
146
+ label="Type"
147
+ :options="fillTypeOptions"
148
+ placeholder=""
149
+ />
150
+ <template v-if="fill.kind === 'solid'">
151
+ <CssColorInput v-model="fill.color" label="Colour" />
152
+ <DomRangeInput v-model="fill.alpha" label="Alpha" :min="0" :max="100" suffix="%" />
153
+ </template>
154
+ <template v-else>
155
+ <CssColorInput v-model="fill.gradientFrom" label="From" />
156
+ <DomRangeInput
157
+ v-model="fill.gradientFromAlpha"
158
+ label="From alpha"
159
+ :min="0"
160
+ :max="100"
161
+ suffix="%"
162
+ />
163
+ <CssColorInput v-model="fill.gradientTo" label="To" />
164
+ <DomRangeInput
165
+ v-model="fill.gradientToAlpha"
166
+ label="To alpha"
167
+ :min="0"
168
+ :max="100"
169
+ suffix="%"
170
+ />
171
+ <DomRangeInput
172
+ v-if="fill.kind === 'linear'"
173
+ v-model="fill.gradientAngle"
174
+ label="Angle"
175
+ :min="0"
176
+ :max="360"
177
+ suffix="deg"
178
+ />
179
+ <DomRangeInput
180
+ v-model="fill.gradientFromStop"
181
+ label="From stop"
182
+ :min="0"
183
+ :max="100"
184
+ suffix="%"
185
+ />
186
+ <DomRangeInput
187
+ v-model="fill.gradientToStop"
188
+ label="To stop"
189
+ :min="0"
190
+ :max="100"
191
+ suffix="%"
192
+ />
193
+ </template>
194
+ </div>
195
+ </div>
196
+ </DomPopover>
197
+ <div class="flex items-center gap-1">
198
+ <ControlIconButton icon="duplicate" label="Duplicate fill" @click="duplicateFill(index)" />
199
+ <ControlIconButton
200
+ :icon="fill.enabled ? 'eye' : 'eye-off'"
201
+ :label="fill.enabled ? 'Hide fill' : 'Show fill'"
202
+ @click="fill.enabled = !fill.enabled"
203
+ />
204
+ <ControlIconButton icon="remove" label="Remove fill" @click="removeFill(index)" />
205
+ </div>
206
+ </div>
207
+ </article>
208
+ </div>
209
+ </StyleControlSection>
210
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup>
2
+ import { ref, watch } from 'vue';
3
+ import BackgroundLayers from './BackgroundLayers.vue';
4
+ import { createBackgroundState, backgroundImageToCss } from './backgroundModel.js';
5
+
6
+ defineOptions({ __doc: { name: 'Background editor', description: 'Beta controlled CSS background editor shared with the theme designer.' } });
7
+ const props = defineProps({
8
+ modelValue: { type: Object, default: () => createBackgroundState({}, { allowEmpty: true }) },
9
+ title: { type: String, default: 'Background layers' },
10
+ });
11
+ const emit = defineEmits(['update:modelValue', 'change']);
12
+ const draft = ref(createBackgroundState({}, { allowEmpty: true }));
13
+ const open = ref(true);
14
+ /** Copies records so nested control edits never mutate caller-owned state. */
15
+ function clone(value) { return { ...value, fills: (value.fills || []).map((fill) => ({ ...fill })) }; }
16
+ /** Synchronizes external state, preserving existing fill identities. */
17
+ function sync(value) {
18
+ if (JSON.stringify(value) !== JSON.stringify(draft.value)) draft.value = clone(value);
19
+ }
20
+ /** Emits independent records and ready-to-bind CSS. */
21
+ function publish(value) {
22
+ if (JSON.stringify(value) === JSON.stringify(props.modelValue)) return;
23
+ const background = clone(value);
24
+ emit('update:modelValue', background);
25
+ emit('change', { background, css: backgroundImageToCss(background) });
26
+ }
27
+ watch(() => props.modelValue, sync, { deep: true, immediate: true });
28
+ watch(draft, publish, { deep: true });
29
+ </script>
30
+
31
+ <template>
32
+ <BackgroundLayers v-model="open" :background="draft" :title="title" />
33
+ </template>
@@ -0,0 +1,102 @@
1
+ import { cssColorWithAlpha } from '../../lib/css/color.js';
2
+
3
+ export const fillTypeOptions = [
4
+ { label: 'Solid', value: 'solid' },
5
+ { label: 'Linear gradient', value: 'linear' },
6
+ { label: 'Radial gradient', value: 'radial' },
7
+ ];
8
+
9
+ /** Shared theme editor helper: createFillLayers. */
10
+ export function createFillLayers(background = {}, options = {}) {
11
+ if (Array.isArray(background.fills) && background.fills.length) {
12
+ return background.fills.map((fill, index) => createFill({ ...fill, open: index === 0 }));
13
+ }
14
+ if (options.allowEmpty) return [];
15
+ return [createFill()];
16
+ }
17
+
18
+ /** Creates a background record with independently owned fill layers. */
19
+ export function createBackgroundState(overrides = {}, options = {}) {
20
+ return {
21
+ enabled: overrides.enabled ?? true,
22
+ open: overrides.open ?? true,
23
+ blendMode: overrides.blendMode ?? 'normal',
24
+ fills: createFillLayers(overrides, options),
25
+ };
26
+ }
27
+
28
+ /** Creates a new fill identity; assign a deterministic ID after construction for SSR. */
29
+ export function createFill(overrides = {}) {
30
+ return {
31
+ enabled: true,
32
+ open: true,
33
+ kind: 'solid',
34
+ color: '#171717',
35
+ alpha: 100,
36
+ gradientFrom: '#171717',
37
+ gradientFromAlpha: 100,
38
+ gradientTo: '#4f46e5',
39
+ gradientToAlpha: 100,
40
+ gradientAngle: 135,
41
+ gradientFromStop: 0,
42
+ gradientToStop: 100,
43
+ ...overrides,
44
+ id: createId(),
45
+ };
46
+ }
47
+
48
+ /** Shared theme editor helper: enabledFills. */
49
+ export function enabledFills(fillList) {
50
+ return fillList.filter((fill) => fill.enabled);
51
+ }
52
+
53
+ /** Shared theme editor helper: fillToCss. */
54
+ export function fillToCss(fill) {
55
+ if (fill.kind === 'linear') {
56
+ return `linear-gradient(${fill.gradientAngle}deg, ${cssColorWithAlpha(fill.gradientFrom, fill.gradientFromAlpha)} ${fill.gradientFromStop}%, ${cssColorWithAlpha(fill.gradientTo, fill.gradientToAlpha)} ${fill.gradientToStop}%)`;
57
+ }
58
+ if (fill.kind === 'radial') {
59
+ return `radial-gradient(circle at 50% 45%, ${cssColorWithAlpha(fill.gradientFrom, fill.gradientFromAlpha)} ${fill.gradientFromStop}%, ${cssColorWithAlpha(fill.gradientTo, fill.gradientToAlpha)} ${fill.gradientToStop}%)`;
60
+ }
61
+ return cssColorWithAlpha(fill.color, fill.alpha);
62
+ }
63
+
64
+ /** Shared theme editor helper: fillListToCss. */
65
+ export function fillListToCss(fillList) {
66
+ const visibleFills = enabledFills(fillList);
67
+ if (!visibleFills.length) return 'transparent';
68
+ return visibleFills.map((fill) => fillToCss(fill)).join(', ');
69
+ }
70
+
71
+ /** Serializes enabled fills as valid CSS background images, first layer on top. */
72
+ export function backgroundImageToCss(background) {
73
+ if (!background?.enabled) return 'none';
74
+ const layers = enabledFills(background.fills || []).map((fill) => {
75
+ const css = fillToCss(fill);
76
+ return fill.kind === 'linear' || fill.kind === 'radial' ? css : `linear-gradient(${css}, ${css})`;
77
+ });
78
+ return layers.join(', ') || 'none';
79
+ }
80
+
81
+ /** Shared theme editor helper: addFillLayer. */
82
+ export function addFillLayer(background) {
83
+ background.fills.forEach((fill) => {
84
+ fill.open = false;
85
+ });
86
+ background.fills.unshift(createFill({ kind: 'linear' }));
87
+ }
88
+
89
+ /** Shared theme editor helper: duplicateFillLayer. */
90
+ export function duplicateFillLayer(background, index) {
91
+ background.fills.splice(index + 1, 0, createFill({ ...background.fills[index], open: true }));
92
+ }
93
+
94
+ /** Shared theme editor helper: removeFillLayer. */
95
+ export function removeFillLayer(background, index) {
96
+ background.fills.splice(index, 1);
97
+ }
98
+
99
+ /** Creates a new fill identity. */
100
+ function createId() {
101
+ return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`;
102
+ }
@@ -0,0 +1,72 @@
1
+ <script setup>
2
+ import CssColorInput from '../color-swatch/DomColorSwatch.vue';
3
+ import {
4
+ DomNativeSelect,
5
+ DomRangeInput,
6
+ } from '../../lib/vue';
7
+ import ControlIconButton from '../_shared/ControlIconButton.vue';
8
+ import StyleControlSection from '../_shared/StyleControlSection.vue';
9
+ import { borderStyleOptions } from './borderModel.js';
10
+
11
+ const props = defineProps({
12
+ border: {
13
+ type: Object,
14
+ required: true,
15
+ },
16
+ modelValue: {
17
+ type: Boolean,
18
+ default: true,
19
+ },
20
+ title: {
21
+ type: String,
22
+ default: 'Border',
23
+ },
24
+ maxWidth: { type: Number, default: 6 },
25
+ variables: {
26
+ type: Object,
27
+ default: () => ({}),
28
+ },
29
+ });
30
+
31
+ const emit = defineEmits(['update:modelValue']);
32
+ </script>
33
+
34
+ <template>
35
+ <StyleControlSection
36
+ :model-value="modelValue"
37
+ :title="title"
38
+ :meta="border.enabled && border.style !== 'none' ? `${border.width}px ${border.style}` : 'none'"
39
+ :muted="!border.enabled"
40
+ @update:model-value="emit('update:modelValue', $event)"
41
+ >
42
+ <template #actions>
43
+ <ControlIconButton
44
+ :icon="border.enabled ? 'eye' : 'eye-off'"
45
+ :label="border.enabled ? 'Hide border' : 'Show border'"
46
+ @click="border.enabled = !border.enabled"
47
+ />
48
+ </template>
49
+
50
+ <div class="p-3">
51
+ <div class="grid gap-3 sm:grid-cols-2">
52
+ <DomRangeInput v-model="border.width" label="Width" :min="0" :max="maxWidth" suffix="px" />
53
+ <DomNativeSelect v-model="border.style" label="Style" :options="borderStyleOptions" placeholder="" />
54
+ <CssColorInput v-model="border.color" label="Colour" />
55
+ </div>
56
+ <div
57
+ v-if="variables.color || variables.width || variables.style"
58
+ class="mt-3 flex flex-wrap gap-1.5 font-mono text-[11px] text-muted-fg"
59
+ >
60
+ <span v-if="variables.color" class="rounded border border-border bg-secondary px-1.5 py-0.5">{{
61
+ variables.color
62
+ }}</span>
63
+ <span v-if="variables.width" class="rounded border border-border bg-secondary px-1.5 py-0.5">{{
64
+ variables.width
65
+ }}</span>
66
+ <span v-if="variables.style" class="rounded border border-border bg-secondary px-1.5 py-0.5">{{
67
+ variables.style
68
+ }}</span>
69
+ </div>
70
+ </div>
71
+ </StyleControlSection>
72
+ </template>
@@ -0,0 +1,34 @@
1
+ <script setup>
2
+ import { ref, watch } from 'vue';
3
+ import BorderControl from './BorderControl.vue';
4
+ import { createDesignerBorder, borderToCss } from './borderModel.js';
5
+
6
+ defineOptions({ __doc: { name: 'Border editor', description: 'Beta controlled CSS border width, style and colour, shared with the CSS button creator.' } });
7
+ const props = defineProps({
8
+ modelValue: { type: Object, default: () => createDesignerBorder() },
9
+ title: { type: String, default: 'Border' },
10
+ maxWidth: { type: Number, default: 40 },
11
+ });
12
+ const emit = defineEmits(['update:modelValue', 'change']);
13
+ const draft = ref(createDesignerBorder());
14
+ const open = ref(true);
15
+
16
+ /** Copies incoming values so the underlying control never mutates application-owned records. */
17
+ function syncBorder(value) {
18
+ const next = createDesignerBorder(value);
19
+ if (JSON.stringify(next) !== JSON.stringify(draft.value)) draft.value = next;
20
+ }
21
+ /** Emits an independent record and its CSS value for application-owned styling. */
22
+ function publishBorder(value) {
23
+ if (JSON.stringify(value) === JSON.stringify(createDesignerBorder(props.modelValue))) return;
24
+ const border = { ...value };
25
+ emit('update:modelValue', border);
26
+ emit('change', { border, css: borderToCss(border) });
27
+ }
28
+ watch(() => props.modelValue, syncBorder, { deep: true, immediate: true });
29
+ watch(draft, publishBorder, { deep: true });
30
+ </script>
31
+
32
+ <template>
33
+ <BorderControl v-model="open" :border="draft" :title="title" :max-width="maxWidth" />
34
+ </template>
@@ -0,0 +1,21 @@
1
+ export const borderStyleOptions = [
2
+ { label: 'Solid', value: 'solid' },
3
+ { label: 'Dashed', value: 'dashed' },
4
+ { label: 'Dotted', value: 'dotted' },
5
+ { label: 'Double', value: 'double' },
6
+ { label: 'None', value: 'none' },
7
+ ];
8
+
9
+ /** Creates a caller-owned uniform border record with a finite, nonnegative pixel width. */
10
+ export function createDesignerBorder(overrides = {}) {
11
+ const width = Number.parseFloat(overrides.width ?? 1);
12
+ return { enabled: true, style: 'solid', color: '#171717', ...overrides, width: Number.isFinite(width) ? Math.max(0, width) : 1 };
13
+ }
14
+
15
+ /** Serializes a uniform border without discarding values when its visibility is disabled. */
16
+ export function borderToCss(border) {
17
+ if (!border?.enabled || border.style === 'none') return 'none';
18
+ const value = createDesignerBorder(border);
19
+ const style = borderStyleOptions.some((option) => option.value === value.style) ? value.style : 'solid';
20
+ return `${value.width}px ${style} ${value.color}`;
21
+ }