Add a dark theme, default, with a switch in the top bar
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s

Colours move into theme tokens (light values in :root, dark in
[data-theme=dark]); the closing contact band inverts on each theme. The
choice is stored in localStorage and applied by a head script before the
first paint. Voxel covers and image shadows follow the theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
This commit is contained in:
Emil
2026-09-20 22:51:13 +03:00
co-authored by Claude Sonnet 5
parent 0c95e0c5d0
commit 15d36d6437
11 changed files with 206 additions and 38 deletions
+4
View File
@@ -54,6 +54,10 @@ npm run build # static export in out/
`./deploy-shanaty.sh` builds and publishes the site to shanaty.ru (needs the ssh key `~/.ssh/MyGitea`). Only needed after changing code or styles.
## Themes
The page is **dark by default** (a night version of the viewport: graphite backdrop, brighter axis colours, and a light closing contact band). A button in the top bar switches to the light theme; the choice is remembered in the browser and applied before the first paint, so there is no flash. Colours live in the tokens at the top of `app/globals.css` (`:root` for light, `:root[data-theme='dark']` for dark); components only use those tokens, so a new theme is a new block of values.
## Design
The page is laid out like a 3D-editor viewport, a nod to the game-engine work it presents: a studio backdrop with a perspective floor whose red X and blue Z axes match the axis colours of Blender and Unity, and an orientation gizmo in the corner that orbits as the page scrolls (press it to go back to the top). Projects without a demo image get a deterministic isometric voxel sculpture generated from the repo name.
+82 -3
View File
@@ -1,16 +1,59 @@
/* Design tokens: a studio-lit 3D viewport. Axis colours (X red, Y green, Z blue) are marks and lines, not text. */
:root {
--studio: #e6e9ed;
--studio-deep: #cdd3db;
color-scheme: light;
/* Palette. The page is a 3D viewport: a studio backdrop and the three axis colours (X red, Y green, Z blue),
which are marks and lines rather than text. Dark is the default theme; see :root[data-theme='dark']. */
--paper: #f4f6f8;
--ink: #15181d;
--muted: #5b6571;
--line: rgba(21, 24, 29, 0.14);
--x: #e0384b;
--y: #5aa300;
--z: #2c63e0;
--z-text: #2455c9;
/* Surfaces */
--hero-top: #f5f7fa;
--hero-bottom: #e6e9ee;
--stage-top: #d8dde4;
--stage-bottom: #cdd3db;
--plate-a: #f3f5f8;
--plate-b: #e6e9ed;
--plate-c: #d3d9e0;
--pill-bg: rgba(255, 255, 255, 0.72);
--disc-bg: rgba(244, 246, 248, 0.62);
--topbar-bg: rgba(244, 246, 248, 0.86);
--grid: rgba(21, 24, 29, 0.2);
--img-shadow: rgba(21, 24, 29, 0.28);
--dot-glow: rgba(90, 163, 0, 0.2);
/* Voxel covers of projects without a demo image */
--cube-top: #f9fafb;
--cube-left: #d6dbe2;
--cube-right: #b8c0cb;
--cube-edge: rgba(21, 24, 29, 0.16);
--cover-grid: rgba(21, 24, 29, 0.09);
/* The closing contact band is the inverse of the page */
--band-bg: #15181d;
--band-ink: #f4f6f8;
/* Light type on a dark ground looks heavier, so the display weight is eased off there */
--name-weight: 800;
/* Derived from the palette above, so they follow the theme */
--line: color-mix(in srgb, var(--ink) 14%, transparent);
--ring: color-mix(in srgb, var(--ink) 9%, transparent);
--horizon: color-mix(in srgb, var(--ink) 22%, transparent);
--chip: color-mix(in srgb, var(--ink) 7%, transparent);
--ink-2: color-mix(in srgb, var(--ink) 86%, transparent);
--ink-3: color-mix(in srgb, var(--ink) 76%, transparent);
--ink-line: color-mix(in srgb, var(--ink) 32%, transparent);
--band-2: color-mix(in srgb, var(--band-ink) 70%, transparent);
--band-3: color-mix(in srgb, var(--band-ink) 58%, transparent);
--band-line: color-mix(in srgb, var(--band-ink) 22%, transparent);
--band-underline: color-mix(in srgb, var(--band-ink) 38%, transparent);
--radius-plate: 18px;
--radius-control: 10px;
--shell: min(1240px, calc(100% - 48px));
@@ -21,6 +64,42 @@
--font-head: var(--font-display), 'Segoe UI', system-ui, sans-serif;
}
:root[data-theme='dark'] {
color-scheme: dark;
--paper: #1c2026;
--ink: #e9edf2;
--muted: #9aa5b1;
--x: #ff5a6e;
--y: #86d62c;
--z: #5b8dff;
--z-text: #8db0ff;
--hero-top: #282d35;
--hero-bottom: #1c2026;
--stage-top: #14171b;
--stage-bottom: #0f1114;
--plate-a: #363c46;
--plate-b: #2a2f37;
--plate-c: #1f2329;
--pill-bg: rgba(255, 255, 255, 0.07);
--disc-bg: rgba(40, 45, 53, 0.72);
--topbar-bg: rgba(28, 32, 38, 0.86);
--grid: rgba(255, 255, 255, 0.15);
--img-shadow: rgba(0, 0, 0, 0.55);
--dot-glow: rgba(134, 214, 44, 0.24);
--cube-top: #e9edf2;
--cube-left: #9aa5b3;
--cube-right: #69737f;
--cube-edge: rgba(0, 0, 0, 0.4);
--cover-grid: rgba(255, 255, 255, 0.09);
--band-bg: #e8ebef;
--band-ink: #15181d;
--name-weight: 730;
}
* { box-sizing: border-box; }
html { background: var(--paper); scroll-behavior: smooth; scroll-padding-top: 88px; }
body {
+7 -1
View File
@@ -12,9 +12,15 @@ export const metadata: Metadata = {
authors: [{ name: 'Emil Shanayev' }],
}
// Dark is the default theme; a stored choice of "light" is applied before the first paint so the page never flashes.
const THEME_SCRIPT = "try{if(localStorage.getItem('theme')==='light')document.documentElement.dataset.theme='light'}catch(e){}"
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${display.variable} ${text.variable}`}>
<html lang="en" data-theme="dark" className={`${display.variable} ${text.variable}`} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
<body>{children}</body>
</html>
)
+6 -6
View File
@@ -26,7 +26,7 @@ const GRID = 5
const TILE = 54
const RISE = 30
const ACCENTS = ['#e0384b', '#5aa300', '#2c63e0']
const NEUTRAL = { top: '#f9fafb', left: '#d6dbe2', right: '#b8c0cb' }
const NEUTRAL = { top: 'var(--cube-top)', left: 'var(--cube-left)', right: 'var(--cube-right)' }
function mix(hex: string, other: number, amount: number): string {
const channel = (i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
@@ -82,7 +82,7 @@ export default function Cover({ name }: Readonly<{ name: string }>) {
key={n}
points={face([point(i, j, 0, 0, 0), point(i, j, 0, TILE / 2, TILE / 4), point(i, j, 0, 0, TILE / 2), point(i, j, 0, -TILE / 2, TILE / 4)])}
fill="none"
stroke="rgba(21,24,29,0.09)"
style={{ stroke: 'var(--cover-grid)' }}
/>
)
})}
@@ -90,10 +90,10 @@ export default function Cover({ name }: Readonly<{ name: string }>) {
const z = (k + 1) * RISE
const base = k * RISE
return (
<g key={`${i}-${j}-${k}`} strokeLinejoin="round" stroke="rgba(21,24,29,0.16)" strokeWidth="0.75">
<polygon points={face([point(i, j, z, -TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, base, 0, TILE / 2), point(i, j, base, -TILE / 2, TILE / 4)])} fill={left} />
<polygon points={face([point(i, j, z, 0, TILE / 2), point(i, j, z, TILE / 2, TILE / 4), point(i, j, base, TILE / 2, TILE / 4), point(i, j, base, 0, TILE / 2)])} fill={right} />
<polygon points={face([point(i, j, z, 0, 0), point(i, j, z, TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, z, -TILE / 2, TILE / 4)])} fill={top} />
<g key={`${i}-${j}-${k}`} strokeLinejoin="round" strokeWidth="0.75" style={{ stroke: 'var(--cube-edge)' }}>
<polygon points={face([point(i, j, z, -TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, base, 0, TILE / 2), point(i, j, base, -TILE / 2, TILE / 4)])} style={{ fill: left }} />
<polygon points={face([point(i, j, z, 0, TILE / 2), point(i, j, z, TILE / 2, TILE / 4), point(i, j, base, TILE / 2, TILE / 4), point(i, j, base, 0, TILE / 2)])} style={{ fill: right }} />
<polygon points={face([point(i, j, z, 0, 0), point(i, j, z, TILE / 2, TILE / 4), point(i, j, z, 0, TILE / 2), point(i, j, z, -TILE / 2, TILE / 4)])} style={{ fill: top }} />
</g>
)
})}
+47
View File
@@ -0,0 +1,47 @@
'use client'
import { useEffect, useState } from 'react'
import styles from './topbar.module.css'
type Theme = 'dark' | 'light'
/** Switches between the dark (default) and light theme; the choice is remembered in this browser. */
export default function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('dark')
// The page starts dark; a small script in <head> applies a stored "light" choice before the first paint.
useEffect(() => {
setTheme(document.documentElement.dataset.theme === 'light' ? 'light' : 'dark')
}, [])
const toggle = () => {
const next: Theme = theme === 'dark' ? 'light' : 'dark'
document.documentElement.dataset.theme = next
try {
localStorage.setItem('theme', next)
} catch {
// Storage can be blocked; the theme still switches for this visit.
}
setTheme(next)
}
return (
<button
type="button"
className={styles.toggle}
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to the light theme' : 'Switch to the dark theme'}
>
{theme === 'dark' ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2.2M12 19.3v2.2M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M2.5 12h2.2M19.3 12h2.2M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6" />
</svg>
) : (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7z" />
</svg>
)}
</button>
)
}
+2
View File
@@ -1,6 +1,7 @@
'use client'
import { useScrolled } from '@/lib/useScroll'
import ThemeToggle from './ThemeToggle'
import styles from './topbar.module.css'
type Props = { name: string; mailHref: string; showProjects: boolean }
@@ -17,6 +18,7 @@ export default function TopBar({ name, mailHref, showProjects }: Readonly<Props>
<a href="#experience">Experience</a>
<a href="#education">Education</a>
</nav>
<ThemeToggle />
<a className={styles.mail} href={mailHref}>Email me</a>
</div>
</header>
+2 -2
View File
@@ -8,8 +8,8 @@
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(244, 246, 248, 0.62);
box-shadow: 0 0 0 1px rgba(21, 24, 29, 0.08);
background: var(--disc-bg);
box-shadow: 0 0 0 1px var(--ring);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
cursor: pointer;
+9 -9
View File
@@ -6,7 +6,7 @@
flex-direction: column;
min-height: min(100svh, 1000px);
overflow: clip;
background: linear-gradient(180deg, #f5f7fa 0%, #e6e9ee 100%);
background: linear-gradient(180deg, var(--hero-top) 0%, var(--hero-bottom) 100%);
}
/* The floor is a stage of its own below the text, so the grid never runs through the copy at any window height. */
@@ -15,8 +15,8 @@
flex: 1;
min-height: 260px;
overflow: hidden;
background: linear-gradient(180deg, #d8dde4 0%, var(--studio-deep) 100%);
border-top: 1px solid rgba(21, 24, 29, 0.2);
background: linear-gradient(180deg, var(--stage-top) 0%, var(--stage-bottom) 100%);
border-top: 1px solid var(--horizon);
}
.stage::after {
@@ -44,8 +44,8 @@
transform-origin: 50% 0;
transform: rotateX(76deg) rotateZ(var(--sway));
background-image:
linear-gradient(to right, rgba(21, 24, 29, 0.2) 1px, transparent 1px),
linear-gradient(to bottom, rgba(21, 24, 29, 0.2) 1px, transparent 1px);
linear-gradient(to right, var(--grid) 1px, transparent 1px),
linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
background-size: 80px 80px;
background-position: center top;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, #000 100%);
@@ -89,7 +89,7 @@
max-width: 100%;
padding: 8px 16px 8px 13px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
background: var(--pill-bg);
box-shadow: inset 0 0 0 1px var(--line);
font-size: 15px;
font-weight: 500;
@@ -102,12 +102,12 @@
height: 9px;
border-radius: 50%;
background: var(--y);
box-shadow: 0 0 0 4px rgba(90, 163, 0, 0.2);
box-shadow: 0 0 0 4px var(--dot-glow);
}
.name {
margin: 30px 0 0 -0.045em;
font: 800 clamp(56px, 12.6vw, 204px) / 0.88 var(--font-head);
font: var(--name-weight) clamp(56px, 12.6vw, 204px) / 0.88 var(--font-head);
letter-spacing: -0.048em;
}
@@ -122,7 +122,7 @@
margin-top: 44px;
font-size: 19px;
line-height: 1.62;
color: rgba(21, 24, 29, 0.88);
color: var(--ink-2);
}
.actions {
+7 -7
View File
@@ -57,8 +57,8 @@
aspect-ratio: 3 / 2;
overflow: hidden;
border-radius: var(--radius-plate);
background: radial-gradient(120% 90% at 50% 0%, #f3f5f8 0%, var(--studio) 55%, #d3d9e0 100%);
box-shadow: inset 0 0 0 1px rgba(21, 24, 29, 0.09);
background: radial-gradient(120% 90% at 50% 0%, var(--plate-a) 0%, var(--plate-b) 55%, var(--plate-c) 100%);
box-shadow: inset 0 0 0 1px var(--ring);
}
.plate img,
@@ -76,7 +76,7 @@
position: relative;
object-fit: contain;
padding: 6%;
filter: drop-shadow(0 10px 24px rgba(21, 24, 29, 0.28));
filter: drop-shadow(0 10px 24px var(--img-shadow));
}
.backdrop {
@@ -108,14 +108,14 @@
.description {
max-width: 62ch;
color: rgba(21, 24, 29, 0.86);
color: var(--ink-2);
}
.points {
display: grid;
gap: 4px;
font-size: 15px;
color: rgba(21, 24, 29, 0.78);
color: var(--ink-3);
}
.points li {
@@ -168,7 +168,7 @@
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 4px;
text-decoration-color: rgba(21, 24, 29, 0.3);
text-decoration-color: var(--ink-line);
transition: text-decoration-color 0.2s, color 0.2s;
}
@@ -187,7 +187,7 @@
.skeleton span {
aspect-ratio: 3 / 2;
border-radius: var(--radius-plate);
background: linear-gradient(160deg, #eef1f4, var(--studio));
background: linear-gradient(160deg, var(--plate-a), var(--plate-c));
}
@media (min-width: 641px) and (max-width: 1000px) {
+9 -9
View File
@@ -36,7 +36,7 @@
.skill p {
max-width: 52ch;
color: rgba(21, 24, 29, 0.82);
color: var(--ink-2);
}
.tags {
@@ -49,7 +49,7 @@
.tags li {
padding: 3px 11px;
border-radius: 999px;
background: rgba(21, 24, 29, 0.06);
background: var(--chip);
font-size: 13.5px;
line-height: 1.5;
}
@@ -136,8 +136,8 @@
.contact {
margin-top: var(--section);
padding: clamp(72px, 9vw, 128px) 0 40px;
background: var(--ink);
color: var(--paper);
background: var(--band-bg);
color: var(--band-ink);
}
.contactTitle {
@@ -147,7 +147,7 @@
.contactLead {
margin-top: 16px;
color: rgba(244, 246, 248, 0.7);
color: var(--band-2);
}
.mail {
@@ -177,19 +177,19 @@
flex-direction: column;
gap: 2px;
padding: 18px 0;
border-top: 1px solid rgba(244, 246, 248, 0.2);
border-top: 1px solid var(--band-line);
}
.contacts span {
font-size: 14px;
color: rgba(244, 246, 248, 0.6);
color: var(--band-3);
}
.contacts a {
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 4px;
text-decoration-color: rgba(244, 246, 248, 0.35);
text-decoration-color: var(--band-underline);
overflow-wrap: anywhere;
}
@@ -200,7 +200,7 @@
.legal {
margin-top: clamp(48px, 6vw, 80px);
font-size: 14px;
color: rgba(244, 246, 248, 0.55);
color: var(--band-3);
}
@media (max-width: 900px) {
+31 -1
View File
@@ -7,7 +7,7 @@
}
.solid {
background: rgba(244, 246, 248, 0.86);
background: var(--topbar-bg);
border-bottom-color: var(--line);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
@@ -48,6 +48,7 @@
}
.mail {
margin-left: -12px;
padding: 10px 18px;
border-radius: var(--radius-control);
background: var(--ink);
@@ -67,3 +68,32 @@
display: none;
}
}
.toggle {
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--ink);
box-shadow: inset 0 0 0 1px var(--line);
cursor: pointer;
transition: box-shadow 0.2s;
}
.toggle:hover {
box-shadow: inset 0 0 0 1.5px var(--ink);
}
.toggle svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}