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

@@ -11,6 +11,7 @@
import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { connectionState } from '$lib/stores/events'
import { currentTask } from '$lib/stores/workspace'
import { isConfigured } from '$lib/config'
import { onMount } from 'svelte'
import { processPendingCallback, initOIDC } from '$lib/oidc'
@@ -22,7 +23,7 @@
import { VERSION } from '$lib/version'
import { Toaster } from '$lib/components/ui/sonner'
import PlusIcon from '@lucide/svelte/icons/plus'
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
@@ -30,6 +31,8 @@
import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
import PaletteIcon from '@lucide/svelte/icons/palette'
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
let page = $state('overview')
let routeParam = $state('')
@@ -73,8 +76,12 @@
location.hash = '#/' + p
}
function cycleTheme() {
toggleTheme()
}
const navItems = [
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
{ id: 'overview', label: 'Tasks', icon: ListTodoIcon },
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
@@ -104,7 +111,7 @@
>
{#snippet child({ props })}
<button {...props}>
<svg viewBox="0 0 91 100" class="!size-5 shrink-0 fill-white" aria-hidden="true" role="img">
<svg viewBox="0 0 91 100" class="!size-5 shrink-0" fill="var(--primary)" aria-hidden="true" role="img">
<title>Oikos</title>
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
</svg>
@@ -171,6 +178,16 @@
<PanelRightIcon />
<span>Chat drawer</span>
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={cycleTheme}
title="Cycle theme"
>
<PaletteIcon />
<span>{THEME_LABELS[getTheme()]}</span>
</Button>
<Button
variant="ghost"
size="sm"
@@ -189,11 +206,13 @@
<Sidebar.Trigger class="-ms-1" />
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
{#if page === 'chat'}
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Overview</button>
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Tasks</button>
<span class="text-muted-foreground">/</span>
<span class="text-base font-medium">Conversation</span>
<span class="text-base font-medium">
{$currentTask?.goal ? $currentTask.goal.replace(/[*_`~#]|\[.*?\]\(.*?\)/g, '') : 'New Task'}
</span>
{:else}
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page}</span>
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page === 'overview' ? 'Tasks' : page}</span>
{/if}
<div class="ms-auto flex items-center gap-2.5">
{#if $summary}

View File

@@ -2,62 +2,10 @@
@custom-variant dark (&:is(.dark *));
/* Neutral gray theme matching shadcn/ui's canonical dark palette (0-chroma
OKLCH grays) — the app is dark-only, so :root carries the dark values
directly rather than gating behind a .dark class. --success/--warning are
Oikos-specific semantic status colors (real health state), kept
distinguishable rather than desaturated to match the neutral chrome. */
:root {
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--success: #3fb950;
--warning: #d29922;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
/* legacy aliases still referenced by Chat/Sessions/App */
--bg: var(--background);
--bg-surface: var(--card);
--bg-deeper: oklch(0.11 0 0);
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
/* accent-blue stays a real blue (matches --sidebar-primary) for the few
spots that want an interactive "pop" — everything else (buttons,
links, focus rings) rides the neutral --primary now. */
--accent-blue: var(--sidebar-primary);
--accent-green: var(--success);
--accent-red: var(--destructive);
--accent-orange: var(--warning);
}
@theme inline {
--font-sans: 'DM Sans', system-ui, sans-serif;
--font-mono: 'DM Sans', system-ui, sans-serif;
--font-heading: 'Inknut Antiqua', Georgia, serif;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -91,25 +39,179 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-mono: var(--font-mono);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
/* ── Terracotta Light Theme ── */
:root {
--radius: 0.625rem;
--background: oklch(0.94 0.02 55);
--foreground: oklch(0.18 0.03 45);
--card: oklch(0.91 0.025 55);
--card-foreground: oklch(0.18 0.03 45);
--popover: oklch(0.91 0.025 55);
--popover-foreground: oklch(0.18 0.03 45);
--primary: oklch(0.55 0.14 45);
--primary-foreground: oklch(0.95 0.02 55);
--secondary: oklch(0.86 0.03 55);
--secondary-foreground: oklch(0.18 0.03 45);
--muted: oklch(0.86 0.025 55);
--muted-foreground: oklch(0.45 0.04 45);
--accent: oklch(0.84 0.035 55);
--accent-foreground: oklch(0.18 0.03 45);
--destructive: oklch(0.5 0.2 25);
--destructive-foreground: oklch(0.95 0.02 55);
--border: oklch(0.6 0.08 45);
--input: oklch(0.78 0.04 55);
--ring: oklch(0.55 0.14 45);
--chart-1: oklch(0.55 0.14 45);
--chart-2: oklch(0.65 0.1 70);
--chart-3: oklch(0.5 0.08 30);
--chart-4: oklch(0.6 0.06 90);
--chart-5: oklch(0.4 0.04 45);
--sidebar: oklch(0.90 0.025 55);
--sidebar-foreground: oklch(0.18 0.03 45);
--sidebar-primary: oklch(0.55 0.14 45);
--sidebar-primary-foreground: oklch(0.95 0.02 55);
--sidebar-accent: oklch(0.84 0.035 55);
--sidebar-accent-foreground: oklch(0.18 0.03 45);
--sidebar-border: oklch(0.6 0.08 45);
--sidebar-ring: oklch(0.55 0.14 45);
--success: #3fb950;
--warning: #d29922;
--bg: var(--background);
--bg-surface: var(--card);
--bg-deeper: oklch(0.98 0.01 55);
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
--accent-blue: var(--primary);
--accent-green: var(--success);
--accent-red: var(--destructive);
--accent-orange: var(--warning);
}
/* ── Carbon Dark Theme ── */
.dark {
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.6 0.15 220);
--chart-3: oklch(0.5 0.1 160);
--chart-4: oklch(0.7 0.08 60);
--chart-5: oklch(0.45 0.05 320);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--success: #3fb950;
--warning: #d29922;
--bg: var(--background);
--bg-surface: var(--card);
--bg-deeper: oklch(0.11 0 0);
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
--accent-blue: var(--sidebar-primary);
--accent-green: var(--success);
--accent-red: var(--destructive);
--accent-orange: var(--warning);
}
/* Terminal-style block cursor — outside @layer so it overrides CodeMirror */
.cm-cursor,
.cm-cursor-primary {
border-left-color: var(--primary) !important;
border-left-width: 0.5em !important;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html,
body {
@apply bg-background text-foreground;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
font-size: 15px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
}
/* Tailwind's preflight resets <button> to cursor: default; every button
and native interactive element in this app is clickable, so restore
the pointer cursor app-wide instead of annotating each one. */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
}
::selection {
background: var(--primary);
color: var(--primary-foreground);
}
.cm-content {
background: var(--background);
}
/* Theme-aware scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
/* Pointer cursor on all interactive elements */
button:not(:disabled),
[role='button']:not([aria-disabled='true']),
a[href],
@@ -117,30 +219,29 @@
select {
cursor: pointer;
}
a,
[role='link'],
[role='tab'],
[role='option'],
[data-slot='popover-trigger'],
[data-slot='toggle-group-item'],
[data-slot='alert-dialog-action'],
[data-slot='alert-dialog-cancel'],
.cm-tooltip-autocomplete [role='option'] {
cursor: pointer;
}
[data-slot='table-container'] {
border: none;
box-shadow: none;
}
}
#app {
height: 100%;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
a {
color: var(--accent-blue);
text-decoration: none;

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'
}

View File

@@ -132,11 +132,11 @@
{#each $messages as msg, i (msg.id)}
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
{#if msg.role === 'user'}
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed">
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
</div>
@@ -194,7 +194,7 @@
</div>
{/each}
<div class="border-t bg-card/50 p-3">
<div class="border-t bg-card/50 p-3 input-ornament relative">
<form
class="mx-auto flex max-w-3xl items-end gap-2"
onsubmit={(e) => {
@@ -245,63 +245,169 @@
</div>
<style>
/* Minimal markdown styling for assistant messages. */
/* ── Art Nouveau chat styling ── */
/* Assistant message wrapper — ornamental frame */
.assistant-msg {
position: relative;
border-left: 2px solid var(--primary);
border-image: linear-gradient(to bottom, var(--primary), transparent 80%) 1;
padding-left: 1rem;
}
/* User message — softened bubble with inner glow */
.user-msg {
box-shadow: 0 0 12px -4px var(--primary);
border: 1px solid var(--primary);
}
/* Prose overrides */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(ul) {
list-style-type: disc;
}
.prose-chat :global(ol) {
list-style-type: decimal;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
padding-left: 0.25rem;
}
.prose-chat :global(li::marker) {
color: var(--primary);
}
.prose-chat :global(code) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.1em 0.35em;
padding: 0.15em 0.4em;
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--primary);
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
padding: 0.75rem 0.875rem;
overflow-x: auto;
margin: 0 0 0.5rem;
position: relative;
}
.prose-chat :global(pre)::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.4;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
color: inherit;
border: none;
}
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
position: relative;
display: inline-block;
}
.prose-chat :global(h1)::after,
.prose-chat :global(h2)::after,
.prose-chat :global(h3)::after {
content: '';
display: block;
height: 1px;
margin-top: 2px;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.5;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
padding: 0.3rem 0.6rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--border);
border-left: 3px solid var(--primary);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
font-style: italic;
position: relative;
}
.prose-chat :global(blockquote)::before {
content: '“';
position: absolute;
left: -0.15rem;
top: -0.35rem;
font-size: 1.5rem;
color: var(--primary);
opacity: 0.6;
font-style: normal;
line-height: 1;
}
.prose-chat :global(hr) {
border: none;
height: 1px;
margin: 0.75rem 0;
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
}
.prose-chat :global(strong) {
color: var(--primary);
}
.prose-chat :global(a) {
color: var(--primary);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
/* Input area ornament */
.input-ornament::before {
content: '';
position: absolute;
top: 0;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
</style>