feat(dashboard): launch quick-wins — view transitions, OKLCH/P3 palette, reduced-motion-ready, responsive graph controls, ws reconnect state

- Native View Transitions API via onNavigate (feature-detected, reduced-motion safe)
- OKLCH + display-p3 accent palette with hex fallback (@supports progressive enhancement)
- WebSocket gains 'reconnecting' state so stale errors clear on reconnect
- Graph control bar wraps + safe-area insets for <640px / notched phones

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-21 19:15:12 -05:00
parent 2b50bf5d53
commit 28d2434843
4 changed files with 105 additions and 24 deletions

View file

@ -43,6 +43,43 @@ html {
font-family: var(--font-mono);
}
/*
OKLCH / DISPLAY-P3 ACCENT PALETTE (PROGRESSIVE ENHANCEMENT)
The @theme block above keeps the original sRGB hex values, which
Tailwind reads at build time and which serve as the fallback for
sRGB monitors and browsers without OKLCH support.
When the browser understands oklch(), we redefine the SAME vivid
accents + node-type colors using their OKLCH equivalents. These are
faithful conversions of the hex values (same hue/chroma identity);
on a wide-gamut display-p3 monitor they render more saturated while
reading as the same color. The void/abyss/surface neutrals are left
untouched only the vivid accents benefit from the wider gamut. */
@supports (color: oklch(0 0 0)) {
:root {
/* Accent colors */
--color-synapse: oklch(0.585 0.222 277);
--color-synapse-glow: oklch(0.685 0.169 277);
--color-dream: oklch(0.627 0.265 304);
--color-dream-glow: oklch(0.714 0.203 305);
--color-memory: oklch(0.623 0.214 259);
--color-recall: oklch(0.696 0.17 162);
--color-decay: oklch(0.637 0.237 25);
--color-warning: oklch(0.769 0.188 70);
/* Node type colors */
--color-node-fact: oklch(0.623 0.214 259);
--color-node-concept: oklch(0.606 0.25 292);
--color-node-event: oklch(0.769 0.188 70);
--color-node-person: oklch(0.696 0.17 162);
--color-node-place: oklch(0.715 0.143 215);
--color-node-note: oklch(0.551 0.027 264);
--color-node-pattern: oklch(0.656 0.241 354);
--color-node-decision: oklch(0.637 0.237 25);
}
}
body {
margin: 0;
min-height: 100vh;
@ -234,3 +271,33 @@ body {
.retention-low { color: var(--color-warning); }
.retention-good { color: var(--color-recall); }
.retention-strong { color: var(--color-synapse); }
/*
VIEW TRANSITIONS (CROSSFADE)
Native View Transitions API crossfade between routes. Pairs with the
onNavigate hook in +layout.svelte that calls document.startViewTransition.
Reduced-motion users get an instant cut (the @media guard disables the
animation entirely, so the default browser cross-fade does not run). */
@media not (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 180ms;
animation-timing-function: ease;
}
::view-transition-old(root) {
animation-name: vt-fade-out;
}
::view-transition-new(root) {
animation-name: vt-fade-in;
}
@keyframes vt-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes vt-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
}

View file

@ -6,11 +6,13 @@ const MAX_EVENTS = 200;
function createWebSocketStore() {
const { subscribe, set, update } = writable<{
connected: boolean;
reconnecting: boolean;
events: VestigeEvent[];
lastHeartbeat: VestigeEvent | null;
error: string | null;
}>({
connected: false,
reconnecting: false,
events: [],
lastHeartbeat: null,
error: null
@ -32,7 +34,7 @@ function createWebSocketStore() {
ws.onopen = () => {
reconnectAttempts = 0;
update(s => ({ ...s, connected: true, error: null }));
update(s => ({ ...s, connected: true, reconnecting: false, error: null }));
};
ws.onmessage = (event) => {
@ -65,6 +67,7 @@ function createWebSocketStore() {
function scheduleReconnect(url: string) {
if (reconnectTimer) clearTimeout(reconnectTimer);
update(s => ({ ...s, reconnecting: true }));
const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000);
reconnectAttempts++;
reconnectTimer = setTimeout(() => connect(url), delay);
@ -74,7 +77,7 @@ function createWebSocketStore() {
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
ws = null;
set({ connected: false, events: [], lastHeartbeat: null, error: null });
set({ connected: false, reconnecting: false, events: [], lastHeartbeat: null, error: null });
}
function clearEvents() {
@ -108,6 +111,7 @@ export const websocket = createWebSocketStore();
// Derived stores for specific event types
export const isConnected = derived(websocket, $ws => $ws.connected);
export const isReconnecting = derived(websocket, $ws => $ws.reconnecting);
export const eventFeed = derived(websocket, $ws => $ws.events);
export const heartbeat = derived(websocket, $ws => $ws.lastHeartbeat);
export const memoryCount = derived(websocket, $ws =>

View file

@ -262,34 +262,38 @@ disown</code>
{/if}
<!-- Top controls bar -->
<div class="absolute top-4 left-4 right-4 z-10 flex items-center gap-3">
<div
class="absolute top-0 left-0 right-0 z-10 flex flex-wrap items-center gap-2 px-3 pt-3 sm:top-4 sm:left-4 sm:right-4 sm:gap-3 sm:p-0
[padding-top:max(0.75rem,env(safe-area-inset-top))] [padding-left:max(0.75rem,env(safe-area-inset-left))] [padding-right:max(0.75rem,env(safe-area-inset-right))]
sm:[padding-top:0] sm:[padding-left:0] sm:[padding-right:0]"
>
<!-- Search -->
<div class="flex gap-2 flex-1 max-w-md">
<div class="flex gap-2 w-full sm:flex-1 sm:w-auto sm:max-w-md">
<input
type="text"
placeholder="Center graph on..."
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Enter' && searchGraph()}
class="flex-1 px-3 py-2 glass rounded-xl text-text text-sm
class="flex-1 min-w-0 px-3 py-2 glass rounded-xl text-text text-sm
placeholder:text-muted focus:outline-none focus:!border-synapse/40 transition"
/>
<button onclick={searchGraph}
class="px-3 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition backdrop-blur-sm">
class="shrink-0 min-h-10 px-3 py-2 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition backdrop-blur-sm">
Focus
</button>
</div>
<div class="flex gap-2 ml-auto">
<div class="flex flex-wrap gap-2 w-full sm:w-auto sm:ml-auto">
<!-- v2.0.8: colour mode toggle. Switches sphere tint between node type
(fact / concept / event / …) and FSRS memory state (active / dormant /
silent / unavailable). Legend auto-renders in state mode. -->
<div class="flex glass rounded-xl p-0.5 text-xs" role="radiogroup" aria-label="Colour mode">
<div class="flex shrink min-w-0 glass rounded-xl p-0.5 text-xs" role="radiogroup" aria-label="Colour mode">
<button
type="button"
role="radio"
aria-checked={colorMode === 'type'}
onclick={() => (colorMode = 'type')}
class="px-3 py-1.5 rounded-lg transition {colorMode === 'type' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
class="min-h-9 px-3 py-1.5 rounded-lg transition {colorMode === 'type' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
title="Colour by node type (fact, concept, event, …)"
>
Type
@ -299,7 +303,7 @@ disown</code>
role="radio"
aria-checked={colorMode === 'state'}
onclick={() => (colorMode = 'state')}
class="px-3 py-1.5 rounded-lg transition {colorMode === 'state' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
class="min-h-9 px-3 py-1.5 rounded-lg transition {colorMode === 'state' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
title="Colour by FSRS memory state (active / dormant / silent / unavailable)"
>
State
@ -309,7 +313,7 @@ disown</code>
role="radio"
aria-checked={colorMode === 'ahagraph'}
onclick={() => (colorMode = 'ahagraph')}
class="px-3 py-1.5 rounded-lg transition {colorMode === 'ahagraph' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
class="min-h-9 px-3 py-1.5 rounded-lg transition {colorMode === 'ahagraph' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
title="Colour by AhaGraph tags (aha / confusion / failure)"
>
AhaGraph
@ -318,7 +322,7 @@ disown</code>
<!-- Node count -->
<select bind:value={maxNodes} onchange={() => loadGraph()}
class="px-2 py-2 glass rounded-xl text-dim text-xs">
class="shrink-0 min-h-10 px-2 py-2 glass rounded-xl text-dim text-xs">
<option value={50}>50 nodes</option>
<option value={100}>100 nodes</option>
<option value={150}>150 nodes</option>
@ -328,7 +332,7 @@ disown</code>
<!-- Brightness slider (persists in localStorage). Scales node emissive,
glow, and distance-compensated fog falloff. Default 1.0, range 0.5-2.5. -->
<label
class="flex items-center gap-2 px-3 py-2 glass rounded-xl text-dim text-xs select-none"
class="flex shrink-0 items-center gap-2 min-h-10 px-3 py-2 glass rounded-xl text-dim text-xs select-none"
title="Adjust graph brightness ({graphState.brightness.toFixed(1)}x). Combines with auto distance compensation."
>
<span class="text-synapse-glow"></span>
@ -350,7 +354,7 @@ disown</code>
<button
onclick={triggerDream}
disabled={isDreaming}
class="px-4 py-2 rounded-xl bg-dream/20 border border-dream/40 text-dream-glow text-sm
class="shrink-0 min-h-10 px-4 py-2 rounded-xl bg-dream/20 border border-dream/40 text-dream-glow text-sm
hover:bg-dream/30 transition-all backdrop-blur-sm disabled:opacity-50
{isDreaming ? 'glow-dream animate-pulse-glow' : ''}"
>
@ -359,7 +363,7 @@ disown</code>
<!-- Reload -->
<button onclick={() => loadGraph()}
class="px-3 py-2 glass rounded-xl text-dim text-sm hover:text-text transition">
class="shrink-0 min-h-10 min-w-10 px-3 py-2 glass rounded-xl text-dim text-sm hover:text-text transition">
</button>
</div>

View file

@ -2,7 +2,7 @@
import '../app.css';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { goto, onNavigate } from '$app/navigation';
import { base } from '$app/paths';
import {
websocket,
@ -80,6 +80,19 @@
};
});
// Native View Transitions for client-side route navigation. Crossfades route
// changes when supported; respects prefers-reduced-motion. This replaces the
// old hand-rolled .animate-page-in keyframe on the route content wrapper.
onNavigate((navigation) => {
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
});
});
const nav = [
{ href: '/graph', label: 'Graph', icon: '◎', shortcut: 'G' },
{ href: '/reasoning', label: 'Reasoning', icon: '✦', shortcut: 'R' },
@ -201,7 +214,7 @@
<main class="flex-1 flex flex-col min-h-0 pb-16 md:pb-0">
<AmbientAwarenessStrip />
<VerdictBar />
<div class="animate-page-in flex-1 min-h-0 overflow-y-auto">
<div class="flex-1 min-h-0 overflow-y-auto">
{@render children()}
</div>
</main>
@ -284,11 +297,4 @@
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
@keyframes page-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-page-in {
animation: page-in 0.2s ease-out;
}
</style>