atagen

malice

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

Blame: web/src/lib/ReactionPicker.svelte

544a170ead2catagen2026-10-20 11:47 UTC1
<script lang="ts">
445bf199a9cfatagen2026-10-25 02:56 UTC2
  import EmojiText from './EmojiText.svelte';
5059ed753f8batagen2026-10-27 07:20 UTC3
  import { onMount, tick } from 'svelte';
ecb8598b38afatagen2026-10-22 09:19 UTC4
  import { mediaUrl, messageMediaUrl } from './api';
544a170ead2catagen2026-10-20 11:47 UTC5
  import Emoji from './Emoji.svelte';
544a170ead2catagen2026-10-20 11:47 UTC6
  import { loadEmoji, searchEmoji } from './emoji';
544a170ead2catagen2026-10-20 11:47 UTC7
  import type { EmojiGroup } from './emoji';
5059ed753f8batagen2026-10-27 07:20 UTC8
  import type { ImagePack, PackImage, QuickEntry, QuickSeparator } from './types';
132eeceeb362atagen2026-10-25 03:34 UTC9
  import { hasUsage } from './packs';
5059ed753f8batagen2026-10-27 07:20 UTC10
  import { isSeparator, normaliseQuick, sameQuick } from './quick-reactions-model';
5059ed753f8batagen2026-10-27 07:20 UTC11
  import { flipAfter, reorder } from './reorder';
5059ed753f8batagen2026-10-27 07:20 UTC12
  import { moveBy, moveToSlot } from './reorder-model';
544a170ead2catagen2026-10-20 11:47 UTC13
544a170ead2catagen2026-10-20 11:47 UTC14
  export let packs: ImagePack[];
5059ed753f8batagen2026-10-27 07:20 UTC15
  export let quick: QuickEntry[];
544a170ead2catagen2026-10-20 11:47 UTC16
  export let onPick: (key: string, shortcode: string | null) => void;
5059ed753f8batagen2026-10-27 07:20 UTC17
  export let onQuickChange: (list: QuickEntry[]) => void;
544a170ead2catagen2026-10-20 11:47 UTC18
  export let onClose: () => void;
df3c0f2b3da4atagen2026-10-22 13:07 UTC19
  export let embedded = false;
544a170ead2catagen2026-10-20 11:47 UTC20
544a170ead2catagen2026-10-20 11:47 UTC21
  let root: HTMLDivElement;
544a170ead2catagen2026-10-20 11:47 UTC22
  let input: HTMLInputElement;
5059ed753f8batagen2026-10-27 07:20 UTC23
  let row: HTMLDivElement;
544a170ead2catagen2026-10-20 11:47 UTC24
  let query = '';
df3c0f2b3da4atagen2026-10-22 13:07 UTC25
  let customising = embedded;
544a170ead2catagen2026-10-20 11:47 UTC26
  let groups: EmojiGroup[] | null = null;
544a170ead2catagen2026-10-20 11:47 UTC27
  let loadFailed = false;
5059ed753f8batagen2026-10-27 07:20 UTC28
  let draftSeparator: QuickSeparator | null = null;
5059ed753f8batagen2026-10-27 07:20 UTC29
  const separatorIds = new WeakMap<QuickSeparator, number>();
5059ed753f8batagen2026-10-27 07:20 UTC30
  let separatorCount = 0;
544a170ead2catagen2026-10-20 11:47 UTC31
544a170ead2catagen2026-10-20 11:47 UTC32
  onMount(() => {
df3c0f2b3da4atagen2026-10-22 13:07 UTC33
    if (!embedded) input.focus();
544a170ead2catagen2026-10-20 11:47 UTC34
    loadEmoji().then(
544a170ead2catagen2026-10-20 11:47 UTC35
      (g) => (groups = g),
544a170ead2catagen2026-10-20 11:47 UTC36
      () => (loadFailed = true)
544a170ead2catagen2026-10-20 11:47 UTC37
    );
544a170ead2catagen2026-10-20 11:47 UTC38
  });
544a170ead2catagen2026-10-20 11:47 UTC39
544a170ead2catagen2026-10-20 11:47 UTC40
  $: q = query.trim().toLowerCase().replace(/^:|:$/g, '');
