Commit c122e088bece
tarball6 files changed+450-78
@@ -18,6 +18,9 @@
1818
--muted: #a3a8ba;1919
--accent: #5865f2;2020
--accent-2: #7c5cff;21
+ --on-accent: #ffffff;22
+ --selection-bg: #5865f266;23
+ --selection-text: #ecedf3;2124
--glow: #1c2440;2225
--hover: rgba(255, 255, 255, 0.06);2326
--hover-faint: rgba(255, 255, 255, 0.018);@@ -82,6 +85,8 @@
8285
--muted: #5e6478;8386
--accent: #4f5bd5;8487
--accent-2: #7c5cff;88
+ --selection-bg: #4f5bd566;89
+ --selection-text: #1d2030;8590
--glow: #dfe4ff;8691
--hover: rgba(0, 0, 0, 0.05);8792
--hover-faint: rgba(0, 0, 0, 0.025);@@ -99,6 +104,36 @@
99104
color-scheme: light;100105
}101106
107
+:root[data-theme='malicious'] {108
+ --bg: #1b2021;109
+ --bg-soft: #202e2f;110
+ --panel: #272a2a;111
+ --panel-2: #202e2f;112
+ --rail-bg: #1b2021;113
+ --border: #5abfc940;114
+ --text: #cecbca;115
+ --muted: #b0c2da;116
+ --accent: #5abfc9;117
+ --accent-2: #ffb852;118
+ --on-accent: #1b2021;119
+ --selection-bg: #ffb852;120
+ --selection-text: #1b2021;121
+ --glow: #202e2f;122
+ --hover: #5abfc91a;123
+ --hover-faint: #5abfc908;124
+ --name-lightness: 72%;125
+ --ok: #8cc992;126
+ --ok-text: #c2dab0;127
+ --danger: #c46056;128
+ --danger-bg: #c4332526;129
+ --danger-border: #c43325;130
+ --danger-text: #c46056;131
+ --warn: #ffb852;132
+ --warn-bg: #ffb8521a;133
+ --warn-border: #ffab5b;134
+ --warn-text: #ffb852;135
+}136
+102137
* { box-sizing: border-box; }103138
104139
html, body, #app {@@ -128,7 +163,7 @@ body {
128163
button { font: inherit; }129164
130165
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }131
-::selection { background: color-mix(in srgb, var(--accent) 40%, transparent); }166
+::selection { background: var(--selection-bg); color: var(--selection-text); }132167
input, textarea { font: inherit; }133168
134169
.visually-hidden {@@ -152,7 +187,7 @@ input, textarea { font: inherit; }
152187
width: 52px; height: 52px;153188
border-radius: var(--r-lg);154189
display: grid; place-items: center;155
- font-size: 26px; font-weight: 800; color: #fff;190
+ font-size: 26px; font-weight: 800; color: var(--on-accent);156191
background: linear-gradient(135deg, var(--accent), var(--accent-2));157192
}158193
@@ -236,8 +271,8 @@ input, textarea { font: inherit; }
236271
overflow: hidden;237272
padding: 0;238273
}239
-.rail-btn:hover { background: var(--accent); color: #fff; }240
-.rail-btn.active { background: var(--accent); color: #fff; }274
+.rail-btn:hover { background: var(--accent); color: var(--on-accent); }275
+.rail-btn.active { background: var(--accent); color: var(--on-accent); }241276
.rail-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }242277
.rail-space { border-radius: 50%; transition: border-radius var(--pop), background var(--pop), color var(--pop), box-shadow var(--pop); }243278
.rail-space:hover, .rail-space.active { background: var(--panel-2); }@@ -356,11 +391,11 @@ input, textarea { font: inherit; }
356391
display: inline-grid;357392
place-items: center;358393
}359
-.badge.mention { background: var(--accent); border-color: var(--accent); color: #fff; }394
+.badge.mention { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }360395
.invite-join {361396
flex: none;362397
background: var(--accent);363
- color: #fff;398
+ color: var(--on-accent);364399
border-radius: var(--r-sm);365400
padding: 2px 10px;366401
font-size: 12px;@@ -427,7 +462,7 @@ input, textarea { font: inherit; }
427462
width: 32px; height: 32px;428463
border-radius: 50%;429464
background: var(--accent);430
- color: #fff;465
+ color: var(--on-accent);431466
font-weight: 700;432467
font-size: 13px;433468
display: inline-grid;@@ -554,7 +589,7 @@ input, textarea { font: inherit; }
554589
height: var(--ctl-h);555590
background: var(--accent);556591
border: none;557
- color: #fff;592
+ color: var(--on-accent);558593
font-weight: 600;559594
border-radius: var(--r-md);560595
padding: 0 14px;@@ -131,7 +131,7 @@
131131
place-items: center;132132
font-weight: 800;133133
font-size: 22px;134
- color: #fff;134
+ color: var(--on-accent);135135
background: linear-gradient(135deg, var(--accent), var(--accent-2));136136
}137137
h1 { font-size: 24px; margin: 2px 0 0; }@@ -180,7 +180,7 @@
180180
margin-top: 4px;181181
background: var(--accent);182182
border: none;183
- color: #fff;183
+ color: var(--on-accent);184184
font-weight: 600;185185
border-radius: var(--r-md);186186
padding: 10px;@@ -7,14 +7,12 @@
77
MESSAGE_SPACING_DEFAULT,88
MESSAGE_SPACING_MAX,99
MESSAGE_SPACING_MIN,10
- THEMES,11
- accent,1210
lineHeight,13
- messageSpacing,14
- theme11
+ messageSpacing1512
} from './appearance';1613
import { DEFAULT_QUICK_REACTIONS } from './quick-reactions';1714
import ReactionPicker from './ReactionPicker.svelte';15
+ import ThemeSettings from './ThemeSettings.svelte';1816
import type { ImagePack, QuickReaction } from './types';1917
2018
export let quick: QuickReaction[];@@ -26,9 +24,6 @@
2624
$: quickIsDefault =2725
quick.length === DEFAULT_QUICK_REACTIONS.length && quick.every((r, i) => r.key === DEFAULT_QUICK_REACTIONS[i].key);2826
29
- function themeAccent(): string {30
- return getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#5865f2';31
- }3227
</script>3328
3429
<div class="modal settings" role="dialog" aria-modal="true" aria-label="Settings">@@ -39,30 +34,7 @@
3934
</button>4035
</div>4136
42
- <section aria-label="Theme">43
- <h3 class="sec-h">Theme</h3>44
- <div class="themes" role="radiogroup" aria-label="Theme">45
- {#each THEMES as t (t.id)}46
- <button47
- type="button"48
- role="radio"49
- aria-checked={$theme === t.id}50
- class="theme-opt"51
- class:active={$theme === t.id}52
- data-swatch={t.id}53
- on:click={() => theme.set(t.id)}54
- >55
- <span class="swatch" aria-hidden="true"></span>56
- {t.label}57
- </button>58
- {/each}59
- </div>60
- <div class="range accent-row">61
- <label for="accent-colour">Accent colour</label>62
- <input id="accent-colour" type="color" value={$accent ?? themeAccent()} on:input={(e) => accent.set(e.currentTarget.value)} />63
- <button type="button" class="ghost-btn" disabled={$accent === null} on:click={() => accent.set(null)}>Use theme colour</button>64
- </div>65
- </section>37
+ <ThemeSettings />6638
6739
<section aria-label="Message spacing">6840
<h3 class="sec-h">Space between messages</h3>@@ -127,7 +99,7 @@
12799
128100
<style>129101
.settings { max-height: calc(100dvh - 32px); overflow-y: auto; }130
- section + section { margin-top: 18px; }102
+ section { margin-top: 18px; }131103
.sec-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }132104
.sec-head .sec-h { margin: 0; }133105
.sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 6px; }@@ -135,26 +107,4 @@
135107
.range { display: flex; align-items: center; gap: 10px; }136108
.range input[type='range'] { flex: 1; accent-color: var(--accent); }137109
.value { font-size: 13px; min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }138
- .themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 6px; }139
- .theme-opt {140
- display: flex; align-items: center; gap: 8px;141
- padding: 6px 8px;142
- background: var(--bg-soft);143
- border: 1px solid var(--border);144
- border-radius: var(--r-md);145
- color: var(--text);146
- font-size: 13px;147
- cursor: pointer;148
- text-align: left;149
- }150
- .theme-opt.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }151
- .swatch { flex: none; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border); }152
- [data-swatch='system'] .swatch { background: linear-gradient(135deg, #14151c 50%, #f7f7fa 50%); }153
- [data-swatch='dark'] .swatch { background: #14151c; }154
- [data-swatch='light'] .swatch { background: #f7f7fa; }155
- [data-swatch='black'] .swatch { background: #000; }156
- [data-swatch='nord'] .swatch { background: #2e3440; }157
- .accent-row { margin-top: 10px; font-size: 13px; }158
- .accent-row label { flex: 1; }159
- .accent-row input[type='color'] { width: 36px; height: 26px; padding: 0; border: 1px solid var(--border); border-radius: var(--r-sm); background: none; cursor: pointer; }160110
</style>@@ -0,0 +1,269 @@
1
+<script lang="ts">2
+ import { onDestroy } from 'svelte';3
+ import { get } from 'svelte/store';4
+ import { THEMES, accent, customThemes, theme, themePreview, type ThemeId } from './appearance';5
+ import {6
+ MALICIOUS_PALETTE, THEME_COLOURS, captureTheme, isThemeColour, readThemeFile,7
+ themeDocument, themeStyle, type CustomTheme, type ThemeColour, type ThemeDocument8
+ } from './custom-themes';9
+10
+ let draft: ThemeDocument | null = null;11
+ let editingId: CustomTheme['id'] | null = null;12
+ let selectedColour: ThemeColour = 'bg';13
+ let hexInput = '';14
+ let previewApp = false;15
+ let error = '';16
+ let notice = '';17
+ let fileInput: HTMLInputElement;18
+19
+ $: selected = $customThemes.find((item) => item.id === $theme);20
+ $: selectedName = selected?.name ?? THEMES.find((item) => item.id === $theme)?.label ?? 'Theme';21
+ $: if (draft) hexInput = draft.colours[selectedColour];22
+ $: themePreview.set(previewApp ? draft : null);23
+24
+ onDestroy(() => themePreview.set(null));25
+26
+ function chooseTheme(id: ThemeId): void {27
+ draft = null;28
+ previewApp = false;29
+ themePreview.set(null);30
+ accent.set(null);31
+ theme.set(id);32
+ error = '';33
+ notice = '';34
+ }35
+36
+ function editTheme(asNew: boolean): void {37
+ themePreview.set(null);38
+ previewApp = false;39
+ editingId = !asNew && selected ? selected.id : null;40
+ draft = selected && !asNew ? themeDocument(selected) : captureTheme(41
+ `${selectedName} custom`, $theme === 'malicious' ? MALICIOUS_PALETTE : selected?.palette42
+ );43
+ selectedColour = 'bg';44
+ error = '';45
+ notice = '';46
+ }47
+48
+ function persistThemes(items: CustomTheme[]): void {49
+ localStorage.setItem('malice.customThemes', JSON.stringify(items));50
+ customThemes.set(items);51
+ }52
+53
+ function saveTheme(doc: ThemeDocument, id: CustomTheme['id'] | null = null): void {54
+ try {55
+ const saved: CustomTheme = { ...themeDocument(doc), id: id ?? `custom:${crypto.randomUUID()}` };56
+ const items = get(customThemes);57
+ persistThemes(id ? items.map((item) => item.id === id ? saved : item) : [...items, saved]);58
+ chooseTheme(saved.id);59
+ notice = `${saved.name} saved on this device.`;60
+ } catch {61
+ error = 'Could not save the theme. Check that browser storage is available and has space.';62
+ }63
+ }64
+65
+ function deleteTheme(): void {66
+ if (!selected) return;67
+ try {68
+ persistThemes($customThemes.filter((item) => item.id !== selected?.id));69
+ chooseTheme('dark');70
+ notice = 'Custom theme deleted.';71
+ } catch {72
+ error = 'Could not delete the theme from browser storage.';73
+ }74
+ }75
+76
+ function exportTheme(doc: ThemeDocument): void {77
+ const blob = new Blob([JSON.stringify(themeDocument(doc), null, 2) + '\n'], { type: 'application/json' });78
+ const url = URL.createObjectURL(blob);79
+ const anchor = document.createElement('a');80
+ anchor.href = url;81
+ anchor.download = `${doc.name.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-|-$/g, '') || 'theme'}.malice-theme.json`;82
+ anchor.click();83
+ setTimeout(() => URL.revokeObjectURL(url), 1000);84
+ }85
+86
+ async function importTheme(event: Event): Promise<void> {87
+ const input = event.currentTarget as HTMLInputElement;88
+ const file = input.files?.[0];89
+ input.value = '';90
+ if (!file) return;91
+ error = '';92
+ notice = '';93
+ try {94
+ if (file.size > 65536) throw new Error('Theme files must be smaller than 64 KB.');95
+ const imported = readThemeFile(await file.text());96
+ draft = imported;97
+ editingId = null;98
+ previewApp = false;99
+ selectedColour = 'bg';100
+ notice = 'Theme loaded. Preview it and save to add it to your themes.';101
+ } catch (cause) {102
+ error = cause instanceof Error ? cause.message : 'Could not read the theme file.';103
+ }104
+ }105
+106
+ function updateColour(value: string): void {107
+ if (!draft || !isThemeColour(value)) return;108
+ draft = { ...draft, colours: { ...draft.colours, [selectedColour]: value.toLowerCase() } };109
+ error = '';110
+ }111
+112
+ function updateHex(): void {113
+ if (!isThemeColour(hexInput)) {114
+ error = 'Enter a hex colour with six digits, or eight digits including opacity.';115
+ return;116
+ }117
+ updateColour(hexInput);118
+ }119
+120
+ function updateOpacity(value: string): void {121
+ if (!draft) return;122
+ const alpha = Math.round(Number(value) * 255 / 100);123
+ updateColour(draft.colours[selectedColour].slice(0, 7) + (alpha === 255 ? '' : alpha.toString(16).padStart(2, '0')));124
+ }125
+126
+ function addToPalette(): void {127
+ if (!draft || draft.palette.length >= 64) return;128
+ const colour = draft.colours[selectedColour];129
+ if (!draft.palette.includes(colour)) draft = { ...draft, palette: [...draft.palette, colour] };130
+ }131
+132
+ function removeFromPalette(): void {133
+ if (draft) draft = { ...draft, palette: draft.palette.filter((colour) => colour !== draft?.colours[selectedColour]) };134
+ }135
+</script>136
+137
+<section aria-label="Theme">138
+ <h3 class="sec-h">Theme</h3>139
+ <div class="themes" role="radiogroup" aria-label="Theme">140
+ {#each THEMES as item (item.id)}141
+ <button type="button" role="radio" aria-checked={$theme === item.id} class="theme-opt" class:active={$theme === item.id}142
+ data-swatch={item.id} on:click={() => chooseTheme(item.id)}>143
+ <span class="swatch" aria-hidden="true"></span>{item.label}144
+ </button>145
+ {/each}146
+ {#each $customThemes as item (item.id)}147
+ <button type="button" role="radio" aria-checked={$theme === item.id} class="theme-opt" class:active={$theme === item.id}148
+ on:click={() => chooseTheme(item.id)}>149
+ <span class="swatch" style:background={item.colours.bg} style:border-color={item.colours.accent} aria-hidden="true"></span>150
+ <span class="theme-name">{item.name}</span>151
+ </button>152
+ {/each}153
+ </div>154
+ <div class="toolbar">155
+ <button type="button" class="ghost-btn" on:click={() => editTheme(true)}>Create theme</button>156
+ {#if selected}<button type="button" class="ghost-btn" on:click={() => editTheme(false)}>Edit theme</button>{/if}157
+ <button type="button" class="ghost-btn" on:click={() => fileInput.click()}>Import theme</button>158
+ <button type="button" class="ghost-btn" disabled={draft !== null} on:click={() => exportTheme(captureTheme(selectedName, selected?.palette ?? ($theme === 'malicious' ? MALICIOUS_PALETTE : undefined)))}>Export theme</button>159
+ {#if selected}<button type="button" class="ghost-btn danger" on:click={deleteTheme}>Delete theme</button>{/if}160
+ <input class="file-input" type="file" accept=".json,application/json" aria-label="Import theme file" bind:this={fileInput} on:change={importTheme} />161
+ </div>162
+ {#if !draft}163
+ <div class="accent-row">164
+ <label for="accent-colour">Accent colour</label>165
+ <input id="accent-colour" type="color" value={$accent ?? captureTheme(selectedName).colours.accent.slice(0, 7)} on:input={(event) => accent.set(event.currentTarget.value)} />166
+ <button type="button" class="ghost-btn" disabled={$accent === null} on:click={() => accent.set(null)}>Use theme colour</button>167
+ </div>168
+ {/if}169
+170
+ {#if draft}171
+ <div class="editor">172
+ <label class="field">Theme name<input type="text" maxlength="80" bind:value={draft.name} /></label>173
+ <label class="field">Colour scheme174
+ <select bind:value={draft.scheme}><option value="dark">Dark</option><option value="light">Light</option></select>175
+ </label>176
+ <div class="colour-roles" role="radiogroup" aria-label="Colour to edit">177
+ {#each THEME_COLOURS as [key, label] (key)}178
+ <button type="button" role="radio" aria-checked={selectedColour === key} class="role-opt" class:active={selectedColour === key}179
+ on:click={() => selectedColour = key}>180
+ <span class="swatch" style:background={draft.colours[key]} aria-hidden="true"></span>{label}181
+ </button>182
+ {/each}183
+ </div>184
+ <div class="colour-edit">185
+ <input type="color" aria-label="Pick colour" value={draft.colours[selectedColour].slice(0, 7)}186
+ on:input={(event) => updateColour(event.currentTarget.value + draft!.colours[selectedColour].slice(7))} />187
+ <label class="hex-field">Hex colour<input type="text" maxlength="9" spellcheck="false" bind:value={hexInput} on:change={updateHex} aria-invalid={!!error && !isThemeColour(hexInput)} /></label>188
+ <label class="opacity-field">Opacity189
+ <input type="range" min="0" max="100" step="1"190
+ value={Math.round(parseInt(draft.colours[selectedColour].slice(7) || 'ff', 16) / 255 * 100)}191
+ on:input={(event) => updateOpacity(event.currentTarget.value)} />192
+ </label>193
+ </div>194
+ <h4 class="sec-h">Palette</h4>195
+ <div class="palette" aria-label="Theme palette">196
+ {#each draft.palette as colour, index (`${index}-${colour}`)}197
+ <button type="button" class="palette-colour" style:background={colour} title={colour} aria-label={`Use ${colour}`}198
+ on:click={() => updateColour(colour)}></button>199
+ {/each}200
+ </div>201
+ <div class="toolbar">202
+ <button type="button" class="ghost-btn" disabled={draft.palette.length >= 64 || draft.palette.includes(draft.colours[selectedColour])} on:click={addToPalette}>Add colour</button>203
+ <button type="button" class="ghost-btn" disabled={!draft.palette.includes(draft.colours[selectedColour])} on:click={removeFromPalette}>Remove colour</button>204
+ </div>205
+ <label class="field">Sender name lightness206
+ <input type="range" min="0" max="100" step="1" bind:value={draft.nameLightness} />207
+ </label>208
+ <div class="theme-preview" style={themeStyle(draft)} aria-label="Theme preview">209
+ <div class="preview-heading">{draft.name || 'Your theme'}<span class="preview-muted">just now</span></div>210
+ <p>A little room to make your own. <span class="preview-link">A shared link</span></p>211
+ <div class="preview-status"><span class="preview-ok">Connected</span><span class="preview-warn">Warning</span><span class="preview-error">Error</span></div>212
+ <div class="preview-composer"><span class="preview-muted">Write a message…</span><span class="primary-btn" aria-hidden="true">Send</span></div>213
+ </div>214
+ <label class="preview-toggle"><input type="checkbox" bind:checked={previewApp} />Preview across the app</label>215
+ <div class="toolbar">216
+ <button type="button" class="primary-btn" disabled={!draft.name.trim() || !isThemeColour(hexInput)} on:click={() => draft && saveTheme(draft, editingId)}>Save theme</button>217
+ {#if editingId}<button type="button" class="ghost-btn" disabled={!draft.name.trim() || !isThemeColour(hexInput)} on:click={() => draft && saveTheme(draft)}>Save as new</button>{/if}218
+ <button type="button" class="ghost-btn" on:click={() => draft && exportTheme(draft)}>Export draft</button>219
+ <button type="button" class="ghost-btn" on:click={() => { draft = null; previewApp = false; error = ''; notice = ''; }}>Cancel</button>220
+ </div>221
+ </div>222
+ {/if}223
+ {#if error}<p class="theme-error" role="alert">{error}</p>{/if}224
+ {#if notice}<p class="theme-notice" role="status">{notice}</p>{/if}225
+</section>226
+227
+<style>228
+ .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 6px; }229
+ .themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(130px, 100%), 1fr)); gap: 6px; }230
+ .theme-opt, .role-opt { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 8px; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-md); color: var(--text); font-size: 13px; cursor: pointer; text-align: left; }231
+ .theme-opt.active, .role-opt.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }232
+ .theme-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }233
+ .swatch { flex: none; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border); }234
+ [data-swatch='system'] .swatch { background: linear-gradient(135deg, #111218 50%, #f7f7fa 50%); }235
+ [data-swatch='dark'] .swatch { background: #111218; }236
+ [data-swatch='light'] .swatch { background: #f7f7fa; }237
+ [data-swatch='black'] .swatch { background: #000; }238
+ [data-swatch='nord'] .swatch { background: #2e3440; }239
+ [data-swatch='malicious'] .swatch { background: #1b2021; border-color: #5abfc9; box-shadow: inset -6px 0 #ffb852; }240
+ .toolbar { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }241
+ .file-input { display: none; }242
+ .accent-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; font-size: 13px; }243
+ .accent-row label { flex: 1; }244
+ input[type='color'] { width: 40px; height: 34px; padding: 2px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--panel); cursor: pointer; }245
+ .editor { margin-top: 14px; padding: 12px; border: 1px solid var(--border); border-radius: var(--r-md); }246
+ .field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); margin-bottom: 12px; }247
+ input[type='text'], select { min-width: 0; width: 100%; min-height: var(--ctl-h); padding: 6px 8px; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); font: inherit; }248
+ .colour-roles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr)); gap: 6px; max-height: 210px; overflow-y: auto; padding: 2px; }249
+ .colour-edit { display: flex; align-items: end; flex-wrap: wrap; gap: 10px; margin: 14px 0; }250
+ .hex-field, .opacity-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }251
+ .hex-field { flex: 1; min-width: 100px; }252
+ .opacity-field { flex: 1; min-width: 100px; }253
+ input[type='range'] { width: 100%; accent-color: var(--accent); }254
+ .palette { display: flex; flex-wrap: wrap; gap: 6px; }255
+ .palette-colour { width: 28px; height: 28px; border: 1px solid var(--border); border-radius: var(--r-sm); cursor: pointer; }256
+ .toolbar + .field { margin-top: 14px; }257
+ .theme-preview { margin-top: 14px; padding: 12px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--bg); color: var(--text); font-size: 13px; }258
+ .preview-heading { display: flex; gap: 10px; flex-wrap: wrap; font-weight: 600; }259
+ .preview-muted { color: var(--muted); font-size: 12px; font-weight: 400; }260
+ .preview-link { color: var(--accent); }261
+ .preview-status { display: flex; gap: 10px; flex-wrap: wrap; }262
+ .preview-ok { color: var(--ok-text); }263
+ .preview-warn { color: var(--warn-text); background: var(--warn-bg); }264
+ .preview-error { color: var(--danger-text); background: var(--danger-bg); }265
+ .preview-composer { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px; margin-top: 12px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-sm); }266
+ .preview-toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; margin-top: 12px; }267
+ .theme-error { color: var(--danger-text); background: var(--danger-bg); padding: 8px; border-radius: var(--r-sm); }268
+ .theme-notice { color: var(--muted); font-size: 13px; }269
+</style>@@ -1,4 +1,5 @@
1
-import { writable, type Writable } from 'svelte/store';1
+import { get, writable, type Writable } from 'svelte/store';2
+import { THEME_COLOURS, isCustomTheme, type CustomTheme, type ThemeDocument } from './custom-themes';23
34
export const MESSAGE_SPACING_MIN = 0;45
export const MESSAGE_SPACING_MAX = 16;@@ -56,12 +57,17 @@ export const THEMES = [
5657
{ id: 'dark', label: 'Dark' },5758
{ id: 'light', label: 'Light' },5859
{ id: 'black', label: 'Black' },59
- { id: 'nord', label: 'Nord' }60
+ { id: 'nord', label: 'Nord' },61
+ { id: 'malicious', label: 'Malicious' }6062
] as const;6163
62
-export type ThemeId = (typeof THEMES)[number]['id'];64
+export type ThemeId = (typeof THEMES)[number]['id'] | CustomTheme['id'];6365
64
-const isTheme = (v: unknown): v is ThemeId => THEMES.some((t) => t.id === v);66
+const isCustomThemeList = (v: unknown): v is CustomTheme[] => Array.isArray(v) && v.every(isCustomTheme);67
+export const customThemes = persisted<CustomTheme[]>('malice.customThemes', [], isCustomThemeList);68
+export const themePreview = writable<ThemeDocument | null>(null);69
+70
+const isTheme = (v: unknown): v is ThemeId => THEMES.some((t) => t.id === v) || get(customThemes).some((t) => t.id === v);6571
const isAccent = (v: unknown): v is string | null => v === null || (typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v));6672
6773
export const theme = persisted<ThemeId>('malice.theme', 'dark', isTheme);@@ -70,17 +76,28 @@ export const accent = persisted<string | null>('malice.accent', null, isAccent);
7076
if (typeof document !== 'undefined') {7177
const root = document.documentElement;7278
const prefersLight = window.matchMedia('(prefers-color-scheme: light)');73
- let current: ThemeId = 'system';7479
const apply = () => {75
- root.dataset.theme = current === 'system' ? (prefersLight.matches ? 'light' : 'dark') : current;80
+ const current = get(theme);81
+ const custom = get(themePreview) ?? get(customThemes).find((t) => t.id === current);82
+ const base = current === 'system' ? (prefersLight.matches ? 'light' : 'dark') : current;83
+ root.dataset.theme = custom?.scheme ?? (current.startsWith('custom:') ? 'dark' : base);84
+ for (const [key] of THEME_COLOURS) {85
+ if (custom) root.style.setProperty(`--${key}`, custom.colours[key]);86
+ else root.style.removeProperty(`--${key}`);87
+ }88
+ if (custom) {89
+ root.style.setProperty('--name-lightness', `${custom.nameLightness}%`);90
+ root.style.colorScheme = custom.scheme;91
+ } else {92
+ root.style.removeProperty('--name-lightness');93
+ root.style.removeProperty('color-scheme');94
+ }95
+ const override = get(accent);96
+ if (override && !get(themePreview)) root.style.setProperty('--accent', override);7697
};77
- theme.subscribe((t) => {78
- current = t;79
- apply();80
- });98
+ theme.subscribe(apply);99
+ customThemes.subscribe(apply);100
+ themePreview.subscribe(apply);81101
prefersLight.addEventListener('change', apply);82
- accent.subscribe((a) => {83
- if (a) root.style.setProperty('--accent', a);84
- else root.style.removeProperty('--accent');85
- });102
+ accent.subscribe(apply);86103
}@@ -0,0 +1,101 @@
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.nameLightness63
+ };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
+}