picocrank 1.23.1 → 1.25.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 (38) hide show
  1. package/AGENTS.md +2 -0
  2. package/docs/filter-query-v1.md +180 -0
  3. package/logo.svg +7 -7
  4. package/package.json +7 -5
  5. package/public/themes/README.md +19 -2
  6. package/public/themes/index.json +1 -5
  7. package/styles/dark-theme.css +16 -1
  8. package/supplemental-themes/README.md +9 -0
  9. package/supplemental-themes/catppuccin-latte-frappe/theme.css +276 -0
  10. package/supplemental-themes/dracula-alucard/theme.css +258 -0
  11. package/supplemental-themes/gruvbox-dark-light/theme.css +264 -0
  12. package/supplemental-themes/index.json +17 -0
  13. package/{public/themes → supplemental-themes}/waffles/theme.css +99 -0
  14. package/vite.config.js +91 -0
  15. package/vue/components/FormField.vue +69 -1
  16. package/vue/components/Header.vue +3 -3
  17. package/vue/components/Login.vue +1 -3
  18. package/vue/components/Section.vue +45 -14
  19. package/vue/components/Sidebar.vue +2 -1
  20. package/vue/components/SidebarNavLink.vue +6 -0
  21. package/vue/components/Table.vue +1113 -73
  22. package/vue/components/TableColumnFilterPopover.vue +263 -44
  23. package/vue/components/TableColumnOptionsPopover.vue +1116 -0
  24. package/vue/components/ThemeSwitcher.vue +119 -0
  25. package/vue/components/TopBar.vue +2 -2
  26. package/vue/composables/tableColumnPresets.js +296 -0
  27. package/vue/composables/tableFilterQuery.js +202 -0
  28. package/vue/composables/tableFilters.js +167 -3
  29. package/vue/composables/useCustomTheme.js +167 -24
  30. package/vue/data/examplePeople.js +2 -6
  31. package/vue/examples/App.vue +1 -1
  32. package/vue/examples/FormExample.vue +40 -6
  33. package/vue/examples/LoginExample.vue +1 -3
  34. package/vue/examples/TableExample.vue +329 -11
  35. package/vue/examples/TableRemoteExample.vue +112 -4
  36. package/vue/examples/ThemePreview.vue +17 -31
  37. package/public/themes/catppuccin/theme.css +0 -195
  38. package/public/themes/dracula/theme.css +0 -181
@@ -1,17 +1,24 @@
1
1
  <template>
2
2
  <Section
3
3
  title="Table Example"
4
+ :icon="TableIcon"
4
5
  subtitle="Sortable columns, pagination, and column filters. Right-click or long-press a column header to filter."
5
6
  :padding="false"
6
7
  >
7
- <template #toolbar>
8
- <button>Add Item</button>
9
- </template>
10
-
11
8
  <Table
12
- :data="data"
9
+ table-id="table-example"
10
+ row-key="name"
11
+ :data="tableData"
13
12
  :headers="headers"
14
- :show-pagination="true"
13
+ :show-pagination="enabledFeatures.includes('pagination')"
14
+ :filterable="enabledFeatures.includes('filters')"
15
+ :column-options="enabledFeatures.includes('columnOptions')"
16
+ :loading="enabledFeatures.includes('loading')"
17
+ :horizontal-scroll="layout === 'scroll'"
18
+ :sticky-cols="stickyCols"
19
+ v-model:column-visibility="columnVisibility"
20
+ :default-column-visibility="defaultColumnVisibility"
21
+ v-bind="tableListeners"
15
22
  >
16
23
  <template #cell-name="{ row, value }">
17
24
  <router-link :to="{ name: 'ViewItem', params: { id: row.name } }">
@@ -24,23 +31,334 @@
24
31
  {{ value }}
25
32
  </span>
26
33
  </template>
34
+
35
+ <template #cell-status="{ value }">
36
+ <span class="tag fg-good">{{ value }}</span>
37
+ </template>
27
38
  </Table>
28
39
  </Section>