544a170ead2catagen2026-10-20 11:47 UTC41
  $: filteredPacks = packs
544a170ead2catagen2026-10-20 11:47 UTC42
    .map((pack) => ({ pack, images: pack.images.filter((i) => imageMatches(i, q)) }))
544a170ead2catagen2026-10-20 11:47 UTC43
    .filter((p) => p.images.length > 0);
544a170ead2catagen2026-10-20 11:47 UTC44
  $: filteredGroups = searchEmoji(groups ?? [], q);
5059ed753f8batagen2026-10-27 07:20 UTC45
  $: working = draftSeparator ? [...quick, draftSeparator] : quick;
5059ed753f8batagen2026-10-27 07:20 UTC46
  $: canSeparate = working.length > 0 && !isSeparator(working[working.length - 1]);
544a170ead2catagen2026-10-20 11:47 UTC47
  $: nothingFound = q !== '' && groups !== null && filteredPacks.length === 0 && filteredGroups.length === 0;
544a170ead2catagen2026-10-20 11:47 UTC48
544a170ead2catagen2026-10-20 11:47 UTC49
  function imageMatches(image: PackImage, needle: string): boolean {
132eeceeb362atagen2026-10-25 03:34 UTC50
    if (!hasUsage(image, 'Emoticon')) return false;
544a170ead2catagen2026-10-20 11:47 UTC51
    if (!needle) return true;
544a170ead2catagen2026-10-20 11:47 UTC52
    return image.shortcode.toLowerCase().includes(needle) || (image.body ?? '').toLowerCase().includes(needle);
544a170ead2catagen2026-10-20 11:47 UTC53
  }
544a170ead2catagen2026-10-20 11:47 UTC54
ecb8598b38afatagen2026-10-22 09:19 UTC55
  function packName(pack: ImagePack): string {
ecb8598b38afatagen2026-10-22 09:19 UTC56
    return pack.display_name ?? 'Emoji pack';
ecb8598b38afatagen2026-10-22 09:19 UTC57
  }
ecb8598b38afatagen2026-10-22 09:19 UTC58
544a170ead2catagen2026-10-20 11:47 UTC59
  function isQuick(key: string): boolean {
5059ed753f8batagen2026-10-27 07:20 UTC60
    return quick.some((r) => !isSeparator(r) && r.key === key);
5059ed753f8batagen2026-10-27 07:20 UTC61
  }
5059ed753f8batagen2026-10-27 07:20 UTC62
5059ed753f8batagen2026-10-27 07:20 UTC63
  function commit(list: QuickEntry[]): void {
5059ed753f8batagen2026-10-27 07:20 UTC64
    const next = normaliseQuick(list);
5059ed753f8batagen2026-10-27 07:20 UTC65
    draftSeparator = null;
5059ed753f8batagen2026-10-27 07:20 UTC66
    if (!sameQuick(next, quick)) onQuickChange(next);
544a170ead2catagen2026-10-20 11:47 UTC67
  }
544a170ead2catagen2026-10-20 11:47 UTC68
544a170ead2catagen2026-10-20 11:47 UTC69
  function choose(key: string, shortcode: string | null): void {
544a170ead2catagen2026-10-20 11:47 UTC70
    if (!customising) {
544a170ead2catagen2026-10-20 11:47 UTC71
      onPick(key, shortcode);
544a170ead2catagen2026-10-20 11:47 UTC72
      return;
544a170ead2catagen2026-10-20 11:47 UTC73
    }
5059ed753f8batagen2026-10-27 07:20 UTC74
    if (isQuick(key)) commit(working.filter((r) => isSeparator(r) || r.key !== key));
5059ed753f8batagen2026-10-27 07:20 UTC75
    else commit([...working, { key, shortcode }]);
5059ed753f8batagen2026-10-27 07:20 UTC76
  }
5059ed753f8batagen2026-10-27 07:20 UTC77
5059ed753f8batagen2026-10-27 07:20 UTC78
  function entryKey(entry: QuickEntry): string {
5059ed753f8batagen2026-10-27 07:20 UTC79
    if (!isSeparator(entry)) return entry.key;
5059ed753f8batagen2026-10-27 07:20 UTC80
    let id = separatorIds.get(entry);
5059ed753f8batagen2026-10-27 07:20 UTC81
    if (id === undefined) separatorIds.set(entry, (id = separatorCount++));
5059ed753f8batagen2026-10-27 07:20 UTC82
    return `separator:${id}`;
5059ed753f8batagen2026-10-27 07:20 UTC83
  }
