mui-atelier 0.1.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +32 -0
  4. package/README.md +282 -0
  5. package/dist/components/AddressFields.js +83 -0
  6. package/dist/components/AuthCard.js +94 -0
  7. package/dist/components/AuthSplash.js +88 -0
  8. package/dist/components/BrandHeader.js +81 -0
  9. package/dist/components/BrandLink.js +22 -0
  10. package/dist/components/BrandMark.js +69 -0
  11. package/dist/components/BrandProvider.js +14 -0
  12. package/dist/components/Breadcrumbs.js +62 -0
  13. package/dist/components/CardHead.js +46 -0
  14. package/dist/components/ConfirmProvider.js +77 -0
  15. package/dist/components/DashboardLayout.js +645 -0
  16. package/dist/components/DataTable.js +546 -0
  17. package/dist/components/DetailRows.js +42 -0
  18. package/dist/components/EmptyNote.js +28 -0
  19. package/dist/components/Eyebrow.js +21 -0
  20. package/dist/components/HeadingHelp.js +38 -0
  21. package/dist/components/HeadingLevel.js +18 -0
  22. package/dist/components/IconTile.js +42 -0
  23. package/dist/components/ImagePreviewDialog.js +86 -0
  24. package/dist/components/LabeledField.js +156 -0
  25. package/dist/components/LanguageSwitcher.js +43 -0
  26. package/dist/components/ListRow.js +59 -0
  27. package/dist/components/ModeToggle.js +35 -0
  28. package/dist/components/PageHeader.js +65 -0
  29. package/dist/components/PasswordField.js +43 -0
  30. package/dist/components/PublicLayout.js +27 -0
  31. package/dist/components/PublicNav.js +17 -0
  32. package/dist/components/RouteMessage.js +31 -0
  33. package/dist/components/RowList.js +10 -0
  34. package/dist/components/SafeHtml.js +57 -0
  35. package/dist/components/SaveState.js +13 -0
  36. package/dist/components/SectionPanel.js +48 -0
  37. package/dist/components/SectionTabs.js +139 -0
  38. package/dist/components/SiteNav.js +130 -0
  39. package/dist/components/Skeletons.js +63 -0
  40. package/dist/components/Sortable.js +81 -0
  41. package/dist/components/StatTile.js +66 -0
  42. package/dist/components/StatusChip.js +17 -0
  43. package/dist/components/StatusDot.js +42 -0
  44. package/dist/components/SubCard.js +30 -0
  45. package/dist/components/TabbedCard.js +20 -0
  46. package/dist/components/TableFilters.js +247 -0
  47. package/dist/components/ToastProvider.js +65 -0
  48. package/dist/eslint/chipRules.mjs +65 -0
  49. package/dist/hooks/useDocumentTitle.js +19 -0
  50. package/dist/hooks/useOverflowTabs.js +53 -0
  51. package/dist/hooks/useUnsavedChanges.js +46 -0
  52. package/dist/i18n/loadMessages.js +15 -0
  53. package/dist/i18n/mergeMessages.js +13 -0
  54. package/dist/i18n/messages/en.json +103 -0
  55. package/dist/i18n/messages/es.json +103 -0
  56. package/dist/i18n/messages/zh.json +103 -0
  57. package/dist/lib/authCard.js +4 -0
  58. package/dist/lib/chipTone.js +46 -0
  59. package/dist/lib/countryRegionData.js +29 -0
  60. package/dist/lib/countryRegionData.json +1 -0
  61. package/dist/lib/navLock.js +22 -0
  62. package/dist/lib/navTabSx.js +78 -0
  63. package/dist/lib/orgTime.js +243 -0
  64. package/dist/lib/pageHtml.js +139 -0
  65. package/dist/lib/safeNext.js +13 -0
  66. package/dist/lib/sanitizeHtml.js +36 -0
  67. package/dist/lib/slugify.js +7 -0
  68. package/dist/lib/tableFilter.js +147 -0
  69. package/dist/lib/tableStyle.js +87 -0
  70. package/dist/test/jsdomShims.js +53 -0
  71. package/dist/test/themeContract.js +178 -0
  72. package/dist/theme/OrgThemeRegistry.js +71 -0
  73. package/dist/theme/ThemeRegistry.js +17 -0
  74. package/dist/theme/berryNavRow.js +21 -0
  75. package/dist/theme/createSiteTheme.js +974 -0
  76. package/dist/theme/themes.js +438 -0
  77. package/package.json +118 -0
