@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 +16 -0
- package/app/assets/css/admin-theme.css +16 -7
- package/app/components/Pluto/Content/PlutoContentList.vue +53 -17
- package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +4 -1
- package/app/components/Pluto/View/PlutoViewAdminPanel.vue +9 -1
- package/app/components/Pluto/View/PlutoViewAside.vue +14 -7
- package/app/components/modal/Modal.vue +1 -1
- package/app/composables/pluto-content-list.ts +88 -7
- package/package.json +1 -1
- package/scripts/generate-admin-theme.cjs +21 -7
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:
|
|
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:
|
|
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
|
-
/*
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
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
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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.
|
|
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="
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// the
|
|
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="
|
|
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>
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
/*
|
|
80
|
-
|
|
81
|
-
|
|
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 {
|