@youcan/celeste 0.18.0 → 0.20.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 (42) hide show
  1. package/ai/README.md +1 -1
  2. package/ai/component-registry.json +229 -3
  3. package/ai/icons.json +2 -2
  4. package/ai/tokens.json +2 -2
  5. package/dist/celeste.css +573 -274
  6. package/dist/cjs/chunks/alpha-slider.cjs +1 -1
  7. package/dist/cjs/chunks/color-area.cjs +1 -1
  8. package/dist/cjs/chunks/color-picker.cjs +20 -25
  9. package/dist/cjs/chunks/color-swatch.cjs +4 -3
  10. package/dist/cjs/chunks/components.cjs +6 -4
  11. package/dist/cjs/chunks/gradient-picker.cjs +638 -0
  12. package/dist/cjs/chunks/textarea.cjs +1 -1
  13. package/dist/{es/chunks/use-color-model.js → cjs/chunks/tinycolor.cjs} +6 -23
  14. package/dist/cjs/gradient-picker.cjs +2 -0
  15. package/dist/cjs/gradient.cjs +71 -0
  16. package/dist/cjs/index.cjs +9 -4
  17. package/dist/cjs/use-color-model.cjs +27 -3
  18. package/dist/components/color-picker/gradient-picker.d.ts +56 -0
  19. package/dist/components/color-picker/index.d.ts +2 -0
  20. package/dist/es/chunks/alpha-slider.js +1 -1
  21. package/dist/es/chunks/color-area.js +1 -1
  22. package/dist/es/chunks/color-picker.js +12 -17
  23. package/dist/es/chunks/color-swatch.js +2 -1
  24. package/dist/es/chunks/components.js +6 -4
  25. package/dist/es/chunks/gradient-picker.js +632 -0
  26. package/dist/es/chunks/textarea.js +1 -1
  27. package/dist/{cjs/chunks/use-color-model.cjs → es/chunks/tinycolor.js} +1 -40
  28. package/dist/es/gradient-picker.js +2 -0
  29. package/dist/es/gradient.js +69 -0
  30. package/dist/es/index.js +7 -5
  31. package/dist/es/use-color-model.js +25 -1
  32. package/dist/utils/gradient.d.ts +13 -0
  33. package/dist/utils/index.d.ts +1 -0
  34. package/package.json +3 -3
  35. package/src/components/color-picker/color-picker.vue +8 -9
  36. package/src/components/color-picker/composable/use-color-model.ts +5 -0
  37. package/src/components/color-picker/gradient-picker.vue +950 -0
  38. package/src/components/color-picker/index.ts +3 -0
  39. package/src/components/color-picker/stories/gradient-picker.stories.ts +48 -0
  40. package/src/components/textarea/textarea.vue +1 -1
  41. package/src/utils/gradient.ts +90 -0
  42. package/src/utils/index.ts +1 -0
