atagen

malice

public
Star0
Watch0
0bc1aca195fb7ffce50e1865090cbdfcbf999f63
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/custom-themes.tsTypescript101 lines4.5 KB
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
}