40
+
41
+ <Section
42
+ title="Explore table options"
43
+ :icon="Settings01Icon"
44
+ subtitle="Adjust layout and behaviour for the table above."
45
+ >
46
+ <template #toolbar>
47
+ <button type="button" @click="resetControls">Reset options</button>
48
+ </template>
49
+
50
+ <FormLayout>
51
+ <FormField label="Data" fake description="Connect or disconnect row data for the table above. Saved column layouts persist in localStorage for this table.">
52
+ <div class="table-data-actions">
53
+ <button type="button" :disabled="dataConnected" @click="dataConnected = true">
54
+ Connect data
55
+ </button>
56
+ <button type="button" class="neutral" :disabled="!dataConnected" @click="dataConnected = false">
57
+ Disconnect data
58
+ </button>
59
+ </div>
60
+ </FormField>
61
+
62
+ <FormField label="Features" fake description="Toggle core table behaviour.">
63
+ <CheckGroup
64
+ v-model="enabledFeatures"
65
+ name="table-features"
66
+ :options="featureOptions"
67
+ />
68
+ </FormField>
69
+
70
+ <FormField
71
+ label="Responsive layout"
72
+ fake
73
+ description="Column priorities hide lower-priority columns as the table narrows. Horizontal scroll keeps every column visible with optional sticky leading columns."
74
+ >
75
+ <RadioGroup
76
+ v-model="layout"
77
+ name="table-layout"
78
+ :options="layoutOptions"
79
+ />
80
+ </FormField>
81
+
82
+ <FormField
83
+ v-if="layout === 'scroll'"
84
+ label="Sticky columns"
85
+ fake
86
+ description="Pin the first columns while scrolling horizontally."
87
+ >
88
+ <RadioGroup
89
+ v-model="stickyColsOption"
90
+ name="table-sticky-cols"
91
+ :options="stickyColOptions"
92
+ />
93
+ </FormField>
94
+
95
+ <FormField
96
+ v-if="layout === 'scroll'"
97
+ label="Wide table"
98
+ fake
99
+ description="Adds extra columns so horizontal scroll is easier to see."
100
+ >
101
+ <label>
102
+ <input v-model="wideTable" type="checkbox" />
103
+ Show extended columns
104
+ </label>
105
+ </FormField>
106
+ </FormLayout>
107
+
108
+ <div class="table-events">
109
+ <h4>Events</h4>
110
+ <p v-if="lastRowClick" class="event-line">
111
+ <strong>Row click:</strong>
112
+ index {{ lastRowClick.index }}, name “{{ lastRowClick.row.name }}”
113
+ </p>
114
+ <p v-else class="event-line subtle">Row click: enable “Row click” above, then click a row to open the detail dialog.</p>
115
+
116
+ <p v-if="lastQuery" class="event-line">
117
+ <strong>Query:</strong>
118
+ page {{ lastQuery.page }},
119
+ sort {{ lastQuery.sortBy ?? 'none' }} {{ lastQuery.sortDir }},
120
+ {{ activeFilterCount }} active filter(s)
121
+ </p>
122
+ <p v-else class="event-line subtle">Query: waiting for the first table update…</p>
123
+ </div>
124
+ </Section>
125
+
126
+ <dialog
127
+ ref="rowDialogRef"
128
+ class="row-detail-dialog"
129
+ @click="onRowDialogBackdropClick"
130
+ >
131
+ <div v-if="lastRowClick" class="dialog-panel" @click.stop>
132
+ <h3>{{ lastRowClick.row.name }}</h3>
133
+ <p class="subtle">Row index {{ lastRowClick.index }} in the current filtered and sorted result set.</p>
134
+
135
+ <dl>
136
+ <dt>Index</dt>
137
+ <dd>{{ lastRowClick.index }}</dd>
138
+
139
+ <template v-for="header in headers" :key="header.key">
140
+ <dt>{{ header.label || header.key }}</dt>
141
+ <dd>
142
+ <span v-if="header.key === 'status'" class="tag fg-good">
143
+ {{ lastRowClick.row[header.key] }}
144
+ </span>
145
+ <template v-else>
146
+ {{ lastRowClick.row[header.key] }}
147
+ </template>
148
+ </dd>
149
+ </template>
150
+ </dl>
151
+
152
+ <form method="dialog">
153
+ <button type="submit" class="neutral">Close</button>
154
+ </form>
155
+ </div>
156
+ </dialog>
29
157
  </template>
30
158
 
31
159
  <script setup>
32
- import { ref, onMounted } from 'vue'
160
+ import { ref, computed, onMounted } from 'vue'
161
+ import { TableIcon, Settings01Icon } from '@hugeicons/core-free-icons'
33
162
  import { getExampleTableRows } from '../data/examplePeople.js'
163
+ import { countActiveFilters } from '../composables/tableFilters.js'
164
+
165
+ const defaultFeatures = ['pagination', 'filters', 'columnOptions', 'rowClick']
34
166
 
