Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
16 changes: 15 additions & 1 deletion apps/api/src/components/modules/landing/hero-data-viz.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -92,6 +102,7 @@ function HeroDataViz({ className }: { className?: string }) {
resize.disconnect();
theme.disconnect();
reducedMotion.removeEventListener("change", syncMotion);
sideBySide.removeEventListener("change", syncAlignment);
globe.dispose();
};
}, []);
Expand All @@ -100,7 +111,10 @@ function HeroDataViz({ className }: { className?: string }) {
<div
ref={hostRef}
data-slot="hero-data-viz"
className={cn("relative isolate h-64 min-w-0 sm:h-80 lg:h-full lg:min-h-[26rem]", className)}
className={cn(
"relative isolate h-64 min-w-0 sm:h-80 lg:h-full lg:min-h-[clamp(26rem,calc(80svh-15rem),44rem)]",
className,
)}
>
<div
ref={canvasHostRef}
Expand Down
60 changes: 52 additions & 8 deletions apps/api/src/components/modules/landing/hero-globe/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
dispose(): void;
Expand Down Expand Up @@ -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<THREE.CircleGeometry, THREE.ShaderMaterial> | null = null;
let sphereRadiusPx = 200;
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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,
};
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/components/modules/landing/hero-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section
data-slot="home-hero"
className="flex flex-col gap-12 px-4 py-16 sm:px-8 lg:flex-row lg:items-stretch lg:justify-between lg:px-16"
className={cn(
sectionPadding,
"relative isolate flex flex-col gap-12 lg:flex-row lg:items-stretch lg:justify-between",
)}
>
{/* Nepal as a feathered field of blueprint grid across the hero — static. */}
<NepalMap className="pointer-events-none absolute inset-0 -z-10 mask-[radial-gradient(ellipse_at_center,black_40%,transparent_92%)] text-border" />

<div className="flex max-w-xl flex-col justify-center gap-6">
<div className="flex flex-col gap-5">
<h1 className="text-5xl font-bold tracking-[-0.02em] text-foreground lg:text-6xl">
Expand Down
55 changes: 55 additions & 0 deletions apps/api/src/components/modules/landing/nepal-map.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
viewBox="0 0 880 497"
preserveAspectRatio="xMidYMid meet"
aria-hidden
focusable="false"
className={cn("block h-full w-full", className)}
>
<defs>
<pattern id="nepal-map-grid" width="32" height="32" patternUnits="userSpaceOnUse">
<path
d="M32 0H0V32"
fill="none"
stroke="currentColor"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
</pattern>
<filter
id="nepal-map-feather"
x="-10%"
y="-10%"
width="120%"
height="120%"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation="16" />
</filter>
<mask id="nepal-map-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="880" height="497">
<path
d="M874.1 310.4L878.4 313.8L879.2 319.2L878.4 325.4L873.8 338.4L869.8 347.9L864.8 367.3L860.5 401.2L861.6 407.2L874.5 426.6L879.2 441.4L880.0 451.7L874.5 468.8L868.3 488.1L865.2 492.4L861.6 493.9L846.0 487.2L835.1 488.1L822.6 491.9L809.3 491.1L798.8 488.9L785.1 496.6L771.8 492.4L763.6 487.6L757.7 474.2L755.4 472.4L728.1 486.6L721.4 487.4L704.2 479.9L690.2 472.4L685.1 470.3L671.4 467.3L659.3 465.6L646.0 460.9L629.6 467.1L623.0 466.5L616.7 462.2L613.6 453.2L612.8 444.6L607.0 438.8L598.4 437.6L586.3 442.7L568.7 449.8L562.8 448.5L557.8 446.5L555.8 444.6L553.1 436.7L550.3 434.8L546.4 434.6L539.0 432.6L530.0 426.8L502.7 412.7L499.2 406.5L499.2 392.6L498.0 386.8L494.5 380.8L480.4 374.8L453.1 365.0L438.2 357.1L430.8 360.7L417.2 364.1L409.7 371.2L400.7 368.8L379.7 361.6L368.3 360.3L361.3 362.8L359.7 367.1L351.1 372.0L342.9 368.2L326.9 362.8L312.5 360.1L291.0 353.6L288.6 344.2L284.7 334.6L279.7 333.1L260.1 334.8L242.6 324.5L223.4 311.0L215.2 306.8L210.1 305.0L205.5 306.8L200.0 310.0L195.3 310.8L185.1 305.0L171.9 296.9L155.5 286.8L136.7 272.7L128.9 264.8L125.4 258.8L121.1 253.0L104.7 243.8L91.4 236.5L75.8 227.8L73.0 226.1L67.2 220.9L58.2 214.3L50.4 212.4L48.0 216.0L46.5 219.8L39.8 219.0L29.7 212.1L18.7 205.3L10.5 198.7L2.0 192.0L0.0 187.1L3.5 171.9L8.6 158.6L12.9 155.8L19.5 147.1L22.3 131.9L21.9 118.8L28.9 100.6L37.9 81.1L53.9 60.2L60.9 53.3L68.7 48.6L83.2 33.2L86.3 30.6L93.0 26.5L99.2 25.7L103.9 27.6L109.0 35.5L114.8 43.2L121.9 42.8L130.5 36.4L148.0 6.2L172.6 0.0L195.7 3.2L216.0 7.5L222.2 17.6L226.2 28.3L228.5 33.6L235.1 39.8L264.0 55.0L280.8 68.7L303.9 86.9L321.5 94.8L336.7 95.5L345.3 102.8L358.2 116.9L369.5 133.4L383.2 148.6L392.5 147.9L405.4 143.0L421.4 136.6L430.8 139.8L439.4 144.1L442.1 151.8L447.2 166.5L453.1 182.0L462.1 187.3L473.0 195.2L478.9 201.7L498.8 213.0L501.9 217.7L505.8 220.9L510.9 223.1L514.8 225.4L521.0 226.1L544.5 219.2L550.3 220.1L554.2 221.3L554.2 223.9L550.0 234.6L546.4 248.5L550.0 255.4L559.7 258.4L581.2 260.3L610.5 260.3L619.1 267.2L628.1 277.6L636.7 295.6L640.2 303.3L644.9 305.5L652.3 302.5L653.5 295.0L653.8 284.1L660.1 280.2L664.4 283.2L669.1 291.8L680.8 299.5L689.8 303.1L698.0 301.8L701.5 298.8L705.4 283.9L712.0 281.7L720.2 282.8L723.4 285.8L726.9 291.8L736.7 294.6L746.8 298.4L756.2 303.1L769.1 314.2L785.5 316.4L804.2 316.2L814.0 316.4L821.4 317.2L828.1 316.4L847.2 308.5L855.0 307.8L864.8 308.9L874.1 310.4Z"
fill="white"
filter="url(#nepal-map-feather)"
/>
</mask>
</defs>
<rect width="880" height="497" fill="url(#nepal-map-grid)" mask="url(#nepal-map-mask)" />
</svg>
);
}

export { NepalMap };
2 changes: 1 addition & 1 deletion apps/api/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/lib/layout.ts
Original file line number Diff line number Diff line change
@@ -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`;
Loading