atagen

malice

public
Star0
Watch0

Commit c122e088bece

tarball

atagen <boss@atagen.co>2026-10-25 02:38 UTC

6 files changed+450-78
themes: add editable palettes and the malicious theme
Modified file: web/src/app.css+43 additions−8 deletions
@@ -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;
Modified file: web/src/lib/LoginPanel.svelte+2 additions−2 deletions
@@ -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;
Modified file: web/src/lib/SettingsPanel.svelte+4 additions−54 deletions
@@ -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- theme
11+ messageSpacing
1512 } 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- <button
47- 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>
Added file: web/src/lib/ThemeSettings.svelte+269 additions
@@ -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 ThemeDocument
8+ } 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?.palette
42+ );
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 scheme
174+ <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">Opacity
189+ <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 lightness
206+ <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>
Modified file: web/src/lib/appearance.ts+31 additions−14 deletions
@@ -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 }
Added file: web/src/lib/custom-themes.ts+101 additions
@@ -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.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+}