@aglyn/shared-ui-theme 1.0.0-beta.230 → 1.0.0-beta.232

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 (33) hide show
  1. package/package.json +4 -4
  2. package/src/lib/console-app.theme.d.ts +25 -0
  3. package/src/lib/console-app.theme.js +142 -0
  4. package/src/lib/console-app.theme.js.map +1 -0
  5. package/src/lib/console.theme.js +8 -2
  6. package/src/lib/console.theme.js.map +1 -1
  7. package/src/lib/constants.d.ts +2 -0
  8. package/src/lib/constants.js +1 -0
  9. package/src/lib/constants.js.map +1 -1
  10. package/src/lib/platform-brand.d.ts +25 -0
  11. package/src/lib/platform-brand.js +40 -0
  12. package/src/lib/platform-brand.js.map +1 -0
  13. package/src/lib/site-base-fonts.d.ts +54 -0
  14. package/src/lib/site-base-fonts.js +125 -0
  15. package/src/lib/site-base-fonts.js.map +1 -0
  16. package/src/lib/tenant.theme.d.ts +1 -2
  17. package/src/lib/tenant.theme.js +8 -30
  18. package/src/lib/tenant.theme.js.map +1 -1
  19. package/src/lib/util/font-stack.d.ts +44 -0
  20. package/src/lib/util/font-stack.js +79 -0
  21. package/src/lib/util/font-stack.js.map +1 -0
  22. package/src/lib/util/roboto-flex.d.ts +65 -0
  23. package/src/lib/util/roboto-flex.js +115 -0
  24. package/src/lib/util/roboto-flex.js.map +1 -0
  25. package/src/lib/util/self-hosted-fonts.d.ts +111 -1
  26. package/src/lib/util/self-hosted-fonts.js +349 -2
  27. package/src/lib/util/self-hosted-fonts.js.map +1 -1
  28. package/src/lib/util/theme-editor-edits.d.ts +105 -0
  29. package/src/lib/util/theme-editor-edits.js +168 -0
  30. package/src/lib/util/theme-editor-edits.js.map +1 -0
  31. package/src/lib/util/theme-editor-fields.d.ts +53 -2
  32. package/src/lib/util/theme-editor-fields.js +171 -20
  33. package/src/lib/util/theme-editor-fields.js.map +1 -1
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ import type { HostTheme, HostThemeScheme } from '@aglyn/shared-data-types';
7
+ import { type ThemeColorToken } from './theme-editor-fields';
8
+ /** One control's change; a null value clears it back to the theme's own. */
9
+ export type ThemeEditorEdit = {
10
+ control: 'color';
11
+ scheme: HostThemeScheme;
12
+ token: ThemeColorToken;
13
+ value: string | null;
14
+ } | {
15
+ control: 'darkScheme';
16
+ value: 'auto' | 'off';
17
+ } | {
18
+ control: 'fontFamily';
19
+ value: string;
20
+ } | {
21
+ control: 'borderRadius';
22
+ value: number | null;
23
+ } | {
24
+ control: 'spacing';
25
+ value: number | null;
26
+ } | {
27
+ control: 'navHeight';
28
+ breakpoint: 'xs' | 'sm';
29
+ value: number | null;
30
+ };
31
+ /** The most edits one save may carry: every color of both schemes and the rest. */
32
+ export declare const THEME_EDITOR_EDITS_MAX: number;
33
+ /** One edit from a request body, or why it was refused. */
34
+ export declare function readThemeEditorEdit(raw: unknown): ThemeEditorEdit | string;
35
+ /** Every edit of a request body, or the first refusal. */
36
+ export declare function readThemeEditorEdits(raw: unknown): ThemeEditorEdit[] | string;
37
+ /** The theme with [edits] applied, in order, by the editor's own writers. */
38
+ export declare function applyThemeEditorEdits(theme: HostTheme, edits: readonly ThemeEditorEdit[]): HostTheme;
39
+ /** What each control shows for [theme]; an absent value inherits the platform's. */
40
+ export interface ThemeEditorValues {
41
+ colors: Record<HostThemeScheme, Record<string, string | null>>;
42
+ darkScheme: 'auto' | 'off';
43
+ fontFamily: string;
44
+ borderRadius: number | null;
45
+ spacing: number | null;
46
+ navHeight: {
47
+ xs: number | null;
48
+ sm: number | null;
49
+ };
50
+ }
51
+ export declare function readThemeEditorValues(theme: HostTheme | undefined): ThemeEditorValues;
52
+ /**
53
+ * The controls themselves, for a client that draws the editor from data:
54
+ * every color field, the font list, and each number's range and label.
55
+ */
56
+ export declare const THEME_EDITOR_CATALOG: {
57
+ readonly schemes: readonly HostThemeScheme[];
58
+ readonly colors: readonly {
59
+ token: ThemeColorToken;
60
+ label: string;
61
+ group: import("./theme-editor-fields").ThemeColorGroup;
62
+ }[];
63
+ readonly darkScheme: {
64
+ readonly label: "Dark scheme";
65
+ readonly options: readonly [{
66
+ readonly value: "auto";
67
+ readonly label: "Follows the visitor";
68
+ }, {
69
+ readonly value: "off";
70
+ readonly label: "Off — always light";
71
+ }];
72
+ };
73
+ readonly systemFont: "__system__";
74
+ readonly fonts: {
75
+ family: string;
76
+ category: "sans-serif" | "serif" | "monospace" | "display";
77
+ weights: Array<number>;
78
+ }[];
79
+ readonly borderRadius: {
80
+ readonly label: "Border radius";
81
+ readonly min: 0;
82
+ readonly max: 24;
83
+ readonly step: 1;
84
+ };
85
+ readonly spacing: {
86
+ readonly label: "Spacing unit (px)";
87
+ readonly min: 2;
88
+ readonly max: 16;
89
+ readonly step: 1;
90
+ };
91
+ readonly navHeight: {
92
+ readonly xs: {
93
+ readonly label: "Nav height, mobile (px)";
94
+ readonly min: 40;
95
+ readonly max: 160;
96
+ readonly step: 1;
97
+ };
98
+ readonly sm: {
99
+ readonly label: "Nav height, desktop (px)";
100
+ readonly min: 40;
101
+ readonly max: 160;
102
+ readonly step: 1;
103
+ };
104
+ };
105
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */ import { BORDER_RADIUS_FIELD, DARK_SCHEME_FIELD, GOOGLE_FONT_OPTIONS, SPACING_FIELD, SYSTEM_FONT_VALUE, THEME_COLOR_FIELDS, THEME_EDITOR_SCHEMES, TOOLBAR_HEIGHT_FIELDS, readDarkScheme, readFontFamily, readThemeColor, readToolbarHeight, writeBorderRadius, writeDarkScheme, writeFontFamily, writeSpacing, writeThemeColor, writeToolbarHeight } from "./theme-editor-fields.js";
6
+ /** The most edits one save may carry: every color of both schemes and the rest. */ export const THEME_EDITOR_EDITS_MAX = THEME_COLOR_FIELDS.length * 2 + 8;
7
+ const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
8
+ function readNumber(value, range) {
9
+ if (value === null || value === undefined || value === '') return null;
10
+ const parsed = typeof value === 'number' ? value : Number(value);
11
+ if (!Number.isFinite(parsed)) return 'Use a number.';
12
+ if (parsed < range.min || parsed > range.max) {
13
+ return `Use a number from ${range.min} to ${range.max}.`;
14
+ }
15
+ return parsed;
16
+ }
17
+ /** One edit from a request body, or why it was refused. */ export function readThemeEditorEdit(raw) {
18
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'That change could not be read.';
19
+ const edit = raw;
20
+ switch(edit['control']){
21
+ case 'color':
22
+ {
23
+ const scheme = edit['scheme'];
24
+ if (!THEME_EDITOR_SCHEMES.includes(scheme)) return 'Pick light or dark.';
25
+ const token = edit['token'];
26
+ if (!THEME_COLOR_FIELDS.some((field)=>field.token === token)) return 'That is not a theme color.';
27
+ const value = edit['value'];
28
+ if (value !== null && value !== undefined && value !== '' && (typeof value !== 'string' || !HEX_COLOR.test(value))) {
29
+ return 'Use a hex color such as #1A73E8.';
30
+ }
31
+ return {
32
+ control: 'color',
33
+ scheme: scheme,
34
+ token: token,
35
+ value: typeof value === 'string' && value ? value : null
36
+ };
37
+ }
38
+ case 'darkScheme':
39
+ return edit['value'] === 'off' ? {
40
+ control: 'darkScheme',
41
+ value: 'off'
42
+ } : {
43
+ control: 'darkScheme',
44
+ value: 'auto'
45
+ };
46
+ case 'fontFamily':
47
+ {
48
+ const value = edit['value'];
49
+ if (value !== SYSTEM_FONT_VALUE && !GOOGLE_FONT_OPTIONS.some((option)=>option.family === value)) {
50
+ return 'Pick a font from the list.';
51
+ }
52
+ return {
53
+ control: 'fontFamily',
54
+ value: value
55
+ };
56
+ }
57
+ case 'borderRadius':
58
+ {
59
+ const value = readNumber(edit['value'], BORDER_RADIUS_FIELD);
60
+ return typeof value === 'string' ? value : {
61
+ control: 'borderRadius',
62
+ value
63
+ };
64
+ }
65
+ case 'spacing':
66
+ {
67
+ const value = readNumber(edit['value'], SPACING_FIELD);
68
+ return typeof value === 'string' ? value : {
69
+ control: 'spacing',
70
+ value
71
+ };
72
+ }
73
+ case 'navHeight':
74
+ {
75
+ const breakpoint = edit['breakpoint'];
76
+ if (breakpoint !== 'xs' && breakpoint !== 'sm') return 'Pick mobile or desktop.';
77
+ const value = readNumber(edit['value'], TOOLBAR_HEIGHT_FIELDS[breakpoint]);
78
+ return typeof value === 'string' ? value : {
79
+ control: 'navHeight',
80
+ breakpoint,
81
+ value
82
+ };
83
+ }
84
+ default:
85
+ return 'That is not a theme control.';
86
+ }
87
+ }
88
+ /** Every edit of a request body, or the first refusal. */ export function readThemeEditorEdits(raw) {
89
+ if (!Array.isArray(raw) || raw.length === 0) return 'Nothing to change.';
90
+ if (raw.length > THEME_EDITOR_EDITS_MAX) return 'Too many changes at once.';
91
+ const edits = [];
92
+ for (const entry of raw){
93
+ const edit = readThemeEditorEdit(entry);
94
+ if (typeof edit === 'string') return edit;
95
+ edits.push(edit);
96
+ }
97
+ return edits;
98
+ }
99
+ /** The theme with [edits] applied, in order, by the editor's own writers. */ export function applyThemeEditorEdits(theme, edits) {
100
+ let next = theme;
101
+ for (const edit of edits){
102
+ switch(edit.control){
103
+ case 'color':
104
+ var _edit_value;
105
+ next = writeThemeColor(next, edit.scheme, edit.token, (_edit_value = edit.value) != null ? _edit_value : undefined);
106
+ break;
107
+ case 'darkScheme':
108
+ next = writeDarkScheme(next, edit.value);
109
+ break;
110
+ case 'fontFamily':
111
+ next = writeFontFamily(next, edit.value);
112
+ break;
113
+ case 'borderRadius':
114
+ var _edit_value1;
115
+ next = writeBorderRadius(next, (_edit_value1 = edit.value) != null ? _edit_value1 : undefined);
116
+ break;
117
+ case 'spacing':
118
+ var _edit_value2;
119
+ next = writeSpacing(next, (_edit_value2 = edit.value) != null ? _edit_value2 : undefined);
120
+ break;
121
+ case 'navHeight':
122
+ var _edit_value3;
123
+ next = writeToolbarHeight(next, edit.breakpoint, (_edit_value3 = edit.value) != null ? _edit_value3 : undefined);
124
+ break;
125
+ }
126
+ }
127
+ return next;
128
+ }
129
+ export function readThemeEditorValues(theme) {
130
+ var _readToolbarHeight, _readToolbarHeight1;
131
+ var _safe_shape;
132
+ const safe = theme != null ? theme : {};
133
+ const colors = {};
134
+ for (const scheme of THEME_EDITOR_SCHEMES){
135
+ colors[scheme] = {};
136
+ for (const field of THEME_COLOR_FIELDS){
137
+ var _readThemeColor;
138
+ colors[scheme][field.token] = (_readThemeColor = readThemeColor(safe, scheme, field.token)) != null ? _readThemeColor : null;
139
+ }
140
+ }
141
+ const radius = (_safe_shape = safe.shape) == null ? void 0 : _safe_shape.borderRadius;
142
+ return {
143
+ colors,
144
+ darkScheme: readDarkScheme(safe),
145
+ fontFamily: readFontFamily(safe),
146
+ borderRadius: typeof radius === 'number' && Number.isFinite(radius) ? radius : null,
147
+ spacing: typeof safe.spacing === 'number' ? safe.spacing : null,
148
+ navHeight: {
149
+ xs: (_readToolbarHeight = readToolbarHeight(safe, 'xs')) != null ? _readToolbarHeight : null,
150
+ sm: (_readToolbarHeight1 = readToolbarHeight(safe, 'sm')) != null ? _readToolbarHeight1 : null
151
+ }
152
+ };
153
+ }
154
+ /**
155
+ * The controls themselves, for a client that draws the editor from data:
156
+ * every color field, the font list, and each number's range and label.
157
+ */ export const THEME_EDITOR_CATALOG = {
158
+ schemes: THEME_EDITOR_SCHEMES,
159
+ colors: THEME_COLOR_FIELDS,
160
+ darkScheme: DARK_SCHEME_FIELD,
161
+ systemFont: SYSTEM_FONT_VALUE,
162
+ fonts: GOOGLE_FONT_OPTIONS,
163
+ borderRadius: BORDER_RADIUS_FIELD,
164
+ spacing: SPACING_FIELD,
165
+ navHeight: TOOLBAR_HEIGHT_FIELDS
166
+ };
167
+
168
+ //# sourceMappingURL=theme-editor-edits.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/shared/ui/theme/src/lib/util/theme-editor-edits.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type { HostTheme, HostThemeScheme } from '@aglyn/shared-data-types'\nimport {\n BORDER_RADIUS_FIELD,\n DARK_SCHEME_FIELD,\n GOOGLE_FONT_OPTIONS,\n SPACING_FIELD,\n SYSTEM_FONT_VALUE,\n THEME_COLOR_FIELDS,\n THEME_EDITOR_SCHEMES,\n TOOLBAR_HEIGHT_FIELDS,\n readDarkScheme,\n readFontFamily,\n readThemeColor,\n readToolbarHeight,\n writeBorderRadius,\n writeDarkScheme,\n writeFontFamily,\n writeSpacing,\n writeThemeColor,\n writeToolbarHeight,\n type ThemeColorToken,\n} from './theme-editor-fields'\n\n/*\n * THE THEME EDITOR'S CONTROLS AS EDITS (AGL-3668).\n *\n * The console's theme section edits the resolved theme in the browser with\n * the writers in `theme-editor-fields.ts` and saves the difference from the\n * picked theme as the site's override. A native app sends the same controls\n * as named edits instead — one color for one scheme, the font, the radius,\n * the spacing unit, a nav height, the dark scheme — and `/api/hosts/theme`\n * applies them here with those same writers, so both clients make exactly the\n * same theme from the same choice. `readThemeEditorValues` is the other half:\n * what each control currently shows.\n */\n\n/** One control's change; a null value clears it back to the theme's own. */\nexport type ThemeEditorEdit =\n | { control: 'color'; scheme: HostThemeScheme; token: ThemeColorToken; value: string | null }\n | { control: 'darkScheme'; value: 'auto' | 'off' }\n | { control: 'fontFamily'; value: string }\n | { control: 'borderRadius'; value: number | null }\n | { control: 'spacing'; value: number | null }\n | { control: 'navHeight'; breakpoint: 'xs' | 'sm'; value: number | null }\n\n/** The most edits one save may carry: every color of both schemes and the rest. */\nexport const THEME_EDITOR_EDITS_MAX = THEME_COLOR_FIELDS.length * 2 + 8\n\nconst HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/\n\nfunction readNumber(\n value: unknown,\n range: { min: number; max: number },\n): number | null | string {\n if (value === null || value === undefined || value === '') return null\n const parsed = typeof value === 'number' ? value : Number(value)\n if (!Number.isFinite(parsed)) return 'Use a number.'\n if (parsed < range.min || parsed > range.max) {\n return `Use a number from ${range.min} to ${range.max}.`\n }\n return parsed\n}\n\n/** One edit from a request body, or why it was refused. */\nexport function readThemeEditorEdit(raw: unknown): ThemeEditorEdit | string {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'That change could not be read.'\n const edit = raw as Record<string, unknown>\n switch (edit['control']) {\n case 'color': {\n const scheme = edit['scheme']\n if (!THEME_EDITOR_SCHEMES.includes(scheme as HostThemeScheme)) return 'Pick light or dark.'\n const token = edit['token']\n if (!THEME_COLOR_FIELDS.some((field) => field.token === token)) return 'That is not a theme color.'\n const value = edit['value']\n if (value !== null && value !== undefined && value !== '' && (typeof value !== 'string' || !HEX_COLOR.test(value))) {\n return 'Use a hex color such as #1A73E8.'\n }\n return {\n control: 'color',\n scheme: scheme as HostThemeScheme,\n token: token as ThemeColorToken,\n value: typeof value === 'string' && value ? value : null,\n }\n }\n case 'darkScheme':\n return edit['value'] === 'off' ? { control: 'darkScheme', value: 'off' } : { control: 'darkScheme', value: 'auto' }\n case 'fontFamily': {\n const value = edit['value']\n if (value !== SYSTEM_FONT_VALUE && !GOOGLE_FONT_OPTIONS.some((option) => option.family === value)) {\n return 'Pick a font from the list.'\n }\n return { control: 'fontFamily', value: value as string }\n }\n case 'borderRadius': {\n const value = readNumber(edit['value'], BORDER_RADIUS_FIELD)\n return typeof value === 'string' ? value : { control: 'borderRadius', value }\n }\n case 'spacing': {\n const value = readNumber(edit['value'], SPACING_FIELD)\n return typeof value === 'string' ? value : { control: 'spacing', value }\n }\n case 'navHeight': {\n const breakpoint = edit['breakpoint']\n if (breakpoint !== 'xs' && breakpoint !== 'sm') return 'Pick mobile or desktop.'\n const value = readNumber(edit['value'], TOOLBAR_HEIGHT_FIELDS[breakpoint])\n return typeof value === 'string' ? value : { control: 'navHeight', breakpoint, value }\n }\n default:\n return 'That is not a theme control.'\n }\n}\n\n/** Every edit of a request body, or the first refusal. */\nexport function readThemeEditorEdits(raw: unknown): ThemeEditorEdit[] | string {\n if (!Array.isArray(raw) || raw.length === 0) return 'Nothing to change.'\n if (raw.length > THEME_EDITOR_EDITS_MAX) return 'Too many changes at once.'\n const edits: ThemeEditorEdit[] = []\n for (const entry of raw) {\n const edit = readThemeEditorEdit(entry)\n if (typeof edit === 'string') return edit\n edits.push(edit)\n }\n return edits\n}\n\n/** The theme with [edits] applied, in order, by the editor's own writers. */\nexport function applyThemeEditorEdits(theme: HostTheme, edits: readonly ThemeEditorEdit[]): HostTheme {\n let next = theme\n for (const edit of edits) {\n switch (edit.control) {\n case 'color':\n next = writeThemeColor(next, edit.scheme, edit.token, edit.value ?? undefined)\n break\n case 'darkScheme':\n next = writeDarkScheme(next, edit.value)\n break\n case 'fontFamily':\n next = writeFontFamily(next, edit.value)\n break\n case 'borderRadius':\n next = writeBorderRadius(next, edit.value ?? undefined)\n break\n case 'spacing':\n next = writeSpacing(next, edit.value ?? undefined)\n break\n case 'navHeight':\n next = writeToolbarHeight(next, edit.breakpoint, edit.value ?? undefined)\n break\n }\n }\n return next\n}\n\n/** What each control shows for [theme]; an absent value inherits the platform's. */\nexport interface ThemeEditorValues {\n colors: Record<HostThemeScheme, Record<string, string | null>>\n darkScheme: 'auto' | 'off'\n fontFamily: string\n borderRadius: number | null\n spacing: number | null\n navHeight: { xs: number | null; sm: number | null }\n}\n\nexport function readThemeEditorValues(theme: HostTheme | undefined): ThemeEditorValues {\n const safe = theme ?? {}\n const colors = {} as Record<HostThemeScheme, Record<string, string | null>>\n for (const scheme of THEME_EDITOR_SCHEMES) {\n colors[scheme] = {}\n for (const field of THEME_COLOR_FIELDS) {\n colors[scheme][field.token] = readThemeColor(safe, scheme, field.token) ?? null\n }\n }\n const radius = safe.shape?.borderRadius\n return {\n colors,\n darkScheme: readDarkScheme(safe),\n fontFamily: readFontFamily(safe),\n borderRadius: typeof radius === 'number' && Number.isFinite(radius) ? radius : null,\n spacing: typeof safe.spacing === 'number' ? safe.spacing : null,\n navHeight: {\n xs: readToolbarHeight(safe, 'xs') ?? null,\n sm: readToolbarHeight(safe, 'sm') ?? null,\n },\n }\n}\n\n/**\n * The controls themselves, for a client that draws the editor from data:\n * every color field, the font list, and each number's range and label.\n */\nexport const THEME_EDITOR_CATALOG = {\n schemes: THEME_EDITOR_SCHEMES,\n colors: THEME_COLOR_FIELDS,\n darkScheme: DARK_SCHEME_FIELD,\n systemFont: SYSTEM_FONT_VALUE,\n fonts: GOOGLE_FONT_OPTIONS,\n borderRadius: BORDER_RADIUS_FIELD,\n spacing: SPACING_FIELD,\n navHeight: TOOLBAR_HEIGHT_FIELDS,\n} as const\n"],"names":["BORDER_RADIUS_FIELD","DARK_SCHEME_FIELD","GOOGLE_FONT_OPTIONS","SPACING_FIELD","SYSTEM_FONT_VALUE","THEME_COLOR_FIELDS","THEME_EDITOR_SCHEMES","TOOLBAR_HEIGHT_FIELDS","readDarkScheme","readFontFamily","readThemeColor","readToolbarHeight","writeBorderRadius","writeDarkScheme","writeFontFamily","writeSpacing","writeThemeColor","writeToolbarHeight","THEME_EDITOR_EDITS_MAX","length","HEX_COLOR","readNumber","value","range","undefined","parsed","Number","isFinite","min","max","readThemeEditorEdit","raw","Array","isArray","edit","scheme","includes","token","some","field","test","control","option","family","breakpoint","readThemeEditorEdits","edits","entry","push","applyThemeEditorEdits","theme","next","readThemeEditorValues","safe","colors","radius","shape","borderRadius","darkScheme","fontFamily","spacing","navHeight","xs","sm","THEME_EDITOR_CATALOG","schemes","systemFont","fonts"],"mappings":"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"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import type { HostTheme, HostThemeScheme, HostThemeSchemeColors } from '@aglyn/shared-data-types';
17
+ import type { HostTheme, HostThemeFontCategory, HostThemeTypographyVariantKey, HostThemeScheme, HostThemeSchemeColors } from '@aglyn/shared-data-types';
18
18
  import { type HostThemeComponentKey } from './host-theme';
19
19
  /**
20
20
  * The host theme editor's field catalog (AGL-2938): every control the editor
@@ -140,7 +140,7 @@ export declare const GOOGLE_FONT_OPTIONS: Array<{
140
140
  category: 'sans-serif' | 'serif' | 'monospace' | 'display';
141
141
  weights: Array<number>;
142
142
  }>;
143
- export declare function fontFamilyStack(family: string, category: (typeof GOOGLE_FONT_OPTIONS)[number]['category']): string;
143
+ export declare function fontFamilyStack(family: string, category: HostThemeFontCategory): string;
144
144
  export declare function getSchemeColor(colors: HostThemeSchemeColors | undefined, path: SurfaceColorPath): string | undefined;
145
145
  /** The two color schemes the editor edits, in tab order. */
146
146
  export declare const THEME_EDITOR_SCHEMES: readonly HostThemeScheme[];
@@ -277,6 +277,57 @@ export declare function readFontFamily(theme: HostTheme | undefined): string;
277
277
  * theme default. A family outside the curated list changes nothing.
278
278
  */
279
279
  export declare function writeFontFamily(theme: HostTheme, value: string): HostTheme;
280
+ /**
281
+ * The text styles a separate heading font is set on: `h1`…`h6`, and
282
+ * `displayXl`, the platform's rung above `h1`, which a heading font left off
283
+ * would draw in the body font — and load the body font's heaviest weight for.
284
+ */
285
+ export declare const THEME_HEADING_VARIANTS: readonly HostThemeTypographyVariantKey[];
286
+ /**
287
+ * One family a theme sets text in, as the font picker shows and writes it
288
+ * (AGL-3656): a `fonts` entry and the role it plays.
289
+ */
290
+ export interface ThemeFontChoice {
291
+ family: string;
292
+ category: HostThemeFontCategory;
293
+ /** Upright weights to load, ascending. */
294
+ weights: number[];
295
+ /** Italic weights to load, ascending; absent or empty for none. */
296
+ italics?: number[];
297
+ source: 'google' | 'custom';
298
+ }
299
+ /**
300
+ * The theme's body font and, when it differs, its heading font. `null` is the
301
+ * theme default: the base stack, nothing loaded.
302
+ */
303
+ export interface ThemeFontSelection {
304
+ body: ThemeFontChoice | null;
305
+ heading: ThemeFontChoice | null;
306
+ }
307
+ /** The first family a CSS font-family stack names, unquoted. */
308
+ export declare function firstStackFamily(stack: unknown): string | undefined;
309
+ /**
310
+ * The body and heading fonts a theme sets text in (AGL-3656).
311
+ *
312
+ * The body font is the family the theme-wide stack names first, when the
313
+ * theme loads it: a font the theme lists that no stack names draws nothing,
314
+ * which is what the theme default is. The heading font is the family `h1`
315
+ * names, when that is a loaded font other than the body's.
316
+ */
317
+ export declare function readThemeFonts(theme: HostTheme | undefined): ThemeFontSelection;
318
+ /**
319
+ * Sets the theme's body and heading fonts (AGL-3656): the `fonts` entries the
320
+ * page loads, the theme-wide stack, and the stack of each style in
321
+ * {@link THEME_HEADING_VARIANTS} for a heading font of its own. `null` goes
322
+ * back to the theme default for that role.
323
+ *
324
+ * An uploaded (`custom`) font the theme lists but no longer sets text in is
325
+ * kept, because it is the site's own and is picked again from the list; a
326
+ * Google font nothing sets text in any more is dropped, so the page stops
327
+ * loading it. Everything else in the typography — sizes, weights, a variant's own
328
+ * family that is not a heading — is left as it was.
329
+ */
330
+ export declare function writeThemeFonts(theme: HostTheme, selection: ThemeFontSelection): HostTheme;
280
331
  /** Sets the corner radius, or clears it with `undefined` so it inherits. */
281
332
  export declare function writeBorderRadius(theme: HostTheme, value: number | undefined): HostTheme;
282
333
  /** Sets the spacing unit; anything but a positive number clears it. */
@@ -596,38 +596,189 @@ export const FONT_FAMILY_FIELD = {
596
596
  }
597
597
  /** The font select's value: the site's font, or {@link SYSTEM_FONT_VALUE}. */ export function readFontFamily(theme) {
598
598
  var _ref;
599
- var _theme_fonts_, _theme_fonts;
600
- return (_ref = theme == null ? void 0 : (_theme_fonts = theme.fonts) == null ? void 0 : (_theme_fonts_ = _theme_fonts[0]) == null ? void 0 : _theme_fonts_.family) != null ? _ref : SYSTEM_FONT_VALUE;
599
+ var _readThemeFonts_body;
600
+ return (_ref = (_readThemeFonts_body = readThemeFonts(theme).body) == null ? void 0 : _readThemeFonts_body.family) != null ? _ref : SYSTEM_FONT_VALUE;
601
601
  }
602
602
  /**
603
603
  * Picks a font from {@link GOOGLE_FONT_OPTIONS}, loading it and naming it as
604
604
  * the typography's stack, or {@link SYSTEM_FONT_VALUE} to go back to the
605
605
  * theme default. A family outside the curated list changes nothing.
606
606
  */ export function writeFontFamily(theme, value) {
607
+ // Through writeThemeFonts (AGL-3656), so a heading font and the site's own
608
+ // uploaded fonts survive a body font picked from the short list or by the
609
+ // AI theme step; replacing `fonts` wholesale dropped both.
610
+ const current = readThemeFonts(theme);
607
611
  if (value === SYSTEM_FONT_VALUE) {
608
- const next = _extends({}, theme);
609
- delete next.fonts;
610
- const typography = _extends({}, next.typography);
611
- delete typography.fontFamily;
612
- if (Object.keys(typography).length) next.typography = typography;
613
- else delete next.typography;
614
- return next;
612
+ return writeThemeFonts(theme, {
613
+ body: null,
614
+ heading: current.heading
615
+ });
615
616
  }
616
617
  const option = GOOGLE_FONT_OPTIONS.find((entry)=>entry.family === value);
617
618
  if (!option) return theme;
618
- return _extends({}, theme, {
619
- fonts: [
620
- {
621
- family: option.family,
622
- weights: option.weights,
623
- source: 'google'
624
- }
625
- ],
626
- typography: _extends({}, theme.typography, {
627
- fontFamily: fontFamilyStack(option.family, option.category)
628
- })
619
+ return writeThemeFonts(theme, {
620
+ body: {
621
+ family: option.family,
622
+ category: option.category,
623
+ weights: option.weights,
624
+ source: 'google'
625
+ },
626
+ heading: current.heading
627
+ });
628
+ }
629
+ /**
630
+ * The text styles a separate heading font is set on: `h1`…`h6`, and
631
+ * `displayXl`, the platform's rung above `h1`, which a heading font left off
632
+ * would draw in the body font — and load the body font's heaviest weight for.
633
+ */ export const THEME_HEADING_VARIANTS = [
634
+ 'displayXl',
635
+ 'h1',
636
+ 'h2',
637
+ 'h3',
638
+ 'h4',
639
+ 'h5',
640
+ 'h6'
641
+ ];
642
+ /** The first family a CSS font-family stack names, unquoted. */ export function firstStackFamily(stack) {
643
+ var _stack_split_;
644
+ if (typeof stack !== 'string') return undefined;
645
+ const first = (_stack_split_ = stack.split(',')[0]) == null ? void 0 : _stack_split_.trim().replace(/^["']|["']$/g, '').trim();
646
+ return first || undefined;
647
+ }
648
+ /** The category a stack's generic keyword names, for a font that records none. */ function stackCategory(stack) {
649
+ if (typeof stack !== 'string') return 'sans-serif';
650
+ if (/\bmonospace\b/i.test(stack)) return 'monospace';
651
+ if (/(^|,)\s*serif\b/i.test(stack)) return 'serif';
652
+ return 'sans-serif';
653
+ }
654
+ function sortedWeights(values) {
655
+ return [
656
+ ...new Set((values != null ? values : []).map((value)=>Math.round(Number(value))).filter((value)=>Number.isFinite(value) && value >= 1 && value <= 1000))
657
+ ].sort((a, b)=>a - b);
658
+ }
659
+ function choiceOf(font, stack) {
660
+ var _font_category;
661
+ const italics = sortedWeights(font.italics);
662
+ return _extends({
663
+ family: font.family.trim(),
664
+ category: (_font_category = font.category) != null ? _font_category : stackCategory(stack),
665
+ weights: sortedWeights(font.weights).length ? sortedWeights(font.weights) : [
666
+ 400
667
+ ]
668
+ }, italics.length ? {
669
+ italics
670
+ } : {}, {
671
+ source: font.source === 'custom' ? 'custom' : 'google'
672
+ });
673
+ }
674
+ function findFont(theme, family) {
675
+ var _theme_fonts;
676
+ if (!family) return undefined;
677
+ const wanted = family.toLowerCase();
678
+ return theme == null ? void 0 : (_theme_fonts = theme.fonts) == null ? void 0 : _theme_fonts.find((font)=>{
679
+ var _font_source;
680
+ var _font_family;
681
+ return ((_font_family = font.family) == null ? void 0 : _font_family.trim().toLowerCase()) === wanted && ((_font_source = font.source) != null ? _font_source : 'google') !== 'system';
629
682
  });
630
683
  }
684
+ /**
685
+ * The body and heading fonts a theme sets text in (AGL-3656).
686
+ *
687
+ * The body font is the family the theme-wide stack names first, when the
688
+ * theme loads it: a font the theme lists that no stack names draws nothing,
689
+ * which is what the theme default is. The heading font is the family `h1`
690
+ * names, when that is a loaded font other than the body's.
691
+ */ export function readThemeFonts(theme) {
692
+ var _theme_typography, _theme_typography_variants_h1, _theme_typography_variants, _theme_typography1;
693
+ const stack = theme == null ? void 0 : (_theme_typography = theme.typography) == null ? void 0 : _theme_typography.fontFamily;
694
+ const bodyFont = findFont(theme, firstStackFamily(stack));
695
+ const headingStack = theme == null ? void 0 : (_theme_typography1 = theme.typography) == null ? void 0 : (_theme_typography_variants = _theme_typography1.variants) == null ? void 0 : (_theme_typography_variants_h1 = _theme_typography_variants.h1) == null ? void 0 : _theme_typography_variants_h1.fontFamily;
696
+ const headingFont = findFont(theme, firstStackFamily(headingStack));
697
+ return {
698
+ body: bodyFont ? choiceOf(bodyFont, stack) : null,
699
+ heading: headingFont && headingFont !== bodyFont ? choiceOf(headingFont, headingStack) : null
700
+ };
701
+ }
702
+ function fontEntry(choice) {
703
+ const italics = sortedWeights(choice.italics);
704
+ return _extends({
705
+ family: choice.family.trim(),
706
+ weights: sortedWeights(choice.weights).length ? sortedWeights(choice.weights) : [
707
+ 400
708
+ ]
709
+ }, italics.length ? {
710
+ italics
711
+ } : {}, {
712
+ source: choice.source,
713
+ category: choice.category
714
+ });
715
+ }
716
+ /**
717
+ * Sets the theme's body and heading fonts (AGL-3656): the `fonts` entries the
718
+ * page loads, the theme-wide stack, and the stack of each style in
719
+ * {@link THEME_HEADING_VARIANTS} for a heading font of its own. `null` goes
720
+ * back to the theme default for that role.
721
+ *
722
+ * An uploaded (`custom`) font the theme lists but no longer sets text in is
723
+ * kept, because it is the site's own and is picked again from the list; a
724
+ * Google font nothing sets text in any more is dropped, so the page stops
725
+ * loading it. Everything else in the typography — sizes, weights, a variant's own
726
+ * family that is not a heading — is left as it was.
727
+ */ export function writeThemeFonts(theme, selection) {
728
+ var _ref, _theme_fonts;
729
+ var _theme_typography_variants_h1, _theme_typography_variants, _theme_typography, _theme_typography1;
730
+ const body = selection.body;
731
+ const heading = selection.heading && (!body || selection.heading.family.toLowerCase() !== body.family.toLowerCase()) ? selection.heading : null;
732
+ const previous = readThemeFonts(theme);
733
+ const previousHeadingStack = previous.heading ? (_theme_typography = theme.typography) == null ? void 0 : (_theme_typography_variants = _theme_typography.variants) == null ? void 0 : (_theme_typography_variants_h1 = _theme_typography_variants.h1) == null ? void 0 : _theme_typography_variants_h1.fontFamily : undefined;
734
+ const chosen = [
735
+ body,
736
+ heading
737
+ ].filter((entry)=>!!entry);
738
+ const chosenKeys = new Set(chosen.map((entry)=>entry.family.trim().toLowerCase()));
739
+ // Families a text style other than a heading still names, by hand or by
740
+ // an earlier editor: they keep loading, as their style keeps drawing them.
741
+ const otherStyles = new Set(Object.entries((_ref = (_theme_typography1 = theme.typography) == null ? void 0 : _theme_typography1.variants) != null ? _ref : {}).filter(([key])=>!THEME_HEADING_VARIANTS.includes(key)).map(([, variant])=>{
742
+ var _firstStackFamily;
743
+ return (_firstStackFamily = firstStackFamily(variant == null ? void 0 : variant.fontFamily)) == null ? void 0 : _firstStackFamily.toLowerCase();
744
+ }).filter((family)=>!!family));
745
+ const kept = ((_theme_fonts = theme.fonts) != null ? _theme_fonts : []).filter((font)=>{
746
+ var _ref;
747
+ var _font_family;
748
+ const key = (_ref = (_font_family = font.family) == null ? void 0 : _font_family.trim().toLowerCase()) != null ? _ref : '';
749
+ if (!key || chosenKeys.has(key)) return false;
750
+ return font.source === 'custom' || otherStyles.has(key);
751
+ });
752
+ const fonts = [
753
+ ...chosen.map(fontEntry),
754
+ ...kept
755
+ ];
756
+ const typography = _extends({}, theme.typography);
757
+ if (body) typography.fontFamily = fontFamilyStack(body.family.trim(), body.category);
758
+ else delete typography.fontFamily;
759
+ const variants = _extends({}, typography.variants);
760
+ for (const key of THEME_HEADING_VARIANTS){
761
+ const variant = _extends({}, variants[key]);
762
+ if (heading) {
763
+ variant.fontFamily = fontFamilyStack(heading.family.trim(), heading.category);
764
+ } else if (variant.fontFamily !== undefined && (variant.fontFamily === previousHeadingStack || !findFont({
765
+ fonts
766
+ }, firstStackFamily(variant.fontFamily)))) {
767
+ // The heading font this theme had, or a family it no longer loads.
768
+ delete variant.fontFamily;
769
+ }
770
+ if (Object.keys(variant).length) variants[key] = variant;
771
+ else delete variants[key];
772
+ }
773
+ if (Object.keys(variants).length) typography.variants = variants;
774
+ else delete typography.variants;
775
+ const next = _extends({}, theme);
776
+ if (fonts.length) next.fonts = fonts;
777
+ else delete next.fonts;
778
+ if (Object.keys(typography).length) next.typography = typography;
779
+ else delete next.typography;
780
+ return next;
781
+ }
631
782
  /** Sets the corner radius, or clears it with `undefined` so it inherits. */ export function writeBorderRadius(theme, value) {
632
783
  if (value !== undefined && Number.isFinite(value)) {
633
784
  return _extends({}, theme, {