atagen

malice

public
Star0
Watch0
e590609b63434b8b595b383f8124dea45191bd6e
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/ThemeSettings.svelte

c122e088beceatagen2026-10-25 02:37 UTC1
<script lang="ts">
c122e088beceatagen2026-10-25 02:37 UTC2
  import { onDestroy } from 'svelte';
445bf199a9cfatagen2026-10-25 02:56 UTC3
  import EmojiText from './EmojiText.svelte';
c122e088beceatagen2026-10-25 02:37 UTC4
  import { get } from 'svelte/store';
c122e088beceatagen2026-10-25 02:37 UTC5
  import { THEMES, accent, customThemes, theme, themePreview, type ThemeId } from './appearance';
c122e088beceatagen2026-10-25 02:37 UTC6
  import {
c122e088beceatagen2026-10-25 02:37 UTC7
    MALICIOUS_PALETTE, THEME_COLOURS, captureTheme, isThemeColour, readThemeFile,
c122e088beceatagen2026-10-25 02:37 UTC8
    themeDocument, themeStyle, type CustomTheme, type ThemeColour, type ThemeDocument
c122e088beceatagen2026-10-25 02:37 UTC9
  } from './custom-themes';
c122e088beceatagen2026-10-25 02:37 UTC10
c122e088beceatagen2026-10-25 02:37 UTC11
  let draft: ThemeDocument | null = null;
c122e088beceatagen2026-10-25 02:37 UTC12
  let editingId: CustomTheme['id'] | null = null;
c122e088beceatagen2026-10-25 02:37 UTC13
  let selectedColour: ThemeColour = 'bg';
c122e088beceatagen2026-10-25 02:37 UTC14
  let hexInput = '';
c122e088beceatagen2026-10-25 02:37 UTC15
  let previewApp = false;
c122e088beceatagen2026-10-25 02:37 UTC16
  let error = '';
c122e088beceatagen2026-10-25 02:37 UTC17
  let notice = '';
c122e088beceatagen2026-10-25 02:37 UTC18
  let fileInput: HTMLInputElement;
c122e088beceatagen2026-10-25 02:37 UTC19
c122e088beceatagen2026-10-25 02:37 UTC20
  $: selected = $customThemes.find((item) => item.id === $theme);
c122e088beceatagen2026-10-25 02:37 UTC21
  $: selectedName = selected?.name ?? THEMES.find((item) => item.id === $theme)?.label ?? 'Theme';
c122e088beceatagen2026-10-25 02:37 UTC22
  $: if (draft) hexInput = draft.colours[selectedColour];
c122e088beceatagen2026-10-25 02:37 UTC23
  $: themePreview.set(previewApp ? draft : null);
c122e088beceatagen2026-10-25 02:37 UTC24
c122e088beceatagen2026-10-25 02:37 UTC25
  onDestroy(() => themePreview.set(null));
c122e088beceatagen2026-10-25 02:37 UTC26
c122e088beceatagen2026-10-25 02:37 UTC27
  function chooseTheme(id: ThemeId): void {
c122e088beceatagen2026-10-25 02:37 UTC28
    draft = null;
c122e088beceatagen2026-10-25 02:37 UTC29
    previewApp = false;
c122e088beceatagen2026-10-25 02:37 UTC30
    themePreview.set(null);
c122e088beceatagen2026-10-25 02:37 UTC31
    accent.set(null);
c122e088beceatagen2026-10-25 02:37 UTC32
    theme.set(id);
c122e088beceatagen2026-10-25 02:37 UTC33
    error = '';
c122e088beceatagen2026-10-25 02:37 UTC34
    notice = '';
c122e088beceatagen2026-10-25 02:37 UTC35
  }
c122e088beceatagen2026-10-25 02:37 UTC36
c122e088beceatagen2026-10-25 02:37 UTC37
  function editTheme(asNew: boolean): void {
c122e088beceatagen2026-10-25 02:37 UTC38
    themePreview.set(null);
c122e088beceatagen2026-10-25 02:37 UTC39
    previewApp = false;
c122e088beceatagen2026-10-25 02:37 UTC40
    editingId = !asNew && selected ? selected.id : null;
c122e088beceatagen2026-10-25 02:37 UTC41
    draft = selected && !asNew ? themeDocument(selected) : captureTheme(
c122e088beceatagen2026-10-25 02:37 UTC42
      `${selectedName} custom`, $theme === 'malicious' ? MALICIOUS_PALETTE : selected?.palette
c122e088beceatagen2026-10-25 02:37 UTC43
    );
c122e088beceatagen2026-10-25 02:37 UTC44
    selectedColour = 'bg';
c122e088beceatagen2026-10-25 02:37 UTC45
    error = '';
c122e088beceatagen2026-10-25 02:37 UTC46
    notice = '';
c122e088beceatagen2026-10-25 02:37 UTC47
  }