5059ed753f8batagen2026-10-27 07:20 UTC84
5059ed753f8batagen2026-10-27 07:20 UTC85
  function entryLabel(entry: QuickEntry): string {
5059ed753f8batagen2026-10-27 07:20 UTC86
    if (isSeparator(entry)) return 'separator';
5059ed753f8batagen2026-10-27 07:20 UTC87
    return entry.shortcode ? `:${entry.shortcode}:` : entry.key;
5059ed753f8batagen2026-10-27 07:20 UTC88
  }
5059ed753f8batagen2026-10-27 07:20 UTC89
5059ed753f8batagen2026-10-27 07:20 UTC90
  function remove(index: number): void {
5059ed753f8batagen2026-10-27 07:20 UTC91
    commit(working.filter((_, i) => i !== index));
5059ed753f8batagen2026-10-27 07:20 UTC92
  }
5059ed753f8batagen2026-10-27 07:20 UTC93
5059ed753f8batagen2026-10-27 07:20 UTC94
  function moveItem(from: number, slot: number): void {
5059ed753f8batagen2026-10-27 07:20 UTC95
    const next = moveToSlot(working, from, slot);
5059ed753f8batagen2026-10-27 07:20 UTC96
    if (next !== working) commit(next);
544a170ead2catagen2026-10-20 11:47 UTC97
  }
