/* hackathon-page.jsx - /hackathon: the Private Sprint hub.
   Reuses Nav/Footer/Reveal/Frame + global CSS vars, same as build-page.jsx.

   v1 is deliberately thin: the project grid renders from a flat projects.json
   produced by a cron job in the strk20-hackathon repo. No auth, no DB, no
   webhooks. Ordering is `pushed_at` descending straight from the GitHub API -
   a weighted activity score, AI change summaries, mainnet badges and the
   click-to-expand detail view all land post-launch, tuned against real data. */

/* The registration repo. Projects register by PR against registry.json here;
   the cron in that same repo resolves each entry through the GitHub API and
   commits projects.json, which this page fetches at runtime. Fetching from raw
   githubusercontent (CORS-open) instead of committing into this repo means the
   grid updates without a website deploy. */
const HACK_ORG = "starkience";
const HACK_REPO = "strk20-hackathon";
const HACK_REPO_URL = `https://github.com/${HACK_ORG}/${HACK_REPO}`;
/* The explicit refs/heads path avoids the stale edge cache of the shorter
   raw.githubusercontent.com/.../main URL while keeping the direct CORS-enabled
   raw host (the github.com raw route redirects without CORS headers). */
const HACK_PROJECTS_URL = `https://raw.githubusercontent.com/${HACK_ORG}/${HACK_REPO}/refs/heads/main/projects.json`;
/* Served from this repo so the page still renders before the hackathon repo
   exists, and if raw.githubusercontent is unreachable mid-event. It also holds
   the preview projects used while designing the page.

   NOTE: empty this file to [] before the sprint opens. While it has entries,
   any moment the live registry is empty - including the days before August 14
   - the page shows these instead of the real empty state. */
const HACK_PROJECTS_FALLBACK = "/hackathon-projects.json";
const HACK_TELEGRAM = "https://t.me/+strk20sprint";
const HACK_IDEAS_URL = `${HACK_REPO_URL}/blob/main/IDEAS.md`;

const HACK_DATES = {
  announce: "August 8",
  opens: "August 14",
  closes: "August 31",
  winners: "September 4",
};
/* UTC instant behind the submission deadline. The public countdown is live
   immediately, so it always runs toward the close rather than pausing on a
   separate pre-sprint clock. */
const HACK_CLOSES_AT = Date.parse("2026-08-31T23:59:00Z");

/* ---------- layout tokens (mirrors build-page.jsx) ---------- */
const HK_SECTION = { position: "relative", overflow: "hidden", padding: "clamp(72px,10vh,132px) 0" };
/* Same container as .wrap in styles.css - the /build ecosystem table uses it,
   so the sprint list lines up with the rest of the site instead of running
   400px wider than everything else. */
const HK_INNER = { width: "100%", maxWidth: "var(--maxw)", margin: "0 auto", padding: "0 var(--gut)" };
const HK_EYE = { display: "flex", alignItems: "center", gap: 14, fontFamily: "var(--mono)", fontSize: 12,
  letterSpacing: "0.18em", textTransform: "uppercase", color: "var(--green)" };
const HK_H = { fontFamily: "var(--display)", fontWeight: 800, textTransform: "uppercase",
  letterSpacing: "-0.025em", lineHeight: 1.0, margin: "18px 0 0" };
const HK_LABEL = { fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.18em",
  textTransform: "uppercase", color: "var(--faint)" };

/* The pair of calls to action, defined once. They appear in the hero and again
   on the participant table's top edge, and the two sets have to stay identical
   - square, mono, 10.5px - so they read as the same control in both places
   rather than as a button and its smaller cousin. */
const HK_BTN = { padding: "10px 18px", cursor: "pointer", fontFamily: "var(--mono)",
  fontSize: 10.5, letterSpacing: "0.12em", textTransform: "uppercase" };
const HK_BTN_GO = { ...HK_BTN, background: "var(--green)", color: "#fff",
  border: "1px solid var(--green)" };
const HK_BTN_GHOST = { ...HK_BTN, background: "none", color: "var(--text)",
  border: "1px solid var(--line)" };

/* ---------- helpers ---------- */

/* "2h ago" / "3d ago". Deliberately coarse: the grid is a liveness signal, not
   a clock, and minute-precision invites reading it as a ranking. */
function hackAgo(iso) {
  if (!iso) return "-";
  const ms = Date.now() - new Date(iso).getTime();
  if (!isFinite(ms) || ms < 0) return "just now";
  const m = Math.floor(ms / 60000);
  if (m < 1) return "just now";
  if (m < 60) return `${m}m ago`;
  const h = Math.floor(m / 60);
  if (h < 24) return `${h}h ago`;
  const d = Math.floor(h / 24);
  return `${d}d ago`;
}

/* Fresh work reads at full strength; stale projects fade rather than vanish.
   A dead project being visibly dead is the point - it just shouldn't be
   dropped from the grid. */
function hackHeat(iso) {
  if (!iso) return 0;
  const h = (Date.now() - new Date(iso).getTime()) / 3600000;
  if (h < 6) return 3;
  if (h < 24) return 2;
  if (h < 72) return 1;
  return 0;
}

/* ---------- builder avatars ---------- */

/* Row-level: faces only. Names live in the expanded panel, where every builder
   fits instead of the first two. */
function HackAvatars({ builders = [], size = 28 }) {
  const shown = builders.slice(0, 3);
  const extra = builders.length - shown.length;
  const overlap = Math.round(size * -0.32);
  return (
    <div style={{ display: "flex", flexShrink: 0 }}>
      {shown.map((b, i) => (
        <img
          key={b.login || i}
          src={b.avatar_url}
          alt=""
          loading="lazy"
          width={size}
          height={size}
          style={{
            width: size, height: size, borderRadius: "50%", display: "block",
            border: "2px solid var(--bg)", background: "var(--bg-2)",
            marginLeft: i === 0 ? 0 : overlap, position: "relative", zIndex: shown.length - i,
          }}
        />
      ))}
      {extra > 0 && (
        <span style={{
          width: size, height: size, borderRadius: "50%", marginLeft: overlap,
          border: "2px solid var(--bg)", background: "var(--bg-2)", color: "var(--dim)",
          fontFamily: "var(--mono)", fontSize: Math.round(size * 0.34), display: "flex", alignItems: "center",
          justifyContent: "center", flexShrink: 0,
        }}>+{extra}</span>
      )}
    </div>
  );
}

/* ---------- formatting ---------- */

/* "Aug 5, 2026" + a live "67h 14m 03s". Seconds tick, which is the cheapest
   possible signal that this page is not a static snapshot. No "ago" - the
   column header says Last pushed, so the word only repeated it. */
function hackWhen(iso, now) {
  if (!iso) return { date: "-", ago: "" };
  const t = new Date(iso);
  if (isNaN(t)) return { date: "-", ago: "" };
  /* No year: the whole sprint runs inside one month. */
  const date = t.toLocaleDateString("en-US", { month: "short", day: "numeric" });
  let sec = Math.max(0, Math.floor((now - t.getTime()) / 1000));
  /* Hours and minutes, and hours keep counting past a day - 67h, not 2d 19h.
     Days are the strip's unit, so leaving them out of this column stops the
     two saying the same thing in different words, and hours stay comparable
     between rows without anyone converting in their head. */
  const h = Math.floor(sec / 3600); sec -= h * 3600;
  const m = Math.floor(sec / 60); sec -= m * 60;
  /* Zero-padded below the leading unit so the string keeps its width and the
     column doesn't twitch on every tick. */
  const p2 = (n) => String(n).padStart(2, "0");
  let ago;
  if (h) ago = `${h}h ${p2(m)}m ${p2(sec)}s`;
  else if (m) ago = `${m}m ${p2(sec)}s`;
  else ago = `${sec}s`;
  return { date, ago };
}

/* The site's one real green - ecosystem.jsx uses it for LIVE status. The
   accent named --green is actually the ember orange, so it can't serve here. */
const HK_GREEN = "#4ade80";
/* GitHub's dark-theme deletion red. Close enough to the ember accent that it
   only works at this size and weight - kept to the diff counts, nowhere else. */
const HK_RED = "#f85149";

const hackNum = (n) => Math.abs(n).toLocaleString("en-US");

/* The row's diff counts lived here. The Stack column replaced them: the counts
   are per-push, so they moved with every commit and said nothing about the
   project. The panel still shows them, where they are context rather than a
   ranking cue. */

/* ---------- expand animation ---------- */

/* Same drawer motion as the ecosystem rows in the Use section. GSAP measures
 * the auto height, which buys a real easing curve and - more usefully - an
 * exit tween whose completion drives the unmount, so the panel's links leave
 * the tab order exactly when they stop being visible.
 *
 * Reimplemented here rather than imported: ecosystem.jsx is a large module
 * carrying its own dataset, and these scripts share one global scope, so
 * loading it for thirty lines would also risk clobbering names. Prefixed for
 * the same reason. */
function hackMotionOn() {
  return typeof gsap !== "undefined" &&
    !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function hackUseEnterLeave(isOpen, enter, leave) {
  const [mounted, setMounted] = useStateT(isOpen);
  const nodeRef = React.useRef(null);
  const tweenRef = React.useRef(null);

  React.useEffect(() => { if (isOpen) setMounted(true); }, [isOpen]);

  React.useLayoutEffect(() => {
    const node = nodeRef.current;
    if (!node) return;
    if (tweenRef.current) { tweenRef.current.kill(); tweenRef.current = null; }

    if (isOpen) {
      /* No GSAP or reduced motion: strip anything a previous tween left behind
         so the panel doesn't inherit a half-finished opacity. */
      if (!hackMotionOn()) {
        if (typeof gsap !== "undefined") gsap.set(node, { clearProps: "all" });
        return;
      }
      tweenRef.current = enter(node);
      return;
    }

    if (!hackMotionOn()) { setMounted(false); return; }
    tweenRef.current = leave(node, () => setMounted(false));
  }, [isOpen]);

  React.useEffect(() => () => { if (tweenRef.current) tweenRef.current.kill(); }, []);

  return { mounted: isOpen || mounted, nodeRef };
}

/* The lead committer's GitHub username. People recognise handles, not project
   slugs, which is what makes a list like this feel social - so the handle is
   the primary line and the project sits under it. */
function hackHandle(p) {
  const b = p.builders || [];
  return b.length ? b[0].login : p.name;
}

/* Agents have no avatar on a commit trailer - the co-author is an email, not
   an account - but each has a GitHub org or app account, so the mark comes
   from there. Verified every handle resolves: github.com/<handle>.png 404s
   for accounts that do not exist. */
/* Lobehub's icon set, colour variants, vendored into /brand/agents rather than
   hotlinked: the site self-hosts its other third-party assets, and an icon
   that fails to load leaves a hole in the avatar stack. Cursor and Windsurf
   publish no colour variant, so those two are the mono mark with its
   currentColor pinned white - black on a dark panel would be invisible.

   Falls back to the agent's own GitHub avatar for anything the set does not
   cover - Devin and Aider today. */
const HK_AGENT_ICONS = {
  "Claude": "claude",
  "Codex": "codex",
  "Cursor": "cursor",
  "GitHub Copilot": "copilot",
  "Windsurf": "windsurf",
  "Jules": "gemini",
};

const HK_AGENT_ACCOUNTS = {
  "Devin": "cognition-ai",
  "Aider": "paul-gauthier",
};

const hackAgentAvatar = (family) => {
  const icon = HK_AGENT_ICONS[family];
  if (icon) return `/brand/agents/${icon}.svg`;
  const account = HK_AGENT_ACCOUNTS[family];
  return account ? `https://github.com/${account}.png?size=48` : null;
};

/* Agents are participants, listed after the people. Same shape as a builder,
   so every list on the page can treat them identically. */
function hackParticipants(p) {
  const people = p.builders || [];
  const agents = (p.agents || []).map((a) => ({
    login: a.name,
    name: a.name,
    /* The vendored mark first, so every agent looks the same whether it
       committed as an account or only signed a trailer. */
    avatar_url: hackAgentAvatar(a.family) || a.avatar_url || "",
    agent: true,
  }));
  return people.concat(agents);
}

/* ---------- project row ---------- */

/* Chips that have a documentation page link to it. Paths come from the site's
   sitemap rather than being guessed: strk20-by-example answers 200 for any
   URL, so a wrong path looks fine here and 404s only for the reader. Anything
   without a real page stays plain text. */
const HK_DOCS = "https://strk20-by-example.org/";

/* Every chip the indexer can emit has a destination. STRK20 concepts go to
   their page on strk20-by-example, with paths taken from that site's sitemap:
   it answers 200 for any URL, so a wrong path would look fine here and 404
   only for the reader. Everything else points at its own documentation. */
const HK_CHIP_DOCS = {
  "Privacy SDK": HK_DOCS + "sdk/getting-started",
  "Wallet API": HK_DOCS + "starknet-wallet-api/overview",
  "privacy_invoke": HK_DOCS + "helpers/privacy-invoke",
  "Anonymizer": HK_DOCS + "helpers/privacy-invoke",
  "Note discovery": HK_DOCS + "sdk/note-discovery",
  "Prover": HK_DOCS + "sdk/proving-config",
  "Shielded balances": HK_DOCS + "what-is-strk20",
  "AVNU": HK_DOCS + "starknet-wallet-api/avnu-private-swaps",
  "Ekubo": HK_DOCS + "helpers/swap-helper",
  "Vesu": HK_DOCS + "helpers/vesu-lending-helper",
  "starknet.js": HK_DOCS + "starknet-wallet-api/starknet-js",
  /* Not shipped, so there is no reference page yet - the build page is where
     it is described. */
  "Sub-accounts": "https://strk20.starknet.io/build",
  "get-starknet": "https://github.com/starknet-io/get-starknet",
  "Starknetkit": "https://www.starknetkit.com/",
  "Cairo": "https://book.cairo-lang.org/",
  "Starknet Foundry": "https://foundry-rs.github.io/starknet-foundry/",
  "Rust": "https://www.rust-lang.org/",
  "React": "https://react.dev/",
  "Next.js": "https://nextjs.org/",
  "Vite": "https://vite.dev/",
  "Svelte": "https://svelte.dev/",
  "TypeScript": "https://www.typescriptlang.org/",
};

/* Which parts of the STRK20 stack the row advertises, deepest first - the order
   is also the priority when only two fit.
 *
 * The page owns this list rather than trusting a flag from the indexer, for two
 * reasons: projects.json keeps whatever the last cron wrote, so entries indexed
 * before the stack flag existed would show nothing; and the row has space for
 * two, which means something has to rank them. Base Starknet and web tooling -
 * starknet.js, get-starknet, Cairo, Next.js - is deliberately absent. It is
 * real, it is in the project panel, and it is not what this page is about.
 *
 * The SDK is one pill, not five. Note discovery, discovery providers, channels
 * and multi-op batches are all parts of driving the SDK yourself - splitting
 * them filled the column with detail nobody reads, and made a wallet team look
 * like it had integrated five things rather than one. Proving stays separate:
 * running your own prover is a different decision from using the SDK. */
const HK_STACK_PILLS = [
  "Anonymizer contract",
  "Privacy SDK",
  "Proving service",
  "Wallet API",
  "starknet-start",
  "AVNU",
  "Ekubo",
  "Vesu",
];

/* The eighteen days of the sprint, as dates, so a dot maps to a calendar day
   rather than to an offset someone has to count. */
const HACK_SPRINT_DAYS = (() => {
  const out = [];
  const d = new Date(Date.UTC(2026, 7, 14));
  for (let i = 0; i < 18; i++) {
    out.push(d.toISOString().slice(0, 10));
    d.setUTCDate(d.getUTCDate() + 1);
  }
  return out;
})();

/* One cell per sprint day, lit on the days the repository was worked on.
 *
 * This is the part of the row that separates a builder from a submission. Lines
 * moved and pushes made both reward a single large dump; showing up on fourteen
 * days out of eighteen cannot be produced any way other than showing up. It is
 * also the only cell that changes when nobody has pushed, which keeps the table
 * honest overnight.
 *
 * Days still to come are drawn fainter than days that passed without work -
 * an empty strip in week one should not read as an absent team. */
function HackDayStrip({ days = [], now }) {
  const on = new Set(days || []);
  const today = new Date(now || Date.now()).toISOString().slice(0, 10);
  const count = HACK_SPRINT_DAYS.filter((d) => on.has(d)).length;
  /* Dots only. The count sat under them saying the same thing in words, and
     the shape is what carries the meaning - twelve scattered dots and twelve
     consecutive ones are different stories that "12 of 18" flattens. The
     number is still there for anyone who wants it, on hover. */
  return (
    <span className="hk-days" title={`${count} of 18 sprint days with a push`}>
      {HACK_SPRINT_DAYS.map((d) => (
        <i key={d} className={"hk-day" + (on.has(d) ? " hk-day--on" : d > today ? " hk-day--future" : "")} />
      ))}
    </span>
  );
}

/* Counts in GitHub's shorthand, then the share of the codebase the push moved.
   Both are per-push, not cumulative: they describe the push named in the cell
   to their left, which is why they sit next to it and not in the ranking. */
function HackDiffStat({ additions = 0, deletions = 0, churn = 0 }) {
  if (!additions && !deletions) return null;
  return (
    <span className="hk-diff">
      <span className="hk-diff__counts">
        <span style={{ color: "rgba(74,222,128,0.78)" }}>+{additions.toLocaleString("en-US")}</span>
        <span style={{ color: "rgba(248,81,73,0.78)" }}>-{deletions.toLocaleString("en-US")}</span>
      </span>
      {churn > 0 && (
        <span className="hk-diff__churn">
          {churn >= 10 ? Math.round(churn) : churn}% changed
        </span>
      )}
    </span>
  );
}

/* Two pills and a count. A developer reading a row should learn what the team
   built with, not everything the repository happens to contain.
   Not on the row any more - the panel's Stack detected block is where this
   lives now. Kept because that block reads the same vocabulary. */
function HackStackPills({ tooling = [], max = 2 }) {
  const rank = (t) => HK_STACK_PILLS.indexOf(typeof t === "string" ? t : t.label);
  const stack = tooling.filter((t) => rank(t) !== -1).sort((a, b) => rank(a) - rank(b));
  if (!stack.length) return null;
  const shown = stack.slice(0, max);
  const extra = stack.length - shown.length;
  return (
    <span className="hk-pills">
      {shown.map((t) => (
        <span key={typeof t === "string" ? t : t.label} className="hk-pill">
          {typeof t === "string" ? t : t.label}
        </span>
      ))}
      {extra > 0 && <span className="hk-pill hk-pill--more" title={stack.slice(max).map((t) => (typeof t === "string" ? t : t.label)).join(", ")}>+{extra}</span>}
    </span>
  );
}

function HackChips({ tooling = [] }) {
  if (!tooling.length) return null;
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
      {tooling.map((t) => {
        const label = typeof t === "string" ? t : t.label;
        const live = typeof t === "object" && t.live;
        const doc = HK_CHIP_DOCS[label];
        const style = {
          fontFamily: "var(--mono)", fontSize: 10, letterSpacing: "0.12em",
          textTransform: "uppercase", padding: "5px 9px",
          border: `1px solid ${live ? "var(--green)" : "var(--line)"}`,
          color: live ? "var(--green)" : "var(--dim)",
          textDecoration: "none", display: "inline-block",
        };
        /* Anything unmapped still renders, just without a link. */
        if (!doc) return <span key={label} style={style}>{label}</span>;
        return (
          <a
            key={label}
            href={doc}
            target="_blank"
            rel="noopener noreferrer"
            className="hk-chip-link"
            style={style}
            title={`${label} documentation`}
            onClick={() => track(`chip:${label}`, "hackathon")}
          >{label}</a>
        );
      })}
    </div>
  );
}

