| 1 | export const THEME_COLOURS = [ |
| 2 | ['bg', 'Background'], ['bg-soft', 'Soft background'], ['panel', 'Panels'], |
| 3 | ['panel-2', 'Raised panels'], ['rail-bg', 'Spaces rail'], ['border', 'Borders'], |
| 4 | ['text', 'Text'], ['muted', 'Muted text'], ['accent', 'Accent'], |
| 5 | ['accent-2', 'Secondary accent'], ['on-accent', 'Text on accent'], ['glow', 'Glow'], |
| 6 | ['hover', 'Hover'], ['hover-faint', 'Subtle hover'], |
| 7 | ['selection-bg', 'Selection background'], ['selection-text', 'Selected text'], |
| 8 | ['ok', 'Success'], ['ok-text', 'Success text'], |
| 9 | ['danger', 'Error'], ['danger-bg', 'Error background'], |
| 10 | ['danger-border', 'Error border'], ['danger-text', 'Error text'], |
| 11 | ['warn', 'Warning'], ['warn-bg', 'Warning background'], |
| 12 | ['warn-border', 'Warning border'], ['warn-text', 'Warning text'] |
| 13 | ] as const; |
| 14 | |
| 15 | export type ThemeColour = (typeof THEME_COLOURS)[number][0]; |
| 16 | export type ThemeColours = Record<ThemeColour, string>; |
| 17 | export interface ThemeDocument { |
| 18 | format: 'malice-theme'; |
| 19 | version: 1; |
| 20 | name: string; |
| 21 | scheme: 'dark' | 'light'; |
| 22 | colours: ThemeColours; |
| 23 | palette: string[]; |
| 24 | nameLightness: number; |
| 25 | } |
| 26 | export interface CustomTheme extends ThemeDocument { |
| 27 | id: `custom:${string}`; |
| 28 | } |
| 29 | |
| 30 | export const MALICIOUS_PALETTE = [ |
| 31 | '#1b2021', '#cecbca', '#272a2a', '#202e2f', '#c43325', '#c46056', |
| 32 | '#8cc992', '#c2dab0', '#ffb852', '#ffab5b', '#5299ff', '#92beff', |
| 33 | '#645ac9', '#928cc9', '#5abfc9', '#8cc4c9', '#b0c2da', '#caccce' |
| 34 | ]; |
| 35 | |
| 36 | export function isThemeColour(value: unknown): value is `#${string}` { |
| 37 | return typeof value === 'string' && /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(value); |
| 38 | } |
| 39 | |
| 40 | export function isThemeDocument(value: unknown): value is ThemeDocument { |
| 41 | if (typeof value !== 'object' || value === null) return false; |
| 42 | const doc = value as Record<string, unknown>; |
| 43 | return doc.format === 'malice-theme' && doc.version === 1 && |
| 44 | typeof doc.name === 'string' && doc.name.trim().length > 0 && doc.name.length <= 80 && |
| 45 | (doc.scheme === 'dark' || doc.scheme === 'light') && |
| 46 | typeof doc.nameLightness === 'number' && Number.isFinite(doc.nameLightness) && |
| 47 | doc.nameLightness >= 0 && doc.nameLightness <= 100 && |
| 48 | Array.isArray(doc.palette) && doc.palette.length <= 64 && doc.palette.every(isThemeColour) && |
| 49 | typeof doc.colours === 'object' && doc.colours !== null && |
| 50 | THEME_COLOURS.every(([key]) => isThemeColour((doc.colours as Record<string, unknown>)[key])); |
| 51 | } |
| 52 | |
| 53 | export function isCustomTheme(value: unknown): value is CustomTheme { |
| 54 | return isThemeDocument(value) && 'id' in value && |
| 55 | typeof value.id === 'string' && /^custom:[\w-]{1,80}$/.test(value.id); |
| 56 | } |
| 57 | |
| 58 | export function themeDocument(value: ThemeDocument): ThemeDocument { |
| 59 | return { |
| 60 | format: 'malice-theme', version: 1, name: value.name.trim(), scheme: value.scheme, |
| 61 | colours: Object.fromEntries(THEME_COLOURS.map(([key]) => [key, value.colours[key]])) as ThemeColours, |
| 62 | palette: [...value.palette], nameLightness: value.nameLightness |
| 63 | }; |
| 64 | } |
| 65 | |
| 66 | export function readThemeFile(contents: string): ThemeDocument { |
| 67 | let value: unknown; |
| 68 | try { |
| 69 | value = JSON.parse(contents); |
| 70 | } catch { |
| 71 | throw new Error('This file does not contain valid JSON.'); |
| 72 | } |
| 73 | if (!isThemeDocument(value)) throw new Error('Choose a valid Malice theme file with version 1.'); |
| 74 | return themeDocument(value); |
| 75 | } |
| 76 | |
| 77 | function colourHex(value: string): string { |
| 78 | if (/^#[0-9a-f]{3,4}$/i.test(value)) return '#' + [...value.slice(1)].map((char) => char + char).join(''); |
| 79 | if (isThemeColour(value)) return value; |
| 80 | const channels = value.match(/[\d.]+/g)?.map(Number); |
| 81 | if (!channels || channels.length < 3) throw new Error('Could not read the current theme colours.'); |
| 82 | const bytes = channels.slice(0, 3); |
| 83 | if (channels.length === 4) bytes.push(Math.round(channels[3] * 255)); |
| 84 | return '#' + bytes.map((byte) => Math.round(byte).toString(16).padStart(2, '0')).join(''); |
| 85 | } |
| 86 | |
| 87 | export function captureTheme(name: string, palette?: string[]): ThemeDocument { |
| 88 | const style = getComputedStyle(document.documentElement); |
| 89 | const colours = Object.fromEntries(THEME_COLOURS.map(([key]) => [key, colourHex(style.getPropertyValue(`--${key}`).trim())])) as ThemeColours; |
| 90 | return { |
| 91 | format: 'malice-theme', version: 1, name, |
| 92 | scheme: style.colorScheme === 'light' ? 'light' : 'dark', colours, |
| 93 | palette: palette ? [...palette] : [...new Set(Object.values(colours))].slice(0, 64), |
| 94 | nameLightness: parseFloat(style.getPropertyValue('--name-lightness')) |
| 95 | }; |
| 96 | } |
| 97 | |
| 98 | export function themeStyle(doc: ThemeDocument): string { |
| 99 | return THEME_COLOURS.map(([key]) => `--${key}:${doc.colours[key]}`).join(';') + |
| 100 | `;--name-lightness:${doc.nameLightness}%;color-scheme:${doc.scheme}`; |
| 101 | } |