Skip to content

Commit a8933c9

Browse files
committed
ts fixes, color stop animation
1 parent 2c3e0d9 commit a8933c9

File tree

11 files changed

+234
-78
lines changed

11 files changed

+234
-78
lines changed

src/components/Gradient.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ import GradientImportDialog from './GradientImportDialog.svelte'
196196
197197
metatag.content = newmeta.to('srgb').toString({ format: 'hex' })
198198
svgicon.href = `data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><mask id='stripes'><rect height='40%' width='100%' fill='white' /><rect height='7%' y='41%' width='100%' fill='white' /><rect height='6%' y='50%' width='100%' fill='white' /><rect height='5%' y='59%' width='100%' fill='white' /><rect height='4%' y='68%' width='100%' fill='white' /><rect height='3%' y='78%' width='100%' fill='white' /><rect height='2%' y='90%' width='100%' fill='white' /><rect height='1%' y='99%' width='100%' fill='white' /></mask><circle mask='url(%23stripes)' fill='${newmeta.to('srgb').toString()}' cx='50' cy='50' r='50'/></svg>`
199+
// Use the leading stop color to tint global proximity glows.
200+
document.documentElement.style.setProperty('--gs-glow-color', newmeta.to('srgb').toString())
199201
}, 500)
200202
}
201203
catch (err) {}