/* ---------- link glyphs ---------- */

/* Same convention as ecosystem.jsx's glyphs: 16 viewBox, currentColor, and
   aria-hidden because the anchor already carries the accessible name. Defined
   here rather than imported - these scripts share one global scope, and
   ecosystem.jsx isn't loaded on this route. */
function HkGlyphGitHub() {
  return (
    <svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true">
      <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
    </svg>
  );
}

function HkGlyphDoc() {
  return (
    <svg viewBox="0 0 16 16" width="13" height="13" fill="none"
         stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" aria-hidden="true">
      <path d="M9.2 1.7H4.4a.8.8 0 0 0-.8.8v11a.8.8 0 0 0 .8.8h7.2a.8.8 0 0 0 .8-.8V5z" />
      <path d="M9.2 1.7V5h3.2" />
      <path d="M5.7 8.6h4.6M5.7 11.1h3.2" strokeLinecap="round" />
    </svg>
  );
}

function HkGlyphGlobe() {
  return (
    <svg viewBox="0 0 16 16" width="13" height="13" fill="none"
         stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
      <circle cx="8" cy="8" r="6.5" />
      <ellipse cx="8" cy="8" rx="2.6" ry="6.5" />
      <line x1="1.5" y1="8" x2="14.5" y2="8" />
    </svg>
  );
}

function HkGlyphCopy() {
  return (
    <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor"
         strokeWidth="1.3" strokeLinejoin="round" aria-hidden="true">
      <rect x="5.6" y="5.6" width="8" height="8.8" rx="1.4" />
      <path d="M10.6 5.6V3a1.4 1.4 0 0 0-1.4-1.4H3.8A1.4 1.4 0 0 0 2.4 3v6.2a1.4 1.4 0 0 0 1.4 1.4h1.8" />
    </svg>
  );
}

function HkGlyphCheck() {
  return (
    <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor"
         strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M3 8.4l3.2 3.1L13 4.6" />
    </svg>
  );
}

function HkGlyphSparkle() {
  return (
    <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true">
      <path d="M8 0.8l1.35 3.9L13.2 6l-3.85 1.3L8 11.2 6.65 7.3 2.8 6l3.85-1.3z" />
      <path d="M12.9 9.6l.63 1.82 1.8.63-1.8.62-.63 1.83-.63-1.83-1.8-.62 1.8-.63z" opacity=".72" />
      <path d="M3.2 10.5l.45 1.3 1.28.45-1.28.44-.45 1.31-.45-1.31L1.47 12.25l1.28-.45z" opacity=".5" />
    </svg>
  );
}

function HkGlyphInfo() {
  return (
    <svg viewBox="0 0 16 16" width="14" height="14" fill="none"
         stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
      <circle cx="8" cy="8" r="6.6" />
      <path d="M8 7.2v4" strokeLinecap="round" />
      <circle cx="8" cy="4.9" r="0.7" fill="currentColor" stroke="none" />
    </svg>
  );
}

function HkGlyphX() {
  return (
    <svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true">
      <path d="M12.2 1.5h2.3l-5 5.7 5.9 7.8h-4.6L7.2 9.3l-4.1 5.7H.7l5.4-6.1L.5 1.5h4.7l3.2 4.3 3.8-4.3zm-.8 11.6h1.3L4.6 2.9H3.2l8.2 10.2z" />
    </svg>
  );
}

/* ---------- typed description ---------- */

/* The description is model-written, so it is introduced the way a model
   answers: the caret blinks on an empty line, then the text types out beneath
   it. Segments keep their own styling while sharing one cursor, so the push
   line stays green while the paragraph beneath it types in the body colour.
 *
 * Reduced motion skips to the finished text - the animation is flavour, and
 * nobody should have to sit through it to read a project's description. */
function HackTypedBlock({ segments, waitMs = 1500, charsPerSec = 36 }) {
  const total = segments.reduce((n, seg) => n + seg.text.length, 0);
  const [shown, setShown] = useStateT(hackMotionOn() ? 0 : total);
  const [waiting, setWaiting] = useStateT(hackMotionOn());

  React.useEffect(() => {
    if (!hackMotionOn()) return;
    let tween;
    const id = setTimeout(() => {
      setWaiting(false);
      const proxy = { n: 0 };
      tween = gsap.to(proxy, {
        n: total,
        duration: Math.max(0.6, total / charsPerSec),
        ease: "none",
        onUpdate: () => setShown(Math.floor(proxy.n)),
        onComplete: () => setShown(total),
      });
    }, waitMs);
    return () => { clearTimeout(id); if (tween) tween.kill(); };
  }, [total, waitMs, charsPerSec]);

  /* The wait is just the caret blinking on an empty line: the same cursor that
     carries the typing, rather than a separate loading idiom. */
  if (waiting) {
    return (
      <p style={segments[0].style} aria-label="Loading description">
        <span className="hk-caret" />
      </p>
    );
  }

  let left = shown;
  const typing = shown < total;
  return (
    <React.Fragment>
      {segments.map((seg, i) => {
        const take = Math.max(0, Math.min(seg.text.length, left));
        left -= take;
        if (!take && i > 0) return null;
        return (
          <p key={i} style={seg.style}>
            {seg.text.slice(0, take)}
            {typing && take > 0 && take < seg.text.length && <span className="hk-caret" />}
          </p>
        );
      })}
    </React.Fragment>
  );
}

/* ---------- how tracking works ---------- */

/* Every field on this panel is derived, so the rules for producing it need to
   be visible somewhere. Written as "detected from X, missing means do Y" so a
   team with a blank row can find the reason without asking. */
/* Which HK_HOW entry answers a given gap, and the one-line action for it.
   The `fix` strings below are written to be read cold; these are written to be
   read by someone who already knows the field is empty. */
/* Mainnet STRK20 pool - the contract every entry has to touch. */
const HACK_POOL = "0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a";
/* Alchemy, keyed per builder. A shared open endpoint is the wrong thing to
   hand thirty teams for eighteen days - the first one to loop a backfill
   rate-limits everyone else, and nobody can tell whose it was. */
const HACK_RPC = "https://starknet-mainnet.g.alchemy.com/v2/<YOUR_ALCHEMY_KEY>";

const HK_GAPS = [
  { key: "demo", t: "Live demo",
    todo: "Fill in the Website field on the repository, or set \"demo_url\" in strk20.json.",
    task: "Live demo - deploy the app somewhere anyone can open without logging in, then set \"demo_url\" in strk20.json. Also paste the URL into the repository's Website field on GitHub." },
  { key: "video", t: "Demo video",
    todo: "Put the link in \"demo_video\" in strk20.json.",
    task: "Demo video - a 3-minute walkthrough, uploaded somewhere public, with the link in \"demo_video\" in strk20.json. Draft a script from the README if there is no video yet." },
  { key: "mainnet", t: "Three mainnet transactions",
    todo: "Make three real calls against the pool on mainnet, then paste the hashes into \"transactions\".",
    task: `Three mainnet transactions - make three real calls against the STRK20 pool at ${HACK_POOL} on Starknet mainnet (CHAIN_ID SN_MAIN, RPC ${HACK_RPC} with a free Alchemy key), then put the three transaction hashes in "transactions" in strk20.json. Each is verified on-chain: it must exist, have succeeded, and carry a pool event.` },
  { key: "contracts", t: "Contracts",
    todo: "Add the deployed addresses to \"contracts\" in strk20.json.",
    task: "Contracts - put every address this project deploys in \"contracts\" in strk20.json. Mainnet is looked up first, then Sepolia." },
  { key: "tooling", t: "Stack detected",
    todo: "Add the dependency to package.json or Scarb.toml, or name it in the README.",
    task: "Stack detected - nothing STRK20-related was found in package.json, Scarb.toml or the README. Add the dependency actually in use, and name the pieces of the stack in the README." },
  { key: "about", t: "AI summary",
    todo: "Add a README. The summary is written from it.",
    task: "AI summary - write a README covering what this project does, why it needs privacy, and how to run it. The summary on the hub is written from it." },
];

/* Handed to a coding agent working inside the project's own repository. Lists
   only what is actually missing, with the file and field to write in each
   case, and rules out the one thing an agent would otherwise try - reopening
   the registry pull request. */
function hackAgentTask(p, gaps) {
  const lines = gaps.map((g, i) => `${i + 1}. ${g.task}`).join("\n\n");
  const touchesManifest = gaps.some((g) => g.key !== "tooling" && g.key !== "about");
  const manifest = touchesManifest
    ? `\nstrk20.json lives at the root of this repository:\n\n${HK_MANIFEST_SNIPPET}\n`
    : "";
  return `Finish the STRK20 Private Sprint entry for ${p.name} in this repository (${p.repo_url}).\n\n`
    + `${gaps.length === 1 ? "One thing is" : `${gaps.length} things are`} missing from the hub at `
    + `https://strk20.starknet.io/hackathon:\n\n${lines}\n${manifest}\n`
    + `Do not touch the hackathon registry - the entry is already merged, and there is no second `
    + `pull request. The hub re-reads this repository every 30 minutes.`;
}

/* What this project is short of, in the order above. */
function hackGaps(p) {
  const has = {
    demo: !!p.requirements?.demo,
    video: !!p.requirements?.video,
    mainnet: !!p.requirements?.mainnet,
    contracts: (p.contracts || []).length > 0,
    tooling: (p.tooling || []).length > 0,
    about: !!(p.description_long || p.summary || p.one_liner),
  };
  return HK_GAPS.filter((g) => !has[g.key]);
}

