diff --git a/scripts/fetch-repo-websites.mjs b/scripts/fetch-repo-websites.mjs index 90ff49e7..557edd35 100644 --- a/scripts/fetch-repo-websites.mjs +++ b/scripts/fetch-repo-websites.mjs @@ -1,31 +1,34 @@ -// Regenerates src/generated/repoWebsites.json: the homepage URL each -// registry repo declares in its GitHub metadata, keyed by fullName. Runs as -// the `prebuild` hook so every build ships whatever the projects currently -// point their GitHub "website" field at — no hand-edited list to go stale. +// Regenerates two snapshots of each registry repo's GitHub metadata, keyed +// by fullName, from a single GitHub fetch per repo: +// - src/generated/repoWebsites.json — the declared homepage URL +// - src/generated/repoDescriptions.json — the repo's one-line description +// Runs as the `prebuild` hook so every build ships whatever the projects +// currently declare — no hand-edited lists to go stale. // // Fail-safe by design: any fetch problem (registry down, GitHub rate limit) -// leaves the committed snapshot untouched and exits 0, so a build can never -// break — worst case the previews are as fresh as the last successful run. +// leaves the committed snapshots untouched and exits 0, so a build can never +// break — worst case the data is as fresh as the last successful run. // Set GITHUB_TOKEN (GitHub Actions' built-in token works) to lift the // unauthenticated 60 req/hr GitHub API limit shared by CI runner IPs. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -const OUT_PATH = join( +const GENERATED_DIR = join( dirname(fileURLToPath(import.meta.url)), '..', 'src', 'generated', - 'repoWebsites.json', ); +const WEBSITES_PATH = join(GENERATED_DIR, 'repoWebsites.json'); +const DESCRIPTIONS_PATH = join(GENERATED_DIR, 'repoDescriptions.json'); const API_BASE = process.env.VITE_REACT_APP_BASE_URL || 'https://api.gittensor.io'; -const readSnapshot = () => { +const readSnapshot = (path) => { try { - return JSON.parse(readFileSync(OUT_PATH, 'utf8')); + return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; } @@ -41,7 +44,8 @@ const githubHeaders = process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}; -const snapshot = readSnapshot(); +const websitesSnapshot = readSnapshot(WEBSITES_PATH); +const descriptionsSnapshot = readSnapshot(DESCRIPTIONS_PATH); let registry; try { @@ -50,12 +54,13 @@ try { if (!Array.isArray(registry)) throw new Error('unexpected registry shape'); } catch (error) { console.warn( - `repo-websites: registry fetch failed, keeping snapshot (${error.message})`, + `repo-websites: registry fetch failed, keeping snapshots (${error.message})`, ); process.exit(0); } const websites = {}; +const descriptions = {}; let failures = 0; for (const repo of registry) { const fullName = repo.fullName; @@ -67,24 +72,34 @@ for (const repo of registry) { ); const homepage = (meta.homepage || '').trim(); if (/^https?:\/\//i.test(homepage)) websites[fullName] = homepage; + const description = (meta.description || '').trim(); + if (description) descriptions[fullName] = description; } catch (error) { failures += 1; console.warn(`repo-websites: ${fullName} fetch failed (${error.message})`); - if (snapshot[fullName]) websites[fullName] = snapshot[fullName]; + if (websitesSnapshot[fullName]) + websites[fullName] = websitesSnapshot[fullName]; + if (descriptionsSnapshot[fullName]) + descriptions[fullName] = descriptionsSnapshot[fullName]; } } -// A blanket failure (e.g. rate limit) must not wipe the snapshot wholesale. +// A blanket failure (e.g. rate limit) must not wipe the snapshots wholesale. if (failures === registry.length) { - console.warn('repo-websites: every GitHub fetch failed, keeping snapshot'); + console.warn('repo-websites: every GitHub fetch failed, keeping snapshots'); process.exit(0); } -const sorted = Object.fromEntries( - Object.entries(websites).sort(([a], [b]) => a.localeCompare(b)), -); -mkdirSync(dirname(OUT_PATH), { recursive: true }); -writeFileSync(OUT_PATH, `${JSON.stringify(sorted, null, 2)}\n`); -console.log( - `repo-websites: wrote ${Object.keys(sorted).length} entries (${failures} fetch failures)`, -); +const writeSorted = (path, entries, label) => { + const sorted = Object.fromEntries( + Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)), + ); + writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`); + console.log( + `repo-websites: wrote ${Object.keys(sorted).length} ${label} (${failures} fetch failures)`, + ); +}; + +mkdirSync(GENERATED_DIR, { recursive: true }); +writeSorted(WEBSITES_PATH, websites, 'websites'); +writeSorted(DESCRIPTIONS_PATH, descriptions, 'descriptions'); diff --git a/src/generated/repoDescriptions.json b/src/generated/repoDescriptions.json new file mode 100644 index 00000000..1f79bede --- /dev/null +++ b/src/generated/repoDescriptions.json @@ -0,0 +1,16 @@ +{ + "Autovara/kata": "Kata builds the best AI agent for a subnet through open competition — so anyone can mine that subnet with a proven, optimized agent.", + "entrius/das-github-mirror": "Mirror api for all tracked gittensor repositories", + "Geniepod/genie-claw": "🦞 Low-latency, limited-context AI harness for private on-device homes.", + "gittensor-ai-lab/sparkinfer": "Fastest MoE/LLM inference runtime for consumer and edge Blackwell GPUs. SN74 on Gittensor.", + "gittensor-model-hub/SparkDistill": "Trustless Triton-native AI distillation on SN74/Gittensor: verified datasets (SparkProof), training recipes, and eval harness for kernel-specialist LLMs on Blackwell.", + "gittensor-vanguard/vanguarstew": "An SN74 repo-maintainer agent + a GitHub-history benchmark that scores it — can an agent make the maintainer decisions a strong maintainer would?", + "JSONbored/awesome-claude": "HeyClaude is a curated registry and distribution surface for Claude and AI-workflow assets: agents, MCP servers, skills, commands, hooks, rules, guides, tools, jobs, Raycast feeds, static data exports, and an npm MCP package.", + "JSONbored/loopover": "Backend intelligence and MCP tooling for Gittensor contributors and maintainers.", + "JSONbored/metagraphed": "Operational metadata, health, schemas, and public interface discovery for Bittensor subnets.", + "mini-router/minirouter": "SN74 Gittensor miner workspace for MiniRouter: router training/eval code, benchmark configs, submission artifacts, and the web competition site for improving the tiny LLM router.", + "phase-rs/phase": "A rules engine and game client — Rust + WASM + React", + "vouchdev/vouch": "A git-native, review-gated knowledge base for AI agents: they propose writes, you approve them. Every claim cites a source, every change is a diff in your repo. MCP + CLI.", + "we-promise/sure": "The personal finance app for everyone (by everyone)", + "zeokin/Cuda-Compute-OSS": "CCO is an open-source system for managing, validating, and improving GPU." +} diff --git a/src/generated/repoWebsites.json b/src/generated/repoWebsites.json index 4451ab23..0d995d85 100644 --- a/src/generated/repoWebsites.json +++ b/src/generated/repoWebsites.json @@ -1,13 +1,13 @@ { "Autovara/kata": "https://dashboardking.ngrok.app/", "Geniepod/genie-claw": "https://genieclaw.org", - "gittensor-ai-lab/sparkinfer": "https://gittensor-ai-lab.github.io/sparkinfer/dashboard/", + "gittensor-agent-forge/gt-imagent": "https://tryimagent.com/", + "gittensor-ai-lab/sparkinfer": "https://sparkinfer.com/", "gittensor-model-hub/SparkDistill": "https://gittensor-model-hub.github.io/SparkDistill/", "gittensor-vanguard/vanguarstew": "https://gittensor-vanguard.github.io/vanguarstew/", - "imagent-ai/imagent": "https://tryimagent.com/", "James-CUDA/Gittensor-TinyRouter": "https://james-cuda.github.io/Gittensor-TinyRouter/", "JSONbored/awesome-claude": "https://heyclau.de", - "JSONbored/gittensory": "https://gittensory.aethereal.dev/", + "JSONbored/loopover": "https://loopover.ai", "JSONbored/metagraphed": "https://metagraph.sh", "mini-router/minirouter": "https://mini-router.github.io/minirouter/", "phase-rs/phase": "http://preview.phase-rs.dev/", diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index f39acfc0..395e3d92 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,14 +1,123 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Avatar, Box, Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { alpha } from '@mui/material/styles'; import { Page } from '../components/layout'; import { SEO } from '../components'; import { LinkBox } from '../components/common/linkBehavior'; -import { useReposAndWeights } from '../api'; +import { useAllMiners, useAllPrs, useReposAndWeights } from '../api'; import { type Repository } from '../api/models/Dashboard'; -import { getRepositoryOwnerAvatarSrc } from '../utils/avatar'; -import { minerRepositoryPath, parseNumber } from '../utils'; +import { formatUsdEstimate, minerRepositoryPath, parseNumber } from '../utils'; import repoWebsitesSnapshot from '../generated/repoWebsites.json'; +import repoDescriptionsSnapshot from '../generated/repoDescriptions.json'; + +// One-line repo descriptions from each project's GitHub metadata. The +// build-time snapshot (scripts/fetch-repo-websites.mjs) paints instantly; +// the current description is then fetched from GitHub once per session and +// reconciled over it, so an owner editing their description shows up on the +// next page view, not the next site deploy. +const REPO_DESCRIPTIONS: Record = repoDescriptionsSnapshot; +const DESCRIPTION_CACHE_KEY = 'gt-repo-descriptions'; +const DESCRIPTION_REFRESH_DELAY_MS = 3500; + +// Hand-written one-liners (sourced from each repo's README) for repos whose +// owners haven't set a GitHub description yet. Lowest-priority fallback: the +// moment an owner adds a real description, the live/snapshot value wins and +// this entry goes unused. +const FALLBACK_DESCRIPTIONS: Record = { + 'entrius/gittensor': + 'The Gittensor subnet itself: incentivizing open source contributions on Bittensor SN74.', + 'DPBG/Engram.AI': 'A self-aware, continuously-learning neuromorphic AI.', + 'touchpilot/touchpilot': + 'Local-first Android AI agent runtime for safe, observable phone control.', + 'gittensor-agent-forge/gt-imagent': + 'An open research project for image-generation agents that plan, critique, and improve, beyond one-shot prompting.', + 'James-CUDA/Gittensor-TinyRouter': + 'An incentivized open benchmark for LLM routing intelligence: train a tiny routing head, beat the king, earn TAO.', +}; + +const readDescriptionCache = (): Record => { + try { + return JSON.parse( + sessionStorage.getItem(DESCRIPTION_CACHE_KEY) ?? '{}', + ) as Record; + } catch { + return {}; + } +}; + +// Per-repo activity digest derived from the network-wide PR dataset the +// dashboard already loads — no extra per-repo requests. mergedDaily is +// oldest-first, one bucket per day over the sparkline window. +type RepoActivity = { + mergedThisWeek: number; + activeMiners: number; + mergedDaily: number[]; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEK_MS = 7 * DAY_MS; +const ACTIVE_MINER_WINDOW_MS = 30 * DAY_MS; +const SPARK_DAYS = 28; + +// Merges-per-day sparkline as instrument telemetry: one 1px monochrome +// hairline tick per day over a faint baseline — no fills, no rounding, no +// color, so it registers as texture rather than pulling the eye. Square- +// root scaling keeps quiet days visible next to a spike day (these +// distributions are heavily peaked). Ticks are drawn as lines with +// non-scaling strokes so they stay hairline-crisp while the x-axis +// stretches to the card width. No axes labels, no tooltip — the activity +// line right below states the headline number as text. +const SPARK_H = 14; +const SPARK_PITCH = 4; + +const MergeSparkline: React.FC<{ counts: number[] }> = ({ counts }) => { + const max = Math.max(...counts, 1); + const viewW = (counts.length - 1) * SPARK_PITCH; + return ( + + ({ + stroke: alpha(theme.palette.text.primary, 0.08), + strokeWidth: 1, + })} + /> + {counts.map((count, i) => { + const tickH = + count === 0 ? 1 : Math.max(2, Math.sqrt(count / max) * SPARK_H); + return ( + ({ + stroke: alpha( + theme.palette.text.primary, + count === 0 ? 0.12 : 0.32, + ), + strokeWidth: 1, + })} + /> + ); + })} + + ); +}; const fadeUp = (delayMs = 0) => ({ opacity: 0, @@ -81,24 +190,64 @@ const EMBED_IFRAME_SANDBOX = const EMBED_ZOOM = 0.25; const EMBED_SIZE = `${100 / EMBED_ZOOM}%`; -// mshots returns a "generating…" placeholder on the first request for a -// URL; remount the image a couple of times so the real shot swaps in. +// The visible iframe reloads the site from scratch after verification, so +// revealing it immediately shows a blank window booting up (worst on slow +// hosts with entrance animations, e.g. kata via ngrok). Instead it loads +// hidden behind the backdrop and fades in only after its load event plus a +// grace period that lets intro animations finish off-screen. +const LIVE_REVEAL_GRACE_MS = 1800; + +// mshots returns a small "generating…" placeholder on the first request for +// a URL; remount the image a couple of times so the real shot swaps in. +// Real screenshots come back at the requested 1280px width, so anything +// narrower is the placeholder — once a real shot is on screen, refreshing +// stops (a remount would blank the card back to its name plate mid-fade). +const SHOT_REAL_MIN_WIDTH = 1024; const SHOT_REFRESH_MAX = 2; const SHOT_REFRESH_BASE_MS = 5000; const SHOT_REFRESH_STAGGER_MS = 200; type EmbedState = 'checking' | 'ok' | 'failed'; +// Preview images (OG cards, screenshots) pop in raw whenever their request +// happens to finish, which makes the loading phase feel chaotic. This wraps +// them so each one fades in on load instead; PREVIEW_MEDIA_SX already +// carries the opacity transition. +const PreviewImg: React.FC<{ + className?: string; + src: string; + alt: string; + onError?: () => void; + onLoad?: (img: HTMLImageElement) => void; + sx: object; +}> = ({ sx, onError, onLoad, ...imgProps }) => { + const [loaded, setLoaded] = useState(false); + return ( + ) => { + setLoaded(true); + onLoad?.(event.currentTarget); + }} + onError={onError} + {...imgProps} + sx={{ ...sx, opacity: loaded ? 1 : 0 }} + /> + ); +}; + // Shared look for every card preview surface (screenshot, OG image, live -// window): grayscale until the card is hovered. +// window): grayscale until the card is hovered. Full opacity + a contrast +// lift, not a dim: the sites are mostly dark-on-dark, so dimming melts them +// into the page while contrast keeps each one's structure legible. const PREVIEW_MEDIA_SX = { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', - filter: 'grayscale(1)', - opacity: 0.88, + filter: 'grayscale(1) contrast(1.14) brightness(1.08)', transition: 'filter 0.25s ease, opacity 0.25s ease', } as const; @@ -126,10 +275,13 @@ const sortByEmissionShare = (repos: Repository[]) => const SiteOverlay: React.FC<{ host: string }> = ({ host }) => ( ({ position: 'absolute', bottom: 6, right: 8, + opacity: 0, + transition: 'opacity 0.2s ease', maxWidth: 'calc(100% - 16px)', px: 0.75, py: 0.25, @@ -150,10 +302,13 @@ const SiteOverlay: React.FC<{ host: string }> = ({ host }) => ( ); -const RepoCard: React.FC<{ repo: Repository; index: number }> = ({ - repo, - index, -}) => { +const RepoCard: React.FC<{ + repo: Repository; + index: number; + description?: string; + activity?: RepoActivity; + usdPerDay?: number | null; +}> = ({ repo, index, description, activity, usdPerDay }) => { const website = REPO_WEBSITES[repo.fullName]; const embedUrl = website ? toEmbedUrl(website) : ''; const websiteHost = website ? getSiteHost(embedUrl) : ''; @@ -163,31 +318,32 @@ const RepoCard: React.FC<{ repo: Repository; index: number }> = ({ const [attempt, setAttempt] = useState(0); const [imageFailed, setImageFailed] = useState(false); const [shotTick, setShotTick] = useState(0); + const [shotIsReal, setShotIsReal] = useState(false); const [siteShotFailed, setSiteShotFailed] = useState(false); const [embedState, setEmbedState] = useState( canAttemptEmbed ? 'checking' : 'failed', ); const [inView, setInView] = useState(false); + const [liveShown, setLiveShown] = useState(false); const retryTimerRef = useRef(undefined); const shotTimerRef = useRef(undefined); + const revealTimerRef = useRef(undefined); const mediaRef = useRef(null); const embedRef = useRef(null); const embedLive = embedState === 'ok'; - // The screenshot is fetched only when it will actually be seen: right - // away in browsers that never attempt embeds, otherwise only after the - // embed failed — verified-live cards fire no mshots requests, and the - // screenshot always targets the site's declared URL (mshots fetches - // server-side, so the mixed-content upgrade is irrelevant to it). - const showSiteShot = - Boolean(website) && - !siteShotFailed && - (!canAttemptEmbed || embedState === 'failed'); + // The screenshot shows immediately as the card's backdrop — including + // while a live embed is still verifying/loading — so a website card is + // never a bare name plate; the live window fades in over it. It always + // targets the site's declared URL (mshots fetches server-side, so the + // mixed-content upgrade is irrelevant to it). + const showSiteShot = Boolean(website) && !siteShotFailed; useEffect( () => () => { window.clearTimeout(retryTimerRef.current); window.clearTimeout(shotTimerRef.current); + window.clearTimeout(revealTimerRef.current); }, [], ); @@ -255,13 +411,13 @@ const RepoCard: React.FC<{ repo: Repository; index: number }> = ({ // Remount the screenshot a couple of times so the real shot replaces the // mshots "generating…" placeholder without a manual reload. useEffect(() => { - if (!showSiteShot || shotTick >= SHOT_REFRESH_MAX) return; + if (!showSiteShot || shotIsReal || shotTick >= SHOT_REFRESH_MAX) return; shotTimerRef.current = window.setTimeout( () => setShotTick(shotTick + 1), SHOT_REFRESH_BASE_MS * (shotTick + 1) + index * SHOT_REFRESH_STAGGER_MS, ); return () => window.clearTimeout(shotTimerRef.current); - }, [showSiteShot, shotTick, index]); + }, [showSiteShot, shotIsReal, shotTick, index]); const handleImageError = () => { if (attempt + 1 >= MAX_PREVIEW_ATTEMPTS) { @@ -284,22 +440,38 @@ const RepoCard: React.FC<{ repo: Repository; index: number }> = ({ display: 'flex', flexDirection: 'column', minWidth: 0, - border: `1px solid ${theme.palette.border.light}`, - borderRadius: 1.5, - overflow: 'hidden', - backgroundColor: theme.palette.surface.subtle, - transition: - 'border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease', + transition: 'transform 0.2s ease', ...fadeUp(120 + Math.min(index, 11) * 45), '&:hover': { - borderColor: alpha(theme.palette.text.primary, 0.32), transform: 'translateY(-3px)', + }, + '&:hover .repo-card-frame': { + borderColor: alpha(theme.palette.text.primary, 0.32), boxShadow: `0 14px 40px ${alpha(theme.palette.common.black, 0.35)}`, }, '&:hover .repo-card-preview': { filter: 'grayscale(0)', opacity: 1, }, + // The host pill only matters while the preview is being inspected; + // at rest it would be one more repeated element muddying the grid. + // Touch devices never hover, so they keep it visible. + '&:hover .repo-card-host': { + opacity: 1, + }, + '&:hover .repo-card-label': { + color: theme.palette.text.primary, + }, + // The payout figure is monochrome at rest like everything else on + // the card; hover restores the accent green along with the color. + '&:hover .repo-card-payout': { + color: theme.palette.status.merged, + }, + '@media (hover: none)': { + '& .repo-card-host': { + opacity: 1, + }, + }, // Hovering a live card hands the pointer to the embedded site so it // can be scrolled and browsed like a real window; the footer below // the preview stays the link to the repo page. Mouse-like pointers @@ -316,66 +488,124 @@ const RepoCard: React.FC<{ repo: Repository; index: number }> = ({ }, })} > + {/* Identity header above the frame: name left, payout right, + description beneath. The framed preview hangs under it like the + piece under a gallery caption. */} + + + + ({ + color: alpha(theme.palette.text.primary, 0.75), + fontFamily: 'var(--font-accent)', + fontSize: '0.68rem', + fontWeight: 700, + letterSpacing: '0.14em', + textTransform: 'uppercase', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + minWidth: 0, + transition: 'color 0.2s ease', + })} + > + {repo.name} + + + {/* Always reserved at exactly two lines — clamped when longer, + padded when shorter — so every frame in a row starts at the + same height regardless of description length. */} + ({ + mt: 0.25, + color: alpha(theme.palette.text.primary, 0.42), + fontFamily: 'var(--font-accent)', + fontSize: '0.7rem', + lineHeight: 1.55, + height: 'calc(0.7rem * 1.55 * 2)', + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + })} + > + {description} + + + ({ position: 'relative', width: '100%', aspectRatio: '2 / 1', - backgroundColor: alpha(theme.palette.text.primary, 0.03), - borderBottom: `1px solid ${theme.palette.border.subtle}`, + border: `1px solid ${theme.palette.border.light}`, + borderRadius: 1.5, + backgroundColor: theme.palette.surface.subtle, overflow: 'hidden', + transition: 'border-color 0.2s ease, box-shadow 0.2s ease', })} > - {website && (embedLive || showSiteShot) && ( + {website && (liveShown || showSiteShot) && ( )} - {/* Backdrop: website screenshot → GitHub OG card → repo name. The - verified live window covers it, so the card never renders blank. - While an embed is still being verified the neutral card - background shows instead — most verdicts land within seconds. */} - {!embedLive && - (showSiteShot ? ( - setSiteShotFailed(true)} - sx={{ ...PREVIEW_MEDIA_SX, objectPosition: 'top' }} - /> - ) : showBackdropOg ? ( - imageFailed ? ( - ({ - position: 'absolute', - inset: 0, - display: 'grid', - placeItems: 'center', - color: alpha(theme.palette.text.primary, 0.3), - fontFamily: 'var(--font-accent)', - fontSize: '1.4rem', - fontWeight: 900, - })} - > - {repo.name} - - ) : ( - - ) - ) : null)} + {/* Name plate: the instant base layer of every card. Previews fade + in over it, so the media area never sits as an empty void while + an embed verifies or an image loads — and it doubles as the + terminal fallback when every preview source fails. */} + ({ + position: 'absolute', + inset: 0, + display: 'grid', + placeItems: 'center', + px: 2, + textAlign: 'center', + color: alpha(theme.palette.text.primary, 0.3), + fontFamily: 'var(--font-accent)', + fontSize: '1.4rem', + fontWeight: 900, + })} + > + {repo.name} + + + {/* Preview: website screenshot → GitHub OG card, fading in over the + name plate. It stays mounted even once the live window is shown: + the embed is opaque and covers it, and unmounting mid-cross-fade + would let the name plate peek through. */} + {showSiteShot ? ( + { + if (img.naturalWidth >= SHOT_REAL_MIN_WIDTH) setShotIsReal(true); + }} + onError={() => setSiteShotFailed(true)} + sx={{ ...PREVIEW_MEDIA_SX, objectPosition: 'top' }} + /> + ) : showBackdropOg && !imageFailed ? ( + + ) : null} {/* Hidden verifier: mounted once near the viewport, unmounted as soon as a verdict arrives (see the verification effect). */} @@ -397,19 +627,25 @@ const RepoCard: React.FC<{ repo: Repository; index: number }> = ({ /> )} - {/* Live window: shown only after verification, as a sandboxed - iframe so the embedded site can never navigate the app away - (an cannot carry a sandbox attribute). */} + {/* Live window: mounted after verification as a sandboxed iframe so + the embedded site can never navigate the app away (an + cannot carry a sandbox attribute). It loads hidden behind the + backdrop and fades in LIVE_REVEAL_GRACE_MS after its load event, + so slow hosts and entrance animations never show a blank window + booting up. The hover classes attach only once it is shown — + otherwise hovering would force the half-loaded window visible. */} {embedLive && ( = ({ title={`${repo.fullName} website`} sandbox={EMBED_IFRAME_SANDBOX} tabIndex={-1} + onLoad={(event: React.SyntheticEvent) => { + if (liveShown || revealTimerRef.current !== undefined) return; + // The same-origin mirror fires load for its initial + // about:blank too; wait for the real document (see the + // verifier's load handler above). + if (sameOrigin) { + try { + const doc = event.currentTarget.contentDocument; + if (!doc || doc.URL === 'about:blank') return; + } catch { + return; + } + } + revealTimerRef.current = window.setTimeout( + () => setLiveShown(true), + LIVE_REVEAL_GRACE_MS, + ); + }} sx={{ width: EMBED_SIZE, height: EMBED_SIZE, @@ -432,110 +686,77 @@ const RepoCard: React.FC<{ repo: Repository; index: number }> = ({ )} - - ({ - width: 26, - height: 26, - border: `1px solid ${theme.palette.border.medium}`, - flexShrink: 0, - filter: 'grayscale(1)', - opacity: 0.88, - transition: 'filter 0.25s ease, opacity 0.25s ease', - })} - /> - - - {repo.name} - + {/* Below the frame: just the telemetry strip and activity line — the + PR data stays under the preview while identity lives above it. */} + + {activity && } + {activity && ( ({ - color: theme.palette.text.secondary, + mt: 0.75, + color: alpha(theme.palette.text.primary, 0.32), fontFamily: 'var(--font-accent)', - fontSize: '0.64rem', - letterSpacing: '0.08em', + fontSize: '0.56rem', + letterSpacing: '0.12em', textTransform: 'uppercase', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', })} > - {repo.owner} + {activity.mergedThisWeek} + {activity.mergedThisWeek === 1 ? ' pr' : ' prs'} merged this week + {' · '} + {activity.activeMiners} + {activity.activeMiners === 1 ? ' active miner' : ' active miners'} + {usdPerDay != null && usdPerDay > 0 && ( + + {' · '} + {formatUsdEstimate(usdPerDay, { includeApproxPrefix: true })} + {'/day'} + + )} - + )} ); }; const RepoCardSkeleton: React.FC<{ index: number }> = ({ index }) => ( - ({ - border: `1px solid ${theme.palette.border.subtle}`, - borderRadius: 1.5, - overflow: 'hidden', - backgroundColor: theme.palette.surface.subtle, - ...fadeUp(120 + index * 45), - })} - > + + + ({ + width: 90, + height: 8, + borderRadius: 0.5, + backgroundColor: alpha(theme.palette.text.primary, 0.07), + })} + /> + ({ + mt: 1, + width: '85%', + height: 7, + borderRadius: 0.5, + backgroundColor: alpha(theme.palette.text.primary, 0.05), + })} + /> + ({ width: '100%', aspectRatio: '2 / 1', + border: `1px solid ${theme.palette.border.subtle}`, + borderRadius: 1.5, backgroundColor: alpha(theme.palette.text.primary, 0.045), })} /> - - ({ - width: 26, - height: 26, - borderRadius: '50%', - backgroundColor: alpha(theme.palette.text.primary, 0.07), - })} - /> - - ({ - width: 120, - height: 12, - borderRadius: 0.5, - backgroundColor: alpha(theme.palette.text.primary, 0.07), - })} - /> - ({ - mt: 0.75, - width: 72, - height: 8, - borderRadius: 0.5, - backgroundColor: alpha(theme.palette.text.primary, 0.05), - })} - /> - - ); @@ -590,6 +811,71 @@ const DialArrow: React.FC<{ ); }; +// Branded curtain shown once per tab session while the repo list loads: a +// short, honest beat (NN/g-style indeterminate wait, well under the ~10s +// bar) that lifts into the grid in one coordinated reveal instead of +// content trickling in. Hard-capped so a slow or failed request can never +// hold the page hostage. +const CURTAIN_SESSION_KEY = 'gt-landing-curtain-shown'; +const CURTAIN_MIN_MS = 700; +const CURTAIN_MAX_MS = 4000; +const CURTAIN_FADE_MS = 400; + +const Curtain: React.FC<{ leaving: boolean }> = ({ leaving }) => ( + ({ + position: 'fixed', + inset: 0, + zIndex: theme.zIndex.modal + 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 2.5, + backgroundColor: '#000', + opacity: leaving ? 0 : 1, + transition: `opacity ${CURTAIN_FADE_MS}ms ease`, + pointerEvents: leaving ? 'none' : 'auto', + '@keyframes curtainDot': { + '0%, 80%, 100%': { opacity: 0.15 }, + '40%': { opacity: 0.85 }, + }, + })} + > + + Gittensor + + + {[0, 1, 2].map((dot) => ( + + ))} + + +); + const HomePage: React.FC = () => { const reposQuery = useReposAndWeights(); const [timeline, setTimeline] = useState('repositories'); @@ -612,6 +898,201 @@ const HomePage: React.FC = () => { [reposQuery.data], ); + // Activity digest per repo (lowercased fullName -> counts), folded from + // the network-wide PR list in one pass. PR records may carry a lowercased + // repository name, so matching is case-insensitive. + const prsQuery = useAllPrs(); + const activityByRepo = useMemo(() => { + if (!prsQuery.data) return undefined; + const now = Date.now(); + const map = new Map< + string, + { mergedThisWeek: number; miners: Set; mergedDaily: number[] } + >(); + for (const pr of prsQuery.data) { + const key = pr.repository?.toLowerCase(); + if (!key) continue; + let entry = map.get(key); + if (!entry) { + entry = { + mergedThisWeek: 0, + miners: new Set(), + mergedDaily: Array.from({ length: SPARK_DAYS }, () => 0), + }; + map.set(key, entry); + } + const mergedAtMs = pr.mergedAt ? new Date(pr.mergedAt).getTime() : null; + if (mergedAtMs !== null) { + if (now - mergedAtMs < WEEK_MS) entry.mergedThisWeek += 1; + const daysAgo = Math.floor((now - mergedAtMs) / DAY_MS); + if (daysAgo >= 0 && daysAgo < SPARK_DAYS) { + entry.mergedDaily[SPARK_DAYS - 1 - daysAgo] += 1; + } + } + const activeAtMs = new Date(pr.mergedAt ?? pr.prCreatedAt).getTime(); + if (pr.author && now - activeAtMs < ACTIVE_MINER_WINDOW_MS) { + entry.miners.add(pr.author); + } + } + return map; + }, [prsQuery.data]); + + // Live description refresh: one GitHub metadata request per repo, at most + // once per session (cached in sessionStorage), fired a few seconds after + // mount so it never competes with the first paint. Failures (rate limit, + // renamed repo) silently keep the build-time snapshot. + const [liveDescriptions, setLiveDescriptions] = + useState>(readDescriptionCache); + const descriptionFetchAttemptedRef = useRef>(new Set()); + useEffect(() => { + const attempted = descriptionFetchAttemptedRef.current; + const missing = repos + .map((repo) => repo.fullName) + .filter( + (name) => liveDescriptions[name] === undefined && !attempted.has(name), + ); + if (missing.length === 0) return; + missing.forEach((name) => attempted.add(name)); + let cancelled = false; + const timer = window.setTimeout(async () => { + const updates: Record = {}; + await Promise.all( + missing.map(async (fullName) => { + try { + const response = await fetch( + `https://api.github.com/repos/${fullName}`, + ); + if (!response.ok) return; + const meta = (await response.json()) as { description?: string }; + updates[fullName] = (meta.description ?? '').trim(); + } catch { + /* offline or blocked: the snapshot stays */ + } + }), + ); + if (cancelled || Object.keys(updates).length === 0) return; + setLiveDescriptions((previous) => { + const next = { ...previous, ...updates }; + try { + sessionStorage.setItem(DESCRIPTION_CACHE_KEY, JSON.stringify(next)); + } catch { + /* cache is best-effort */ + } + return next; + }); + }, DESCRIPTION_REFRESH_DELAY_MS); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [repos, liveDescriptions]); + + // A repo's estimated payout: its emission share of the total daily USD + // currently flowing to miners across the network. + const minersQuery = useAllMiners(); + const networkUsdPerDay = useMemo( + () => + minersQuery.data?.reduce( + (acc, miner) => acc + parseNumber(miner.usdPerDay ?? 0), + 0, + ) ?? null, + [minersQuery.data], + ); + + // Curtain state: 'shown' -> 'leaving' (fading) -> 'done' (unmounted). + // Shown once per tab session; SPA navigations back here skip it. + // sessionStorage throws when the browser blocks all site data; the + // curtain is cosmetic, so it is simply skipped there. + const [curtain, setCurtain] = useState<'shown' | 'leaving' | 'done'>(() => { + try { + return sessionStorage.getItem(CURTAIN_SESSION_KEY) ? 'done' : 'shown'; + } catch { + return 'done'; + } + }); + const [curtainMinPassed, setCurtainMinPassed] = useState(false); + const [curtainCapPassed, setCurtainCapPassed] = useState(false); + + useEffect(() => { + if (curtain !== 'shown') return; + try { + sessionStorage.setItem(CURTAIN_SESSION_KEY, '1'); + } catch { + /* storage unavailable: the curtain just shows again next visit */ + } + const minTimer = window.setTimeout( + () => setCurtainMinPassed(true), + CURTAIN_MIN_MS, + ); + const capTimer = window.setTimeout( + () => setCurtainCapPassed(true), + CURTAIN_MAX_MS, + ); + return () => { + window.clearTimeout(minTimer); + window.clearTimeout(capTimer); + }; + }, [curtain]); + + // Preload the first screen of preview images (the same URLs the cards + // render, so the browser cache makes them paint instantly) once the repo + // list is in; the curtain holds until they have settled so it lifts into + // formed cards, not a wall of name plates with images trickling in. + const [previewsReady, setPreviewsReady] = useState(false); + useEffect(() => { + if (curtain !== 'shown' || previewsReady || repos.length === 0) return; + let cancelled = false; + const firstScreen = repos.slice(0, 9).map((repo) => { + const website = REPO_WEBSITES[repo.fullName]; + return website + ? getSiteScreenshotSrc(website) + : getRepoPreviewSrc(repo.fullName, 0); + }); + Promise.all( + firstScreen.map( + (src) => + new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(); + img.onerror = () => resolve(); + img.src = src; + }), + ), + ).then(() => { + if (!cancelled) setPreviewsReady(true); + }); + return () => { + cancelled = true; + }; + }, [curtain, previewsReady, repos]); + + // Lift once the repo list has settled and the first screen of previews + // has loaded, but never before the minimum beat and never later than the + // hard cap. + const reposSettled = !reposQuery.isLoading; + useEffect(() => { + if (curtain !== 'shown' || !curtainMinPassed) return; + const ready = reposSettled && (previewsReady || repos.length === 0); + if (!ready && !curtainCapPassed) return; + setCurtain('leaving'); + }, [ + curtain, + curtainMinPassed, + curtainCapPassed, + reposSettled, + previewsReady, + repos.length, + ]); + + useEffect(() => { + if (curtain !== 'leaving') return; + const fadeTimer = window.setTimeout( + () => setCurtain('done'), + CURTAIN_FADE_MS, + ); + return () => window.clearTimeout(fadeTimer); + }, [curtain]); + return ( { description="A permissionless market of miners on Bittensor Subnet 74. Explore every project the network is building." type="website" /> + {curtain !== 'done' && } { '0%': { opacity: 0, transform: 'translateY(18px)' }, '100%': { opacity: 1, transform: 'translateY(0)' }, }, + // While the curtain is up, entrance animations are held at frame + // zero so the sweep plays for the viewer, not behind the curtain. + '&.landing-hold *': { + animationPlayState: 'paused', + }, }} > { sm: 'repeat(2, minmax(0, 1fr))', md: 'repeat(3, minmax(0, 1fr))', }, - gap: { xs: 2, md: 2.5 }, + columnGap: { xs: 2, md: 2.5 }, + rowGap: { xs: 3, md: 3.5 }, }} > {reposQuery.isLoading ? Array.from({ length: 9 }, (_, index) => ( )) - : repos.map((repo, index) => ( - - ))} + : repos.map((repo, index) => { + const digest = activityByRepo?.get( + repo.fullName.toLowerCase(), + ); + const liveDescription = liveDescriptions[repo.fullName]; + return ( + 0), + } + } + usdPerDay={ + networkUsdPerDay !== null + ? networkUsdPerDay * + parseNumber(repo.config?.emissionShare ?? 0) + : null + } + /> + ); + })} {!reposQuery.isLoading && repos.length === 0 && (