c122e088beceatagen2026-10-25 02:37 UTC48
c122e088beceatagen2026-10-25 02:37 UTC49
  function persistThemes(items: CustomTheme[]): void {
c122e088beceatagen2026-10-25 02:37 UTC50
    localStorage.setItem('malice.customThemes', JSON.stringify(items));
c122e088beceatagen2026-10-25 02:37 UTC51
    customThemes.set(items);
c122e088beceatagen2026-10-25 02:37 UTC52
  }
c122e088beceatagen2026-10-25 02:37 UTC53
c122e088beceatagen2026-10-25 02:37 UTC54
  function saveTheme(doc: ThemeDocument, id: CustomTheme['id'] | null = null): void {
c122e088beceatagen2026-10-25 02:37 UTC55
    try {
c122e088beceatagen2026-10-25 02:37 UTC56
      const saved: CustomTheme = { ...themeDocument(doc), id: id ?? `custom:${crypto.randomUUID()}` };
c122e088beceatagen2026-10-25 02:37 UTC57
      const items = get(customThemes);
c122e088beceatagen2026-10-25 02:37 UTC58
      persistThemes(id ? items.map((item) => item.id === id ? saved : item) : [...items, saved]);
c122e088beceatagen2026-10-25 02:37 UTC59
      chooseTheme(saved.id);
c122e088beceatagen2026-10-25 02:37 UTC60
      notice = `${saved.name} saved on this device.`;
c122e088beceatagen2026-10-25 02:37 UTC61
    } catch {
c122e088beceatagen2026-10-25 02:37 UTC62
      error = 'Could not save the theme. Check that browser storage is available and has space.';
c122e088beceatagen2026-10-25 02:37 UTC63
    }
c122e088beceatagen2026-10-25 02:37 UTC64
  }
c122e088beceatagen2026-10-25 02:37 UTC65
c122e088beceatagen2026-10-25 02:37 UTC66
  function deleteTheme(): void {
c122e088beceatagen2026-10-25 02:37 UTC67
    if (!selected) return;
c122e088beceatagen2026-10-25 02:37 UTC68
    try {
c122e088beceatagen2026-10-25 02:37 UTC69
      persistThemes($customThemes.filter((item) => item.id !== selected?.id));
c122e088beceatagen2026-10-25 02:37 UTC70
      chooseTheme('dark');
c122e088beceatagen2026-10-25 02:37 UTC71
      notice = 'Custom theme deleted.';
c122e088beceatagen2026-10-25 02:37 UTC72
    } catch {
c122e088beceatagen2026-10-25 02:37 UTC73
      error = 'Could not delete the theme from browser storage.';
c122e088beceatagen2026-10-25 02:37 UTC74
    }
c122e088beceatagen2026-10-25 02:37 UTC75
  }
c122e088beceatagen2026-10-25 02:37 UTC76
c122e088beceatagen2026-10-25 02:37 UTC77
  function exportTheme(doc: ThemeDocument): void {
c122e088beceatagen2026-10-25 02:37 UTC78
    const blob = new Blob([JSON.stringify(themeDocument(doc), null, 2) + '\n'], { type: 'application/json' });
c122e088beceatagen2026-10-25 02:37 UTC79
    const url = URL.createObjectURL(blob);
c122e088beceatagen2026-10-25 02:37 UTC80
    const anchor = document.createElement('a');
c122e088beceatagen2026-10-25 02:37 UTC81
    anchor.href = url;
c122e088beceatagen2026-10-25 02:37 UTC82
    anchor.download = `${doc.name.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-|-$/g, '') || 'theme'}.malice-theme.json`;
c122e088beceatagen2026-10-25 02:37 UTC83
    anchor.click();
c122e088beceatagen2026-10-25 02:37 UTC84
    setTimeout(() => URL.revokeObjectURL(url), 1000);
c122e088beceatagen2026-10-25 02:37 UTC85
  }