@@ -0,0 +1,950 @@
1
+ <script setup lang="ts">
2
+ import type { PopoverContentProps } from '../popover';
3
+ import type { GradientType } from '@/utils/gradient';
4
+ import tinycolor from 'tinycolor2';
5
+ import { computed, reactive, ref, watch } from 'vue';
6
+ import { parseGradient, stringifyGradient } from '@/utils/gradient';
7
+ import { CompactButton } from '../button';
8
+ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
9
+ import { TextInput } from '../text-input';
10
+ import { Tooltip } from '../tooltip';
11
+
12
+ export interface GradientPickerProps {
13
+ modelValue?: string;
14
+ placeholder?: string;
15
+ emptyHint?: string;
16
+ noneLabel?: string;
17
+ linearLabel?: string;
18
+ radialLabel?: string;
19
+ angleLabel?: string;
20
+ colorStopLabel?: string;
21
+ addStopLabel?: string;
22
+ removeStopLabel?: string;
23
+ positionLabel?: string;
24
+ opacityLabel?: string;
25
+ hueLabel?: string;
26
+ areaLabel?: string;
27
+ eyedropperLabel?: string;
28
+ usedLabel?: string;
29
+ removeLabel?: string;
30
+ presets?: string[];
31
+ swatches?: string[];
32
+ side?: PopoverContentProps['side'];
33
+ align?: PopoverContentProps['align'];
34
+ }
35
+
36
+ export interface GradientPickerEmits {
37
+ 'update:modelValue': [value: string];
38
+ }
39
+
40
+ interface Stop { id: number; h: number; s: number; v: number; a: number; position: number }
41
+ interface State { type: GradientType; angle: number; shape?: string; stops: Stop[] }
42
+
43
+ const props = withDefaults(defineProps<GradientPickerProps>(), {
44
+ modelValue: '',
45
+ side: 'bottom',
46
+ align: 'start',
47
+ placeholder: 'No gradient chosen',
48
+ emptyHint: 'Select a gradient to begin editing',
49
+ noneLabel: 'None',
50
+ linearLabel: 'Linear gradient',
51
+ radialLabel: 'Radial gradient',
52
+ angleLabel: 'Angle',
53
+ colorStopLabel: 'Color stop',
54
+ addStopLabel: 'Add stop',
55
+ removeStopLabel: 'Remove stop',
56
+ positionLabel: 'Position',
57
+ opacityLabel: 'Opacity',
58
+ hueLabel: 'Hue',
59
+ areaLabel: 'Saturation and brightness',
60
+ eyedropperLabel: 'Pick color',
61
+ usedLabel: 'Currently used',
62
+ removeLabel: 'Remove gradient',
63
+ presets: () => [
64
+ 'linear-gradient(135deg, #e1116f 0%, #7d52f4 100%)',
65
+ 'linear-gradient(180deg, #f7f1e8 0%, #e3cfb4 100%)',
66
+ 'linear-gradient(160deg, #1f3d2b 0%, #4f7a5a 100%)',
67
+ 'linear-gradient(180deg, #3d3d3d 0%, #171717 100%)',
68
+ 'radial-gradient(#ffffff 0%, #dbe4ee 100%)',
69
+ 'linear-gradient(135deg, #e8a07a 0%, #b5532f 100%)',
70
+ 'linear-gradient(200deg, #0b3c5d 0%, #1e7f8c 100%)',
71
+ 'radial-gradient(#fff1f5 0%, #f7b5c9 100%)',
72
+ 'linear-gradient(120deg, #d6d29a 0%, #7a7a3a 100%)',
73
+ 'linear-gradient(180deg, #1b1f3b 0%, #4b3f8f 100%)',
74
+ 'linear-gradient(90deg, #ffd86b 0%, #f08a24 100%)',
75
+ ],
76
+ swatches: () => ['#ffffff', '#171717', '#f5f5f5', '#222530', '#000000', '#335cff', '#e1116f'],
77
+ });
78
+
79
+ const emit = defineEmits<GradientPickerEmits>();
80
+
81
+ const open = ref(false);
82
+ const state = ref<State | null>(null);
83
+ const selected = ref<number>();
84
+ const removing = ref<number>();
85
+ const drafts = reactive<Record<string, string>>({});
86
+ const preview = ref<HTMLElement>();
87
+ const bar = ref<HTMLElement>();
88
+ const area = ref<HTMLElement>();
89
+ const hue = ref<HTMLElement>();
90
+ const alpha = ref<HTMLElement>();
91
+ let uid = 0;
92
+
93
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
94
+ const numeric = (raw: string) => raw.trim() !== '' && !Number.isNaN(Number(raw));
95
+
96
+ function hsva(color: string) {
97
+ const parsed = tinycolor(color);
98
+ const { h, s, v } = parsed.toHsv();
99
+
100
+ return { h, s: s * 100, v: v * 100, a: parsed.getAlpha() };
101
+ }
102
+
103
+ function colorOf(stop: Omit<Stop, 'id' | 'position'>) {
104
+ const color = tinycolor({ h: stop.h, s: stop.s / 100, v: stop.v / 100, a: stop.a });
105
+
106
+ return stop.a >= 1 ? color.toHexString() : color.toRgbString();
107
+ }
108
+
109
+ function toCss(value: State, linear = false) {
110
+ return stringifyGradient({
111
+ type: linear ? 'linear' : value.type,
112
+ angle: linear ? 90 : value.angle,
113
+ shape: value.shape,
114
+ stops: value.stops.map(stop => ({ color: colorOf(stop), position: stop.position })),
115
+ });
116
+ }
117
+
118
+ function load(value?: string) {
119
+ const gradient = parseGradient(value);
120
+
121
+ state.value = gradient && {
122
+ type: gradient.type,
123
+ angle: gradient.angle,
124
+ shape: gradient.shape,
125
+ stops: gradient.stops.map(stop => ({ id: ++uid, ...hsva(stop.color), position: stop.position })),
126
+ };
127
+ selected.value = state.value?.stops[0].id;
128
+ }
129
+
130
+ const css = computed(() => (state.value ? toCss(state.value) : ''));
131
+
132
+ watch(() => props.modelValue, value => value !== css.value && load(value), { immediate: true });
133
+
134
+ const track = computed(() => (state.value ? toCss(state.value, true) : ''));
135
+ const current = computed(() => state.value?.stops.find(stop => stop.id === selected.value));
136
+ const solid = computed(() => current.value && colorOf({ ...current.value, a: 1 }));
137
+
138
+ function save() {
139
+ css.value !== props.modelValue && emit('update:modelValue', css.value);
140
+ }
141
+
142
+ function patch(changes: Partial<Stop>, keepDrafts = false) {
143
+ Object.assign(current.value!, changes);
144
+ keepDrafts || Object.keys(drafts).forEach(key => delete drafts[key]);
145
+ save();
146
+ }
147
+
148
+ function field(key: string, value: string | number, commit: (raw: string) => unknown) {
149
+ return {
150
+ value: drafts[key] ?? String(value),
151
+ input: (raw?: string | number) => {
152
+ drafts[key] = String(raw ?? '');
153
+ commit(drafts[key].trim());
154
+ },
155
+ };
156
+ }
157
+
158
+ const fields = computed(() => current.value && {
159
+ hex: field('hex', solid.value!.toUpperCase(), raw => /^#?[0-9a-f]{6}$/i.test(raw) && patch({ ...hsva(raw.replace(/^#?/, '#')), a: current.value!.a }, true)),
160
+ alpha: field('alpha', Math.round(current.value.a * 100), raw => numeric(raw) && patch({ a: clamp(Number(raw), 0, 100) / 100 }, true)),
161
+ position: field('position', current.value.position, raw => numeric(raw) && patch({ position: clamp(Math.round(Number(raw)), 0, 100) }, true)),
162
+ angle: field('angle', state.value!.angle, (raw) => {
163
+ if (numeric(raw)) {
164
+ state.value!.angle = ((Math.round(Number(raw)) % 360) + 360) % 360;
165
+ save();
166
+ }
167
+ }),
168
+ });
169
+
170
+ function drag(event: PointerEvent, move: (event: PointerEvent) => void, up?: () => void) {
171
+ event.preventDefault();
172
+ move(event);
173
+
174
+ const stop = () => {
175
+ window.removeEventListener('pointermove', move);
176
+ window.removeEventListener('pointerup', stop);
177
+ up?.();
178
+ };
179
+
180
+ window.addEventListener('pointermove', move);
181
+ window.addEventListener('pointerup', stop);
182
+ }
183
+
184
+ function keys(event: KeyboardEvent, apply: (dx: number, dy: number) => void) {
185
+ const step = event.shiftKey ? 10 : 1;
186
+ const delta = ({ ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] } as Record<string, [number, number]>)[event.key];
187
+
188
+ if (delta) {
189
+ event.preventDefault();
190
+ apply(...delta);
191
+ }
192
+ }
193
+
194
+ function onHandleKey(event: KeyboardEvent, id: number) {
195
+ selected.value = id;
196
+
197
+ if (['Delete', 'Backspace'].includes(event.key) && state.value!.stops.length > 2) {
198
+ event.preventDefault();
199
+ removeStop();
200
+ }
201
+
202
+ keys(event, (dx, dy) => patch({ position: clamp(current.value!.position + dx - dy, 0, 100) }));
203
+ }
204
+
205
+ function relative(element: HTMLElement, event: PointerEvent, inset = 0) {
206
+ const rect = element.getBoundingClientRect();
207
+
208
+ return {
209
+ x: clamp((event.clientX - rect.left - inset) / (rect.width - 2 * inset), 0, 1),
210
+ y: clamp((event.clientY - rect.top - inset) / (rect.height - 2 * inset), 0, 1),
211
+ dy: event.clientY - rect.top - rect.height / 2,
212
+ };
213
+ }
214
+
215
+ function colorAt(position: number) {
216
+ const stops = [...state.value!.stops].sort((a, b) => a.position - b.position);
217
+ const after = stops.find(stop => stop.position >= position) ?? stops.at(-1)!;
218
+ const before = stops.findLast(stop => stop.position <= position) ?? stops[0];
219
+ const ratio = after.position === before.position ? 0 : (position - before.position) / (after.position - before.position);
220
+ const mixed = tinycolor.mix(colorOf({ ...before, a: 1 }), colorOf({ ...after, a: 1 }), ratio * 100).toHexString();
221
+
222
+ return { ...hsva(mixed), a: before.a + (after.a - before.a) * ratio };
223
+ }
224
+
225
+ function addAt(position: number) {
226
+ const id = ++uid;
227
+
228
+ state.value!.stops.push({ id, ...colorAt(position), position });
229
+ selected.value = id;
230
+ save();
231
+
232
+ return id;
233
+ }
234
+
235
+ function dragStop(event: PointerEvent, id: number) {
236
+ selected.value = id;
237
+ Object.keys(drafts).forEach(key => delete drafts[key]);
238
+
239
+ drag(event, (moved) => {
240
+ const { x, dy } = relative(bar.value!, moved, 10);
241
+
242
+ current.value!.position = Math.round(x * 100);
243
+ removing.value = state.value!.stops.length > 2 && Math.abs(dy) > 40 ? id : undefined;
244
+ save();
245
+ }, () => removing.value === id && removeStop());
246
+ }
247
+
248
+ function onBar(event: PointerEvent) {
249
+ event.target === event.currentTarget && dragStop(event, addAt(Math.round(relative(bar.value!, event, 10).x * 100)));
250
+ }
251
+
252
+ function onPreview(event: PointerEvent) {
253
+ state.value!.type === 'linear' && drag(event, (moved) => {
254
+ const rect = preview.value!.getBoundingClientRect();
255
+ const radians = Math.atan2(moved.clientX - rect.left - rect.width / 2, rect.top + rect.height / 2 - moved.clientY);
256
+ const degrees = (radians * 180) / Math.PI + 360;
257
+
258
+ state.value!.angle = Math.round(moved.shiftKey ? Math.round(degrees / 15) * 15 : degrees) % 360;
259
+ delete drafts.angle;
260
+ save();
261
+ });
262
+ }
263
+
264
+ function onArea(event: PointerEvent) {
265
+ drag(event, (moved) => {
266
+ const { x, y } = relative(area.value!, moved);
267
+
268
+ patch({ s: x * 100, v: (1 - y) * 100 });
269
+ });
270
+ }
271
+
272
+ function onHue(event: PointerEvent) {
273
+ drag(event, moved => patch({ h: Math.min(relative(hue.value!, moved, 12).y * 360, 359) }));
274
+ }
275
+
276
+ function onAlpha(event: PointerEvent) {
277
+ drag(event, moved => patch({ a: Math.round((1 - relative(alpha.value!, moved, 12).y) * 100) / 100 }));
278
+ }
279
+
280
+ function addStop() {
281
+ const stops = [...state.value!.stops].sort((a, b) => a.position - b.position);
282
+ const index = stops.indexOf(current.value!);
283
+ const next = stops[index + 1] ?? stops[index - 1];
284
+
285
+ addAt(Math.round((current.value!.position + next.position) / 2));
286
+ }
287
+
288
+ function removeStop() {
289
+ state.value!.stops = state.value!.stops.filter(stop => stop.id !== selected.value);
290
+ selected.value = state.value!.stops[0].id;
291
+ removing.value = undefined;
292
+ save();
293
+ }
294
+
295
+ function setType(type: GradientType) {
296
+ state.value!.type = type;
297
+ save();
298
+ }
299
+
300
+ function pick(value: string) {
301
+ load(value);
302
+ save();
303
+ }
304
+
305
+ async function sip() {
306
+ const result = await new window.EyeDropper!().open().catch(() => undefined);
307
+
308
+ result && patch({ ...hsva(result.sRGBHex), a: current.value!.a });
309
+ }
310
+
311
+ const fill = (color: string) => `linear-gradient(${color}, ${color}), repeating-conic-gradient(#e5e5e5 0 25%, #fff 0 50%) 0 0 / 8px 8px`;
312
+ const along = (fraction: number) => `calc(12px + (100% - 24px) * ${fraction})`;
313
+ const canSip = 'EyeDropper' in window;
314
+ </script>
315
+
316
+ <template>
317
+ <Popover v-model:open="open">
318
+ <PopoverTrigger as-child>
319
+ <button type="button" class="celeste-gradient-trigger">
320
+ <span
321
+ class="celeste-gradient-swatch"
322
+ :data-empty="!state"
323
+ :style="state ? { background: css } : undefined"
324
+ />
325
+ <span class="celeste-gradient-summary">
326
+ {{ state ? (state.type === 'linear' ? linearLabel : radialLabel) : placeholder }}
327
+ </span>
328
+ <i class="i-celeste-expand-up-down-line" />
329
+ </button>
330
+ </PopoverTrigger>
331
+ <PopoverContent
332
+ :dismissible="false"
333
+ :show-tail="false"
334
+ :side="side"
335
+ :align="align"
336
+ @open-auto-focus.prevent
337
+ >
338
+ <div class="celeste-gradient-picker">
339
+ <div v-if="!state" class="celeste-gradient-section">
340
+ <span class="celeste-gradient-hint">{{ emptyHint }}</span>
341
+ <div class="celeste-gradient-presets">
342
+ <button
343
+ type="button"
344
+ class="celeste-gradient-preset"
345
+ data-empty="true"
346
+ :title="noneLabel"
347
+ :aria-label="noneLabel"
348
+ @click="open = false"
349
+ />
350
+ <button
351
+ v-for="preset in presets"
352
+ :key="preset"
353
+ type="button"
354
+ class="celeste-gradient-preset"
355
+ :style="{ background: preset }"
356
+ :aria-label="preset"
357
+ @click="pick(preset)"
358
+ />
359
+ </div>
360
+ </div>
361
+
362
+ <template v-else-if="current && fields">
363
+ <div class="celeste-gradient-section">
364
+ <div
365
+ ref="preview"
366
+ class="celeste-gradient-preview"
367
+ :data-linear="state.type === 'linear'"
368
+ @pointerdown="onPreview"
369
+ >
370
+ <span class="celeste-gradient-fill" :style="{ background: css }" />
371
+ <span v-if="state.type === 'linear'" class="celeste-gradient-ring">
372
+ <span :style="{ rotate: `${state.angle}deg` }" />
373
+ </span>
374
+ </div>
375
+
376
+ <div class="celeste-gradient-row">
377
+ <Tooltip
378
+ v-for="type in (['linear', 'radial'] as const)"
379
+ :key="type"
380
+ :title="type === 'linear' ? linearLabel : radialLabel"
381
+ size="xs"
382
+ variant="dark"
383
+ >
384
+ <button
385
+ type="button"
386
+ class="celeste-gradient-type"
387
+ :data-type="type"
388
+ :data-active="state.type === type"
389
+ :aria-label="type === 'linear' ? linearLabel : radialLabel"
390
+ @click="setType(type)"
391
+ >
392
+ <span />
393
+ </button>
394
+ </Tooltip>
395
+ <TextInput
396
+ v-if="state.type === 'linear'"
397
+ :model-value="fields.angle.value"
398
+ inputmode="numeric"
399
+ size="xs"
400
+ :aria-label="angleLabel"
401
+ class="celeste-gradient-number"
402
+ @update:model-value="fields.angle.input"
403
+ >
404
+ <template #trailingNode>
405
+ °
406
+ </template>
407
+ </TextInput>
408
+ </div>
409
+
410
+ <div
411
+ ref="bar"
412
+ class="celeste-gradient-bar"
413
+ @pointerdown="onBar"
414
+ >
415
+ <span class="celeste-gradient-fill" :style="{ background: track }" />
416
+ <button
417
+ v-for="stop in state.stops"
418
+ :key="stop.id"
419
+ type="button"
420
+ class="celeste-gradient-handle"
421
+ :style="{ left: `calc(10px + (100% - 20px) * ${stop.position / 100})`, background: fill(colorOf(stop)) }"
422
+ :data-active="stop.id === selected"
423
+ :data-removing="stop.id === removing"
424
+ :aria-label="`${colorOf(stop)} ${stop.position}%`"
425
+ @pointerdown.stop="dragStop($event, stop.id)"
426
+ @keydown="onHandleKey($event, stop.id)"
427
+ />
428
+ </div>
429
+
430
+ <div class="celeste-gradient-row">
431
+ <span class="celeste-gradient-eyebrow">{{ colorStopLabel }}</span>
432
+ <CompactButton
433
+ icon="i-celeste-add-line"
434
+ variant="ghost"
435
+ size="md"
436
+ :aria-label="addStopLabel"
437
+ @click="addStop"
438
+ />
439
+ <CompactButton
440
+ icon="i-celeste-subtract-line"
441
+ variant="ghost"
442
+ size="md"
443
+ :aria-label="removeStopLabel"
444
+ :disabled="state.stops.length <= 2"
445
+ @click="removeStop"
446
+ />
447
+ </div>
448
+
449
+ <div class="celeste-gradient-row">
450
+ <span class="celeste-gradient-label">{{ positionLabel }}</span>
451
+ <TextInput
452
+ :model-value="fields.position.value"
453
+ inputmode="numeric"
454
+ size="xs"
455
+ :aria-label="positionLabel"
456
+ class="celeste-gradient-number"
457
+ @update:model-value="fields.position.input"
458
+ >
459
+ <template #trailingNode>
460
+ %
461
+ </template>
462
+ </TextInput>
463
+ </div>
464
+
465
+ <div class="celeste-gradient-row">
466
+ <span class="celeste-gradient-current">
467
+ <span class="celeste-gradient-fill" :style="{ background: colorOf(current) }" />
468
+ </span>
469
+ <TextInput
470
+ :model-value="fields.hex.value"
471
+ size="xs"
472
+ class="celeste-gradient-hex"
473
+ @update:model-value="fields.hex.input"
474
+ >
475
+ <template v-if="canSip" #trailingNode>
476
+ <CompactButton
477
+ icon="i-celeste-sip-line"
478
+ variant="ghost"
479
+ size="md"
480
+ :aria-label="eyedropperLabel"
481
+ @click="sip"
482
+ />
483
+ </template>
484
+ </TextInput>
485
+ <TextInput
486
+ :model-value="fields.alpha.value"
487
+ inputmode="numeric"
488
+ size="xs"
489
+ :aria-label="opacityLabel"
490
+ class="celeste-gradient-number"
491
+ @update:model-value="fields.alpha.input"
492
+ >
493
+ <template #trailingNode>
494
+ %
495
+ </template>
496
+ </TextInput>
497
+ </div>
498
+
499
+ <div class="celeste-gradient-color">
500
+ <div
501
+ ref="area"
502
+ class="celeste-gradient-area"
503
+ :style="{ backgroundColor: `hsl(${current.h}, 100%, 50%)` }"
504
+ role="slider"
505
+ tabindex="0"
506
+ :aria-label="areaLabel"
507
+ :aria-valuenow="Math.round(current.s)"
508
+ :aria-valuetext="`${Math.round(current.s)}%, ${Math.round(current.v)}%`"
509
+ @pointerdown="onArea"
510
+ @keydown="keys($event, (dx, dy) => patch({ s: clamp(current!.s + dx, 0, 100), v: clamp(current!.v - dy, 0, 100) }))"
511
+ >
512
+ <span class="celeste-gradient-knob" :style="{ left: `${current.s}%`, top: `${100 - current.v}%`, background: fill(colorOf(current)) }" />
513
+ </div>
514
+ <div
515
+ ref="hue"
516
+ class="celeste-gradient-slider"
517
+ data-hue="true"
518
+ role="slider"
519
+ tabindex="0"
520
+ aria-orientation="vertical"
521
+ aria-valuemin="0"
522
+ aria-valuemax="359"
523
+ :aria-label="hueLabel"
524
+ :aria-valuenow="Math.round(current.h)"
525
+ @pointerdown="onHue"
526
+ @keydown="keys($event, (dx, dy) => patch({ h: clamp(current!.h + dx + dy, 0, 359) }))"
527
+ >
528
+ <span class="celeste-gradient-knob" :style="{ top: along(current.h / 360), background: fill(colorOf(current)) }" />
529
+ </div>
530
+ <div
531
+ ref="alpha"
532
+ class="celeste-gradient-slider"
533
+ role="slider"
534
+ tabindex="0"
535
+ aria-orientation="vertical"
536
+ aria-valuemin="0"
537
+ aria-valuemax="100"
538
+ :aria-label="opacityLabel"
539
+ :aria-valuenow="Math.round(current.a * 100)"
540
+ @pointerdown="onAlpha"
541
+ @keydown="keys($event, (dx, dy) => patch({ a: clamp(Math.round(current!.a * 100 + dx - dy), 0, 100) / 100 }))"
542
+ >
543
+ <span class="celeste-gradient-fill" :style="{ background: `linear-gradient(${solid}, transparent)` }" />
544
+ <span class="celeste-gradient-knob" :style="{ top: along(1 - current.a), background: fill(colorOf(current)) }" />
545
+ </div>
546
+ </div>
547
+ </div>
548
+
549
+ <div class="celeste-gradient-section celeste-gradient-used">
550
+ <span class="celeste-gradient-label">{{ usedLabel }}</span>
551
+ <div class="celeste-gradient-swatches">
552
+ <button
553
+ v-for="swatch in swatches"
554
+ :key="swatch"
555
+ type="button"
556
+ :style="{ background: swatch }"
557
+ :title="swatch"
558
+ :aria-label="swatch"
559
+ @click="patch({ ...hsva(swatch), a: current.a })"
560
+ />
561
+ </div>
562
+ </div>
563
+
564
+ <div class="celeste-gradient-footer">
565
+ <button
566
+ type="button"
567
+ class="celeste-gradient-remove"
568
+ @click="pick('')"
569
+ >
570
+ <i class="i-celeste-delete-bin-line" />
571
+ {{ removeLabel }}
572
+ </button>
573
+ </div>
574
+ </template>
575
+ </div>
576
+ </PopoverContent>
577
+ </Popover>
578
+ </template>
579
+
580
+ <style scoped lang="scss">
581
+ $checker: repeating-conic-gradient(#e5e5e5 0 25%, #fff 0 50%) 0 0 / 8px 8px;
582
+ $none:
583
+ linear-gradient(135deg, transparent 47%, var(--color-stroke-sub-300) 47% 53%, transparent 53%),
584
+ var(--color-bg-white-0);
585
+ $inset: inset 0 0 0 1px #0000001a;
586
+
587
+ .celeste-gradient-trigger {
588
+ display: flex;
589
+ align-items: center;
590
+ width: 100%;
591
+ height: 36px;
592
+ padding: 0 var(--spacing-8) 0 var(--spacing-6);
593
+ border: 1px solid var(--color-stroke-soft-200);
594
+ border-radius: var(--radius-8);
595
+ background: var(--color-bg-white-0);
596
+ box-shadow: var(--shadow-regular-xs);
597
+ color: var(--color-text-strong-950);
598
+ font: var(--label-sm);
599
+ text-align: start;
600
+ cursor: pointer;
601
+ gap: var(--spacing-8);
602
+
603
+ &:hover {
604
+ background: var(--color-bg-weak-50);
605
+ }
606
+
607
+ i {
608
+ flex: none;
609
+ width: 16px;
610
+ height: 16px;
611
+ color: var(--color-icon-sub-600);
612
+ }
613
+ }
614
+
615
+ .celeste-gradient-swatch {
616
+ flex: none;
617
+ width: 24px;
618
+ height: 24px;
619
+ border-radius: var(--radius-full);
620
+ box-shadow: $inset;
621
+
622
+ &[data-empty='true'] {
623
+ background: $none;
624
+ }
625
+ }
626
+
627
+ .celeste-gradient-summary {
628
+ flex: 1;
629
+ min-width: 0;
630
+ overflow: hidden;
631
+ text-overflow: ellipsis;
632
+ white-space: nowrap;
633
+ }
634
+
635
+ .celeste-gradient-picker {
636
+ display: flex;
637
+ flex-direction: column;
638
+ width: 304px;
639
+ max-height: var(--reka-popover-content-available-height);
640
+ overflow-y: auto;
641
+ overscroll-behavior: contain;
642
+ }
643
+
644
+ .celeste-gradient-section {
645
+ display: flex;
646
+ flex-direction: column;
647
+ padding: var(--spacing-16);
648
+ gap: var(--spacing-12);
649
+ }
650
+
651
+ .celeste-gradient-fill {
652
+ position: absolute;
653
+ inset: 0;
654
+ border-radius: inherit;
655
+ box-shadow: $inset;
656
+ pointer-events: none;
657
+ }
658
+
659
+ .celeste-gradient-hint {
660
+ color: var(--color-text-sub-600);
661
+ font: var(--paragraph-sm);
662
+ }
663
+
664
+ .celeste-gradient-presets {
665
+ display: grid;
666
+ grid-template-columns: repeat(6, 1fr);
667
+ gap: var(--spacing-8);
668
+ }
669
+
670
+ .celeste-gradient-preset {
671
+ aspect-ratio: 1;
672
+ padding: 0;
673
+ transition: box-shadow 200ms ease-out;
674
+ border: 0;
675
+ border-radius: var(--radius-full);
676
+ box-shadow: $inset;
677
+ cursor: pointer;
678
+
679
+ &[data-empty='true'] {
680
+ background: $none;
681
+ }
682
+
683
+ &:hover,
684
+ &:focus-visible {
685
+ outline: none;
686
+ box-shadow:
687
+ $inset,
688
+ 0 0 0 2px var(--color-bg-white-0),
689
+ 0 0 0 4px var(--color-primary-base);
690
+ }
691
+ }
692
+
693
+ .celeste-gradient-preview {
694
+ position: relative;
695
+ height: 144px;
696
+ border-radius: var(--radius-12);
697
+ background: $checker;
698
+ touch-action: none;
699
+
700
+ &[data-linear='true'] {
701
+ cursor: grab;
702
+ }
703
+ }
704
+
705
+ .celeste-gradient-ring {
706
+ position: absolute;
707
+ top: calc(50% - 40px);
708
+ left: calc(50% - 40px);
709
+ width: 80px;
710
+ height: 80px;
711
+ border: 2px solid #ffffffb3;
712
+ border-radius: var(--radius-full);
713
+ box-shadow: 0 0 0 1px #0000001a;
714
+ pointer-events: none;
715
+
716
+ span {
717
+ position: absolute;
718
+ inset: -2px;
719
+
720
+ &::after {
721
+ content: '';
722
+ position: absolute;
723
+ top: -6px;
724
+ left: calc(50% - 6px);
725
+ width: 12px;
726
+ height: 12px;
727
+ border-radius: var(--radius-full);
728
+ background: var(--color-static-white);
729
+ box-shadow: 0 1px 3px #0e121b40;
730
+ }
731
+ }
732
+ }
733
+
734
+ .celeste-gradient-row {
735
+ display: flex;
736
+ align-items: center;
737
+ gap: var(--spacing-8);
738
+ }
739
+
740
+ .celeste-gradient-type {
741
+ width: 40px;
742
+ height: 40px;
743
+ padding: var(--spacing-4);
744
+ border: 1px solid var(--color-stroke-soft-200);
745
+ border-radius: var(--radius-10);
746
+ background: var(--color-bg-white-0);
747
+ cursor: pointer;
748
+
749
+ &[data-active='true'] {
750
+ border-color: var(--color-primary-base);
751
+ box-shadow: 0 0 0 2px var(--color-primary-alpha-10);
752
+ }
753
+
754
+ span {
755
+ display: block;
756
+ height: 100%;
757
+ border-radius: var(--radius-6);
758
+ background: linear-gradient(var(--color-bg-white-0), var(--color-text-soft-400));
759
+ box-shadow: $inset;
760
+ }
761
+
762
+ &[data-type='radial'] span {
763
+ background: radial-gradient(var(--color-bg-white-0), var(--color-text-soft-400));
764
+ }
765
+ }
766
+
767
+ .celeste-gradient-number {
768
+ flex: none;
769
+ width: 80px;
770
+ margin-inline-start: auto;
771
+ }
772
+
773
+ .celeste-gradient-bar {
774
+ position: relative;
775
+ height: 24px;
776
+ border-radius: var(--radius-full);
777
+ background: $checker;
778
+ cursor: copy;
779
+ touch-action: none;
780
+ }
781
+
782
+ .celeste-gradient-handle {
783
+ position: absolute;
784
+ top: calc(50% - 10px);
785
+ width: 20px;
786
+ height: 20px;
787
+ margin-inline-start: -10px;
788
+ padding: 0;
789
+ border: 3px solid var(--color-static-white);
790
+ border-radius: var(--radius-full);
791
+ box-shadow:
792
+ 0 0 0 1px #0000002e,
793
+ 0 1px 3px #0e121b40;
794
+ cursor: grab;
795
+ touch-action: none;
796
+
797
+ &[data-active='true'] {
798
+ box-shadow:
799
+ 0 0 0 2px var(--color-primary-base),
800
+ 0 1px 3px #0e121b40;
801
+ }
802
+
803
+ &[data-removing='true'] {
804
+ opacity: 0.3;
805
+ }
806
+ }
807
+
808
+ .celeste-gradient-eyebrow {
809
+ flex: 1;
810
+ color: var(--color-text-soft-400);
811
+ font: var(--subheading-xxs);
812
+ letter-spacing: 0.06em;
813
+ text-transform: uppercase;
814
+ }
815
+
816
+ .celeste-gradient-label {
817
+ flex: 1;
818
+ color: var(--color-text-sub-600);
819
+ font: var(--label-xs);
820
+ }
821
+
822
+ .celeste-gradient-current {
823
+ position: relative;
824
+ flex: none;
825
+ width: 32px;
826
+ height: 32px;
827
+ border-radius: var(--radius-8);
828
+ background: $checker;
829
+ }
830
+
831
+ .celeste-gradient-hex {
832
+ flex: 1;
833
+ min-width: 0;
834
+ }
835
+
836
+ .celeste-gradient-color {
837
+ display: flex;
838
+ height: 200px;
839
+ gap: var(--spacing-8);
840
+ }
841
+
842
+ .celeste-gradient-area {
843
+ position: relative;
844
+ flex: 1;
845
+ border-radius: var(--radius-10);
846
+ background-image: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent);
847
+ cursor: crosshair;
848
+ touch-action: none;
849
+ }
850
+
851
+ .celeste-gradient-slider {
852
+ position: relative;
853
+ flex: none;
854
+ width: 24px;
855
+ border-radius: var(--radius-full);
856
+ background: $checker;
857
+ cursor: pointer;
858
+ touch-action: none;
859
+
860
+ &[data-hue='true'] {
861
+ background: linear-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
862
+ }
863
+
864
+ .celeste-gradient-knob {
865
+ left: 50%;
866
+ }
867
+ }
868
+
869
+ .celeste-gradient-area,
870
+ .celeste-gradient-slider,
871
+ .celeste-gradient-handle {
872
+ &:focus-visible {
873
+ outline: 2px solid var(--color-primary-base);
874
+ outline-offset: 2px;
875
+ }
876
+ }
877
+
878
+ .celeste-gradient-knob {
879
+ position: absolute;
880
+ width: 20px;
881
+ height: 20px;
882
+ transform: translate(-50%, -50%);
883
+ border: 3px solid var(--color-static-white);
884
+ border-radius: var(--radius-full);
885
+ box-shadow:
886
+ 0 0 0 1px #0000001f,
887
+ 0 1px 3px #0e121b40;
888
+ pointer-events: none;
889
+ }
890
+
891
+ .celeste-gradient-used {
892
+ padding-top: var(--spacing-12);
893
+ border-top: 1px solid var(--color-stroke-soft-200);
894
+ gap: var(--spacing-8);
895
+ }
896
+
897
+ .celeste-gradient-swatches {
898
+ display: flex;
899
+ flex-wrap: wrap;
900
+ gap: var(--spacing-8);
901
+
902
+ button {
903
+ width: 28px;
904
+ height: 28px;
905
+ padding: 0;
906
+ border: 0;
907
+ border-radius: var(--radius-8);
908
+ box-shadow: $inset;
909
+ cursor: pointer;
910
+
911
+ &:hover,
912
+ &:focus-visible {
913
+ outline: none;
914
+ box-shadow:
915
+ $inset,
916
+ 0 0 0 2px var(--color-bg-white-0),
917
+ 0 0 0 3px var(--color-stroke-sub-300);
918
+ }
919
+ }
920
+ }
921
+
922
+ .celeste-gradient-footer {
923
+ padding: var(--spacing-8);
924
+ border-top: 1px solid var(--color-stroke-soft-200);
925
+ }
926
+
927
+ .celeste-gradient-remove {
928
+ display: flex;
929
+ align-items: center;
930
+ width: 100%;
931
+ padding: var(--spacing-8);
932
+ border: 0;
933
+ border-radius: var(--radius-8);
934
+ background: transparent;
935
+ color: var(--color-text-sub-600);
936
+ font: var(--label-sm);
937
+ cursor: pointer;
938
+ gap: var(--spacing-8);
939
+
940
+ &:hover {
941
+ background: var(--color-bg-weak-50);
942
+ color: var(--color-text-strong-950);
943
+ }
944
+
945
+ i {
946
+ width: 20px;
947
+ height: 20px;
948
+ }
949
+ }
950
+ </style>