@@ -148,7 +149,7 @@ export function YoyoScene({ dict }: { dict: Dict }) {
function Caption({ tag, text }: { tag: string; text: string }) {
return (
-
+
diff --git a/components/sections/home/AiScams.tsx b/components/sections/home/AiScams.tsx
new file mode 100644
index 0000000..79af065
--- /dev/null
+++ b/components/sections/home/AiScams.tsx
@@ -0,0 +1,130 @@
+import Link from "next/link";
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+import { CheckIcon } from "../shared/icons";
+
+const FONT = "Plus Jakarta Sans,sans-serif";
+const FTC_ALERT =
+ "https://consumer.ftc.gov/consumer-alerts/2026/09/scammers-are-spoofing-car-dealership-websites-what-you-need-know";
+const FTC_REPORT = "https://reportfraud.ftc.gov/";
+
+export function AiScams({
+ dict,
+ rightsHref,
+}: {
+ dict: HomePageDict["ai"];
+ rightsHref: string;
+}) {
+ const a = dict.art;
+ const art = [
+
+
+
+
+
+
+
+
+
+
+
+ {a.boosted}
+
+ ,
+
+
+
+
+
+ {a.license}
+
+
+ {a.dealerName}
+
+
+ {a.dealerDropped}
+
+
+
+
+
+
+ ,
+
+
+
+
+
+ {a.loanFunded}
+
+
+ {a.loanAmount}
+
+
+ {a.now}
+
+ ,
+
+
+
+
+
+
+
+
+ {a.delivered}
+
+ ,
+ ];
+
+ return (
+
+
+
+
{dict.title}
+
{dict.body}
+
+
+ {dict.steps.map((s, i) => (
+
+ {art[i]}
+ {s.title}
+ {s.body}
+
+ ))}
+
+
{dict.source}
+
+
+
{dict.saveStat}
+
{dict.saveTitle}
+
{dict.saveBody}
+
+ {dict.saveCta}
+
+
+
+
+
{dict.tipsTitle}
+
+
+ {dict.tips.map((t) => (
+
+
+ {t}
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/components/sections/home/CampusChapters.tsx b/components/sections/home/CampusChapters.tsx
new file mode 100644
index 0000000..3bda8a5
--- /dev/null
+++ b/components/sections/home/CampusChapters.tsx
@@ -0,0 +1,84 @@
+import Image from "next/image";
+import Link from "next/link";
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+import { ArrowRightIcon } from "../shared/icons";
+
+// Arched frames echo the arches of Royce Hall and the Campanile.
+const CAMPUSES = [
+ {
+ chapter: "ucla",
+ frame: "royce",
+ src: "/images/campus/royce-hall.webp",
+ width: 697,
+ height: 321,
+ // Sized by height (60% of the frame), so it renders wider than the card.
+ sizes: "(max-width: 720px) 120vw, 560px",
+ },
+ {
+ chapter: "berkeley",
+ frame: "tower",
+ src: "/images/campus/campanile.webp",
+ width: 523,
+ height: 477,
+ // Sized by height (96% of the frame).
+ sizes: "(max-width: 720px) 95vw, 460px",
+ },
+ {
+ chapter: "ucsc",
+ frame: "photo",
+ src: "/images/campus/quarry-amphitheater.webp",
+ width: 1200,
+ height: 800,
+ sizes: "(max-width: 720px) 78vw, 380px",
+ },
+] as const;
+
+export function CampusChapters({
+ dict,
+ joinHref,
+}: {
+ dict: HomePageDict["chapters"];
+ // Base join URL; the chapter is passed as ?chapter= to preselect the form.
+ joinHref: string;
+}) {
+ return (
+
+
+
+
{dict.title}
+
{dict.body}
+
+
+ {CAMPUSES.map((c, i) => {
+ const item = dict.items[i];
+ return (
+
+
+
+
+
+
{item.tag}
+
{item.title}
+
{item.body}
+
+ {item.cta}
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/components/sections/home/EqualFooting.tsx b/components/sections/home/EqualFooting.tsx
new file mode 100644
index 0000000..162924c
--- /dev/null
+++ b/components/sections/home/EqualFooting.tsx
@@ -0,0 +1,70 @@
+import type { ReactNode } from "react";
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+
+const FONT = "Plus Jakarta Sans,sans-serif";
+
+export function EqualFooting({ dict }: { dict: HomePageDict["footing"] }) {
+ const art: ReactNode[] = [
+
+
+
+
+
+ {dict.quoteA}
+
+
+ {dict.quoteB}
+
+ ,
+
+
+
+
+
+
+
+
+ !
+
+ ,
+
+
+
+
+
+
+ ,
+ ];
+
+ return (
+
+
+
+
{dict.title}
+
+
+ {dict.quote}
+
+
+ {dict.quoteCite}
+
+
+
{dict.body}
+
+
+ {dict.rows.map((row, i) => (
+
+
{art[i]}
+
+
{row.stat}
+
{row.title}
+
{row.body}
+
{row.cite}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/sections/home/FollowLinks.tsx b/components/sections/home/FollowLinks.tsx
new file mode 100644
index 0000000..42861a6
--- /dev/null
+++ b/components/sections/home/FollowLinks.tsx
@@ -0,0 +1,61 @@
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+import { SOCIAL_URLS } from "@/lib/social";
+import { ArrowUpRightIcon } from "../shared/icons";
+
+const LINKS = [
+ { url: SOCIAL_URLS.linkedin, kind: "linkedin" },
+ { url: SOCIAL_URLS.instagramUcla, kind: "instagram" },
+ { url: SOCIAL_URLS.instagramBerkeley, kind: "instagram" },
+ { url: SOCIAL_URLS.instagramUcsc, kind: "instagram" },
+] as const;
+
+export function FollowLinks({ dict }: { dict: HomePageDict["follow"] }) {
+ return (
+
+
+
+
{dict.title}
+
{dict.body}
+
+
+
+
+ );
+}
+
+function LinkedInIcon() {
+ return (
+
+
+
+ );
+}
+
+function InstagramIcon() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/components/sections/home/FounderStrikes.tsx b/components/sections/home/FounderStrikes.tsx
new file mode 100644
index 0000000..aa931a1
--- /dev/null
+++ b/components/sections/home/FounderStrikes.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useRef, useState, type KeyboardEvent } from "react";
+import Link from "next/link";
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+
+export function FounderStrikes({
+ dict,
+ storyHref,
+}: {
+ dict: HomePageDict["strikes"];
+ storyHref: string;
+}) {
+ const [active, setActive] = useState(0);
+ const tabs = useRef<(HTMLButtonElement | null)[]>([]);
+ const count = dict.items.length;
+
+ function onKeyDown(e: KeyboardEvent
, i: number) {
+ let next: number | null = null;
+ if (e.key === "ArrowRight") next = (i + 1) % count;
+ else if (e.key === "ArrowLeft") next = (i + count - 1) % count;
+ else if (e.key === "Home") next = 0;
+ else if (e.key === "End") next = count - 1;
+ if (next === null) return;
+ e.preventDefault();
+ setActive(next);
+ tabs.current[next]?.focus();
+ }
+
+ return (
+
+
+
{dict.title}
+
{dict.body}
+
+ {dict.items.map((s, i) => (
+ {
+ tabs.current[i] = el;
+ }}
+ type="button"
+ role="tab"
+ id={`strike-tab-${i}`}
+ aria-controls={`strike-panel-${i}`}
+ aria-selected={active === i}
+ tabIndex={active === i ? 0 : -1}
+ onClick={() => setActive(i)}
+ onKeyDown={(e) => onKeyDown(e, i)}
+ >
+ {dict.tab.replace("{n}", s.n)}
+
+ ))}
+
+ {dict.items.map((s, i) => (
+
+
+
+ {s.n}
+
+
{s.title}
+
{s.meta}
+
+
+ {s.paras.map((p) => (
+
{p}
+ ))}
+ {i === count - 1 && (
+
+ {dict.readMore}
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/sections/home/FreeResources.tsx b/components/sections/home/FreeResources.tsx
new file mode 100644
index 0000000..3a9fc52
--- /dev/null
+++ b/components/sections/home/FreeResources.tsx
@@ -0,0 +1,37 @@
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+import { CheckIcon } from "../shared/icons";
+
+export function FreeResources({ dict }: { dict: HomePageDict["free"] }) {
+ return (
+
+
+
+
{dict.title}
+
{dict.body}
+
+
+
+
{dict.planTitle}
+
{dict.price}
+
+
+ {dict.checks.map((c) => (
+
+
+ {c}
+
+ ))}
+
+
+ {dict.offers.map((o) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/sections/home/HomeCta.tsx b/components/sections/home/HomeCta.tsx
new file mode 100644
index 0000000..78237b6
--- /dev/null
+++ b/components/sections/home/HomeCta.tsx
@@ -0,0 +1,22 @@
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+import { ChecklistArt, CtaBand } from "../shared/CtaBand";
+
+export function HomeCta({
+ dict,
+ ppiHref,
+ documentsHref,
+}: {
+ dict: HomePageDict["cta"];
+ ppiHref: string;
+ documentsHref: string;
+}) {
+ return (
+ }
+ />
+ );
+}
diff --git a/components/sections/home/HomeHero.tsx b/components/sections/home/HomeHero.tsx
new file mode 100644
index 0000000..a34c802
--- /dev/null
+++ b/components/sections/home/HomeHero.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+import { usePrefersReducedMotion } from "../shared/usePrefersReducedMotion";
+
+// Typewriter timings (ms).
+const TYPE_MS = 70;
+const ERASE_MS = 35;
+const HOLD_MS = 1600;
+const GAP_MS = 300;
+
+export function HomeHero({
+ dict,
+ ppiHref,
+ storyHref,
+}: {
+ dict: HomePageDict["hero"];
+ ppiHref: string;
+ storyHref: string;
+}) {
+ const reduce = usePrefersReducedMotion();
+ const phrases = dict.phrases;
+ // Start with the first phrase fully shown so the heading is never blank.
+ const [text, setText] = useState(phrases[0] ?? "");
+ const [busy, setBusy] = useState(false);
+
+ // Hold the phrase, erase it, then type the next one.
+ useEffect(() => {
+ if (reduce || phrases.length === 0) return;
+ let timer: ReturnType;
+ let i = 0;
+ let n = phrases[0].length;
+ let erasing = true;
+ const tick = () => {
+ const phrase = phrases[i];
+ if (!erasing) {
+ n += 1;
+ setText(phrase.slice(0, n));
+ if (n < phrase.length) {
+ setBusy(true);
+ timer = setTimeout(tick, TYPE_MS);
+ } else {
+ setBusy(false);
+ erasing = true;
+ timer = setTimeout(tick, HOLD_MS);
+ }
+ } else {
+ n -= 1;
+ setBusy(true);
+ setText(phrase.slice(0, n));
+ if (n > 0) {
+ timer = setTimeout(tick, ERASE_MS);
+ } else {
+ erasing = false;
+ i = (i + 1) % phrases.length;
+ setBusy(false);
+ timer = setTimeout(tick, GAP_MS);
+ }
+ }
+ };
+ timer = setTimeout(tick, HOLD_MS);
+ return () => clearTimeout(timer);
+ }, [reduce, phrases]);
+
+ return (
+
+
+
+
+ {dict.titleLead}{" "}
+ {/* Screen readers get one stable heading; the rotating words are visual only. */}
+ {dict.phrases[0]}
+
+ {/* Every phrase, invisible and stacked, so the box is always as
+ tall as the longest one and the page never jumps. */}
+
+ {phrases.map((p) => (
+ {p}
+ ))}
+
+ {reduce ? (
+ {phrases[0]}
+ ) : (
+
+ {text}
+
+
+ )}
+
+
+
{dict.body}
+
+
+ {dict.ctaPrimary}
+
+
+ {dict.ctaSecondary}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/sections/home/PhoneNotifications.tsx b/components/sections/home/PhoneNotifications.tsx
new file mode 100644
index 0000000..6d362ba
--- /dev/null
+++ b/components/sections/home/PhoneNotifications.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+import { usePrefersReducedMotion } from "../shared/usePrefersReducedMotion";
+
+// A phone lock screen whose notifications slide in one by one the first
+// time it scrolls into view.
+export function PhoneNotifications({
+ dict,
+}: {
+ dict: HomePageDict["publicKnowledge"];
+}) {
+ const reduce = usePrefersReducedMotion();
+ const phoneRef = useRef(null);
+ const [shown, setShown] = useState(0);
+
+ useEffect(() => {
+ if (reduce) return;
+ const el = phoneRef.current;
+ if (!el) return;
+ const timers: ReturnType[] = [];
+ const io = new IntersectionObserver(
+ (entries) => {
+ if (!entries.some((e) => e.isIntersecting)) return;
+ io.disconnect();
+ dict.notifs.forEach((_, i) =>
+ timers.push(setTimeout(() => setShown(i + 1), 300 + i * 700)),
+ );
+ },
+ { threshold: 0.4 },
+ );
+ io.observe(el);
+ return () => {
+ io.disconnect();
+ timers.forEach(clearTimeout);
+ };
+ }, [reduce, dict.notifs]);
+
+ return (
+
+
+
+
{dict.clock}
+ {dict.notifs.map((n, i) => (
+
+
+ {dict.appName} {n.time}
+
+ {n.body}
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/sections/home/Pillars.tsx b/components/sections/home/Pillars.tsx
new file mode 100644
index 0000000..19bc6cb
--- /dev/null
+++ b/components/sections/home/Pillars.tsx
@@ -0,0 +1,44 @@
+import Link from "next/link";
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+
+export function Pillars({
+ dict,
+ ppiHref,
+ fraudHref,
+}: {
+ dict: HomePageDict["pillars"];
+ ppiHref: string;
+ fraudHref: string;
+}) {
+ const [inspection, fraud] = dict.items;
+ return (
+
+
+
{dict.title}
+
{dict.body}
+
+
+
+
{inspection.title}
+
{inspection.body}
+
+
+
+
{fraud.title}
+
{fraud.body}
+
+
+
+
+ );
+}
diff --git a/components/sections/home/PriceIsRightPreview.tsx b/components/sections/home/PriceIsRightPreview.tsx
new file mode 100644
index 0000000..a22a2ce
--- /dev/null
+++ b/components/sections/home/PriceIsRightPreview.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+
+// Illustrative numbers until the real pricing pipeline is live.
+const OPTIONS = [12400, 15900, 19800];
+const FAIR = 15900;
+
+export function PriceIsRightPreview({
+ dict,
+ locale,
+ gameHref,
+}: {
+ dict: HomePageDict["preview"];
+ locale: string;
+ gameHref: string;
+}) {
+ const [picked, setPicked] = useState(null);
+ const money = (n: number) =>
+ new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 0,
+ }).format(n);
+
+ const verdict =
+ picked === null
+ ? null
+ : picked === FAIR
+ ? dict.right
+ : picked > FAIR
+ ? dict.over.replace("{amount}", money(picked - FAIR))
+ : dict.low;
+
+ return (
+
+
+
+
{dict.tag}
+
{dict.title}
+
{dict.body}
+
+ {dict.cta}
+
+
+
+
+
+
+
{dict.carName}
+
+ {dict.carTags.map((t) => (
+ {t}
+ ))}
+
+
+ {dict.question}
+
+
+ {OPTIONS.map((v) => {
+ const state =
+ picked === null
+ ? undefined
+ : v === FAIR
+ ? "right"
+ : v === picked
+ ? "wrong"
+ : undefined;
+ return (
+ setPicked(v)}
+ >
+ {money(v)}
+
+ );
+ })}
+
+
+ {verdict && (
+ <>
+ {verdict} {dict.breakdown}{" "}
+ {dict.playAll}
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/components/sections/home/PublicKnowledge.tsx b/components/sections/home/PublicKnowledge.tsx
new file mode 100644
index 0000000..b1c8953
--- /dev/null
+++ b/components/sections/home/PublicKnowledge.tsx
@@ -0,0 +1,36 @@
+import Link from "next/link";
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+import { PhoneNotifications } from "./PhoneNotifications";
+import { Rich } from "../shared/Rich";
+
+export function PublicKnowledge({
+ dict,
+ ppiHref,
+ fraudHref,
+}: {
+ dict: HomePageDict["publicKnowledge"];
+ ppiHref: string;
+ fraudHref: string;
+}) {
+ return (
+
+
+
+
{dict.title}
+
+
+
+
+
+ {dict.ctaPrimary}
+
+
+ {dict.ctaSecondary}
+
+
+
+
+
+
+ );
+}
diff --git a/components/sections/home/ResearchSources.tsx b/components/sections/home/ResearchSources.tsx
new file mode 100644
index 0000000..d06c736
--- /dev/null
+++ b/components/sections/home/ResearchSources.tsx
@@ -0,0 +1,16 @@
+import type { HomePageDict } from "@/lib/i18n/dictionary";
+
+export function ResearchSources({ dict }: { dict: HomePageDict["sources"] }) {
+ return (
+
+
+
{dict.heading}
+
+ {dict.items.map((s) => (
+ {s}
+ ))}
+
+
+
+ );
+}
diff --git a/components/sections/inspection/PpiDrive.tsx b/components/sections/inspection/PpiDrive.tsx
new file mode 100644
index 0000000..16de747
--- /dev/null
+++ b/components/sections/inspection/PpiDrive.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { InspectionPageDict } from "@/lib/i18n/dictionary";
+import { Rich } from "../shared/Rich";
+import { usePrefersReducedMotion } from "../shared/usePrefersReducedMotion";
+
+type Dict = InspectionPageDict["schedule"];
+
+const ROAD =
+ "M20 190 C 180 190, 220 70, 400 70 S 620 200, 800 190 S 1020 70, 1180 80";
+
+// A car drives along a winding road as you scroll, stopping at each step.
+export function PpiDrive({ dict }: { dict: Dict }) {
+ const reduce = usePrefersReducedMotion();
+ const sectionRef = useRef(null);
+ const pathRef = useRef(null);
+ const carRef = useRef(null);
+ const [stopPoints, setStopPoints] = useState<{ x: number; y: number }[]>([]);
+ const [step, setStep] = useState(-1);
+ const [pop, setPop] = useState(false);
+ const count = dict.stops.length;
+
+ useEffect(() => {
+ const path = pathRef.current;
+ const section = sectionRef.current;
+ const car = carRef.current;
+ if (!path || !section || !car) return;
+ const L = path.getTotalLength();
+ const marks = dict.stops.map((_, i) => 0.08 + i * (0.84 / (count - 1)));
+ setStopPoints(
+ marks.map((m) => {
+ const p = path.getPointAtLength(m * L);
+ return { x: p.x, y: p.y };
+ }),
+ );
+
+ let ticking = false;
+ const update = () => {
+ ticking = false;
+ const r = section.getBoundingClientRect();
+ const total = r.height - window.innerHeight;
+ const p = reduce ? 1 : Math.min(1, Math.max(0, -r.top / total));
+ const t = Math.min(0.999, p);
+ const a = path.getPointAtLength(t * L);
+ const b = path.getPointAtLength(Math.min(L, t * L + 2));
+ const ang = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
+ car.setAttribute("transform", `translate(${a.x},${a.y}) rotate(${ang})`);
+ let i = -1;
+ marks.forEach((m, k) => {
+ if (t >= m - 0.02) i = k;
+ });
+ setStep(i);
+ };
+ const onScroll = () => {
+ if (!ticking) {
+ ticking = true;
+ requestAnimationFrame(update);
+ }
+ };
+ update();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ window.addEventListener("resize", update);
+ return () => {
+ window.removeEventListener("scroll", onScroll);
+ window.removeEventListener("resize", update);
+ };
+ }, [reduce, count, dict.stops]);
+
+ // Replay the pop-in each time the car reaches a new stop.
+ useEffect(() => {
+ if (step < 0) return;
+ setPop(false);
+ const r = requestAnimationFrame(() => requestAnimationFrame(() => setPop(true)));
+ return () => cancelAnimationFrame(r);
+ }, [step]);
+
+ const stop = step >= 0 ? dict.stops[step] : null;
+
+ return (
+
+
+
+
+
{dict.title}
+
{dict.body}
+
+
+
+
+
+
+ {stopPoints.map((p, i) => (
+
+
+
+ {i + 1}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {stop ? (
+ <>
+
+ {dict.stop.replace("{n}", String(step + 1)).replace("{total}", String(count))}
+
+
{stop.title}
+
{stop.body}
+ >
+ ) : (
+ <>
+
{dict.introTag}
+
{dict.introTitle}
+
{dict.introBody}
+ >
+ )}
+
+
+
+ {dict.alertTitle}
+
+
+
+
+ {/* The steps as a plain list for screen readers and no-JS readers. */}
+
+ {dict.stops.map((s) => (
+
+ {s.title} {s.body}
+
+ ))}
+
+
+ );
+}
diff --git a/components/sections/inspection/PpiSteps.tsx b/components/sections/inspection/PpiSteps.tsx
deleted file mode 100644
index 2a49b4b..0000000
--- a/components/sections/inspection/PpiSteps.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { InspectionPageDict } from "@/lib/i18n/dictionary";
-import { Rich } from "../shared/Rich";
-
-export function PpiSteps({ dict }: { dict: InspectionPageDict["schedule"] }) {
- return (
-
-
-
{dict.title}
-
- {dict.stops.map((s) => (
-
- {s.title}
- {s.body}
-
- ))}
-
-
- {dict.alertTitle}
-
-
-
-
- );
-}
diff --git a/components/sections/research/BuildTimeline.tsx b/components/sections/research/BuildTimeline.tsx
new file mode 100644
index 0000000..a6715bd
--- /dev/null
+++ b/components/sections/research/BuildTimeline.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { ResearchPageDict } from "@/lib/i18n/dictionary";
+
+// Week 1 starts Monday, September 28, 2026.
+const START = new Date(2026, 8, 28);
+const WEEK_MS = 7 * 864e5;
+
+export function BuildTimeline({ dict }: { dict: ResearchPageDict["timeline"] }) {
+ // Computed after mount: the page is prerendered, so the server doesn't
+ // know today's date.
+ const [week, setWeek] = useState(null);
+ useEffect(() => {
+ setWeek(Math.floor((Date.now() - START.getTime()) / WEEK_MS));
+ }, []);
+
+ return (
+
+
{dict.title}
+
{dict.body}
+
+ {dict.weeks.map(([date, text], i) => {
+ const state =
+ week === null ? undefined : i < week ? "past" : i === week ? "now" : undefined;
+ return (
+
+ {dict.week.replace("{n}", String(i + 1))}
+ {date}
+ {text}
+ {state === "now" && {dict.now} }
+
+ );
+ })}
+
+
+ );
+}
diff --git a/components/sections/research/PriceIsRightGame.tsx b/components/sections/research/PriceIsRightGame.tsx
new file mode 100644
index 0000000..2f9b8a2
--- /dev/null
+++ b/components/sections/research/PriceIsRightGame.tsx
@@ -0,0 +1,441 @@
+"use client";
+
+import { useEffect, useRef, useState, type CSSProperties } from "react";
+import Image from "next/image";
+import type { ResearchPageDict } from "@/lib/i18n/dictionary";
+
+type GameDict = ResearchPageDict["game"];
+type Region = keyof GameDict["regions"];
+type StateCode = keyof GameDict["states"];
+
+// Illustrative numbers until the real pricing pipeline is live.
+const ROUNDS = [
+ {
+ img: "/images/cars/2018-honda-civic.webp",
+ width: 618,
+ height: 462,
+ type: "sedan",
+ bad: [] as number[],
+ parts: [15200, 400, 300],
+ },
+ {
+ img: "/images/cars/2015-ford-f150.webp",
+ width: 1000,
+ height: 439,
+ type: "truck",
+ bad: [1],
+ parts: [19000, -2800, -2200, -500],
+ },
+ {
+ img: "/images/cars/2020-toyota-camry.webp",
+ width: 864,
+ height: 477,
+ type: "sedan",
+ bad: [1],
+ parts: [21500, 500, -6500, -200],
+ },
+] as const;
+
+const REGION_ADJUST: Record = {
+ west: { sedan: 0.03, truck: -0.02 },
+ mountain: { sedan: 0, truck: 0.04 },
+ southwest: { sedan: -0.01, truck: 0.05 },
+ midwest: { sedan: -0.02, truck: 0.02 },
+ south: { sedan: -0.01, truck: 0.03 },
+ northeast: { sedan: 0.01, truck: 0 },
+};
+
+const STATE_REGION: Record = {
+ AL: "south", AK: "west", AZ: "southwest", AR: "south", CA: "west",
+ CO: "mountain", CT: "northeast", DE: "northeast", DC: "northeast",
+ FL: "south", GA: "south", HI: "west", ID: "mountain", IL: "midwest",
+ IN: "midwest", IA: "midwest", KS: "midwest", KY: "south", LA: "south",
+ ME: "northeast", MD: "northeast", MA: "northeast", MI: "midwest",
+ MN: "midwest", MS: "south", MO: "midwest", MT: "mountain",
+ NE: "midwest", NV: "mountain", NH: "northeast", NJ: "northeast",
+ NM: "southwest", NY: "northeast", NC: "south", ND: "midwest",
+ OH: "midwest", OK: "southwest", OR: "west", PA: "northeast",
+ RI: "northeast", SC: "south", SD: "midwest", TN: "south",
+ TX: "southwest", UT: "mountain", VT: "northeast", VA: "south",
+ WA: "west", WV: "south", WI: "midwest", WY: "mountain",
+};
+
+const MIN = 3000;
+const MAX = 40000;
+const START_GUESS = 15000;
+
+type Phase = "start" | "guess" | "reveal" | "done";
+type Result = {
+ guess: number;
+ fair: number;
+ points: number;
+ parts: [string, number][];
+};
+
+export function PriceIsRightGame({
+ dict,
+ locale,
+}: {
+ dict: GameDict;
+ locale: string;
+}) {
+ const [state, setState] = useState("");
+ const [phase, setPhase] = useState("start");
+ const [round, setRound] = useState(0);
+ const [guess, setGuess] = useState(START_GUESS);
+ const [score, setScore] = useState(0);
+ const [result, setResult] = useState(null);
+ const [animate, setAnimate] = useState(false);
+ const selectRef = useRef(null);
+ const focusRef = useRef(null);
+
+ const money = (n: number) =>
+ new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 0,
+ }).format(Math.round(n));
+ const compact = (n: number) =>
+ new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency: "USD",
+ notation: "compact",
+ }).format(n);
+ const fill = (s: string, vars: Record) =>
+ Object.entries(vars).reduce((t, [k, v]) => t.replace(`{${k}}`, String(v)), s);
+
+ const stateName = state ? dict.states[state] : "";
+ const sortedStates = (Object.keys(dict.states) as StateCode[]).sort((a, b) =>
+ dict.states[a].localeCompare(dict.states[b], locale),
+ );
+
+ // Move focus to the new content after each phase change.
+ useEffect(() => {
+ focusRef.current?.focus();
+ }, [phase, round]);
+
+ useEffect(() => {
+ if (phase !== "reveal") return setAnimate(false);
+ const r = requestAnimationFrame(() =>
+ requestAnimationFrame(() => setAnimate(true)),
+ );
+ return () => cancelAnimationFrame(r);
+ }, [phase, round]);
+
+ function partsFor(i: number): [string, number][] {
+ const r = ROUNDS[i];
+ const labels = dict.rounds[i].parts;
+ const parts: [string, number][] = r.parts.map((v, k) => [labels[k], v]);
+ if (!state) return parts;
+ const region = STATE_REGION[state];
+ const base = r.parts.reduce((s, v) => s + v, 0);
+ const adj = Math.round((base * REGION_ADJUST[region][r.type]) / 50) * 50;
+ return [
+ ...parts,
+ [fill(dict.stateAdjust, { state: stateName, note: dict.regions[region] }), adj],
+ ];
+ }
+
+ function startGame() {
+ setRound(0);
+ setScore(0);
+ setGuess(START_GUESS);
+ setPhase("guess");
+ }
+
+ function lockIn() {
+ const parts = partsFor(round);
+ const fair = parts.reduce((s, p) => s + p[1], 0);
+ const pct = Math.abs(guess - fair) / fair;
+ const points =
+ Math.max(0, Math.round(1000 * (1 - pct * 2))) + (pct <= 0.05 ? 250 : 0);
+ setScore((s) => s + points);
+ setResult({ guess, fair, points, parts });
+ setPhase("reveal");
+ }
+
+ function next() {
+ if (round < ROUNDS.length - 1) {
+ setRound(round + 1);
+ setGuess(START_GUESS);
+ setPhase("guess");
+ } else {
+ setPhase("done");
+ }
+ }
+
+ function changeState() {
+ setPhase("start");
+ requestAnimationFrame(() => selectRef.current?.focus());
+ }
+
+ const car = ROUNDS[round];
+ const carCopy = dict.rounds[round];
+
+ return (
+
+
+
+
{dict.tag}
+
{dict.title}
+
{dict.body}
+
+
+ {dict.score}
+ {score.toLocaleString(locale)}
+
+
+
+ {phase === "start" && (
+
+
+ {dict.stateLabel}
+
+
{dict.stateBody}
+
+ setState(e.target.value as StateCode | "")}
+ >
+ {dict.statePlaceholder}
+ {sortedStates.map((code) => (
+
+ {dict.states[code]}
+
+ ))}
+
+
+ {dict.start}
+
+
+
+ )}
+
+ {phase !== "start" && (
+
+ {phase !== "done" && (
+
+
+
+
+
+
+ {fill(dict.round, { n: round + 1, total: ROUNDS.length })}
+
+
{
+ if (phase === "guess") focusRef.current = el;
+ }}
+ >
+ {carCopy.name}
+
+
+ {carCopy.tags.map((t, i) => (
+
+ {t}
+
+ ))}
+ {stateName}
+
+
+ {dict.changeState}
+
+
+
+ )}
+
+ {phase === "guess" && (
+
+
+ {dict.guessLabel}
+
+
+ {money(guess)}
+
+
setGuess(+e.target.value)}
+ />
+
+ {compact(MIN)}
+ {compact(MAX)}
+
+
+ {dict.lockIn}
+
+
+ )}
+
+ {phase === "reveal" && result && (
+
+ )}
+
+ {phase === "done" && (
+
+
+
{fill(dict.done, { state: stateName })}
+
{ focusRef.current = el; }}>
+ {fill(dict.points, { points: score.toLocaleString(locale) })}
+
+
{dict.doneBody}
+
+
+ {dict.again}
+
+
+ {dict.otherState}
+
+
+
+
+ )}
+
+ )}
+
{dict.disclaimer}
+
+ );
+}
+
+function Reveal({
+ dict,
+ result,
+ stateCode,
+ tip,
+ money,
+ animate,
+ last,
+ onNext,
+ focusRef,
+}: {
+ dict: GameDict;
+ result: Result;
+ stateCode: StateCode;
+ tip: string;
+ money: (n: number) => string;
+ animate: boolean;
+ last: boolean;
+ onNext: () => void;
+ focusRef: React.MutableRefObject;
+}) {
+ const { guess, fair, points, parts } = result;
+ const diff = guess - fair;
+ const pct = Math.abs(diff) / fair;
+ const verdict =
+ pct <= 0.05
+ ? dict.nailed
+ : diff > 0
+ ? dict.over.replace("{amount}", money(diff))
+ : dict.under.replace("{amount}", money(-diff));
+ const max = Math.max(...parts.slice(1).map((p) => Math.abs(p[1])), 1);
+ const signed = (v: number, i: number) =>
+ i === 0 ? money(v) : v === 0 ? money(0) : `${v > 0 ? "+" : "−"}${money(Math.abs(v))}`;
+
+ return (
+
+
+
+ {dict.compareGuess}
+ {money(guess)}
+
+
+ {dict.compareFair.replace("{state}", stateCode)}
+ {money(fair)}
+
+
+ {dict.comparePoints}
+ +{points.toLocaleString()}
+
+
+
0 && pct > 0.05 ? " over" : ""}`}
+ tabIndex={-1}
+ ref={(el) => {
+ focusRef.current = el;
+ }}
+ >
+ {verdict}
+
+
{dict.why}
+
+ {parts.map(([label, v], i) => (
+
+ {label}
+ 0 ? "pos" : "zero"}
+ style={{ "--w": `${i === 0 ? 100 : Math.max(6, (Math.abs(v) / max) * 100)}%` } as CSSProperties}
+ aria-hidden="true"
+ />
+ {signed(v, i)}
+
+ ))}
+
+
{dict.tip.replace("{tip}", tip)}
+
+ {last ? dict.seeScore : dict.next}
+
+
+ );
+}
+
+// Slides the car in from the side each time a new round mounts it.
+function CarPhoto({
+ alt,
+ ...props
+}: {
+ src: string;
+ alt: string;
+ width: number;
+ height: number;
+}) {
+ const [shown, setShown] = useState(false);
+ useEffect(() => {
+ const r = requestAnimationFrame(() =>
+ requestAnimationFrame(() => setShown(true)),
+ );
+ return () => cancelAnimationFrame(r);
+ }, []);
+ return (
+
+ );
+}
diff --git a/components/sections/research/ProjectCard.tsx b/components/sections/research/ProjectCard.tsx
new file mode 100644
index 0000000..f62660d
--- /dev/null
+++ b/components/sections/research/ProjectCard.tsx
@@ -0,0 +1,71 @@
+import Link from "next/link";
+import type { ResearchPageDict } from "@/lib/i18n/dictionary";
+import { applyHref, type ProjectMeta } from "./projects";
+
+type ProjectCopy =
+ ResearchPageDict["tracks"]["app"]["projects"][number];
+
+export function ProjectCard({
+ copy,
+ meta,
+ dict,
+ joinHref,
+}: {
+ copy: ProjectCopy;
+ meta: ProjectMeta;
+ dict: ResearchPageDict;
+ joinHref: string;
+}) {
+ return (
+
+
+ {meta.chapter ? (
+ {dict.chapters[meta.chapter]}
+ ) : (
+
+ )}
+ {dict.status}
+
+ {copy.name}
+ {copy.desc}
+
+
+
+
+
+
+ {copy.skills.map((s) => (
+ {s}
+ ))}
+
+
+
+
+
+
+
+
+
{copy.members}
+
+ {Array.from({ length: 10 }, (_, i) => (
+
+ ))}
+
+
+
+
+
+ {copy.join}
+
+
+ );
+}
diff --git a/components/sections/research/TrackNav.tsx b/components/sections/research/TrackNav.tsx
new file mode 100644
index 0000000..d520346
--- /dev/null
+++ b/components/sections/research/TrackNav.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { TrackKey } from "./projects";
+
+// Sticky pill nav that highlights the track currently under the header.
+export function TrackNav({
+ label,
+ items,
+}: {
+ label: string;
+ items: { key: TrackKey; title: string; count: number }[];
+}) {
+ const [current, setCurrent] = useState(items[0].key);
+ const rowRef = useRef(null);
+
+ // On narrow screens the pills overflow sideways; keep the active one
+ // visible without moving the page itself.
+ useEffect(() => {
+ const row = rowRef.current;
+ const pill = row?.querySelector("a.on");
+ if (!row || !pill) return;
+ const left = pill.offsetLeft - row.clientWidth / 2 + pill.offsetWidth / 2;
+ row.scrollTo({ left, behavior: "smooth" });
+ }, [current]);
+
+ useEffect(() => {
+ let ticking = false;
+ const spy = () => {
+ ticking = false;
+ let cur = items[0].key;
+ for (const { key } of items) {
+ const el = document.getElementById(key);
+ if (el && el.getBoundingClientRect().top < 200) cur = key;
+ }
+ setCurrent(cur);
+ };
+ const onScroll = () => {
+ if (!ticking) {
+ ticking = true;
+ requestAnimationFrame(spy);
+ }
+ };
+ spy();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, [items]);
+
+ return (
+
+
+
+ );
+}
diff --git a/components/sections/research/projects.ts b/components/sections/research/projects.ts
index 7d37a11..2ea6942 100644
--- a/components/sections/research/projects.ts
+++ b/components/sections/research/projects.ts
@@ -15,38 +15,41 @@ export type RoleKey =
export type ProjectMeta = {
chapter?: ChapterKey;
role: RoleKey;
+ // Seats shown as dots: `filled` guaranteed, `range` more possible, out of 10.
+ filled: number;
+ range: number;
};
export const TRACKS: { key: TrackKey; projects: ProjectMeta[] }[] = [
{
key: "app",
projects: [
- { role: "software" },
- { role: "software" },
+ { role: "software", filled: 2, range: 2 },
+ { role: "software", filled: 2, range: 2 },
],
},
{
key: "data",
projects: [
- { role: "data" },
- { chapter: "berkeley", role: "data" },
- { chapter: "berkeley", role: "data" },
+ { role: "data", filled: 5, range: 0 },
+ { chapter: "berkeley", role: "data", filled: 5, range: 0 },
+ { chapter: "berkeley", role: "data", filled: 5, range: 0 },
],
},
{
key: "research",
projects: [
- { chapter: "ucla", role: "research" },
- { chapter: "ucla", role: "research" },
- { chapter: "ucla", role: "legal" },
+ { chapter: "ucla", role: "research", filled: 4, range: 2 },
+ { chapter: "ucla", role: "research", filled: 4, range: 2 },
+ { chapter: "ucla", role: "legal", filled: 6, range: 4 },
],
},
{
key: "outreach",
projects: [
- { role: "design" },
- { role: "outreach" },
- { role: "outreach" },
+ { role: "design", filled: 2, range: 1 },
+ { role: "outreach", filled: 2, range: 1 },
+ { role: "outreach", filled: 3, range: 2 },
],
},
];
diff --git a/components/sections/shared/CtaBand.tsx b/components/sections/shared/CtaBand.tsx
index 30b7d9f..238ee39 100644
--- a/components/sections/shared/CtaBand.tsx
+++ b/components/sections/shared/CtaBand.tsx
@@ -1,36 +1,67 @@
+import type { ReactNode } from "react";
import Link from "next/link";
type CtaLink = { label: string; href: string };
-// The closing call to action on most pages: a heading, a line of text,
-// and a button or two.
+// The purple call-to-action card that closes most redesigned pages.
export function CtaBand({
title,
body,
primary,
secondary,
+ art,
}: {
title: string;
body: string;
primary: CtaLink;
secondary?: CtaLink;
+ art: ReactNode;
}) {
return (
-
+
-
{title}
-
{body}
-
-
- {primary.label}
-
- {secondary && (
-
- {secondary.label}
-
- )}
+
+
+
{title}
+
{body}
+
+
+ {primary.label}
+
+ {secondary && (
+
+ {secondary.label}
+
+ )}
+
+
+ {art}
);
}
+
+export function ChecklistArt() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export function PeopleArt() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/sections/shared/PageHero.tsx b/components/sections/shared/PageHero.tsx
index 47c138a..1bbf579 100644
--- a/components/sections/shared/PageHero.tsx
+++ b/components/sections/shared/PageHero.tsx
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
-// The title block at the top of every inner page.
+// The tinted hero at the top of every inner page.
export function PageHero({
title,
body,
diff --git a/components/sections/shared/icons.tsx b/components/sections/shared/icons.tsx
index 721921d..9d306c1 100644
--- a/components/sections/shared/icons.tsx
+++ b/components/sections/shared/icons.tsx
@@ -17,3 +17,40 @@ export function CheckIcon({ size = 22 }: { size?: number }) {
);
}
+
+export function ArrowRightIcon() {
+ return (
+
+
+
+ );
+}
+
+export function ArrowUpRightIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/components/sections/team/TeamRoster.tsx b/components/sections/team/TeamRoster.tsx
index 03097b1..446b995 100644
--- a/components/sections/team/TeamRoster.tsx
+++ b/components/sections/team/TeamRoster.tsx
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import type { Chapter, Social, TeamVideo } from "@/lib/team";
import type { TeamPageDict } from "@/lib/i18n/dictionary";
+import { usePrefersReducedMotion } from "../shared/usePrefersReducedMotion";
export type RosterMember = {
name: string;
@@ -54,7 +55,7 @@ export function TeamRoster({
return (
<>
-
+
{FILTERS.map((f) => (
void;
}) {
+ const reduce = usePrefersReducedMotion();
const dialogRef = useRef(null);
const videoRef = useRef(null);
+ const [open, setOpen] = useState(false);
useEffect(() => {
const d = dialogRef.current;
if (!d || !member) return;
d.showModal();
+ const raf = requestAnimationFrame(() => setOpen(true));
videoRef.current?.play().catch(() => {});
+ return () => cancelAnimationFrame(raf);
}, [member]);
function close() {
videoRef.current?.pause();
- dialogRef.current?.close();
- onClose();
+ setOpen(false);
+ setTimeout(
+ () => {
+ dialogRef.current?.close();
+ onClose();
+ },
+ reduce ? 0 : 220,
+ );
}
const v = member?.video;
return (
{
e.preventDefault();
diff --git a/lib/fonts.ts b/lib/fonts.ts
index a4e9a0a..dd6b04c 100644
--- a/lib/fonts.ts
+++ b/lib/fonts.ts
@@ -1,7 +1,7 @@
-import { Caveat } from "next/font/google";
+import { Bitter, Caveat } from "next/font/google";
-// Accent font used by a single section. Apply `.variable` on the page
-// wrapper that needs it so other pages don't load it.
+// Accent fonts used by a single section each. Apply `.variable` on the
+// page wrapper that needs them so other pages don't load them.
// Handwriting on the four-square worksheet (Fraud page).
export const caveat = Caveat({
@@ -11,3 +11,10 @@ export const caveat = Caveat({
display: "swap",
});
+// Newspaper-style headlines on the Dealer Map news cards.
+export const bitter = Bitter({
+ subsets: ["latin"],
+ weight: ["400", "700"],
+ variable: "--font-bitter",
+ display: "swap",
+});
diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json
index 2a3d169..56054a4 100644
--- a/lib/i18n/dictionaries/en.json
+++ b/lib/i18n/dictionaries/en.json
@@ -198,9 +198,23 @@
"walk away."
],
"body": "Most people buy a car without basic car knowledge. Putting that much money into something without knowing what it's worth can be financially devastating. So we explain it all in plain language, for free.",
- "ctaPrimary": "Get the Inspection Guide",
+ "ctaPrimary": "Get the PPI Guide",
"ctaSecondary": "Our story",
- "photoAlt": "VINdicated's UC Berkeley chapter together on the lawn in front of Doe Library, with the Campanile behind them"
+ "photoAlt": "VINdicated's UC Berkeley chapter together on the lawn in front of Doe Library, with the Campanile behind them",
+ "cards": [
+ {
+ "title": "Inspection passed",
+ "body": "Independent mechanic"
+ },
+ {
+ "title": "Red flag spotted",
+ "body": "Seller wants a wire transfer"
+ },
+ {
+ "title": "APR 6.9%",
+ "body": "Within CA limit"
+ }
+ ]
},
"sources": {
"heading": "Our numbers come from",
@@ -219,13 +233,33 @@
},
"."
],
- "cta": "See common scams"
+ "ctaPrimary": "Start the inspection guide",
+ "ctaSecondary": "See common scams",
+ "phoneLabel": "Example of VINdicated flagging dealer red flags on a phone",
+ "clock": "2:14",
+ "appName": "VINdicated",
+ "notifs": [
+ {
+ "time": "now",
+ "body": "Red flag: this dealer's site only takes wire transfers up front. Ask to see the car in person first."
+ },
+ {
+ "time": "2 min ago",
+ "body": "Red flag: a financing contract on a cash deal. Don't sign until you know how it's voided."
+ },
+ {
+ "time": "5 min ago",
+ "body": "Checklist done. Ask for the service records before you negotiate."
+ }
+ ]
},
"footing": {
"title": "You shouldn't pay more because of who you are. In California, it's illegal.",
"quote": "“All persons within the jurisdiction of this state are free and equal, and no matter what their sex, race, color, religion, ancestry, national origin… are entitled to the full and equal accommodations, advantages, facilities, privileges, or services in all business establishments of every kind whatsoever.”",
"quoteCite": "Unruh Civil Rights Act, Cal. Civ. Code § 51",
"body": "Studies show dealers quote different prices to different people. Scammers also have better tools now.",
+ "quoteA": "Quote A",
+ "quoteB": "Quote B",
"rows": [
{
"stat": "$1,100",
@@ -247,6 +281,26 @@
}
]
},
+ "preview": {
+ "tag": "Preview",
+ "title": "Guess what a used car is worth.",
+ "body": "Our UC Berkeley team is building Price Is Right, a game that trains your eye for a good deal. Here's a taste.",
+ "cta": "Play the full preview",
+ "carAlt": "Gray 2018 Honda Civic sedan",
+ "carName": "2018 Honda Civic LX",
+ "carTags": [
+ "72,000 miles",
+ "Clean title",
+ "1 owner"
+ ],
+ "question": "What's a fair price?",
+ "optionsLabel": "Price options",
+ "right": "Nailed it.",
+ "over": "You'd overpay by {amount}.",
+ "low": "Too low.",
+ "breakdown": "Base value $15,200, plus $400 for low miles and $300 for one owner.",
+ "playAll": "Play all three cars →"
+ },
"chapters": {
"title": "Find us on campus.",
"body": "We have chapters at UCLA, UC Berkeley, and UC Santa Cruz. Find yours and come to a meeting.",
@@ -296,6 +350,53 @@
}
]
},
+ "ai": {
+ "title": "Scammers are using AI now, too.",
+ "body": "Fake dealer websites, listing photos, and paperwork are getting easier to make. This is what happened to one buyer in September.",
+ "steps": [
+ {
+ "title": "The listing looked normal",
+ "body": "A fairly priced SUV shows up on Facebook Marketplace, boosted to reach more buyers."
+ },
+ {
+ "title": "The dealer was a copy",
+ "body": "Scammers clone a real dealership down to the paperwork, dropping a single word from its name."
+ },
+ {
+ "title": "The bank paid the fake dealer",
+ "body": "A major lender approves the loan and wires $36,240 to the fake dealer."
+ },
+ {
+ "title": "The truck never came",
+ "body": "A spoofed tracker shows the car crossing the country. It \"arrives\" at an empty street."
+ }
+ ],
+ "art": {
+ "boosted": "Boosted",
+ "license": "DEALER LICENSE",
+ "dealerName": "Route Motor",
+ "dealerDropped": "Co.",
+ "loanFunded": "Loan funded",
+ "loanAmount": "$36,240",
+ "now": "now",
+ "delivered": "Delivered?"
+ },
+ "source": "Based on reporting by Caleb Jacobs in The Drive, September 2026.",
+ "saveStat": "$0",
+ "saveTitle": "He didn't have to repay the loan.",
+ "saveBody": "Because he'd saved every message and document, the buyer used the FTC Holder Rule to get out of the loan. Most people don't know that rule exists.",
+ "saveCta": "Learn your rights",
+ "tipsTitle": "Before you pay anyone",
+ "tips": [
+ "Search the dealer's name with \"scam,\" \"review,\" or \"complaint.\"",
+ "Don't trust a dealer just because its reviews are good. A fake site can borrow a real dealer's reviews.",
+ "Ask to see the car and the lot in person. If they refuse, walk away.",
+ "Too far to visit? Send a mobile inspector. A real seller won't mind.",
+ "Never pay up front by wire transfer only."
+ ],
+ "ftcCta": "Read the FTC alert",
+ "reportCta": "Report a scam"
+ },
"pillars": {
"title": "Start with these two guides.",
"body": "Free guides you can pull up at the dealership.",
@@ -379,35 +480,11 @@
}
]
},
- "ppi": {
- "title": "Buying a used car? Get a PPI first.",
- "body": [
- "A ",
- {
- "b": "pre-purchase inspection (PPI)"
- },
- " is when you pay an independent mechanic, not the seller or the dealership, to check a car before you buy it. They find the problems a test drive won't show you."
- ],
- "items": [
- {
- "title": "What they check",
- "body": "The engine, transmission, brakes, suspension, and frame, plus leaks, rust, and signs of past accidents."
- },
- {
- "title": "What it costs",
- "body": "Usually $100–$200, and some AAA locations do it free or discounted for members. One hidden problem can cost you thousands."
- },
- {
- "title": "Where to go",
- "body": "A mechanic you trust, a mobile inspection service, or a dealership for that car's brand that isn't the one selling it."
- },
- {
- "title": "What you get",
- "body": "A written report you can use to negotiate the price down, or a good reason to walk away."
- }
- ],
- "primary": "Read the PPI Guide",
- "secondary": "Where to get one"
+ "cta": {
+ "title": "Buying a car soon?",
+ "body": "Start with the inspection guide, then read up on common dealer tricks.",
+ "primary": "Get the PPI Guide",
+ "secondary": "Decode your documents"
}
},
"about": {
@@ -698,24 +775,6 @@
"A VINdicated volunteer pointing to the UCLA display board"
]
},
- "photos": {
- "title": "Our chapters",
- "body": "Students at UCLA, UC Berkeley, and UC Santa Cruz who table, teach, and run research on car buying.",
- "items": [
- {
- "alt": "VINdicated volunteers tabling on the UC Berkeley campus beside a VINdicated banner"
- },
- {
- "alt": "Two VINdicated volunteers holding a VINdicated sign at UC Berkeley"
- },
- {
- "alt": "VINdicated volunteers at their UCLA tabling booth on a sunny activities day"
- },
- {
- "alt": "A VINdicated volunteer sharing free car-buying resources with a student at UCLA"
- }
- ]
- },
"mission": {
"title": "Educate. Empower. Vindicate.",
"body": "Our motto, and the order we work in.",
@@ -769,43 +828,6 @@
"name": "Rana Darwich",
"role": "Founder & President, VINdicated"
},
- "ai": {
- "title": "Scammers are using AI now, too.",
- "body": "Fake dealer websites, listing photos, and paperwork are getting easier to make. This is what happened to one buyer in September.",
- "steps": [
- {
- "title": "The listing looked normal",
- "body": "A fairly priced SUV shows up on Facebook Marketplace, boosted to reach more buyers."
- },
- {
- "title": "The dealer was a copy",
- "body": "Scammers clone a real dealership down to the paperwork, dropping a single word from its name."
- },
- {
- "title": "The bank paid the fake dealer",
- "body": "A major lender approves the loan and wires $36,240 to the fake dealer."
- },
- {
- "title": "The truck never came",
- "body": "A spoofed tracker shows the car crossing the country. It \"arrives\" at an empty street."
- }
- ],
- "source": "Based on reporting by Caleb Jacobs in The Drive, September 2026.",
- "saveStat": "$0",
- "saveTitle": "He didn't have to repay the loan.",
- "saveBody": "Because he'd saved every message and document, the buyer used the FTC Holder Rule to get out of the loan. Most people don't know that rule exists.",
- "saveCta": "Learn your rights",
- "tipsTitle": "Before you pay anyone",
- "tips": [
- "Search the dealer's name with \"scam,\" \"review,\" or \"complaint.\"",
- "Don't trust a dealer just because its reviews are good. A fake site can borrow a real dealer's reviews.",
- "Ask to see the car and the lot in person. If they refuse, walk away.",
- "Too far to visit? Send a mobile inspector. A real seller won't mind.",
- "Never pay up front by wire transfer only."
- ],
- "ftcCta": "Read the FTC alert",
- "reportCta": "Report a scam"
- },
"quote": {
"text": "Everyone gets taken advantage of at some point. We want you to notice when it's happening.",
"cite": "The idea behind VINdicated"
@@ -877,6 +899,7 @@
"body": "Students across our chapters are building apps, crunching data, doing research, and making content that helps people buy, finance, and fix cars without getting taken advantage of."
},
"navLabel": "Project teams",
+ "status": "Now forming",
"skillsLabel": "Skills",
"membersLabel": "Team size",
"chapters": {
@@ -1025,6 +1048,179 @@
]
}
},
+ "game": {
+ "tag": "Preview · UC Berkeley",
+ "title": "Try Price Is Right.",
+ "body": "Guess what this car should cost. Then see what pushed the price up or down.",
+ "score": "Score",
+ "stateLabel": "First, where are you buying?",
+ "stateBody": "Used-car prices shift from state to state. Pick yours and we'll adjust every fair price to match.",
+ "statePlaceholder": "Choose a state",
+ "start": "Start game",
+ "round": "Round {n} of {total}",
+ "changeState": "Change state",
+ "guessLabel": "Your guess",
+ "lockIn": "Lock in my guess",
+ "compareGuess": "Your guess",
+ "compareFair": "Fair price in {state}",
+ "comparePoints": "Points",
+ "nailed": "Nailed it.",
+ "over": "You'd have overpaid by {amount}.",
+ "under": "You guessed {amount} under.",
+ "why": "Why this price",
+ "tip": "Tip: {tip}",
+ "next": "Next car →",
+ "seeScore": "See my score",
+ "done": "Session complete · {state}",
+ "points": "{points} points",
+ "doneBody": "Mileage, title status, accidents, owners, and even your state all move the price. Now you know what to look for before you make an offer.",
+ "again": "Play again",
+ "otherState": "Try another state",
+ "stateAdjust": "{state} market, {note}",
+ "disclaimer": "Preview: sample rounds with illustrative prices and state adjustments. The full game launches after the November 6 demo and pulls fair prices from live listing data.",
+ "regions": {
+ "west": "where commuter sedans are in demand",
+ "mountain": "where trucks hold value for snow and towing",
+ "southwest": "where trucks sell high",
+ "midwest": "where road salt means more rust risk",
+ "south": "with steady truck demand",
+ "northeast": "where city buyers favor sedans"
+ },
+ "rounds": [
+ {
+ "name": "2018 Honda Civic LX",
+ "alt": "Gray 2018 Honda Civic sedan",
+ "tags": [
+ "72,000 miles",
+ "Clean title",
+ "1 owner"
+ ],
+ "parts": [
+ "Base value for this model and year",
+ "Low miles for its age",
+ "One owner"
+ ],
+ "tip": "One owner and a full service history are worth paying a little more for."
+ },
+ {
+ "name": "2015 Ford F-150 XL",
+ "alt": "White 2015 Ford F-150 pickup truck",
+ "tags": [
+ "138,000 miles",
+ "Accident reported",
+ "3 owners"
+ ],
+ "parts": [
+ "Base value for this model and year",
+ "High mileage",
+ "Accident on the history report",
+ "Three owners"
+ ],
+ "tip": "An accident on the report should always lower the price, even if the truck looks perfect."
+ },
+ {
+ "name": "2020 Toyota Camry LE",
+ "alt": "Black 2020 Toyota Camry sedan",
+ "tags": [
+ "41,000 miles",
+ "Rebuilt title",
+ "2 owners"
+ ],
+ "parts": [
+ "Base value for this model and year",
+ "Low miles",
+ "Rebuilt title",
+ "Two owners"
+ ],
+ "tip": "Rebuilt titles usually sell for 20 to 40% less. If the asking price doesn't reflect that, walk away."
+ }
+ ],
+ "states": {
+ "AL": "Alabama",
+ "AK": "Alaska",
+ "AZ": "Arizona",
+ "AR": "Arkansas",
+ "CA": "California",
+ "CO": "Colorado",
+ "CT": "Connecticut",
+ "DE": "Delaware",
+ "DC": "District of Columbia",
+ "FL": "Florida",
+ "GA": "Georgia",
+ "HI": "Hawaii",
+ "ID": "Idaho",
+ "IL": "Illinois",
+ "IN": "Indiana",
+ "IA": "Iowa",
+ "KS": "Kansas",
+ "KY": "Kentucky",
+ "LA": "Louisiana",
+ "ME": "Maine",
+ "MD": "Maryland",
+ "MA": "Massachusetts",
+ "MI": "Michigan",
+ "MN": "Minnesota",
+ "MS": "Mississippi",
+ "MO": "Missouri",
+ "MT": "Montana",
+ "NE": "Nebraska",
+ "NV": "Nevada",
+ "NH": "New Hampshire",
+ "NJ": "New Jersey",
+ "NM": "New Mexico",
+ "NY": "New York",
+ "NC": "North Carolina",
+ "ND": "North Dakota",
+ "OH": "Ohio",
+ "OK": "Oklahoma",
+ "OR": "Oregon",
+ "PA": "Pennsylvania",
+ "RI": "Rhode Island",
+ "SC": "South Carolina",
+ "SD": "South Dakota",
+ "TN": "Tennessee",
+ "TX": "Texas",
+ "UT": "Utah",
+ "VT": "Vermont",
+ "VA": "Virginia",
+ "WA": "Washington",
+ "WV": "West Virginia",
+ "WI": "Wisconsin",
+ "WY": "Wyoming"
+ }
+ },
+ "timeline": {
+ "title": "The six-week build",
+ "body": "Price Is Right goes from empty repo to final demo by November 6.",
+ "week": "Week {n}",
+ "now": "We're here",
+ "weeks": [
+ [
+ "Sep 28",
+ "Set up the repo, database, and app skeleton."
+ ],
+ [
+ "Oct 5",
+ "Choose data sources and pipeline tools; start building screens."
+ ],
+ [
+ "Oct 12",
+ "Finish the data pipeline and connect it to the app."
+ ],
+ [
+ "Oct 19",
+ "Build guessing, scoring, and the price reveal; fix bugs."
+ ],
+ [
+ "Oct 26",
+ "Finish the car-price part of the app."
+ ],
+ [
+ "Nov 2",
+ "Test end to end, fix bugs, final demo Nov 6."
+ ]
+ ]
+ },
"cta": {
"title": "No experience? That's fine.",
"body": "You'll learn the tools on the team. What matters is curiosity and a few hours a week.",
@@ -1069,7 +1265,7 @@
},
"engine": {
"title": "Inspect it yourself.",
- "body": "Tap any marked part, or start a guided inspection. We'll zoom in, walk you through the check, and keep score as you go.",
+ "body": "Tap any glowing part, or start a guided inspection. We'll zoom in, walk you through the check, and keep score as you go.",
"progress": "{n} of {total} checked",
"start": "Start guided inspection",
"imageAlt": "Illustrated engine bay with labeled parts including the fuse box, intake air tube, brake fluid reservoir, spark plugs, battery, coolant reservoir, radiator, and fan",
@@ -1395,7 +1591,13 @@
"cite": "Rana Darwich, VINdicated Founder"
},
"schedule": {
+ "label": "How to schedule a PPI",
"title": "How to schedule a PPI.",
+ "body": "Keep scrolling. The car stops at each step.",
+ "introTag": "6 stops",
+ "introTitle": "Start your engine.",
+ "introBody": "Scroll to drive.",
+ "stop": "Stop {n} of {total}",
"stops": [
{
"title": "Find your inspector first.",
@@ -1438,13 +1640,9 @@
"ctaPrimary": "See the red flags",
"ctaSecondary": "Know your rights"
},
- "tour": {
- "countIntro": "{total} stops",
- "countStep": "{n} / {total}"
- },
"pinkSlip": {
"title": "This document is your only proof you own the car.",
- "body": "A California Certificate of Title, the \"pink slip,\" is the legal document that proves ownership.",
+ "body": "A California Certificate of Title, the \"pink slip,\" is the legal document that proves ownership. Scroll to walk through it one section at a time.",
"imageAlt": "Sample California Certificate of Title",
"intro": "Six boxes decide whether the car is really yours. Scroll to spotlight each one.",
"items": [
@@ -1486,6 +1684,10 @@
}
]
},
+ "tour": {
+ "countIntro": "{total} stops",
+ "countStep": "{n} / {total}"
+ },
"flags": {
"title": "Car purchasing red flags.",
"body": "Six things dealers say, and exactly how to answer. Scroll to play each conversation.",
@@ -1805,6 +2007,7 @@
},
"after": {
"title": "What to do after a scam.",
+ "body": "Select a card to flip it.",
"items": [
{
"front": "Document everything immediately.",
@@ -1822,6 +2025,7 @@
},
"brochure": {
"coverAlt": "Cover of the VINdicated brochure",
+ "tag": "Take it with you",
"title": "The VINdicated brochure.",
"body": "The VIN, the yo-yo scam, the four-square trap, dealer add-ons, and your legal rights, folded into one handout. We bring it to tabling and school visits.",
"soon": "New edition coming soon as a free download."
@@ -1840,19 +2044,19 @@
"ctaPrimary": "Read a history report",
"ctaSecondary": "Read the Buyers Guide"
},
- "tour": {
- "countIntro": "{total} stops",
- "countStep": "{n} / {total}"
- },
"severity": {
"easy": "Easy to check",
"watch": "Watch carefully",
"red": "Serious red flag",
"know": "Need to know"
},
+ "tour": {
+ "countIntro": "{total} stops",
+ "countStep": "{n} / {total}"
+ },
"page1": {
"title": "How to read a Carfax, page one.",
- "body": "Mileage and VIN, accident history, reliability, and recent service.",
+ "body": "Mileage and VIN, accident history, reliability, and recent service. Scroll to walk through each section and what it means for the price.",
"imageAlt": "Page one of a sample Carfax vehicle history report for a 2013 Honda Accord",
"intro": "Scroll and each section of the report lights up.",
"items": [
@@ -1890,7 +2094,7 @@
},
"page2": {
"title": "How to read a Carfax, page two.",
- "body": "Title brands, the history grid, and the ownership table. This is where the biggest problems show up.",
+ "body": "Title brands, the history grid, and the ownership table. This is where the biggest problems show up. Scroll to walk through them.",
"imageAlt": "Page two of a sample Carfax vehicle history report showing the history grid, title history, and ownership table",
"intro": "Now the serious history. Keep scrolling.",
"items": [
@@ -1994,18 +2198,21 @@
"leadsLabel": "Leads:",
"items": [
{
+ "tag": "UCLA",
"name": "UCLA Chapter",
"school": "University of California, Los Angeles",
"focus": "Built the interactive dealership risk map and leads VINdicated's core consumer research and data infrastructure.",
"leads": "Fiona Wangsawidjaja, President · William Prawira, Internal Vice President"
},
{
+ "tag": "UC Berkeley",
"name": "UC Berkeley Chapter",
"school": "University of California, Berkeley",
"focus": "Currently building out its membership. Focused on consumer education and outreach.",
"leads": "Rana Darwich, President · Noirit Ghosh Choudhuri, Internal Vice President · Halima Cherif Hminat, External Vice President"
},
{
+ "tag": "UC Santa Cruz",
"name": "UC Santa Cruz Chapter",
"school": "University of California, Santa Cruz",
"focus": "Focused on algorithmic discrimination and AI bias in car sales.",
@@ -2093,44 +2300,55 @@
"news": {
"title": "Learn from others.",
"body": "Real stories of buyers who got taken advantage of, and what regulators are doing about it. Each one links to the original reporting.",
+ "credit": "Illustration: VINdicated",
"items": [
{
+ "kicker": "AI scams",
"title": "AI Car Dealer Scams Are Infesting Facebook Marketplace",
"byline": "By Caleb Jacobs / The Drive / Sept. 18, 2026",
"dek": "A California photographer financed a 4Runner from a Marketplace listing posted by a near-perfect clone of a real Texas dealer, with fake paperwork, a fake GPS tracker, and a synthetic driver's voice. The truck never arrived.",
"takeLabel": "The lesson",
- "take": "A valid VIN and a lender's approval don't prove a car exists. See it in person or send an inspector."
+ "take": "A valid VIN and a lender's approval don't prove a car exists. See it in person or send an inspector.",
+ "cta": "Read the full story at The Drive →"
},
{
+ "kicker": "Consumer alert",
"title": "Scammers are spoofing car dealership websites",
"byline": "By BCP Staff / Federal Trade Commission / Sept. 1, 2026",
"dek": "The FTC warns that scammers are copying real dealers' websites, often with AI, down to the logos, listings, and fake testimonials, then taking payment for cars buyers never receive.",
"takeLabel": "The lesson",
- "take": "Search the dealer's name with “scam” or “complaint,” and walk away from wire-transfer-only deals."
+ "take": "Search the dealer's name with “scam” or “complaint,” and walk away from wire-transfer-only deals.",
+ "cta": "Read the full story at Federal Trade Commission →"
},
{
+ "kicker": "Enforcement",
"title": "Regulators crack down on junk fees with a $4 million dealer settlement",
"byline": "Hinshaw & Culbertson / Aug. 2026",
"dek": "The FTC and Connecticut's attorney general settled with a Nissan dealership accused of misleading buyers about prices and charging for add-ons and fees they never agreed to. The money goes back to consumers.",
"takeLabel": "The lesson",
- "take": "Read every line of the contract. Charges you didn't agree to can be challenged, and regulators do act."
+ "take": "Read every line of the contract. Charges you didn't agree to can be challenged, and regulators do act.",
+ "cta": "Read the full story at Hinshaw & Culbertson →"
},
{
+ "kicker": "Enforcement",
"title": "Car dealerships nationwide warned to stop junk fees",
"byline": "PIRG",
"dek": "Regulators sent letters to 97 auto groups, which own hundreds of dealerships, saying advertised prices must include mandatory fees and dealers can't require their own financing to get the advertised price.",
"takeLabel": "The lesson",
- "take": "The advertised price should be a price you can pay. Ask for the out-the-door number in writing."
+ "take": "The advertised price should be a price you can pay. Ask for the out-the-door number in writing.",
+ "cta": "Read the full story at PIRG →"
},
{
+ "kicker": "New law",
"title": "California's new law for car buyers",
"byline": "CalMatters / Dec. 2025",
"dek": "SB 766, written by Santa Monica's state senator, gives many used-car buyers a three-day right to return the car, requires dealers to show a full price up front, and bans charging for add-ons that don't benefit you.",
"takeLabel": "The lesson",
- "take": "If you buy a used car in California after October 1, 2026, you may have three days to change your mind."
+ "take": "If you buy a used car in California after October 1, 2026, you may have three days to change your mind.",
+ "cta": "Read the full story at CalMatters →"
}
],
- "footnote": "Headlines belong to their publications. Summaries by VINdicated. Stories link to outside publications, which we don't control."
+ "footnote": "Headlines belong to their publications. Summaries and illustrations by VINdicated. Stories link to outside publications, which we don't control."
}
},
"mapMethods": {
diff --git a/lib/i18n/dictionary.ts b/lib/i18n/dictionary.ts
index eaed612..6a1874b 100644
--- a/lib/i18n/dictionary.ts
+++ b/lib/i18n/dictionary.ts
@@ -60,13 +60,19 @@ export type HomePageDict = {
ctaPrimary: string;
ctaSecondary: string;
photoAlt: string;
+ cards: TitleBody[];
};
sources: { heading: string; items: string[] };
publicKnowledge: {
title: string;
// Rich text: plain strings plus { b } for the bold phrase.
body: RichText;
- cta: string;
+ ctaPrimary: string;
+ ctaSecondary: string;
+ phoneLabel: string;
+ clock: string;
+ appName: string;
+ notifs: { time: string; body: string }[];
};
footing: {
title: string;
@@ -74,8 +80,27 @@ export type HomePageDict = {
quote: string;
quoteCite: string;
body: string;
+ quoteA: string;
+ quoteB: string;
rows: { stat: string; title: string; body: string; cite: string }[];
};
+ preview: {
+ tag: string;
+ title: string;
+ body: string;
+ cta: string;
+ carAlt: string;
+ carName: string;
+ carTags: string[];
+ question: string;
+ optionsLabel: string;
+ right: string;
+ // "{amount}" is replaced with the formatted dollar difference.
+ over: string;
+ low: string;
+ breakdown: string;
+ playAll: string;
+ };
chapters: {
title: string;
body: string;
@@ -86,6 +111,30 @@ export type HomePageDict = {
body: string;
links: { platform: string; handle: string }[];
};
+ ai: {
+ title: string;
+ body: string;
+ steps: TitleBody[];
+ art: {
+ boosted: string;
+ license: string;
+ dealerName: string;
+ dealerDropped: string;
+ loanFunded: string;
+ loanAmount: string;
+ now: string;
+ delivered: string;
+ };
+ source: string;
+ saveStat: string;
+ saveTitle: string;
+ saveBody: string;
+ saveCta: string;
+ tipsTitle: string;
+ tips: string[];
+ ftcCta: string;
+ reportCta: string;
+ };
pillars: { title: string; body: string; items: TitleBody[] };
strikes: {
title: string;
@@ -104,15 +153,7 @@ export type HomePageDict = {
checks: string[];
offers: TitleBody[];
};
- // Pre-purchase inspection explainer near the top of the home page.
- ppi: {
- title: string;
- // Rich text: plain strings plus { b } for the bold phrase.
- body: RichText;
- items: TitleBody[];
- primary: string;
- secondary: string;
- };
+ cta: { title: string; body: string; primary: string; secondary: string };
};
export type AboutTextSegment = { text: string; bold?: boolean };
diff --git a/public/images/news/ai-dealer-scams.svg b/public/images/news/ai-dealer-scams.svg
new file mode 100644
index 0000000..acff76b
--- /dev/null
+++ b/public/images/news/ai-dealer-scams.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+$36,240
+SENT TO A DEALER THAT DIDN'T EXIST
+Delivered?
+
+
\ No newline at end of file
diff --git a/public/images/news/california-sb-766.svg b/public/images/news/california-sb-766.svg
new file mode 100644
index 0000000..7762c76
--- /dev/null
+++ b/public/images/news/california-sb-766.svg
@@ -0,0 +1,5 @@
+
+
+OCT 1
+1 2 3
+days to return many used cars
\ No newline at end of file
diff --git a/public/images/news/dealer-warning-letters.svg b/public/images/news/dealer-warning-letters.svg
new file mode 100644
index 0000000..b18c94f
--- /dev/null
+++ b/public/images/news/dealer-warning-letters.svg
@@ -0,0 +1,3 @@
+
+
+97 warning letters
\ No newline at end of file
diff --git a/public/images/news/junk-fee-settlement.svg b/public/images/news/junk-fee-settlement.svg
new file mode 100644
index 0000000..8ff3504
--- /dev/null
+++ b/public/images/news/junk-fee-settlement.svg
@@ -0,0 +1,6 @@
+
+
+
+
+$4M
+BACK TO CONSUMERS
\ No newline at end of file
diff --git a/public/images/news/spoofed-dealer-sites.svg b/public/images/news/spoofed-dealer-sites.svg
new file mode 100644
index 0000000..d857fea
--- /dev/null
+++ b/public/images/news/spoofed-dealer-sites.svg
@@ -0,0 +1,7 @@
+
+
+Real Dealer Co.
+REAL
+
+Real Dealer
+CLONE
\ No newline at end of file
diff --git a/public/vindimap/index.html b/public/vindimap/index.html
index f76956c..9aff14c 100644
--- a/public/vindimap/index.html
+++ b/public/vindimap/index.html
@@ -15,24 +15,24 @@
@media (max-width:860px){.app{grid-template-columns:1fr;grid-template-rows:58dvh 1fr}.side{order:2}}
.side{display:flex;flex-direction:column;min-height:0;background:#fff;border-right:1px solid var(--line)}
.head{padding:20px 20px 14px;border-bottom:1px solid var(--line)}
-.head h1{margin:0;font-size:1.35rem}
+.head h1{margin:0;font-size:1.35rem;letter-spacing:-.02em}
.head p{margin:6px 0 0;font-size:.92rem;line-height:1.5}
-.src{display:inline-flex;align-items:center;gap:6px;margin-top:10px;font-size:.8rem;font-weight:700;background:var(--tint);border-radius:0;padding:4px 10px}
+.src{display:inline-flex;align-items:center;gap:6px;margin-top:10px;font-size:.8rem;font-weight:700;background:var(--tint);border-radius:999px;padding:4px 10px}
.src i{width:8px;height:8px;border-radius:50%;background:var(--amber)}
.src.live i{background:var(--green)}
.tools{padding:14px 16px 10px;display:flex;flex-direction:column;gap:10px;border-bottom:1px solid var(--line)}
.chips{display:flex;gap:6px;flex-wrap:wrap}
-.chips button{display:inline-flex;align-items:center;gap:6px;font:inherit;font-weight:700;font-size:.88rem;border:1.5px solid var(--line);background:#fff;border-radius:0;padding:7px 12px;cursor:pointer;color:var(--ink)}
-.chips button b{font-size:.78rem;background:var(--tint);border-radius:0;padding:1px 7px}
+.chips button{display:inline-flex;align-items:center;gap:6px;font:inherit;font-weight:700;font-size:.88rem;border:1.5px solid var(--line);background:#fff;border-radius:999px;padding:7px 12px;cursor:pointer;color:var(--ink)}
+.chips button b{font-size:.78rem;background:var(--tint);border-radius:999px;padding:1px 7px}
.chips button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:#fff}
.chips button[aria-pressed="true"] b{background:rgba(255,255,255,.2)}
.dot{width:11px;height:11px;border-radius:50%;display:inline-block;flex:none;background:var(--tc)}
.t-flagged{--tc:var(--red)}.t-caution{--tc:var(--amber)}.t-clear{--tc:var(--green)}.t-all{--tc:var(--purple)}
-.search{display:flex;align-items:center;gap:8px;border:1.5px solid var(--line);border-radius:0;padding:0 14px}
+.search{display:flex;align-items:center;gap:8px;border:1.5px solid var(--line);border-radius:999px;padding:0 14px}
.search:focus-within{border-color:var(--purple)}
.search input{border:0;outline:0;font:inherit;padding:10px 0;width:100%;background:none;color:var(--ink)}
.list{list-style:none;margin:0;padding:6px;overflow:auto;flex:1}
-.list button{width:100%;display:flex;gap:12px;align-items:flex-start;text-align:left;border:0;background:none;padding:10px 12px;border-radius:0;font:inherit;color:var(--ink);cursor:pointer}
+.list button{width:100%;display:flex;gap:12px;align-items:flex-start;text-align:left;border:0;background:none;padding:10px 12px;border-radius:12px;font:inherit;color:var(--ink);cursor:pointer}
.list .dot{margin-top:5px}
.list b{display:block}
.list em{font-style:normal;font-size:.85rem;font-weight:600;color:var(--deep)}
@@ -40,49 +40,53 @@
.list li.sel button{background:var(--tint2)}
.foot{padding:12px 16px;border-top:1px solid var(--line);font-size:.8rem;line-height:1.5}
#map{position:relative;min-height:0;background:#1b1b1b}
-.layers{position:absolute;z-index:500;top:14px;left:50%;transform:translateX(-50%);display:flex;background:#fff;border-radius:0;padding:4px;border:1px solid var(--line)}
-.layers button{font:inherit;font-weight:700;border:0;background:none;border-radius:0;padding:8px 16px;cursor:pointer;color:var(--ink)}
+.layers{position:absolute;z-index:500;top:14px;left:50%;transform:translateX(-50%);display:flex;background:#fff;border-radius:999px;padding:4px;box-shadow:0 6px 18px -6px rgba(0,0,0,.45)}
+.layers button{font:inherit;font-weight:800;border:0;background:none;border-radius:999px;padding:8px 16px;cursor:pointer;color:var(--ink)}
.layers button[aria-pressed="true"]{background:var(--purple);color:#fff}
-.fit{position:absolute;z-index:500;right:14px;bottom:28px;font:inherit;font-weight:700;border:1px solid var(--line);background:#fff;border-radius:0;padding:10px 16px;cursor:pointer;color:var(--ink)}
-.pin{width:18px;height:18px;border-radius:50%;background:var(--tc);border:3px solid #fff;transition:transform .2s}
+.fit{position:absolute;z-index:500;right:14px;bottom:28px;font:inherit;font-weight:800;border:0;background:#fff;border-radius:999px;padding:10px 16px;cursor:pointer;box-shadow:0 6px 18px -6px rgba(0,0,0,.45);color:var(--ink)}
+.pin{width:18px;height:18px;border-radius:50%;background:var(--tc);border:3px solid #fff;box-shadow:0 3px 8px rgba(0,0,0,.45);transition:transform .2s}
.pin.sel{transform:scale(1.5);border-color:var(--ink)}
-.pin-wrap.t-flagged::after{content:"";position:absolute;inset:0;border-radius:50%;border:2px solid var(--red)}
+.pin-wrap{animation:drop .6s cubic-bezier(.2,.9,.3,1.4) both;animation-delay:var(--d)}
+@keyframes drop{from{opacity:0;transform:translateY(-24px)}to{opacity:1;transform:none}}
+.pin-wrap.t-flagged::after{content:"";position:absolute;inset:0;border-radius:50%;border:2px solid var(--red);animation:ring 2.2s ease-out infinite}
+@keyframes ring{0%{transform:scale(1);opacity:.9}100%{transform:scale(2.6);opacity:0}}
.pin-wrap.dim{opacity:.15;pointer-events:none}
-.leaflet-popup-content-wrapper{border-radius:0;border:1px solid var(--line);box-shadow:none}
+.leaflet-popup-content-wrapper{border-radius:18px;box-shadow:0 20px 40px -12px rgba(0,0,0,.5)}
.leaflet-popup-content{margin:18px 20px;font-family:var(--font);color:var(--ink);width:300px!important;max-height:60vh;overflow:auto}
-.badge{display:inline-block;font-weight:700;font-size:.8rem;color:#fff;background:var(--tc);border-radius:0;padding:3px 11px}
-.pc h3{margin:8px 0 4px;font-size:1.15rem}
+.badge{display:inline-block;font-weight:800;font-size:.8rem;color:#fff;background:var(--tc);border-radius:999px;padding:3px 11px}
+.pc h3{margin:8px 0 4px;font-size:1.15rem;letter-spacing:-.015em}
.pc .addr{margin:0;font-size:.88rem;line-height:1.45}
.rev{display:flex;align-items:center;gap:4px;margin-top:12px;flex-wrap:wrap}
-.rev i{width:15px;height:15px;border-radius:0;background:#d9efe0}
+.rev i{width:15px;height:15px;border-radius:4px;background:#d9efe0}
.rev i.bad{background:var(--red)}
.rev span{margin-left:6px;font-size:.88rem}
.cats{list-style:none;margin:8px 0 0;padding:0;display:flex;flex-direction:column;gap:5px}
-.cats li{background:var(--tint);border-radius:0;padding:7px 10px;font-size:.88rem}
+.cats li{background:var(--tint);border-radius:9px;padding:7px 10px;font-size:.88rem}
.ct{display:flex;justify-content:space-between;gap:10px;font-weight:600}
.cats q{display:block;margin-top:5px;padding-left:9px;border-left:3px solid var(--tc);font-style:italic;line-height:1.45}
.cats q::before{content:"“"}.cats q::after{content:"”"}
.attr{margin:8px 0 0;font-size:.8rem;font-weight:600;color:var(--deep)}
-.h{font-weight:700;margin:14px 0 0;font-size:.9rem}
+.h{font-weight:800;margin:14px 0 0;font-size:.9rem}
.ok{margin:12px 0 0;font-weight:600;color:#14512b}
-.go{display:inline-block;margin-top:14px;font-weight:700;color:var(--purple)!important;text-decoration:none}
+.go{display:inline-block;margin-top:14px;font-weight:800;color:var(--purple)!important;text-decoration:none}
+@media (prefers-reduced-motion:reduce){.pin-wrap{animation:none}.pin-wrap.t-flagged::after{animation:none}}
/* Short disclaimer gate. Acceptance is shared with getvindicated.org/map
through sessionStorage ("vd-ack") when both are served from the same site. */
-.gate{position:fixed;inset:0;z-index:2000;overflow:auto;background:rgba(42,15,49,.72);padding:clamp(12px,4vw,48px);display:flex;align-items:center;justify-content:center}
+.gate{position:fixed;inset:0;z-index:2000;overflow:auto;background:rgba(42,15,49,.72);backdrop-filter:blur(6px);padding:clamp(12px,4vw,48px);display:flex;align-items:center;justify-content:center}
.gate[hidden]{display:none}
-.vd{max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid var(--line);border-radius:0;padding:clamp(22px,4vw,40px)}
-.vd-title{margin:0;font-size:clamp(1.9rem,4vw,2.5rem);line-height:1.05}
+.vd{max-width:640px;width:100%;margin:auto;background:#fff;border:4px solid var(--ink);box-shadow:10px 10px 0 var(--purple);border-radius:28px;padding:clamp(22px,4vw,40px)}
+.vd-title{margin:0;font-size:clamp(1.9rem,4vw,2.5rem);letter-spacing:-.03em;line-height:1.05}
.vd-lede{margin:14px 0 0;font-size:1.02rem;line-height:1.65}
.vd-accept{margin-top:22px;border-top:3px solid var(--ink);padding-top:20px;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
.vd-check{display:flex;gap:12px;align-items:flex-start;max-width:420px;cursor:pointer;font-weight:700;line-height:1.5;font-size:.98rem}
.vd-check input{width:24px;height:24px;margin-top:1px;accent-color:var(--purple);flex:none}
-.vd-go{font:inherit;font-weight:700;font-size:1.05rem;border:0;border-radius:0;padding:14px 26px;background:var(--purple);color:#fff;cursor:pointer}
+.vd-go{font:inherit;font-weight:800;font-size:1.05rem;border:0;border-radius:999px;padding:14px 26px;background:var(--purple);color:#fff;cursor:pointer}
.vd-go:disabled{opacity:.35;cursor:not-allowed}
.vd-methods{display:inline-block;margin-top:16px;font-weight:700;color:var(--purple);text-decoration:none}
.vd-methods:hover{text-decoration:underline}
.vd-title:focus{outline:none}
-.info-btn{position:absolute;z-index:500;right:14px;top:14px;font:inherit;font-weight:700;border:1px solid var(--line);background:#fff;border-radius:0;padding:9px 14px;cursor:pointer;color:var(--ink)}
+.info-btn{position:absolute;z-index:500;right:14px;top:14px;font:inherit;font-weight:800;border:0;background:#fff;border-radius:999px;padding:9px 14px;cursor:pointer;box-shadow:0 6px 18px -6px rgba(0,0,0,.45);color:var(--ink)}
/* Narrow screens: drop "About this map" below the Satellite/Map switch. */
@media (max-width:560px){.info-btn{top:62px;right:10px}}
diff --git a/scripts/scope-prototype-css.py b/scripts/scope-prototype-css.py
new file mode 100644
index 0000000..61907f0
--- /dev/null
+++ b/scripts/scope-prototype-css.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Generate app/redesign.css from the redesign prototype's ", html, re.S).group(1)
+ css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
+ keyframes = set(re.findall(r"@keyframes\s+([\w-]+)", css))
+
+ def rename_animations(body):
+ def fix(m):
+ val = m.group(2)
+ for k in keyframes:
+ val = re.sub(r"(?
{dict.title}
+{dict.body}
+