c122e088beceatagen2026-10-25 02:37 UTC86
c122e088beceatagen2026-10-25 02:37 UTC87
  async function importTheme(event: Event): Promise<void> {
c122e088beceatagen2026-10-25 02:37 UTC88
    const input = event.currentTarget as HTMLInputElement;
c122e088beceatagen2026-10-25 02:37 UTC89
    const file = input.files?.[0];
c122e088beceatagen2026-10-25 02:37 UTC90
    input.value = '';
c122e088beceatagen2026-10-25 02:37 UTC91
    if (!file) return;
c122e088beceatagen2026-10-25 02:37 UTC92
    error = '';
c122e088beceatagen2026-10-25 02:37 UTC93
    notice = '';
c122e088beceatagen2026-10-25 02:37 UTC94
    try {
c122e088beceatagen2026-10-25 02:37 UTC95
      if (file.size > 65536) throw new Error('Theme files must be smaller than 64 KB.');
c122e088beceatagen2026-10-25 02:37 UTC96
      const imported = readThemeFile(await file.text());
c122e088beceatagen2026-10-25 02:37 UTC97
      draft = imported;
c122e088beceatagen2026-10-25 02:37 UTC98
      editingId = null;
c122e088beceatagen2026-10-25 02:37 UTC99
      previewApp = false;
c122e088beceatagen2026-10-25 02:37 UTC100
      selectedColour = 'bg';
c122e088beceatagen2026-10-25 02:37 UTC101
      notice = 'Theme loaded. Preview it and save to add it to your themes.';
c122e088beceatagen2026-10-25 02:37 UTC102
    } catch (cause) {
c122e088beceatagen2026-10-25 02:37 UTC103
      error = cause instanceof Error ? cause.message : 'Could not read the theme file.';
c122e088beceatagen2026-10-25 02:37 UTC104
    }
c122e088beceatagen2026-10-25 02:37 UTC105
  }
c122e088beceatagen2026-10-25 02:37 UTC106
c122e088beceatagen2026-10-25 02:37 UTC107
  function updateColour(value: string): void {
c122e088beceatagen2026-10-25 02:37 UTC108
    if (!draft || !isThemeColour(value)) return;
c122e088beceatagen2026-10-25 02:37 UTC109
    draft = { ...draft, colours: { ...draft.colours, [selectedColour]: value.toLowerCase() } };
c122e088beceatagen2026-10-25 02:37 UTC110
    error = '';
c122e088beceatagen2026-10-25 02:37 UTC111
  }
c122e088beceatagen2026-10-25 02:37 UTC112
c122e088beceatagen2026-10-25 02:37 UTC113
  function updateHex(): void {
c122e088beceatagen2026-10-25 02:37 UTC114
    if (!isThemeColour(hexInput)) {
c122e088beceatagen2026-10-25 02:37 UTC115
      error = 'Enter a hex colour with six digits, or eight digits including opacity.';
c122e088beceatagen2026-10-25 02:37 UTC116
      return;
c122e088beceatagen2026-10-25 02:37 UTC117
    }
c122e088beceatagen2026-10-25 02:37 UTC118
    updateColour(hexInput);
c122e088beceatagen2026-10-25 02:37 UTC119
  }
c122e088beceatagen2026-10-25 02:37 UTC120
c122e088beceatagen2026-10-25 02:37 UTC121
  function updateOpacity(value: string): void {
c122e088beceatagen2026-10-25 02:37 UTC122
    if (!draft) return;
c122e088beceatagen2026-10-25 02:37 UTC123
    const alpha = Math.round(Number(value) * 255 / 100);
c122e088beceatagen2026-10-25 02:37 UTC124
    updateColour(draft.colours[selectedColour].slice(0, 7) + (alpha === 255 ? '' : alpha.toString(16).padStart(2, '0')));
c122e088beceatagen2026-10-25 02:37 UTC125
  }
c122e088beceatagen2026-10-25 02:37 UTC126
c122e088beceatagen2026-10-25 02:37 UTC127
  function addToPalette(): void {
c122e088beceatagen2026-10-25 02:37 UTC128
    if (!draft || draft.palette.length >= 64) return;
c122e088beceatagen2026-10-25 02:37 UTC129
    const colour = draft.colours[selectedColour];
c122e088beceatagen2026-10-25 02:37 UTC130
    if (!draft.palette.includes(colour)) draft = { ...draft, palette: [...draft.palette, colour] };
c122e088beceatagen2026-10-25 02:37 UTC131
  }
