@we-are-singular/svelte-chop-chop 0.1.0

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/dist/components/.gitkeep +0 -0
  4. package/dist/components/CircleStencil.svelte +126 -0
  5. package/dist/components/CircleStencil.svelte.d.ts +10 -0
  6. package/dist/components/CropOverlay.svelte +51 -0
  7. package/dist/components/CropOverlay.svelte.d.ts +8 -0
  8. package/dist/components/CropStencil.svelte +84 -0
  9. package/dist/components/CropStencil.svelte.d.ts +10 -0
  10. package/dist/components/Cropper.svelte +242 -0
  11. package/dist/components/Cropper.svelte.d.ts +32 -0
  12. package/dist/components/DragHandle.svelte +129 -0
  13. package/dist/components/DragHandle.svelte.d.ts +13 -0
  14. package/dist/components/FilterStrip.svelte +58 -0
  15. package/dist/components/FilterStrip.svelte.d.ts +9 -0
  16. package/dist/components/GridOverlay.svelte +85 -0
  17. package/dist/components/GridOverlay.svelte.d.ts +9 -0
  18. package/dist/components/ImageEditor.svelte +1087 -0
  19. package/dist/components/ImageEditor.svelte.d.ts +16 -0
  20. package/dist/components/Toolbar.svelte +103 -0
  21. package/dist/components/Toolbar.svelte.d.ts +7 -0
  22. package/dist/composables/.gitkeep +0 -0
  23. package/dist/composables/create-cropper.svelte.d.ts +49 -0
  24. package/dist/composables/create-cropper.svelte.js +257 -0
  25. package/dist/composables/create-image-editor.svelte.d.ts +20 -0
  26. package/dist/composables/create-image-editor.svelte.js +596 -0
  27. package/dist/composables/create-transform.svelte.d.ts +28 -0
  28. package/dist/composables/create-transform.svelte.js +26 -0
  29. package/dist/core/.gitkeep +0 -0
  30. package/dist/core/color-matrix.d.ts +39 -0
  31. package/dist/core/color-matrix.js +137 -0
  32. package/dist/core/constraints.d.ts +46 -0
  33. package/dist/core/constraints.js +107 -0
  34. package/dist/core/coordinate-system.d.ts +65 -0
  35. package/dist/core/coordinate-system.js +185 -0
  36. package/dist/core/crop-engine.svelte.d.ts +33 -0
  37. package/dist/core/crop-engine.svelte.js +192 -0
  38. package/dist/core/export.d.ts +14 -0
  39. package/dist/core/export.js +99 -0
  40. package/dist/core/history-manager.svelte.d.ts +22 -0
  41. package/dist/core/history-manager.svelte.js +72 -0
  42. package/dist/core/image-loader.svelte.d.ts +17 -0
  43. package/dist/core/image-loader.svelte.js +126 -0
  44. package/dist/core/interactions.d.ts +52 -0
  45. package/dist/core/interactions.js +118 -0
  46. package/dist/core/keyboard.d.ts +11 -0
  47. package/dist/core/keyboard.js +23 -0
  48. package/dist/core/transform-engine.svelte.d.ts +27 -0
  49. package/dist/core/transform-engine.svelte.js +79 -0
  50. package/dist/core/types.d.ts +265 -0
  51. package/dist/core/types.js +5 -0
  52. package/dist/index.d.ts +30 -0
  53. package/dist/index.js +35 -0
  54. package/dist/plugins/.gitkeep +0 -0
  55. package/dist/plugins/index.d.ts +8 -0
  56. package/dist/plugins/index.js +8 -0
  57. package/dist/plugins/plugin-filters.d.ts +14 -0
  58. package/dist/plugins/plugin-filters.js +100 -0
  59. package/dist/plugins/plugin-finetune.d.ts +10 -0
  60. package/dist/plugins/plugin-finetune.js +23 -0
  61. package/dist/plugins/plugin-frame.d.ts +11 -0
  62. package/dist/plugins/plugin-frame.js +81 -0
  63. package/dist/plugins/plugin-resize.d.ts +10 -0
  64. package/dist/plugins/plugin-resize.js +23 -0
  65. package/dist/plugins/plugin-watermark.d.ts +10 -0
  66. package/dist/plugins/plugin-watermark.js +86 -0
  67. package/dist/presets/.gitkeep +0 -0
  68. package/dist/presets/cover-photo.d.ts +14 -0
  69. package/dist/presets/cover-photo.js +14 -0
  70. package/dist/presets/index.d.ts +6 -0
  71. package/dist/presets/index.js +6 -0
  72. package/dist/presets/product-image.d.ts +11 -0
  73. package/dist/presets/product-image.js +11 -0
  74. package/dist/presets/profile-picture.d.ts +17 -0
  75. package/dist/presets/profile-picture.js +17 -0
  76. package/dist/themes/.gitkeep +0 -0
  77. package/dist/themes/dark.css +17 -0
  78. package/dist/themes/default.css +23 -0
  79. package/dist/themes/minimal.css +17 -0
  80. package/package.json +118 -0
