This commit is contained in:
2026-07-27 19:51:11 +02:00
parent cddb48157c
commit db407b1262
10 changed files with 151 additions and 201 deletions
+26 -33
View File
@@ -4,38 +4,37 @@
@layer base { @layer base {
:root { :root {
/* 3blue1brown-inspired dark palette: deep space blue background, /* Monochrome chrome: the shell carries no color of its own, so the
a warm gold accent for emphasis, and a small, deliberate set of five algorithm-state colors below are the only color on the page. */
"state" colors used consistently across every visualization. */ --background: 224 15% 6%;
--background: 222 47% 7%; --foreground: 210 20% 95%;
--foreground: 210 40% 96%;
--primary: 199 89% 64%; /* 3b1b sky blue */ --primary: 210 25% 96%; /* near-white, inverted CTA fill */
--primary-foreground: 222 47% 7%; --primary-foreground: 224 20% 9%;
--secondary: 222 30% 16%; --secondary: 224 12% 15%;
--secondary-foreground: 210 40% 90%; --secondary-foreground: 210 15% 90%;
--accent: 43 96% 62%; /* warm gold, used sparingly */ --accent: 224 12% 17%; /* neutral hover surface, not a brand color */
--accent-foreground: 222 47% 7%; --accent-foreground: 210 20% 95%;
--destructive: 358 75% 59%; --destructive: 358 65% 55%;
--destructive-foreground: 210 40% 96%; --destructive-foreground: 210 20% 96%;
--muted: 222 25% 14%; --muted: 224 12% 13%;
--muted-foreground: 215 20% 65%; --muted-foreground: 216 10% 62%;
--card: 222 40% 10%; --card: 224 14% 9%;
--card-foreground: 210 40% 96%; --card-foreground: 210 20% 95%;
--popover: 222 44% 9%; --popover: 224 16% 8%;
--popover-foreground: 210 40% 96%; --popover-foreground: 210 20% 95%;
--border: 222 25% 20%; --border: 224 12% 18%;
--input: 222 25% 20%; --input: 224 12% 18%;
--ring: 199 89% 64%; --ring: 205 90% 62%; /* the one deliberate accent: focus only */
--radius: 0.75rem; --radius: 0.75rem;
/* Algorithm state colors — one meaning each, used everywhere. */ /* Algorithm state colors — one meaning each, used everywhere. */
--state-idle: 199 60% 55%; --state-idle: 205 40% 50%;
--state-compare: 43 96% 62%; --state-compare: 43 90% 60%;
--state-swap: 358 75% 59%; --state-swap: 358 75% 59%;
--state-sorted: 152 60% 50%; --state-sorted: 152 55% 48%;
--state-pivot: 271 76% 68%; --state-pivot: 271 70% 66%;
--state-active-range: 199 89% 64%; --state-active-range: 205 60% 55%;
} }
} }
@@ -71,12 +70,6 @@
} }
@layer utilities { @layer utilities {
.glow-primary {
box-shadow: 0 0 24px hsl(var(--primary) / 0.35);
}
.glow-accent {
box-shadow: 0 0 24px hsl(var(--accent) / 0.35);
}
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
} }
+4 -4
View File
@@ -1,22 +1,22 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Inter, JetBrains_Mono } from 'next/font/google'; import { Geist, Geist_Mono } from 'next/font/google';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import './globals.css'; import './globals.css';
const fontSans = Inter({ const fontSans = Geist({
subsets: ['latin'], subsets: ['latin'],
display: 'swap', display: 'swap',
variable: '--font-sans', variable: '--font-sans',
}); });
const fontMono = JetBrains_Mono({ const fontMono = Geist_Mono({
subsets: ['latin'], subsets: ['latin'],
display: 'swap', display: 'swap',
variable: '--font-mono', variable: '--font-mono',
}); });
export const metadata = { export const metadata = {
title: 'Valgo — Watch how sorting actually works', title: 'Valgo - Watch how sorting actually works',
description: 'An animated, narrated visualizer for nine classic sorting algorithms.', description: 'An animated, narrated visualizer for nine classic sorting algorithms.',
}; };
+48 -26
View File
@@ -14,7 +14,7 @@ import StatsPanel from "@/components/StatsPanel";
import PlaybackControls, { Speed } from "@/components/PlaybackControls"; import PlaybackControls, { Speed } from "@/components/PlaybackControls";
import { AlgorithmPicker } from "@/components/Pickers"; import { AlgorithmPicker } from "@/components/Pickers";
import SiteFooter from "@/components/SiteFooter"; import SiteFooter from "@/components/SiteFooter";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import Panel from "@/components/Panel";
const SPEED_MS: Record<Speed, number> = { slow: 260, normal: 120, fast: 55, turbo: 18 }; const SPEED_MS: Record<Speed, number> = { slow: 260, normal: 120, fast: 55, turbo: 18 };
@@ -22,11 +22,27 @@ function randomArray(size: number): number[] {
return Array.from({ length: size }, () => Math.floor(Math.random() * 100) + 1); return Array.from({ length: size }, () => Math.floor(Math.random() * 100) + 1);
} }
// Deterministic so the server-rendered array matches the client's first
// render; the real random array is generated client-side after mount.
function seededArray(size: number): number[] {
return Array.from({ length: size }, (_, i) => ((i * 37) % 100) + 1);
}
function BrandMark(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 20 20" fill="none" {...props}>
<rect x="2" y="11" width="4" height="7" rx="1" fill="currentColor" opacity="0.5" />
<rect x="8" y="6" width="4" height="12" rx="1" fill="currentColor" opacity="0.75" />
<rect x="14" y="2" width="4" height="16" rx="1" fill="currentColor" />
</svg>
);
}
export default function Page() { export default function Page() {
const [algorithm, setAlgorithm] = useState<AlgorithmKey>("bubble"); const [algorithm, setAlgorithm] = useState<AlgorithmKey>("bubble");
const [language, setLanguage] = useState<LanguageCode>("en"); const [language, setLanguage] = useState<LanguageCode>("en");
const [arraySize, setArraySize] = useState(24); const [arraySize, setArraySize] = useState(24);
const [baseArray, setBaseArray] = useState<number[]>(() => randomArray(24)); const [baseArray, setBaseArray] = useState<number[]>(() => seededArray(24));
const [steps, setSteps] = useState<Step[]>([]); const [steps, setSteps] = useState<Step[]>([]);
const [stepIndex, setStepIndex] = useState(0); const [stepIndex, setStepIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -34,6 +50,11 @@ export default function Page() {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setBaseArray(randomArray(arraySize));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// (Re)compute the full step-by-step story whenever the algorithm or the // (Re)compute the full step-by-step story whenever the algorithm or the
// underlying data changes. Everything downstream just plays it back. // underlying data changes. Everything downstream just plays it back.
useEffect(() => { useEffect(() => {
@@ -111,25 +132,27 @@ export default function Page() {
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<header className="border-b border-border bg-card/30 backdrop-blur-sm"> <header className="sticky top-0 z-40 border-b border-border bg-background/85 backdrop-blur-md">
<div className="container mx-auto px-4 md:px-6 py-6 flex flex-wrap items-center justify-between gap-4"> <div className="container mx-auto max-w-6xl px-4 md:px-6 h-16 flex items-center justify-between gap-4">
<div> <div className="flex items-center gap-2.5 min-w-0">
<h1 className="text-2xl font-bold tracking-tight"> <BrandMark className="h-5 w-5 shrink-0 text-foreground" />
<span className="text-primary">{t(language, "brand")}</span> <h1 className="text-base font-semibold tracking-tight truncate">{t(language, "brand")}</h1>
</h1> <span className="hidden md:inline text-sm text-muted-foreground ml-2 pl-3 border-l border-border truncate">
<p className="text-sm text-muted-foreground">{t(language, "tagline")}</p> {t(language, "tagline")}
</span>
</div> </div>
<AlgorithmPicker value={algorithm} onChange={setAlgorithm} triggerLabel={t(language, "pickAlgorithm")} /> <AlgorithmPicker value={algorithm} onChange={setAlgorithm} triggerLabel={t(language, "pickAlgorithm")} />
</div> </div>
</header> </header>
<main className="flex-1 container mx-auto px-4 md:px-6 py-10 space-y-8"> <main className="flex-1">
<motion.div <motion.section
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-6" className="border-b border-border bg-card/30"
> >
<div className="container mx-auto max-w-6xl px-4 md:px-6 py-8 md:py-10">
<Bars <Bars
values={currentStep?.array ?? baseArray} values={currentStep?.array ?? baseArray}
indices={currentStep?.indices ?? []} indices={currentStep?.indices ?? []}
@@ -139,8 +162,10 @@ export default function Page() {
pivotIndex={pivotIndex} pivotIndex={pivotIndex}
stepDurationMs={SPEED_MS[speed]} stepDurationMs={SPEED_MS[speed]}
/> />
</motion.div> </div>
</motion.section>
<div className="container mx-auto max-w-6xl px-4 md:px-6 py-8 md:py-10">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<Narration text={narration} stepIndex={stepIndex} title={t(language, "narrationTitle")} /> <Narration text={narration} stepIndex={stepIndex} title={t(language, "narrationTitle")} />
@@ -181,24 +206,21 @@ export default function Page() {
/> />
{description && ( {description && (
<Card className="bg-card/60 backdrop-blur-sm"> <Panel>
<CardHeader> <h2 className="text-xl font-semibold tracking-tight">{description.title}</h2>
<CardTitle className="text-xl">{description.title}</CardTitle> <div className="mt-1 space-y-0.5">
<CardDescription className="space-y-0.5"> <div className="font-mono text-xs text-muted-foreground">{description.timeComplexity}</div>
<div className="font-mono text-xs">{description.timeComplexity}</div> <div className="font-mono text-xs text-muted-foreground">{description.spaceComplexity}</div>
<div className="font-mono text-xs">{description.spaceComplexity}</div> </div>
</CardDescription> <p className="mt-4 text-sm text-muted-foreground leading-relaxed">{description.description}</p>
</CardHeader> </Panel>
<CardContent>
<p className="text-sm text-muted-foreground leading-relaxed">{description.description}</p>
</CardContent>
</Card>
)} )}
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5"> <Panel>
<p className="text-xs uppercase tracking-widest text-muted-foreground mb-1">{t(language, "growthTitle")}</p> <p className="text-xs uppercase tracking-widest text-muted-foreground mb-1">{t(language, "growthTitle")}</p>
<p className="text-xs text-muted-foreground mb-4">{t(language, "growthCaption")}</p> <p className="text-xs text-muted-foreground mb-4">{t(language, "growthCaption")}</p>
<GrowthChart active={cclass} n={arraySize} maxN={100} labels={growthLabels} /> <GrowthChart active={cclass} n={arraySize} maxN={100} labels={growthLabels} />
</Panel>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -36,7 +36,7 @@ function colorFor(
if (action === "swap" || action === "shift" || action === "merge-write" || action === "overwrite") return stateColor.swap; if (action === "swap" || action === "shift" || action === "merge-write" || action === "overwrite") return stateColor.swap;
return stateColor.compare; return stateColor.compare;
} }
if (range && i >= range[0] && i <= range[1]) return "hsl(var(--state-range) / 0.55)"; if (range && i >= range[0] && i <= range[1]) return "hsl(var(--state-active-range) / 0.55)";
return stateColor.idle; return stateColor.idle;
} }
+3 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import Panel from "@/components/Panel";
interface NarrationProps { interface NarrationProps {
text: string; text: string;
@@ -10,7 +11,7 @@ interface NarrationProps {
export default function Narration({ text, stepIndex, title }: NarrationProps) { export default function Narration({ text, stepIndex, title }: NarrationProps) {
return ( return (
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5"> <Panel>
<p className="text-xs uppercase tracking-widest text-muted-foreground mb-2">{title}</p> <p className="text-xs uppercase tracking-widest text-muted-foreground mb-2">{title}</p>
<div className="min-h-[3.5rem]"> <div className="min-h-[3.5rem]">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@@ -26,6 +27,6 @@ export default function Narration({ text, stepIndex, title }: NarrationProps) {
</motion.p> </motion.p>
</AnimatePresence> </AnimatePresence>
</div> </div>
</div> </Panel>
); );
} }
+11
View File
@@ -0,0 +1,11 @@
import { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export default function Panel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 md:p-6", className)}
{...props}
/>
);
}
+3 -2
View File
@@ -3,6 +3,7 @@
import { Play, Pause, Shuffle, RotateCcw } from "lucide-react"; import { Play, Pause, Shuffle, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import Panel from "@/components/Panel";
export type Speed = "slow" | "normal" | "fast" | "turbo"; export type Speed = "slow" | "normal" | "fast" | "turbo";
@@ -38,7 +39,7 @@ export default function PlaybackControls({
}; };
return ( return (
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5 space-y-5"> <Panel className="space-y-5">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button onClick={onTogglePlay} size="lg" className="gap-2 min-w-[112px]"> <Button onClick={onTogglePlay} size="lg" className="gap-2 min-w-[112px]">
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />} {isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
@@ -95,6 +96,6 @@ export default function PlaybackControls({
disabled={disabled} disabled={disabled}
/> />
</div> </div>
</div> </Panel>
); );
} }
+1 -1
View File
@@ -40,7 +40,7 @@ interface SiteFooterProps {
export default function SiteFooter({ year, rightsLabel, licenseLabel, viewLicenseLabel, lang, onLangChange }: SiteFooterProps) { export default function SiteFooter({ year, rightsLabel, licenseLabel, viewLicenseLabel, lang, onLangChange }: SiteFooterProps) {
return ( return (
<footer className="border-t border-border bg-card/40 py-6 mt-16"> <footer className="border-t border-border bg-card/40 py-6 mt-16">
<div className="container mx-auto px-4 md:px-6 flex flex-wrap items-center justify-between gap-4"> <div className="container mx-auto max-w-6xl px-4 md:px-6 flex flex-wrap items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">&copy; {year} Quintavalle Pietro. {rightsLabel}</p> <p className="text-sm text-muted-foreground">&copy; {year} Quintavalle Pietro. {rightsLabel}</p>
<nav className="flex items-center gap-4"> <nav className="flex items-center gap-4">
<LanguagePicker value={lang} onChange={onLangChange} /> <LanguagePicker value={lang} onChange={onLangChange} />
+4 -3
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Panel from "@/components/Panel";
interface StatItemProps { interface StatItemProps {
label: string; label: string;
@@ -16,7 +17,7 @@ function StatItem({ label, value }: StatItemProps) {
initial={{ opacity: 0.4 }} initial={{ opacity: 0.4 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
className="text-2xl font-mono tabular-nums text-primary" className="text-2xl font-mono tabular-nums font-semibold text-foreground"
> >
{value} {value}
</motion.span> </motion.span>
@@ -40,7 +41,7 @@ export default function StatsPanel({
title, comparisons, writes, stepLabel, stepIndex, stepCount, sortedLabel, sortedCount, total, title, comparisons, writes, stepLabel, stepIndex, stepCount, sortedLabel, sortedCount, total,
}: StatsPanelProps) { }: StatsPanelProps) {
return ( return (
<div className="rounded-xl border border-border bg-card/60 backdrop-blur-sm p-5"> <Panel>
<p className="text-xs uppercase tracking-widest text-muted-foreground mb-3">{title}</p> <p className="text-xs uppercase tracking-widest text-muted-foreground mb-3">{title}</p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<StatItem label="Comparisons" value={comparisons} /> <StatItem label="Comparisons" value={comparisons} />
@@ -48,6 +49,6 @@ export default function StatsPanel({
<StatItem label={stepLabel} value={`${stepIndex} / ${stepCount}`} /> <StatItem label={stepLabel} value={`${stepIndex} / ${stepCount}`} />
<StatItem label={sortedLabel} value={`${sortedCount} / ${total}`} /> <StatItem label={sortedLabel} value={`${sortedCount} / ${total}`} />
</div> </div>
</div> </Panel>
); );
} }
-79
View File
@@ -1,79 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }