@bison-lab/payload-core 0.1.0 → 3.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/README.md CHANGED
@@ -1,23 +1,32 @@
1
1
  # `@bison-lab/payload-core`
2
2
 
3
- Site-agnostic Payload CMS configuration for Bison Lab sites. Today: the SEO
4
- tab on a collection and the metadata reader for the pages it describes. The
5
- rest of the shared CMS layer (access matrix, collection factories, slug field)
6
- joins it here as it is extracted.
3
+ Site-agnostic Payload CMS configuration for Bison Lab sites: the SEO tab on
4
+ a collection, the Theme Global, and the readers a page route and a root
5
+ layout use. The rest of the shared CMS layer (access matrix, collection
6
+ factories, slug field) joins it here as it is extracted.
7
+
8
+ Full guide: <https://payload.bisonlab.ai/site/seo/>
7
9
 
8
10
  ```bash
9
- pnpm add @bison-lab/payload-core @payloadcms/plugin-seo
11
+ pnpm add @bison-lab/payload-core @payloadcms/plugin-seo @bison-lab/tokens @bison-lab/fonts
10
12
  ```
11
13
 
12
- Peers: `payload` and `@payloadcms/plugin-seo`. Pin the plugin to the same
13
- version as `payload`; Payload releases them in lockstep.
14
+ Peers: `payload` and `@payloadcms/plugin-seo` are required. `@bison-lab/tokens`
15
+ and `@bison-lab/fonts` are needed to adopt the Theme Global; `@payloadcms/ui`
16
+ and `react` are needed for its admin fields; `@bison-lab/ui` and
17
+ `@payloadcms/live-preview-react` are needed for the live theme preview.
18
+ Pin the plugin to the same version as `payload`; Payload releases them in
19
+ lockstep.
14
20
 
15
- ## Two entry points, and why
21
+ ## Five entry points, and why
16
22
 
17
- | Import | Contents | Runs where |
18
- | ---------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- |
19
- | `@bison-lab/payload-core` | `seoPlugin`, `noIndexField`, `SHARE_IMAGE_SIZE`, title and text helpers, types | Node. What `payload.config.ts` imports; it loads the plugin. |
20
- | `@bison-lab/payload-core/metadata` | `pageMetadata`, the title helpers, the same types | Server. What a page route imports; it does not load the plugin. |
23
+ | Import | Contents | Runs where |
24
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
25
+ | `@bison-lab/payload-core` | `seoPlugin`, `createTheme`, `seedTheme`, import-map strings, title and text helpers, types | Node. What `payload.config.ts` imports; it loads the plugin. |
26
+ | `@bison-lab/payload-core/metadata` | `pageMetadata`, the title helpers, the same types | Server. What a page route imports; it does not load the plugin. |
27
+ | `@bison-lab/payload-core/theme` | `getPublishedTheme`, `themeConfigFromDoc`, `themeHead` | Server. What a root layout imports; it does not load the plugin or React. |
28
+ | `@bison-lab/payload-core/admin` | `ColorField`, `FontField`, `ContrastReport` | Admin. Referenced by import-map string; `generate:importmap` writes it. |
29
+ | `@bison-lab/payload-core/react` | `ThemePreview` | Client. What the theme-preview route imports; it loads ui and live-preview. |
21
30
 
22
31
  ## What editors get
23
32
 
@@ -114,15 +123,120 @@ carry through Next's metadata merge.
114
123
 
115
124
  The sitemap is the site's: filter `meta.noIndex` out of it.
116
125
 