544a170ead2catagen2026-10-20 11:47 UTC98
5059ed753f8batagen2026-10-27 07:20 UTC99
  async function onChipKeydown(e: KeyboardEvent, index: number, entry: QuickEntry): Promise<void> {
5059ed753f8batagen2026-10-27 07:20 UTC100
    if (!(e.altKey || e.shiftKey) || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
5059ed753f8batagen2026-10-27 07:20 UTC101
    e.preventDefault();
5059ed753f8batagen2026-10-27 07:20 UTC102
    const next = moveBy(working, index, e.key === 'ArrowLeft' ? -1 : 1);
5059ed753f8batagen2026-10-27 07:20 UTC103
    if (next === working) return;
5059ed753f8batagen2026-10-27 07:20 UTC104
    const id = entryKey(entry);
5059ed753f8batagen2026-10-27 07:20 UTC105
    void flipAfter(row, () => commit(next));
5059ed753f8batagen2026-10-27 07:20 UTC106
    await tick();
5059ed753f8batagen2026-10-27 07:20 UTC107
    const grabs = row.querySelectorAll<HTMLElement>('.grab');
5059ed753f8batagen2026-10-27 07:20 UTC108
    (row.querySelector<HTMLElement>(`[data-reorder="${CSS.escape(id)}"] .grab`) ?? grabs[Math.min(index, grabs.length - 1)])?.focus();
544a170ead2catagen2026-10-20 11:47 UTC109
  }
544a170ead2catagen2026-10-20 11:47 UTC110
544a170ead2catagen2026-10-20 11:47 UTC111
  function onKeydown(e: KeyboardEvent): void {
544a170ead2catagen2026-10-20 11:47 UTC112
    if (e.key !== 'Escape') return;
544a170ead2catagen2026-10-20 11:47 UTC113
    e.stopPropagation();
544a170ead2catagen2026-10-20 11:47 UTC114
    onClose();
544a170ead2catagen2026-10-20 11:47 UTC115
  }
544a170ead2catagen2026-10-20 11:47 UTC116
544a170ead2catagen2026-10-20 11:47 UTC117
  function closeOutside(e: PointerEvent): void {
df3c0f2b3da4atagen2026-10-22 13:07 UTC118
    if (!embedded && !root.contains(e.target as Node)) onClose();
544a170ead2catagen2026-10-20 11:47 UTC119
  }
544a170ead2catagen2026-10-20 11:47 UTC120
</script>
544a170ead2catagen2026-10-20 11:47 UTC121
544a170ead2catagen2026-10-20 11:47 UTC122
<svelte:window on:pointerdown={closeOutside} />
544a170ead2catagen2026-10-20 11:47 UTC123
544a170ead2catagen2026-10-20 11:47 UTC124
<div
544a170ead2catagen2026-10-20 11:47 UTC125
  class="picker"
df3c0f2b3da4atagen2026-10-22 13:07 UTC126
  class:embedded
544a170ead2catagen2026-10-20 11:47 UTC127
  role="dialog"
544a170ead2catagen2026-10-20 11:47 UTC128
  tabindex="-1"
544a170ead2catagen2026-10-20 11:47 UTC129
  aria-label={customising ? 'Customise quick reactions' : 'Pick a reaction'}
544a170ead2catagen2026-10-20 11:47 UTC130
  bind:this={root}
544a170ead2catagen2026-10-20 11:47 UTC131
  on:keydown={onKeydown}
544a170ead2catagen2026-10-20 11:47 UTC132
>
544a170ead2catagen2026-10-20 11:47 UTC133
  <input
544a170ead2catagen2026-10-20 11:47 UTC134
    class="search"
544a170ead2catagen2026-10-20 11:47 UTC135
    type="search"
544a170ead2catagen2026-10-20 11:47 UTC136
    placeholder="Search emoji"
544a170ead2catagen2026-10-20 11:47 UTC137
    aria-label="Search emoji"
544a170ead2catagen2026-10-20 11:47 UTC138
    autocomplete="off"
544a170ead2catagen2026-10-20 11:47 UTC139
    bind:this={input}
544a170ead2catagen2026-10-20 11:47 UTC140
    bind:value={query}
544a170ead2catagen2026-10-20 11:47 UTC141
  />
544a170ead2catagen2026-10-20 11:47 UTC142
544a170ead2catagen2026-10-20 11:47 UTC143
  {#if customising}
544a170ead2catagen2026-10-20 11:47 UTC144
    <div class="quick" role="group" aria-label="Quick reactions">
544a170ead2catagen2026-10-20 11:47 UTC145
      <div class="quick-head">
544a170ead2catagen2026-10-20 11:47 UTC146
        <span>Quick reactions</span>
5059ed753f8batagen2026-10-27 07:20 UTC147
        <button type="button" class="add-sep" disabled={!canSeparate} on:click={() => (draftSeparator = { separator: true })}>
5059ed753f8batagen2026-10-27 07:20 UTC148
          Add separator
5059ed753f8batagen2026-10-27 07:20 UTC149
        </button>
544a170ead2catagen2026-10-20 11:47 UTC150
      </div>
5059ed753f8batagen2026-10-27 07:20 UTC151
      <div class="quick-row" bind:this={row} use:reorder={{ onMove: moveItem }}>
5059ed753f8batagen2026-10-27 07:20 UTC152
        {#each working as entry, i (entryKey(entry))}
5059ed753f8batagen2026-10-27 07:20 UTC153
          <span class="quick-item" class:separator={isSeparator(entry)} data-reorder={entryKey(entry)}>
5059ed753f8batagen2026-10-27 07:20 UTC154
            <button
5059ed753f8batagen2026-10-27 07:20 UTC155
              type="button"
5059ed753f8batagen2026-10-27 07:20 UTC156
              class="grab"
5059ed753f8batagen2026-10-27 07:20 UTC157
              data-reorder-handle
5059ed753f8batagen2026-10-27 07:20 UTC158
              aria-label={`Move ${entryLabel(entry)}, ${i + 1} of ${working.length}`}
5059ed753f8batagen2026-10-27 07:20 UTC159
              on:keydown={(e) => onChipKeydown(e, i, entry)}
5059ed753f8batagen2026-10-27 07:20 UTC160
            >
5059ed753f8batagen2026-10-27 07:20 UTC161
              {#if isSeparator(entry)}
5059ed753f8batagen2026-10-27 07:20 UTC162
                <span class="bar"></span>
5059ed753f8batagen2026-10-27 07:20 UTC163
              {:else if entry.key.startsWith('mxc://')}
5059ed753f8batagen2026-10-27 07:20 UTC164
                <img src={mediaUrl(entry.key, 64) ?? ''} alt="" draggable="false" />
5059ed753f8batagen2026-10-27 07:20 UTC165
              {:else}
5059ed753f8batagen2026-10-27 07:20 UTC166
                <Emoji emoji={entry.key} size={18} />
5059ed753f8batagen2026-10-27 07:20 UTC167
              {/if}
5059ed753f8batagen2026-10-27 07:20 UTC168
            </button>
544a170ead2catagen2026-10-20 11:47 UTC169
            <button
544a170ead2catagen2026-10-20 11:47 UTC170
              type="button"
544a170ead2catagen2026-10-20 11:47 UTC171
              class="remove"
5059ed753f8batagen2026-10-27 07:20 UTC172
              aria-label={`Remove ${entryLabel(entry)} from quick reactions`}
5059ed753f8batagen2026-10-27 07:20 UTC173
              on:click={() => remove(i)}
544a170ead2catagen2026-10-20 11:47 UTC174
            >
544a170ead2catagen2026-10-20 11:47 UTC175
              ×
544a170ead2catagen2026-10-20 11:47 UTC176
            </button>
544a170ead2catagen2026-10-20 11:47 UTC177
          </span>
544a170ead2catagen2026-10-20 11:47 UTC178
        {:else}
544a170ead2catagen2026-10-20 11:47 UTC179
          <span class="muted">None. Pick emoji below to add them.</span>
544a170ead2catagen2026-10-20 11:47 UTC180
        {/each}
544a170ead2catagen2026-10-20 11:47 UTC181
      </div>
544a170ead2catagen2026-10-20 11:47 UTC182
    </div>
544a170ead2catagen2026-10-20 11:47 UTC183
  {/if}
544a170ead2catagen2026-10-20 11:47 UTC184
544a170ead2catagen2026-10-20 11:47 UTC185
  <div class="scroll" aria-busy={groups === null && !loadFailed}>
544a170ead2catagen2026-10-20 11:47 UTC186
    {#each filteredPacks as { pack, images } (pack.id)}
ecb8598b38afatagen2026-10-22 09:19 UTC187
      <section class="group" aria-label={packName(pack)}>
445bf199a9cfatagen2026-10-25 02:56 UTC188
        <h3><EmojiText text={packName(pack)} /></h3>
544a170ead2catagen2026-10-20 11:47 UTC189
        <div class="grid">
ecb8598b38afatagen2026-10-22 09:19 UTC190
          {#each images as image (image.reaction_key + image.shortcode)}
544a170ead2catagen2026-10-20 11:47 UTC191
            <button
544a170ead2catagen2026-10-20 11:47 UTC192
              type="button"
544a170ead2catagen2026-10-20 11:47 UTC193
              class="cell"
ecb8598b38afatagen2026-10-22 09:19 UTC194
              class:chosen={customising && isQuick(image.reaction_key)}
544a170ead2catagen2026-10-20 11:47 UTC195
              title={`:${image.shortcode}:`}
544a170ead2catagen2026-10-20 11:47 UTC196
              aria-label={`:${image.shortcode}:`}
ecb8598b38afatagen2026-10-22 09:19 UTC197
              aria-pressed={customising ? isQuick(image.reaction_key) : undefined}
ecb8598b38afatagen2026-10-22 09:19 UTC198
              on:click={() => choose(image.reaction_key, image.shortcode)}
544a170ead2catagen2026-10-20 11:47 UTC199
            >
ecb8598b38afatagen2026-10-22 09:19 UTC200
              <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt="" loading="lazy" />
544a170ead2catagen2026-10-20 11:47 UTC201
            </button>
544a170ead2catagen2026-10-20 11:47 UTC202
          {/each}
544a170ead2catagen2026-10-20 11:47 UTC203
        </div>
544a170ead2catagen2026-10-20 11:47 UTC204
      </section>
544a170ead2catagen2026-10-20 11:47 UTC205
    {/each}
544a170ead2catagen2026-10-20 11:47 UTC206
544a170ead2catagen2026-10-20 11:47 UTC207
    {#if loadFailed}
544a170ead2catagen2026-10-20 11:47 UTC208
      <p class="status" role="alert">Could not load the emoji list.</p>
544a170ead2catagen2026-10-20 11:47 UTC209
    {:else if groups === null}
544a170ead2catagen2026-10-20 11:47 UTC210
      <p class="status" role="status">Loading emoji…</p>
544a170ead2catagen2026-10-20 11:47 UTC211
    {:else}
544a170ead2catagen2026-10-20 11:47 UTC212
      {#each filteredGroups as g (g.name)}
544a170ead2catagen2026-10-20 11:47 UTC213
        <section class="group" aria-label={g.name}>
544a170ead2catagen2026-10-20 11:47 UTC214
          <h3>{g.name}</h3>
544a170ead2catagen2026-10-20 11:47 UTC215
          <div class="grid">
544a170ead2catagen2026-10-20 11:47 UTC216
            {#each g.emojis as e (e.emoji)}
544a170ead2catagen2026-10-20 11:47 UTC217
              <button
544a170ead2catagen2026-10-20 11:47 UTC218
                type="button"
544a170ead2catagen2026-10-20 11:47 UTC219
                class="cell"
544a170ead2catagen2026-10-20 11:47 UTC220
                class:chosen={customising && isQuick(e.emoji)}
544a170ead2catagen2026-10-20 11:47 UTC221
                title={e.label}
544a170ead2catagen2026-10-20 11:47 UTC222
                aria-label={e.label}
544a170ead2catagen2026-10-20 11:47 UTC223
                aria-pressed={customising ? isQuick(e.emoji) : undefined}
544a170ead2catagen2026-10-20 11:47 UTC224
                on:click={() => choose(e.emoji, e.shortcodes[0] ?? null)}
544a170ead2catagen2026-10-20 11:47 UTC225
              >
d07bca5da4aeatagen2026-10-26 03:48 UTC226
                <Emoji emoji={e.emoji} size={24} lazy />
544a170ead2catagen2026-10-20 11:47 UTC227
              </button>
544a170ead2catagen2026-10-20 11:47 UTC228
            {/each}
544a170ead2catagen2026-10-20 11:47 UTC229
          </div>
544a170ead2catagen2026-10-20 11:47 UTC230
        </section>
544a170ead2catagen2026-10-20 11:47 UTC231
      {/each}
544a170ead2catagen2026-10-20 11:47 UTC232
    {/if}
544a170ead2catagen2026-10-20 11:47 UTC233
    {#if nothingFound}
445bf199a9cfatagen2026-10-25 02:56 UTC234
      <p class="status" role="status">No emoji match "<EmojiText text={query.trim()} />".</p>
544a170ead2catagen2026-10-20 11:47 UTC235
    {/if}
544a170ead2catagen2026-10-20 11:47 UTC236
  </div>
544a170ead2catagen2026-10-20 11:47 UTC237
05a48e9e7942atagen2026-10-24 09:32 UTC238
  {#if !embedded}
05a48e9e7942atagen2026-10-24 09:32 UTC239
    <div class="foot">
df3c0f2b3da4atagen2026-10-22 13:07 UTC240
      <button type="button" class="mode" aria-pressed={customising} on:click={() => (customising = !customising)}>
df3c0f2b3da4atagen2026-10-22 13:07 UTC241
        {customising ? 'Done' : 'Customise quick reactions'}
df3c0f2b3da4atagen2026-10-22 13:07 UTC242
      </button>
05a48e9e7942atagen2026-10-24 09:32 UTC243
    </div>
05a48e9e7942atagen2026-10-24 09:32 UTC244
  {/if}
544a170ead2catagen2026-10-20 11:47 UTC245
</div>
544a170ead2catagen2026-10-20 11:47 UTC246
544a170ead2catagen2026-10-20 11:47 UTC247
<style>
df3c0f2b3da4atagen2026-10-22 13:07 UTC248
  .picker.embedded { width: 100%; height: 340px; box-shadow: none; }
544a170ead2catagen2026-10-20 11:47 UTC249
  .picker {
544a170ead2catagen2026-10-20 11:47 UTC250
    outline: none;
544a170ead2catagen2026-10-20 11:47 UTC251
    display: flex; flex-direction: column; gap: 6px;
544a170ead2catagen2026-10-20 11:47 UTC252
    width: 320px; height: 380px; max-width: 100%; max-height: 100%;
544a170ead2catagen2026-10-20 11:47 UTC253
    padding: 8px;
544a170ead2catagen2026-10-20 11:47 UTC254
    background: var(--panel);
544a170ead2catagen2026-10-20 11:47 UTC255
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC256
    border-radius: var(--r-md);
544a170ead2catagen2026-10-20 11:47 UTC257
    box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
544a170ead2catagen2026-10-20 11:47 UTC258
  }
544a170ead2catagen2026-10-20 11:47 UTC259
  .search {
544a170ead2catagen2026-10-20 11:47 UTC260
    background: var(--bg-soft);
544a170ead2catagen2026-10-20 11:47 UTC261
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC262
    border-radius: var(--r-md);
544a170ead2catagen2026-10-20 11:47 UTC263
    color: var(--text);
544a170ead2catagen2026-10-20 11:47 UTC264
    padding: 6px 10px;
544a170ead2catagen2026-10-20 11:47 UTC265
    font-size: 13px;
544a170ead2catagen2026-10-20 11:47 UTC266
  }
544a170ead2catagen2026-10-20 11:47 UTC267
  .search:focus-visible { outline: none; border-color: var(--accent); }
544a170ead2catagen2026-10-20 11:47 UTC268
  .muted { color: var(--muted); font-size: 12px; }
05a48e9e7942atagen2026-10-24 09:32 UTC269
  .quick { border: 1px solid var(--border); border-radius: var(--r-md); padding: 6px; background: var(--bg-soft); }
5059ed753f8batagen2026-10-27 07:20 UTC270
  .quick-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 650; margin-bottom: 4px; }
5059ed753f8batagen2026-10-27 07:20 UTC271
  .add-sep {
5059ed753f8batagen2026-10-27 07:20 UTC272
    background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm);
5059ed753f8batagen2026-10-27 07:20 UTC273
    color: var(--text); font-size: 11px; font-weight: 400; padding: 1px 6px; cursor: pointer;
5059ed753f8batagen2026-10-27 07:20 UTC274
    transition: border-color var(--pop);
5059ed753f8batagen2026-10-27 07:20 UTC275
  }
5059ed753f8batagen2026-10-27 07:20 UTC276
  .add-sep:hover:not(:disabled) { border-color: var(--accent); }
5059ed753f8batagen2026-10-27 07:20 UTC277
  .add-sep:disabled { opacity: 0.4; cursor: default; }
5059ed753f8batagen2026-10-27 07:20 UTC278
  .quick-row { position: relative; display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; align-items: center; }
544a170ead2catagen2026-10-20 11:47 UTC279
  .quick-item {
544a170ead2catagen2026-10-20 11:47 UTC280
    display: inline-flex; align-items: center; gap: 2px;
5059ed753f8batagen2026-10-27 07:20 UTC281
    padding: 1px 2px 1px 0;
05a48e9e7942atagen2026-10-24 09:32 UTC282
    border: 1px solid var(--border); border-radius: var(--r-sm);
544a170ead2catagen2026-10-20 11:47 UTC283
    font-size: 16px;
5059ed753f8batagen2026-10-27 07:20 UTC284
    user-select: none;
5059ed753f8batagen2026-10-27 07:20 UTC285
  }
5059ed753f8batagen2026-10-27 07:20 UTC286
  .quick-item.separator { border-style: dashed; }
5059ed753f8batagen2026-10-27 07:20 UTC287
  .quick-item:has(.grab:focus-visible) { border-color: var(--accent); }
5059ed753f8batagen2026-10-27 07:20 UTC288
  .quick-item:global([data-dragging]) {
5059ed753f8batagen2026-10-27 07:20 UTC289
    z-index: 2; background: var(--bg-soft); border-color: var(--accent);
5059ed753f8batagen2026-10-27 07:20 UTC290
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
544a170ead2catagen2026-10-20 11:47 UTC291
  }
544a170ead2catagen2026-10-20 11:47 UTC292
  .quick-item img { width: 18px; height: 18px; object-fit: contain; }
5059ed753f8batagen2026-10-27 07:20 UTC293
  .grab {
5059ed753f8batagen2026-10-27 07:20 UTC294
    display: inline-flex; align-items: center; height: 20px; padding: 0 2px 0 6px;
5059ed753f8batagen2026-10-27 07:20 UTC295
    background: transparent; border: none; color: inherit; font: inherit;
5059ed753f8batagen2026-10-27 07:20 UTC296
    cursor: grab; touch-action: none; -webkit-touch-callout: none; user-select: none;
5059ed753f8batagen2026-10-27 07:20 UTC297
  }
5059ed753f8batagen2026-10-27 07:20 UTC298
  .grab:focus-visible { outline: none; }
5059ed753f8batagen2026-10-27 07:20 UTC299
  .quick-item:global([data-dragging]) .grab { cursor: grabbing; }
5059ed753f8batagen2026-10-27 07:20 UTC300
  .separator .grab { padding: 0 6px; }
5059ed753f8batagen2026-10-27 07:20 UTC301
  .bar { width: 1px; height: 14px; background: var(--muted); }
5059ed753f8batagen2026-10-27 07:20 UTC302
  @media (pointer: coarse) {
5059ed753f8batagen2026-10-27 07:20 UTC303
    .grab { height: 30px; }
5059ed753f8batagen2026-10-27 07:20 UTC304
  }
544a170ead2catagen2026-10-20 11:47 UTC305
  .remove {
544a170ead2catagen2026-10-20 11:47 UTC306
    background: transparent; border: none; color: var(--muted);
05a48e9e7942atagen2026-10-24 09:32 UTC307
    cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 5px; border-radius: var(--r-sm);
544a170ead2catagen2026-10-20 11:47 UTC308
  }
544a170ead2catagen2026-10-20 11:47 UTC309
  .remove:hover { color: var(--danger); }
544a170ead2catagen2026-10-20 11:47 UTC310
  .scroll { flex: 1; min-height: 0; overflow-y: auto; }
544a170ead2catagen2026-10-20 11:47 UTC311
  .group h3 {
544a170ead2catagen2026-10-20 11:47 UTC312
    margin: 6px 2px 2px; font-size: 11px; font-weight: 650;
544a170ead2catagen2026-10-20 11:47 UTC313
    text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted);
544a170ead2catagen2026-10-20 11:47 UTC314
  }
544a170ead2catagen2026-10-20 11:47 UTC315
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 2px; }
544a170ead2catagen2026-10-20 11:47 UTC316
  .cell {
544a170ead2catagen2026-10-20 11:47 UTC317
    display: grid; place-items: center;
544a170ead2catagen2026-10-20 11:47 UTC318
    height: 32px; padding: 0;
05a48e9e7942atagen2026-10-24 09:32 UTC319
    background: transparent; border: 1px solid transparent; border-radius: var(--r-sm);
544a170ead2catagen2026-10-20 11:47 UTC320
    font-size: 20px; cursor: pointer; color: var(--text);
544a170ead2catagen2026-10-20 11:47 UTC321
  }
544a170ead2catagen2026-10-20 11:47 UTC322
  .cell img { width: 24px; height: 24px; object-fit: contain; }
df3c0f2b3da4atagen2026-10-22 13:07 UTC323
  .cell:hover:not(:disabled), .cell:focus-visible { background: var(--hover); border-color: var(--border); outline: none; }
df3c0f2b3da4atagen2026-10-22 13:07 UTC324
  .cell.chosen { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
544a170ead2catagen2026-10-20 11:47 UTC325
  .cell:disabled { opacity: 0.3; cursor: default; }
544a170ead2catagen2026-10-20 11:47 UTC326
  .status { color: var(--muted); font-size: 13px; text-align: center; margin: 16px 0; }
544a170ead2catagen2026-10-20 11:47 UTC327
  .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
544a170ead2catagen2026-10-20 11:47 UTC328
  .mode {
544a170ead2catagen2026-10-20 11:47 UTC329
    margin-left: auto;
05a48e9e7942atagen2026-10-24 09:32 UTC330
    background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm);
544a170ead2catagen2026-10-20 11:47 UTC331
    color: var(--text); font-size: 12px; padding: 3px 8px; cursor: pointer;
544a170ead2catagen2026-10-20 11:47 UTC332
  }
544a170ead2catagen2026-10-20 11:47 UTC333
  .mode:hover, .mode[aria-pressed='true'] { border-color: var(--accent); }
544a170ead2catagen2026-10-20 11:47 UTC334
</style>