35
- const headers = ref([
36
- { key: 'name', label: 'Name', sortable: true, filterable: true, filterType: 'text', hidden: false, width: '20%' },
167
+ const defaultColumnVisibility = {
168
+ birthYear: false,
169
+ }
170
+
171
+ const enabledFeatures = ref([...defaultFeatures])
172
+ const columnVisibility = ref({ ...defaultColumnVisibility })
173
+ const layout = ref('default')
174
+ const stickyColsOption = ref('2')
175
+ const wideTable = ref(true)
176
+ const dataConnected = ref(true)
177
+ const data = ref([])
178
+ const lastRowClick = ref(null)
179
+ const lastQuery = ref(null)
180
+ const rowDialogRef = ref(null)
181
+
182
+ const featureOptions = [
183
+ { value: 'pagination', label: 'Pagination' },
184
+ { value: 'filters', label: 'Column filters' },
185
+ { value: 'columnOptions', label: 'Column options' },
186
+ { value: 'rowClick', label: 'Row click' },
187
+ { value: 'loading', label: 'Loading state' },
188
+ ]
189
+
190
+ const layoutOptions = [
191
+ { value: 'default', label: 'Default' },
192
+ { value: 'priorities', label: 'Column priorities' },
193
+ { value: 'scroll', label: 'Horizontal scroll' },
194
+ ]
195
+
196
+ const stickyColOptions = [
197
+ { value: 'none', label: 'None' },
198
+ { value: '1', label: '1 column' },
199
+ { value: '2', label: '2 columns' },
200
+ { value: '3', label: '3 columns' },
201
+ ]
202
+
203
+ const baseHeaders = [
204
+ { key: 'name', label: 'Name', sortable: true, filterable: true, filterType: 'text', width: '20%' },
37
205
  { key: 'age', label: 'Age', sortable: true, filterable: true, filterType: 'number' },
206
+ { key: 'birthYear', label: 'Birth year', sortable: true, filterable: true, filterType: 'number' },
38
207
  { key: 'city', label: 'City', sortable: true, filterable: true, filterType: 'select', width: '200px' },
39
- ])
208
+ ]
40
209
 
41
- const data = ref([])
210
+ const extendedHeaders = [
211
+ { key: 'email', label: 'Email', sortable: true, filterable: true, filterType: 'text' },
212
+ { key: 'department', label: 'Department', sortable: true, filterable: true, filterType: 'select' },
213
+ { key: 'role', label: 'Role', sortable: true, filterable: true, filterType: 'text' },
214
+ { key: 'status', label: 'Status', sortable: true, filterable: true, filterType: 'select' },
215
+ ]
216
+
217
+ const priorityByKey = {
218
+ age: 2,
219
+ birthYear: 3,
220
+ city: 1,
221
+ department: 2,
222
+ role: 1,
223
+ status: 1,
224
+ }
225
+
226
+ const stickyCols = computed(() => {
227
+ if (layout.value !== 'scroll' || stickyColsOption.value === 'none') {
228
+ return null
229
+ }
230
+ return Number(stickyColsOption.value)
231
+ })
232
+
233
+ const useExtendedColumns = computed(() => layout.value === 'scroll' && wideTable.value)
234
+
235
+ const headers = computed(() => {
236
+ const columns = useExtendedColumns.value
237
+ ? [...baseHeaders, ...extendedHeaders]
238
+ : [...baseHeaders]
239
+
240
+ if (layout.value !== 'priorities') {
241
+ return columns
242
+ }
243
+
244
+ return columns.map((header) => {
245
+ const colPriority = priorityByKey[header.key]
246
+ if (!colPriority) {
247
+ return { ...header }
248
+ }
249
+ return { ...header, colPriority }
250
+ })
251
+ })
252
+
253
+ const tableData = computed(() => {
254
+ if (!dataConnected.value) {
255
+ return []
256
+ }
257
+
258
+ const birthYear = new Date().getFullYear()
259
+
260
+ return data.value.map((row) => {
261
+ const withBirthYear = {
262
+ ...row,
263
+ birthYear: birthYear - row.age,
264
+ }
265
+
266
+ if (!useExtendedColumns.value) {
267
+ return withBirthYear
268
+ }
269
+
270
+ return {
271
+ ...withBirthYear,
272
+ email: `${String(row.name).toLowerCase()}@example.com`,
273
+ department: row.age >= 30 ? 'Operations' : 'Engineering',
274
+ role: row.age >= 35 ? 'Manager' : 'Member',
275
+ status: row.age % 2 === 0 ? 'Active' : 'Pending',
276
+ }
277
+ })
278
+ })
279
+
280
+ const activeFilterCount = computed(() => {
281
+ if (!lastQuery.value?.filters) {
282
+ return 0
283
+ }
284
+ return countActiveFilters(lastQuery.value.filters, headers.value, tableData.value)
285
+ })
286
+
287
+ const tableListeners = computed(() => {
288
+ const listeners = {
289
+ onQueryChange: handleQueryChange,
290
+ }
291
+ if (enabledFeatures.value.includes('rowClick')) {
292
+ listeners.onRowClick = handleRowClick
293
+ }
294
+ return listeners
295
+ })
296
+
297
+ function handleRowClick(payload) {
298
+ lastRowClick.value = payload
299
+ rowDialogRef.value?.showModal()
300
+ }
301
+
302
+ function onRowDialogBackdropClick(event) {
303
+ if (event.target === rowDialogRef.value) {
304
+ rowDialogRef.value.close()
305
+ }
306
+ }
307
+
308
+ function handleQueryChange(query) {
309
+ lastQuery.value = query
310
+ }
311
+
312
+ function resetControls() {
313
+ enabledFeatures.value = [...defaultFeatures]
314
+ columnVisibility.value = { ...defaultColumnVisibility }
315
+ layout.value = 'default'
316
+ stickyColsOption.value = '2'
317
+ wideTable.value = true
318
+ dataConnected.value = true
319
+ lastRowClick.value = null
320
+ if (rowDialogRef.value?.open) {
321
+ rowDialogRef.value.close()
322
+ }
323
+ }
42
324
 
