@bison-lab/payload-core 3.12.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/admin.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { a as colorDocFromState, i as SYSTEM_COLOR_KEYS, n as pageEditorLooks, r as pageEditorTokens, s as stateFromColorDoc } from "./looks-BbL309kO.mjs";
3
- import { ArrayField, CheckboxInput, FieldDescription, FieldError, FieldLabel, fieldBaseClass, useConfig, useField, useForm, useFormFields } from "@payloadcms/ui";
3
+ import { ArrayField, CheckboxInput, FieldDescription, FieldError, FieldLabel, fieldBaseClass, useConfig, useField, useForm, useFormFields, useRowLabel } from "@payloadcms/ui";
4
4
  import { GREY_SCALES, SHADE_STEPS, contrastForeground, contrastRatio, createColorScale, deriveDarkPalette, deriveLightPalette, hexToHSL, hslToString, includedShadeSteps, isThemeHex, presetHints, setColorScaleInclude } from "@bison-lab/tokens";
5
5
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
6
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -1010,15 +1010,19 @@ function deleteLibraryColor(doc, key, replacement) {
1010
1010
  return next;
1011
1011
  }
1012
1012
  //#endregion
1013
+ //#region src/theme/color-usages.ts
1014
+ /** Theme-store REST path. LibraryField calls `/api/globals/theme` + this. */
1015
+ const COLOR_USAGES_PATH = "/color-usages";
1016
+ //#endregion
1013
1017
  //#region src/admin/library-field.tsx
1014
1018
  function slugify(value) {
1015
1019
  return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1016
1020
  }
1017
1021
  /**
1018
- * Custom colors: the same scale controls as system colors. A row that is
1019
- * not referenced on a page deletes immediately. Rewriting in-use tokens
1020
- * (`rewriteColorToken`) waits on a site usage lookup Theme cannot see
1021
- * page assignments by itself.
1022
+ * Custom colors: the same scale controls as system colors. Unused: the
1023
+ * Theme store's color-usages endpoint returns nothing and the row is
1024
+ * gone. In use: pick a live replacement; confirm rewrites stored tokens
1025
+ * then drops the row. Success and Destructive stay off that list.
1022
1026
  */
1023
1027
  const LibraryField = ({ field, path, readOnly }) => {
1024
1028
  const dataPath = typeof field.admin?.custom?.dataPath === "string" ? field.admin.custom.dataPath : path;
@@ -1027,14 +1031,21 @@ const LibraryField = ({ field, path, readOnly }) => {
1027
1031
  hasRows: true
1028
1032
  });
1029
1033
  const { getDataByPath } = useForm();
1034
+ const { config } = useConfig();
1030
1035
  const rows = asLibraryRows(value).length > 0 ? asLibraryRows(value) : asLibraryRows(getDataByPath?.(dataPath));
1031
1036
  const [adding, setAdding] = useState(false);
1032
1037
  const [name, setName] = useState("");
1033
1038
  const [hex, setHex] = useState("");
1034
1039
  const [sourceStep, setSourceStep] = useState(500);
1040
+ const [replacing, setReplacing] = useState(null);
1041
+ const [replacement, setReplacement] = useState("");
1042
+ const [busy, setBusy] = useState(false);
1035
1043
  function write(next) {
1036
1044
  setValue(next);
1037
1045
  }
