Compare commits

...

19 Commits

Author SHA1 Message Date
9b626faba8 Update hero section; part 1 2026-04-06 17:57:29 -07:00
153bd0cf39 Update comment themes; add github theme family 2026-04-06 16:32:15 -07:00
162032e3f3 Update theme families 2026-04-06 16:25:10 -07:00
237cacb612 Add more themes 2026-04-06 16:20:17 -07:00
f6e9e16227 Update mobile settings layout 2026-04-06 15:41:31 -07:00
db46f7d6ba Rework mobile device detection 2026-04-06 15:35:46 -07:00
e640e87d3f Add theme families 2026-04-06 15:27:40 -07:00
1cd76b03df mobile optimizations 2026-04-06 14:46:49 -07:00
5ac736cad4 mobile optimizations 2026-04-06 14:42:08 -07:00
997106eb92 mobile optimizations 2026-04-06 14:40:03 -07:00
3f103c3e15 mobile optimizations 2026-04-06 14:37:07 -07:00
16f271c1c9 mobile optimizations 2026-04-06 14:33:30 -07:00
1a445548f2 mobile optimizations 2026-04-06 14:21:03 -07:00
dc7ca40b9b mobile optimizations 2026-04-06 14:15:26 -07:00
14f9ef3ffd mobile optimizations 2026-04-06 14:10:46 -07:00
336c652bf7 mobile optimizations 2026-04-06 13:57:15 -07:00
873090310a mobile optimizations 2026-04-06 13:21:43 -07:00
c7762f099c mobile optimizations 2026-04-06 13:19:38 -07:00
c2407408fa Mobile optimizations 2026-04-06 13:08:41 -07:00
72 changed files with 2299 additions and 468 deletions

View File