43
325
  onMounted(() => {
44
326
  data.value = getExampleTableRows()
45
327
  })
46
328
  </script>
329
+
330
+ <style scoped>
331
+ .table-events {
332
+ margin-top: 1.5rem;
333
+ }
334
+
335
+ .table-data-actions {
336
+ display: flex;
337
+ flex-wrap: wrap;
338
+ gap: 0.5rem;
339
+ }
340
+
341
+ .table-events h4 {
342
+ margin-top: 0;
343
+ }
344
+
345
+ .event-line {
346
+ margin: 0.35rem 0;
347
+ }
348
+
349
+ .row-detail-dialog .dialog-panel h3 {
350
+ margin: 0 0 0.5rem;
351
+ }
352
+
353
+ .row-detail-dialog .dialog-panel p {
354
+ margin: 0 0 1rem;
355
+ }
356
+
357
+ .row-detail-dialog dl {
358
+ margin: 0 0 1rem;
359
+ }
360
+
361
+ .row-detail-dialog form {
362
+ margin: 0;
363
+ }
364
+ </style>
@@ -1,14 +1,17 @@
1
1
  <template>
2
2
  <Section
3
3
  title="Remote table"
4
- subtitle="Server-style pagination with fetchRows. Filters, sort, and page changes are sent to the callback. Right-click or long-press a column header to filter."
4
+ :icon="CloudServerIcon"
5
+ subtitle="Server-style pagination with fetchRows. Filter queries, sort, and page changes are sent to the callback. Right-click or long-press a column header to filter. Layout save/load uses server callbacks instead of localStorage."
5
6
  :padding="false"
6
7
  >
7
8
  <p v-if="lastQuerySummary" class="subtle query-summary">{{ lastQuerySummary }}</p>
8
9
 
9
10
  <Table
11
+ row-key="name"
10
12
  :headers="headers"
11
13
  :fetch-rows="fetchRows"
14
+ :layout-presets="layoutPresets"
12
15
  :show-pagination="true"
13
16
  :remote-debounce-ms="250"
14
17
  />
@@ -17,8 +20,13 @@
17
20
 
18
21
  <script setup>
19
22
  import { ref } from 'vue'
23
+ import { CloudServerIcon } from '@hugeicons/core-free-icons'
20
24
  import { getExampleTableRows } from '../data/examplePeople.js'
21
- import { applyFilters, sortRows } from '../composables/tableFilters.js'
25
+ import {
26
+ applyFilterQuery,
27
+ countActiveFilterQuery,
28
+ } from '../composables/tableFilterQuery.js'
29
+ import { sortRows } from '../composables/tableFilters.js'
22
30
 