1046
+ function usagesUrl(key) {
1047
+ return `${config.serverURL ?? ""}${config.routes?.api ?? "/api"}/globals/theme${COLOR_USAGES_PATH}?key=${encodeURIComponent(key)}`;
1048
+ }
1038
1049
  function add() {
1039
1050
  const key = slugify(name);
1040
1051
  if (!key || !isThemeHex(hex) || rows.some((row) => row.key === key)) return;
@@ -1048,9 +1059,42 @@ const LibraryField = ({ field, path, readOnly }) => {
1048
1059
  setSourceStep(500);
1049
1060
  setAdding(false);
1050
1061
  }
1051
- function remove(row) {
1052
- write(deleteLibraryColor({ colors: { library: rows } }, row.key ?? "").colors?.library ?? []);
1062
+ async function remove(row) {
1063
+ const key = row.key ?? "";
1064
+ const response = await fetch(usagesUrl(key), { credentials: "include" });
1065
+ if (!response.ok) return;
1066
+ const usages = (await response.json()).usages ?? [];
1067
+ if (usages.length === 0) {
1068
+ write(deleteLibraryColor({ colors: { library: rows } }, key).colors?.library ?? []);
1069
+ return;
1070
+ }
1071
+ setReplacement("");
1072
+ setReplacing({
1073
+ row,
1074
+ usages
1075
+ });
1053
1076
  }
1077
+ async function confirmReplace() {
1078
+ if (!replacing || !replacement || busy) return;
1079
+ const key = replacing.row.key ?? "";
1080
+ setBusy(true);
1081
+ try {
1082
+ if (!(await fetch(usagesUrl(key), {
1083
+ method: "POST",
1084
+ credentials: "include",
1085
+ headers: { "Content-Type": "application/json" },
1086
+ body: JSON.stringify({
1087
+ key,
1088
+ replacement
1089
+ })
1090
+ })).ok) return;
1091
+ write(deleteLibraryColor({ colors: { library: rows } }, key, replacement).colors?.library ?? []);
1092
+ setReplacing(null);
1093
+ } finally {
1094
+ setBusy(false);
1095
+ }
1096
+ }
1097
+ const replacementOptions = replacing ? pageEditorLooks({ colors: { library: rows.filter((row) => row.key !== replacing.row.key) } }) : [];
1054
1098
  return /* @__PURE__ */ jsxs("div", {
1055
1099
  className: "field-type bl-library",
1056
1100
  children: [
@@ -1136,6 +1180,69 @@ const LibraryField = ({ field, path, readOnly }) => {
1136
1180
  ]
1137
1181
  })
1138
1182
  }) }) : null,
1183
+ replacing ? /* @__PURE__ */ jsx(ThemePortal, { children: /* @__PURE__ */ jsx("div", {
1184
+ className: "bl-library__modal",
1185
+ role: "dialog",
1186
+ "aria-modal": "true",
1187
+ "aria-labelledby": "bl-library-replace-title",
1188
+ onClick: (event) => {
1189
+ if (event.target === event.currentTarget && !busy) setReplacing(null);
1190
+ },
1191
+ children: /* @__PURE__ */ jsxs("div", {
1192
+ className: "bl-library__panel",
1193
+ children: [
1194
+ /* @__PURE__ */ jsxs("div", {
1195
+ className: "bl-library__panel-head",
1196
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("h2", {
1197
+ id: "bl-library-replace-title",
1198
+ children: ["Replace ", replacing.row.label ?? replacing.row.key]
1199
+ }), /* @__PURE__ */ jsx("p", { children: replacing.usages.length === 1 ? "1 document uses this color. Pick a replacement at the same step." : `${replacing.usages.length} documents use this color. Pick a replacement at the same step.` })] }), /* @__PURE__ */ jsx("button", {
1200
+ type: "button",
1201
+ onClick: () => setReplacing(null),
1202
+ "aria-label": "Close",
1203
+ disabled: busy,
1204
+ children: "×"
1205
+ })]
1206
+ }),
1207
+ /* @__PURE__ */ jsx("ul", {
1208
+ className: "bl-library__usages",
1209
+ children: replacing.usages.map((usage, index) => /* @__PURE__ */ jsxs("li", { children: [
1210
+ usage.collection ?? usage.global,
1211
+ usage.id != null ? ` #${usage.id}` : "",
1212
+ " · ",
1213
+ usage.path,
1214
+ " · ",
1215
+ usage.token
1216
+ ] }, `${usage.collection ?? usage.global}-${usage.id ?? ""}-${usage.path}-${index}`))
1217
+ }),
1218
+ /* @__PURE__ */ jsxs("label", { children: ["Replacement", /* @__PURE__ */ jsxs("select", {
1219
+ value: replacement,
1220
+ onChange: (event) => setReplacement(event.target.value),
1221
+ children: [/* @__PURE__ */ jsx("option", {
1222
+ value: "",
1223
+ children: "Select a color…"
1224
+ }), replacementOptions.map((option) => /* @__PURE__ */ jsx("option", {
1225
+ value: option.value,
1226
+ children: option.label
1227
+ }, option.value))]
1228
+ })] }),
1229
+ /* @__PURE__ */ jsxs("div", {
1230
+ className: "bl-library__actions",
1231
+ children: [/* @__PURE__ */ jsx("button", {
1232
+ type: "button",
1233
+ onClick: () => setReplacing(null),
1234
+ disabled: busy,
1235
+ children: "Cancel"
1236
+ }), /* @__PURE__ */ jsx("button", {
1237
+ type: "button",
1238
+ disabled: !replacement || busy,
1239
+ onClick: () => void confirmReplace(),
1240
+ children: "Replace and delete"
1241
+ })]
1242
+ })
1243
+ ]
1244
+ })
1245
+ }) }) : null,
1139
1246
  /* @__PURE__ */ jsx("div", {
1140
1247
  className: "bl-library__list",
1141
1248
  children: rows.map((row, index) => /* @__PURE__ */ jsx(ColorScaleCard, {
@@ -1152,7 +1259,7 @@ const LibraryField = ({ field, path, readOnly }) => {
1152
1259
  };
1153
1260
  write(copy);
1154
1261
  },
1155
- onDelete: readOnly ? void 0 : () => remove(row),
1262
+ onDelete: readOnly ? void 0 : () => void remove(row),
1156
1263
  readOnly
1157
1264
  }, row.key ?? index))
1158
1265
  })
