- Terracotta (light) and Carbon (dark) themes with toggle - Inknut Antiqua headings, DM Sans body - Dot grid background on EntityGraph and GraphBackground - Theme-adaptive graph colors on EntityGraph - Art Nouveau chat styling (borders, underlines, blockquote quotes) - Bullet point styles in chat prose - Task goal in header, rename Overview→Tasks, New Task labels - Logo uses var(--primary) for theme awareness
172 lines
4.8 KiB
Svelte
172 lines
4.8 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte'
|
|
import { getTheme } from '$lib/stores/theme.svelte'
|
|
|
|
interface Particle {
|
|
x: number
|
|
y: number
|
|
vx: number
|
|
vy: number
|
|
r: number
|
|
phase: number
|
|
pulse: number
|
|
}
|
|
|
|
const COUNT = 90
|
|
const CONNECT_DIST = 160
|
|
const MOUSE_RADIUS = 200
|
|
const MOUSE_FORCE = 0.012
|
|
|
|
let host = $state<HTMLDivElement | null>(null)
|
|
let canvas = $state<HTMLCanvasElement | null>(null)
|
|
let particles: Particle[] = []
|
|
let mouse = { x: -500, y: -500 }
|
|
let w = 0, h = 0, dpr = 1
|
|
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
|
|
|
function spawn() {
|
|
particles = Array.from({ length: COUNT }, () => ({
|
|
x: Math.random() * w,
|
|
y: Math.random() * h,
|
|
vx: (Math.random() - 0.5) * 0.3,
|
|
vy: (Math.random() - 0.5) * 0.3,
|
|
r: 1 + Math.random() * 2,
|
|
phase: Math.random() * Math.PI * 2,
|
|
pulse: 0.4 + Math.random() * 0.6
|
|
}))
|
|
}
|
|
|
|
function resize() {
|
|
if (!host || !canvas) return
|
|
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
|
w = host.clientWidth
|
|
h = host.clientHeight
|
|
canvas.width = Math.round(w * dpr)
|
|
canvas.height = Math.round(h * dpr)
|
|
if (particles.length === 0) spawn()
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
if (!host) return
|
|
const rect = host.getBoundingClientRect()
|
|
mouse.x = e.clientX - rect.left
|
|
mouse.y = e.clientY - rect.top
|
|
}
|
|
|
|
function onPointerLeave() {
|
|
mouse.x = -500
|
|
mouse.y = -500
|
|
}
|
|
|
|
function draw(ts: number) {
|
|
timer = setTimeout(() => draw(performance.now()), 33)
|
|
if (!canvas || particles.length === 0) return
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
const t = ts / 1000
|
|
const dark = getTheme() !== 'light'
|
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
ctx.clearRect(0, 0, w, h)
|
|
|
|
// update + draw particles
|
|
for (const p of particles) {
|
|
// autonomous drift
|
|
p.vx += (Math.sin(t * 0.4 + p.phase) * 0.003) * 0.15
|
|
p.vy += (Math.cos(t * 0.35 + p.phase) * 0.003) * 0.15
|
|
|
|
// mouse interaction
|
|
const dx = p.x - mouse.x
|
|
const dy = p.y - mouse.y
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
if (dist < MOUSE_RADIUS && dist > 0) {
|
|
const force = (MOUSE_RADIUS - dist) / MOUSE_RADIUS * MOUSE_FORCE
|
|
p.vx += (dx / dist) * force * 0.6
|
|
p.vy += (dy / dist) * force * 0.6
|
|
}
|
|
|
|
// friction + random nudge
|
|
p.vx *= 0.995
|
|
p.vy *= 0.995
|
|
if (Math.random() < 0.003) {
|
|
p.vx += (Math.random() - 0.5) * 0.04
|
|
p.vy += (Math.random() - 0.5) * 0.04
|
|
}
|
|
|
|
// wrap
|
|
p.x += p.vx
|
|
p.y += p.vy
|
|
if (p.x < -40) p.x = w + 40
|
|
if (p.x > w + 40) p.x = -40
|
|
if (p.y < -40) p.y = h + 40
|
|
if (p.y > h + 40) p.y = -40
|
|
|
|
// pulse brightness
|
|
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
|
ctx.fillStyle = dark
|
|
? `rgba(140,175,230,${alpha})`
|
|
: `rgba(60,90,140,${alpha})`
|
|
ctx.beginPath()
|
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
}
|
|
|
|
// connections between nearby particles
|
|
ctx.lineWidth = 0.6
|
|
for (let i = 0; i < particles.length; i++) {
|
|
for (let j = i + 1; j < particles.length; j++) {
|
|
const a = particles[i]
|
|
const b = particles[j]
|
|
const dx = a.x - b.x
|
|
const dy = a.y - b.y
|
|
const dist = dx * dx + dy * dy
|
|
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
|
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
|
ctx.strokeStyle = dark
|
|
? `rgba(140,175,230,${alpha})`
|
|
: `rgba(60,90,140,${alpha})`
|
|
ctx.beginPath()
|
|
ctx.moveTo(a.x, a.y)
|
|
ctx.lineTo(b.x, b.y)
|
|
ctx.stroke()
|
|
}
|
|
}
|
|
}
|
|
|
|
// radial scrim to keep center legible
|
|
const cx = w / 2, cy = h / 2
|
|
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
|
const base = dark ? '13,17,23' : '255,255,255'
|
|
scrim.addColorStop(0, `rgba(${base},0.72)`)
|
|
scrim.addColorStop(0.35, `rgba(${base},0.40)`)
|
|
scrim.addColorStop(0.65, `rgba(${base},0.08)`)
|
|
scrim.addColorStop(1, 'rgba(0,0,0,0)')
|
|
ctx.fillStyle = scrim
|
|
ctx.fillRect(0, 0, w, h)
|
|
}
|
|
|
|
onMount(() => {
|
|
resize()
|
|
spawn()
|
|
const ro = new ResizeObserver(() => {
|
|
resize()
|
|
spawn()
|
|
})
|
|
if (host) ro.observe(host)
|
|
window.addEventListener('pointermove', onPointerMove)
|
|
window.addEventListener('pointerleave', onPointerLeave)
|
|
timer = setTimeout(() => draw(performance.now()), 33)
|
|
return () => {
|
|
clearTimeout(timer)
|
|
ro.disconnect()
|
|
window.removeEventListener('pointermove', onPointerMove)
|
|
window.removeEventListener('pointerleave', onPointerLeave)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<div bind:this={host} class="absolute inset-0 overflow-hidden bg-background">
|
|
<canvas bind:this={canvas} class="h-full w-full"></canvas>
|
|
</div>
|