23
31
  const allRows = getExampleTableRows()
24
32
 
@@ -41,7 +49,107 @@ const headers = ref([
41
49
 
42
50
  const lastQuerySummary = ref('')
43
51
 
52
+ const remoteLayoutStore = ref({
53
+ defaultPresetId: null,
54
+ presets: [],
55
+ })
56
+
57
+ let remotePresetCounter = 0
58
+
59
+ function createRemotePresetId() {
60
+ remotePresetCounter += 1
61
+ return `remote-preset-${remotePresetCounter}`
62
+ }
63
+
64
+ const layoutPresets = {
65
+ async list() {
66
+ return {
67
+ presets: remoteLayoutStore.value.presets.map((preset) => ({ ...preset })),
68
+ defaultPresetId: remoteLayoutStore.value.defaultPresetId,
69
+ }
70
+ },
71
+
72
+ async getDefault() {
73
+ const { defaultPresetId, presets } = remoteLayoutStore.value
74
+ if (!defaultPresetId) {
75
+ return { ok: true, preset: null }
76
+ }
77
+
78
+ const preset = presets.find((item) => item.id === defaultPresetId) ?? null
79
+ return { ok: true, preset: preset ? { ...preset } : null }
80
+ },
81
+
82
+ async save({ name, state, setAsDefault = false }) {
83
+ const trimmedName = String(name ?? '').trim()
84
+ if (!trimmedName) {
85
+ return { ok: false, error: 'Preset name is required.' }
86
+ }
87
+
88
+ const preset = {
89
+ id: createRemotePresetId(),
90
+ name: trimmedName,
91
+ savedAt: new Date().toISOString(),
92
+ state: JSON.parse(JSON.stringify(state)),
93
+ }
94
+
95
+ remoteLayoutStore.value.presets = [...remoteLayoutStore.value.presets, preset]
96
+ if (setAsDefault) {
97
+ remoteLayoutStore.value.defaultPresetId = preset.id
98
+ }
99
+
100
+ console.info('[remote table] saved layout preset', preset)
101
+ return {
102
+ ok: true,
103
+ preset,
104
+ defaultPresetId: remoteLayoutStore.value.defaultPresetId,
105
+ }
106
+ },
107
+
108
+ async load(presetId) {
109
+ const preset = remoteLayoutStore.value.presets.find((item) => item.id === presetId)
110
+ if (!preset) {
111
+ return { ok: false, error: 'Preset not found.' }
112
+ }
113
+
114
+ console.info('[remote table] loaded layout preset', preset)
115
+ return { ok: true, preset: { ...preset } }
116
+ },
117
+
118
+ async delete(presetId) {
119
+ const nextPresets = remoteLayoutStore.value.presets.filter((item) => item.id !== presetId)
120
+ if (nextPresets.length === remoteLayoutStore.value.presets.length) {
121
+ return { ok: false, error: 'Preset not found.' }
122
+ }
123
+
124
+ remoteLayoutStore.value.presets = nextPresets
125
+ if (remoteLayoutStore.value.defaultPresetId === presetId) {
126
+ remoteLayoutStore.value.defaultPresetId = null
127
+ }
128
+
129
+ console.info('[remote table] deleted layout preset', presetId)
130
+ return { ok: true, defaultPresetId: remoteLayoutStore.value.defaultPresetId }
131
+ },
132
+
133
+ async setDefault(presetId) {
134
+ if (presetId === null) {
135
+ remoteLayoutStore.value.defaultPresetId = null
136
+ return { ok: true, defaultPresetId: null }
137
+ }
138
+
139
+ const preset = remoteLayoutStore.value.presets.find((item) => item.id === presetId)
140
+ if (!preset) {
141
+ return { ok: false, error: 'Preset not found.' }
142
+ }
143
+
144
+ remoteLayoutStore.value.defaultPresetId = presetId
145
+ console.info('[remote table] set default layout preset', presetId)
146
+ return { ok: true, defaultPresetId: presetId }
147
+ },
148
+ }
149
+
44
150
  async function fetchRows(query, { signal } = {}) {
151
+ console.log('[remote table] filterQuery', query.filterQuery)
152
+
45
153
  await new Promise((resolve, reject) => {
46
154
  const timer = window.setTimeout(resolve, 200)
47
155
  if (!signal) {
@@ -58,12 +166,12 @@ async function fetchRows(query, { signal } = {}) {
58
166
  signal.addEventListener('abort', onAbort, { once: true })
59
167
  })
60
168
 
61
- const filtered = applyFilters(allRows, headers.value, query.filters)
169
+ const filtered = applyFilterQuery(allRows, headers.value, query.filterQuery)
62
170
  const sorted = sortRows(filtered, query.sortBy, query.sortDir)
63
171
  const start = (query.page - 1) * query.pageSize
64
172
  const rows = sorted.slice(start, start + query.pageSize)
65
173
 
66
- lastQuerySummary.value = `Last query: page ${query.page}, ${Object.keys(query.filters ?? {}).length} active filter(s), ${sorted.length} matching row(s).`
174
+ lastQuerySummary.value = `Last query: page ${query.page}, ${countActiveFilterQuery(query.filterQuery, headers.value, allRows)} active filter(s), ${sorted.length} matching row(s).`
67
175
 
68
176
  return {
69
177
  rows,
@@ -4,32 +4,27 @@
4
4
  subtitle="Drop-in themes layered on Femtocrank — not part of the Vite CSS/JS bundle"
5
5
  >
6
6
  <p>
7
- Themes live under <code>public/themes/&lt;name&gt;/theme.css</code>. Drop a folder
7
+ Themes live under <code>public/themes/&lt;name&gt;/theme.css</code> (app themes) and
8
+ optionally <code>supplemental-themes/</code> (Catppuccin, Dracula / Alucard, Gruvbox, waffles). Drop a folder
8
9
  in and restart the dev server (or rebuild); PicoCrank discovers themes from
9
- <code>index.json</code> and applies them with <code>useCustomTheme()</code>.
10
+ <code>index.json</code> and applies them with <code>useCustomTheme()</code> or
11
+ <code>ThemeSwitcher</code>. Supplemental themes are bundled by default but only
12
+ listed when <code>include-supplemental-themes</code> is enabled.
10
13
  </p>
11
14
 
12
- <label class="theme-picker">
13
- <span>Active theme</span>
14
- <select :value="themePreference" @change="onThemeChange">
15
- <option value="">Default (Femtocrank)</option>
16
- <option
17
- v-for="name in availableThemes"
18
- :key="name"
19
- :value="name"
20
- >
21
- {{ name }}
22
- </option>
23
- </select>
24
- </label>
15
+ <ThemeSwitcher
16
+ class="theme-switcher-block"
17
+ :include-supplemental-themes="true"
18
+ @change="onThemeChange"
19
+ />
25
20
 
26
21
  <p v-if="availableThemes.length === 0" class="subtle">
27
22
  No drop-in themes found. Add <code>public/themes/&lt;name&gt;/theme.css</code>
28
- and restart Vite.
23
+ or enable supplemental themes on <code>ThemeSwitcher</code>.
29
24
  </p>
30
25
  <p v-else class="subtle">
31
26
  Preference is stored in localStorage and applies across the whole examples site.
32
- Light/dark mode (header sun/moon) is separate from drop-in themes.
27
+ Light/dark mode (header sun/moon) switches Femtocrank defaults, or the dual-variant supplemental themes when active.
33
28
  </p>
34
29
  </Section>
35
30
 
@@ -118,32 +113,23 @@
118
113
 
119
114
  <script setup>
120
115
  import Section from '../components/Section.vue'
116
+ import ThemeSwitcher from '../components/ThemeSwitcher.vue'
121
117
  import FormLayout from '../components/FormLayout.vue'
122
118
  import FormField from '../components/FormField.vue'
123
119
  import { useCustomTheme } from '../composables/useCustomTheme.js'
124
120
 
125
- const { availableThemes, themePreference, setTheme, discoverThemes } = useCustomTheme()
121
+ const { availableThemes } = useCustomTheme()
126
122
 
127
- discoverThemes()
128
-
129
- function onThemeChange(event) {
130
- setTheme(event.target.value)
123
+ function onThemeChange() {
124
+ // ThemeSwitcher updates shared useCustomTheme state.
131
125
  }
132
126
  </script>
133
127
 
134
128
  <style scoped>
135
- .theme-picker {
136
- display: flex;
137
- flex-wrap: wrap;
138
- align-items: center;
139
- gap: 0.75rem;
129
+ .theme-switcher-block {
140
130
  margin: 1rem 0;
141
131
  }
142
132
 
143
- .theme-picker select {
144
- min-width: 14rem;
145
- }
146
-
147
133
  .preview-row {
148
134
  display: flex;
149
135
  flex-wrap: wrap;