feat: add theme system, fonts, graph styling, rename Overview→Tasks
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

- 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
This commit is contained in:
2026-07-15 00:14:31 +02:00
parent 49dfaa77e6
commit e8b30cddcf
12 changed files with 422 additions and 105 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import { mode } from 'mode-watcher'
import { getTheme } from '$lib/stores/theme.svelte'
interface Particle {
x: number
@@ -65,7 +65,7 @@
if (!ctx) return
const t = ts / 1000
const dark = mode.current !== 'light'
const dark = getTheme() !== 'light'
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)

View File

@@ -437,10 +437,11 @@
{#if loading && !nodes.length}
<Skeleton class="h-full min-h-0" />
{:else}
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border bg-[radial-gradient(ellipse_at_center,rgba(88,166,255,0.04),transparent_70%)]">
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border">
<svg
bind:this={svgEl}
viewBox="0 0 {width} {height}"
preserveAspectRatio="xMidYMid slice"
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
role="application"
aria-label="Entity graph"
@@ -451,12 +452,16 @@
onpointercancel={onPointerUp}
>
<defs>
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
</pattern>
{#each allRelTypes as type}
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
</marker>
{/each}
</defs>
<rect x="0" y="0" width={width} height={height} fill="url(#dot-grid)" />
<g transform="translate({view.x},{view.y}) scale({view.k})">
<g>
{#each links as link}
@@ -522,15 +527,15 @@
{#if isFocus || isMatch}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? '#e6edf3' : '#0d1117'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? 'var(--foreground)' : 'var(--background)'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
<text
y={r + 12}
text-anchor="middle"
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
fill={isFocus ? '#e6edf3' : '#8b949e'}
fill={isFocus ? 'var(--foreground)' : 'var(--muted-foreground)'}
paint-order="stroke"
stroke="#0d1117"
stroke="var(--background)"
stroke-width={3 / view.k}
class="pointer-events-none select-none"
>

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
import { fetchGraph, type Health } from '$lib/api'
import { mode } from 'mode-watcher'
import { getTheme } from '$lib/stores/theme.svelte'
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
// host places this behind the page with pointer-events:none, so it never
@@ -102,6 +102,28 @@
let dpr = 1
let w = 0
let h = 0
let dotCanvas: HTMLCanvasElement | null = null
let lastDotDark: boolean | null = null
function drawDots(dark: boolean) {
if (!dotCanvas) {
dotCanvas = document.createElement('canvas')
}
dotCanvas.width = Math.round(w * dpr)
dotCanvas.height = Math.round(h * dpr)
const dctx = dotCanvas.getContext('2d')!
dctx.setTransform(dpr, 0, 0, dpr, 0, 0)
dctx.clearRect(0, 0, w, h)
dctx.fillStyle = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
const spacing = 12
for (let x = spacing; x < w; x += spacing) {
for (let y = spacing; y < h; y += spacing) {
dctx.beginPath()
dctx.arc(x, y, 0.7, 0, Math.PI * 2)
dctx.fill()
}
}
}
function onPointerMove(e: PointerEvent) {
if (!host) return
@@ -118,6 +140,7 @@
h = host.clientHeight
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
dotCanvas = null // force redraw on next frame
}
function colorForNode(n: SimNode): string {
@@ -145,10 +168,14 @@
const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03
const dark = mode.current !== 'light'
const dark = getTheme() !== 'light'
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
if (lastDotDark !== dark) { dotCanvas = null; lastDotDark = dark }
if (!dotCanvas) drawDots(dark)
ctx.drawImage(dotCanvas!, 0, 0)
const cx = w / 2
const cy = h / 2

View File

@@ -343,6 +343,12 @@
onpointerup={onUp}
onpointercancel={onUp}
>
<defs>
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
</pattern>
</defs>
<rect width={cw} height={ch} fill="url(#dot-grid)" />
<g>
{#each links as link}
{@const s = endpoint(link.source)}

View File

@@ -39,7 +39,7 @@
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => newChat()}>
<PlusIcon class="size-3.5" />
New chat
New Task
</Button>
<ScrollArea class="min-h-0 flex-1">
<div class="flex flex-col gap-1 pr-2">

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
import { getTheme } from "$lib/stores/theme.svelte";
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
import OctagonXIcon from '@lucide/svelte/icons/octagon-x';
@@ -11,7 +11,7 @@
</script>
<Sonner
theme={mode.current}
theme={getTheme() === 'light' ? 'light' : 'dark'}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}

View File

@@ -0,0 +1,46 @@
export type Theme = 'light' | 'dark'
const STORAGE_KEY = 'oikos-theme'
function applyClass(theme: Theme): void {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
function storedTheme(): Theme {
if (typeof localStorage === 'undefined') return 'dark'
const v = localStorage.getItem(STORAGE_KEY)
if (v === 'light' || v === 'dark') return v
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
}
let current: Theme = $state(storedTheme())
applyClass(current)
export function setTheme(t: Theme): void {
current = t
applyClass(t)
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, t)
}
}
export function getTheme(): Theme {
return current
}
export function toggleTheme(): Theme {
const next = current === 'dark' ? 'light' : 'dark'
setTheme(next)
return next
}
export const THEME_LABELS: Record<Theme, string> = {
light: 'Terracotta',
dark: 'Carbon'
}