@pixerate/editor-svelte 0.108.0 → 0.109.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/EditableTextNodeEditor.svelte +6 -0
  2. package/dist/canvas/createCanvasPlaybackSurface.svelte.d.ts +8 -2
  3. package/dist/canvas/createCanvasPlaybackSurface.svelte.js +13 -8
  4. package/dist/image-editor/AdjustmentLayersPanel.svelte +27 -26
  5. package/dist/image-editor/AdjustmentLayersPanel.svelte.d.ts +2 -0
  6. package/dist/image-editor/BrushOptionsBar.svelte +35 -34
  7. package/dist/image-editor/ColorProfilePanel.svelte +12 -12
  8. package/dist/image-editor/ControlSlider.svelte +6 -4
  9. package/dist/image-editor/ControlSlider.svelte.d.ts +2 -0
  10. package/dist/image-editor/CurvesEditor.svelte +6 -4
  11. package/dist/image-editor/CurvesEditor.svelte.d.ts +2 -0
  12. package/dist/image-editor/FreeTransformOptionsBar.svelte +22 -21
  13. package/dist/image-editor/Histogram.svelte +1 -1
  14. package/dist/image-editor/ImageEditor.svelte +127 -142
  15. package/dist/image-editor/ImageEffectControls.svelte +15 -11
  16. package/dist/image-editor/ImageEffectControls.svelte.d.ts +2 -0
  17. package/dist/image-editor/ImageProviderEffectsPanel.svelte +15 -15
  18. package/dist/image-editor/LayerEffectsPanel.svelte +22 -23
  19. package/dist/image-editor/LayersPanel.svelte +39 -39
  20. package/dist/image-editor/RefineEdgePanel.svelte +14 -13
  21. package/dist/image-editor/SelectionOptionsBar.svelte +12 -11
  22. package/dist/image-editor/ShapeOptionsBar.svelte +14 -13
  23. package/dist/image-editor/TypeOptionsBar.svelte +12 -11
  24. package/dist/image-editor/styles.d.ts +51 -0
  25. package/dist/image-editor/styles.js +51 -0
  26. package/dist/playback/playback.svelte.d.ts +7 -4
  27. package/dist/playback/playback.svelte.js +17 -9
  28. package/package.json +2 -2
@@ -10,20 +10,24 @@
10
10
  values: Readonly<Record<string, unknown>>;
11
11
  onchange: (control: string, value: unknown, gesture: string | null) => void;
12
12
  onpreset: (values: Readonly<Record<string, unknown>>) => void;
13
+ /** Extra classes on the root element. */
14
+ class?: string;
13
15
  }
14
16
 
15
- let { provider, effect, name, values, onchange, onpreset }: Props = $props();
17
+ let { provider, effect, name, values, onchange, onpreset, class: className = '' }: Props = $props();
16
18
  const loaded = effectDescriptionOf(() => provider, () => effect);
17
19
  </script>
18
20
 
19
21
  <!-- One effect's controls, loaded from the provider; a note when it does not have the effect. -->