@@ -1173,6 +1280,8 @@ const CSS$3 = `
1173
1280
  .bl-library__panel p{margin:.5rem 0 0;color:#475569;font-size:14px}
1174
1281
  .bl-library__panel label{display:flex;flex-direction:column;gap:.4rem;margin-top:1rem;font-size:13px;font-weight:600}
1175
1282
  .bl-library__panel input,.bl-library__panel select{min-height:2.25rem;border:1px solid #cbd5e1;border-radius:8px;padding:.35rem .5rem;background:#fff;color:#0f172a}
1283
+ .bl-library__usages{margin:1rem 0 0;padding-left:1.1rem;color:#475569;font-size:13px;font-weight:400}
1284
+ .bl-library__usages li{margin:.25rem 0}
1176
1285
  .bl-library__actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:.5rem;margin-top:1rem}
1177
1286
  .bl-library__actions button:last-child{border:0;background:#0f172a;color:#fff;font-weight:600}
1178
1287
  .bl-library__actions button:last-child:disabled{opacity:.4;cursor:not-allowed}
@@ -2433,8 +2542,18 @@ const CAPABILITIES = [
2433
2542
  function isRole(value) {
2434
2543
  return typeof value === "string" && ROLES.includes(value);
2435
2544
  }
2545
+ /** Lowercase slug: `editor`, `site-editor`. Empty and punctuation are out. */
2546
+ function isRoleSlug(value) {
2547
+ return typeof value === "string" && /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(value);
2548
+ }
2549
+ function roleLabel(role, label) {
2550
+ const trimmed = typeof label === "string" ? label.trim() : "";
2551
+ if (trimmed) return trimmed;
2552
+ return isRole(role) ? ROLE_LABELS[role] : role;
2553
+ }
2436
2554
  //#endregion
2437
2555
  //#region src/roles/matrix.ts
2556
+ const ROLES_SLUG = "roles";
2438
2557
  /**
2439
2558
  * Default rank and ticks. Brand is Designer only. The API tab is not a
2440
2559
  * column — it is locked to Developer in `isDeveloper`.
@@ -2470,21 +2589,73 @@ const DEFAULT_ROLE_MATRIX = [
2470
2589
  }
2471
2590
  ];
2472
2591
  const DEVELOPER_DESCRIPTION = "Everything Admin can do, plus the document API tab.";
2592
+ const MISSING_SEED_ROLES_MESSAGE = "Roles must include Developer, Admin, Designer, and Author.";
2593
+ const DUPLICATE_ROLE_SLUG_MESSAGE = "Each role needs a unique slug.";
2473
2594
  const CAPABILITY_LABELS = {
2474
2595
  content: "Content",
2475
2596
  brand: "Brand",
2476
2597
  publish: "Publish",
2477
2598
  users: "Users"
2478
2599
  };
2479
- function defaultRolesFieldValue() {
2480
- return DEFAULT_ROLE_MATRIX.map((row) => ({
2600
+ function seedRoleRows(extras = []) {
2601
+ return [...DEFAULT_ROLE_MATRIX.map((row) => ({
2602
+ ...row,
2603
+ label: isRole(row.role) ? ROLE_LABELS[row.role] : row.role
2604
+ })), ...extras.map((extra) => ({ ...extra }))];
2605
+ }
2606
+ function defaultRolesFieldValue(extras = []) {
2607
+ return seedRoleRows(extras).map((row) => ({
2481
2608
  id: row.role,
2482
2609
  ...row
2483
2610
  }));
2484
2611
  }
2612
+ function readRoleRow(item) {
2613
+ const role = "role" in item ? item.role : void 0;
2614
+ if (typeof role !== "string" || role.trim() === "") return { error: DUPLICATE_ROLE_SLUG_MESSAGE };
2615
+ if (!isRoleSlug(role)) return { error: DUPLICATE_ROLE_SLUG_MESSAGE };
2616
+ return {
2617
+ id: role,
2618
+ role,
2619
+ label: roleLabel(role, "label" in item && typeof item.label === "string" ? item.label : void 0),
2620
+ content: Boolean("content" in item && item.content),
2621
+ brand: Boolean("brand" in item && item.brand),
2622
+ publish: Boolean("publish" in item && item.publish),
2623
+ users: Boolean("users" in item && item.users)
2624
+ };
2625
+ }
2626
+ function parseRolesMatrix(value) {
2627
+ if (!Array.isArray(value)) return {
2628
+ ok: false,
2629
+ message: MISSING_SEED_ROLES_MESSAGE
2630
+ };
2631
+ const rows = [];
2632
+ const seen = /* @__PURE__ */ new Set();
2633
+ for (const item of value) {
2634
+ if (!item || typeof item !== "object") continue;
2635
+ const parsed = readRoleRow(item);
2636
+ if ("error" in parsed) return {
2637
+ ok: false,
2638
+ message: parsed.error
2639
+ };
2640
+ if (seen.has(parsed.role)) return {
2641
+ ok: false,
2642
+ message: DUPLICATE_ROLE_SLUG_MESSAGE
2643
+ };
2644
+ seen.add(parsed.role);
2645
+ rows.push(parsed);
2646
+ }
2647
+ if (ROLES.some((role) => !seen.has(role))) return {
2648
+ ok: false,
2649
+ message: MISSING_SEED_ROLES_MESSAGE
2650
+ };
2651
+ return {
2652
+ ok: true,
2653
+ rows
2654
+ };
2655
+ }
2485
2656
  function roleSelectOptions(matrix = DEFAULT_ROLE_MATRIX) {
2486
2657
  return matrix.map((row) => ({
2487
- label: ROLE_LABELS[row.role],
2658
+ label: roleLabel(row.role, row.label),
2488
2659
  value: row.role
2489
2660
  }));
2490
2661
  }
@@ -2495,17 +2666,58 @@ function roleDescription(role, matrix = DEFAULT_ROLE_MATRIX) {
2495
2666
  if (!row) return "No capabilities";
2496
2667
  return CAPABILITIES.filter((capability) => row[capability]).map((capability) => CAPABILITY_LABELS[capability]).join(", ") || "No capabilities";
2497
2668
  }
2669
+ /**
2670
+ * Seed ticks and package labels come back; extra rows stay as they are.
2671
+ */
2672
+ function resetRolesMatrix(current) {
2673
+ const extras = [];
2674
+ if (Array.isArray(current)) for (const item of current) {
2675
+ if (!item || typeof item !== "object") continue;
2676
+ const parsed = readRoleRow(item);
2677
+ if ("error" in parsed || isRole(parsed.role)) continue;
2678
+ extras.push(parsed);
2679
+ }
2680
+ return [...defaultRolesFieldValue(), ...extras];
2681
+ }
2498
2682
  //#endregion
2499
2683
  //#region src/admin/roles-matrix-field.tsx
2500
- const HIDE_ROW_CHROME = `.bl-roles-matrix .array-field__add-row,.bl-roles-matrix .array-actions__add,.bl-roles-matrix .array-actions__remove,.bl-roles-matrix .array-actions__duplicate,.bl-roles-matrix .array-actions__copy{display:none}.bl-roles-matrix__toolbar{display:flex;justify-content:flex-end;margin-block-end:.75rem}.bl-roles-matrix__reset{min-height:2.25rem;padding:.35rem .9rem;border:1px solid var(--theme-elevation-150,#e2e8f0);border-radius:8px;background:transparent;color:inherit;font:inherit;cursor:pointer}`;
2684
+ const HIDE_ROW_CHROME = `.bl-roles-matrix .array-actions__add,.bl-roles-matrix .array-actions__duplicate,.bl-roles-matrix .array-actions__copy{display:none}.bl-roles-matrix__toolbar{display:flex;justify-content:flex-end;margin-block-end:.75rem}.bl-roles-matrix__reset{min-height:2.25rem;padding:.35rem .9rem;border:1px solid var(--theme-elevation-150,#e2e8f0);border-radius:8px;background:transparent;color:inherit;font:inherit;cursor:pointer}body.bl-roles-matrix-seed-menu .array-actions__remove{display:none}`;
2685
+ function seedRoleFromTrigger(trigger) {
2686
+ let node = trigger.parentElement;
2687
+ while (node) {
2688
+ const slugs = node.querySelectorAll("[data-role-slug]");
2689
+ if (slugs.length === 1) return isRole(slugs[0]?.getAttribute("data-role-slug"));
2690
+ if (slugs.length > 1) return false;
2691
+ node = node.parentElement;
2692
+ }
2693
+ return false;
2694
+ }
2501
2695
  /**
2502
- * Payload's array field: drag rank stays, add/remove go away. Reset writes
2503
- * the package seed back onto the field.
2696
+ * Payload's array field: drag rank stays, add is for custom rows, seed
2697
+ * rows have no remove. Reset restores seed ticks and package labels.
2504
2698
  */
2505
2699
  const RolesMatrixField = (props) => {
2506
- const { setValue } = useField({ path: props.path });
2700
+ const { setValue, value } = useField({ path: props.path });
2701
+ const rootRef = useRef(null);
2702
+ const seedMenu = useRef(false);
2507
2703
  return /* @__PURE__ */ jsxs("div", {
2508
2704
  className: "bl-roles-matrix",
2705
+ ref: rootRef,
2706
+ onClickCapture: useCallback((event) => {
2707
+ const target = event.target;
2708
+ if (!(target instanceof Element)) return;
2709
+ const trigger = target.closest(".array-actions__button");
2710
+ if (trigger) {
2711
+ const owner = trigger.closest(".array-field");
2712
+ seedMenu.current = owner !== null && owner === rootRef.current?.querySelector(".array-field") && seedRoleFromTrigger(trigger);
2713
+ document.body.classList.toggle("bl-roles-matrix-seed-menu", seedMenu.current);
2714
+ return;
2715
+ }
2716
+ if (!target.closest(".array-actions__remove")) return;
2717
+ if (!seedMenu.current) return;
2718
+ event.preventDefault();
2719
+ event.stopPropagation();
2720
+ }, []),
2509
2721
  children: [
2510
2722
  /* @__PURE__ */ jsx("style", {
2511
2723
  href: "bl-roles-matrix",
@@ -2517,7 +2729,7 @@ const RolesMatrixField = (props) => {
2517
2729
  children: /* @__PURE__ */ jsx("button", {
2518
2730
  type: "button",
2519
2731
  className: "bl-roles-matrix__reset",
2520
- onClick: () => setValue(defaultRolesFieldValue()),
2732
+ onClick: () => setValue(resetRolesMatrix(value)),
2521
2733
  children: "Reset to defaults"
2522
2734
  })
2523
2735
  }),
@@ -2526,25 +2738,107 @@ const RolesMatrixField = (props) => {
2526
2738
  });
2527
2739
  };
2528
2740
  //#endregion
2741
+ //#region src/admin/roles-row-label.tsx
2742
+ /**
2743
+ * Array row header. Shows the current display name, not “Role 01”.
2744
+ */
2745
+ function RolesRowLabel() {
2746
+ const { data } = useRowLabel();
2747
+ const role = typeof data.role === "string" ? data.role : "";
2748
+ const label = roleLabel(role, data.label) || "Role";
2749
+ return /* @__PURE__ */ jsx("span", {
2750
+ "data-role-slug": role,
2751
+ children: label
2752
+ });
2753
+ }
2754
+ //#endregion
2755
+ //#region src/admin/role-slug-field.tsx
2756
+ /**
2757
+ * Seed slugs stay read-only. A custom row's slug is typed here.
2758
+ */
2759
+ const RoleSlugField = ({ field, path, readOnly }) => {
2760
+ const { value, setValue, showError, errorMessage } = useField({ path });
2761
+ const locked = Boolean(readOnly) || isRole(value);
2762
+ const required = Boolean(field.required);
2763
+ const id = `field-${path.replace(/\./g, "__")}`;
2764
+ return /* @__PURE__ */ jsxs("div", {
2765
+ className: [
2766
+ fieldBaseClass,
2767
+ "text",
2768
+ locked && "read-only"
2769
+ ].filter(Boolean).join(" "),
2770
+ children: [
2771
+ /* @__PURE__ */ jsx(FieldLabel, {
2772
+ htmlFor: id,
2773
+ label: field.label,
2774
+ required
2775
+ }),
2776
+ /* @__PURE__ */ jsxs("div", {
2777
+ className: `${fieldBaseClass}__wrap`,
2778
+ children: [/* @__PURE__ */ jsx(FieldError, {
2779
+ message: errorMessage,
2780
+ path,
2781
+ showError
2782
+ }), /* @__PURE__ */ jsx("input", {
2783
+ id,
2784
+ "data-role-slug": value ?? "",
2785
+ type: "text",
2786
+ autoComplete: "off",
2787
+ spellCheck: false,
2788
+ readOnly: locked,
2789
+ disabled: locked,
2790
+ value: value ?? "",
2791
+ onChange: (event) => {
2792
+ if (!locked) setValue(event.target.value);
2793
+ }
2794
+ })]
2795
+ }),
2796
+ /* @__PURE__ */ jsx(FieldDescription, {
2797
+ description: field.admin?.description,
2798
+ path
2799
+ })
2800
+ ]
2801
+ });
2802
+ };
2803
+ //#endregion
2529
2804
  //#region src/admin/roles-field.tsx
2530
- const GRID_CSS = `.bl-roles__grid{display:grid;gap:.75rem;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));margin-block:.35rem 0}.bl-roles__option{display:flex;flex-direction:column;gap:.25rem;padding:.65rem .75rem;border:1px solid var(--theme-elevation-150,#e2e8f0);border-radius:8px}.bl-roles__option.is-checked{border-color:var(--theme-elevation-400,#94a3b8)}.bl-roles__option.is-included{opacity:.7}.bl-roles__what{font-size:.8rem;line-height:1.35;color:var(--theme-elevation-500,#64748b)}`;
2805
+ const GRID_CSS$1 = `.bl-roles__grid{display:grid;gap:.75rem;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));margin-block:.35rem 0}.bl-roles__option{display:flex;flex-direction:column;gap:.25rem;padding:.65rem .75rem;border:1px solid var(--theme-elevation-150,#e2e8f0);border-radius:8px}.bl-roles__option.is-checked{border-color:var(--theme-elevation-400,#94a3b8)}.bl-roles__option.is-included{opacity:.7}.bl-roles__what{font-size:.8rem;line-height:1.35;color:var(--theme-elevation-500,#64748b)}`;
2531
2806
  function optionValue(option) {
2532
2807
  return typeof option === "string" ? option : String(option.value);
2533
2808
  }
2534
2809
  function optionLabel(option) {
2535
2810
  return typeof option === "string" ? option : String(option.label);
2536
2811
  }
2812
+ function useOfferedRoles(fallback) {
2813
+ const { config } = useConfig();
2814
+ const [live, setLive] = useState(null);
2815
+ const api = config?.routes?.api;
2816
+ const serverURL = config?.serverURL ?? "";
2817
+ useEffect(() => {
2818
+ if (!api) return;
2819
+ let cancelled = false;
2820
+ fetch(`${serverURL}${api}/globals/${ROLES_SLUG}?depth=0`, { credentials: "include" }).then((response) => response.ok ? response.json() : null).then((doc) => {
2821
+ if (cancelled || !doc) return;
2822
+ const parsed = parseRolesMatrix(doc.roles);
2823
+ if (parsed.ok) setLive(roleSelectOptions(parsed.rows));
2824
+ }).catch(() => void 0);
2825
+ return () => {
2826
+ cancelled = true;
2827
+ };
2828
+ }, [api, serverURL]);
2829
+ return live ?? fallback;
2830
+ }
2537
2831
  /**
2538
2832
  * Users Roles checklist. Order and labels come from the field options (the
2539
- * Roles Global, or the seed). Copy is `roleDescription`. Developer is
2540
- * exclusive; Admin does not swallow Designer.
2833
+ * Roles Global, or the seed plus extras). Copy is `roleDescription`.
2834
+ * Developer is exclusive; a custom role does not clear Designer.
2541
2835
  */
2542
2836
  const RolesField = ({ field, path: pathFromProps, readOnly, validate }) => {
2543
2837
  const { admin: { description } = {}, label, localized, options = [], required } = field;
2544
- const offered = (options.length ? options : roleSelectOptions()).map((option) => ({
2838
+ const offered = useOfferedRoles((options.length ? options : roleSelectOptions()).map((option) => ({
2545
2839
  value: optionValue(option),
2546
2840
  label: optionLabel(option)
2547
- }));
2841
+ })));
2548
2842
  const { disabled, errorMessage, path, setValue, showError, value } = useField({
2549
2843
  potentiallyStalePath: pathFromProps,
2550
2844
  validate: useCallback((value, validationOptions) => {
@@ -2587,7 +2881,7 @@ const RolesField = ({ field, path: pathFromProps, readOnly, validate }) => {
2587
2881
  /* @__PURE__ */ jsx("style", {
2588
2882
  href: "bl-roles",
2589
2883
  precedence: "default",
2590
- children: GRID_CSS
2884
+ children: GRID_CSS$1
2591
2885
  }),
2592
2886
  /* @__PURE__ */ jsx(FieldLabel, {
2593
2887
  as: "span",
@@ -2607,7 +2901,7 @@ const RolesField = ({ field, path: pathFromProps, readOnly, validate }) => {
2607
2901
  className: "bl-roles__grid",
2608
2902
  role: "group",
2609
2903
  children: offered.map((option) => {
2610
- const role = isRole(option.value) ? option.value : null;
2904
+ const role = option.value;
2611
2905
  if (!role) return null;
2612
2906
  const included = exclusive !== null && role !== exclusive;
2613
2907
  const checked = selected.includes(role);
@@ -2620,7 +2914,7 @@ const RolesField = ({ field, path: pathFromProps, readOnly, validate }) => {
2620
2914
  children: [/* @__PURE__ */ jsx(CheckboxInput, {
2621
2915
  checked,
2622
2916
  id: `${path}-${role}`,
2623
- label: option.label || ROLE_LABELS[role],
2917
+ label: option.label || roleLabel(role),
2624
2918
  onToggle: () => toggle(role),
2625
2919
  readOnly: locked || included
2626
2920
  }), /* @__PURE__ */ jsx("span", {
@@ -2639,6 +2933,135 @@ const RolesField = ({ field, path: pathFromProps, readOnly, validate }) => {
2639
2933
  });
2640
2934
  };
2641
2935
  //#endregion
2642
- export { AppearanceField, ColorField, ColorScaleField, ContrastReport, FontField, GreyScaleField, HiddenSaveButton, IdentityFallback, LibraryField, LookField, PairingField, PublishChild, RolesField, RolesMatrixField, SectionHeading, ThemeDocumentControls, ThemeSaveButton };
2936
+ //#region src/features/types.ts
2937
+ /**
2938
+ * Package catalogue. Locked rows cannot be turned off. Empty Global falls
2939
+ * back to the named capability (Content → Pages/Media, Brand → Theme /
2940
+ * Brand assets, Users → Users/Roles/Features).
2941
+ */
2942
+ const PACKAGE_FEATURES = [
2943
+ {
2944
+ slug: "pages",
2945
+ label: "Pages",
2946
+ fallback: "content",
2947
+ locked: false
2948
+ },
2949
+ {
2950
+ slug: "media",
2951
+ label: "Media",
2952
+ fallback: "content",
2953
+ locked: false
2954
+ },
2955
+ {
2956
+ slug: "theme",
2957
+ label: "Theme",
2958
+ fallback: "brand",
2959
+ locked: false
2960
+ },
2961
+ {
2962
+ slug: "brand-assets",
2963
+ label: "Brand assets",
2964
+ fallback: "brand",
2965
+ locked: false
2966
+ },
2967
+ {
2968
+ slug: "users",
2969
+ label: "Users",
2970
+ fallback: "users",
2971
+ locked: true
2972
+ },
2973
+ {
2974
+ slug: "roles",
2975
+ label: "Roles",
2976
+ fallback: "users",
2977
+ locked: true
2978
+ },
2979
+ {
2980
+ slug: "features",
2981
+ label: "Features",
2982
+ fallback: "users",
2983
+ locked: true
2984
+ }
2985
+ ];
2986
+ function isLockedFeature(slug, locked) {
2987
+ if (locked === true) return true;
2988
+ return PACKAGE_FEATURES.find((feature) => feature.slug === slug)?.locked === true;
2989
+ }
2990
+ //#endregion
2991
+ //#region src/admin/features-matrix-field.tsx
2992
+ const GRID_CSS = `.bl-features{margin-block:.35rem 0;overflow:auto}.bl-features table{width:100%;border-collapse:collapse;font:inherit}.bl-features th,.bl-features td{padding:.45rem .6rem;border-bottom:1px solid var(--theme-elevation-150,#e2e8f0);text-align:center;white-space:nowrap}.bl-features th:first-child,.bl-features td:first-child{text-align:left;font-weight:600}.bl-features th{font-size:.8rem;color:var(--theme-elevation-500,#64748b)}.bl-features input{inline-size:1rem;block-size:1rem}`;
2993
+ function asRows(value) {
2994
+ return Array.isArray(value) ? value : [];
2995
+ }
2996
+ /**
2997
+ * Feature × role grid. Columns come from the Roles Global (or the seed).
2998
+ * Locked rows and Developer cannot be cleared.
2999
+ */
3000
+ const FeaturesMatrixField = (props) => {
3001
+ const { setValue, value } = useField({ path: props.path });
3002
+ const { config } = useConfig();
3003
+ const [columns, setColumns] = useState(roleSelectOptions(seedRoleRows()));
3004
+ const api = config?.routes?.api;
3005
+ const serverURL = config?.serverURL ?? "";
3006
+ useEffect(() => {
3007
+ if (!api) return;
3008
+ let cancelled = false;
3009
+ fetch(`${serverURL}${api}/globals/${ROLES_SLUG}?depth=0`, { credentials: "include" }).then((response) => response.ok ? response.json() : null).then((doc) => {
3010
+ if (cancelled || !doc) return;
3011
+ const parsed = parseRolesMatrix(doc.roles);
3012
+ if (parsed.ok) setColumns(roleSelectOptions(parsed.rows));
3013
+ }).catch(() => void 0);
3014
+ return () => {
3015
+ cancelled = true;
3016
+ };
3017
+ }, [api, serverURL]);
3018
+ const rows = asRows(value);
3019
+ function toggle(slug, role, checked) {
3020
+ if (role === "developer") return;
3021
+ setValue(rows.map((row) => {
3022
+ if (row.slug !== slug || isLockedFeature(row.slug, row.locked)) return row;
3023
+ const roles = new Set(row.roles ?? []);
3024
+ if (checked) roles.add(role);
3025
+ else roles.delete(role);
3026
+ roles.add("developer");
3027
+ return {
3028
+ ...row,
3029
+ roles: [...roles]
3030
+ };
3031
+ }));
3032
+ }
3033
+ return /* @__PURE__ */ jsxs("div", {
3034
+ className: "bl-features",
3035
+ children: [/* @__PURE__ */ jsx("style", {
3036
+ href: "bl-features",
3037
+ precedence: "default",
3038
+ children: GRID_CSS
3039
+ }), /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("th", {
3040
+ scope: "col",
3041
+ children: "Feature"
3042
+ }), columns.map((column) => /* @__PURE__ */ jsx("th", {
3043
+ scope: "col",
3044
+ children: column.label
3045
+ }, column.value))] }) }), /* @__PURE__ */ jsx("tbody", { children: rows.map((row) => {
3046
+ const locked = isLockedFeature(row.slug, row.locked);
3047
+ return /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("th", {
3048
+ scope: "row",
3049
+ children: row.label || row.slug
3050
+ }), columns.map((column) => {
3051
+ const disabled = locked || column.value === "developer";
3052
+ const checked = column.value === "developer" || locked || Boolean(row.roles?.includes(column.value));
3053
+ return /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx("input", {
3054
+ type: "checkbox",
3055
+ "aria-label": `${row.label || row.slug} · ${column.label}`,
3056
+ checked,
3057
+ disabled,
3058
+ onChange: (event) => toggle(row.slug, column.value, event.target.checked)
3059
+ }) }, column.value);
3060
+ })] }, row.slug);
3061
+ }) })] })]
3062
+ });
3063
+ };
3064
+ //#endregion
3065
+ export { AppearanceField, ColorField, ColorScaleField, ContrastReport, FeaturesMatrixField, FontField, GreyScaleField, HiddenSaveButton, IdentityFallback, LibraryField, LookField, PairingField, PublishChild, RoleSlugField, RolesField, RolesMatrixField, RolesRowLabel, SectionHeading, ThemeDocumentControls, ThemeSaveButton };
2643
3066
 
2644
3067
  //# sourceMappingURL=admin.mjs.map