@@ -0,0 +1,1087 @@
1
+ <!--
2
+ svelte-chop-chop — Full-featured image editor
3
+ Minimal floating-toolbar layout: canvas fills the editor, controls float above.
4
+ -->
5
+ <script lang="ts">
6
+ import type { AspectRatio, AspectRatioPreset, ImageSource, Point } from "../core/types.js";
7
+ import { createImageEditor } from "../composables/create-image-editor.svelte.js";
8
+ import FilterStrip from "./FilterStrip.svelte";
9
+ import CropStencil from "./CropStencil.svelte";
10
+ import CircleStencil from "./CircleStencil.svelte";
11
+ import CropOverlay from "./CropOverlay.svelte";
12
+
13
+ let {
14
+ src,
15
+ plugins = [],
16
+ shape = $bindable("rect"),
17
+ aspectRatio = $bindable(null),
18
+ aspectRatioPresets,
19
+ exportDefaults,
20
+ onexport,
21
+ style = "",
22
+ class: className = "",
23
+ ...rest
24
+ }: {
25
+ src?: ImageSource;
26
+ plugins?: import("../core/types.js").ChopPlugin[];
27
+ shape?: "rect" | "circle";
28
+ aspectRatio?: AspectRatio;
29
+ aspectRatioPresets?: AspectRatioPreset[];
30
+ exportDefaults?: import("../core/types.js").ExportOptions;
31
+ onexport?: (result: import("../core/types.js").ExportResult) => void | Promise<void>;
32
+ style?: string;
33
+ class?: string;
34
+ [key: string]: unknown;
35
+ } = $props();
36
+
37
+ const defaultAspectPresets: AspectRatioPreset[] = [
38
+ { label: "Free", value: null },
39
+ { label: "1:1", value: 1 },
40
+ { label: "4:3", value: 4 / 3 },
41
+ { label: "3:2", value: 3 / 2 },
42
+ { label: "16:9", value: 16 / 9 },
43
+ { label: "9:16", value: 9 / 16 },
44
+ ];
45
+
46
+ const arPresets = $derived(aspectRatioPresets ?? defaultAspectPresets);
47
+
48
+ const editor = createImageEditor({
49
+ get src() { return src; },
50
+ get plugins() { return plugins; },
51
+ get aspectRatio() { return aspectRatio; },
52
+ get exportDefaults() { return exportDefaults; },
53
+ });
54
+
55
+ let viewportEl = $state<HTMLElement | undefined>();
56
+ let canvasEl = $state<HTMLCanvasElement | undefined>();
57
+ let stencilActive = $state(false);
58
+
59
+ $effect(() => { const el = viewportEl; if (el) editor.bindContainer(el); });
60
+ $effect(() => { const el = canvasEl; if (el) editor.bindCanvas(el); });
61
+ $effect(() => { return () => editor.destroy(); });
62
+ $effect(() => { editor.setAspectRatio(aspectRatio ?? null); });
63
+
64
+ async function handleExport() {
65
+ const result = await editor.export({ ...exportDefaults, shape });
66
+ await onexport?.(result);
67
+ }
68
+
69
+ function setShape(s: "rect" | "circle") {
70
+ shape = s;
71
+ if (s === "circle") aspectRatio = 1;
72
+ }
73
+
74
+ /** Plugin tab action buttons */
75
+ const tabActions = $derived(editor.actions.filter((a) => a.group === "tabs"));
76
+
77
+ /** CSS transform for rotation + flip only (keeps handles crisp). */
78
+ const viewportCSSTransform = $derived((() => {
79
+ const { rotation, flipX, flipY } = editor.transforms;
80
+ const sx = flipX ? -1 : 1;
81
+ const sy = flipY ? -1 : 1;
82
+ return rotation !== 0 || flipX || flipY
83
+ ? `rotate(${rotation}deg) scale(${sx}, ${sy})`
84
+ : "none";
85
+ })());
86
+
87
+ function invertDelta(delta: Point): Point {
88
+ const { rotation, flipX, flipY } = editor.transforms;
89
+ let x = delta.x * (flipX ? -1 : 1);
90
+ let y = delta.y * (flipY ? -1 : 1);
91
+ if (rotation % 360 !== 0) {
92
+ const rad = -(rotation * Math.PI) / 180;
93
+ return { x: x * Math.cos(rad) - y * Math.sin(rad), y: x * Math.sin(rad) + y * Math.cos(rad) };
94
+ }
95
+ return { x, y };
96
+ }
97
+
98
+ // ── Live overlay scaling ──────────────────────────────────
99
+
100
+ const framePreviewWidth = $derived(
101
+ editor.image && editor.imageRect.width > 0
102
+ ? Math.max(1, Math.round(editor.frameSettings.width * (editor.imageRect.width / editor.image.naturalWidth)))
103
+ : 0
104
+ );
105
+
106
+ const wmFontSizePreview = $derived(
107
+ editor.image && editor.imageRect.width > 0
108
+ ? Math.max(8, Math.round(editor.watermarkSettings.fontSize * (editor.imageRect.width / editor.image.naturalWidth)))
109
+ : editor.watermarkSettings.fontSize
110
+ );
111
+
112
+ // ── Panel static data ─────────────────────────────────────
113
+
114
+ const finetuneControls = [
115
+ { key: "brightness" as const, label: "Brightness", min: -100, max: 100, step: 1 },
116
+ { key: "contrast" as const, label: "Contrast", min: -100, max: 100, step: 1 },
117
+ { key: "saturation" as const, label: "Saturation", min: -100, max: 100, step: 1 },
118
+ { key: "exposure" as const, label: "Exposure", min: -100, max: 100, step: 1 },
119
+ { key: "temperature" as const, label: "Temperature", min: -100, max: 100, step: 1 },
120
+ { key: "clarity" as const, label: "Clarity", min: 0, max: 100, step: 1 },
121
+ { key: "gamma" as const, label: "Gamma", min: 0.1, max: 5, step: 0.05 },
122
+ ];
123
+
124
+ const frameTypes = ["none", "solid", "line", "hook"] as const;
125
+ const watermarkPositions = [
126
+ "top-left", "top-center", "top-right",
127
+ "center",
128
+ "bottom-left", "bottom-center", "bottom-right",
129
+ ] as const;
130
+
131
+ /** Label shown in the aspect-ratio picker button */
132
+ const arLabel = $derived(arPresets.find((p) => p.value === aspectRatio)?.label ?? "Free");
133
+ </script>
134
+
135
+ <svelte:head>
136
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" />
137
+ </svelte:head>
138
+
139
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
140
+ <div
141
+ class="chop-editor {className}"
142
+ {style}
143
+ role="application"
144
+ aria-label="Image editor"
145
+ tabindex="0"
146
+ onkeydown={editor.handleKeyboard}
147
+ {...rest}
148
+ >
149
+ {#if editor.loading}
150
+ <div class="chop-state-msg">Loading…</div>
151
+ {:else if editor.error}
152
+ <div class="chop-state-msg chop-state-error">{editor.error.message}</div>
153
+ {:else if !editor.image}
154
+ <div class="chop-state-msg">No image loaded</div>
155
+ {:else}
156
+
157
+ <!-- ── Stage: image viewport only ───────────────────────── -->
158
+ <div class="chop-stage">
159
+
160
+ <!-- ── Full-area canvas viewport ──────────────────────── -->
161
+ <div class="chop-viewport" bind:this={viewportEl}>
162
+ <div
163
+ class="chop-transform-layer"
164
+ style:transform={viewportCSSTransform}
165
+ style:transform-origin="center center"
166
+ >
167
+ <canvas bind:this={canvasEl} class="chop-canvas" aria-label="Image preview"></canvas>
168
+
169
+ {#if editor.imageRect.width > 0}
170
+ {#if shape === "circle"}
171
+ <CropOverlay rect={editor.crop.viewport} imageBounds={editor.imageRect} />
172
+ <CircleStencil
173
+ rect={editor.crop.viewport}
174
+ aspectRatio={1}
175
+ active={stencilActive}
176
+ imageBounds={editor.imageRect}
177
+ grid="none"
178
+ transitions={false}
179
+ onmove={(delta) => editor.moveBy(invertDelta(delta))}
180
+ onresize={(handle, delta) => editor.resizeBy(handle, invertDelta(delta))}
181
+ onresizestart={() => { stencilActive = true; editor.setInteracting(true); }}
182
+ onresizeend={() => { stencilActive = false; editor.setInteracting(false); }}
183
+ />
184
+ {:else}
185
+ <CropOverlay rect={editor.crop.viewport} imageBounds={editor.imageRect} />
186
+ <CropStencil
187
+ rect={editor.crop.viewport}
188
+ aspectRatio={aspectRatio ?? null}
189
+ active={stencilActive}
190
+ imageBounds={editor.imageRect}
191
+ grid="rule-of-thirds"
192
+ transitions={false}
193
+ onmove={(delta) => editor.moveBy(invertDelta(delta))}
194
+ onresize={(handle, delta) => editor.resizeBy(handle, invertDelta(delta))}
195
+ onresizestart={() => { stencilActive = true; editor.setInteracting(true); }}
196
+ onresizeend={() => { stencilActive = false; editor.setInteracting(false); }}
197
+ />
198
+ {/if}
199
+ {/if}
200
+ </div>
201
+
202
+ <!-- Live frame preview (viewport-space, outside transform layer) -->
203
+ {#if editor.imageRect.width > 0 && editor.frameSettings.type !== "none" && framePreviewWidth > 0}
204
+ {#if editor.frameSettings.type === "hook"}
205
+ {@const bw = framePreviewWidth}
206
+ {@const len = Math.max(12, bw * 5)}
207
+ {@const c = editor.frameSettings.color}
208
+ <div class="chop-frame-hook"
209
+ style:left="{editor.imageRect.x}px" style:top="{editor.imageRect.y}px"
210
+ style:width="{editor.imageRect.width}px" style:height="{editor.imageRect.height}px"
211
+ aria-hidden="true">
212
+ <div class="chop-hook-corner" style="top:0;left:0;width:{len}px;height:{len}px;border-top:{bw}px solid {c};border-left:{bw}px solid {c}"></div>
213
+ <div class="chop-hook-corner" style="top:0;right:0;width:{len}px;height:{len}px;border-top:{bw}px solid {c};border-right:{bw}px solid {c}"></div>
214
+ <div class="chop-hook-corner" style="bottom:0;left:0;width:{len}px;height:{len}px;border-bottom:{bw}px solid {c};border-left:{bw}px solid {c}"></div>
215
+ <div class="chop-hook-corner" style="bottom:0;right:0;width:{len}px;height:{len}px;border-bottom:{bw}px solid {c};border-right:{bw}px solid {c}"></div>
216
+ </div>
217
+ {:else}
218
+ <div class="chop-frame-box"
219
+ style:left="{editor.imageRect.x}px" style:top="{editor.imageRect.y}px"
220
+ style:width="{editor.imageRect.width}px" style:height="{editor.imageRect.height}px"
221
+ style:box-shadow="inset 0 0 0 {framePreviewWidth}px {editor.frameSettings.color}"
222
+ aria-hidden="true"></div>
223
+ {/if}
224
+ {/if}
225
+
226
+ <!-- Live watermark preview (viewport-space, outside transform layer) -->
227
+ {#if editor.imageRect.width > 0 && editor.watermarkSettings.text.trim()}
228
+ {@const pos = editor.watermarkSettings.position}
229
+ <div class="chop-wm-preview"
230
+ class:chop-wm-tl={pos === "top-left"}
231
+ class:chop-wm-tc={pos === "top-center"}
232
+ class:chop-wm-tr={pos === "top-right"}
233
+ class:chop-wm-c={pos === "center"}
234
+ class:chop-wm-bl={pos === "bottom-left"}
235
+ class:chop-wm-bc={pos === "bottom-center"}
236
+ class:chop-wm-br={pos === "bottom-right"}
237
+ style:left="{editor.imageRect.x}px" style:top="{editor.imageRect.y}px"
238
+ style:width="{editor.imageRect.width}px" style:height="{editor.imageRect.height}px"
239
+ style:opacity={editor.watermarkSettings.opacity}
240
+ aria-hidden="true">
241
+ <span class="chop-wm-text"
242
+ style:color={editor.watermarkSettings.color}
243
+ style:font-size="{wmFontSizePreview}px"
244
+ >{editor.watermarkSettings.text}</span>
245
+ </div>
246
+ {/if}
247
+ </div><!-- /chop-viewport -->
248
+
249
+ <!-- ── Floating: history pill (top-left) ──────────────── -->
250
+ <div class="chop-hist-pill" role="toolbar" aria-label="History">
251
+ <button type="button" class="chop-pill-btn"
252
+ disabled={!editor.canUndo}
253
+ onclick={() => editor.undo()}
254
+ aria-label="Undo" title="Undo">
255
+ <span class="material-symbols-rounded" aria-hidden="true">undo</span>
256
+ </button>
257
+ <button type="button" class="chop-pill-btn"
258
+ disabled={!editor.canRedo}
259
+ onclick={() => editor.redo()}
260
+ aria-label="Redo" title="Redo">
261
+ <span class="material-symbols-rounded" aria-hidden="true">redo</span>
262
+ </button>
263
+ </div>
264
+
265
+ <!-- ── Floating: Done button (top-right) ──────────────── -->
266
+ <button type="button" class="chop-done-btn" onclick={handleExport} aria-label="Export image">
267
+ Done
268
+ </button>
269
+
270
+ </div><!-- /chop-stage -->
271
+
272
+ <!-- ── Plugin panel area (outside image, below stage) ─── -->
273
+ {#if editor.activeTab}
274
+ <div class="chop-panel-area" role="region" aria-label="Settings">
275
+
276
+ {#if editor.activeTab === "filters"}
277
+ <div class="chop-panel chop-panel--strip">
278
+ <FilterStrip
279
+ filters={editor.filterPresets.length ? editor.filterPresets : []}
280
+ selected={editor.filters.preset}
281
+ onselect={(name) => editor.applyFilter(name)}
282
+ />
283
+ </div>
284
+
285
+ {:else if editor.activeTab === "finetune"}
286
+ <div class="chop-panel chop-panel--form">
287
+ {#each finetuneControls as ctrl (ctrl.key)}
288
+ <label class="chop-slider-row">
289
+ <span class="chop-slider-label">{ctrl.label}</span>
290
+ <div class="chop-slider-track-wrap">
291
+ <input type="range" class="chop-slider"
292
+ min={ctrl.min} max={ctrl.max} step={ctrl.step}
293
+ value={editor.filters[ctrl.key]}
294
+ style:--pct="{((Number(editor.filters[ctrl.key]) - ctrl.min) / (ctrl.max - ctrl.min)) * 100}%"
295
+ oninput={(e) => editor.setFinetune(ctrl.key, Number((e.target as HTMLInputElement).value))}
296
+ />
297
+ </div>
298
+ <span class="chop-slider-value">
299
+ {ctrl.key === "gamma"
300
+ ? Number(editor.filters[ctrl.key]).toFixed(2)
301
+ : editor.filters[ctrl.key]}
302
+ </span>
303
+ </label>
304
+ {/each}
305
+ <div class="chop-panel-footer">
306
+ <button type="button" class="chop-btn-reset" onclick={() => editor.resetFilters()}>
307
+ <span class="material-symbols-rounded" aria-hidden="true">restart_alt</span>
308
+ Reset
309
+ </button>
310
+ </div>
311
+ </div>
312
+
313
+ {:else if editor.activeTab === "frame"}
314
+ <div class="chop-panel chop-panel--form">
315
+ <div class="chop-ctrl-row">
316
+ <span class="chop-ctrl-label">Style</span>
317
+ <div class="chop-toggle-group">
318
+ {#each frameTypes as ft}
319
+ <button type="button" class="chop-toggle-btn"
320
+ class:chop-toggle-btn--active={editor.frameSettings.type === ft}
321
+ onclick={() => editor.setFrame({ type: ft })}
322
+ aria-pressed={editor.frameSettings.type === ft}
323
+ >{ft.charAt(0).toUpperCase() + ft.slice(1)}</button>
324
+ {/each}
325
+ </div>
326
+ </div>
327
+ {#if editor.frameSettings.type !== "none"}
328
+ <label class="chop-slider-row">
329
+ <span class="chop-slider-label">Width</span>
330
+ <div class="chop-slider-track-wrap">
331
+ <input type="range" class="chop-slider" min="1" max="80" step="1"
332
+ value={editor.frameSettings.width}
333
+ style:--pct="{((editor.frameSettings.width - 1) / 79) * 100}%"
334
+ oninput={(e) => editor.setFrame({ width: Number((e.target as HTMLInputElement).value) })}
335
+ />
336
+ </div>
337
+ <span class="chop-slider-value">{editor.frameSettings.width}px</span>
338
+ </label>
339
+ <div class="chop-ctrl-row">
340
+ <span class="chop-ctrl-label">Color</span>
341
+ <label class="chop-color-wrap">
342
+ <span class="chop-color-swatch" style:background={editor.frameSettings.color}></span>
343
+ <span class="chop-color-hex">{editor.frameSettings.color}</span>
344
+ <input type="color" class="chop-color-input"
345
+ value={editor.frameSettings.color}
346
+ oninput={(e) => editor.setFrame({ color: (e.target as HTMLInputElement).value })}
347
+ aria-label="Frame color"
348
+ />
349
+ </label>
350
+ </div>
351
+ {/if}
352
+ </div>
353
+
354
+ {:else if editor.activeTab === "watermark"}
355
+ <div class="chop-panel chop-panel--form">
356
+ <label class="chop-ctrl-row">
357
+ <span class="chop-ctrl-label">Text</span>
358
+ <input type="text" class="chop-text-input"
359
+ placeholder="Your watermark…"
360
+ value={editor.watermarkSettings.text}
361
+ oninput={(e) => editor.setWatermark({ text: (e.target as HTMLInputElement).value })}
362
+ aria-label="Watermark text"
363
+ />
364
+ </label>
365
+ <div class="chop-ctrl-row">
366
+ <span class="chop-ctrl-label">Position</span>
367
+ <div class="chop-wm-grid" role="group" aria-label="Watermark position">
368
+ {#each watermarkPositions as pos}
369
+ <button type="button" class="chop-wm-pos-btn"
370
+ class:chop-wm-pos-btn--active={editor.watermarkSettings.position === pos}
371
+ onclick={() => editor.setWatermark({ position: pos })}
372
+ aria-label={pos.replace(/-/g, " ")}
373
+ aria-pressed={editor.watermarkSettings.position === pos}
374
+ ><span class="chop-wm-pos-dot"></span></button>
375
+ {/each}
376
+ </div>
377
+ </div>
378
+ <label class="chop-slider-row">
379
+ <span class="chop-slider-label">Opacity</span>
380
+ <div class="chop-slider-track-wrap">
381
+ <input type="range" class="chop-slider" min="0" max="1" step="0.05"
382
+ value={editor.watermarkSettings.opacity}
383
+ style:--pct="{editor.watermarkSettings.opacity * 100}%"
384
+ oninput={(e) => editor.setWatermark({ opacity: Number((e.target as HTMLInputElement).value) })}
385
+ />
386
+ </div>
387
+ <span class="chop-slider-value">{Math.round(editor.watermarkSettings.opacity * 100)}%</span>
388
+ </label>
389
+ <label class="chop-slider-row">
390
+ <span class="chop-slider-label">Size</span>
391
+ <div class="chop-slider-track-wrap">
392
+ <input type="range" class="chop-slider" min="8" max="96" step="1"
393
+ value={editor.watermarkSettings.fontSize}
394
+ style:--pct="{((editor.watermarkSettings.fontSize - 8) / 88) * 100}%"
395
+ oninput={(e) => editor.setWatermark({ fontSize: Number((e.target as HTMLInputElement).value) })}
396
+ />
397
+ </div>
398
+ <span class="chop-slider-value">{editor.watermarkSettings.fontSize}px</span>
399
+ </label>
400
+ <div class="chop-ctrl-row">
401
+ <span class="chop-ctrl-label">Color</span>
402
+ <label class="chop-color-wrap">
403
+ <span class="chop-color-swatch" style:background={editor.watermarkSettings.color}></span>
404
+ <span class="chop-color-hex">{editor.watermarkSettings.color}</span>
405
+ <input type="color" class="chop-color-input"
406
+ value={editor.watermarkSettings.color}
407
+ oninput={(e) => editor.setWatermark({ color: (e.target as HTMLInputElement).value })}
408
+ aria-label="Watermark color"
409
+ />
410
+ </label>
411
+ </div>
412
+ </div>
413
+ {/if}
414
+
415
+ </div><!-- /chop-panel-area -->
416
+ {/if}
417
+
418
+ <!-- ── Bottom toolbar row ────────────────────────────── -->
419
+ <div class="chop-bottombar">
420
+
421
+ <!-- Rotate: bare icon buttons, left side -->
422
+ <div class="chop-rotate-grp" role="group" aria-label="Rotate">
423
+ <button type="button" class="chop-bare-btn"
424
+ onclick={() => editor.rotate(-90)}
425
+ aria-label="Rotate left 90°" title="Rotate left">
426
+ <span class="material-symbols-rounded" aria-hidden="true">rotate_left</span>
427
+ </button>
428
+ <button type="button" class="chop-bare-btn"
429
+ onclick={() => editor.rotate(90)}
430
+ aria-label="Rotate right 90°" title="Rotate right">
431
+ <span class="material-symbols-rounded" aria-hidden="true">rotate_right</span>
432
+ </button>
433
+ </div>
434
+
435
+ <!-- Tool tabs pill — centered -->
436
+ <div class="chop-tool-pill" role="toolbar" aria-label="Tools">
437
+ <button type="button" class="chop-pill-tab"
438
+ class:chop-pill-tab--active={!editor.activeTab}
439
+ onclick={() => editor.setActiveTab(null)}
440
+ aria-label="Crop" title="Crop" aria-pressed={!editor.activeTab}>
441
+ <span class="material-symbols-rounded" aria-hidden="true">crop</span>
442
+ </button>
443
+ {#each tabActions as action (action.id)}
444
+ {@const panelName = action.id.replace("-tab", "")}
445
+ {@const isActive = editor.activeTab === panelName}
446
+ <button type="button" class="chop-pill-tab"
447
+ class:chop-pill-tab--active={isActive}
448
+ onclick={() => action.execute()}
449
+ aria-label={action.label} aria-pressed={isActive} title={action.label}
450
+ >
451
+ {#if action.id === "finetune-tab"}
452
+ <span class="material-symbols-rounded" aria-hidden="true">tune</span>
453
+ {:else if action.id === "filters-tab"}
454
+ <span class="material-symbols-rounded" aria-hidden="true">photo_filter</span>
455
+ {:else if action.id === "frame-tab"}
456
+ <span class="material-symbols-rounded" aria-hidden="true">border_all</span>
457
+ {:else if action.id === "watermark-tab"}
458
+ <span class="material-symbols-rounded" aria-hidden="true">text_fields</span>
459
+ {:else}
460
+ <span class="material-symbols-rounded" aria-hidden="true">star</span>
461
+ {/if}
462
+ </button>
463
+ {/each}
464
+ </div>
465
+
466
+ <!-- Right controls: shape + AR -->
467
+ <div class="chop-right-controls">
468
+ <div class="chop-shape-pill" role="group" aria-label="Crop shape">
469
+ <button type="button" class="chop-pill-tab"
470
+ class:chop-pill-tab--active={shape === "rect"}
471
+ onclick={() => setShape("rect")}
472
+ aria-label="Rectangle crop" title="Rectangle"
473
+ aria-pressed={shape === "rect"}>
474
+ <span class="material-symbols-rounded" aria-hidden="true">crop_square</span>
475
+ </button>
476
+ <button type="button" class="chop-pill-tab"
477
+ class:chop-pill-tab--active={shape === "circle"}
478
+ onclick={() => setShape("circle")}
479
+ aria-label="Circle crop" title="Circle"
480
+ aria-pressed={shape === "circle"}>
481
+ <span class="material-symbols-rounded" aria-hidden="true">circle</span>
482
+ </button>
483
+ </div>
484
+
485
+ <div class="chop-ar-pill" title="Aspect ratio">
486
+ <span class="material-symbols-rounded chop-ar-icon" aria-hidden="true">aspect_ratio</span>
487
+ <span class="chop-ar-label" aria-hidden="true">{arLabel}</span>
488
+ <span class="material-symbols-rounded chop-ar-caret" aria-hidden="true">expand_more</span>
489
+ <select class="chop-ar-select"
490
+ disabled={shape === "circle"}
491
+ aria-label="Aspect ratio"
492
+ value={aspectRatio}
493
+ onchange={(e) => {
494
+ const v = (e.target as HTMLSelectElement).value;
495
+ aspectRatio = v === "null" ? null : Number(v);
496
+ }}
497
+ >
498
+ {#each arPresets as p (p.label)}
499
+ <option value={p.value ?? "null"}>{p.label}</option>
500
+ {/each}
501
+ </select>
502
+ </div>
503
+ </div>
504
+
505
+ </div><!-- /chop-bottombar -->
506
+
507
+ {/if}
508
+ </div>
509
+
510
+ <style>
511
+ /* ── Material Symbols config ─────────────────────────── */
512
+ .material-symbols-rounded {
513
+ font-family: 'Material Symbols Rounded', sans-serif;
514
+ font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
515
+ font-size: 20px;
516
+ line-height: 1;
517
+ letter-spacing: normal;
518
+ text-transform: none;
519
+ display: inline-block;
520
+ white-space: nowrap;
521
+ word-wrap: normal;
522
+ direction: ltr;
523
+ -webkit-font-smoothing: antialiased;
524
+ font-feature-settings: 'liga';
525
+ user-select: none;
526
+ }
527
+
528
+ /* ── Shell ─────────────────────────────────────────────── */
529
+ .chop-editor {
530
+ position: relative;
531
+ display: flex;
532
+ flex-direction: column;
533
+ background: var(--chop-bg, #0e0e0e);
534
+ border-radius: var(--chop-border-radius, 0);
535
+ overflow: hidden;
536
+ outline: none;
537
+ user-select: none;
538
+ color: var(--chop-color, #e0e0e0);
539
+ font-size: 0.82rem;
540
+ }
541
+
542
+ /* ── Stage: fills remaining vertical space ───────────── */
543
+ .chop-stage {
544
+ position: relative;
545
+ flex: 1;
546
+ min-height: 0;
547
+ overflow: hidden;
548
+ }
549
+
550
+ /* ── Full-area viewport (fills the stage) ────────────── */
551
+ .chop-viewport {
552
+ position: absolute;
553
+ inset: 0;
554
+ background: var(--chop-canvas-bg, #111);
555
+ }
556
+
557
+ .chop-transform-layer {
558
+ position: absolute;
559
+ inset: 0;
560
+ will-change: transform;
561
+ }
562
+
563
+ .chop-canvas {
564
+ position: absolute;
565
+ inset: 0;
566
+ width: 100%;
567
+ height: 100%;
568
+ display: block;
569
+ }
570
+
571
+ /* ── Live frame overlay ──────────────────────────────── */
572
+ .chop-frame-box,
573
+ .chop-frame-hook {
574
+ position: absolute;
575
+ pointer-events: none;
576
+ z-index: 8;
577
+ box-sizing: border-box;
578
+ }
579
+
580
+ .chop-hook-corner {
581
+ position: absolute;
582
+ box-sizing: border-box;
583
+ }
584
+
585
+ /* ── Live watermark overlay ──────────────────────────── */
586
+ .chop-wm-preview {
587
+ position: absolute;
588
+ pointer-events: none;
589
+ z-index: 9;
590
+ overflow: hidden;
591
+ box-sizing: border-box;
592
+ }
593
+
594
+ .chop-wm-text {
595
+ position: absolute;
596
+ font-weight: 700;
597
+ font-family: sans-serif;
598
+ text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.65);
599
+ white-space: nowrap;
600
+ line-height: 1.2;
601
+ }
602
+
603
+ .chop-wm-tl :global(.chop-wm-text) { top: 5%; left: 5%; }
604
+ .chop-wm-tc :global(.chop-wm-text) { top: 5%; left: 50%; transform: translateX(-50%); }
605
+ .chop-wm-tr :global(.chop-wm-text) { top: 5%; right: 5%; }
606
+ .chop-wm-c :global(.chop-wm-text) { top: 50%; left: 50%; transform: translate(-50%, -50%); }
607
+ .chop-wm-bl :global(.chop-wm-text) { bottom: 5%; left: 5%; }
608
+ .chop-wm-bc :global(.chop-wm-text) { bottom: 5%; left: 50%; transform: translateX(-50%); }
609
+ .chop-wm-br :global(.chop-wm-text) { bottom: 5%; right: 5%; }
610
+
611
+ /* ── History pill — floats inside stage (top-left) ───── */
612
+ .chop-hist-pill {
613
+ position: absolute;
614
+ top: 14px;
615
+ left: 14px;
616
+ z-index: 20;
617
+ display: flex;
618
+ align-items: center;
619
+ padding: 4px;
620
+ background: rgba(0, 0, 0, 0.25);
621
+ border: 1px solid rgba(255, 255, 255, 0.07);
622
+ border-radius: 30px;
623
+ backdrop-filter: blur(16px);
624
+ -webkit-backdrop-filter: blur(16px);
625
+ }
626
+
627
+ .chop-pill-btn {
628
+ display: flex;
629
+ align-items: center;
630
+ justify-content: center;
631
+ width: 34px;
632
+ height: 34px;
633
+ padding: 0;
634
+ border: 1px solid transparent;
635
+ border-radius: 30px;
636
+ background: transparent;
637
+ color: rgba(255, 255, 255, 0.5);
638
+ cursor: pointer;
639
+ transition: background 0.12s, color 0.12s;
640
+ }
641
+
642
+ .chop-pill-btn .material-symbols-rounded { font-size: 18px; }
643
+
644
+ .chop-pill-btn:hover:not(:disabled) {
645
+ background: rgba(255, 255, 255, 0.08);
646
+ color: #fff;
647
+ }
648
+
649
+ .chop-pill-btn:disabled { opacity: 0.28; cursor: not-allowed; }
650
+
651
+ /* ── Done button — floats inside stage (top-right) ───── */
652
+ .chop-done-btn {
653
+ position: absolute;
654
+ top: 12px;
655
+ right: 12px;
656
+ z-index: 20;
657
+ display: flex;
658
+ align-items: center;
659
+ gap: 5px;
660
+ padding: 0.4rem 1.1rem;
661
+ border: 1px solid rgba(255, 185, 0, 0.35);
662
+ border-radius: 30px;
663
+ background: rgba(240, 180, 41, 0.18);
664
+ color: #f0b429;
665
+ font-weight: 600;
666
+ font-size: 0.82rem;
667
+ cursor: pointer;
668
+ letter-spacing: 0.02em;
669
+ backdrop-filter: blur(16px);
670
+ -webkit-backdrop-filter: blur(16px);
671
+ transition: background 0.14s, color 0.14s, border-color 0.14s;
672
+ }
673
+
674
+ .chop-done-btn:hover {
675
+ background: #f0b429;
676
+ color: #000;
677
+ border-color: transparent;
678
+ }
679
+
680
+ /* ── Plugin panel area (outside stage, between stage & bar) */
681
+ .chop-panel-area {
682
+ flex-shrink: 0;
683
+ background: rgba(14, 14, 16, 0.96);
684
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
685
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
686
+ }
687
+
688
+ /* Base panel — strip layout (filters) */
689
+ .chop-panel {
690
+ width: 100%;
691
+ }
692
+
693
+ .chop-panel--form {
694
+ display: grid;
695
+ grid-template-columns: 1fr 1fr;
696
+ gap: 0 2rem;
697
+ padding: 1rem 1.5rem;
698
+ align-items: start;
699
+ }
700
+
701
+ /* Form rows */
702
+ .chop-ctrl-row {
703
+ display: flex;
704
+ align-items: center;
705
+ gap: 0.75rem;
706
+ min-height: 38px;
707
+ }
708
+
709
+ .chop-ctrl-label {
710
+ width: 72px;
711
+ font-size: 0.75rem;
712
+ font-weight: 500;
713
+ letter-spacing: 0.04em;
714
+ text-transform: uppercase;
715
+ color: rgba(255, 255, 255, 0.35);
716
+ flex-shrink: 0;
717
+ }
718
+
719
+ .chop-slider-row {
720
+ display: flex;
721
+ align-items: center;
722
+ gap: 0.75rem;
723
+ min-height: 38px;
724
+ }
725
+
726
+ .chop-slider-label {
727
+ width: 72px;
728
+ font-size: 0.75rem;
729
+ font-weight: 500;
730
+ letter-spacing: 0.04em;
731
+ text-transform: uppercase;
732
+ color: rgba(255, 255, 255, 0.35);
733
+ flex-shrink: 0;
734
+ }
735
+
736
+ /* Custom slider track wrapper */
737
+ .chop-slider-track-wrap {
738
+ flex: 1;
739
+ display: flex;
740
+ align-items: center;
741
+ }
742
+
743
+ .chop-slider {
744
+ -webkit-appearance: none;
745
+ appearance: none;
746
+ width: 100%;
747
+ height: 3px;
748
+ border-radius: 99px;
749
+ outline: none;
750
+ cursor: pointer;
751
+ background: linear-gradient(
752
+ to right,
753
+ rgba(255, 255, 255, 0.7) 0%,
754
+ rgba(255, 255, 255, 0.7) var(--pct, 50%),
755
+ rgba(255, 255, 255, 0.12) var(--pct, 50%),
756
+ rgba(255, 255, 255, 0.12) 100%
757
+ );
758
+ transition: background 0s;
759
+ }
760
+
761
+ .chop-slider::-webkit-slider-thumb {
762
+ -webkit-appearance: none;
763
+ width: 14px;
764
+ height: 14px;
765
+ border-radius: 50%;
766
+ background: #fff;
767
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
768
+ cursor: pointer;
769
+ transition: transform 0.1s;
770
+ }
771
+
772
+ .chop-slider::-moz-range-thumb {
773
+ width: 14px;
774
+ height: 14px;
775
+ border: none;
776
+ border-radius: 50%;
777
+ background: #fff;
778
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
779
+ cursor: pointer;
780
+ }
781
+
782
+ .chop-slider:hover::-webkit-slider-thumb { transform: scale(1.2); }
783
+ .chop-slider:active::-webkit-slider-thumb { transform: scale(1.1); }
784
+
785
+ .chop-slider-value {
786
+ width: 40px;
787
+ text-align: right;
788
+ font-size: 0.78rem;
789
+ color: rgba(255, 255, 255, 0.55);
790
+ font-variant-numeric: tabular-nums;
791
+ flex-shrink: 0;
792
+ }
793
+
794
+ /* Panel footer (reset button row) */
795
+ .chop-panel-footer {
796
+ grid-column: 1 / -1;
797
+ display: flex;
798
+ align-items: center;
799
+ padding-top: 0.25rem;
800
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
801
+ margin-top: 0.25rem;
802
+ }
803
+
804
+ .chop-btn-reset {
805
+ display: flex;
806
+ align-items: center;
807
+ gap: 5px;
808
+ padding: 0.3rem 0.8rem 0.3rem 0.6rem;
809
+ border: 1px solid rgba(255, 255, 255, 0.1);
810
+ border-radius: 30px;
811
+ background: transparent;
812
+ color: rgba(255, 255, 255, 0.45);
813
+ font-size: 0.78rem;
814
+ cursor: pointer;
815
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
816
+ }
817
+
818
+ .chop-btn-reset .material-symbols-rounded { font-size: 16px; }
819
+ .chop-btn-reset:hover { background: rgba(255,255,255,0.07); color: #fff; border-color: rgba(255,255,255,0.18); }
820
+
821
+ /* Frame / watermark type toggles */
822
+ .chop-toggle-group { display: flex; gap: 4px; flex-wrap: wrap; }
823
+
824
+ .chop-toggle-btn {
825
+ padding: 0.22rem 0.7rem;
826
+ border: 1px solid rgba(255, 255, 255, 0.1);
827
+ border-radius: 30px;
828
+ background: rgba(255,255,255,0.03);
829
+ color: rgba(255, 255, 255, 0.5);
830
+ font-size: 0.78rem;
831
+ cursor: pointer;
832
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
833
+ }
834
+
835
+ .chop-toggle-btn:hover { background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.85); }
836
+
837
+ .chop-toggle-btn--active {
838
+ background: rgba(255, 255, 255, 0.1);
839
+ border-color: rgba(255, 255, 255, 0.22);
840
+ color: #fff;
841
+ }
842
+
843
+ /* Watermark 3×3 position grid */
844
+ .chop-wm-grid {
845
+ display: grid;
846
+ grid-template-columns: repeat(3, 26px);
847
+ grid-template-rows: repeat(3, 22px);
848
+ gap: 2px;
849
+ }
850
+
851
+ .chop-wm-pos-btn {
852
+ display: flex;
853
+ align-items: center;
854
+ justify-content: center;
855
+ border: 1px solid rgba(255, 255, 255, 0.1);
856
+ border-radius: 4px;
857
+ background: rgba(255, 255, 255, 0.03);
858
+ cursor: pointer;
859
+ transition: background 0.1s, border-color 0.1s;
860
+ }
861
+
862
+ .chop-wm-pos-btn:hover {
863
+ background: rgba(255, 255, 255, 0.09);
864
+ border-color: rgba(255, 255, 255, 0.25);
865
+ }
866
+
867
+ .chop-wm-pos-btn--active {
868
+ background: rgba(255, 255, 255, 0.12);
869
+ border-color: rgba(255, 255, 255, 0.28);
870
+ }
871
+
872
+ .chop-wm-pos-dot {
873
+ display: block;
874
+ width: 4px;
875
+ height: 4px;
876
+ border-radius: 50%;
877
+ background: currentColor;
878
+ opacity: 0.45;
879
+ }
880
+
881
+ .chop-wm-pos-btn--active .chop-wm-pos-dot { opacity: 1; }
882
+
883
+ /* Color picker row */
884
+ .chop-color-wrap {
885
+ position: relative;
886
+ display: flex;
887
+ align-items: center;
888
+ gap: 8px;
889
+ padding: 4px 10px 4px 6px;
890
+ border: 1px solid rgba(255, 255, 255, 0.1);
891
+ border-radius: 30px;
892
+ background: rgba(255,255,255,0.04);
893
+ cursor: pointer;
894
+ transition: background 0.12s, border-color 0.12s;
895
+ }
896
+
897
+ .chop-color-wrap:hover { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.18); }
898
+
899
+ .chop-color-swatch {
900
+ width: 18px;
901
+ height: 18px;
902
+ border-radius: 50%;
903
+ border: 1px solid rgba(255,255,255,0.15);
904
+ flex-shrink: 0;
905
+ }
906
+
907
+ .chop-color-hex {
908
+ font-size: 0.78rem;
909
+ color: rgba(255,255,255,0.65);
910
+ font-variant-numeric: tabular-nums;
911
+ letter-spacing: 0.04em;
912
+ }
913
+
914
+ .chop-color-input {
915
+ position: absolute;
916
+ inset: 0;
917
+ width: 100%;
918
+ height: 100%;
919
+ opacity: 0;
920
+ cursor: pointer;
921
+ border: none;
922
+ padding: 0;
923
+ }
924
+
925
+ .chop-text-input {
926
+ flex: 1;
927
+ padding: 0.32rem 0.7rem;
928
+ border: 1px solid rgba(255, 255, 255, 0.1);
929
+ border-radius: 30px;
930
+ background: rgba(255, 255, 255, 0.05);
931
+ color: #e0e0e0;
932
+ font-size: 0.82rem;
933
+ outline: none;
934
+ transition: border-color 0.12s, background 0.12s;
935
+ }
936
+
937
+ .chop-text-input:focus {
938
+ border-color: rgba(255,255,255,0.22);
939
+ background: rgba(255,255,255,0.08);
940
+ }
941
+
942
+ /* ── Bottom toolbar row ─────────────────────────────── */
943
+ .chop-bottombar {
944
+ display: flex;
945
+ align-items: center;
946
+ justify-content: space-between;
947
+ padding: 10px 14px;
948
+ flex-shrink: 0;
949
+ background: var(--chop-bg, #0e0e0e);
950
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
951
+ }
952
+
953
+ /* Rotate group */
954
+ .chop-rotate-grp {
955
+ display: flex;
956
+ align-items: center;
957
+ }
958
+
959
+ .chop-bare-btn {
960
+ display: flex;
961
+ align-items: center;
962
+ justify-content: center;
963
+ width: 36px;
964
+ height: 36px;
965
+ border: none;
966
+ border-radius: 10px;
967
+ background: transparent;
968
+ color: rgba(255, 255, 255, 0.4);
969
+ cursor: pointer;
970
+ transition: background 0.12s, color 0.12s;
971
+ }
972
+
973
+ .chop-bare-btn:hover { background: rgba(255, 255, 255, 0.07); color: rgba(255,255,255,0.85); }
974
+
975
+ .chop-bare-btn .material-symbols-rounded { font-size: 22px; }
976
+
977
+ /* Tool tabs pill */
978
+ .chop-tool-pill {
979
+ display: flex;
980
+ align-items: center;
981
+ padding: 4px;
982
+ background: rgba(255, 255, 255, 0.04);
983
+ border: 1px solid rgba(255, 255, 255, 0.06);
984
+ border-radius: 30px;
985
+ white-space: nowrap;
986
+ }
987
+
988
+ /* shared tab button */
989
+ .chop-pill-tab {
990
+ display: flex;
991
+ align-items: center;
992
+ justify-content: center;
993
+ padding: 7px 18px;
994
+ border: 1px solid transparent;
995
+ border-radius: 30px;
996
+ background: transparent;
997
+ color: rgba(255, 255, 255, 0.38);
998
+ cursor: pointer;
999
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
1000
+ flex-shrink: 0;
1001
+ gap: 5px;
1002
+ }
1003
+
1004
+ .chop-pill-tab .material-symbols-rounded { font-size: 20px; }
1005
+ .chop-pill-tab:hover { background: rgba(255, 255, 255, 0.06); color: rgba(255,255,255,0.8); }
1006
+
1007
+ .chop-pill-tab--active {
1008
+ background: rgba(255, 255, 255, 0.08);
1009
+ border-color: rgba(255, 255, 255, 0.08);
1010
+ color: #fff;
1011
+ }
1012
+
1013
+ /* Right controls */
1014
+ .chop-right-controls {
1015
+ display: flex;
1016
+ align-items: center;
1017
+ gap: 6px;
1018
+ }
1019
+
1020
+ .chop-shape-pill {
1021
+ display: flex;
1022
+ align-items: center;
1023
+ padding: 4px;
1024
+ background: rgba(255, 255, 255, 0.04);
1025
+ border: 1px solid rgba(255, 255, 255, 0.06);
1026
+ border-radius: 30px;
1027
+ }
1028
+
1029
+ /* Aspect ratio pill */
1030
+ .chop-ar-pill {
1031
+ position: relative;
1032
+ display: flex;
1033
+ align-items: center;
1034
+ gap: 6px;
1035
+ padding: 7px 10px 7px 14px;
1036
+ background: rgba(255, 255, 255, 0.04);
1037
+ border: 1px solid rgba(255, 255, 255, 0.06);
1038
+ border-radius: 30px;
1039
+ color: rgba(255,255,255,0.65);
1040
+ cursor: pointer;
1041
+ transition: background 0.12s, color 0.12s;
1042
+ white-space: nowrap;
1043
+ }
1044
+
1045
+ .chop-ar-pill:hover { background: rgba(255, 255, 255, 0.07); color: #fff; }
1046
+
1047
+ .chop-ar-icon { font-size: 18px; flex-shrink: 0; opacity: 0.7; }
1048
+ .chop-ar-caret { font-size: 18px; flex-shrink: 0; opacity: 0.5; }
1049
+
1050
+ .chop-ar-label {
1051
+ font-size: 0.8rem;
1052
+ font-weight: 400;
1053
+ line-height: 1;
1054
+ min-width: 24px;
1055
+ text-align: center;
1056
+ letter-spacing: -0.01em;
1057
+ pointer-events: none;
1058
+ }
1059
+
1060
+ .chop-ar-select {
1061
+ position: absolute;
1062
+ inset: 0;
1063
+ width: 100%;
1064
+ height: 100%;
1065
+ opacity: 0;
1066
+ cursor: pointer;
1067
+ border: none;
1068
+ background: transparent;
1069
+ font-size: inherit;
1070
+ }
1071
+
1072
+ .chop-ar-select:disabled { cursor: not-allowed; }
1073
+
1074
+ /* ── State messages ──────────────────────────────────── */
1075
+ .chop-state-msg {
1076
+ display: flex;
1077
+ align-items: center;
1078
+ justify-content: center;
1079
+ width: 100%;
1080
+ height: 100%;
1081
+ min-height: 240px;
1082
+ color: rgba(255, 255, 255, 0.3);
1083
+ font-size: 0.9rem;
1084
+ }
1085
+
1086
+ .chop-state-error { color: #f87171; }
1087
+ </style>