const HK_HOW = [
  {
    t: "Builders",
    d: "Taken from who commits to the repository during the sprint, ordered by number of commits. Bots are filtered out.",
    fix: "Someone missing? Their commit email has to be linked to their GitHub account. Failing that, add the username to \"team\" in the registry entry.",
  },
  {
    t: "Latest push",
    d: "Read from the commit range since the last index. The sentence is written from the commit messages and the files they touched. Everything refreshes every 30 minutes.",
    fix: "Nothing to do. Push, and the row updates on the next run.",
  },
  {
    t: "Stack",
    d: "The parts of STRK20 a project is built on: an anonymizer contract, the Privacy SDK, the Wallet API, a venue like AVNU. Read from package.json and Scarb.toml - a declared dependency, never a mention in the README, so nothing here is a claim.",
    fix: "Missing a part you are using? Depend on it properly rather than describing it. An anonymizer is recognised by the privacy dependency in Scarb.toml.",
  },
  {
    t: "AI summary",
    d: "Written from the repository README, and rewritten only when the README changes, so a source-only push never restates what the project is.",
    fix: "To change it, edit the README.",
  },
  {
    t: "Stack detected",
    d: "Read from package.json, Scarb.toml, and the text of the README. Covers the Privacy SDK, the Wallet API, privacy_invoke, anonymizers, sub-accounts, note discovery, the prover, AVNU, Ekubo, Vesu and Cairo.",
    fix: "Something missing? Add the dependency, or name it in the README.",
  },
  {
    t: "Live demo",
    d: "Found without being declared: the repository's Website field first, then GitHub Pages, then the most recent successful deployment reported to GitHub - which is what Vercel and Netlify do on every deploy.",
    fix: "Not showing? Fill in the Website field on the repository page, or set \"demo_url\" in strk20.json.",
  },
  {
    t: "Demo video",
    d: "Comes only from \"demo_video\" in strk20.json. There is nothing to detect it from otherwise.",
    fix: "Add the link to strk20.json.",
  },
  {
    t: "Three mainnet transactions",
    d: "Each hash in \"transactions\" is checked against Starknet mainnet: it has to exist, to have succeeded, and to carry an event from the STRK20 pool contract. Three passing hashes satisfy the rule.",
    fix: "Still unchecked? Make three real calls against the pool on mainnet and paste the hashes into strk20.json. A relayer submits private transactions, so the sender on-chain is never the account that made them, which is why hashes are asked for rather than an address.",
  },
  {
    t: "Contracts",
    d: "Each address in \"contracts\" is looked up on mainnet, then on Sepolia, and shown with whichever network it was found on.",
    fix: "Nothing found means the address is not deployed on either.",
  },
];

const HK_MANIFEST = `{
  "transactions": ["0x07c0...", "0x04b2...", "0x0919..."],
  "contracts": ["0x0abc..."],
  "demo_video": "https://youtu.be/...",
  "demo_url": "https://your-demo.example"
}`;

function HackHow({ p }) {
  const gaps = p ? hackGaps(p) : [];
  const [copied, setCopied] = useStateT(false);
  const copyTask = () => {
    navigator.clipboard?.writeText(hackAgentTask(p, gaps)).then(() => {
      setCopied(true);
      track(`modal:agent-task:${p.slug}`, "hackathon");
      setTimeout(() => setCopied(false), 1800);
    }).catch(() => {});
  };
  return (
    <div className="hk-how">
      <div className="hk-how__head">
        <span className="hk-how__sec" style={{ marginBottom: 0 }}>
          {gaps.length ? `Missing from ${p.name}` : "Nothing missing"}
        </span>
        {gaps.length > 0 && (
          /* The list is already a task list; this hands it to the agent that
             is doing the work, with the repository and the fields filled in. */
          <button type="button" className="hk-link hk-how__copy" onClick={copyTask}>
            {copied ? <HkGlyphCheck /> : <HkGlyphCopy />}
            <span>{copied ? "Copied" : "Copy for agent"}</span>
          </button>
        )}
      </div>
      {gaps.length ? (
        <ol className="hk-stepper">
          {gaps.map((g, i) => (
            <li key={g.key}>
              <span className="hk-stepper__n">{i + 1}</span>
              <div style={{ minWidth: 0, flex: 1 }}>
                <div className="hk-stepper__t">{g.t}</div>
                <p className="hk-stepper__d">{g.todo}</p>
              </div>
            </li>
          ))}
        </ol>
      ) : (
        <p className="hk-stepper__d" style={{ marginTop: 0 }}>
          Every field this panel checks for is filled. Keep pushing - the rest is scored on what
          the repository shows at the deadline.
        </p>
      )}

      <div className="hk-how__sec hk-how__sec--rule">How each field is filled</div>
      <p className="hk-stepper__d" style={{ margin: "0 0 20px" }}>
        Read from the repository every 30 minutes. Nothing here is submitted, and no pull request
        updates it.
      </p>
      <ol className="hk-stepper">
        {HK_HOW.map((item, i) => (
          <li key={item.t}>
            <span className="hk-stepper__n">{i + 1}</span>
            <div style={{ minWidth: 0, flex: 1 }}>
              <div className="hk-stepper__t">{item.t}</div>
              <p className="hk-stepper__d">{item.d}</p>
              <p className="hk-stepper__d hk-stepper__d--fix">{item.fix}</p>
            </div>
          </li>
        ))}
      </ol>

      <div className="hk-how__sec hk-how__sec--rule">strk20.json</div>
      <p className="hk-stepper__d" style={{ margin: "0 0 12px" }}>
        A file at the root of the project repository. It holds the four things that cannot be
        detected, and is what the judging panel reads.
      </p>
      <pre className="hk-how__code">{HK_MANIFEST}</pre>
    </div>
  );
}

/* ---------- project modal ---------- */

/* The three values are set at three sizes, so top-anchoring them left their
   baselines on three different lines across a row of equal-height cards.
   Label pinned to the top, value to the bottom left, and they line up. */
const HK_STAT = {
  border: "1px solid var(--line)", borderRadius: 4, padding: "14px 16px",
  background: "var(--bg)", minWidth: 0,
  display: "flex", flexDirection: "column", alignItems: "flex-start",
  justifyContent: "space-between",
};

function HackModal({ p, onClose, now }) {
  const [how, setHow] = useStateT(false);
  const howRef = React.useRef(false);
  howRef.current = how;

  const { mounted: howMounted, nodeRef: howRefEl } = hackUseEnterLeave(
    how,
    (node) => gsap.fromTo(node, { yPercent: 100 }, { yPercent: 0, duration: 0.42, ease: "power3.out" }),
    (node, done) => gsap.to(node, { yPercent: 100, duration: 0.32, ease: "power2.in", onComplete: done }),
  );
  const panelRef = React.useRef(null);
  const backdropRef = React.useRef(null);
  const when = hackWhen(p.pushed_at, now);
  /* People only in the header: the agent belongs to the work, not to the
     byline. It still appears in the row and under Builders below. */
  const people = p.builders || [];
  const named = people.slice(0, 3);
  const rest = people.slice(3);

  /* Escape closes, and the page behind is locked so a scroll gesture over the
     backdrop doesn't move the list underneath. */
  React.useEffect(() => {
    /* Escape peels one layer at a time: the explainer first, then the modal. */
    const onKey = (e) => {
      if (e.key !== "Escape") return;
      if (howRef.current) { setHow(false); return; }
      onClose();
    };
    document.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
  }, [onClose]);

  React.useLayoutEffect(() => {
    if (!hackMotionOn()) return;
    gsap.fromTo(backdropRef.current, { opacity: 0 }, { opacity: 1, duration: 0.22, ease: "power2.out" });
    gsap.fromTo(panelRef.current,
      { opacity: 0, y: 18, scale: 0.985 },
      { opacity: 1, y: 0, scale: 1, duration: 0.34, ease: "power3.out" });
  }, []);

  const contracts = (p.contracts || []).map((c) => (typeof c === "string" ? { address: c, network: "mainnet" } : c));
  const mainnet = contracts.filter((c) => c.network === "mainnet");
  const testnet = contracts.filter((c) => c.network === "sepolia" || c.network === "testnet");
  const shown = mainnet.length ? mainnet : testnet;
  const contractLabel = mainnet.length ? "Contracts on mainnet" : testnet.length ? "Contracts on testnet" : "Contracts";
  const explorer = mainnet.length ? "https://voyager.online/contract/" : "https://sepolia.voyager.online/contract/";

  const links = [
    p.has_readme ? ["Readme", `${p.repo_url}#readme`, <HkGlyphDoc />] : null,
    p.demo_url ? ["Demo", p.demo_url, <HkGlyphGlobe />] : null,
    p.x_handle ? ["X", `https://x.com/${p.x_handle}`, <HkGlyphX />, true] : null,
  ].filter((b) => b && b[1]);

  /* Rendered into <body> rather than in place. A z-index only competes inside
     its own stacking context, so left in the section the modal sat under the
     fixed nav no matter how high it was set. */
  return ReactDOM.createPortal((
    <div
      ref={backdropRef}
      className="hk-modal__backdrop"
      onClick={(e) => { if (e.target === backdropRef.current) onClose(); }}
      role="dialog"
      aria-modal="true"
      aria-label={p.name}
    >
      <div ref={panelRef} className="hk-modal">
        <header className="hk-modal__head">
          <HackAvatars builders={people} size={40} />
          <span className="hk-modal__who">
            {/* Three names, then a count: a six-person team otherwise pushed the
                project name and the header buttons off the panel. */}
            <span className="hk-modal__handle">{named.map((b) => b.login).join(", ")}</span>
            {rest.length > 0 && (
              <span className="hk-modal__more">
                +{rest.length}
                <span className="hk-team" aria-hidden="true">
                  {rest.map((b) => (
                    <span key={b.login} className="hk-team__row">
                      <img src={b.avatar_url} alt="" loading="lazy" width={22} height={22} />
                      <span>{b.login}</span>
                    </span>
                  ))}
                </span>
              </span>
            )}
            <span className="hk-modal__title">{p.name}</span>
          </span>

          <button
            type="button"
            className={how ? "hk-info hk-info--on" : "hk-info"}
            aria-pressed={how}
            aria-label="How this information is tracked"
            title="How this information is tracked"
            onClick={() => { setHow((v) => !v); if (!how) track("modal:how", "hackathon"); }}
          >
            <HkGlyphInfo />
          </button>

          <a href={p.repo_url} target="_blank" rel="noopener noreferrer" className="hk-link"
            onClick={() => track(`modal:github:${p.slug}`, "hackathon")}>
            <HkGlyphGitHub /><span>GitHub ↗</span>
          </a>
          <button type="button" className="hk-modal__close" aria-label="Close" onClick={onClose}>✕</button>
        </header>

        <div className="hk-modal__stage">
        <div className="hk-modal__body">
          <div className="hk-modal__stats">
            <div style={HK_STAT}>
              <div style={HK_LABEL}>Codebase changed</div>
              <div className="hk-modal__stat-v" style={{ color: HK_GREEN }}>
                {p.churn_pct ? `${p.churn_pct >= 10 ? Math.round(p.churn_pct) : p.churn_pct}%` : "-"}
              </div>
            </div>
            <div style={HK_STAT}>
              {/* "Lines changed", not "Last push": the card beside it is now
                  Last pushed, and the two labels next to each other read as
                  the same field twice. */}
              <div style={HK_LABEL}>Lines changed</div>
              <div className="hk-modal__stat-v" style={{ fontSize: 18 }}>
                <span style={{ color: HK_GREEN }}>+{(p.additions || 0).toLocaleString("en-US")}</span>{" "}
                <span style={{ color: HK_RED }}>-{(p.deletions || 0).toLocaleString("en-US")}</span>
              </div>
            </div>
            <div style={HK_STAT}>
              <div style={HK_LABEL}>Last pushed</div>
              {/* Elapsed time only: it is the live number, and the calendar
                  date is already on the row this panel opened from. Sized to
                  the widest string this can produce - "432h 59m 59s" in a
                  face this wide overran the box at 15px. */}
              <div className="hk-modal__stat-v"
                style={{ fontSize: 12, whiteSpace: "nowrap", letterSpacing: "-0.03em" }}>{when.ago}</div>
            </div>
          </div>

          <div className="hk-modal__pair">
            <div>
              <div style={HK_LABEL}>Latest push</div>
              <div className="hk-modal__note">
                <HackTypedBlock
                  key={`${p.slug}-push`}
                  segments={[{
                    text: p.latest_push || "No pushes indexed yet.",
                    style: { margin: 0, color: "rgba(74,222,128,0.74)", fontSize: 14, lineHeight: 1.7, fontWeight: 400 },
                  }]}
                />
                {p.head_sha && (
                  <a
                    href={`${p.repo_url}/commit/${p.head_sha}`}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="hk-commit"
                    onClick={() => track(`modal:commit:${p.slug}`, "hackathon")}
                  >
                    <HkGlyphGitHub />
                    <span>{p.head_sha.slice(0, 7)}</span>
                    <span className="hk-commit__go">View on GitHub</span>
                  </a>
                )}
              </div>
            </div>
            <div>
              <div style={{ ...HK_LABEL, display: "flex", alignItems: "center", gap: 6 }}>
                AI summary<HkGlyphSparkle />
              </div>
              <div className="hk-modal__note">
                <HackTypedBlock
                  key={`${p.slug}-about`}
                  segments={[{
                    text: p.description_long || p.summary || p.one_liner || "No description yet.",
                    style: { margin: 0, color: "rgba(255,255,255,0.58)", fontSize: 14, lineHeight: 1.7, fontWeight: 400 },
                  }]}
                />
              </div>
            </div>
          </div>

          <div className="hk-modal__cols">
            <div>
              <div style={HK_LABEL}>Stack detected</div>
              <div style={{ marginTop: 12 }}>
                {p.tooling && p.tooling.length
                  ? <HackChips tooling={p.tooling} />
                  : <p style={{ margin: 0, color: "rgba(255,255,255,0.42)", fontSize: 13 }}>Nothing detected yet.</p>}
              </div>

              <div style={{ marginTop: 22 }}>
                <div style={HK_LABEL}>{contractLabel}</div>
                <div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 6 }}>
                  {shown.length === 0 && (
                    <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "rgba(255,255,255,0.42)" }}>N/A</span>
                  )}
                  {shown.map((c) => (
                    <a key={c.address} href={`${explorer}${c.address}`} target="_blank" rel="noopener noreferrer"
                      style={{ fontFamily: "var(--mono)", fontSize: 11, color: "rgba(255,255,255,0.62)", textDecoration: "none" }}>
                      {c.address.slice(0, 10)}…{c.address.slice(-6)} ↗
                    </a>
                  ))}
                </div>
              </div>

              {links.length > 0 && (
                <div style={{ marginTop: 22 }}>
                  <div style={HK_LABEL}>Links</div>
                  <div style={{ display: "flex", flexWrap: "wrap", gap: 9, marginTop: 12 }}>
                    {links.map(([t, href, icon, iconOnly]) => (
                      <a key={t} href={href} target="_blank" rel="noopener noreferrer"
                        className={iconOnly ? "hk-link hk-link--icon" : "hk-link"} aria-label={t}
                        onClick={() => track(`modal:${t}:${p.slug}`, "hackathon")}>
                        {icon}{!iconOnly && <span>{t}</span>}
                      </a>
                    ))}
                  </div>
                </div>
              )}
            </div>

            <div>
              <div style={HK_LABEL}>Important links detected</div>
              <div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 7, marginBottom: 22 }}>
                {[["Live demo", "demo"], ["Demo video", "video"], ["Three mainnet transactions", "mainnet"]].map(([label, key]) => {
                  const done = p.requirements?.[key];
                  return (
                    <span key={key} style={{ display: "flex", alignItems: "center", gap: 9,
                      fontFamily: "var(--mono)", fontSize: 11, color: done ? HK_GREEN : "rgba(255,255,255,0.42)" }}>
                      <span style={{ width: 11 }}>{done ? "✓" : "○"}</span>{label}
                    </span>
                  );
                })}
              </div>

              <div style={HK_LABEL}>Builders</div>
              {/* Wraps rather than stacking: a five-person team ran down the
                  panel and pushed everything beside it out of line. */}
              <div className="hk-builders">
                {hackParticipants(p).map((b) => (
                  b.agent ? (
                    <span key={b.login} className="hk-builder hk-builder--plain">
                      {b.avatar_url && <img src={b.avatar_url} alt="" loading="lazy" width={24} height={24} />}
                      <span>{b.name}</span>
                    </span>
                  ) : (
                    <a key={b.login} href={`https://github.com/${b.login}`} target="_blank" rel="noopener noreferrer"
                      className="hk-builder">
                      <img src={b.avatar_url} alt="" loading="lazy" width={24} height={24} />
                      <span>{b.login}</span>
                    </a>
                  )
                ))}
              </div>
            </div>
          </div>
        </div>

        {howMounted && (
          /* Slides up over the panel rather than replacing it, so the thing it
             explains is still there underneath when it slides back down. */
          <div ref={howRefEl} className="hk-how__sheet" aria-label="How this is tracked">
            <HackHow p={p} />
          </div>
        )}
        </div>
      </div>
    </div>
  ), document.body);
}