126
+ ## Adopting the Theme global
127
+
128
+ Settings → Theme: the five brand colours, grey scale, radius, shadow, motion,
129
+ density, default theme, body and heading fonts, and an optional logo. Whoever
130
+ holds brand rights edits a draft; publish is deliberate. Contrast is measured
131
+ as they type and shown; it is never enforced. `bison.config.json` is the seed
132
+ a site starts from, not the source of truth.
133
+
134
+ **1. Register the Global.** Access is required — the predicates live in the
135
+ site's `src/platform` until they move here. Pass `canManageBrand` as
136
+ `update`.
137
+
138
+ ```ts
139
+ // payload.config.ts
140
+ import { createTheme } from "@bison-lab/payload-core";
141
+ import bisonConfig from "../bison.config.json";
142
+ import { canManageBrand, isAuthenticated } from "@/platform/access";
143
+
144
+ export default buildConfig({
145
+ globals: [
146
+ createTheme({
147
+ access: { read: isAuthenticated, update: canManageBrand },
148
+ seed: bisonConfig,
149
+ previewPath: "/theme-preview",
150
+ logo: { collection: "brand-assets" },
151
+ onPublish: async () => {
152
+ revalidateTag("theme");
153
+ },
154
+ }),
155
+ ],
156
+ });
157
+ ```
158
+
159
+ Then `payload generate:importmap` (the colour, font and contrast fields
160
+ resolve from `@bison-lab/payload-core/admin`), `payload generate:types`, and
161
+ `payload migrate:create`.
162
+
163
+ **2. Seed the row on deploy.** A migration `up()` writes a published version
164
+ so the site renders what `bison-theme.css` rendered before anyone opens the
165
+ admin:
166
+
167
+ ```ts
168
+ import { seedTheme } from "@bison-lab/payload-core";
169
+ import bisonConfig from "../bison.config.json";
170
+
171
+ export async function up({ payload }) {
172
+ await seedTheme(payload, bisonConfig);
173
+ }
174
+ ```
175
+
176
+ **3. Serve the catalogue.** A Next route around `serveFont` from
177
+ `@bison-lab/fonts/server`, listed in `serverExternalPackages`, at the path
178
+ you pass as `fontsBaseUrl` (default `/fonts`).
179
+
180
+ **4. Render the published theme in the root layout.**
181
+
182
+ ```ts
183
+ // app/layout.tsx
184
+ import { getPublishedTheme, themeHead } from "@bison-lab/payload-core/theme";
185
+ import bisonConfig from "../bison.config.json";
186
+
187
+ const theme = await getPublishedTheme(payload, bisonConfig);
188
+ const { css, preloads } = themeHead(theme);
189
+
190
+ // <link rel="preload" as="font" type="font/woff2" crossOrigin="" href={href} />
191
+ // <style dangerouslySetInnerHTML={{ __html: css }} />
192
+ ```
193
+
194
+ If the admin layout renders the same head, the admin panel restyles too.
195
+
196
+ `getPublishedTheme` reads `draft: false` and falls back to the seed when
197
+ nothing has been published. A newer draft never reaches the public site.
198
+
199
+ **5. Own the preview route.** The path is code-owned (in the site's
200
+ `ENDPOINTS`), never a nav link, never in the sitemap, reserved from CMS
201
+ slugs, and gated to an authenticated user. It renders `ThemePreview` with
202
+ no site chrome. `createTheme({ previewPath })` points Payload's live-preview
203
+ pane at that path; omit `previewPath` and the pane stays closed.
204
+
205
+ ```ts
206
+ // app/theme-preview/page.tsx
207
+ import { ThemePreview } from "@bison-lab/payload-core/react";
208
+ import { getPublishedTheme } from "@bison-lab/payload-core/theme";
209
+ import bisonConfig from "../../../bison.config.json";
210
+
211
+ const theme = await getPublishedTheme(payload, bisonConfig);
212
+
213
+ return (
214
+ <ThemePreview
215
+ theme={theme}
216
+ seed={bisonConfig}
217
+ fontsBaseUrl="/fonts"
218
+ serverURL={absoluteSiteUrl}
219
+ />
220
+ );
221
+ ```
222
+
223
+ `ThemePreview` rebuilds the stylesheet in the browser from each live-preview
224
+ message. A half-typed hex keeps the last complete theme so the iframe does
225
+ not go blank. The light / dark toggle sets `data-theme` on the preview root,
226
+ not the document. Pages that later grow a live-preview pane should import
227
+ `THEME_PREVIEW_BREAKPOINTS` from `@bison-lab/payload-core` so the toolbars
228
+ match.
229
+
117
230
  ## No generated types
