mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
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:
parent
2b50bf5d53
commit
28d2434843
4 changed files with 105 additions and 24 deletions
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue