@asturiu/v0-sdk 0.0.0-stage → 1.0.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/CHANGELOG.md +42 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +82 -2
- package/contract/assertion-vectors.json +90 -0
- package/contract/schemas/branding.schema.json +46 -0
- package/contract/schemas/definitions.schema.json +376 -0
- package/contract/schemas/portal.schema.json +56 -0
- package/contract/tenant-api-routes.json +165 -0
- package/dist/branding.d.ts +131 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +206 -0
- package/dist/branding.js.map +1 -0
- package/dist/changesets.d.ts +58 -0
- package/dist/changesets.d.ts.map +1 -0
- package/dist/changesets.js +18 -0
- package/dist/changesets.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +12 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/changesets.d.ts +14 -0
- package/dist/cli/changesets.d.ts.map +1 -0
- package/dist/cli/changesets.js +252 -0
- package/dist/cli/changesets.js.map +1 -0
- package/dist/cli/cli.d.ts +37 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/cli/cli.js +415 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/cli/context.d.ts +147 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/context.js +258 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli/engine.d.ts +59 -0
- package/dist/cli/engine.d.ts.map +1 -0
- package/dist/cli/engine.js +85 -0
- package/dist/cli/engine.js.map +1 -0
- package/dist/cli/feedback.d.ts +40 -0
- package/dist/cli/feedback.d.ts.map +1 -0
- package/dist/cli/feedback.js +420 -0
- package/dist/cli/feedback.js.map +1 -0
- package/dist/cli/files.d.ts +83 -0
- package/dist/cli/files.d.ts.map +1 -0
- package/dist/cli/files.js +389 -0
- package/dist/cli/files.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +8 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/overview.d.ts +6 -0
- package/dist/cli/overview.d.ts.map +1 -0
- package/dist/cli/overview.js +379 -0
- package/dist/cli/overview.js.map +1 -0
- package/dist/cli/parts.d.ts +5 -0
- package/dist/cli/parts.d.ts.map +1 -0
- package/dist/cli/parts.js +414 -0
- package/dist/cli/parts.js.map +1 -0
- package/dist/cli/sources.d.ts +37 -0
- package/dist/cli/sources.d.ts.map +1 -0
- package/dist/cli/sources.js +91 -0
- package/dist/cli/sources.js.map +1 -0
- package/dist/cli/terminal.d.ts +135 -0
- package/dist/cli/terminal.d.ts.map +1 -0
- package/dist/cli/terminal.js +353 -0
- package/dist/cli/terminal.js.map +1 -0
- package/dist/cli/ui-page.d.ts +8 -0
- package/dist/cli/ui-page.d.ts.map +1 -0
- package/dist/cli/ui-page.js +216 -0
- package/dist/cli/ui-page.js.map +1 -0
- package/dist/cli/ui.d.ts +54 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +251 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +165 -0
- package/dist/client.js.map +1 -0
- package/dist/contacts.d.ts +43 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +46 -0
- package/dist/contacts.js.map +1 -0
- package/dist/definition-set.d.ts +731 -0
- package/dist/definition-set.d.ts.map +1 -0
- package/dist/definition-set.js +659 -0
- package/dist/definition-set.js.map +1 -0
- package/dist/definitions.d.ts +42 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +154 -0
- package/dist/definitions.js.map +1 -0
- package/dist/enrolment.d.ts +9 -0
- package/dist/enrolment.d.ts.map +1 -0
- package/dist/enrolment.js +8 -0
- package/dist/enrolment.js.map +1 -0
- package/dist/fill.d.ts +73 -0
- package/dist/fill.d.ts.map +1 -0
- package/dist/fill.js +46 -0
- package/dist/fill.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications.d.ts +33 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +36 -0
- package/dist/notifications.js.map +1 -0
- package/dist/oidc.d.ts +122 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +150 -0
- package/dist/oidc.js.map +1 -0
- package/dist/overlay.d.ts +109 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +58 -0
- package/dist/overlay.js.map +1 -0
- package/dist/passes.d.ts +95 -0
- package/dist/passes.d.ts.map +1 -0
- package/dist/passes.js +140 -0
- package/dist/passes.js.map +1 -0
- package/dist/portal-options.d.ts +65 -0
- package/dist/portal-options.d.ts.map +1 -0
- package/dist/portal-options.js +120 -0
- package/dist/portal-options.js.map +1 -0
- package/dist/reads.d.ts +41 -0
- package/dist/reads.d.ts.map +1 -0
- package/dist/reads.js +44 -0
- package/dist/reads.js.map +1 -0
- package/dist/recovery-words.d.ts +8 -0
- package/dist/recovery-words.d.ts.map +1 -0
- package/dist/recovery-words.js +136 -0
- package/dist/recovery-words.js.map +1 -0
- package/dist/recovery.d.ts +198 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +112 -0
- package/dist/recovery.js.map +1 -0
- package/dist/reveal.d.ts +35 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +66 -0
- package/dist/reveal.js.map +1 -0
- package/dist/seal.d.ts +15 -0
- package/dist/seal.d.ts.map +1 -0
- package/dist/seal.js +0 -0
- package/dist/seal.js.map +1 -0
- package/dist/session.d.ts +66 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +61 -0
- package/dist/session.js.map +1 -0
- package/dist/signing-in-mark.d.ts +14 -0
- package/dist/signing-in-mark.d.ts.map +1 -0
- package/dist/signing-in-mark.js +24 -0
- package/dist/signing-in-mark.js.map +1 -0
- package/dist/signing-in.d.ts +22 -0
- package/dist/signing-in.d.ts.map +1 -0
- package/dist/signing-in.js +203 -0
- package/dist/signing-in.js.map +1 -0
- package/dist/tags.d.ts +96 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +88 -0
- package/dist/tags.js.map +1 -0
- package/dist/tenant-subscription.d.ts +47 -0
- package/dist/tenant-subscription.d.ts.map +1 -0
- package/dist/tenant-subscription.js +7 -0
- package/dist/tenant-subscription.js.map +1 -0
- package/dist/testing/index.d.ts +125 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +320 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/theme.d.ts +237 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +504 -0
- package/dist/theme.js.map +1 -0
- package/dist/tickets.d.ts +152 -0
- package/dist/tickets.d.ts.map +1 -0
- package/dist/tickets.js +65 -0
- package/dist/tickets.js.map +1 -0
- package/dist/tiers.d.ts +168 -0
- package/dist/tiers.d.ts.map +1 -0
- package/dist/tiers.js +341 -0
- package/dist/tiers.js.map +1 -0
- package/dist/types.d.ts +885 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/urlid.d.ts +50 -0
- package/dist/urlid.d.ts.map +1 -0
- package/dist/urlid.js +78 -0
- package/dist/urlid.js.map +1 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +15 -0
- package/dist/version.js.map +1 -0
- package/make/asturiu.mk +43 -0
- package/package.json +63 -4
- package/src/branding.ts +221 -0
- package/src/changesets.ts +61 -0
- package/src/cli/bin.ts +12 -0
- package/src/cli/changesets.ts +233 -0
- package/src/cli/cli.ts +425 -0
- package/src/cli/context.ts +299 -0
- package/src/cli/engine.ts +112 -0
- package/src/cli/feedback.ts +406 -0
- package/src/cli/files.ts +371 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/overview.ts +343 -0
- package/src/cli/parts.ts +371 -0
- package/src/cli/sources.ts +111 -0
- package/src/cli/terminal.ts +384 -0
- package/src/cli/ui-page.ts +215 -0
- package/src/cli/ui.ts +266 -0
- package/src/client.ts +202 -0
- package/src/contacts.ts +61 -0
- package/src/definition-set.ts +965 -0
- package/src/definitions.ts +156 -0
- package/src/enrolment.ts +10 -0
- package/src/fill.ts +83 -0
- package/src/index.ts +38 -0
- package/src/notifications.ts +55 -0
- package/src/oidc.ts +199 -0
- package/src/overlay.ts +137 -0
- package/src/passes.ts +168 -0
- package/src/portal-options.ts +125 -0
- package/src/reads.ts +71 -0
- package/src/recovery-words.ts +137 -0
- package/src/recovery.ts +269 -0
- package/src/reveal.ts +72 -0
- package/src/seal.ts +0 -0
- package/src/session.ts +93 -0
- package/src/signing-in-mark.ts +23 -0
- package/src/signing-in.ts +213 -0
- package/src/tags.ts +146 -0
- package/src/tenant-subscription.ts +44 -0
- package/src/testing/index.ts +337 -0
- package/src/theme.ts +628 -0
- package/src/tickets.ts +195 -0
- package/src/tiers.ts +397 -0
- package/src/types.ts +847 -0
- package/src/urlid.ts +77 -0
- package/src/version.ts +15 -0
- package/templates/asturiu/branding.json +4 -0
- package/templates/asturiu/definitions.json +23 -0
- package/templates/asturiu/portal.json +16 -0
package/src/theme.ts
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal themes (ADR-033): how a Tenant's Customer Portal looks, as data. A
|
|
3
|
+
* closed vocabulary — a preset and overrides picked from fixed lists — so
|
|
4
|
+
* every choice is one the portal's stylesheet knows how to draw, and nothing
|
|
5
|
+
* a Tenant writes reaches a page as CSS. Pure and dependency-free: the Engine
|
|
6
|
+
* validates with these lists, the portal renders with them, the Admin UI
|
|
7
|
+
* edits and previews with them.
|
|
8
|
+
*
|
|
9
|
+
* Stored as `branding.theme` on the Tenant record; served on
|
|
10
|
+
* `GET /v0/portal/presentation` as `theme`.
|
|
11
|
+
*/
|
|
12
|
+
import { INLINE_IMAGE_PATTERN } from "./branding.js";
|
|
13
|
+
|
|
14
|
+
export const THEME_PRESETS = ["asturiu", "porcelain", "midnight", "aurora", "sandstone", "evergreen", "graphite", "ember"] as const;
|
|
15
|
+
/** `auto` follows the device, and the person may pick light or dark on the page. */
|
|
16
|
+
export const THEME_SCHEMES = ["dark", "light", "auto"] as const;
|
|
17
|
+
export const THEME_BACKGROUNDS = ["plain", "glow", "gradient", "mesh", "grid", "dots", "image"] as const;
|
|
18
|
+
export const THEME_INTENSITIES = ["subtle", "medium", "bold"] as const;
|
|
19
|
+
/** Corner radius scale: controls and cards together. */
|
|
20
|
+
export const THEME_SHAPES = ["sharp", "soft", "rounded", "pill"] as const;
|
|
21
|
+
export const THEME_DENSITIES = ["compact", "comfortable", "spacious"] as const;
|
|
22
|
+
export const THEME_BUTTONS = ["solid", "gradient", "outline", "soft", "glow"] as const;
|
|
23
|
+
/** How cards, lists and tiles sit on the page. */
|
|
24
|
+
export const THEME_SURFACES = ["flat", "outlined", "raised", "glass"] as const;
|
|
25
|
+
/** `bar`: a tab bar on a phone and a side rail on a wide screen; `floating`: a dock and a floating panel. */
|
|
26
|
+
export const THEME_NAVIGATION = ["bar", "floating"] as const;
|
|
27
|
+
export const THEME_FONTS = ["system", "humanist", "geometric", "grotesque", "rounded", "serif", "classic", "editorial", "industrial", "mono"] as const;
|
|
28
|
+
export const THEME_WEIGHTS = ["regular", "medium", "semibold", "bold", "heavy"] as const;
|
|
29
|
+
/** Section labels and eyebrows. */
|
|
30
|
+
export const THEME_LABEL_CASES = ["upper", "normal"] as const;
|
|
31
|
+
export const THEME_TONES = ["primary", "secondary", "neutral"] as const;
|
|
32
|
+
export const THEME_ICON_STROKES = ["light", "regular", "bold"] as const;
|
|
33
|
+
export const THEME_ICON_FRAMES = ["tile", "circle", "outline", "plain"] as const;
|
|
34
|
+
/** The frame around the Tenant's logo (or its initial, when it has none). */
|
|
35
|
+
export const THEME_MARK_FRAMES = ["tile", "circle", "plain"] as const;
|
|
36
|
+
export const THEME_SIGN_IN_LAYOUTS = ["centered", "card", "split"] as const;
|
|
37
|
+
export const THEME_MOTION = ["full", "subtle", "off"] as const;
|
|
38
|
+
|
|
39
|
+
export type ThemePreset = (typeof THEME_PRESETS)[number];
|
|
40
|
+
export type ThemeScheme = (typeof THEME_SCHEMES)[number];
|
|
41
|
+
export type ThemeBackground = (typeof THEME_BACKGROUNDS)[number];
|
|
42
|
+
export type ThemeIntensity = (typeof THEME_INTENSITIES)[number];
|
|
43
|
+
export type ThemeShape = (typeof THEME_SHAPES)[number];
|
|
44
|
+
export type ThemeDensity = (typeof THEME_DENSITIES)[number];
|
|
45
|
+
export type ThemeButtons = (typeof THEME_BUTTONS)[number];
|
|
46
|
+
export type ThemeSurfaces = (typeof THEME_SURFACES)[number];
|
|
47
|
+
export type ThemeNavigation = (typeof THEME_NAVIGATION)[number];
|
|
48
|
+
export type ThemeFont = (typeof THEME_FONTS)[number];
|
|
49
|
+
export type ThemeWeight = (typeof THEME_WEIGHTS)[number];
|
|
50
|
+
export type ThemeLabelCase = (typeof THEME_LABEL_CASES)[number];
|
|
51
|
+
export type ThemeTone = (typeof THEME_TONES)[number];
|
|
52
|
+
export type ThemeIconStroke = (typeof THEME_ICON_STROKES)[number];
|
|
53
|
+
export type ThemeIconFrame = (typeof THEME_ICON_FRAMES)[number];
|
|
54
|
+
export type ThemeMarkFrame = (typeof THEME_MARK_FRAMES)[number];
|
|
55
|
+
export type ThemeSignInLayout = (typeof THEME_SIGN_IN_LAYOUTS)[number];
|
|
56
|
+
export type ThemeMotion = (typeof THEME_MOTION)[number];
|
|
57
|
+
|
|
58
|
+
/** Colours one scheme may override. Everything else in the scheme is derived from these. */
|
|
59
|
+
export interface ThemeSchemeColors {
|
|
60
|
+
background?: string;
|
|
61
|
+
surface?: string;
|
|
62
|
+
text?: string;
|
|
63
|
+
primary?: string;
|
|
64
|
+
secondary?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `branding.theme`: a preset and what the Tenant changes about it. Every key is optional. */
|
|
68
|
+
export interface PortalThemeSpec {
|
|
69
|
+
preset?: ThemePreset;
|
|
70
|
+
scheme?: ThemeScheme;
|
|
71
|
+
colors?: { primary?: string; secondary?: string; light?: ThemeSchemeColors; dark?: ThemeSchemeColors };
|
|
72
|
+
background?: { style?: ThemeBackground; intensity?: ThemeIntensity; grain?: boolean; image_url?: string };
|
|
73
|
+
shape?: ThemeShape;
|
|
74
|
+
density?: ThemeDensity;
|
|
75
|
+
buttons?: ThemeButtons;
|
|
76
|
+
surfaces?: ThemeSurfaces;
|
|
77
|
+
navigation?: ThemeNavigation;
|
|
78
|
+
typography?: { body?: ThemeFont; display?: ThemeFont; weight?: ThemeWeight; labels?: ThemeLabelCase; label_tone?: ThemeTone };
|
|
79
|
+
icons?: { stroke?: ThemeIconStroke; frame?: ThemeIconFrame; tone?: ThemeTone };
|
|
80
|
+
mark?: ThemeMarkFrame;
|
|
81
|
+
sign_in?: { layout?: ThemeSignInLayout; headline?: string; tagline?: string; image_url?: string };
|
|
82
|
+
motion?: ThemeMotion;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Limits the Engine enforces on the free text and images of a theme. */
|
|
86
|
+
export const THEME_LIMITS = { headline: 80, tagline: 200, imageUrl: 400_000 } as const;
|
|
87
|
+
|
|
88
|
+
/** Every colour token of one scheme, as `#rrggbb`. */
|
|
89
|
+
export interface ThemePalette {
|
|
90
|
+
background: string;
|
|
91
|
+
surface: string;
|
|
92
|
+
surface2: string;
|
|
93
|
+
line: string;
|
|
94
|
+
line2: string;
|
|
95
|
+
text: string;
|
|
96
|
+
text2: string;
|
|
97
|
+
text3: string;
|
|
98
|
+
primary: string;
|
|
99
|
+
onPrimary: string;
|
|
100
|
+
/** The primary as text or an icon on this scheme's background: adjusted until it reads (WCAG AA). */
|
|
101
|
+
primaryText: string;
|
|
102
|
+
primarySoft: string;
|
|
103
|
+
primaryHover: string;
|
|
104
|
+
secondary: string;
|
|
105
|
+
onSecondary: string;
|
|
106
|
+
secondaryText: string;
|
|
107
|
+
secondarySoft: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A theme with every choice made: what the portal draws. */
|
|
111
|
+
export interface ResolvedPortalTheme {
|
|
112
|
+
preset: ThemePreset;
|
|
113
|
+
scheme: ThemeScheme;
|
|
114
|
+
light: ThemePalette;
|
|
115
|
+
dark: ThemePalette;
|
|
116
|
+
background: { style: ThemeBackground; intensity: ThemeIntensity; grain: boolean; imageUrl: string | null };
|
|
117
|
+
shape: ThemeShape;
|
|
118
|
+
density: ThemeDensity;
|
|
119
|
+
buttons: ThemeButtons;
|
|
120
|
+
surfaces: ThemeSurfaces;
|
|
121
|
+
navigation: ThemeNavigation;
|
|
122
|
+
typography: { body: ThemeFont; display: ThemeFont; weight: ThemeWeight; labels: ThemeLabelCase; labelTone: ThemeTone };
|
|
123
|
+
icons: { stroke: ThemeIconStroke; frame: ThemeIconFrame; tone: ThemeTone };
|
|
124
|
+
mark: ThemeMarkFrame;
|
|
125
|
+
signIn: { layout: ThemeSignInLayout; headline: string | null; tagline: string | null; imageUrl: string | null };
|
|
126
|
+
motion: ThemeMotion;
|
|
127
|
+
logoUrl: string | null;
|
|
128
|
+
/** A logo drawn for dark backgrounds; the dark scheme shows it instead of `logoUrl`. */
|
|
129
|
+
logoDarkUrl: string | null;
|
|
130
|
+
faviconUrl: string | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** System font stacks only: nothing is downloaded, nothing reaches another origin, and each reads well where it falls back. */
|
|
134
|
+
export const THEME_FONT_STACKS: Record<ThemeFont, { label: string; stack: string }> = {
|
|
135
|
+
system: { label: "System", stack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", system-ui, sans-serif' },
|
|
136
|
+
humanist: { label: "Humanist", stack: 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif' },
|
|
137
|
+
geometric: { label: "Geometric", stack: 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif' },
|
|
138
|
+
grotesque: { label: "Grotesque", stack: 'Inter, "Helvetica Neue", Helvetica, "Arial Nova", "Nimbus Sans", Arial, sans-serif' },
|
|
139
|
+
rounded: { label: "Rounded", stack: 'ui-rounded, "SF Pro Rounded", "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT", Calibri, sans-serif' },
|
|
140
|
+
serif: { label: "Serif", stack: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif' },
|
|
141
|
+
classic: { label: "Classic", stack: '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif' },
|
|
142
|
+
editorial: { label: "Editorial", stack: 'Didot, "Bodoni MT", "Noto Serif Display", "URW Palladio L", P052, Sylfaen, serif' },
|
|
143
|
+
industrial: { label: "Industrial", stack: 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif' },
|
|
144
|
+
mono: { label: "Monospace", stack: 'ui-monospace, "SF Mono", "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace' },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const WEIGHT: Record<ThemeWeight, string> = { regular: "400", medium: "500", semibold: "600", bold: "700", heavy: "800" };
|
|
148
|
+
|
|
149
|
+
/** A preset's scheme: the neutrals, and optionally its own primary and secondary with their exact companions (used only while the Tenant keeps the preset's colour). */
|
|
150
|
+
type SchemeBase = Pick<ThemePalette, "background" | "surface" | "surface2" | "line" | "line2" | "text" | "text2" | "text3"> & Partial<Pick<ThemePalette, "primary" | "secondary" | "onPrimary" | "primaryHover">>;
|
|
151
|
+
|
|
152
|
+
interface PresetDefinition {
|
|
153
|
+
label: string;
|
|
154
|
+
description: string;
|
|
155
|
+
primary: string;
|
|
156
|
+
secondary: string;
|
|
157
|
+
light: SchemeBase;
|
|
158
|
+
dark: SchemeBase;
|
|
159
|
+
choices: Omit<ResolvedPortalTheme, "preset" | "light" | "dark" | "logoUrl" | "logoDarkUrl" | "faviconUrl" | "background" | "signIn"> & { background: Omit<ResolvedPortalTheme["background"], "imageUrl">; signIn: Pick<ResolvedPortalTheme["signIn"], "layout"> };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The presets. `asturiu` is the platform's own look — the Admin UI's tokens,
|
|
164
|
+
* one for one — and the default for a Tenant that sets no branding at all.
|
|
165
|
+
* Names describe a look, never a product.
|
|
166
|
+
*/
|
|
167
|
+
export const THEME_PRESET_DEFINITIONS: Record<ThemePreset, PresetDefinition> = {
|
|
168
|
+
asturiu: {
|
|
169
|
+
label: "Asturiu",
|
|
170
|
+
description: "Ink, gold and teal. The platform's own look, as in the Admin UI.",
|
|
171
|
+
primary: "#f2c230",
|
|
172
|
+
secondary: "#35a8a4",
|
|
173
|
+
dark: { background: "#0a0b0d", surface: "#111316", surface2: "#181b20", line: "#262a31", line2: "#343941", text: "#f4f4f1", text2: "#a5aab3", text3: "#6f7580", onPrimary: "#1a1400", primaryHover: "#ffd54a" },
|
|
174
|
+
light: { background: "#f4f4f1", surface: "#ffffff", surface2: "#ecebe6", line: "#e2e1db", line2: "#cfcec7", text: "#0a0b0d", text2: "#4a4f57", text3: "#6f7580", secondary: "#2b7f8b", onPrimary: "#1a1400", primaryHover: "#ffd54a" },
|
|
175
|
+
choices: {
|
|
176
|
+
scheme: "dark",
|
|
177
|
+
background: { style: "glow", intensity: "subtle", grain: false },
|
|
178
|
+
shape: "soft",
|
|
179
|
+
density: "comfortable",
|
|
180
|
+
buttons: "solid",
|
|
181
|
+
surfaces: "outlined",
|
|
182
|
+
navigation: "bar",
|
|
183
|
+
typography: { body: "system", display: "system", weight: "semibold", labels: "upper", labelTone: "primary" },
|
|
184
|
+
icons: { stroke: "regular", frame: "tile", tone: "secondary" },
|
|
185
|
+
mark: "plain",
|
|
186
|
+
signIn: { layout: "card" },
|
|
187
|
+
motion: "subtle",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
porcelain: {
|
|
191
|
+
label: "Porcelain",
|
|
192
|
+
description: "Bright and calm, soft shadows, rounded corners. Follows the device's light or dark setting.",
|
|
193
|
+
primary: "#4f46e5",
|
|
194
|
+
secondary: "#0ea5e9",
|
|
195
|
+
light: { background: "#f5f6f8", surface: "#ffffff", surface2: "#f1f3f6", line: "#e4e7ec", line2: "#d0d5dd", text: "#101828", text2: "#475467", text3: "#667085" },
|
|
196
|
+
dark: { background: "#0c0e12", surface: "#15181e", surface2: "#1c2028", line: "#252a33", line2: "#323844", text: "#f2f4f7", text2: "#aab2bf", text3: "#7d8696" },
|
|
197
|
+
choices: {
|
|
198
|
+
scheme: "auto",
|
|
199
|
+
background: { style: "gradient", intensity: "subtle", grain: false },
|
|
200
|
+
shape: "rounded",
|
|
201
|
+
density: "comfortable",
|
|
202
|
+
buttons: "solid",
|
|
203
|
+
surfaces: "raised",
|
|
204
|
+
navigation: "bar",
|
|
205
|
+
typography: { body: "system", display: "system", weight: "bold", labels: "upper", labelTone: "neutral" },
|
|
206
|
+
icons: { stroke: "regular", frame: "tile", tone: "primary" },
|
|
207
|
+
mark: "tile",
|
|
208
|
+
signIn: { layout: "centered" },
|
|
209
|
+
motion: "full",
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
midnight: {
|
|
213
|
+
label: "Midnight",
|
|
214
|
+
description: "Deep navy, electric blue, frosted glass over a soft colour mesh.",
|
|
215
|
+
primary: "#5b8cff",
|
|
216
|
+
secondary: "#22d3ee",
|
|
217
|
+
dark: { background: "#060a14", surface: "#0d1424", surface2: "#131c31", line: "#1c2742", line2: "#283656", text: "#eaf0ff", text2: "#9fb0d0", text3: "#6b7ca0" },
|
|
218
|
+
light: { background: "#eef2fb", surface: "#ffffff", surface2: "#e8edf8", line: "#d8e0f0", line2: "#c3cee5", text: "#0a1228", text2: "#44506b", text3: "#66728d", primary: "#3563e9", secondary: "#0891b2" },
|
|
219
|
+
choices: {
|
|
220
|
+
scheme: "dark",
|
|
221
|
+
background: { style: "mesh", intensity: "medium", grain: false },
|
|
222
|
+
shape: "rounded",
|
|
223
|
+
density: "comfortable",
|
|
224
|
+
buttons: "gradient",
|
|
225
|
+
surfaces: "glass",
|
|
226
|
+
navigation: "floating",
|
|
227
|
+
typography: { body: "geometric", display: "geometric", weight: "bold", labels: "upper", labelTone: "secondary" },
|
|
228
|
+
icons: { stroke: "light", frame: "circle", tone: "secondary" },
|
|
229
|
+
mark: "circle",
|
|
230
|
+
signIn: { layout: "split" },
|
|
231
|
+
motion: "full",
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
aurora: {
|
|
235
|
+
label: "Aurora",
|
|
236
|
+
description: "Violet and pink light on a night sky, pill shapes, glowing buttons.",
|
|
237
|
+
primary: "#a78bfa",
|
|
238
|
+
secondary: "#f472b6",
|
|
239
|
+
dark: { background: "#0a0614", surface: "#130d22", surface2: "#1b1430", line: "#2a2040", line2: "#3b2e57", text: "#f5f0ff", text2: "#b8acd6", text3: "#8576a8" },
|
|
240
|
+
light: { background: "#f7f3ff", surface: "#ffffff", surface2: "#f1ebfd", line: "#e6dcfa", line2: "#d3c4f2", text: "#1c1030", text2: "#574a73", text3: "#7a6d96", primary: "#7c3aed", secondary: "#db2777" },
|
|
241
|
+
choices: {
|
|
242
|
+
scheme: "dark",
|
|
243
|
+
background: { style: "mesh", intensity: "bold", grain: true },
|
|
244
|
+
shape: "pill",
|
|
245
|
+
density: "comfortable",
|
|
246
|
+
buttons: "glow",
|
|
247
|
+
surfaces: "glass",
|
|
248
|
+
navigation: "floating",
|
|
249
|
+
typography: { body: "rounded", display: "rounded", weight: "heavy", labels: "normal", labelTone: "primary" },
|
|
250
|
+
icons: { stroke: "regular", frame: "circle", tone: "primary" },
|
|
251
|
+
mark: "circle",
|
|
252
|
+
signIn: { layout: "card" },
|
|
253
|
+
motion: "full",
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
sandstone: {
|
|
257
|
+
label: "Sandstone",
|
|
258
|
+
description: "Warm paper, terracotta and olive, editorial serif headings, square corners.",
|
|
259
|
+
primary: "#b4532a",
|
|
260
|
+
secondary: "#5f7a3a",
|
|
261
|
+
light: { background: "#f4efe6", surface: "#fffcf6", surface2: "#efe8db", line: "#e3d9c8", line2: "#d1c3ab", text: "#2a2118", text2: "#5e5143", text3: "#85776a" },
|
|
262
|
+
dark: { background: "#15110d", surface: "#1e1913", surface2: "#262019", line: "#332b22", line2: "#463b2e", text: "#f3ece1", text2: "#bfb2a0", text3: "#8f8373", primary: "#e07a4a", secondary: "#9bb26b" },
|
|
263
|
+
choices: {
|
|
264
|
+
scheme: "light",
|
|
265
|
+
background: { style: "dots", intensity: "subtle", grain: true },
|
|
266
|
+
shape: "sharp",
|
|
267
|
+
density: "spacious",
|
|
268
|
+
buttons: "solid",
|
|
269
|
+
surfaces: "outlined",
|
|
270
|
+
navigation: "bar",
|
|
271
|
+
typography: { body: "humanist", display: "editorial", weight: "regular", labels: "upper", labelTone: "neutral" },
|
|
272
|
+
icons: { stroke: "light", frame: "plain", tone: "primary" },
|
|
273
|
+
mark: "plain",
|
|
274
|
+
signIn: { layout: "split" },
|
|
275
|
+
motion: "subtle",
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
evergreen: {
|
|
279
|
+
label: "Evergreen",
|
|
280
|
+
description: "Forest green with a touch of brass, quiet and trustworthy.",
|
|
281
|
+
primary: "#1f7a52",
|
|
282
|
+
secondary: "#c8a24a",
|
|
283
|
+
light: { background: "#f2f5f1", surface: "#ffffff", surface2: "#ebf0ea", line: "#dde5db", line2: "#c7d3c4", text: "#10201a", text2: "#44574e", text3: "#687a71", secondary: "#9a7a2c" },
|
|
284
|
+
dark: { background: "#08110d", surface: "#0f1a15", surface2: "#15231c", line: "#1f3029", line2: "#2b4136", text: "#e9f3ee", text2: "#a3b8ad", text3: "#6f8579", primary: "#3fae7c" },
|
|
285
|
+
choices: {
|
|
286
|
+
scheme: "auto",
|
|
287
|
+
background: { style: "glow", intensity: "medium", grain: false },
|
|
288
|
+
shape: "soft",
|
|
289
|
+
density: "comfortable",
|
|
290
|
+
buttons: "solid",
|
|
291
|
+
surfaces: "raised",
|
|
292
|
+
navigation: "bar",
|
|
293
|
+
typography: { body: "humanist", display: "serif", weight: "semibold", labels: "normal", labelTone: "primary" },
|
|
294
|
+
icons: { stroke: "regular", frame: "tile", tone: "primary" },
|
|
295
|
+
mark: "tile",
|
|
296
|
+
signIn: { layout: "card" },
|
|
297
|
+
motion: "subtle",
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
graphite: {
|
|
301
|
+
label: "Graphite",
|
|
302
|
+
description: "Black and white, hairlines and a fine grid. Nothing but the content.",
|
|
303
|
+
primary: "#0a0a0a",
|
|
304
|
+
secondary: "#52525b",
|
|
305
|
+
light: { background: "#fafafa", surface: "#ffffff", surface2: "#f4f4f5", line: "#e7e7e9", line2: "#d4d4d8", text: "#0a0a0a", text2: "#52525b", text3: "#71717a" },
|
|
306
|
+
dark: { background: "#09090b", surface: "#111113", surface2: "#18181b", line: "#232326", line2: "#323236", text: "#fafafa", text2: "#a1a1aa", text3: "#71717a", primary: "#fafafa", secondary: "#a1a1aa" },
|
|
307
|
+
choices: {
|
|
308
|
+
scheme: "auto",
|
|
309
|
+
background: { style: "grid", intensity: "subtle", grain: false },
|
|
310
|
+
shape: "sharp",
|
|
311
|
+
density: "compact",
|
|
312
|
+
buttons: "solid",
|
|
313
|
+
surfaces: "outlined",
|
|
314
|
+
navigation: "bar",
|
|
315
|
+
typography: { body: "grotesque", display: "grotesque", weight: "semibold", labels: "upper", labelTone: "neutral" },
|
|
316
|
+
icons: { stroke: "light", frame: "outline", tone: "neutral" },
|
|
317
|
+
mark: "tile",
|
|
318
|
+
signIn: { layout: "centered" },
|
|
319
|
+
motion: "subtle",
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
ember: {
|
|
323
|
+
label: "Ember",
|
|
324
|
+
description: "Glowing orange and amber on charcoal, bold and energetic.",
|
|
325
|
+
primary: "#ff7a1a",
|
|
326
|
+
secondary: "#ffc247",
|
|
327
|
+
dark: { background: "#0f0a08", surface: "#1a120e", surface2: "#221813", line: "#33241c", line2: "#4a3529", text: "#fff3ea", text2: "#c9ab98", text3: "#93786a" },
|
|
328
|
+
light: { background: "#fff7f0", surface: "#ffffff", surface2: "#fdeee2", line: "#f6dfcd", line2: "#e9c5a9", text: "#2a140a", text2: "#6b4a39", text3: "#8f6e5d", primary: "#e8600a", secondary: "#c98a00" },
|
|
329
|
+
choices: {
|
|
330
|
+
scheme: "dark",
|
|
331
|
+
background: { style: "gradient", intensity: "bold", grain: true },
|
|
332
|
+
shape: "rounded",
|
|
333
|
+
density: "comfortable",
|
|
334
|
+
buttons: "gradient",
|
|
335
|
+
surfaces: "raised",
|
|
336
|
+
navigation: "floating",
|
|
337
|
+
typography: { body: "system", display: "industrial", weight: "bold", labels: "upper", labelTone: "primary" },
|
|
338
|
+
icons: { stroke: "bold", frame: "tile", tone: "primary" },
|
|
339
|
+
mark: "tile",
|
|
340
|
+
signIn: { layout: "split" },
|
|
341
|
+
motion: "full",
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** The preset for a Tenant that names no theme: the platform's look, or — for a Tenant that only ever set `primary_color` — Porcelain in that colour, close to the portal's look before themes. */
|
|
347
|
+
export function defaultPresetFor(branding: Record<string, unknown> | null | undefined): ThemePreset {
|
|
348
|
+
return parseHex(branding?.primary_color) ? "porcelain" : "asturiu";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ------------------------------------------------------------------ colour
|
|
352
|
+
|
|
353
|
+
type Rgb = [number, number, number];
|
|
354
|
+
|
|
355
|
+
function parseHex(c: unknown): Rgb | null {
|
|
356
|
+
if (typeof c !== "string") return null;
|
|
357
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
|
|
358
|
+
if (!m) return null;
|
|
359
|
+
const hex = m[1].length === 3 ? m[1].split("").map((x) => x + x).join("") : m[1];
|
|
360
|
+
return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)) as Rgb;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A colour as the theme stores it: `#rrggbb`, lower case; null when it is not one. */
|
|
364
|
+
export function themeColor(c: unknown): string | null {
|
|
365
|
+
const rgb = parseHex(c);
|
|
366
|
+
return rgb ? toHex(rgb) : null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const toHex = (rgb: number[]) => `#${rgb.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("")}`;
|
|
370
|
+
const mix = (a: Rgb, b: Rgb, t: number): Rgb => a.map((v, i) => v + (b[i] - v) * t) as Rgb;
|
|
371
|
+
const WHITE: Rgb = [255, 255, 255];
|
|
372
|
+
const BLACK: Rgb = [0, 0, 0];
|
|
373
|
+
|
|
374
|
+
function luminance([r, g, b]: Rgb): number {
|
|
375
|
+
const lin = (v: number) => {
|
|
376
|
+
const s = v / 255;
|
|
377
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
378
|
+
};
|
|
379
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** WCAG contrast ratio of two colours. */
|
|
383
|
+
export function contrastRatio(a: string, b: string): number {
|
|
384
|
+
const x = parseHex(a);
|
|
385
|
+
const y = parseHex(b);
|
|
386
|
+
if (!x || !y) return 1;
|
|
387
|
+
return ratio(x, y);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function ratio(a: Rgb, b: Rgb): number {
|
|
391
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((p, q) => q - p);
|
|
392
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Moved towards white on a dark ground, towards black on a light one, until it reads at `min` against `ground`. */
|
|
396
|
+
function readable(c: Rgb, ground: Rgb, min: number): Rgb {
|
|
397
|
+
const toward = luminance(ground) < 0.2 ? WHITE : BLACK;
|
|
398
|
+
let out = c;
|
|
399
|
+
for (let i = 0; i < 24 && ratio(out, ground) < min; i++) out = mix(out, toward, 0.08);
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Text on a fill: a deep shade of the fill itself when that reads, else white or black, whichever reads better. */
|
|
404
|
+
function onColor(fill: Rgb): Rgb {
|
|
405
|
+
const deep = mix(fill, BLACK, 0.86);
|
|
406
|
+
if (ratio(deep, fill) >= 4.5) return deep;
|
|
407
|
+
return ratio(WHITE, fill) >= ratio(BLACK, fill) ? WHITE : BLACK;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function palette(base: SchemeBase, over: ThemeSchemeColors | undefined, primaryHex: string | null, secondaryHex: string | null, presetPrimary: string, presetSecondary: string, dark: boolean): ThemePalette {
|
|
411
|
+
const bg = parseHex(over?.background) ?? (parseHex(base.background) as Rgb);
|
|
412
|
+
const surface = parseHex(over?.surface) ?? (parseHex(base.surface) as Rgb);
|
|
413
|
+
const custom = !!(over?.background || over?.surface || over?.text);
|
|
414
|
+
let text = parseHex(over?.text) ?? (parseHex(base.text) as Rgb);
|
|
415
|
+
if (ratio(text, bg) < 7) text = luminance(bg) < 0.2 ? [244, 244, 241] : [12, 14, 18];
|
|
416
|
+
const keep = (k: keyof SchemeBase, derived: Rgb) => (custom ? derived : (parseHex(base[k]) as Rgb));
|
|
417
|
+
const ownPrimary = !over?.primary && !primaryHex;
|
|
418
|
+
const primary = parseHex(over?.primary) ?? parseHex(primaryHex) ?? (parseHex(base.primary ?? presetPrimary) as Rgb);
|
|
419
|
+
const secondary = parseHex(over?.secondary) ?? parseHex(secondaryHex) ?? (parseHex(base.secondary ?? presetSecondary) as Rgb);
|
|
420
|
+
return {
|
|
421
|
+
background: toHex(bg),
|
|
422
|
+
surface: toHex(surface),
|
|
423
|
+
surface2: toHex(keep("surface2", mix(surface, text, dark ? 0.05 : 0.035))),
|
|
424
|
+
line: toHex(keep("line", mix(surface, text, dark ? 0.12 : 0.1))),
|
|
425
|
+
line2: toHex(keep("line2", mix(surface, text, dark ? 0.19 : 0.17))),
|
|
426
|
+
text: toHex(text),
|
|
427
|
+
text2: toHex(keep("text2", readable(mix(text, bg, 0.32), bg, 4.5))),
|
|
428
|
+
text3: toHex(keep("text3", readable(mix(text, bg, 0.5), bg, 3))),
|
|
429
|
+
primary: toHex(primary),
|
|
430
|
+
onPrimary: ownPrimary && base.onPrimary ? base.onPrimary : toHex(onColor(primary)),
|
|
431
|
+
primaryText: toHex(readable(primary, bg, 4.5)),
|
|
432
|
+
primarySoft: toHex(mix(primary, surface, dark ? 0.84 : 0.88)),
|
|
433
|
+
primaryHover: ownPrimary && base.primaryHover ? base.primaryHover : toHex(mix(primary, dark ? WHITE : BLACK, dark ? 0.16 : 0.1)),
|
|
434
|
+
secondary: toHex(secondary),
|
|
435
|
+
onSecondary: toHex(onColor(secondary)),
|
|
436
|
+
secondaryText: toHex(readable(secondary, bg, 4.5)),
|
|
437
|
+
secondarySoft: toHex(mix(secondary, surface, dark ? 0.84 : 0.88)),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ------------------------------------------------------------------ reading a spec
|
|
442
|
+
|
|
443
|
+
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
444
|
+
const pick = <T extends string>(list: readonly T[], v: unknown): T | undefined => (typeof v === "string" && (list as readonly string[]).includes(v) ? (v as T) : undefined);
|
|
445
|
+
const text = (v: unknown, max: number): string | undefined => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined);
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* An image a theme may show: an inline base64 image, a path on this origin
|
|
449
|
+
* (how the Customer Portal names an image it serves, ADR-038) or an http(s)
|
|
450
|
+
* URL, and nothing that could leave a CSS `url("…")` — no quotes, brackets,
|
|
451
|
+
* backslashes or white space.
|
|
452
|
+
*/
|
|
453
|
+
export function themeImageUrl(v: unknown): string | null {
|
|
454
|
+
if (typeof v !== "string" || !v || v.length > THEME_LIMITS.imageUrl) return null;
|
|
455
|
+
if (INLINE_IMAGE_PATTERN.test(v)) return v;
|
|
456
|
+
return /^(https?:\/\/|\/(?!\/))[^\s"'()<>\\]*$/.test(v) ? v : null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function schemeColors(v: unknown): ThemeSchemeColors | undefined {
|
|
460
|
+
if (!isObj(v)) return undefined;
|
|
461
|
+
const out: ThemeSchemeColors = {};
|
|
462
|
+
for (const k of ["background", "surface", "text", "primary", "secondary"] as const) {
|
|
463
|
+
const c = themeColor(v[k]);
|
|
464
|
+
if (c) out[k] = c;
|
|
465
|
+
}
|
|
466
|
+
return Object.keys(out).length ? out : undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The theme as far as it can be read: every known key with a valid value, the
|
|
471
|
+
* rest dropped. The Engine refuses a bad theme when it is written (the Tenant
|
|
472
|
+
* schema); this is for reading, so an odd stored value falls back to the
|
|
473
|
+
* preset's instead of breaking a page. Image paths are kept as they are — the
|
|
474
|
+
* Interface API resolves them against the Tenant's site, like `logo_url`.
|
|
475
|
+
*/
|
|
476
|
+
export function sanitizeThemeSpec(raw: unknown): PortalThemeSpec {
|
|
477
|
+
if (!isObj(raw)) return {};
|
|
478
|
+
const out: PortalThemeSpec = {};
|
|
479
|
+
const set = <K extends keyof PortalThemeSpec>(k: K, v: PortalThemeSpec[K] | undefined) => {
|
|
480
|
+
if (v !== undefined && !(isObj(v) && !Object.keys(v).length)) out[k] = v;
|
|
481
|
+
};
|
|
482
|
+
const strip = <T extends Record<string, unknown>>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
|
|
483
|
+
const image = (v: unknown) => (typeof v === "string" && /^\/(?!\/)[^\s"'()<>\\]*$/.test(v) && v.length < 2048 ? v : (themeImageUrl(v) ?? undefined));
|
|
484
|
+
set("preset", pick(THEME_PRESETS, raw.preset));
|
|
485
|
+
set("scheme", pick(THEME_SCHEMES, raw.scheme));
|
|
486
|
+
if (isObj(raw.colors)) set("colors", strip({ primary: themeColor(raw.colors.primary) ?? undefined, secondary: themeColor(raw.colors.secondary) ?? undefined, light: schemeColors(raw.colors.light), dark: schemeColors(raw.colors.dark) }));
|
|
487
|
+
if (isObj(raw.background))
|
|
488
|
+
set("background", strip({ style: pick(THEME_BACKGROUNDS, raw.background.style), intensity: pick(THEME_INTENSITIES, raw.background.intensity), grain: typeof raw.background.grain === "boolean" ? raw.background.grain : undefined, image_url: image(raw.background.image_url) }));
|
|
489
|
+
set("shape", pick(THEME_SHAPES, raw.shape));
|
|
490
|
+
set("density", pick(THEME_DENSITIES, raw.density));
|
|
491
|
+
set("buttons", pick(THEME_BUTTONS, raw.buttons));
|
|
492
|
+
set("surfaces", pick(THEME_SURFACES, raw.surfaces));
|
|
493
|
+
set("navigation", pick(THEME_NAVIGATION, raw.navigation));
|
|
494
|
+
if (isObj(raw.typography))
|
|
495
|
+
set("typography", strip({ body: pick(THEME_FONTS, raw.typography.body), display: pick(THEME_FONTS, raw.typography.display), weight: pick(THEME_WEIGHTS, raw.typography.weight), labels: pick(THEME_LABEL_CASES, raw.typography.labels), label_tone: pick(THEME_TONES, raw.typography.label_tone) }));
|
|
496
|
+
if (isObj(raw.icons)) set("icons", strip({ stroke: pick(THEME_ICON_STROKES, raw.icons.stroke), frame: pick(THEME_ICON_FRAMES, raw.icons.frame), tone: pick(THEME_TONES, raw.icons.tone) }));
|
|
497
|
+
set("mark", pick(THEME_MARK_FRAMES, raw.mark));
|
|
498
|
+
if (isObj(raw.sign_in))
|
|
499
|
+
set("sign_in", strip({ layout: pick(THEME_SIGN_IN_LAYOUTS, raw.sign_in.layout), headline: text(raw.sign_in.headline, THEME_LIMITS.headline), tagline: text(raw.sign_in.tagline, THEME_LIMITS.tagline), image_url: image(raw.sign_in.image_url) }));
|
|
500
|
+
set("motion", pick(THEME_MOTION, raw.motion));
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ------------------------------------------------------------------ resolving
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Every choice made: the preset (named, or the default for this branding),
|
|
508
|
+
* then the Tenant's overrides. `branding` supplies the logo, the dark logo,
|
|
509
|
+
* the favicon and — when the theme names no primary — `primary_color`.
|
|
510
|
+
*/
|
|
511
|
+
export function resolvePortalTheme(input: { branding?: Record<string, unknown> | null; theme?: unknown } | null | undefined): ResolvedPortalTheme {
|
|
512
|
+
const branding = input?.branding ?? {};
|
|
513
|
+
const spec = sanitizeThemeSpec(input?.theme ?? branding.theme);
|
|
514
|
+
const name = spec.preset ?? defaultPresetFor(branding);
|
|
515
|
+
const p = THEME_PRESET_DEFINITIONS[name];
|
|
516
|
+
const c = p.choices;
|
|
517
|
+
// A theme is the whole truth once set; `primary_color` speaks only for a Tenant that has none.
|
|
518
|
+
const legacy = Object.keys(spec).length ? null : themeColor(branding.primary_color);
|
|
519
|
+
const primary = spec.colors?.primary ?? legacy;
|
|
520
|
+
const secondary = spec.colors?.secondary;
|
|
521
|
+
const url = (v: unknown) => themeImageUrl(v);
|
|
522
|
+
return {
|
|
523
|
+
preset: name,
|
|
524
|
+
scheme: spec.scheme ?? c.scheme,
|
|
525
|
+
light: palette(p.light, spec.colors?.light, primary ?? null, secondary ?? null, p.primary, p.secondary, false),
|
|
526
|
+
dark: palette(p.dark, spec.colors?.dark, primary ?? null, secondary ?? null, p.primary, p.secondary, true),
|
|
527
|
+
background: {
|
|
528
|
+
style: spec.background?.style ?? c.background.style,
|
|
529
|
+
intensity: spec.background?.intensity ?? c.background.intensity,
|
|
530
|
+
grain: spec.background?.grain ?? c.background.grain,
|
|
531
|
+
imageUrl: url(spec.background?.image_url),
|
|
532
|
+
},
|
|
533
|
+
shape: spec.shape ?? c.shape,
|
|
534
|
+
density: spec.density ?? c.density,
|
|
535
|
+
buttons: spec.buttons ?? c.buttons,
|
|
536
|
+
surfaces: spec.surfaces ?? c.surfaces,
|
|
537
|
+
navigation: spec.navigation ?? c.navigation,
|
|
538
|
+
typography: {
|
|
539
|
+
body: spec.typography?.body ?? c.typography.body,
|
|
540
|
+
display: spec.typography?.display ?? c.typography.display,
|
|
541
|
+
weight: spec.typography?.weight ?? c.typography.weight,
|
|
542
|
+
labels: spec.typography?.labels ?? c.typography.labels,
|
|
543
|
+
labelTone: spec.typography?.label_tone ?? c.typography.labelTone,
|
|
544
|
+
},
|
|
545
|
+
icons: { stroke: spec.icons?.stroke ?? c.icons.stroke, frame: spec.icons?.frame ?? c.icons.frame, tone: spec.icons?.tone ?? c.icons.tone },
|
|
546
|
+
mark: spec.mark ?? c.mark,
|
|
547
|
+
signIn: { layout: spec.sign_in?.layout ?? c.signIn.layout, headline: spec.sign_in?.headline ?? null, tagline: spec.sign_in?.tagline ?? null, imageUrl: url(spec.sign_in?.image_url) },
|
|
548
|
+
motion: spec.motion ?? c.motion,
|
|
549
|
+
logoUrl: url(branding.logo_url),
|
|
550
|
+
logoDarkUrl: url(branding.logo_dark_url),
|
|
551
|
+
faviconUrl: url(branding.favicon_url),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const PALETTE_VARS: [keyof ThemePalette, string][] = [
|
|
556
|
+
["background", "bg"],
|
|
557
|
+
["surface", "surface"],
|
|
558
|
+
["surface2", "surface-2"],
|
|
559
|
+
["line", "line"],
|
|
560
|
+
["line2", "line-2"],
|
|
561
|
+
["text", "text"],
|
|
562
|
+
["text2", "text-2"],
|
|
563
|
+
["text3", "text-3"],
|
|
564
|
+
["primary", "primary"],
|
|
565
|
+
["onPrimary", "on-primary"],
|
|
566
|
+
["primaryText", "primary-fg"],
|
|
567
|
+
["primarySoft", "primary-soft"],
|
|
568
|
+
["primaryHover", "primary-hover"],
|
|
569
|
+
["secondary", "secondary"],
|
|
570
|
+
["onSecondary", "on-secondary"],
|
|
571
|
+
["secondaryText", "secondary-fg"],
|
|
572
|
+
["secondarySoft", "secondary-soft"],
|
|
573
|
+
];
|
|
574
|
+
|
|
575
|
+
/** The CSS custom property names a palette is written to, per scheme prefix (`--l-*`, `--d-*`). */
|
|
576
|
+
export const THEME_PALETTE_VARS = PALETTE_VARS.map(([, v]) => v);
|
|
577
|
+
|
|
578
|
+
export interface PortalThemeStyle {
|
|
579
|
+
/** CSS custom properties for the root element: both schemes' palettes (`--l-*`, `--d-*`), the fonts, and the images as `url("…")`. */
|
|
580
|
+
vars: Record<string, string>;
|
|
581
|
+
/** `data-*` attributes for the root element, one per choice; the stylesheet draws each. */
|
|
582
|
+
attrs: Record<string, string>;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* A resolved theme as the root element carries it. `scheme` overrides the
|
|
587
|
+
* theme's (a person's own light or dark choice on an `auto` theme).
|
|
588
|
+
*/
|
|
589
|
+
export function portalThemeStyle(t: ResolvedPortalTheme, scheme?: "light" | "dark" | null): PortalThemeStyle {
|
|
590
|
+
const vars: Record<string, string> = {};
|
|
591
|
+
for (const [k, v] of PALETTE_VARS) {
|
|
592
|
+
vars[`--l-${v}`] = t.light[k];
|
|
593
|
+
vars[`--d-${v}`] = t.dark[k];
|
|
594
|
+
}
|
|
595
|
+
vars["--font"] = THEME_FONT_STACKS[t.typography.body].stack;
|
|
596
|
+
vars["--font-display"] = THEME_FONT_STACKS[t.typography.display].stack;
|
|
597
|
+
vars["--display-weight"] = WEIGHT[t.typography.weight];
|
|
598
|
+
if (t.background.imageUrl) vars["--ground-image"] = `url("${t.background.imageUrl}")`;
|
|
599
|
+
if (t.signIn.imageUrl) vars["--signin-image"] = `url("${t.signIn.imageUrl}")`;
|
|
600
|
+
const attrs: Record<string, string> = {
|
|
601
|
+
"data-preset": t.preset,
|
|
602
|
+
"data-scheme": t.scheme === "auto" && scheme ? scheme : t.scheme,
|
|
603
|
+
"data-ground": t.background.style === "image" && !t.background.imageUrl ? "glow" : t.background.style,
|
|
604
|
+
"data-intensity": t.background.intensity,
|
|
605
|
+
"data-grain": String(t.background.grain),
|
|
606
|
+
"data-shape": t.shape,
|
|
607
|
+
"data-density": t.density,
|
|
608
|
+
"data-buttons": t.buttons,
|
|
609
|
+
"data-surfaces": t.surfaces,
|
|
610
|
+
"data-nav": t.navigation,
|
|
611
|
+
"data-font-body": t.typography.body,
|
|
612
|
+
"data-font-display": t.typography.display,
|
|
613
|
+
"data-labels": t.typography.labels,
|
|
614
|
+
"data-label-tone": t.typography.labelTone,
|
|
615
|
+
"data-icon-stroke": t.icons.stroke,
|
|
616
|
+
"data-icon-frame": t.icons.frame,
|
|
617
|
+
"data-icon-tone": t.icons.tone,
|
|
618
|
+
"data-mark": t.mark,
|
|
619
|
+
"data-signin": t.signIn.layout,
|
|
620
|
+
"data-motion": t.motion,
|
|
621
|
+
};
|
|
622
|
+
return { vars, attrs };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The ground colour a browser's own chrome (status bar, overscroll) should take for each scheme. */
|
|
626
|
+
export function portalThemeColors(t: ResolvedPortalTheme): { light: string; dark: string; scheme: ThemeScheme } {
|
|
627
|
+
return { light: t.light.background, dark: t.dark.background, scheme: t.scheme };
|
|
628
|
+
}
|