The marketing LP is a single Astro component with a global <style> block, driven
by data files for theme/config, copy, and images. Design reference:
design_handoff_navara_landing_page.
Files & the three-way split
docs/src/components/LandingPage.astro— the whole page (markup +<style is:global>+ reveal/showcase scripts). Rendered per locale by pages that do<LandingPage locale="..." />.docs/src/components/LpHeader.astro— the site header, shared with the docs site and the brand assets page:variant="lp"(fixed/transparent, frosts vialp-header-solidtoggled by LandingPage's script, carries the?colorswitcher markup — its behavior script stays in LandingPage),variant="site"(the same fixed header, frosted from the start, for other site-root pages; passsitePathso the language switcher lands on the same page) andvariant="docs"(StarlightHeaderoverride wraps it, inside Starlight's header shell, with asearchslot; its LP vars are pinned from--nv-navy/--nv-creamset indocs/src/styles/theme.css). Header markup/styles/lang-select script live here, not in LandingPage. The brand logo always links to the LP (/or/<locale>/) in every variant; on the LP the link itself isvisibility: hiddenuntillp-header-solid, so it stays out of the tab order while the hero lockup carries the branding. The examples gallery mirrors the same design in React (web/navara_three/example/components/SiteHeader.tsx, logo linking to the LP viasiteUrl(lang)).docs/src/components/LpFooter.astro— the site footer (Docs / GitHub / Discord / Brand Assets + license credit), shared by the LP and the brand assets page. Transparent and unpositioned by itself: the LP lays it over the closing globe still (.lp-close .lp-footer { position: absolute }in LandingPage), the brand assets page sits it on its primary canvas under a--line-darkhairline. The examples get the same links (and hairline) fromweb/navara_three/example/components/SiteFooter.tsx; the docs site links to the brand page from the guide instead (guides/Community/brand-assets.md, en + ja), not from its footer.docs/src/components/lp-shared.ts— what the LP, brand assets page, header and footer all need:ROOT_LOCALE/DOCS_LOCALES,DOCS_BASE, thelp-localesloader (getLpLocale),docsHref(locale, href)(docs-internal links: base + locale segment),siteHref(locale, path)(site-root pages that exist per locale: the LP is"", the brand page"brand-assets/") andlpRootStyle(the theme as the<html>inline style).docs/src/styles/lp-base.cssholds the theme-derived custom properties (--ink*,--on-dark*,--line*,--on-band*,--lp-safe-*), thebodycanvas,.lp alink defaults and.lp-container; both pages import it, the LP's own<style is:global>starts after it.docs/src/data/lp.json— theme colors, image slots, external links (not copy). Colors:primary/accent/sub/maplibre(fed as CSS vars--pri/--acc/--sub/--ml; the derived--ink*/--on-dark*/--line*vars come from these).themeVariantslists candidate primary/sub/accent sets (verbatim from the design palette sheet) for the header preview switcher, revealed only by?colorin the URL (?color=<name>preselects one). Switching rewrites--pri/--sub/--accat runtime; roles a candidate can't fill without losing contrast fall back to plain black/white via--pri-ink(always-dark ink/scrim source, =--prinormally),--on-band(text on--pribands) and--on-acc— and a synthetic resize re-samples the adaptive gallery inks.images.<slot>.srcis a repo-root-relative path intodocs/src/assetsorweb/navara_three/example/public/screenshots, resolved byresolveImage.docs/src/data/lp-locales/<locale>.json— all copy, per locale.enisROOT_LOCALE(served at/, others under/<locale>/). Image alt text lives here, inimageAltskeyed by the lp.json slot id (a missing alt throws at build).
To add a locale: add its lp-locales/<code>.json and a page rendering <LandingPage locale="code" />.
Conventions
- Never hardcode copy or colors in the component — copy → locale JSON, theme →
lp.json. Markup pulls both viat.*and the--pri/--acc/...vars. - Copy style: no dash decorations, no semicolons. LP copy (and alt text / meta strings) never uses em/en dashes,
-parentheticals, or;clause joins — write full sentences with commas, colons, or splits, and avoid stacked "and"s (prefer "from A and B to C and D, then …"). Sole exception: showcase tile names keep an ASCII-separator (they are the sites' official display names). The docs site follows the same rule — seedocs/guide/WRITING_RULES.md("Prose style"). meta.descriptionis the hero copy, compressed — the intro panel's first two sentences (definition + data range), without the third. The hero's third sentence is the extension ladder (declarative → plugins → custom representations), deliberately prefiguring the tier showcase below: keep it an escalation, never a restatement of sentence two. When the hero copy changes, re-derive the description.- Images go through slots: define in
lp.json, alt in every locale'simageAlts, reference with thesrc(id)/alt(id)helpers. Prefer AVIF for photos. - Reveal-on-scroll: mark elements
data-reveal(individually) or a containerdata-reveal-group(staggers children via--rd/--rd-base). Hidden states only exist underprefers-reduced-motion: no-preference, so reduced motion is a no-op. Don't reinvent entrance animation — reuse these hooks. Never putloading="lazy"images inside a reveal element: the reveal gates ondecode()of every image inside, which never resolves for a lazy image still outside the viewport (e.g. off to the side in a scroller/marquee) — the element stays invisible forever. - Showcase grid (
.lp-showcase-grid): the gallery strip's third grid (tutorial → example → showcase), not a section of its own: square tiles, three per row (keep the item count a multiple of three so the full-bleed rows stay even), built from theshowcase-*slots inlp.json(numeric order;href= the site's URL) — add/remove slots there plusimageAltsin every locale (the alt doubles as the hover title, so it's the site's display name). Screenshots live indocs/src/assets/showcases/<repo-name>.avif(PNG originals sit beside them, unreferenced so not bundled). The sticky side label crossfades TUTORIALS → EXAMPLES → SHOWCASES with one IntersectionObserver per grid, deepest intersecting grid wins (the example grid is still partly on screen while the label sits over the showcase grid). The label overlay carries no links ("See more" was dropped) and stayspointer-events: nonethroughout, so clicks fall through to the tile underneath..lp-gallery-side'stopclamps the sticky label's start so its BOTTOM first sits one bottom-gap above the first tile's bottom edge and never rides higher:top: calc(<.lp-tile-wide height> - var(--side-h) - <gap>), where--side-his the label block's height, measured by script (vertical text — depends on font and wording; re-measured on webfont load and resize). The tile-height and gap terms must stay in sync with.lp-tile-wideand the label's bottom offset at each breakpoint. Showcase tile credits live inSHOWCASE_ATTRIB(keyed by slot id, notTILE_SOURCES): they are extracted verbatim from each site's own Navara attribution UI (scrape.navara-attr-itemon the live site, drop the Navara self-credits, split rows that carry several links so each credit keeps one link) — re-extract when a site's data changes or a new site is added.GalleryTilehides the attribution icon while a tile's credit list is empty. - Theme-derived tints: use
color-mix(in oklab, var(--pri), white NN%)(matching the existing--ink*/--line*derivations) rather than raw hex, so a theme change inlp.jsonpropagates. - Deployment: the LP builds at
/docs/lp/(the Astro build usesbase: "/docs") andscripts/assemble-site.mjsrelocates it to the site root of https://navara.world/ (/and/ja/). Inastro dev/previewit is served at/docs/lp/. Pre-release: it carries<meta name="robots" content="noindex,nofollow">; leave that until release. - Code snippets (
.lp-helloHELLO WORLD section): the snippet is language-agnostic, so it lives in the component frontmatter (helloCode), not the locale JSONs — explanations stay in localized copy, code comments in English. Rendered with Astro's<Code theme="css-variables">; the--astro-code-*vars on.lp-hello-codemap Shiki onto the LP palette so a theme change propagates. Every API call must be real (it's a TileJSON-based variant of the docs' Getting Started example — the TileJSON document supplies tile URL, zoom range, and attribution); verify against the docs before editing. - Phone gutters are 20px for every band — keep section padding uniform so
headings line up down the page; if a visual needs the extra width (the
.lp-archdiagram), let it break out with negative margins instead of shrinking the section's padding. - Stills bake in the example's own attribution button (bottom-right). Where
the LP overlays its own
ImageAttributionthe baked one must be cropped away —.lp-hello-visual imgis oversized (height: 106%) so the frame clips that band at every aspect ratio; don't crop the shared asset, docs pages use it. - MapLibre section is parked behind
SHOW_MAPLIBRE = falsein the component frontmatter (copy and image slots kept); flip to true to bring it back. .lp-archline-art SVGs are inlined (?raw+ thearchSvghelper, classes land on the<svg>so the img-era sizing rules still apply) and re-themed by CSS attribute selectors keyed to their baked hexes (#F4F3EF→--sub,#090C11→--pri-ink,#C9C9C9graticule). Re-exporting those SVGs must keep exactly those values, or the selectors inLandingPage.astromust be updated with them.
Reusable components
docs/src/components/ImageAttribution.astro— data-attribution overlay: an info icon that reveals a per-source credit list on hover/focus (no plate, never overflows). Props:items: string[],label,corner(br/bl/tr/tl),class. Drop inside anyposition: relativeimage wrapper. Use it for any credited image, not just the LP. Source the credit strings fromweb/navara_three/example/helpers/constants.tsattributionfields (+ a basemap tilejson's own credit).
Brand logo & favicon
- Logo files live in
docs/public/logo/{svg,png}/{black,white}/<ink>_Navara_{Horizontal,Vertical}_logo.<ext>(served at/docs/logo/..., also the downloads on the brand assets page), mirrored verbatim inweb/navara_three/example/public/logo/. The SVGs are the delivered Illustrator exports with the<metadata>(<i:aipgf>round-trip block, ~97% of the file), the<defs><style>block, the invisible artboard<rect>and the Illustrator root attributes stripped — white variants getfill="#fff"inline, black ones carry no fill. Their viewBox is kept as delivered (it includes the clear space around the mark, matching the PNGs). If the assets are re-delivered, redo that cleanup (a Playwright script works: strip, thengetBBox()the result) and re-measure the tight boxes below. - In-page uses inline the black variants and crop them to the mark:
docs/src/components/brand-logo.ts(logoSvg("horizontal" | "vertical", className)) swaps the viewBox for the measured tight content box (59.5 58.9 681.5 118.7horizontal,57.3 57.1 335.9 205vertical) and addsrole="img"; since the paths carry nofill, CSSfill: currentColorre-inks them per surface..lp-hero-logois the vertical lockup centered over the hero video,.lp-brandthe horizontal one in the header (inside the.lp-brand-homelink). The examples header instead points an<img>at the delivered white horizontal SVG (web/navara_three/example/components/SiteHeader.tsx) and cancels the clear space with a negative margin, since that header is always on navy..lp-video-blockedclears the hero lockup so the fallback play button gets the center. - The favicon is the delivered white-bird-on-black-square mark, present as
favicon.png(72×72, the referenced one) andfavicon.svg(cleaned like the logos; kept but unreferenced) in bothdocs/public/andweb/.../example/public/. Three places point at/favicon.png: the LP<head>, Starlight'sfaviconoption indocs/astro.config.mjs, and the example'stemplate.html(injected into every generated page).
Brand assets page
docs/src/components/BrandAssets.astro (rendered by docs/src/pages/{,ja/,az/}brand-assets.astro, one per LP locale) is the LP-styled page behind the "Brand Assets" footer links, modeled on maplibre.org/brand-assets, on a single --pri canvas (every text role is an on-band one, --sec/--acc are set inline from brand.palette so links rest in the brand secondary and hover to the accent, cards take the --line-dark hairline): a centered title block under the variant="site" header (no eyebrow labels anywhere on this page), then one Logos section with the horizontal and vertical lockups as two unlabeled tile rows (the locale's lockup names are alt text only), each on two surfaces (primary / white, no secondary tile, SVG + PNG download links per tile), the two typefaces as specimen cards, and the palette swatches captioned with name and hex only (caption ink picked from the swatch luminance), closing with LpFooter under a hairline.
- Data split as on the LP: the palette (
primary #143c6e,secondary #c8ebfa,accent #faf0c8, verbatim from the brand color sheet) and the font list live inlp.jsonunderbrand; every string (meta, headings, format labels, color names, font samples) lives in each locale'sbrandblock, the footer label infooter.brandAssets. The page throws on a missing name/sample like the LP does on a missing alt. No captions or notes under headings or tiles (no "On primary", no "for narrow placements"): the visuals speak, only the lead under the title stays. Note the LP's owncolors.accent(teal) is a separate, older choice — the brand page shows the brand palette but is themed with the LP theme like every other page. - Deployment: built at
/docs/brand-assets/(and/docs/<locale>/brand-assets/);scripts/assemble-site.mjsrelocates it to/brand-assets/and/<locale>/brand-assets/(itsSITE_PAGESlist — add new site-root pages there). Links to it usesiteHref(locale, links.brandAssets)/siteUrl(lang, "brand-assets/"), i.e. production paths (inastro dev/previewopen/docs/brand-assets/directly). - Styles are page-scoped under
.lp-ba-*(the LP's section CSS is not shared, onlylp-base.css); the eyebrow/heading pairing repeats the LP's at one step smaller (h1 56/44/36px, h2 40/34/30px across the 960/640 breakpoints), gutters are the LP's 48/28/20px.
Build & verify
cd docs && pnpm build(whole docs site) — LP copy/image errors surface here (missing alt, missing image, unknown slot).- Verify interactive bits headlessly (Playwright against
pnpm preview): reveal-on-scroll, the tier showcase cross-fade + synced list/caption highlight, the attribution tooltip. Look at the screenshots — a clean build can still be visually wrong. - After Rust/WASM-adjacent changes elsewhere, also run repo-root
pnpm run build:example,format,lint.
Hero video
The hero is the looping promo video (assets in docs/public/promo/: desktop +
SP H.264 encodes, AVIF posters of the video's first frame; produced per the
navara-promo-video skill). Implementation lives in LandingPage.astro
(markup near the top, script mid-file, styles by .lp-hero). Hard-won
invariants — do not regress these:
- iOS never fires
loadeddata(or decodes a frame) until playback starts, so nothing may gate on it. The fade-in-then-play sequence primes instead: mutedplay()while the video is transparent, firstplayingevent → pause + rewind + fade the static first frame in over the poster →play()again ontransitionend(with a timeout fallback). - Autoplay is refused in iOS Low Power Mode and cannot be forced; a
tap-initiated
play()still works — a centered play button appears via thelp-video-blockedclass whenever the video is stopped unexpectedly. - Exiting fullscreen pauses the video, and iOS can pause again after a
successful programmatic resume (teardown race) — so the resume path retries
and then verifies
pausedbefore falling back to the play button. The deliberate priming pause is excluded from that watchdog (fadeRestartPending). - SP (≤640px) picks the smaller encode via
matchMedia, crops center withobject-fit: cover, and shows a bottom-right fullscreen button (webkitEnterFullscreenfallback for iPhones withoutvideo.requestFullscreen). - Reduced motion keeps the AVIF poster; no video src is ever set.
- Video credits are overlaid, not baked, and scene-synced: the credit line
baked into the video's bottom ~3.3% is force-cropped at every aspect ratio —
poster and video are oversized (
height: 104.5%, clipped by the hero'soverflow: hidden; cover alone only crops vertically on wide windows, so a 16:10 window would otherwise show the baked line under the overlay) — and the hero renders oneImageAttributionblock per scene (heroScenesin the frontmatter — credits +endboundary in seconds, read off the published cut's frames) and atimeupdatehandler swaps them with playback; past the last boundary (the loop-closing still = the dive's first frame) it wraps to the first block, which is also the no-JS/reduced-motion state matching the poster. The Google logo rides only the Photorealistic-3D-Tiles scenes, whose credit list transcribes the per-tile copyright line baked into those frames (Google / Landsat / Copernicus / Data SIO… / Airbus for the current cut) — read it from the frames, not from the shot code, which only registers the static baseline. Re-cutting the promo means re-readingheroScenesboundaries and credits from the new take (extract 1 s frames with ffmpeg; zoom the bottom strip for the Google line). Generic.lp-hero imgCSS must stay scoped topicture img, or it swallows the overlay's logo. .lp-heromust keepoverflow: hidden. The opening animation (nv-hero-in) holds the full-viewport poster at scale ~1.045 for 3s; unclipped, that widens the document's scrollable area and Chromium keeps the stale horizontal scrollbar even after the animation ends (it only clears on the next relayout, e.g. scrolling into the reveal sections).- Verify on a real iPhone (
pnpm dev:docs --host), including Low Power Mode on/off — desktop Chromium allows muted autoplay everywhere and cannot reproduce any of the above.
Mobile viewport units and page ends
Desktop Chromium has no dynamic toolbar, so none of this reproduces there — these were all found on an iPhone.
- Covering elements use
lvh, content bands usesvh. The first scroll on iOS is spent collapsing the toolbar, which grows the viewport fromsvhtolvh: a sticky hero sized100svhuncovers a strip of page background under the video exactly while that happens..lp-herois100lvhand publishes--lp-toolbar-h: calc(100lvh - 100svh), which its bottom-anchored chrome (scroll cue, fullscreen button) adds back so it stays inside the smaller, toolbar-shown viewport it is first seen in.min-heightbands (.lp-hello,.lp-outro) keepsvh— they must fit the smaller viewport. - A pin line that must hug the bottom edge uses
dvh..lp-introsticks atcalc(100dvh - var(--lp-intro-h)): withsvhthe panel stops a collapsed toolbar's height short of the bottom and leaves a strip of bare video under it, withlvhit is cut off while the toolbar is out. Onlydvhfollows the viewport that is actually on screen. viewport-fit=cover+--lp-safe-*. The meta viewport opts into the full screen so the full-bleed media (hero video, closing globe still) reaches the physical edges on notched phones — without it iOS insets the layout viewport and the closing band's image stops short of the bottom.svh/lvhthen include the safe areas too, which is what makes thelvhhero cover the home-indicator strip. Everything that hugs an edge pays the inset back through--lp-safe-l/r/b(env(safe-area-inset-*, 0px)named on:root): the header and.lp-containerfor the landscape notch,.lp-intro,.lp-footerand the hero's bottom chrome for the home indicator. Because they are custom properties, a desktop browser can simulate a notch — append:root{--lp-safe-l:47px;--lp-safe-r:47px;--lp-safe-b:34px}to<body>(the LP's own<style is:global>lives there, so a<head>tag would lose the cascade).- The page can never paint iOS Safari's toolbars — only tint them.
viewport-fit=coverreaches the display's safe areas, not the browser chrome, so "extend the closing image under the URL bar" is not achievable; matching the color is. Safari tints its bars from<meta name="theme-color">(falling back to the page background), so both that meta and the canvas are the closing band's--outro-bgblack, and the bottom reads as one surface. body's background is the document canvas (htmlsets none), so it shows through the sub-pixel remainder of a fractional-height page, during overscroll, and as that toolbar tint. It is--outro-bg(black, hoisted to:root) — a light canvas showed as a ~1px white hairline under the closing band on iOS, and a--prione as a navy seam below it. Every band paints its own background (.lp-maincarries the light one).
References
- references/scene-tiers.md — the "4 API tiers, 1 engine" showcase (
.lp-api-stage: cross-fading stills behind a bottom-right editorial open-circle ring — Declarative core, Plugin/API/Shader on the ring): building the fixed-camera, multi-look capture page (web/navara_three/example/pages/lp-tiers/), the four look recipes, night FogLight tuning, real OSM street lamps, and the capture→AVIF→slots workflow. Read this before creating or re-shooting those stills.