/* ---------- project row ---------- */

function HackRow({ p, now, index, registerRow, onOpen }) {
  const heat = hackHeat(p.pushed_at);
  const when = hackWhen(p.pushed_at, now);

  return (
    <Reveal delay={Math.min(index * 0.035, 0.4)} className={"hk-row" + (index === 0 ? " hk-row--lead" : "")}>
      <div
        data-slug={p.slug}
        ref={(el) => registerRow && registerRow(p.slug, el)}
        style={{ opacity: heat === 0 ? 0.66 : 1 }}
      >
        <button
          type="button"
          className="hk-row__head"
          onClick={() => { onOpen(p.slug); track(`open:${p.slug}`, "hackathon-row"); }}
        >
          <span className="hk-row__num">{String(index + 1).padStart(2, "0")}</span>

          <span className="hk-row__who">
          <HackAvatars builders={hackParticipants(p)} />

          {hackParticipants(p).length > 1 && (
            /* Spans rather than links: this sits inside the row button, and a
               nested anchor would be invalid and would swallow the click. */
            <span className="hk-team" aria-hidden="true">
              {hackParticipants(p).map((b) => (
                <span key={b.login} className="hk-team__row">
                  <img src={b.avatar_url} alt="" loading="lazy" width={22} height={22} />
                  <span>{b.login}</span>
                </span>
              ))}
            </span>
          )}

          <span className="hk-row__project">
            <span className="hk-row__name">
              {hackHandle(p)}
              {hackParticipants(p).length > 1 && (
                <span className="hk-more">+{hackParticipants(p).length - 1}</span>
              )}
            </span>
            <span className="hk-row__blurb">{p.name}</span>
          </span>
          </span>

          <span className="hk-row__push">
            {p.latest_push
              ? <span style={{ color: "rgba(74,222,128,0.82)" }}>{p.latest_push}</span>
              : <span style={{ color: "var(--faint)" }}>-</span>}
          </span>

          {/* The size of the push beside the push itself. The stack pills that
              were here moved to the panel: what a team built with does not
              change between rows the way the diff does, so it was static
              furniture in a column that has to earn its width. */}
          <span className="hk-row__changes">
            <HackDiffStat additions={p.additions} deletions={p.deletions} churn={p.churn_pct} />
          </span>

          <span className="hk-row__days">
            <HackDayStrip days={p.active_days} now={now} />
          </span>

          <span className="hk-row__when">
            {/* Only the most recent push is lit. Three lit rows read as a
                podium; one reads as what it is, the latest thing to land. */}
            <span className="hk-row__date" style={{ color: index === 0 ? "var(--green)" : "rgba(255,255,255,0.45)" }}>
              {when.ago}
            </span>
          </span>

          <span className="hk-row__caret" aria-hidden="true">›</span>
        </button>
      </div>
    </Reveal>
  );
}

/* Keep six visible table slots while the sprint is still filling up. Empty
   slots are deliberately inert and use one dash per column, so they read as
   available rows rather than projects that can be opened. */
const HK_VISIBLE_SLOTS = 6;

function HackPlaceholderRow() {
  return (
    <div className="hk-row hk-row--placeholder" aria-hidden="true">
      <div className="hk-row__head">
        <span className="hk-row__num">-</span>
        <span className="hk-row__who">-</span>
        <span className="hk-row__push">-</span>
        <span className="hk-row__changes">-</span>
        <span className="hk-row__days">-</span>
        <span className="hk-row__when"><span className="hk-row__date">-</span></span>
        <span className="hk-row__caret">-</span>
      </div>
    </div>
  );
}

/* ---------- the list ---------- */

/* The sprint clock. Not a score - the one number everybody shares, which is
   the honest counterpart to the record ECDSA.fail puts here. */
/* Floored, and off the same instants as the hero countdown: rounding up put
   "3 days" beside a hero reading 2d 9h. Under a day it switches to hours
   rather than showing a zero. */
function hackSpan(ms, left) {
  const d = Math.floor(ms / 86400000);
  if (d >= 1) return { value: d, unit: (d === 1 ? "day" : "days") + (left ? " left" : "") };
  const h = Math.max(1, Math.floor(ms / 3600000));
  return { value: h, unit: (h === 1 ? "hour" : "hours") + (left ? " left" : "") };
}

function hackClock(now) {
  if (now < HACK_CLOSES_AT) return { label: "Time remaining", ...hackSpan(HACK_CLOSES_AT - now, true) };
  return { label: "Sprint closed", value: 0, unit: "submissions in" };
}

/* Compact live readout for the participant table. Unlike the larger hero
   countdown, this stays small enough to sit in the card's upper-right corner. */
function hackTableCountdown(now) {
  if (now >= HACK_CLOSES_AT) return "Closed";
  const left = Math.max(0, HACK_CLOSES_AT - now);
  const d = Math.floor(left / 86400000);
  const h = Math.floor(left / 3600000) % 24;
  const m = Math.floor(left / 60000) % 60;
  const sec = Math.floor(left / 1000) % 60;
  const pad = (n) => String(n).padStart(2, "0");
  return `${d}d ${pad(h)}h ${pad(m)}m ${pad(sec)}s`;
}

/* How often the page re-checks for new pushes. raw.githubusercontent caches
   for a few minutes, so polling faster buys nothing. */
const HACK_POLL_MS = 60000;

/* The Participate and How it works panels are owned by HackathonPage, and the
   pair above the table sits several components below it. Asking by event beats
   threading two setters down through the header. */
const hackOpenPanel = (panel) =>
  window.dispatchEvent(new CustomEvent("strk20:hackathon-cta", { detail: panel }));

