mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-04 22:02:14 +02:00
feat(dashboard): alive overhaul — unique icons, dropdowns, motion on every page
Make the dashboard feel alive every second, with clear controls, for the July 14 HN relaunch. Memory Cinema is left fully untouched (zero changes to MemoryCinema.svelte / graph/cinema/*; its tests still pass). Foundation (lifts every page): - Icon.svelte: inline-SVG icon system, zero runtime dep. A UNIQUE semantic silhouette per nav item — kills the old duplicated Unicode glyphs (◎◈◉◷ were each reused across multiple items). Wired into sidebar, mobile nav, command palette, logo. - Dropdown.svelte: accessible, keyboard-nav, type-ahead, animated select replacement with color dots / badges. Replaces dead native <select>s. - AnimatedNumber.svelte: rAF count-up/tween, reduced-motion safe. - PageHeader.svelte: shared masthead (drawn route icon + aurora title). - actions/reveal.ts + actions/interactions.ts: scroll-reveal, magnetic, tilt(+glare), spotlight — all no-op under reduced-motion. - app.css "alive layer": @property animatable gradients, conic live-border, breathe/ping, shimmer skeletons, @starting-style entry, aurora text, lift. Per-page: every route (graph non-cinema controls, reasoning, memories, timeline, feed, explore, activation, dreams, schedule, importance, duplicates, contradictions, patterns, intentions, stats, settings) now uses PageHeader, real Icons, count-ups, staggered reveals, shimmer loaders, spotlight cards, and warm empty states. Native selects and button-row filters became clear Dropdowns where it improves clarity. Gates: svelte-check 0 errors/0 warnings, 937/937 tests pass, build green, verified live in the browser preview. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bc81da46eb
commit
1fbbecb0b3
24 changed files with 2360 additions and 585 deletions
136
apps/dashboard/src/lib/actions/interactions.ts
Normal file
136
apps/dashboard/src/lib/actions/interactions.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Micro-interaction actions — the "alive" layer for pointer feel.
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// All actions no-op under prefers-reduced-motion and clean up their own
|
||||
// listeners on destroy. Pure pointer events, no dependency.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// ── magnetic: element drifts toward the cursor, snaps back on leave ──────
|
||||
export interface MagneticOptions {
|
||||
/** How far the element is allowed to drift, in px. */
|
||||
strength?: number;
|
||||
}
|
||||
export function magnetic(node: HTMLElement, options: MagneticOptions = {}) {
|
||||
if (prefersReducedMotion()) return {};
|
||||
const strength = options.strength ?? 8;
|
||||
let raf = 0;
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
const r = node.getBoundingClientRect();
|
||||
const mx = e.clientX - (r.left + r.width / 2);
|
||||
const my = e.clientY - (r.top + r.height / 2);
|
||||
const dx = Math.max(-1, Math.min(1, mx / (r.width / 2))) * strength;
|
||||
const dy = Math.max(-1, Math.min(1, my / (r.height / 2))) * strength;
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
node.style.transform = `translate(${dx}px, ${dy}px)`;
|
||||
});
|
||||
}
|
||||
function onLeave() {
|
||||
cancelAnimationFrame(raf);
|
||||
node.style.transform = '';
|
||||
}
|
||||
|
||||
node.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
node.addEventListener('pointermove', onMove);
|
||||
node.addEventListener('pointerleave', onLeave);
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointermove', onMove);
|
||||
node.removeEventListener('pointerleave', onLeave);
|
||||
cancelAnimationFrame(raf);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── tilt: 3D parallax tilt toward the cursor (cards, hero panels) ────────
|
||||
export interface TiltOptions {
|
||||
/** Max tilt in degrees. */
|
||||
max?: number;
|
||||
/** Lift the card toward the viewer on hover, in px. */
|
||||
lift?: number;
|
||||
/** Add a moving sheen highlight that follows the cursor. */
|
||||
glare?: boolean;
|
||||
}
|
||||
export function tilt(node: HTMLElement, options: TiltOptions = {}) {
|
||||
if (prefersReducedMotion()) return {};
|
||||
const max = options.max ?? 6;
|
||||
const lift = options.lift ?? 0;
|
||||
const glare = options.glare ?? false;
|
||||
let raf = 0;
|
||||
|
||||
node.style.transformStyle = 'preserve-3d';
|
||||
node.style.transition = 'transform 0.3s cubic-bezier(0.23, 1, 0.32, 1)';
|
||||
|
||||
if (glare) {
|
||||
node.style.setProperty('--glare-x', '50%');
|
||||
node.style.setProperty('--glare-y', '50%');
|
||||
node.style.setProperty('--glare-o', '0');
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
const r = node.getBoundingClientRect();
|
||||
const px = (e.clientX - r.left) / r.width;
|
||||
const py = (e.clientY - r.top) / r.height;
|
||||
const rx = (0.5 - py) * max * 2;
|
||||
const ry = (px - 0.5) * max * 2;
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
node.style.transform = `perspective(900px) rotateX(${rx}deg) rotateY(${ry}deg)${lift ? ` translateZ(${lift}px)` : ''}`;
|
||||
if (glare) {
|
||||
node.style.setProperty('--glare-x', `${px * 100}%`);
|
||||
node.style.setProperty('--glare-y', `${py * 100}%`);
|
||||
node.style.setProperty('--glare-o', '1');
|
||||
}
|
||||
});
|
||||
}
|
||||
function onLeave() {
|
||||
cancelAnimationFrame(raf);
|
||||
node.style.transform = '';
|
||||
if (glare) node.style.setProperty('--glare-o', '0');
|
||||
}
|
||||
|
||||
node.addEventListener('pointermove', onMove);
|
||||
node.addEventListener('pointerleave', onLeave);
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointermove', onMove);
|
||||
node.removeEventListener('pointerleave', onLeave);
|
||||
cancelAnimationFrame(raf);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── spotlight: a soft radial glow that tracks the cursor over a surface ──
|
||||
// Sets --spot-x / --spot-y custom props the element's CSS can read to
|
||||
// position a radial-gradient highlight. Makes large panels feel responsive
|
||||
// to the pointer even when nothing is "hovered".
|
||||
export function spotlight(node: HTMLElement) {
|
||||
if (prefersReducedMotion()) return {};
|
||||
let raf = 0;
|
||||
function onMove(e: PointerEvent) {
|
||||
const r = node.getBoundingClientRect();
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
node.style.setProperty('--spot-x', `${e.clientX - r.left}px`);
|
||||
node.style.setProperty('--spot-y', `${e.clientY - r.top}px`);
|
||||
node.style.setProperty('--spot-o', '1');
|
||||
});
|
||||
}
|
||||
function onLeave() {
|
||||
node.style.setProperty('--spot-o', '0');
|
||||
}
|
||||
node.addEventListener('pointermove', onMove);
|
||||
node.addEventListener('pointerleave', onLeave);
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointermove', onMove);
|
||||
node.removeEventListener('pointerleave', onLeave);
|
||||
cancelAnimationFrame(raf);
|
||||
},
|
||||
};
|
||||
}
|
||||
68
apps/dashboard/src/lib/actions/reveal.ts
Normal file
68
apps/dashboard/src/lib/actions/reveal.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// reveal — scroll-into-view entrance animation as a Svelte action.
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// Usage: <div use:reveal> // default rise+fade
|
||||
// <div use:reveal={{ y: 24, delay: 80, once: true }}>
|
||||
//
|
||||
// Adds an IntersectionObserver that flips the element to its "revealed"
|
||||
// state the first time it scrolls into view. Pairs with the .reveal /
|
||||
// .reveal-in CSS in app.css. Honors prefers-reduced-motion by revealing
|
||||
// instantly (no transform), so motion-sensitive users still see content.
|
||||
//
|
||||
// Why an action and not CSS scroll-timeline alone: scroll-driven
|
||||
// animation-timeline is great for continuous scrubbing, but for a simple
|
||||
// "appear once when seen" we want a one-shot that also works as a staggered
|
||||
// list entrance with per-item delay — an observer is the robust path with
|
||||
// universal mid-2026 support.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface RevealOptions {
|
||||
/** Pixels to translate up from on entry. */
|
||||
y?: number;
|
||||
/** ms delay before the transition starts (use for stagger). */
|
||||
delay?: number;
|
||||
/** Only reveal once, then stop observing (default true). */
|
||||
once?: boolean;
|
||||
/** 0-1 visibility threshold to trigger. */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
export function reveal(node: HTMLElement, options: RevealOptions = {}) {
|
||||
const { y = 16, delay = 0, once = true, threshold = 0.12 } = options;
|
||||
|
||||
// Motion-off: show immediately, do nothing else.
|
||||
if (prefersReducedMotion()) {
|
||||
node.classList.add('reveal-in');
|
||||
return {};
|
||||
}
|
||||
|
||||
node.classList.add('reveal');
|
||||
node.style.setProperty('--reveal-y', `${y}px`);
|
||||
if (delay) node.style.setProperty('--reveal-delay', `${delay}ms`);
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
node.classList.add('reveal-in');
|
||||
if (once) io.unobserve(node);
|
||||
} else if (!once) {
|
||||
node.classList.remove('reveal-in');
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin: '0px 0px -8% 0px' }
|
||||
);
|
||||
|
||||
io.observe(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
io.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
91
apps/dashboard/src/lib/components/AnimatedNumber.svelte
Normal file
91
apps/dashboard/src/lib/components/AnimatedNumber.svelte
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<script lang="ts">
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// ANIMATED NUMBER — smooth count-up / tween between values.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Tweens from the previous value to the new one with an ease-out curve.
|
||||
// Counts up on first mount (from 0) so every figure feels like it's
|
||||
// being tallied live, and re-tweens whenever the bound value changes
|
||||
// (e.g. a websocket push). Respects prefers-reduced-motion: those users
|
||||
// get the final value instantly. Pure rAF — no dependency.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
interface Props {
|
||||
value: number;
|
||||
/** Decimal places to render. */
|
||||
decimals?: number;
|
||||
/** Multiply before formatting (e.g. 100 for a 0-1 ratio → percent). */
|
||||
scale?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
duration?: number;
|
||||
class?: string;
|
||||
/** Group thousands with separators. */
|
||||
group?: boolean;
|
||||
}
|
||||
let {
|
||||
value,
|
||||
decimals = 0,
|
||||
scale = 1,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
duration = 900,
|
||||
class: cls = '',
|
||||
group = true,
|
||||
}: Props = $props();
|
||||
|
||||
let display = $state(0);
|
||||
let raf = 0;
|
||||
let from = 0;
|
||||
let start = 0;
|
||||
let mounted = false;
|
||||
|
||||
const reduceMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function easeOutExpo(t: number): number {
|
||||
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
|
||||
}
|
||||
|
||||
function animateTo(target: number) {
|
||||
if (reduceMotion) {
|
||||
display = target;
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(raf);
|
||||
from = display;
|
||||
start = 0;
|
||||
function tick(ts: number) {
|
||||
if (!start) start = ts;
|
||||
const t = Math.min(1, (ts - start) / duration);
|
||||
display = from + (target - from) * easeOutExpo(t);
|
||||
if (t < 1) raf = requestAnimationFrame(tick);
|
||||
else display = target;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Track the incoming value; tween toward it.
|
||||
const target = value;
|
||||
if (!mounted) {
|
||||
mounted = true;
|
||||
animateTo(target);
|
||||
} else {
|
||||
animateTo(target);
|
||||
}
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
let formatted = $derived(
|
||||
(() => {
|
||||
const v = display * scale;
|
||||
const fixed = v.toFixed(decimals);
|
||||
if (!group) return fixed;
|
||||
const [int, frac] = fixed.split('.');
|
||||
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return frac !== undefined ? `${grouped}.${frac}` : grouped;
|
||||
})()
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="tabular-nums {cls}">{prefix}{formatted}{suffix}</span>
|
||||
338
apps/dashboard/src/lib/components/Dropdown.svelte
Normal file
338
apps/dashboard/src/lib/components/Dropdown.svelte
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<script lang="ts" module>
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Optional color dot (e.g. node-type color) shown before the label. */
|
||||
color?: string;
|
||||
/** Optional small count/badge shown after the label. */
|
||||
badge?: string | number;
|
||||
/** Optional icon name from the Icon system. */
|
||||
icon?: IconName;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Icon, { type IconName } from './Icon.svelte';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// DROPDOWN — accessible, animated, themed select replacement.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Replaces the dead native <select>. Keyboard-navigable (↑/↓/Enter/Esc/
|
||||
// Home/End/type-ahead), closes on outside click, animates open with a
|
||||
// spring-ish scale, and themes to the cosmic palette. Makes filtering
|
||||
// CLEAR: shows the current value, an icon, color dots, and counts.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
interface Props {
|
||||
options: DropdownOption[];
|
||||
value: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
icon?: IconName;
|
||||
/** Width hint for the trigger; menu matches the trigger width. */
|
||||
class?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
let {
|
||||
options,
|
||||
value = $bindable(),
|
||||
label,
|
||||
placeholder = 'Select…',
|
||||
icon,
|
||||
class: cls = '',
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let triggerEl = $state<HTMLButtonElement>();
|
||||
let menuEl = $state<HTMLDivElement>();
|
||||
let activeIndex = $state(-1);
|
||||
let typeAhead = '';
|
||||
let typeAheadTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let selected = $derived(options.find((o) => o.value === value));
|
||||
|
||||
function select(opt: DropdownOption) {
|
||||
value = opt.value;
|
||||
onChange?.(opt.value);
|
||||
close();
|
||||
triggerEl?.focus();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open ? close() : openMenu();
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
open = true;
|
||||
activeIndex = Math.max(0, options.findIndex((o) => o.value === value));
|
||||
requestAnimationFrame(() => {
|
||||
(menuEl?.querySelectorAll('[role="option"]')[activeIndex] as HTMLElement)?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function onTriggerKey(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
open ? move(1) : openMenu();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
open ? move(-1) : openMenu();
|
||||
} else if (e.key === 'Escape') {
|
||||
close();
|
||||
} else if (e.key === 'Home' && open) {
|
||||
e.preventDefault();
|
||||
activeIndex = 0;
|
||||
} else if (e.key === 'End' && open) {
|
||||
e.preventDefault();
|
||||
activeIndex = options.length - 1;
|
||||
} else if (open && e.key === 'Enter' && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
select(options[activeIndex]);
|
||||
} else if (e.key.length === 1 && /\S/.test(e.key)) {
|
||||
// type-ahead
|
||||
if (!open) openMenu();
|
||||
clearTimeout(typeAheadTimer);
|
||||
typeAhead += e.key.toLowerCase();
|
||||
typeAheadTimer = setTimeout(() => (typeAhead = ''), 600);
|
||||
const idx = options.findIndex((o) => o.label.toLowerCase().startsWith(typeAhead));
|
||||
if (idx >= 0) activeIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
function move(delta: number) {
|
||||
const n = options.length;
|
||||
activeIndex = (activeIndex + delta + n) % n;
|
||||
requestAnimationFrame(() => {
|
||||
(menuEl?.querySelectorAll('[role="option"]')[activeIndex] as HTMLElement)?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Close on outside click while open.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!triggerEl?.contains(e.target as Node) && !menuEl?.contains(e.target as Node)) close();
|
||||
}
|
||||
document.addEventListener('click', onDocClick, true);
|
||||
return () => document.removeEventListener('click', onDocClick, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dd {cls}">
|
||||
{#if label}
|
||||
<span class="dd-label">{label}</span>
|
||||
{/if}
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
class="dd-trigger"
|
||||
class:dd-open={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onclick={toggle}
|
||||
onkeydown={onTriggerKey}
|
||||
>
|
||||
{#if icon}
|
||||
<span class="dd-trigger-icon"><Icon name={icon} size={15} /></span>
|
||||
{/if}
|
||||
<span class="dd-value" class:dd-placeholder={!selected}>
|
||||
{#if selected?.color}
|
||||
<span class="dd-dot" style="background:{selected.color}"></span>
|
||||
{/if}
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<span class="dd-chevron" class:dd-chevron-open={open}><Icon name="chevron" size={14} /></span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
bind:this={menuEl}
|
||||
class="dd-menu glass-panel"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
aria-label={label ?? placeholder}
|
||||
>
|
||||
{#each options as opt, i (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
class="dd-option"
|
||||
class:dd-active={i === activeIndex}
|
||||
class:dd-selected={opt.value === value}
|
||||
onmouseenter={() => (activeIndex = i)}
|
||||
onclick={() => select(opt)}
|
||||
>
|
||||
{#if opt.icon}<span class="dd-opt-icon"><Icon name={opt.icon} size={15} /></span>{/if}
|
||||
{#if opt.color}<span class="dd-dot" style="background:{opt.color}"></span>{/if}
|
||||
<span class="dd-opt-label">{opt.label}</span>
|
||||
{#if opt.badge !== undefined}<span class="dd-badge">{opt.badge}</span>{/if}
|
||||
{#if opt.value === value}<span class="dd-check"><Icon name="sparkle" size={12} /></span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dd {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.dd-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-muted);
|
||||
padding-left: 0.15rem;
|
||||
}
|
||||
.dd-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.7rem 0.55rem 0.8rem;
|
||||
min-width: 9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
.dd-trigger:hover {
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.dd-trigger:focus-visible,
|
||||
.dd-trigger.dd-open {
|
||||
outline: none;
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
.dd-trigger-icon {
|
||||
color: var(--color-synapse-glow);
|
||||
display: inline-flex;
|
||||
}
|
||||
.dd-value {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dd-placeholder {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.dd-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 6px currentColor;
|
||||
}
|
||||
.dd-chevron {
|
||||
color: var(--color-dim);
|
||||
display: inline-flex;
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.dd-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
color: var(--color-synapse-glow);
|
||||
}
|
||||
|
||||
.dd-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 0;
|
||||
z-index: 60;
|
||||
min-width: 100%;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem;
|
||||
border-radius: 0.85rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
.dd-menu {
|
||||
animation: dd-pop 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@keyframes dd-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.dd-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-dim);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
border-radius: 0.6rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.dd-active {
|
||||
background: rgba(99, 102, 241, 0.14);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.dd-selected {
|
||||
color: var(--color-synapse-glow);
|
||||
}
|
||||
.dd-opt-icon {
|
||||
color: var(--color-dim);
|
||||
display: inline-flex;
|
||||
}
|
||||
.dd-active .dd-opt-icon {
|
||||
color: var(--color-synapse-glow);
|
||||
}
|
||||
.dd-opt-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dd-badge {
|
||||
font-size: 0.65rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.dd-check {
|
||||
color: var(--color-synapse-glow);
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
||||
143
apps/dashboard/src/lib/components/Icon.svelte
Normal file
143
apps/dashboard/src/lib/components/Icon.svelte
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<script lang="ts" module>
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// VESTIGE ICON SYSTEM
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Hand-tuned inline SVG icons — ZERO runtime dependency. Every nav
|
||||
// item gets a UNIQUE, semantically-meaningful silhouette that reads
|
||||
// instantly even at 18px. All strokes use `currentColor` so a single
|
||||
// parent `color` (or our glow utilities) themes the whole set.
|
||||
//
|
||||
// Design language: 24×24 viewBox, 1.6 stroke, round caps/joins, a
|
||||
// cosmic/neural motif. Distinct silhouettes are the contract — no two
|
||||
// icons may be confused at a glance (the old Unicode set reused
|
||||
// ◎ ◈ ◉ ◷ across multiple items; that bug is dead here).
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
export type IconName =
|
||||
| 'graph'
|
||||
| 'reasoning'
|
||||
| 'memories'
|
||||
| 'timeline'
|
||||
| 'feed'
|
||||
| 'explore'
|
||||
| 'activation'
|
||||
| 'dreams'
|
||||
| 'schedule'
|
||||
| 'importance'
|
||||
| 'duplicates'
|
||||
| 'contradictions'
|
||||
| 'patterns'
|
||||
| 'intentions'
|
||||
| 'stats'
|
||||
| 'settings'
|
||||
| 'command'
|
||||
| 'search'
|
||||
| 'filter'
|
||||
| 'sparkle'
|
||||
| 'chevron'
|
||||
| 'close'
|
||||
| 'pulse'
|
||||
| 'logo';
|
||||
|
||||
// Each entry is the inner markup of a 24×24 SVG. Strokes inherit
|
||||
// currentColor; fills are explicit where a solid accent reads better.
|
||||
export const ICON_PATHS: Record<IconName, string> = {
|
||||
// Connected nodes — a literal knowledge graph.
|
||||
graph: `<circle cx="6" cy="7" r="2.1"/><circle cx="18" cy="6" r="2.1"/><circle cx="12" cy="17.5" r="2.3"/><path d="M7.7 8.4 10.6 15.5M16.4 7.6 13.2 15.6M8 7l8-1"/>`,
|
||||
// Branching logic tree with a spark — deduction.
|
||||
reasoning: `<path d="M12 3v4M12 7 7 11M12 7l5 4"/><circle cx="12" cy="3" r="1.6"/><circle cx="7" cy="12" r="1.8"/><circle cx="17" cy="12" r="1.8"/><path d="m7 16 .9 1.9 1.9.6-1.4 1.5.2 2-1.6-1-1.6 1 .2-2L4.2 18.5l1.9-.6z" fill="currentColor" stroke="none" opacity=".55"/>`,
|
||||
// Stacked memory cards / layered recall.
|
||||
memories: `<rect x="5" y="8" width="14" height="11" rx="2.2"/><path d="M7.5 8V6.4A1.4 1.4 0 0 1 8.9 5h8.2A1.9 1.9 0 0 1 19 7v8.5"/><path d="M9 12.5h6M9 15.5h4"/>`,
|
||||
// Time axis with marked moments.
|
||||
timeline: `<path d="M3 12h18"/><circle cx="7" cy="12" r="2" fill="currentColor" stroke="none"/><circle cx="13" cy="12" r="2.2"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/><path d="M7 12V7M13 12v6M19 12V9"/>`,
|
||||
// Streaming feed lines.
|
||||
feed: `<path d="M4 7h16M4 12h16M4 17h10"/><circle cx="18.5" cy="17" r="1.4" fill="currentColor" stroke="none"/>`,
|
||||
// Compass — exploration.
|
||||
explore: `<circle cx="12" cy="12" r="9"/><path d="m15.5 8.5-2 5-5 2 2-5z" fill="currentColor" stroke="none" opacity=".8"/><circle cx="12" cy="12" r="1" fill="var(--color-void,#050510)" stroke="none"/>`,
|
||||
// Radiating activation — spreading energy from a core.
|
||||
activation: `<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/>`,
|
||||
// Crescent moon + stars — dream consolidation.
|
||||
dreams: `<path d="M20 13.5A7.5 7.5 0 1 1 11 4.2a6 6 0 0 0 9 9.3Z"/><path d="m6.5 6 .5 1.4L8.4 8 7 8.6 6.5 10 6 8.6 4.6 8 6 7.4z" fill="currentColor" stroke="none" opacity=".7"/>`,
|
||||
// Calendar — scheduling.
|
||||
schedule: `<rect x="4" y="5.5" width="16" height="14" rx="2"/><path d="M4 9.5h16M8 3.5v4M16 3.5v4"/><circle cx="9" cy="14" r="1" fill="currentColor" stroke="none"/><circle cx="13" cy="14" r="1" fill="currentColor" stroke="none"/><circle cx="9" cy="17" r="1" fill="currentColor" stroke="none"/>`,
|
||||
// Filled award star — importance.
|
||||
importance: `<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 17l-5.2 2.6 1-5.8L3.5 9.7l5.9-.9z" fill="currentColor" stroke="none"/>`,
|
||||
// Two overlapping cards — duplicates.
|
||||
duplicates: `<rect x="4" y="7" width="11" height="11" rx="2"/><path d="M9 7V6a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-1"/>`,
|
||||
// Opposing arrows colliding — contradiction.
|
||||
contradictions: `<path d="M3 9h8l-2.5-2.5M3 9l2.5 2.5M21 15h-8l2.5 2.5M21 15l-2.5-2.5"/><path d="M12 4v16" opacity=".35" stroke-dasharray="2 2.5"/>`,
|
||||
// Tessellated grid — recurring patterns.
|
||||
patterns: `<path d="M4 8.5 8.5 4 13 8.5 8.5 13zM13 8.5 17.5 4 22 8.5 17.5 13zM8.5 17 13 12.5 17.5 17 13 21.5z" opacity=".9"/>`,
|
||||
// Crosshair / target — intentions / aim.
|
||||
intentions: `<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3.4"/><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none"/><path d="M12 1.5v3M12 19.5v3M1.5 12h3M19.5 12h3"/>`,
|
||||
// Bar chart — stats.
|
||||
stats: `<path d="M4 20h16"/><rect x="5.5" y="11" width="3.2" height="6.5" rx="1" fill="currentColor" stroke="none"/><rect x="10.4" y="7" width="3.2" height="10.5" rx="1" fill="currentColor" stroke="none" opacity=".85"/><rect x="15.3" y="13.5" width="3.2" height="4" rx="1" fill="currentColor" stroke="none" opacity=".7"/>`,
|
||||
// Gear — settings.
|
||||
settings: `<circle cx="12" cy="12" r="3.2"/><path d="M12 2.5v2.6M12 18.9v2.6M21.5 12h-2.6M5.1 12H2.5M18.7 5.3l-1.9 1.9M7.2 16.8l-1.9 1.9M18.7 18.7l-1.9-1.9M7.2 7.2 5.3 5.3"/>`,
|
||||
|
||||
// ── UI utility glyphs (not nav, but part of the system) ──
|
||||
command: `<path d="M9 6.5A2.5 2.5 0 1 0 6.5 9H9V6.5ZM9 9v6M9 9h6M15 9V6.5A2.5 2.5 0 1 1 17.5 9H15ZM15 15h2.5A2.5 2.5 0 1 1 15 17.5V15ZM15 15H9M9 15v2.5A2.5 2.5 0 1 1 6.5 15H9Z"/>`,
|
||||
search: `<circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.3-4.3"/>`,
|
||||
filter: `<path d="M4 5.5h16l-6 7v5l-4 2v-7z"/>`,
|
||||
sparkle: `<path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6z" fill="currentColor" stroke="none"/><path d="m18.5 14 .7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z" fill="currentColor" stroke="none" opacity=".6"/>`,
|
||||
chevron: `<path d="m8 10 4 4 4-4"/>`,
|
||||
close: `<path d="m6 6 12 12M18 6 6 18"/>`,
|
||||
pulse: `<path d="M3 12h4l2-6 4 12 2-6h6"/>`,
|
||||
logo: `<circle cx="6" cy="8" r="2"/><circle cx="17" cy="6.5" r="2"/><circle cx="12" cy="17" r="2.4" fill="currentColor" stroke="none"/><circle cx="18" cy="16" r="1.6"/><path d="M7.7 9.2 10.5 15M15.3 7.4 12.7 15M8 8l7-1M14 16.5l2.4-.3"/>`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
class?: string;
|
||||
/** When true, the icon plays a one-shot stroke-draw on mount. */
|
||||
draw?: boolean;
|
||||
}
|
||||
let { name, size = 20, strokeWidth = 1.6, class: cls = '', draw = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="vestige-icon {draw ? 'vestige-icon-draw' : ''} {cls}"
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
>
|
||||
{@html ICON_PATHS[name]}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.vestige-icon {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* One-shot stroke-draw — used on page-title icons so each route entry
|
||||
feels like the icon is being "written" for you. Reduced-motion users
|
||||
get the fully-drawn icon with no animation. */
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
.vestige-icon-draw :global(path),
|
||||
.vestige-icon-draw :global(circle),
|
||||
.vestige-icon-draw :global(rect) {
|
||||
stroke-dasharray: 64;
|
||||
stroke-dashoffset: 64;
|
||||
animation: icon-draw 0.7s cubic-bezier(0.65, 0, 0.35, 1) forwards;
|
||||
}
|
||||
@keyframes icon-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
69
apps/dashboard/src/lib/components/PageHeader.svelte
Normal file
69
apps/dashboard/src/lib/components/PageHeader.svelte
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
import Icon, { type IconName } from './Icon.svelte';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// PAGE HEADER — the shared "alive" page title used on every route.
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// A drawn-on unique route icon in a glowing tile, an aurora-gradient
|
||||
// title, an optional subtitle, and an optional right-aligned slot for
|
||||
// live counts / actions. Replaces the flat `<h1>` each page had, giving
|
||||
// the whole app one premium, consistent, animated masthead.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
interface Props {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Tailwind color token name for the icon tile accent (e.g. 'synapse'). */
|
||||
accent?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
let { icon, title, subtitle, accent = 'synapse', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="flex items-start justify-between gap-4 mb-6 enter">
|
||||
<div class="flex items-center gap-3.5 min-w-0">
|
||||
<div
|
||||
class="header-tile relative flex items-center justify-center w-11 h-11 rounded-xl shrink-0
|
||||
bg-{accent}/12 border border-{accent}/25 text-{accent}-glow"
|
||||
>
|
||||
<Icon name={icon} size={22} draw />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-bold text-aurora leading-tight text-balance">{title}</h1>
|
||||
{#if subtitle}
|
||||
<p class="text-sm text-dim mt-0.5 text-pretty">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if children}
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* Soft outward glow that gently pulses, so the masthead icon reads as
|
||||
"live" the moment the page lands. */
|
||||
.header-tile::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 0 18px -2px currentColor;
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media not (prefers-reduced-motion: reduce) {
|
||||
.header-tile::after {
|
||||
animation: tile-glow 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes tile-glow {
|
||||
0%, 100% { opacity: 0.22; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
}
|
||||
|
||||
.text-balance { text-wrap: balance; }
|
||||
.text-pretty { text-wrap: pretty; }
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue