From f7a99dfe2ac6236c4d5339e894106ad811c757f1 Mon Sep 17 00:00:00 2001 From: madansap Date: Mon, 21 Sep 2026 17:10:39 +0545 Subject: [PATCH 1/3] feat(landing): add blueprint map backdrop to the hero Ports the hero refinements from the DevNepalFront prototype into the portal's own hero, taking only the visual layer. - NepalMap: Nepal drawn as a feathered field of blueprint grid, from the same Natural Earth 50m boundary the globe uses for its land mask. Pure SVG, no dependencies, sits behind the hero at -z-10. - sectionPadding / sectionGutter in lib/layout.ts: one definition of the gutter and vertical rhythm the landing sections already share by hand. The hero was the only section on py-16 rather than py-12 lg:py-16; it now lines up with the rest. - The globe's lower bound follows the viewport (clamp on 80svh) instead of a fixed 26rem, so it fills tall screens without overflowing short ones. Deliberately not taken from the prototype: its hero copy (which frames the portal as multi-ministry) and its CONTRIBUTORS naming for the globe waypoints. Both were corrected on main and stay corrected. --- .../modules/landing/hero-data-viz.tsx | 5 +- .../modules/landing/hero-section.tsx | 11 +++- .../components/modules/landing/nepal-map.tsx | 53 +++++++++++++++++++ apps/api/src/lib/layout.ts | 12 +++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/components/modules/landing/nepal-map.tsx create mode 100644 apps/api/src/lib/layout.ts 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..80b90d8 100644 --- a/apps/api/src/components/modules/landing/hero-data-viz.tsx +++ b/apps/api/src/components/modules/landing/hero-data-viz.tsx @@ -100,7 +100,10 @@ function HeroDataViz({ className }: { className?: string }) {
+ {/* 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..2a251f6 --- /dev/null +++ b/apps/api/src/components/modules/landing/nepal-map.tsx @@ -0,0 +1,53 @@ +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. + * Scales to cover its box (`preserveAspectRatio: slice`); colour is + * `currentColor`; strokes stay 1px at any size. + */ +function NepalMap({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + ); +} + +export { NepalMap }; 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`; From b4382c0fad77f91a21b61457996f46dd38c14c6d Mon Sep 17 00:00:00 2001 From: madansap Date: Mon, 21 Sep 2026 17:16:27 +0545 Subject: [PATCH 2/3] fix(landing): centre the hero globe in the stacked layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The globe chose its horizontal position from the shape of its own box: wide box, sit right of centre and leave room for the copy on the left. Below `lg` the hero stacks, and the globe's box is `h-64 w-full` — short and wide — so the same rule pushed the sphere to the right edge of a column that has no copy beside it. Box shape cannot tell the two compositions apart, so the caller decides now: `GlobeScene.setAlignment`, driven by a media query on the same `lg` breakpoint at which the hero switches to two columns. Above it nothing changes. Also capitalizes the second line of the hero heading, which is a sentence of its own. --- .../modules/landing/hero-data-viz.tsx | 11 ++++++++++ .../modules/landing/hero-globe/scene.ts | 22 ++++++++++++++++--- apps/api/src/lib/i18n.ts | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) 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 80b90d8..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(); }; }, []); diff --git a/apps/api/src/components/modules/landing/hero-globe/scene.ts b/apps/api/src/components/modules/landing/hero-globe/scene.ts index 1b17adf..d54b390 100644 --- a/apps/api/src/components/modules/landing/hero-globe/scene.ts +++ b/apps/api/src/components/modules/landing/hero-globe/scene.ts @@ -79,6 +79,12 @@ export type GlobeScene = { */ setPalette(accentCss: string, neutralCss: string): void; setReducedMotion(reduced: boolean): void; + /** + * Where the sphere sits in its box. `"right"` is the side-by-side hero, with + * the copy to its left; `"center"` is the stacked layout, where the globe has + * the full width to itself. + */ + setAlignment(alignment: "center" | "right"): void; /** Decode the land mask and drop in the halftone continents. */ loadLand(url: string, signal?: AbortSignal): Promise; 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; @@ -419,10 +426,13 @@ export function createGlobeScene(host: HTMLElement): GlobeScene | null { if (width === 0 || height === 0) return; 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. + // As big as the canvas allows. Which side of the box it sits on is the + // caller's to decide: the box stays short and wide in the stacked layout + // too, so its shape alone cannot tell the two compositions apart. sphereRadiusPx = short / 2 - margin; - const centerX = Math.max(width / 2, width - sphereRadiusPx - margin - RIGHT_INSET); + 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 @@ -558,6 +568,12 @@ export function createGlobeScene(host: HTMLElement): GlobeScene | null { setReducedMotion: (value) => { reduced = value; }, + setAlignment: (value) => { + const next = value === "right"; + if (next === alignRight) return; + alignRight = next; + resize(); + }, loadLand, dispose, }; 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", From 6cebfc62b216b7be1fad5d796f08541048a58ef9 Mon Sep 17 00:00:00 2001 From: madansap Date: Mon, 21 Sep 2026 20:26:56 +0545 Subject: [PATCH 3/3] fix(landing): repaint on alignment change, and stop cropping the map Three review findings. The globe's alignment setter ran the full resize(), which ends in renderer.setSize() and so clears the drawing buffer, but did not paint the frame that every other resize() call site pairs with it. Nothing was visibly wrong only because crossing `lg` also changes the canvas box, so the ResizeObserver repainted a moment later; with the hero scrolled out of view the frame loop is stopped and the canvas would have stayed blank. Camera framing is now its own step, applyViewOffset(), which touches no GPU buffer: an alignment change costs a projection matrix instead of a reallocated canvas, and it paints itself. NepalMap covered its box, so once the hero stacked and the box went narrow and tall, three quarters of the map's width was cropped and what remained was an unreadable band of grid rather than Nepal. It fits the box now. The hero heading's second line was capitalized last commit; the page title and description still carried the lower-case form. --- apps/api/src/app/layout.tsx | 4 +- .../modules/landing/hero-globe/scene.ts | 46 +++++++++++++++---- .../components/modules/landing/nepal-map.tsx | 8 ++-- 3 files changed, 44 insertions(+), 14 deletions(-) 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-globe/scene.ts b/apps/api/src/components/modules/landing/hero-globe/scene.ts index d54b390..2164efd 100644 --- a/apps/api/src/components/modules/landing/hero-globe/scene.ts +++ b/apps/api/src/components/modules/landing/hero-globe/scene.ts @@ -421,15 +421,28 @@ 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. Which side of the box it sits on is the - // caller's to decide: the box stays short and wide in the stacked layout - // too, so its shape alone cannot tell the two compositions apart. - sphereRadiusPx = short / 2 - margin; + 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; @@ -446,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(); @@ -569,10 +590,17 @@ export function createGlobeScene(host: HTMLElement): GlobeScene | null { reduced = value; }, setAlignment: (value) => { + if (disposed) return; const next = value === "right"; if (next === alignRight) return; alignRight = next; - resize(); + 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/nepal-map.tsx b/apps/api/src/components/modules/landing/nepal-map.tsx index 2a251f6..d248534 100644 --- a/apps/api/src/components/modules/landing/nepal-map.tsx +++ b/apps/api/src/components/modules/landing/nepal-map.tsx @@ -5,14 +5,16 @@ import { cn } from "@/lib/utils"; * 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. - * Scales to cover its box (`preserveAspectRatio: slice`); colour is - * `currentColor`; strokes stay 1px at any size. + * 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 (