General Update
|
After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 874 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 641 KiB |
@@ -1,18 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* AnimatedBackground.tsx
|
* AnimatedBackground.tsx
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* The hero background island. Three layers:
|
* The hero background island. Layers:
|
||||||
*
|
*
|
||||||
* 1. A CSS-driven mesh-gradient layer (always on, GPU-friendly, animated via
|
* 1. A CSS-driven night-sky gradient (deep blue, darker at top → lighter near
|
||||||
* the `animate-gradient-shift` keyframes in global.css). Tuned to the
|
* horizon) plus a diagonal Milky Way galaxy band and teal/violet nebula
|
||||||
* night-sky palette: midnight indigo with violet + gold nebular edges.
|
* clouds. GPU-friendly, animated via `animate-gradient-shift` keyframes.
|
||||||
* 2. A faint static SVG ornament layer (astrolabe + sacred geometry) baked
|
* 2. A faint static SVG ornament layer (astrolabe + sacred geometry) for
|
||||||
* in as absolutely-positioned decorations for esoteric texture.
|
* esoteric texture.
|
||||||
* 3. An optional lightweight <canvas> star field: dozens of twinkling stars
|
* 3. An optional <canvas> star field: a dense field of slowly-twinkling stars
|
||||||
* with thin constellation lines drawn between near neighbors, plus the
|
* with thin constellation lines between near neighbors, plus the occasional
|
||||||
* occasional slow shooting-star streak. We render at devicePixelRatio and
|
* shooting star. The WHOLE field rotates together very slowly around the
|
||||||
* cap the count for perf. It pauses when off-screen and is fully skipped
|
* field center — reading as a slice of sky turning as the planet rotates.
|
||||||
* when prefers-reduced-motion is set.
|
* We render at devicePixelRatio and cap the count for perf. It pauses when
|
||||||
|
* off-screen and is fully skipped when prefers-reduced-motion is set.
|
||||||
*
|
*
|
||||||
* The component is presentational only; it reads CSS variables for colors so it
|
* The component is presentational only; it reads CSS variables for colors so it
|
||||||
* adapts to the section surface tone automatically.
|
* adapts to the section surface tone automatically.
|
||||||
@@ -24,9 +25,21 @@ interface Props {
|
|||||||
particles?: boolean;
|
particles?: boolean;
|
||||||
/** Tailwind/className passthrough for the root. */
|
/** Tailwind/className passthrough for the root. */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Opt-in View Transition name. Unset by default. The View Transitions API
|
||||||
|
* requires this name to be UNIQUE within a page snapshot, so the page must
|
||||||
|
* opt in on AT MOST ONE field (typically the hero). Applied via the `.vt-sky`
|
||||||
|
* CSS class rather than React's inline style to avoid an SSR/hydration
|
||||||
|
* attribute mismatch.
|
||||||
|
*/
|
||||||
|
transitionName?: 'sky';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnimatedBackground({ particles = true, className = '' }: Props) {
|
export default function AnimatedBackground({
|
||||||
|
particles = true,
|
||||||
|
className = '',
|
||||||
|
transitionName,
|
||||||
|
}: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,15 +58,15 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
|
||||||
interface Star {
|
interface Star {
|
||||||
x: number;
|
// Position in sky coordinates (pre-rotation). The whole field rotates.
|
||||||
y: number;
|
rx: number; // offset from field center (cx, cy)
|
||||||
|
ry: number;
|
||||||
r: number; // base radius
|
r: number; // base radius
|
||||||
vx: number; // slow drift
|
|
||||||
vy: number;
|
|
||||||
tw: number; // twinkle phase
|
tw: number; // twinkle phase
|
||||||
tws: number; // twinkle speed
|
tws: number; // twinkle speed (slow)
|
||||||
baseA: number; // base alpha
|
baseA: number; // base alpha
|
||||||
anchor: boolean; // brighter "anchor" star used for constellations
|
anchor: boolean; // brighter "anchor" star used for constellations
|
||||||
|
hue: 'white' | 'gold' | 'violet' | 'teal';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Shooter {
|
interface Shooter {
|
||||||
@@ -61,15 +74,15 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
y: number;
|
y: number;
|
||||||
len: number;
|
len: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
angle: number; // direction of travel (radians)
|
angle: number;
|
||||||
life: number; // 0..1 remaining
|
life: number;
|
||||||
maxLife: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let stars: Star[] = [];
|
let stars: Star[] = [];
|
||||||
let shooters: Shooter[] = [];
|
let shooters: Shooter[] = [];
|
||||||
let nextShooterAt = 0;
|
let nextShooterAt = 0;
|
||||||
let t0 = performance.now();
|
let t0 = performance.now();
|
||||||
|
let angle = 0; // current field rotation (radians)
|
||||||
|
|
||||||
// Resolve star colors from CSS variables so the field matches the theme.
|
// Resolve star colors from CSS variables so the field matches the theme.
|
||||||
const gold =
|
const gold =
|
||||||
@@ -85,10 +98,25 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
getComputedStyle(document.documentElement).getPropertyValue('--color-cream').trim() ||
|
getComputedStyle(document.documentElement).getPropertyValue('--color-cream').trim() ||
|
||||||
'#f4f1e8';
|
'#f4f1e8';
|
||||||
|
|
||||||
|
const hueColor = (h: Star['hue']) => (h === 'gold' ? gold : h === 'violet' ? violet : h === 'teal' ? teal : white);
|
||||||
|
|
||||||
// Max distance for two stars to be connected by a constellation line.
|
// Max distance for two stars to be connected by a constellation line.
|
||||||
const LINK_DIST = 132;
|
const LINK_DIST = 168;
|
||||||
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
|
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
|
||||||
|
|
||||||
|
// Rotation speed: a full turn takes ~6 minutes, so the drift is felt but
|
||||||
|
// never distracting — "as the planet rotates".
|
||||||
|
const ROT_SPEED = (Math.PI * 2) / (6 * 60); // radians per second
|
||||||
|
|
||||||
|
function pickHue(): Star['hue'] {
|
||||||
|
const n = Math.random();
|
||||||
|
// Mostly white stars, with occasional colored ones for richness.
|
||||||
|
if (n < 0.7) return 'white';
|
||||||
|
if (n < 0.85) return 'gold';
|
||||||
|
if (n < 0.94) return 'violet';
|
||||||
|
return 'teal';
|
||||||
|
}
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
if (!canvas || !ctx) return;
|
if (!canvas || !ctx) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
@@ -98,97 +126,117 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
canvas.height = Math.floor(height * dpr);
|
canvas.height = Math.floor(height * dpr);
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
// Cap density: one star per ~9k px^2, max 140. Anchor stars are rarer.
|
// Dense field tuned for ~40% constellation coverage on any slice. Stars
|
||||||
const count = Math.min(140, Math.floor((width * height) / 9000));
|
// live in a sky disk larger than the viewport so rotation never reveals
|
||||||
|
// an empty edge. Radius = distance to the far viewport corner.
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const maxR = Math.sqrt(cx * cx + cy * cy) + 40;
|
||||||
|
|
||||||
|
// One star per ~4000 px^2 of the disk, capped for perf (~12% anchors).
|
||||||
|
const diskArea = Math.PI * maxR * maxR;
|
||||||
|
const count = Math.min(420, Math.floor(diskArea / 4000));
|
||||||
stars = Array.from({ length: count }, (_, i) => {
|
stars = Array.from({ length: count }, (_, i) => {
|
||||||
const anchor = i % 11 === 0; // ~9% anchors -> the brighter backbone stars
|
const anchor = i % 8 === 0; // ~12% anchors -> denser constellation backbone
|
||||||
const r = anchor ? 1.4 + Math.random() * 1.3 : 0.4 + Math.random() * 1.1;
|
// Uniform-ish distribution across the disk via sqrt on radius.
|
||||||
|
const rr = maxR * Math.sqrt(Math.random());
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const r = anchor ? 1.4 + Math.random() * 1.4 : 0.35 + Math.random() * 1.1;
|
||||||
return {
|
return {
|
||||||
x: Math.random() * width,
|
rx: cx + rr * Math.cos(a),
|
||||||
y: Math.random() * height,
|
ry: cy + rr * Math.sin(a),
|
||||||
r,
|
r,
|
||||||
vx: (Math.random() - 0.5) * 0.06,
|
|
||||||
vy: (Math.random() - 0.5) * 0.06,
|
|
||||||
tw: Math.random() * Math.PI * 2,
|
tw: Math.random() * Math.PI * 2,
|
||||||
tws: 0.4 + Math.random() * 1.1, // twinkle cycles/sec-ish
|
// Slow twinkle: 0.15–0.5 cycles/sec.
|
||||||
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.18 + Math.random() * 0.4,
|
tws: 0.15 + Math.random() * 0.35,
|
||||||
|
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.16 + Math.random() * 0.4,
|
||||||
anchor,
|
anchor,
|
||||||
|
hue: anchor ? (Math.random() < 0.5 ? 'gold' : 'white') : pickHue(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnShooter(now: number) {
|
function spawnShooter(now: number) {
|
||||||
// Diagonal streak starting somewhere in the top half.
|
|
||||||
const startX = Math.random() * width * 0.8;
|
const startX = Math.random() * width * 0.8;
|
||||||
const startY = Math.random() * height * 0.5;
|
const startY = Math.random() * height * 0.5;
|
||||||
const angle = Math.PI * (0.18 + Math.random() * 0.12); // ~32-54deg down-right
|
const a = Math.PI * (0.18 + Math.random() * 0.12);
|
||||||
const speed = 5.5 + Math.random() * 3.5;
|
|
||||||
shooters.push({
|
shooters.push({
|
||||||
x: startX,
|
x: startX,
|
||||||
y: startY,
|
y: startY,
|
||||||
len: 90 + Math.random() * 120,
|
len: 90 + Math.random() * 120,
|
||||||
speed,
|
speed: 5.5 + Math.random() * 3.5,
|
||||||
angle,
|
angle: a,
|
||||||
life: 1,
|
life: 1,
|
||||||
maxLife: 1,
|
|
||||||
});
|
});
|
||||||
// Next shooter in 5-13s.
|
|
||||||
nextShooterAt = now + 5000 + Math.random() * 8000;
|
nextShooterAt = now + 5000 + Math.random() * 8000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function frame(now: number) {
|
function frame(now: number) {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const dt = Math.min(0.05, (now - t0) / 1000); // seconds, clamped
|
const dt = Math.min(0.05, (now - t0) / 1000);
|
||||||
t0 = now;
|
t0 = now;
|
||||||
|
angle += ROT_SPEED * dt; // whole sky rotates together
|
||||||
ctx.clearRect(0, 0, width, height);
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
// 1) Constellation lines (drawn first, behind stars). Connect near anchors.
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const cosA = Math.cos(angle);
|
||||||
|
const sinA = Math.sin(angle);
|
||||||
|
|
||||||
|
// Rotate each sky point about the field center into screen space.
|
||||||
|
const project = (rx: number, ry: number): [number, number] => [
|
||||||
|
cx + (rx - cx) * cosA - (ry - cy) * sinA,
|
||||||
|
cy + (rx - cx) * sinA + (ry - cy) * cosA,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Project every star's sky position into screen space once.
|
||||||
|
// Each entry carries the source star + its rotated (x, y).
|
||||||
|
const proj = stars.map((s) => {
|
||||||
|
const [x, y] = project(s.rx, s.ry);
|
||||||
|
return { s, x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1) Constellation lines (behind stars) — connect near anchor pairs.
|
||||||
ctx.lineWidth = 0.6;
|
ctx.lineWidth = 0.6;
|
||||||
for (let i = 0; i < stars.length; i++) {
|
for (let i = 0; i < proj.length; i++) {
|
||||||
const a = stars[i];
|
const A = proj[i];
|
||||||
if (!a.anchor) continue; // only anchors backbone the constellations
|
if (!A.s.anchor) continue;
|
||||||
for (let j = i + 1; j < stars.length; j++) {
|
for (let j = i + 1; j < proj.length; j++) {
|
||||||
const b = stars[j];
|
const B = proj[j];
|
||||||
if (!b.anchor) continue;
|
if (!B.s.anchor) continue;
|
||||||
const dx = a.x - b.x;
|
const ddx = A.x - B.x;
|
||||||
const dy = a.y - b.y;
|
const ddy = A.y - B.y;
|
||||||
const d2 = dx * dx + dy * dy;
|
const d2 = ddx * ddx + ddy * ddy;
|
||||||
if (d2 > LINK_DIST_SQ) continue;
|
if (d2 > LINK_DIST_SQ) continue;
|
||||||
// Alpha falls off with distance; gentle pulse tied to time.
|
|
||||||
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
|
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
|
||||||
const pulse = 0.5 + 0.5 * Math.sin(now / 1400 + (i + j) * 0.7);
|
const pulse = 0.5 + 0.5 * Math.sin(now / 1400 + (i + j) * 0.7);
|
||||||
const alpha = proximity * 0.22 * (0.55 + 0.45 * pulse);
|
const alpha = proximity * 0.22 * (0.55 + 0.45 * pulse);
|
||||||
ctx.strokeStyle = `rgba(232, 181, 74, ${alpha.toFixed(3)})`; // gold, rgb fixed for perf
|
ctx.strokeStyle = `rgba(232, 181, 74, ${alpha.toFixed(3)})`;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(a.x, a.y);
|
ctx.moveTo(A.x, A.y);
|
||||||
ctx.lineTo(b.x, b.y);
|
ctx.lineTo(B.x, B.y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Stars (twinkle + drift).
|
// 2) Stars (slow twinkle; positions already rotated).
|
||||||
for (const s of stars) {
|
for (const p of proj) {
|
||||||
s.x += s.vx;
|
const s = p.s;
|
||||||
s.y += s.vy;
|
|
||||||
s.tw += s.tws * dt;
|
s.tw += s.tws * dt;
|
||||||
// Wrap softly at edges.
|
// Cull stars rotated off-screen for perf.
|
||||||
if (s.x < -4) s.x = width + 4;
|
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
|
||||||
else if (s.x > width + 4) s.x = -4;
|
|
||||||
if (s.y < -4) s.y = height + 4;
|
|
||||||
else if (s.y > height + 4) s.y = -4;
|
|
||||||
|
|
||||||
const twinkle = 0.6 + 0.4 * Math.sin(s.tw);
|
const twinkle = 0.6 + 0.4 * Math.sin(s.tw);
|
||||||
const alpha = Math.max(0, Math.min(1, s.baseA * twinkle));
|
const alpha = Math.max(0, Math.min(1, s.baseA * twinkle));
|
||||||
const color = s.anchor ? gold : s.r > 1 ? white : s.r > 0.7 ? violet : teal;
|
const color = hueColor(s.hue);
|
||||||
|
|
||||||
if (s.anchor) {
|
if (s.anchor) {
|
||||||
// Glow halo for anchors.
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.globalAlpha = alpha * 0.5;
|
ctx.globalAlpha = alpha * 0.5;
|
||||||
ctx.shadowBlur = 10;
|
ctx.shadowBlur = 10;
|
||||||
ctx.shadowColor = color;
|
ctx.shadowColor = color;
|
||||||
ctx.arc(s.x, s.y, s.r * 1.8, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, s.r * 1.8, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,19 +245,19 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha;
|
||||||
ctx.shadowBlur = s.anchor ? 6 : 3;
|
ctx.shadowBlur = s.anchor ? 6 : 3;
|
||||||
ctx.shadowColor = color;
|
ctx.shadowColor = color;
|
||||||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// 3) Shooting stars.
|
// 3) Shooting stars (screen-space; not part of the rotating sky).
|
||||||
if (now >= nextShooterAt) spawnShooter(now);
|
if (now >= nextShooterAt) spawnShooter(now);
|
||||||
for (let i = shooters.length - 1; i >= 0; i--) {
|
for (let i = shooters.length - 1; i >= 0; i--) {
|
||||||
const sh = shooters[i];
|
const sh = shooters[i];
|
||||||
sh.x += Math.cos(sh.angle) * sh.speed;
|
sh.x += Math.cos(sh.angle) * sh.speed;
|
||||||
sh.y += Math.sin(sh.angle) * sh.speed;
|
sh.y += Math.sin(sh.angle) * sh.speed;
|
||||||
sh.life -= dt * 0.5; // fade over ~2s
|
sh.life -= dt * 0.5;
|
||||||
if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) {
|
if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) {
|
||||||
shooters.splice(i, 1);
|
shooters.splice(i, 1);
|
||||||
continue;
|
continue;
|
||||||
@@ -218,8 +266,8 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
const tailY = sh.y - Math.sin(sh.angle) * sh.len;
|
const tailY = sh.y - Math.sin(sh.angle) * sh.len;
|
||||||
const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY);
|
const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY);
|
||||||
const headA = Math.max(0, Math.min(1, sh.life));
|
const headA = Math.max(0, Math.min(1, sh.life));
|
||||||
grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`); // star-white head
|
grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`);
|
||||||
grad.addColorStop(0.4, `rgba(232, 181, 74, ${(headA * 0.6).toFixed(3)})`); // gold mid
|
grad.addColorStop(0.4, `rgba(232, 181, 74, ${(headA * 0.6).toFixed(3)})`);
|
||||||
grad.addColorStop(1, 'rgba(232, 181, 74, 0)');
|
grad.addColorStop(1, 'rgba(232, 181, 74, 0)');
|
||||||
ctx.strokeStyle = grad;
|
ctx.strokeStyle = grad;
|
||||||
ctx.lineWidth = 1.6;
|
ctx.lineWidth = 1.6;
|
||||||
@@ -228,7 +276,6 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
ctx.lineTo(tailX, tailY);
|
ctx.lineTo(tailX, tailY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Bright head dot.
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
|
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
|
||||||
ctx.shadowBlur = 8;
|
ctx.shadowBlur = 8;
|
||||||
@@ -271,26 +318,49 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
};
|
};
|
||||||
}, [particles]);
|
}, [particles]);
|
||||||
|
|
||||||
|
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
|
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${skyClass} ${className}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{/* Layer 1: animated mesh gradient (midnight indigo -> violet + gold edges). */}
|
{/* Layer 1: night-sky gradient — deep blue, darker at top, lighter toward
|
||||||
|
a low horizon. Evokes a true clear night sky rather than a flat color. */}
|
||||||
<div
|
<div
|
||||||
className="animate-gradient-shift absolute inset-0 opacity-95"
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(180deg, #04061a 0%, #070b24 32%, #0a1230 58%, #0e1a3e 82%, #12224a 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Milky Way galaxy band — a soft diagonal luminous streak with a denser
|
||||||
|
core, blending teal + violet + faint gold. Sits over the base sky. */}
|
||||||
|
<div
|
||||||
|
className="animate-gradient-shift absolute inset-0 opacity-90"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-sunken)',
|
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
radial-gradient(60% 80% at 15% 10%, color-mix(in srgb, var(--color-amber) 18%, transparent), transparent 60%),
|
radial-gradient(38% 70% at 68% 38%, rgba(111, 182, 173, 0.22), transparent 62%),
|
||||||
radial-gradient(55% 70% at 85% 0%, color-mix(in srgb, var(--color-violet, var(--color-secondary)) 30%, transparent), transparent 60%),
|
radial-gradient(30% 55% at 72% 44%, rgba(139, 123, 216, 0.26), transparent 60%),
|
||||||
radial-gradient(70% 90% at 50% 110%, color-mix(in srgb, var(--color-amber) 14%, transparent), transparent 60%)
|
radial-gradient(22% 40% at 76% 50%, rgba(232, 181, 74, 0.12), transparent 60%),
|
||||||
|
linear-gradient(115deg, transparent 30%, rgba(236, 233, 255, 0.06) 45%, rgba(236, 233, 255, 0.09) 52%, rgba(236, 233, 255, 0.05) 60%, transparent 72%)
|
||||||
|
`,
|
||||||
|
backgroundSize: '200% 200%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Nebula clouds near the corners for color depth. */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `
|
||||||
|
radial-gradient(45% 55% at 12% 12%, rgba(139, 123, 216, 0.18), transparent 60%),
|
||||||
|
radial-gradient(40% 50% at 88% 88%, rgba(111, 182, 173, 0.16), transparent 62%)
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Faint astrolabe + sacred-geometry ornaments for esoteric texture. */}
|
{/* Faint astrolabe + sacred-geometry ornaments for esoteric texture. */}
|
||||||
<svg
|
<svg
|
||||||
className="absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow text-(--color-amber) opacity-[0.08]"
|
className="absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow text-(--color-amber) opacity-[0.11]"
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -317,7 +387,7 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
className="absolute -right-44 bottom-[-12rem] h-[30rem] w-[30rem] text-(--color-violet) opacity-[0.08]"
|
className="absolute -right-44 bottom-[-12rem] h-[30rem] w-[30rem] text-(--color-violet) opacity-[0.11]"
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -343,10 +413,10 @@ export default function AnimatedBackground({ particles = true, className = '' }:
|
|||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
'radial-gradient(120% 80% at 50% 30%, transparent 40%, color-mix(in srgb, var(--color-surface-sunken) 70%, transparent) 100%)',
|
'radial-gradient(120% 80% at 50% 30%, transparent 40%, rgba(4, 6, 26, 0.7) 100%)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Layer 3: canvas star field (skipped under reduced motion). */}
|
{/* Canvas star field (skipped under reduced motion). */}
|
||||||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,20 +2,31 @@
|
|||||||
/**
|
/**
|
||||||
* Footer.astro
|
* Footer.astro
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* Minimal footer: social links (mdi icons), copyright, and the spec microcopy
|
* Minimal footer on a deep night-sky surface: a faint static starfield accent,
|
||||||
* "Built with intention. Powered by caffeine." No emojis per spec.
|
* a StarBurst mark beside the name, social links (mdi icons), copyright, and
|
||||||
|
* the spec microcopy "Built with intention. Powered by caffeine." No emojis.
|
||||||
*/
|
*/
|
||||||
import Icon from '@components/Icon.astro';
|
import Icon from '@components/Icon.astro';
|
||||||
|
import StarBurst from '@components/mystic/StarBurst.astro';
|
||||||
|
import StarField from '@components/mystic/StarField.astro';
|
||||||
import { socials, footerMicrocopy, siteIdentity } from '@lib/site';
|
import { socials, footerMicrocopy, siteIdentity } from '@lib/site';
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const socialList = Object.values(socials);
|
const socialList = Object.values(socials);
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer
|
<footer
|
||||||
class="border-t"
|
class="relative overflow-hidden border-t"
|
||||||
style="border-color: var(--color-border); background: var(--color-surface-sunken);"
|
style="border-color: var(--color-border); background: var(--color-surface-sunken);"
|
||||||
>
|
>
|
||||||
<div class="container-prose flex flex-col items-center gap-6 py-10 text-center sm:flex-row sm:justify-between sm:text-left">
|
{/* Faint static starfield — carries the night-sky treatment into the footer. */}
|
||||||
|
<StarField
|
||||||
|
class="pointer-events-none absolute inset-0 h-full w-full"
|
||||||
|
style="color: var(--color-accent); opacity:.35;"
|
||||||
|
density="sparse"
|
||||||
|
/>
|
||||||
|
<div class="container-prose relative z-10 flex flex-col items-center gap-6 py-10 text-center sm:flex-row sm:justify-between sm:text-left">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<StarBurst class="h-5 w-5 shrink-0" style="color: var(--color-accent);" />
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<p class="font-sans text-sm font-semibold" style="color: var(--color-text);">
|
<p class="font-sans text-sm font-semibold" style="color: var(--color-text);">
|
||||||
{siteIdentity.name}
|
{siteIdentity.name}
|
||||||
@@ -24,6 +35,7 @@ const socialList = Object.values(socials);
|
|||||||
{footerMicrocopy}
|
{footerMicrocopy}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="flex items-center gap-2">
|
<ul class="flex items-center gap-2">
|
||||||
{socialList.map((s) => (
|
{socialList.map((s) => (
|
||||||
@@ -43,7 +55,7 @@ const socialList = Object.values(socials);
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="container-prose pb-8 text-center text-xs" style="color: var(--color-text-subtle);">
|
<div class="container-prose relative z-10 pb-8 text-center text-xs" style="color: var(--color-text-subtle);">
|
||||||
© {year} {siteIdentity.name}. All rights reserved.
|
© {year} {siteIdentity.name}. All rights reserved.
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ const {
|
|||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="relative overflow-hidden pt-28 sm:pt-32">
|
<section class="relative min-h-[60vh] overflow-hidden pt-28 sm:pt-32">
|
||||||
<!-- Calmer background: gradient only, no particles on interest pages. -->
|
<!-- Full animated Milky Way field + stars (matches the Home/Interests hero).
|
||||||
<AnimatedBackground client:idle particles={false} />
|
Carries the per-page morph name (one .vt-sky per page snapshot). -->
|
||||||
|
<AnimatedBackground client:idle transitionName="sky" />
|
||||||
|
|
||||||
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-12">
|
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-16">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<a
|
<a
|
||||||
href="/interests"
|
href="/interests"
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* CircuitField.tsx
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* The Professional page's counterpart to ConstellationField. It draws a faint
|
||||||
|
* circuit-board pattern — orthogonal traces connecting node pads, with a slow
|
||||||
|
* "signal pulse" traveling the traces — instead of stars and constellations.
|
||||||
|
*
|
||||||
|
* Crucially it carries the SAME `view-transition-name` ("sky") as the star
|
||||||
|
* fields on other pages. So when you navigate from, say, the Home hero
|
||||||
|
* (ConstellationField / AnimatedBackground) to the Professional page, the
|
||||||
|
* browser's View Transition morphs the star field into the circuit field as
|
||||||
|
* one element — constellations visibly transform into circuitry.
|
||||||
|
*
|
||||||
|
* Honors prefers-reduced-motion (renders a static board) and pauses off-screen.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Opt-in View Transition name. Unset by default. The page should set this on
|
||||||
|
* AT MOST ONE element per page (the hero field). Applied via the `.vt-sky`
|
||||||
|
* CSS class to avoid an SSR/hydration mismatch with React's inline style.
|
||||||
|
*/
|
||||||
|
transitionName?: 'sky';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Trace {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
}
|
||||||
|
interface Node {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CircuitField({
|
||||||
|
className = '',
|
||||||
|
transitionName,
|
||||||
|
}: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
let raf = 0;
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
let t0 = performance.now();
|
||||||
|
|
||||||
|
let traces: Trace[] = [];
|
||||||
|
let nodes: Node[] = [];
|
||||||
|
|
||||||
|
const gold = '#e8b54a';
|
||||||
|
const violet = '#8b7bd8';
|
||||||
|
const teal = '#6fb6ad';
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
width = rect.width;
|
||||||
|
height = rect.height;
|
||||||
|
canvas.width = Math.floor(width * dpr);
|
||||||
|
canvas.height = Math.floor(height * dpr);
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
// Build an orthogonal circuit on a grid: place nodes at grid intersections
|
||||||
|
// (with jitter + random omissions), then connect neighbors with right-angle
|
||||||
|
// traces. This reads as a PCB / schematic, not a star map.
|
||||||
|
const step = 64;
|
||||||
|
const cols = Math.ceil(width / step) + 1;
|
||||||
|
const rows = Math.ceil(height / step) + 1;
|
||||||
|
const present: boolean[][] = [];
|
||||||
|
nodes = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
present[r] = [];
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
// ~62% of intersections get a pad.
|
||||||
|
present[r][c] = Math.random() < 0.62;
|
||||||
|
if (present[r][c]) {
|
||||||
|
const jx = (Math.random() - 0.5) * step * 0.25;
|
||||||
|
const jy = (Math.random() - 0.5) * step * 0.25;
|
||||||
|
nodes.push({ x: c * step + jx, y: r * step + jy });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traces = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
if (!present[r][c]) continue;
|
||||||
|
const here = { x: c * step, y: r * step };
|
||||||
|
// Right neighbor.
|
||||||
|
if (c + 1 < cols && present[r][c + 1] && Math.random() < 0.7) {
|
||||||
|
traces.push({ x1: here.x, y1: here.y, x2: (c + 1) * step, y2: r * step });
|
||||||
|
}
|
||||||
|
// Down neighbor.
|
||||||
|
if (r + 1 < rows && present[r + 1][c] && Math.random() < 0.7) {
|
||||||
|
traces.push({ x1: here.x, y1: here.y, x2: c * step, y2: (r + 1) * step });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(now: number) {
|
||||||
|
if (!ctx) return;
|
||||||
|
const dt = Math.min(0.05, (now - t0) / 1000);
|
||||||
|
t0 = now;
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Traces. A signal pulse brightens a moving window along each trace, with
|
||||||
|
// traces staggered so pulses are distributed across the board over time.
|
||||||
|
for (let i = 0; i < traces.length; i++) {
|
||||||
|
const t = traces[i];
|
||||||
|
// Base trace — clearly visible violet conductor line.
|
||||||
|
ctx.strokeStyle = 'rgba(168, 158, 220, 0.55)';
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(t.x1, t.y1);
|
||||||
|
ctx.lineTo(t.x2, t.y2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pulse: a bright gold "signal" traveling the trace. position 0..1,
|
||||||
|
// looping, with traces staggered so pulses spread across the board.
|
||||||
|
const period = 6 + (i % 7);
|
||||||
|
const phase = ((now / 1000) % period) / period;
|
||||||
|
const px = t.x1 + (t.x2 - t.x1) * phase;
|
||||||
|
const py = t.y1 + (t.y2 - t.y1) * phase;
|
||||||
|
ctx.strokeStyle = gold;
|
||||||
|
ctx.globalAlpha = 0.95;
|
||||||
|
ctx.lineWidth = 2.2;
|
||||||
|
ctx.shadowBlur = 10;
|
||||||
|
ctx.shadowColor = gold;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px - (t.x2 - t.x1) * 0.08, py - (t.y2 - t.y1) * 0.08);
|
||||||
|
ctx.lineTo(px, py);
|
||||||
|
ctx.stroke();
|
||||||
|
// Bright pulse head dot.
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = '#fff4d6';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, 1.8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node pads — solid teal fill with a brighter violet ring (solder pad).
|
||||||
|
for (const n of nodes) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = teal;
|
||||||
|
ctx.globalAlpha = 0.95;
|
||||||
|
ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 0.85;
|
||||||
|
ctx.strokeStyle = violet;
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(n.x, n.y, 4.5, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
void dt;
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawStatic() {
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
for (const t of traces) {
|
||||||
|
ctx.strokeStyle = 'rgba(168, 158, 220, 0.5)';
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(t.x1, t.y1);
|
||||||
|
ctx.lineTo(t.x2, t.y2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (const n of nodes) {
|
||||||
|
ctx.fillStyle = teal;
|
||||||
|
ctx.globalAlpha = 0.95;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
build();
|
||||||
|
if (reduce) {
|
||||||
|
drawStatic();
|
||||||
|
} else {
|
||||||
|
t0 = performance.now();
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
const onResize = () => {
|
||||||
|
build();
|
||||||
|
if (reduce) drawStatic();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
if (reduce) return;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
if (!raf) {
|
||||||
|
t0 = performance.now();
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
io.observe(canvas);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
io.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`pointer-events-none absolute inset-0 h-full w-full ${skyClass} ${className}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* ConstellationField.tsx
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* A calmer, reusable star-field island for SECTION backgrounds (everything that
|
||||||
|
* isn't the hero). Compared to AnimatedBackground it is:
|
||||||
|
* - sparser and dimmer (so foreground content stays readable),
|
||||||
|
* - slow-blink only (no shooting stars / nebula),
|
||||||
|
* - the whole field rotates together very slowly, reading as a slice of sky
|
||||||
|
* turning as the planet rotates.
|
||||||
|
*
|
||||||
|
* Carries a `view-transition-name` (default "sky") so that when the browser
|
||||||
|
* morphs between pages via Astro's <ClientRouter />, the hero's star field and
|
||||||
|
* a section's constellation field cross-fade/translate as one continuous sky.
|
||||||
|
* On the Professional page we render CircuitField instead, which shares the
|
||||||
|
* same transition name — so constellations visibly MORPH into circuit traces.
|
||||||
|
*
|
||||||
|
* Honors prefers-reduced-motion (renders a static field) and pauses when the
|
||||||
|
* section is off-screen.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Density multiplier. 1 = default; lower for quieter sections. */
|
||||||
|
density?: number;
|
||||||
|
/** Tailwind/className passthrough for the root. */
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Opt-in View Transition name. DEFAULT IS UNSET so the field carries NO
|
||||||
|
* view-transition-name. The View Transitions API requires the name to be
|
||||||
|
* UNIQUE within a single page snapshot; assigning "sky" to every instance
|
||||||
|
* caused "Unexpected duplicate view-transition-name: sky" + transition
|
||||||
|
* aborts. The page should set this on AT MOST ONE field per page (typically
|
||||||
|
* the hero), and it must match the corresponding element on the destination
|
||||||
|
* page for the morph (e.g. constellations -> circuitry) to play.
|
||||||
|
*
|
||||||
|
* Set via the `data-sky` attribute + the `.vt-sky` CSS class (see global.css)
|
||||||
|
* rather than React's inline style, which avoids an SSR/hydration mismatch.
|
||||||
|
*/
|
||||||
|
transitionName?: 'sky';
|
||||||
|
/** Star color theme. 'gold' leans warm, 'violet' leans mystic. */
|
||||||
|
tint?: 'gold' | 'violet' | 'teal';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConstellationField({
|
||||||
|
density = 1,
|
||||||
|
className = '',
|
||||||
|
transitionName,
|
||||||
|
tint = 'gold',
|
||||||
|
}: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
let raf = 0;
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
let angle = 0;
|
||||||
|
let t0 = performance.now();
|
||||||
|
|
||||||
|
interface Star {
|
||||||
|
rx: number;
|
||||||
|
ry: number;
|
||||||
|
r: number;
|
||||||
|
tw: number;
|
||||||
|
tws: number;
|
||||||
|
baseA: number;
|
||||||
|
anchor: boolean;
|
||||||
|
}
|
||||||
|
let stars: Star[] = [];
|
||||||
|
|
||||||
|
const gold = '#e8b54a';
|
||||||
|
const violet = '#8b7bd8';
|
||||||
|
const teal = '#6fb6ad';
|
||||||
|
const white = '#f4f1e8';
|
||||||
|
const accent = tint === 'violet' ? violet : tint === 'teal' ? teal : gold;
|
||||||
|
|
||||||
|
// One full rotation every ~10 minutes — barely perceptible, "planet turning".
|
||||||
|
const ROT_SPEED = (Math.PI * 2) / (10 * 60);
|
||||||
|
const LINK_DIST = 175;
|
||||||
|
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
width = rect.width;
|
||||||
|
height = rect.height;
|
||||||
|
canvas.width = Math.floor(width * dpr);
|
||||||
|
canvas.height = Math.floor(height * dpr);
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const maxR = Math.sqrt(cx * cx + cy * cy) + 40;
|
||||||
|
// Tuned for ~40% constellation coverage. One star per ~6500 px^2, scaled
|
||||||
|
// by density; ~14% anchors for a rich constellation backbone.
|
||||||
|
const count = Math.min(
|
||||||
|
260,
|
||||||
|
Math.floor(((Math.PI * maxR * maxR) / 6500) * density),
|
||||||
|
);
|
||||||
|
stars = Array.from({ length: count }, (_, i) => {
|
||||||
|
const anchor = i % 7 === 0;
|
||||||
|
const rr = maxR * Math.sqrt(Math.random());
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
return {
|
||||||
|
rx: cx + rr * Math.cos(a),
|
||||||
|
ry: cy + rr * Math.sin(a),
|
||||||
|
r: anchor ? 1.1 + Math.random() * 1.0 : 0.3 + Math.random() * 0.9,
|
||||||
|
tw: Math.random() * Math.PI * 2,
|
||||||
|
tws: 0.1 + Math.random() * 0.25,
|
||||||
|
baseA: anchor ? 0.4 + Math.random() * 0.3 : 0.1 + Math.random() * 0.3,
|
||||||
|
anchor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawStatic() {
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
for (const s of stars) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = s.anchor ? accent : white;
|
||||||
|
ctx.globalAlpha = s.baseA;
|
||||||
|
ctx.arc(s.rx, s.ry, s.r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
void cx;
|
||||||
|
void cy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(now: number) {
|
||||||
|
if (!ctx) return;
|
||||||
|
const dt = Math.min(0.05, (now - t0) / 1000);
|
||||||
|
t0 = now;
|
||||||
|
angle += ROT_SPEED * dt;
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
const cosA = Math.cos(angle);
|
||||||
|
const sinA = Math.sin(angle);
|
||||||
|
const project = (rx: number, ry: number): [number, number] => [
|
||||||
|
cx + (rx - cx) * cosA - (ry - cy) * sinA,
|
||||||
|
cy + (rx - cx) * sinA + (ry - cy) * cosA,
|
||||||
|
];
|
||||||
|
|
||||||
|
const proj = stars.map((s) => {
|
||||||
|
const [x, y] = project(s.rx, s.ry);
|
||||||
|
return { s, x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Constellation lines between near anchors.
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let i = 0; i < proj.length; i++) {
|
||||||
|
const A = proj[i];
|
||||||
|
if (!A.s.anchor) continue;
|
||||||
|
for (let j = i + 1; j < proj.length; j++) {
|
||||||
|
const B = proj[j];
|
||||||
|
if (!B.s.anchor) continue;
|
||||||
|
const ddx = A.x - B.x;
|
||||||
|
const ddy = A.y - B.y;
|
||||||
|
const d2 = ddx * ddx + ddy * ddy;
|
||||||
|
if (d2 > LINK_DIST_SQ) continue;
|
||||||
|
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
|
||||||
|
const pulse = 0.5 + 0.5 * Math.sin(now / 1600 + (i + j) * 0.7);
|
||||||
|
const alpha = proximity * 0.24 * (0.55 + 0.45 * pulse);
|
||||||
|
ctx.strokeStyle = `rgba(232, 181, 74, ${alpha.toFixed(3)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(A.x, A.y);
|
||||||
|
ctx.lineTo(B.x, B.y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow-blink stars.
|
||||||
|
for (const p of proj) {
|
||||||
|
const s = p.s;
|
||||||
|
s.tw += s.tws * dt;
|
||||||
|
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
|
||||||
|
const blink = 0.55 + 0.45 * Math.sin(s.tw);
|
||||||
|
const alpha = Math.max(0, Math.min(1, s.baseA * blink));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = s.anchor ? accent : white;
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
if (s.anchor) {
|
||||||
|
ctx.shadowBlur = 6;
|
||||||
|
ctx.shadowColor = accent;
|
||||||
|
} else {
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize();
|
||||||
|
if (reduce) {
|
||||||
|
drawStatic();
|
||||||
|
} else {
|
||||||
|
t0 = performance.now();
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
const onResize = () => {
|
||||||
|
resize();
|
||||||
|
if (reduce) drawStatic();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
if (reduce) return;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
if (!raf) {
|
||||||
|
t0 = performance.now();
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
io.observe(canvas);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
io.disconnect();
|
||||||
|
};
|
||||||
|
}, [density, tint]);
|
||||||
|
|
||||||
|
// Apply the view-transition-name via a CSS class (global.css `.vt-sky`) rather
|
||||||
|
// than React's inline style: avoids an SSR/hydration attribute mismatch and
|
||||||
|
// keeps the name off by default (only one field per page should opt in).
|
||||||
|
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`pointer-events-none absolute inset-0 h-full w-full ${skyClass} ${className}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
* Mobile renders a hamburger (MobileMenu.tsx) with the same two-group structure.
|
* Mobile renders a hamburger (MobileMenu.tsx) with the same two-group structure.
|
||||||
*/
|
*/
|
||||||
import Icon from '@components/Icon.astro';
|
import Icon from '@components/Icon.astro';
|
||||||
import ThemeToggle from './ThemeToggle.tsx';
|
|
||||||
import MobileMenu from './MobileMenu.tsx';
|
import MobileMenu from './MobileMenu.tsx';
|
||||||
|
import StarBurst from '@components/mystic/StarBurst.astro';
|
||||||
import { navGroups, siteIdentity } from '@lib/site';
|
import { navGroups, siteIdentity } from '@lib/site';
|
||||||
|
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
@@ -31,13 +31,16 @@ const isActive = (href: string) =>
|
|||||||
aria-label="Primary"
|
aria-label="Primary"
|
||||||
>
|
>
|
||||||
<!-- Logo / identity -->
|
<!-- Logo / identity -->
|
||||||
<a href="/" class="group flex flex-col leading-tight" aria-label={`${siteIdentity.name} home`}>
|
<a href="/" class="group flex items-center gap-2 leading-tight" aria-label={`${siteIdentity.name} home`}>
|
||||||
|
<StarBurst class="h-4 w-4 shrink-0 transition-transform duration-700 group-hover:rotate-90" style="color: var(--color-accent);" />
|
||||||
|
<span class="flex flex-col">
|
||||||
<span class="font-sans text-base font-bold tracking-tight" style="color: var(--color-text);">
|
<span class="font-sans text-base font-bold tracking-tight" style="color: var(--color-text);">
|
||||||
{siteIdentity.name}
|
{siteIdentity.name}
|
||||||
</span>
|
</span>
|
||||||
<span class="hidden font-mono text-[0.65rem] uppercase tracking-[0.18em] sm:block" style="color: var(--color-text-subtle);">
|
<span class="hidden font-mono text-[0.65rem] uppercase tracking-[0.18em] sm:block" style="color: var(--color-text-subtle);">
|
||||||
{siteIdentity.tagline}
|
{siteIdentity.tagline}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop nav: two groups with a divider -->
|
<!-- Desktop nav: two groups with a divider -->
|
||||||
@@ -63,14 +66,11 @@ const isActive = (href: string) =>
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div class="ml-1">
|
<div class="ml-1" />
|
||||||
<ThemeToggle client:idle />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile controls -->
|
<!-- Mobile controls (theme toggle removed: dark mode is forced for now) -->
|
||||||
<div class="flex items-center gap-2 md:hidden">
|
<div class="flex items-center gap-2 md:hidden">
|
||||||
<ThemeToggle client:idle />
|
|
||||||
<MobileMenu client:idle groups={navGroups} currentPath={path} />
|
<MobileMenu client:idle groups={navGroups} currentPath={path} />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -54,10 +54,13 @@ const ogImageUrl = ogImage
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<!-- `no-js` is removed by reveal-init.ts the moment JS runs. While it stays,
|
<!-- no-js guard: removed by reveal-init.ts once JS runs; while present, the
|
||||||
the global.css `.no-js .reveal { opacity: 1 }` rule guarantees content is
|
global.css `.no-js .reveal { opacity:1 }` rule keeps content visible even
|
||||||
never invisible even if the reveal script fails to load. -->
|
if the reveal script fails. Theme model: data-theme on <html>, data-surface
|
||||||
<html lang="en" class="no-js" data-theme="light">
|
on <body>. DARK MODE IS CURRENTLY FORCED — light mode is deferred (the
|
||||||
|
site is designed for dark first). theme-init.ts pins data-theme="dark" and
|
||||||
|
ignores any stored/OS preference until light mode is reintroduced. -->
|
||||||
|
<html lang="en" class="no-js" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
@@ -79,7 +82,10 @@ const ogImageUrl = ogImage
|
|||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|
||||||
<!-- Theme boot: blocking inline script prevents flash of wrong theme. -->
|
<!-- Theme boot: blocking inline script prevents flash of the wrong theme.
|
||||||
|
Dark mode is currently forced (light mode deferred), so this just pins
|
||||||
|
data-theme="dark" before paint. No data-astro-rerun needed since the
|
||||||
|
theme never changes across navigations. -->
|
||||||
<script is:inline set:html={themeInitScript} />
|
<script is:inline set:html={themeInitScript} />
|
||||||
|
|
||||||
<!-- Reveal-on-scroll observer. Pairs with Reveal.astro / [data-reveal].
|
<!-- Reveal-on-scroll observer. Pairs with Reveal.astro / [data-reveal].
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import SectionHeader from '@components/layout/SectionHeader.astro';
|
|||||||
import SplitTransition from '@components/layout/SplitTransition.astro';
|
import SplitTransition from '@components/layout/SplitTransition.astro';
|
||||||
import CelestialImage from '@components/mystic/CelestialImage.astro';
|
import CelestialImage from '@components/mystic/CelestialImage.astro';
|
||||||
import AlchemicalDivider from '@components/mystic/AlchemicalDivider.astro';
|
import AlchemicalDivider from '@components/mystic/AlchemicalDivider.astro';
|
||||||
|
import ConstellationField from '@components/mystic/ConstellationField.tsx';
|
||||||
|
|
||||||
// Spec "Quick Facts", emojis -> mdi icons.
|
// Spec "Quick Facts", emojis -> mdi icons.
|
||||||
const quickFacts = [
|
const quickFacts = [
|
||||||
@@ -31,7 +32,9 @@ const quickFacts = [
|
|||||||
description="The human story behind the resume: Greg Kilmer on curiosity, tabletop RPGs, resin dice, and a Philadelphia apartment with too many board games."
|
description="The human story behind the resume: Greg Kilmer on curiosity, tabletop RPGs, resin dice, and a Philadelphia apartment with too many board games."
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<section class="container-prose flex flex-col gap-10 pb-10 pt-28 sm:pt-32">
|
<section class="relative overflow-hidden pb-10 pt-28 sm:pt-32">
|
||||||
|
<ConstellationField client:idle className="opacity-40" tint="violet" transitionName="sky" />
|
||||||
|
<div class="container-prose relative z-10 flex flex-col gap-10">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
kicker="About"
|
kicker="About"
|
||||||
@@ -40,6 +43,7 @@ const quickFacts = [
|
|||||||
subtitle="Two wings of the same house. This page is the bridge between them."
|
subtitle="Two wings of the same house. This page is the bridge between them."
|
||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Section 1: The Short Version -->
|
<!-- Section 1: The Short Version -->
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import SectionHeader from '@components/layout/SectionHeader.astro';
|
|||||||
import CelestialImage from '@components/mystic/CelestialImage.astro';
|
import CelestialImage from '@components/mystic/CelestialImage.astro';
|
||||||
import AlchemicalDivider from '@components/mystic/AlchemicalDivider.astro';
|
import AlchemicalDivider from '@components/mystic/AlchemicalDivider.astro';
|
||||||
import SacredGeometry from '@components/mystic/SacredGeometry.astro';
|
import SacredGeometry from '@components/mystic/SacredGeometry.astro';
|
||||||
|
import ConstellationField from '@components/mystic/ConstellationField.tsx';
|
||||||
|
|
||||||
// Snapshot grid content from the spec, with spec emojis mapped to mdi icons.
|
// Snapshot grid content from the spec, with spec emojis mapped to mdi icons.
|
||||||
// Each card now carries a vibe-matched stock image (swap for real photos later).
|
// Each card now carries a vibe-matched stock image (swap for real photos later).
|
||||||
@@ -51,34 +52,28 @@ const cards = [
|
|||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
<!-- ===================== HERO ===================== -->
|
<!-- ===================== HERO ===================== -->
|
||||||
<section class="relative flex min-h-[88vh] items-center overflow-hidden pt-16">
|
<section class="relative flex min-h-[88vh] items-center overflow-hidden pt-16">
|
||||||
<AnimatedBackground client:idle />
|
<AnimatedBackground client:idle transitionName="sky" />
|
||||||
|
|
||||||
<div class="container-prose relative z-10 flex flex-col items-start gap-8 py-20">
|
<div class="container-prose relative z-10 flex flex-col items-start gap-8 py-20">
|
||||||
<div class="flex w-full flex-col gap-10 lg:flex-row lg:items-center">
|
<div class="flex w-full flex-col gap-10 lg:flex-row lg:items-center">
|
||||||
<Reveal as="div" class="max-w-2xl grow">
|
<Reveal as="div" class="max-w-2xl grow">
|
||||||
<span
|
<span
|
||||||
class="mb-5 inline-flex items-center gap-2 rounded-full border px-3 py-1 font-mono text-xs uppercase tracking-[0.18em]"
|
class="mb-5 inline-flex items-center gap-2 rounded-full border px-3 py-1 font-mono text-xs uppercase tracking-[0.18em]"
|
||||||
style="border-color: var(--color-border); color: var(--color-text-muted); background: color-mix(in srgb, var(--color-surface-raised) 60%, transparent);"
|
style="border-color: var(--color-border); color: var(--color-text-muted); background: color-mix(in srgb, var(--color-surface-raised) 60%, transparent);">
|
||||||
>
|
|
||||||
<span class="h-2 w-2 rounded-full animate-pulse-glow" style="background: var(--color-accent);"></span>
|
<span class="h-2 w-2 rounded-full animate-pulse-glow" style="background: var(--color-accent);"></span>
|
||||||
Developer. Maker. Storyteller.
|
Developer. Maker. Storyteller.
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<h1 class="text-balance" style="color: var(--color-text);">
|
<h1 class="text-balance" style="color: var(--text);">
|
||||||
Greg Kilmer
|
Greg Kilmer
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p
|
<p class="mt-3 font-sans text-xl font-medium sm:text-2xl" style="color: var(--color-accent);">
|
||||||
class="mt-3 font-sans text-xl font-medium sm:text-2xl"
|
|
||||||
style="color: var(--color-accent);"
|
|
||||||
>
|
|
||||||
Software Developer · Maker · Tabletop Gamemaster
|
Software Developer · Maker · Tabletop Gamemaster
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<blockquote
|
<blockquote class="mt-7 max-w-2xl border-l-2 pl-5 text-lg leading-relaxed"
|
||||||
class="mt-7 max-w-2xl border-l-2 pl-5 text-lg leading-relaxed"
|
style="border-color: var(--color-accent); color: var(--text-muted);">
|
||||||
style="border-color: var(--color-accent); color: var(--color-text-muted);"
|
|
||||||
>
|
|
||||||
I build things with code during the day and with resin, plastic, and
|
I build things with code during the day and with resin, plastic, and
|
||||||
imagination at night. I lead cloud and mobile development at Implico,
|
imagination at night. I lead cloud and mobile development at Implico,
|
||||||
run D&D campaigns that span years, and fill my shelves with Brandon
|
run D&D campaigns that span years, and fill my shelves with Brandon
|
||||||
@@ -100,37 +95,29 @@ const cards = [
|
|||||||
|
|
||||||
{/* Hero portrait — framed in celestial ornament. Swap for a real photo of Greg. */}
|
{/* Hero portrait — framed in celestial ornament. Swap for a real photo of Greg. */}
|
||||||
<Reveal as="div" delay={120} class="w-full max-w-sm shrink-0 lg:w-80">
|
<Reveal as="div" delay={120} class="w-full max-w-sm shrink-0 lg:w-80">
|
||||||
<CelestialImage
|
<CelestialImage src="/images/hero-portrait.jpg" alt="Greg at his desk" aspect="4/5" />
|
||||||
src="/images/hero-portrait.jpg"
|
|
||||||
alt="Greg at his desk"
|
|
||||||
aspect="4/5"
|
|
||||||
/>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scroll cue -->
|
<!-- Scroll cue -->
|
||||||
<div class="pointer-events-none absolute bottom-6 left-1/2 -translate-x-1/2 animate-float" style="color: var(--color-text-subtle);" aria-hidden="true">
|
<div class="pointer-events-none absolute bottom-6 left-1/2 -translate-x-1/2 animate-float"
|
||||||
|
style="color: var(--color-text-subtle);" aria-hidden="true">
|
||||||
<Icon name="chevron-down" class="h-7 w-7" />
|
<Icon name="chevron-down" class="h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===================== SNAPSHOT GRID ===================== -->
|
<!-- ===================== SNAPSHOT GRID ===================== -->
|
||||||
<section class="relative overflow-hidden py-20 sm:py-28">
|
<section class="relative overflow-hidden py-20 sm:py-28">
|
||||||
|
{/* Slow-rotating constellation field behind the section — a slice of sky. */}
|
||||||
|
<ConstellationField client:idle className="opacity-60" />
|
||||||
{/* Faint sacred-geometry ornament behind the section. */}
|
{/* Faint sacred-geometry ornament behind the section. */}
|
||||||
<SacredGeometry
|
<SacredGeometry figure="flower" class="pointer-events-none absolute -right-40 top-10 z-0 h-[40rem] w-[40rem]"
|
||||||
figure="flower"
|
style="color: var(--color-violet); opacity:.08;" />
|
||||||
class="pointer-events-none absolute -right-40 top-10 h-[40rem] w-[40rem]"
|
<div class="container-prose relative z-10 flex flex-col gap-12">
|
||||||
style="color: var(--color-violet); opacity:.06;"
|
|
||||||
/>
|
|
||||||
<div class="container-prose relative flex flex-col gap-12">
|
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeader
|
<SectionHeader kicker="The two wings" icon="home-heart" title="A snapshot"
|
||||||
kicker="The two wings"
|
subtitle="One house, two wings. Polished and precise on the professional side; warm and inviting on the personal side." />
|
||||||
icon="home-heart"
|
|
||||||
title="A snapshot"
|
|
||||||
subtitle="One house, two wings. Polished and precise on the professional side; warm and inviting on the personal side."
|
|
||||||
/>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div class="grid gap-5 md:grid-cols-3">
|
<div class="grid gap-5 md:grid-cols-3">
|
||||||
@@ -138,20 +125,13 @@ const cards = [
|
|||||||
<Reveal delay={i * 90}>
|
<Reveal delay={i * 90}>
|
||||||
<a href={card.href} class="card card-interactive group flex h-full flex-col gap-4 overflow-hidden p-0">
|
<a href={card.href} class="card card-interactive group flex h-full flex-col gap-4 overflow-hidden p-0">
|
||||||
<div class="aspect-[16/10] w-full overflow-hidden">
|
<div class="aspect-[16/10] w-full overflow-hidden">
|
||||||
<img
|
<img src={card.image} alt={card.alt} loading="lazy" decoding="async"
|
||||||
src={card.image}
|
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105" />
|
||||||
alt={card.alt}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4 px-5 pb-5">
|
<div class="flex flex-col gap-4 px-5 pb-5">
|
||||||
<span
|
<span class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
|
||||||
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
|
|
||||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
|
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
|
||||||
aria-hidden="true"
|
aria-hidden="true">
|
||||||
>
|
|
||||||
<Icon name={card.icon} class="h-6 w-6" />
|
<Icon name={card.icon} class="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<h3 class="font-sans" style="color: var(--color-text);">{card.title}</h3>
|
<h3 class="font-sans" style="color: var(--color-text);">{card.title}</h3>
|
||||||
@@ -160,8 +140,7 @@ const cards = [
|
|||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
class="mt-1 inline-flex items-center gap-1 font-sans text-sm font-semibold transition-transform group-hover:translate-x-1"
|
class="mt-1 inline-flex items-center gap-1 font-sans text-sm font-semibold transition-transform group-hover:translate-x-1"
|
||||||
style="color: var(--color-accent);"
|
style="color: var(--color-accent);">
|
||||||
>
|
|
||||||
Learn more
|
Learn more
|
||||||
<Icon name="arrow-right" class="h-4 w-4" />
|
<Icon name="arrow-right" class="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
@@ -177,13 +156,12 @@ const cards = [
|
|||||||
|
|
||||||
{/* Wider strip: Reading & Media */}
|
{/* Wider strip: Reading & Media */}
|
||||||
<Reveal delay={120}>
|
<Reveal delay={120}>
|
||||||
<a
|
<a href="/interests/reading"
|
||||||
href="/interests/reading"
|
class="card card-interactive group flex flex-col gap-4 overflow-hidden p-0 sm:flex-row sm:items-center sm:justify-between">
|
||||||
class="card card-interactive group flex flex-col gap-4 overflow-hidden p-0 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-4">
|
<span class="flex items-center gap-4">
|
||||||
<span class="hidden aspect-square w-32 shrink-0 overflow-hidden sm:block">
|
<span class="hidden aspect-square w-32 shrink-0 overflow-hidden sm:block">
|
||||||
<img src="/images/fantasy-books.jpg" alt="Fantasy bookshelf" loading="lazy" decoding="async" class="h-full w-full object-cover" />
|
<img src="/images/fantasy-books.jpg" alt="Fantasy bookshelf" loading="lazy" decoding="async"
|
||||||
|
class="h-full w-full object-cover" />
|
||||||
</span>
|
</span>
|
||||||
<span class="flex flex-col gap-1 px-5 py-4 sm:px-0 sm:py-0">
|
<span class="flex flex-col gap-1 px-5 py-4 sm:px-0 sm:py-0">
|
||||||
<span class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
<span class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
||||||
@@ -194,7 +172,8 @@ const cards = [
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Icon name="arrow-right" class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1 sm:ml-4" style="color: var(--color-accent);" />
|
<Icon name="arrow-right" class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1 sm:ml-4"
|
||||||
|
style="color: var(--color-accent);" />
|
||||||
</a>
|
</a>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Icon from '@components/Icon.astro';
|
|||||||
import SectionHeader from '@components/layout/SectionHeader.astro';
|
import SectionHeader from '@components/layout/SectionHeader.astro';
|
||||||
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
|
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
|
||||||
import SacredGeometry from '@components/mystic/SacredGeometry.astro';
|
import SacredGeometry from '@components/mystic/SacredGeometry.astro';
|
||||||
|
import ConstellationField from '@components/mystic/ConstellationField.tsx';
|
||||||
|
|
||||||
const interests = [
|
const interests = [
|
||||||
{
|
{
|
||||||
@@ -68,9 +69,10 @@ const interests = [
|
|||||||
description="The fun wing: tabletop RPGs, gaming, reading, resin dice and 3D printing, and Pokémon. Where Greg's passions live as stories and collections."
|
description="The fun wing: tabletop RPGs, gaming, reading, resin dice and 3D printing, and Pokémon. Where Greg's passions live as stories and collections."
|
||||||
>
|
>
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="relative overflow-hidden pt-28 sm:pt-32">
|
<section class="relative min-h-[60vh] overflow-hidden pt-28 sm:pt-32">
|
||||||
<AnimatedBackground client:idle particles={false} />
|
{/* Full animated Milky Way field + stars, matching the Home hero treatment. */}
|
||||||
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-12">
|
<AnimatedBackground client:idle transitionName="sky" />
|
||||||
|
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-16">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
kicker="The personal wing"
|
kicker="The personal wing"
|
||||||
@@ -84,12 +86,13 @@ const interests = [
|
|||||||
|
|
||||||
<!-- Interest grid -->
|
<!-- Interest grid -->
|
||||||
<section class="relative overflow-hidden py-12 sm:py-16">
|
<section class="relative overflow-hidden py-12 sm:py-16">
|
||||||
|
<ConstellationField client:idle className="opacity-45" tint="teal" />
|
||||||
<SacredGeometry
|
<SacredGeometry
|
||||||
figure="metatron"
|
figure="metatron"
|
||||||
class="pointer-events-none absolute -left-48 bottom-0 h-[38rem] w-[38rem]"
|
class="pointer-events-none absolute -left-48 bottom-0 z-0 h-[38rem] w-[38rem]"
|
||||||
style="color: var(--color-accent); opacity:.05;"
|
style="color: var(--color-accent); opacity:.07;"
|
||||||
/>
|
/>
|
||||||
<div class="container-prose relative">
|
<div class="container-prose relative z-10">
|
||||||
<div class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{interests.map((item, i) => (
|
{interests.map((item, i) => (
|
||||||
<Reveal delay={i * 80}>
|
<Reveal delay={i * 80}>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import SkillGrid from '@components/professional/SkillGrid.astro';
|
|||||||
import ProjectCard from '@components/professional/ProjectCard.astro';
|
import ProjectCard from '@components/professional/ProjectCard.astro';
|
||||||
import AlchemicalDivider from '@components/mystic/AlchemicalDivider.astro';
|
import AlchemicalDivider from '@components/mystic/AlchemicalDivider.astro';
|
||||||
import Astrolabe from '@components/mystic/Astrolabe.astro';
|
import Astrolabe from '@components/mystic/Astrolabe.astro';
|
||||||
|
import CircuitField from '@components/mystic/CircuitField.tsx';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
const experience = (await getCollection('experience')).sort(
|
const experience = (await getCollection('experience')).sort(
|
||||||
@@ -38,12 +39,16 @@ const projects = (await getCollection('projects')).sort(
|
|||||||
>
|
>
|
||||||
<!-- Page header -->
|
<!-- Page header -->
|
||||||
<section class="relative overflow-hidden pt-28 sm:pt-32">
|
<section class="relative overflow-hidden pt-28 sm:pt-32">
|
||||||
|
{/* Circuit-board field. Shares view-transition-name "sky" with the star
|
||||||
|
fields on other pages, so navigating here morphs constellations into
|
||||||
|
circuitry. */}
|
||||||
|
<CircuitField client:idle className="opacity-70" transitionName="sky" />
|
||||||
{/* Slowly rotating astrolabe ornament behind the header. */}
|
{/* Slowly rotating astrolabe ornament behind the header. */}
|
||||||
<Astrolabe
|
<Astrolabe
|
||||||
class="pointer-events-none absolute -right-48 -top-24 h-[34rem] w-[34rem] animate-spin-slow"
|
class="pointer-events-none absolute z-0 -right-48 -top-24 h-[34rem] w-[34rem] animate-spin-slow"
|
||||||
style="color: var(--color-accent); opacity:.10;"
|
style="color: var(--color-accent); opacity:.13;"
|
||||||
/>
|
/>
|
||||||
<div class="container-prose relative flex flex-col gap-10">
|
<div class="container-prose relative z-10 flex flex-col gap-10">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
kicker="Professional"
|
kicker="Professional"
|
||||||
|
|||||||
@@ -2,34 +2,23 @@
|
|||||||
* theme-init.ts
|
* theme-init.ts
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* Inlined (as a string) into the <head> of BaseLayout.astro so it runs BEFORE
|
* Inlined (as a string) into the <head> of BaseLayout.astro so it runs BEFORE
|
||||||
* first paint. Reads the saved theme from localStorage, falling back to the OS
|
* first paint. Doing this inline + blocking is what prevents the flash-of-wrong-
|
||||||
* preference via prefers-color-scheme, and sets `data-theme` on <html>.
|
* theme (FOUC). Keep this file dependency-free and tiny: it ships to every page.
|
||||||
*
|
*
|
||||||
* Doing this inline + blocking is what prevents the flash-of-wrong-theme (FOUC).
|
* DARK MODE IS CURRENTLY FORCED. The site is designed dark-first; light mode is
|
||||||
* Keep this file dependency-free and tiny: it ships to every page.
|
* deferred. This script pins data-theme="dark" and ignores any stored/OS
|
||||||
|
* preference, so the page never flashes light and the (removed) toggle can't
|
||||||
|
* desync it. To reintroduce light mode later: restore the stored/prefers-color-
|
||||||
|
* scheme logic and re-add the ThemeToggle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const themeInitScript = `
|
export const themeInitScript = `
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var STORAGE_KEY = 'gk-theme';
|
// Dark mode is forced for now (light mode is deferred).
|
||||||
var stored = localStorage.getItem(STORAGE_KEY);
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
var theme = stored || (prefersDark ? 'dark' : 'light');
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
|
|
||||||
// React to OS theme changes only if the user hasn't chosen explicitly.
|
|
||||||
if (!stored) {
|
|
||||||
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
var onChange = function (e) {
|
|
||||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
|
||||||
};
|
|
||||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
|
||||||
else if (mq.addListener) mq.addListener(onChange);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If anything fails (private mode, etc.), default to light.
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
document.documentElement.setAttribute('data-theme', 'light');
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -236,7 +236,7 @@
|
|||||||
}
|
}
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--text);
|
||||||
border: 1.5px solid var(--color-border);
|
border: 1.5px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
@@ -291,3 +291,20 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* View Transition name utility. Assigns `view-transition-name: sky` to a single
|
||||||
|
* hero/section field per page so the browser morphs the star/circuit field as
|
||||||
|
* one element across navigations (constellations <-> circuitry).
|
||||||
|
*
|
||||||
|
* IMPORTANT: the View Transitions API requires this name to be UNIQUE within a
|
||||||
|
* page snapshot. Assigning it to more than one element per page throws
|
||||||
|
* "Unexpected duplicate view-transition-name: sky" and aborts the transition.
|
||||||
|
* Only ONE field per page should ever carry `.vt-sky`.
|
||||||
|
*
|
||||||
|
* Implemented as a CSS class (not React inline style) so SSR + hydration
|
||||||
|
* attributes match exactly and React never rewrites the property.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
.vt-sky {
|
||||||
|
view-transition-name: sky;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,121 +4,115 @@
|
|||||||
* Two-axis theming model for the site:
|
* Two-axis theming model for the site:
|
||||||
*
|
*
|
||||||
* 1. Global light/dark mode -> set on <html data-theme="light|dark">
|
* 1. Global light/dark mode -> set on <html data-theme="light|dark">
|
||||||
* 2. Section surface tone -> set on a wrapper via data-surface:
|
* 2. Section surface tone -> set on <body data-surface="...">
|
||||||
* data-surface="neutral" Home / About / Contact (deep night-sky)
|
* data-surface="neutral" Home / About / Contact (deep night-sky)
|
||||||
* data-surface="professional" Professional wing (polished obsidian + gold)
|
* data-surface="professional" Professional wing (obsidian + violet/gold)
|
||||||
* data-surface="interest" Interests wing (candlelit study under stars)
|
* data-surface="interest" Interests wing (candlelit study)
|
||||||
*
|
*
|
||||||
* The semantic tokens (--surface, --text, --accent, ...) are redefined for each
|
* SELECTOR MODEL: data-theme lives on <html>, data-surface on <body>. We match
|
||||||
* (data-theme x data-surface) combination below. Components only ever reference
|
* the descendant combination `html[data-theme=...] body[data-surface=...]`.
|
||||||
* the semantic tokens, so swapping either axis automatically restyles them.
|
* `:root` holds the light/neutral FALLBACK only; every other rule beats it via
|
||||||
|
* specificity (0,2,0) > (0,1,0).
|
||||||
*
|
*
|
||||||
* Palette direction: night-sky mystical — midnight indigo bases, star-gold
|
* STATUS: DARK MODE IS FORCED (light mode deferred). The dark rules below are
|
||||||
* accents, mystic-violet secondary, nebula-teal tertiary. Light mode is
|
* tuned for contrast first — --text / --text-muted / --text-subtle are kept
|
||||||
* reinterpreted as a "twilight"/parchment variant so the toggle always works
|
* bright against the deep surfaces so content always stands out. Light rules
|
||||||
* without breaking the celestial mood.
|
* are retained for the day light mode is reintroduced.
|
||||||
*
|
*
|
||||||
* `theme-init.ts` (inlined in <head>) sets data-theme before paint to avoid a
|
* `theme-init.ts` (inlined in <head>) pins data-theme="dark" before paint.
|
||||||
* flash of the wrong theme. data-surface is applied by the layout wrappers.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ===========================================================================
|
/* ===========================================================================
|
||||||
* DEFAULT / NEUTRAL SURFACE (Home, About, Contact)
|
* DEFAULT / NEUTRAL SURFACE (Home, About, Contact)
|
||||||
* Deep midnight indigo with star-gold accents. The default night sky.
|
|
||||||
* ======================================================================== */
|
* ======================================================================== */
|
||||||
|
|
||||||
/* Light + neutral — twilight parchment (warm, low-contrast, still celestial). */
|
/* Light + neutral — fallback (not currently used; dark is forced). */
|
||||||
:root,
|
:root,
|
||||||
[data-theme="light"][data-surface="neutral"],
|
html[data-theme='light'] body[data-surface='neutral'] {
|
||||||
[data-theme="light"][data-surface="neutral"] body {
|
--surface: #efe7d2;
|
||||||
--surface: #f3eee0; /* aged parchment / star-map paper */
|
--surface-raised: #f7f0dc;
|
||||||
--surface-raised: #fbf7ec;
|
--surface-sunken: #e3d9bd;
|
||||||
--surface-sunken: #e7dfca;
|
--text: #1b1730;
|
||||||
--text: #1c1830; /* deep indigo ink */
|
|
||||||
--text-muted: #4a445e;
|
--text-muted: #4a445e;
|
||||||
--text-subtle: #6b6582;
|
--text-subtle: #6b6582;
|
||||||
--border: #d8cdb0;
|
--border: #d2c6a2;
|
||||||
--accent: #9a6b1a; /* deeper gold for AA on light */
|
--accent: #7e5512;
|
||||||
--accent-contrast: #fbf7ec;
|
--accent-contrast: #f7f0dc;
|
||||||
--secondary: #6f5fb6; /* mystic violet */
|
--secondary: #5e4f9e;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark + neutral — the canonical night sky. */
|
/* Dark + neutral — the canonical deep mystical nightscape. High-contrast text. */
|
||||||
[data-theme="dark"][data-surface="neutral"],
|
html[data-theme='dark'] body[data-surface='neutral'] {
|
||||||
[data-theme="dark"][data-surface="neutral"] body {
|
--surface: #070a1c; /* near-void midnight indigo */
|
||||||
--surface: #0b1026; /* midnight indigo */
|
--surface-raised: #11163a; /* indigo panel, violet-edged */
|
||||||
--surface-raised: #161b3a; /* indigo panel */
|
--surface-sunken: #04061a; /* deep cosmic void */
|
||||||
--surface-sunken: #070a1a; /* near-black void */
|
--text: #f6f4ff; /* near-white for max legibility */
|
||||||
--text: #f4f1e8; /* star-white */
|
--text-muted: #d4d0f0; /* bright enough to read clearly on deep blue */
|
||||||
--text-muted: #c7c3df;
|
--text-subtle: #a8a2d8; /* lifted from the old #7e79b8 for contrast */
|
||||||
--text-subtle: #8e89b5;
|
--border: #2e3470;
|
||||||
--border: #2c3160;
|
--accent: #f0c262; /* slightly lifted gold for pop on dark */
|
||||||
--accent: #e8b54a; /* star gold */
|
--accent-contrast: #070a1c;
|
||||||
--accent-contrast: #0b1026;
|
--secondary: #b3a4ec; /* brighter violet, easier on the eye */
|
||||||
--secondary: #8b7bd8; /* mystic violet */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================================================
|
/* ===========================================================================
|
||||||
* PROFESSIONAL SURFACE
|
* PROFESSIONAL SURFACE — violet-dominant obsidian, dark by default.
|
||||||
* Polished obsidian: deepest indigo with gold + subtle violet. Confident,
|
|
||||||
* precise, the "polished wing" from the spec — now with a starlit edge.
|
|
||||||
* ======================================================================== */
|
* ======================================================================== */
|
||||||
|
|
||||||
[data-surface="professional"] {
|
body[data-surface='professional'],
|
||||||
--surface: #0a0e22;
|
html[data-theme='dark'] body[data-surface='professional'] {
|
||||||
--surface-raised: #141a38;
|
--surface: #080a20;
|
||||||
--surface-sunken: #05081a;
|
--surface-raised: #121638;
|
||||||
--text: #f4f1e8;
|
--surface-sunken: #040517;
|
||||||
--text-muted: #c5c0db;
|
--text: #f6f4ff;
|
||||||
--text-subtle: #8782ad;
|
--text-muted: #cec9f2;
|
||||||
--border: #292e57;
|
--text-subtle: #a59fd6;
|
||||||
--accent: #e8b54a;
|
--border: #2e3270;
|
||||||
--accent-contrast: #0a0e22;
|
--accent: #f0c262;
|
||||||
--secondary: #8b7bd8;
|
--accent-contrast: #080a20;
|
||||||
|
--secondary: #b3a4ec;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light professional: keeps a dignified deep tone, slightly lifted so the
|
html[data-theme='light'] body[data-surface='professional'] {
|
||||||
* global toggle is never jarring next to a parchment Home page. */
|
--surface: #21183f;
|
||||||
[data-theme="light"][data-surface="professional"] {
|
--surface-raised: #2c2152;
|
||||||
--surface: #1a1f44;
|
--surface-sunken: #18113a;
|
||||||
--surface-raised: #252b54;
|
--text: #f1edff;
|
||||||
--surface-sunken: #141838;
|
--text-muted: #d5cff0;
|
||||||
--text: #f4f1e8;
|
--text-subtle: #aaa3d6;
|
||||||
--text-muted: #d4d0ec;
|
--border: #3c3170;
|
||||||
--text-subtle: #aaa5cd;
|
--accent: #7e5512;
|
||||||
--border: #3a3f72;
|
--accent-contrast: #f1edff;
|
||||||
|
--secondary: #b6a6e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================================================
|
/* ===========================================================================
|
||||||
* INTEREST SURFACE
|
* INTEREST SURFACE — warm plum-brown night, dark by default.
|
||||||
* Candlelit study under a starfield: warm amber-gold bases with deep indigo
|
|
||||||
* text by default (light), flipping to a warm brown-indigo at night. The
|
|
||||||
* "fun wing" stays inviting, now with an esoteric warmth.
|
|
||||||
* ======================================================================== */
|
* ======================================================================== */
|
||||||
|
|
||||||
[data-theme="light"][data-surface="interest"],
|
body[data-surface='interest'],
|
||||||
[data-surface="interest"] {
|
html[data-theme='dark'] body[data-surface='interest'] {
|
||||||
--surface: #f6ecd2; /* warm candlelight parchment */
|
--surface: #170f24; /* warm plum-brown night */
|
||||||
--surface-raised: #fdf5dc;
|
--surface-raised: #221630;
|
||||||
--surface-sunken: #ecdcb4;
|
--surface-sunken: #100920;
|
||||||
--text: #241a2e; /* plum-indigo ink */
|
--text: #fbf2e4; /* warm near-white */
|
||||||
--text-muted: #5c4a3a;
|
--text-muted: #ecdcc6; /* lifted for contrast */
|
||||||
--text-subtle: #826a52;
|
--text-subtle: #c4ab8e; /* lifted from #a99a86 */
|
||||||
--border: #ddc79a;
|
--border: #3e2f4a;
|
||||||
--accent: #9a6b1a; /* deeper gold for AA */
|
--accent: #f0c262;
|
||||||
--accent-contrast: #fdf5dc;
|
--accent-contrast: #170f24;
|
||||||
--secondary: #6f5fb6; /* mystic violet */
|
--secondary: #c8abe0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"][data-surface="interest"] {
|
html[data-theme='light'] body[data-surface='interest'] {
|
||||||
--surface: #1a1320; /* warm brown-indigo night */
|
--surface: #f4e9cf;
|
||||||
--surface-raised: #261c2e;
|
--surface-raised: #fdf4d9;
|
||||||
--surface-sunken: #120d18;
|
--surface-sunken: #e8d8b2;
|
||||||
--text: #f6ecd2; /* warm starlight */
|
--text: #22152e;
|
||||||
--text-muted: #ddcbb0;
|
--text-muted: #5c4733;
|
||||||
--text-subtle: #b09a82;
|
--text-subtle: #826a4f;
|
||||||
--border: #3a2c1f;
|
--border: #d8c290;
|
||||||
--accent: #e8b54a; /* star gold */
|
--accent: #7e5512;
|
||||||
--accent-contrast: #1a1320;
|
--accent-contrast: #fdf4d9;
|
||||||
--secondary: #b09a78; /* warm gold-bronze secondary at night */
|
--secondary: #5e4f9e;
|
||||||
}
|
}
|
||||||
|
|||||||