diff --git a/apps/api/src/app/layout.tsx b/apps/api/src/app/layout.tsx index 933ef41..b9a002f 100644 --- a/apps/api/src/app/layout.tsx +++ b/apps/api/src/app/layout.tsx @@ -10,11 +10,11 @@ const fontMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" }); export const metadata: Metadata = { title: { - default: "Dev Nepal — public technology, built in public", + default: "Dev Nepal — Public technology, Built in public", template: "%s · Dev Nepal", }, description: - "Public technology, built in public — one project, its open issues, and the people contributing to it.", + "Public technology, Built in public — one project, its open issues, and the people contributing to it.", icons: { icon: [ { diff --git a/apps/api/src/components/modules/landing/hero-data-viz.tsx b/apps/api/src/components/modules/landing/hero-data-viz.tsx index 6c7f40c..97299ed 100644 --- a/apps/api/src/components/modules/landing/hero-data-viz.tsx +++ b/apps/api/src/components/modules/landing/hero-data-viz.tsx @@ -6,6 +6,8 @@ import { cn } from "@/lib/utils"; import { createGlobeScene } from "./hero-globe/scene"; const LAND_MASK_URL = "/hero/land-mask.png"; +/** Tailwind's `lg`, the width at which the hero switches to its two-column layout. */ +const LG_BREAKPOINT = "64rem"; function HeroDataViz({ className }: { className?: string }) { const hostRef = useRef(null); @@ -36,6 +38,14 @@ function HeroDataViz({ className }: { className?: string }) { syncMotion(); reducedMotion.addEventListener("change", syncMotion); + // The hero puts the copy beside the globe from `lg` up and stacks them + // below it. Stacked, the globe owns the full width and belongs in the + // middle of it. + const sideBySide = window.matchMedia(`(min-width: ${LG_BREAKPOINT})`); + const syncAlignment = () => globe.setAlignment(sideBySide.matches ? "right" : "center"); + syncAlignment(); + sideBySide.addEventListener("change", syncAlignment); + const abort = new AbortController(); globe.loadLand(LAND_MASK_URL, abort.signal).catch((error: unknown) => { if (error instanceof DOMException && error.name === "AbortError") return; @@ -92,6 +102,7 @@ function HeroDataViz({ className }: { className?: string }) { resize.disconnect(); theme.disconnect(); reducedMotion.removeEventListener("change", syncMotion); + sideBySide.removeEventListener("change", syncAlignment); globe.dispose(); }; }, []); @@ -100,7 +111,10 @@ function HeroDataViz({ className }: { className?: string }) {
; dispose(): void; @@ -335,6 +341,7 @@ export function createGlobeScene(host: HTMLElement): GlobeScene | null { const baseYaw = -THREE.MathUtils.degToRad(HUB.lon) + HUB_YAW; let time = 0; let reduced = false; + let alignRight = true; let disposed = false; let dots: THREE.InstancedMesh | null = null; let sphereRadiusPx = 200; @@ -414,15 +421,31 @@ export function createGlobeScene(host: HTMLElement): GlobeScene | null { } }; - const resize = () => { + type Box = { width: number; height: number; short: number; margin: number }; + + /** One layout read, plus the arc margin every fit derives from it. */ + const measure = (): Box | null => { const { width, height } = host.getBoundingClientRect(); - if (width === 0 || height === 0) return; + if (width === 0 || height === 0) return null; const short = Math.min(width, height); - const margin = THREE.MathUtils.clamp(short * ARC_MARGIN.ratio, ARC_MARGIN.min, ARC_MARGIN.max); - // As big as the canvas allows. Sits right of centre in wide boxes (the - // hero copy is on the left) and centres itself once the box is narrow. - sphereRadiusPx = short / 2 - margin; - const centerX = Math.max(width / 2, width - sphereRadiusPx - margin - RIGHT_INSET); + return { + width, + height, + short, + margin: THREE.MathUtils.clamp(short * ARC_MARGIN.ratio, ARC_MARGIN.min, ARC_MARGIN.max), + }; + }; + + /** + * Camera framing alone. Where the sphere sits on the x axis is the caller's + * to decide: the box stays short and wide in the stacked layout too, so its + * shape cannot tell the two compositions apart. Touches no GPU buffer, so an + * alignment change costs a projection matrix rather than a reallocated canvas. + */ + const applyViewOffset = ({ width, height, margin }: Box) => { + const centerX = alignRight + ? Math.max(width / 2, width - sphereRadiusPx - margin - RIGHT_INSET) + : width / 2; const centerY = height / 2; // Size of a square virtual viewport in which an on-axis sphere has radius // `sphereRadiusPx`; the canvas is a window into it, so the sphere is a true @@ -436,7 +459,15 @@ export function createGlobeScene(host: HTMLElement): GlobeScene | null { camera.aspect = 1; camera.setViewOffset(full, full, full / 2 - centerX, full / 2 - centerY, width, height); camera.updateProjectionMatrix(); - renderer.setSize(width, height, false); + }; + + const resize = () => { + const box = measure(); + if (box === null) return; + // As big as the canvas allows. + sphereRadiusPx = box.short / 2 - box.margin; + applyViewOffset(box); + renderer.setSize(box.width, box.height, false); applyScreenSizes(); }; resize(); @@ -558,6 +589,19 @@ export function createGlobeScene(host: HTMLElement): GlobeScene | null { setReducedMotion: (value) => { reduced = value; }, + setAlignment: (value) => { + if (disposed) return; + const next = value === "right"; + if (next === alignRight) return; + alignRight = next; + const box = measure(); + // A flip that lands on a zero-sized box is picked up by the next resize. + if (box === null) return; + // Only the framing changed, so the drawing buffer survives — but nothing + // else will repaint it if the frame loop is stopped (hero off-screen). + applyViewOffset(box); + frame(0); + }, loadLand, dispose, }; diff --git a/apps/api/src/components/modules/landing/hero-section.tsx b/apps/api/src/components/modules/landing/hero-section.tsx index 3e08e31..453483f 100644 --- a/apps/api/src/components/modules/landing/hero-section.tsx +++ b/apps/api/src/components/modules/landing/hero-section.tsx @@ -3,15 +3,24 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import type { Dictionary, Locale } from "@/lib/i18n"; import { localePath } from "@/lib/i18n"; +import { sectionPadding } from "@/lib/layout"; +import { cn } from "@/lib/utils"; import { HeroDataViz } from "./hero-data-viz"; import { HeroSignIn } from "./hero-sign-in"; +import { NepalMap } from "./nepal-map"; export function HeroSection({ dict, locale }: { dict: Dictionary; locale: Locale }) { return (
+ {/* Nepal as a feathered field of blueprint grid across the hero — static. */} + +

diff --git a/apps/api/src/components/modules/landing/nepal-map.tsx b/apps/api/src/components/modules/landing/nepal-map.tsx new file mode 100644 index 0000000..d248534 --- /dev/null +++ b/apps/api/src/components/modules/landing/nepal-map.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; + +/** + * Nepal as a field of blueprint grid: Natural Earth 50m boundary (same source + * as the globe's land mask), equirectangular with a cos(lat) width + * correction. Hairline cells are masked by a Gaussian-blurred copy of the + * shape, so the grid dissolves before it reaches the border — no outline. + * Fits inside its box (`preserveAspectRatio: meet`) so the country stays + * recognisable at every aspect ratio — `slice` cropped away three quarters of + * its width once the hero stacked. Colour is `currentColor`; strokes stay 1px + * at any size. + */ +function NepalMap({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + ); +} + +export { NepalMap }; diff --git a/apps/api/src/lib/i18n.ts b/apps/api/src/lib/i18n.ts index c98dc4c..55d12b7 100644 --- a/apps/api/src/lib/i18n.ts +++ b/apps/api/src/lib/i18n.ts @@ -48,7 +48,7 @@ const en = { home: { tag: "Open public work", titleLine1: "Public technology,", - titleLine2: "built in public.", + titleLine2: "Built in public.", lead: "One project publishes the technology work it needs help with, and anyone can contribute. The work happens in its public repository on GitHub, so no portal account is needed to start.", browseIssues: "Browse open issues", browseProject: "Open the project", diff --git a/apps/api/src/lib/layout.ts b/apps/api/src/lib/layout.ts new file mode 100644 index 0000000..f64a68a --- /dev/null +++ b/apps/api/src/lib/layout.ts @@ -0,0 +1,12 @@ +/** + * The one horizontal gutter every band of the page uses — sections, navbar, + * masthead and footer — so their left and right edges line up exactly. + * 16 / 32 / 64px. + */ +export const sectionGutter = "px-4 sm:px-8 lg:px-16"; + +/** + * Gutter plus the vertical rhythm shared by every landing-page section: + * 48px rising to 64px at `lg`. Apply as `cn(sectionPadding, "…")`. + */ +export const sectionPadding = `${sectionGutter} py-12 lg:py-16`;