function HackList() {
  const [state, setState] = useStateT({ status: "loading", projects: [] });
  const [now, setNow] = useStateT(Date.now());
  const [openSlug, setOpenSlug] = useStateT(null);

  /* Row nodes by slug, plus where each sat before the last update. Together
     they drive the FLIP animation when the order changes. */
  const rowNodes = React.useRef(new Map());
  const lastTops = React.useRef(null);
  const lastLeader = React.useRef(null);

  const registerRow = React.useCallback((slug, el) => {
    if (el) rowNodes.current.set(slug, el);
    else rowNodes.current.delete(slug);
  }, []);

  React.useEffect(() => {
    let live = true;
    /* Try the hackathon repo first, fall back to the copy in this repo. A
       failure on both is an empty list, not a broken page. */
    const load = async () => {
      /* Whichever source actually has projects wins. The live repo answers 200
         with [] before anyone has registered, so "fetch succeeded" isn't
         enough to stop looking - otherwise the bundled preview could never
         render. */
      for (const url of [HACK_PROJECTS_URL, HACK_PROJECTS_FALLBACK]) {
        try {
          const res = await fetch(url, { cache: "no-store" });
          if (!res.ok) continue;
          const json = await res.json();
          const projects = Array.isArray(json) ? json : (json.projects || []);
          if (!projects.length) continue;
          if (!live) return;
          setState((prev) => {
            /* Nothing moved: skip the render, and leave lastTops alone so a
               later real change still has somewhere to animate from. */
            const sig = (list) => list.map((x) => `${x.slug}:${x.pushed_at}`).join("|");
            if (sig(prev.projects) === sig(projects)) return prev;
            /* Measure before React reorders the DOM. */
            const tops = new Map();
            rowNodes.current.forEach((el, slug) => {
              if (el) tops.set(slug, el.getBoundingClientRect().top);
            });
            lastTops.current = tops.size ? tops : null;
            return { status: "ready", projects };
          });
          return;
        } catch (e) { /* try the next source */ }
      }
      if (live) setState((prev) => (prev.projects.length ? prev : { status: "ready", projects: [] }));
    };
    load();
    const id = setInterval(load, HACK_POLL_MS);
    return () => { live = false; clearInterval(id); };
  }, []);

  /* Drives the ticking seconds in every row from one interval. */
  React.useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  /* FLIP: the rows have already been re-sorted into their new DOM positions by
     the time this runs, so put each one back where it was and let GSAP carry
     it forward. Someone watching the page sees the row travel to the top and
     the others give way, rather than the list silently snapping. */
  React.useLayoutEffect(() => {
    const tops = lastTops.current;
    lastTops.current = null;
    if (!tops || !hackMotionOn()) return;

    rowNodes.current.forEach((el, slug) => {
      if (!el) return;
      const before = tops.get(slug);
      if (before == null) return;
      const delta = before - el.getBoundingClientRect().top;
      if (Math.abs(delta) < 1) return;
      gsap.fromTo(el, { y: delta }, { y: 0, duration: 0.65, ease: "power3.inOut" });
    });
  });

  /* Whoever pushed last sits on top. Not a ranking - see the note under the
     list, which is load-bearing rather than decorative. */
  const ordered = state.projects
    .slice()
    .sort((a, b) => new Date(b.pushed_at || 0) - new Date(a.pushed_at || 0));

  /* A new project at the top is the moment worth noticing, so it gets a brief
     wash of the accent once it lands. Skipped on first paint - everything is
     new then, and flashing the whole list would mean nothing. */
  React.useEffect(() => {
    const leader = ordered[0]?.slug;
    if (!leader) return;
    const previous = lastLeader.current;
    lastLeader.current = leader;
    if (!previous || previous === leader || !hackMotionOn()) return;
    const el = rowNodes.current.get(leader);
    if (!el) return;
    gsap.fromTo(el,
      { backgroundColor: "rgba(74, 222, 128, 0.16)" },
      { backgroundColor: "rgba(74, 222, 128, 0)", duration: 1.8, ease: "power2.out", delay: 0.35 });
  }, [state.projects]);

  const devs = new Set();
  ordered.forEach((p) => (p.builders || []).forEach((b) => devs.add(b.login)));
  const clock = hackClock(now);
  const tableCountdown = hackTableCountdown(now);

  /* Headings and the framing sentence sit above the table rather than inside
     it, so the bordered list is only rows - the same shape as the ecosystem
     table in the Use section. */
  const header = (
    <React.Fragment>
      <div className="hk-card__head" style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start",
        justifyContent: "space-between", gap: 20 }}>
        <div>
          <h2 style={{ fontFamily: "var(--display)", fontWeight: 800, textTransform: "uppercase",
            fontSize: "clamp(19px,1.7vw,23px)", letterSpacing: "-0.015em", margin: 0 }}>Live Participants</h2>
          <p style={{ margin: "8px 0 0", color: "var(--dim)", fontSize: 14 }}>
            {ordered.length} {ordered.length === 1 ? "project" : "projects"}, {devs.size} {devs.size === 1 ? "builder" : "builders"}
          </p>
        </div>
        <div style={{ textAlign: "right" }}>
          <div style={{ ...HK_LABEL, marginBottom: 6 }}>{clock.label}</div>
          <div style={{ fontFamily: "var(--mono)", fontWeight: 700, lineHeight: 1,
            fontSize: "clamp(15px,1.65vw,22px)", letterSpacing: "0.04em", whiteSpace: "nowrap" }}>
            {tableCountdown}
          </div>
          <div style={{ marginTop: 6, color: "var(--dim)", fontSize: 11 }}>{clock.unit}</div>
        </div>
      </div>

      {/* The framing sentence on the left, the two calls to action on the right,
          both sitting on the table's top edge. Someone who has read down to the
          list is the most likely person on the page to enter, and until now the
          only way to act on that was to scroll back to the hero. */}
      <div style={{ marginTop: "clamp(18px,2vw,26px)", marginBottom: 14,
        display: "flex", flexWrap: "wrap", alignItems: "flex-end",
        justifyContent: "space-between", gap: 16 }}>
        <div style={{ minWidth: 0 }}>
          <div style={HK_LABEL}>By live activity</div>
          <p style={{ margin: "8px 0 0", color: "var(--dim)", fontSize: 14, lineHeight: 1.55, maxWidth: "72ch" }}>
            Most recent push first, with the order updating as participants push their GitHub
            repository. Open any project to see its description, the stack detected from its
            repository, and its deployments.
          </p>
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
          {/* Participate always leads, How it works always follows - the same
              order as the hero, so the pair never reshuffles between them. */}
          <button type="button" className="hk-cta" style={HK_BTN_GO}
            onClick={() => { track("participate", "hackathon-list"); hackOpenPanel("join"); }}
          >Participate</button>
          <button type="button" className="hk-cta" style={HK_BTN_GHOST}
            onClick={() => { track("how_it_works", "hackathon-list"); hackOpenPanel("about"); }}
          >How it works</button>
        </div>
      </div>
    </React.Fragment>
  );

  let body;
  if (state.status === "loading") {
    body = <p className="hk-empty__text" style={{ padding: "34px 18px", textAlign: "center", margin: 0 }}>Loading projects…</p>;
  } else if (!ordered.length) {
    body = (
      <div style={{ padding: "44px 18px", textAlign: "center" }}>
        <p style={{ fontFamily: "var(--mono)", fontSize: 12, letterSpacing: "0.16em",
          textTransform: "uppercase", color: "var(--green)", margin: 0 }}>No projects yet</p>
        <p style={{ margin: "14px auto 0", color: "rgba(255,255,255,0.6)", maxWidth: 500,
          fontSize: 14, lineHeight: 1.6 }}>
          Applications are open. The first accepted project appears here, and every push
          after that is visible to everyone.
        </p>
        <a href={HACK_REPO_URL} target="_blank" rel="noopener noreferrer" className="hk-link"
          onClick={() => track("apply", "hackathon-empty")}
          style={{ marginTop: 20 }}
        >Apply to build</a>
      </div>
    );
  } else {
    body = (
      <React.Fragment>
        <div className="hk-row hk-row--head">
          <div className="hk-row__head hk-colhead">
            <span />
            <span>Builder</span>
            <span className="hk-row__push">Latest push</span>
            <span className="hk-row__changes">Changes</span>
            <span className="hk-row__days">Days active</span>
            <span className="hk-colhead__pushed" style={{ textAlign: "right" }}>Last pushed</span>
            <span />
          </div>
        </div>
        {ordered.map((p, i) => (
          <HackRow key={p.slug || p.repo_url || i} p={p} now={now} index={i}
            registerRow={registerRow} onOpen={setOpenSlug} />
        ))}
        {Array.from({ length: Math.max(0, HK_VISIBLE_SLOTS - ordered.length) }, (_, i) => (
          <HackPlaceholderRow key={`empty-${i}`} />
        ))}
      </React.Fragment>
    );
  }

  return (
    <React.Fragment>
      {/* Mirrors .eco-list / .eco-row in ecosystem.jsx so the sprint table and
          the Use-section table are the same object with different columns. */}
      <style>{`
        /* The card. Everything about the list - what it is, how many, how long
           is left, and the rows themselves - sits inside one bordered panel,
           rather than a heading floating above a separate box. */
        .hk-card {
          border: 1px solid var(--line);
          border-radius: 8px;
          background: var(--bg);
          padding: clamp(16px,2.2vw,26px);
        }
        /* Rule under the title-and-clock row, the way the two halves of a
           dashboard header are usually separated. */
        .hk-card__head { padding-bottom: clamp(14px,1.6vw,20px); border-bottom: 1px solid var(--line); }

        .hk-list {
          border: 1px solid var(--line);
          border-radius: 5px;
          border-top: 2px solid var(--orange);
          /* Capped and scrolled internally: the list is the liveliest thing on
             the page and should stay browsable, but it cannot be allowed to
             bury the four steps and the judging criteria under it.

             The cap is eight rows rather than a share of the viewport, so the
             window is the same on a laptop and on a 27in monitor and the
             sections below always start in the same place. A row is its 71px
             minimum plus the 1px divider above it - the push sentence is
             clamped to two lines, so nothing grows past that - under a 41px
             column header. The viewport bound is only a floor for short
             screens. */
          /* 72px: the 71px minimum plus the 1px divider above it. Every cell
             is either one line or clamped to two, so no row exceeds it. */
          --hk-row-h: 72px;
          --hk-head-h: 41px;
          /* +3px because max-height is border-box here: the 2px accent rule on
             top and the 1px border underneath would otherwise eat into the
             eighth row and leave it clipped. */
          max-height: min(calc(var(--hk-head-h) + 8 * var(--hk-row-h) + 3px), 84vh);
          overflow-y: auto;
          scrollbar-width: thin;
        }
        /* Pinned while the rows move under it, so the columns stay labelled.
           Above .hk-row's hover z-index, or a hovered row would ride over it.
           Qualified by .hk-list to outrank the .hk-row position:relative below,
           which is declared later and would otherwise win on source order. */
        .hk-list .hk-row--head {
          position: sticky; top: 0; z-index: 40;
          background: var(--bg);
        }
        /* Not overflow:hidden - that clipped the roster popup at the list edge.
           Reveal puts a transform on every row, which makes each one its own
           stacking context, so the hovered row has to be lifted above the rows
           that come after it in the DOM. */
        .hk-row { position: relative; }
        .hk-row:hover { z-index: 30; }
        .hk-row + .hk-row { border-top: 1px solid var(--line); }

        /* The lead row carries the promo bar's orange, which is the strongest
           surface the site uses. Every colour the row normally relies on -
           green for the sentence, green and red for the diff, the ember accent
           for the rank and the clock - lands between 1.2:1 and 3.2:1 against
           it, so the row switches to a white-on-orange palette wholesale and
           keeps its meaning in the +/- signs and the strip's shape instead of
           in hue. The overrides carry !important because the values they
           replace are inline styles on the cells. */
        /* Sampled off the reference: #c93809 at the top left corner through
           #d24e1c at the centre to #d75b24 at the bottom right. Split into a
           horizontal ramp and a soft vertical shade because the row is twenty
           times wider than it is tall - a single 135deg ramp crosses it in the
           first eighth and reads as a flat colour after that. */
        .hk-row--lead .hk-row__head {
          background:
            linear-gradient(180deg, rgba(0,0,0,0.06) 0%, rgba(255,255,255,0.045) 100%),
            linear-gradient(92deg, #c93809 0%, #d75b24 100%);
        }
        .hk-row--lead .hk-row__head:hover {
          background:
            linear-gradient(180deg, rgba(0,0,0,0.03) 0%, rgba(255,255,255,0.08) 100%),
            linear-gradient(92deg, #d13f0e 0%, #e0642a 100%);
        }
        .hk-row--lead .hk-row__num,
        .hk-row--lead .hk-row__name,
        .hk-row--lead .hk-row__push span,
        .hk-row--lead .hk-diff__counts span:first-child,
        .hk-row--lead .hk-row__date { color: #fff !important; }
        .hk-row--lead .hk-row__blurb,
        .hk-row--lead .hk-more,
        .hk-row--lead .hk-diff__counts span:last-child,
        .hk-row--lead .hk-diff__churn,
        .hk-row--lead .hk-row__caret { color: rgba(255,255,255,0.9) !important; }
        .hk-row--lead .hk-day { background: rgba(255,255,255,0.28); }
        .hk-row--lead .hk-day--on { background: #fff; }
        .hk-row--lead .hk-day--future { background: rgba(255,255,255,0.14); }
        .hk-row--lead .hk-row__head:hover .hk-row__caret { color: #fff !important; }

        .hk-row__head {
          display: grid;
          /* Seven cells: place, builder, what just happened, what they built
             with, which days they worked, when. The gutter that used to sit
             between the sentence and the counts paid for the day strip. */
          /* Builder is capped rather than flexible: with the project name gone
             it only holds an avatar stack and a handle, and letting it take
             the slack left a gutter before the sentence. The slack goes to the
             sentence instead, which buys it a line. */
          grid-template-columns: 28px minmax(0, 262px) minmax(0, 244px) minmax(150px, 1fr) 112px 142px 14px;
          align-items: center; gap: 14px;
          width: 100%;
          min-height: 71px; padding: 14px;
          background: none; border: 0;
          text-align: left; cursor: pointer; color: var(--text);
          transition: background .18s ease;
        }
        .hk-row__head:hover { background: rgba(255,255,255,0.035); }
        .hk-row--head .hk-row__head {
          cursor: default; min-height: 41px; padding-top: 0; padding-bottom: 0;
        }
        .hk-row--head .hk-row__head:hover { background: none; }
        .hk-row--placeholder .hk-row__head {
          cursor: default;
          color: rgba(255,255,255,0.2);
        }
        .hk-row--placeholder .hk-row__head:hover { background: none; }
        .hk-row--placeholder .hk-row__caret { color: inherit; }
        .hk-row--placeholder .hk-row__head:hover .hk-row__caret {
          color: inherit;
          transform: none;
        }
        .hk-colhead {
          font-family: var(--mono); font-size: 10px;
          letter-spacing: 0.18em; text-transform: uppercase;
          color: rgba(255,255,255,0.42);
        }
        .hk-colhead__pushed { font-weight: 700; color: rgba(255,255,255,0.58); }

        .hk-row__num {
          font-family: var(--mono); font-size: 12px; letter-spacing: 0.04em;
          color: rgba(255,255,255,0.32);
        }
        /* The most recent push leads the list; give it the accent so the eye
           lands there first. */
        .hk-row--head + .hk-row .hk-row__num { color: var(--green); }

        /* Avatar and handle sit together, so the column header lines up with the
           avatar's left edge rather than the text. */
        .hk-row__who { display: flex; align-items: center; gap: 12px; min-width: 0; position: relative; }
        .hk-more { color: rgba(255,255,255,0.42); font-size: 13px; margin-left: 5px; }
        .hk-agent {
          margin-left: 8px; padding: 2px 7px; border-radius: 3px;
          border: 1px solid rgba(74,222,128,0.35); background: rgba(74,222,128,0.08);
          font-family: var(--mono); font-weight: 400; font-size: 9.5px;
          letter-spacing: 0.1em; text-transform: uppercase; color: #4ade80;
          white-space: nowrap; vertical-align: 2px;
          display: inline-flex; align-items: center; gap: 5px;
        }
        .hk-agent img { width: 13px; height: 13px; border-radius: 2px; background: var(--bg-2); }

        /* Roster on hover: the row only has space for the lead committer, and
           the count alone tells you nothing about who else is on it. */
        .hk-team {
          position: absolute; top: calc(100% + 8px); left: 0; z-index: 20;
          display: flex; flex-direction: column; gap: 8px;
          min-width: 170px; padding: 10px 12px;
          background: #141414; border: 1px solid var(--line); border-radius: 6px;
          box-shadow: 0 22px 50px -22px rgba(0,0,0,0.9);
          opacity: 0; visibility: hidden; transform: translateY(-4px);
          transition: opacity .16s ease, transform .16s ease, visibility .16s;
          pointer-events: none;
        }
        .hk-row__who:hover .hk-team { opacity: 1; visibility: visible; transform: translateY(0); }
        /* Near the bottom of the list there is no room below, so open upward. */
        .hk-row:nth-last-child(-n+2) .hk-team { top: auto; bottom: calc(100% + 8px); }
        .hk-team__row { display: flex; align-items: center; gap: 9px; white-space: nowrap; }
        .hk-team__row img { width: 22px; height: 22px; border-radius: 50%; background: var(--bg-2); flex: none; }
        .hk-team__row span { font-family: var(--body); font-weight: 500; font-size: 14px; color: rgba(255,255,255,0.82); }
        @media (prefers-reduced-motion: reduce) { .hk-team { transition: none; } }
        /* Handle and project on one line: stacked, the project read as a
           caption on the handle rather than as the thing being built. */
        /* Centre, not baseline: the project is set in a smaller face, and baseline
   alignment dropped it a pixel below the avatar's midline. */
        .hk-row__project { display: flex; align-items: center; gap: 10px; min-width: 0; }
        .hk-row__name {
          font-family: var(--body); font-weight: 500;
          font-size: 14px; letter-spacing: 0; text-transform: none;
          color: var(--text); white-space: nowrap; flex: none;
        }
        /* The project name yields first: the handle is the row's identity, and
           a long project name should ellipsis rather than push it out. */
        .hk-row__blurb {
          font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em;
          color: rgba(255,255,255,0.45);
          overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
        }
        .hk-row__push {
          font-family: var(--mono); font-size: 11px; font-weight: 400;
          line-height: 1.6; min-width: 0;
          /* Two lines, then an ellipsis. A long generated sentence would
             otherwise set the height of every row it appears in. */
          display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
          overflow: hidden;
        }
        /* Pushed to the right of its track rather than sitting flush against
           the sentence: numbers next to prose read as part of it. */
        .hk-row__changes {
          min-width: 0; display: flex; justify-content: flex-end; text-align: right;
          /* Right-aligned but held well clear of the strip: at the 14px grid
             gap alone the header read as "CHANGES DAYS ACTIVE" and the counts
             crowded the dots. */
          padding-right: 46px;
        }
        .hk-diff {
          display: flex; flex-direction: column; align-items: flex-end; gap: 3px;
          font-family: var(--mono); font-size: 11.5px; white-space: nowrap; min-width: 0;
        }
        .hk-diff__counts { display: flex; gap: 7px; }
        .hk-diff__churn { color: rgba(255,255,255,0.45); }

        /* Quieter than the chips in the project panel: two of these sit in
           every row, so at panel weight the column became the loudest thing on
           the page and outshouted the push sentence beside it. */
        .hk-pills { display: flex; flex-wrap: wrap; gap: 5px; min-width: 0; }
        .hk-pill {
          font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.1em;
          text-transform: uppercase; white-space: nowrap;
          padding: 3px 7px; border-radius: 3px;
          border: 1px solid rgba(197,52,0,0.32);
          background: rgba(197,52,0,0.08);
          color: rgba(255,255,255,0.72);
        }
        /* The overflow count is not a part of the stack, so it does not wear
           the accent - it carries the rest of the list in its tooltip. */
        .hk-pill--more {
          border-color: var(--line); background: none;
          color: rgba(255,255,255,0.42); cursor: default;
        }

        .hk-row__days { min-width: 0; }
        .hk-days { display: flex; gap: 2px; cursor: default; }
        .hk-day {
          width: 4px; height: 14px; border-radius: 1px;
          background: rgba(255,255,255,0.14);
        }
        .hk-day--on { background: var(--green); }
        /* Fainter than a day that passed without work: an empty strip in week
           one is a sprint that has barely started, not an absent team. */
        .hk-day--future { background: rgba(255,255,255,0.05); }

        .hk-row__when { text-align: right; white-space: nowrap; }
        .hk-row__date {
          display: flex; align-items: center; justify-content: flex-end; gap: 7px;
          font-family: var(--mono); font-size: 11px; font-weight: 400; letter-spacing: 0.04em;
        }

        .hk-row__caret {
          font-size: 11px; color: rgba(255,255,255,0.45);
          transition: transform .22s ease;
        }
        .hk-row__head:hover .hk-row__caret { transform: translateX(2px); color: var(--green); }

        /* Opening a project is a modal rather than an inline drawer: the write-up
           runs long, and pushing every row below it down the page made the list
           lose its place. */
        .hk-modal__backdrop {
          position: fixed; inset: 0; z-index: 200;
          display: flex; align-items: flex-start; justify-content: center;
          padding: clamp(16px,6vh,72px) 16px;
          background: rgba(6,6,6,0.72); backdrop-filter: blur(6px);
          overflow-y: auto;
        }
        .hk-modal {
          position: relative;
          width: 100%; max-width: 720px; max-height: 86vh;
          display: flex; flex-direction: column;
          background: var(--bg-1); border: 1px solid var(--line);
          border-top: 2px solid var(--orange); border-radius: 5px;
          box-shadow: 0 40px 90px -30px rgba(0,0,0,0.9);
        }
        .hk-modal--about { max-width: 672px; }
        /* The reference is a single padded card rather than a header bar over a
           body: no rule between them, 22px of air all round, 16px between blocks. */
        .hk-modal--about .hk-modal__head {
          align-items: flex-start; padding: 22px 22px 0; border-bottom: 0;
        }
        /* Only this panel: it is a numbered list under a title, and the rule
           separates the two the way the steps are separated from each other.
           Participate opens on a segmented control, which does that job. */
        .hk-modal--how .hk-modal__head {
          padding-bottom: 18px; border-bottom: 1px solid var(--line);
        }
        .hk-modal--about .hk-modal__body {
          padding: 16px 22px 22px;
          display: flex; flex-direction: column; flex: 1; min-height: 0;
        }
        .hk-modal__foot {
          display: flex; flex-wrap: wrap; gap: 9px;
          margin-top: auto; padding-top: 22px;
        }
        .hk-modal--about .hk-modal__close {
          width: 32px; height: 32px; margin: -4px -4px 0 auto;
          border: 0; border-radius: 6px; color: rgba(255,255,255,0.38);
          transition: background .15s ease, color .15s ease;
        }
        .hk-modal--about .hk-modal__close:hover {
          background: rgba(255,255,255,0.06); color: var(--text);
        }
        .hk-how__title {
          display: block; font-family: var(--body); font-weight: 500;
          font-size: 16px; letter-spacing: -0.025em; color: var(--text);
        }
        /* Sits under the title in the header, not at the top of the body. */
        .hk-how__sub {
          margin: 4px 0 0; font-size: 14px; line-height: 1.5;
          color: rgba(255,255,255,0.5);
        }
        .hk-modal--how { max-width: 512px; width: 512px; height: 667px; max-height: 90vh; }

        /* Matched to the reference: heading and body at nearly the same size,
           separated by weight and colour rather than scale, on a loose 1.55
           rhythm. */
        .hk-stepper { margin: 0; padding: 0; list-style: none; }
        .hk-stepper li { display: flex; gap: 15px; margin-bottom: 22px; }
        .hk-stepper li:last-child { margin-bottom: 0; }
        .hk-stepper__n {
          flex: none; width: 20px; height: 20px; margin-top: 3px;
          display: inline-flex; align-items: center; justify-content: center;
          border-radius: 50%; background: rgba(197,52,0,0.16); color: var(--green);
          font-family: var(--mono); font-size: 10.5px;
        }
        .hk-stepper__t {
          font-family: var(--body); font-weight: 500; font-size: 15px;
          color: var(--text); line-height: 1.45;
        }
        .hk-stepper__d {
          margin: 2px 0 0; font-weight: 400; font-size: 15px; line-height: 1.55;
          color: rgba(255,255,255,0.5);
        }

        .hk-tabs {
          display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
          padding: 4px; margin-bottom: 16px;
          background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
        }
        /* Sentence case at body size, not a mono label: the reference reads these
           as two choices, and uppercase mono reads as a heading. */
        .hk-tab {
          padding: 8px 12px; cursor: pointer; background: none; border: 0; border-radius: 6px;
          font-family: var(--body); font-size: 14px; font-weight: 500; letter-spacing: 0;
          color: rgba(255,255,255,0.5);
          transition: background .15s ease, color .15s ease, box-shadow .15s ease;
        }
        .hk-tab:hover { color: var(--text); }
        /* Raised out of the well rather than tinted into it. */
        .hk-tab--on {
          background: var(--bg-1); color: var(--text);
          box-shadow: 0 1px 2px rgba(0,0,0,0.55);
        }

        .hk-prompt { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
        .hk-prompt__head {
          display: flex; align-items: center; justify-content: space-between; gap: 12px;
          padding: 10px 16px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,0.02);
          font-family: var(--mono); font-size: 11px; font-weight: 400; letter-spacing: 0.14em;
          text-transform: uppercase; color: rgba(255,255,255,0.42);
        }
        /* Borderless, like the reference: the card already has an edge, and a
           second one around a 14px icon made the header look like a toolbar. */
        .hk-prompt__copy {
          display: inline-flex; align-items: center; justify-content: center;
          width: 26px; height: 26px; padding: 0; margin: -2px -4px -2px 0;
          background: none; border: 0; border-radius: 4px; cursor: pointer;
          color: rgba(255,255,255,0.42);
          transition: background .15s ease, color .15s ease;
        }
        .hk-prompt__copy:hover { background: rgba(255,255,255,0.06); color: var(--text); }
        .hk-prompt__body {
          margin: 0; padding: 16px; max-height: 55dvh; overflow: auto;
          font-family: var(--mono); font-size: 12.5px; line-height: 24px;
          color: rgba(255,255,255,0.68); white-space: pre-wrap; word-break: break-word;
          background: var(--bg);
          scrollbar-width: none; -ms-overflow-style: none;
        }
        .hk-prompt__body::-webkit-scrollbar { width: 0; height: 0; }
        .hk-prompt__note {
          margin: 10px 0 0; font-size: 12px; line-height: 20px;
          color: rgba(255,255,255,0.42);
        }

        .hk-stepper--human li { margin-bottom: 20px; }
        .hk-copy { position: relative; margin-top: 10px; }
        .hk-copy pre {
          margin: 0; padding: 14px 54px 14px 16px; overflow-x: auto;
          font-family: var(--mono); font-size: 12.5px; line-height: 22px;
          color: rgba(255,255,255,0.68); background: var(--bg);
          border: 1px solid var(--line); border-radius: 8px;
          scrollbar-width: none; -ms-overflow-style: none;
        }
        .hk-copy pre::-webkit-scrollbar { width: 0; height: 0; }
        .hk-copy button {
          position: absolute; top: 9px; right: 9px; cursor: pointer;
          display: inline-flex; align-items: center; justify-content: center;
          width: 26px; height: 26px; padding: 0;
          border: 0; border-radius: 4px;
          background: none; color: rgba(255,255,255,0.42);
          transition: background .15s ease, color .15s ease;
        }
        .hk-copy button:hover { background: rgba(255,255,255,0.08); color: var(--text); }

        .hk-steps { margin: 0; padding: 0; list-style: none; }
        .hk-steps li {
          display: flex; gap: 12px; padding: 12px 0;
          border-top: 1px solid var(--line);
          font-size: 14px; line-height: 1.6; color: rgba(255,255,255,0.72);
        }
        .hk-steps li:last-child { border-bottom: 1px solid var(--line); }
        .hk-steps span {
          font-family: var(--mono); font-size: 11px; color: var(--green); flex: none; padding-top: 3px;
        }
        .hk-modal__head {
          display: flex; align-items: center; gap: 12px;
          padding: 16px 18px; border-bottom: 1px solid var(--line); flex: none;
        }
        /* Same shape as the row: handle, then project beside it, both centred
           on the avatar. */
        .hk-modal__who { display: flex; align-items: center; gap: 10px; min-width: 0; }
        /* Carries the hover roster, so it has to be the positioning parent. */
        .hk-modal__more {
          position: relative; flex: none; cursor: default;
          font-family: var(--mono); font-size: 11px; color: rgba(255,255,255,0.45);
        }
        .hk-modal__more:hover { color: var(--text); }
        .hk-modal__more:hover .hk-team { opacity: 1; visibility: visible; transform: translateY(0); }
        .hk-modal__handle {
          font-family: var(--body); font-weight: 500; font-size: 14px;
          color: var(--text); white-space: nowrap; flex: none;
        }
        .hk-modal__title {
          font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em;
          color: rgba(255,255,255,0.45);
          overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
        }
        /* Pushes the GitHub button and close to the right edge. */
        .hk-modal__head .hk-link { margin-left: auto; flex: none; }
        .hk-modal__close {
          background: none; border: 1px solid var(--line); border-radius: 3px;
          color: rgba(255,255,255,0.62); width: 30px; height: 30px; flex: none;
          cursor: pointer; font-size: 12px; line-height: 1;
          transition: border-color .18s ease, color .18s ease;
        }
        .hk-modal__close:hover { border-color: rgba(255,255,255,0.32); color: var(--text); }

        /* Still scrolls, just without the bar: the panel is short and the chrome
           was a visible seam down its right edge. */
        /* Sits before the GitHub link, so the header reads: who, how, where. */
        .hk-info {
          margin-left: auto; flex: none; display: inline-flex;
          align-items: center; justify-content: center;
          width: 30px; height: 30px; cursor: pointer;
          background: none; border: 1px solid var(--line); border-radius: 3px;
          color: rgba(255,255,255,0.5);
          transition: color .18s ease, border-color .18s ease, background .18s ease;
        }
        .hk-info:hover { color: var(--text); border-color: rgba(255,255,255,0.32); }
        .hk-info--on { color: var(--green); border-color: var(--green); background: rgba(197,52,0,0.12); }
        /* The info button now carries the auto margin that pushed the row right. */
        .hk-modal__head .hk-link { margin-left: 0; }

        /* The stage clips the sheet, so it can start below the panel and slide
           into it without escaping the rounded corners. */
        .hk-modal__stage { position: relative; overflow: hidden; flex: 1; min-height: 0; display: flex; }
        .hk-modal__stage .hk-modal__body { flex: 1; }
        .hk-how__sheet {
          position: absolute; inset: 0; z-index: 5;
          background: var(--bg-1);
          padding: 20px 22px 26px; overflow-y: auto;
          scrollbar-width: none; -ms-overflow-style: none;
        }
        .hk-how__sheet::-webkit-scrollbar { width: 0; height: 0; }

        .hk-how__sec {
          display: block;
          font-family: var(--body); font-weight: 500; font-size: 16px;
          letter-spacing: -0.025em; color: var(--text); margin-bottom: 16px;
        }
        .hk-how__head {
          display: flex; align-items: center; justify-content: space-between;
          gap: 14px; flex-wrap: wrap; margin-bottom: 16px;
        }
        .hk-how__copy { cursor: pointer; background: none; flex: none; }
        .hk-how__copy:hover { border-color: var(--green); color: var(--green); }
        .hk-how__sec--rule {
          margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--line);
        }
        .hk-stepper__d--fix { color: rgba(255,255,255,0.34); }
        .hk-how__lead {
          margin: 0 0 22px; font-size: 14px; line-height: 1.65;
          color: rgba(255,255,255,0.72);
        }
        .hk-how__item { margin-bottom: 20px; }
        .hk-how__t {
          font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.14em;
          text-transform: uppercase; color: var(--green);
        }
        .hk-how__d {
          margin: 8px 0 0; font-size: 13.5px; line-height: 1.6;
          color: rgba(255,255,255,0.72);
        }
        .hk-how__fix {
          margin: 6px 0 0; font-size: 13.5px; line-height: 1.6;
          color: rgba(255,255,255,0.45);
        }
        .hk-how__code {
          margin: 12px 0 0; padding: 14px; overflow-x: auto;
          font-family: var(--mono); font-size: 11.5px; line-height: 1.7;
          color: rgba(255,255,255,0.6);
          border: 1px solid var(--line); border-radius: 4px; background: var(--bg);
        }

        .hk-modal__body {
          padding: 20px 18px 26px; overflow-y: auto;
          scrollbar-width: none; -ms-overflow-style: none;
        }
        .hk-modal__body::-webkit-scrollbar { width: 0; height: 0; }
        .hk-modal__note::-webkit-scrollbar { width: 0; height: 0; }
        .hk-modal__note { scrollbar-width: none; -ms-overflow-style: none; }
        .hk-modal__backdrop { scrollbar-width: none; -ms-overflow-style: none; }
        .hk-modal__backdrop::-webkit-scrollbar { width: 0; height: 0; }
        .hk-modal__stats {
          display: grid; gap: 10px; margin-bottom: 24px;
          grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
        }
        .hk-modal__stat-v {
          margin-top: 8px; font-family: var(--display); font-weight: 800;
          font-size: 24px; letter-spacing: -0.02em; line-height: 1;
        }
        .hk-caret {
          display: inline-block; width: 7px; height: 1em; margin-left: 2px;
          background: currentColor; opacity: .7; vertical-align: -0.15em;
          animation: hkCaret .9s step-end infinite;
        }
        @keyframes hkCaret { 0%,100% { opacity: .7 } 50% { opacity: 0 } }

        /* Two boxes side by side: what just happened, and what the project is.
           They stack on narrow screens rather than squeezing into a column each. */
        .hk-modal__pair {
          display: grid; gap: 14px; align-items: stretch;
          grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
        }
        /* Pushed to the foot of the box by the auto margin, so it stays put
           regardless of how many lines the sentence types out to. */
        .hk-commit {
          display: inline-flex; align-items: center; gap: 8px;
          margin-top: auto; padding-top: 16px; align-self: flex-start;
          font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em;
          color: rgba(255,255,255,0.45); text-decoration: none;
          transition: color .18s ease;
        }
        .hk-commit:hover { color: var(--text); }
        .hk-commit__go { color: rgba(255,255,255,0.3); }
        .hk-commit:hover .hk-commit__go { color: var(--green); }

        /* Fixed height, not stretched to content: typing into an auto-height box
           reflowed everything below it on almost every frame. Overflow scrolls
           for the rare description that runs past it. */
        .hk-modal__note {
          margin-top: 10px; padding: 16px; height: 162px;
          display: flex; flex-direction: column; overflow-y: auto;
          border: 1px solid var(--line); border-radius: 4px; background: var(--bg);
        }
        .hk-chip-link { transition: background .18s ease, border-color .18s ease; }
        .hk-chip-link:hover { background: rgba(197,52,0,0.14); border-color: var(--green); }

        .hk-builders { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
        .hk-builder {
          display: inline-flex; align-items: center; gap: 8px;
          padding: 5px 11px 5px 5px;
          border: 1px solid var(--line); border-radius: 999px;
          text-decoration: none; color: rgba(255,255,255,0.78);
          transition: border-color .18s ease, color .18s ease;
        }
        /* Anchors only: agents render as a span, and a hover state on
           something that cannot be clicked promises a link that is not there. */
        a.hk-builder:hover { border-color: rgba(255,255,255,0.32); color: var(--text); }
        /* No pill for agents: the outline reads as a control, and this one does
           nothing when clicked. */
        .hk-builder--plain { border-color: transparent; padding-left: 0; }
        .hk-builder--agent { padding: 5px 11px 5px 5px; border-color: rgba(74,222,128,0.35); color: #4ade80; }
        .hk-builder--agent img { width: 22px; height: 22px; border-radius: 50%; background: var(--bg-2); flex: none; }
        .hk-builder--agent em { font-style: normal; font-family: var(--mono); font-size: 10px; color: rgba(255,255,255,0.4); }
        .hk-builder img { width: 24px; height: 24px; border-radius: 50%; background: var(--bg-2); flex: none; }
        .hk-builder img[src^="/brand/agents/"] { padding: 3px; background: rgba(255,255,255,0.08); }
        .hk-builder span { font-family: var(--body); font-weight: 500; font-size: 13px; white-space: nowrap; }

        .hk-modal__cols {
          margin-top: 26px; display: grid; gap: clamp(20px,3vw,44px);
          grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
        }
        @media (max-width: 640px) {
          /* The desktop panel is a fixed 512 wide; on a phone it just fills
             what is there and grows to its content. */
          .hk-modal--how { width: 100%; max-width: 100%; height: auto; max-height: 86vh; }
          .hk-modal__head { flex-wrap: wrap; }
          .hk-modal__head .hk-link { margin-left: 0; }
        }

        .hk-link {
          display: inline-flex; align-items: center; gap: 7px;
          padding: 7px 11px;
          font-family: var(--mono); font-size: 10px;
          letter-spacing: 0.06em; text-transform: uppercase;
          color: var(--text); text-decoration: none; white-space: nowrap;
          border: 1px solid var(--line); border-radius: 3px;
          transition: border-color .18s ease, background .18s ease, color .18s ease;
        }
        .hk-link:hover { border-color: rgba(255,255,255,0.32); }
        /* Shape and colour come from HK_BTN_GO / HK_BTN_GHOST, shared with the
           hero. The class exists for the states inline styles cannot express.
           Focus returns to these every time a panel closes, so the ring is on
           screen often - recoloured, not removed, since keyboard users need
           it and the browser default is blue against an ember palette. */
        .hk-cta:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
        .hk-link svg { flex-shrink: 0; }
        /* Icon-only destination: square target, none of the padding meant to
           balance uppercase letter-spacing. */
        .hk-link--icon {
          padding: 0; width: 30px; height: 30px;
          justify-content: center; gap: 0;
          color: rgba(255,255,255,0.62);
        }
        .hk-link--icon:hover { color: var(--text); }

        @media (max-width: 900px) {
          /* Four cells for the four things that survive: place, builder,
             pushed, caret. The push sentence, the diff counts and the gutter
             between them all leave - and the gutter has to leave explicitly,
             or it occupies the Pushed column and the caret wraps to a second
             line under the row. */
          .hk-row__head { grid-template-columns: 26px minmax(0,1fr) auto 14px; gap: 10px; }
          .hk-row__changes, .hk-row__push, .hk-row__days { display: none; }
          /* The project name goes too: at this width it truncated to a letter
             or two, which reads as a rendering fault rather than a name. It is
             in the panel, one tap away. */
          .hk-row__blurb { display: none; }
          .hk-row__body { padding-left: 18px; }
          .hk-colhead, .hk-row__date { font-size: 10px; }
          /* Faces and a nowrap handle do not shrink on their own, so without
             this the builder cell simply drew over the Pushed one. */
          .hk-row__who { overflow: hidden; }
        }
        @media (prefers-reduced-motion: reduce) {
          .hk-row__caret, .hk-row__head, .hk-link { transition: none; }
        }
      `}</style>

      {/* One card holds the heading, the clock and the list, and the list scrolls
          inside it rather than down the page. Thirty projects otherwise run
          past two screens and push everything after them - the four steps, the
          judging criteria - out of reach of anyone who only skims. */}
      <section className="hk-card" aria-label="Live participants">
        {header}
        <div className="hk-list">{body}</div>

        <p style={{
          marginTop: 14, fontFamily: "var(--mono)", fontSize: 11, letterSpacing: "0.08em",
          color: "rgba(255,255,255,0.42)", lineHeight: 1.7,
        }}>
          Changes are the lines the latest push added and removed, and the share of the codebase
          it moved - one push, not the whole sprint. Winners are chosen by the panel after
          31 August, against the criteria below.
        </p>
      </section>

      {openSlug && ordered.some((x) => x.slug === openSlug) && (
        <HackModal p={ordered.find((x) => x.slug === openSlug)} now={now} onClose={() => setOpenSlug(null)} />
      )}
    </React.Fragment>
  );
}