20
- {#if !provider}
21
- <p class="text-[11px] text-slate-400">No effects provider: "{name ?? effect}" is kept but not drawn.</p>
22
- {:else if loaded.error}
23
- <p class="text-[11px] text-amber-400">{loaded.error}</p>
24
- {:else if !loaded.description}
25
- <p class="text-[11px] text-slate-400">Loading…</p>
26
- {:else}
27
- {@const description = loaded.description}
28
- <EffectParameters {description} {values} {onchange} onreset={(k) => onchange(k, description.defaults[k], null)} {onpreset} />
29
- {/if}
22
+ <div class={className || undefined} data-testid="image-effect-controls">
23
+ {#if !provider}
24
+ <p class="text-[11px] text-muted-foreground">No effects provider: "{name ?? effect}" is kept but not drawn.</p>
25
+ {:else if loaded.error}
26
+ <p class="text-[11px] text-amber-600">{loaded.error}</p>
27
+ {:else if !loaded.description}
28
+ <p class="text-[11px] text-muted-foreground">Loading…</p>
29
+ {:else}
30
+ {@const description = loaded.description}
31
+ <EffectParameters {description} {values} {onchange} onreset={(k) => onchange(k, description.defaults[k], null)} {onpreset} />
32
+ {/if}
33
+ </div>
@@ -6,6 +6,8 @@ interface Props {
6
6
  values: Readonly<Record<string, unknown>>;
7
7
  onchange: (control: string, value: unknown, gesture: string | null) => void;
8
8
  onpreset: (values: Readonly<Record<string, unknown>>) => void;
9
+ /** Extra classes on the root element. */
10
+ class?: string;
9
11
  }
10
12
  declare const ImageEffectControls: import("svelte").Component<Props, {}, "">;
11
13
  type ImageEffectControls = ReturnType<typeof ImageEffectControls>;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { imageOutlineButtonClass } from './styles.js';
2
3
  import { findLayer, type ImageEditorController, type ImageLayer, type SmartObjectLayer } from '@pixerate/editor/image-editor';
3
4
  import { untrack } from 'svelte';
4
5
  import EffectsBrowser from '../effects/EffectsBrowser.svelte';
@@ -29,7 +30,6 @@
29
30
  const all = $derived(layer?.type === 'smart' ? ((layer as SmartObjectLayer).smartFilters?.filters ?? []) : []);
30
31
  const generator = $derived(layer?.type === 'pixel' ? layer.generator : undefined);
31
32
  const canFilter = $derived(!!layer && layer.type !== 'adjustment' && layer.type !== 'annotation');
32
- const btn = 'rounded border border-slate-700 px-1.5 py-0.5 text-[11px] text-slate-300 hover:text-white disabled:opacity-40';
33
33
  const drags = new Set<string>();
34
34
  /** Changes during a drag skip the undo stack after the first, so the drag is one step. */
35
35
  function commit(gesture: string | null) {
@@ -54,11 +54,11 @@
54
54
  -->
55
55
  <section class="flex flex-col gap-2 text-xs {className}" aria-label="Provider effects" data-image-provider-effects="">
56
56
  <div class="flex items-center gap-1">
57
- <span class="flex-1 text-[11px] font-semibold uppercase tracking-wider text-slate-200">Effects</span>
58
- <button type="button" class={btn} disabled={!provider || !canFilter} aria-pressed={browse === 'filter'} onclick={() => (browse = browse === 'filter' ? null : 'filter')}>Apply effect…</button>
59
- <button type="button" class={btn} disabled={!provider} aria-pressed={browse === 'layer'} onclick={() => (browse = browse === 'layer' ? null : 'layer')}>Effect layer…</button>
57
+ <span class="flex-1 text-[11px] font-semibold uppercase tracking-wider text-foreground">Effects</span>
58
+ <button type="button" class={imageOutlineButtonClass} disabled={!provider || !canFilter} aria-pressed={browse === 'filter'} onclick={() => (browse = browse === 'filter' ? null : 'filter')}>Apply effect…</button>
59
+ <button type="button" class={imageOutlineButtonClass} disabled={!provider} aria-pressed={browse === 'layer'} onclick={() => (browse = browse === 'layer' ? null : 'layer')}>Effect layer…</button>
60
60
  </div>
61
- {#if !provider}<p class="text-[11px] text-slate-400">No effects provider is registered. Effects in the document are kept.</p>{/if}
61
+ {#if !provider}<p class="text-[11px] text-muted-foreground">No effects provider is registered. Effects in the document are kept.</p>{/if}
62
62
  {#if browse === 'filter' && layer}
63
63
  {@const id = layer.id}
64
64
  <EffectsBrowser {provider} uses={['filter']} title="Filters" onpick={(e) => ((browse = null), run(() => controller.applyEffectFilter(id, e.id)))} />
@@ -70,8 +70,8 @@
70
70
  {@const id = layer.id}
71
71
  <div class="space-y-1" data-effect-part="generator">
72
72
  <div class="flex items-center gap-1">
73
- <span class="flex-1 font-medium text-slate-200">{generator.name ?? generator.effect}</span>
74
- <button type="button" class={btn} title="Keep the pixels as a plain layer" onclick={() => run(() => controller.rasterizeEffectLayer(id))}>Rasterize</button>
73
+ <span class="flex-1 font-medium text-foreground">{generator.name ?? generator.effect}</span>
74
+ <button type="button" class={imageOutlineButtonClass} title="Keep the pixels as a plain layer" onclick={() => run(() => controller.rasterizeEffectLayer(id))}>Rasterize</button>
75
75
  </div>
76
76
  <ImageEffectControls
77
77
  {provider}
@@ -89,21 +89,21 @@
89
89
  {#each all as f, index (f.id)}
90
90
  {#if f.kind === 'effect'}
91
91
  {@const label = f.name ?? f.effect}
92
- <li class="rounded border border-slate-700 p-1" data-smart-effect={f.id}>
92
+ <li class="rounded border border-border p-1" data-smart-effect={f.id}>
93
93
  <div class="flex items-center gap-1">
94
94
  <input type="checkbox" checked={f.enabled} aria-label="{label} on" onchange={(e) => {
95
95
  const enabled = e.currentTarget.checked;
96
96
  run(() => controller.updateEffectFilter(id, f.id, { enabled }));
97
97
  }} />
98
- <button type="button" class="min-w-0 flex-1 truncate text-left text-slate-200" aria-expanded={open === f.id} onclick={() => (open = open === f.id ? null : f.id)}>{label}</button>
99
- <button type="button" class={btn} disabled={index === all.length - 1} aria-label="Move up" onclick={() => run(() => controller.updateEffectFilter(id, f.id, { index: index + 1 }))}>↑</button>
100
- <button type="button" class={btn} disabled={index === 0} aria-label="Move down" onclick={() => run(() => controller.updateEffectFilter(id, f.id, { index: index - 1 }))}>↓</button>
101
- <button type="button" class={btn} aria-label="Remove {label}" onclick={() => run(() => controller.removeSmartFilter(id, f.id))}>✕</button>
98
+ <button type="button" class="min-w-0 flex-1 truncate text-left text-foreground" aria-expanded={open === f.id} onclick={() => (open = open === f.id ? null : f.id)}>{label}</button>
99
+ <button type="button" class={imageOutlineButtonClass} disabled={index === all.length - 1} aria-label="Move up" onclick={() => run(() => controller.updateEffectFilter(id, f.id, { index: index + 1 }))}>↑</button>
100
+ <button type="button" class={imageOutlineButtonClass} disabled={index === 0} aria-label="Move down" onclick={() => run(() => controller.updateEffectFilter(id, f.id, { index: index - 1 }))}>↓</button>
101
+ <button type="button" class={imageOutlineButtonClass} aria-label="Remove {label}" onclick={() => run(() => controller.removeSmartFilter(id, f.id))}>✕</button>
102
102
  </div>
103
103
  {#if open === f.id}
104
104
  <div class="mt-1 space-y-2">
105
105
  <label class="flex items-center gap-1">
106
- <span class="w-14 text-[11px] text-slate-300">Opacity</span>
106
+ <span class="w-14 text-[11px] text-foreground">Opacity</span>
107
107
  <input type="range" min="0" max="1" step="0.01" class="flex-1" value={f.opacity} aria-label="{label} opacity" oninput={(e) => {
108
108
  const opacity = Number(e.currentTarget.value);
109
109
  run(() => controller.updateEffectFilter(id, f.id, { opacity }));
@@ -124,6 +124,6 @@
124
124
  {/each}
125
125
  </ol>
126
126
  {/if}
127
- {#if !layer}<p class="text-[11px] text-slate-400">Select a layer to apply effects to it.</p>{/if}
128
- {#if message}<p role="alert" class="text-[11px] text-rose-400">{message}</p>{/if}
127
+ {#if !layer}<p class="text-[11px] text-muted-foreground">Select a layer to apply effects to it.</p>{/if}
128
+ {#if message}<p role="alert" class="text-[11px] text-destructive">{message}</p>{/if}
129
129
  </section>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { imageIconButtonClass, imageSelectClass } from './styles.js';
2
3
  import type { EffectControl, ImageEditorController, LayerEffectKind } from '@pixerate/editor/image-editor';
3
4
  import { LAYER_EFFECT_MENU, effectControlChange, layerEffectControls, layerEffectsPanel } from '@pixerate/editor/image-editor';
4
5
  import { untrack } from 'svelte';
@@ -37,8 +38,6 @@
37
38
  /** Whether the next slider change records an undo step (the first of a drag does, the rest replace it). */
38
39
  let commitNext = true;
39
40
 
40
- const button = 'px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 hover:text-white disabled:opacity-40';
41
-
42
41
  function attempt(fn: () => unknown) {
43
42
  try {
44
43
  error = null;
@@ -78,24 +77,24 @@
78
77
  </script>
79
78
 
80
79
  {#if !view.model.available || !view.id}
81
- <div class="text-xs text-slate-500 {className}" data-testid="layer-effects">{view.model.reason}</div>
80
+ <div class="text-xs text-muted-foreground {className}" data-testid="layer-effects">{view.model.reason}</div>
82
81
  {:else}
83
82
  {@const id = view.id}
84
- <div class="flex flex-col gap-1.5 text-xs text-slate-200 {className}" data-testid="layer-effects" aria-label="Layer effects" role="group">
83
+ <div class="flex flex-col gap-1.5 text-xs text-foreground {className}" data-testid="layer-effects" aria-label="Layer effects" role="group">
85
84
  <div class="flex flex-wrap items-center gap-2">
86
- <span class="font-semibold text-slate-300">fx Effects</span>
87
- <label class="flex items-center gap-1 text-slate-300">
85
+ <span class="font-semibold text-foreground">fx Effects</span>
86
+ <label class="flex items-center gap-1 text-foreground">
88
87
  <input
89
88
  type="checkbox"
90
89
  aria-label="Effects on"
91
90
  checked={view.model.enabled}
92
91
  disabled={view.model.readOnly}
93
92
  onchange={(e) => attempt(() => controller.setLayerEffectsEnabled(id, (e.currentTarget as HTMLInputElement).checked))}
94
- class="accent-indigo-500"
93
+ class="accent-primary"
95
94
  />
96
95
  On
97
96
  </label>
98
- <label class="flex items-center gap-1 text-slate-400">
97
+ <label class="flex items-center gap-1 text-muted-foreground">
99
98
  Scale
100
99
  <input
101
100
  type="number"
@@ -106,23 +105,23 @@
106
105
  value={Math.round(view.model.scale * 100)}
107
106
  disabled={view.model.readOnly}
108
107
  oninput={setScale}
109
- class="w-14 bg-slate-800 border border-slate-700 rounded px-1"
108
+ class="w-14 bg-muted border border-border rounded px-1"
110
109
  />
111
110
  %
112
111
  </label>
113
- <select aria-label="Add effect" value="" disabled={view.model.readOnly} onchange={add} class="bg-slate-800 border border-slate-700 rounded px-1.5 py-1">
112
+ <select aria-label="Add effect" value="" disabled={view.model.readOnly} onchange={add} class={imageSelectClass}>
114
113
  <option value="">Add effect…</option>
115
114
  {#each LAYER_EFFECT_MENU as m (m.kind)}
116
115
  <option value={m.kind}>{m.label}</option>
117
116
  {/each}
118
117
  </select>
119
118
  </div>
120
- {#if view.model.readOnly && view.model.reason}<p class="text-slate-500">{view.model.reason}</p>{/if}
119
+ {#if view.model.readOnly && view.model.reason}<p class="text-muted-foreground">{view.model.reason}</p>{/if}
121
120
  <ul class="flex flex-col gap-1" aria-label="Effects">
122
121
  {#each view.model.rows as row (row.id)}
123
122
  {@const effect = view.items.find((e) => e.id === row.id)}
124
123
  {@const expanded = open === row.id}
125
- <li data-effect-id={row.id} class="rounded bg-slate-900/60 ring-1 ring-slate-800">
124
+ <li data-effect-id={row.id} class="rounded bg-background/60 ring-1 ring-border">
126
125
  <div class="flex items-center gap-1.5 px-1.5 py-1">
127
126
  <input
128
127
  type="checkbox"
@@ -130,19 +129,19 @@
130
129
  checked={row.enabled}
131
130
  disabled={view.model.readOnly}
132
131
  onchange={(e) => attempt(() => controller.updateLayerEffect(id, row.id, { enabled: (e.currentTarget as HTMLInputElement).checked }))}
133
- class="accent-indigo-500"
132
+ class="accent-primary"
134
133
  />
135
- <button type="button" aria-expanded={expanded} class="flex-1 text-left {row.enabled && view.model.enabled ? '' : 'text-slate-500'}" onclick={() => (open = expanded ? null : row.id)}>
134
+ <button type="button" aria-expanded={expanded} class="flex-1 text-left {row.enabled && view.model.enabled ? '' : 'text-muted-foreground'}" onclick={() => (open = expanded ? null : row.id)}>
136
135
  {row.label}
137
136
  </button>
138
- <button type="button" class={button} aria-label="Remove {row.label}" disabled={view.model.readOnly} onclick={() => attempt(() => controller.removeLayerEffect(id, row.id))}>✕</button>
137
+ <button type="button" class={imageIconButtonClass} aria-label="Remove {row.label}" disabled={view.model.readOnly} onclick={() => attempt(() => controller.removeLayerEffect(id, row.id))}>✕</button>
139
138
  </div>
140
139
  {#if expanded && effect}
141
140
  <div class="grid grid-cols-2 gap-x-2 gap-y-1 px-1.5 pb-1.5" data-testid="effect-settings">
142
141
  {#each layerEffectControls(effect, view.patterns) as c (c.key)}
143
142
  {#if c.type === 'range'}
144
143
  <label class="flex flex-col gap-0.5">
145
- <span class="text-slate-400">{c.label} {shown(c)}{c.unit ?? ''}</span>
144
+ <span class="text-muted-foreground">{c.label} {shown(c)}{c.unit ?? ''}</span>
146
145
  <input
147
146
  type="range"
148
147
  aria-label={c.label}
@@ -155,23 +154,23 @@
155
154
  onpointerup={() => (commitNext = true)}
156
155
  onkeydown={() => (commitNext = true)}
157
156
  oninput={(e) => change(row.id, c, Number((e.currentTarget as HTMLInputElement).value) / k(c), true)}
158
- class="accent-indigo-500"
157
+ class="accent-primary"
159
158
  />
160
159
  </label>
161
160
  {:else if c.type === 'color'}
162
- <label class="flex items-center gap-1.5 text-slate-300">
161
+ <label class="flex items-center gap-1.5 text-foreground">
163
162
  <input type="color" aria-label={c.label} value={c.value} disabled={view.model.readOnly} oninput={(e) => change(row.id, c, (e.currentTarget as HTMLInputElement).value)} />
164
163
  {c.label}
165
164
  </label>
166
165
  {:else if c.type === 'checkbox'}
167
- <label class="flex items-center gap-1.5 text-slate-300">
168
- <input type="checkbox" aria-label={c.label} checked={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLInputElement).checked)} class="accent-indigo-500" />
166
+ <label class="flex items-center gap-1.5 text-foreground">
167
+ <input type="checkbox" aria-label={c.label} checked={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLInputElement).checked)} class="accent-primary" />
169
168
  {c.label}
170
169
  </label>
171
170
  {:else}
172
171
  <label class="flex flex-col gap-0.5">
173
- <span class="text-slate-400">{c.label}</span>
174
- <select aria-label={c.label} value={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLSelectElement).value)} class="bg-slate-800 border border-slate-700 rounded px-1.5 py-1">
172
+ <span class="text-muted-foreground">{c.label}</span>
173
+ <select aria-label={c.label} value={c.value} disabled={view.model.readOnly} onchange={(e) => change(row.id, c, (e.currentTarget as HTMLSelectElement).value)} class={imageSelectClass}>
175
174
  {#each c.options as o (o.value)}
176
175
  <option value={o.value}>{o.label}</option>
177
176
  {/each}
@@ -184,6 +183,6 @@
184
183
  </li>
185
184
  {/each}
186
185
  </ul>
187
- {#if error}<p role="alert" class="text-rose-400">{error}</p>{/if}
186
+ {#if error}<p role="alert" class="text-destructive">{error}</p>{/if}
188
187
  </div>
189
188
  {/if}
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { imageIconButtonClass, imageSelectClass } from './styles.js';
2
3
  import type { ImageEditorController, ImageLayer, LayerMenuAction, LayerPanelRow, PixelBuffer } from '@pixerate/editor/image-editor';
3
4
  import {
4
5
  BIT_DEPTH_OPTIONS,
@@ -82,9 +83,8 @@
82
83
  let commitNext = true;
83
84
 
84
85
  const LOCKS = ['pixels', 'transparency', 'position', 'all'] as const;
85
- const iconButton = 'px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 hover:text-white disabled:opacity-40';
86
86
  const rowClass = (active: boolean) =>
87
- `flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none ${active ? 'bg-indigo-600/30 ring-1 ring-indigo-500' : 'hover:bg-slate-800'}`;
87
+ `flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none ${active ? 'bg-primary/30 ring-1 ring-primary' : 'hover:bg-muted'}`;
88
88
 
89
89
  function attempt(fn: () => unknown) {
90
90
  try {
@@ -147,21 +147,21 @@
147
147
  return layer.mask && !layer.mask.source ? controller.getLayerThumbnail(layer.id, { maxSide: 64, mask: true }) : null;
148
148
  }
149
149
  const thumbClass = (selected: boolean) =>
150
- `shrink-0 rounded bg-slate-800 ${selected ? 'ring-2 ring-white' : 'ring-1 ring-slate-700'}`;
150
+ `shrink-0 rounded bg-muted ${selected ? 'ring-2 ring-foreground' : 'ring-1 ring-border'}`;
151
151
  </script>
152
152
 
153
153
  <svelte:window onclick={() => menu && (menu = null)} />
154
154
 
155
- <div class="flex flex-col gap-2 text-xs text-slate-200 {className}" data-testid="layers-panel">
155
+ <div class="flex flex-col gap-2 text-xs text-foreground {className}" data-testid="layers-panel">
156
156
  <div class="flex flex-wrap items-center gap-1" role="toolbar" aria-label="Layer actions">
157
- <button type="button" class={iconButton} onclick={() => run('new-layer')}>New Layer</button>
158
- <button type="button" class={iconButton} onclick={() => run('new-group')}>New Group</button>
159
- <button type="button" class={iconButton} disabled={!snapshot.active} onclick={() => run('duplicate')}>Duplicate</button>
160
- <button type="button" class={iconButton} disabled={!snapshot.active} onclick={() => run('add-mask')}>Add Mask</button>
161
- <button type="button" class={iconButton} disabled={!snapshot.active} onclick={() => run('delete')}>Delete</button>
157
+ <button type="button" class={imageIconButtonClass} onclick={() => run('new-layer')}>New Layer</button>
158
+ <button type="button" class={imageIconButtonClass} onclick={() => run('new-group')}>New Group</button>
159
+ <button type="button" class={imageIconButtonClass} disabled={!snapshot.active} onclick={() => run('duplicate')}>Duplicate</button>
160
+ <button type="button" class={imageIconButtonClass} disabled={!snapshot.active} onclick={() => run('add-mask')}>Add Mask</button>
161
+ <button type="button" class={imageIconButtonClass} disabled={!snapshot.active} onclick={() => run('delete')}>Delete</button>
162
162
  <button
163
163
  type="button"
164
- class={iconButton}
164
+ class={imageIconButtonClass}
165
165
  aria-label="Layer menu"
166
166
  aria-haspopup="menu"
167
167
  onclick={(e) => {
@@ -172,7 +172,7 @@
172
172
  <select
173
173
  aria-label="Bits per channel"
174
174
  title="Image › Mode: bits per channel"
175
- class="rounded bg-slate-800 px-1 py-0.5 text-[11px] text-slate-200"
175
+ class="rounded bg-muted px-1 py-0.5 text-[11px] text-foreground"
176
176
  value={snapshot.bitDepth}
177
177
  onchange={(e) => attempt(() => controller.setBitDepth(Number((e.target as HTMLSelectElement).value) as 8 | 16 | 32))}>
178
178
  {#each BIT_DEPTH_OPTIONS as o (o.depth)}
@@ -180,8 +180,8 @@
180
180
  {/each}
181
181
  </select>
182
182
  {#if psd}
183
- <button type="button" class={iconButton} onclick={() => fileInput?.click()}>Open PSD…</button>
184
- <button type="button" class={iconButton} onclick={() => psdTask(async () => (await downloadPsd(controller, psdFileName)).warnings)}>Export PSD</button>
183
+ <button type="button" class={imageIconButtonClass} onclick={() => fileInput?.click()}>Open PSD…</button>
184
+ <button type="button" class={imageIconButtonClass} onclick={() => psdTask(async () => (await downloadPsd(controller, psdFileName)).warnings)}>Export PSD</button>
185
185
  <input
186
186
  bind:this={fileInput}
187
187
  type="file"
@@ -193,7 +193,7 @@
193
193
  {/if}
194
194
  </div>
195
195
  {#if psdNotes.length > 0}
196
- <ul class="rounded bg-amber-900/30 text-amber-200 px-2 py-1 space-y-0.5" data-testid="psd-warnings" aria-label="PSD warnings">
196
+ <ul class="rounded bg-amber-500/10 text-amber-600 px-2 py-1 space-y-0.5" data-testid="psd-warnings" aria-label="PSD warnings">
197
197
  {#each psdNotes as n (n)}
198
198
  <li>{n}</li>
199
199
  {/each}
@@ -205,7 +205,7 @@
205
205
  {@const active = snapshot.active}
206
206
  <div class="grid grid-cols-2 gap-x-2 gap-y-1 items-center" data-testid="layer-properties">
207
207
  <label class="flex flex-col gap-0.5">
208
- <span class="text-slate-400">Opacity {Math.round(active.opacity * 100)}%</span>
208
+ <span class="text-muted-foreground">Opacity {Math.round(active.opacity * 100)}%</span>
209
209
  <input
210
210
  type="range"
211
211
  min="0"
@@ -216,11 +216,11 @@
216
216
  onpointerup={() => (commitNext = true)}
217
217
  onkeydown={() => (commitNext = true)}
218
218
  oninput={(e) => prop('opacity', Number((e.target as HTMLInputElement).value) / 100)}
219
- class="accent-indigo-500"
219
+ class="accent-primary"
220
220
  />
221
221
  </label>
222
222
  <label class="flex flex-col gap-0.5">
223
- <span class="text-slate-400">Fill {Math.round(fillOf(active) * 100)}%</span>
223
+ <span class="text-muted-foreground">Fill {Math.round(fillOf(active) * 100)}%</span>
224
224
  <input
225
225
  type="range"
226
226
  min="0"
@@ -232,12 +232,12 @@
232
232
  onpointerup={() => (commitNext = true)}
233
233
  onkeydown={() => (commitNext = true)}
234
234
  oninput={(e) => prop('fillOpacity', Number((e.target as HTMLInputElement).value) / 100)}
235
- class="accent-indigo-500 disabled:opacity-40"
235
+ class="accent-primary disabled:opacity-40"
236
236
  />
237
237
  </label>
238
238
  <select
239
239
  aria-label="Layer blend mode"
240
- class="bg-slate-800 border border-slate-700 rounded px-1.5 py-1"
240
+ class={imageSelectClass}
241
241
  value={active.blend}
242
242
  onchange={(e) => attempt(() => controller.setLayerProperties(active.id, { blend: (e.target as HTMLSelectElement).value as never }))}
243
243
  >
@@ -246,17 +246,17 @@
246
246
  <option value={m.id}>{m.label}</option>
247
247
  {/each}
248
248
  </select>
249
- <label class="flex items-center gap-1.5 text-slate-300">
249
+ <label class="flex items-center gap-1.5 text-foreground">
250
250
  <input
251
251
  type="checkbox"
252
252
  aria-label="Clip to layer below"
253
253
  checked={Boolean(active.clipped)}
254
254
  onchange={(e) => attempt(() => controller.setLayerProperties(active.id, { clipped: (e.target as HTMLInputElement).checked }))}
255
- class="accent-indigo-500"
255
+ class="accent-primary"
256
256
  />
257
257
  Clip to below
258
258
  </label>
259
- <div class="col-span-2 flex flex-wrap gap-2 text-slate-400" role="group" aria-label="Layer locks">
259
+ <div class="col-span-2 flex flex-wrap gap-2 text-muted-foreground" role="group" aria-label="Layer locks">
260
260
  {#each LOCKS as k (k)}
261
261
  <label class="flex items-center gap-1">
262
262
  <input
@@ -264,7 +264,7 @@
264
264
  aria-label={`Lock ${k}`}
265
265
  checked={Boolean(active.locks?.[k])}
266
266
  onchange={(e) => attempt(() => controller.setLayerProperties(active.id, { locks: { ...active.locks, [k]: (e.target as HTMLInputElement).checked } }))}
267
- class="accent-indigo-500"
267
+ class="accent-primary"
268
268
  />
269
269
  {k}
270
270
  </label>
@@ -274,7 +274,7 @@
274
274
  {/if}
275
275
 
276
276
  {#if effects && snapshot.active && snapshot.active.type !== 'adjustment'}
277
- <LayerEffectsPanel {controller} class="border-t border-slate-800 pt-1.5" />
277
+ <LayerEffectsPanel {controller} class="border-t border-border pt-1.5" />
278
278
  {/if}
279
279
 
280
280
  <ul class="flex flex-col gap-0.5" role="tree" aria-label="Layers">
@@ -312,7 +312,7 @@
312
312
  type="button"
313
313
  aria-label={layer.visible ? `Hide ${row.label}` : `Show ${row.label}`}
314
314
  aria-pressed={layer.visible}
315
- class="w-4 text-center text-slate-300"
315
+ class="w-4 text-center text-foreground"
316
316
  onclick={(e) => {
317
317
  e.stopPropagation();
318
318
  attempt(() => controller.setLayerProperties(layer.id, { visible: !layer.visible }));
@@ -321,13 +321,13 @@
321
321
  <button
322
322
  type="button"
323
323
  aria-label={layer.collapsed ? `Expand ${row.label}` : `Collapse ${row.label}`}
324
- class="w-3 text-slate-400"
324
+ class="w-3 text-muted-foreground"
325
325
  onclick={(e) => {
326
326
  e.stopPropagation();
327
327
  attempt(() => controller.setLayerProperties(layer.id, { collapsed: !(layer.type === 'group' && layer.collapsed) }));
328
328
  }}>{layer.collapsed ? '▸' : '▾'}</button>
329
329
  {/if}
330
- {#if row.clipped}<span class="text-slate-500" aria-label="Clipped">↳</span>{/if}
330
+ {#if row.clipped}<span class="text-muted-foreground" aria-label="Clipped">↳</span>{/if}
331
331
  {#if layerHasThumbnail(layer)}
332
332
  <span class="relative shrink-0">
333
333
  <canvas
@@ -339,12 +339,12 @@
339
339
  {#if layer.type !== 'pixel'}
340
340
  <span
341
341
  data-testid="layer-kind-badge"
342
- class="absolute -bottom-1 -right-1 rounded bg-slate-700 px-0.5 text-[8px] font-bold leading-tight text-slate-100 ring-1 ring-slate-900">{layerBadge(layer)}</span>
342
+ class="absolute -bottom-1 -right-1 rounded bg-accent px-0.5 text-[8px] font-bold leading-tight text-foreground ring-1 ring-background">{layerBadge(layer)}</span>
343
343
  {/if}
344
344
  </span>
345
345
  {:else}
346
346
  <span
347
- class="shrink-0 grid place-items-center rounded bg-slate-800 text-[9px] text-slate-400 ring-1 ring-slate-700"
347
+ class="shrink-0 grid place-items-center rounded bg-muted text-[9px] text-muted-foreground ring-1 ring-border"
348
348
  style="width: {thumbnailSize}px; height: {thumbnailSize}px">{layerBadge(layer)}</span>
349
349
  {/if}
350
350
  {#if layer.mask && !layer.mask.source && layer.type === 'pixel'}
@@ -354,7 +354,7 @@
354
354
  aria-pressed={layer.mask.linked !== false}
355
355
  title={layer.mask.linked === false ? 'Mask unlinked: it stays put when the layer moves or transforms. Click to link.' : 'Mask linked: it moves with the layer. Click to unlink.'}
356
356
  data-testid="layer-mask-link"
357
- class="shrink-0 text-[10px] leading-none {layer.mask.linked === false ? 'text-slate-600' : 'text-slate-300'}"
357
+ class="shrink-0 text-[10px] leading-none {layer.mask.linked === false ? 'text-muted-foreground/70' : 'text-foreground'}"
358
358
  onclick={(e) => {
359
359
  e.stopPropagation();
360
360
  attempt(() => controller.setLayerMaskProps(layer.id, { linked: layer.mask!.linked === false }));
@@ -382,7 +382,7 @@
382
382
  autofocus
383
383
  aria-label="Layer name"
384
384
  value={layer.name || row.label}
385
- class="flex-1 min-w-0 bg-slate-900 border border-slate-600 rounded px-1"
385
+ class="flex-1 min-w-0 bg-background border border-border rounded px-1"
386
386
  onclick={(e) => e.stopPropagation()}
387
387
  onblur={(e) => {
388
388
  const v = (e.target as HTMLInputElement).value;
@@ -397,14 +397,14 @@
397
397
  {:else}
398
398
  <!-- svelte-ignore a11y_no_static_element_interactions -->
399
399
  <span
400
- class="flex-1 min-w-0 truncate {layer.visible ? '' : 'text-slate-500'}"
400
+ class="flex-1 min-w-0 truncate {layer.visible ? '' : 'text-muted-foreground'}"
401
401
  ondblclick={(e) => {
402
402
  e.stopPropagation();
403
403
  renaming = layer.id;
404
404
  }}>{row.label}</span>
405
405
  {/if}
406
406
  {#if layerWarning(layer)}
407
- <span role="img" data-testid="layer-warning" aria-label={layerWarning(layer)} title={layerWarning(layer)} class="text-[10px] text-amber-400">⚠</span>
407
+ <span role="img" data-testid="layer-warning" aria-label={layerWarning(layer)} title={layerWarning(layer)} class="text-[10px] text-amber-600">⚠</span>
408
408
  {/if}
409
409
  {#if layerEffectsBadge(layer)}
410
410
  {@const fxOn = layerEffectsBadge(layer) === 'on'}
@@ -413,21 +413,21 @@
413
413
  data-testid="layer-fx-badge"
414
414
  aria-label={fxOn ? `Turn effects off for ${row.label}` : `Turn effects on for ${row.label}`}
415
415
  aria-pressed={fxOn}
416
- class="text-[9px] italic font-semibold {fxOn ? 'text-indigo-300' : 'text-slate-600 line-through'}"
416
+ class="text-[9px] italic font-semibold {fxOn ? 'text-primary' : 'text-muted-foreground/70 line-through'}"
417
417
  onclick={(e) => {
418
418
  e.stopPropagation();
419
419
  attempt(() => controller.setLayerEffectsEnabled(layer.id, !fxOn));
420
420
  }}>fx</button
421
421
  >
422
422
  {/if}
423
- {#if locked(layer)}<span aria-label="Locked" class="text-slate-400">🔒</span>{/if}
424
- {#if layer.blend !== 'normal' && layer.blend !== 'pass-through'}<span class="text-[9px] text-slate-500">{layer.blend}</span>{/if}
423
+ {#if locked(layer)}<span aria-label="Locked" class="text-muted-foreground">🔒</span>{/if}
424
+ {#if layer.blend !== 'normal' && layer.blend !== 'pass-through'}<span class="text-[9px] text-muted-foreground">{layer.blend}</span>{/if}
425
425
  </li>
426
426
  {/each}
427
427
  </ul>
428
428
 
429
429
  {#if error}
430
- <p role="alert" class="text-rose-400">{error}</p>
430
+ <p role="alert" class="text-destructive">{error}</p>
431
431
  {/if}
432
432
 
433
433
  {#if menu}
@@ -435,7 +435,7 @@
435
435
  role="menu"
436
436
  tabindex="-1"
437
437
  aria-label="Layer menu"
438
- class="fixed z-50 min-w-44 rounded border border-slate-700 bg-slate-900 py-1 shadow-xl"
438
+ class="fixed z-50 min-w-44 rounded border border-border bg-background py-1 shadow-xl"
439
439
  style="left: {menu.x}px; top: {menu.y}px"
440
440
  onclick={(e) => e.stopPropagation()}
441
441
  onkeydown={(e) => e.key === 'Escape' && (menu = null)}
@@ -445,7 +445,7 @@
445
445
  type="button"
446
446
  role="menuitem"
447
447
  disabled={!item.enabled}
448
- class="block w-full px-3 py-1 text-left hover:bg-slate-800 disabled:opacity-40"
448
+ class="block w-full px-3 py-1 text-left hover:bg-muted disabled:opacity-40"
449
449
  onclick={() => run(item.action)}>{item.label}</button>
450
450
  {/each}
451
451
  </div>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { imageRangeClass, imageSectionTitleClass } from './styles.js';
2
3
  import type {
3
4
  AdjustmentKind,
4
5
  AdjustmentLayer,
@@ -65,14 +66,14 @@
65
66
  }
66
67
 
67
68
  const button =
68
- 'px-2 py-1 rounded-md border border-slate-800 bg-slate-950 text-[11px] text-slate-200 hover:text-white disabled:opacity-40';
69
+ 'px-2 py-1 rounded-md border border-border bg-background text-[11px] text-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40';
69
70
  </script>
70
71
 
71
72
  <div data-testid="refine-edge-panel" class="flex flex-col gap-3 text-xs {className}">
72
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px]">Refine Edge</span>
73
+ <span class={imageSectionTitleClass}>Refine Edge</span>
73
74
  {#each REFINE_EDGE_CONTROLS as c (c.id)}
74
75
  <label class="flex flex-col gap-1">
75
- <span class="flex justify-between text-slate-400">
76
+ <span class="flex justify-between text-muted-foreground">
76
77
  <span>{c.label}</span>
77
78
  <span class="font-mono">{params[c.id]}{c.unit}</span>
78
79
  </span>
@@ -84,11 +85,11 @@
84
85
  aria-label={c.label}
85
86
  value={params[c.id]}
86
87
  oninput={(e) => (params = { ...params, [c.id]: Number((e.currentTarget as HTMLInputElement).value) })}
87
- class="w-full accent-indigo-500"
88
+ class={imageRangeClass}
88
89
  />
89
90
  </label>
90
91
  {/each}
91
- <label class="flex items-center gap-2 text-slate-300">
92
+ <label class="flex items-center gap-2 text-foreground">
92
93
  <input
93
94
  type="checkbox"
94
95
  checked={params.smartRadius}
@@ -101,16 +102,16 @@
101
102
  <button type="button" class={button} onclick={() => (params = { ...DEFAULT_PARAMS })}>Reset</button>
102
103
  </div>
103
104
  {#if error}
104
- <p role="alert" class="text-rose-400">{error}</p>
105
+ <p role="alert" class="text-destructive">{error}</p>
105
106
  {/if}
106
107
 
107
- <span class="font-semibold text-slate-200 uppercase tracking-wider text-[11px] mt-2">Layer Mask</span>
108
+ <span class="font-semibold text-foreground uppercase tracking-wider text-[11px] mt-2">Layer Mask</span>
108
109
  <div class="flex gap-1 items-center">
109
110
  <select
110
111
  aria-label="New masked layer kind"
111
112
  value={newKind}
112
113
  onchange={(e) => (newKind = (e.currentTarget as HTMLSelectElement).value as AdjustmentKind)}
113
- class="flex-1 bg-slate-950 border border-slate-800 rounded px-1 py-1 text-slate-200"
114
+ class="flex-1 bg-background border border-border rounded px-1 py-1 text-foreground"
114
115
  >
115
116
  {#each ADJUSTMENT_KINDS as k (k.kind)}
116
117
  <option value={k.kind}>{k.label}</option>
@@ -124,7 +125,7 @@
124
125
  aria-label="Mask layer"
125
126
  value={target.id}
126
127
  onchange={(e) => (layerId = (e.currentTarget as HTMLSelectElement).value)}
127
- class="bg-slate-950 border border-slate-800 rounded px-1 py-1 text-slate-200"
128
+ class="bg-background border border-border rounded px-1 py-1 text-foreground"
128
129
  >
129
130
  {#each list as l (l.id)}
130
131
  <option value={l.id}>{l.name || adjustmentKindLabel(l.kind)}{l.mask ? ' (masked)' : ''}</option>
@@ -143,7 +144,7 @@
143
144
  </div>
144
145
  {#if target.mask}
145
146
  <label class="flex flex-col gap-1">
146
- <span class="flex justify-between text-slate-400">
147
+ <span class="flex justify-between text-muted-foreground">
147
148
  <span>Density</span>
148
149
  <span class="font-mono">{Math.round((target.mask.density ?? 1) * 100)}%</span>
149
150
  </span>
@@ -155,11 +156,11 @@
155
156
  aria-label="Mask density"
156
157
  value={Math.round((target.mask.density ?? 1) * 100)}
157
158
  oninput={(e) => controller.setAdjustmentLayerMaskProps(target.id, { density: Number((e.currentTarget as HTMLInputElement).value) / 100 })}
158
- class="w-full accent-indigo-500"
159
+ class={imageRangeClass}
159
160
  />
160
161
  </label>
161
162
  <label class="flex flex-col gap-1">
162
- <span class="flex justify-between text-slate-400">
163
+ <span class="flex justify-between text-muted-foreground">
163
164
  <span>Mask Feather</span>
164
165
  <span class="font-mono">{target.mask.feather ?? 0}px</span>
165
166
  </span>
@@ -171,7 +172,7 @@
171
172
  aria-label="Mask feather"
172
173
  value={target.mask.feather ?? 0}
173
174
  oninput={(e) => controller.setAdjustmentLayerMaskProps(target.id, { feather: Number((e.currentTarget as HTMLInputElement).value) })}
174
- class="w-full accent-indigo-500"
175
+ class={imageRangeClass}
175
176
  />
176
177
  </label>
177
178
  {/if}