@@ -0,0 +1,147 @@
1
+ const BLANK = "\0blank";
2
+ const isEmpty = (v) => v === null || v === void 0 || v === "";
3
+ const valuesOf = (v) => Array.isArray(v) ? v : [v];
4
+ const keysOf = (raw) => {
5
+ const values = valuesOf(raw).filter((v) => !isEmpty(v));
6
+ return values.length === 0 ? [BLANK] : values.map((v) => String(v));
7
+ };
8
+ function filterableColumns(columns) {
9
+ return (columns ?? []).map((c) => {
10
+ if (c.filter === false) return null;
11
+ if (c.filter === void 0 && !c.value) return null;
12
+ const spec = typeof c.filter === "string" ? { kind: c.filter } : c.filter ?? {};
13
+ const of = spec.of ?? ((row) => c.value?.(row));
14
+ const label = spec.label ?? ((row) => {
15
+ const v = of(row);
16
+ return isEmpty(v) ? "" : String(v);
17
+ });
18
+ return {
19
+ id: c.id,
20
+ /*
21
+ * THE HEADER AS TEXT, because the chips build a string out of it.
22
+ *
23
+ * A column's `header` may be a NODE — the report viewer clamps a long
24
+ * question to two lines with a Box, and the submissions queue draws an
25
+ * icon beside one. That renders fine as a label and stringifies to
26
+ * "[object Object]" the moment a chip interpolates it, which is what the
27
+ * report filters have been showing. `filterHeader` is the plain words for
28
+ * a column that has any; every other column already is its own.
29
+ */
30
+ header: c.filterHeader ?? c.header,
31
+ kind: spec.kind ?? "text",
32
+ of,
33
+ label,
34
+ blankLabel: spec.blankLabel ?? "\u2014",
35
+ /*
36
+ * The declared value set, for a column whose values are CONFIGURED rather
37
+ * than intrinsic — review statuses are defined per org under Settings, so
38
+ * the vocabulary is a fact about the org, not about the rows on screen.
39
+ * Given, it is used verbatim; omitted, the options are derived from the
40
+ * rows (see optionsFor).
41
+ */
42
+ options: spec.options ?? null,
43
+ match: spec.match ?? "contains",
44
+ // Ordering for the dropdown: the column's sort value if it has one, so a
45
+ // review pipeline lists in pipeline order rather than alphabetically.
46
+ order: c.value
47
+ };
48
+ }).filter(Boolean);
49
+ }
50
+ function optionsFor(column, rows) {
51
+ const seen = /* @__PURE__ */ new Map();
52
+ if (column.options) {
53
+ for (const o of column.options) {
54
+ seen.set(String(o.value), {
55
+ value: String(o.value),
56
+ label: o.label,
57
+ order: o.order ?? null,
58
+ // Offered but unselectable — a bucket the product will have and does
59
+ // not yet. See TableFilters.
60
+ disabled: Boolean(o.disabled)
61
+ });
62
+ }
63
+ }
64
+ for (const row of rows ?? []) {
65
+ const raw = column.of(row);
66
+ const keys = keysOf(raw);
67
+ const labels = valuesOf(column.label(row));
68
+ keys.forEach((key, i) => {
69
+ if (seen.has(key)) return;
70
+ seen.set(key, {
71
+ value: key,
72
+ label: key === BLANK ? column.blankLabel : labels[i] ?? String(key),
73
+ order: column.order ? column.order(row) : null
74
+ });
75
+ });
76
+ }
77
+ const options = [...seen.values()];
78
+ return options.sort((a, b) => {
79
+ if (a.value === BLANK !== (b.value === BLANK)) return a.value === BLANK ? 1 : -1;
80
+ if (a.order !== null && b.order !== null && a.order !== b.order) return a.order < b.order ? -1 : 1;
81
+ return String(a.label).localeCompare(String(b.label));
82
+ });
83
+ }
84
+ function matchesQuery(row, columns, query, searchAlso) {
85
+ const terms = termsOf(query).map((t) => t.toLowerCase());
86
+ if (terms.length === 0) return true;
87
+ const hay = [
88
+ ...columns.map((c) => c.label(row)),
89
+ ...searchAlso?.(row) ?? []
90
+ ].filter((v) => !isEmpty(v)).join(" ").toLowerCase();
91
+ return terms.every((t) => hay.includes(t));
92
+ }
93
+ function termsOf(value) {
94
+ return String(value ?? "").split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
95
+ }
96
+ const isRange = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
97
+ function isSet(value) {
98
+ if (isRange(value)) return !isEmpty(value.from) || !isEmpty(value.to);
99
+ return Array.isArray(value) ? value.length > 0 : !isEmpty(value);
100
+ }
101
+ function matchesColumn(row, column, wanted) {
102
+ if (column.kind === "number") {
103
+ const n = Number(column.of(row));
104
+ if (!Number.isFinite(n)) return false;
105
+ if (!isEmpty(wanted.from) && n < Number(wanted.from)) return false;
106
+ if (!isEmpty(wanted.to) && n > Number(wanted.to)) return false;
107
+ return true;
108
+ }
109
+ if (column.kind === "daterange") {
110
+ const on = String(column.of(row) ?? "").slice(0, 10);
111
+ if (on === "") return false;
112
+ if (!isEmpty(wanted.from) && on < wanted.from) return false;
113
+ if (!isEmpty(wanted.to) && on > wanted.to) return false;
114
+ return true;
115
+ }
116
+ if (column.kind === "select") {
117
+ const keys = keysOf(column.of(row));
118
+ const want = Array.isArray(wanted) ? wanted : [wanted];
119
+ return keys.some((key) => want.includes(key));
120
+ }
121
+ const hay = String(column.label(row) ?? "").toLowerCase();
122
+ const terms = termsOf(wanted).map((t) => t.toLowerCase());
123
+ if (terms.length === 0) return true;
124
+ return terms.some((t) => column.match === "exact" ? hay === t : hay.includes(t));
125
+ }
126
+ function applyFilters(rows, columns, { query = "", byColumn = {} } = {}, searchAlso) {
127
+ const filterable = filterableColumns(columns);
128
+ return (rows ?? []).filter((row) => {
129
+ for (const column of filterable) {
130
+ const wanted = byColumn[column.id];
131
+ if (!isSet(wanted)) continue;
132
+ if (!matchesColumn(row, column, wanted)) return false;
133
+ }
134
+ return matchesQuery(row, filterable, query, searchAlso);
135
+ });
136
+ }
137
+ function activeCount({ query = "", byColumn = {} } = {}) {
138
+ return (String(query).trim() === "" ? 0 : 1) + Object.values(byColumn).filter(isSet).length;
139
+ }
140
+ export {
141
+ BLANK,
142
+ activeCount,
143
+ applyFilters,
144
+ filterableColumns,
145
+ optionsFor,
146
+ termsOf
147
+ };
@@ -0,0 +1,87 @@
1
+ const PREFIX = "rt-tbl";
2
+ const TABLE_STYLE_OPTIONS = {
3
+ borders: {
4
+ label: "Borders",
5
+ default: "all",
6
+ choices: [
7
+ { value: "all", label: "All cells", attrs: { border: "1" } },
8
+ { value: "outer", label: "Outline only", attrs: { border: "1" } },
9
+ { value: "none", label: "None", attrs: { border: "0" } }
10
+ ]
11
+ },
12
+ padding: {
13
+ label: "Cell padding",
14
+ default: "normal",
15
+ choices: [
16
+ { value: "compact", label: "Compact", attrs: { cellpadding: "4" } },
17
+ { value: "normal", label: "Normal", attrs: { cellpadding: "8" } },
18
+ { value: "roomy", label: "Roomy", attrs: { cellpadding: "14" } }
19
+ ]
20
+ },
21
+ width: {
22
+ label: "Width",
23
+ default: "full",
24
+ choices: [
25
+ { value: "full", label: "Full width", attrs: { width: "100%" } },
26
+ { value: "auto", label: "Fit content", attrs: null }
27
+ ]
28
+ }
29
+ };
30
+ const TABLE_STYLE_KEYS = Object.keys(TABLE_STYLE_OPTIONS);
31
+ function tableStyleClass(attrs = {}) {
32
+ return TABLE_STYLE_KEYS.filter((key) => attrs[key] && attrs[key] !== TABLE_STYLE_OPTIONS[key].default).map((key) => `${PREFIX}-${key}-${attrs[key]}`).join(" ");
33
+ }
34
+ function tableStyleAttrs(attrs = {}) {
35
+ return TABLE_STYLE_KEYS.reduce((out, key) => {
36
+ const spec = TABLE_STYLE_OPTIONS[key];
37
+ const chosen = spec.choices.find((c) => c.value === (attrs[key] ?? spec.default));
38
+ return chosen?.attrs ? { ...out, ...chosen.attrs } : out;
39
+ }, { cellspacing: "0" });
40
+ }
41
+ function tableSx(theme) {
42
+ const line = `1px solid ${theme.vars?.palette?.divider ?? theme.palette.divider}`;
43
+ return {
44
+ "& table": {
45
+ borderCollapse: "collapse",
46
+ width: "100%",
47
+ my: 3,
48
+ // A wide table scrolls itself rather than the page. `display: block` is
49
+ // what makes overflow work on a table element — and it is also why the
50
+ // fit-content rule below needs `max-content` rather than `width: auto`:
51
+ // a block box fills its container whatever `auto` says.
52
+ display: "block",
53
+ overflowX: "auto"
54
+ },
55
+ "& table th, & table td": {
56
+ border: line,
57
+ px: 1.5,
58
+ py: 1,
59
+ verticalAlign: "top",
60
+ textAlign: "left"
61
+ },
62
+ "& table th": {
63
+ backgroundColor: theme.vars?.palette?.action?.hover ?? theme.palette.action.hover,
64
+ fontWeight: 700
65
+ },
66
+ "& table caption": {
67
+ captionSide: "bottom",
68
+ paddingTop: theme.spacing(1),
69
+ color: theme.vars?.palette?.text?.secondary ?? theme.palette.text.secondary,
70
+ fontSize: "0.875rem"
71
+ },
72
+ // Author overrides.
73
+ [`& table.${PREFIX}-borders-none th, & table.${PREFIX}-borders-none td`]: { border: 0 },
74
+ [`& table.${PREFIX}-borders-outer`]: { border: line },
75
+ [`& table.${PREFIX}-borders-outer th, & table.${PREFIX}-borders-outer td`]: { border: 0 },
76
+ [`& table.${PREFIX}-padding-compact th, & table.${PREFIX}-padding-compact td`]: { px: 0.75, py: 0.5 },
77
+ [`& table.${PREFIX}-padding-roomy th, & table.${PREFIX}-padding-roomy td`]: { px: 2.5, py: 1.75 },
78
+ [`& table.${PREFIX}-width-auto`]: { width: "max-content", maxWidth: "100%" }
79
+ };
80
+ }
81
+ export {
82
+ TABLE_STYLE_KEYS,
83
+ TABLE_STYLE_OPTIONS,
84
+ tableStyleAttrs,
85
+ tableStyleClass,
86
+ tableSx
87
+ };
@@ -0,0 +1,53 @@
1
+ if (typeof globalThis.ResizeObserver === "undefined") {
2
+ globalThis.ResizeObserver = class {
3
+ observe() {
4
+ }
5
+ unobserve() {
6
+ }
7
+ disconnect() {
8
+ }
9
+ };
10
+ }
11
+ if (typeof globalThis.localStorage === "undefined") {
12
+ const store = /* @__PURE__ */ new Map();
13
+ globalThis.localStorage = {
14
+ get length() {
15
+ return store.size;
16
+ },
17
+ key: (i) => [...store.keys()][i] ?? null,
18
+ getItem: (k) => store.has(String(k)) ? store.get(String(k)) : null,
19
+ setItem: (k, v) => {
20
+ store.set(String(k), String(v));
21
+ },
22
+ removeItem: (k) => {
23
+ store.delete(String(k));
24
+ },
25
+ clear: () => {
26
+ store.clear();
27
+ }
28
+ };
29
+ }
30
+ if (typeof Range !== "undefined") {
31
+ if (typeof Range.prototype.getClientRects !== "function") {
32
+ Range.prototype.getClientRects = function getClientRects() {
33
+ return Object.assign([], { item: () => null });
34
+ };
35
+ }
36
+ if (typeof Range.prototype.getBoundingClientRect !== "function") {
37
+ Range.prototype.getBoundingClientRect = function getBoundingClientRect() {
38
+ return {
39
+ x: 0,
40
+ y: 0,
41
+ top: 0,
42
+ left: 0,
43
+ bottom: 0,
44
+ right: 0,
45
+ width: 0,
46
+ height: 0,
47
+ toJSON() {
48
+ return this;
49
+ }
50
+ };
51
+ };
52
+ }
53
+ }
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import createSiteTheme from "../theme/createSiteTheme.js";
3
+ function describeThemeContract(themes, { aa = true } = {}) {
4
+ const ORG_THEMES = themes;
5
+ const getOrgTheme = (id) => (themes.find((t) => t.id === id) ?? themes[0]).def;
6
+ const SCHEME_KEYS = ["textPrimary", "textSecondary", "divider", "bgDefault", "bgPaper", "secondary", "data", "success", "warning", "error"];
7
+ const RADII_KEYS = ["base", "card", "button", "buttonLarge", "chip", "input", "dialog", "menu"];
8
+ describe.each(ORG_THEMES.map((t) => [t.id, t.def]))("the %s theme", (id, def) => {
9
+ it("builds", () => {
10
+ const theme = createSiteTheme(def);
11
+ expect(theme.colorSchemes.light).toBeTruthy();
12
+ expect(theme.colorSchemes.dark).toBeTruthy();
13
+ expect(theme.rt.ui).toEqual(def.ui);
14
+ });
15
+ it("carries both colour schemes in full", () => {
16
+ SCHEME_KEYS.forEach((key) => {
17
+ expect(def.palette.light[key], `${id}.palette.light.${key}`).toBeTruthy();
18
+ expect(def.palette.dark[key], `${id}.palette.dark.${key}`).toBeTruthy();
19
+ });
20
+ });
21
+ it("carries an accent with both schemes, both contrasts and a gradient", () => {
22
+ ["light", "dark", "contrastLight", "contrastDark"].forEach((key) => {
23
+ expect(def.accent[key], `${id}.accent.${key}`).toBeTruthy();
24
+ });
25
+ expect(def.accent.gradient.light, `${id}.accent.gradient.light`).toHaveLength(2);
26
+ expect(def.accent.gradient.dark, `${id}.accent.gradient.dark`).toHaveLength(2);
27
+ });
28
+ it("carries every radius the components ask for", () => {
29
+ RADII_KEYS.forEach((key) => {
30
+ expect(typeof def.radii[key], `${id}.radii.${key}`).toBe("number");
31
+ });
32
+ });
33
+ it("carries a rail surface when its nav owns the column", () => {
34
+ if (def.ui.nav !== "rail") return;
35
+ expect(def.rail?.bg, `${id}.rail.bg`).toBeTruthy();
36
+ expect(def.rail?.fg, `${id}.rail.fg`).toBeTruthy();
37
+ });
38
+ });
39
+ describe("the set as a whole", () => {
40
+ it("has no duplicate ids or labels", () => {
41
+ const ids = ORG_THEMES.map((t) => t.id);
42
+ const labels = ORG_THEMES.map((t) => t.label);
43
+ expect(new Set(ids).size).toBe(ids.length);
44
+ expect(new Set(labels).size).toBe(labels.length);
45
+ });
46
+ });
47
+ const relativeLuminance = (hex) => {
48
+ const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((x) => x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4);
49
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
50
+ };
51
+ const contrast = (a, b) => {
52
+ const [x, y] = [relativeLuminance(a), relativeLuminance(b)];
53
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
54
+ };
55
+ const flatten = (colour, over) => {
56
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(colour);
57
+ if (short) return `#${short.slice(1).map((c) => c + c).join("")}`;
58
+ const rgba = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/.exec(colour);
59
+ if (!rgba) return colour;
60
+ const [r, g, b] = rgba.slice(1, 4).map(Number);
61
+ const alpha = rgba[4] === void 0 ? 1 : Number(rgba[4]);
62
+ const base = [1, 3, 5].map((i) => parseInt(over.slice(i, i + 2), 16));
63
+ const mix = [r, g, b].map((c, i) => Math.round(c * alpha + base[i] * (1 - alpha)));
64
+ return `#${mix.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
65
+ };
66
+ describe("contrast", () => {
67
+ const AA = 4.5;
68
+ const measured = aa ? ORG_THEMES : [];
69
+ if (measured.length) it.each(measured.map((t) => [t.id, t.def]))("%s reads on its own paper", (id, def) => {
70
+ const paper = def.palette.light.bgPaper;
71
+ const inks = {
72
+ accent: def.accent.light,
73
+ ...def.accent2 ? { accent2: def.accent2.light } : {},
74
+ // The data hue is a chip label and an alert's text like the other three.
75
+ data: def.palette.light.data,
76
+ success: def.palette.light.success,
77
+ warning: def.palette.light.warning,
78
+ error: def.palette.light.error
79
+ };
80
+ Object.entries(inks).forEach(([role, hex]) => {
81
+ const ratio = contrast(hex, paper);
82
+ expect(ratio, `${id}.${role} is ${ratio.toFixed(2)}:1 on ${paper}`).toBeGreaterThanOrEqual(AA);
83
+ });
84
+ });
85
+ it.each(ORG_THEMES.map((t) => [t.id, t.def]))("%s serves its data hue as info", (id, def) => {
86
+ const theme = createSiteTheme(def);
87
+ expect(theme.colorSchemes.light.palette.info.main).toBe(def.palette.light.data);
88
+ expect(theme.colorSchemes.dark.palette.info.main).toBe(def.palette.dark.data);
89
+ expect(def.palette.light.data).not.toBe(def.accent.light);
90
+ });
91
+ it.each(ORG_THEMES.map((t) => [t.id, t.def]))("%s tints a filled info chip", (id, def) => {
92
+ const theme = createSiteTheme(def);
93
+ const variants = theme.components.MuiChip.variants.filter((v) => v.props.variant === "filled" && v.props.color === "info");
94
+ expect(variants.length, `${id} has no filled info chip variant`).toBeGreaterThan(0);
95
+ });
96
+ it.each(ORG_THEMES.map((t) => [t.id, t.def]))("%s frees a large dialog on a phone", (id, def) => {
97
+ const theme = createSiteTheme(def);
98
+ const paper = theme.components.MuiDialog.styleOverrides.paper({ theme });
99
+ const phone = paper[theme.breakpoints.down("sm")];
100
+ expect(phone.margin, `${id} lost the phone gutter`).toBe(12);
101
+ const large = phone["&.MuiDialog-paperWidthMd, &.MuiDialog-paperWidthLg, &.MuiDialog-paperWidthXl"];
102
+ expect(large, `${id} does not free a large dialog`).toBeTruthy();
103
+ expect(large.margin).toBe(0);
104
+ expect(large.width).toBe("100%");
105
+ expect(large.height).toBe("100%");
106
+ });
107
+ it.each(ORG_THEMES.map((t) => [t.id, t.def]))("%s emits the focus-visible rule", (id, def) => {
108
+ const theme = createSiteTheme(def);
109
+ const css = theme.components.MuiCssBaseline.styleOverrides(theme);
110
+ expect(css["html :focus-visible"]?.outline, `${id} has no html :focus-visible outline`).toBe("2px solid var(--rt-focus)");
111
+ expect(css["html :focus-visible"]?.outlineOffset).toBe("2px");
112
+ expect(css.body["--rt-focus"], `${id} sets no --rt-focus`).toBeTruthy();
113
+ });
114
+ it.each(ORG_THEMES.map((t) => [t.id, t.def]))("%s draws a visible focus ring on both grounds", (id, def) => {
115
+ const NON_TEXT = 3;
116
+ const ring = def.focus ?? { light: def.accent.light, dark: def.accent.dark };
117
+ ["light", "dark"].forEach((scheme) => {
118
+ [["paper", def.palette[scheme].bgPaper], ["ground", def.palette[scheme].bgDefault]].forEach(([where, bg]) => {
119
+ const ratio = contrast(ring[scheme], bg);
120
+ expect(
121
+ ratio,
122
+ `${id} ${scheme}: focus ring ${ring[scheme]} on ${where} ${bg} is ${ratio.toFixed(2)}:1`
123
+ ).toBeGreaterThanOrEqual(NON_TEXT);
124
+ });
125
+ });
126
+ });
127
+ it.each(ORG_THEMES.filter((t) => t.def.ui.nav === "rail").map((t) => [t.id, t.def]))(
128
+ "%s rail focus ring reads on the rail",
129
+ (id, def) => {
130
+ const ratio = contrast(flatten(def.rail.fg, def.rail.bg), def.rail.bg);
131
+ expect(ratio, `${id} rail ring ${def.rail.fg} on ${def.rail.bg} is ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(3);
132
+ }
133
+ );
134
+ it.each(ORG_THEMES.map((t) => [t.id, t.def]))("%s reads its caution button label on the fill", (id, def) => {
135
+ const theme = createSiteTheme(def);
136
+ ["light", "dark"].forEach((scheme) => {
137
+ const { main, contrastText } = theme.colorSchemes[scheme].palette.warning;
138
+ const ratio = contrast(flatten(contrastText, main), main);
139
+ expect(
140
+ ratio,
141
+ `${id} ${scheme}: warning label ${contrastText} on ${main} is ${ratio.toFixed(2)}:1`
142
+ ).toBeGreaterThanOrEqual(AA);
143
+ });
144
+ });
145
+ it.each(ORG_THEMES.map((t) => [t.id, t.def]))("%s reads on its own page ground", (id, def) => {
146
+ const ground = def.palette.light.bgDefault;
147
+ const ratio = contrast(def.accent.light, ground);
148
+ expect(ratio, `${id} accent is ${ratio.toFixed(2)}:1 on ${ground}`).toBeGreaterThanOrEqual(AA);
149
+ });
150
+ it.each(ORG_THEMES.map((t) => [t.id, t.def]))("%s draws an outline you can see", (id, def) => {
151
+ const theme = createSiteTheme(def);
152
+ const ground = def.palette.light.bgDefault;
153
+ const painted = theme.components.MuiButton.styleOverrides.outlined({ theme }).borderColor;
154
+ const match = /primary-mainChannel[^)]*\)\s*\/\s*([\d.]+)\s*\)/.exec(painted);
155
+ expect(match, `${id} outlines with "${painted}" \u2014 expected the accent at an alpha, not a hairline`).not.toBeNull();
156
+ const alpha = Number(match[1]);
157
+ const hex = (c) => [0, 2, 4].map((i) => parseInt(c.replace("#", "").slice(i, i + 2), 16));
158
+ const [fg, bg] = [hex(def.accent.light), hex(ground)];
159
+ const composited = `#${fg.map((v, i) => Math.round(bg[i] + alpha * (v - bg[i])).toString(16).padStart(2, "0")).join("")}`;
160
+ const ratio = contrast(composited, ground);
161
+ expect(ratio, `${id} outline is ${ratio.toFixed(2)}:1 on ${ground} at alpha ${alpha}`).toBeGreaterThanOrEqual(3);
162
+ });
163
+ it.each(ORG_THEMES.filter((t) => t.def.ui?.solidChips).map((t) => [t.id]))(
164
+ "%s keeps its solid chip label legible in dark mode",
165
+ (id) => {
166
+ const theme = createSiteTheme(getOrgTheme(id));
167
+ const chip = theme.components.MuiChip.variants.filter((v) => v.props.variant === "filled" && v.props.color === "primary").at(-1);
168
+ const style = chip.style({ theme });
169
+ const dark = Object.entries(style).find(([k]) => k.includes("dark"))?.[1] ?? {};
170
+ expect(style.color).toBe(theme.vars.palette.primary.contrastText);
171
+ expect(dark.color, `${id} has no dark-scheme label colour to outrank softFill`).toBe(theme.vars.palette.primary.contrastText);
172
+ }
173
+ );
174
+ });
175
+ }
176
+ export {
177
+ describeThemeContract
178
+ };
@@ -0,0 +1,71 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { createContext, useContext, useId, useMemo } from "react";
4
+ import { ThemeProvider } from "@mui/material/styles";
5
+ import createSiteTheme from "./createSiteTheme.js";
6
+ import { getOrgTheme, resolveThemeId } from "./themes.js";
7
+ const OrgThemeIdContext = createContext("default");
8
+ function useOrgThemeId() {
9
+ return useContext(OrgThemeIdContext);
10
+ }
11
+ const OrgThemeScopeContext = createContext("");
12
+ function useOrgThemeScope() {
13
+ return useContext(OrgThemeScopeContext);
14
+ }
15
+ function scopeSheets(theme, def, scope) {
16
+ const sheets = typeof theme.generateStyleSheets === "function" ? theme.generateStyleSheets() : [];
17
+ const rules = [];
18
+ for (const sheet of sheets) {
19
+ for (const rawSelector of Object.keys(sheet)) {
20
+ const vars = sheet[rawSelector];
21
+ if (!vars || typeof vars !== "object") continue;
22
+ const body = Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(";");
23
+ if (!body) continue;
24
+ const isDark = /\.dark\b/.test(rawSelector) || /color-scheme[:=]?\s*["']?dark/i.test(rawSelector);
25
+ const sel = isDark ? `:where(.dark) .${scope}` : `.${scope}`;
26
+ rules.push(`${sel}{${body}}`);
27
+ }
28
+ }
29
+ const g = def.accent.gradient;
30
+ const focus = def.focus ?? { light: def.accent.light, dark: def.accent.dark };
31
+ rules.push(`.${scope}{--rt-grad-from:${g.light[0]};--rt-grad-to:${g.light[1]};--rt-focus:${focus.light}}`);
32
+ rules.push(`:where(.dark) .${scope}{--rt-grad-from:${g.dark[0]};--rt-grad-to:${g.dark[1]};--rt-focus:${focus.dark}}`);
33
+ return rules.join("");
34
+ }
35
+ const PORTAL_COMPONENTS = ["MuiDialog", "MuiPopover", "MuiMenu", "MuiDrawer", "MuiTooltip", "MuiPopper"];
36
+ function OrgThemeRegistry({ themeId, theme: themeDef = null, children }) {
37
+ const resolvedId = themeDef ? themeId ?? "custom" : resolveThemeId(themeId);
38
+ const def = themeDef ?? getOrgTheme(themeId);
39
+ const scope = `rt-org-${useId().replace(/[:]/g, "")}`;
40
+ const key = JSON.stringify(def);
41
+ const theme = useMemo(() => {
42
+ const t = createSiteTheme(def);
43
+ const components = { ...t.components };
44
+ for (const name of PORTAL_COMPONENTS) {
45
+ components[name] = { ...components[name], defaultProps: { ...components[name]?.defaultProps, className: scope } };
46
+ }
47
+ t.components = components;
48
+ return t;
49
+ }, [key, scope]);
50
+ const css = useMemo(() => scopeSheets(theme, def, scope), [key, theme, scope]);
51
+ return /* @__PURE__ */ jsxs(ThemeProvider, { theme, children: [
52
+ /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: css } }),
53
+ /* @__PURE__ */ jsx(
54
+ "div",
55
+ {
56
+ className: scope,
57
+ style: {
58
+ minHeight: "100dvh",
59
+ backgroundColor: "var(--mui-palette-background-default)",
60
+ color: "var(--mui-palette-text-primary)"
61
+ },
62
+ children: /* @__PURE__ */ jsx(OrgThemeIdContext.Provider, { value: resolvedId, children: /* @__PURE__ */ jsx(OrgThemeScopeContext.Provider, { value: scope, children }) })
63
+ }
64
+ )
65
+ ] });
66
+ }
67
+ export {
68
+ OrgThemeRegistry as default,
69
+ useOrgThemeId,
70
+ useOrgThemeScope
71
+ };
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useMemo } from "react";
4
+ import { ThemeProvider } from "@mui/material/styles";
5
+ import CssBaseline from "@mui/material/CssBaseline";
6
+ import createSiteTheme from "./createSiteTheme.js";
7
+ function ThemeRegistry({ theme: def, children }) {
8
+ const key = JSON.stringify(def);
9
+ const muiTheme = useMemo(() => createSiteTheme(def), [key]);
10
+ return /* @__PURE__ */ jsxs(ThemeProvider, { theme: muiTheme, defaultMode: "system", children: [
11
+ /* @__PURE__ */ jsx(CssBaseline, { enableColorScheme: true }),
12
+ children
13
+ ] });
14
+ }
15
+ export {
16
+ ThemeRegistry as default
17
+ };
@@ -0,0 +1,21 @@
1
+ function berryNavRowSx(theme) {
2
+ const wash = `rgba(${theme.vars.palette.primary.mainChannel} / 0.12)`;
3
+ return {
4
+ borderRadius: "8px",
5
+ "&:hover": {
6
+ backgroundColor: wash,
7
+ color: theme.vars.palette.primary.dark,
8
+ "& .MuiListItemIcon-root": { color: theme.vars.palette.primary.dark }
9
+ },
10
+ "&.Mui-selected": {
11
+ backgroundColor: wash,
12
+ color: theme.vars.palette.primary.dark,
13
+ "& .MuiListItemIcon-root": { color: theme.vars.palette.primary.main },
14
+ "& .MuiListItemText-primary": { fontWeight: 500 },
15
+ "&:hover": { backgroundColor: wash }
16
+ }
17
+ };
18
+ }
19
+ export {
20
+ berryNavRowSx as default
21
+ };