@bison-lab/payload-core 0.1.0 → 3.7.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 +96 -17
- package/dist/admin.d.mts +28 -0
- package/dist/admin.d.mts.map +1 -0
- package/dist/admin.mjs +433 -0
- package/dist/admin.mjs.map +1 -0
- package/dist/index.d.mts +35 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +159 -1
- package/dist/index.mjs.map +1 -1
- package/dist/metadata.d.mts +1 -1
- package/dist/theme.d.mts +38 -0
- package/dist/theme.d.mts.map +1 -0
- package/dist/theme.mjs +39 -0
- package/dist/theme.mjs.map +1 -0
- package/dist/{title-Wut0nzJQ.d.mts → title-B2-gWQZd.d.mts} +1 -1
- package/dist/{title-Wut0nzJQ.d.mts.map → title-B2-gWQZd.d.mts.map} +1 -1
- package/dist/types-B3FC9sdF.d.mts +89 -0
- package/dist/types-B3FC9sdF.d.mts.map +1 -0
- package/dist/types-DWwL-JEr.mjs +76 -0
- package/dist/types-DWwL-JEr.mjs.map +1 -0
- package/package.json +39 -4
package/README.md
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
# `@bison-lab/payload-core`
|
|
2
2
|
|
|
3
|
-
Site-agnostic Payload CMS configuration for Bison Lab sites
|
|
4
|
-
|
|
5
|
-
rest of the shared CMS layer (access matrix, collection
|
|
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
|
|
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. Pin the plugin to the same
|
|
13
17
|
version as `payload`; Payload releases them in lockstep.
|
|
14
18
|
|
|
15
|
-
##
|
|
19
|
+
## Four entry points, and why
|
|
16
20
|
|
|
17
|
-
| Import | Contents
|
|
18
|
-
| ---------------------------------- |
|
|
19
|
-
| `@bison-lab/payload-core` | `seoPlugin`, `
|
|
20
|
-
| `@bison-lab/payload-core/metadata` | `pageMetadata`, the title helpers, the same types
|
|
21
|
+
| Import | Contents | Runs where |
|
|
22
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
|
23
|
+
| `@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. |
|
|
24
|
+
| `@bison-lab/payload-core/metadata` | `pageMetadata`, the title helpers, the same types | Server. What a page route imports; it does not load the plugin. |
|
|
25
|
+
| `@bison-lab/payload-core/theme` | `getPublishedTheme`, `themeConfigFromDoc`, `themeHead` | Server. What a root layout imports; it does not load the plugin or React. |
|
|
26
|
+
| `@bison-lab/payload-core/admin` | `ColorField`, `FontField`, `ContrastReport` | Admin. Referenced by import-map string; `generate:importmap` writes it. |
|
|
21
27
|
|
|
22
28
|
## What editors get
|
|
23
29
|
|
|
@@ -114,15 +120,88 @@ carry through Next's metadata merge.
|
|
|
114
120
|
|
|
115
121
|
The sitemap is the site's: filter `meta.noIndex` out of it.
|
|
116
122
|
|
|
123
|
+
## Adopting the Theme global
|
|
124
|
+
|
|
125
|
+
Settings → Theme: the five brand colours, grey scale, radius, shadow, motion,
|
|
126
|
+
density, default theme, body and heading fonts, and an optional logo. Whoever
|
|
127
|
+
holds brand rights edits a draft; publish is deliberate. Contrast is measured
|
|
128
|
+
as they type and shown; it is never enforced. `bison.config.json` is the seed
|
|
129
|
+
a site starts from, not the source of truth.
|
|
130
|
+
|
|
131
|
+
**1. Register the Global.** Access is required — the predicates live in the
|
|
132
|
+
site's `src/platform` until they move here. Pass `canManageBrand` as
|
|
133
|
+
`update`.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// payload.config.ts
|
|
137
|
+
import { createTheme } from "@bison-lab/payload-core";
|
|
138
|
+
import bisonConfig from "../bison.config.json";
|
|
139
|
+
import { canManageBrand, isAuthenticated } from "@/platform/access";
|
|
140
|
+
|
|
141
|
+
export default buildConfig({
|
|
142
|
+
globals: [
|
|
143
|
+
createTheme({
|
|
144
|
+
access: { read: isAuthenticated, update: canManageBrand },
|
|
145
|
+
seed: bisonConfig,
|
|
146
|
+
logo: { collection: "brand-assets" },
|
|
147
|
+
onPublish: async () => {
|
|
148
|
+
revalidateTag("theme");
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Then `payload generate:importmap` (the colour, font and contrast fields
|
|
156
|
+
resolve from `@bison-lab/payload-core/admin`), `payload generate:types`, and
|
|
157
|
+
`payload migrate:create`.
|
|
158
|
+
|
|
159
|
+
**2. Seed the row on deploy.** A migration `up()` writes a published version
|
|
160
|
+
so the site renders what `bison-theme.css` rendered before anyone opens the
|
|
161
|
+
admin:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { seedTheme } from "@bison-lab/payload-core";
|
|
165
|
+
import bisonConfig from "../bison.config.json";
|
|
166
|
+
|
|
167
|
+
export async function up({ payload }) {
|
|
168
|
+
await seedTheme(payload, bisonConfig);
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**3. Serve the catalogue.** A Next route around `serveFont` from
|
|
173
|
+
`@bison-lab/fonts/server`, listed in `serverExternalPackages`, at the path
|
|
174
|
+
you pass as `fontsBaseUrl` (default `/fonts`).
|
|
175
|
+
|
|
176
|
+
**4. Render the published theme in the root layout.**
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// app/layout.tsx
|
|
180
|
+
import { getPublishedTheme, themeHead } from "@bison-lab/payload-core/theme";
|
|
181
|
+
import bisonConfig from "../bison.config.json";
|
|
182
|
+
|
|
183
|
+
const theme = await getPublishedTheme(payload, bisonConfig);
|
|
184
|
+
const { css, preloads } = themeHead(theme);
|
|
185
|
+
|
|
186
|
+
// <link rel="preload" as="font" type="font/woff2" crossOrigin="" href={href} />
|
|
187
|
+
// <style dangerouslySetInnerHTML={{ __html: css }} />
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
If the admin layout renders the same head, the admin panel restyles too.
|
|
191
|
+
|
|
192
|
+
`getPublishedTheme` reads `draft: false` and falls back to the seed when
|
|
193
|
+
nothing has been published. A newer draft never reaches the public site.
|
|
194
|
+
|
|
117
195
|
## No generated types
|
|
118
196
|
|
|
119
197
|
The package cannot import a site's `payload-types`, so the shapes it reads
|
|
120
|
-
(`SeoMeta`, `SeoImageDoc`, `SeoPage`) are hand-written and
|
|
121
|
-
generated `Page
|
|
122
|
-
signature, since an interface will not assign
|
|
198
|
+
(`SeoMeta`, `SeoImageDoc`, `SeoPage`, `ThemeDoc`) are hand-written and
|
|
199
|
+
structural. A generated `Page`, `Media` or Theme Global is assignable to
|
|
200
|
+
them; nothing carries an index signature, since an interface will not assign
|
|
201
|
+
to a type that has one.
|
|
123
202
|
|
|
124
203
|
## Changing a field
|
|
125
204
|
|
|
126
|
-
`noIndexField
|
|
127
|
-
site. A change to them is a schema change: say
|
|
128
|
-
in the changeset.
|
|
205
|
+
`noIndexField`, the plugin's own fields, and the Theme Global fields are
|
|
206
|
+
columns in every consuming site. A change to them is a schema change: say
|
|
207
|
+
"run `payload migrate:create`" in the changeset.
|
package/dist/admin.d.mts
ADDED
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.mjs","names":["CSS","CSS"],"sources":["../src/admin/color-field.tsx","../src/admin/font-field.tsx","../src/admin/contrast-report.tsx"],"sourcesContent":["import { FieldDescription, FieldError, FieldLabel, useField } from \"@payloadcms/ui\";\nimport { isThemeHex } from \"@bison-lab/tokens\";\nimport type { TextFieldClientComponent } from \"payload\";\nimport { useEffect, useId, useState } from \"react\";\n\nconst HEX_ERROR = \"Enter a six-digit hex colour like #1e3a5f\";\n\n/**\n * Swatch, native colour input, and hex text, kept in step. `#rrggbb` on\n * blur; the field's `validate` refuses anything else on save.\n */\nexport const ColorField: TextFieldClientComponent = ({ field, path, readOnly }) => {\n const { value, setValue, showError, errorMessage } = useField<string>({ path });\n const [text, setText] = useState(value ?? \"\");\n const [localError, setLocalError] = useState<string | null>(null);\n const id = useId();\n const hexId = `${id}-hex`;\n const pickerId = `${id}-picker`;\n const required = Boolean(field.required);\n const valid = isThemeHex(value);\n const message = localError ?? errorMessage;\n const show = Boolean(localError) || showError;\n\n useEffect(() => {\n setText(value ?? \"\");\n }, [value]);\n\n function commit(next: string) {\n const trimmed = next.trim();\n if (isThemeHex(trimmed)) {\n setValue(trimmed);\n setLocalError(null);\n return;\n }\n setLocalError(HEX_ERROR);\n }\n\n return (\n <div className=\"field-type bl-color-field\">\n <style href=\"bl-color-field\" precedence=\"default\">\n {CSS}\n </style>\n <FieldLabel htmlFor={hexId} label={field.label} required={required} />\n <div className=\"bl-color-field__control\">\n <span\n className=\"bl-color-field__swatch\"\n aria-hidden=\"true\"\n style={{ background: valid ? value : \"transparent\" }}\n />\n <input\n id={pickerId}\n type=\"color\"\n className=\"bl-color-field__picker\"\n disabled={readOnly}\n value={valid ? value : \"#000000\"}\n aria-label={`${typeof field.label === \"string\" ? field.label : \"Colour\"} picker`}\n onChange={(event) => {\n setValue(event.target.value);\n setText(event.target.value);\n setLocalError(null);\n }}\n />\n <input\n id={hexId}\n type=\"text\"\n className=\"bl-color-field__hex\"\n spellCheck={false}\n autoComplete=\"off\"\n disabled={readOnly}\n value={text}\n onChange={(event) => {\n setText(event.target.value);\n if (isThemeHex(event.target.value.trim())) {\n setValue(event.target.value.trim());\n setLocalError(null);\n }\n }}\n onBlur={() => commit(text)}\n />\n </div>\n <FieldDescription description={field.admin?.description} path={path} />\n <FieldError path={path} showError={show} message={message} />\n </div>\n );\n};\n\nconst CSS = `\n.bl-color-field__control{display:flex;align-items:center;gap:calc(var(--base) * .4)}\n.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)}\n.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}\n.bl-color-field__picker:disabled{cursor:not-allowed;opacity:.6}\n.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}\n.bl-color-field__hex:focus{border-color:var(--theme-elevation-400);outline:none;box-shadow:0 0 0 1px var(--theme-elevation-400)}\n.bl-color-field__hex:disabled{color:var(--theme-elevation-400)}\n`;\n","import { FieldDescription, FieldError, FieldLabel, useField } from \"@payloadcms/ui\";\nimport { findFont, fontFaceCss, type FontId } from \"@bison-lab/fonts\";\nimport type { SelectFieldClientComponent } from \"payload\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\n\nimport { SAME_AS_BODY } from \"../theme/fields\";\n\ntype Option = { value: string; family: string; hint: string; specimen: boolean };\n\n/**\n * Listbox over the catalogue ids in `admin.custom.ids`. Each option is the\n * family name in that face, with the catalogue hint beneath. Faces load\n * from the site's font route the first time the picker opens.\n */\nexport const FontField: SelectFieldClientComponent = ({ field, path, readOnly }) => {\n const { value, setValue, showError, errorMessage } = useField<string>({ path });\n const custom = (field.admin?.custom ?? {}) as {\n fontsBaseUrl?: string;\n ids?: string[];\n sameAsBody?: boolean;\n };\n const ids = custom.ids ?? [];\n const fontsBaseUrl = custom.fontsBaseUrl ?? \"/fonts\";\n const sameAsBody = Boolean(custom.sameAsBody);\n\n const options = useMemo<Option[]>(() => {\n const fromCatalogue: Option[] = ids.flatMap((id) => {\n const font = findFont(id);\n if (!font) return [];\n return [{ value: font.id, family: font.family, hint: font.hint, specimen: true }];\n });\n if (!sameAsBody) return fromCatalogue;\n return [{ value: SAME_AS_BODY, family: \"Same as body\", hint: \"Use the body family for headings\", specimen: false }, ...fromCatalogue];\n }, [ids, sameAsBody]);\n\n const [open, setOpen] = useState(false);\n const [faces, setFaces] = useState(false);\n const [active, setActive] = useState(-1);\n const typed = useRef(\"\");\n const typedAt = useRef(0);\n const id = useId();\n const buttonId = `${id}-button`;\n const listId = `${id}-list`;\n const required = Boolean(field.required);\n\n useEffect(() => {\n if (open) setFaces(true);\n }, [open]);\n\n const selected = options.find((option) => option.value === (value ?? SAME_AS_BODY)) ?? options[0];\n\n function choose(option: Option) {\n setValue(option.value);\n setOpen(false);\n setActive(-1);\n }\n\n function onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n if (!open) {\n setOpen(true);\n setActive(0);\n return;\n }\n setActive((i) => (options.length ? (i + 1) % options.length : -1));\n } else if (event.key === \"ArrowUp\") {\n event.preventDefault();\n if (!open) {\n setOpen(true);\n setActive(options.length - 1);\n return;\n }\n setActive((i) => (options.length ? (i <= 0 ? options.length - 1 : i - 1) : -1));\n } else if (event.key === \"Enter\") {\n if (!open) return;\n event.preventDefault();\n const pick = options[active] ?? options[0];\n if (pick) choose(pick);\n } else if (event.key === \"Escape\") {\n if (!open) return;\n event.preventDefault();\n event.stopPropagation();\n setOpen(false);\n setActive(-1);\n } else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {\n const now = Date.now();\n typed.current = now - typedAt.current > 600 ? event.key : typed.current + event.key;\n typedAt.current = now;\n const needle = typed.current.toLowerCase();\n const index = options.findIndex((option) => option.family.toLowerCase().startsWith(needle));\n if (index >= 0) {\n if (!open) setOpen(true);\n setActive(index);\n }\n }\n }\n\n const activeId = active >= 0 && open ? `${id}-option-${active}` : undefined;\n\n return (\n <div className=\"field-type bl-font-field\">\n <style href=\"bl-font-field\" precedence=\"default\">\n {CSS}\n </style>\n {faces ? <style data-bl-font-faces=\"\">{fontFaceCss(ids as FontId[], fontsBaseUrl)}</style> : null}\n <FieldLabel htmlFor={buttonId} label={field.label} required={required} />\n <div className=\"bl-font-field__control\">\n <button\n id={buttonId}\n type=\"button\"\n className=\"bl-font-field__trigger\"\n disabled={readOnly}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-controls={listId}\n aria-activedescendant={activeId}\n onClick={() => setOpen((was) => !was)}\n onBlur={() => setOpen(false)}\n onKeyDown={onKeyDown}\n >\n {selected?.family ?? \"Choose a font\"}\n </button>\n {open ? (\n <ul id={listId} role=\"listbox\" className=\"bl-font-field__menu\">\n {options.map((option, i) => (\n <li\n key={option.value || \"same\"}\n id={`${id}-option-${i}`}\n role=\"option\"\n aria-selected={option.value === (value ?? SAME_AS_BODY)}\n className=\"bl-font-field__option\"\n onMouseDown={(event) => {\n event.preventDefault();\n choose(option);\n }}\n onMouseEnter={() => setActive(i)}\n >\n <span\n className=\"bl-font-field__family\"\n style={option.specimen ? { fontFamily: `\"${option.family}\"` } : undefined}\n >\n {option.family}\n </span>\n <span className=\"bl-font-field__hint\">{option.hint}</span>\n </li>\n ))}\n </ul>\n ) : null}\n </div>\n <FieldDescription description={field.admin?.description} path={path} />\n <FieldError path={path} showError={showError} message={errorMessage} />\n </div>\n );\n};\n\nconst CSS = `\n.bl-font-field__control{position:relative}\n.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}\n.bl-font-field__trigger:focus{border-color:var(--theme-elevation-400);outline:none;box-shadow:0 0 0 1px var(--theme-elevation-400)}\n.bl-font-field__trigger:disabled{color:var(--theme-elevation-400);cursor:not-allowed}\n.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)}\n.bl-font-field__option{display:flex;flex-direction:column;gap:2px;padding:calc(var(--base) * .35) calc(var(--base) * .6);cursor:pointer}\n.bl-font-field__option[aria-selected=\"true\"],.bl-font-field__option:hover{background:var(--theme-elevation-100)}\n.bl-font-field__family{font-weight:500}\n.bl-font-field__hint{color:var(--theme-elevation-500);font-size:12px}\n`;\n","import { useFormFields } from \"@payloadcms/ui\";\nimport {\n auditTheme,\n contrastLevelLabel,\n contrastVerdict,\n isThemeHex,\n type ContrastCheck,\n type ContrastLevel,\n type GreyScale,\n} from \"@bison-lab/tokens\";\nimport type { UIFieldClientComponent } from \"payload\";\nimport { useRef } from \"react\";\n\nfunction fieldValue(\n fields: Record<string, { value?: unknown } | undefined>,\n path: string,\n): unknown {\n return fields[path]?.value;\n}\n\ntype BrandFormValues = {\n primary: unknown;\n secondary: unknown;\n accent: unknown;\n highlight: unknown;\n success: unknown;\n greyScale: unknown;\n};\n\nfunction isCompleteBrand(values: BrandFormValues): values is BrandFormValues & {\n primary: string;\n secondary: string;\n accent: string;\n highlight: string;\n success: string;\n greyScale: GreyScale;\n} {\n return (\n isThemeHex(values.primary) &&\n isThemeHex(values.secondary) &&\n isThemeHex(values.accent) &&\n isThemeHex(values.highlight) &&\n isThemeHex(values.success) &&\n typeof values.greyScale === \"string\"\n );\n}\n\nfunction LevelIcon({ level }: { level: ContrastLevel }) {\n if (level === \"fail\") {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" aria-hidden=\"true\">\n <path\n fill=\"currentColor\"\n 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\"\n />\n </svg>\n );\n }\n if (level === \"aa-large\") {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" aria-hidden=\"true\">\n <path fill=\"currentColor\" d=\"M3 8h10v1.5H3z\" />\n </svg>\n );\n }\n return (\n <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" aria-hidden=\"true\">\n <path\n fill=\"currentColor\"\n 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\"\n />\n </svg>\n );\n}\n\n/**\n * Reads the five brand colours and grey scale from the form, runs\n * `auditTheme` client-side, and paints its own rows. Warn only: nothing\n * here touches validity.\n */\nexport const ContrastReport: UIFieldClientComponent = ({ field }) => {\n const target = Number((field.admin?.custom as { target?: number } | undefined)?.target) || 4.5;\n const values = useFormFields(([fields]) => {\n const record = fields as Record<string, { value?: unknown } | undefined>;\n return {\n primary: fieldValue(record, \"brand.primary\"),\n secondary: fieldValue(record, \"brand.secondary\"),\n accent: fieldValue(record, \"brand.accent\"),\n highlight: fieldValue(record, \"brand.highlight\"),\n success: fieldValue(record, \"brand.success\"),\n greyScale: fieldValue(record, \"greyScale\"),\n };\n });\n\n const last = useRef<ContrastCheck[] | null>(null);\n const complete = isCompleteBrand(values);\n if (complete) {\n last.current = auditTheme(\n {\n brandPrimary: values.primary,\n brandSecondary: values.secondary,\n brandAccent: values.accent,\n brandHighlight: values.highlight,\n brandSuccess: values.success,\n greyScale: values.greyScale,\n },\n { target },\n );\n }\n\n const checks = last.current;\n const stale = Boolean(checks) && !complete;\n const failing = checks?.filter((check) => !check.meetsTarget).length ?? 0;\n const summary = !checks\n ? \"Enter five hex colours to measure contrast.\"\n : failing === 0\n ? `Every pairing meets ${target}:1`\n : `${failing} of ${checks.length} pairings below ${target}:1`;\n\n return (\n <div className=\"field-type bl-contrast-report\">\n <style href=\"bl-contrast-report\" precedence=\"default\">\n {CSS}\n </style>\n <p className=\"bl-contrast-report__summary\">{summary}</p>\n {stale ? (\n <p className=\"bl-contrast-report__stale\">Showing the last complete audit while a colour is being typed.</p>\n ) : null}\n {checks ? (\n <ul className=\"bl-contrast-report__list\">\n {checks.flatMap((check) =>\n ([\"light\", \"dark\"] as const).map((mode) => {\n const sample = check[mode];\n const failingRow = sample.ratio < check.target;\n const verdict = contrastVerdict(sample, check.target);\n return (\n <li\n key={`${check.id}-${mode}`}\n className=\"bl-contrast-report__row\"\n data-failing={failingRow ? \"\" : undefined}\n >\n <span\n aria-hidden=\"true\"\n className=\"bl-contrast-report__swatch\"\n style={{\n color: `hsl(${sample.foreground})`,\n backgroundColor: `hsl(${sample.background})`,\n }}\n >\n Aa\n </span>\n <span className=\"bl-contrast-report__ratio\">{sample.ratio.toFixed(2)}:1</span>\n <span className=\"bl-contrast-report__level\">\n <LevelIcon level={sample.level} />\n {contrastLevelLabel(sample.level)}\n </span>\n <span className=\"bl-contrast-report__verdict\">{verdict.text}</span>\n <span className=\"bl-contrast-report__label\">\n {check.label} ({mode === \"light\" ? \"Light\" : \"Dark\"})\n </span>\n </li>\n );\n }),\n )}\n </ul>\n ) : null}\n </div>\n );\n};\n\nconst CSS = `\n.bl-contrast-report{margin:calc(var(--base) * .6) 0}\n.bl-contrast-report__summary{margin:0 0 calc(var(--base) * .3);font-weight:600}\n.bl-contrast-report__stale{margin:0 0 calc(var(--base) * .3);color:var(--theme-elevation-500);font-size:12px}\n.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)}\n.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)}\n.bl-contrast-report__row:first-child{border-top:0}\n.bl-contrast-report__row[data-failing]{background:var(--theme-error-50)}\n.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}\n.bl-contrast-report__ratio{width:4.5rem;font-variant-numeric:tabular-nums}\n.bl-contrast-report__level{display:inline-flex;align-items:center;gap:4px;width:5.5rem}\n.bl-contrast-report__verdict{width:6rem;font-weight:600}\n.bl-contrast-report__label{flex:1;min-width:12rem;color:var(--theme-elevation-500)}\n`;\n"],"mappings":"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"}
|