/* ---------- participate ---------- */

/* One prompt that carries someone the whole way, for the coding agent most of
   this audience already works in: it registers them, adds the manifest they
   would otherwise forget, and then hands their agent the skill, the docs and
   the addresses it needs to start. Registration comes first because it is the
   part with a deadline; everything after it is the eighteen days. */
const HK_AGENT_PROMPT = `Register my project for the STRK20 Private Sprint, then set it up so I can start building.

STEP 1 - REGISTER

Fork ${HACK_REPO_URL} and append one object to registry.json:

   {
     "repo_url": "<MY PUBLIC GITHUB REPO URL>",
     "telegram": ["<MY TELEGRAM USERNAME>"]
   }

Do not modify any other entry. Open a pull request describing what I am
building and who I am. That is the whole application - there is no second
pull request.

STEP 2 - ADD strk20.json TO MY OWN REPOSITORY

At the root, create strk20.json:

   {
     "transactions": [],
     "contracts": [],
     "demo_video": "",
     "demo_url": ""
   }

Fill each field in as it comes to exist:
- transactions: three Starknet mainnet transaction hashes that touched the
  STRK20 pool at ${HACK_POOL}
- contracts: any contract addresses I deploy
- demo_video: a 3-minute demo video link
- demo_url: only if the demo is not detected already. The repository's
  Website field, GitHub Pages, and the most recent successful deployment
  reported to GitHub (Vercel and Netlify report one on every deploy) are
  all picked up without being declared

STEP 3 - INSTALL THE STRK20 SKILL

   npx skills add starkience/strk20-agent-skills

Then ask me to "plan STRK20 privacy for this app". The skill reads this
repository, asks what should actually be private, picks an integration route,
and writes a phased STRK20_INTEGRATION_PLAN.md before changing anything. It
does not write Cairo contracts, does not put key material in files, and does
not touch mainnet without being told.

STEP 4 - LOAD THE CONTEXT

- Docs: https://strk20-by-example.org/what-is-strk20
  The whole site is mirrored as Markdown. Fetch
  https://strk20-by-example.org/llms-full.txt instead of parsing pages.
- Starter kit, Next.js - wallet picker, shield, unshield, private transfer:
  https://github.com/Akashneelesh/strk20-starter-kit
- SDKs, helper contracts and examples:
  https://github.com/Akashneelesh/awesome-strk20
- Ideas in scope: ${HACK_IDEAS_URL}

Mainnet is CHAIN_ID SN_MAIN. Create a free Alchemy key at
https://www.alchemy.com and point the RPC at
${HACK_RPC}
Keep the key in an env var. Never commit it.

Nothing else is submitted. Pushes, stack, contracts, demo and builders are
read from my repository every 30 minutes.`;

