From dc45f3843a0fa4a54b2b86456241cedb707e1bf9 Mon Sep 17 00:00:00 2001 From: aurph Date: Mon, 3 Aug 2026 20:50:34 -0400 Subject: [PATCH 1/4] Home: lead the hero with the sourced-numbers claim and prove it inline The hero spent its largest type on a logo the nav already carries, said "Energy infrastructure, in plain sight." (which does not say what GridTilt is, or that anything is sourced), and showed four numbers with no date and no provenance. For a product whose whole differentiator is sourced numbers, the front door argued the opposite. - The sentence leads: "The AI power buildout, tracked with sourced numbers." with "sourced numbers" set in the marketing serif italic, which anchor.css already reserves for exactly this emphasis. The scope line underneath names what is covered. The wordmark drops to masthead size. - Every figure in the ledger now prints the date its own dataset was last refreshed, read from the same endpoint that serves the figure. Nothing is hardcoded, and a stat whose API returns no date renders "no date" rather than borrowing today's. This surfaces real staleness immediately: the deals figure reads "as of 20 May 2026", because it is. - Each ledger cell links to the module that shows its working. - Adds the live cluster map behind the hero, using the same projection and the same dot semantics as the social card, so the landing page and the images that go out on X read as one system. It uses geoAlbers rather than geoAlbersUsa, whose composite clip draws stray Alaska and Hawaii inset frames over a lower-48 feature set. - Cuts the reveal delay from 1.5s to 0.12s. The old hero showed a visitor a bare wordmark for a second and a half before saying anything. - Uses .gt-tagline-primary / .gt-tagline-emphasis, which were designed in anchor.css for the landing and then never wired to anything. Also carries the Virginia winding fix from the daily-card branch, because this hero's map reads the same geojson: the ring shipped backwards, d3 read it as the whole sphere minus Virginia, and every geoAlbersUsa fitSize on the site has been fitting to a global extent. tsc clean, 300 tests pass, client build passes, verified in headless Chrome at 1440px and 430px. --- .gitignore | 1 + client/src/components/home/Hero.tsx | 168 +++++++++++++++++++----- client/src/components/home/hero-map.tsx | 87 ++++++++++++ client/src/data/us-states.geo.json | 2 +- client/src/styles/anchor.css | 55 +++++--- 5 files changed, 263 insertions(+), 50 deletions(-) create mode 100644 client/src/components/home/hero-map.tsx diff --git a/.gitignore b/.gitignore index 70e48a1..d0b3597 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ attached_assets/Pasted-* /tmp/ coverage/ .nyc_output/ +.card-preview/ diff --git a/client/src/components/home/Hero.tsx b/client/src/components/home/Hero.tsx index 2d89833..54bf6f1 100644 --- a/client/src/components/home/Hero.tsx +++ b/client/src/components/home/Hero.tsx @@ -2,19 +2,35 @@ import { useEffect, useRef, useState } from "react"; import { Link } from "wouter"; import { useQuery } from "@tanstack/react-query"; import { motion, type Variants } from "framer-motion"; -import { Zap, Handshake, Cpu, MapPin } from "lucide-react"; import { Wordmark } from "./Wordmark"; import { GridPulse } from "./grid-pulse"; import { MarketTape } from "./market-tape"; +import { HeroMap } from "./hero-map"; + +// The hero's job is to say what GridTilt is and prove the numbers are real. +// Every figure below carries the date its dataset was last refreshed, read +// from the same API that serves the figure. Nothing here is hardcoded, and a +// stat with no refresh date says so rather than borrowing today's. interface ClusterMetrics { clusterCount: number; operationalMW: number; totalPlannedMW: number; byOperator: { operator: string }[]; + lastRefreshed: string | null; +} +interface DealMetrics { + dealCount: number; + totalContractedMW: number; + lastRefreshed: string | null; +} +interface GpuMetrics { + fleetAvg: number; + fleetAvg1yChange: number; + modelCount: number; + lastRefreshed: string | null; + asOf: string | null; } -interface DealMetrics { dealCount: number; totalContractedMW: number; } -interface GpuMetrics { fleetAvg: number; fleetAvg1yChange: number; modelCount: number; } /** rAF count-up toward a target once it arrives; honest "--" before data. */ function useCountUp(target: number | null, decimals = 1, ms = 1100): string | null { @@ -35,11 +51,31 @@ function useCountUp(target: number | null, decimals = 1, ms = 1100): string | nu return display; } +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** "2026-06-26" -> "26 Jun 2026". Null when the API gave us nothing usable. */ +function shortDate(iso: string | null | undefined): string | null { + if (!iso) return null; + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + if (!m) return null; + const mon = MONTHS[Number(m[2]) - 1]; + return mon ? `${Number(m[3])} ${mon} ${m[1]}` : null; +} + const fadeUp: Variants = { - hidden: { opacity: 0, y: 14 }, - show: { opacity: 1, y: 0, transition: { duration: 0.55, ease: "easeOut" } }, + hidden: { opacity: 0, y: 12 }, + show: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } }, }; +interface HeroStat { + label: string; + value: string; + sub?: string; + /** Dataset refresh date. Null renders "no date" rather than inventing one. */ + asOf: string | null; + href: string; +} + export function Hero() { const { data: clusters } = useQuery({ queryKey: ["/api/clusters/metrics"] }); const { data: deals } = useQuery({ queryKey: ["/api/deals/metrics"] }); @@ -50,11 +86,35 @@ export function Hero() { const gpuHr = useCountUp(gpu ? gpu.fleetAvg : null, 2); const clusterCount = useCountUp(clusters ? clusters.clusterCount : null, 0); - const stats: { icon: typeof Zap; label: string; value: string; sub?: string }[] = [ - { icon: Zap, label: "Operational power for compute", value: opGw ? `${opGw} GW` : "--", sub: clusters ? `${(clusters.totalPlannedMW / 1000).toFixed(1)} GW planned` : undefined }, - { icon: Handshake, label: "Contracted power deals", value: dealGw ? `${dealGw} GW` : "--", sub: deals ? `${deals.dealCount} corporate deals` : undefined }, - { icon: Cpu, label: "Cost of compute", value: gpuHr ? `$${gpuHr}/hr` : "--", sub: gpu ? `${gpu.fleetAvg1yChange}% over a year` : undefined }, - { icon: MapPin, label: "Tracked clusters", value: clusterCount ?? "--", sub: clusters?.byOperator ? `${clusters.byOperator.length} operators` : undefined }, + const stats: HeroStat[] = [ + { + label: "Operational power for compute", + value: opGw ? `${opGw} GW` : "--", + sub: clusters ? `${(clusters.totalPlannedMW / 1000).toFixed(1)} GW planned` : undefined, + asOf: shortDate(clusters?.lastRefreshed), + href: "/compute-frontier", + }, + { + label: "Contracted power deals", + value: dealGw ? `${dealGw} GW` : "--", + sub: deals ? `${deals.dealCount} corporate deals` : undefined, + asOf: shortDate(deals?.lastRefreshed), + href: "/power-deals", + }, + { + label: "Cost of compute", + value: gpuHr ? `$${gpuHr}/hr` : "--", + sub: gpu ? `${gpu.fleetAvg1yChange}% over a year` : undefined, + asOf: shortDate(gpu?.asOf ?? gpu?.lastRefreshed), + href: "/neocloud-intel", + }, + { + label: "Tracked clusters", + value: clusterCount ?? "--", + sub: clusters?.byOperator ? `${clusters.byOperator.length} operators` : undefined, + asOf: shortDate(clusters?.lastRefreshed), + href: "/power-map", + }, ]; return ( @@ -66,23 +126,41 @@ export function Hero() {
+ -
- +
- - Energy infrastructure, in plain sight. - - - Data centers are rewriting the American power grid. GridTilt maps who is building, - where the electricity comes from, and what it means for the bill you pay. + {/* Masthead. The wordmark anchors the brand at reading size so the + sentence below can lead; the nav already carries it persistently. */} + +
+ +
+
+ + + The AI power buildout, tracked with{" "} + sourced numbers. + + + + Data centers, generation, transmission, and the companies behind them. - + + + {/* The ledger. Each figure links to the module that shows its working + and prints the date its dataset was last refreshed. This is the + claim in the headline, made checkable in the same viewport. */} - {stats.map(({ icon: Icon, label, value, sub }) => ( - ( + -
- - {label} -
-

+

{value}

- {sub &&

{sub}

} -
+

+ {label} +

+ {sub && ( +

+ {sub} +

+ )} +

+ {asOf ? `as of ${asOf}` : "no date"} +

+ ))}
diff --git a/client/src/components/home/hero-map.tsx b/client/src/components/home/hero-map.tsx new file mode 100644 index 0000000..da2ecf7 --- /dev/null +++ b/client/src/components/home/hero-map.tsx @@ -0,0 +1,87 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { geoAlbers, geoPath } from "d3-geo"; +import type { FeatureCollection } from "geojson"; +import statesGeoRaw from "@/data/us-states.geo.json"; + +// Faded cluster map behind the hero. Same projection and same dot semantics as +// the social card (server/og-card.ts), so the landing page and the images that +// go out on X read as one system. +// +// geoAlbers, not geoAlbersUsa: the composite projection draws its Alaska and +// Hawaii inset frames over a lower-48-only feature set. We show the lower 48, +// so the plain conic is the honest choice. + +interface Cluster { + status: string; + plannedPowerMW: number | null; + location: { lat: number; lng: number }; +} + +const MAP_W = 760; +const MAP_H = 470; +const SKIP = ["Alaska", "Hawaii", "Puerto Rico"]; + +const STATES = statesGeoRaw as unknown as FeatureCollection; +const CONTINENTAL: FeatureCollection = { + type: "FeatureCollection", + features: STATES.features.filter( + (f) => !SKIP.includes((f.properties as { name?: string })?.name ?? ""), + ), +}; + +const PROJECTION = geoAlbers().fitSize([MAP_W, MAP_H], CONTINENTAL); +const STATE_PATHS = CONTINENTAL.features.map((f) => geoPath(PROJECTION)(f) ?? "").filter(Boolean); + +const STATUS_OPACITY: Record = { + operational: 1, + construction: 0.7, + announced: 0.38, +}; + +function radius(mw: number | null): number { + const v = mw ?? 0; + if (v >= 1500) return 6.5; + if (v >= 600) return 5; + if (v >= 200) return 3.8; + return 3; +} + +export function HeroMap() { + const { data } = useQuery({ queryKey: ["/api/clusters"] }); + + const dots = useMemo(() => { + if (!data) return []; + return data + .map((c) => { + if (typeof c.location?.lat !== "number" || typeof c.location?.lng !== "number") return null; + const p = PROJECTION([c.location.lng, c.location.lat]); + if (!p) return null; + const [x, y] = p; + if (x < 0 || x > MAP_W || y < 0 || y > MAP_H) return null; + return { x, y, r: radius(c.plannedPowerMW), o: STATUS_OPACITY[c.status] ?? 0.5 }; + }) + .filter(Boolean) as { x: number; y: number; r: number; o: number }[]; + }, [data]); + + return ( + + + {STATE_PATHS.map((d, i) => ( + + ))} + + + {dots.map((d, i) => ( + + ))} + + + ); +} diff --git a/client/src/data/us-states.geo.json b/client/src/data/us-states.geo.json index 1c1ebe5..40f3325 100644 --- a/client/src/data/us-states.geo.json +++ b/client/src/data/us-states.geo.json @@ -45,7 +45,7 @@ {"type":"Feature","id":"48","properties":{"name":"Texas","density":98.07},"geometry":{"type":"Polygon","coordinates":[[[-101.812942,36.501861],[-100.000075,36.501861],[-100.000075,34.563024],[-99.923398,34.573978],[-99.698843,34.382285],[-99.57835,34.415147],[-99.260688,34.404193],[-99.189488,34.2125],[-98.986841,34.223454],[-98.767763,34.135823],[-98.570593,34.146777],[-98.488439,34.064623],[-98.36247,34.157731],[-98.170777,34.113915],[-98.088623,34.004376],[-97.946222,33.987946],[-97.869545,33.851022],[-97.694283,33.982469],[-97.458774,33.905791],[-97.371143,33.823637],[-97.256128,33.861976],[-97.173974,33.736006],[-96.922034,33.960561],[-96.850834,33.845545],[-96.631756,33.845545],[-96.423633,33.774345],[-96.346956,33.686714],[-96.149786,33.840068],[-95.936185,33.889361],[-95.8376,33.834591],[-95.602092,33.933176],[-95.547322,33.878407],[-95.289906,33.87293],[-95.224183,33.960561],[-94.966767,33.861976],[-94.868182,33.74696],[-94.484796,33.637421],[-94.380734,33.544313],[-94.183564,33.593606],[-94.041164,33.54979],[-94.041164,33.018527],[-94.041164,31.994339],[-93.822086,31.775262],[-93.816609,31.556184],[-93.542762,31.15089],[-93.526331,30.93729],[-93.630393,30.679874],[-93.728978,30.575812],[-93.696116,30.438888],[-93.767317,30.334826],[-93.690639,30.143133],[-93.926148,29.787132],[-93.838517,29.688547],[-94.002825,29.68307],[-94.523134,29.546147],[-94.70935,29.622824],[-94.742212,29.787132],[-94.873659,29.672117],[-94.966767,29.699501],[-95.016059,29.557101],[-94.911997,29.496854],[-94.895566,29.310638],[-95.081782,29.113469],[-95.383014,28.867006],[-95.985477,28.604113],[-96.045724,28.647929],[-96.226463,28.582205],[-96.23194,28.642452],[-96.478402,28.598636],[-96.593418,28.724606],[-96.664618,28.697221],[-96.401725,28.439805],[-96.593418,28.357651],[-96.774157,28.406943],[-96.801542,28.226204],[-97.026096,28.039988],[-97.256128,27.694941],[-97.404005,27.333463],[-97.513544,27.360848],[-97.540929,27.229401],[-97.425913,27.262263],[-97.480682,26.99937],[-97.557359,26.988416],[-97.562836,26.840538],[-97.469728,26.758384],[-97.442344,26.457153],[-97.332805,26.353091],[-97.30542,26.161398],[-97.217789,25.991613],[-97.524498,25.887551],[-97.650467,26.018997],[-97.885976,26.06829],[-98.198161,26.057336],[-98.466531,26.221644],[-98.669178,26.238075],[-98.822533,26.369522],[-99.030656,26.413337],[-99.173057,26.539307],[-99.266165,26.840538],[-99.446904,27.021277],[-99.424996,27.174632],[-99.50715,27.33894],[-99.479765,27.48134],[-99.605735,27.640172],[-99.709797,27.656603],[-99.879582,27.799003],[-99.934351,27.979742],[-100.082229,28.14405],[-100.29583,28.280974],[-100.399891,28.582205],[-100.498476,28.66436],[-100.629923,28.905345],[-100.673738,29.102515],[-100.799708,29.244915],[-101.013309,29.370885],[-101.062601,29.458516],[-101.259771,29.535193],[-101.413125,29.754271],[-101.851281,29.803563],[-102.114174,29.792609],[-102.338728,29.869286],[-102.388021,29.765225],[-102.629006,29.732363],[-102.809745,29.524239],[-102.919284,29.190146],[-102.97953,29.184669],[-103.116454,28.987499],[-103.280762,28.982022],[-103.527224,29.135376],[-104.146119,29.381839],[-104.266611,29.513285],[-104.507597,29.639255],[-104.677382,29.924056],[-104.688336,30.181472],[-104.858121,30.389596],[-104.896459,30.570335],[-105.005998,30.685351],[-105.394861,30.855136],[-105.602985,31.085167],[-105.77277,31.167321],[-105.953509,31.364491],[-106.205448,31.468553],[-106.38071,31.731446],[-106.528588,31.786216],[-106.643603,31.901231],[-106.616219,31.999816],[-103.067161,31.999816],[-103.067161,33.002096],[-103.045254,34.01533],[-103.039777,36.501861],[-103.001438,36.501861],[-101.812942,36.501861]]]}}, {"type":"Feature","id":"49","properties":{"name":"Utah","density":34.30},"geometry":{"type":"Polygon","coordinates":[[[-112.164359,41.995232],[-111.047063,42.000709],[-111.047063,40.998429],[-109.04798,40.998429],[-109.053457,39.125316],[-109.058934,38.27639],[-109.042503,38.166851],[-109.042503,37.000263],[-110.499369,37.00574],[-114.048427,37.000263],[-114.04295,41.995232],[-112.164359,41.995232]]]}}, {"type":"Feature","id":"50","properties":{"name":"Vermont","density":67.73},"geometry":{"type":"Polygon","coordinates":[[[-71.503554,45.013027],[-71.4926,44.914442],[-71.629524,44.750133],[-71.536416,44.585825],[-71.700724,44.41604],[-72.034817,44.322932],[-72.02934,44.07647],[-72.116971,43.994316],[-72.204602,43.769761],[-72.379864,43.572591],[-72.456542,43.150867],[-72.445588,43.008466],[-72.533219,42.953697],[-72.544173,42.80582],[-72.456542,42.729142],[-73.267129,42.745573],[-73.278083,42.833204],[-73.245221,43.523299],[-73.404052,43.687607],[-73.349283,43.769761],[-73.436914,44.043608],[-73.321898,44.246255],[-73.294514,44.437948],[-73.387622,44.618687],[-73.332852,44.804903],[-73.343806,45.013027],[-72.308664,45.002073],[-71.503554,45.013027]]]}}, -{"type":"Feature","id":"51","properties":{"name":"Virginia","density":204.5},"geometry":{"type":"MultiPolygon","coordinates":[[[[-75.397659,38.013497],[-75.244304,38.029928],[-75.375751,37.860142],[-75.512674,37.799896],[-75.594828,37.569865],[-75.802952,37.197433],[-75.972737,37.120755],[-76.027507,37.257679],[-75.939876,37.564388],[-75.671506,37.95325],[-75.397659,38.013497]]],[[[-76.016553,37.95325],[-75.994645,37.95325],[-76.043938,37.95325],[-76.016553,37.95325]]],[[[-78.349729,39.464886],[-77.82942,39.130793],[-77.719881,39.322485],[-77.566527,39.306055],[-77.456988,39.223901],[-77.456988,39.076023],[-77.248864,39.026731],[-77.117418,38.933623],[-77.040741,38.791222],[-77.128372,38.632391],[-77.248864,38.588575],[-77.325542,38.446175],[-77.281726,38.342113],[-77.013356,38.374975],[-76.964064,38.216144],[-76.613539,38.15042],[-76.514954,38.024451],[-76.235631,37.887527],[-76.3616,37.608203],[-76.246584,37.389126],[-76.383508,37.285064],[-76.399939,37.159094],[-76.273969,37.082417],[-76.410893,36.961924],[-76.619016,37.120755],[-76.668309,37.065986],[-76.48757,36.95097],[-75.994645,36.923586],[-75.868676,36.551154],[-79.510841,36.5402],[-80.294043,36.545677],[-80.978661,36.562108],[-81.679709,36.589492],[-83.673316,36.600446],[-83.136575,36.742847],[-83.070852,36.852385],[-82.879159,36.890724],[-82.868205,36.978355],[-82.720328,37.044078],[-82.720328,37.120755],[-82.353373,37.268633],[-81.969987,37.537003],[-81.986418,37.454849],[-81.849494,37.285064],[-81.679709,37.20291],[-81.55374,37.208387],[-81.362047,37.339833],[-81.225123,37.235771],[-80.967707,37.290541],[-80.513121,37.482234],[-80.474782,37.421987],[-80.29952,37.509618],[-80.294043,37.690357],[-80.184505,37.849189],[-79.998289,37.997066],[-79.921611,38.177805],[-79.724442,38.364021],[-79.647764,38.594052],[-79.477979,38.457129],[-79.313671,38.413313],[-79.209609,38.495467],[-78.996008,38.851469],[-78.870039,38.763838],[-78.404499,39.169131],[-78.349729,39.464886]]]]}}, +{"type":"Feature","id":"51","properties":{"name":"Virginia","density":204.5},"geometry":{"type":"MultiPolygon","coordinates":[[[[-75.397659,38.013497],[-75.244304,38.029928],[-75.375751,37.860142],[-75.512674,37.799896],[-75.594828,37.569865],[-75.802952,37.197433],[-75.972737,37.120755],[-76.027507,37.257679],[-75.939876,37.564388],[-75.671506,37.95325],[-75.397659,38.013497]]],[[[-76.016553,37.95325],[-76.043938,37.95325],[-75.994645,37.95325],[-76.016553,37.95325]]],[[[-78.349729,39.464886],[-77.82942,39.130793],[-77.719881,39.322485],[-77.566527,39.306055],[-77.456988,39.223901],[-77.456988,39.076023],[-77.248864,39.026731],[-77.117418,38.933623],[-77.040741,38.791222],[-77.128372,38.632391],[-77.248864,38.588575],[-77.325542,38.446175],[-77.281726,38.342113],[-77.013356,38.374975],[-76.964064,38.216144],[-76.613539,38.15042],[-76.514954,38.024451],[-76.235631,37.887527],[-76.3616,37.608203],[-76.246584,37.389126],[-76.383508,37.285064],[-76.399939,37.159094],[-76.273969,37.082417],[-76.410893,36.961924],[-76.619016,37.120755],[-76.668309,37.065986],[-76.48757,36.95097],[-75.994645,36.923586],[-75.868676,36.551154],[-79.510841,36.5402],[-80.294043,36.545677],[-80.978661,36.562108],[-81.679709,36.589492],[-83.673316,36.600446],[-83.136575,36.742847],[-83.070852,36.852385],[-82.879159,36.890724],[-82.868205,36.978355],[-82.720328,37.044078],[-82.720328,37.120755],[-82.353373,37.268633],[-81.969987,37.537003],[-81.986418,37.454849],[-81.849494,37.285064],[-81.679709,37.20291],[-81.55374,37.208387],[-81.362047,37.339833],[-81.225123,37.235771],[-80.967707,37.290541],[-80.513121,37.482234],[-80.474782,37.421987],[-80.29952,37.509618],[-80.294043,37.690357],[-80.184505,37.849189],[-79.998289,37.997066],[-79.921611,38.177805],[-79.724442,38.364021],[-79.647764,38.594052],[-79.477979,38.457129],[-79.313671,38.413313],[-79.209609,38.495467],[-78.996008,38.851469],[-78.870039,38.763838],[-78.404499,39.169131],[-78.349729,39.464886]]]]}}, {"type":"Feature","id":"53","properties":{"name":"Washington","density":102.6},"geometry":{"type":"MultiPolygon","coordinates":[[[[-117.033359,49.000239],[-117.044313,47.762451],[-117.038836,46.426077],[-117.055267,46.343923],[-116.92382,46.168661],[-116.918344,45.993399],[-118.988627,45.998876],[-119.125551,45.933153],[-119.525367,45.911245],[-119.963522,45.823614],[-120.209985,45.725029],[-120.505739,45.697644],[-120.637186,45.746937],[-121.18488,45.604536],[-121.217742,45.670259],[-121.535404,45.725029],[-121.809251,45.708598],[-122.247407,45.549767],[-122.762239,45.659305],[-122.811531,45.960537],[-122.904639,46.08103],[-123.11824,46.185092],[-123.211348,46.174138],[-123.370179,46.146753],[-123.545441,46.261769],[-123.72618,46.300108],[-123.874058,46.239861],[-124.065751,46.327492],[-124.027412,46.464416],[-123.895966,46.535616],[-124.098612,46.74374],[-124.235536,47.285957],[-124.31769,47.357157],[-124.427229,47.740543],[-124.624399,47.88842],[-124.706553,48.184175],[-124.597014,48.381345],[-124.394367,48.288237],[-123.983597,48.162267],[-123.704273,48.167744],[-123.424949,48.118452],[-123.162056,48.167744],[-123.036086,48.080113],[-122.800578,48.08559],[-122.636269,47.866512],[-122.515777,47.882943],[-122.493869,47.587189],[-122.422669,47.318818],[-122.324084,47.346203],[-122.422669,47.576235],[-122.395284,47.800789],[-122.230976,48.030821],[-122.362422,48.123929],[-122.373376,48.288237],[-122.471961,48.468976],[-122.422669,48.600422],[-122.488392,48.753777],[-122.647223,48.775685],[-122.795101,48.8907],[-122.756762,49.000239],[-117.033359,49.000239]]],[[[-122.718423,48.310145],[-122.586977,48.35396],[-122.608885,48.151313],[-122.767716,48.227991],[-122.718423,48.310145]]],[[[-123.025132,48.583992],[-122.915593,48.715438],[-122.767716,48.556607],[-122.811531,48.419683],[-123.041563,48.458022],[-123.025132,48.583992]]]]}}, {"type":"Feature","id":"54","properties":{"name":"West Virginia","density":77.06},"geometry":{"type":"Polygon","coordinates":[[[-80.518598,40.636951],[-80.518598,39.722302],[-79.477979,39.722302],[-79.488933,39.20747],[-79.291763,39.300578],[-79.094593,39.470363],[-78.963147,39.437501],[-78.765977,39.585379],[-78.470222,39.514178],[-78.431884,39.623717],[-78.267575,39.61824],[-78.174467,39.694917],[-78.004682,39.601809],[-77.834897,39.601809],[-77.719881,39.322485],[-77.82942,39.130793],[-78.349729,39.464886],[-78.404499,39.169131],[-78.870039,38.763838],[-78.996008,38.851469],[-79.209609,38.495467],[-79.313671,38.413313],[-79.477979,38.457129],[-79.647764,38.594052],[-79.724442,38.364021],[-79.921611,38.177805],[-79.998289,37.997066],[-80.184505,37.849189],[-80.294043,37.690357],[-80.29952,37.509618],[-80.474782,37.421987],[-80.513121,37.482234],[-80.967707,37.290541],[-81.225123,37.235771],[-81.362047,37.339833],[-81.55374,37.208387],[-81.679709,37.20291],[-81.849494,37.285064],[-81.986418,37.454849],[-81.969987,37.537003],[-82.101434,37.553434],[-82.293127,37.668449],[-82.342419,37.783465],[-82.50125,37.931343],[-82.621743,38.123036],[-82.594358,38.424267],[-82.331465,38.446175],[-82.293127,38.577622],[-82.172634,38.632391],[-82.221926,38.785745],[-82.03571,39.026731],[-81.887833,38.873376],[-81.783771,38.966484],[-81.811156,39.0815],[-81.685186,39.273193],[-81.57017,39.267716],[-81.455155,39.410117],[-81.345616,39.344393],[-81.219646,39.388209],[-80.830783,39.711348],[-80.737675,40.078303],[-80.600752,40.319289],[-80.595275,40.472643],[-80.666475,40.582182],[-80.518598,40.636951]]]}}, {"type":"Feature","id":"55","properties":{"name":"Wisconsin","density":105.2},"geometry":{"type":"Polygon","coordinates":[[[-90.415429,46.568478],[-90.229213,46.508231],[-90.119674,46.338446],[-89.09001,46.135799],[-88.662808,45.987922],[-88.531362,46.020784],[-88.10416,45.922199],[-87.989145,45.796229],[-87.781021,45.675736],[-87.791975,45.500474],[-87.885083,45.363551],[-87.649574,45.341643],[-87.742682,45.199243],[-87.589328,45.095181],[-87.627666,44.974688],[-87.819359,44.95278],[-87.983668,44.722749],[-88.043914,44.563917],[-87.928898,44.536533],[-87.775544,44.640595],[-87.611236,44.837764],[-87.403112,44.914442],[-87.238804,45.166381],[-87.03068,45.22115],[-87.047111,45.089704],[-87.189511,44.969211],[-87.468835,44.552964],[-87.545512,44.322932],[-87.540035,44.158624],[-87.644097,44.103854],[-87.737205,43.8793],[-87.704344,43.687607],[-87.791975,43.561637],[-87.912467,43.249452],[-87.885083,43.002989],[-87.76459,42.783912],[-87.802929,42.493634],[-88.788778,42.493634],[-90.639984,42.510065],[-90.711184,42.636034],[-91.067185,42.75105],[-91.143862,42.909881],[-91.176724,43.134436],[-91.056231,43.254929],[-91.204109,43.353514],[-91.215062,43.501391],[-91.269832,43.616407],[-91.242447,43.775238],[-91.43414,43.994316],[-91.592971,44.032654],[-91.877772,44.202439],[-91.927065,44.333886],[-92.233773,44.443425],[-92.337835,44.552964],[-92.545959,44.569394],[-92.808852,44.750133],[-92.737652,45.117088],[-92.75956,45.286874],[-92.644544,45.440228],[-92.770513,45.566198],[-92.885529,45.577151],[-92.869098,45.719552],[-92.639067,45.933153],[-92.354266,46.015307],[-92.29402,46.075553],[-92.29402,46.667063],[-92.091373,46.749217],[-92.014696,46.705401],[-91.790141,46.694447],[-91.09457,46.864232],[-90.837154,46.95734],[-90.749522,46.88614],[-90.886446,46.754694],[-90.55783,46.584908],[-90.415429,46.568478]]]}}, diff --git a/client/src/styles/anchor.css b/client/src/styles/anchor.css index 9bb7265..faff768 100644 --- a/client/src/styles/anchor.css +++ b/client/src/styles/anchor.css @@ -96,23 +96,34 @@ /* ─── Hero faded power map decoration ──────────────────────────────────── */ +/* Sits in the right half, clear of the headline column, so it can carry real + contrast instead of hiding at decoration opacity. The cluster dots are the + point: they are the same 235 records the ledger below counts. */ .gt-hero-map { position: absolute; - right: -8%; + right: 0; top: 50%; transform: translateY(-50%); - width: 62vw; - max-width: 720px; + width: 56vw; + max-width: 740px; height: auto; - opacity: 0.085; + opacity: 0.5; pointer-events: none; z-index: 0; - filter: saturate(0.7) contrast(1.1); animation: gt-hero-map-breathe 18s ease-in-out infinite; } @keyframes gt-hero-map-breathe { - 0%, 100% { opacity: 0.06; transform: translateY(-50%) scale(1); } - 50% { opacity: 0.11; transform: translateY(-50%) scale(1.015); } + 0%, 100% { opacity: 0.42; transform: translateY(-50%) scale(1); } + 50% { opacity: 0.56; transform: translateY(-50%) scale(1.012); } +} + +/* Below the lg breakpoint the headline reflows over the map's column, so it + drops back to a faint texture rather than competing with the type. */ +@media (max-width: 1023px) { + .gt-hero-map { opacity: 0.14; animation: none; } +} +@media (prefers-reduced-motion: reduce) { + .gt-hero-map { animation: none; } } /* ─── Animated wordmark ────────────────────────────────────────────────── */ @@ -154,26 +165,40 @@ @keyframes gt-caret { to { opacity: 0; } } @keyframes gt-caret-fade { to { opacity: 0; } } +/* ─── Hero masthead ────────────────────────────────────────────────────── */ + +/* The wordmark drops to reading size on the hero so the sentence leads. The + nav carries the brand persistently, so a full-bleed logo here would spend + the best real estate on something the visitor can already see. */ +.gt-hero-masthead .gt-wordmark { + font-size: clamp(28px, 3.2vw, 40px); + letter-spacing: -0.03em; +} + /* ─── Tagline ──────────────────────────────────────────────────────────── */ +/* Sized to lead the hero. The emphasis span carries the differentiator + ("sourced numbers") in the editorial serif, which is the one place the + marketing voice allows it. */ .gt-tagline-primary { font-family: var(--mkt-display); - font-size: clamp(24px, 2.7vw, 36px); - font-weight: 500; - letter-spacing: -0.02em; - line-height: 1.2; + font-size: clamp(32px, 4.5vw, 58px); + font-weight: 600; + letter-spacing: -0.028em; + line-height: 1.1; color: var(--mkt-ink); - margin: 0 0 4px 0; + margin: 0; + text-wrap: balance; } .gt-tagline-emphasis { font-family: var(--mkt-serif); font-style: italic; font-weight: 400; - font-size: clamp(28px, 3.2vw, 44px); - line-height: 1.15; + font-size: 1.04em; + line-height: inherit; color: var(--mkt-accent); margin: 0; - letter-spacing: -0.01em; + letter-spacing: -0.012em; } /* ─── Staggered reveal animations ──────────────────────────────────────── */ From 9d7b3684c9487ba5a81eea4d4d7a394373739556 Mon Sep 17 00:00:00 2001 From: aurph Date: Mon, 3 Aug 2026 21:24:08 -0400 Subject: [PATCH 2/4] Home: derive the Power Map facility count instead of hardcoding it The card claimed "Thirty-three tracked facilities" while the map rendered directly beneath it plotted 58. /api/datacenters returns 58 today. The count was never going to hold. The datacenter ingester appends newly discovered facilities every six hours, so any number written into the copy starts drifting the moment it ships. This is the same class of problem as a stat with no as-of date: a claim the page cannot back up. The caption now reads from /api/datacenters, sharing the query key with RealUSMap so react-query serves both from one fetch and the sentence cannot disagree with the dots. While the request is in flight it drops the number rather than guessing one. Checked the neighbouring claim rather than assuming: "One hundred public companies" is correct, /api/stack carries exactly 100 unique tickers across 17 sectors, so it stays as written. --- .../src/components/home/FeaturesShowcase.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/client/src/components/home/FeaturesShowcase.tsx b/client/src/components/home/FeaturesShowcase.tsx index eb38b7d..015bed9 100644 --- a/client/src/components/home/FeaturesShowcase.tsx +++ b/client/src/components/home/FeaturesShowcase.tsx @@ -200,14 +200,32 @@ function PreviewSkeleton() { interface Module { number: string; name: string; caption: string; cta: string; route: string; preview: () => JSX.Element; } const MODULES: Module[] = [ { number: "01", name: "Equity Heatmap", caption: "One hundred public companies behind the buildout, priced live.", cta: "Open the heatmap", route: "/stack", preview: SectorHeatStrip }, - { number: "02", name: "Power Map", caption: "Thirty-three tracked facilities, plotted by operator and grid region.", cta: "Open the map", route: "/power-map", preview: RealUSMap }, + // Caption is filled in from /api/datacenters at render; see facilityCaption. + // A hardcoded count cannot survive here: the datacenter ingester appends new + // facilities every 6 hours, which is how this card came to claim 33 while the + // map directly beneath it plotted 58. + { number: "02", name: "Power Map", caption: "", cta: "Open the map", route: "/power-map", preview: RealUSMap }, { number: "03", name: "Supply Chain Flow", caption: "Where the buildout can get stuck, mapped to the companies exposed.", cta: "Trace the chain", route: "/stack?view=flow", preview: SupplyChainMini }, { number: "04", name: "Catalyst Tracker", caption: "Earnings dates, rule changes, and policy votes. One calendar.", cta: "See what's next", route: "/catalysts", preview: CatalystRows }, { number: "05", name: "Analyze: Portfolio", caption: "Type a ticker. See how exposed it is to the power story.", cta: "Score a ticker", route: "/analyze?tab=portfolio", preview: PortfolioPentagon }, { number: "06", name: "Analyze: Scenario", caption: "Pick how fast demand grows. See what it does to the grid by 2030.", cta: "Run a scenario", route: "/analyze?tab=scenario", preview: DemandSparkline }, ]; +/** + * Live caption for the Power Map card. Shares the /api/datacenters query key + * with RealUSMap, so react-query serves both from one fetch and the sentence + * can never disagree with the dots underneath it. Falls back to a claim with + * no number rather than guessing one while the request is in flight. + */ +function useFacilityCaption(): string { + const { data } = useQuery({ queryKey: ["/api/datacenters"] }); + const suffix = "plotted by operator and grid region."; + if (!data) return `Tracked facilities, ${suffix}`; + return `${data.length} tracked facilities, ${suffix}`; +} + export function FeaturesShowcase() { + const facilityCaption = useFacilityCaption(); return (
@@ -231,7 +249,9 @@ export function FeaturesShowcase() {

{m.name}

{m.number}
-

{m.caption}

+

+ {m.caption || facilityCaption} +

From 65bfa4c674fd4576364597e3cfaf4c7f4317cc1a Mon Sep 17 00:00:00 2001 From: aurph Date: Mon, 3 Aug 2026 21:33:41 -0400 Subject: [PATCH 3/4] Home: one footer, and one spelling of the company name Two defects found by actually scrolling the rendered page rather than reading the source. The landing shipped two footers. App.tsx renders SiteFooter inside
for every route, including "/", and Home.tsx rendered HomeFooter on top of it. The front door therefore ended with two brand lockups, two copyright lines and two source lists stacked on each other. SiteFooter is the more complete of the two, so HomeFooter goes. The two links only HomeFooter carried, the GitHub repo and the contact address, move into SiteFooter's Data column, so nothing is lost and every page gains them rather than just "/". Separately, the header and the footer disagreed on the company's own name. The animated Wordmark splits the constant "GridTilt" into "Grid" + "Tilt", while HomeFooter hardcoded its own lockup and rendered the accent half lowercase, as "Grid" + "tilt". Wordmark now exports BRAND_TEXT, BRAND_HEAD and BRAND_TAIL, and the surfaces share one split. The guard test pins the constant and fails on any surface that renders the brand with a lowercase second half, because this is a one-character drift that reads as correct until you put the header and the footer side by side. Also checked and deliberately left alone: the em dash rule. Seven hits in client/src are all JSX comments, null placeholders, or the DemandChart line that strips em dashes out of annotation labels. No product copy violates it. tsc clean, 289 tests pass, client build passes, verified in Chrome. --- client/src/components/home/HomeFooter.tsx | 145 ------------------ client/src/components/home/Wordmark.tsx | 15 +- client/src/components/site-footer.tsx | 13 ++ client/src/lib/__tests__/brand-lockup.test.ts | 51 ++++++ client/src/pages/Home.tsx | 6 +- 5 files changed, 81 insertions(+), 149 deletions(-) delete mode 100644 client/src/components/home/HomeFooter.tsx create mode 100644 client/src/lib/__tests__/brand-lockup.test.ts diff --git a/client/src/components/home/HomeFooter.tsx b/client/src/components/home/HomeFooter.tsx deleted file mode 100644 index 8e3d265..0000000 --- a/client/src/components/home/HomeFooter.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import logoPath from "@assets/Image_[Vectorized]_(2)_1773890483514.png"; - -const HORIZ_PAD = "clamp(24px, 5vw, 96px)"; - -export function HomeFooter() { - return ( -
-
-
-
-
- GridTilt - - Gridtilt - -
- - -
- -
-

- Sources: Yahoo Finance, EIA, DOE, NERC, LBNL. RSS: Utility Dive, Data - Center Dynamics, World Nuclear News, Power Engineering. -

- - gridtilt1@gmail.com - -
- -
- © 2026 GridTilt - gridtilt.com -
-
-
- ); -} - -function XIcon() { - return ( - - - - ); -} - -function GithubIcon() { - return ( - - - - ); -} diff --git a/client/src/components/home/Wordmark.tsx b/client/src/components/home/Wordmark.tsx index a0fe974..92b4284 100644 --- a/client/src/components/home/Wordmark.tsx +++ b/client/src/components/home/Wordmark.tsx @@ -1,7 +1,18 @@ import { useEffect, useState } from "react"; -const TEXT = "GridTilt"; -const SPLIT = 4; // "Grid" then "tilt" +/** + * The brand lockup, exported so every surface splits the same string the same + * way. The footer used to hardcode its own "Grid" + "tilt" and rendered the + * second half lowercase, so the header and footer disagreed on the company's + * own name. + */ +export const BRAND_TEXT = "GridTilt"; +export const BRAND_SPLIT = 4; +export const BRAND_HEAD = BRAND_TEXT.slice(0, BRAND_SPLIT); // "Grid" +export const BRAND_TAIL = BRAND_TEXT.slice(BRAND_SPLIT); // "Tilt" + +const TEXT = BRAND_TEXT; +const SPLIT = BRAND_SPLIT; // "Grid" then "Tilt" const STEP_MS = 95; const FIRST_DELAY_MS = 220; const TILT_DELAY_MS = 240; diff --git a/client/src/components/site-footer.tsx b/client/src/components/site-footer.tsx index 9b03ceb..10300fe 100644 --- a/client/src/components/site-footer.tsx +++ b/client/src/components/site-footer.tsx @@ -66,6 +66,19 @@ export function SiteFooter() { @gridtilt + {/* Carried up from the marketing footer when the landing stopped + rendering a second one. These were previously reachable only + from "/"; every page has them now. */} +
  • + + Source on GitHub + +
  • +
  • + + Contact + +
  • diff --git a/client/src/lib/__tests__/brand-lockup.test.ts b/client/src/lib/__tests__/brand-lockup.test.ts new file mode 100644 index 0000000..498c789 --- /dev/null +++ b/client/src/lib/__tests__/brand-lockup.test.ts @@ -0,0 +1,51 @@ +// The header and the footer used to disagree on the company's own name: the +// animated Wordmark rendered "Grid" + "Tilt" while HomeFooter hardcoded its +// own "Grid" + "tilt". Both now split one exported constant. These tests scan +// source rather than importing the .tsx, so they need no DOM. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "fs"; +import { join } from "path"; + +const ROOT = process.cwd(); +const CLIENT = join(ROOT, "client", "src"); + +function sourceFiles(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== "node_modules" && entry.name !== "__tests__") sourceFiles(p, acc); + } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) { + acc.push(p); + } + } + return acc; +} + +test("the wordmark exports one lockup and the halves rebuild the brand", () => { + const src = readFileSync(join(CLIENT, "components", "home", "Wordmark.tsx"), "utf-8"); + assert.match(src, /export const BRAND_TEXT = "GridTilt";/, "brand text must stay GridTilt"); + assert.match(src, /export const BRAND_HEAD/); + assert.match(src, /export const BRAND_TAIL/); + + const split = Number(/export const BRAND_SPLIT = (\d+);/.exec(src)?.[1]); + assert.equal("GridTilt".slice(0, split), "Grid"); + assert.equal("GridTilt".slice(split), "Tilt", "the accent half is Tilt, not tilt"); +}); + +test("no surface renders the brand with a lowercase second half", () => { + const offenders: string[] = []; + for (const file of sourceFiles(CLIENT)) { + const src = readFileSync(file, "utf-8"); + // "Grid" immediately followed by a lowercase "tilt" in rendered markup. + // The domain (gridtilt.com), the X handle and mailto addresses are all + // lowercase on purpose and never start with a capital G here. + for (const m of src.match(/Grid<[^>]*>tilt/g) ?? []) { + offenders.push(`${file.replace(ROOT + "/", "")}: ${m.slice(0, 48)}`); + } + for (const m of src.match(/>\s*Gridtilt\s* for every route, including +// "/". This page used to add HomeFooter on top of it, so the landing shipped +// two footers: two brand lockups, two copyright lines, two source lists. The +// GitHub and contact links that only HomeFooter carried moved into SiteFooter. export default function Home() { return (
    -
    ); From 50a1cc0a5352b1a95e72e39225f7ca7ddc047988 Mon Sep 17 00:00:00 2001 From: aurph Date: Wed, 5 Aug 2026 08:13:33 -0400 Subject: [PATCH 4/4] Home: keep the shipped landing, add the provenance ledger under it The hero rewrite in this branch changed three things that are settled brand decisions, so they are reverted here and the part worth keeping stays. Reverted: - The headline read "The AI power buildout, tracked with sourced numbers." Marquee surfaces stay power and grid first; AI belongs on data labels deep in the product, not on the front door. - "sourced numbers" rendered in Source Serif 4 italic via .gt-tagline-emphasis, which was dead CSS on main. Serif is not part of this brand. - The typed wordmark dropped to 28-40px masthead size so the sentence could lead. It leads instead, at full size, as shipped. - The hero map backdrop went from 0.085 to 0.5 opacity. Background layers stay low opacity, and a map behind the wordmark was already rejected once. The GridPulse backdrop stands on its own, so hero-map.tsx is removed and anchor.css returns to main. Kept, and the reason this branch exists: - Every hero stat prints the date its own dataset was last refreshed, read from the same endpoint that serves the figure. Nothing is hardcoded, and a stat whose API returns no date renders "no date" instead of borrowing today. - Each stat links to the module that shows its working. The dates this puts on screen are the point and they are not flattering: clusters 26 Jun, deals 20 May, GPU 5 Aug from the live sweep. Two of the four datasets are weeks stale and the front page now says so. tsc clean, 289 tests pass, build passes, homepage verified in Chrome. --- client/src/components/home/Hero.tsx | 106 ++++++++++-------------- client/src/components/home/hero-map.tsx | 87 ------------------- client/src/styles/anchor.css | 55 ++++-------- 3 files changed, 57 insertions(+), 191 deletions(-) delete mode 100644 client/src/components/home/hero-map.tsx diff --git a/client/src/components/home/Hero.tsx b/client/src/components/home/Hero.tsx index 54bf6f1..ce3e5d8 100644 --- a/client/src/components/home/Hero.tsx +++ b/client/src/components/home/Hero.tsx @@ -2,15 +2,16 @@ import { useEffect, useRef, useState } from "react"; import { Link } from "wouter"; import { useQuery } from "@tanstack/react-query"; import { motion, type Variants } from "framer-motion"; +import { Zap, Handshake, Cpu, MapPin } from "lucide-react"; import { Wordmark } from "./Wordmark"; import { GridPulse } from "./grid-pulse"; import { MarketTape } from "./market-tape"; -import { HeroMap } from "./hero-map"; -// The hero's job is to say what GridTilt is and prove the numbers are real. -// Every figure below carries the date its dataset was last refreshed, read -// from the same API that serves the figure. Nothing here is hardcoded, and a -// stat with no refresh date says so rather than borrowing today's. +// The wordmark leads and the tagline stays as shipped; the hero's job here is +// to prove the numbers under it are real. Every figure carries the date its +// dataset was last refreshed, read from the same API that serves the figure. +// Nothing is hardcoded, and a stat with no refresh date says so rather than +// borrowing today's. interface ClusterMetrics { clusterCount: number; @@ -68,6 +69,7 @@ const fadeUp: Variants = { }; interface HeroStat { + icon: typeof Zap; label: string; value: string; sub?: string; @@ -88,6 +90,7 @@ export function Hero() { const stats: HeroStat[] = [ { + icon: Zap, label: "Operational power for compute", value: opGw ? `${opGw} GW` : "--", sub: clusters ? `${(clusters.totalPlannedMW / 1000).toFixed(1)} GW planned` : undefined, @@ -95,6 +98,7 @@ export function Hero() { href: "/compute-frontier", }, { + icon: Handshake, label: "Contracted power deals", value: dealGw ? `${dealGw} GW` : "--", sub: deals ? `${deals.dealCount} corporate deals` : undefined, @@ -102,6 +106,7 @@ export function Hero() { href: "/power-deals", }, { + icon: Cpu, label: "Cost of compute", value: gpuHr ? `$${gpuHr}/hr` : "--", sub: gpu ? `${gpu.fleetAvg1yChange}% over a year` : undefined, @@ -109,6 +114,7 @@ export function Hero() { href: "/neocloud-intel", }, { + icon: MapPin, label: "Tracked clusters", value: clusterCount ?? "--", sub: clusters?.byOperator ? `${clusters.byOperator.length} operators` : undefined, @@ -126,41 +132,23 @@ export function Hero() {
    - -
    +
    + - {/* Masthead. The wordmark anchors the brand at reading size so the - sentence below can lead; the nav already carries it persistently. */} - -
    - -
    -
    - - - The AI power buildout, tracked with{" "} - sourced numbers. - - - - Data centers, generation, transmission, and the companies behind them. + + Energy infrastructure, in plain sight. - - + + Data centers are rewriting the American power grid. GridTilt maps who is building, + where the electricity comes from, and what it means for the bill you pay. + + {/* The ledger. Each figure links to the module that shows its working - and prints the date its dataset was last refreshed. This is the - claim in the headline, made checkable in the same viewport. */} + and prints the date its own dataset was last refreshed, so a stale + number is visible as stale instead of reading as today's. */} - {stats.map(({ label, value, sub, asOf, href }, i) => ( + {stats.map(({ icon: Icon, label, value, sub, asOf, href }, i) => ( -

    - {value} -

    -

    - {label} -

    - {sub && ( -

    - {sub} +

    + + {label} +
    +

    + {value}

    - )} -

    - {asOf ? `as of ${asOf}` : "no date"} -

    + {sub &&

    {sub}

    } +

    + {asOf ? `as of ${asOf}` : "no date"} +

    +
    ))}
    diff --git a/client/src/components/home/hero-map.tsx b/client/src/components/home/hero-map.tsx deleted file mode 100644 index da2ecf7..0000000 --- a/client/src/components/home/hero-map.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { geoAlbers, geoPath } from "d3-geo"; -import type { FeatureCollection } from "geojson"; -import statesGeoRaw from "@/data/us-states.geo.json"; - -// Faded cluster map behind the hero. Same projection and same dot semantics as -// the social card (server/og-card.ts), so the landing page and the images that -// go out on X read as one system. -// -// geoAlbers, not geoAlbersUsa: the composite projection draws its Alaska and -// Hawaii inset frames over a lower-48-only feature set. We show the lower 48, -// so the plain conic is the honest choice. - -interface Cluster { - status: string; - plannedPowerMW: number | null; - location: { lat: number; lng: number }; -} - -const MAP_W = 760; -const MAP_H = 470; -const SKIP = ["Alaska", "Hawaii", "Puerto Rico"]; - -const STATES = statesGeoRaw as unknown as FeatureCollection; -const CONTINENTAL: FeatureCollection = { - type: "FeatureCollection", - features: STATES.features.filter( - (f) => !SKIP.includes((f.properties as { name?: string })?.name ?? ""), - ), -}; - -const PROJECTION = geoAlbers().fitSize([MAP_W, MAP_H], CONTINENTAL); -const STATE_PATHS = CONTINENTAL.features.map((f) => geoPath(PROJECTION)(f) ?? "").filter(Boolean); - -const STATUS_OPACITY: Record = { - operational: 1, - construction: 0.7, - announced: 0.38, -}; - -function radius(mw: number | null): number { - const v = mw ?? 0; - if (v >= 1500) return 6.5; - if (v >= 600) return 5; - if (v >= 200) return 3.8; - return 3; -} - -export function HeroMap() { - const { data } = useQuery({ queryKey: ["/api/clusters"] }); - - const dots = useMemo(() => { - if (!data) return []; - return data - .map((c) => { - if (typeof c.location?.lat !== "number" || typeof c.location?.lng !== "number") return null; - const p = PROJECTION([c.location.lng, c.location.lat]); - if (!p) return null; - const [x, y] = p; - if (x < 0 || x > MAP_W || y < 0 || y > MAP_H) return null; - return { x, y, r: radius(c.plannedPowerMW), o: STATUS_OPACITY[c.status] ?? 0.5 }; - }) - .filter(Boolean) as { x: number; y: number; r: number; o: number }[]; - }, [data]); - - return ( - - - {STATE_PATHS.map((d, i) => ( - - ))} - - - {dots.map((d, i) => ( - - ))} - - - ); -} diff --git a/client/src/styles/anchor.css b/client/src/styles/anchor.css index faff768..9bb7265 100644 --- a/client/src/styles/anchor.css +++ b/client/src/styles/anchor.css @@ -96,34 +96,23 @@ /* ─── Hero faded power map decoration ──────────────────────────────────── */ -/* Sits in the right half, clear of the headline column, so it can carry real - contrast instead of hiding at decoration opacity. The cluster dots are the - point: they are the same 235 records the ledger below counts. */ .gt-hero-map { position: absolute; - right: 0; + right: -8%; top: 50%; transform: translateY(-50%); - width: 56vw; - max-width: 740px; + width: 62vw; + max-width: 720px; height: auto; - opacity: 0.5; + opacity: 0.085; pointer-events: none; z-index: 0; + filter: saturate(0.7) contrast(1.1); animation: gt-hero-map-breathe 18s ease-in-out infinite; } @keyframes gt-hero-map-breathe { - 0%, 100% { opacity: 0.42; transform: translateY(-50%) scale(1); } - 50% { opacity: 0.56; transform: translateY(-50%) scale(1.012); } -} - -/* Below the lg breakpoint the headline reflows over the map's column, so it - drops back to a faint texture rather than competing with the type. */ -@media (max-width: 1023px) { - .gt-hero-map { opacity: 0.14; animation: none; } -} -@media (prefers-reduced-motion: reduce) { - .gt-hero-map { animation: none; } + 0%, 100% { opacity: 0.06; transform: translateY(-50%) scale(1); } + 50% { opacity: 0.11; transform: translateY(-50%) scale(1.015); } } /* ─── Animated wordmark ────────────────────────────────────────────────── */ @@ -165,40 +154,26 @@ @keyframes gt-caret { to { opacity: 0; } } @keyframes gt-caret-fade { to { opacity: 0; } } -/* ─── Hero masthead ────────────────────────────────────────────────────── */ - -/* The wordmark drops to reading size on the hero so the sentence leads. The - nav carries the brand persistently, so a full-bleed logo here would spend - the best real estate on something the visitor can already see. */ -.gt-hero-masthead .gt-wordmark { - font-size: clamp(28px, 3.2vw, 40px); - letter-spacing: -0.03em; -} - /* ─── Tagline ──────────────────────────────────────────────────────────── */ -/* Sized to lead the hero. The emphasis span carries the differentiator - ("sourced numbers") in the editorial serif, which is the one place the - marketing voice allows it. */ .gt-tagline-primary { font-family: var(--mkt-display); - font-size: clamp(32px, 4.5vw, 58px); - font-weight: 600; - letter-spacing: -0.028em; - line-height: 1.1; + font-size: clamp(24px, 2.7vw, 36px); + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.2; color: var(--mkt-ink); - margin: 0; - text-wrap: balance; + margin: 0 0 4px 0; } .gt-tagline-emphasis { font-family: var(--mkt-serif); font-style: italic; font-weight: 400; - font-size: 1.04em; - line-height: inherit; + font-size: clamp(28px, 3.2vw, 44px); + line-height: 1.15; color: var(--mkt-accent); margin: 0; - letter-spacing: -0.012em; + letter-spacing: -0.01em; } /* ─── Staggered reveal animations ──────────────────────────────────────── */