c122e088beceatagen2026-10-25 02:37 UTC132
c122e088beceatagen2026-10-25 02:37 UTC133
  function removeFromPalette(): void {
c122e088beceatagen2026-10-25 02:37 UTC134
    if (draft) draft = { ...draft, palette: draft.palette.filter((colour) => colour !== draft?.colours[selectedColour]) };
c122e088beceatagen2026-10-25 02:37 UTC135
  }
c122e088beceatagen2026-10-25 02:37 UTC136
</script>
c122e088beceatagen2026-10-25 02:37 UTC137
c122e088beceatagen2026-10-25 02:37 UTC138
<section aria-label="Theme">
c122e088beceatagen2026-10-25 02:37 UTC139
  <h3 class="sec-h">Theme</h3>
c122e088beceatagen2026-10-25 02:37 UTC140
  <div class="themes" role="radiogroup" aria-label="Theme">
c122e088beceatagen2026-10-25 02:37 UTC141
    {#each THEMES as item (item.id)}
c122e088beceatagen2026-10-25 02:37 UTC142
      <button type="button" role="radio" aria-checked={$theme === item.id} class="theme-opt" class:active={$theme === item.id}
c122e088beceatagen2026-10-25 02:37 UTC143
        data-swatch={item.id} on:click={() => chooseTheme(item.id)}>
445bf199a9cfatagen2026-10-25 02:56 UTC144
        <span class="swatch" aria-hidden="true"></span><EmojiText text={item.label} />
c122e088beceatagen2026-10-25 02:37 UTC145
      </button>
c122e088beceatagen2026-10-25 02:37 UTC146
    {/each}
c122e088beceatagen2026-10-25 02:37 UTC147
    {#each $customThemes as item (item.id)}
c122e088beceatagen2026-10-25 02:37 UTC148
      <button type="button" role="radio" aria-checked={$theme === item.id} class="theme-opt" class:active={$theme === item.id}
c122e088beceatagen2026-10-25 02:37 UTC149
        on:click={() => chooseTheme(item.id)}>
c122e088beceatagen2026-10-25 02:37 UTC150
        <span class="swatch" style:background={item.colours.bg} style:border-color={item.colours.accent} aria-hidden="true"></span>
445bf199a9cfatagen2026-10-25 02:56 UTC151
        <span class="theme-name"><EmojiText text={item.name} /></span>
c122e088beceatagen2026-10-25 02:37 UTC152
      </button>
c122e088beceatagen2026-10-25 02:37 UTC153
    {/each}
c122e088beceatagen2026-10-25 02:37 UTC154
  </div>
c122e088beceatagen2026-10-25 02:37 UTC155
  <div class="toolbar">
c122e088beceatagen2026-10-25 02:37 UTC156
    <button type="button" class="ghost-btn" on:click={() => editTheme(true)}>Create theme</button>
c122e088beceatagen2026-10-25 02:37 UTC157
    {#if selected}<button type="button" class="ghost-btn" on:click={() => editTheme(false)}>Edit theme</button>{/if}
c122e088beceatagen2026-10-25 02:37 UTC158
    <button type="button" class="ghost-btn" on:click={() => fileInput.click()}>Import theme</button>
c122e088beceatagen2026-10-25 02:37 UTC159
    <button type="button" class="ghost-btn" disabled={draft !== null} on:click={() => exportTheme(captureTheme(selectedName, selected?.palette ?? ($theme === 'malicious' ? MALICIOUS_PALETTE : undefined)))}>Export theme</button>
c122e088beceatagen2026-10-25 02:37 UTC160
    {#if selected}<button type="button" class="ghost-btn danger" on:click={deleteTheme}>Delete theme</button>{/if}
c122e088beceatagen2026-10-25 02:37 UTC161
    <input class="file-input" type="file" accept=".json,application/json" aria-label="Import theme file" bind:this={fileInput} on:change={importTheme} />
c122e088beceatagen2026-10-25 02:37 UTC162
  </div>
c122e088beceatagen2026-10-25 02:37 UTC163
  {#if !draft}
c122e088beceatagen2026-10-25 02:37 UTC164
    <div class="accent-row">
c122e088beceatagen2026-10-25 02:37 UTC165
      <label for="accent-colour">Accent colour</label>
c122e088beceatagen2026-10-25 02:37 UTC166
      <input id="accent-colour" type="color" value={$accent ?? captureTheme(selectedName).colours.accent.slice(0, 7)} on:input={(event) => accent.set(event.currentTarget.value)} />
c122e088beceatagen2026-10-25 02:37 UTC167
      <button type="button" class="ghost-btn" disabled={$accent === null} on:click={() => accent.set(null)}>Use theme colour</button>
c122e088beceatagen2026-10-25 02:37 UTC168
    </div>
c122e088beceatagen2026-10-25 02:37 UTC169
  {/if}
c122e088beceatagen2026-10-25 02:37 UTC170
c122e088beceatagen2026-10-25 02:37 UTC171
  {#if draft}
c122e088beceatagen2026-10-25 02:37 UTC172
    <div class="editor">
c122e088beceatagen2026-10-25 02:37 UTC173
      <label class="field">Theme name<input type="text" maxlength="80" bind:value={draft.name} /></label>
c122e088beceatagen2026-10-25 02:37 UTC174
      <label class="field">Colour scheme
c122e088beceatagen2026-10-25 02:37 UTC175
        <select bind:value={draft.scheme}><option value="dark">Dark</option><option value="light">Light</option></select>
c122e088beceatagen2026-10-25 02:37 UTC176
      </label>
c122e088beceatagen2026-10-25 02:37 UTC177
      <div class="colour-roles" role="radiogroup" aria-label="Colour to edit">
c122e088beceatagen2026-10-25 02:37 UTC178
        {#each THEME_COLOURS as [key, label] (key)}
c122e088beceatagen2026-10-25 02:37 UTC179
          <button type="button" role="radio" aria-checked={selectedColour === key} class="role-opt" class:active={selectedColour === key}
c122e088beceatagen2026-10-25 02:37 UTC180
            on:click={() => selectedColour = key}>
c122e088beceatagen2026-10-25 02:37 UTC181
            <span class="swatch" style:background={draft.colours[key]} aria-hidden="true"></span>{label}
c122e088beceatagen2026-10-25 02:37 UTC182
          </button>
c122e088beceatagen2026-10-25 02:37 UTC183
        {/each}
c122e088beceatagen2026-10-25 02:37 UTC184
      </div>
c122e088beceatagen2026-10-25 02:37 UTC185
      <div class="colour-edit">
c122e088beceatagen2026-10-25 02:37 UTC186
        <input type="color" aria-label="Pick colour" value={draft.colours[selectedColour].slice(0, 7)}
c122e088beceatagen2026-10-25 02:37 UTC187
          on:input={(event) => updateColour(event.currentTarget.value + draft!.colours[selectedColour].slice(7))} />
c122e088beceatagen2026-10-25 02:37 UTC188
        <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>
c122e088beceatagen2026-10-25 02:37 UTC189
        <label class="opacity-field">Opacity
c122e088beceatagen2026-10-25 02:37 UTC190
          <input type="range" min="0" max="100" step="1"
c122e088beceatagen2026-10-25 02:37 UTC191
            value={Math.round(parseInt(draft.colours[selectedColour].slice(7) || 'ff', 16) / 255 * 100)}
c122e088beceatagen2026-10-25 02:37 UTC192
            on:input={(event) => updateOpacity(event.currentTarget.value)} />
c122e088beceatagen2026-10-25 02:37 UTC193
        </label>
c122e088beceatagen2026-10-25 02:37 UTC194
      </div>
c122e088beceatagen2026-10-25 02:37 UTC195
      <h4 class="sec-h">Palette</h4>
c122e088beceatagen2026-10-25 02:37 UTC196
      <div class="palette" aria-label="Theme palette">
c122e088beceatagen2026-10-25 02:37 UTC197
        {#each draft.palette as colour, index (`${index}-${colour}`)}
c122e088beceatagen2026-10-25 02:37 UTC198
          <button type="button" class="palette-colour" style:background={colour} title={colour} aria-label={`Use ${colour}`}
c122e088beceatagen2026-10-25 02:37 UTC199
            on:click={() => updateColour(colour)}></button>
c122e088beceatagen2026-10-25 02:37 UTC200
        {/each}
c122e088beceatagen2026-10-25 02:37 UTC201
      </div>
c122e088beceatagen2026-10-25 02:37 UTC202
      <div class="toolbar">
c122e088beceatagen2026-10-25 02:37 UTC203
        <button type="button" class="ghost-btn" disabled={draft.palette.length >= 64 || draft.palette.includes(draft.colours[selectedColour])} on:click={addToPalette}>Add colour</button>
c122e088beceatagen2026-10-25 02:37 UTC204
        <button type="button" class="ghost-btn" disabled={!draft.palette.includes(draft.colours[selectedColour])} on:click={removeFromPalette}>Remove colour</button>
c122e088beceatagen2026-10-25 02:37 UTC205
      </div>
c122e088beceatagen2026-10-25 02:37 UTC206
      <label class="field">Sender name lightness
c122e088beceatagen2026-10-25 02:37 UTC207
        <input type="range" min="0" max="100" step="1" bind:value={draft.nameLightness} />
c122e088beceatagen2026-10-25 02:37 UTC208
      </label>
c122e088beceatagen2026-10-25 02:37 UTC209
      <div class="theme-preview" style={themeStyle(draft)} aria-label="Theme preview">
445bf199a9cfatagen2026-10-25 02:56 UTC210
        <div class="preview-heading"><span><EmojiText text={draft.name || 'Your theme'} /></span><span class="preview-muted">just now</span></div>
c122e088beceatagen2026-10-25 02:37 UTC211
        <p>A little room to make your own. <span class="preview-link">A shared link</span></p>
c122e088beceatagen2026-10-25 02:37 UTC212
        <div class="preview-status"><span class="preview-ok">Connected</span><span class="preview-warn">Warning</span><span class="preview-error">Error</span></div>
c122e088beceatagen2026-10-25 02:37 UTC213
        <div class="preview-composer"><span class="preview-muted">Write a message…</span><span class="primary-btn" aria-hidden="true">Send</span></div>
c122e088beceatagen2026-10-25 02:37 UTC214
      </div>
c122e088beceatagen2026-10-25 02:37 UTC215
      <label class="preview-toggle"><input type="checkbox" bind:checked={previewApp} />Preview across the app</label>
c122e088beceatagen2026-10-25 02:37 UTC216
      <div class="toolbar">
c122e088beceatagen2026-10-25 02:37 UTC217
        <button type="button" class="primary-btn" disabled={!draft.name.trim() || !isThemeColour(hexInput)} on:click={() => draft && saveTheme(draft, editingId)}>Save theme</button>
c122e088beceatagen2026-10-25 02:37 UTC218
        {#if editingId}<button type="button" class="ghost-btn" disabled={!draft.name.trim() || !isThemeColour(hexInput)} on:click={() => draft && saveTheme(draft)}>Save as new</button>{/if}
c122e088beceatagen2026-10-25 02:37 UTC219
        <button type="button" class="ghost-btn" on:click={() => draft && exportTheme(draft)}>Export draft</button>
c122e088beceatagen2026-10-25 02:37 UTC220
        <button type="button" class="ghost-btn" on:click={() => { draft = null; previewApp = false; error = ''; notice = ''; }}>Cancel</button>
c122e088beceatagen2026-10-25 02:37 UTC221
      </div>
c122e088beceatagen2026-10-25 02:37 UTC222
    </div>
c122e088beceatagen2026-10-25 02:37 UTC223
  {/if}
445bf199a9cfatagen2026-10-25 02:56 UTC224
  {#if error}<p class="theme-error" role="alert"><EmojiText text={error} /></p>{/if}
445bf199a9cfatagen2026-10-25 02:56 UTC225
  {#if notice}<p class="theme-notice" role="status"><EmojiText text={notice} /></p>{/if}
c122e088beceatagen2026-10-25 02:37 UTC226
</section>
c122e088beceatagen2026-10-25 02:37 UTC227
c122e088beceatagen2026-10-25 02:37 UTC228
<style>
c122e088beceatagen2026-10-25 02:37 UTC229
  .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 6px; }
c122e088beceatagen2026-10-25 02:37 UTC230
  .themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(130px, 100%), 1fr)); gap: 6px; }
c122e088beceatagen2026-10-25 02:37 UTC231
  .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; }
c122e088beceatagen2026-10-25 02:37 UTC232
  .theme-opt.active, .role-opt.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
c122e088beceatagen2026-10-25 02:37 UTC233
  .theme-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
c122e088beceatagen2026-10-25 02:37 UTC234
  .swatch { flex: none; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border); }
c122e088beceatagen2026-10-25 02:37 UTC235
  [data-swatch='system'] .swatch { background: linear-gradient(135deg, #111218 50%, #f7f7fa 50%); }
c122e088beceatagen2026-10-25 02:37 UTC236
  [data-swatch='dark'] .swatch { background: #111218; }
c122e088beceatagen2026-10-25 02:37 UTC237
  [data-swatch='light'] .swatch { background: #f7f7fa; }
c122e088beceatagen2026-10-25 02:37 UTC238
  [data-swatch='black'] .swatch { background: #000; }
c122e088beceatagen2026-10-25 02:37 UTC239
  [data-swatch='nord'] .swatch { background: #2e3440; }
c122e088beceatagen2026-10-25 02:37 UTC240
  [data-swatch='malicious'] .swatch { background: #1b2021; border-color: #5abfc9; box-shadow: inset -6px 0 #ffb852; }
c122e088beceatagen2026-10-25 02:37 UTC241
  .toolbar { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
c122e088beceatagen2026-10-25 02:37 UTC242
  .file-input { display: none; }
c122e088beceatagen2026-10-25 02:37 UTC243
  .accent-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; font-size: 13px; }
c122e088beceatagen2026-10-25 02:37 UTC244
  .accent-row label { flex: 1; }
c122e088beceatagen2026-10-25 02:37 UTC245
  input[type='color'] { width: 40px; height: 34px; padding: 2px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--panel); cursor: pointer; }
c122e088beceatagen2026-10-25 02:37 UTC246
  .editor { margin-top: 14px; padding: 12px; border: 1px solid var(--border); border-radius: var(--r-md); }
c122e088beceatagen2026-10-25 02:37 UTC247
  .field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); margin-bottom: 12px; }
c122e088beceatagen2026-10-25 02:37 UTC248
  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; }
c122e088beceatagen2026-10-25 02:37 UTC249
  .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; }
c122e088beceatagen2026-10-25 02:37 UTC250
  .colour-edit { display: flex; align-items: end; flex-wrap: wrap; gap: 10px; margin: 14px 0; }
c122e088beceatagen2026-10-25 02:37 UTC251
  .hex-field, .opacity-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
c122e088beceatagen2026-10-25 02:37 UTC252
  .hex-field { flex: 1; min-width: 100px; }
c122e088beceatagen2026-10-25 02:37 UTC253
  .opacity-field { flex: 1; min-width: 100px; }
c122e088beceatagen2026-10-25 02:37 UTC254
  input[type='range'] { width: 100%; accent-color: var(--accent); }
c122e088beceatagen2026-10-25 02:37 UTC255
  .palette { display: flex; flex-wrap: wrap; gap: 6px; }
c122e088beceatagen2026-10-25 02:37 UTC256
  .palette-colour { width: 28px; height: 28px; border: 1px solid var(--border); border-radius: var(--r-sm); cursor: pointer; }
c122e088beceatagen2026-10-25 02:37 UTC257
  .toolbar + .field { margin-top: 14px; }
c122e088beceatagen2026-10-25 02:37 UTC258
  .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; }
c122e088beceatagen2026-10-25 02:37 UTC259
  .preview-heading { display: flex; gap: 10px; flex-wrap: wrap; font-weight: 600; }
c122e088beceatagen2026-10-25 02:37 UTC260
  .preview-muted { color: var(--muted); font-size: 12px; font-weight: 400; }
c122e088beceatagen2026-10-25 02:37 UTC261
  .preview-link { color: var(--accent); }
c122e088beceatagen2026-10-25 02:37 UTC262
  .preview-status { display: flex; gap: 10px; flex-wrap: wrap; }
c122e088beceatagen2026-10-25 02:37 UTC263
  .preview-ok { color: var(--ok-text); }
c122e088beceatagen2026-10-25 02:37 UTC264
  .preview-warn { color: var(--warn-text); background: var(--warn-bg); }
c122e088beceatagen2026-10-25 02:37 UTC265
  .preview-error { color: var(--danger-text); background: var(--danger-bg); }
c122e088beceatagen2026-10-25 02:37 UTC266
  .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); }
c122e088beceatagen2026-10-25 02:37 UTC267
  .preview-toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; margin-top: 12px; }
c122e088beceatagen2026-10-25 02:37 UTC268
  .theme-error { color: var(--danger-text); background: var(--danger-bg); padding: 8px; border-radius: var(--r-sm); }
c122e088beceatagen2026-10-25 02:37 UTC269
  .theme-notice { color: var(--muted); font-size: 13px; }
c122e088beceatagen2026-10-25 02:37 UTC270
</style>