@canopy-iiif/app 0.8.4 → 0.8.6

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/ui/theme.js ADDED
@@ -0,0 +1,303 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const yaml = require("js-yaml");
4
+ const radixColors = require("@radix-ui/colors");
5
+
6
+ const DEFAULT_ACCENT = "indigo";
7
+ const DEFAULT_GRAY = "slate";
8
+ const DEFAULT_APPEARANCE = "light";
9
+ const DEBUG_FLAG_RAW = String(process.env.CANOPY_DEBUG_THEME || "");
10
+ const DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(DEBUG_FLAG_RAW.trim());
11
+ const SASS_STYLES_DIR = path.join(__dirname, "styles");
12
+ const SASS_VARIABLES_ENTRY = path.join(SASS_STYLES_DIR, "_variables.scss");
13
+
14
+ function debugLog(...args) {
15
+ if (!DEBUG_ENABLED) return;
16
+ try {
17
+ console.log("[canopy-theme]", ...args);
18
+ } catch (_) {}
19
+ }
20
+
21
+ function loadSassVariableTokens() {
22
+ try {
23
+ if (!fs.existsSync(SASS_VARIABLES_ENTRY)) return {map: {}, css: ""};
24
+ const sass = require("sass");
25
+ const source = `@use 'sass:meta';\n@use 'sass:string';\n@use 'variables';\n$__canopy_tokens: meta.module-variables('variables');\n@function canopy-token-name($name) {\n $identifier: #{$name};\n @if $identifier == null or $identifier == '' {\n @return null;\n }\n @if string.slice($identifier, 1, 1) == '_' {\n @return null;\n }\n @return '--#{$identifier}';\n}\n@mixin canopy-emit-tokens() {\n @each $name, $value in $__canopy_tokens {\n $css-name: canopy-token-name($name);\n @if $css-name {\n #{$css-name}: #{meta.inspect($value)};\n }\n }\n}\n:root {\n @include canopy-emit-tokens();\n}\n:host {\n @include canopy-emit-tokens();\n}\n`;
26
+ const result = sass.compileString(source, {
27
+ loadPaths: [SASS_STYLES_DIR],
28
+ style: "expanded",
29
+ });
30
+ const css = (result && result.css ? result.css : "").trim();
31
+ const vars = {};
32
+ const regex = /--([A-Za-z0-9_-]+)\s*:\s*([^;]+);/g;
33
+ let match;
34
+ while ((match = regex.exec(css))) {
35
+ vars[`--${match[1]}`] = match[2].trim();
36
+ }
37
+ return {map: vars, css};
38
+ } catch (error) {
39
+ debugLog("failed to compile Sass variables", error && error.message ? error.message : error);
40
+ return {map: {}, css: ""};
41
+ }
42
+ }
43
+ const LEVELS = [
44
+ "50",
45
+ "100",
46
+ "200",
47
+ "300",
48
+ "400",
49
+ "500",
50
+ "600",
51
+ "700",
52
+ "800",
53
+ "900",
54
+ ];
55
+ const STEP_MAP = {
56
+ 50: 1,
57
+ 100: 3,
58
+ 200: 5,
59
+ 300: 7,
60
+ 400: 8,
61
+ 500: 9,
62
+ 600: 10,
63
+ 700: 11,
64
+ 800: 12,
65
+ 900: 12,
66
+ };
67
+
68
+ const AVAILABLE = new Set(
69
+ Object.keys(radixColors).filter(
70
+ (key) =>
71
+ /^[a-z]+$/.test(key) && radixColors[key] && radixColors[key][`${key}1`]
72
+ )
73
+ );
74
+
75
+ const APPEARANCES = new Set(["light", "dark"]);
76
+
77
+ function readYamlConfig(cfgPath) {
78
+ try {
79
+ if (!cfgPath) return {};
80
+ if (!fs.existsSync(cfgPath)) {
81
+ debugLog("config not found; falling back to defaults", cfgPath);
82
+ return {};
83
+ }
84
+ const raw = fs.readFileSync(cfgPath, "utf8");
85
+ const data = yaml.load(raw) || {};
86
+ debugLog("loaded config", cfgPath);
87
+ return data;
88
+ } catch (_) {
89
+ return {};
90
+ }
91
+ }
92
+
93
+ function normalizePaletteName(raw) {
94
+ if (!raw) return "";
95
+ const cleaned = String(raw).trim().toLowerCase();
96
+ const compact = cleaned.replace(/[^a-z]/g, "");
97
+ return AVAILABLE.has(compact) ? compact : "";
98
+ }
99
+
100
+ function normalizeAppearance(raw) {
101
+ if (!raw) return DEFAULT_APPEARANCE;
102
+ const cleaned = String(raw).trim().toLowerCase();
103
+ return APPEARANCES.has(cleaned) ? cleaned : DEFAULT_APPEARANCE;
104
+ }
105
+
106
+ function resolveRadixPalette(name, appearance) {
107
+ if (!name || !AVAILABLE.has(name)) return null;
108
+ const paletteKey = appearance === "dark" ? `${name}Dark` : name;
109
+ const palette = radixColors[paletteKey];
110
+ if (palette && palette[`${name}1`]) return palette;
111
+ const fallback = radixColors[name];
112
+ return fallback && fallback[`${name}1`] ? fallback : null;
113
+ }
114
+
115
+ function darkenHex(hex, amount = 0.15) {
116
+ if (!hex) return hex;
117
+ const normalized = hex.replace("#", "");
118
+ if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return hex;
119
+ const num = parseInt(normalized, 16);
120
+ const r = (num >> 16) & 255;
121
+ const g = (num >> 8) & 255;
122
+ const b = num & 255;
123
+ const clamp = (value) => Math.max(0, Math.min(255, Math.round(value)));
124
+ const toHex = (value) => clamp(value).toString(16).padStart(2, "0");
125
+ const factor = 1 - amount;
126
+ return `#${toHex(r * factor)}${toHex(g * factor)}${toHex(b * factor)}`;
127
+ }
128
+
129
+ function toTailwindScale(name, options = {}) {
130
+ if (!name || !AVAILABLE.has(name)) return null;
131
+ const appearance = normalizeAppearance(options.appearance);
132
+ const palette = resolveRadixPalette(name, appearance);
133
+ if (!palette) return null;
134
+ const prefix = name;
135
+ const scale = {};
136
+ for (const lvl of LEVELS) {
137
+ const radixStep = STEP_MAP[lvl];
138
+ const key = `${prefix}${radixStep}`;
139
+ const value = palette[key];
140
+ if (!value) return null;
141
+ scale[lvl] = value;
142
+ }
143
+ const darkestKey = `${prefix}${STEP_MAP["900"]}`;
144
+ if (scale["800"] && palette[darkestKey]) {
145
+ scale["900"] = darkenHex(palette[darkestKey], 0.15);
146
+ }
147
+ return scale;
148
+ }
149
+
150
+ function buildVariablesMap(brandScale, grayScale, options = {}) {
151
+ const appearance = normalizeAppearance(options.appearance);
152
+ const vars = {};
153
+ if (brandScale) {
154
+ for (const lvl of LEVELS) {
155
+ const value = brandScale[lvl];
156
+ if (value) vars[`--color-brand-${lvl}`] = value;
157
+ }
158
+ if (brandScale["600"]) vars["--color-brand-default"] = brandScale["600"];
159
+ }
160
+ if (grayScale) {
161
+ for (const lvl of LEVELS) {
162
+ const value = grayScale[lvl];
163
+ if (value) vars[`--color-gray-${lvl}`] = value;
164
+ }
165
+ if (grayScale["900"]) vars["--color-gray-default"] = grayScale["900"];
166
+ if (grayScale["600"]) vars["--color-gray-muted"] = grayScale["600"];
167
+ }
168
+ if (brandScale && grayScale) {
169
+ if (brandScale["600"])
170
+ vars["--colors-accent"] = `${brandScale["600"]} !important`;
171
+ if (brandScale["800"])
172
+ vars["--colors-accentAlt"] = `${brandScale["800"]} !important`;
173
+ if (brandScale["400"])
174
+ vars["--colors-accentMuted"] = `${brandScale["400"]} !important`;
175
+ if (grayScale["900"]) {
176
+ const primary = `${grayScale["900"]} !important`;
177
+ vars["--colors-primary"] = primary;
178
+ vars["--colors-primaryAlt"] = primary;
179
+ vars["--colors-primaryMuted"] = primary;
180
+ }
181
+ if (grayScale["50"]) {
182
+ const secondary = `${grayScale["50"]} !important`;
183
+ vars["--colors-secondary"] = secondary;
184
+ vars["--colors-secondaryAlt"] = secondary;
185
+ vars["--colors-secondaryMuted"] = secondary;
186
+ }
187
+ }
188
+ vars["color-scheme"] = appearance === "dark" ? "dark" : "light";
189
+ return vars;
190
+ }
191
+
192
+ function variablesToCss(vars) {
193
+ const entries = Object.entries(vars || {});
194
+ if (!entries.length) return "";
195
+ const body = entries
196
+ .map(([prop, value]) => ` ${prop}: ${value};`)
197
+ .join("\n");
198
+ return `@layer properties {\n :root {\n${body}\n }\n :host {\n${body}\n }\n}`;
199
+ }
200
+
201
+ function buildSassConfig(brandScale, grayScale) {
202
+ return "";
203
+ }
204
+
205
+ function loadCanopyTheme(options = {}) {
206
+ const cwd = options.cwd || process.cwd();
207
+ const cfgPath =
208
+ options.configPath ||
209
+ path.resolve(cwd, process.env.CANOPY_CONFIG || "canopy.yml");
210
+ const cfg = readYamlConfig(cfgPath);
211
+ const theme = (cfg && cfg.theme) || {};
212
+ const accentRequested = theme && theme.accentColor;
213
+ const grayRequested = theme && theme.grayColor;
214
+ const appearanceRequested = theme && theme.appearance;
215
+ const appearance = normalizeAppearance(appearanceRequested);
216
+
217
+ let accentName = normalizePaletteName(accentRequested);
218
+ let accentScale = accentName
219
+ ? toTailwindScale(accentName, {appearance})
220
+ : null;
221
+ let accentFallback = false;
222
+ if (!accentScale) {
223
+ accentFallback = true;
224
+ accentName = DEFAULT_ACCENT;
225
+ accentScale = toTailwindScale(DEFAULT_ACCENT, {appearance});
226
+ }
227
+
228
+ let grayName = normalizePaletteName(grayRequested);
229
+ let grayScale = grayName ? toTailwindScale(grayName, {appearance}) : null;
230
+ let grayFallback = false;
231
+ if (!grayScale) {
232
+ grayFallback = true;
233
+ grayName = DEFAULT_GRAY;
234
+ grayScale = toTailwindScale(DEFAULT_GRAY, {appearance});
235
+ }
236
+
237
+ const sassTokens = loadSassVariableTokens();
238
+ const dynamicVars = buildVariablesMap(accentScale, grayScale, {appearance});
239
+ const mergedVars = {...(sassTokens && sassTokens.map ? sassTokens.map : {}), ...dynamicVars};
240
+ const css = variablesToCss(mergedVars);
241
+ const sassConfig = buildSassConfig(accentScale, grayScale);
242
+
243
+ debugLog("resolved theme", {
244
+ configPath: cfgPath,
245
+ requested: {
246
+ accent: accentRequested || null,
247
+ gray: grayRequested || null,
248
+ appearance: appearanceRequested || null,
249
+ },
250
+ resolved: {
251
+ appearance,
252
+ accent: accentName,
253
+ gray: grayName,
254
+ accentFallback,
255
+ grayFallback,
256
+ brandSamples: accentScale
257
+ ? {
258
+ 50: accentScale["50"],
259
+ 500: accentScale["500"],
260
+ 900: accentScale["900"],
261
+ }
262
+ : null,
263
+ graySamples: grayScale
264
+ ? {50: grayScale["50"], 500: grayScale["500"], 900: grayScale["900"]}
265
+ : null,
266
+ },
267
+ });
268
+
269
+ if (
270
+ !DEBUG_ENABLED &&
271
+ (accentName !== DEFAULT_ACCENT || grayName !== DEFAULT_GRAY)
272
+ ) {
273
+ try {
274
+ console.log("[canopy-theme]", "resolved", {
275
+ appearance,
276
+ accent: accentName,
277
+ accent500: accentScale && accentScale["500"],
278
+ gray: grayName,
279
+ gray500: grayScale && grayScale["500"],
280
+ });
281
+ } catch (_) {}
282
+ }
283
+
284
+ return {
285
+ appearance,
286
+ accent: {name: accentName, scale: accentScale},
287
+ gray: {name: grayName, scale: grayScale},
288
+ variables: mergedVars,
289
+ css,
290
+ sassConfig,
291
+ };
292
+ }
293
+
294
+ module.exports = {
295
+ loadCanopyTheme,
296
+ toTailwindScale,
297
+ normalizePaletteName,
298
+ normalizeAppearance,
299
+ AVAILABLE_PALETTES: Array.from(AVAILABLE).sort(),
300
+ __DEBUG_ENABLED: DEBUG_ENABLED,
301
+ variablesToCss,
302
+ buildSassConfig,
303
+ };
@@ -1,72 +0,0 @@
1
- @use "./variables" as *;
2
-
3
- :root {
4
- --color-brand-50: #{$color-brand-50};
5
- --color-brand-100: #{$color-brand-100};
6
- --color-brand-200: #{$color-brand-200};
7
- --color-brand-300: #{$color-brand-300};
8
- --color-brand-400: #{$color-brand-400};
9
- --color-brand-500: #{$color-brand-500};
10
- --color-brand-600: #{$color-brand-600};
11
- --color-brand-700: #{$color-brand-700};
12
- --color-brand-800: #{$color-brand-800};
13
- --color-brand-900: #{$color-brand-900};
14
- --color-brand-default: #{$color-brand-default};
15
-
16
- --color-gray-50: #{$color-gray-50};
17
- --color-gray-100: #{$color-gray-100};
18
- --color-gray-200: #{$color-gray-200};
19
- --color-gray-300: #{$color-gray-300};
20
- --color-gray-400: #{$color-gray-400};
21
- --color-gray-500: #{$color-gray-500};
22
- --color-gray-600: #{$color-gray-600};
23
- --color-gray-700: #{$color-gray-700};
24
- --color-gray-800: #{$color-gray-800};
25
- --color-gray-900: #{$color-gray-900};
26
- --color-gray-default: #{$color-gray-default};
27
- --color-gray-muted: #{$color-gray-muted};
28
-
29
- // Clover IIIF specific color roles
30
- --colors-accent: #{$color-brand-default} !important;
31
- --colors-accentAlt: #{$color-brand-800} !important;
32
- --colors-accentMuted: #{$color-brand-400} !important;
33
- --colors-primary: #{$color-gray-default} !important;
34
- --colors-primaryAlt: #{$color-gray-default} !important;
35
- --colors-primaryMuted: #{$color-gray-default} !important;
36
- --colors-secondary: #{$color-gray-50} !important;
37
- --colors-secondaryAlt: #{$color-gray-50} !important;
38
- --colors-secondaryMuted: #{$color-gray-50} !important;
39
-
40
- --font-sans: #{$font-sans} !important;
41
- --font-mono: #{$font-mono} !important;
42
-
43
- --font-size-xs: #{$font-size-xs} !important;
44
- --line-height-xs: #{$line-height-xs} !important;
45
- --font-size-sm: #{$font-size-sm} !important;
46
- --line-height-sm: #{$line-height-sm} !important;
47
- --font-size-base: #{$font-size-base} !important;
48
- --line-height-base: #{$line-height-base} !important;
49
- --font-size-lg: #{$font-size-lg} !important;
50
- --line-height-lg: #{$line-height-lg} !important;
51
- --font-size-xl: #{$font-size-xl} !important;
52
- --line-height-xl: #{$line-height-xl} !important;
53
- --font-size-2xl: #{$font-size-2xl} !important;
54
- --line-height-2xl: #{$line-height-2xl} !important;
55
- --font-size-3xl: #{$font-size-3xl} !important;
56
- --line-height-3xl: #{$line-height-3xl} !important;
57
-
58
- --radius-sm: #{$radius-sm} !important;
59
- --radius-default: #{$radius-default} !important;
60
- --radius-md: #{$radius-md} !important;
61
-
62
- --max-w-content: #{$max-w-content} !important;
63
- --max-w-wide: #{$max-w-wide} !important;
64
-
65
- --shadow-sm: #{$shadow-sm} !important;
66
- --shadow: #{$shadow} !important;
67
- --shadow-md: #{$shadow-md} !important;
68
- --shadow-lg: #{$shadow-lg} !important;
69
-
70
- --duration-fast: #{$duration-fast} !important;
71
- --easing-standard: #{$easing-standard} !important;
72
- }
@@ -1,76 +0,0 @@
1
- // Sass variables (source of truth) and CSS variables (emitted) for Canopy UI.
2
-
3
- // Brand scale
4
- $color-brand-50: #fdfdfe !default;
5
- $color-brand-100: #edf2fe !default;
6
- $color-brand-200: #d2deff !default;
7
- $color-brand-300: #abbdf9 !default;
8
- $color-brand-400: #8da4ef !default;
9
- $color-brand-500: #3e63dd !default;
10
- $color-brand-600: #3358d4 !default;
11
- $color-brand-700: #2c4bbd !default;
12
- $color-brand-800: #243c94 !default;
13
- $color-brand-900: #1f2d5c !default;
14
- $color-brand-default: $color-brand-600 !default;
15
-
16
- $color-gray-50: #fcfcfd !default;
17
- $color-gray-100: #f0f0f3 !default;
18
- $color-gray-200: #e0e1e6 !default;
19
- $color-gray-300: #cdced6 !default;
20
- $color-gray-400: #b9bbc6 !default;
21
- $color-gray-500: #8b8d98 !default;
22
- $color-gray-600: #80838d !default;
23
- $color-gray-700: #60646c !default;
24
- $color-gray-800: #1c2024 !default;
25
- $color-gray-900: #121418 !default;
26
- $color-gray-default: $color-gray-900 !default;
27
- $color-gray-muted: $color-gray-600 !default;
28
-
29
- // Fonts
30
- $font-sans:
31
- "DM Sans", "-apple-system", "Segoe UI", Roboto, Ubuntu, Helvetica, Arial,
32
- sans-serif !default;
33
- $font-serif: "DM Serif Display", Georgia, "Times New Roman", serif !default;
34
- $font-mono:
35
- "ui-monospace", "SFMono-Regular", Menlo, Monaco, Consolas, monospace !default;
36
-
37
- // Font sizes / line heights
38
- $font-size-xs: 0.75rem !default;
39
- $line-height-xs: 1rem !default;
40
- $font-size-sm: 0.875rem !default;
41
- $line-height-sm: 1.25rem !default;
42
- $font-size-base: 1rem !default;
43
- $line-height-base: 1.5rem !default;
44
- $font-size-lg: 1.125rem !default;
45
- $line-height-lg: 1.75rem !default;
46
- $font-size-xl: 1.25rem !default;
47
- $line-height-xl: 1.75rem !default;
48
- $font-size-2xl: 1.5rem !default;
49
- $line-height-2xl: 2rem !default;
50
- $font-size-3xl: 1.875rem !default;
51
- $line-height-3xl: 2.25rem !default;
52
-
53
- // Radii
54
- $radius-sm: 0.125rem !default;
55
- $radius-default: 0.25rem !default;
56
- $radius-md: 0.375rem !default;
57
-
58
- // Max widths
59
- $max-w-content: 1200px !default;
60
- $max-w-wide: 1440px !default;
61
-
62
- // Shadows
63
- $shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05) !default;
64
- $shadow:
65
- 0 1px 3px rgba(0, 0, 0, 0.1),
66
- 0 1px 2px rgba(0, 0, 0, 0.06) !default;
67
- $shadow-md:
68
- 0 4px 6px rgba(0, 0, 0, 0.1),
69
- 0 2px 4px rgba(0, 0, 0, 0.06) !default;
70
- $shadow-lg:
71
- 0 10px 15px rgba(0, 0, 0, 0.1),
72
- 0 4px 6px rgba(0, 0, 0, 0.05) !default;
73
-
74
- // Transitions
75
- $duration-fast: 150ms !default;
76
- $easing-standard: cubic-bezier(0.2, 0.8, 0.2, 1) !default;