@@ -12,6 +12,7 @@ export default defineConfig({
output: "server", output: "server",
adapter: vercel(), adapter: vercel(),
site: "https://timmypidashev.dev", site: "https://timmypidashev.dev",
devToolbar: { enabled: false },
build: { build: {
// Enable build-time optimizations // Enable build-time optimizations
inlineStylesheets: "auto", inlineStylesheets: "auto",

BIN
public/emoji/coffee.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

BIN
public/emoji/lightbulb.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
public/emoji/memo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

BIN
public/emoji/mood-cold.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

BIN
public/emoji/mood-cool.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
public/emoji/mood-fire.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

BIN
public/emoji/mood-nerd.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
public/emoji/mood-nod.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

BIN
public/emoji/sparkles.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
public/emoji/tinker.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

BIN
public/emoji/wave.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

View File

@@ -1,57 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react"; import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react";
import { AnimateIn } from "@/components/animate-in";
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}
export default function CurrentFocus() { export default function CurrentFocus() {
const recentProjects = [ const recentProjects = [
@@ -98,7 +46,7 @@ export default function CurrentFocus() {
<a <a
href={project.href} href={project.href}
className="block p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50 className="block p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50
transition-all duration-300 group bg-background/50 h-full" transition-colors duration-300 group bg-background/50 h-full"
> >
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors"> <h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
{project.title} {project.title}

View File

@@ -12,13 +12,12 @@ export default function Intro() {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0; const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload"; const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && isReload) { if (inView && (isReload || isSpaNav)) {
setVisible(true); setVisible(true);
return; return;
} }
if (inView) { if (inView) {
// Fresh navigation — animate in
requestAnimationFrame(() => setVisible(true)); requestAnimationFrame(() => setVisible(true));
return; return;
} }
@@ -48,36 +47,37 @@ export default function Intro() {
const anim = (delay: number) => const anim = (delay: number) =>
({ ({
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)", transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
transition: `all 0.7s ease-out ${delay}ms`, transition: `opacity 0.7s ease-out ${delay}ms, transform 0.7s ease-out ${delay}ms`,
willChange: "transform, opacity",
}) as React.CSSProperties; }) as React.CSSProperties;
return ( return (
<div ref={ref} className="w-full max-w-4xl px-4"> <div ref={ref} className="w-full max-w-4xl px-4">
<div className="space-y-8 md:space-y-12"> <div className="space-y-8 md:space-y-12">
<div className="flex flex-col sm:flex-row items-center sm:items-center justify-center gap-8 sm:gap-16"> <div className="flex flex-col sm:flex-row items-center justify-center gap-8 sm:gap-16">
<div <div
className="w-32 h-32 sm:w-48 sm:h-48 shrink-0" className="w-44 h-44 sm:w-40 sm:h-40 lg:w-48 lg:h-48 shrink-0"
style={anim(0)} style={anim(0)}
> >
<img <img
src="/me.jpeg" src="/me.jpeg"
alt="Timothy Pidashev" alt="Timothy Pidashev"
className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-all duration-300" className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-colors duration-300"
/> />
</div> </div>
<div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}> <div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
<h2 className="text-xl sm:text-5xl font-bold text-yellow-bright"> <h2 className="text-3xl sm:text-3xl lg:text-5xl font-bold text-yellow-bright">
Timothy Pidashev Timothy Pidashev
</h2> </h2>
<div className="text-sm sm:text-xl text-foreground/70 space-y-2 sm:space-y-3"> <div className="text-base sm:text-lg lg:text-xl text-foreground/70 space-y-2 sm:space-y-3">
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(300)}> <p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(300)}>
<span className="text-blue">Software Systems Engineer</span> <span className="text-blue">Software Systems Engineer</span>
</p> </p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(450)}> <p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(450)}>
<span className="text-green">Open Source Enthusiast</span> <span className="text-green">Open Source Enthusiast</span>
</p> </p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(600)}> <p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(600)}>
<span className="text-yellow">Coffee Connoisseur</span> <span className="text-yellow">Coffee Connoisseur</span>
</p> </p>
</div> </div>

View File

@@ -1,57 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Cross, Fish, Mountain, Book } from "lucide-react"; import { Cross, Fish, Mountain, Book } from "lucide-react";
import { AnimateIn } from "@/components/animate-in";
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0) scale(1)" : "translateY(20px) scale(0.97)",
}}
>
{children}
</div>
);
}
const interests = [ const interests = [
{ {
@@ -86,12 +34,12 @@ export default function OutsideCoding() {
</h2> </h2>
</AnimateIn> </AnimateIn>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">
{interests.map((interest, i) => ( {interests.map((interest, i) => (
<AnimateIn key={interest.title} delay={100 + i * 100}> <AnimateIn key={interest.title} delay={100 + i * 100}>
<div <div
className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10 className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-all duration-300 bg-background/50 h-full" hover:border-yellow-bright/50 transition-colors duration-300 bg-background/50 h-full"
> >
<div className="mb-3">{interest.icon}</div> <div className="mb-3">{interest.icon}</div>
<h3 className="font-bold text-foreground/90 mb-2">{interest.title}</h3> <h3 className="font-bold text-foreground/90 mb-2">{interest.title}</h3>

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
interface ActivityDay { interface ActivityDay {
grand_total: { total_seconds: number }; grand_total: { total_seconds: number };
@@ -9,6 +10,7 @@ interface ActivityGridProps {
} }
export const ActivityGrid = ({ data }: ActivityGridProps) => { export const ActivityGrid = ({ data }: ActivityGridProps) => {
const [tapped, setTapped] = useState<string | null>(null);
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -46,7 +48,7 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
} }
return ( return (
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors"> <div className="bg-background/50 border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors">
<div className="text-lg text-aqua-bright mb-6">Activity</div> <div className="text-lg text-aqua-bright mb-6">Activity</div>
<div className="flex gap-4"> <div className="flex gap-4">
@@ -69,12 +71,13 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
<div <div
key={dayIndex} key={dayIndex}
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)} className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}
hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer hover:ring-1 hover:ring-foreground/30 transition-colors cursor-pointer
group relative`} group relative`}
onClick={() => setTapped(tapped === day.date ? null : day.date)}
> >
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 <div className={`absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2
bg-background border border-foreground/10 rounded-md opacity-0 bg-background border border-foreground/10 rounded-md transition-opacity z-10 whitespace-nowrap text-xs
group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs"> ${tapped === day.date ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
{hours.toFixed(1)} hours on {day.date} {hours.toFixed(1)} hours on {day.date}
</div> </div>
</div> </div>

View File

@@ -28,8 +28,8 @@ const Stats = () => {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0; const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload"; const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && isReload) { if (inView && (isReload || isSpaNav)) {
setSkipAnim(true); setSkipAnim(true);
setIsVisible(true); setIsVisible(true);
return; return;
@@ -60,21 +60,23 @@ const Stats = () => {
const totalSeconds = stats.total_seconds; const totalSeconds = stats.total_seconds;
const duration = 2000; const duration = 2000;
const steps = 60; let start: number | null = null;
let currentStep = 0; let rafId: number;
const timer = setInterval(() => { const step = (timestamp: number) => {
currentStep += 1; if (!start) start = timestamp;
if (currentStep >= steps) { const elapsed = timestamp - start;
setCount(totalSeconds); const progress = Math.min(elapsed / duration, 1);
clearInterval(timer); const eased = 1 - Math.pow(1 - progress, 4);
return; setCount(Math.floor(totalSeconds * eased));
if (progress < 1) {
rafId = requestAnimationFrame(step);
} }
const progress = 1 - Math.pow(1 - currentStep / steps, 4); };
setCount(Math.floor(totalSeconds * progress));
}, duration / steps);
return () => clearInterval(timer); rafId = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafId);
}, [isVisible, stats]); }, [isVisible, stats]);
if (error) return null; if (error) return null;
@@ -88,25 +90,25 @@ const Stats = () => {
return ( return (
<div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6"> <div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
<div className={skipAnim ? "text-2xl opacity-80" : `text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}> <div className={skipAnim ? "text-lg md:text-2xl opacity-80" : `text-lg md:text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}>
I've spent I've spent
</div> </div>
<div className="relative"> <div className="relative">
<div className="text-8xl text-center relative z-10"> <div className="text-5xl md:text-8xl text-center relative z-10">
<span className="font-bold relative"> <span className="font-bold relative">
<span className={skipAnim ? "bg-gradient-text" : `bg-gradient-text opacity-0 ${isVisible ? "animate-fade-in-second" : ""}`}> <span className={skipAnim ? "bg-gradient-text" : `bg-gradient-text opacity-0 ${isVisible ? "animate-fade-in-second" : ""}`}>
{formattedHours} {formattedHours}
</span> </span>
</span> </span>
<span className={skipAnim ? "text-4xl opacity-60 ml-4" : `text-4xl opacity-0 ${isVisible ? "animate-slide-in-hours" : ""}`}> <span className={skipAnim ? "text-2xl md:text-4xl opacity-60 ml-4" : `text-2xl md:text-4xl opacity-0 ${isVisible ? "animate-slide-in-hours" : ""}`}>
hours hours
</span> </span>
</div> </div>
</div> </div>
<div className="flex flex-col items-center gap-3 text-center"> <div className="flex flex-col items-center gap-3 text-center">
<div className={skipAnim ? "text-xl opacity-80" : `text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}> <div className={skipAnim ? "text-base md:text-xl opacity-80" : `text-base md:text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
writing code & building apps writing code & building apps
</div> </div>
<div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `flex items-center gap-3 text-lg opacity-0 ${isVisible ? "animate-fade-in-fourth" : ""}`}> <div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `flex items-center gap-3 text-lg opacity-0 ${isVisible ? "animate-fade-in-fourth" : ""}`}>

View File

@@ -35,8 +35,8 @@ const DetailedStats = () => {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0; const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload"; const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && isReload) { if (inView && (isReload || isSpaNav)) {
setSkipAnim(true); setSkipAnim(true);
setVisible(true); setVisible(true);
return; return;
@@ -131,7 +131,7 @@ const DetailedStats = () => {
<> <>
{/* Header */} {/* Header */}
<h2 <h2
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-all duration-700 ease-out"}`} className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
style={skipAnim ? {} : { style={skipAnim ? {} : {
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)", transform: visible ? "translateY(0)" : "translateY(20px)",
@@ -147,7 +147,7 @@ const DetailedStats = () => {
return ( return (
<div <div
key={card.title} key={card.title}
className={`bg-background border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-all duration-500 ease-out"}`} className={`bg-background/50 border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-[opacity,transform] duration-500 ease-out"}`}
style={skipAnim ? {} : { style={skipAnim ? {} : {
transitionDelay: `${150 + i * 100}ms`, transitionDelay: `${150 + i * 100}ms`,
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
@@ -174,7 +174,7 @@ const DetailedStats = () => {
{/* Languages */} {/* Languages */}
<div <div
className={`bg-background border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-all duration-700 ease-out"}`} className={`bg-background/50 border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
style={skipAnim ? {} : { style={skipAnim ? {} : {
transitionDelay: "550ms", transitionDelay: "550ms",
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
@@ -194,9 +194,11 @@ const DetailedStats = () => {
<div <div
className={`h-full ${lang.color} rounded-full`} className={`h-full ${lang.color} rounded-full`}
style={{ style={{
width: visible ? `${lang.percent}%` : "0%", width: `${lang.percent}%`,
opacity: 0.85, opacity: 0.85,
transition: skipAnim ? "none" : `width 1s ease-out ${700 + i * 80}ms`, transform: visible ? "scaleX(1)" : "scaleX(0)",
transformOrigin: "left",
transition: skipAnim ? "none" : `transform 1s ease-out ${700 + i * 80}ms`,
}} }}
/> />
</div> </div>
@@ -212,7 +214,7 @@ const DetailedStats = () => {
{/* Activity Grid */} {/* Activity Grid */}
{activity && ( {activity && (
<div <div
className={skipAnim ? "" : "transition-all duration-700 ease-out"} className={skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}
style={skipAnim ? {} : { style={skipAnim ? {} : {
transitionDelay: "750ms", transitionDelay: "750ms",
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,

View File

@@ -51,8 +51,9 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0; const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload"; const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && isReload) { if (inView && (isReload || isSpaNav)) {
setSkip(true); setSkip(true);
setVisible(true); setVisible(true);
return; return;
@@ -87,7 +88,7 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background
rounded-full border-2 border-yellow-bright sm:-translate-x-1/2 rounded-full border-2 border-yellow-bright sm:-translate-x-1/2
flex items-center justify-center z-10 flex items-center justify-center z-10
${skip ? "" : "transition-all duration-500"} ${skip ? "" : "transition-[opacity,transform] duration-500"}
${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"} ${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"}
`} `}
> >
@@ -99,7 +100,7 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
className={` className={`
w-full sm:w-[calc(50%-32px)] w-full sm:w-[calc(50%-32px)]
${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"} ${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"}
${skip ? "" : "transition-all duration-700 ease-out"} ${skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
${visible ${visible
? "opacity-100 translate-x-0" ? "opacity-100 translate-x-0"
: `opacity-0 ${isLeft ? "sm:translate-x-8" : "sm:-translate-x-8"} translate-y-4 sm:translate-y-0` : `opacity-0 ${isLeft ? "sm:translate-x-8" : "sm:-translate-x-8"} translate-y-4 sm:translate-y-0`
@@ -168,8 +169,8 @@ export default function Timeline() {
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2"> <div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2">
<div <div
ref={lineRef} ref={lineRef}
className="w-full bg-foreground/10 transition-all duration-[1500ms] ease-out origin-top" className="w-full h-full bg-foreground/10 transition-transform duration-[1500ms] ease-out origin-top"
style={{ height: `${lineHeight}%` }} style={{ transform: `scaleY(${lineHeight / 100})` }}
/> />
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
interface AnimateInProps { interface AnimateInProps {
children: React.ReactNode; children: React.ReactNode;
@@ -9,13 +10,29 @@ interface AnimateInProps {
export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) { export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
if (prefersReducedMotion()) {
setSkip(true);
setVisible(true);
return;
}
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) { const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = (performance.getEntriesByType?.("navigation")?.[0] as PerformanceNavigationTiming)?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && (isReload || isSpaNav)) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true)); requestAnimationFrame(() => setVisible(true));
return; return;
} }
@@ -37,11 +54,12 @@ export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInPr
return ( return (
<div <div
ref={ref} ref={ref}
className="transition-all duration-700 ease-out" className={skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
style={{ style={skip ? {} : {
transitionDelay: `${delay}ms`, transitionDelay: `${delay}ms`,
willChange: "transform, opacity",
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)", transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
}} }}
> >
{children} {children}

View File

@@ -41,7 +41,7 @@ export default function AnimationSwitcher() {
return ( return (
<div <div
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden md:block" className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden desk:block"
onMouseEnter={() => setHovering(true)} onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)} onMouseLeave={() => setHovering(false)}
onClick={handleClick} onClick={handleClick}

View File

@@ -77,11 +77,13 @@ function readBgFromCSS(): string {
interface BackgroundProps { interface BackgroundProps {
layout?: "index" | "sidebar" | "content"; layout?: "index" | "sidebar" | "content";
position?: "left" | "right"; position?: "left" | "right";
mobileOnly?: boolean;
} }
const Background: React.FC<BackgroundProps> = ({ const Background: React.FC<BackgroundProps> = ({
layout = "index", layout = "index",
position = "left", position = "left",
mobileOnly = false,
}) => { }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<AnimationEngine | null>(null); const engineRef = useRef<AnimationEngine | null>(null);
@@ -330,10 +332,12 @@ const Background: React.FC<BackgroundProps> = ({
const getContainerClasses = () => { const getContainerClasses = () => {
if (isIndex) { if (isIndex) {
return "fixed inset-0 -z-10"; return mobileOnly
? "fixed inset-0 -z-10 desk:hidden"
: "fixed inset-0 -z-10";
} }
const baseClasses = "fixed top-0 bottom-0 hidden lg:block -z-10"; const baseClasses = "fixed top-0 bottom-0 hidden desk:block -z-10";
return position === "left" return position === "left"
? `${baseClasses} left-0` ? `${baseClasses} left-0`
: `${baseClasses} right-0`; : `${baseClasses} right-0`;

View File

@@ -1,25 +1,50 @@
import * as React from "react"; import * as React from "react";
import Giscus from "@giscus/react"; import Giscus from "@giscus/react";
import { getStoredThemeId } from "@/lib/themes/engine";
const id = "inject-comments"; const id = "inject-comments";
function getThemeUrl(themeId: string): string {
// Giscus iframe needs a publicly accessible URL — always use production domain
return `https://timmypidashev.dev/api/giscus-theme?theme=${themeId}`;
}
export const Comments = () => { export const Comments = () => {
const [mounted, setMounted] = React.useState(false); const [mounted, setMounted] = React.useState(false);
const [themeUrl, setThemeUrl] = React.useState("");
React.useEffect(() => { React.useEffect(() => {
setThemeUrl(getThemeUrl(getStoredThemeId()));
setMounted(true); setMounted(true);
const handleThemeChange = () => {
const newUrl = getThemeUrl(getStoredThemeId());
setThemeUrl(newUrl);
// Tell the giscus iframe to update its theme
const iframe = document.querySelector<HTMLIFrameElement>("iframe.giscus-frame");
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{ giscus: { setConfig: { theme: newUrl } } },
"https://giscus.app"
);
}
};
document.addEventListener("theme-changed", handleThemeChange);
return () => document.removeEventListener("theme-changed", handleThemeChange);
}, []); }, []);
return ( return (
<div id={id}> <div id={id} className="mt-8">
{mounted ? ( {mounted && themeUrl ? (
<Giscus <Giscus
id={id} id={id}
repo="timmypidashev/web" repo="timmypidashev/web"
repoId="MDEwOlJlcG9zaXRvcnkzODYxMjk5Mjk=" repoId="MDEwOlJlcG9zaXRvcnkzODYxMjk5Mjk="
category="Blog & Project Comments" category="Blog & Project Comments"
categoryId="DIC_kwDOFwPgCc4CpKtV" categoryId="DIC_kwDOFwPgCc4CpKtV"
theme="https://timmypidashev.us-sea-1.linodeobjects.com/comments.css" theme={themeUrl}
mapping="pathname" mapping="pathname"
strict="0" strict="0"
reactionsEnabled="1" reactionsEnabled="1"

View File

@@ -3,11 +3,10 @@ import { AnimateIn } from "@/components/animate-in";
export const BlogHeader = () => { export const BlogHeader = () => {
return ( return (
<div className="w-full max-w-6xl mx-auto px-4 pt-24 sm:pt-24"> <div className="w-full max-w-6xl mx-auto px-4 pt-12 md:pt-24">
<AnimateIn> <AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed"> <h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
Latest Thoughts <br className="sm:hidden" /> Latest Thoughts & Writings
& Writings
</h1> </h1>
</AnimateIn> </AnimateIn>
<AnimateIn delay={100}> <AnimateIn delay={100}>

View File

@@ -36,7 +36,7 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
href={`/blog/${post.id}`} href={`/blog/${post.id}`}
className="block" className="block"
> >
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200"> <article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-[outline-color] duration-200">
{/* Image container with fixed aspect ratio */} {/* Image container with fixed aspect ratio */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0"> <div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0">
<img <img

View File

@@ -30,7 +30,7 @@ const formatDate = (dateString: string) => {
const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => { const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => {
return ( return (
<div className="w-full max-w-6xl mx-auto"> <div className="w-full max-w-6xl mx-auto">
<div className="w-full px-4 pt-24 sm:pt-24"> <div className="w-full px-4 pt-12 md:pt-24">
<AnimateIn> <AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed"> <h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
#{tag} #{tag}
@@ -68,7 +68,7 @@ const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => {
<AnimateIn key={post.id} delay={200 + i * 80}> <AnimateIn key={post.id} delay={200 + i * 80}>
<li className="group px-4 md:px-0"> <li className="group px-4 md:px-0">
<a href={`/blog/${post.id}`} className="block"> <a href={`/blog/${post.id}`} className="block">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200"> <article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-[outline-color] duration-200">
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0"> <div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0">
<img <img
src={post.data.image || "/blog/placeholder.png"} src={post.data.image || "/blog/placeholder.png"}

View File

@@ -12,7 +12,7 @@ export default function Footer({ fixed = false }) {
return ( return (
<footer className={`w-full font-bold pointer-events-none ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}> <footer className={`w-full font-bold pointer-events-none ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}>
<div className="flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 pointer-events-none [&_a]:pointer-events-auto"> <div className="hidden desk:flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 pointer-events-none [&_a]:pointer-events-auto">
{footerLinks} {footerLinks}
</div> </div>
</footer> </footer>

View File

@@ -92,7 +92,7 @@ export default function Header({ transparent = false }: { transparent?: boolean
`} `}
> >
<div className={` <div className={`
w-full flex flex-row items-center justify-center w-full hidden desk:flex flex-row items-center justify-center
pointer-events-none pointer-events-none
${!isIndexPage ? 'bg-background md:bg-transparent' : ''} ${!isIndexPage ? 'bg-background md:bg-transparent' : ''}
`}> `}>

View File

@@ -1,5 +1,12 @@
import { useState, useEffect, useRef } from "react";
import Typewriter from "typewriter-effect"; import Typewriter from "typewriter-effect";
interface GithubData {
status: { message: string } | null;
commit: { message: string; repo: string; date: string; url: string } | null;
tinkering: { repo: string; url: string } | null;
}
const html = (strings: TemplateStringsArray, ...values: any[]) => { const html = (strings: TemplateStringsArray, ...values: any[]) => {
let result = strings[0]; let result = strings[0];
for (let i = 0; i < values.length; i++) { for (let i = 0; i < values.length; i++) {
@@ -8,75 +15,160 @@ const html = (strings: TemplateStringsArray, ...values: any[]) => {
return result.replace(/\n\s*/g, "").replace(/\s+/g, " ").trim(); return result.replace(/\n\s*/g, "").replace(/\s+/g, " ").trim();
}; };
interface TypewriterOptions { function timeAgo(dateStr: string): string {
autoStart: boolean; const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
loop: boolean; if (seconds < 60) return "just now";
delay: number; const minutes = Math.floor(seconds / 60);
deleteSpeed: number; if (minutes < 60) return `${minutes}m ago`;
cursor: string; const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
interface TypewriterInstance { interface TypewriterInstance {
typeString: (str: string) => TypewriterInstance; typeString: (str: string) => TypewriterInstance;
pauseFor: (ms: number) => TypewriterInstance; pauseFor: (ms: number) => TypewriterInstance;
deleteAll: () => TypewriterInstance; deleteAll: () => TypewriterInstance;
callFunction: (cb: () => void) => TypewriterInstance;
start: () => TypewriterInstance; start: () => TypewriterInstance;
} }
export default function Hero() { const emoji = (name: string) =>
const SECTION_1 = html` `<img src="/emoji/${name}.webp" alt="" style="display:inline;height:1em;width:1em;vertical-align:middle">`;
const SECTION_1 = html`
<span>Hello, I'm</span> <span>Hello, I'm</span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a></span> <span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a> ${emoji("wave")}</span>
`; `;
const SECTION_2 = html` const SECTION_2 = html`
<span>I've been turning</span> <span>I've been turning</span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span><a href="/projects" class="text-green hover:underline"><strong>coffee</strong></a> into <span><a href="/projects" class="text-green hover:underline"><strong>coffee</strong></a> into <a href="/projects" class="text-yellow hover:underline"><strong>code</strong></a></span>
<a href="/projects" class="text-yellow hover:underline"><strong>code</strong></a></span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a>!</span> <span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a> ${emoji("sparkles")}</span>
`; `;
const SECTION_3 = html` const SECTION_3 = html`
<span>Check out my</span> <span>Check out my</span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span><a href="/blog" class="text-purple hover:underline"><strong>blog</strong></a>/ <span><a href="/blog" class="text-purple hover:underline"><strong>blog</strong></a>/
<a href="/projects" class="text-blue hover:underline"><strong>projects</strong></a> or</span> <a href="/projects" class="text-blue hover:underline"><strong>projects</strong></a> or</span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span><a href="/contact" class="text-green hover:underline"><strong>contact</strong></a> me below!</span> <span><a href="/contact" class="text-green hover:underline"><strong>contact</strong></a> me below ${emoji("point-down")}</span>
`; `;
const handleInit = (typewriter: TypewriterInstance): void => { const MOODS = [
typewriter "mood-cool", "mood-nerd", "mood-think", "mood-starstruck",
.typeString(SECTION_1) "mood-fire", "mood-cold", "mood-salute",
.pauseFor(2000) "mood-dotted", "mood-expressionless", "mood-neutral",
.deleteAll() "mood-nomouth", "mood-nod", "mood-melting",
.typeString(SECTION_2) ];
.pauseFor(2000)
.deleteAll() function addGreetings(tw: TypewriterInstance) {
.typeString(SECTION_3) tw.typeString(SECTION_1).pauseFor(2000).deleteAll()
.pauseFor(2000) .typeString(SECTION_2).pauseFor(2000).deleteAll()
.deleteAll() .typeString(SECTION_3).pauseFor(2000).deleteAll();
.start(); }
function addGithubSections(tw: TypewriterInstance, github: GithubData) {
if (github.status) {
const moodImg = emoji(MOODS[Math.floor(Math.random() * MOODS.length)]);
const statusStr =
`<span>My current mood ${moodImg}</span>` +
`<br><div class="mb-4"></div>` +
`<a href="https://github.com/timmypidashev" target="_blank" class="text-orange-bright hover:underline">${escapeHtml(github.status.message)}</a>`;
tw.typeString(statusStr).pauseFor(3000).deleteAll();
}
if (github.tinkering) {
const tinkerImg = emoji("tinker");
const tinkerStr =
`<span>Currently tinkering with ${tinkerImg}</span>` +
`<br><div class="mb-4"></div>` +
`<a href="${github.tinkering.url}" target="_blank" class="text-yellow hover:underline">${github.tinkering.url}</a>`;
tw.typeString(tinkerStr).pauseFor(3000).deleteAll();
}
if (github.commit) {
const ago = timeAgo(github.commit.date);
const memoImg = emoji("memo");
const repoUrl = `https://github.com/timmypidashev/${github.commit.repo}`;
const commitStr =
`<span>My latest <span class="text-foreground/40">(unbroken?)</span> commit ${memoImg}</span>` +
`<br><div class="mb-4"></div>` +
`<a href="${github.commit.url}" target="_blank" class="text-green hover:underline">"${escapeHtml(github.commit.message)}"</a>` +
`<br><div class="mb-4"></div>` +
`<a href="${repoUrl}" target="_blank" class="text-yellow hover:underline">${escapeHtml(github.commit.repo)}</a>` +
`<span class="text-foreground/40"> · ${ago}</span>`;
tw.typeString(commitStr).pauseFor(3000).deleteAll();
}
}
export default function Hero() {
const [phase, setPhase] = useState<"intro" | "full">("intro");
const githubRef = useRef<GithubData | null>(null);
useEffect(() => {
fetch("/api/github")
.then((r) => r.json())
.then((data) => { githubRef.current = data; })
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
}, []);
const handleIntroInit = (typewriter: TypewriterInstance): void => {
addGreetings(typewriter);
typewriter.callFunction(() => {
// Greetings done — data is almost certainly ready (API ~500ms, greetings ~20s)
const check = () => {
if (githubRef.current) {
setPhase("full");
} else {
setTimeout(check, 200);
}
};
check();
}).start();
}; };
const typewriterOptions: TypewriterOptions = { const handleFullInit = (typewriter: TypewriterInstance): void => {
autoStart: true, const github = githubRef.current!;
loop: true, // GitHub sections first (greetings just played in intro phase)
delay: 50, addGithubSections(typewriter, github);
deleteSpeed: 800, // Then greetings for the loop
cursor: '|' addGreetings(typewriter);
typewriter.start();
}; };
const baseOptions = { delay: 35, deleteSpeed: 35, cursor: "|" };
return ( return (
<div className="flex justify-center items-center min-h-screen pointer-events-none"> <div className="flex justify-center items-center min-h-screen pointer-events-none">
<div className="text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto"> <div className="text-2xl md:text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto">
{phase === "intro" ? (
<Typewriter <Typewriter
options={typewriterOptions} key="intro"
onInit={handleInit} options={{ ...baseOptions, autoStart: true, loop: false }}
onInit={handleIntroInit}
/> />
) : (
<Typewriter
key="full"
options={{ ...baseOptions, autoStart: true, loop: true }}
onInit={handleFullInit}
/>
)}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,102 @@
import { useState, useEffect, useRef } from "react";
import { Home, User, FolderOpen, BookOpen, FileText, Settings } from "lucide-react";
import { SettingsSheet } from "./settings-sheet";
const tabs = [
{ href: "/", label: "Home", icon: Home, color: "text-green" },
{ href: "/about", label: "About", icon: User, color: "text-yellow" },
{ href: "/projects", label: "Projects", icon: FolderOpen, color: "text-blue" },
{ href: "/blog", label: "Blog", icon: BookOpen, color: "text-purple" },
{ href: "/resume", label: "Resume", icon: FileText, color: "text-aqua" },
];
export default function MobileNav({ transparent = false }: { transparent?: boolean }) {
const [path, setPath] = useState("/");
const [settingsOpen, setSettingsOpen] = useState(false);
const [visible, setVisible] = useState(true);
const lastScrollY = useRef(0);
const lastTime = useRef(Date.now());
useEffect(() => {
setPath(window.location.pathname);
}, []);
useEffect(() => {
const handleScroll = () => {
const y = document.documentElement.scrollTop;
const now = Date.now();
const dt = now - lastTime.current;
const dy = lastScrollY.current - y; // positive = scrolling up
const velocity = dt > 0 ? dy / dt : 0; // px/ms
if (y < 10) {
setVisible(true);
} else if (dy > 0 && velocity > 1.5) {
// Fast upward scroll
setVisible(true);
} else if (dy < 0) {
// Scrolling down
setVisible(false);
}
lastScrollY.current = y;
lastTime.current = now;
};
document.addEventListener("scroll", handleScroll, { passive: true });
return () => document.removeEventListener("scroll", handleScroll);
}, []);
const isActive = (href: string) => {
if (href === "/") return path === "/";
return path.startsWith(href);
};
return (
<>
<nav
className={`fixed bottom-0 left-0 right-0 z-50 desk:hidden transition-transform duration-300 ${
visible ? "translate-y-0" : "translate-y-full"
} ${
transparent
? "bg-transparent"
: "bg-background border-t border-foreground/10"
}`}
style={{
paddingBottom: "env(safe-area-inset-bottom, 0px)",
touchAction: "manipulation",
}}
>
<div className="flex items-center justify-around px-1 h-14">
{tabs.map((tab) => {
const Icon = tab.icon;
const active = isActive(tab.href);
return (
<a
key={tab.href}
href={tab.href}
data-astro-reload
className={`flex flex-col items-center justify-center gap-0.5 flex-1 py-1 ${
active ? tab.color : "text-foreground/40"
}`}
>
<Icon size={20} strokeWidth={active ? 2 : 1.5} />
<span className="text-[10px]">{tab.label}</span>
</a>
);
})}
<button
onClick={() => setSettingsOpen(true)}
className={`flex flex-col items-center justify-center gap-0.5 flex-1 py-1 ${
settingsOpen ? "text-foreground" : "text-foreground/40"
}`}
>
<Settings size={20} strokeWidth={1.5} />
<span className="text-[10px]">Settings</span>
</button>
</div>
</nav>
<SettingsSheet open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import { X, ExternalLink } from "lucide-react";
import { FAMILIES, THEMES } from "@/lib/themes";
import { applyTheme, getStoredThemeId } from "@/lib/themes/engine";
import { ANIMATION_IDS, ANIMATION_LABELS, type AnimationId } from "@/lib/animations";
const footerLinks = [
{ href: "mailto:contact@timmypidashev.dev", label: "Contact", color: "text-green", activeBg: "bg-green/15", activeBorder: "border-green/40" },
{ href: "https://github.com/timmypidashev", label: "Github", color: "text-yellow", activeBg: "bg-yellow/15", activeBorder: "border-yellow/40" },
{ href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "LinkedIn", color: "text-blue", activeBg: "bg-blue/15", activeBorder: "border-blue/40" },
{ href: "https://github.com/timmypidashev/web", label: "Source", color: "text-purple", activeBg: "bg-purple/15", activeBorder: "border-purple/40" },
];
const animOptions = [
{ id: "shuffle", color: "text-red-bright", activeBg: "bg-red-bright/15", activeBorder: "border-red-bright/40" },
{ id: "game-of-life", color: "text-green-bright", activeBg: "bg-green-bright/15", activeBorder: "border-green-bright/40" },
{ id: "lava-lamp", color: "text-orange-bright", activeBg: "bg-orange-bright/15", activeBorder: "border-orange-bright/40" },
{ id: "confetti", color: "text-yellow-bright", activeBg: "bg-yellow-bright/15", activeBorder: "border-yellow-bright/40" },
{ id: "asciiquarium", color: "text-aqua-bright", activeBg: "bg-aqua-bright/15", activeBorder: "border-aqua-bright/40" },
{ id: "pipes", color: "text-blue-bright", activeBg: "bg-blue-bright/15", activeBorder: "border-blue-bright/40" },
];
// Cycle through accent colors for variant buttons
const variantColors = [
{ color: "text-yellow-bright", activeBg: "bg-yellow-bright/15", activeBorder: "border-yellow-bright/40" },
{ color: "text-orange-bright", activeBg: "bg-orange-bright/15", activeBorder: "border-orange-bright/40" },
{ color: "text-purple-bright", activeBg: "bg-purple-bright/15", activeBorder: "border-purple-bright/40" },
{ color: "text-blue-bright", activeBg: "bg-blue-bright/15", activeBorder: "border-blue-bright/40" },
{ color: "text-green-bright", activeBg: "bg-green-bright/15", activeBorder: "border-green-bright/40" },
{ color: "text-red-bright", activeBg: "bg-red-bright/15", activeBorder: "border-red-bright/40" },
{ color: "text-aqua-bright", activeBg: "bg-aqua-bright/15", activeBorder: "border-aqua-bright/40" },
];
export function SettingsSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const [currentTheme, setCurrentTheme] = useState(getStoredThemeId());
const [currentAnim, setCurrentAnim] = useState<string>("shuffle");
useEffect(() => {
setCurrentAnim(localStorage.getItem("animation") || "shuffle");
}, [open]);
const handleTheme = (id: string) => {
applyTheme(id);
setCurrentTheme(id);
};
const handleAnim = (id: string) => {
localStorage.setItem("animation", id);
document.documentElement.dataset.animation = id;
document.dispatchEvent(new CustomEvent("animation-changed", { detail: { id } }));
setCurrentAnim(id);
onClose();
};
const currentFamily = THEMES[currentTheme]?.family ?? FAMILIES[0].id;
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-[60] bg-black/50 transition-opacity duration-300 ${
open ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* Sheet */}
<div
className={`fixed left-0 right-0 bottom-0 z-[70] bg-background border-t border-foreground/10 rounded-t-2xl transition-transform duration-300 ease-out ${
open ? "translate-y-0" : "translate-y-full"
}`}
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)", maxHeight: "80vh", overflowY: "auto" }}
>
<div className="flex items-center justify-between px-5 pt-4 pb-2">
<span className="text-foreground/80 font-bold text-lg">Settings</span>
<button onClick={onClose} className="p-2 text-foreground/50">
<X size={20} />
</button>
</div>
<div className="px-5 pb-6 space-y-6">
{/* Theme */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Theme</div>
{/* Family selector */}
<div className="flex gap-2 mb-3 overflow-x-auto pb-1">
{FAMILIES.map((family) => (
<button
key={family.id}
onClick={() => handleTheme(family.default)}
className={`flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors duration-200 ${
currentFamily === family.id
? "bg-foreground/10 text-foreground/80 border-foreground/20"
: "bg-foreground/5 text-foreground/30 border-transparent hover:text-foreground/50"
}`}
>
{family.name}
</button>
))}
</div>
{/* Variant selector for current family */}
<div className="flex gap-2">
{FAMILIES.find((f) => f.id === currentFamily)?.themes.map((theme, i) => {
const style = variantColors[i % variantColors.length];
return (
<button
key={theme.id}
onClick={() => handleTheme(theme.id)}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium border transition-colors duration-200 ${
currentTheme === theme.id
? `${style.activeBg} ${style.color} ${style.activeBorder}`
: "bg-foreground/5 text-foreground/40 border-transparent hover:text-foreground/60"
}`}
>
{theme.label}
</button>
);
})}
</div>
</div>
{/* Animation */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Animation</div>
<div className="grid grid-cols-3 gap-2">
{animOptions.map((opt) => (
<button
key={opt.id}
onClick={() => handleAnim(opt.id)}
className={`py-2.5 rounded-lg text-sm font-medium border transition-colors duration-200 ${
currentAnim === opt.id
? `${opt.activeBg} ${opt.color} ${opt.activeBorder}`
: "bg-foreground/5 text-foreground/40 border-transparent hover:text-foreground/60"
}`}
>
{ANIMATION_LABELS[opt.id as AnimationId]}
</button>
))}
</div>
</div>
{/* Links */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Links</div>
<div className="grid grid-cols-2 gap-2">
{footerLinks.map((link) => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={`${link.activeBg} ${link.color} ${link.activeBorder} py-2.5 rounded-lg text-sm font-medium border text-center inline-flex items-center justify-center gap-1.5`}
>
{link.label}
<ExternalLink size={12} className="opacity-50" />
</a>
))}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -7,11 +7,10 @@ interface ProjectListProps {
export function ProjectList({ projects }: ProjectListProps) { export function ProjectList({ projects }: ProjectListProps) {
return ( return (
<div className="w-full max-w-6xl mx-auto pt-24 sm:pt-32 px-4"> <div className="w-full max-w-6xl mx-auto pt-12 md:pt-24 lg:pt-32 px-4">
<AnimateIn> <AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-blue mb-12 text-center leading-relaxed"> <h1 className="text-2xl sm:text-3xl font-bold text-blue mb-12 text-center leading-relaxed">
Here's what I've been <br className="sm:hidden" /> Here's what I've been building lately
building lately
</h1> </h1>
</AnimateIn> </AnimateIn>
@@ -20,7 +19,7 @@ export function ProjectList({ projects }: ProjectListProps) {
<AnimateIn key={project.id} delay={i * 80}> <AnimateIn key={project.id} delay={i * 80}>
<li className="group"> <li className="group">
<a href={`/projects/${project.id}`} className="block"> <a href={`/projects/${project.id}`} className="block">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-blue transition-all duration-200"> <article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-blue transition-[outline-color] duration-200">
{/* Image */} {/* Image */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-foreground/5 flex-shrink-0"> <div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-foreground/5 flex-shrink-0">
{project.data.image ? ( {project.data.image ? (

View File

@@ -1,71 +1,11 @@
import React, { useEffect, useRef, useState } from "react"; import React from "react";
import { import {
FileDown, FileDown,
Github, Github,
Linkedin, Linkedin,
Globe Globe
} from "lucide-react"; } from "lucide-react";
import { useTypewriter, useScrollVisible } from "@/components/typed-text";
// --- Typewriter hook ---
function useTypewriter(text: string, trigger: boolean, speed = 12) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
if (!trigger) return;
let i = 0;
setDisplayed("");
setDone(false);
const interval = setInterval(() => {
i++;
setDisplayed(text.slice(0, i));
if (i >= text.length) {
setDone(true);
clearInterval(interval);
}
}, speed);
return () => clearInterval(interval);
}, [trigger, text, speed]);
return { displayed, done };
}
// --- Visibility hook ---
function useScrollVisible(threshold = 0.1) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, visible };
}
// --- Section fade-in --- // --- Section fade-in ---
@@ -75,11 +15,12 @@ function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: n
return ( return (
<div <div
ref={ref} ref={ref}
className="transition-all duration-700 ease-out" className="transition-[opacity,transform] duration-700 ease-out"
style={{ style={{
transitionDelay: `${delay}ms`, transitionDelay: `${delay}ms`,
willChange: "transform, opacity",
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)", transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
}} }}
> >
{children} {children}
@@ -91,7 +32,7 @@ function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: n
function TypedSection({ function TypedSection({
heading, heading,
headingClass = "text-3xl font-bold text-yellow-bright", headingClass = "text-2xl md:text-3xl font-bold text-yellow-bright",
children, children,
}: { }: {
heading: string; heading: string;
@@ -108,10 +49,11 @@ function TypedSection({
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>} {visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3> </h3>
<div <div
className="transition-all duration-500 ease-out" className="transition-[opacity,transform] duration-500 ease-out"
style={{ style={{
willChange: "transform, opacity",
opacity: done ? 1 : 0, opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)", transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
}} }}
> >
{children} {children}
@@ -128,11 +70,12 @@ function SkillTags({ skills, trigger }: { skills: string[]; trigger: boolean })
{skills.map((skill, i) => ( {skills.map((skill, i) => (
<span <span
key={i} key={i}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-all duration-500 ease-out" className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-[opacity,transform] duration-500 ease-out"
style={{ style={{
transitionDelay: `${i * 60}ms`, transitionDelay: `${i * 60}ms`,
willChange: "transform, opacity",
opacity: trigger ? 1 : 0, opacity: trigger ? 1 : 0,
transform: trigger ? "translateY(0) scale(1)" : "translateY(12px) scale(0.95)", transform: trigger ? "translate3d(0,0,0) scale(1)" : "translate3d(0,12px,0) scale(0.95)",
}} }}
> >
{skill} {skill}
@@ -212,19 +155,19 @@ const Resume = () => {
}; };
return ( return (
<div className="max-w-4xl mx-auto px-6 md:px-8 pt-24 pb-16"> <div className="max-w-4xl mx-auto px-4 md:px-8 pt-16 md:pt-24 pb-16">
<div className="space-y-16"> <div className="space-y-16">
{/* Header */} {/* Header */}
<header className="text-center space-y-6"> <header className="text-center space-y-6">
<Section> <Section>
<h1 className="text-6xl font-bold text-aqua-bright">{resumeData.name}</h1> <h1 className="text-3xl md:text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
</Section> </Section>
<Section delay={150}> <Section delay={150}>
<h2 className="text-3xl text-foreground/80">{resumeData.title}</h2> <h2 className="text-xl md:text-3xl text-foreground/80">{resumeData.title}</h2>
</Section> </Section>
<Section delay={300}> <Section delay={300}>
<div className="flex flex-col md:flex-row justify-center gap-4 md:gap-6 text-foreground/60 text-lg"> <div className="flex flex-col md:flex-row justify-center gap-2 md:gap-6 text-foreground/60 text-sm md:text-lg">
<a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200"> <a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200 break-all md:break-normal">
{resumeData.contact.email} {resumeData.contact.email}
</a> </a>
<span className="hidden md:inline"></span> <span className="hidden md:inline"></span>
@@ -236,7 +179,7 @@ const Resume = () => {
</div> </div>
</Section> </Section>
<Section delay={450}> <Section delay={450}>
<div className="flex justify-center items-center gap-6 text-lg"> <div className="flex justify-center items-center gap-4 md:gap-6 text-base md:text-lg">
<a href={`https://${resumeData.contact.github}`} <a href={`https://${resumeData.contact.github}`}
target="_blank" target="_blank"
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2" className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
@@ -264,7 +207,7 @@ const Resume = () => {
{/* Summary */} {/* Summary */}
<TypedSection heading="Professional Summary"> <TypedSection heading="Professional Summary">
<p className="text-xl leading-relaxed">{resumeData.summary}</p> <p className="text-base md:text-xl leading-relaxed">{resumeData.summary}</p>
</TypedSection> </TypedSection>
{/* Experience */} {/* Experience */}
@@ -275,14 +218,14 @@ const Resume = () => {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2"> <div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div> <div>
<h4 className="text-2xl font-semibold text-green-bright">{exp.title}</h4> <h4 className="text-xl md:text-2xl font-semibold text-green-bright">{exp.title}</h4>
<div className="text-foreground/60 text-lg">{exp.company} - {exp.location}</div> <div className="text-foreground/60 text-base md:text-lg">{exp.company} - {exp.location}</div>
</div> </div>
<div className="text-foreground/60 text-lg font-medium">{exp.period}</div> <div className="text-foreground/60 text-sm md:text-lg font-medium">{exp.period}</div>
</div> </div>
<ul className="list-disc pl-6 space-y-3"> <ul className="list-disc pl-6 space-y-3">
{exp.achievements.map((a, i) => ( {exp.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li> <li key={i} className="text-base md:text-lg leading-relaxed">{a}</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -300,7 +243,7 @@ const Resume = () => {
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2"> <div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h4 className="text-2xl font-semibold text-green-bright">{project.title}</h4> <h4 className="text-xl md:text-2xl font-semibold text-green-bright">{project.title}</h4>
{project.url && ( {project.url && (
<a <a
href={project.url} href={project.url}
@@ -312,27 +255,27 @@ const Resume = () => {
</a> </a>
)} )}
</div> </div>
<div className="text-foreground/60 text-lg">{project.type}</div> <div className="text-foreground/60 text-base md:text-lg">{project.type}</div>
</div> </div>
<div className="text-foreground/60 text-lg font-medium">Since {project.startDate}</div> <div className="text-foreground/60 text-sm md:text-lg font-medium">Since {project.startDate}</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{project.responsibilities && ( {project.responsibilities && (
<div> <div>
<h5 className="text-lg text-aqua font-semibold mb-2">Responsibilities</h5> <h5 className="text-base md:text-lg text-aqua font-semibold mb-2">Responsibilities</h5>
<ul className="list-disc pl-6 space-y-3"> <ul className="list-disc pl-6 space-y-3">
{project.responsibilities.map((r, i) => ( {project.responsibilities.map((r, i) => (
<li key={i} className="text-lg leading-relaxed">{r}</li> <li key={i} className="text-base md:text-lg leading-relaxed">{r}</li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
{project.achievements && ( {project.achievements && (
<div> <div>
<h5 className="text-lg text-aqua font-semibold mb-2">Key Achievements</h5> <h5 className="text-base md:text-lg text-aqua font-semibold mb-2">Key Achievements</h5>
<ul className="list-disc pl-6 space-y-3"> <ul className="list-disc pl-6 space-y-3">
{project.achievements.map((a, i) => ( {project.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li> <li key={i} className="text-base md:text-lg leading-relaxed">{a}</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -344,32 +287,6 @@ const Resume = () => {
</div> </div>
</TypedSection> </TypedSection>
{/* Education */}
<TypedSection heading="Education">
<div className="space-y-8">
{resumeData.education.map((edu, index) => (
<Section key={index}>
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<h4 className="text-2xl font-semibold text-green-bright">{edu.degree}</h4>
<div className="text-foreground/60 text-lg">{edu.school} - {edu.location}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">{edu.period}</div>
</div>
{edu.achievements.length > 0 && (
<ul className="list-disc pl-6 space-y-3">
{edu.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
))}
</ul>
)}
</div>
</Section>
))}
</div>
</TypedSection>
{/* Skills */} {/* Skills */}
<SkillsSection /> <SkillsSection />
</div> </div>
@@ -385,24 +302,25 @@ function SkillsSection() {
return ( return (
<div ref={ref} className="space-y-8"> <div ref={ref} className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}> <h3 className="text-2xl md:text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"} {visible ? displayed : "\u00A0"}
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>} {visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3> </h3>
<div <div
className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-all duration-500 ease-out" className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-[opacity,transform] duration-500 ease-out"
style={{ style={{
willChange: "transform, opacity",
opacity: done ? 1 : 0, opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)", transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
}} }}
> >
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Technical Skills</h4> <h4 className="text-xl md:text-2xl font-semibold text-green-bright">Technical Skills</h4>
<SkillTags skills={resumeData.skills.technical} trigger={done} /> <SkillTags skills={resumeData.skills.technical} trigger={done} />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Soft Skills</h4> <h4 className="text-xl md:text-2xl font-semibold text-green-bright">Soft Skills</h4>
<SkillTags skills={resumeData.skills.soft} trigger={done} /> <SkillTags skills={resumeData.skills.soft} trigger={done} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
const HEADING_TAGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
function typeInHeading(el: HTMLElement): Promise<void> {
return new Promise((resolve) => {
const text = el.textContent || "";
const textLength = text.length;
if (textLength === 0) { resolve(); return; }
const speed = Math.max(8, Math.min(25, 600 / textLength));
const originalHTML = el.innerHTML;
// Wrap each character in a span with opacity:0
// The full text stays in the DOM so layout/wrapping is correct from the start
el.innerHTML = "";
const chars: HTMLSpanElement[] = [];
for (const char of text) {
const span = document.createElement("span");
span.textContent = char;
span.style.opacity = "0";
chars.push(span);
el.appendChild(span);
}
el.style.opacity = "1";
el.style.transform = "translate3d(0,0,0)";
let i = 0;
const step = () => {
if (i >= chars.length) {
// Restore original HTML to clean up spans
el.innerHTML = originalHTML;
resolve();
return;
}
chars[i].style.opacity = "1";
i++;
setTimeout(step, speed);
};
step();
});
}
export function StreamContent({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = ref.current;
if (!container || prefersReducedMotion()) {
if (container) container.classList.remove("stream-hidden");
return;
}
const prose = container.querySelector(".prose") || container;
const blocks = Array.from(prose.querySelectorAll(":scope > *")) as HTMLElement[];
if (blocks.length === 0) {
container.classList.remove("stream-hidden");
return;
}
// Set inline opacity:0 on every block BEFORE removing the CSS class
// This prevents the flash of visible content between class removal and style application
blocks.forEach((el) => {
el.style.opacity = "0";
el.style.transform = "translate3d(0,16px,0)";
});
// Now safe to remove the CSS class — inline styles keep everything hidden
container.classList.remove("stream-hidden");
// Add transition properties in the next frame so the initial state is set first
requestAnimationFrame(() => {
blocks.forEach((el) => {
el.style.transition = "opacity 0.6s ease-out, transform 0.6s ease-out";
el.style.willChange = "transform, opacity";
});
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement;
observer.unobserve(el);
if (HEADING_TAGS.has(el.tagName)) {
typeInHeading(el);
} else {
el.style.opacity = "1";
el.style.transform = "translate3d(0,0,0)";
}
}
});
},
{ threshold: 0.05 }
);
blocks.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className="stream-hidden">
{children}
</div>
);
}

View File

@@ -1,31 +1,35 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect } from "react";
import { getStoredThemeId, getNextTheme, applyTheme } from "@/lib/themes/engine"; import { THEMES, FAMILIES } from "@/lib/themes";
import { getStoredThemeId, getNextFamily, getNextVariant, applyTheme } from "@/lib/themes/engine";
const FADE_DURATION = 300; const FADE_DURATION = 300;
const LABELS: Record<string, string> = {
darkbox: "classic",
"darkbox-retro": "retro",
"darkbox-dim": "dim",
};
export default function ThemeSwitcher() { export default function ThemeSwitcher() {
const [hovering, setHovering] = useState(false); const [hovering, setHovering] = useState(false);
const [currentLabel, setCurrentLabel] = useState(""); const [familyName, setFamilyName] = useState("");
const [variantLabel, setVariantLabel] = useState("");
const maskRef = useRef<HTMLDivElement>(null); const maskRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false); const animatingRef = useRef(false);
const committedRef = useRef(""); const committedRef = useRef("");
function syncLabels(id: string) {
const theme = THEMES[id];
if (!theme) return;
const family = FAMILIES.find((f) => f.id === theme.family);
setFamilyName(family?.name.toLowerCase() ?? theme.family);
setVariantLabel(theme.label);
}
useEffect(() => { useEffect(() => {
committedRef.current = getStoredThemeId(); committedRef.current = getStoredThemeId();
setCurrentLabel(LABELS[committedRef.current] ?? ""); syncLabels(committedRef.current);
const handleSwap = () => { const handleSwap = () => {
const id = getStoredThemeId(); const id = getStoredThemeId();
applyTheme(id); applyTheme(id);
committedRef.current = id; committedRef.current = id;
setCurrentLabel(LABELS[id] ?? ""); syncLabels(id);
}; };
document.addEventListener("astro:after-swap", handleSwap); document.addEventListener("astro:after-swap", handleSwap);
@@ -34,7 +38,7 @@ export default function ThemeSwitcher() {
}; };
}, []); }, []);
const handleClick = () => { function animateTransition(nextId: string) {
if (animatingRef.current) return; if (animatingRef.current) return;
animatingRef.current = true; animatingRef.current = true;
@@ -51,10 +55,9 @@ export default function ThemeSwitcher() {
mask.style.visibility = "visible"; mask.style.visibility = "visible";
mask.style.transition = "none"; mask.style.transition = "none";
const next = getNextTheme(committedRef.current); applyTheme(nextId);
applyTheme(next.id); committedRef.current = nextId;
committedRef.current = next.id; syncLabels(nextId);
setCurrentLabel(LABELS[next.id] ?? "");
mask.offsetHeight; mask.offsetHeight;
@@ -69,22 +72,44 @@ export default function ThemeSwitcher() {
}; };
mask.addEventListener("transitionend", onEnd); mask.addEventListener("transitionend", onEnd);
}
const handleFamilyClick = (e: React.MouseEvent) => {
e.stopPropagation();
const next = getNextFamily(committedRef.current);
animateTransition(next.id);
};
const handleVariantClick = (e: React.MouseEvent) => {
e.stopPropagation();
const next = getNextVariant(committedRef.current);
animateTransition(next.id);
}; };
return ( return (
<> <>
<div <div
className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden md:block" className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden desk:block"
onMouseEnter={() => setHovering(true)} onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)} onMouseLeave={() => setHovering(false)}
onClick={handleClick}
style={{ cursor: "pointer" }}
> >
<span <span
className="text-foreground font-bold text-sm select-none transition-opacity duration-200" className="text-foreground font-bold text-sm select-none transition-opacity duration-200 inline-flex items-center gap-0"
style={{ opacity: hovering ? 0.8 : 0.15 }} style={{ opacity: hovering ? 0.8 : 0.15 }}
> >
{currentLabel} <button
onClick={handleFamilyClick}
className="hover:text-yellow-bright transition-colors duration-150 cursor-pointer bg-transparent border-none p-0 font-bold text-sm text-inherit"
>
{familyName}
</button>
<span className="mx-1 opacity-40">·</span>
<button
onClick={handleVariantClick}
className="hover:text-blue-bright transition-colors duration-150 cursor-pointer bg-transparent border-none p-0 font-bold text-sm text-inherit"
>
{variantLabel}
</button>
</span> </span>
</div> </div>

View File

@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
export function useTypewriter(text: string, trigger: boolean, speed = 12) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
if (!trigger) return;
if (prefersReducedMotion()) {
setDisplayed(text);
setDone(true);
return;
}
let i = 0;
setDisplayed("");
setDone(false);
const interval = setInterval(() => {
i++;
setDisplayed(text.slice(0, i));
if (i >= text.length) {
setDone(true);
clearInterval(interval);
}
}, speed);
return () => clearInterval(interval);
}, [trigger, text, speed]);
return { displayed, done };
}
export function useScrollVisible(threshold = 0.1) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, visible };
}
interface TypedTextProps {
text: string;
as?: "h1" | "h2" | "h3" | "h4" | "span" | "p";
className?: string;
speed?: number;
cursor?: boolean;
}
export function TypedText({
text,
as: Tag = "span",
className = "",
speed = 12,
cursor = true,
}: TypedTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
const tagRef = useRef<HTMLElement>(null);
const { ref, visible } = useScrollVisible();
const [done, setDone] = useState(false);
const [started, setStarted] = useState(false);
useEffect(() => {
if (!visible || started) return;
setStarted(true);
if (prefersReducedMotion()) {
setDone(true);
return;
}
const el = tagRef.current;
if (!el) return;
// Wrap each character in an invisible span — layout stays correct
el.textContent = "";
const chars: HTMLSpanElement[] = [];
for (const char of text) {
const span = document.createElement("span");
span.textContent = char;
span.style.opacity = "0";
chars.push(span);
el.appendChild(span);
}
const textLength = text.length;
const charSpeed = Math.max(8, Math.min(speed, 600 / textLength));
let i = 0;
const step = () => {
if (i >= chars.length) {
el.textContent = text;
setDone(true);
return;
}
chars[i].style.opacity = "1";
i++;
setTimeout(step, charSpeed);
};
step();
}, [visible, started, text, speed]);
return (
<div ref={ref}>
<Tag
ref={tagRef as any}
className={className}
style={{ minHeight: "1.2em" }}
>
{!started ? "\u00A0" : done ? text : null}
</Tag>
{cursor && started && !done && (
<span className="animate-pulse text-foreground/40">|</span>
)}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher"; import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher"; import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics"; import VercelAnalytics from "@/components/analytics";
import MobileNav from "@/components/mobile-nav";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader"; import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader"; import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -38,25 +39,25 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
defaultTransition={false} defaultTransition={false}
handleFocus={false} handleFocus={false}
/> />
<style> <script is:inline>
::view-transition-new(:root) { if (window.innerWidth < 1024 || navigator.maxTouchPoints > 0) {
animation: none; document.addEventListener('click', function(e) {
var a = e.target.closest('a[href]');
if (a && a.href && !a.target && a.origin === location.origin) {
e.preventDefault();
e.stopPropagation();
window.location.href = a.href;
} }
::view-transition-old(:root) { }, true);
animation: 90ms ease-out both fade-out;
} }
@keyframes fade-out { </script>
from { opacity: 1; }
to { opacity: 0; }
}
</style>
<script is:inline set:html={THEME_LOADER_SCRIPT} /> <script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} /> <script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head> </head>
<body class="bg-background text-foreground min-h-screen flex flex-col"> <body class="bg-background text-foreground min-h-screen flex flex-col">
<Header client:load /> <Header client:load />
<main class="flex-1 flex flex-col"> <main class="flex-1 flex flex-col">
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1"> <div class="max-w-5xl mx-auto pt-2 lg:pt-12 px-4 py-4 lg:py-8 pb-20 lg:pb-8 flex-1 relative z-10">
<Background layout="content" position="right" client:only="react" transition:persist /> <Background layout="content" position="right" client:only="react" transition:persist />
<div> <div>
<slot /> <slot />
@@ -70,6 +71,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<ThemeSwitcher client:only="react" transition:persist /> <ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist /> <AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load /> <VercelAnalytics client:load />
<MobileNav client:load />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} /> <script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} /> <script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body> </body>

View File

@@ -9,6 +9,7 @@ import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher"; import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher"; import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics"; import VercelAnalytics from "@/components/analytics";
import MobileNav from "@/components/mobile-nav";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader"; import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader"; import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -37,12 +38,24 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<link rel="icon" type="image/jpeg" href="/me.jpeg" /> <link rel="icon" type="image/jpeg" href="/me.jpeg" />
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />
<ClientRouter /> <ClientRouter />
<script is:inline>
if (window.innerWidth < 1024 || navigator.maxTouchPoints > 0) {
document.addEventListener('click', function(e) {
var a = e.target.closest('a[href]');
if (a && a.href && !a.target && a.origin === location.origin) {
e.preventDefault();
e.stopPropagation();
window.location.href = a.href;
}
}, true);
}
</script>
<script is:inline set:html={THEME_LOADER_SCRIPT} /> <script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} /> <script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head> </head>
<body class="bg-background text-foreground"> <body class="bg-background text-foreground overflow-hidden h-screen">
<Header client:load transparent /> <Header client:load transparent />
<main transition:animate="fade"> <main>
<Background layout="index" client:only="react" transition:persist /> <Background layout="index" client:only="react" transition:persist />
<slot /> <slot />
</main> </main>
@@ -50,6 +63,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<ThemeSwitcher client:only="react" transition:persist /> <ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist /> <AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load /> <VercelAnalytics client:load />
<MobileNav client:load transparent />
<script is:inline set:html={THEME_NAV_SCRIPT} /> <script is:inline set:html={THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} /> <script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body> </body>

View File

@@ -8,6 +8,7 @@ import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher"; import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher"; import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics"; import VercelAnalytics from "@/components/analytics";
import MobileNav from "@/components/mobile-nav";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader"; import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader"; import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -38,24 +39,24 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
defaultTransition={false} defaultTransition={false}
handleFocus={false} handleFocus={false}
/> />
<style> <script is:inline>
::view-transition-new(:root) { if (window.innerWidth < 1024 || navigator.maxTouchPoints > 0) {
animation: none; document.addEventListener('click', function(e) {
var a = e.target.closest('a[href]');
if (a && a.href && !a.target && a.origin === location.origin) {
e.preventDefault();
e.stopPropagation();
window.location.href = a.href;
} }
::view-transition-old(:root) { }, true);
animation: 90ms ease-out both fade-out;
} }
@keyframes fade-out { </script>
from { opacity: 1; }
to { opacity: 0; }
}
</style>
<script is:inline set:html={THEME_LOADER_SCRIPT} /> <script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} /> <script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head> </head>
<body class="bg-background text-foreground min-h-screen flex flex-col"> <body class="bg-background text-foreground min-h-screen flex flex-col">
<main class="flex-1 flex flex-col"> <main class="flex-1 flex flex-col">
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1"> <div class="max-w-5xl mx-auto pt-2 lg:pt-12 px-4 py-4 lg:py-8 flex-1 relative z-10">
<Background layout="content" position="right" client:only="react" transition:persist /> <Background layout="content" position="right" client:only="react" transition:persist />
<div> <div>
<slot /> <slot />
@@ -66,6 +67,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<ThemeSwitcher client:only="react" transition:persist /> <ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist /> <AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load /> <VercelAnalytics client:load />
<MobileNav client:load />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} /> <script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} /> <script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body> </body>

View File

@@ -0,0 +1,4 @@
export function prefersReducedMotion(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

View File

@@ -1,4 +1,4 @@
import { THEMES, DEFAULT_THEME_ID } from "@/lib/themes"; import { THEMES, FAMILIES, DEFAULT_THEME_ID } from "@/lib/themes";
import { CSS_PROPS } from "@/lib/themes/props"; import { CSS_PROPS } from "@/lib/themes/props";
import type { Theme } from "@/lib/themes/types"; import type { Theme } from "@/lib/themes/types";
@@ -11,6 +11,26 @@ export function saveTheme(id: string): void {
localStorage.setItem("theme", id); localStorage.setItem("theme", id);
} }
/** Cycle to the next theme family, jumping to its default variant. */
export function getNextFamily(currentId: string): Theme {
const current = THEMES[currentId];
const familyId = current?.family ?? FAMILIES[0].id;
const idx = FAMILIES.findIndex((f) => f.id === familyId);
const next = FAMILIES[(idx + 1) % FAMILIES.length];
return THEMES[next.default];
}
/** Cycle to the next variant within the current family. */
export function getNextVariant(currentId: string): Theme {
const current = THEMES[currentId];
if (!current) return Object.values(THEMES)[0];
const family = FAMILIES.find((f) => f.id === current.family);
if (!family) return current;
const idx = family.themes.findIndex((t) => t.id === currentId);
return family.themes[(idx + 1) % family.themes.length];
}
// Keep for backward compat (cycles all themes linearly)
export function getNextTheme(currentId: string): Theme { export function getNextTheme(currentId: string): Theme {
const list = Object.values(THEMES); const list = Object.values(THEMES);
const idx = list.findIndex((t) => t.id === currentId); const idx = list.findIndex((t) => t.id === currentId);
@@ -27,7 +47,6 @@ export function previewTheme(id: string): void {
root.style.setProperty(prop, theme.colors[key]); root.style.setProperty(prop, theme.colors[key]);
} }
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } })); document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
} }

View File

@@ -0,0 +1,94 @@
import type { Theme, ThemeFamily } from "../types";
// Catppuccin — warm pastel palette. Each flavor has its own accent colors.
const mocha: Theme = {
id: "catppuccin-mocha",
family: "catppuccin",
label: "mocha",
name: "Catppuccin Mocha",
type: "dark",
colors: {
background: "30 30 46",
foreground: "205 214 244",
red: "243 139 168", redBright: "246 166 190",
orange: "250 179 135", orangeBright: "252 200 170",
green: "166 227 161", greenBright: "190 236 186",
yellow: "249 226 175", yellowBright: "251 235 200",
blue: "137 180 250", blueBright: "172 202 251",
purple: "203 166 247", purpleBright: "220 192 249",
aqua: "148 226 213", aquaBright: "180 236 228",
surface: "49 50 68",
},
canvasPalette: [[243,139,168],[166,227,161],[249,226,175],[137,180,250],[203,166,247],[148,226,213]],
};
const macchiato: Theme = {
id: "catppuccin-macchiato",
family: "catppuccin",
label: "macchiato",
name: "Catppuccin Macchiato",
type: "dark",
colors: {
background: "36 39 58",
foreground: "202 211 245",
red: "237 135 150", redBright: "242 167 180",
orange: "245 169 127", orangeBright: "248 192 165",
green: "166 218 149", greenBright: "190 232 180",
yellow: "238 212 159", yellowBright: "243 226 190",
blue: "138 173 244", blueBright: "170 198 247",
purple: "198 160 246", purpleBright: "218 190 249",
aqua: "139 213 202", aquaBright: "175 228 220",
surface: "54 58 79",
},
canvasPalette: [[237,135,150],[166,218,149],[238,212,159],[138,173,244],[198,160,246],[139,213,202]],
};
const frappe: Theme = {
id: "catppuccin-frappe",
family: "catppuccin",
label: "frappé",
name: "Catppuccin Frappé",
type: "dark",
colors: {
background: "48 52 70",
foreground: "198 208 245",
red: "231 130 132", redBright: "238 160 162",
orange: "239 159 118", orangeBright: "244 185 158",
green: "166 209 137", greenBright: "190 222 172",
yellow: "229 200 144", yellowBright: "237 216 178",
blue: "140 170 238", blueBright: "172 196 242",
purple: "202 158 230", purpleBright: "218 186 238",
aqua: "129 200 190", aquaBright: "168 216 208",
surface: "65 69 89",
},
canvasPalette: [[231,130,132],[166,209,137],[229,200,144],[140,170,238],[202,158,230],[129,200,190]],
};
const latte: Theme = {
id: "catppuccin-latte",
family: "catppuccin",
label: "latte",
name: "Catppuccin Latte",
type: "light",
colors: {
background: "239 241 245",
foreground: "76 79 105",
red: "210 15 57", redBright: "228 50 82",
orange: "254 100 11", orangeBright: "254 135 60",
green: "64 160 43", greenBright: "85 180 65",
yellow: "223 142 29", yellowBright: "236 170 60",
blue: "30 102 245", blueBright: "70 130 248",
purple: "136 57 239", purpleBright: "162 95 244",
aqua: "23 146 153", aquaBright: "55 168 175",
surface: "204 208 218",
},
canvasPalette: [[210,15,57],[64,160,43],[223,142,29],[30,102,245],[136,57,239],[23,146,153]],
};
export const catppuccin: ThemeFamily = {
id: "catppuccin",
name: "Catppuccin",
themes: [mocha, macchiato, frappe, latte],
default: "catppuccin-mocha",
};

View File

@@ -0,0 +1,71 @@
import type { Theme, ThemeFamily } from "../types";
const classic: Theme = {
id: "darkbox",
family: "darkbox",
label: "classic",
name: "Darkbox Classic",
type: "dark",
colors: {
background: "0 0 0",
foreground: "235 219 178",
red: "251 73 52", redBright: "255 110 85",
orange: "254 128 25", orangeBright: "255 165 65",
green: "184 187 38", greenBright: "210 215 70",
yellow: "250 189 47", yellowBright: "255 215 85",
blue: "131 165 152", blueBright: "165 195 180",
purple: "211 134 155", purpleBright: "235 165 180",
aqua: "142 192 124", aquaBright: "175 220 160",
surface: "60 56 54",
},
canvasPalette: [[251,73,52],[184,187,38],[250,189,47],[131,165,152],[211,134,155],[142,192,124]],
};
const retro: Theme = {
id: "darkbox-retro",
family: "darkbox",
label: "retro",
name: "Darkbox Retro",
type: "dark",
colors: {
background: "0 0 0",
foreground: "189 174 147",
red: "204 36 29", redBright: "251 73 52",
orange: "214 93 14", orangeBright: "254 128 25",
green: "152 151 26", greenBright: "184 187 38",
yellow: "215 153 33", yellowBright: "250 189 47",
blue: "69 133 136", blueBright: "131 165 152",
purple: "177 98 134", purpleBright: "211 134 155",
aqua: "104 157 106", aquaBright: "142 192 124",
surface: "60 56 54",
},
canvasPalette: [[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106]],
};
const dim: Theme = {
id: "darkbox-dim",
family: "darkbox",
label: "dim",
name: "Darkbox Dim",
type: "dark",
colors: {
background: "0 0 0",
foreground: "168 153 132",
red: "157 0 6", redBright: "204 36 29",
orange: "175 58 3", orangeBright: "214 93 14",
green: "121 116 14", greenBright: "152 151 26",
yellow: "181 118 20", yellowBright: "215 153 33",
blue: "7 102 120", blueBright: "69 133 136",
purple: "143 63 113", purpleBright: "177 98 134",
aqua: "66 123 88", aquaBright: "104 157 106",
surface: "60 56 54",
},
canvasPalette: [[157,0,6],[121,116,14],[181,118,20],[7,102,120],[143,63,113],[66,123,88]],
};
export const darkbox: ThemeFamily = {
id: "darkbox",
name: "Darkbox",
themes: [classic, retro, dim],
default: "darkbox-retro",
};

View File

@@ -0,0 +1,70 @@
import type { Theme, ThemeFamily } from "../types";
// Everforest — warm green-toned palette. Same accents across
// hard/medium/soft, only backgrounds change.
const accents = {
red: "230 126 128", redBright: "240 155 157",
orange: "230 152 117", orangeBright: "240 177 150",
green: "167 192 128", greenBright: "190 210 160",
yellow: "219 188 127", yellowBright: "233 208 163",
blue: "127 187 179", blueBright: "160 208 200",
purple: "214 153 182", purpleBright: "228 180 202",
aqua: "131 192 146", aquaBright: "162 212 176",
} as const;
const palette: [number, number, number][] = [
[230,126,128],[167,192,128],[219,188,127],[127,187,179],[214,153,182],[131,192,146],
];
const hard: Theme = {
id: "everforest-hard",
family: "everforest",
label: "hard",
name: "Everforest Hard",
type: "dark",
colors: {
background: "39 46 51",
foreground: "211 198 170",
...accents,
surface: "55 65 69",
},
canvasPalette: palette,
};
const medium: Theme = {
id: "everforest-medium",
family: "everforest",
label: "medium",
name: "Everforest Medium",
type: "dark",
colors: {
background: "45 53 59",
foreground: "211 198 170",
...accents,
surface: "61 72 77",
},
canvasPalette: palette,
};
const soft: Theme = {
id: "everforest-soft",
family: "everforest",
label: "soft",
name: "Everforest Soft",
type: "dark",
colors: {
background: "52 63 68",
foreground: "211 198 170",
...accents,
surface: "68 80 85",
},
canvasPalette: palette,
};
export const everforest: ThemeFamily = {
id: "everforest",
name: "Everforest",
themes: [hard, medium, soft],
default: "everforest-medium",
};

View File

@@ -0,0 +1,74 @@
import type { Theme, ThemeFamily } from "../types";
// GitHub — the familiar look from github.com.
// Dark (default dark), Dark Dimmed (softer), Light (classic white).
const dark: Theme = {
id: "github-dark",
family: "github",
label: "dark",
name: "GitHub Dark",
type: "dark",
colors: {
background: "13 17 23",
foreground: "230 237 243",
red: "255 123 114", redBright: "255 166 158",
orange: "217 156 90", orangeBright: "240 183 122",
green: "126 231 135", greenBright: "168 242 175",
yellow: "224 194 133", yellowBright: "240 215 168",
blue: "121 192 255", blueBright: "165 214 255",
purple: "210 153 255", purpleBright: "226 187 255",
aqua: "118 214 198", aquaBright: "160 230 218",
surface: "22 27 34",
},
canvasPalette: [[255,123,114],[126,231,135],[224,194,133],[121,192,255],[210,153,255],[118,214,198]],
};
const dimmed: Theme = {
id: "github-dimmed",
family: "github",
label: "dimmed",
name: "GitHub Dark Dimmed",
type: "dark",
colors: {
background: "34 39 46",
foreground: "173 186 199",
red: "255 123 114", redBright: "255 166 158",
orange: "219 171 127", orangeBright: "236 195 158",
green: "87 196 106", greenBright: "130 218 144",
yellow: "224 194 133", yellowBright: "240 215 168",
blue: "108 182 255", blueBright: "152 206 255",
purple: "195 145 243", purpleBright: "218 180 248",
aqua: "96 200 182", aquaBright: "140 220 208",
surface: "45 51 59",
},
canvasPalette: [[255,123,114],[87,196,106],[224,194,133],[108,182,255],[195,145,243],[96,200,182]],
};
const light: Theme = {
id: "github-light",
family: "github",
label: "light",
name: "GitHub Light",
type: "light",
colors: {
background: "255 255 255",
foreground: "31 35 40",
red: "207 34 46", redBright: "227 70 80",
orange: "191 135 0", orangeBright: "212 160 30",
green: "26 127 55", greenBright: "45 155 78",
yellow: "159 115 0", yellowBright: "182 140 22",
blue: "9 105 218", blueBright: "48 132 238",
purple: "130 80 223", purpleBright: "158 112 238",
aqua: "18 130 140", aquaBright: "42 158 168",
surface: "246 248 250",
},
canvasPalette: [[207,34,46],[26,127,55],[159,115,0],[9,105,218],[130,80,223],[18,130,140]],
};
export const github: ThemeFamily = {
id: "github",
name: "GitHub",
themes: [dark, dimmed, light],
default: "github-dark",
};

View File

@@ -0,0 +1,70 @@
import type { Theme, ThemeFamily } from "../types";
// Original gruvbox palette — same accents across hard/medium/soft,
// only background and surface change.
const accents = {
red: "204 36 29", redBright: "251 73 52",
orange: "214 93 14", orangeBright: "254 128 25",
green: "152 151 26", greenBright: "184 187 38",
yellow: "215 153 33", yellowBright: "250 189 47",
blue: "69 133 136", blueBright: "131 165 152",
purple: "177 98 134", purpleBright: "211 134 155",
aqua: "104 157 106", aquaBright: "142 192 124",
} as const;
const palette: [number, number, number][] = [
[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106],
];
const hard: Theme = {
id: "gruvbox-hard",
family: "gruvbox",
label: "hard",
name: "Gruvbox Hard",
type: "dark",
colors: {
background: "29 32 33",
foreground: "235 219 178",
...accents,
surface: "60 56 54",
},
canvasPalette: palette,
};
const medium: Theme = {
id: "gruvbox-medium",
family: "gruvbox",
label: "medium",
name: "Gruvbox Medium",
type: "dark",
colors: {
background: "40 40 40",
foreground: "235 219 178",
...accents,
surface: "60 56 54",
},
canvasPalette: palette,
};
const soft: Theme = {
id: "gruvbox-soft",
family: "gruvbox",
label: "soft",
name: "Gruvbox Soft",
type: "dark",
colors: {
background: "50 48 47",
foreground: "235 219 178",
...accents,
surface: "80 73 69",
},
canvasPalette: palette,
};
export const gruvbox: ThemeFamily = {
id: "gruvbox",
name: "Gruvbox",
themes: [hard, medium, soft],
default: "gruvbox-medium",
};

View File

@@ -0,0 +1,74 @@
import type { Theme, ThemeFamily } from "../types";
// Kanagawa — inspired by Katsushika Hokusai's paintings.
// Each variant has its own distinct palette.
const wave: Theme = {
id: "kanagawa-wave",
family: "kanagawa",
label: "wave",
name: "Kanagawa Wave",
type: "dark",
colors: {
background: "31 31 40",
foreground: "220 215 186",
red: "195 64 67", redBright: "255 93 98",
orange: "255 160 102", orangeBright: "255 184 140",
green: "118 148 106", greenBright: "152 187 108",
yellow: "192 163 110", yellowBright: "230 195 132",
blue: "126 156 216", blueBright: "127 180 202",
purple: "149 127 184", purpleBright: "175 158 206",
aqua: "106 149 137", aquaBright: "122 168 159",
surface: "42 42 55",
},
canvasPalette: [[195,64,67],[118,148,106],[192,163,110],[126,156,216],[149,127,184],[106,149,137]],
};
const dragon: Theme = {
id: "kanagawa-dragon",
family: "kanagawa",
label: "dragon",
name: "Kanagawa Dragon",
type: "dark",
colors: {
background: "24 22 22",
foreground: "197 201 197",
red: "196 116 110", redBright: "195 64 67",
orange: "182 146 123", orangeBright: "255 160 102",
green: "135 169 135", greenBright: "152 187 108",
yellow: "196 178 138", yellowBright: "230 195 132",
blue: "139 164 176", blueBright: "126 156 216",
purple: "162 146 163", purpleBright: "149 127 184",
aqua: "142 164 162", aquaBright: "122 168 159",
surface: "40 39 39",
},
canvasPalette: [[196,116,110],[135,169,135],[196,178,138],[139,164,176],[162,146,163],[142,164,162]],
};
const lotus: Theme = {
id: "kanagawa-lotus",
family: "kanagawa",
label: "lotus",
name: "Kanagawa Lotus",
type: "light",
colors: {
background: "242 236 188",
foreground: "84 84 100",
red: "200 64 83", redBright: "215 71 75",
orange: "233 138 0", orangeBright: "245 160 30",
green: "111 137 78", greenBright: "130 158 98",
yellow: "222 152 0", yellowBright: "240 178 40",
blue: "77 105 155", blueBright: "93 87 163",
purple: "98 76 131", purpleBright: "118 100 155",
aqua: "89 123 117", aquaBright: "110 145 138",
surface: "228 215 148",
},
canvasPalette: [[200,64,83],[111,137,78],[222,152,0],[77,105,155],[98,76,131],[89,123,117]],
};
export const kanagawa: ThemeFamily = {
id: "kanagawa",
name: "Kanagawa",
themes: [wave, dragon, lotus],
default: "kanagawa-wave",
};

View File

@@ -0,0 +1,136 @@
import type { Theme, ThemeFamily } from "../types";
// Monokai — the Sublime Text classic, plus Monokai Pro filter variants.
const classic: Theme = {
id: "monokai-classic",
family: "monokai",
label: "classic",
name: "Monokai Classic",
type: "dark",
colors: {
background: "39 40 34",
foreground: "248 248 242",
red: "249 38 114", redBright: "252 85 145",
orange: "253 151 31", orangeBright: "254 182 85",
green: "166 226 46", greenBright: "195 240 95",
yellow: "230 219 116", yellowBright: "240 232 160",
blue: "102 217 239", blueBright: "145 230 245",
purple: "174 129 255", purpleBright: "200 165 255",
aqua: "161 239 228", aquaBright: "192 245 238",
surface: "73 72 62",
},
canvasPalette: [[249,38,114],[166,226,46],[230,219,116],[102,217,239],[174,129,255],[161,239,228]],
};
const pro: Theme = {
id: "monokai-pro",
family: "monokai",
label: "pro",
name: "Monokai Pro",
type: "dark",
colors: {
background: "45 42 46",
foreground: "252 252 250",
red: "255 97 136", redBright: "255 140 170",
orange: "252 152 103", orangeBright: "253 182 142",
green: "169 220 118", greenBright: "195 234 155",
yellow: "255 216 102", yellowBright: "255 230 155",
blue: "120 220 232", blueBright: "160 234 242",
purple: "171 157 242", purpleBright: "198 188 248",
aqua: "140 228 200", aquaBright: "175 240 220",
surface: "64 62 65",
},
canvasPalette: [[255,97,136],[169,220,118],[255,216,102],[120,220,232],[171,157,242],[140,228,200]],
};
const octagon: Theme = {
id: "monokai-octagon",
family: "monokai",
label: "octagon",
name: "Monokai Octagon",
type: "dark",
colors: {
background: "40 42 58",
foreground: "234 242 241",
red: "255 101 122", redBright: "255 142 158",
orange: "255 155 94", orangeBright: "255 185 138",
green: "186 215 97", greenBright: "210 230 140",
yellow: "255 215 109", yellowBright: "255 230 160",
blue: "156 209 187", blueBright: "185 225 208",
purple: "195 154 201", purpleBright: "218 182 222",
aqua: "130 212 200", aquaBright: "165 228 218",
surface: "58 61 75",
},
canvasPalette: [[255,101,122],[186,215,97],[255,215,109],[156,209,187],[195,154,201],[130,212,200]],
};
const ristretto: Theme = {
id: "monokai-ristretto",
family: "monokai",
label: "ristretto",
name: "Monokai Ristretto",
type: "dark",
colors: {
background: "44 37 37",
foreground: "255 241 243",
red: "253 104 131", redBright: "254 145 165",
orange: "243 141 112", orangeBright: "248 175 150",
green: "173 218 120", greenBright: "198 232 158",
yellow: "249 204 108", yellowBright: "252 222 155",
blue: "133 218 204", blueBright: "168 232 222",
purple: "168 169 235", purpleBright: "195 196 242",
aqua: "150 222 195", aquaBright: "180 235 215",
surface: "64 56 56",
},
canvasPalette: [[253,104,131],[173,218,120],[249,204,108],[133,218,204],[168,169,235],[150,222,195]],
};
const machine: Theme = {
id: "monokai-machine",
family: "monokai",
label: "machine",
name: "Monokai Machine",
type: "dark",
colors: {
background: "39 49 54",
foreground: "242 255 252",
red: "255 109 126", redBright: "255 148 162",
orange: "255 178 112", orangeBright: "255 202 155",
green: "162 229 123", greenBright: "192 240 160",
yellow: "255 237 114", yellowBright: "255 244 168",
blue: "124 213 241", blueBright: "162 228 246",
purple: "186 160 248", purpleBright: "210 188 251",
aqua: "142 225 200", aquaBright: "175 238 220",
surface: "58 68 73",
},
canvasPalette: [[255,109,126],[162,229,123],[255,237,114],[124,213,241],[186,160,248],[142,225,200]],
};
const spectrum: Theme = {
id: "monokai-spectrum",
family: "monokai",
label: "spectrum",
name: "Monokai Spectrum",
type: "dark",
colors: {
background: "34 34 34",
foreground: "247 241 255",
red: "252 97 141", redBright: "253 140 172",
orange: "253 147 83", orangeBright: "254 180 125",
green: "123 216 143", greenBright: "162 232 175",
yellow: "252 229 102", yellowBright: "253 238 155",
blue: "90 212 230", blueBright: "135 226 240",
purple: "148 138 227", purpleBright: "180 172 238",
aqua: "108 218 190", aquaBright: "148 232 212",
surface: "54 53 55",
},
canvasPalette: [[252,97,141],[123,216,143],[252,229,102],[90,212,230],[148,138,227],[108,218,190]],
};
export const monokai: ThemeFamily = {
id: "monokai",
name: "Monokai",
themes: [classic, pro, octagon, ristretto, machine, spectrum],
default: "monokai-pro",
};

View File

@@ -0,0 +1,53 @@
import type { Theme, ThemeFamily } from "../types";
// Nord — arctic, bluish clean aesthetic.
// Polar Night (dark bg), Snow Storm (light bg), Frost (blues), Aurora (accents).
const dark: Theme = {
id: "nord-dark",
family: "nord",
label: "dark",
name: "Nord Dark",
type: "dark",
colors: {
background: "46 52 64",
foreground: "216 222 233",
red: "191 97 106", redBright: "210 130 138",
orange: "208 135 112", orangeBright: "224 165 145",
green: "163 190 140", greenBright: "185 210 168",
yellow: "235 203 139", yellowBright: "242 220 175",
blue: "94 129 172", blueBright: "129 161 193",
purple: "180 142 173", purpleBright: "200 170 195",
aqua: "143 188 187", aquaBright: "136 192 208",
surface: "59 66 82",
},
canvasPalette: [[191,97,106],[163,190,140],[235,203,139],[94,129,172],[180,142,173],[143,188,187]],
};
const light: Theme = {
id: "nord-light",
family: "nord",
label: "light",
name: "Nord Light",
type: "light",
colors: {
background: "236 239 244",
foreground: "46 52 64",
red: "191 97 106", redBright: "170 75 85",
orange: "208 135 112", orangeBright: "185 110 88",
green: "163 190 140", greenBright: "135 162 110",
yellow: "235 203 139", yellowBright: "200 170 100",
blue: "94 129 172", blueBright: "75 108 150",
purple: "180 142 173", purpleBright: "155 115 148",
aqua: "143 188 187", aquaBright: "110 160 162",
surface: "229 233 240",
},
canvasPalette: [[191,97,106],[163,190,140],[235,203,139],[94,129,172],[180,142,173],[143,188,187]],
};
export const nord: ThemeFamily = {
id: "nord",
name: "Nord",
themes: [dark, light],
default: "nord-dark",
};

View File

@@ -0,0 +1,52 @@
import type { Theme, ThemeFamily } from "../types";
// One Dark / One Light — the Atom editor classics.
const dark: Theme = {
id: "onedark-dark",
family: "onedark",
label: "dark",
name: "One Dark",
type: "dark",
colors: {
background: "40 44 52",
foreground: "171 178 191",
red: "224 108 117", redBright: "240 140 148",
orange: "209 154 102", orangeBright: "228 180 135",
green: "152 195 121", greenBright: "180 215 155",
yellow: "229 192 123", yellowBright: "240 212 162",
blue: "97 175 239", blueBright: "135 198 245",
purple: "198 120 221", purpleBright: "218 158 238",
aqua: "86 182 194", aquaBright: "120 202 212",
surface: "62 68 81",
},
canvasPalette: [[224,108,117],[152,195,121],[229,192,123],[97,175,239],[198,120,221],[86,182,194]],
};
const light: Theme = {
id: "onedark-light",
family: "onedark",
label: "light",
name: "One Light",
type: "light",
colors: {
background: "250 250 250",
foreground: "56 58 66",
red: "228 86 73", redBright: "240 115 100",
orange: "152 104 1", orangeBright: "180 130 30",
green: "80 161 79", greenBright: "105 185 104",
yellow: "193 132 1", yellowBright: "215 160 35",
blue: "64 120 242", blueBright: "100 148 248",
purple: "166 38 164", purpleBright: "192 75 190",
aqua: "1 132 188", aquaBright: "40 162 210",
surface: "229 229 230",
},
canvasPalette: [[228,86,73],[80,161,79],[193,132,1],[64,120,242],[166,38,164],[1,132,188]],
};
export const onedark: ThemeFamily = {
id: "onedark",
name: "One Dark",
themes: [dark, light],
default: "onedark-dark",
};

View File

@@ -0,0 +1,74 @@
import type { Theme, ThemeFamily } from "../types";
// Rosé Pine — soft muted palette inspired by the natural world.
// Only 6 accent hues; aqua is derived between pine and foam.
const main: Theme = {
id: "rosepine-main",
family: "rosepine",
label: "main",
name: "Rosé Pine",
type: "dark",
colors: {
background: "25 23 36",
foreground: "224 222 244",
red: "235 111 146", redBright: "241 145 174",
orange: "235 188 186", orangeBright: "240 208 206",
green: "49 116 143", greenBright: "78 140 165",
yellow: "246 193 119", yellowBright: "249 212 160",
blue: "156 207 216", blueBright: "180 222 229",
purple: "196 167 231", purpleBright: "214 190 239",
aqua: "100 170 185", aquaBright: "135 192 205",
surface: "38 35 58",
},
canvasPalette: [[235,111,146],[49,116,143],[246,193,119],[156,207,216],[196,167,231],[235,188,186]],
};
const moon: Theme = {
id: "rosepine-moon",
family: "rosepine",
label: "moon",
name: "Rosé Pine Moon",
type: "dark",
colors: {
background: "35 33 54",
foreground: "224 222 244",
red: "235 111 146", redBright: "241 145 174",
orange: "234 154 151", orangeBright: "241 186 184",
green: "62 143 176", greenBright: "90 165 195",
yellow: "246 193 119", yellowBright: "249 212 160",
blue: "156 207 216", blueBright: "180 222 229",
purple: "196 167 231", purpleBright: "214 190 239",
aqua: "110 178 196", aquaBright: "140 196 210",
surface: "57 53 82",
},
canvasPalette: [[235,111,146],[62,143,176],[246,193,119],[156,207,216],[196,167,231],[234,154,151]],
};
const dawn: Theme = {
id: "rosepine-dawn",
family: "rosepine",
label: "dawn",
name: "Rosé Pine Dawn",
type: "light",
colors: {
background: "250 244 237",
foreground: "87 82 121",
red: "180 99 122", redBright: "200 120 142",
orange: "215 130 126", orangeBright: "230 155 152",
green: "40 105 131", greenBright: "60 125 150",
yellow: "234 157 52", yellowBright: "242 180 85",
blue: "86 148 159", blueBright: "110 168 178",
purple: "144 122 169", purpleBright: "168 148 188",
aqua: "62 128 146", aquaBright: "85 150 165",
surface: "242 233 225",
},
canvasPalette: [[180,99,122],[40,105,131],[234,157,52],[86,148,159],[144,122,169],[215,130,126]],
};
export const rosepine: ThemeFamily = {
id: "rosepine",
name: "Rosé Pine",
themes: [main, moon, dawn],
default: "rosepine-main",
};

View File

@@ -0,0 +1,55 @@
import type { Theme, ThemeFamily } from "../types";
// Solarized — Ethan Schoonover's precision-engineered color scheme.
// Same accent colors in both dark and light — that's the whole point.
const accents = {
red: "220 50 47", redBright: "238 85 80",
orange: "203 75 22", orangeBright: "225 110 60",
yellow: "181 137 0", yellowBright: "210 168 40",
green: "133 153 0", greenBright: "165 185 35",
blue: "38 139 210", blueBright: "75 165 228",
purple: "108 113 196", purpleBright: "140 145 215",
aqua: "42 161 152", aquaBright: "80 190 182",
} as const;
const palette: [number, number, number][] = [
[220,50,47],[133,153,0],[181,137,0],[38,139,210],[108,113,196],[42,161,152],
];
const dark: Theme = {
id: "solarized-dark",
family: "solarized",
label: "dark",
name: "Solarized Dark",
type: "dark",
colors: {
background: "0 43 54",
foreground: "131 148 150",
...accents,
surface: "7 54 66",
},
canvasPalette: palette,
};
const light: Theme = {
id: "solarized-light",
family: "solarized",
label: "light",
name: "Solarized Light",
type: "light",
colors: {
background: "253 246 227",
foreground: "101 123 131",
...accents,
surface: "238 232 213",
},
canvasPalette: palette,
};
export const solarized: ThemeFamily = {
id: "solarized",
name: "Solarized",
themes: [dark, light],
default: "solarized-dark",
};

View File

@@ -0,0 +1,74 @@
import type { Theme, ThemeFamily } from "../types";
// Tokyo Night — modern, popular blue/purple-toned palette.
// Three variants: Night (deep), Storm (slightly lighter), Day (light).
const night: Theme = {
id: "tokyonight-night",
family: "tokyonight",
label: "night",
name: "Tokyo Night",
type: "dark",
colors: {
background: "26 27 38",
foreground: "169 177 214",
red: "247 118 142", redBright: "250 150 170",
orange: "255 158 100", orangeBright: "255 185 140",
green: "158 206 106", greenBright: "185 222 140",
yellow: "224 175 104", yellowBright: "238 200 140",
blue: "122 162 247", blueBright: "155 185 250",
purple: "187 154 247", purpleBright: "208 180 250",
aqua: "125 207 255", aquaBright: "165 222 255",
surface: "41 46 66",
},
canvasPalette: [[247,118,142],[158,206,106],[224,175,104],[122,162,247],[187,154,247],[125,207,255]],
};
const storm: Theme = {
id: "tokyonight-storm",
family: "tokyonight",
label: "storm",
name: "Tokyo Night Storm",
type: "dark",
colors: {
background: "36 40 59",
foreground: "169 177 214",
red: "247 118 142", redBright: "250 150 170",
orange: "255 158 100", orangeBright: "255 185 140",
green: "158 206 106", greenBright: "185 222 140",
yellow: "224 175 104", yellowBright: "238 200 140",
blue: "122 162 247", blueBright: "155 185 250",
purple: "187 154 247", purpleBright: "208 180 250",
aqua: "125 207 255", aquaBright: "165 222 255",
surface: "59 66 97",
},
canvasPalette: [[247,118,142],[158,206,106],[224,175,104],[122,162,247],[187,154,247],[125,207,255]],
};
const day: Theme = {
id: "tokyonight-day",
family: "tokyonight",
label: "day",
name: "Tokyo Night Day",
type: "light",
colors: {
background: "225 226 231",
foreground: "55 96 191",
red: "245 42 101", redBright: "248 80 130",
orange: "177 92 0", orangeBright: "200 120 30",
green: "88 117 57", greenBright: "110 140 78",
yellow: "140 108 62", yellowBright: "165 135 85",
blue: "46 125 233", blueBright: "80 150 240",
purple: "152 84 241", purpleBright: "175 115 245",
aqua: "0 113 151", aquaBright: "30 140 175",
surface: "196 200 218",
},
canvasPalette: [[245,42,101],[88,117,57],[140,108,62],[46,125,233],[152,84,241],[0,113,151]],
};
export const tokyonight: ThemeFamily = {
id: "tokyonight",
name: "Tokyo Night",
themes: [night, storm, day],
default: "tokyonight-night",
};

View File

@@ -1,58 +1,38 @@
import type { Theme } from "./types"; import type { Theme, ThemeFamily } from "./types";
import { darkbox } from "./families/darkbox";
import { gruvbox } from "./families/gruvbox";
import { everforest } from "./families/everforest";
import { catppuccin } from "./families/catppuccin";
import { rosepine } from "./families/rosepine";
import { kanagawa } from "./families/kanagawa";
import { nord } from "./families/nord";
import { tokyonight } from "./families/tokyonight";
import { solarized } from "./families/solarized";
import { onedark } from "./families/onedark";
import { monokai } from "./families/monokai";
import { github } from "./families/github";
export const DEFAULT_THEME_ID = "darkbox-retro"; export const DEFAULT_THEME_ID = "darkbox-retro";
function theme( export const FAMILIES: ThemeFamily[] = [
id: string, darkbox,
name: string, gruvbox,
type: "dark" | "light", everforest,
colors: Theme["colors"], catppuccin,
palette: [number, number, number][] rosepine,
): Theme { kanagawa,
return { id, name, type, colors, canvasPalette: palette }; nord,
tokyonight,
solarized,
onedark,
monokai,
github,
];
// Flat lookup — backward compatible with all existing consumers
export const THEMES: Record<string, Theme> = {};
for (const family of FAMILIES) {
for (const theme of family.themes) {
THEMES[theme.id] = theme;
}
} }
// Three darkbox variants from darkbox.nvim
// Classic (vivid) → Retro (muted) → Dim (deep)
// Each variant's "bright" is the next level up's base.
export const THEMES: Record<string, Theme> = {
darkbox: theme("darkbox", "Darkbox Classic", "dark", {
background: "0 0 0",
foreground: "235 219 178",
red: "251 73 52", redBright: "255 110 85",
orange: "254 128 25", orangeBright: "255 165 65",
green: "184 187 38", greenBright: "210 215 70",
yellow: "250 189 47", yellowBright: "255 215 85",
blue: "131 165 152", blueBright: "165 195 180",
purple: "211 134 155", purpleBright: "235 165 180",
aqua: "142 192 124", aquaBright: "175 220 160",
surface: "60 56 54",
}, [[251,73,52],[184,187,38],[250,189,47],[131,165,152],[211,134,155],[142,192,124]]),
"darkbox-retro": theme("darkbox-retro", "Darkbox Retro", "dark", {
background: "0 0 0",
foreground: "189 174 147",
red: "204 36 29", redBright: "251 73 52",
orange: "214 93 14", orangeBright: "254 128 25",
green: "152 151 26", greenBright: "184 187 38",
yellow: "215 153 33", yellowBright: "250 189 47",
blue: "69 133 136", blueBright: "131 165 152",
purple: "177 98 134", purpleBright: "211 134 155",
aqua: "104 157 106", aquaBright: "142 192 124",
surface: "60 56 54",
}, [[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106]]),
"darkbox-dim": theme("darkbox-dim", "Darkbox Dim", "dark", {
background: "0 0 0",
foreground: "168 153 132",
red: "157 0 6", redBright: "204 36 29",
orange: "175 58 3", orangeBright: "214 93 14",
green: "121 116 14", greenBright: "152 151 26",
yellow: "181 118 20", yellowBright: "215 153 33",
blue: "7 102 120", blueBright: "69 133 136",
purple: "143 63 113", purpleBright: "177 98 134",
aqua: "66 123 88", aquaBright: "104 157 106",
surface: "60 56 54",
}, [[157,0,6],[121,116,14],[181,118,20],[7,102,120],[143,63,113],[66,123,88]]),
};

View File

@@ -20,8 +20,17 @@ export interface ThemeColors {
export interface Theme { export interface Theme {
id: string; id: string;
family: string;
label: string;
name: string; name: string;
type: "dark" | "light"; type: "dark" | "light";
colors: ThemeColors; colors: ThemeColors;
canvasPalette: [number, number, number][]; canvasPalette: [number, number, number][];
} }
export interface ThemeFamily {
id: string;
name: string;
themes: Theme[];
default: string;
}

View File

@@ -17,23 +17,23 @@ import OutsideCoding from "@/components/about/outside-coding";
<Intro client:load /> <Intro client:load />
</section> </section>
<section class="min-h-[60vh] flex items-center justify-center py-16"> <section class="min-h-[40vh] md:min-h-[60vh] flex items-center justify-center py-8 md:py-16">
<AllTimeStats client:load /> <AllTimeStats client:load />
</section> </section>
<section class="min-h-screen flex items-center justify-center py-16"> <section class="min-h-[60vh] md:min-h-screen flex items-center justify-center py-8 md:py-16">
<DetailedStats client:load /> <DetailedStats client:load />
</section> </section>
<section class="min-h-[80vh] flex items-center justify-center py-16"> <section class="min-h-[50vh] md:min-h-[80vh] flex items-center justify-center py-8 md:py-16">
<Timeline client:load /> <Timeline client:load />
</section> </section>
<section class="min-h-[80vh] flex items-center justify-center py-16"> <section class="min-h-[50vh] md:min-h-[80vh] flex items-center justify-center py-8 md:py-16">
<CurrentFocus client:load /> <CurrentFocus client:load />
</section> </section>
<section class="min-h-[50vh] flex items-center justify-center py-16"> <section class="min-h-[30vh] md:min-h-[50vh] flex items-center justify-center py-8 md:py-16">
<OutsideCoding client:load /> <OutsideCoding client:load />
</section> </section>
</div> </div>

View File

@@ -0,0 +1,276 @@
import type { APIRoute } from "astro";
import { THEMES, DEFAULT_THEME_ID } from "@/lib/themes";
function rgbToHex(rgb: string): string {
return "#" + rgb.split(" ").map(n => parseInt(n).toString(16).padStart(2, "0")).join("");
}
function rgbToRgba(rgb: string, alpha: number): string {
return `rgba(${rgb.replaceAll(" ", ", ")}, ${alpha})`;
}
export const GET: APIRoute = ({ url }) => {
const themeId = url.searchParams.get("theme") || DEFAULT_THEME_ID;
const theme = THEMES[themeId];
if (!theme) {
return new Response("Unknown theme", { status: 404 });
}
const c = theme.colors;
const isLight = theme.type === "light";
const fg = rgbToHex(c.foreground);
const fgMuted = rgbToRgba(c.foreground, 0.6);
const fgSubtle = rgbToRgba(c.foreground, 0.4);
const blue = rgbToHex(c.blue);
const blueBright = rgbToHex(c.blueBright);
const green = rgbToHex(c.green);
const yellow = rgbToHex(c.yellow);
const red = rgbToHex(c.red);
const purple = rgbToHex(c.purple);
const orange = rgbToHex(c.orange);
const surface = rgbToHex(c.surface);
const surfaceAlpha = rgbToRgba(c.surface, 0.3);
const surfaceBorder = rgbToRgba(c.surface, 0.5);
const surfaceHover = rgbToRgba(c.surface, 0.6);
const bgTransparent = isLight ? rgbToRgba(c.foreground, 0.06) : rgbToRgba(c.foreground, 0.08);
const bgSubtle = isLight ? rgbToRgba(c.foreground, 0.04) : rgbToRgba(c.foreground, 0.05);
const css = `
main {
--color-prettylights-syntax-comment: ${fgSubtle};
--color-prettylights-syntax-constant: ${blueBright};
--color-prettylights-syntax-entity: ${purple};
--color-prettylights-syntax-storage-modifier-import: ${fg};
--color-prettylights-syntax-entity-tag: ${green};
--color-prettylights-syntax-keyword: ${red};
--color-prettylights-syntax-string: ${blueBright};
--color-prettylights-syntax-variable: ${orange};
--color-prettylights-syntax-brackethighlighter-unmatched: ${red};
--color-prettylights-syntax-invalid-illegal-text: ${fg};
--color-prettylights-syntax-invalid-illegal-bg: ${red};
--color-prettylights-syntax-carriage-return-text: ${fg};
--color-prettylights-syntax-carriage-return-bg: ${red};
--color-prettylights-syntax-string-regexp: ${green};
--color-prettylights-syntax-markup-list: ${yellow};
--color-prettylights-syntax-markup-heading: ${blueBright};
--color-prettylights-syntax-markup-italic: ${fg};
--color-prettylights-syntax-markup-bold: ${orange};
--color-prettylights-syntax-markup-deleted-text: ${red};
--color-prettylights-syntax-markup-deleted-bg: transparent;
--color-prettylights-syntax-markup-inserted-text: ${green};
--color-prettylights-syntax-markup-inserted-bg: transparent;
--color-prettylights-syntax-markup-changed-text: ${yellow};
--color-prettylights-syntax-markup-changed-bg: transparent;
--color-prettylights-syntax-markup-ignored-text: ${fg};
--color-prettylights-syntax-markup-ignored-bg: transparent;
--color-prettylights-syntax-meta-diff-range: ${purple};
--color-prettylights-syntax-brackethighlighter-angle: ${fgSubtle};
--color-prettylights-syntax-sublimelinter-gutter-mark: ${fgSubtle};
--color-prettylights-syntax-constant-other-reference-link: ${blueBright};
--color-btn-text: ${fg};
--color-btn-bg: ${bgTransparent};
--color-btn-border: ${surfaceBorder};
--color-btn-shadow: 0 0 transparent;
--color-btn-inset-shadow: 0 0 transparent;
--color-btn-hover-bg: ${surfaceAlpha};
--color-btn-hover-border: ${surfaceHover};
--color-btn-active-bg: ${bgSubtle};
--color-btn-active-border: ${surfaceHover};
--color-btn-selected-bg: ${bgTransparent};
--color-btn-primary-text: ${fg};
--color-btn-primary-bg: ${blue};
--color-btn-primary-border: transparent;
--color-btn-primary-shadow: 0 0 transparent;
--color-btn-primary-inset-shadow: 0 0 transparent;
--color-btn-primary-hover-bg: ${blueBright};
--color-btn-primary-hover-border: transparent;
--color-btn-primary-selected-bg: ${blue};
--color-btn-primary-selected-shadow: 0 0 transparent;
--color-btn-primary-disabled-text: ${fgMuted};
--color-btn-primary-disabled-bg: ${rgbToRgba(c.blue, 0.6)};
--color-btn-primary-disabled-border: transparent;
--color-action-list-item-default-hover-bg: ${surfaceAlpha};
--color-segmented-control-bg: ${surfaceAlpha};
--color-segmented-control-button-bg: ${bgTransparent};
--color-segmented-control-button-selected-border: ${surfaceBorder};
--color-fg-default: ${fg};
--color-fg-muted: ${fgMuted};
--color-fg-subtle: ${fgSubtle};
--color-canvas-default: transparent;
--color-canvas-overlay: ${bgTransparent};
--color-canvas-inset: ${bgSubtle};
--color-canvas-subtle: ${bgSubtle};
--color-border-default: ${surfaceBorder};
--color-border-muted: ${surfaceAlpha};
--color-neutral-muted: ${rgbToRgba(c.surface, 0.25)};
--color-accent-fg: ${blueBright};
--color-accent-emphasis: ${blue};
--color-accent-muted: ${rgbToRgba(c.blue, 0.4)};
--color-accent-subtle: ${rgbToRgba(c.blue, 0.1)};
--color-success-fg: ${green};
--color-attention-fg: ${yellow};
--color-attention-muted: ${rgbToRgba(c.yellow, 0.4)};
--color-attention-subtle: ${rgbToRgba(c.yellow, 0.15)};
--color-danger-fg: ${red};
--color-danger-muted: ${rgbToRgba(c.red, 0.4)};
--color-danger-subtle: ${rgbToRgba(c.red, 0.1)};
--color-primer-shadow-inset: 0 0 transparent;
--color-scale-gray-7: ${surface};
--color-scale-blue-8: ${blue};
--color-social-reaction-bg-hover: ${surfaceAlpha};
--color-social-reaction-bg-reacted-hover: ${rgbToRgba(c.blue, 0.3)};
}
main .pagination-loader-container {
background-image: url(https://github.com/images/modules/pulls/progressive-disclosure-line${isLight ? "" : "-dark"}.svg);
}
.gsc-reactions-count { display: none; }
.gsc-timeline { flex-direction: column-reverse; }
.gsc-header {
padding-bottom: 1rem;
font-family: "Comic Code", monospace;
border-bottom: none;
}
.gsc-comments > .gsc-header { order: 1; }
.gsc-comments > .gsc-comment-box {
margin-bottom: 1rem;
order: 2;
font-family: "Comic Code", monospace;
background-color: ${bgTransparent};
border-radius: 0.5rem;
border: 1px solid ${surfaceBorder};
}
.gsc-comments > .gsc-timeline { order: 3; }
.gsc-homepage-bg { background-color: transparent; }
main .gsc-loading-image {
background-image: url(https://github.githubassets.com/images/mona-loading-${isLight ? "default" : "dimmed"}.gif);
}
.gsc-comment {
border: 1px solid ${surfaceBorder};
border-radius: 0.5rem;
margin-bottom: 1rem;
background-color: ${bgTransparent};
transition: border-color 0.2s ease;
}
.gsc-comment-header {
background-color: ${bgSubtle};
padding: 0.75rem;
border-bottom: 1px solid ${rgbToRgba(c.surface, 0.2)};
font-family: "Comic Code", monospace;
}
.gsc-comment-content {
padding: 1rem;
font-family: "Comic Code", monospace;
}
.gsc-comment-author { color: var(--color-fg-default); font-weight: 600; }
.gsc-comment-author-avatar img { border-radius: 50%; }
.gsc-comment-reactions { border-top: none; padding-top: 0.5rem; }
.gsc-reply-box {
background-color: ${bgTransparent};
border-radius: 0.5rem;
margin-top: 0.5rem;
margin-left: 1rem;
font-family: "Comic Code", monospace;
border: 1px solid ${rgbToRgba(c.surface, 0.25)};
}
.gsc-comment-box-textarea {
background-color: ${bgSubtle};
border: 1px solid ${surfaceBorder};
border-radius: 0.5rem;
color: var(--color-fg-default);
font-family: "Comic Code", monospace;
padding: 0.75rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.gsc-comment-box-textarea:focus {
border-color: ${blueBright};
box-shadow: 0 0 0 2px ${rgbToRgba(c.blue, 0.2)};
outline: none;
}
.gsc-comment-box-buttons button {
font-family: "Comic Code", monospace;
border-radius: 0.5rem;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.gsc-comment pre {
background-color: ${bgSubtle};
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
border: 1px solid ${rgbToRgba(c.surface, 0.25)};
}
.gsc-comment code {
font-family: "Comic Code", monospace;
background-color: ${bgSubtle};
color: ${purple};
padding: 0.2em 0.4em;
border-radius: 0.25rem;
}
.gsc-comment:hover { border-color: ${surfaceHover}; }
.gsc-social-reaction-summary-item:hover { background-color: ${surfaceAlpha}; }
.gsc-timeline::-webkit-scrollbar { width: 8px; height: 8px; }
.gsc-timeline::-webkit-scrollbar-track { background: transparent; border-radius: 4px; }
.gsc-timeline::-webkit-scrollbar-thumb { background: ${surfaceBorder}; border-radius: 4px; }
.gsc-timeline::-webkit-scrollbar-thumb:hover { background: ${surface}; }
.gsc-comment-footer, .gsc-comment-footer-separator,
.gsc-reactions-button, .gsc-social-reaction-summary-item:not(:hover) { border: none; }
.gsc-upvote svg { fill: ${blueBright}; }
.gsc-downvote svg { fill: ${red}; }
.gsc-comment-box, .gsc-comment, .gsc-comment-reactions, button, .gsc-reply-box {
transition: all 0.2s ease-in-out;
}
.gsc-main { border: none !important; }
.gsc-left-header { background-color: transparent !important; }
.gsc-right-header { background-color: transparent !important; }
.gsc-header-status { background-color: transparent !important; }
.gsc-comment-box, .gsc-comment-box-md-toolbar, .gsc-comment-box-buttons { border: none !important; }
.gsc-comment-box-md-toolbar-item { color: ${blueBright} !important; }
.gsc-comment-box-md-toolbar {
background-color: ${bgSubtle} !important;
padding: 0.5rem !important;
}
.gsc-comments .gsc-powered-by { display: none !important; }
.gsc-comments footer { display: none !important; }
.gsc-comments .gsc-powered-by a { visibility: hidden !important; }
`;
return new Response(css, {
headers: {
"Content-Type": "text/css",
"Cache-Control": "public, max-age=3600",
"Access-Control-Allow-Origin": "*",
},
});
};

96
src/pages/api/github.ts Normal file
View File

@@ -0,0 +1,96 @@
import type { APIRoute } from "astro";
const GITHUB_USER = "timmypidashev";
export const GET: APIRoute = async () => {
const token = import.meta.env.GITHUB_TOKEN;
const headers: Record<string, string> = {
Accept: "application/json",
"User-Agent": "timmypidashev-web",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
let status: { message: string } | null = null;
let commit: { message: string; repo: string; date: string; url: string } | null = null;
let tinkering: { repo: string; url: string } | null = null;
// Fetch user status via GraphQL (requires token)
if (token) {
try {
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
query: `{ user(login: "${GITHUB_USER}") { status { message } } }`,
}),
});
const data = await res.json();
const s = data?.data?.user?.status;
if (s?.message) {
status = { message: s.message };
}
} catch {
// Status unavailable — skip
}
}
// Fetch latest public push event, then fetch commit details
try {
const eventsRes = await fetch(
`https://api.github.com/users/${GITHUB_USER}/events/public?per_page=30`,
{ headers },
);
const events = await eventsRes.json();
// Find most active repo from recent push events
if (Array.isArray(events)) {
const repoCounts: Record<string, number> = {};
for (const e of events) {
if (e.type === "PushEvent") {
const name = e.repo.name.replace(`${GITHUB_USER}/`, "");
repoCounts[name] = (repoCounts[name] || 0) + 1;
}
}
const topRepo = Object.entries(repoCounts).sort((a, b) => b[1] - a[1])[0];
if (topRepo) {
tinkering = {
repo: topRepo[0],
url: `https://github.com/${GITHUB_USER}/${topRepo[0]}`,
};
}
}
const push = Array.isArray(events)
? events.find((e: any) => e.type === "PushEvent")
: null;
if (push) {
const repo = push.repo.name.replace(`${GITHUB_USER}/`, "");
const sha = push.payload?.head;
if (sha) {
const commitRes = await fetch(
`https://api.github.com/repos/${GITHUB_USER}/${repo}/commits/${sha}`,
{ headers },
);
const commitData = await commitRes.json();
if (commitData?.commit?.message) {
commit = {
message: commitData.commit.message.split("\n")[0],
repo,
date: push.created_at,
url: commitData.html_url,
};
}
}
}
} catch {
// Commit unavailable — skip
}
return new Response(JSON.stringify({ status, commit, tinkering }), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300",
},
});
};

View File

@@ -5,6 +5,7 @@ import ContentLayout from "@/layouts/content.astro";
import { getArticleSchema } from "@/lib/structuredData"; import { getArticleSchema } from "@/lib/structuredData";
import { blogWebsite } from "@/lib/structuredData"; import { blogWebsite } from "@/lib/structuredData";
import { Comments } from "@/components/blog/comments"; import { Comments } from "@/components/blog/comments";
import { StreamContent } from "@/components/stream-content";
import { incrementViews, getViews } from "@/lib/views"; import { incrementViews, getViews } from "@/lib/views";
// This is a dynamic route in SSR mode // This is a dynamic route in SSR mode
@@ -110,9 +111,11 @@ const jsonLd = {
</span> </span>
))} ))}
</div> </div>
<StreamContent client:load>
<div class="prose prose-invert prose-lg max-w-none"> <div class="prose prose-invert prose-lg max-w-none">
<Content /> <Content />
</div> </div>
</StreamContent>
</article> </article>
<Comments client:idle /> <Comments client:idle />
</div> </div>

View File

@@ -5,6 +5,7 @@ import { getCollection, render } from "astro:content";
import ContentLayout from "@/layouts/content.astro"; import ContentLayout from "@/layouts/content.astro";
import { Comments } from "@/components/blog/comments"; import { Comments } from "@/components/blog/comments";
import { StreamContent } from "@/components/stream-content";
export async function getStaticPaths() { export async function getStaticPaths() {
const projects = await getCollection("projects"); const projects = await getCollection("projects");
@@ -60,9 +61,11 @@ const { Content } = await render(project);
</div> </div>
</header> </header>
<StreamContent client:load>
<div class="prose prose-invert prose-lg max-w-none"> <div class="prose prose-invert prose-lg max-w-none">
<Content /> <Content />
</div> </div>
</StreamContent>
</article> </article>
<Comments client:idle /> <Comments client:idle />
</ContentLayout> </ContentLayout>

View File

@@ -22,6 +22,10 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
.stream-hidden > .prose > * {
opacity: 0;
}
/* Chrome, Edge, and Safari */ /* Chrome, Edge, and Safari */
*::-webkit-scrollbar { *::-webkit-scrollbar {
display: none; display: none;
@@ -43,19 +47,19 @@
to bottom, to bottom,
transparent 0px, transparent 0px,
transparent 2px, transparent 2px,
rgba(0, 0, 0, 0.12) 2px, rgb(var(--color-foreground) / 0.06) 2px,
rgba(0, 0, 0, 0.12) 4px rgb(var(--color-foreground) / 0.06) 4px
); );
animation: crt-scroll 12s linear infinite; animation: crt-scroll 12s linear infinite;
z-index: 1; z-index: 1;
} }
.crt-bloom { .crt-bloom {
box-shadow: inset 0 0 100px 30px rgba(0, 0, 0, 0.3); box-shadow: inset 0 0 100px 30px rgb(var(--color-background) / 0.3);
background: radial-gradient( background: radial-gradient(
ellipse at center, ellipse at center,
transparent 50%, transparent 50%,
rgba(0, 0, 0, 0.25) 100% rgb(var(--color-background) / 0.25) 100%
); );
z-index: 2; z-index: 2;
} }
@@ -69,6 +73,14 @@
} }
} }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Regular */ /* Regular */
@font-face { @font-face {
font-family: "ComicRegular"; font-family: "ComicRegular";

View File

@@ -1,6 +1,15 @@
module.exports = { module.exports = {
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: { theme: {
screens: {
sm: "640px",
md: "768px",
lg: "1024px",
xl: "1280px",
"2xl": "1536px",
// Desktop = wide screen + non-touch pointer. Used for mobile/desktop layout split.
desk: { raw: "(min-width: 1024px) and (hover: hover) and (pointer: fine)" },
},
extend: { extend: {
fontFamily: { fontFamily: {
"comic-code": ["Comic Code", "monospace"], "comic-code": ["Comic Code", "monospace"],