@calliopelabs/ui 0.6.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -3
- package/dist/appearance/AppearancePicker.js +6 -4
- package/dist/appearance/AppearanceProvider.d.ts +10 -2
- package/dist/appearance/AppearanceProvider.js +6 -2
- package/dist/appearance/AppearanceScript.d.ts +15 -4
- package/dist/appearance/AppearanceScript.js +11 -5
- package/dist/appearance/appearance.js +1 -1
- package/dist/appearance/boot.d.ts +13 -2
- package/dist/appearance/boot.js +35 -5
- package/dist/appearance/product.d.ts +13 -0
- package/dist/appearance/product.js +2 -2
- package/dist/appearance/runtime-brand.d.ts +106 -0
- package/dist/appearance/runtime-brand.js +420 -0
- package/dist/css/appearance.js +147 -0
- package/dist/css/calliope.css +1221 -0
- package/dist/css/components.css +729 -0
- package/dist/css/tokens.css +491 -0
- package/dist/data/DefinitionList.d.ts +16 -0
- package/dist/data/DefinitionList.js +9 -0
- package/dist/data/Stat.d.ts +25 -0
- package/dist/data/Stat.js +19 -0
- package/dist/data/Tag.d.ts +21 -0
- package/dist/data/Tag.js +18 -0
- package/dist/data/types.d.ts +3 -2
- package/dist/data/use-local-table.d.ts +36 -0
- package/dist/data/use-local-table.js +127 -0
- package/dist/index.d.ts +42 -3
- package/dist/index.js +39 -1
- package/dist/nav/Nav.d.ts +2 -1
- package/dist/nav/Nav.js +34 -15
- package/dist/nav/model.d.ts +21 -0
- package/dist/page/PageHeader.d.ts +5 -3
- package/dist/page/PageHeader.js +5 -4
- package/dist/page/Panel.d.ts +19 -0
- package/dist/page/Panel.js +12 -0
- package/dist/primitives/Avatar.d.ts +37 -0
- package/dist/primitives/Avatar.js +43 -0
- package/dist/primitives/Field.d.ts +33 -0
- package/dist/primitives/Field.js +30 -0
- package/dist/primitives/Segmented.d.ts +30 -0
- package/dist/primitives/Segmented.js +20 -0
- package/dist/primitives/Select.d.ts +40 -0
- package/dist/primitives/Select.js +47 -0
- package/dist/primitives/Switch.d.ts +7 -0
- package/dist/primitives/Switch.js +8 -0
- package/dist/primitives/Textarea.d.ts +3 -0
- package/dist/primitives/Textarea.js +6 -0
- package/dist/provider/messages.d.ts +415 -2
- package/dist/provider/messages.js +633 -0
- package/dist/shell/BrandMark.d.ts +25 -3
- package/dist/shell/BrandMark.js +34 -4
- package/dist/shell/RailPanel.js +2 -2
- package/dist/shell/RailTree.js +2 -2
- package/dist/shell/Sidebar.d.ts +9 -2
- package/dist/shell/Sidebar.js +10 -4
- package/dist/shell/UserMenu.d.ts +3 -1
- package/dist/shell/UserMenu.js +3 -11
- package/dist/shell/rail-boot.d.ts +28 -0
- package/dist/shell/rail-boot.js +45 -0
- package/dist/shell/rail-state.d.ts +6 -1
- package/dist/shell/rail-state.js +18 -2
- package/dist/styles/preset.css +4 -0
- package/dist/styles/theme.css +36 -13
- package/dist/theme/css-layer.d.ts +19 -0
- package/dist/theme/css-layer.js +171 -0
- package/dist/theme/css.d.ts +1 -0
- package/dist/theme/css.js +45 -9
- package/dist/workflow/ArtifactCard.d.ts +47 -0
- package/dist/workflow/ArtifactCard.js +45 -0
- package/dist/workflow/DocumentReview.d.ts +74 -0
- package/dist/workflow/DocumentReview.js +54 -0
- package/dist/workflow/FormReview.d.ts +53 -0
- package/dist/workflow/FormReview.js +83 -0
- package/dist/workflow/GateBadge.d.ts +11 -0
- package/dist/workflow/GateBadge.js +14 -0
- package/dist/workflow/MediaReview.d.ts +63 -0
- package/dist/workflow/MediaReview.js +166 -0
- package/dist/workflow/ReviewPanel.d.ts +41 -0
- package/dist/workflow/ReviewPanel.js +59 -0
- package/dist/workflow/RunTimeline.d.ts +25 -0
- package/dist/workflow/RunTimeline.js +56 -0
- package/dist/workflow/WorkflowCard.d.ts +39 -0
- package/dist/workflow/WorkflowCard.js +56 -0
- package/dist/workflow/WorkflowGallery.d.ts +43 -0
- package/dist/workflow/WorkflowGallery.js +69 -0
- package/dist/workflow/canvas/WorkflowCanvas.d.ts +39 -0
- package/dist/workflow/canvas/WorkflowCanvas.js +648 -0
- package/dist/workflow/definition.d.ts +408 -0
- package/dist/workflow/definition.js +411 -0
- package/dist/workflow/editor/WorkflowDiff.d.ts +17 -0
- package/dist/workflow/editor/WorkflowDiff.js +25 -0
- package/dist/workflow/editor/WorkflowImportPreview.d.ts +19 -0
- package/dist/workflow/editor/WorkflowImportPreview.js +50 -0
- package/dist/workflow/editor/WorkflowInspector.d.ts +40 -0
- package/dist/workflow/editor/WorkflowInspector.js +349 -0
- package/dist/workflow/editor/WorkflowJsonView.d.ts +16 -0
- package/dist/workflow/editor/WorkflowJsonView.js +40 -0
- package/dist/workflow/editor/WorkflowPalette.d.ts +16 -0
- package/dist/workflow/editor/WorkflowPalette.js +36 -0
- package/dist/workflow/editor/WorkflowProblems.d.ts +20 -0
- package/dist/workflow/editor/WorkflowProblems.js +51 -0
- package/dist/workflow/editor/WorkflowWorkspace.d.ts +49 -0
- package/dist/workflow/editor/WorkflowWorkspace.js +154 -0
- package/dist/workflow/gallery.d.ts +21 -0
- package/dist/workflow/gallery.js +29 -0
- package/dist/workflow/layout.d.ts +49 -0
- package/dist/workflow/layout.js +162 -0
- package/dist/workflow/model.d.ts +50 -0
- package/dist/workflow/model.js +7 -0
- package/dist/workflow/palette.d.ts +34 -0
- package/dist/workflow/palette.js +70 -0
- package/dist/workflow/portable.d.ts +162 -0
- package/dist/workflow/portable.js +547 -0
- package/dist/workflow/problems.d.ts +38 -0
- package/dist/workflow/problems.js +409 -0
- package/dist/workflow/run.d.ts +94 -0
- package/dist/workflow/run.js +31 -0
- package/dist/workflow/workspace.d.ts +33 -0
- package/dist/workflow/workspace.js +17 -0
- package/package.json +6 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type Ground, type Mode } from "../theme/tokens.js";
|
|
2
|
+
import type { Product } from "./product.js";
|
|
3
|
+
/**
|
|
4
|
+
* A per-deployment brand, resolved on the server: one product (say the
|
|
5
|
+
* studio) shown under a customer's name, logo, accent and sidebar. Plain
|
|
6
|
+
* data, so it crosses the Server Component boundary; the `Product`, with its
|
|
7
|
+
* nav icons, stays a client-module constant, and `withBrand` merges the two.
|
|
8
|
+
*
|
|
9
|
+
* Every colour is lowercase `#rrggbb`. That is the injection guard: these
|
|
10
|
+
* values land in a <style>. Run untrusted input through
|
|
11
|
+
* `resolveRuntimeBrand` first; `brandStyles` also skips anything that is not
|
|
12
|
+
* a colour, so a brand built in code cannot break out either.
|
|
13
|
+
*/
|
|
14
|
+
export interface RuntimeBrand {
|
|
15
|
+
/** Replaces the product's name in the brand mark and titles. */
|
|
16
|
+
name?: string;
|
|
17
|
+
/** For tight spots: a collapsed rail, a phone bar. */
|
|
18
|
+
shortName?: string;
|
|
19
|
+
/**
|
|
20
|
+
* `https://` or `data:image/...` URLs only, for an <img src>; never put them
|
|
21
|
+
* in CSS. `onDark` is drawn on a dark sidebar, `onLight` on a light one.
|
|
22
|
+
*/
|
|
23
|
+
logo?: {
|
|
24
|
+
mark?: string;
|
|
25
|
+
onDark?: string;
|
|
26
|
+
onLight?: string;
|
|
27
|
+
};
|
|
28
|
+
/** Offered under the reserved accent key `brand`. The ink on it is derived when absent or faint. */
|
|
29
|
+
accent?: {
|
|
30
|
+
dark: string;
|
|
31
|
+
light: string;
|
|
32
|
+
onDark?: string;
|
|
33
|
+
onLight?: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
};
|
|
36
|
+
/** True: the brand accent is the only accent and no custom colour is accepted. Default false: offered first, as the default. */
|
|
37
|
+
lockAccent?: boolean;
|
|
38
|
+
/** The brand surface. A string paints the brand sidebar grounds (dark); `light` opts light grounds into a brand sidebar too. */
|
|
39
|
+
sidebar?: string | {
|
|
40
|
+
dark: string;
|
|
41
|
+
light?: string;
|
|
42
|
+
};
|
|
43
|
+
/** The current-item marker on the brand sidebar; the accent when absent. */
|
|
44
|
+
sidebarMarker?: string;
|
|
45
|
+
/** Narrows the product's grounds. */
|
|
46
|
+
grounds?: readonly Ground[];
|
|
47
|
+
/** One ground pins it whatever the operating system says; a pair follows the system like a product default. */
|
|
48
|
+
defaultGround?: Ground | {
|
|
49
|
+
dark: Ground;
|
|
50
|
+
light: Ground;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export interface ResolvedRuntimeBrand {
|
|
54
|
+
/** Validated: unknown or malformed fields dropped, inks filled, failing accents replaced. */
|
|
55
|
+
brand: RuntimeBrand;
|
|
56
|
+
/** One line per thing dropped or replaced, for the server log and the brand admin. */
|
|
57
|
+
warnings: string[];
|
|
58
|
+
}
|
|
59
|
+
/** The accent key a runtime brand's accent is offered under. Products may not use it. */
|
|
60
|
+
export declare const BRAND_ACCENT_KEY = "brand";
|
|
61
|
+
/**
|
|
62
|
+
* Validate a brand from anywhere (a database row, an env var, a form) for one
|
|
63
|
+
* product, and fix what would paint badly. Never throws: a malformed field is
|
|
64
|
+
* dropped and a colour that fails contrast is replaced, each with a warning,
|
|
65
|
+
* so a bad brand degrades to the product rather than taking the page down.
|
|
66
|
+
*
|
|
67
|
+
* - Colours must be lowercase `#rrggbb`.
|
|
68
|
+
* - The accent stands 3:1 off every offered ground of its mode, or that mode
|
|
69
|
+
* falls back to the product's default accent. Its ink reads 4.5:1 on it,
|
|
70
|
+
* or becomes black or white.
|
|
71
|
+
* - `grounds` keeps only the product's grounds; `defaultGround` must be
|
|
72
|
+
* offered, and a pair must name a dark and a light ground.
|
|
73
|
+
* - Logos are `https://` or `data:image/(svg+xml|png|webp|jpeg|x-icon)`, at
|
|
74
|
+
* most 300,000 characters.
|
|
75
|
+
* - The sidebar text is chosen to read 4.5:1; a marker under 3:1 on the
|
|
76
|
+
* sidebar is drawn in the sidebar text colour instead.
|
|
77
|
+
*/
|
|
78
|
+
export declare function resolveRuntimeBrand(input: unknown, product: Product): ResolvedRuntimeBrand;
|
|
79
|
+
/**
|
|
80
|
+
* The product as this deployment shows it: the brand's name, logo, sidebar,
|
|
81
|
+
* grounds and default ground, and its accent under the key `brand`, first
|
|
82
|
+
* (or alone, with `lockAccent`). Pure. A missing or empty brand returns the
|
|
83
|
+
* product itself, so an unbranded app is unchanged. Untrusted input is
|
|
84
|
+
* validated on the way in (`resolveRuntimeBrand`), warnings dropped.
|
|
85
|
+
*/
|
|
86
|
+
export declare function withBrand(product: Product, brand: RuntimeBrand | null | undefined): Product;
|
|
87
|
+
/**
|
|
88
|
+
* The brand's CSS for a <style> in <head>, beside `productStyles`: the
|
|
89
|
+
* `data-accent="brand"` blocks and the `--brand-sidebar-*` variables that
|
|
90
|
+
* theme.css reads. Every value is a validated `#rrggbb` or a fixed string,
|
|
91
|
+
* so the text is safe to inline. Pass a resolved brand: this checks syntax,
|
|
92
|
+
* not the accent's contrast on the product's grounds.
|
|
93
|
+
*/
|
|
94
|
+
export declare function brandStyles(brand: RuntimeBrand): string;
|
|
95
|
+
/**
|
|
96
|
+
* The same tokens for one mode as an inline style map, accent included, for
|
|
97
|
+
* a preview frame that carries its own `data-mode` and `data-ground` (a
|
|
98
|
+
* brand admin showing a draft).
|
|
99
|
+
*/
|
|
100
|
+
export declare function brandStyleVars(brand: RuntimeBrand, mode: Mode): Record<`--${string}`, string>;
|
|
101
|
+
/**
|
|
102
|
+
* Attributes for the server-rendered <html>: what the page paints for someone
|
|
103
|
+
* who has not chosen, before (or without) the boot script. Pair with
|
|
104
|
+
* `suppressHydrationWarning`, since the script may change them.
|
|
105
|
+
*/
|
|
106
|
+
export declare function htmlAttributes(product: Product, brand?: RuntimeBrand | null): Record<`data-${string}`, string>;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { MIN_NON_TEXT_CONTRAST, MIN_TEXT_CONTRAST, contrastRatio } from "../theme/contrast.js";
|
|
2
|
+
import { GROUNDS, GROUND_KEYS, MODE_TOKENS, SURFACES, } from "../theme/tokens.js";
|
|
3
|
+
import { inkFor, resolveAppearance } from "./appearance.js";
|
|
4
|
+
/** The accent key a runtime brand's accent is offered under. Products may not use it. */
|
|
5
|
+
export const BRAND_ACCENT_KEY = "brand";
|
|
6
|
+
const HEX = /^#[0-9a-f]{6}$/;
|
|
7
|
+
const MODES = ["dark", "light"];
|
|
8
|
+
const FIELDS = new Set([
|
|
9
|
+
"name",
|
|
10
|
+
"shortName",
|
|
11
|
+
"logo",
|
|
12
|
+
"accent",
|
|
13
|
+
"lockAccent",
|
|
14
|
+
"sidebar",
|
|
15
|
+
"sidebarMarker",
|
|
16
|
+
"grounds",
|
|
17
|
+
"defaultGround",
|
|
18
|
+
]);
|
|
19
|
+
const LOGO_MAX = 300_000;
|
|
20
|
+
const HTTPS_URL = /^https:\/\/[^\s"'<>\\()]+$/;
|
|
21
|
+
const DATA_IMAGE = /^data:image\/(svg\+xml|png|webp|jpeg|x-icon)[;,]/;
|
|
22
|
+
// eslint-disable-next-line no-control-regex
|
|
23
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
24
|
+
function isHex(value) {
|
|
25
|
+
return typeof value === "string" && HEX.test(value);
|
|
26
|
+
}
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
/** A short, safe rendering of a rejected value for a warning. */
|
|
31
|
+
function shown(value) {
|
|
32
|
+
const text = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
|
|
33
|
+
return JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}...` : text)
|
|
34
|
+
.replace(/</g, "\\u003c")
|
|
35
|
+
.replace(/>/g, "\\u003e");
|
|
36
|
+
}
|
|
37
|
+
/** The sidebar text colour: the mode's own when it reads 4.5:1, else black or white. */
|
|
38
|
+
function sidebarInk(sidebar, mode) {
|
|
39
|
+
const own = MODE_TOKENS[mode].sidebarForeground;
|
|
40
|
+
return contrastRatio(own, sidebar) >= MIN_TEXT_CONTRAST ? own : inkFor(sidebar);
|
|
41
|
+
}
|
|
42
|
+
/** `a` moved `t` of the way to `b`, both `#rrggbb`. */
|
|
43
|
+
function mix(a, b, t) {
|
|
44
|
+
const [x, y] = [parseInt(a.slice(1), 16), parseInt(b.slice(1), 16)];
|
|
45
|
+
const channel = (shift) => {
|
|
46
|
+
const from = (x >> shift) & 255;
|
|
47
|
+
const to = (y >> shift) & 255;
|
|
48
|
+
return Math.round(from + (to - from) * t)
|
|
49
|
+
.toString(16)
|
|
50
|
+
.padStart(2, "0");
|
|
51
|
+
};
|
|
52
|
+
return `#${channel(16)}${channel(8)}${channel(0)}`;
|
|
53
|
+
}
|
|
54
|
+
/** Secondary sidebar text: the sidebar ink faded toward the sidebar as far as 4.5:1 allows. */
|
|
55
|
+
function sidebarMutedInk(sidebar, ink) {
|
|
56
|
+
for (let t = 0.5; t > 0; t -= 0.05) {
|
|
57
|
+
const muted = mix(ink, sidebar, t);
|
|
58
|
+
if (contrastRatio(muted, sidebar) >= MIN_TEXT_CONTRAST)
|
|
59
|
+
return muted;
|
|
60
|
+
}
|
|
61
|
+
return ink;
|
|
62
|
+
}
|
|
63
|
+
/** Grounds of `mode` whose own secondary text reads under 4.5:1 on the brand sidebar. */
|
|
64
|
+
function faintMutedGrounds(grounds, mode, sidebar) {
|
|
65
|
+
return brandSidebarGrounds(grounds, mode).filter((g) => contrastRatio(SURFACES[g].mutedForeground, sidebar) < MIN_TEXT_CONTRAST);
|
|
66
|
+
}
|
|
67
|
+
function sidebarsOf(brand) {
|
|
68
|
+
const { sidebar } = brand;
|
|
69
|
+
if (sidebar === undefined)
|
|
70
|
+
return {};
|
|
71
|
+
if (typeof sidebar === "string")
|
|
72
|
+
return isHex(sidebar) ? { dark: sidebar } : {};
|
|
73
|
+
const out = {};
|
|
74
|
+
if (isHex(sidebar.dark))
|
|
75
|
+
out.dark = sidebar.dark;
|
|
76
|
+
if (isHex(sidebar.light))
|
|
77
|
+
out.light = sidebar.light;
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** Grounds of `mode` that paint the brand sidebar of that mode. */
|
|
81
|
+
function brandSidebarGrounds(grounds, mode) {
|
|
82
|
+
return grounds.filter((g) => GROUNDS[g].mode === mode && (mode === "light" || SURFACES[g].sidebar === "brand"));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Validate a brand from anywhere (a database row, an env var, a form) for one
|
|
86
|
+
* product, and fix what would paint badly. Never throws: a malformed field is
|
|
87
|
+
* dropped and a colour that fails contrast is replaced, each with a warning,
|
|
88
|
+
* so a bad brand degrades to the product rather than taking the page down.
|
|
89
|
+
*
|
|
90
|
+
* - Colours must be lowercase `#rrggbb`.
|
|
91
|
+
* - The accent stands 3:1 off every offered ground of its mode, or that mode
|
|
92
|
+
* falls back to the product's default accent. Its ink reads 4.5:1 on it,
|
|
93
|
+
* or becomes black or white.
|
|
94
|
+
* - `grounds` keeps only the product's grounds; `defaultGround` must be
|
|
95
|
+
* offered, and a pair must name a dark and a light ground.
|
|
96
|
+
* - Logos are `https://` or `data:image/(svg+xml|png|webp|jpeg|x-icon)`, at
|
|
97
|
+
* most 300,000 characters.
|
|
98
|
+
* - The sidebar text is chosen to read 4.5:1; a marker under 3:1 on the
|
|
99
|
+
* sidebar is drawn in the sidebar text colour instead.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveRuntimeBrand(input, product) {
|
|
102
|
+
const warnings = [];
|
|
103
|
+
const warn = (message) => warnings.push(message);
|
|
104
|
+
const brand = {};
|
|
105
|
+
if (input === null || input === undefined)
|
|
106
|
+
return { brand, warnings };
|
|
107
|
+
if (!isRecord(input)) {
|
|
108
|
+
warn("brand is not an object; ignored");
|
|
109
|
+
return { brand, warnings };
|
|
110
|
+
}
|
|
111
|
+
for (const key of Object.keys(input)) {
|
|
112
|
+
if (!FIELDS.has(key))
|
|
113
|
+
warn(`unknown field ${shown(key)} ignored`);
|
|
114
|
+
}
|
|
115
|
+
const text = (field, value, max) => {
|
|
116
|
+
if (value === undefined)
|
|
117
|
+
return undefined;
|
|
118
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
119
|
+
if (trimmed && trimmed.length <= max && !CONTROL.test(trimmed))
|
|
120
|
+
return trimmed;
|
|
121
|
+
warn(`${field} must be text of 1 to ${max} characters; ignored`);
|
|
122
|
+
return undefined;
|
|
123
|
+
};
|
|
124
|
+
const colour = (field, value) => {
|
|
125
|
+
if (value === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
if (isHex(value))
|
|
128
|
+
return value;
|
|
129
|
+
warn(`${field} must be lowercase #rrggbb, got ${shown(value)}; ignored`);
|
|
130
|
+
return undefined;
|
|
131
|
+
};
|
|
132
|
+
const name = text("name", input.name, 120);
|
|
133
|
+
if (name)
|
|
134
|
+
brand.name = name;
|
|
135
|
+
const shortName = text("shortName", input.shortName, 40);
|
|
136
|
+
if (shortName)
|
|
137
|
+
brand.shortName = shortName;
|
|
138
|
+
if (input.logo !== undefined) {
|
|
139
|
+
if (isRecord(input.logo)) {
|
|
140
|
+
const logo = {};
|
|
141
|
+
for (const key of ["mark", "onDark", "onLight"]) {
|
|
142
|
+
const url = input.logo[key];
|
|
143
|
+
if (url === undefined)
|
|
144
|
+
continue;
|
|
145
|
+
if (typeof url === "string" &&
|
|
146
|
+
url.length <= LOGO_MAX &&
|
|
147
|
+
(HTTPS_URL.test(url) || DATA_IMAGE.test(url))) {
|
|
148
|
+
logo[key] = url;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
warn(`logo.${key} must be an https:// or data:image URL; ignored`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (Object.keys(logo).length > 0)
|
|
155
|
+
brand.logo = logo;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
warn("logo must be an object; ignored");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Grounds first: the accent is checked against the grounds actually offered.
|
|
162
|
+
if (input.grounds !== undefined) {
|
|
163
|
+
if (Array.isArray(input.grounds)) {
|
|
164
|
+
for (const g of input.grounds) {
|
|
165
|
+
if (!product.grounds.includes(g))
|
|
166
|
+
warn(`ground ${shown(g)} is not one of ${product.name}'s grounds; ignored`);
|
|
167
|
+
}
|
|
168
|
+
const grounds = product.grounds.filter((g) => input.grounds.includes(g));
|
|
169
|
+
if (grounds.length > 0)
|
|
170
|
+
brand.grounds = grounds;
|
|
171
|
+
else
|
|
172
|
+
warn("grounds offers none of the product's grounds; ignored");
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
warn("grounds must be a list; ignored");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const offered = brand.grounds ?? product.grounds;
|
|
179
|
+
const isOffered = (g) => offered.includes(g);
|
|
180
|
+
const pinned = input.defaultGround;
|
|
181
|
+
if (pinned !== undefined) {
|
|
182
|
+
if (isOffered(pinned)) {
|
|
183
|
+
brand.defaultGround = pinned;
|
|
184
|
+
}
|
|
185
|
+
else if (isRecord(pinned) &&
|
|
186
|
+
isOffered(pinned.dark) &&
|
|
187
|
+
isOffered(pinned.light) &&
|
|
188
|
+
GROUNDS[pinned.dark].mode === "dark" &&
|
|
189
|
+
GROUNDS[pinned.light].mode === "light") {
|
|
190
|
+
brand.defaultGround = { dark: pinned.dark, light: pinned.light };
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
warn(`defaultGround ${shown(pinned)} must be an offered ground, or an offered {dark, light} pair; ignored`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (input.accent !== undefined) {
|
|
197
|
+
const raw = isRecord(input.accent) ? input.accent : {};
|
|
198
|
+
const dark = colour("accent.dark", raw.dark);
|
|
199
|
+
const light = colour("accent.light", raw.light);
|
|
200
|
+
if (dark && light) {
|
|
201
|
+
const fallback = product.accents.find((a) => a.key === product.defaults.accent) ?? product.accents[0];
|
|
202
|
+
const accent = { dark, light };
|
|
203
|
+
for (const mode of MODES) {
|
|
204
|
+
const ink = mode === "dark" ? "onDark" : "onLight";
|
|
205
|
+
const faint = offered.filter((g) => GROUNDS[g].mode === mode &&
|
|
206
|
+
contrastRatio(accent[mode], SURFACES[g].background) < MIN_NON_TEXT_CONTRAST);
|
|
207
|
+
let given = colour(`accent.${ink}`, raw[ink]);
|
|
208
|
+
if (faint.length > 0) {
|
|
209
|
+
warn(`accent.${mode} ${accent[mode]} is under ${MIN_NON_TEXT_CONTRAST}:1 on ${faint.join(", ")}; using ${fallback.label} on ${mode} grounds`);
|
|
210
|
+
accent[mode] = fallback[mode];
|
|
211
|
+
given = fallback[ink];
|
|
212
|
+
}
|
|
213
|
+
if (given && contrastRatio(given, accent[mode]) < MIN_TEXT_CONTRAST) {
|
|
214
|
+
warn(`accent.${ink} ${given} is under ${MIN_TEXT_CONTRAST}:1 on its accent; derived`);
|
|
215
|
+
given = undefined;
|
|
216
|
+
}
|
|
217
|
+
accent[ink] = given ?? inkFor(accent[mode]);
|
|
218
|
+
}
|
|
219
|
+
const label = text("accent.label", raw.label, 40);
|
|
220
|
+
if (label)
|
|
221
|
+
accent.label = label;
|
|
222
|
+
brand.accent = accent;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
warn("accent needs dark and light colours; ignored");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (input.lockAccent !== undefined) {
|
|
229
|
+
if (typeof input.lockAccent !== "boolean")
|
|
230
|
+
warn("lockAccent must be true or false; ignored");
|
|
231
|
+
else if (input.lockAccent && !brand.accent)
|
|
232
|
+
warn("lockAccent needs an accent; ignored");
|
|
233
|
+
else
|
|
234
|
+
brand.lockAccent = input.lockAccent;
|
|
235
|
+
}
|
|
236
|
+
const sidebar = input.sidebar;
|
|
237
|
+
if (sidebar !== undefined) {
|
|
238
|
+
if (typeof sidebar === "string") {
|
|
239
|
+
const dark = colour("sidebar", sidebar);
|
|
240
|
+
if (dark)
|
|
241
|
+
brand.sidebar = dark;
|
|
242
|
+
}
|
|
243
|
+
else if (isRecord(sidebar)) {
|
|
244
|
+
const dark = colour("sidebar.dark", sidebar.dark);
|
|
245
|
+
const light = colour("sidebar.light", sidebar.light);
|
|
246
|
+
if (dark)
|
|
247
|
+
brand.sidebar = light ? { dark, light } : { dark };
|
|
248
|
+
else if (sidebar.dark === undefined)
|
|
249
|
+
warn("sidebar needs a dark colour; ignored");
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
warn("sidebar must be a colour or {dark, light}; ignored");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const marker = colour("sidebarMarker", input.sidebarMarker);
|
|
256
|
+
if (marker)
|
|
257
|
+
brand.sidebarMarker = marker;
|
|
258
|
+
// Warnings only: brandStyles picks the sidebar text and marker per mode.
|
|
259
|
+
const sidebars = sidebarsOf(brand);
|
|
260
|
+
for (const mode of MODES) {
|
|
261
|
+
const surface = sidebars[mode];
|
|
262
|
+
const grounds = brandSidebarGrounds(offered, mode);
|
|
263
|
+
if (!surface || grounds.length === 0)
|
|
264
|
+
continue;
|
|
265
|
+
const ink = sidebarInk(surface, mode);
|
|
266
|
+
if (ink !== MODE_TOKENS[mode].sidebarForeground)
|
|
267
|
+
warn(`the ${mode}-ground sidebar ${surface} does not suit ${mode} text; sidebar text is ${ink}`);
|
|
268
|
+
const muted = faintMutedGrounds(offered, mode, surface);
|
|
269
|
+
if (muted.length > 0)
|
|
270
|
+
warn(`secondary text reads under ${MIN_TEXT_CONTRAST}:1 on the ${mode}-ground sidebar ${surface} (${muted.join(", ")}); drawn in ${sidebarMutedInk(surface, ink)}`);
|
|
271
|
+
if (marker && contrastRatio(marker, surface) < MIN_NON_TEXT_CONTRAST)
|
|
272
|
+
warn(`sidebarMarker ${marker} is under ${MIN_NON_TEXT_CONTRAST}:1 on the ${mode}-ground sidebar; drawn in ${ink}`);
|
|
273
|
+
}
|
|
274
|
+
return { brand, warnings };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The product as this deployment shows it: the brand's name, logo, sidebar,
|
|
278
|
+
* grounds and default ground, and its accent under the key `brand`, first
|
|
279
|
+
* (or alone, with `lockAccent`). Pure. A missing or empty brand returns the
|
|
280
|
+
* product itself, so an unbranded app is unchanged. Untrusted input is
|
|
281
|
+
* validated on the way in (`resolveRuntimeBrand`), warnings dropped.
|
|
282
|
+
*/
|
|
283
|
+
export function withBrand(product, brand) {
|
|
284
|
+
if (!brand)
|
|
285
|
+
return product;
|
|
286
|
+
const b = resolveRuntimeBrand(brand, product).brand;
|
|
287
|
+
if (Object.keys(b).length === 0)
|
|
288
|
+
return product;
|
|
289
|
+
const grounds = b.grounds ?? product.grounds;
|
|
290
|
+
const offered = (g) => grounds.includes(g);
|
|
291
|
+
const brandAccent = b.accent
|
|
292
|
+
? {
|
|
293
|
+
key: BRAND_ACCENT_KEY,
|
|
294
|
+
label: b.accent.label ?? b.name ?? "Brand",
|
|
295
|
+
dark: b.accent.dark,
|
|
296
|
+
onDark: b.accent.onDark ?? inkFor(b.accent.dark),
|
|
297
|
+
light: b.accent.light,
|
|
298
|
+
onLight: b.accent.onLight ?? inkFor(b.accent.light),
|
|
299
|
+
brand: true,
|
|
300
|
+
}
|
|
301
|
+
: null;
|
|
302
|
+
const accentLocked = Boolean(brandAccent && b.lockAccent);
|
|
303
|
+
const accents = brandAccent
|
|
304
|
+
? accentLocked
|
|
305
|
+
? [brandAccent]
|
|
306
|
+
: [brandAccent, ...product.accents]
|
|
307
|
+
: product.accents;
|
|
308
|
+
const groundFor = (mode) => {
|
|
309
|
+
const own = product.defaults.ground[mode];
|
|
310
|
+
if (offered(own))
|
|
311
|
+
return own;
|
|
312
|
+
return grounds.find((g) => GROUNDS[g].mode === mode) ?? grounds[0];
|
|
313
|
+
};
|
|
314
|
+
const ground = typeof b.defaultGround === "string"
|
|
315
|
+
? { dark: b.defaultGround, light: b.defaultGround }
|
|
316
|
+
: (b.defaultGround ?? { dark: groundFor("dark"), light: groundFor("light") });
|
|
317
|
+
const sidebars = sidebarsOf(b);
|
|
318
|
+
const result = {
|
|
319
|
+
...product,
|
|
320
|
+
name: b.name ?? product.name,
|
|
321
|
+
sidebar: sidebars.dark ?? product.sidebar,
|
|
322
|
+
grounds,
|
|
323
|
+
accents,
|
|
324
|
+
defaults: {
|
|
325
|
+
...product.defaults,
|
|
326
|
+
ground,
|
|
327
|
+
accent: brandAccent ? BRAND_ACCENT_KEY : product.defaults.accent,
|
|
328
|
+
},
|
|
329
|
+
themes: product.themes.filter((t) => offered(t.ground) && accents.some((a) => a.key === t.accent)),
|
|
330
|
+
};
|
|
331
|
+
if (b.shortName)
|
|
332
|
+
result.shortName = b.shortName;
|
|
333
|
+
if (b.logo)
|
|
334
|
+
result.logo = b.logo;
|
|
335
|
+
if (sidebars.light)
|
|
336
|
+
result.sidebarLight = sidebars.light;
|
|
337
|
+
if (accentLocked)
|
|
338
|
+
result.accentLocked = true;
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
/** The brand's tokens for one mode: the accent's (under `data-accent="brand"`) and the sidebar's. */
|
|
342
|
+
function brandDeclarations(brand, mode) {
|
|
343
|
+
const accent = [];
|
|
344
|
+
const raw = brand.accent;
|
|
345
|
+
if (raw && isHex(raw.dark) && isHex(raw.light)) {
|
|
346
|
+
const colour = raw[mode];
|
|
347
|
+
const given = mode === "dark" ? raw.onDark : raw.onLight;
|
|
348
|
+
const ink = isHex(given) && contrastRatio(given, colour) >= MIN_TEXT_CONTRAST
|
|
349
|
+
? given
|
|
350
|
+
: inkFor(colour);
|
|
351
|
+
accent.push(["--brand-primary", colour], ["--brand-on-primary", ink]);
|
|
352
|
+
}
|
|
353
|
+
const sidebar = [];
|
|
354
|
+
const surface = sidebarsOf(brand)[mode];
|
|
355
|
+
if (surface) {
|
|
356
|
+
const ink = sidebarInk(surface, mode);
|
|
357
|
+
if (mode === "dark")
|
|
358
|
+
sidebar.push(["--brand-sidebar", surface]);
|
|
359
|
+
else
|
|
360
|
+
sidebar.push(["--brand-sidebar-light", surface]);
|
|
361
|
+
sidebar.push(["--brand-sidebar-foreground", ink]);
|
|
362
|
+
if (faintMutedGrounds(brand.grounds ?? GROUND_KEYS, mode, surface).length > 0)
|
|
363
|
+
sidebar.push(["--brand-sidebar-muted-foreground", sidebarMutedInk(surface, ink)]);
|
|
364
|
+
const marker = brand.sidebarMarker;
|
|
365
|
+
if (isHex(marker))
|
|
366
|
+
sidebar.push([
|
|
367
|
+
"--brand-sidebar-primary",
|
|
368
|
+
contrastRatio(marker, surface) >= MIN_NON_TEXT_CONTRAST ? marker : ink,
|
|
369
|
+
]);
|
|
370
|
+
if (mode === "light")
|
|
371
|
+
sidebar.push(["--brand-sidebar-accent", "color-mix(in oklab, var(--primary) 16%, transparent)"], ["--brand-sidebar-border", `color-mix(in oklab, ${ink} 12%, transparent)`]);
|
|
372
|
+
}
|
|
373
|
+
return { accent, sidebar };
|
|
374
|
+
}
|
|
375
|
+
function rule(selector, declarations) {
|
|
376
|
+
return `${selector}{${declarations.map(([p, v]) => `${p}:${v}`).join(";")}}`;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The brand's CSS for a <style> in <head>, beside `productStyles`: the
|
|
380
|
+
* `data-accent="brand"` blocks and the `--brand-sidebar-*` variables that
|
|
381
|
+
* theme.css reads. Every value is a validated `#rrggbb` or a fixed string,
|
|
382
|
+
* so the text is safe to inline. Pass a resolved brand: this checks syntax,
|
|
383
|
+
* not the accent's contrast on the product's grounds.
|
|
384
|
+
*/
|
|
385
|
+
export function brandStyles(brand) {
|
|
386
|
+
return MODES.flatMap((mode) => {
|
|
387
|
+
const { accent, sidebar } = brandDeclarations(brand, mode);
|
|
388
|
+
const rules = [];
|
|
389
|
+
if (accent.length > 0)
|
|
390
|
+
rules.push(rule(`[data-mode="${mode}"][data-accent="${BRAND_ACCENT_KEY}"]`, accent));
|
|
391
|
+
if (sidebar.length > 0)
|
|
392
|
+
rules.push(rule(`[data-mode="${mode}"]`, sidebar));
|
|
393
|
+
return rules;
|
|
394
|
+
}).join("");
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* The same tokens for one mode as an inline style map, accent included, for
|
|
398
|
+
* a preview frame that carries its own `data-mode` and `data-ground` (a
|
|
399
|
+
* brand admin showing a draft).
|
|
400
|
+
*/
|
|
401
|
+
export function brandStyleVars(brand, mode) {
|
|
402
|
+
const { accent, sidebar } = brandDeclarations(brand, mode);
|
|
403
|
+
return Object.fromEntries([...accent, ...sidebar]);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Attributes for the server-rendered <html>: what the page paints for someone
|
|
407
|
+
* who has not chosen, before (or without) the boot script. Pair with
|
|
408
|
+
* `suppressHydrationWarning`, since the script may change them.
|
|
409
|
+
*/
|
|
410
|
+
export function htmlAttributes(product, brand) {
|
|
411
|
+
const effective = withBrand(product, brand);
|
|
412
|
+
const { value, mode } = resolveAppearance(null, null, effective, "dark");
|
|
413
|
+
return {
|
|
414
|
+
"data-product": effective.id,
|
|
415
|
+
"data-ground": value.ground,
|
|
416
|
+
"data-mode": mode,
|
|
417
|
+
"data-accent": value.accent,
|
|
418
|
+
"data-density": value.density,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @calliopelabs/ui CSS layer: appearance.
|
|
3
|
+
* Generated by `npm run theme` from src/appearance/boot.ts. Do not edit by hand.
|
|
4
|
+
*
|
|
5
|
+
* Load it in <head>, before the stylesheets, as a plain script element
|
|
6
|
+
* (no async, no defer) with the product on it, so it runs before first
|
|
7
|
+
* paint: src=".../appearance.js" data-product="astrolift".
|
|
8
|
+
*
|
|
9
|
+
* It stamps the person's ground, accent, density and corners on <html> from
|
|
10
|
+
* localStorage, exactly as AppearanceScript does, and leaves an API on
|
|
11
|
+
* window.CalliopeAppearance:
|
|
12
|
+
* get() the appearance on <html> now
|
|
13
|
+
* set(partial) store and apply { ground, accent, density, corners }
|
|
14
|
+
* reset() forget the choice; the product's defaults apply
|
|
15
|
+
* boot(config) use another product's boot config
|
|
16
|
+
* config the active product's grounds, accents and corners
|
|
17
|
+
* products the built-in products' boot configs, by id
|
|
18
|
+
* Every change dispatches "calliope:appearance" on document.
|
|
19
|
+
*/
|
|
20
|
+
(function () {
|
|
21
|
+
var boot = function (c) {
|
|
22
|
+
var d = document.documentElement, p = {};
|
|
23
|
+
try {
|
|
24
|
+
var raw = window.localStorage.getItem(c.key);
|
|
25
|
+
var parsed = raw ? JSON.parse(raw) : null;
|
|
26
|
+
if (parsed && typeof parsed === "object") p = parsed;
|
|
27
|
+
} catch (e) {}
|
|
28
|
+
var light = false;
|
|
29
|
+
try {
|
|
30
|
+
light = window.matchMedia("(prefers-color-scheme: light)").matches;
|
|
31
|
+
} catch (e) {}
|
|
32
|
+
var has = function (o, k) {
|
|
33
|
+
return typeof k === "string" && Object.prototype.hasOwnProperty.call(o, k);
|
|
34
|
+
};
|
|
35
|
+
var lum = function (h) {
|
|
36
|
+
var n = parseInt(h.slice(1), 16), rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
37
|
+
var w = [0.2126, 0.7152, 0.0722], s = 0;
|
|
38
|
+
for (var i = 0; i < 3; i++) {
|
|
39
|
+
var v = rgb[i] / 255;
|
|
40
|
+
s += w[i] * (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
41
|
+
}
|
|
42
|
+
return s;
|
|
43
|
+
};
|
|
44
|
+
var ratio = function (x, y) {
|
|
45
|
+
var a = lum(x), b = lum(y);
|
|
46
|
+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
47
|
+
};
|
|
48
|
+
var ground = has(c.grounds, p.ground) ? p.ground : light ? c.defaults.light : c.defaults.dark;
|
|
49
|
+
var accent = p.accent;
|
|
50
|
+
var custom = typeof accent === "string" && /^#[0-9a-f]{6}$/.test(accent) &&
|
|
51
|
+
ratio(accent, c.grounds[ground].background) >= c.minContrast;
|
|
52
|
+
d.dataset.product = c.product;
|
|
53
|
+
d.dataset.ground = ground;
|
|
54
|
+
d.dataset.mode = c.grounds[ground].mode;
|
|
55
|
+
if (custom) {
|
|
56
|
+
d.dataset.accent = "custom";
|
|
57
|
+
d.style.setProperty("--brand-primary", accent);
|
|
58
|
+
d.style.setProperty("--brand-on-primary",
|
|
59
|
+
ratio(accent, "#000000") >= ratio(accent, "#ffffff") ? "#000000" : "#ffffff");
|
|
60
|
+
} else {
|
|
61
|
+
d.dataset.accent = c.accents.indexOf(accent) > -1 ? accent : c.defaults.accent;
|
|
62
|
+
}
|
|
63
|
+
d.dataset.density = p.density === "compact" || p.density === "cards" ? p.density : c.defaults.density;
|
|
64
|
+
d.style.setProperty("--radius",
|
|
65
|
+
(c.corners.indexOf(p.corners) > -1 ? p.corners : c.defaults.corners) + "px");
|
|
66
|
+
};
|
|
67
|
+
var products = {"astrolift":{"product":"astrolift","key":"astrolift.appearance","grounds":{"black":{"mode":"dark","background":"#000000"},"charcoal":{"mode":"dark","background":"#0e0f10"},"graphite":{"mode":"dark","background":"#111214"},"emerald":{"mode":"dark","background":"#050b08"},"paper":{"mode":"light","background":"#faf6ee"},"mist":{"mode":"light","background":"#f2f7f9"}},"accents":["green","copper","ice","periwinkle","amber"],"corners":[0,2,4,10],"minContrast":3,"defaults":{"dark":"graphite","light":"mist","accent":"green","density":"compact","corners":2}},"zentinelle":{"product":"zentinelle","key":"zentinelle.appearance","grounds":{"black":{"mode":"dark","background":"#000000"},"charcoal":{"mode":"dark","background":"#0e0f10"},"graphite":{"mode":"dark","background":"#111214"},"paper":{"mode":"light","background":"#faf6ee"},"mist":{"mode":"light","background":"#f2f7f9"}},"accents":["teal","green","copper","ice","periwinkle","amber"],"corners":[0,2,4,10],"minContrast":3,"defaults":{"dark":"black","light":"mist","accent":"teal","density":"compact","corners":2}},"workbench":{"product":"workbench","key":"workbench.appearance","grounds":{"navy":{"mode":"dark","background":"#0b1120"},"black":{"mode":"dark","background":"#000000"},"charcoal":{"mode":"dark","background":"#0e0f10"},"graphite":{"mode":"dark","background":"#111214"},"paper":{"mode":"light","background":"#faf6ee"},"mist":{"mode":"light","background":"#f2f7f9"}},"accents":["workbench-blue","green","copper","ice","periwinkle","amber"],"corners":[0,2,4,10],"minContrast":3,"defaults":{"dark":"navy","light":"mist","accent":"workbench-blue","density":"compact","corners":2}},"chat-studio":{"product":"chat-studio","key":"chat-studio.appearance","grounds":{"black":{"mode":"dark","background":"#000000"},"charcoal":{"mode":"dark","background":"#0e0f10"},"graphite":{"mode":"dark","background":"#111214"},"paper":{"mode":"light","background":"#faf6ee"},"mist":{"mode":"light","background":"#f2f7f9"}},"accents":["studio-blue","green","copper","ice","periwinkle","amber"],"corners":[0,2,4,10],"minContrast":3,"defaults":{"dark":"black","light":"paper","accent":"studio-blue","density":"compact","corners":2}},"loadr":{"product":"loadr","key":"loadr.appearance","grounds":{"black":{"mode":"dark","background":"#000000"},"charcoal":{"mode":"dark","background":"#0e0f10"},"graphite":{"mode":"dark","background":"#111214"},"paper":{"mode":"light","background":"#faf6ee"},"mist":{"mode":"light","background":"#f2f7f9"}},"accents":["loadr-blue","green","copper","ice","periwinkle","amber"],"corners":[0,2,4,10],"minContrast":3,"defaults":{"dark":"charcoal","light":"paper","accent":"loadr-blue","density":"compact","corners":2}},"cykick":{"product":"cykick","key":"cykick.appearance","grounds":{"black":{"mode":"dark","background":"#000000"},"charcoal":{"mode":"dark","background":"#0e0f10"},"graphite":{"mode":"dark","background":"#111214"},"paper":{"mode":"light","background":"#faf6ee"},"mist":{"mode":"light","background":"#f2f7f9"}},"accents":["cykick-cyan","green","copper","ice","periwinkle","amber"],"corners":[0,2,4,10],"minContrast":3,"defaults":{"dark":"black","light":"mist","accent":"cykick-cyan","density":"compact","corners":0}}};
|
|
68
|
+
var d = document.documentElement;
|
|
69
|
+
var hex = /^#[0-9a-f]{6}$/;
|
|
70
|
+
var has = function (o, k) {
|
|
71
|
+
return typeof k === "string" && Object.prototype.hasOwnProperty.call(o, k);
|
|
72
|
+
};
|
|
73
|
+
var stored = function (c) {
|
|
74
|
+
try {
|
|
75
|
+
var parsed = JSON.parse(window.localStorage.getItem(c.key) || "null");
|
|
76
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/* normalizePartial: only the axes that are valid for this product. A locked accent (22:1) takes no custom colour. */
|
|
82
|
+
var valid = function (c, v) {
|
|
83
|
+
var out = {};
|
|
84
|
+
if (has(c.grounds, v.ground)) out.ground = v.ground;
|
|
85
|
+
if (c.accents.indexOf(v.accent) > -1 || (hex.test(v.accent) && c.minContrast <= 21))
|
|
86
|
+
out.accent = v.accent;
|
|
87
|
+
if (v.density === "compact" || v.density === "cards") out.density = v.density;
|
|
88
|
+
if (c.corners.indexOf(v.corners) > -1) out.corners = v.corners;
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
var api = {
|
|
92
|
+
products: products,
|
|
93
|
+
config: null,
|
|
94
|
+
get: function () {
|
|
95
|
+
var custom = d.getAttribute("data-accent") === "custom";
|
|
96
|
+
return {
|
|
97
|
+
ground: d.getAttribute("data-ground"),
|
|
98
|
+
mode: d.getAttribute("data-mode"),
|
|
99
|
+
accent: custom ? d.style.getPropertyValue("--brand-primary") : d.getAttribute("data-accent"),
|
|
100
|
+
density: d.getAttribute("data-density"),
|
|
101
|
+
corners: parseInt(d.style.getPropertyValue("--radius"), 10)
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
set: function (next) {
|
|
105
|
+
var c = api.config;
|
|
106
|
+
if (!c || !next) return;
|
|
107
|
+
var merged = stored(c);
|
|
108
|
+
for (var k in next) if (has(next, k)) merged[k] = next[k];
|
|
109
|
+
if (typeof merged.accent === "string") merged.accent = merged.accent.toLowerCase();
|
|
110
|
+
try {
|
|
111
|
+
window.localStorage.setItem(c.key, JSON.stringify(valid(c, merged)));
|
|
112
|
+
} catch (e) {}
|
|
113
|
+
apply();
|
|
114
|
+
},
|
|
115
|
+
reset: function () {
|
|
116
|
+
if (!api.config) return;
|
|
117
|
+
try {
|
|
118
|
+
window.localStorage.removeItem(api.config.key);
|
|
119
|
+
} catch (e) {}
|
|
120
|
+
apply();
|
|
121
|
+
},
|
|
122
|
+
boot: function (config) {
|
|
123
|
+
api.config = config;
|
|
124
|
+
apply();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
var apply = function () {
|
|
128
|
+
/* A custom accent is inline; a family accent comes from the stylesheet. */
|
|
129
|
+
d.style.removeProperty("--brand-primary");
|
|
130
|
+
d.style.removeProperty("--brand-on-primary");
|
|
131
|
+
try {
|
|
132
|
+
boot(api.config);
|
|
133
|
+
} catch (e) {}
|
|
134
|
+
var event;
|
|
135
|
+
try {
|
|
136
|
+
event = new CustomEvent("calliope:appearance", { detail: api.get() });
|
|
137
|
+
} catch (e) {
|
|
138
|
+
event = document.createEvent("CustomEvent");
|
|
139
|
+
event.initCustomEvent("calliope:appearance", false, false, api.get());
|
|
140
|
+
}
|
|
141
|
+
document.dispatchEvent(event);
|
|
142
|
+
};
|
|
143
|
+
window.CalliopeAppearance = api;
|
|
144
|
+
var me = document.currentScript;
|
|
145
|
+
var id = me && me.getAttribute("data-product");
|
|
146
|
+
if (has(products, id)) api.boot(products[id]);
|
|
147
|
+
})();
|