/* Their humans tab is numbered steps, each with the exact thing to copy. Same
   shape here, minus a CLI we do not have: the copyable parts are the two JSON
   blocks, which is where applications actually go wrong. */
const HK_ENTRY_SNIPPET = `{
  "repo_url": "https://github.com/you/your-project",
  "telegram": ["your_telegram"]
}`;
/* Shown, not copied: the ellipsis stands for whoever is already in the file
   and would not parse. The copy button hands over the object alone. */
const HK_ENTRY_DISPLAY = `[
  ...,
  {
    "repo_url": "https://github.com/you/your-project",
    "telegram": ["your_telegram"]
  }
]`;

const HK_MANIFEST_SNIPPET = `{
  "transactions": [],
  "contracts": [],
  "demo_video": "",
  "demo_url": ""
}`;

const HK_HUMAN_STEPS = [
  {
    t: "Open registry.json",
    d: "Opens registry.json in GitHub's editor. GitHub forks the repository for you on your first keystroke.",
    link: { label: "Edit on GitHub", href: `${HACK_REPO_URL}/edit/main/registry.json` },
  },
  {
    t: "Append your entry",
    d: "Two fields, inside the brackets, with a comma after the entry above it. Leave every other entry alone, then propose the change as a pull request saying what you are building.",
    code: HK_ENTRY_SNIPPET,
    display: HK_ENTRY_DISPLAY,
  },
  {
    t: "Add strk20.json to your own repo",
    d: "Fill each field in as it comes to exist. This is what the panel reads at the deadline.",
    code: HK_MANIFEST_SNIPPET,
  },
];

function HackCopy({ code, display }) {
  const [copied, setCopied] = useStateT(false);
  return (
    <div className="hk-copy">
      <pre>{display || code}</pre>
      <button type="button" onClick={() => {
        navigator.clipboard?.writeText(code).then(() => {
          setCopied(true); setTimeout(() => setCopied(false), 1600);
        }).catch(() => {});
      }} aria-label={copied ? "Copied" : "Copy"} title={copied ? "Copied" : "Copy"}>
        {copied ? <HkGlyphCheck /> : <HkGlyphCopy />}
      </button>
    </div>
  );
}

function HackParticipate({ onClose }) {
  const [tab, setTab] = useStateT("agents");
  const [copied, setCopied] = useStateT(false);
  const panelRef = React.useRef(null);
  const backdropRef = React.useRef(null);

  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
  }, [onClose]);

  React.useLayoutEffect(() => {
    if (!hackMotionOn()) return;
    gsap.fromTo(backdropRef.current, { opacity: 0 }, { opacity: 1, duration: 0.22, ease: "power2.out" });
    gsap.fromTo(panelRef.current,
      { opacity: 0, y: 18, scale: 0.985 },
      { opacity: 1, y: 0, scale: 1, duration: 0.34, ease: "power3.out" });
  }, []);

  const copy = () => {
    navigator.clipboard?.writeText(HK_AGENT_PROMPT).then(() => {
      setCopied(true);
      track("participate:copy", "hackathon");
      setTimeout(() => setCopied(false), 1800);
    }).catch(() => {});
  };

  return ReactDOM.createPortal((
    <div
      ref={backdropRef}
      className="hk-modal__backdrop"
      onClick={(e) => { if (e.target === backdropRef.current) onClose(); }}
      role="dialog"
      aria-modal="true"
      aria-label="Participate"
    >
      <div ref={panelRef} className="hk-modal hk-modal--about">
        <header className="hk-modal__head">
          <div style={{ minWidth: 0 }}>
            <span className="hk-how__title">Participate</span>
            <p className="hk-how__sub">One pull request. Two fields.</p>
          </div>
          <button type="button" className="hk-modal__close"
            aria-label="Close" onClick={onClose}>✕</button>
        </header>

        <div className="hk-modal__body">
          <div className="hk-tabs" role="tablist">
            <button type="button" role="tab" aria-selected={tab === "agents"}
              className={tab === "agents" ? "hk-tab hk-tab--on" : "hk-tab"}
              onClick={() => setTab("agents")}>For agents</button>
            <button type="button" role="tab" aria-selected={tab === "humans"}
              className={tab === "humans" ? "hk-tab hk-tab--on" : "hk-tab"}
              onClick={() => setTab("humans")}>For humans</button>
          </div>

          {tab === "agents" ? (
            <React.Fragment>
            <div className="hk-prompt">
              <div className="hk-prompt__head">
                <span>Agent prompt</span>
                <button type="button" className="hk-prompt__copy" onClick={copy}
                  aria-label={copied ? "Copied" : "Copy prompt"} title={copied ? "Copied" : "Copy prompt"}>
                  {copied ? <HkGlyphCheck /> : <HkGlyphCopy />}
                </button>
              </div>
              <pre className="hk-prompt__body">{HK_AGENT_PROMPT}</pre>
            </div>
            {/* Someone handing a wall of text to an agent should know what it
                is about to do on their behalf before they paste it. */}
            <p className="hk-prompt__note">
              Paste it into your coding agent: it opens the pull request that registers you, adds
              strk20.json to your own repository, installs the STRK20 skill, and reads itself into
              the docs. Registration is the only part with a deadline.
            </p>
            </React.Fragment>
          ) : (
            <div>
              <ol className="hk-stepper hk-stepper--human">
                {HK_HUMAN_STEPS.map((step, i) => (
                  <li key={step.t}>
                    <span className="hk-stepper__n">{i + 1}</span>
                    <div style={{ minWidth: 0, flex: 1 }}>
                      <div className="hk-stepper__t">{step.t}</div>
                      <p className="hk-stepper__d">{step.d}</p>
                      {step.code && <HackCopy code={step.code} display={step.display} />}
                      {step.link && (
                        <a href={step.link.href} target="_blank" rel="noopener noreferrer"
                          className="hk-link" style={{ marginTop: 10 }}
                          onClick={() => track("participate:edit", "hackathon")}>
                          <HkGlyphGitHub /><span>{step.link.label}</span>
                        </a>
                      )}
                    </div>
                  </li>
                ))}
              </ol>
              <p className="hk-stepper__d" style={{ marginTop: 14 }}>
                Once merged you are in the builders group and your project appears on this page.
              </p>
            </div>
          )}

          <div className="hk-modal__foot">
            <a href={HACK_REPO_URL} target="_blank" rel="noopener noreferrer"
              className="hk-link" onClick={() => track("participate:repo", "hackathon")}>
              <HkGlyphDoc /><span>Full rules</span>
            </a>
          </div>
        </div>
      </div>
    </div>
  ), document.body);
}

/* ---------- how the sprint works ---------- */

/* Second person here, unlike the rest of the page: this popup is read by
   someone deciding whether to enter, and it is addressed to them. Kept to two
   lines a step so the whole thing fits without scrolling - anyone who has to
   scroll a "how it works" has already stopped reading it. */
const HK_RULES = [
  { t: "Apply in one pull request", d: "Add your repository URL and Telegram username to registry.json. Nothing needs to be deployed. Merged means you are in the builders group." },
  { t: "Build in public", d: "Work in your own public repository. Pushes, stack, contracts and demo are read from it every 30 minutes and appear on this page." },
  { t: "Ship to mainnet", d: "Three mainnet transactions against the live STRK20 pool, and a demo anyone can open." },
  { t: "Nothing to submit", d: "Whatever your repository shows on 31 August is your entry. Demo video, contracts and transaction hashes go in a strk20.json at your repository root." },
  { t: "Judged 4 September", d: "A named panel scores integration depth 30%, mainnet product 30%, innovation 25%, docs 15%. Prizes $2,500 / $1,500 / $1,000 in STRK." },
];

function HackAbout({ onClose }) {
  const panelRef = React.useRef(null);
  const backdropRef = React.useRef(null);

  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
  }, [onClose]);

  React.useLayoutEffect(() => {
    if (!hackMotionOn()) return;
    gsap.fromTo(backdropRef.current, { opacity: 0 }, { opacity: 1, duration: 0.22, ease: "power2.out" });
    gsap.fromTo(panelRef.current,
      { opacity: 0, y: 18, scale: 0.985 },
      { opacity: 1, y: 0, scale: 1, duration: 0.34, ease: "power3.out" });
  }, []);

  return ReactDOM.createPortal((
    <div
      ref={backdropRef}
      className="hk-modal__backdrop"
      onClick={(e) => { if (e.target === backdropRef.current) onClose(); }}
      role="dialog"
      aria-modal="true"
      aria-label="How the sprint works"
    >
      <div ref={panelRef} className="hk-modal hk-modal--about hk-modal--how">
        <header className="hk-modal__head">
          <span className="hk-how__title">How it works</span>
          <button type="button" className="hk-modal__close" style={{ marginLeft: "auto" }}
            aria-label="Close" onClick={onClose}>✕</button>
        </header>

        <div className="hk-modal__body">
          <ol className="hk-stepper">
            {HK_RULES.map((r, i) => (
              <li key={r.t}>
                <span className="hk-stepper__n">{i + 1}</span>
                <div>
                  <div className="hk-stepper__t">{r.t}</div>
                  <p className="hk-stepper__d">{r.d}</p>
                </div>
              </li>
            ))}
          </ol>

          <div className="hk-modal__foot">
            <a href={HACK_REPO_URL} target="_blank" rel="noopener noreferrer" className="hk-link"
              onClick={() => track("about:repo", "hackathon")}>
              <HkGlyphGitHub /><span>Full rules</span>
            </a>
            <a href={HACK_IDEAS_URL} target="_blank" rel="noopener noreferrer" className="hk-link"
              onClick={() => track("about:ideas", "hackathon")}>
              <HkGlyphDoc /><span>Ideas</span>
            </a>
          </div>
        </div>
      </div>
    </div>
  ), document.body);
}

/* ---------- static content ---------- */

const HACK_STEPS = [
  { n: "01", t: "Apply", d: "One pull request adding a repository URL and a Telegram username to registry.json. Nothing needs to be deployed, and it is the only pull request anyone opens." },
  { n: "02", t: "Build in public", d: "Work in a public repository. Pushes, stack, contracts and demo are read from it every 30 minutes." },
  { n: "03", t: "Ship to mainnet", d: "Run against the live pool: three mainnet transactions, and a demo anyone can open." },
  { n: "04", t: "Nothing to submit", d: `Whatever the repository shows on ${HACK_DATES.closes} counts. Everything the panel needs goes in a strk20.json at the repository root: demo video, contracts, transaction hashes.` },
];

const HACK_RUBRIC = [
  { w: "30%", t: "STRK20 integration depth", d: "How far into the stack the project went - shielded balances, private transfers, anonymizer contracts, the SDK, using stealth accounts." },
  { w: "30%", t: "Working mainnet product", d: "It runs, on mainnet, for a real user. Not a prototype behind a login." },
  { w: "25%", t: "Innovation", d: "Something the ecosystem doesn't have yet, or a materially better take on something it does." },
  { w: "15%", t: "Documentation & open-source quality", d: "A README someone can follow, code someone can build on, a license." },
];

