diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 47d4447..90e8252 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -114,12 +114,23 @@ input[type="radio"] { accent-color: var(--color-malachite); } + /* Tailwind's reset gives buttons the arrow cursor; anything you can press shows a hand here. */ + button:not(:disabled), + [role="button"], + [role="tab"], + label[for], + summary, + select { + cursor: pointer; + } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-delay: 0ms !important; + /* One pass, so looping animations rest instead of flickering through their frames. */ + animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } @@ -626,3 +637,381 @@ .term[aria-expanded="true"] { color: var(--color-malachite); } + +/* ---- Caveman ------------------------------------------------------------------------------ + * Parts rotate about joints given in the drawing's own units (viewBox 0 0 64 80). Poses that + * hold an arm up set it as a plain transform too, so they still read with motion turned off. + * ------------------------------------------------------------------------------------------ */ +.cm .cm-fig { + transform-origin: 32px 76px; +} +.cm .cm-torso { + transform-origin: 32px 76px; + animation: cm-breathe 3.4s ease-in-out infinite; +} +.cm .cm-eyes { + transform-origin: 32px 22.4px; + animation: cm-blink 5.3s ease-in-out infinite; +} +.cm .cm-head { + transform-origin: 32px 33px; + transition: transform 300ms; +} +.cm .cm-look { + transition: transform 300ms; +} +.cm .cm-tool-arm { + transform-origin: 21.5px 38.5px; +} +.cm .cm-free-arm { + transform-origin: 42.5px 38.5px; +} +.cm .cm-leg-l { + transform-origin: 27px 56px; +} +.cm .cm-leg-r { + transform-origin: 37px 56px; +} +.cm .cm-dust { + opacity: 0; + transform-box: fill-box; + transform-origin: center; +} +.cm .cm-hop { + animation: cm-hop 560ms cubic-bezier(0.3, 0.9, 0.4, 1) both; +} + +.cm[data-pose="dig"] .cm-tool-arm { + animation: cm-dig 3.6s cubic-bezier(0.45, 0, 0.3, 1) infinite; +} +.cm[data-pose="dig"] .cm-dust { + animation: cm-dust 3.6s ease-out infinite; +} +.cm[data-pose="dig"] .cm-look { + transform: translate(-0.8px, 0.9px); +} + +.cm[data-pose="walk"] .cm-leg-l { + animation: cm-step 0.62s ease-in-out infinite alternate; +} +.cm[data-pose="walk"] .cm-leg-r { + animation: cm-step 0.62s ease-in-out infinite alternate-reverse; +} +.cm[data-pose="walk"] .cm-torso { + animation: cm-bob 0.31s ease-in-out infinite alternate; +} +.cm[data-pose="walk"] .cm-tool-arm { + animation: cm-swing 0.62s ease-in-out infinite alternate-reverse; +} +.cm[data-pose="walk"] .cm-free-arm { + animation: cm-swing 0.62s ease-in-out infinite alternate; +} +.cm[data-pose="walk"] .cm-look { + transform: translateX(1.3px); +} + +.cm[data-pose="puzzled"] .cm-free-arm { + transform: rotate(-146deg); + animation: cm-scratch 2.8s ease-in-out infinite; +} +.cm[data-pose="puzzled"] .cm-head { + transform: rotate(-7deg); +} +.cm[data-pose="puzzled"] .cm-look { + transform: translate(1px, -0.9px); +} +.cm .cm-q { + transform-box: fill-box; + transform-origin: center; + animation: cm-q 2.8s ease-in-out infinite; +} + +.cm[data-pose="cheer"] .cm-tool-arm { + transform: rotate(120deg); + animation: cm-cheer-l 0.5s ease-in-out infinite alternate; +} +.cm[data-pose="cheer"] .cm-free-arm { + transform: rotate(-120deg); + animation: cm-cheer-r 0.5s ease-in-out infinite alternate; +} +.cm[data-pose="cheer"] .cm-fig { + animation: cm-bounce 0.5s ease-in-out infinite alternate; +} +.cm[data-pose="cheer"] .cm-head { + transform: rotate(4deg); +} + +/* A wave hello when the pointer comes near (on whatever element carries .cm-host). */ +.cm-host:hover .cm:is([data-pose="dig"], [data-pose="idle"]) .cm-free-arm, +.cm-host:focus-visible .cm:is([data-pose="dig"], [data-pose="idle"]) .cm-free-arm { + animation: cm-wave 1.25s ease-in-out both; +} +.cm-host:hover .cm:is([data-pose="dig"], [data-pose="idle"]) .cm-look { + transform: translate(0.6px, -0.4px); +} +.cm-button { + display: inline-block; + line-height: 0; + padding: 0; + background: none; + border: 0; + border-radius: 8px; +} + +@keyframes cm-breathe { + 50% { + transform: scaleY(1.018); + } +} +@keyframes cm-blink { + 0%, + 90%, + 97%, + 100% { + transform: none; + } + 93% { + transform: scaleY(0.1); + } +} +@keyframes cm-dig { + 0%, + 52% { + transform: none; + } + 66% { + transform: rotate(62deg); + } + 70% { + transform: rotate(66deg); + } + 77% { + transform: rotate(-5deg); + } + 82% { + transform: rotate(1deg); + } + 88%, + 100% { + transform: none; + } +} +@keyframes cm-dust { + 0%, + 76% { + opacity: 0; + transform: scale(0.4); + } + 79% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: translate(var(--dx), var(--dy)) scale(1.1); + } +} +@keyframes cm-hop { + 0% { + transform: none; + } + 30% { + transform: translateY(-8px); + } + 55% { + transform: scale(1.05, 0.95); + } + 75% { + transform: scale(0.98, 1.02); + } + 100% { + transform: none; + } +} +@keyframes cm-wave { + 0%, + 100% { + transform: none; + } + 22% { + transform: rotate(-128deg); + } + 40% { + transform: rotate(-100deg); + } + 58% { + transform: rotate(-130deg); + } + 76% { + transform: rotate(-104deg); + } +} +@keyframes cm-step { + from { + transform: rotate(-16deg); + } + to { + transform: rotate(16deg); + } +} +@keyframes cm-bob { + to { + transform: translateY(-1.2px); + } +} +@keyframes cm-swing { + from { + transform: rotate(-12deg); + } + to { + transform: rotate(12deg); + } +} +@keyframes cm-scratch { + 0%, + 12%, + 24%, + 36%, + 100% { + transform: rotate(-146deg); + } + 6%, + 18%, + 30% { + transform: rotate(-158deg); + } +} +@keyframes cm-q { + 0%, + 100% { + transform: translateY(0) rotate(-8deg); + } + 50% { + transform: translateY(-2.5px) rotate(8deg); + } +} +@keyframes cm-cheer-l { + from { + transform: rotate(108deg); + } + to { + transform: rotate(132deg); + } +} +@keyframes cm-cheer-r { + from { + transform: rotate(-108deg); + } + to { + transform: rotate(-132deg); + } +} +@keyframes cm-bounce { + to { + transform: translateY(-3px); + } +} + +/* The footer digger strolls along the sediment rule and turns at each end. */ +.cm-walker { + position: absolute; + bottom: calc(100% - 3px); + left: 0; + pointer-events: none; + animation: cm-stroll 84s linear infinite; +} +@keyframes cm-stroll { + 0% { + left: 0; + transform: none; + } + 49.4% { + left: calc(100% - 30px); + transform: none; + } + 50% { + left: calc(100% - 30px); + transform: scaleX(-1); + } + 99.4% { + left: 0; + transform: scaleX(-1); + } + 100% { + left: 0; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .cm *, + .cm-walker { + animation: none !important; + } +} + +/* ---- Things you can press ----------------------------------------------------------------- */ + +/* The upload drop zone: the whole box opens the file picker. */ +.dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 15.5rem; + padding: 1.25rem 1.5rem; + text-align: center; + border: 2px dashed #b9c1bf; + border-radius: var(--radius-panel); + background: var(--color-panel); + transition: + border-color 150ms, + background-color 150ms, + box-shadow 150ms; +} +.dropzone:hover { + border-color: var(--color-malachite); + background: color-mix(in srgb, var(--color-malachite-soft) 55%, white); +} +.dropzone[data-drag="true"] { + border-style: solid; + border-color: var(--color-malachite); + background: var(--color-malachite-soft); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-malachite) 16%, transparent); +} +.dropzone-input:focus-visible + .dropzone { + outline: 2px solid var(--color-malachite); + outline-offset: 3px; +} +.dropzone:hover .btn-quiet, +.dropzone-input:focus-visible + .dropzone .btn-quiet { + border-color: var(--color-malachite); + color: var(--color-malachite); + transform: translateY(-1px); + box-shadow: 0 4px 0 #b9c1bf; +} + +/* Diagram boxes breathe a halo until someone touches one, so the map reads as pressable. */ +@keyframes strata-beckon { + 0%, + 100% { + stroke-opacity: 0; + } + 50% { + stroke-opacity: 0.7; + } +} +/* A hand that taps, beside "tap a box". */ +@keyframes strata-tap { + 0%, + 60%, + 100% { + transform: none; + } + 70% { + transform: translate(1px, 2px) scale(0.92); + } + 80% { + transform: none; + } +} diff --git a/apps/web/src/app/not-found.tsx b/apps/web/src/app/not-found.tsx index bd0ae66..691259f 100644 --- a/apps/web/src/app/not-found.tsx +++ b/apps/web/src/app/not-found.tsx @@ -1,14 +1,18 @@ import Link from "next/link"; import { StrataMark } from "@/components/StrataMark"; +import { Caveman } from "@/components/Caveman"; export default function NotFound() { return (
-
-
-
- +
+
+
+
+ +
+

Nothing at this depth.

diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index c0609af..f180933 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -58,7 +58,7 @@ function LibraryCore() { const individuals = pops.data!.reduce((a, p) => a + (p.n_samples ?? 0), 0); return (
- +
{int(total)} populations,{" "} {int(individuals)} individuals. Click a layer to browse it. diff --git a/apps/web/src/app/upload/page.tsx b/apps/web/src/app/upload/page.tsx index 0a9628a..83b665c 100644 --- a/apps/web/src/app/upload/page.tsx +++ b/apps/web/src/app/upload/page.tsx @@ -7,6 +7,7 @@ import { parseG25 } from "@/lib/g25"; import { relativeExpiry } from "@/lib/format"; import type { Dataset } from "@/lib/types"; import { QcReport } from "@/components/QcReport"; +import { Caveman } from "@/components/Caveman"; import { useSession } from "@/components/SessionProvider"; import { Caveat, ErrorNote, PageHeader, Section } from "@/components/typography"; @@ -88,47 +89,48 @@ export default function UploadPage() {

23andMe, AncestryDNA, FamilyTreeDNA, MyHeritage or VCF. Up to 60 MB.

-
pick(e.target.files?.[0])} + /> +
+ diff --git a/apps/web/src/components/BuildDiagram.tsx b/apps/web/src/components/BuildDiagram.tsx index 1d23d63..0826bd4 100644 --- a/apps/web/src/components/BuildDiagram.tsx +++ b/apps/web/src/components/BuildDiagram.tsx @@ -196,7 +196,13 @@ export interface BuildStats { export function BuildDiagram({ stats }: { stats: BuildStats }) { const [wrap, width] = useWidth(820); const [seenRef, seen] = useSeen(0.25); - const [sel, setSel] = useState("engine"); + const [sel, setSelected] = useState("engine"); + // Until someone points at a box, the others glow in turn to show they can be opened. + const [touched, setTouched] = useState(false); + const setSel = (id: Id) => { + setSelected(id); + setTouched(true); + }; const L = width < 620 ? NARROW : WIDE; const linked = (e: Edge) => e.from === sel || e.to === sel; @@ -224,6 +230,18 @@ export function BuildDiagram({ stats }: { stats: BuildStats }) {
+

+ + + + Tap a box to see what it does. +

@@ -297,6 +315,20 @@ export function BuildDiagram({ stats }: { stats: BuildStats }) { animation: seen ? `strata-pop 320ms cubic-bezier(.3,1.5,.5,1) ${i * 70}ms both` : undefined, }} > + {!touched && !on ? ( + + ) : null} {n.sub} + {/* A corner badge: plus to open, filled when this box is the one shown below. */} + + + {on ? ( + + ) : ( + + )} + ); })} diff --git a/apps/web/src/components/Caveman.tsx b/apps/web/src/components/Caveman.tsx new file mode 100644 index 0000000..21ecb43 --- /dev/null +++ b/apps/web/src/components/Caveman.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; + +/* ------------------------------------------------------------------------------------------ + * The site's small Palaeolithic digger, drawn like a coloured pencil sketch: ink outlines over + * fills printed a hair off register, with a slow "boil" on the lines. Every movement is CSS + * (see the Caveman section of globals.css) and stops under prefers-reduced-motion. + * + * dig chips at the ground with a hafted flint pick + * idle breathes and blinks + * walk strolls (the footer moves him; this only swings the legs) + * puzzled scratches his head + * cheer both arms up, bouncing + * ---------------------------------------------------------------------------------------- */ + +export type CavemanPose = "dig" | "idle" | "walk" | "puzzled" | "cheer"; + +const INK = "#1e2630"; +const SKIN = "#e8b98d"; +const HAIR = "#4a3424"; +const HAIR_LIGHT = "#7a5a42"; +const FUR = "#a46a3a"; +const FUR_DARK = "#7d4b27"; +const FLINT = "#7f8b98"; +const FLINT_LIGHT = "#c3ccd4"; +const WOOD = "#8a5418"; +const MALACHITE = "#1d6b56"; + +/** Fill nudged off its outline, the way a marker colours a pencil sketch. */ +const OFF = "translate(0.9 0.7)"; + +function Shape({ d, fill, sw = 1.4 }: { d: string; fill: string; sw?: number }) { + return ( + <> + + + + ); +} + +/** A limb: a thick ink stroke with a thinner skin stroke inside it. */ +function Limb({ d }: { d: string }) { + return ( + <> + + + + ); +} + +function useReducedMotion(): boolean { + // Starts true so the static export never ships an animated filter to someone who opted out. + const [reduced, setReduced] = useState(true); + useEffect(() => { + const m = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (!m) return setReduced(false); + const update = () => setReduced(m.matches); + update(); + m.addEventListener("change", update); + return () => m.removeEventListener("change", update); + }, []); + return reduced; +} + +export interface CavemanProps { + pose?: CavemanPose; + /** Height in CSS pixels; the width follows (0.8 of it). */ + size?: number; + className?: string; + /** When given, he becomes a button that hops when pressed. */ + onPoke?: () => void; + /** Accessible name when he is a button. */ + label?: string; + style?: React.CSSProperties; +} + +export function Caveman({ pose = "idle", size = 72, className, onPoke, label = "Caveman", style }: CavemanProps) { + const uid = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const reduced = useReducedMotion(); + const [hops, setHops] = useState(0); + const rough = `cm-rough-${uid}`; + + const art = ( + + + + + {reduced ? null : } + + + + + + + + + + {/* Legs, each with a hide wrap on the foot */} + + + + + + + + + + + {/* Fur tunic with a ragged hem */} + + + + + + + + + + {/* Pick arm: a flint point lashed to a stick */} + + + + + + + + + + + + + + + + + {/* Ears, face */} + + + + + + + {/* Shaggy hair with a fringe */} + + + + {/* Beard, open around the mouth */} + + + + {/* Heavy brow, eyes, a round nose, a smile */} + + + + + + + + + + + + + + {/* A cord with two malachite beads and a tooth */} + + + + + + + + {/* Chips thrown up when the pick lands */} + + + + + + + {pose === "puzzled" ? ( + + ? + + ) : null} + + + ); + + if (!onPoke) + return ( + + {art} + + ); + return ( + + ); +} diff --git a/apps/web/src/components/Explainer.tsx b/apps/web/src/components/Explainer.tsx index 15dd745..705a088 100644 --- a/apps/web/src/components/Explainer.tsx +++ b/apps/web/src/components/Explainer.tsx @@ -351,10 +351,21 @@ export function Explainer() { aria-controls="step-panel" tabIndex={on ? 0 : -1} onClick={() => pick(i)} - className={`group relative overflow-hidden rounded-[var(--radius-panel)] border px-1 py-2 text-center lg:text-left transition-[background-color,border-color,transform] duration-200 lg:px-4 lg:py-3 ${ - on ? "border-ink bg-panel" : "border-transparent hover:-translate-y-px hover:bg-panel/70" + className={`group relative overflow-hidden rounded-[var(--radius-panel)] border px-1 py-2 text-center lg:text-left transition-[background-color,border-color,box-shadow,transform] duration-150 lg:py-3 lg:pr-9 lg:pl-4 ${ + on + ? "border-malachite bg-panel shadow-[0_0_0_1px_var(--color-malachite)]" + : "border-rule bg-panel/75 shadow-[0_3px_0_var(--color-rule)] hover:-translate-y-0.5 hover:border-ink-3/50 hover:bg-panel hover:shadow-[0_5px_0_#b9c1bf] active:translate-y-0.5 active:shadow-[0_1px_0_#b9c1bf]" }`} > + ))}
-
@@ -417,8 +435,11 @@ export function Explainer() { {hood ? s.hood : s.plain}

- + {s.action} + + → + Illustration, not real data.
diff --git a/apps/web/src/components/SiteFooter.tsx b/apps/web/src/components/SiteFooter.tsx index ae4c329..967f002 100644 --- a/apps/web/src/components/SiteFooter.tsx +++ b/apps/web/src/components/SiteFooter.tsx @@ -1,9 +1,15 @@ import Link from "next/link"; +import { Caveman } from "./Caveman"; export function SiteFooter() { return (