From e57bef9f0bca1cff29148fef7a3021660abeeccc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 23:28:39 +0000 Subject: [PATCH] web: a sketched caveman mascot, and clearer clickables - Caveman: an original coloured-pencil-style Palaeolithic digger drawn as inline SVG with CSS animation. He chips at the top of the landing core (tap him to shake it), strolls along the footer rule, scratches his head on the 404 page, and cheers when a file is dragged onto the upload box. All motion stops under prefers-reduced-motion. - How it works: the Upload / Place / Fit / Test steps are raised, bordered keys with a chevron that lift on hover; the step action is a button. - How it is built: a "tap a box" hint, a plus badge on each box, and a halo that glows over the boxes in turn until one is touched. - Upload: the whole drop zone opens the file picker, with hover, focus and drag states and a "Choose a file" button. - Buttons, tabs and labels show a pointer cursor site-wide (Tailwind's reset gives buttons the arrow), and looping animations rest instead of flickering under reduced motion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HEuHS4cfoZR8XF5eD6UH64 --- apps/web/src/app/globals.css | 389 ++++++++++++++++++ apps/web/src/app/not-found.tsx | 12 +- apps/web/src/app/page.tsx | 2 +- apps/web/src/app/upload/page.tsx | 50 +-- apps/web/src/components/BuildDiagram.tsx | 43 +- apps/web/src/components/Caveman.tsx | 228 ++++++++++ apps/web/src/components/Explainer.tsx | 29 +- apps/web/src/components/SiteFooter.tsx | 8 +- apps/web/src/components/charts/CoreSample.tsx | 20 +- docs/screenshots/mascot/build-map.png | Bin 0 -> 356038 bytes docs/screenshots/mascot/digger.gif | Bin 0 -> 228383 bytes docs/screenshots/mascot/footer-walker.png | Bin 0 -> 40623 bytes docs/screenshots/mascot/landing-hero.png | Bin 0 -> 546553 bytes docs/screenshots/mascot/not-found.png | Bin 0 -> 93170 bytes docs/screenshots/mascot/phone-hero.png | Bin 0 -> 298075 bytes docs/screenshots/mascot/phone-steps.png | Bin 0 -> 128714 bytes docs/screenshots/mascot/steps-hover.png | Bin 0 -> 313358 bytes docs/screenshots/mascot/upload-dragging.png | Bin 0 -> 108049 bytes docs/screenshots/mascot/upload-hover.png | Bin 0 -> 114316 bytes 19 files changed, 745 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/components/Caveman.tsx create mode 100644 docs/screenshots/mascot/build-map.png create mode 100644 docs/screenshots/mascot/digger.gif create mode 100644 docs/screenshots/mascot/footer-walker.png create mode 100644 docs/screenshots/mascot/landing-hero.png create mode 100644 docs/screenshots/mascot/not-found.png create mode 100644 docs/screenshots/mascot/phone-hero.png create mode 100644 docs/screenshots/mascot/phone-steps.png create mode 100644 docs/screenshots/mascot/steps-hover.png create mode 100644 docs/screenshots/mascot/upload-dragging.png create mode 100644 docs/screenshots/mascot/upload-hover.png 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 (