const HACK_RESOURCES = [
  /* First because it is the shortest path from an empty repository to a plan:
     one npx line, and the agent already in the editor does the reading. */
  { t: "Agent skill", d: "npx skills add starkience/strk20-agent-skills - your coding agent reads the repo, picks a route, and writes the integration plan.", href: "https://strk20-by-example.org/agent-skill" },
  { t: "Documentation", d: "STRK20 by example - the pool, the wallet API, anonymizer contracts, the SDK. Mirrored as Markdown at /llms-full.txt for agents.", href: "https://strk20-by-example.org/what-is-strk20" },
  { t: "Build on STRK20", d: "Integration routes: private dapp, privacy wallet, or a self-hosted prover.", href: "/build" },
  { t: "Starter kit", d: "Next.js starter: wallet picker, shield, unshield, private transfer, and a deployable privacy_invoke helper.", href: "https://github.com/Akashneelesh/strk20-starter-kit" },
  { t: "Awesome STRK20", d: "Curated index of the SDKs, helper contracts, proof-of-concept apps and guides.", href: "https://github.com/Akashneelesh/awesome-strk20" },
  { t: "Ideas list", d: "Forty-plus ideas in scope for the sprint. Build one, or something else entirely.", href: HACK_IDEAS_URL },
  { t: "Privacy SDK", d: "The monorepo - pool contracts, the TypeScript SDK, and the proving service.", href: "https://github.com/starkware-libs/starknet-privacy" },
];

/* ---------- page ---------- */

/* The official StarkWare lockup, vendored into /brand rather than hotlinked -
   the same call the agent marks make. Sized off the display line it replaces,
   so it grows and shrinks with the three figures beside it; a wordmark's caps
   sit lower than a numeral's, so it is set taller than 33px to read level. */
const HK_SW_LOGO = { height: "clamp(28px,3vw,38px)", width: "auto", display: "block" };

/* Ticks every second so the hero carries a live clock rather than a fact.
   Counts to the submission deadline from the moment the page is public, then stops.
   Separate from hackClock above, which the list header renders in days. */
function hackHeroClock(now) {
  if (now >= HACK_CLOSES_AT) return ["Closed", "winners " + HACK_DATES.winners];
  const left = Math.max(0, HACK_CLOSES_AT - now);
  const d = Math.floor(left / 86400000);
  const h = Math.floor(left / 3600000) % 24;
  const m = Math.floor(left / 60000) % 60;
  const sec = Math.floor(left / 1000) % 60;
  return [(d > 0 ? d + "d " : "") + h + "h " + m + "m " + sec + "s", "left to build"];
}

/* The clock owns its own state. Ticking it from HackathonPage re-rendered the
   whole hero every second, and the shader restarted with it - it read as a
   flash once a second. */
function HackHeroStats() {
  const [now, setNow] = useStateT(Date.now());
  React.useEffect(() => {
    const t = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(t);
  }, []);
  const clock = hackHeroClock(now);
  return (
    /* Two by two rather than one row: at four items the row wrapped to 3 + 1
       on a desktop screen, which reads as a mistake. A block of four is the
       same information and looks deliberate at every width, collapsing to a
       single column on a phone. */
    <div style={{
      display: "grid", gap: "clamp(20px,2.6vw,32px) clamp(28px,4vw,64px)",
      gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 300px), 1fr))",
      maxWidth: 800,
      marginTop: "clamp(64px,13vh,152px)",
    }}>
      {/* Four reasons to enter, in the order someone weighs them: what it pays,
          what they get while building, who is reading the code at the end, and
          how long they have. "Open source, public repos only" is gone from
          here - it is a rule rather than a reason, and the four steps below
          already say it. */}
      {[
        ["$5,000", "$2,500 / $1,500 / $1,000 in STRK"],
        ["Full support", "technical + ecosystem"],
        [<img src="/brand/starkware-logo.svg" alt="StarkWare" style={HK_SW_LOGO} />, "judges every entry"],
        clock,
      ].map(([k, v]) => (
        <div key={v}>
          <div style={{ fontFamily: "var(--display)", fontWeight: 800,
            fontSize: "clamp(24px,2.6vw,33px)", letterSpacing: "-0.02em", lineHeight: 1,
            whiteSpace: "nowrap" }}>{k}</div>
          <div style={{ marginTop: 9, fontFamily: "var(--mono)", fontSize: 10,
            letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--faint)" }}>{v}</div>
        </div>
      ))}
    </div>
  );
}

/* Memoised with its props hoisted out of render: without both, any state change
   in the page hands the shader a fresh props object and it remounts. */
const HkTerminal = React.memo(FaultyTerminal);
const HK_TERM_STYLE = { position: "absolute", inset: 0, zIndex: 0, opacity: 0.42 };
const HK_TERM_GRID = [2, 1];

function HackathonPage() {
  const [about, setAbout] = useStateT(false);
  const [join, setJoin] = useStateT(false);

  /* The nav carries the same two calls to action and is shared by every route,
     so it announces which panel to open rather than holding the state. */
  React.useEffect(() => {
    const onCta = (e) => {
      if (e.detail === "join") setJoin(true);
      else if (e.detail === "about") setAbout(true);
    };
    window.addEventListener("strk20:hackathon-cta", onCta);
    return () => window.removeEventListener("strk20:hackathon-cta", onCta);
  }, []);

  return (
    <React.Fragment>
      <Nav />
      {about && <HackAbout onClose={() => setAbout(false)} />}
      {join && <HackParticipate onClose={() => setJoin(false)} />}

      {/* Hero. Deliberately short - the project grid is the point of this page,
          so the hero has to hand off to it inside the first viewport rather
          than occupying one. Copy, type scale and rhythm are all tuned down
          from the /build hero for that reason. */}
      <section style={{ ...HK_SECTION, paddingTop: "clamp(120px,16vh,190px)", paddingBottom: "clamp(26px,4vh,44px)" }}>
        {/* Same FaultyTerminal backdrop as the /build hero, tinted strk20
            orange, behind the same darkening gradient so the headline keeps
            its contrast. Weaker opacity than /build: that hero is a full
            viewport of shader, this one has a table starting right underneath
            and shouldn't compete with it. */}
        <HkTerminal
          style={HK_TERM_STYLE}
          tint="#c53400"
          scale={1.6}
          gridMul={HK_TERM_GRID}
          digitSize={1.3}
          timeScale={0.5}
          scanlineIntensity={0.6}
          glitchAmount={1}
          flickerAmount={0.6}
          noiseAmp={1}
          curvature={0.1}
          mouseReact={true}
          mouseStrength={0.35}
          pageLoadAnimation={true}
          brightness={1}
        />
        <div style={{ position: "absolute", inset: 0, zIndex: 1, pointerEvents: "none",
          background: "radial-gradient(ellipse 75% 60% at 40% 50%, rgba(197, 52, 0,0.15), transparent 60%), radial-gradient(ellipse at 45% 50%, rgba(13,13,13,0.62) 22%, var(--bg) 86%)" }} />
        <div style={{ ...HK_INNER, position: "relative", zIndex: 2 }}>
          <Reveal>
            <div>
              <div style={{ minWidth: 280 }}>
                <div style={HK_EYE}>
                  <span style={{ width: 28, height: 1, background: "var(--green)" }} />
                  STRK20 · {HACK_DATES.opens}–{HACK_DATES.closes}
                </div>
                {/* One line at every width: the break landed after PRIVATE and
                    read as two words rather than a name. */}
                <h1 style={{ ...HK_H, fontSize: "clamp(28px,6.2vw,88px)", margin: "16px 0 0",
                  whiteSpace: "nowrap" }}>Private Sprint</h1>
                <p style={{
                  margin: "12px 0 0", maxWidth: 520, color: "var(--dim)",
                  fontSize: "clamp(15px,1.25vw,18px)", lineHeight: 1.6,
                }}>
                  Eighteen days to ship a real privacy app on Starknet mainnet.
                  Build in public, see everyone's latest progress.
                </p>

                <div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 20 }}>
                  <button
                    type="button"
                    onClick={() => { setJoin(true); track("participate", "hackathon-hero"); }}
                    className="hk-cta"
                    style={HK_BTN_GO}
                  >Participate</button>
                  <button
                    type="button"
                    onClick={() => { setAbout(true); track("how_it_works", "hackathon-hero"); }}
                    className="hk-cta"
                    style={HK_BTN_GHOST}
                  >How it works</button>
                </div>
              </div>

              {/* Foot of the hero, values only: the captions under them said
                  things the How it works panel already says. */}
              <HackHeroStats />
            </div>
          </Reveal>
        </div>
      </section>

      {/* The list is the page. Its own header carries the framing, so there is
          no separate section heading above it - that was ~180px of nothing. */}
      <section id="projects" style={{ ...HK_SECTION, background: "var(--bg-1)", paddingTop: "clamp(76px,10vh,128px)" }}>
        <div style={HK_INNER}>
          <HackList />
        </div>
      </section>

      {/* resources - directly under the table. Someone who has just read six
          rows of other people's work is at the point of starting their own,
          and this is the section that lets them; it was three screens further
          down, behind the rules. */}
      <section style={HK_SECTION}>
        <div style={HK_INNER}>
          <Reveal>
            <div style={HK_EYE}>
              <span style={{ width: 28, height: 1, background: "var(--green)" }} />
              Resources
            </div>
            <h2 style={{ ...HK_H, fontSize: "clamp(30px,4.4vw,62px)" }}>Builder resources</h2>
          </Reveal>
          <div style={{
            display: "grid", gap: 18, marginTop: 44,
            gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 300px), 1fr))",
          }}>
            {HACK_RESOURCES.map((r, i) => (
              <Reveal key={r.t} delay={i * 0.06}>
                <a
                  href={r.href}
                  target={r.href.startsWith("/") ? undefined : "_blank"}
                  rel={r.href.startsWith("/") ? undefined : "noopener noreferrer"}
                  onClick={() => track(`resource:${r.t}`, "hackathon")}
                  style={{
                    display: "block", height: "100%", textDecoration: "none", color: "var(--text)",
                    border: "1px solid var(--line)", background: "var(--bg-1)", padding: "24px 22px",
                  }}
                >
                  <h3 style={{ fontFamily: "var(--display)", fontWeight: 800, textTransform: "uppercase",
                    fontSize: 19, letterSpacing: "-0.015em", margin: 0 }}>{r.t}</h3>
                  <p style={{ margin: "12px 0 0", color: "var(--dim)", fontSize: 14, lineHeight: 1.6 }}>{r.d}</p>
                </a>
              </Reveal>
            ))}
          </div>
          <Reveal delay={0.2}>
            <p style={{ marginTop: 40, color: "var(--dim)", maxWidth: 640, lineHeight: 1.7 }}>
              The STRK20 team is in the Telegram group every day of the sprint, for architecture
              questions, integration help and anything blocking a mainnet deploy.
            </p>
          </Reveal>
        </div>
      </section>

      {/* The rules, in one section rather than two.
       *
       * Four steps and four criteria: the same count and the same shape, so
       * they set side by side as two columns of four sharing one headline -
       * what you do to enter on the left, what it is scored on to win on the
       * right.
       *
       * They were two full-height sections with two 62px headlines saying
       * roughly the same thing in different words, one after the other, and a
       * visitor who had already scrolled past the table met both before
       * reaching anything they could act on. */}
      <section id="judging" style={{ ...HK_SECTION, background: "var(--bg-1)" }}>
        <div style={HK_INNER}>
          <Reveal>
            <div style={HK_EYE}>
              <span style={{ width: 28, height: 1, background: "var(--green)" }} />
              How it works
            </div>
            <h2 style={{ ...HK_H, fontSize: "clamp(30px,4.4vw,62px)" }}>Four steps, four criteria</h2>
            {/* Carries the two dates the timeline bar used to hold on its own:
                the deadline's time, and when winners are announced. The rest
                of that bar restated the hero's date range. */}
            <p style={{ margin: "20px 0 0", maxWidth: 660, color: "var(--dim)", lineHeight: 1.6 }}>
              One pull request to enter, and nothing to submit at the end. Submissions close
              {" "}{HACK_DATES.closes} at 23:59 UTC and winners are announced {HACK_DATES.winners}.
              A named panel does the scoring - the list above is ordered by latest push, not
              by merit.
            </p>
          </Reveal>

          <div className="hk-rules">
            <div className="hk-rules__col">
              <Reveal>
                <div className="hk-rules__hd">To enter</div>
              </Reveal>
              {HACK_STEPS.map((s, i) => (
                <Reveal key={s.n} delay={i * 0.05}>
                  <div className="hk-rules__row">
                    <span className="hk-rules__k hk-rules__k--n">{s.n}</span>
                    <div>
                      <h3 className="hk-rules__t">{s.t}</h3>
                      <p className="hk-rules__d">{s.d}</p>
                    </div>
                  </div>
                </Reveal>
              ))}
            </div>

            <div className="hk-rules__col hk-rules__col--2">
              <Reveal>
                <div className="hk-rules__hd">To win</div>
              </Reveal>
              {HACK_RUBRIC.map((r, i) => (
                <Reveal key={r.t} delay={i * 0.05}>
                  <div className="hk-rules__row">
                    <span className="hk-rules__k hk-rules__k--w">{r.w}</span>
                    <div>
                      <h3 className="hk-rules__t">{r.t}</h3>
                      <p className="hk-rules__d">{r.d}</p>
                    </div>
                  </div>
                </Reveal>
              ))}
            </div>
          </div>

        </div>

        <style>{`
          .hk-rules {
            display: grid; margin-top: clamp(36px,4.5vw,56px);
            grid-template-columns: 1fr; gap: 34px;
          }
          /* Two columns only where a four-line paragraph still has room to be
             read. Below that they stack, and the divider would be a rule
             across the page rather than between two things. */
          @media (min-width: 900px) {
            .hk-rules { grid-template-columns: 1fr 1fr; gap: 0; }
            .hk-rules__col--2 {
              border-left: 1px solid var(--line);
              padding-left: clamp(28px,3.4vw,52px);
            }
            .hk-rules__col { padding-right: clamp(28px,3.4vw,52px); }
            .hk-rules__col--2 { padding-right: 0; }
          }
          .hk-rules__hd {
            font-family: var(--mono); font-size: 11px; letter-spacing: 0.18em;
            text-transform: uppercase; color: var(--faint);
            padding-bottom: 14px; border-bottom: 1px solid var(--line);
          }
          /* One rhythm for both columns - same padding, same rule, same key
             width - so the two lists read as one object. The hairlines do not
             line up across the divider, and should not: step three and
             criterion three have nothing to do with each other. */
          .hk-rules__row {
            display: flex; gap: clamp(16px,2vw,26px); align-items: baseline;
            padding: 22px 0; border-bottom: 1px solid var(--line);
          }
          /* Each key column is sized to its own widest value rather than to a
             shared number: 26px display "30%" needs 84px and a 12px mono "01"
             needs 34, and one width for both either clipped the weight or left
             a hole beside the step number. */
          .hk-rules__k { flex: none; color: var(--green); }
          .hk-rules__k--n {
            width: 34px; font-family: var(--mono); font-size: 12px; letter-spacing: 0.16em;
          }
          .hk-rules__k--w {
            width: 84px; font-family: var(--display); font-weight: 800; font-size: 26px;
            letter-spacing: -0.02em;
          }
          .hk-rules__t {
            font-family: var(--display); font-weight: 800; text-transform: uppercase;
            font-size: 18px; letter-spacing: -0.015em; margin: 0;
          }
          .hk-rules__d { margin: 9px 0 0; color: var(--dim); font-size: 14px; line-height: 1.6; }
        `}</style>
      </section>

      <Footer />
    </React.Fragment>
  );
}
