Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions dashboard/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
@plugin '@tailwindcss/typography';

/*
Stenion design tokens — dark-mode ONLY (no toggle, no light theme yet).
Deep navy surfaces + off-white ink, with a deliberately non-default accent
pair: cool aqua (`accent`) against a soft periwinkle (`accent-2`) for gradient
wordmarks/headings. The score scale (safe/warn/danger) is a separate semantic
ramp — it encodes data, not brand, so it must never be reused as decoration.
Stenion design tokens — dark mode by default; a light-mode palette is layered
on via data-theme="light" on <html> (see the :root[data-theme='light'] block
below). The light palette is the existing design expressed in a second
palette, not a redesign: same hierarchy, same accent pair, same safe/warn/
danger score ramp, just inverted surfaces and re-tuned contrast. All colors
flow through CSS custom properties, so components need zero changes to work
in both modes.
*/
@theme {
--color-bg: #06090f;
Expand All @@ -33,6 +35,35 @@
--radius-xl: 1rem;
}

/*
Light-mode token overrides. WCAG AA (4.5:1 for normal text) was verified for
every foreground/background pair in use:
- ink #0b1220 on bg #f7f8fa — 17.6:1
- muted #3f4a5c on bg/surface — 8.4:1
- faint #5f6a7c on bg — 5.2:1
- accent #0c7a72 on bg — 4.9:1 (links/headings)
- accent-2 #4f46e5 on bg — 5.9:1
- safe/warn/danger on bg — 5.0:1 / 4.7:1 / 5.9:1
*/
:root[data-theme='light'] {
--color-bg: #f7f8fa;
--color-surface: #ffffff;
--color-surface-2: #eef1f5;
--color-line: #d9dfe8;
--color-line-soft: #e6eaf0;

--color-ink: #0b1220;
--color-muted: #3f4a5c;
--color-faint: #5f6a7c;

--color-accent: #0c7a72; /* darkened aqua for AA on light bg */
--color-accent-2: #4f46e5; /* periwinkle darkened for AA on light bg */

--color-safe: #0e7a55;
--color-warn: #b45309;
--color-danger: #be123c;
}

html {
scroll-behavior: smooth;
}
Expand All @@ -58,6 +89,12 @@ body {
var(--color-surface);
}

:root[data-theme='light'] .surface-lit {
background:
linear-gradient(180deg, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0) 40%),
var(--color-surface);
}

/* Faint dotted grid for hero backdrops. */
.grid-backdrop {
background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px);
Expand All @@ -66,6 +103,10 @@ body {
mask-image: radial-gradient(ellipse 70% 60% at 50% 0%, #000 40%, transparent 100%);
}

:root[data-theme='light'] .grid-backdrop {
background-image: radial-gradient(rgba(0, 0, 0, 0.08) 1px, transparent 1px);
}

/* Shimmer for skeleton loaders. */
@keyframes shimmer {
100% {
Expand All @@ -81,6 +122,10 @@ body {
animation: shimmer 1.6s infinite;
}

:root[data-theme='light'] .shimmer::after {
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.05), transparent);
}

/* Prose (methodology page) tuned to the dark palette instead of prose-invert's
default grays — headings in ink, links in accent, code on a real surface. */
.prose-stenion {
Expand Down Expand Up @@ -115,4 +160,4 @@ body {
}
.prose-stenion :where(table) {
font-size: 0.9em;
}
}
34 changes: 32 additions & 2 deletions dashboard/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,39 @@ export const metadata: Metadata = {
'Continuous, on-chain-derived safety scores for Stellar/Soroban DeFi protocols. Audits are a snapshot; Stenion tracks risk as it moves.',
};

/**
* Runs before first paint to set the resolved theme on <html data-theme=...>,
* so there is no flash of the wrong theme on load. Explicit user choice wins;
* otherwise the system preference decides; otherwise dark (the default).
*/
const themeScript = `
(function () {
try {
var choice = localStorage.getItem('stenion-theme');
if (choice === 'light' || choice === 'dark') {
document.documentElement.setAttribute('data-theme', choice);
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.setAttribute('data-theme', 'dark');
}
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
`;

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable} ${display.variable}`}>
<html
lang="en"
data-theme="dark"
className={`${GeistSans.variable} ${GeistMono.variable} ${display.variable}`}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-screen bg-bg text-ink antialiased">
<Nav />
<main>{children}</main>
Expand All @@ -41,4 +71,4 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</body>
</html>
);
}
}
10 changes: 8 additions & 2 deletions dashboard/components/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { Github, Menu, Radar, X } from 'lucide-react';
import { cn } from '../app/lib/cn';
import { GITHUB_URL, NAV_LINKS } from '../app/lib/site';
import { ThemeToggle } from './theme-toggle';

// Same expo-out ease the rest of the site uses (see reveal.tsx) — subtle, not bouncy.
const EASE = [0.16, 1, 0.3, 1] as const;
Expand Down Expand Up @@ -81,12 +82,13 @@ export function Nav() {
)}
</Link>
))}
<ThemeToggle />
<a
href={GITHUB_URL}
target="_blank"
rel="noreferrer"
aria-label="Stenion on GitHub"
className="ml-1 grid h-9 w-9 place-items-center rounded-md text-muted transition-colors hover:bg-surface-2 hover:text-ink"
className="grid h-9 w-9 place-items-center rounded-md text-muted transition-colors hover:bg-surface-2 hover:text-ink"
>
<Github className="h-4 w-4" />
</a>
Expand Down Expand Up @@ -145,6 +147,10 @@ export function Nav() {
{link.label}
</Link>
))}
<div className="flex items-center justify-between rounded-md px-3 py-3 text-muted">
<span className="text-xs">Appearance</span>
<ThemeToggle />
</div>
<a
href={GITHUB_URL}
target="_blank"
Expand All @@ -162,4 +168,4 @@ export function Nav() {
</AnimatePresence>
</header>
);
}
}
70 changes: 70 additions & 0 deletions dashboard/components/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';

import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';

const STORAGE_KEY = 'stenion-theme';

/**
* Reads the current theme from the <html> data-theme attribute (set by the
* no-flash inline script in layout.tsx). Falls back to 'dark' when no attribute
* is present (the default dark-mode state).
*/
function readTheme(): 'light' | 'dark' {
if (typeof document === 'undefined') return 'dark';
const stored = document.documentElement.getAttribute('data-theme');
return stored === 'light' ? 'light' : 'dark';
}

/**
* Theme toggle that respects system preference on first visit and persists
* explicit user choices in localStorage.
*
* 1. On first visit with no stored preference → follows prefers-color-scheme.
* 2. On subsequent visits with a stored preference → uses that.
* 3. User clicks toggle → switches mode, saves choice to localStorage.
* 4. System preference changes while no explicit choice is saved → follows it.
*/
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>(readTheme);

// Follow system preference changes when the user hasn't made an explicit choice.
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: light)');
const onChange = (e: MediaQueryListEvent) => {
const choice = localStorage.getItem(STORAGE_KEY);
if (choice === null) {
setTheme(e.matches ? 'light' : 'dark');
}
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);

// Sync the data-theme attribute to the DOM on every state change.
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);

const toggle = () => {
const next = theme === 'light' ? 'dark' : 'light';
setTheme(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
/* localStorage may be unavailable in private browsing / sandboxed iframes */
}
};

return (
<button
type="button"
onClick={toggle}
aria-label={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
className="grid h-9 w-9 place-items-center rounded-md text-muted transition-colors hover:bg-surface-2 hover:text-ink"
>
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</button>
);
}
Loading