src/components/GradientStops.svelte

Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1-
<script>
1+
<script lang="ts">
2+
// @ts-nocheck
23
import {flip} from 'svelte/animate'
34
import {fade,scale} from 'svelte/transition'
45
56
import { tooltip } from 'svooltip'
67
7-
import {gradient_stops, gradient_space, active_stop_index} from '../store/gradient.ts'
8-
import {picker_value} from '../store/colorpicker.ts'
9-
import {updateStops, removeStop} from '../utils/stops.ts'
10-
import {copyToClipboard} from '../utils/clipboard.ts'
11-
import {randomNumber} from '../utils/numbers.ts'
12-
import {whatsTheGamutDamnit} from '../utils/colorspace.ts'
8+
import {gradient_stops, gradient_space, active_stop_index} from '../store/gradient'
9+
import {picker_value} from '../store/colorpicker'
10+
11+
import {copyToClipboard} from '../utils/clipboard'
12+
import {randomNumber} from '../utils/numbers'
13+
import {whatsTheGamutDamnit} from '../utils/colorspace'
1314
1415
import RangeSlider from './RangeSlider.svelte'
1516
import Hint from './Hint.svelte'
1617
18+
type GradientStop = any
19+
1720
// Drag-reorder state
1821
let dragging = $state(false)
1922
let dragStart = $state(null) // start index of dragged unit (stop + optional hint)
@@ -27,65 +30,77 @@
2730
catch { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
2831
}
2932
30-
function ensureIds(list) {
33+
function ensureIds(list: GradientStop[]): GradientStop[] {
3134
return list.map(item => item?.id ? item : ({ ...item, id: genId(item.kind) }))
3235
}
3336
34-
function colorAction(event, position) {
35-
switch (event.target.value) {
37+
function colorAction(event: Event, position: number) {
38+
const target = event.target as HTMLSelectElement
39+
switch (target.value) {
3640
case 'Remove':
3741
if (colorStopCount() <= 1) break
38-
$gradient_stops = updateStops(removeStop($gradient_stops, position))
42+
const newStops = [...$gradient_stops]
43+
newStops.splice(position, 2)
44+
$gradient_stops = newStops
3945
break
4046
case 'Reset':
41-
$gradient_stops[position].position1 = null
42-
$gradient_stops[position].position2 = null
43-
updateStops($gradient_stops)
47+
const resetStops = [...$gradient_stops]
48+
resetStops[position].position1 = undefined
49+
resetStops[position].position2 = undefined
50+
$gradient_stops = resetStops
4451
break
4552
case 'Duplicate':
4653
dupeStop(position)
4754
break
4855
case 'Copy CSS color':
49-
copyToClipboard($gradient_stops[position].color)
56+
copyToClipboard($gradient_stops[position].color || '')
5057
break
5158
case 'Random color':
52-
$gradient_stops[position].color = `oklch(80% 0.3 ${randomNumber(0,360)})`
59+
const randomStops = [...$gradient_stops]
60+
randomStops[position].color = `oklch(80% 0.3 ${randomNumber(0,360)})`
61+
$gradient_stops = randomStops
5362
break
5463
}
5564
5665
// reset
57-
event.target.selectedIndex = 0
66+
target.selectedIndex = 0
5867
}
5968
6069
function colorStopCount() {
6170
return ($gradient_stops || []).filter(s => s?.kind === 'stop').length
6271
}
6372
64-
function dupeStop(pos) {
73+
function dupeStop(pos: number) {
74+
const newStops: any[] = [...$gradient_stops]
6575
const clone = {
6676
id: genId('stop'),
6777
kind: 'stop',
68-
color: $gradient_stops[pos].color,
69-
position1: $gradient_stops[pos].position1,
70-
position2: $gradient_stops[pos].position2,
78+
color: newStops[pos].color,
79+
position1: newStops[pos].position1,
80+
position2: newStops[pos].position2,
81+
auto: String(Number(newStops[pos].auto || 0))
7182
}
7283
73-
$gradient_stops.splice(pos, 0, {id: genId('hint'), kind: 'hint', percentage: null})
74-
$gradient_stops.splice(pos, 0, clone)
84+
newStops.splice(pos, 0, {id: genId('hint'), kind: 'hint', percentage: undefined, auto: ''})
85+
newStops.splice(pos, 0, clone)
7586
76-
$gradient_stops = updateStops(ensureIds($gradient_stops))
87+
$gradient_stops = ensureIds(newStops)
7788
}
7889
79-
function removePositionByIndex(index, pos) {
80-
$gradient_stops[index]['position'+pos] = null
90+
function removePositionByIndex(index: number, pos: number) {
91+
const newStops: GradientStop[] = [...$gradient_stops]
92+
;(newStops[index] as any)['position'+pos] = undefined
8193
8294
// spec fix, cant have 2nd position without the 1st one
83-
if (pos === 1 && $gradient_stops[index].position2 !== null)
84-
$gradient_stops[index]['position2'] = null
95+
if (pos === 1 && (newStops[index] as any).position2 !== undefined) {
96+
;(newStops[index] as any)['position2'] = undefined
97+
}
98+
99+
$gradient_stops = newStops
85100
}
86101
87-
function pickColor(stop, e) {
88-
const picker = document.getElementById('color-picker')
102+
function pickColor(stop: GradientStop, e: Event) {
103+
const picker = document.getElementById('color-picker') as any
89104
90105
// Seed the picker with the current stop color to avoid stale value flashes
91106
$picker_value = stop.color
@@ -108,30 +123,30 @@
108123
})
109124
}
110125
111-
function fieldsetInteractingStart(stop) {
126+
function fieldsetInteractingStart(stop: GradientStop) {
112127
$active_stop_index = $gradient_stops.indexOf(stop)
113128
}
114129
115-
function fieldsetInteractingEnd(stop) {
130+
function fieldsetInteractingEnd() {
116131
$active_stop_index = null
117132
}
118133
119-
function fixIfEmptied(stop) {
120-
if (stop.percentage === null) {
121-
stop.percentage = stop.auto
134+
function fixIfEmptied(stop: GradientStop) {
135+
if (stop.percentage === null || stop.percentage === undefined) {
136+
stop.percentage = String(stop.auto || '')
122137
$gradient_stops = [...$gradient_stops]
123138
}
124139
}
125140
126-
function unitBoundsForIndex(i) {
141+
function unitBoundsForIndex(i: number) {
127142
// Drag units are a stop and its following hint (if present)
128143
const isStop = $gradient_stops[i]?.kind === 'stop'
129144
if (!isStop) return null
130145
const hasFollowingHint = $gradient_stops[i+1]?.kind === 'hint'
131146
return { start: i, length: hasFollowingHint ? 2 : 1 }
132147
}
133148
134-
function beginDrag(e, i) {
149+
function beginDrag(e: DragEvent, i: number) {
135150
// prevent dragging from inputs/buttons
136151
if (e.target.closest('input, select, button')) return e.preventDefault()
137152
const unit = unitBoundsForIndex(i)
@@ -254,7 +269,7 @@
254269
ondragend={endDrag}
255270
class:drop-before={dragging && dropStart === i && dropPos === 'before'}
256271
class:drop-after={dragging && dropStart === i && dropPos === 'after'}
257-
style="accent-color: {stop.color}; --brand: {stop.color}"
272+
style="accent-color: {stop.color}; --brand: {stop.color}; --gs-glow-color: {stop.color}"
258273
class="stop control-set"
259274
onmouseenter={() => fieldsetInteractingStart(stop)}
260275
onfocusin={() => fieldsetInteractingStart(stop)}
@@ -267,7 +282,7 @@
267282
<Hint title="Color stop" copy="The color and position of that color on the gradient line.<br><br>The three dot menu has actions you can take on the color, like duplicate.<br><br>A color is not required in CSS to only be at a single position on the line, it may span the line by specifying a 2nd position." />
268283
{/if}
269284
<div class="chip color-stop" use:tooltip={{content: 'Gamut: '+ whatsTheGamutDamnit(stop.color), placement: 'top-start',}}>
270-
<button class="round" style="background-color: {stop.color}" onclick={e => pickColor(stop,e)}></button>
285+
<button class="round" style="background-color: {stop.color}" onclick={e => pickColor(stop,e)} aria-label="Pick color"></button>
271286
<input type="text" class="color-string" style="caret-color: {stop.color}" bind:value={stop.color}/>
272287
</div>
273288
<div class="positions-pair">
@@ -311,7 +326,7 @@
311326
<option disabled={colorStopCount() <= 1}>Remove</option>
312327
</select>
313328
</button>
314-
<div class="drag-handle" use:tooltip={{content: 'Drag to reorder'}} draggable="true" ondragstart={(e) => beginDrag(e, i)} aria-label="Drag to reorder"></div>
329+
<div class="drag-handle" use:tooltip={{content: 'Drag to reorder'}} draggable="true" ondragstart={(e) => beginDrag(e, i)} role="button" aria-label="Drag to reorder" tabindex="0"></div>
315330
</fieldset>
316331
{/if}
317332
{#if stop.kind === 'hint'}
@@ -343,7 +358,7 @@
343358
</div>
344359
{/each}
345360
<!-- End drop zone to allow dropping after the last stop -->
346-
<div class="end-dropzone" ondragover={(e)=> onDragOverEnd(e)} ondrop={(e)=> dropAtEnd(e)}></div>
361+
<div class="end-dropzone" ondragover={(e)=> onDragOverEnd(e)} ondrop={(e)=> dropAtEnd(e)} role="region" aria-label="Drop zone"></div>
347362
</section>
348363

349364
<style>
@@ -367,6 +382,8 @@
367382
box-shadow: var(--shadow-2);
368383
gap: var(--size-3);
369384
cursor: auto;
385+
/* Each stop carries its own glow color for the proximity sheen */
386+
--gs-glow-color: var(--brand);
370387
}
371388
372389
@media (prefers-color-scheme: light) {

src/routes/+layout.svelte

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script>
2+
import { onMount } from 'svelte';
23
import "prismjs"
34
import "../utils/prism-css.js"
45
/**
@@ -8,6 +9,86 @@
89
910
/** @type {Props} */
1011
let { children } = $props();
12+
13+
onMount(() => {
14+
/**
15+
* Pointer-based proximity glow for buttons, selects and .stop components.
16+
* Extends detection beyond the element bounds so near-edge hover still shines.
17+
*/
18+
const SELECTOR = 'button, select, .stop';
19+
const SEARCH_RADIUS = 96; // px beyond the element bounds
20+
let activeEl = /** @type {HTMLElement | null} */ (null);
21+
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
22+
23+
function updateProximity(event) {
24+
const { clientX, clientY } = event;
25+
const nodes = document.querySelectorAll(SELECTOR);
26+
27+
let closest = null;
28+
let closestDist = Infinity;
29+
let closestRect = null;
30+
let clampedX = 0;
31+
let clampedY = 0;
32+
33+
nodes.forEach((el) => {
34+
const rect = el.getBoundingClientRect();
35+
const cx = Math.min(Math.max(clientX, rect.left), rect.right);
36+
const cy = Math.min(Math.max(clientY, rect.top), rect.bottom);
37+
const dx = clientX - cx;
38+
const dy = clientY - cy;
39+
const dist = Math.hypot(dx, dy);
40+
41+
if (dist < closestDist && dist <= SEARCH_RADIUS) {
42+
closest = el;
43+
closestDist = dist;
44+
closestRect = rect;
45+
clampedX = cx;
46+
clampedY = cy;
47+
}
48+
});
49+
50+
// Nothing nearby: fade out any previous active element.
51+
if (!closest || !closestRect) {
52+
if (activeEl) {
53+
activeEl.style.setProperty('--gs-pointer-opacity', '0');
54+
activeEl = null;
55+
}
56+
return;
57+
}
58+
59+
// If switching targets, fade the previous one.
60+
if (activeEl && activeEl !== closest) {
61+
activeEl.style.setProperty('--gs-pointer-opacity', '0');
62+
}
63+
activeEl = closest;
64+
65+
const localX = clampedX - closestRect.left;
66+
const localY = clampedY - closestRect.top;
67+
68+
const falloff = 1 - Math.min(closestDist / SEARCH_RADIUS, 1);
69+
const baseOpacity = 0.12 + 0.60 * falloff;
70+
const opacity = baseOpacity * (darkQuery.matches ? 0.5 : 1);
71+
72+
closest.style.setProperty('--gs-pointer-x', `${localX}px`);
73+
closest.style.setProperty('--gs-pointer-y', `${localY}px`);
74+
closest.style.setProperty('--gs-pointer-opacity', opacity.toFixed(3));
75+
}
76+
77+
function resetProximity() {
78+
if (activeEl) {
79+
activeEl.style.setProperty('--gs-pointer-opacity', '0');
80+
activeEl = null;
81+
}
82+
}
83+
84+
window.addEventListener('pointermove', updateProximity, { passive: true });
85+
window.addEventListener('pointerleave', resetProximity, { passive: true });
86+
87+
return () => {
88+
window.removeEventListener('pointermove', updateProximity);
89+
window.removeEventListener('pointerleave', resetProximity);
90+
};
91+
});
1192
</script>
1293

1394
<div class="app">
@@ -57,4 +138,44 @@
57138
:global(.rich-tooltip p) {
58139
color: var(--text-2);
59140
}
141+
142+
/* ---------------------------------------------
143+
* Proximity-aware gradient border shine
144+
* ------------------------------------------ */
145+
146+
:global(button),
147+
:global(select),
148+
:global(.stop) {
149+
position: relative;
150+
isolation: isolate;
151+
/* Default glow color pulls from the active gradient accent when available */
152+
--gs-glow-color: var(--gs-glow-color, var(--link));
153+
--gs-pointer-x: 50%;
154+
--gs-pointer-y: 0%;
155+
--gs-pointer-opacity: 0;
156+
}
157+
158+
:global(button)::before,
159+
:global(select)::before,
160+
:global(.stop)::before {
161+
content: "";
162+
position: absolute;
163+
inset: -1px;
164+
border-radius: inherit;
165+
pointer-events: none;
166+
z-index: -1;
167+
background:
168+
radial-gradient(
169+
160px circle at var(--gs-pointer-x) var(--gs-pointer-y),
170+
color-mix(in oklch, var(--gs-glow-color) 92%, transparent) 0%,
171+
color-mix(in oklch, var(--gs-glow-color) 70%, transparent) 35%,
172+
transparent 70%
173+
);
174+
opacity: var(--gs-pointer-opacity);
175+
filter: blur(10px);
176+
mix-blend-mode: screen;
177+
transition:
178+
opacity 160ms var(--ease-3),
179+
transform 160ms var(--ease-3);
180+
}
60181
</style>

src/utils/clipboard.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export async function copyToClipboard(text) {
1+
export async function copyToClipboard(text: string): Promise<void> {
22
try {
3-
return navigator.clipboard.writeText(text)
3+
await navigator.clipboard.writeText(text)
44
}
55
catch (err) {
66
return Promise.reject(err)

0 commit comments

Comments
 (0)