118
231
 
119
232
  The package cannot import a site's `payload-types`, so the shapes it reads
120
- (`SeoMeta`, `SeoImageDoc`, `SeoPage`) are hand-written and structural. A
121
- generated `Page` and `Media` are assignable to them; nothing carries an index
122
- signature, since an interface will not assign to a type that has one.
233
+ (`SeoMeta`, `SeoImageDoc`, `SeoPage`, `ThemeDoc`) are hand-written and
234
+ structural. A generated `Page`, `Media` or Theme Global is assignable to
235
+ them; nothing carries an index signature, since an interface will not assign
236
+ to a type that has one.
123
237
 
124
238
  ## Changing a field
125
239
 
126
- `noIndexField` and the plugin's own fields are columns in every consuming
127
- site. A change to them is a schema change: say "run `payload migrate:create`"
128
- in the changeset.
240
+ `noIndexField`, the plugin's own fields, and the Theme Global fields are
241
+ columns in every consuming site. A change to them is a schema change: say
242
+ "run `payload migrate:create`" in the changeset.
@@ -0,0 +1,28 @@
1
+
2
+ import { SelectFieldClientComponent, TextFieldClientComponent, UIFieldClientComponent } from "payload";
3
+
4
+ //#region src/admin/color-field.d.ts
5
+ /**
6
+ * Swatch, native colour input, and hex text, kept in step. `#rrggbb` on
7
+ * blur; the field's `validate` refuses anything else on save.
8
+ */
9
+ declare const ColorField: TextFieldClientComponent;
10
+ //#endregion
11
+ //#region src/admin/font-field.d.ts
12
+ /**
13
+ * Listbox over the catalogue ids in `admin.custom.ids`. Each option is the
14
+ * family name in that face, with the catalogue hint beneath. Faces load
15
+ * from the site's font route the first time the picker opens.
16
+ */
17
+ declare const FontField: SelectFieldClientComponent;
18
+ //#endregion
19
+ //#region src/admin/contrast-report.d.ts
20
+ /**
21
+ * Reads the five brand colours and grey scale from the form, runs
22
+ * `auditTheme` client-side, and paints its own rows. Warn only: nothing
23
+ * here touches validity.
24
+ */
25
+ declare const ContrastReport: UIFieldClientComponent;
26
+ //#endregion
27
+ export { ColorField, ContrastReport, FontField };
28
+ //# sourceMappingURL=admin.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin.d.mts","names":[],"sources":["../src/admin/color-field.tsx","../src/admin/font-field.tsx","../src/admin/contrast-report.tsx"],"mappings":";;;;;;AAWA;;cAAa,UAAA,EAAY,wBAAA;;;;;AAAzB;;;cCGa,SAAA,EAAW,0BAAA;;;;;ADHxB;;;cEqEa,cAAA,EAAgB,sBAAA"}
package/dist/admin.mjs ADDED
@@ -0,0 +1,433 @@
1
+ "use client";
2
+ import { FieldDescription, FieldError, FieldLabel, useField, useFormFields } from "@payloadcms/ui";
3
+ import { auditTheme, contrastLevelLabel, contrastVerdict, isThemeHex } from "@bison-lab/tokens";
4
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { findFont, fontFaceCss } from "@bison-lab/fonts";
7
+ //#region src/admin/color-field.tsx
8
+ const HEX_ERROR = "Enter a six-digit hex colour like #1e3a5f";
9
+ /**
10
+ * Swatch, native colour input, and hex text, kept in step. `#rrggbb` on
11
+ * blur; the field's `validate` refuses anything else on save.
12
+ */
13
+ const ColorField = ({ field, path, readOnly }) => {
14
+ const { value, setValue, showError, errorMessage } = useField({ path });
15
+ const [text, setText] = useState(value ?? "");
16
+ const [localError, setLocalError] = useState(null);
17
+ const id = useId();
18
+ const hexId = `${id}-hex`;
19
+ const pickerId = `${id}-picker`;
20
+ const required = Boolean(field.required);
21
+ const valid = isThemeHex(value);
22
+ const message = localError ?? errorMessage;
23
+ const show = Boolean(localError) || showError;
24
+ useEffect(() => {
25
+ setText(value ?? "");
26
+ }, [value]);
27
+ function commit(next) {
28
+ const trimmed = next.trim();
29
+ if (isThemeHex(trimmed)) {
30
+ setValue(trimmed);
31
+ setLocalError(null);
32
+ return;
33
+ }
34
+ setLocalError(HEX_ERROR);
35
+ }
36
+ return /* @__PURE__ */ jsxs("div", {
37
+ className: "field-type bl-color-field",
38
+ children: [
39
+ /* @__PURE__ */ jsx("style", {
40
+ href: "bl-color-field",
41
+ precedence: "default",
42
+ children: CSS$2
43
+ }),
44
+ /* @__PURE__ */ jsx(FieldLabel, {
45
+ htmlFor: hexId,
46
+ label: field.label,
47
+ required
48
+ }),
49
+ /* @__PURE__ */ jsxs("div", {
50
+ className: "bl-color-field__control",
51
+ children: [
52
+ /* @__PURE__ */ jsx("span", {
53
+ className: "bl-color-field__swatch",
54
+ "aria-hidden": "true",
55
+ style: { background: valid ? value : "transparent" }
56
+ }),
57
+ /* @__PURE__ */ jsx("input", {
58
+ id: pickerId,
59
+ type: "color",
60
+ className: "bl-color-field__picker",
61
+ disabled: readOnly,
62
+ value: valid ? value : "#000000",
63
+ "aria-label": `${typeof field.label === "string" ? field.label : "Colour"} picker`,
64
+ onChange: (event) => {
65
+ setValue(event.target.value);
66
+ setText(event.target.value);
67
+ setLocalError(null);
68
+ }
69
+ }),
70
+ /* @__PURE__ */ jsx("input", {
71
+ id: hexId,
72
+ type: "text",
73
+ className: "bl-color-field__hex",
74
+ spellCheck: false,
75
+ autoComplete: "off",
76
+ disabled: readOnly,
77
+ value: text,
78
+ onChange: (event) => {
79
+ setText(event.target.value);
80
+ if (isThemeHex(event.target.value.trim())) {
81
+ setValue(event.target.value.trim());
82
+ setLocalError(null);
83
+ }
84
+ },
85
+ onBlur: () => commit(text)
86
+ })
87
+ ]
88
+ }),
89
+ /* @__PURE__ */ jsx(FieldDescription, {
90
+ description: field.admin?.description,
91
+ path
92
+ }),
93
+ /* @__PURE__ */ jsx(FieldError, {
94
+ path,
95
+ showError: show,
96
+ message
97
+ })
98
+ ]
99
+ });
100
+ };
101
+ const CSS$2 = `
102
+ .bl-color-field__control{display:flex;align-items:center;gap:calc(var(--base) * .4)}
103
+ .bl-color-field__swatch{flex:none;width:calc(var(--base) * 1.6);height:calc(var(--base) * 1.6);border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-elevation-50)}
104
+ .bl-color-field__picker{flex:none;width:calc(var(--base) * 2);height:calc(var(--base) * 2);padding:0;border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:transparent;cursor:pointer}
105
+ .bl-color-field__picker:disabled{cursor:not-allowed;opacity:.6}
106
+ .bl-color-field__hex{flex:1;min-width:0;min-height:calc(var(--base) * 2);padding:calc(var(--base) * .25) calc(var(--base) * .6);border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-input-bg);color:var(--theme-elevation-800);font-family:var(--font-mono);font-size:13px}
107
+ .bl-color-field__hex:focus{border-color:var(--theme-elevation-400);outline:none;box-shadow:0 0 0 1px var(--theme-elevation-400)}
108
+ .bl-color-field__hex:disabled{color:var(--theme-elevation-400)}
109
+ `;
110
+ //#endregion
111
+ //#region src/admin/font-field.tsx
112
+ /**
113
+ * Listbox over the catalogue ids in `admin.custom.ids`. Each option is the
114
+ * family name in that face, with the catalogue hint beneath. Faces load
115
+ * from the site's font route the first time the picker opens.
116
+ */
117
+ const FontField = ({ field, path, readOnly }) => {
118
+ const { value, setValue, showError, errorMessage } = useField({ path });
119
+ const custom = field.admin?.custom ?? {};
120
+ const ids = custom.ids ?? [];
121
+ const fontsBaseUrl = custom.fontsBaseUrl ?? "/fonts";
122
+ const sameAsBody = Boolean(custom.sameAsBody);
123
+ const options = useMemo(() => {
124
+ const fromCatalogue = ids.flatMap((id) => {
125
+ const font = findFont(id);
126
+ if (!font) return [];
127
+ return [{
128
+ value: font.id,
129
+ family: font.family,
130
+ hint: font.hint,
131
+ specimen: true
132
+ }];
133
+ });
134
+ if (!sameAsBody) return fromCatalogue;
135
+ return [{
136
+ value: "",
137
+ family: "Same as body",
138
+ hint: "Use the body family for headings",
139
+ specimen: false
140
+ }, ...fromCatalogue];
141
+ }, [ids, sameAsBody]);
142
+ const [open, setOpen] = useState(false);
143
+ const [faces, setFaces] = useState(false);
144
+ const [active, setActive] = useState(-1);
145
+ const typed = useRef("");
146
+ const typedAt = useRef(0);
147
+ const id = useId();
148
+ const buttonId = `${id}-button`;
149
+ const listId = `${id}-list`;
150
+ const required = Boolean(field.required);
151
+ useEffect(() => {
152
+ if (open) setFaces(true);
153
+ }, [open]);
154
+ const selected = options.find((option) => option.value === (value ?? "")) ?? options[0];
155
+ function choose(option) {
156
+ setValue(option.value);
157
+ setOpen(false);
158
+ setActive(-1);
159
+ }
160
+ function onKeyDown(event) {
161
+ if (event.key === "ArrowDown") {
162
+ event.preventDefault();
163
+ if (!open) {
164
+ setOpen(true);
165
+ setActive(0);
166
+ return;
167
+ }
168
+ setActive((i) => options.length ? (i + 1) % options.length : -1);
169
+ } else if (event.key === "ArrowUp") {
170
+ event.preventDefault();
171
+ if (!open) {
172
+ setOpen(true);
173
+ setActive(options.length - 1);
174
+ return;
175
+ }
176
+ setActive((i) => options.length ? i <= 0 ? options.length - 1 : i - 1 : -1);
177
+ } else if (event.key === "Enter") {
178
+ if (!open) return;
179
+ event.preventDefault();
180
+ const pick = options[active] ?? options[0];
181
+ if (pick) choose(pick);
182
+ } else if (event.key === "Escape") {
183
+ if (!open) return;
184
+ event.preventDefault();
185
+ event.stopPropagation();
186
+ setOpen(false);
187
+ setActive(-1);
188
+ } else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
189
+ const now = Date.now();
190
+ typed.current = now - typedAt.current > 600 ? event.key : typed.current + event.key;
191
+ typedAt.current = now;
192
+ const needle = typed.current.toLowerCase();
193
+ const index = options.findIndex((option) => option.family.toLowerCase().startsWith(needle));
194
+ if (index >= 0) {
195
+ if (!open) setOpen(true);
196
+ setActive(index);
197
+ }
198
+ }
199
+ }
200
+ const activeId = active >= 0 && open ? `${id}-option-${active}` : void 0;
201
+ return /* @__PURE__ */ jsxs("div", {
202
+ className: "field-type bl-font-field",
203
+ children: [
204
+ /* @__PURE__ */ jsx("style", {
205
+ href: "bl-font-field",
206
+ precedence: "default",
207
+ children: CSS$1
208
+ }),
209
+ faces ? /* @__PURE__ */ jsx("style", {
210
+ "data-bl-font-faces": "",
211
+ children: fontFaceCss(ids, fontsBaseUrl)
212
+ }) : null,
213
+ /* @__PURE__ */ jsx(FieldLabel, {
214
+ htmlFor: buttonId,
215
+ label: field.label,
216
+ required
217
+ }),
218
+ /* @__PURE__ */ jsxs("div", {
219
+ className: "bl-font-field__control",
220
+ children: [/* @__PURE__ */ jsx("button", {
221
+ id: buttonId,
222
+ type: "button",
223
+ className: "bl-font-field__trigger",
224
+ disabled: readOnly,
225
+ "aria-haspopup": "listbox",
226
+ "aria-expanded": open,
227
+ "aria-controls": listId,
228
+ "aria-activedescendant": activeId,
229
+ onClick: () => setOpen((was) => !was),
230
+ onBlur: () => setOpen(false),
231
+ onKeyDown,
232
+ children: selected?.family ?? "Choose a font"
233
+ }), open ? /* @__PURE__ */ jsx("ul", {
234
+ id: listId,
235
+ role: "listbox",
236
+ className: "bl-font-field__menu",
237
+ children: options.map((option, i) => /* @__PURE__ */ jsxs("li", {
238
+ id: `${id}-option-${i}`,
239
+ role: "option",
240
+ "aria-selected": option.value === (value ?? ""),
241
+ className: "bl-font-field__option",
242
+ onMouseDown: (event) => {
243
+ event.preventDefault();
244
+ choose(option);
245
+ },
246
+ onMouseEnter: () => setActive(i),
247
+ children: [/* @__PURE__ */ jsx("span", {
248
+ className: "bl-font-field__family",
249
+ style: option.specimen ? { fontFamily: `"${option.family}"` } : void 0,
250
+ children: option.family
251
+ }), /* @__PURE__ */ jsx("span", {
252
+ className: "bl-font-field__hint",
253
+ children: option.hint
254
+ })]
255
+ }, option.value || "same"))
256
+ }) : null]
257
+ }),
258
+ /* @__PURE__ */ jsx(FieldDescription, {
259
+ description: field.admin?.description,
260
+ path
261
+ }),
262
+ /* @__PURE__ */ jsx(FieldError, {
263
+ path,
264
+ showError,
265
+ message: errorMessage
266
+ })
267
+ ]
268
+ });
269
+ };
270
+ const CSS$1 = `
271
+ .bl-font-field__control{position:relative}
272
+ .bl-font-field__trigger{display:flex;align-items:center;width:100%;min-height:calc(var(--base) * 2);padding:calc(var(--base) * .25) calc(var(--base) * .6);border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-input-bg);color:var(--theme-elevation-800);font:inherit;text-align:left;cursor:pointer}
273
+ .bl-font-field__trigger:focus{border-color:var(--theme-elevation-400);outline:none;box-shadow:0 0 0 1px var(--theme-elevation-400)}
274
+ .bl-font-field__trigger:disabled{color:var(--theme-elevation-400);cursor:not-allowed}
275
+ .bl-font-field__menu{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:10;margin:0;padding:calc(var(--base) * .2) 0;list-style:none;max-height:18rem;overflow:auto;border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-elevation-0);box-shadow:0 8px 24px var(--theme-overlay)}
276
+ .bl-font-field__option{display:flex;flex-direction:column;gap:2px;padding:calc(var(--base) * .35) calc(var(--base) * .6);cursor:pointer}
277
+ .bl-font-field__option[aria-selected="true"],.bl-font-field__option:hover{background:var(--theme-elevation-100)}
278
+ .bl-font-field__family{font-weight:500}
279
+ .bl-font-field__hint{color:var(--theme-elevation-500);font-size:12px}
280
+ `;
281
+ //#endregion
282
+ //#region src/admin/contrast-report.tsx
283
+ function fieldValue(fields, path) {
284
+ return fields[path]?.value;
285
+ }
286
+ function isCompleteBrand(values) {
287
+ return isThemeHex(values.primary) && isThemeHex(values.secondary) && isThemeHex(values.accent) && isThemeHex(values.highlight) && isThemeHex(values.success) && typeof values.greyScale === "string";
288
+ }
289
+ function LevelIcon({ level }) {
290
+ if (level === "fail") return /* @__PURE__ */ jsx("svg", {
291
+ viewBox: "0 0 16 16",
292
+ width: "14",
293
+ height: "14",
294
+ "aria-hidden": "true",
295
+ children: /* @__PURE__ */ jsx("path", {
296
+ fill: "currentColor",
297
+ d: "M8 1.5 1.5 14h13L8 1.5zm0 4.2c.4 0 .7.3.7.8v3.2c0 .4-.3.8-.7.8s-.8-.4-.8-.8V6.5c0-.5.4-.8.8-.8zm0 6.3a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"
298
+ })
299
+ });
300
+ if (level === "aa-large") return /* @__PURE__ */ jsx("svg", {
301
+ viewBox: "0 0 16 16",
302
+ width: "14",
303
+ height: "14",
304
+ "aria-hidden": "true",
305
+ children: /* @__PURE__ */ jsx("path", {
306
+ fill: "currentColor",
307
+ d: "M3 8h10v1.5H3z"
308
+ })
309
+ });
310
+ return /* @__PURE__ */ jsx("svg", {
311
+ viewBox: "0 0 16 16",
312
+ width: "14",
313
+ height: "14",
314
+ "aria-hidden": "true",
315
+ children: /* @__PURE__ */ jsx("path", {
316
+ fill: "currentColor",
317
+ d: "M8 1.2a6.8 6.8 0 1 1 0 13.6A6.8 6.8 0 0 1 8 1.2zm3.1 4.3L7.2 9.4 5 7.2l-.9.9 3.1 3.1 4.8-4.8-.9-.9z"
318
+ })
319
+ });
320
+ }
321
+ /**
322
+ * Reads the five brand colours and grey scale from the form, runs
323
+ * `auditTheme` client-side, and paints its own rows. Warn only: nothing
324
+ * here touches validity.
325
+ */
326
+ const ContrastReport = ({ field }) => {
327
+ const target = Number((field.admin?.custom)?.target) || 4.5;
328
+ const values = useFormFields(([fields]) => {
329
+ const record = fields;
330
+ return {
331
+ primary: fieldValue(record, "brand.primary"),
332
+ secondary: fieldValue(record, "brand.secondary"),
333
+ accent: fieldValue(record, "brand.accent"),
334
+ highlight: fieldValue(record, "brand.highlight"),
335
+ success: fieldValue(record, "brand.success"),
336
+ greyScale: fieldValue(record, "greyScale")
337
+ };
338
+ });
339
+ const last = useRef(null);
340
+ const complete = isCompleteBrand(values);
341
+ if (complete) last.current = auditTheme({
342
+ brandPrimary: values.primary,
343
+ brandSecondary: values.secondary,
344
+ brandAccent: values.accent,
345
+ brandHighlight: values.highlight,
346
+ brandSuccess: values.success,
347
+ greyScale: values.greyScale
348
+ }, { target });
349
+ const checks = last.current;
350
+ const stale = Boolean(checks) && !complete;
351
+ const failing = checks?.filter((check) => !check.meetsTarget).length ?? 0;
352
+ const summary = !checks ? "Enter five hex colours to measure contrast." : failing === 0 ? `Every pairing meets ${target}:1` : `${failing} of ${checks.length} pairings below ${target}:1`;
353
+ return /* @__PURE__ */ jsxs("div", {
354
+ className: "field-type bl-contrast-report",
355
+ children: [
356
+ /* @__PURE__ */ jsx("style", {
357
+ href: "bl-contrast-report",
358
+ precedence: "default",
359
+ children: CSS
360
+ }),
361
+ /* @__PURE__ */ jsx("p", {
362
+ className: "bl-contrast-report__summary",
363
+ children: summary
364
+ }),
365
+ stale ? /* @__PURE__ */ jsx("p", {
366
+ className: "bl-contrast-report__stale",
367
+ children: "Showing the last complete audit while a colour is being typed."
368
+ }) : null,
369
+ checks ? /* @__PURE__ */ jsx("ul", {
370
+ className: "bl-contrast-report__list",
371
+ children: checks.flatMap((check) => ["light", "dark"].map((mode) => {
372
+ const sample = check[mode];
373
+ const failingRow = sample.ratio < check.target;
374
+ const verdict = contrastVerdict(sample, check.target);
375
+ return /* @__PURE__ */ jsxs("li", {
376
+ className: "bl-contrast-report__row",
377
+ "data-failing": failingRow ? "" : void 0,
378
+ children: [
379
+ /* @__PURE__ */ jsx("span", {
380
+ "aria-hidden": "true",
381
+ className: "bl-contrast-report__swatch",
382
+ style: {
383
+ color: `hsl(${sample.foreground})`,
384
+ backgroundColor: `hsl(${sample.background})`
385
+ },
386
+ children: "Aa"
387
+ }),
388
+ /* @__PURE__ */ jsxs("span", {
389
+ className: "bl-contrast-report__ratio",
390
+ children: [sample.ratio.toFixed(2), ":1"]
391
+ }),
392
+ /* @__PURE__ */ jsxs("span", {
393
+ className: "bl-contrast-report__level",
394
+ children: [/* @__PURE__ */ jsx(LevelIcon, { level: sample.level }), contrastLevelLabel(sample.level)]
395
+ }),
396
+ /* @__PURE__ */ jsx("span", {
397
+ className: "bl-contrast-report__verdict",
398
+ children: verdict.text
399
+ }),
400
+ /* @__PURE__ */ jsxs("span", {
401
+ className: "bl-contrast-report__label",
402
+ children: [
403
+ check.label,
404
+ " (",
405
+ mode === "light" ? "Light" : "Dark",
406
+ ")"
407
+ ]
408
+ })
409
+ ]
410
+ }, `${check.id}-${mode}`);
411
+ }))
412
+ }) : null
413
+ ]
414
+ });
415
+ };
416
+ const CSS = `
417
+ .bl-contrast-report{margin:calc(var(--base) * .6) 0}
418
+ .bl-contrast-report__summary{margin:0 0 calc(var(--base) * .3);font-weight:600}
419
+ .bl-contrast-report__stale{margin:0 0 calc(var(--base) * .3);color:var(--theme-elevation-500);font-size:12px}
420
+ .bl-contrast-report__list{margin:0;padding:0;list-style:none;border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);background:var(--theme-elevation-0)}
421
+ .bl-contrast-report__row{display:flex;flex-wrap:wrap;align-items:center;gap:calc(var(--base) * .4);padding:calc(var(--base) * .35) calc(var(--base) * .6);border-top:1px solid var(--theme-elevation-100)}
422
+ .bl-contrast-report__row:first-child{border-top:0}
423
+ .bl-contrast-report__row[data-failing]{background:var(--theme-error-50)}
424
+ .bl-contrast-report__swatch{flex:none;display:inline-flex;align-items:center;justify-content:center;width:2.5rem;height:1.75rem;border:1px solid var(--theme-elevation-150);border-radius:var(--style-radius-s);font-weight:600}
425
+ .bl-contrast-report__ratio{width:4.5rem;font-variant-numeric:tabular-nums}
426
+ .bl-contrast-report__level{display:inline-flex;align-items:center;gap:4px;width:5.5rem}
427
+ .bl-contrast-report__verdict{width:6rem;font-weight:600}
428
+ .bl-contrast-report__label{flex:1;min-width:12rem;color:var(--theme-elevation-500)}
429
+ `;
430
+ //#endregion
431
+ export { ColorField, ContrastReport, FontField };
432
+
433
+ //# sourceMappingURL=admin.mjs.map