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