@prenta/admin 2.7.0 → 2.8.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 +34 -0
- package/design-system/tokens.css +17 -0
- package/design-system/tokens.json +35 -1
- package/design-system/tokens.scss +15 -0
- package/design-system/tokens.tailwind.cjs +14 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +2 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
- package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
- package/dist/__tests__/lib/batch-wizard.test.js +144 -0
- package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
- package/dist/__tests__/lib/job-drawer.test.js +2 -0
- package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
- package/dist/__tests__/lib/job-view.test.js +100 -3
- package/dist/__tests__/lib/job-view.test.js.map +1 -1
- package/dist/__tests__/lib/jobs-service.test.js +56 -2
- package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.js +266 -0
- package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
- package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
- package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-list.render.test.js +7 -5
- package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
- package/dist/lib/api.d.ts +2 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +8 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/batch-wizard.d.ts +84 -0
- package/dist/lib/batch-wizard.d.ts.map +1 -0
- package/dist/lib/batch-wizard.js +122 -0
- package/dist/lib/batch-wizard.js.map +1 -0
- package/dist/lib/chrome-service.d.ts +69 -0
- package/dist/lib/chrome-service.d.ts.map +1 -0
- package/dist/lib/chrome-service.js +108 -0
- package/dist/lib/chrome-service.js.map +1 -0
- package/dist/lib/job-view.d.ts +43 -0
- package/dist/lib/job-view.d.ts.map +1 -1
- package/dist/lib/job-view.js +57 -1
- package/dist/lib/job-view.js.map +1 -1
- package/dist/lib/jobs-service.d.ts +19 -1
- package/dist/lib/jobs-service.d.ts.map +1 -1
- package/dist/lib/jobs-service.js +41 -0
- package/dist/lib/jobs-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/AppearanceChrome.d.ts +22 -4
- package/dist/views/AppearanceChrome.d.ts.map +1 -1
- package/dist/views/AppearanceChrome.js +451 -15
- package/dist/views/AppearanceChrome.js.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.js +13 -3
- package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
- package/dist/views/Jobs/DrawerReview.d.ts +7 -0
- package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
- package/dist/views/Jobs/DrawerReview.js +1 -1
- package/dist/views/Jobs/DrawerReview.js.map +1 -1
- package/dist/views/Jobs/JobDrawer.d.ts +23 -3
- package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
- package/dist/views/Jobs/JobDrawer.js +30 -5
- package/dist/views/Jobs/JobDrawer.js.map +1 -1
- package/dist/views/Jobs/JobsTable.d.ts +4 -2
- package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
- package/dist/views/Jobs/JobsTable.js +6 -5
- package/dist/views/Jobs/JobsTable.js.map +1 -1
- package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
- package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
- package/dist/views/Jobs/NewBatchJob.js +282 -0
- package/dist/views/Jobs/NewBatchJob.js.map +1 -0
- package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
- package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
- package/dist/views/Jobs/WorkerStrip.js +2 -2
- package/dist/views/Jobs/WorkerStrip.js.map +1 -1
- package/dist/views/Jobs/focus.d.ts +6 -0
- package/dist/views/Jobs/focus.d.ts.map +1 -1
- package/dist/views/Jobs/focus.js +10 -0
- package/dist/views/Jobs/focus.js.map +1 -1
- package/dist/views/Jobs.d.ts +12 -3
- package/dist/views/Jobs.d.ts.map +1 -1
- package/dist/views/Jobs.js +109 -31
- package/dist/views/Jobs.js.map +1 -1
- package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
- package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
- package/dist/views/header-footer/AnnouncementTab.js +43 -0
- package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
- package/dist/views/header-footer/ChromeField.d.ts +25 -0
- package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
- package/dist/views/header-footer/ChromeField.js +99 -0
- package/dist/views/header-footer/ChromeField.js.map +1 -0
- package/dist/views/header-footer/ChromePreview.d.ts +31 -0
- package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
- package/dist/views/header-footer/ChromePreview.js +195 -0
- package/dist/views/header-footer/ChromePreview.js.map +1 -0
- package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
- package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
- package/dist/views/header-footer/DeveloperTab.js +98 -0
- package/dist/views/header-footer/DeveloperTab.js.map +1 -0
- package/dist/views/header-footer/RegionTab.d.ts +27 -0
- package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
- package/dist/views/header-footer/RegionTab.js +80 -0
- package/dist/views/header-footer/RegionTab.js.map +1 -0
- package/dist/views/header-footer/chrome-form.d.ts +137 -0
- package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
- package/dist/views/header-footer/chrome-form.js +518 -0
- package/dist/views/header-footer/chrome-form.js.map +1 -0
- package/dist/views/header-footer/code-samples.d.ts +30 -0
- package/dist/views/header-footer/code-samples.d.ts.map +1 -0
- package/dist/views/header-footer/code-samples.js +127 -0
- package/dist/views/header-footer/code-samples.js.map +1 -0
- package/dist/views/header-footer/controls.d.ts +57 -0
- package/dist/views/header-footer/controls.d.ts.map +1 -0
- package/dist/views/header-footer/controls.js +43 -0
- package/dist/views/header-footer/controls.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +16 -2
- package/src/__tests__/lib/batch-wizard.test.ts +199 -0
- package/src/__tests__/lib/job-drawer.test.ts +2 -0
- package/src/__tests__/lib/job-view.test.ts +123 -2
- package/src/__tests__/lib/jobs-service.test.ts +72 -3
- package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
- package/src/__tests__/views/header-footer-form.test.ts +333 -0
- package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
- package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
- package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
- package/src/lib/api.ts +12 -1
- package/src/lib/batch-wizard.ts +188 -0
- package/src/lib/chrome-service.ts +200 -0
- package/src/lib/job-view.ts +95 -1
- package/src/lib/jobs-service.ts +56 -1
- package/src/styles/prenta-tokens.css +17 -0
- package/src/styles/theme.css +15 -0
- package/src/views/AppearanceChrome.tsx +704 -46
- package/src/views/Jobs/CancelJobDialog.tsx +20 -2
- package/src/views/Jobs/DrawerReview.tsx +1 -1
- package/src/views/Jobs/JobDrawer.tsx +83 -3
- package/src/views/Jobs/JobsTable.tsx +11 -4
- package/src/views/Jobs/NewBatchJob.tsx +857 -0
- package/src/views/Jobs/WorkerStrip.tsx +16 -11
- package/src/views/Jobs/focus.ts +11 -0
- package/src/views/Jobs.tsx +163 -49
- package/src/views/header-footer/AnnouncementTab.tsx +231 -0
- package/src/views/header-footer/ChromeField.tsx +358 -0
- package/src/views/header-footer/ChromePreview.tsx +497 -0
- package/src/views/header-footer/DeveloperTab.tsx +331 -0
- package/src/views/header-footer/RegionTab.tsx +398 -0
- package/src/views/header-footer/chrome-form.ts +603 -0
- package/src/views/header-footer/code-samples.ts +146 -0
- package/src/views/header-footer/controls.tsx +166 -0
- package/dist/views/Jobs/placeholders.d.ts +0 -6
- package/dist/views/Jobs/placeholders.d.ts.map +0 -1
- package/dist/views/Jobs/placeholders.js +0 -20
- package/dist/views/Jobs/placeholders.js.map +0 -1
- package/src/views/Jobs/placeholders.tsx +0 -66
package/src/lib/api.ts
CHANGED
|
@@ -82,6 +82,8 @@ export interface CmsApiResult<T> {
|
|
|
82
82
|
* narrow it themselves.
|
|
83
83
|
*/
|
|
84
84
|
body?: unknown
|
|
85
|
+
/** The response's `ETag`, for endpoints that take `If-Match` on the next write. */
|
|
86
|
+
etag?: string
|
|
85
87
|
status: number
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -118,6 +120,10 @@ export async function cmsApi<T = unknown>(
|
|
|
118
120
|
const res = await fetch(url, { ...options, headers, credentials: 'include' })
|
|
119
121
|
const json = await res.json().catch(() => ({}))
|
|
120
122
|
|
|
123
|
+
// Test doubles often stub `fetch` with a bare `{ ok, json }`.
|
|
124
|
+
const etag =
|
|
125
|
+
typeof res.headers?.get === 'function' ? (res.headers.get('ETag') ?? undefined) : undefined
|
|
126
|
+
|
|
121
127
|
if (!res.ok) {
|
|
122
128
|
return {
|
|
123
129
|
error: json.error || `Request failed (${res.status})`,
|
|
@@ -127,7 +133,12 @@ export async function cmsApi<T = unknown>(
|
|
|
127
133
|
}
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
return {
|
|
136
|
+
return {
|
|
137
|
+
data: json.data ?? json,
|
|
138
|
+
body: json,
|
|
139
|
+
status: res.status,
|
|
140
|
+
...(etag ? { etag } : {}),
|
|
141
|
+
}
|
|
131
142
|
} catch (err) {
|
|
132
143
|
return { error: err instanceof Error ? err.message : 'Network error', status: 0 }
|
|
133
144
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The New batch AI job wizard (jobs-new.jsx, TRACKING P-20): its sources,
|
|
3
|
+
* validation and request, kept apart from the component so they can be
|
|
4
|
+
* tested on their own. The server's rules are in cms-core `jobs/batch-ai.ts`
|
|
5
|
+
* and CONFLICTS.md §4 (P-16).
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
BatchAiEstimate,
|
|
9
|
+
BatchAiPayload,
|
|
10
|
+
BatchAiRequest,
|
|
11
|
+
BatchAiTextField,
|
|
12
|
+
} from '@prenta/core'
|
|
13
|
+
import { JOB_OPS } from './job-view.js'
|
|
14
|
+
|
|
15
|
+
export type BatchOperation = BatchAiPayload['operation']
|
|
16
|
+
export type BatchOp = (typeof JOB_OPS)[number]
|
|
17
|
+
|
|
18
|
+
/** Where a batch runs: a page or post collection, or the media library. */
|
|
19
|
+
export interface BatchSource {
|
|
20
|
+
/** The collection's key in the config, or `media`. */
|
|
21
|
+
id: string
|
|
22
|
+
/** The kit's Content select label ("Posts", "Media library"). */
|
|
23
|
+
label: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const MEDIA_SOURCE: BatchSource = { id: 'media', label: 'Media library' }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Documents picked in a list (P-21's "Run with AI…"). They run as they are,
|
|
30
|
+
* without the operation's "Only where" preset.
|
|
31
|
+
*/
|
|
32
|
+
export interface BatchSelection {
|
|
33
|
+
/** A collection key, or `media` for images picked in the media library. */
|
|
34
|
+
source: string
|
|
35
|
+
documentIds: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A custom instruction needs more than 8 characters, and at most 2,000. */
|
|
39
|
+
const INSTRUCTION_MIN = 9
|
|
40
|
+
export const INSTRUCTION_MAX = 2000
|
|
41
|
+
/** The most documents one batch freezes. */
|
|
42
|
+
const BATCH_MAX = 1000
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The fields a custom instruction can rewrite. `field` isn't in the kit's
|
|
46
|
+
* contract; it's the kit's "Field you choose" (CONFLICTS.md §4, P-16).
|
|
47
|
+
*/
|
|
48
|
+
export const CUSTOM_FIELDS: ReadonlyArray<{ id: BatchAiTextField; label: string }> = [
|
|
49
|
+
{ id: 'metaDescription', label: 'Meta description' },
|
|
50
|
+
{ id: 'seoTitle', label: 'SEO title' },
|
|
51
|
+
{ id: 'excerpt', label: 'Excerpt' },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
export function batchOp(id: BatchOperation): BatchOp {
|
|
55
|
+
return JOB_OPS.find((op) => op.id === id) ?? JOB_OPS[0]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ConfigCollection {
|
|
59
|
+
slug?: string
|
|
60
|
+
type?: string
|
|
61
|
+
labels?: { plural?: string }
|
|
62
|
+
admin?: { hidden?: boolean }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The Content select's options: the config's page and post collections, in
|
|
67
|
+
* config order, without hidden ones (as Settings → AI's brand voice lists
|
|
68
|
+
* them). The media library is added for alt text only.
|
|
69
|
+
*/
|
|
70
|
+
export function batchSources(config: unknown): BatchSource[] {
|
|
71
|
+
const raw = (config as { collections?: unknown } | null | undefined)?.collections
|
|
72
|
+
if (!raw || typeof raw !== 'object') return []
|
|
73
|
+
const entries: Array<[string, ConfigCollection]> = Array.isArray(raw)
|
|
74
|
+
? (raw as ConfigCollection[]).map((c) => [c.slug ?? '', c])
|
|
75
|
+
: Object.entries(raw as Record<string, ConfigCollection>)
|
|
76
|
+
return entries
|
|
77
|
+
.filter(([key, c]) => key && (c.type === 'page' || c.type === 'post') && !c.admin?.hidden)
|
|
78
|
+
.map(([key, c]) => ({
|
|
79
|
+
id: key,
|
|
80
|
+
label: c.labels?.plural ?? key.charAt(0).toUpperCase() + key.slice(1),
|
|
81
|
+
}))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** cms-core's `ADMIN_ROLES`: who may queue a batch that applies changes automatically. */
|
|
85
|
+
export function isAdminRole(role: string | null | undefined): boolean {
|
|
86
|
+
return role === 'OWNER' || role === 'ADMIN'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Alt text runs on images; every other operation on a collection. */
|
|
90
|
+
export function runsOnMedia(operation: BatchOperation): boolean {
|
|
91
|
+
return operation === 'alt'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether an operation can run on a picked selection's source. */
|
|
95
|
+
export function operationFits(
|
|
96
|
+
operation: BatchOperation,
|
|
97
|
+
selection: BatchSelection | null,
|
|
98
|
+
): boolean {
|
|
99
|
+
if (!selection) return true
|
|
100
|
+
return runsOnMedia(operation) === (selection.source === MEDIA_SOURCE.id)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Everything the wizard asks, before it becomes a request. */
|
|
104
|
+
export interface BatchDraft {
|
|
105
|
+
operation: BatchOperation
|
|
106
|
+
instruction: string
|
|
107
|
+
field: BatchAiTextField
|
|
108
|
+
/** The collection picked in the Content select (ignored for alt text). */
|
|
109
|
+
collection: string
|
|
110
|
+
statusFilter: 'published' | 'all'
|
|
111
|
+
review: 'review' | 'auto'
|
|
112
|
+
onApprove: 'draft' | 'publish'
|
|
113
|
+
selection: BatchSelection | null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The source a draft runs on: the media library for alt text, else its collection. */
|
|
117
|
+
function draftSource(draft: Pick<BatchDraft, 'operation' | 'collection'>): string {
|
|
118
|
+
return runsOnMedia(draft.operation) ? MEDIA_SOURCE.id : draft.collection
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Step 1's check: a custom instruction needs more than 8 characters (the server's message). */
|
|
122
|
+
export function instructionError(operation: BatchOperation, instruction: string): string | null {
|
|
123
|
+
if (operation !== 'custom') return null
|
|
124
|
+
return instruction.trim().length < INSTRUCTION_MIN
|
|
125
|
+
? 'A custom instruction needs more than 8 characters.'
|
|
126
|
+
: null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The request for estimate, preview and create. "Apply automatically" never
|
|
131
|
+
* publishes (the server refuses it), so it always sends `draft`.
|
|
132
|
+
*/
|
|
133
|
+
export function batchRequest(draft: BatchDraft): BatchAiRequest {
|
|
134
|
+
const instruction = draft.instruction.trim()
|
|
135
|
+
return {
|
|
136
|
+
operation: draft.operation,
|
|
137
|
+
source: draft.selection?.source ?? draftSource(draft),
|
|
138
|
+
statusFilter: draft.statusFilter,
|
|
139
|
+
review: draft.review,
|
|
140
|
+
onApprove: draft.review === 'auto' ? 'draft' : draft.onApprove,
|
|
141
|
+
instruction: instruction || null,
|
|
142
|
+
...(draft.operation === 'custom' ? { field: draft.field } : {}),
|
|
143
|
+
...(draft.selection ? { filter: { documentIds: draft.selection.documentIds } } : {}),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The fields that change which documents match, or what the AI writes. An
|
|
149
|
+
* estimate or preview is stale once its key differs from the draft's.
|
|
150
|
+
*/
|
|
151
|
+
export function estimateKey(request: BatchAiRequest): string {
|
|
152
|
+
return JSON.stringify([
|
|
153
|
+
request.operation,
|
|
154
|
+
request.source,
|
|
155
|
+
request.statusFilter,
|
|
156
|
+
request.filter?.documentIds ?? null,
|
|
157
|
+
])
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function previewKey(request: BatchAiRequest): string {
|
|
161
|
+
return JSON.stringify([estimateKey(request), request.instruction, request.field ?? null])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Why step 2 can't continue: nothing matches, or more than a batch holds. Null when it can. */
|
|
165
|
+
export function matchProblem(estimate: BatchAiEstimate): string | null {
|
|
166
|
+
if (estimate.count === 0) return 'No documents match this filter.'
|
|
167
|
+
if (estimate.count > BATCH_MAX) {
|
|
168
|
+
return `This matches ${estimate.count.toLocaleString('en-US')} documents. Narrow it to ${BATCH_MAX.toLocaleString('en-US')} or fewer.`
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The estimate tiles: "~42 min" and "~38k tokens", as the kit rounds them. */
|
|
174
|
+
export function estimateTiles(estimate: BatchAiEstimate): { minutes: string; tokens: string } {
|
|
175
|
+
return {
|
|
176
|
+
minutes: `~${estimate.minutes.toLocaleString('en-US')} min`,
|
|
177
|
+
tokens: `~${Math.round(estimate.tokens / 1000).toLocaleString('en-US')}k tokens`,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The line under the tiles. The limit sentence is left out when Settings → AI sets none. */
|
|
182
|
+
export function estimateNote(estimate: BatchAiEstimate): string {
|
|
183
|
+
const pace = 'One document per slice, about one a minute.'
|
|
184
|
+
const pct = estimate.pctOfMonthlyLimit
|
|
185
|
+
if (pct === null) return pace
|
|
186
|
+
const share = pct === 0 && estimate.tokens > 0 ? 'under 1' : String(pct)
|
|
187
|
+
return `${pace} That’s ${share}% of this month’s AI limit.`
|
|
188
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site chrome API (DATA_CONTRACTS §2) for Appearance → Header & Footer.
|
|
3
|
+
* Region values and the announcement come with an `ETag`; a save sends it as
|
|
4
|
+
* `If-Match`, so a stale form gets 412 instead of overwriting a newer save.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
Announcement,
|
|
8
|
+
ChromeOverridesList,
|
|
9
|
+
ChromeRegionName,
|
|
10
|
+
ChromeSettings,
|
|
11
|
+
ChromeSyncStatus,
|
|
12
|
+
RegionSchema,
|
|
13
|
+
RegionValues,
|
|
14
|
+
} from '@prenta/core'
|
|
15
|
+
|
|
16
|
+
import { cmsApi } from './api.js'
|
|
17
|
+
|
|
18
|
+
export interface ChromeSchemaView {
|
|
19
|
+
regions: RegionSchema[]
|
|
20
|
+
sync: ChromeSyncStatus
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A result the screen can show: the value, or why there isn't one. */
|
|
24
|
+
export type ChromeResult<T> =
|
|
25
|
+
| { ok: true; value: T; etag: string | null }
|
|
26
|
+
| { ok: false; status: number; error: string }
|
|
27
|
+
|
|
28
|
+
function result<T>(res: {
|
|
29
|
+
data?: T
|
|
30
|
+
error?: string
|
|
31
|
+
status: number
|
|
32
|
+
etag?: string
|
|
33
|
+
}): ChromeResult<T> {
|
|
34
|
+
if (res.error !== undefined || res.data === undefined) {
|
|
35
|
+
return { ok: false, status: res.status, error: res.error ?? 'Request failed' }
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, value: res.data, etag: res.etag ?? null }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ifMatch(etag: string | null): Record<string, string> {
|
|
41
|
+
return etag ? { 'If-Match': etag } : {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function fetchChromeSchema(): Promise<ChromeResult<ChromeSchemaView>> {
|
|
45
|
+
return result(await cmsApi<ChromeSchemaView>('/chrome/schema'))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 404 until the site has pushed the region's schema. */
|
|
49
|
+
export async function fetchRegion(region: ChromeRegionName): Promise<ChromeResult<RegionValues>> {
|
|
50
|
+
return result(await cmsApi<RegionValues>(`/chrome/${region}`))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What `PUT /chrome/:region` takes. `fields` replaces every saved value. */
|
|
54
|
+
export interface RegionSaveBody {
|
|
55
|
+
variant: string
|
|
56
|
+
fields: Record<string, unknown>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function saveRegion(
|
|
60
|
+
region: ChromeRegionName,
|
|
61
|
+
body: RegionSaveBody,
|
|
62
|
+
etag: string | null,
|
|
63
|
+
): Promise<ChromeResult<RegionValues>> {
|
|
64
|
+
return result(
|
|
65
|
+
await cmsApi<RegionValues>(`/chrome/${region}`, {
|
|
66
|
+
method: 'PUT',
|
|
67
|
+
headers: ifMatch(etag),
|
|
68
|
+
body: JSON.stringify(body),
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** "Remove now" on the orphan row. */
|
|
74
|
+
export async function removeOrphan(
|
|
75
|
+
region: ChromeRegionName,
|
|
76
|
+
key: string,
|
|
77
|
+
): Promise<ChromeResult<RegionValues>> {
|
|
78
|
+
return result(
|
|
79
|
+
await cmsApi<RegionValues>(`/chrome/${region}/orphans/${encodeURIComponent(key)}`, {
|
|
80
|
+
method: 'DELETE',
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function fetchAnnouncement(): Promise<ChromeResult<Announcement>> {
|
|
86
|
+
return result(await cmsApi<Announcement>('/chrome/announcement'))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What `PUT /chrome/announcement` takes. The server mints `id`. */
|
|
90
|
+
export type AnnouncementSaveBody = Omit<Announcement, 'id'>
|
|
91
|
+
|
|
92
|
+
export async function saveAnnouncement(
|
|
93
|
+
body: AnnouncementSaveBody,
|
|
94
|
+
etag: string | null,
|
|
95
|
+
): Promise<ChromeResult<Announcement>> {
|
|
96
|
+
return result(
|
|
97
|
+
await cmsApi<Announcement>('/chrome/announcement', {
|
|
98
|
+
method: 'PUT',
|
|
99
|
+
headers: ifMatch(etag),
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function fetchChromeSettings(): Promise<ChromeResult<ChromeSettings>> {
|
|
106
|
+
return result(await cmsApi<ChromeSettings>('/chrome/settings'))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Saves the keys sent; one left out stays as it is. `secret` comes back once,
|
|
111
|
+
* when the save creates the revalidate webhook.
|
|
112
|
+
*/
|
|
113
|
+
export async function saveChromeSettings(
|
|
114
|
+
body: Partial<ChromeSettings>,
|
|
115
|
+
): Promise<ChromeResult<ChromeSettings & { secret?: string }>> {
|
|
116
|
+
return result(
|
|
117
|
+
await cmsApi<ChromeSettings & { secret?: string }>('/chrome/settings', {
|
|
118
|
+
method: 'PUT',
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The pages whose `_layout` overrides a region. A core before 8.11 answers 404. */
|
|
125
|
+
export async function fetchChromeOverrides(): Promise<ChromeResult<ChromeOverridesList>> {
|
|
126
|
+
return result(await cmsApi<ChromeOverridesList>('/chrome/overrides'))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A menu item as the form and the preview mock read it. */
|
|
130
|
+
export interface ChromeMenuItem {
|
|
131
|
+
id: string
|
|
132
|
+
label: string
|
|
133
|
+
url: string
|
|
134
|
+
target?: '_blank' | '_self'
|
|
135
|
+
/** Shown as a button (the kit's "Show as button"). */
|
|
136
|
+
button: boolean
|
|
137
|
+
children: ChromeMenuItem[]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A menu a menu field can pick, by its slug. */
|
|
141
|
+
export interface ChromeMenuOption {
|
|
142
|
+
slug: string
|
|
143
|
+
name: string
|
|
144
|
+
items: ChromeMenuItem[]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function asText(value: unknown): string {
|
|
148
|
+
return typeof value === 'string' ? value : ''
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function menuItems(value: unknown, depth = 0): ChromeMenuItem[] {
|
|
152
|
+
if (!Array.isArray(value) || depth > 6) return []
|
|
153
|
+
const out: ChromeMenuItem[] = []
|
|
154
|
+
value.forEach((raw, index) => {
|
|
155
|
+
if (!raw || typeof raw !== 'object') return
|
|
156
|
+
const item = raw as Record<string, unknown>
|
|
157
|
+
const label = asText(item.label).trim()
|
|
158
|
+
if (!label) return
|
|
159
|
+
out.push({
|
|
160
|
+
id: asText(item.id) || `${depth}-${index}`,
|
|
161
|
+
label,
|
|
162
|
+
url: asText(item.url),
|
|
163
|
+
...(item.target === '_blank' || item.target === '_self' ? { target: item.target } : {}),
|
|
164
|
+
button: item.button === true,
|
|
165
|
+
children: menuItems(item.children, depth + 1),
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Menus from the navigation plugin's `navigations` collection. Without the
|
|
173
|
+
* plugin there are none, and menu fields offer only what's saved.
|
|
174
|
+
*/
|
|
175
|
+
export async function fetchChromeMenus(): Promise<ChromeMenuOption[]> {
|
|
176
|
+
const res = await cmsApi<{
|
|
177
|
+
docs?: Array<{ slug?: string | null; title?: string | null; data?: unknown }>
|
|
178
|
+
}>('/collections/navigations?pageSize=100&sort=title&order=asc&pick=name,slug,items')
|
|
179
|
+
const menus: ChromeMenuOption[] = []
|
|
180
|
+
for (const doc of res.data?.docs ?? []) {
|
|
181
|
+
const data = (doc.data && typeof doc.data === 'object' ? doc.data : {}) as Record<
|
|
182
|
+
string,
|
|
183
|
+
unknown
|
|
184
|
+
>
|
|
185
|
+
const slug = asText(data.slug) || asText(doc.slug)
|
|
186
|
+
if (!slug || menus.some((menu) => menu.slug === slug)) continue
|
|
187
|
+
menus.push({
|
|
188
|
+
slug,
|
|
189
|
+
name: asText(data.name) || asText(doc.title) || slug,
|
|
190
|
+
items: menuItems(data.items),
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
return menus
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The site title, for brand fields and the preview's logo. */
|
|
197
|
+
export async function fetchSiteTitle(): Promise<string | null> {
|
|
198
|
+
const res = await cmsApi<{ siteTitle?: string | null }>('/appearance')
|
|
199
|
+
return typeof res.data?.siteTitle === 'string' && res.data.siteTitle ? res.data.siteTitle : null
|
|
200
|
+
}
|
package/src/lib/job-view.ts
CHANGED
|
@@ -141,10 +141,18 @@ export interface JobRow {
|
|
|
141
141
|
items: boolean
|
|
142
142
|
/** Batch AI approval mode, for the cancel copy. Null for scans. */
|
|
143
143
|
review: 'review' | 'auto' | null
|
|
144
|
+
/**
|
|
145
|
+
* Batch AI: only an admin may retry it, because "Apply automatically" was
|
|
146
|
+
* chosen when it was queued. A job from `/ai/batch` (a list's bulk actions)
|
|
147
|
+
* also applies directly, but stores no review mode, so it isn't admin-only.
|
|
148
|
+
*/
|
|
149
|
+
adminRetry: boolean
|
|
144
150
|
/** Run time of a finished job, e.g. "17 min". */
|
|
145
151
|
took: string | null
|
|
146
152
|
/** Who queued it: a person, or the cron. */
|
|
147
153
|
by: string
|
|
154
|
+
/** The id of the person who queued it; null for a cron run. */
|
|
155
|
+
byId: string | null
|
|
148
156
|
/** When it was queued, e.g. "34 min ago" or "Yesterday 16:04". */
|
|
149
157
|
created: string
|
|
150
158
|
}
|
|
@@ -261,6 +269,70 @@ export function hasReviewAction(j: JobRow): boolean {
|
|
|
261
269
|
return view(j).k === 'review' || (j.items && j.pending > 0)
|
|
262
270
|
}
|
|
263
271
|
|
|
272
|
+
/** "document 8" or "page 101": where a retry picks up (jobs-drawer.jsx footer, jobs-view.jsx toast). */
|
|
273
|
+
export function retryPoint(j: Pick<JobRow, 'type' | 'done'>): string {
|
|
274
|
+
return `${jobType(j.type).unit === 'pages' ? 'page' : 'document'} ${j.done + 1}`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Who is looking at the Jobs screen, from the admin session. */
|
|
278
|
+
export interface JobViewer {
|
|
279
|
+
role: string | null
|
|
280
|
+
userId: string | null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Roles that may run, cancel and retry jobs: cms-core `WRITE_ROLES` (`requireStaffAccess`). */
|
|
284
|
+
const JOB_WRITE_ROLES: ReadonlySet<string> = new Set(['OWNER', 'ADMIN', 'EDITOR', 'AUTHOR'])
|
|
285
|
+
/** cms-core `ADMIN_ROLES`: may retry any batch. */
|
|
286
|
+
const JOB_ADMIN_ROLES: ReadonlySet<string> = new Set(['OWNER', 'ADMIN'])
|
|
287
|
+
|
|
288
|
+
function roleOf(viewer: JobViewer): string {
|
|
289
|
+
return (viewer.role ?? '').toUpperCase()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Run now and Cancel. An unknown role fails closed; the server checks every request anyway. */
|
|
293
|
+
export function canActOnJobs(viewer: JobViewer): boolean {
|
|
294
|
+
return JOB_WRITE_ROLES.has(roleOf(viewer))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Retry, as cms-core's retry route allows it: a write role, and for a batch
|
|
299
|
+
* (`batchRetryDenial`) an admin, or the person who queued it when it doesn't
|
|
300
|
+
* apply changes directly. The server also wants their role unchanged since
|
|
301
|
+
* they queued it, which the list doesn't carry; it refuses that with a 403.
|
|
302
|
+
*/
|
|
303
|
+
export function canRetryJob(
|
|
304
|
+
j: Pick<JobRow, 'type' | 'adminRetry' | 'byId'>,
|
|
305
|
+
viewer: JobViewer,
|
|
306
|
+
): boolean {
|
|
307
|
+
if (!canActOnJobs(viewer)) return false
|
|
308
|
+
if (j.type !== 'batch-ai' || JOB_ADMIN_ROLES.has(roleOf(viewer))) return true
|
|
309
|
+
return !j.adminRetry && j.byId !== null && j.byId === viewer.userId
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The job actions a row or the drawer footer (jobs-drawer.jsx `.jd-f`) offers this viewer. */
|
|
313
|
+
export interface JobActions {
|
|
314
|
+
/** Cancel job: while it's queued or running, until a cancel is asked for. */
|
|
315
|
+
cancel: boolean
|
|
316
|
+
/** "Retry from document {n+1}": a failed job. */
|
|
317
|
+
retry: boolean
|
|
318
|
+
/**
|
|
319
|
+
* "Retry now": a job waiting out a failed slice's backoff. Not on a stale
|
|
320
|
+
* lock, which the server still holds as running (the next worker run picks
|
|
321
|
+
* it up), and not while cancelling: a retry clears the cancel.
|
|
322
|
+
*/
|
|
323
|
+
retryNow: boolean
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function jobActions(j: JobRow, viewer: JobViewer): JobActions {
|
|
327
|
+
const retryable = canRetryJob(j, viewer)
|
|
328
|
+
return {
|
|
329
|
+
cancel: canActOnJobs(viewer) && active(j) && !j.cancelling,
|
|
330
|
+
retry: retryable && j.state === 'dead',
|
|
331
|
+
retryNow:
|
|
332
|
+
retryable && j.state === 'queued' && j.status === 'queued' && !!j.error && !j.cancelling,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
264
336
|
/** A running job whose lock is older than this crashed mid-slice (JOBS.md). */
|
|
265
337
|
const STALE_LOCK_MS = 10 * 60_000
|
|
266
338
|
/** The worker cron runs once a minute. */
|
|
@@ -392,6 +464,11 @@ function who(job: Job): string {
|
|
|
392
464
|
return by.name
|
|
393
465
|
}
|
|
394
466
|
|
|
467
|
+
function whoId(job: Job): string | null {
|
|
468
|
+
const by = job.createdBy
|
|
469
|
+
return by && 'id' in by ? by.id : null
|
|
470
|
+
}
|
|
471
|
+
|
|
395
472
|
/** Claim order (`claimNext`): earliest `runAt`, then oldest. */
|
|
396
473
|
function claimsBefore(a: Job, b: Job): boolean {
|
|
397
474
|
const runA = Date.parse(a.runAt)
|
|
@@ -400,6 +477,16 @@ function claimsBefore(a: Job, b: Job): boolean {
|
|
|
400
477
|
return Date.parse(a.createdAt) < Date.parse(b.createdAt)
|
|
401
478
|
}
|
|
402
479
|
|
|
480
|
+
/**
|
|
481
|
+
* How many units a job covers. A batch's worker sets `progress.total` on its
|
|
482
|
+
* first slice, so until then it's the documents frozen in its payload.
|
|
483
|
+
*/
|
|
484
|
+
export function jobTotal(job: Job): number {
|
|
485
|
+
if (job.progress.total > 0 || job.type !== 'batch-ai') return job.progress.total
|
|
486
|
+
const ids = 'documentIds' in job.payload ? job.payload.documentIds : null
|
|
487
|
+
return Array.isArray(ids) ? ids.length : 0
|
|
488
|
+
}
|
|
489
|
+
|
|
403
490
|
/**
|
|
404
491
|
* API jobs in the kit's row shape. `now` is when the list was fetched, so the
|
|
405
492
|
* retry countdown and "34 min ago" move with each poll. `worker` aligns the
|
|
@@ -427,7 +514,7 @@ export function toJobRows(jobs: Job[], now: number, worker: JobWorkerStatus | nu
|
|
|
427
514
|
status: job.status,
|
|
428
515
|
cancelling: !!job.cancelRequestedAt && isActive(job),
|
|
429
516
|
done: job.progress.done,
|
|
430
|
-
total: job
|
|
517
|
+
total: jobTotal(job),
|
|
431
518
|
attempts: job.attempts,
|
|
432
519
|
maxAttempts: job.maxAttempts,
|
|
433
520
|
error,
|
|
@@ -448,8 +535,15 @@ export function toJobRows(jobs: Job[], now: number, worker: JobWorkerStatus | nu
|
|
|
448
535
|
pending: review?.pending ?? 0,
|
|
449
536
|
items: !!review && review.pending + review.approved + review.rejected > 0,
|
|
450
537
|
review: job.type === 'batch-ai' && 'review' in job.payload ? job.payload.review : null,
|
|
538
|
+
// cms-core `batchAiWriter`: only a stored `review: 'auto'` is admin-only.
|
|
539
|
+
adminRetry:
|
|
540
|
+
job.type === 'batch-ai' &&
|
|
541
|
+
'review' in job.payload &&
|
|
542
|
+
job.payload.review === 'auto' &&
|
|
543
|
+
job.payload.origin !== 'ai-batch',
|
|
451
544
|
took: finished ? formatDuration(Date.parse(finished) - Date.parse(job.createdAt)) : null,
|
|
452
545
|
by: who(job),
|
|
546
|
+
byId: whoId(job),
|
|
453
547
|
created: formatCreated(job.createdAt, now),
|
|
454
548
|
}
|
|
455
549
|
})
|
package/src/lib/jobs-service.ts
CHANGED
|
@@ -7,7 +7,15 @@
|
|
|
7
7
|
* failed request from an empty list and show the server's message (a
|
|
8
8
|
* viewer's 403, a 409 for a job that can't be retried).
|
|
9
9
|
*/
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
BatchAiEstimate,
|
|
12
|
+
BatchAiPreviewItem,
|
|
13
|
+
BatchAiRequest,
|
|
14
|
+
Job,
|
|
15
|
+
JobEvent,
|
|
16
|
+
JobReviewItem,
|
|
17
|
+
JobWorkerStatus,
|
|
18
|
+
} from '@prenta/core'
|
|
11
19
|
import { cmsApi, getApiBase } from './api.js'
|
|
12
20
|
|
|
13
21
|
export type JobsOutcome<T> = { ok: true; value: T } | { ok: false; error: string; status: number }
|
|
@@ -140,6 +148,53 @@ export function cancelJob(id: string): Promise<JobsOutcome<Job>> {
|
|
|
140
148
|
return call(`/jobs/${encodeURIComponent(id)}/cancel`, { method: 'POST' })
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
/**
|
|
152
|
+
* `GET /jobs/batch-ai/estimate`: how many documents a batch would change,
|
|
153
|
+
* 3 of them as a sample, and its tokens, minutes and share of the monthly AI
|
|
154
|
+
* limit. The query carries the request's fields, `documentIds` comma-separated.
|
|
155
|
+
*/
|
|
156
|
+
export function estimateBatchAi(
|
|
157
|
+
request: BatchAiRequest,
|
|
158
|
+
signal?: AbortSignal,
|
|
159
|
+
): Promise<JobsOutcome<BatchAiEstimate>> {
|
|
160
|
+
const params = new URLSearchParams({
|
|
161
|
+
operation: request.operation,
|
|
162
|
+
source: request.source,
|
|
163
|
+
statusFilter: request.statusFilter,
|
|
164
|
+
})
|
|
165
|
+
if (request.field) params.set('field', request.field)
|
|
166
|
+
if (request.instruction) params.set('instruction', request.instruction)
|
|
167
|
+
const ids = request.filter?.documentIds
|
|
168
|
+
if (ids?.length) params.set('documentIds', ids.join(','))
|
|
169
|
+
return call(`/jobs/batch-ai/estimate?${params}`, { signal })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `POST /jobs/batch-ai/preview`: what the AI would write for the first 3
|
|
174
|
+
* matching documents. Nothing is written. A document it would leave as it is
|
|
175
|
+
* isn't listed, and a spent AI cap is a 429.
|
|
176
|
+
*/
|
|
177
|
+
export async function previewBatchAi(
|
|
178
|
+
request: BatchAiRequest,
|
|
179
|
+
signal?: AbortSignal,
|
|
180
|
+
): Promise<JobsOutcome<BatchAiPreviewItem[]>> {
|
|
181
|
+
const res = await call<{ items?: BatchAiPreviewItem[] }>('/jobs/batch-ai/preview', {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
body: JSON.stringify(request),
|
|
184
|
+
signal,
|
|
185
|
+
})
|
|
186
|
+
return res.ok ? { ok: true, value: res.value.items ?? [] } : res
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `POST /jobs/batch-ai`: resolve the documents once, freeze them in a new
|
|
191
|
+
* queued job, and return it. "Apply automatically" is admin-only (403), and
|
|
192
|
+
* a filter matching no documents, or more than 1,000, is a 400.
|
|
193
|
+
*/
|
|
194
|
+
export function createBatchAiJob(request: BatchAiRequest): Promise<JobsOutcome<Job>> {
|
|
195
|
+
return call('/jobs/batch-ai', { method: 'POST', body: JSON.stringify(request) })
|
|
196
|
+
}
|
|
197
|
+
|
|
143
198
|
function jobPath(id: string): string {
|
|
144
199
|
return `/jobs/${encodeURIComponent(id)}`
|
|
145
200
|
}
|
|
@@ -128,6 +128,22 @@
|
|
|
128
128
|
--code-btn-bg: #171a20;
|
|
129
129
|
--code-btn-bdr: #2a2e36;
|
|
130
130
|
--code-btn-txt: #c9cdd4;
|
|
131
|
+
--code-file: #8a8f99; /* File-name caption over a code block */
|
|
132
|
+
|
|
133
|
+
/* Site preview */
|
|
134
|
+
--site-bg: #ffffff;
|
|
135
|
+
--site-txt: #16181d;
|
|
136
|
+
--site-sub: #50555f;
|
|
137
|
+
--site-muted: #8a8f99;
|
|
138
|
+
--site-bdr: #e3e5ea;
|
|
139
|
+
--site-alt: #f4f5f7;
|
|
140
|
+
--site-alt2: #eef0f3;
|
|
141
|
+
--site-chip: #d9dce1;
|
|
142
|
+
--site-foot-txt: #c9cdd4;
|
|
143
|
+
/* The announcement's Alert tone, as the mock and the Style pills draw it */
|
|
144
|
+
--site-alert: #b45309;
|
|
145
|
+
--site-thumb: #2a2d33; /* Footer variant thumbnails */
|
|
146
|
+
--site-thumb-bar: #555555;
|
|
131
147
|
|
|
132
148
|
/* Search preview */
|
|
133
149
|
/*
|
|
@@ -155,6 +171,7 @@
|
|
|
155
171
|
--fs-page: 21px; /* Page title */
|
|
156
172
|
--fs-drawer: 16px; /* Drawer / modal title */
|
|
157
173
|
--fs-stat: 20px; /* Jobs drawer result numbers */
|
|
174
|
+
--fs-figure: 14px; /* Batch wizard match count and estimates */
|
|
158
175
|
--fs-card: 13.5px; /* Card heading */
|
|
159
176
|
--fs-body: 13px; /* Body, controls, table cells */
|
|
160
177
|
--fs-sm: 12.5px; /* Secondary rows, breadcrumbs, chips */
|
package/src/styles/theme.css
CHANGED
|
@@ -239,6 +239,19 @@
|
|
|
239
239
|
--color-code-btn-bg: var(--code-btn-bg);
|
|
240
240
|
--color-code-btn-bdr: var(--code-btn-bdr);
|
|
241
241
|
--color-code-btn-txt: var(--code-btn-txt);
|
|
242
|
+
--color-code-file: var(--code-file);
|
|
243
|
+
--color-site-bg: var(--site-bg);
|
|
244
|
+
--color-site-txt: var(--site-txt);
|
|
245
|
+
--color-site-sub: var(--site-sub);
|
|
246
|
+
--color-site-muted: var(--site-muted);
|
|
247
|
+
--color-site-bdr: var(--site-bdr);
|
|
248
|
+
--color-site-alt: var(--site-alt);
|
|
249
|
+
--color-site-alt2: var(--site-alt2);
|
|
250
|
+
--color-site-chip: var(--site-chip);
|
|
251
|
+
--color-site-foot-txt: var(--site-foot-txt);
|
|
252
|
+
--color-site-alert: var(--site-alert);
|
|
253
|
+
--color-site-thumb: var(--site-thumb);
|
|
254
|
+
--color-site-thumb-bar: var(--site-thumb-bar);
|
|
242
255
|
|
|
243
256
|
--text-page: var(--fs-page);
|
|
244
257
|
--text-page--letter-spacing: -0.5px;
|
|
@@ -249,6 +262,8 @@
|
|
|
249
262
|
--text-stat: var(--fs-stat);
|
|
250
263
|
--text-stat--letter-spacing: -0.4px;
|
|
251
264
|
--text-stat--font-weight: 700;
|
|
265
|
+
--text-figure: var(--fs-figure);
|
|
266
|
+
--text-figure--font-weight: 700;
|
|
252
267
|
/* The kit's `text-card` would collide with the card color, which wins in v4. */
|
|
253
268
|
--text-card-title: var(--fs-card);
|
|
254
269
|
--text-body: var(--fs-body);
|