@plutocms/pluto 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.13.0](https://github.com/plutocms/pluto/compare/v0.12.0...v0.13.0) (2026-10-18)
5
+
6
+
7
+ ### Features
8
+
9
+ * **admin:** mount the aside once and keep it mounted on mobile ([#158](https://github.com/plutocms/pluto/issues/158)) ([dcca464](https://github.com/plutocms/pluto/commit/dcca464ead4ec5f46b2c26c0eea0f94260553ac9))
10
+ * **content:** page and virtualize long content lists ([#154](https://github.com/plutocms/pluto/issues/154)) ([b8d35b0](https://github.com/plutocms/pluto/commit/b8d35b05230169713a3e7cacbdfd95f52c72936a))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **admin:** darken the navbar and sidebar in dark mode ([#159](https://github.com/plutocms/pluto/issues/159)) ([011fd2f](https://github.com/plutocms/pluto/commit/011fd2f616daf961486e99f69a0e8f0f0db5e51f))
16
+ * **admin:** make the navbar near black in light mode too ([#160](https://github.com/plutocms/pluto/issues/160)) ([f39656a](https://github.com/plutocms/pluto/commit/f39656a5b52081c05e8128b4d0828970b96a14e5))
17
+ * **admin:** use a dim dark gray instead of pitch black in dark mode ([#156](https://github.com/plutocms/pluto/issues/156)) ([500c900](https://github.com/plutocms/pluto/commit/500c900783980d760d6fade406dfc15334a61a4e))
18
+ * **admin:** use the theme text color in the modal ([#157](https://github.com/plutocms/pluto/issues/157)) ([e73c613](https://github.com/plutocms/pluto/commit/e73c613eead017fabbae66ef6f6a7624ec5a5a04))
19
+
4
20
  ## [0.12.0](https://github.com/plutocms/pluto/compare/v0.11.0...v0.12.0) (2026-10-17)
5
21
 
6
22
 
@@ -9,6 +9,10 @@
9
9
  * - `[data-pluto-admin-scope]`: for admin chrome that also shows on the
10
10
  * public site, such as the admin navbar for a logged-in admin.
11
11
  *
12
+ * `[data-pluto-admin-dark]` takes the dark tokens in both color modes. The
13
+ * admin navbar is near black in light mode too, so its text and buttons need
14
+ * the dark values.
15
+ *
12
16
  * Every value is a literal. The consumer app styles its public site through
13
17
  * its own app.config colors and @theme. Both reach the admin unless the admin
14
18
  * pins its own values: app.config colors set Nuxt UI's --ui-color-* scales on
@@ -149,17 +153,18 @@
149
153
  --ui-chart-5: oklch(58.8% 0.158 241.966); /* sky-600 */
150
154
  --ui-chart-6: oklch(53.2% 0.157 131.589); /* lime-700 */
151
155
 
152
- /* Surfaces */
156
+ /* Surfaces. The navbar is near black in both modes. */
153
157
  --pluto-admin-canvas: #fff;
154
158
  --pluto-admin-sidebar: oklch(98.5% 0 none);
155
- --pluto-admin-navbar: #fff;
159
+ --pluto-admin-navbar: oklch(14.1% 0.005 285.823);
156
160
 
157
161
  --ui-radius: 0.375rem;
158
162
  --font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
159
163
  }
160
164
 
161
165
  :root[data-pluto-admin].dark,
162
- .dark [data-pluto-admin-scope] {
166
+ .dark [data-pluto-admin-scope],
167
+ [data-pluto-admin-dark] {
163
168
  --ui-primary: var(--ui-color-primary-400);
164
169
  --ui-secondary: var(--ui-color-secondary-400);
165
170
  --ui-success: var(--ui-color-success-400);
@@ -173,7 +178,7 @@
173
178
  --ui-text: var(--ui-color-neutral-200);
174
179
  --ui-text-highlighted: #fff;
175
180
  --ui-text-inverted: var(--ui-color-neutral-900);
176
- --ui-bg: var(--ui-color-neutral-900);
181
+ --ui-bg: oklch(24% 0.006 286);
177
182
  --ui-bg-muted: var(--ui-color-neutral-800);
178
183
  --ui-bg-elevated: var(--ui-color-neutral-800);
179
184
  --ui-bg-accented: var(--ui-color-neutral-700);
@@ -190,10 +195,14 @@
190
195
  --ui-chart-5: oklch(58.8% 0.158 241.966); /* sky-600 */
191
196
  --ui-chart-6: oklch(64.8% 0.2 131.684); /* lime-600 */
192
197
 
193
- /* Cards (--ui-bg, zinc-900) sit one step above the canvas (zinc-950). */
194
- --pluto-admin-canvas: oklch(14.1% 0.005 285.823);
195
- --pluto-admin-sidebar: oklch(14.1% 0.005 285.823);
198
+ /*
199
+ * Four steps, darkest at the top: a near-black navbar, a darker sidebar,
200
+ * a dim dark gray canvas (not pitch black), and cards (--ui-bg) one step
201
+ * above the canvas.
202
+ */
196
203
  --pluto-admin-navbar: oklch(14.1% 0.005 285.823);
204
+ --pluto-admin-sidebar: oklch(17.5% 0.0055 286);
205
+ --pluto-admin-canvas: oklch(21% 0.006 285.885);
197
206
  }
198
207
 
199
208
  :root[data-pluto-admin] body {
@@ -48,7 +48,31 @@ function editPath(id: string | number) {
48
48
  return contentType.value?.editPath?.(id) ?? `${basePath.value}/${id}`
49
49
  }
50
50
 
51
- const { items, pending, error, refresh, remove } = usePlutoContentList(props.type)
51
+ // Load the list 50 rows at a time. The table is virtualized, so only the
52
+ // visible rows render, and the next page loads when the user scrolls near
53
+ // the end of the table (or of the card list, on mobile).
54
+ const PAGE_SIZE = 50
55
+ const { items, total, pending, loadingMore, hasMore, error, refresh, loadMore, remove } = usePlutoContentList(
56
+ props.type,
57
+ { pageSize: PAGE_SIZE }
58
+ )
59
+
60
+ const tableRef = useTemplateRef<{
61
+ $el: HTMLElement
62
+ }>('table')
63
+ const mobileListRef = useTemplateRef<HTMLElement>('mobileList')
64
+
65
+ function loadMoreSafely() {
66
+ // A failure lands in `error`, and the alert offers "Try again".
67
+ loadMore().catch(() => {})
68
+ }
69
+
70
+ // Stop while an error shows, so a failing page does not load in a loop.
71
+ const canLoadMore = () => hasMore.value && !error.value
72
+ useInfiniteScroll(() => tableRef.value?.$el, loadMoreSafely, { distance: 240, canLoadMore })
73
+ useInfiniteScroll(mobileListRef, loadMoreSafely, { distance: 240, canLoadMore })
74
+
75
+ const number = new Intl.NumberFormat()
52
76
 
53
77
  // What to show while the list has no rows. With rows, the list shows
54
78
  // them, and a failed refresh shows an alert above them instead.
@@ -119,7 +143,7 @@ async function confirmDelete() {
119
143
  </script>
120
144
 
121
145
  <template>
122
- <PlutoViewAdminPanel v-if="contentType">
146
+ <PlutoViewAdminPanel v-if="contentType" fill>
123
147
  <template #toolbar>
124
148
  <PlutoViewToolbar :title="contentType.labelPlural">
125
149
  <template #trailing>
@@ -176,8 +200,12 @@ async function confirmDelete() {
176
200
  icon="lucide:circle-alert"
177
201
  />
178
202
 
179
- <div v-if="!emptyState" class="grid gap-3 lg:hidden">
180
- <UCard v-for="item in items" :key="item.id">
203
+ <div
204
+ v-if="!emptyState"
205
+ ref="mobileList"
206
+ class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto lg:hidden"
207
+ >
208
+ <UCard v-for="item in items" :key="item.id" class="shrink-0">
181
209
  <div class="flex flex-col gap-4">
182
210
  <div class="min-w-0">
183
211
  <NuxtLink
@@ -222,15 +250,18 @@ async function confirmDelete() {
222
250
  </UCard>
223
251
  </div>
224
252
 
225
- <UCard v-if="!emptyState" :ui="{ body: 'sm:p-0 p-0' }" class="hidden lg:block">
226
- <div class="overflow-x-auto">
227
- <UTable
228
- :data="items"
229
- :columns="columns"
230
- :loading="pending"
231
- empty="No items yet."
232
- >
233
- <template #actions-cell="{ row }">
253
+ <UTable
254
+ v-if="!emptyState"
255
+ ref="table"
256
+ :data="items"
257
+ :columns="columns"
258
+ :loading="pending || loadingMore"
259
+ class="ring-default hidden min-h-0 flex-1 rounded-lg ring lg:block"
260
+ empty="No items yet."
261
+ sticky
262
+ virtualize
263
+ >
264
+ <template #actions-cell="{ row }">
234
265
  <div class="flex justify-end gap-3">
235
266
  <NuxtLink
236
267
  v-if="canWrite"
@@ -249,10 +280,15 @@ async function confirmDelete() {
249
280
  Remove
250
281
  </button>
251
282
  </div>
252
- </template>
253
- </UTable>
254
- </div>
255
- </UCard>
283
+ </template>
284
+ </UTable>
285
+
286
+ <p v-if="!emptyState" class="text-muted shrink-0 text-xs tabular-nums" aria-live="polite">
287
+ Showing {{ number.format(items.length) }}
288
+ <template v-if="total !== undefined">of {{ number.format(total) }}</template>
289
+ {{ contentType.labelPlural.toLowerCase() }}
290
+ <template v-if="loadingMore">· Loading more…</template>
291
+ </p>
256
292
  </PlutoViewAdminPanel>
257
293
 
258
294
  <PlutoViewAdminPanel v-else>
@@ -3,11 +3,14 @@
3
3
  <template>
4
4
  <!--
5
5
  `data-pluto-admin-scope` keeps the admin theme on this bar when it shows
6
- on the public site for a logged-in admin. See admin-theme.css.
6
+ on the public site for a logged-in admin. The bar is near black in both
7
+ color modes, so `data-pluto-admin-dark` gives it the dark tokens.
8
+ See admin-theme.css.
7
9
  -->
8
10
  <nav
9
11
  class="text-default fixed inset-0 z-50 h-(--ui-header-height) shrink-0 border-b border-default bg-(--pluto-admin-navbar)"
10
12
  data-pluto-admin-scope
13
+ data-pluto-admin-dark
11
14
  >
12
15
  <slot />
13
16
  </nav>
@@ -11,10 +11,17 @@ const props = withDefaults(
11
11
  * resizable panel. The panel width persists across visits.
12
12
  */
13
13
  asideOpen?: boolean
14
+ /**
15
+ * Fill the main region's height, and let the content scroll inside its
16
+ * own region, not the whole page. For a view with one long scrolling
17
+ * list, such as a virtualized table. Default false.
18
+ */
19
+ fill?: boolean
14
20
  }>(),
15
21
  {
16
22
  width: 'container',
17
23
  asideOpen: false,
24
+ fill: false,
18
25
  }
19
26
  )
20
27
 
@@ -86,7 +93,7 @@ const items = computed<SplitterItem[]>(() => [
86
93
  @dragging="onDragging"
87
94
  >
88
95
  <template #main>
89
- <main class="min-h-0 w-full overflow-y-auto">
96
+ <main :class="fill ? 'flex flex-col overflow-hidden' : 'overflow-y-auto'" class="min-h-0 w-full">
90
97
  <!--
91
98
  'container' matches AdminView's own padding/gap classes, so a view
92
99
  moving from AdminView to this panel looks the same. 'full' adds
@@ -95,6 +102,7 @@ const items = computed<SplitterItem[]>(() => [
95
102
  -->
96
103
  <UContainer
97
104
  v-if="width === 'container'"
105
+ :class="{ 'h-full min-h-0': fill }"
98
106
  class="flex max-w-300 flex-col gap-y-6 px-4 py-6 sm:px-6 lg:gap-y-8 lg:px-8 lg:py-10"
99
107
  >
100
108
  <slot />
@@ -26,11 +26,11 @@ function close() {
26
26
  open.value = false
27
27
  }
28
28
 
29
- // Open the slideover on mobile only. A `lg:hidden` class alone is not
30
- // enough: an open slideover is a modal dialog, so it disables pointer
31
- // events on the rest of the page even while it is hidden. On desktop, the
32
- // page then stops taking clicks as soon as the aside opens. It also mounts
33
- // the slot a second time.
29
+ // Render the plain aside on desktop only, and the slideover on mobile only,
30
+ // so the slot mounts once. CSS classes (`hidden lg:flex`, `lg:hidden`) are
31
+ // not enough: they hide a copy, but the copy stays mounted and fetches its
32
+ // own data. An open slideover is also a modal dialog. It disables pointer
33
+ // events on the rest of the page even while CSS hides it.
34
34
  const isDesktop = useMediaQuery('(min-width: 64rem)')
35
35
  const resizable = inject(plutoViewAsideResizableKey, ref(false))
36
36
  const slideoverOpen = computed({
@@ -47,10 +47,10 @@ const slideoverOpen = computed({
47
47
  width and its handle draws the border. Elsewhere, keep the fixed width.
48
48
  -->
49
49
  <aside
50
- v-if="open"
50
+ v-if="open && isDesktop"
51
51
  :style="resizable ? undefined : { width: `${width}px` }"
52
52
  :class="resizable ? 'w-full' : 'border-default shrink-0 border-s'"
53
- class="hidden h-full flex-col lg:flex"
53
+ class="flex h-full flex-col"
54
54
  >
55
55
  <div class="border-default flex h-12 shrink-0 items-center justify-between gap-1.5 border-b px-4">
56
56
  <h2 class="text-highlighted truncate text-sm font-semibold">{{ title }}</h2>
@@ -67,9 +67,16 @@ const slideoverOpen = computed({
67
67
  </div>
68
68
  </aside>
69
69
 
70
+ <!--
71
+ `unmount-on-hide="false"` keeps the slot mounted after the first open,
72
+ so a reopen keeps the widget state (an open picker, a typed search) and
73
+ does not fetch the field options again.
74
+ -->
70
75
  <USlideover
76
+ v-if="!isDesktop"
71
77
  v-model:open="slideoverOpen"
72
78
  :title="title"
79
+ :unmount-on-hide="false"
73
80
  side="right"
74
81
  >
75
82
  <template #body>
@@ -40,7 +40,7 @@ const resolvedClasses = computed(() => {
40
40
  my-2 mx-2 sm:my-4 sm:mx-4
41
41
  shadow-2xl
42
42
  bg-default
43
- light:text-zinc-800 dark:text-white
43
+ text-default
44
44
  `
45
45
 
46
46
  return twMerge([
@@ -1,3 +1,13 @@
1
+ export interface PlutoContentListOptions {
2
+ /**
3
+ * Rows per request. Set it to load the list page by page: the first page
4
+ * on creation, the next pages through `loadMore()`. Leave it unset for one
5
+ * request that returns every row (an adapter can cap that request; the
6
+ * Supabase adapter returns at most 1000 rows).
7
+ */
8
+ pageSize?: number
9
+ }
10
+
1
11
  /**
2
12
  * Fetches one content type's list of items, for the generic list page
3
13
  * (`PlutoContentList`).
@@ -15,25 +25,86 @@
15
25
  * of `await`ing the composable's own return value through a hand-rolled
16
26
  * `.then()`.
17
27
  */
18
- export function usePlutoContentList(typeName: string) {
28
+ export function usePlutoContentList(typeName: string, options: PlutoContentListOptions = {}) {
29
+ const { pageSize } = options
30
+ const url = `/api/_pluto/content/${typeName}`
31
+
19
32
  const items = ref<PlutoContentItem[]>([])
20
33
  const total = ref<number | undefined>(undefined)
21
34
  const pending = ref(false)
35
+ const loadingMore = ref(false)
36
+ /** `true` while more pages exist. Always `false` without `pageSize`. */
37
+ const hasMore = ref(false)
22
38
  const error = ref<unknown>(null)
23
39
 
40
+ // A refresh() while a page is in flight makes that page stale. Each fetch
41
+ // remembers the generation it started in, and drops its result when a
42
+ // newer refresh() has started since.
43
+ let generation = 0
44
+
45
+ function fetchPage(offset: number): Promise<PlutoContentListResult> {
46
+ return pageSize === undefined
47
+ ? $fetch<PlutoContentListResult>(url)
48
+ : $fetch<PlutoContentListResult>(url, { query: { limit: pageSize, offset } })
49
+ }
50
+
51
+ function updateHasMore(lastPageLength: number) {
52
+ if (pageSize === undefined) {
53
+ hasMore.value = false
54
+ return
55
+ }
56
+ // Without a total, a full page means there may be another one.
57
+ hasMore.value = total.value === undefined ? lastPageLength === pageSize : items.value.length < total.value
58
+ }
59
+
24
60
  async function refresh(): Promise<void> {
61
+ const current = ++generation
25
62
  pending.value = true
26
63
  error.value = null
27
64
 
28
65
  try {
29
- const result = await $fetch<PlutoContentListResult>(`/api/_pluto/content/${typeName}`)
66
+ const result = await fetchPage(0)
67
+ if (current !== generation) {
68
+ return
69
+ }
30
70
  items.value = result.data
31
71
  total.value = result.total
72
+ updateHasMore(result.data.length)
32
73
  } catch (caught) {
33
- error.value = caught
74
+ if (current === generation) {
75
+ error.value = caught
76
+ }
77
+ throw caught
78
+ } finally {
79
+ if (current === generation) {
80
+ pending.value = false
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Appends the next page. Does nothing without `pageSize`, without more pages, or while a fetch runs. */
86
+ async function loadMore(): Promise<void> {
87
+ if (!hasMore.value || pending.value || loadingMore.value) {
88
+ return
89
+ }
90
+ const current = generation
91
+ loadingMore.value = true
92
+
93
+ try {
94
+ const result = await fetchPage(items.value.length)
95
+ if (current !== generation) {
96
+ return
97
+ }
98
+ items.value = [...items.value, ...result.data]
99
+ total.value = result.total ?? total.value
100
+ updateHasMore(result.data.length)
101
+ } catch (caught) {
102
+ if (current === generation) {
103
+ error.value = caught
104
+ }
34
105
  throw caught
35
106
  } finally {
36
- pending.value = false
107
+ loadingMore.value = false
37
108
  }
38
109
  }
39
110
 
@@ -47,14 +118,24 @@ export function usePlutoContentList(typeName: string) {
47
118
 
48
119
  async function remove(id: string | number): Promise<void> {
49
120
  try {
50
- await $fetch(`/api/_pluto/content/${typeName}/${id}`, { method: 'DELETE' })
121
+ await $fetch(`${url}/${id}`, { method: 'DELETE' })
51
122
  } catch (caught) {
52
123
  error.value = caught
53
124
  throw caught
54
125
  }
55
126
 
56
- await refresh()
127
+ if (pageSize === undefined) {
128
+ await refresh()
129
+ return
130
+ }
131
+
132
+ // Paged: drop the row in place, so the user keeps the pages already
133
+ // loaded and the scroll position.
134
+ items.value = items.value.filter((item) => item.id !== id)
135
+ if (total.value !== undefined) {
136
+ total.value = Math.max(0, total.value - 1)
137
+ }
57
138
  }
58
139
 
59
- return { items, total, pending, error, refresh, remove }
140
+ return { items, total, pending, loadingMore, hasMore, error, refresh, loadMore, remove }
60
141
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.12.0",
4
+ "version": "0.13.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -13,7 +13,12 @@ const scales = Object.entries(roles).map(([role, hue]) => ` /* ${role}: ${hue}
13
13
  const semantic = (shade) => Object.keys(roles).filter((r) => r !== 'neutral').map((r) => ` --ui-${r}: var(--ui-color-${r}-${shade});`).join('\n')
14
14
  const n = (s) => `var(--ui-color-neutral-${s})`
15
15
  const neutralLight = { 'text-dimmed': n(400), 'text-muted': n(500), 'text-toned': n(600), 'text': n(700), 'text-highlighted': n(900), 'text-inverted': '#fff', 'bg': '#fff', 'bg-muted': n(50), 'bg-elevated': n(100), 'bg-accented': n(200), 'bg-inverted': n(900), 'border': n(200), 'border-muted': n(200), 'border-accented': n(300), 'border-inverted': n(900) }
16
- const neutralDark = { 'text-dimmed': n(500), 'text-muted': n(400), 'text-toned': n(300), 'text': n(200), 'text-highlighted': '#fff', 'text-inverted': n(900), 'bg': n(900), 'bg-muted': n(800), 'bg-elevated': n(800), 'bg-accented': n(700), 'bg-inverted': '#fff', 'border': n(800), 'border-muted': n(700), 'border-accented': n(700), 'border-inverted': '#fff' }
16
+ // Dark cards sit one step above the zinc-900 canvas: between zinc-900 and
17
+ // zinc-800. The chart palette passes the dataviz checks on this surface.
18
+ const CARD_DARK = 'oklch(24% 0.006 286)'
19
+ // The dark sidebar sits between the zinc-950 navbar and the zinc-900 canvas.
20
+ const SIDEBAR_DARK = 'oklch(17.5% 0.0055 286)'
21
+ const neutralDark = { 'text-dimmed': n(500), 'text-muted': n(400), 'text-toned': n(300), 'text': n(200), 'text-highlighted': '#fff', 'text-inverted': n(900), 'bg': CARD_DARK, 'bg-muted': n(800), 'bg-elevated': n(800), 'bg-accented': n(700), 'bg-inverted': '#fff', 'border': n(800), 'border-muted': n(700), 'border-accented': n(700), 'border-inverted': '#fff' }
17
22
  const tokens = (o) => Object.entries(o).map(([k, val]) => ` --ui-${k}: ${val};`).join('\n')
18
23
  // Validated with the dataviz palette checks on white (light) and zinc-900
19
24
  // (dark). Re-run the checks before you change a slot.
@@ -32,6 +37,10 @@ const css = `/*
32
37
  * - \`[data-pluto-admin-scope]\`: for admin chrome that also shows on the
33
38
  * public site, such as the admin navbar for a logged-in admin.
34
39
  *
40
+ * \`[data-pluto-admin-dark]\` takes the dark tokens in both color modes. The
41
+ * admin navbar is near black in light mode too, so its text and buttons need
42
+ * the dark values.
43
+ *
35
44
  * Every value is a literal. The consumer app styles its public site through
36
45
  * its own app.config colors and @theme. Both reach the admin unless the admin
37
46
  * pins its own values: app.config colors set Nuxt UI's --ui-color-* scales on
@@ -59,27 +68,32 @@ ${tokens(neutralLight)}
59
68
 
60
69
  ${chart(chartLight)}
61
70
 
62
- /* Surfaces */
71
+ /* Surfaces. The navbar is near black in both modes. */
63
72
  --pluto-admin-canvas: #fff;
64
73
  --pluto-admin-sidebar: ${v('zinc-50')};
65
- --pluto-admin-navbar: #fff;
74
+ --pluto-admin-navbar: ${v('zinc-950')};
66
75
 
67
76
  --ui-radius: 0.375rem;
68
77
  --font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
69
78
  }
70
79
 
71
80
  :root[data-pluto-admin].dark,
72
- .dark [data-pluto-admin-scope] {
81
+ .dark [data-pluto-admin-scope],
82
+ [data-pluto-admin-dark] {
73
83
  ${semantic(400)}
74
84
 
75
85
  ${tokens(neutralDark)}
76
86
 
77
87
  ${chart(chartDark)}
78
88
 
79
- /* Cards (--ui-bg, zinc-900) sit one step above the canvas (zinc-950). */
80
- --pluto-admin-canvas: ${v('zinc-950')};
81
- --pluto-admin-sidebar: ${v('zinc-950')};
89
+ /*
90
+ * Four steps, darkest at the top: a near-black navbar, a darker sidebar,
91
+ * a dim dark gray canvas (not pitch black), and cards (--ui-bg) one step
92
+ * above the canvas.
93
+ */
82
94
  --pluto-admin-navbar: ${v('zinc-950')};
95
+ --pluto-admin-sidebar: ${SIDEBAR_DARK};
96
+ --pluto-admin-canvas: ${v('zinc-900')};
83
97
  }
84
98
 
85
99
  :root[data-pluto-admin] body {