diff --git a/.github/workflows/deploy-landing-pages.yml b/.github/workflows/deploy-landing-pages.yml new file mode 100644 index 0000000..1fde333 --- /dev/null +++ b/.github/workflows/deploy-landing-pages.yml @@ -0,0 +1,85 @@ +name: Deploy landing to GitHub Pages + +on: + pull_request: + paths: + - "apps/landing/**" + - "apps/web/public/**" + - "docs/assets/dashboard.png" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig.base.json" + - ".github/workflows/deploy-landing-pages.yml" + push: + branches: [main] + paths: + - "apps/landing/**" + - "apps/web/public/**" + - "docs/assets/dashboard.png" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig.base.json" + - ".github/workflows/deploy-landing-pages.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - name: Enable Corepack + run: corepack enable + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Check landing types + run: pnpm --filter @memorepo/landing typecheck + - name: Build landing + run: pnpm --filter @memorepo/landing build + - name: Run landing unit tests + run: pnpm --filter @memorepo/landing test:unit + - name: Install browsers for landing checks + run: pnpm --filter @memorepo/landing exec playwright install --with-deps chromium firefox webkit + - name: Run motion, responsive, accessibility, and interaction checks + run: pnpm --filter @memorepo/landing test:browser + - name: Upload GitHub Pages artifact + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: apps/landing/dist + + deploy: + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Configure GitHub Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.gitignore b/.gitignore index c6e7e11..ec4d021 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.astro/ .memorepo/ .tmp-memorepo-tests/ .env diff --git a/CHANGELOG.md b/CHANGELOG.md index c63a202..ea9fbed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.3.3] - 2026-07-22 + +### Added + +- Add the public MemoRepo landing page with product, architecture, quickstart, documentation, and operating-contract sections. +- Add accessible Motion animations with reduced-motion support and responsive browser coverage across Chromium, Firefox, and WebKit. + +### Changed + +- Refine landing-page navigation, card alignment, architecture connectors, dashboard presentation, and animation pacing across desktop, tablet, and mobile layouts. +- Publish the landing page through the GitHub Pages workflow after validation on the main branch. + ## [0.3.2] - 2026-07-20 ### Added diff --git a/apps/api/package.json b/apps/api/package.json index 56c5296..52816d1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@memorepo/api", - "version": "0.3.2", + "version": "0.3.3", "private": true, "type": "module", "bin": { diff --git a/apps/landing/README.md b/apps/landing/README.md new file mode 100644 index 0000000..a3ff7ca --- /dev/null +++ b/apps/landing/README.md @@ -0,0 +1,59 @@ +# MemoRepo landing + +Static Astro landing page for the public MemoRepo project site. The generated HTML contains the complete page content and is configured for the case-sensitive GitHub Pages path `/MemoRepo/`. + +## Local development + +From the repository root: + +```bash +pnpm install --frozen-lockfile +pnpm dev:landing +``` + +Open `http://localhost:4321/MemoRepo/` unless Astro reports a different port. + +## Validation and build + +```bash +pnpm --filter @memorepo/landing typecheck +pnpm --filter @memorepo/landing build +pnpm --filter @memorepo/landing test:unit +pnpm --filter @memorepo/landing test:browser +``` + +The browser smoke test expects a completed build and checks the full Motion journey, reduced-motion behavior, breakpoint boundaries, orientation changes, accessibility, keyboard interactions, console errors, horizontal overflow, and the JavaScript-disabled page across Chromium, Firefox, and WebKit. + +The production output is written to `apps/landing/dist`. + +## GitHub Pages + +The workflow at `.github/workflows/deploy-landing-pages.yml` validates pull requests and deploys successful `main` builds. + +Enable it once in the repository: + +1. Open **Repository Settings → Pages**. +2. Set **Source** to **GitHub Actions**. +3. Run **Deploy landing to GitHub Pages** manually or merge a landing change to `main`. + +The default deployment URL is `https://abelmaro.github.io/MemoRepo/`. + +The project-site `robots.txt` is served at `/MemoRepo/robots.txt`; it is not the host-level robots file for `abelmaro.github.io`. Indexing does not depend on it. The generated sitemap can be submitted directly to Search Console at `https://abelmaro.github.io/MemoRepo/sitemap.xml`. + +## Custom domain + +Site URLs are controlled at build time: + +- `SITE_ORIGIN` defaults to `https://abelmaro.github.io`. +- `BASE_PATH` defaults to `/MemoRepo`. +- `CANONICAL_URL` is derived from those two values. + +For a verified custom domain, configure the domain in GitHub Pages first, set DNS without wildcards, enable HTTPS, then build with the custom origin and a root base path. For example in PowerShell: + +```powershell +$env:SITE_ORIGIN = "https://docs.example.com" +$env:BASE_PATH = "/" +pnpm --filter @memorepo/landing build +``` + +Validate the canonical URL, Open Graph image, sitemap, manifest, and `.well-known/security.txt` after the change. Add a `CNAME` only after the real domain is selected and configured. diff --git a/apps/landing/astro.config.mjs b/apps/landing/astro.config.mjs new file mode 100644 index 0000000..4595242 --- /dev/null +++ b/apps/landing/astro.config.mjs @@ -0,0 +1,15 @@ +import react from "@astrojs/react"; +import { defineConfig } from "astro/config"; +import { BASE_PATH, SITE_ORIGIN } from "./site.config.mjs"; + +export default defineConfig({ + integrations: [react()], + site: SITE_ORIGIN, + base: BASE_PATH, + output: "static", + trailingSlash: "always", + compressHTML: true, + build: { + assets: "assets", + }, +}); diff --git a/apps/landing/package.json b/apps/landing/package.json new file mode 100644 index 0000000..3e04a47 --- /dev/null +++ b/apps/landing/package.json @@ -0,0 +1,34 @@ +{ + "name": "@memorepo/landing", + "version": "0.3.3", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev --host 0.0.0.0", + "build": "astro check && astro build", + "preview": "astro preview --host 0.0.0.0", + "test": "pnpm build && vitest run", + "test:browser": "node scripts/accessibility-smoke.mjs", + "test:unit": "vitest run", + "typecheck": "astro check" + }, + "dependencies": { + "@astrojs/react": "^6.0.1", + "astro": "^7.1.3", + "motion": "^12.42.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-icons": "^5.7.0", + "sharp": "^0.35.3" + }, + "devDependencies": { + "@astrojs/check": "^0.9.9", + "@types/node": "^22.20.0", + "axe-core": "^4.12.1", + "jsdom": "^29.1.1", + "lighthouse": "^13.4.1", + "playwright": "^1.61.1", + "typescript": "^5.9.2", + "vitest": "^4.1.10" + } +} diff --git a/apps/landing/public/android-chrome-192x192.png b/apps/landing/public/android-chrome-192x192.png new file mode 100644 index 0000000..24308c8 Binary files /dev/null and b/apps/landing/public/android-chrome-192x192.png differ diff --git a/apps/landing/public/android-chrome-512x512.png b/apps/landing/public/android-chrome-512x512.png new file mode 100644 index 0000000..44250a8 Binary files /dev/null and b/apps/landing/public/android-chrome-512x512.png differ diff --git a/apps/landing/public/apple-touch-icon.png b/apps/landing/public/apple-touch-icon.png new file mode 100644 index 0000000..be80579 Binary files /dev/null and b/apps/landing/public/apple-touch-icon.png differ diff --git a/apps/landing/public/favicon-16x16.png b/apps/landing/public/favicon-16x16.png new file mode 100644 index 0000000..4581df8 Binary files /dev/null and b/apps/landing/public/favicon-16x16.png differ diff --git a/apps/landing/public/favicon-32x32.png b/apps/landing/public/favicon-32x32.png new file mode 100644 index 0000000..fbcbfd6 Binary files /dev/null and b/apps/landing/public/favicon-32x32.png differ diff --git a/apps/landing/public/favicon.ico b/apps/landing/public/favicon.ico new file mode 100644 index 0000000..7e1ee45 Binary files /dev/null and b/apps/landing/public/favicon.ico differ diff --git a/apps/landing/public/media/hero-ocean-loop.mp4 b/apps/landing/public/media/hero-ocean-loop.mp4 new file mode 100644 index 0000000..78a78ab Binary files /dev/null and b/apps/landing/public/media/hero-ocean-loop.mp4 differ diff --git a/apps/landing/public/og/memorepo-og.png b/apps/landing/public/og/memorepo-og.png new file mode 100644 index 0000000..ca2c26d Binary files /dev/null and b/apps/landing/public/og/memorepo-og.png differ diff --git a/apps/landing/scripts/accessibility-smoke.mjs b/apps/landing/scripts/accessibility-smoke.mjs new file mode 100644 index 0000000..1925d56 --- /dev/null +++ b/apps/landing/scripts/accessibility-smoke.mjs @@ -0,0 +1,883 @@ +import assert from "node:assert/strict"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import { dirname, extname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import axe from "axe-core"; +import { chromium, firefox, webkit } from "playwright"; + +const APP_ROOT = dirname(fileURLToPath(import.meta.url)); +const DIST_ROOT = resolve(APP_ROOT, "..", "dist"); +const HOST = "127.0.0.1"; +const BASE_PATH = "/MemoRepo"; +const EXPECTED_HERO = "Give coding agents cross-repository context they can trust."; + +/** @type {Record} */ +const CONTENT_TYPES = { + ".avif": "image/avif", + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".webmanifest": "application/manifest+json; charset=utf-8", + ".webp": "image/webp", + ".xml": "application/xml; charset=utf-8", +}; + +/** @typedef {{ impact: string | null, id: string, help: string, nodes: Array<{ target: string[], failureSummary?: string }> }} AxeViolation */ +/** @typedef {{ violations: AxeViolation[] }} AxeResults */ + +/** @param {unknown} error */ +function formatError(error) { + return error instanceof Error ? error.stack || error.message : String(error); +} + +/** + * @param {string} path + * @returns {Promise} + */ +async function existingFile(path) { + try { + const result = await stat(path); + if (result.isDirectory()) { + return existingFile(join(path, "index.html")); + } + return result.isFile() ? path : null; + } catch { + return null; + } +} + +/** @param {string} path */ +function isInsideDist(path) { + const fromRoot = relative(DIST_ROOT, path); + return fromRoot === "" || (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".."); +} + +/** @param {string} pathname */ +async function resolveRequestPath(pathname) { + if (pathname === BASE_PATH) { + return { redirect: `${BASE_PATH}/` }; + } + if (!pathname.startsWith(`${BASE_PATH}/`)) { + return { file: null }; + } + + const decoded = decodeURIComponent(pathname.slice(BASE_PATH.length + 1)); + const segments = decoded.split("/").filter(Boolean); + let candidate = resolve(DIST_ROOT, ...segments); + if (pathname.endsWith("/")) { + candidate = join(candidate, "index.html"); + } + + if (!isInsideDist(candidate)) { + return { file: null }; + } + + return { file: await existingFile(candidate) }; +} + +async function startServer() { + const server = createServer(async (request, response) => { + try { + const requestUrl = new URL(request.url || "/", `http://${HOST}`); + const resolved = await resolveRequestPath(requestUrl.pathname); + + if (resolved.redirect) { + response.writeHead(308, { Location: resolved.redirect }); + response.end(); + return; + } + + let file = resolved.file; + let statusCode = 200; + if (!file) { + file = await existingFile(join(DIST_ROOT, "404.html")); + statusCode = 404; + } + + if (!file) { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + return; + } + + response.writeHead(statusCode, { + "Cache-Control": "no-store", + "Content-Type": CONTENT_TYPES[extname(file).toLowerCase()] || "application/octet-stream", + }); + if (request.method === "HEAD") { + response.end(); + return; + } + createReadStream(file).pipe(response); + } catch (error) { + response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + response.end(formatError(error)); + } + }); + + await new Promise((resolvePromise, rejectPromise) => { + /** @param {Error} error */ + const onError = (error) => rejectPromise(error); + server.once("error", onError); + server.listen(0, HOST, () => { + server.off("error", onError); + resolvePromise(undefined); + }); + }); + + const address = server.address(); + assert(address && typeof address === "object", "Static test server did not expose a port"); + return { server, origin: `http://${HOST}:${address.port}` }; +} + +/** @param {import("node:http").Server} server */ +async function stopServer(server) { + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => (error ? rejectPromise(error) : resolvePromise(undefined))); + }); +} + +/** @param {AxeViolation[]} violations */ +function formatViolations(violations) { + return violations + .map((violation) => { + const targets = violation.nodes.map((node) => node.target.join(" ")).join(", "); + return `${violation.impact}: ${violation.id} (${violation.help}) at ${targets}`; + }) + .join("\n"); +} + +/** + * @param {import("playwright").Page} page + * @param {string} viewportName + */ +async function runAxe(page, viewportName) { + await page.addScriptTag({ content: axe.source }); + const results = /** @type {AxeResults} */ ( + await page.evaluate(async () => { + const axeRuntime = /** @type {any} */ (globalThis).axe; + return axeRuntime.run(document, { resultTypes: ["violations"] }); + }) + ); + const blocking = results.violations.filter( + (violation) => violation.impact === "serious" || violation.impact === "critical", + ); + assert.equal( + blocking.length, + 0, + `${viewportName} has serious or critical axe violations:\n${formatViolations(blocking)}`, + ); +} + +/** + * @param {import("playwright").Page} page + * @param {string} viewportName + */ +async function assertNoHorizontalOverflow(page, viewportName) { + const overflow = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + assert( + overflow.scrollWidth <= overflow.clientWidth + 1, + `${viewportName} overflows horizontally: ${overflow.scrollWidth}px > ${overflow.clientWidth}px`, + ); +} + +/** @param {import("playwright").Page} page */ +async function assertHeroWires(page) { + const wireState = await page.locator("[data-motion-wire]").evaluateAll((paths) => + paths.map((path) => { + const geometry = /** @type {SVGGeometryElement} */ (path); + return { + d: geometry.getAttribute("d") || "", + length: typeof geometry.getTotalLength === "function" ? geometry.getTotalLength() : 0, + }; + }), + ); + assert(wireState.length > 0, "Expected animated hero wires"); + wireState.forEach((wire, index) => { + assert(wire.d.length > 0 && !wire.d.includes("NaN"), `Hero wire ${index + 1} has invalid geometry`); + assert(wire.length > 0, `Hero wire ${index + 1} has no measurable length`); + }); + + const verticalWireState = await page.locator('[data-wire-from="space"][data-wire-to="gateway"]').evaluate((path) => { + const svg = path.closest("svg"); + const gradient = svg?.querySelector("#wire-gradient-vertical"); + const glow = svg?.querySelector("#wire-glow"); + return { + axis: path.getAttribute("data-wire-axis"), + gradientUnits: gradient?.getAttribute("gradientUnits"), + gradientHeight: Number(gradient?.getAttribute("y2")), + filterUnits: glow?.getAttribute("filterUnits"), + filterWidth: Number(glow?.getAttribute("width")), + viewBoxHeight: svg instanceof SVGSVGElement ? svg.viewBox.baseVal.height : 0, + viewBoxWidth: svg instanceof SVGSVGElement ? svg.viewBox.baseVal.width : 0, + stroke: getComputedStyle(path).stroke, + }; + }); + assert.equal(verticalWireState.axis, "vertical", "Space-to-gateway wire needs an explicit vertical axis"); + assert.equal(verticalWireState.gradientUnits, "userSpaceOnUse", "Vertical wire gradient uses a zero-width box"); + assert.equal(verticalWireState.filterUnits, "userSpaceOnUse", "Vertical wire glow uses a zero-width box"); + assert( + Math.abs(verticalWireState.gradientHeight - verticalWireState.viewBoxHeight) <= 1, + "Vertical wire gradient does not cover the responsive map height", + ); + assert( + verticalWireState.filterWidth > verticalWireState.viewBoxWidth, + "Vertical wire glow does not cover the responsive map width", + ); + assert.notEqual(verticalWireState.stroke, "none", "Space-to-gateway wire has no visible stroke"); +} + +/** + * @param {import("playwright").Locator} group + * @param {string} viewportName + * @param {number} groupIndex + */ +async function assertCalmReveal(group, viewportName, groupIndex) { + const minimumOpacity = await group.evaluate((element) => { + const animatedElements = element.querySelectorAll("[data-motion-item], [data-motion-stage]"); + if (animatedElements.length === 0) return 1; + return Math.min(...Array.from(animatedElements, (item) => Number(getComputedStyle(item).opacity))); + }); + assert( + minimumOpacity >= 0.76, + `${viewportName} motion group ${groupIndex + 1} drops opacity too aggressively: ${minimumOpacity}`, + ); +} + +/** + * @param {import("playwright").Page} page + * @param {{ name: string, width: number }} viewport + */ +async function assertRequestedVisualAlignment(page, viewport) { + const editorialAlignment = await page.locator(".problem-item, .feature-item").evaluateAll((items) => + items.map((item) => { + const icon = item.querySelector(".item-icon .icon"); + const heading = item.querySelector("h3"); + return { + iconLeft: icon?.getBoundingClientRect().left ?? 0, + headingLeft: heading?.getBoundingClientRect().left ?? 0, + }; + }), + ); + editorialAlignment.forEach((alignment, index) => { + assert( + Math.abs(alignment.iconLeft - alignment.headingLeft) <= 2, + `${viewport.name} editorial card ${index + 1} icon is not aligned with its title`, + ); + }); + + const documentationAlignment = await page.locator(".doc-link").evaluateAll((links) => + links.map((link) => { + const icon = link.querySelector(".doc-link__icon .icon"); + const title = link.querySelector("strong"); + return { + iconTop: icon?.getBoundingClientRect().top ?? 0, + titleTop: title?.getBoundingClientRect().top ?? 0, + }; + }), + ); + documentationAlignment.forEach((alignment, index) => { + assert( + Math.abs(alignment.iconTop - alignment.titleTop) <= 3, + `${viewport.name} documentation item ${index + 1} icon is not aligned with its title: ${JSON.stringify(alignment)}`, + ); + }); + + const coloredLeftBorders = await page + .locator(".architecture-figure figcaption p, .quickstart__note, .after-start") + .evaluateAll((elements) => elements.map((element) => Number.parseFloat(getComputedStyle(element).borderLeftWidth))); + coloredLeftBorders.forEach((width, index) => { + assert.equal(width, 0, `${viewport.name} left accent ${index + 1} is still visible`); + }); + + const architectureSpacing = await page.locator(".architecture-flow li").evaluateAll((items) => + items.map((item) => { + const indexRect = item.querySelector(".architecture-flow__index")?.getBoundingClientRect(); + const iconRect = item.querySelector(".architecture-flow__icon")?.getBoundingClientRect(); + return { + horizontalGap: (iconRect?.left ?? 0) - (indexRect?.right ?? 0), + verticalGap: (iconRect?.top ?? 0) - (indexRect?.bottom ?? 0), + }; + }), + ); + architectureSpacing.forEach((spacing, index) => { + const gap = viewport.width <= 900 ? spacing.horizontalGap : spacing.verticalGap; + assert(gap >= 4, `${viewport.name} architecture index ${index + 1} is too close to its icon: ${gap}px`); + }); + + if (viewport.width <= 700) { + const dashboardRadii = await page.locator(".product-shot").evaluate((figure) => { + const image = figure.querySelector("img"); + return { + figure: Number.parseFloat(getComputedStyle(figure).borderTopLeftRadius), + image: image ? Number.parseFloat(getComputedStyle(image).borderTopLeftRadius) : 0, + }; + }); + assert.deepEqual(dashboardRadii, { figure: 0, image: 0 }, `${viewport.name} dashboard is still rounded`); + } +} + +/** + * @param {import("playwright").Page} page + * @param {string} viewportName + */ +async function exerciseMotionJourney(page, viewportName) { + await page.waitForFunction(() => document.documentElement.dataset.motionRuntime === "ready"); + const groups = page.locator("[data-motion-group]"); + const groupCount = await groups.count(); + assert(groupCount >= 15, `${viewportName} should expose the complete motion choreography`); + + for (let index = 0; index < groupCount; index += 1) { + await groups.nth(index).evaluate((element) => + element.scrollIntoView({ behavior: "instant", block: "center" }), + ); + await page.waitForFunction( + (groupIndex) => { + const group = document.querySelectorAll("[data-motion-group]")[groupIndex]; + return group?.getAttribute("data-motion-state") !== "armed"; + }, + index, + { timeout: 10_000, polling: 100 }, + ); + await assertCalmReveal(groups.nth(index), viewportName, index); + await assertNoHorizontalOverflow(page, `${viewportName} motion group ${index + 1}`); + } + + try { + await page.waitForFunction( + () => + Array.from(document.querySelectorAll("[data-motion-group]")).every( + (group) => group.getAttribute("data-motion-state") === "complete", + ), + undefined, + { timeout: 10_000 }, + ); + } catch (error) { + const incomplete = await page.locator('[data-motion-group]:not([data-motion-state="complete"])').evaluateAll( + (groups) => groups.map((group) => ({ + className: group.className, + kind: group.getAttribute("data-motion-kind"), + state: group.getAttribute("data-motion-state"), + })), + ); + throw new Error(`${viewportName} left incomplete motion groups: ${JSON.stringify(incomplete)}`, { cause: error }); + } + + await page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" })); + await page.waitForTimeout(100); + const replayedGroups = await page.locator('[data-motion-group]:not([data-motion-state="complete"])').count(); + assert.equal(replayedGroups, 0, `${viewportName} replayed completed scroll animations`); + await assertHeroWires(page); +} + +/** + * @param {import("playwright").Page} page + * @param {{ name: string, width: number, height: number }} viewport + */ +async function exerciseAnchorNavigation(page, viewport) { + const usesMobileNavigation = viewport.width <= 900; + const navigation = usesMobileNavigation ? ".mobile-nav nav" : ".desktop-nav"; + const links = page.locator(`${navigation} a[href*="#"]`); + const linkCount = await links.count(); + assert.equal(linkCount, 5, `${viewport.name} should expose five section links`); + + for (let index = 0; index < linkCount; index += 1) { + if (usesMobileNavigation) { + const menu = page.locator("details[data-mobile-nav]"); + if (!(await menu.evaluate((element) => element.hasAttribute("open")))) { + await menu.locator("summary").click(); + } + } + + const href = await links.nth(index).getAttribute("href"); + assert(href, `${viewport.name} navigation link ${index + 1} has no href`); + const targetId = new URL(href, "http://localhost").hash.slice(1); + assert(targetId, `${viewport.name} navigation link ${index + 1} has no hash target`); + + const expectedAlignment = await page.evaluate((id) => { + const header = document.querySelector(".site-header"); + const target = document.getElementById(id); + const headerBottom = header?.getBoundingClientRect().bottom ?? 0; + const targetDocumentTop = (target?.getBoundingClientRect().top ?? 0) + window.scrollY; + const maximumScroll = document.documentElement.scrollHeight - window.innerHeight; + const expectedScrollY = Math.min(Math.max(targetDocumentTop - headerBottom, 0), maximumScroll); + return { + expectedScrollY, + expectedTargetTop: targetDocumentTop - expectedScrollY, + }; + }, targetId); + + await links.nth(index).click(); + try { + await page.waitForFunction( + (expectedScrollY) => { + return Math.abs(window.scrollY - expectedScrollY) <= 2; + }, + expectedAlignment.expectedScrollY, + { timeout: 4_000 }, + ); + } catch (error) { + const failedAlignment = await page.evaluate((id) => { + const header = document.querySelector(".site-header"); + const target = document.getElementById(id); + return { + headerBottom: header?.getBoundingClientRect().bottom ?? 0, + targetTop: target?.getBoundingClientRect().top ?? 0, + scrollY: window.scrollY, + }; + }, targetId); + throw new Error( + `${viewport.name} #${targetId} did not settle below the header: ${JSON.stringify({ ...failedAlignment, expected: expectedAlignment })}`, + { cause: error }, + ); + } + + const alignment = await page.evaluate((id) => { + const header = document.querySelector(".site-header"); + const target = document.getElementById(id); + return { + headerBottom: header?.getBoundingClientRect().bottom ?? 0, + targetTop: target?.getBoundingClientRect().top ?? 0, + }; + }, targetId); + assert( + alignment.targetTop >= alignment.headerBottom - 2, + `${viewport.name} #${targetId} is hidden by the header: target ${alignment.targetTop}px, header ${alignment.headerBottom}px`, + ); + assert( + Math.abs(alignment.targetTop - expectedAlignment.expectedTargetTop) <= 2, + `${viewport.name} #${targetId} is not at its closest valid position: target ${alignment.targetTop}px, expected ${expectedAlignment.expectedTargetTop}px`, + ); + + if (usesMobileNavigation) { + assert.equal( + await page.locator("details[data-mobile-nav]").evaluate((element) => element.hasAttribute("open")), + false, + `${viewport.name} mobile navigation stayed open after selecting #${targetId}`, + ); + } + } +} + +/** @param {import("playwright").Page} page */ +async function exerciseMobileNavigation(page) { + const menu = page.locator("details[data-mobile-nav]"); + const summary = menu.locator("summary"); + assert.equal(await menu.count(), 1, "Expected one details[data-mobile-nav] control"); + assert.equal(await summary.count(), 1, "The mobile navigation needs a summary control"); + assert.equal(await menu.evaluate((element) => element.hasAttribute("open")), false); + assert.equal(await summary.getAttribute("aria-label"), "Open navigation"); + await summary.focus(); + await summary.press("Enter"); + await page.waitForFunction(() => { + const menu = document.querySelector("details[data-mobile-nav]"); + const summary = menu?.querySelector("summary"); + return menu?.hasAttribute("open") && summary?.getAttribute("aria-label") === "Close navigation"; + }); + assert.equal(await summary.getAttribute("aria-label"), "Close navigation"); + await summary.press("Escape"); + await page.waitForFunction(() => { + const menu = document.querySelector("details[data-mobile-nav]"); + const summary = menu?.querySelector("summary"); + return !menu?.hasAttribute("open") && summary?.getAttribute("aria-label") === "Open navigation"; + }); + assert.equal(await summary.getAttribute("aria-label"), "Open navigation"); + assert.equal(await summary.evaluate((element) => element === document.activeElement), true); +} + +/** @param {import("playwright").Page} page */ +async function exerciseCopyButton(page) { + const copyButton = page.locator("button[data-copy-button]").first(); + assert((await copyButton.count()) > 0, "Expected at least one keyboard-accessible copy button"); + await copyButton.focus(); + await copyButton.press("Enter"); + await page.waitForFunction(() => + Array.from(document.querySelectorAll("[data-copy-status]")).some( + (element) => element.textContent?.trim() === "Copied", + ), + ); +} + +/** @param {import("playwright").Page} page */ +async function exerciseDisclosureControls(page) { + for (const selector of [".platform-details", ".faq-list details"]) { + const details = page.locator(selector).first(); + const summary = details.locator("summary"); + await summary.scrollIntoViewIfNeeded(); + await summary.focus(); + await summary.press("Enter"); + await page.waitForFunction( + (targetSelector) => document.querySelector(targetSelector)?.hasAttribute("open") === true, + selector, + ); + assert.equal(await details.evaluate((element) => element.hasAttribute("open")), true); + await summary.press("Enter"); + await page.waitForFunction( + (targetSelector) => document.querySelector(targetSelector)?.hasAttribute("open") === false, + selector, + ); + } +} + +/** + * @param {import("playwright").Browser} browser + * @param {string} origin + * @param {{ name: string, width: number, height: number }} viewport + */ +async function auditViewport(browser, origin, viewport) { + const context = await browser.newContext({ + bypassCSP: true, + hasTouch: viewport.width <= 844, + viewport: { width: viewport.width, height: viewport.height }, + }); + const clipboardPermissionsGranted = browser.browserType().name() === "chromium"; + if (clipboardPermissionsGranted) { + await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin }); + } + const page = await context.newPage(); + /** @type {string[]} */ + const consoleErrors = []; + /** @type {string[]} */ + const pageErrors = []; + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + page.on("pageerror", (error) => pageErrors.push(formatError(error))); + + try { + await page.goto(`${origin}${BASE_PATH}/`, { waitUntil: "networkidle" }); + assert.equal(await page.locator("h1").count(), 1, `${viewport.name} should render one H1`); + assert.equal( + (await page.locator("h1").innerText()).replace(/\s+/g, " ").trim(), + EXPECTED_HERO, + `${viewport.name} rendered unexpected core content`, + ); + await assertNoHorizontalOverflow(page, viewport.name); + if (viewport.width <= 390) { + const headingLines = await page.locator("h1").evaluate((element) => { + const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight); + return Math.round(element.getBoundingClientRect().height / lineHeight); + }); + assert( + headingLines >= 3 && headingLines <= 5, + `${viewport.name} hero heading uses ${headingLines} lines; expected 3–5`, + ); + } + await exerciseMotionJourney(page, viewport.name); + await assertRequestedVisualAlignment(page, viewport); + await exerciseAnchorNavigation(page, viewport); + await runAxe(page, viewport.name); + + if (viewport.width <= 900) { + await exerciseMobileNavigation(page); + } + + if (viewport.width === 390 && viewport.height === 844 && clipboardPermissionsGranted) { + await exerciseCopyButton(page); + } + + if (viewport.width === 390 && viewport.height === 844) { + await exerciseDisclosureControls(page); + } + + await page.waitForTimeout(50); + assert.deepEqual(consoleErrors, [], `${viewport.name} console errors:\n${consoleErrors.join("\n")}`); + assert.deepEqual(pageErrors, [], `${viewport.name} page errors:\n${pageErrors.join("\n")}`); + } finally { + await context.close(); + } +} + +/** + * @param {import("playwright").Browser} browser + * @param {string} origin + */ +async function auditNotFound(browser, origin) { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const page = await context.newPage(); + + try { + const response = await page.goto(`${origin}${BASE_PATH}/missing-page`, { waitUntil: "networkidle" }); + assert.equal(response?.status(), 404, "Missing routes should return the branded 404 page"); + assert.match((await page.locator("h1").innerText()).trim(), /no record of that page/i); + await exerciseMobileNavigation(page); + await runAxe(page, "Mobile 404"); + await assertNoHorizontalOverflow(page, "Mobile 404"); + } finally { + await context.close(); + } +} + +/** + * @param {import("playwright").Browser} browser + * @param {string} origin + */ +async function auditWithoutJavaScript(browser, origin) { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }); + const page = await context.newPage(); + + try { + await page.goto(`${origin}${BASE_PATH}/`, { waitUntil: "load" }); + assert(await page.locator("main").isVisible(), "Main content is hidden without JavaScript"); + assert(await page.locator("h1").isVisible(), "The hero heading is hidden without JavaScript"); + assert(await page.locator("#how-it-works").isVisible(), "Workflow content is hidden without JavaScript"); + assert(await page.locator("#security").isVisible(), "Security content is hidden without JavaScript"); + assert(await page.locator("#quickstart").isVisible(), "Quickstart content is hidden without JavaScript"); + assert.match(await page.locator("body").innerText(), /View MemoRepo on GitHub/); + const visuallyHidden = await page.evaluate((selectors) => + selectors.filter((selector) => { + const element = document.querySelector(selector); + if (!element) { + return true; + } + const style = getComputedStyle(element); + return style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0; + }), ["main", "h1", "#how-it-works", "#security", "#quickstart"]); + assert.deepEqual( + visuallyHidden, + [], + `Core content is visually hidden without JavaScript: ${visuallyHidden.join(", ")}`, + ); + await assertNoHorizontalOverflow(page, "JavaScript-disabled desktop"); + } finally { + await context.close(); + } +} + +/** + * @param {import("playwright").Browser} browser + * @param {string} origin + * @param {{ name: string, width: number, height: number }} viewport + */ +async function auditReducedMotion(browser, origin, viewport) { + const context = await browser.newContext({ + reducedMotion: "reduce", + viewport: { width: viewport.width, height: viewport.height }, + }); + const page = await context.newPage(); + + try { + await page.goto(`${origin}${BASE_PATH}/`, { waitUntil: "networkidle" }); + await page.waitForFunction(() => document.documentElement.dataset.motionRuntime === "reduced"); + const incompleteGroups = await page.locator('[data-motion-group]:not([data-motion-state="complete"])').count(); + assert.equal(incompleteGroups, 0, `${viewport.name} reduced motion left incomplete groups`); + + const videoState = await page.locator("[data-hero-video]").evaluate((video) => ({ + display: getComputedStyle(video).display, + paused: /** @type {HTMLVideoElement} */ (video).paused, + })); + assert.equal(videoState.display, "none", `${viewport.name} reduced motion should hide the hero video`); + assert.equal(videoState.paused, true, `${viewport.name} reduced motion should pause the hero video`); + + const runningMotion = await page.evaluate(() => + document.getAnimations().filter((animation) => { + const target = animation.effect instanceof KeyframeEffect ? animation.effect.target : null; + return animation.playState === "running" && target instanceof Element && target.closest("[data-motion-group]"); + }).length, + ); + assert.equal(runningMotion, 0, `${viewport.name} reduced motion has active group animations`); + + await page.evaluate(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" })); + await assertNoHorizontalOverflow(page, `${viewport.name} reduced motion`); + await runAxe(page, `${viewport.name} reduced motion`); + } finally { + await context.close(); + } +} + +/** + * @param {import("playwright").Browser} browser + * @param {string} origin + */ +async function auditMotionPreferenceChange(browser, origin) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + try { + await page.goto(`${origin}${BASE_PATH}/`, { waitUntil: "networkidle" }); + await page.waitForFunction(() => document.documentElement.dataset.motionRuntime === "ready"); + const workflow = page.locator('[data-motion-kind="workflow"]'); + await workflow.evaluate((element) => element.scrollIntoView({ behavior: "instant", block: "center" })); + await page.waitForFunction(() => + document.querySelector('[data-motion-kind="workflow"]')?.getAttribute("data-motion-state") === "running", + ); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.waitForFunction(() => + document.documentElement.dataset.motionRuntime === "reduced" && + Array.from(document.querySelectorAll("[data-motion-group]")).every( + (group) => group.getAttribute("data-motion-state") === "complete", + ), + ); + const videoState = await page.locator("[data-hero-video]").evaluate((video) => ({ + display: getComputedStyle(video).display, + paused: /** @type {HTMLVideoElement} */ (video).paused, + })); + assert.equal(videoState.display, "none"); + assert.equal(videoState.paused, true); + + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.waitForFunction(() => document.documentElement.dataset.motionRuntime === "ready"); + await assertNoHorizontalOverflow(page, "Live reduced-motion preference change"); + } finally { + await context.close(); + } +} + +/** + * @param {import("playwright").Browser} browser + * @param {string} origin + * @param {{ name: string, width: number, height: number }} viewport + */ +async function auditBreakpoint(browser, origin, viewport) { + const context = await browser.newContext({ + reducedMotion: "reduce", + viewport: { width: viewport.width, height: viewport.height }, + }); + const page = await context.newPage(); + try { + await page.goto(`${origin}${BASE_PATH}/`, { waitUntil: "networkidle" }); + await page.waitForFunction(() => document.documentElement.dataset.motionRuntime === "reduced"); + for (const position of [0, 0.33, 0.66, 1]) { + await page.evaluate((progress) => { + const maximum = document.documentElement.scrollHeight - document.documentElement.clientHeight; + window.scrollTo({ top: maximum * progress, behavior: "instant" }); + }, position); + await assertNoHorizontalOverflow(page, viewport.name); + } + } finally { + await context.close(); + } +} + +/** + * @param {import("playwright").Browser} browser + * @param {string} origin + * @param {{ name: string, from: { width: number, height: number }, to: { width: number, height: number } }} scenario + */ +async function auditOrientationChange(browser, origin, scenario) { + const context = await browser.newContext({ viewport: scenario.from }); + const page = await context.newPage(); + try { + await page.goto(`${origin}${BASE_PATH}/`, { waitUntil: "networkidle" }); + await page.waitForFunction(() => document.documentElement.dataset.motionRuntime === "ready"); + await assertHeroWires(page); + await page.setViewportSize(scenario.to); + await page.waitForTimeout(150); + await assertNoHorizontalOverflow(page, `${scenario.name} after rotation`); + await assertHeroWires(page); + await page.setViewportSize(scenario.from); + await page.waitForTimeout(150); + await assertNoHorizontalOverflow(page, `${scenario.name} after restoring orientation`); + await assertHeroWires(page); + } finally { + await context.close(); + } +} + +async function main() { + const { server, origin } = await startServer(); + /** @type {import("playwright").Browser[]} */ + const openBrowsers = []; + + const primaryViewports = [ + { name: "Mobile portrait 320 × 568", width: 320, height: 568 }, + { name: "Mobile portrait 390 × 844", width: 390, height: 844 }, + { name: "Mobile landscape 568 × 320", width: 568, height: 320 }, + { name: "Mobile landscape 844 × 390", width: 844, height: 390 }, + { name: "Tablet portrait 768 × 1024", width: 768, height: 1024 }, + { name: "Tablet portrait 820 × 1180", width: 820, height: 1180 }, + { name: "Tablet landscape 1024 × 768", width: 1024, height: 768 }, + { name: "Tablet landscape 1180 × 820", width: 1180, height: 820 }, + { name: "Desktop portrait 1080 × 1920", width: 1080, height: 1920 }, + { name: "Desktop 1280 × 800", width: 1280, height: 800 }, + { name: "Desktop 1440 × 900", width: 1440, height: 900 }, + { name: "Desktop 1920 × 1080", width: 1920, height: 1080 }, + ]; + const representativeViewports = [ + { name: "Mobile portrait 390 × 844", width: 390, height: 844 }, + { name: "Mobile landscape 844 × 390", width: 844, height: 390 }, + { name: "Tablet portrait 768 × 1024", width: 768, height: 1024 }, + { name: "Tablet landscape 1024 × 768", width: 1024, height: 768 }, + { name: "Desktop portrait 1080 × 1920", width: 1080, height: 1920 }, + { name: "Desktop 1440 × 900", width: 1440, height: 900 }, + ]; + const breakpointViewports = [383, 384, 385, 699, 700, 701, 899, 900, 901, 1151, 1152, 1153].map((width) => ({ + name: `Breakpoint ${width} × 900`, + width, + height: 900, + })); + + try { + const chromiumBrowser = await chromium.launch({ headless: true }); + openBrowsers.push(chromiumBrowser); + for (const viewport of primaryViewports) { + await auditViewport(chromiumBrowser, origin, { ...viewport, name: `Chromium ${viewport.name}` }); + } + for (const viewport of representativeViewports) { + await auditReducedMotion(chromiumBrowser, origin, { ...viewport, name: `Chromium ${viewport.name}` }); + } + await auditMotionPreferenceChange(chromiumBrowser, origin); + for (const viewport of breakpointViewports) { + await auditBreakpoint(chromiumBrowser, origin, viewport); + } + for (const scenario of [ + { + name: "Mobile orientation", + from: { width: 390, height: 844 }, + to: { width: 844, height: 390 }, + }, + { + name: "Tablet orientation", + from: { width: 768, height: 1024 }, + to: { width: 1024, height: 768 }, + }, + ]) { + await auditOrientationChange(chromiumBrowser, origin, scenario); + } + await auditNotFound(chromiumBrowser, origin); + await auditWithoutJavaScript(chromiumBrowser, origin); + await chromiumBrowser.close(); + openBrowsers.splice(openBrowsers.indexOf(chromiumBrowser), 1); + + const crossBrowserTypes = /** @type {Array<[string, import("playwright").BrowserType]>} */ ([ + ["Firefox", firefox], + ["WebKit", webkit], + ]); + for (const [browserName, browserType] of crossBrowserTypes) { + const browser = await browserType.launch({ headless: true }); + openBrowsers.push(browser); + for (const viewport of representativeViewports) { + await auditViewport(browser, origin, { ...viewport, name: `${browserName} ${viewport.name}` }); + } + await browser.close(); + openBrowsers.splice(openBrowsers.indexOf(browser), 1); + } + console.log("Landing motion, accessibility, responsive, and interaction checks passed."); + } finally { + try { + await Promise.all(openBrowsers.map((browser) => browser.close())); + } finally { + await stopServer(server); + } + } +} + +main().catch((error) => { + console.error(formatError(error)); + process.exitCode = 1; +}); diff --git a/apps/landing/site.config.mjs b/apps/landing/site.config.mjs new file mode 100644 index 0000000..c1bc1af --- /dev/null +++ b/apps/landing/site.config.mjs @@ -0,0 +1,39 @@ +const DEFAULT_SITE_ORIGIN = "https://abelmaro.github.io"; +const DEFAULT_BASE_PATH = "/MemoRepo"; + +/** @param {string | undefined} value */ +function normalizeOrigin(value) { + const origin = new URL(value || DEFAULT_SITE_ORIGIN).origin; + return origin.replace(/\/$/, ""); +} + +/** @param {string | undefined} value */ +function normalizeBasePath(value) { + const source = String(value || DEFAULT_BASE_PATH).trim(); + if (source === "/") { + return "/"; + } + return `/${source.replace(/^\/+|\/+$/g, "")}`; +} + +export const SITE_ORIGIN = normalizeOrigin(process.env.SITE_ORIGIN); +export const BASE_PATH = normalizeBasePath(process.env.BASE_PATH); +export const CANONICAL_URL = new URL(BASE_PATH === "/" ? "/" : `${BASE_PATH}/`, `${SITE_ORIGIN}/`).toString(); + +/** @param {string} [path] */ +export function withBase(path = "/") { + if (/^(?:[a-z]+:|#)/i.test(path)) { + return path; + } + + const suffix = path.replace(/^\/+/, ""); + if (BASE_PATH === "/") { + return suffix ? `/${suffix}` : "/"; + } + return suffix ? `${BASE_PATH}/${suffix}` : `${BASE_PATH}/`; +} + +/** @param {string} [path] */ +export function absoluteUrl(path = "/") { + return new URL(withBase(path), `${SITE_ORIGIN}/`).toString(); +} diff --git a/apps/landing/src/assets/cta-octopus.webp b/apps/landing/src/assets/cta-octopus.webp new file mode 100644 index 0000000..33a26de Binary files /dev/null and b/apps/landing/src/assets/cta-octopus.webp differ diff --git a/apps/landing/src/assets/dashboard.png b/apps/landing/src/assets/dashboard.png new file mode 100644 index 0000000..1ccf54f Binary files /dev/null and b/apps/landing/src/assets/dashboard.png differ diff --git a/apps/landing/src/assets/tentacle-atmosphere.webp b/apps/landing/src/assets/tentacle-atmosphere.webp new file mode 100644 index 0000000..e63576e Binary files /dev/null and b/apps/landing/src/assets/tentacle-atmosphere.webp differ diff --git a/apps/landing/src/components/ArchitectureDiagram.astro b/apps/landing/src/components/ArchitectureDiagram.astro new file mode 100644 index 0000000..7e28a07 --- /dev/null +++ b/apps/landing/src/components/ArchitectureDiagram.astro @@ -0,0 +1,37 @@ +--- +import { withBase } from "../../site.config.mjs"; +import { siteContent } from "../content/site"; +import Icon from "./Icon.astro"; + +const { architecture } = siteContent; +const nodeIcons = ["repositories", "connect", "snapshot", "space", "graph", "gateway", "agent"] as const; +--- + +
+ + +
+ {architecture.statements.map((statement) =>

{statement}

)} +
+

{architecture.textAlternativeLabel}: {architecture.textAlternative}

+
diff --git a/apps/landing/src/components/Brand.astro b/apps/landing/src/components/Brand.astro new file mode 100644 index 0000000..719dc63 --- /dev/null +++ b/apps/landing/src/components/Brand.astro @@ -0,0 +1,20 @@ +--- +import { withBase } from "../../site.config.mjs"; + +interface Props { + compact?: boolean; +} + +const { compact = false } = Astro.props; +--- + + + + MemoRepo + diff --git a/apps/landing/src/components/CodeBlock.astro b/apps/landing/src/components/CodeBlock.astro new file mode 100644 index 0000000..0b1b8b8 --- /dev/null +++ b/apps/landing/src/components/CodeBlock.astro @@ -0,0 +1,36 @@ +--- +import { siteContent } from "../content/site"; + +interface Props { + id: string; + title: string; + language: string; + code: string; + description?: string; +} + +const { id, title, language, code, description } = Astro.props; +--- + +
+
+
+

{title}

+ {language} +
+ +
+ {description ?

{description}

: null} +
{code}
+
diff --git a/apps/landing/src/components/HeroMap.astro b/apps/landing/src/components/HeroMap.astro new file mode 100644 index 0000000..20e64af --- /dev/null +++ b/apps/landing/src/components/HeroMap.astro @@ -0,0 +1,130 @@ +--- +import Icon from "./Icon.astro"; +--- + + + + diff --git a/apps/landing/src/components/Icon.astro b/apps/landing/src/components/Icon.astro new file mode 100644 index 0000000..ecb33e0 --- /dev/null +++ b/apps/landing/src/components/Icon.astro @@ -0,0 +1,106 @@ +--- +import { + LuArrowRight, + LuBookOpen, + LuBot, + LuBoxes, + LuCodeXml, + LuFileText, + LuFolderGit2, + LuGitCompareArrows, + LuGitFork, + LuHistory, + LuKeyRound, + LuLayers3, + LuMenu, + LuMonitor, + LuMousePointerClick, + LuNetwork, + LuPlug, + LuRotateCcw, + LuShield, + LuShieldAlert, + LuShieldCheck, + LuShieldX, + LuSlidersHorizontal, + LuSparkles, + LuWaypoints, + LuX, +} from "react-icons/lu"; +import type { IconType } from "react-icons"; + +export type IconName = + | "access" + | "agent" + | "arrow" + | "book" + | "close" + | "connect" + | "consent" + | "contract" + | "control" + | "credentials" + | "execution" + | "fragmented" + | "gateway" + | "graph" + | "history" + | "local" + | "menu" + | "moving" + | "policy" + | "recovery" + | "repositories" + | "snapshot" + | "space" + | "sparkles" + | "tools" + | "validated"; + +interface Props { + name: IconName; + size?: number; + label?: string; + class?: string; +} + +const icons = { + access: LuShieldAlert, + agent: LuBot, + arrow: LuArrowRight, + book: LuBookOpen, + close: LuX, + connect: LuPlug, + consent: LuMousePointerClick, + contract: LuFileText, + control: LuSlidersHorizontal, + credentials: LuKeyRound, + execution: LuShieldX, + fragmented: LuGitFork, + gateway: LuNetwork, + graph: LuWaypoints, + history: LuHistory, + local: LuMonitor, + menu: LuMenu, + moving: LuGitCompareArrows, + policy: LuShield, + recovery: LuRotateCcw, + repositories: LuFolderGit2, + snapshot: LuLayers3, + space: LuBoxes, + sparkles: LuSparkles, + tools: LuCodeXml, + validated: LuShieldCheck, +} satisfies Record; + +const { name, size = 24, label, class: className } = Astro.props; +const SelectedIcon = icons[name]; +--- + + diff --git a/apps/landing/src/components/SecurityBoundary.astro b/apps/landing/src/components/SecurityBoundary.astro new file mode 100644 index 0000000..6138ace --- /dev/null +++ b/apps/landing/src/components/SecurityBoundary.astro @@ -0,0 +1,30 @@ +--- +import { withBase } from "../../site.config.mjs"; +import Icon from "./Icon.astro"; +--- + + diff --git a/apps/landing/src/components/SiteHeader.astro b/apps/landing/src/components/SiteHeader.astro new file mode 100644 index 0000000..9aa358d --- /dev/null +++ b/apps/landing/src/components/SiteHeader.astro @@ -0,0 +1,64 @@ +--- +import { withBase } from "../../site.config.mjs"; +import { siteContent } from "../content/site"; +import Brand from "./Brand.astro"; +import Icon from "./Icon.astro"; + +function anchoredHome(href: string) { + return href.startsWith("#") ? withBase(`/${href}`) : href; +} + +function eventFor(label: string) { + if (label === "Quickstart") return "quickstart_click"; + return undefined; +} +--- + + + + diff --git a/apps/landing/src/content/site.ts b/apps/landing/src/content/site.ts new file mode 100644 index 0000000..d915bb8 --- /dev/null +++ b/apps/landing/src/content/site.ts @@ -0,0 +1,655 @@ +import rootPackage from "../../../../package.json"; + +type HttpsUrl = `https://${string}`; +type HttpUrl = `http://${string}`; +type SectionHref = `#${string}`; +type LinkHref = HttpsUrl | HttpUrl | SectionHref; + +type Link = { + readonly label: string; + readonly href: LinkHref; +}; + +type TitledCopy = { + readonly title: string; + readonly body: string; +}; + +type CodeBlock = { + readonly title: string; + readonly language: "bash" | "dotenv" | "powershell"; + readonly code: string; + readonly description?: string; +}; + +type Five = readonly [T, T, T, T, T]; +type Six = readonly [T, T, T, T, T, T]; +type Seven = readonly [T, T, T, T, T, T, T]; +type Eight = readonly [T, T, T, T, T, T, T, T]; + +export interface SiteContent { + readonly links: { + readonly repository: HttpsUrl; + readonly quickstart: HttpsUrl; + readonly mcpTools: HttpsUrl; + readonly operatingContract: HttpsUrl; + readonly changelog: HttpsUrl; + readonly securityPolicy: HttpsUrl; + readonly vulnerabilityReport: HttpsUrl; + readonly issues: HttpsUrl; + readonly license: HttpsUrl; + readonly localDashboard: HttpUrl; + }; + readonly metadata: { + readonly language: "en"; + readonly siteName: string; + readonly title: string; + readonly description: string; + readonly socialDescription: string; + }; + readonly ui: { + readonly skipToContent: string; + readonly openNavigation: string; + readonly closeNavigation: string; + readonly copyCode: string; + readonly copied: string; + readonly copyFailed: string; + readonly externalLinkNotice: string; + }; + readonly header: { + readonly brand: string; + readonly brandLinkLabel: string; + readonly navigationLabel: string; + readonly navigation: Five; + readonly cta: Link; + }; + readonly hero: { + readonly eyebrow: string; + readonly title: string; + readonly body: string; + readonly support: string; + readonly primaryCta: Link; + readonly secondaryCta: Link; + readonly visualAlt: string; + readonly visualCaption: string; + }; + readonly problems: { + readonly id: "why"; + readonly eyebrow: string; + readonly title: string; + readonly intro: string; + readonly items: readonly [TitledCopy, TitledCopy, TitledCopy]; + readonly conclusion: string; + }; + readonly features: { + readonly eyebrow: string; + readonly title: string; + readonly intro: string; + readonly items: Six; + }; + readonly workflow: { + readonly id: "how-it-works"; + readonly eyebrow: string; + readonly title: string; + readonly intro: string; + readonly steps: Five; + readonly optionalNoteTitle: string; + readonly optionalNote: string; + }; + readonly architecture: { + readonly eyebrow: string; + readonly title: string; + readonly intro: string; + readonly boundaryLabel: string; + readonly boundarySupport: string; + readonly nodes: Seven<{ + readonly label: string; + readonly boundary: "inside" | "outside"; + }>; + readonly statements: readonly [string, string]; + readonly textAlternativeLabel: string; + readonly textAlternative: string; + }; + readonly security: { + readonly id: "security"; + readonly eyebrow: string; + readonly title: string; + readonly intro: string; + readonly controls: Six; + readonly calloutTitle: string; + readonly callout: string; + readonly actions: readonly [Link, Link, Link]; + }; + readonly scope: { + readonly title: string; + readonly intro: string; + readonly builtForTitle: string; + readonly builtFor: Six; + readonly notBuiltForTitle: string; + readonly notBuiltFor: Five; + readonly conclusion: string; + }; + readonly quickstart: { + readonly id: "quickstart"; + readonly eyebrow: string; + readonly title: string; + readonly intro: string; + readonly requirementsLabel: string; + readonly requirements: readonly [string, string]; + readonly blocks: readonly [CodeBlock, CodeBlock, CodeBlock, CodeBlock]; + readonly powershellTitle: string; + readonly powershellIntro: string; + readonly powershellBlocks: readonly [CodeBlock, CodeBlock]; + readonly tokenNote: string; + readonly optionalTokenTitle: string; + readonly optionalTokenBody: string; + readonly optionalTokenBlock: CodeBlock; + readonly afterStart: string; + readonly action: Link; + }; + readonly faq: { + readonly id: "faq"; + readonly title: string; + readonly intro: string; + readonly items: Seven<{ + readonly question: string; + readonly answer: string; + }>; + }; + readonly finalCta: { + readonly eyebrow: string; + readonly title: string; + readonly body: string; + readonly primaryCta: Link; + readonly secondaryCta: Link; + }; + readonly docs: { + readonly id: "docs"; + readonly eyebrow: string; + readonly title: string; + readonly intro: string; + readonly items: Five; + }; + readonly footer: { + readonly brand: string; + readonly tagline: string; + readonly navigationLabel: string; + readonly navigation: Eight; + readonly licenseNotice: string; + readonly versionLabel: string; + readonly version: string; + }; +} + +export const siteContent = { + links: { + repository: "https://github.com/abelmaro/MemoRepo", + quickstart: + "https://github.com/abelmaro/MemoRepo/blob/main/docs/quickstart.md", + mcpTools: + "https://github.com/abelmaro/MemoRepo/blob/main/docs/mcp-tools.md", + operatingContract: + "https://github.com/abelmaro/MemoRepo/blob/main/docs/operating-contract.md", + changelog: "https://github.com/abelmaro/MemoRepo/blob/main/CHANGELOG.md", + securityPolicy: "https://github.com/abelmaro/MemoRepo/security/policy", + vulnerabilityReport: + "https://github.com/abelmaro/MemoRepo/security/advisories/new", + issues: "https://github.com/abelmaro/MemoRepo/issues", + license: "https://github.com/abelmaro/MemoRepo/blob/main/LICENSE", + localDashboard: "http://127.0.0.1:5173", + }, + metadata: { + language: "en", + siteName: "MemoRepo", + title: "MemoRepo — Cross-repository context for coding agents", + description: + "MemoRepo turns related GitHub repositories into immutable, source-verifiable context for coding agents through a local, read-only MCP gateway.", + socialDescription: + "Give coding agents reproducible context across related repositories through immutable snapshots and a read-only MCP gateway.", + }, + ui: { + skipToContent: "Skip to main content", + openNavigation: "Open navigation", + closeNavigation: "Close navigation", + copyCode: "Copy", + copied: "Copied", + copyFailed: "Could not copy", + externalLinkNotice: "opens in a new tab", + }, + header: { + brand: "MemoRepo", + brandLinkLabel: "MemoRepo home", + navigationLabel: "Primary navigation", + navigation: [ + { label: "Why MemoRepo", href: "#why" }, + { label: "How it works", href: "#how-it-works" }, + { label: "Security", href: "#security" }, + { label: "Quickstart", href: "#quickstart" }, + { label: "Docs", href: "#docs" }, + ], + cta: { + label: "View on GitHub", + href: "https://github.com/abelmaro/MemoRepo", + }, + }, + hero: { + eyebrow: "LOCAL-FIRST · READ-ONLY MCP · OPEN SOURCE", + title: "Give coding agents cross-repository context they can trust.", + body: + "MemoRepo groups related GitHub repositories into isolated Spaces, records the exact commits behind selected branches, and exposes graph-guided discovery plus verifiable snapshot source through a bounded, read-only Model Context Protocol (MCP) gateway.", + support: + "Designed for one developer on one workstation. Runs locally with Docker Compose on Windows, macOS, and Linux.", + primaryCta: { + label: "View MemoRepo on GitHub", + href: "https://github.com/abelmaro/MemoRepo", + }, + secondaryCta: { + label: "Read the quickstart", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/quickstart.md", + }, + visualAlt: + "MemoRepo dashboard showing a Space with repositories, snapshot status, and agent connection controls.", + visualCaption: + "One Space. Multiple repositories. One active, reproducible snapshot.", + }, + problems: { + id: "why", + eyebrow: "THE CONTEXT GAP", + title: "An agent is only as reliable as the context it can inspect.", + intro: + "Modern systems rarely fit inside one repository. Services, packages, schemas, clients, and infrastructure evolve independently, while many agent workflows see only the current working tree or receive access far broader than the question requires.", + items: [ + { + title: "Fragmented context", + body: "Repository boundaries hide the relationships an agent needs to understand a system end to end.", + }, + { + title: "Moving evidence", + body: "Live branches change. Without a recorded revision, the evidence behind an answer is difficult to reproduce.", + }, + { + title: "Over-broad access", + body: "General filesystem and shell access expands the trust boundary without making the context more precise.", + }, + ], + conclusion: + "MemoRepo creates a stable, scoped read model from only the repositories you select.", + }, + features: { + eyebrow: "WHY MEMOREPO", + title: "Built around evidence, scope, and repeatability.", + intro: + "MemoRepo treats code context as an operational artifact: selected deliberately, built reproducibly, inspected visibly, and exposed through a narrow interface.", + items: [ + { + title: "One Space, many repositories", + body: "Group related repositories into one shared context. Search across the snapshot or narrow the question to one project.", + }, + { + title: "Exact-commit snapshots", + body: "MemoRepo records resolved commits and activates a replacement only after the complete snapshot succeeds.", + }, + { + title: "Graph discovery, source truth", + body: "Discover relationships in the graph, then verify important claims against the immutable source snapshot.", + }, + { + title: "Read-only by construction", + body: "Each connection reads one Space through bounded query tools—without shell, editor, or write access.", + }, + { + title: "Local control", + body: "Clones, indexes, state, and credentials remain local. Agent configs receive only Space-scoped access.", + }, + { + title: "Visible recovery", + body: "Track snapshot quality, jobs, retries, cleanup, and recovery from the dashboard.", + }, + ], + }, + workflow: { + id: "how-it-works", + eyebrow: "HOW IT WORKS", + title: "From repositories to agent-ready context in five steps.", + intro: + "MemoRepo manages the operational path from authorized GitHub access to a bounded query connection.", + steps: [ + { + number: "01", + title: "Connect GitHub", + body: "Use GitHub Device Flow or a read-capable token. Only granted repositories are visible.", + }, + { + number: "02", + title: "Create a Space", + body: "Create a named context for the product or domain the repositories form together.", + }, + { + number: "03", + title: "Add related repositories", + body: "Choose repositories and branches. MemoRepo records the exact commit behind each selection.", + }, + { + number: "04", + title: "Build the immutable snapshot", + body: "Build and index one immutable snapshot. It activates only after the complete build succeeds.", + }, + { + number: "05", + title: "Connect an agent", + body: "Generate a Space-scoped MCP connection and give the client its separate read-only token.", + }, + ], + optionalNoteTitle: "Optional: Ask this Space", + optionalNote: + "Chats stay pinned to the snapshot active at creation. External inference starts only after disclosure and explicit consent.", + }, + architecture: { + eyebrow: "ARCHITECTURE", + title: "A narrow path from source to answer.", + intro: + "The local boundary contains the stateful control plane and the read-only agent gateway; source acquisition and the MCP-compatible client sit outside it.", + boundaryLabel: "Local MemoRepo boundary", + boundarySupport: "Also inside: dashboard and control API · SQLite state", + nodes: [ + { label: "GitHub repositories", boundary: "outside" }, + { label: "MemoRepo-managed local clones", boundary: "inside" }, + { + label: "Exact-commit, content-addressed source trees", + boundary: "inside", + }, + { label: "Immutable Space snapshot", boundary: "inside" }, + { + label: "Shared code graph and direct source verification", + boundary: "inside", + }, + { + label: "Space-scoped read-only MCP gateway", + boundary: "inside", + }, + { label: "MCP-compatible coding agent", boundary: "outside" }, + ], + statements: [ + "Graph discovery accelerates investigation. Immutable source is the authority for exact claims; exhaustive and negative claims also require complete coverage, untruncated results, and finished pagination.", + "Repository credentials are not embedded in generated MCP configurations.", + ], + textAlternativeLabel: "Architecture flow", + textAlternative: + "GitHub repositories are cloned into MemoRepo's local boundary, which also contains the dashboard, control API, and SQLite state. MemoRepo records selected-branch commits, materializes exact trees in a content-addressed store, builds one immutable Space snapshot, and combines a shared code graph with direct source verification. A Space-scoped read-only MCP gateway then serves the active snapshot to an MCP-compatible coding agent outside the MemoRepo boundary.", + }, + security: { + id: "security", + eyebrow: "SUPPORTED SECURITY BOUNDARY", + title: "Narrow by design. Explicit about its limits.", + intro: + "MemoRepo reduces the agent-facing surface by separating control access from snapshot query access and exposing only bounded, read-only MCP tools.", + controls: [ + { + title: "Localhost by default", + body: "The dashboard and API bind to the local machine in the supported Docker Compose setup.", + }, + { + title: "Separate credentials", + body: "The control plane and each MCP connection use separate bearer credentials. Deleting a connection revokes its token independently.", + }, + { + title: "Repository credentials stay out of agent configs", + body: "Repository-access credentials are not included in generated MCP configurations or normal model prompt and tool payloads. The MemoRepo control token is also excluded from model payloads.", + }, + { + title: "No general-purpose execution surface", + body: "The MCP gateway does not expose a shell, terminal, editor, arbitrary filesystem browser, web browser, external app gateway, or write operations.", + }, + { + title: "Validated and redacted source access", + body: "Snapshot tools accept bounded repository-relative inputs, reject unsafe paths, and sanitize internal paths from normal responses.", + }, + { + title: "Explicit external-inference consent", + body: "Optional in-dashboard inference starts only after a disclosure explains that questions, chat history, query results, and relevant code excerpts may be sent to the selected provider.", + }, + ], + calloutTitle: "Supported deployment boundary", + callout: + "MemoRepo is designed for trusted local use by one developer on one workstation. It is not a supported public, network-exposed, reverse-proxied, or multi-user service.", + actions: [ + { + label: "Read the operating contract", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/operating-contract.md", + }, + { + label: "Review the security policy", + href: "https://github.com/abelmaro/MemoRepo/security/policy", + }, + { + label: "Report a vulnerability privately", + href: "https://github.com/abelmaro/MemoRepo/security/advisories/new", + }, + ], + }, + scope: { + title: "A focused control plane—not another autonomous agent.", + intro: + "MemoRepo manages local code context and exposes a narrow read model. The MCP-compatible client remains the agent.", + builtForTitle: "Built for", + builtFor: [ + "Local developer workstations", + "Related GitHub repositories", + "Read-only investigation", + "MCP-compatible coding agents", + "Reproducible architecture and source evidence", + "Visible snapshot and job operations", + ], + notBuiltForTitle: "Intentionally not built for", + notBuiltFor: [ + "Editing, committing, pushing, merging, or opening pull requests", + "Public hosting or multi-user tenancy", + "General shell, terminal, or arbitrary filesystem access", + "Bypassing GitHub permissions", + "Following remote changes before a replacement snapshot succeeds", + ], + conclusion: + "The boundary is part of the product—not a limitation hidden behind marketing copy.", + }, + quickstart: { + id: "quickstart", + eyebrow: "QUICKSTART", + title: "Run MemoRepo locally with Docker Compose.", + intro: + "Start from a clean checkout. Productive local use requires Docker Desktop or Docker Engine; direct Node workspace execution is for development.", + requirementsLabel: "You need", + requirements: [ + "Docker Desktop on Windows or macOS, or Docker Engine or Desktop on Linux.", + "A GitHub account with access to the repositories you want to index.", + ], + blocks: [ + { + title: "Clone the repository", + language: "bash", + code: "git clone https://github.com/abelmaro/MemoRepo.git\ncd MemoRepo", + }, + { + title: "Prepare the environment", + language: "bash", + code: "cp .env.example .env", + }, + { + title: "Generate the control token", + language: "bash", + code: "openssl rand -hex 32", + }, + { + title: "Start MemoRepo", + language: "bash", + code: "docker compose up --build", + }, + ], + powershellTitle: "PowerShell 7", + powershellIntro: + "Use the native file-copy command and cryptographic token generator on Windows.", + powershellBlocks: [ + { + title: "Prepare the environment", + language: "powershell", + code: "Copy-Item .env.example .env", + }, + { + title: "Generate the control token", + language: "powershell", + code: "[Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)).ToLowerInvariant()", + }, + ], + tokenNote: + "Set the generated value in .env as MEMOREPO_CONTROL_TOKEN. Treat the control token and generated MCP configurations as local secrets.", + optionalTokenTitle: "Optional: use an existing GitHub token", + optionalTokenBody: + "Set GH_TOKEN only when you want to skip the default OAuth Device Flow. It must be able to read every repository you plan to index and takes priority over a stored OAuth credential.", + optionalTokenBlock: { + title: "Set an existing GitHub token", + language: "dotenv", + code: "GH_TOKEN=your-github-token", + }, + afterStart: + "Open http://127.0.0.1:5173, unlock the dashboard with the control token, connect GitHub, create a Space, add repositories, wait for the immutable snapshot to become active, and generate a read-only MCP connection.", + action: { + label: "Open the complete quickstart", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/quickstart.md", + }, + }, + faq: { + id: "faq", + title: "Questions engineers usually ask first.", + intro: + "The short answers below reflect MemoRepo's documented local operating contract.", + items: [ + { + question: "What problem does MemoRepo solve?", + answer: + "It gives coding agents one reproducible context across related repositories, with graph-guided discovery, direct source verification, and a read-only MCP boundary.", + }, + { + question: "Is MemoRepo a coding agent?", + answer: + "No. MemoRepo prepares and serves bounded code context. Your MCP-compatible client remains the agent that asks questions and uses the returned evidence.", + }, + { + question: "Can an agent modify my repositories through MemoRepo?", + answer: + "No. The MCP gateway does not expose source editing, commits, pushes, merges, pull requests, a shell, or arbitrary filesystem access. The control plane separately manages its own local clones, snapshots, jobs, and SQLite state.", + }, + { + question: "Does repository content leave my machine?", + answer: + "Core clones, snapshots, indexes, and operational state remain local. Optional Ask this Space inference can send disclosed questions, chat history, query results, and relevant code excerpts to the selected provider after explicit consent. Repository-access credentials and the MemoRepo control token are excluded from those model payloads.", + }, + { + question: "How does MemoRepo keep context reproducible?", + answer: + "MCP tools read the Space's active immutable snapshot rather than a live working tree, and initialization reports its version. A successful rebuild can change the active snapshot used by later MCP calls. Ask this Space chats remain pinned to the snapshot active when they were created.", + }, + { + question: "Is public or multi-user deployment supported?", + answer: + "No. The supported target is trusted local use by one developer on one workstation through Docker Compose.", + }, + { + question: "Which operating systems are supported?", + answer: + "Windows and macOS through Docker Desktop, and Linux through Docker Engine or Docker Desktop.", + }, + ], + }, + finalCta: { + eyebrow: "START WITH A SPACE", + title: "Better context. Smaller trust boundary.", + body: "Give your coding agent one reproducible view of the repositories that belong together—without giving it control of your source.", + primaryCta: { + label: "View MemoRepo on GitHub", + href: "https://github.com/abelmaro/MemoRepo", + }, + secondaryCta: { + label: "Read the quickstart", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/quickstart.md", + }, + }, + docs: { + id: "docs", + eyebrow: "DOCUMENTATION", + title: "Inspect the operating contract before you connect an agent.", + intro: + "The repository documentation defines setup, tool behavior, limits, releases, and the supported security boundary.", + items: [ + { + label: "Quickstart", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/quickstart.md", + description: + "Installation, GitHub connection, Spaces, agent connections, and troubleshooting.", + }, + { + label: "MCP tools", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/mcp-tools.md", + description: + "Connection modes, query contracts, completeness rules, limits, and recommended workflow.", + }, + { + label: "Operating contract", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/operating-contract.md", + description: + "Runtime support, configuration, data ownership, trust boundaries, and security.", + }, + { + label: "Changelog", + href: "https://github.com/abelmaro/MemoRepo/blob/main/CHANGELOG.md", + description: "Releases and notable product changes.", + }, + { + label: "Security policy", + href: "https://github.com/abelmaro/MemoRepo/security/policy", + description: + "Supported versions, private vulnerability reporting, and security scope.", + }, + ], + }, + footer: { + brand: "MemoRepo", + tagline: "Local-first, read-only code intelligence for coding agents.", + navigationLabel: "Project links", + navigation: [ + { + label: "GitHub repository", + href: "https://github.com/abelmaro/MemoRepo", + }, + { + label: "Quickstart", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/quickstart.md", + }, + { + label: "MCP tools", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/mcp-tools.md", + }, + { + label: "Operating contract", + href: "https://github.com/abelmaro/MemoRepo/blob/main/docs/operating-contract.md", + }, + { + label: "Changelog", + href: "https://github.com/abelmaro/MemoRepo/blob/main/CHANGELOG.md", + }, + { + label: "Security policy", + href: "https://github.com/abelmaro/MemoRepo/security/policy", + }, + { + label: "Issues", + href: "https://github.com/abelmaro/MemoRepo/issues", + }, + { + label: "MIT License", + href: "https://github.com/abelmaro/MemoRepo/blob/main/LICENSE", + }, + ], + licenseNotice: "Free and open source under the MIT License.", + versionLabel: "Current build:", + version: rootPackage.version, + }, +} as const satisfies SiteContent; diff --git a/apps/landing/src/env.d.ts b/apps/landing/src/env.d.ts new file mode 100644 index 0000000..82c97e5 --- /dev/null +++ b/apps/landing/src/env.d.ts @@ -0,0 +1,9 @@ +/// + +declare module "../../site.config.mjs" { + export const SITE_ORIGIN: string; + export const BASE_PATH: string; + export const CANONICAL_URL: string; + export function withBase(path?: string): string; + export function absoluteUrl(path?: string): string; +} diff --git a/apps/landing/src/layouts/BaseLayout.astro b/apps/landing/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..090f3f2 --- /dev/null +++ b/apps/landing/src/layouts/BaseLayout.astro @@ -0,0 +1,67 @@ +--- +import { absoluteUrl, CANONICAL_URL, withBase } from "../../site.config.mjs"; +import { siteContent } from "../content/site"; +import "../styles/tokens.css"; +import "../styles/global.css"; + +interface Props { + title?: string; + description?: string; + noindex?: boolean; + structuredData?: Record; +} + +const { + title = siteContent.metadata.title, + description = siteContent.metadata.description, + noindex = false, + structuredData, +} = Astro.props; +const socialImage = absoluteUrl("/og/memorepo-og.png"); +const serializedStructuredData = structuredData + ? JSON.stringify(structuredData).replace(/ + + + + + + {title} + + + + + + + + + + + + + + + + + + + + + + + + + + {serializedStructuredData ? : null} + + + + + + diff --git a/apps/landing/src/pages/.well-known/security.txt.ts b/apps/landing/src/pages/.well-known/security.txt.ts new file mode 100644 index 0000000..a47c327 --- /dev/null +++ b/apps/landing/src/pages/.well-known/security.txt.ts @@ -0,0 +1,19 @@ +import type { APIRoute } from "astro"; +import { absoluteUrl } from "../../../site.config.mjs"; + +export const prerender = true; + +export const GET: APIRoute = () => { + const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(); + return new Response( + [ + "Contact: https://github.com/abelmaro/MemoRepo/security/advisories/new", + "Policy: https://github.com/abelmaro/MemoRepo/security/policy", + `Canonical: ${absoluteUrl("/.well-known/security.txt")}`, + `Expires: ${expires}`, + "Preferred-Languages: en", + "", + ].join("\n"), + { headers: { "content-type": "text/plain; charset=utf-8" } }, + ); +}; diff --git a/apps/landing/src/pages/404.astro b/apps/landing/src/pages/404.astro new file mode 100644 index 0000000..7c8a906 --- /dev/null +++ b/apps/landing/src/pages/404.astro @@ -0,0 +1,23 @@ +--- +import { withBase } from "../../site.config.mjs"; +import Brand from "../components/Brand.astro"; +import SiteHeader from "../components/SiteHeader.astro"; +import BaseLayout from "../layouts/BaseLayout.astro"; +--- + + + +
+
+ +

404 · PAGE NOT FOUND

+

This snapshot has no record of that page.

+

The address may have changed, or the link may point outside the published MemoRepo site.

+ Return to MemoRepo +
+
+
diff --git a/apps/landing/src/pages/index.astro b/apps/landing/src/pages/index.astro new file mode 100644 index 0000000..8799bd3 --- /dev/null +++ b/apps/landing/src/pages/index.astro @@ -0,0 +1,392 @@ +--- +import { Picture } from "astro:assets"; +import { CANONICAL_URL, withBase } from "../../site.config.mjs"; +import ctaOctopus from "../assets/cta-octopus.webp"; +import dashboardImage from "../assets/dashboard.png"; +import tentacleAtmosphere from "../assets/tentacle-atmosphere.webp"; +import ArchitectureDiagram from "../components/ArchitectureDiagram.astro"; +import Brand from "../components/Brand.astro"; +import CodeBlock from "../components/CodeBlock.astro"; +import HeroMap from "../components/HeroMap.astro"; +import Icon from "../components/Icon.astro"; +import SecurityBoundary from "../components/SecurityBoundary.astro"; +import SiteHeader from "../components/SiteHeader.astro"; +import { siteContent } from "../content/site"; +import BaseLayout from "../layouts/BaseLayout.astro"; + +const { + architecture, + docs, + faq, + features, + finalCta, + footer, + hero, + problems, + quickstart, + scope, + security, + workflow, +} = siteContent; + +const heroAccent = "cross-repository"; +const [heroTitleBefore, heroTitleAfter] = hero.title.split(heroAccent); +const problemIcons = ["fragmented", "moving", "access"] as const; +const featureIcons = ["repositories", "snapshot", "graph", "gateway", "control", "recovery"] as const; +const workflowIcons = ["connect", "space", "snapshot", "gateway", "agent"] as const; +const securityIcons = ["local", "credentials", "execution", "validated", "validated", "consent"] as const; +const docsIcons = ["book", "tools", "contract", "history", "policy"] as const; + +const structuredData = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "WebSite", + "@id": `${CANONICAL_URL}#website`, + url: CANONICAL_URL, + name: siteContent.metadata.siteName, + description: siteContent.metadata.description, + inLanguage: "en", + }, + { + "@type": "WebPage", + "@id": `${CANONICAL_URL}#webpage`, + url: CANONICAL_URL, + name: siteContent.metadata.title, + description: siteContent.metadata.description, + isPartOf: { "@id": `${CANONICAL_URL}#website` }, + inLanguage: "en", + }, + { + "@type": "SoftwareApplication", + "@id": `${CANONICAL_URL}#software`, + name: "MemoRepo", + applicationCategory: "DeveloperApplication", + operatingSystem: "Windows, macOS, Linux", + description: siteContent.metadata.description, + url: CANONICAL_URL, + downloadUrl: siteContent.links.repository, + softwareVersion: footer.version, + isAccessibleForFree: true, + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }, + license: siteContent.links.license, + }, + ], +}; + +const afterStartInstructions = quickstart.afterStart.replace( + `Open ${siteContent.links.localDashboard}, `, + "", +); + +function analyticsFor(label: string) { + if (label.includes("GitHub")) return "github_primary_click"; + if (label.toLowerCase().includes("quickstart")) return "quickstart_click"; + if (label === "MCP tools") return "docs_mcp_click"; + if (label.toLowerCase().includes("operating contract")) return "operating_contract_click"; + if (label.toLowerCase().includes("security policy")) return "security_policy_click"; + return undefined; +} +--- + + + + +
+
+ +
+
+

{hero.eyebrow}

+

{heroTitleBefore}{heroAccent}{heroTitleAfter}

+

{hero.body}

+ +

{hero.support}

+
+ + +
+ +
+
+
+ +
{hero.visualCaption}
+
+
+
+
+ +
+
+

{problems.eyebrow}

+

{problems.title}

+

{problems.intro}

+
+
+ {problems.items.map((item, index) => ( +
+ + +

{item.title}

+

{item.body}

+
+ ))} +
+

{problems.conclusion}

+
+ +
+
+

{features.eyebrow}

+

{features.title}

+

{features.intro}

+
+
+ {features.items.map((item, index) => ( +
+ + +

{item.title}

+

{item.body}

+
+ ))} +
+
+ +
+
+

{workflow.eyebrow}

+

{workflow.title}

+

{workflow.intro}

+
+
+
    + {workflow.steps.map((step, index) => ( +
  1. + + +
    +

    {step.title}

    +

    {step.body}

    +
    +
  2. + ))} +
+ +
+
+ +
+
+

{architecture.eyebrow}

+

{architecture.title}

+

{architecture.intro}

+
+
+ +
+
+ +
+
+

{security.eyebrow}

+

{security.title}

+

{security.intro}

+
+
+ +
+ {security.controls.map((control, index) => ( +
+ +
+

{control.title}

+

{control.body}

+
+
+ ))} +
+
+
+
+

{security.calloutTitle}

+

{security.callout}

+
+ +
+
+ +
+
+

{scope.title}

+

{scope.intro}

+
+
+
+

{scope.builtForTitle}

+
    {scope.builtFor.map((item) =>
  • {item}
  • )}
+
+
+

{scope.notBuiltForTitle}

+
    {scope.notBuiltFor.map((item) =>
  • {item}
  • )}
+
+
+

{scope.conclusion}

+
+ +
+
+
+

{quickstart.eyebrow}

+

{quickstart.title}

+

{quickstart.intro}

+

{quickstart.requirementsLabel}

+
    + {quickstart.requirements.map((requirement) =>
  • {requirement}
  • )} +
+ {quickstart.action.label} +
+
+ {quickstart.blocks.slice(0, 3).map((block, index) => ( + + ))} +

{quickstart.tokenNote}

+ +
+ {quickstart.powershellTitle} +

{quickstart.powershellIntro}

+ {quickstart.powershellBlocks.map((block, index) => ( + + ))} +
+
+ {quickstart.optionalTokenTitle} +

{quickstart.optionalTokenBody}

+ +
+

+ Open {siteContent.links.localDashboard}, {afterStartInstructions} +

+
+
+

+
+ +
+
+

{faq.title}

+

{faq.intro}

+
+
+ {faq.items.map((item) => ( +
+ {item.question} +

{item.answer}

+
+ ))} +
+
+ +
+
+

{docs.eyebrow}

+

{docs.title}

+

{docs.intro}

+
+
+ {docs.items.map((item, index) => ( + + + {item.label}{item.description} + + + ))} +
+
+ +
+
+ +
+
+

{finalCta.eyebrow}

+

{finalCta.title}

+

{finalCta.body}

+ +
+
+
+ + + +
diff --git a/apps/landing/src/pages/robots.txt.ts b/apps/landing/src/pages/robots.txt.ts new file mode 100644 index 0000000..a1f8acd --- /dev/null +++ b/apps/landing/src/pages/robots.txt.ts @@ -0,0 +1,9 @@ +import type { APIRoute } from "astro"; +import { absoluteUrl } from "../../site.config.mjs"; + +export const prerender = true; + +export const GET: APIRoute = () => + new Response(`User-agent: *\nAllow: /\nSitemap: ${absoluteUrl("/sitemap.xml")}\n`, { + headers: { "content-type": "text/plain; charset=utf-8" }, + }); diff --git a/apps/landing/src/pages/site.webmanifest.ts b/apps/landing/src/pages/site.webmanifest.ts new file mode 100644 index 0000000..8ab6ef4 --- /dev/null +++ b/apps/landing/src/pages/site.webmanifest.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from "astro"; +import { withBase } from "../../site.config.mjs"; + +export const prerender = true; + +export const GET: APIRoute = () => + new Response( + JSON.stringify({ + name: "MemoRepo", + short_name: "MemoRepo", + description: "Local-first, read-only code intelligence for coding agents.", + start_url: withBase("/"), + scope: withBase("/"), + display: "standalone", + background_color: "#090b0d", + theme_color: "#090b0d", + icons: [ + { src: withBase("/android-chrome-192x192.png"), sizes: "192x192", type: "image/png" }, + { src: withBase("/android-chrome-512x512.png"), sizes: "512x512", type: "image/png" }, + ], + }), + { headers: { "content-type": "application/manifest+json; charset=utf-8" } }, + ); diff --git a/apps/landing/src/pages/sitemap.xml.ts b/apps/landing/src/pages/sitemap.xml.ts new file mode 100644 index 0000000..a78d1c8 --- /dev/null +++ b/apps/landing/src/pages/sitemap.xml.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from "astro"; +import { CANONICAL_URL } from "../../site.config.mjs"; + +export const prerender = true; + +export const GET: APIRoute = () => + new Response( + `\n${CANONICAL_URL}\n`, + { headers: { "content-type": "application/xml; charset=utf-8" } }, + ); diff --git a/apps/landing/src/scripts/motion.ts b/apps/landing/src/scripts/motion.ts new file mode 100644 index 0000000..b814395 --- /dev/null +++ b/apps/landing/src/scripts/motion.ts @@ -0,0 +1,282 @@ +import { + animate, + inView, + stagger, + type AnimationPlaybackControls, +} from "motion"; + +const EASE_GENTLE = [0.25, 0.1, 0.25, 1] as const; +const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); +const compactMotion = window.matchMedia("(max-width: 43.75rem), (max-height: 30rem)"); +const verticalFlow = window.matchMedia("(max-width: 56.25rem)"); + +const activeAnimations = new Set(); +const observerCleanups: Array<() => void> = []; + +function markComplete(group: HTMLElement) { + group.dataset.motionState = "complete"; +} + +function track(controls: AnimationPlaybackControls) { + activeAnimations.add(controls); + void controls.finished.finally(() => activeAnimations.delete(controls)); + return controls; +} + +async function releaseWhenFinished(controls: AnimationPlaybackControls) { + track(controls); + try { + await controls.finished; + } finally { + controls.cancel(); + } +} + +function finishActiveAnimations() { + for (const controls of activeAnimations) { + controls.complete(); + controls.cancel(); + } + activeAnimations.clear(); + + document.querySelectorAll("[data-motion-wire]").forEach((path) => { + path.style.removeProperty("opacity"); + }); + + document.querySelectorAll("[data-motion-group]").forEach(markComplete); +} + +function itemTransition(group: HTMLElement) { + const requestedDistance = Number(group.dataset.motionDistance); + const distance = compactMotion.matches + ? 8 + : Number.isFinite(requestedDistance) + ? Math.min(requestedDistance, 15) + : 14; + const requestedStagger = Number(group.dataset.motionStagger); + const staggerDelay = compactMotion.matches + ? 0.04 + : Number.isFinite(requestedStagger) + ? Math.min(requestedStagger, 0.065) + : 0.06; + + return { + distance, + duration: compactMotion.matches ? 0.58 : 0.72, + staggerDelay, + }; +} + +function animateFlowDecoration(group: HTMLElement, kind: string) { + const controls: AnimationPlaybackControls[] = []; + const axis = verticalFlow.matches ? "Y" : "X"; + + if (kind === "workflow") { + const connectors = group.querySelectorAll(".workflow-list li:not(:last-child)"); + if (connectors.length > 0) { + controls.push( + animate( + connectors, + { "--motion-connector-progress": [0.52, 1] }, + { + duration: compactMotion.matches ? 0.58 : 0.76, + delay: stagger(compactMotion.matches ? 0.04 : 0.055), + ease: EASE_GENTLE, + }, + ), + ); + } + } + + if (kind === "architecture") { + const rail = group.querySelector(".architecture-flow ol"); + const boundary = group.querySelector(".architecture-flow__boundary"); + if (rail) { + controls.push( + animate( + rail, + { "--motion-line-progress": [0.52, 1] }, + { duration: compactMotion.matches ? 0.62 : 0.84, ease: EASE_GENTLE }, + ), + ); + } + if (boundary) { + controls.push( + animate( + boundary, + { opacity: [0.68, 1] }, + { duration: compactMotion.matches ? 0.56 : 0.76, ease: EASE_GENTLE }, + ), + ); + } + } + + if (kind === "security") { + const arrows = group.querySelectorAll(".security-boundary__arrow i"); + if (arrows.length > 0) { + controls.push( + animate( + arrows, + { transform: [`scale${axis}(0.62)`, `scale${axis}(1)`] }, + { + duration: compactMotion.matches ? 0.56 : 0.76, + delay: stagger(compactMotion.matches ? 0.045 : 0.07), + ease: EASE_GENTLE, + }, + ), + ); + } + } + + return controls; +} + +function registerRevealGroup(group: HTMLElement) { + const items = group.querySelectorAll("[data-motion-item]"); + group.dataset.motionState = "armed"; + + let stopObserving: () => void = () => undefined; + stopObserving = inView( + group, + () => { + stopObserving(); + if (group.dataset.motionState === "complete" || reducedMotion.matches || items.length === 0) { + markComplete(group); + return; + } + + group.dataset.motionState = "running"; + const { distance, duration, staggerDelay } = itemTransition(group); + const kind = group.dataset.motionKind ?? "reveal"; + const startDelay = kind === "workflow" || kind === "architecture" || kind === "security" ? 0.06 : 0; + const itemControls = animate( + items, + { + opacity: [0.8, 1], + transform: [`translateY(${distance}px)`, "translateY(0px)"], + }, + { + duration, + delay: stagger(staggerDelay, { startDelay }), + ease: EASE_GENTLE, + }, + ); + const decorationControls = animateFlowDecoration(group, kind); + + void Promise.all([ + releaseWhenFinished(itemControls), + ...decorationControls.map(releaseWhenFinished), + ]).finally(() => markComplete(group)); + }, + { amount: compactMotion.matches ? 0.05 : 0.08, margin: "0px 0px 14% 0px" }, + ); + + observerCleanups.push(stopObserving); +} + +function registerHeroMap(group: HTMLElement) { + group.dataset.motionState = "armed"; + let stopObserving: () => void = () => undefined; + + stopObserving = inView( + group, + () => { + stopObserving(); + if (group.dataset.motionState === "complete" || reducedMotion.matches) { + markComplete(group); + return; + } + + group.dataset.motionState = "running"; + const nodes = Array.from(group.querySelectorAll("[data-motion-stage]")).sort( + (left, right) => Number(left.dataset.motionStage) - Number(right.dataset.motionStage), + ); + const icons = nodes.flatMap((node) => Array.from(node.querySelectorAll(".icon"))); + const wires = Array.from(group.querySelectorAll("[data-motion-wire]")); + const nodeControls = animate( + nodes, + { opacity: [0.78, 1] }, + { + duration: compactMotion.matches ? 0.6 : 0.8, + delay: stagger(compactMotion.matches ? 0.035 : 0.055), + ease: EASE_GENTLE, + }, + ); + const iconControls = animate( + icons, + { transform: ["scale(0.94)", "scale(1)"] }, + { + duration: compactMotion.matches ? 0.58 : 0.76, + delay: stagger(compactMotion.matches ? 0.03 : 0.045, { startDelay: 0.05 }), + ease: EASE_GENTLE, + }, + ); + + const wireControls = animate( + wires, + { opacity: [0.58, 1] }, + { + duration: compactMotion.matches ? 0.72 : 0.92, + delay: stagger(compactMotion.matches ? 0.025 : 0.035), + ease: EASE_GENTLE, + }, + ); + + void Promise.all([ + releaseWhenFinished(nodeControls), + releaseWhenFinished(iconControls), + releaseWhenFinished(wireControls), + ]).finally(() => markComplete(group)); + }, + { amount: 0.06, margin: "0px 0px 14% 0px" }, + ); + + observerCleanups.push(stopObserving); +} + +function animateOpenDetails(details: HTMLDetailsElement) { + if (!details.open || reducedMotion.matches) return; + const content = Array.from(details.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement && child.tagName !== "SUMMARY", + ); + if (content.length === 0) return; + + const controls = animate( + content, + { opacity: [0.86, 1], transform: ["translateY(-4px)", "translateY(0px)"] }, + { duration: compactMotion.matches ? 0.34 : 0.42, ease: EASE_GENTLE }, + ); + void releaseWhenFinished(controls); +} + +function registerDetailsMotion() { + document + .querySelectorAll("[data-mobile-nav], .platform-details, .faq-list details") + .forEach((details) => details.addEventListener("toggle", () => animateOpenDetails(details))); +} + +document.querySelectorAll("[data-motion-group]").forEach((group) => { + if (group.dataset.motionKind === "hero-map") { + registerHeroMap(group); + } else { + registerRevealGroup(group); + } +}); + +registerDetailsMotion(); +document.documentElement.dataset.motionRuntime = reducedMotion.matches ? "reduced" : "ready"; +if (reducedMotion.matches) finishActiveAnimations(); + +reducedMotion.addEventListener("change", () => { + document.documentElement.dataset.motionRuntime = reducedMotion.matches ? "reduced" : "ready"; + if (reducedMotion.matches) finishActiveAnimations(); +}); + +window.addEventListener( + "pagehide", + () => { + observerCleanups.forEach((cleanup) => cleanup()); + finishActiveAnimations(); + }, + { once: true }, +); diff --git a/apps/landing/src/scripts/site.ts b/apps/landing/src/scripts/site.ts new file mode 100644 index 0000000..7968c6d --- /dev/null +++ b/apps/landing/src/scripts/site.ts @@ -0,0 +1,98 @@ +type AnalyticsEvent = + | "github_primary_click" + | "quickstart_click" + | "copy_install_command" + | "docs_mcp_click" + | "operating_contract_click" + | "security_policy_click"; + +export {}; + +declare global { + interface Window { + dispatchMemoRepoEvent?: (event: AnalyticsEvent, detail?: Record) => void; + } +} + +window.dispatchMemoRepoEvent ??= () => undefined; + +const heroVideo = document.querySelector("[data-hero-video]"); +const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + +function syncHeroVideo() { + if (!heroVideo) return; + heroVideo.playbackRate = 0.55; + if (reducedMotion.matches) { + heroVideo.pause(); + return; + } + void heroVideo.play().catch(() => undefined); +} + +reducedMotion.addEventListener("change", syncHeroVideo); +syncHeroVideo(); + +async function copyText(value: string) { + if (!navigator.clipboard?.writeText) { + throw new Error("Clipboard access is unavailable"); + } + await navigator.clipboard.writeText(value); +} + +document.addEventListener("click", async (event) => { + const target = event.target instanceof Element ? event.target : null; + const analyticsTarget = target?.closest("[data-analytics]"); + const analyticsEvent = analyticsTarget?.dataset.analytics as AnalyticsEvent | undefined; + if (analyticsEvent) { + const href = analyticsTarget instanceof HTMLAnchorElement ? analyticsTarget.href : undefined; + window.dispatchMemoRepoEvent?.(analyticsEvent, href ? { href } : undefined); + } + + const button = target?.closest("[data-copy-button]"); + if (!button) return; + + const code = button.dataset.copyTarget ? document.getElementById(button.dataset.copyTarget) : null; + const label = button.querySelector("[data-copy-button-label]"); + const status = document.querySelector("[data-copy-status]"); + if (!code || !label) return; + + const defaultLabel = button.dataset.copyLabel ?? "Copy"; + try { + await copyText(code.textContent ?? ""); + label.textContent = button.dataset.copiedLabel ?? "Copied"; + if (status) status.textContent = label.textContent; + } catch { + label.textContent = button.dataset.copyFailedLabel ?? "Could not copy"; + if (status) status.textContent = label.textContent; + } + + window.setTimeout(() => { + label.textContent = defaultLabel; + }, 1800); +}); + +for (const details of document.querySelectorAll("[data-mobile-nav]")) { + const summary = details.querySelector("summary"); + const syncNavigationLabel = () => { + if (!summary) return; + summary.setAttribute( + "aria-label", + details.open + ? (summary.dataset.closeLabel ?? "Close navigation") + : (summary.dataset.openLabel ?? "Open navigation"), + ); + }; + details.addEventListener("toggle", syncNavigationLabel); + syncNavigationLabel(); + details.addEventListener("keydown", (event) => { + if (event.key === "Escape" && details.open) { + details.open = false; + summary?.focus(); + } + }); + details.querySelectorAll("a").forEach((link) => { + link.addEventListener("click", () => { + details.open = false; + }); + }); +} diff --git a/apps/landing/src/styles/global.css b/apps/landing/src/styles/global.css new file mode 100644 index 0000000..4b0d61b --- /dev/null +++ b/apps/landing/src/styles/global.css @@ -0,0 +1,3153 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + min-width: 20rem; + scroll-behavior: smooth; + scroll-padding-top: calc(var(--header-height) + 1px); + background: var(--color-canvas); +} + +body { + margin: 0; + overflow-x: hidden; + background: var(--color-canvas); + color: var(--color-text); + font-family: var(--font-sans); + font-size: var(--text-body); + line-height: 1.65; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +::selection { + background: color-mix(in srgb, var(--color-brand) 40%, transparent); + color: var(--color-text); +} + +img, +svg { + display: block; + max-width: 100%; +} + +img { + height: auto; +} + +button, +input, +summary { + font: inherit; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1, +h2, +h3 { + color: var(--color-text); + letter-spacing: -0.025em; + text-wrap: balance; +} + +h1 { + max-width: 14ch; + margin-bottom: 1.5rem; + font-size: var(--text-display); + font-weight: 800; + line-height: 1.02; +} + +h2 { + max-width: 21ch; + margin-bottom: 1.25rem; + font-size: var(--text-title); + font-weight: 800; + line-height: 1.08; +} + +h3 { + margin-bottom: 0.55rem; + font-size: var(--text-body); + font-weight: 750; + line-height: 1.3; +} + +p { + color: var(--color-text-muted); +} + +a { + color: var(--color-brand-soft); + text-decoration-thickness: 0.08em; + text-underline-offset: 0.2em; +} + +a:hover { + color: var(--color-text); +} + +:where(a, button, summary):focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 3px; +} + +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.container { + width: min(100%, var(--container)); + margin-inline: auto; + padding-inline: clamp(1rem, 4vw, 3rem); +} + +.section { + padding-block: clamp(5rem, 9vw, 8.5rem); +} + +.section--alternate { + border-block: 1px solid var(--color-border); + background: var(--color-surface); +} + +.narrow-intro { + margin-bottom: clamp(2.5rem, 5vw, 4.5rem); +} + +.narrow-intro > :not(.eyebrow) { + max-width: var(--reading-width); +} + +.section-lead { + max-width: var(--reading-width); + margin-bottom: 0; + font-size: var(--text-body); + line-height: 1.65; +} + +.eyebrow { + margin-bottom: 0.8rem; + color: var(--color-brand-soft); + font-family: var(--font-mono); + font-size: var(--text-sm); + font-weight: 800; + letter-spacing: 0.11em; + line-height: 1.4; + text-transform: uppercase; +} + +.item-index { + display: block; + margin-bottom: 1.1rem; + color: var(--color-brand-soft); + font-family: var(--font-mono); + font-size: var(--text-sm); + font-weight: 800; + letter-spacing: 0.08em; +} + +.skip-link { + position: fixed; + z-index: 1000; + top: 0.75rem; + left: 0.75rem; + padding: 0.7rem 1rem; + border-radius: var(--radius-sm); + background: var(--color-focus); + color: var(--color-canvas); + font-weight: 800; + transform: translateY(-160%); + transition: transform 180ms ease; +} + +.skip-link:focus { + color: var(--color-canvas); + transform: translateY(0); +} + +.site-header { + position: sticky; + z-index: 100; + top: 0; + min-height: var(--header-height); + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); + background: color-mix(in srgb, var(--color-canvas) 96%, transparent); +} + +.site-header__inner { + display: flex; + min-height: var(--header-height); + align-items: center; + gap: 1.25rem; +} + +.brand-link { + flex: none; + color: var(--color-text); + text-decoration: none; +} + +.brand-link:hover { + color: var(--color-text); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.7rem; +} + +.brand__mark { + width: 1.875rem; + height: 1.875rem; +} + +.brand__wordmark { + font-size: var(--text-body); + font-weight: 800; + letter-spacing: -0.025em; +} + +.brand--compact .brand__mark { + width: 1.65rem; + height: 1.65rem; +} + +.brand--compact .brand__wordmark { + font-size: var(--text-body); +} + +.desktop-nav { + display: flex; + margin-left: auto; + align-items: center; + gap: clamp(0.5rem, 1.4vw, 1.4rem); +} + +.desktop-nav a { + display: inline-flex; + min-height: 2.75rem; + align-items: center; + color: var(--color-text-muted); + font-size: var(--text-sm); + font-weight: 700; + text-decoration: none; +} + +.desktop-nav a:hover { + color: var(--color-text); +} + +.mobile-nav { + display: none; + margin-left: auto; +} + +.button { + display: inline-flex; + min-height: 2.9rem; + align-items: center; + justify-content: center; + padding: 0.7rem 1.15rem; + border: 1px solid transparent; + border-radius: 0.7rem; + font-size: var(--text-sm); + font-weight: 750; + line-height: 1.25; + text-align: center; + text-decoration: none; + transition: background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease; +} + +.button--primary { + background: var(--color-brand); + color: var(--color-text); + box-shadow: 0 10px 28px color-mix(in srgb, var(--color-brand) 20%, transparent); +} + +.button--primary:hover { + background: var(--color-brand); + color: var(--color-text); + box-shadow: 0 12px 32px color-mix(in srgb, var(--color-brand) 28%, transparent); +} + +.button--secondary { + border-color: var(--color-border); + background: var(--color-surface); + color: var(--color-text); +} + +.button--secondary:hover { + border-color: var(--color-brand-soft); + background: var(--color-surface-raised); + color: var(--color-text); +} + +.desktop-cta { + min-height: 2.75rem; + margin-left: 0.25rem; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; +} + +.hero { + position: relative; + overflow: clip; + padding-top: clamp(5rem, 9vw, 8rem); + background: radial-gradient(circle at 18% 4%, color-mix(in srgb, var(--color-brand) 15%, transparent), transparent 34rem); +} + +.hero__copy { + max-width: 61rem; +} + +.hero__lead { + max-width: 74ch; + margin-bottom: 1.75rem; + color: var(--color-text-muted); + font-size: var(--text-body); + line-height: 1.65; +} + +.hero__support { + max-width: 70ch; + margin: 1.35rem 0 0; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.evidence-strip { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + margin: clamp(2.5rem, 5vw, 4rem) 0 0; + padding: 0; + list-style: none; +} + +.evidence-strip li { + display: inline-flex; + min-height: 2.4rem; + align-items: center; + gap: 0.55rem; + padding: 0.45rem 0.8rem; + border: 1px solid var(--color-border); + border-radius: 999px; + background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); + font-weight: 700; +} + +.evidence-strip li::before { + width: 0.42rem; + height: 0.42rem; + border-radius: 50%; + background: var(--color-success); + content: ""; +} + +.product-shot { + margin: clamp(3rem, 7vw, 5.5rem) 0 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-raised); +} + +.product-shot__bar { + display: flex; + min-height: 2.7rem; + align-items: center; + gap: 0.4rem; + padding-inline: 1rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-raised); +} + +.product-shot__bar > span { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--color-border); +} + +.product-shot__bar strong { + margin-left: auto; + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); + letter-spacing: 0.04em; +} + +.product-shot picture, +.product-shot img { + width: 100%; +} + +.product-shot figcaption { + padding: 0.9rem 1rem; + border-top: 1px solid var(--color-border); + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); + text-align: center; +} + +.problem-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} + +.problem-item, +.feature-item, +.security-control, +.scope-column, +.doc-link { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.problem-item { + min-height: 15rem; + padding: clamp(1.4rem, 3vw, 2rem); +} + +.problem-item p, +.feature-item p, +.security-control p { + margin-bottom: 0; +} + +.section-conclusion { + margin-top: 2rem; + margin-bottom: 0; + color: var(--color-text); + font-size: var(--text-body); + font-weight: 700; +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 1rem; +} + +.feature-item { + grid-column: span 3; + padding: clamp(1.4rem, 2.5vw, 1.9rem); +} + +.feature-item--featured { + grid-column: span 6; + min-height: 19rem; + border-color: color-mix(in srgb, var(--color-brand) 45%, transparent); + background: linear-gradient(145deg, color-mix(in srgb, var(--color-brand) 13%, transparent), transparent 48%), var(--color-surface); +} + +.feature-item--featured h3 { + max-width: 24ch; + font-size: var(--text-body); +} + +.workflow-list { + position: relative; + display: grid; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.workflow-list::before { + position: absolute; + top: 2rem; + bottom: 2rem; + left: 1.45rem; + width: 1px; + background: linear-gradient(var(--color-brand), var(--color-border)); + content: ""; +} + +.workflow-list li { + position: relative; + display: grid; + grid-template-columns: 4.3rem minmax(0, 1fr); + gap: 1.25rem; + padding: 1.25rem 0; +} + +.workflow-list__number { + z-index: 1; + display: grid; + width: 2.9rem; + height: 2.9rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-brand) 54%, transparent); + border-radius: 50%; + background: var(--color-surface); + color: var(--color-brand-soft); + font-family: var(--font-mono); + font-size: var(--text-sm); + font-weight: 800; +} + +.workflow-list p { + max-width: 68ch; + margin-bottom: 0; +} + +.provider-note { + display: grid; + grid-template-columns: minmax(10rem, 0.35fr) minmax(0, 1fr); + gap: 1.5rem; + margin-top: 2.5rem; + padding: clamp(1.4rem, 3vw, 2rem); + border: 1px solid color-mix(in srgb, var(--color-brand) 48%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-brand) 10%, transparent); +} + +.provider-note p { + margin-bottom: 0; +} + +.architecture-figure { + margin: 0; + padding: clamp(1.25rem, 3vw, 2rem); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-card); +} + +.architecture-overview { + margin-bottom: 2rem; +} + +.architecture-overview__line { + fill: none; + stroke: var(--color-border); + stroke-width: 2; +} + +.architecture-overview__node { + fill: var(--color-surface-raised); + stroke: var(--color-border); + stroke-width: 2; +} + +.architecture-overview__node.is-inside { + fill: var(--color-brand); + stroke: var(--color-brand-soft); +} + +.architecture-overview text { + fill: var(--color-text); + font-family: var(--font-mono); + font-size: var(--text-sm); + font-weight: 800; +} + +.architecture-sequence, +.architecture-boundary ol { + margin: 0; + padding: 0; + list-style: none; +} + +.architecture-sequence { + display: grid; + grid-template-columns: minmax(9rem, 0.8fr) minmax(20rem, 2fr) minmax(9rem, 0.8fr); + align-items: start; + gap: 1rem; +} + +.architecture-sequence > *, +.architecture-boundary, +.architecture-boundary ol { + min-width: 0; + max-width: 100%; +} + +.architecture-boundary { + padding: 1.1rem; + border: 1px dashed var(--color-brand-soft); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-brand) 8%, transparent); +} + +.architecture-boundary__label { + display: block; + color: var(--color-brand-soft); + font-family: var(--font-mono); + font-size: var(--text-sm); + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.architecture-boundary__support { + margin: 0.35rem 0 0.8rem; + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: 1.45; +} + +.architecture-boundary ol { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.55rem; +} + +.architecture-boundary ol:focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 4px; +} + +.architecture-node { + min-width: 0; + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + text-align: left; +} + +.architecture-node strong, +.architecture-node__scope { + display: block; +} + +.architecture-node strong { + font-size: var(--text-sm); + line-height: 1.4; +} + +.architecture-node__number, +.architecture-node__scope { + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +.architecture-node__number { + margin-bottom: 0.55rem; +} + +.architecture-node__scope { + margin-top: 0.55rem; +} + +.architecture-node--outside { + border-color: var(--color-border); +} + +.architecture-figure figcaption { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin-top: 1.25rem; +} + +.architecture-figure figcaption p { + margin: 0; + padding: 1rem; + background: var(--color-surface); + font-size: var(--text-sm); +} + +.security-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.security-control { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + gap: 1rem; + padding: 1.4rem; +} + +.security-control__marker { + width: 0.72rem; + height: 0.72rem; + margin-top: 0.35rem; + border: 2px solid var(--color-success); + border-radius: 50%; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-success) 10%, transparent); +} + +.boundary-callout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(16rem, 0.8fr); + gap: 2rem; + margin-top: 2rem; + padding-top: 2rem; +} + +.boundary-callout > div:first-child { + padding: 1.5rem; + border: 1px solid color-mix(in srgb, var(--color-warning) 48%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-warning) 7%, transparent); +} + +.boundary-callout p:last-child { + margin-bottom: 0; + color: var(--color-text); + font-weight: 650; +} + +.text-link-row { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; +} + +.text-link-row a, +.text-link { + min-height: 2.75rem; + align-content: center; + font-weight: 750; +} + +.scope-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.scope-column { + padding: clamp(1.4rem, 3vw, 2rem); +} + +.scope-column--included { + border-top: 3px solid var(--color-success); +} + +.scope-column--excluded { + border-top: 3px solid var(--color-warning); +} + +.scope-column ul, +.requirements-list { + display: grid; + gap: 0.7rem; + margin: 1.2rem 0 0; + padding: 0; + list-style: none; +} + +.scope-column li, +.requirements-list li { + position: relative; + padding-left: 1.4rem; + color: var(--color-text-muted); +} + +.scope-column li::before, +.requirements-list li::before { + position: absolute; + top: 0.78em; + left: 0; + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: var(--color-success); + content: ""; +} + +.scope-column--excluded li::before { + border-radius: 1px; + background: var(--color-warning); +} + +.quickstart__layout { + display: grid; + grid-template-columns: minmax(17rem, 0.75fr) minmax(0, 1.45fr); + gap: clamp(2.5rem, 6vw, 6rem); + align-items: start; +} + +.quickstart__intro { + position: sticky; + top: calc(var(--header-height) + 2rem); +} + +.quickstart__intro h3 { + margin-top: 2rem; +} + +.quickstart__intro .text-link { + display: inline-flex; + margin-top: 1.5rem; +} + +.quickstart__commands { + display: grid; + gap: 1rem; + min-width: 0; +} + +.code-sample { + min-width: 0; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.code-sample__header { + display: flex; + min-height: 3.7rem; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem 0.7rem 0.65rem 1rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-raised); +} + +.code-sample__header h3 { + margin: 0; + font-size: var(--text-sm); + letter-spacing: 0; +} + +.code-sample__header div > span { + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); + text-transform: uppercase; +} + +.copy-button { + min-width: 4.5rem; + min-height: 2.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; + font-size: var(--text-sm); + font-weight: 750; +} + +.copy-button:hover { + border-color: var(--color-brand-soft); + background: var(--color-surface-raised); +} + +.code-sample pre { + max-width: 100%; + margin: 0; + overflow-x: auto; + padding: 1.15rem; + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: 1.65; + tab-size: 2; +} + +.code-sample pre:focus-visible { + outline-offset: -4px; +} + +.quickstart__note, +.after-start { + margin: 0; + padding: 1rem 1.1rem; + background: var(--color-surface); + font-size: var(--text-sm); +} + +.platform-details, +.faq-list details { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.platform-details > summary, +.faq-list summary { + position: relative; + min-height: 3.4rem; + padding: 0.9rem 3.25rem 0.9rem 1.1rem; + color: var(--color-text); + cursor: pointer; + font-weight: 750; + list-style: none; +} + +.platform-details > summary::-webkit-details-marker, +.faq-list summary::-webkit-details-marker, +.mobile-nav > summary::-webkit-details-marker { + display: none; +} + +.details-marker { + position: absolute; + top: 50%; + right: 1.15rem; + color: var(--color-brand-soft); + font-family: var(--font-mono); + font-size: var(--text-body); + transform: translateY(-50%); +} + +.platform-details[open] > summary .details-marker, +.faq-list details[open] summary .details-marker { + color: transparent; +} + +.platform-details[open] > summary .details-marker::after, +.faq-list details[open] summary .details-marker::after { + content: "−"; + color: var(--color-brand-soft); + font-size: var(--text-body); +} + +.platform-details > p { + margin: 0; + padding: 0 1.1rem 1rem; +} + +.platform-details .code-sample { + margin: 0 1rem 1rem; +} + +.faq-list { + display: grid; + max-width: 58rem; + gap: 0.75rem; +} + +.faq-list details[open] { + border-color: color-mix(in srgb, var(--color-brand) 52%, transparent); +} + +.faq-list p { + margin: 0; + padding: 0 1.1rem 1.15rem; +} + +.docs-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.doc-link { + display: flex; + min-height: 10rem; + flex-direction: column; + gap: 0.65rem; + padding: 1.35rem; + color: var(--color-text); + text-decoration: none; + transition: border-color 180ms ease, background-color 180ms ease; +} + +.doc-link:hover { + border-color: var(--color-brand-soft); + background: var(--color-surface-raised); + color: var(--color-text); +} + +.doc-link strong::after { + margin-left: 0.45rem; + color: var(--color-brand-soft); + content: "↗"; + font-family: var(--font-mono); +} + +.doc-link span { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.final-cta { + background: radial-gradient(circle at 50% 100%, color-mix(in srgb, var(--color-brand) 18%, transparent), transparent 31rem); +} + +.final-cta__inner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.final-cta__inner h2, +.final-cta__inner p:not(.eyebrow) { + max-width: 44rem; +} + +.final-cta__inner p:not(.eyebrow) { + font-size: var(--text-body); +} + +.site-footer { + padding-block: 3.5rem; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.site-footer__grid { + display: grid; + grid-template-columns: minmax(16rem, 0.8fr) minmax(0, 1.2fr); + gap: 3rem; +} + +.site-footer p { + max-width: 32rem; + margin: 1rem 0 0; +} + +.site-footer__meta { + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +.site-footer nav { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; + gap: 0.5rem 1.25rem; +} + +.site-footer nav a { + min-height: 2.75rem; + align-content: center; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.not-found { + display: grid; + min-height: calc(100svh - var(--header-height)); + place-items: center; + background: radial-gradient(circle at 50% 30%, color-mix(in srgb, var(--color-brand) 16%, transparent), transparent 28rem); +} + +.not-found__inner { + max-width: 48rem; + padding-block: 5rem; + text-align: center; +} + +.not-found__inner > .brand { + margin-bottom: 2rem; +} + +.not-found h1 { + max-width: 14ch; + margin-inline: auto; + font-size: var(--text-display); +} + +.not-found p:not(.eyebrow) { + max-width: 50ch; + margin: 0 auto 1.75rem; +} + +@media (max-width: 72rem) { + .feature-item { + grid-column: span 6; + } + + .feature-item--featured { + min-height: 17rem; + } + + .architecture-overview { + display: none; + } + + .architecture-sequence { + grid-template-columns: 1fr; + } + + .architecture-boundary ol { + width: 100%; + grid-template-columns: repeat(5, minmax(8.5rem, 1fr)); + overflow-x: auto; + padding-bottom: 0.4rem; + } +} + +@media (max-width: 56.25rem) { + :root { + --header-height: 4.25rem; + } + + .desktop-nav, + .desktop-cta { + display: none; + } + + .mobile-nav { + display: block; + } + + .mobile-nav > summary { + display: grid; + width: 3rem; + height: 3rem; + place-items: center; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + cursor: pointer; + list-style: none; + } + + .mobile-nav__icon { + display: inline-grid; + place-items: center; + } + + .mobile-nav__icon .icon { + grid-area: 1 / 1; + transition: opacity 160ms ease, transform 160ms ease; + } + + .mobile-nav__close-icon { + opacity: 0; + transform: rotate(-90deg) scale(0.8); + } + + .mobile-nav[open] .mobile-nav__menu-icon { + opacity: 0; + transform: rotate(90deg) scale(0.8); + } + + .mobile-nav[open] .mobile-nav__close-icon { + opacity: 1; + transform: none; + } + + .mobile-nav nav { + position: absolute; + top: calc(100% + 0.55rem); + right: clamp(1rem, 4vw, 3rem); + left: clamp(1rem, 4vw, 3rem); + display: grid; + gap: 0.25rem; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + box-shadow: var(--shadow-card); + } + + .mobile-nav nav > a:not(.button) { + min-height: 2.9rem; + padding-inline: 0.8rem; + align-content: center; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + font-weight: 700; + text-decoration: none; + } + + .mobile-nav nav > a:not(.button):hover { + background: var(--color-surface-raised); + color: var(--color-text); + } + + .problem-grid, + .security-grid, + .scope-grid, + .docs-grid, + .architecture-figure figcaption { + grid-template-columns: 1fr; + } + + .provider-note, + .boundary-callout, + .quickstart__layout, + .site-footer__grid { + grid-template-columns: 1fr; + } + + .provider-note { + gap: 0.5rem; + } + + .quickstart__intro { + position: static; + } + + .site-footer nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 56.25rem) and (max-height: 30rem) { + .mobile-nav nav { + gap: 0.125rem; + max-height: calc(100dvh - var(--header-height) - 1rem); + overflow-y: auto; + padding: 0.5rem; + overscroll-behavior: contain; + } +} + +@media (max-width: 43.75rem) { + .section { + padding-block: 4.5rem; + } + + h1 { + font-size: var(--text-display); + } + + h2 { + font-size: var(--text-title); + } + + .hero { + padding-top: 4.5rem; + } + + .button-row { + display: grid; + grid-template-columns: 1fr; + } + + .button-row .button { + width: 100%; + } + + .evidence-strip { + gap: 0.5rem; + } + + .evidence-strip li { + min-height: 2.2rem; + padding: 0.38rem 0.65rem; + font-size: var(--text-sm); + } + + .product-shot__bar strong { + display: none; + } + + .feature-grid { + grid-template-columns: 1fr; + } + + .feature-item, + .feature-item--featured { + grid-column: auto; + min-height: 0; + } + + .workflow-list li { + grid-template-columns: 3.5rem minmax(0, 1fr); + gap: 0.75rem; + } + + .architecture-boundary ol { + display: grid; + grid-template-columns: 1fr; + overflow: visible; + } + + .site-footer nav { + grid-template-columns: 1fr; + } +} + +@media (max-width: 24rem) { + .brand__wordmark { + font-size: var(--text-body); + } + + .brand__mark { + width: 1.55rem; + height: 1.55rem; + } + + .product-shot { + border-radius: var(--radius-md); + } + + .code-sample__header { + align-items: flex-start; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +/* Expressive ocean system */ +body { + background: + radial-gradient(circle at 50% -10%, color-mix(in srgb, var(--color-accent) 16%, transparent), transparent 32rem), + var(--color-canvas); +} + +.landing-main { + position: relative; + isolation: isolate; + background: + linear-gradient(180deg, transparent 0 42rem, var(--color-canvas) 67rem), + radial-gradient(circle at 12% 48%, color-mix(in srgb, var(--color-brand) 8%, transparent), transparent 30rem), + radial-gradient(circle at 88% 72%, color-mix(in srgb, var(--color-accent) 9%, transparent), transparent 34rem); +} + +.site-header { + border-bottom-color: color-mix(in srgb, var(--color-brand) 17%, transparent); + background: color-mix(in srgb, var(--color-canvas) 72%, transparent); + box-shadow: 0 12px 40px color-mix(in srgb, var(--color-canvas) 20%, transparent); + backdrop-filter: blur(18px) saturate(130%); +} + +.site-header .button--primary, +.button--primary { + border-color: color-mix(in srgb, var(--color-brand-soft) 58%, transparent); + background: linear-gradient(135deg, var(--color-brand), var(--color-brand)); + box-shadow: 0 12px 34px color-mix(in srgb, var(--color-brand) 30%, transparent), inset 0 1px color-mix(in srgb, var(--color-text) 18%, transparent); +} + +.button--primary:hover { + background: linear-gradient(135deg, var(--color-brand), var(--color-brand)); + box-shadow: 0 15px 42px color-mix(in srgb, var(--color-brand) 42%, transparent), inset 0 1px color-mix(in srgb, var(--color-text) 22%, transparent); + transform: translateY(-2px); +} + +.button--secondary { + border-color: color-mix(in srgb, var(--color-brand-soft) 55%, transparent); + background: color-mix(in srgb, var(--color-surface) 48%, transparent); + backdrop-filter: blur(12px); +} + +.section--alternate { + border-block-color: color-mix(in srgb, var(--color-accent) 10%, transparent); + background: + linear-gradient(90deg, transparent, color-mix(in srgb, var(--color-surface-raised) 78%, transparent) 18% 82%, transparent), + color-mix(in srgb, var(--color-canvas) 58%, transparent); +} + +.narrow-intro { + position: relative; + z-index: 2; +} + +.eyebrow { + color: var(--color-brand-soft); + text-shadow: 0 0 22px color-mix(in srgb, var(--color-brand) 48%, transparent); +} + +.item-icon { + display: grid; + width: 3.6rem; + height: 3.6rem; + margin-bottom: 1.5rem; + align-items: center; + justify-items: start; + color: var(--color-brand-soft); + filter: drop-shadow(0 0 12px color-mix(in srgb, var(--color-brand) 46%, transparent)); +} + +.item-index { + position: absolute; + top: clamp(1.6rem, 3vw, 2.5rem); + right: 1rem; + margin: 0; + color: color-mix(in srgb, var(--color-brand-soft) 48%, transparent); + line-height: 3.6rem; +} + +/* Hero */ +.hero { + min-height: calc(100svh - var(--header-height)); + overflow: clip; + padding-top: clamp(1.5rem, 2vw, 2rem); + background-image: + linear-gradient(180deg, color-mix(in srgb, var(--color-canvas) 26%, transparent), color-mix(in srgb, var(--color-canvas) 4%, transparent) 40%, var(--color-canvas) 96%), + linear-gradient(90deg, color-mix(in srgb, var(--color-canvas) 74%, transparent), transparent 55%, color-mix(in srgb, var(--color-canvas) 20%, transparent)), + var(--ocean-art); + background-position: center top; + background-size: cover, cover, min(100%, 90rem) auto; + background-repeat: no-repeat; +} + +.hero::before { + position: absolute; + z-index: -1; + inset: 0; + background: + radial-gradient(circle at 64% 22%, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent 20rem), + radial-gradient(circle at 46% 35%, color-mix(in srgb, var(--color-brand) 11%, transparent), transparent 24rem); + content: ""; + pointer-events: none; +} + +.hero__grid { + display: grid; + grid-template-columns: minmax(0, 0.88fr) minmax(34rem, 1.12fr); + gap: clamp(2rem, 4vw, 5rem); + align-items: center; +} + +.hero__copy { + width: 100%; + min-width: 0; + max-width: 37rem; +} + +.hero h1 { + width: 100%; + max-width: 15ch; + margin-bottom: 1.45rem; + font-size: var(--text-display); + text-shadow: 0 4px 34px color-mix(in srgb, var(--color-canvas) 42%, transparent); +} + +.hero h1 span { + display: block; + width: auto; + background: linear-gradient(100deg, var(--color-brand-soft), var(--color-brand) 62%, var(--color-accent)); + background-clip: text; + color: transparent; + filter: drop-shadow(0 0 17px color-mix(in srgb, var(--color-brand) 18%, transparent)); +} + +.hero__lead { + max-width: 57ch; + color: var(--color-text-muted); + font-size: var(--text-body); +} + +.hero__support { + display: flex; + gap: 0.6rem; + align-items: flex-start; + color: var(--color-text-muted); +} + +.hero__support .icon { + flex: 0 0 auto; + margin-top: 0.25rem; + color: var(--color-accent); +} + +.hero-map { + position: relative; + min-height: 33rem; + color: var(--color-text); +} + +.hero-map::before { + position: absolute; + inset: 8% 9% 8%; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--color-brand) 22%, transparent), transparent 66%); + content: ""; + filter: blur(22px); +} + +.hero-map__wires { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +.hero-map__wires path { + fill: none; + stroke: url(#wire-gradient); + stroke-width: 2; + stroke-linecap: round; + filter: url(#wire-glow); +} + +.hero-map__wires path[data-wire-gradient="vertical"] { + stroke: url(#wire-gradient-vertical); +} + +.hero-map__cluster, +.hero-map__core, +.hero-map__gateway, +.hero-map__consumer { + position: absolute; + border: 1px solid color-mix(in srgb, var(--color-brand-soft) 35%, transparent); + background: linear-gradient(150deg, color-mix(in srgb, var(--color-surface-raised) 78%, transparent), color-mix(in srgb, var(--color-surface) 66%, transparent)); + box-shadow: 0 18px 44px color-mix(in srgb, var(--color-canvas) 34%, transparent), inset 0 1px color-mix(in srgb, var(--color-text) 8%, transparent), 0 0 28px color-mix(in srgb, var(--color-brand) 8%, transparent); + backdrop-filter: blur(15px); +} + +.hero-map__cluster { + top: 3.7rem; + display: grid; + width: 9.5rem; + gap: 0.45rem; + padding: 0.85rem; + border-radius: 1rem; + transform: perspective(28rem) rotateY(5deg); +} + +.hero-map__repositories { + left: 0; +} + +.hero-map__snapshots { + right: 0; + transform: perspective(28rem) rotateY(-5deg); +} + +.hero-map__cluster > span:not(.hero-map__label) { + display: flex; + gap: 0.42rem; + align-items: center; + padding: 0.48rem; + border-radius: 0.48rem; + background: color-mix(in srgb, var(--color-text) 3%, transparent); + color: var(--color-text); + font-size: var(--text-sm); + line-height: 1.2; +} + +.hero-map__cluster .icon { + flex: 0 0 auto; + color: var(--color-brand-soft); +} + +.hero-map__label { + color: var(--color-brand-soft); + font-size: var(--text-sm); + font-weight: 800; +} + +.hero-map__core { + top: 5rem; + left: 50%; + display: grid; + width: 10.7rem; + min-height: 11.5rem; + place-items: center; + align-content: center; + padding: 1rem; + border-color: color-mix(in srgb, var(--color-brand) 62%, transparent); + border-radius: 1.1rem; + box-shadow: 0 0 40px color-mix(in srgb, var(--color-brand) 22%, transparent), inset 0 1px color-mix(in srgb, var(--color-text) 11%, transparent); + text-align: center; + transform: translateX(-50%); +} + +.hero-map__core .icon { + margin-block: 0.5rem; + color: var(--color-brand-soft); + filter: drop-shadow(0 0 13px color-mix(in srgb, var(--color-brand) 65%, transparent)); +} + +.hero-map__core strong { + font-size: var(--text-sm); +} + +.hero-map__core small, +.hero-map__gateway small { + display: block; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.hero-map__gateway { + top: 22.6rem; + left: 50%; + display: flex; + width: 17rem; + min-height: 4rem; + gap: 0.8rem; + align-items: center; + justify-content: center; + border-color: color-mix(in srgb, var(--color-brand) 58%, transparent); + border-radius: 0.9rem; + transform: translateX(-50%); +} + +.hero-map__gateway .icon { + color: var(--color-brand-soft); + filter: drop-shadow(0 0 8px color-mix(in srgb, var(--color-brand) 62%, transparent)); +} + +.hero-map__gateway strong { + display: block; + font-size: var(--text-sm); +} + +.hero-map__consumer { + bottom: -1rem; + display: grid; + width: 7rem; + gap: 0.35rem; + justify-items: center; + padding: 0.65rem; + border: 0; + background: transparent; + box-shadow: none; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.hero-map__consumer .icon { + color: var(--color-accent); +} + +.hero-map__consumer--left { + left: 7.3rem; +} + +.hero-map__consumer--right { + right: 7.3rem; +} + +.product-shot-wrap { + perspective: 90rem; +} + +.product-shot-motion { + transform-origin: center top; +} + +.product-shot { + position: relative; + max-width: 68rem; + margin: clamp(3rem, 7vw, 6rem) auto 0; + border-color: color-mix(in srgb, var(--color-brand) 48%, transparent); + border-radius: 1.15rem; + background: var(--color-surface); + box-shadow: 0 40px 100px color-mix(in srgb, var(--color-canvas) 62%, transparent), 0 0 70px color-mix(in srgb, var(--color-accent) 10%, transparent), 0 0 36px color-mix(in srgb, var(--color-brand) 12%, transparent); + transform: rotateX(1.5deg) rotateZ(-0.4deg); +} + +.product-shot::before { + position: absolute; + z-index: -1; + inset: -2rem 8% 30%; + border-radius: 50%; + background: color-mix(in srgb, var(--color-brand) 20%, transparent); + content: ""; + filter: blur(65px); +} + +.product-shot img { + border-radius: 1.08rem 1.08rem 0 0; +} + +.product-shot figcaption { + display: flex; + gap: 0.55rem; + align-items: center; + justify-content: center; + border-top-color: color-mix(in srgb, var(--color-brand) 24%, transparent); + color: var(--color-text-muted); +} + +.product-shot figcaption .icon { + color: var(--color-brand-soft); +} + +/* Open editorial sections */ +.problem-grid, +.feature-grid { + border-block: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); +} + +.problem-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; +} + +.problem-item, +.feature-item, +.feature-item--featured { + position: relative; + min-height: 17rem; + padding: clamp(1.6rem, 3vw, 2.5rem); + border: 0; + border-right: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.problem-item:last-child { + border-right: 0; +} + +.problem-item:hover, +.feature-item:hover { + background: linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 9%, transparent), transparent); +} + +.problem-item h3, +.feature-item h3 { + font-size: var(--text-body); +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; +} + +.feature-item { + grid-column: auto; + border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); +} + +.feature-item:nth-child(3n) { + border-right: 0; +} + +.feature-item:nth-last-child(-n + 3) { + border-bottom: 0; +} + +.feature-item--featured { + min-height: 17rem; + border-color: color-mix(in srgb, var(--color-accent) 13%, transparent); + background: transparent; +} + +/* Workflow as a constellation */ +.workflow-section { + overflow: clip; +} + +.workflow-list { + position: relative; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1rem; + padding: 0; + list-style: none; +} + +.workflow-list::before { + content: none; +} + +.workflow-list li { + position: relative; + display: grid; + grid-template-columns: 1fr; + gap: 0.95rem; + justify-items: center; + padding: 0; + text-align: center; +} + +.workflow-list li:not(:last-child)::after { + position: absolute; + z-index: 0; + top: calc(2.25rem - 1px); + right: calc(-50% - 1rem + 2.8rem); + left: calc(50% + 2.8rem); + height: 2px; + background: repeating-linear-gradient(90deg, var(--color-brand) 0 6px, transparent 6px 16px); + filter: drop-shadow(0 0 5px color-mix(in srgb, var(--color-brand) 70%, transparent)); + content: ""; + pointer-events: none; + transform: scaleX(var(--motion-connector-progress, 1)); + transform-origin: left center; +} + +.workflow-list__number { + z-index: 1; + display: grid; + width: 4.5rem; + height: 4.5rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-brand-soft) 62%, transparent); + background: radial-gradient(circle, var(--color-surface-raised) 0%, var(--color-canvas) 70%); + box-shadow: 0 0 28px color-mix(in srgb, var(--color-brand) 22%, transparent); + color: var(--color-brand-soft); +} + +.workflow-list__index { + position: absolute; + z-index: 2; + top: -0.65rem; + left: calc(50% - 2.9rem); + display: grid; + width: 1.55rem; + height: 1.55rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-brand-soft) 40%, transparent); + border-radius: 50%; + background: var(--color-surface-raised); + color: var(--color-brand-soft); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +.workflow-list h3 { + margin-bottom: 0.5rem; + font-size: var(--text-body); +} + +.workflow-list p { + margin: 0 auto; + font-size: var(--text-sm); + line-height: 1.55; +} + +.provider-note { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + max-width: 54rem; + margin: 3.5rem auto 0; + gap: 1.15rem; + align-items: start; + padding: 1.5rem 0; + border: 0; + border-block: 1px solid color-mix(in srgb, var(--color-brand-soft) 27%, transparent); + border-radius: 0; + background: transparent; +} + +.provider-note > .icon { + margin-top: 0.15rem; + color: var(--color-brand-soft); + filter: drop-shadow(0 0 12px color-mix(in srgb, var(--color-brand) 60%, transparent)); +} + +.provider-note .eyebrow { + margin-bottom: 0.3rem; +} + +.provider-note p:last-child { + margin: 0; +} + +/* Architecture constellation */ +.architecture-figure { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.architecture-constellation { + display: grid; + min-height: 25rem; + grid-template-columns: 9rem minmax(4rem, 1fr) 21rem minmax(4rem, 1fr) 9rem; + gap: 0.6rem; + align-items: center; +} + +.architecture-endpoint { + display: grid; + gap: 0.45rem; + justify-items: center; + text-align: center; +} + +.architecture-endpoint > span { + display: grid; + width: 4.5rem; + height: 4.5rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-accent) 34%, transparent); + border-radius: 1rem; + background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); + box-shadow: 0 0 30px color-mix(in srgb, var(--color-accent) 13%, transparent); + color: var(--color-accent); +} + +.architecture-endpoint strong { + font-size: var(--text-sm); +} + +.architecture-endpoint small { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.architecture-signal { + display: flex; + align-items: center; + justify-content: space-around; +} + +.architecture-signal::before { + position: absolute; + height: 1px; + background: linear-gradient(90deg, var(--color-accent), var(--color-brand)); + content: ""; +} + +.architecture-signal i { + z-index: 1; + width: 0.32rem; + height: 0.32rem; + border-radius: 50%; + background: var(--color-brand-soft); + box-shadow: 0 0 10px var(--color-brand); +} + +.architecture-core { + position: relative; + display: grid; + min-height: 21rem; + place-items: center; + align-content: center; + text-align: center; +} + +.architecture-core__orbit { + position: absolute; + border: 1px solid color-mix(in srgb, var(--color-brand) 35%, transparent); + border-radius: 50%; + box-shadow: inset 0 0 32px color-mix(in srgb, var(--color-brand) 7%, transparent), 0 0 28px color-mix(in srgb, var(--color-brand) 6%, transparent); +} + +.architecture-core__orbit--outer { + width: 20rem; + height: 20rem; +} + +.architecture-core__orbit--inner { + width: 12rem; + height: 12rem; + border-color: color-mix(in srgb, var(--color-accent) 28%, transparent); +} + +.architecture-core__center { + z-index: 2; + display: grid; + width: 6.5rem; + height: 6.5rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-brand-soft) 58%, transparent); + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--color-brand) 28%, transparent), var(--color-surface) 70%); + box-shadow: 0 0 42px color-mix(in srgb, var(--color-brand) 28%, transparent); + color: var(--color-brand-soft); +} + +.architecture-core > strong, +.architecture-core > small { + position: absolute; + z-index: 3; +} + +.architecture-core > strong { + top: 1rem; + color: var(--color-brand-soft); + font-size: var(--text-sm); +} + +.architecture-core > small { + bottom: 0.8rem; + max-width: 24ch; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.architecture-core__satellite { + position: absolute; + z-index: 4; + color: var(--color-text-muted); + font-size: var(--text-sm); + font-weight: 700; +} + +.architecture-core__satellite::before { + display: inline-block; + width: 0.42rem; + height: 0.42rem; + margin-right: 0.38rem; + border-radius: 50%; + background: var(--color-brand); + box-shadow: 0 0 9px var(--color-brand); + content: ""; +} + +.architecture-core__satellite--one { top: 4.2rem; left: 1.2rem; } +.architecture-core__satellite--two { top: 4.2rem; right: 1.2rem; } +.architecture-core__satellite--three { top: 50%; left: 0; } +.architecture-core__satellite--four { top: 50%; right: 0; } +.architecture-core__satellite--five { right: 50%; bottom: 3.7rem; transform: translateX(50%); } + +.architecture-rail { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + margin: 1rem 0 0; + padding: 0; + border-block: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + list-style: none; +} + +.architecture-rail li { + min-width: 0; + padding: 1rem 0.65rem; + border-right: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + text-align: center; +} + +.architecture-rail li:last-child { + border-right: 0; +} + +.architecture-rail span { + display: block; + color: var(--color-brand-soft); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +.architecture-rail strong { + display: block; + overflow: hidden; + margin-top: 0.3rem; + font-size: var(--text-sm); + text-overflow: ellipsis; +} + +.architecture-figure figcaption { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + margin-top: 1.4rem; + border-block: 1px solid color-mix(in srgb, var(--color-brand-soft) 20%, transparent); +} + +.architecture-figure figcaption p { + margin: 0; + padding: 1.25rem 1.5rem; + border: 0; + background: transparent; +} + +/* Security orbit and open controls */ +.security-section { + position: relative; + overflow: clip; +} + +.security-section::after { + position: absolute; + z-index: 0; + right: -9rem; + bottom: -18rem; + width: 42rem; + height: 42rem; + border: 1px solid color-mix(in srgb, var(--color-brand) 12%, transparent); + border-radius: 50%; + box-shadow: 0 0 90px color-mix(in srgb, var(--color-brand) 6%, transparent); + content: ""; +} + +.security-layout { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(23rem, 0.9fr) minmax(0, 1.1fr); + gap: clamp(3rem, 7vw, 7rem); + align-items: center; +} + +.security-orbit { + position: relative; + display: grid; + min-height: 31rem; + place-items: center; +} + +.security-orbit::before, +.security-orbit__ring { + position: absolute; + border-radius: 50%; +} + +.security-orbit::before { + width: 21rem; + height: 21rem; + background: radial-gradient(circle, color-mix(in srgb, var(--color-brand) 20%, transparent), transparent 68%); + content: ""; + filter: blur(15px); +} + +.security-orbit__ring { + border: 1px solid color-mix(in srgb, var(--color-brand) 33%, transparent); +} + +.security-orbit__ring::after { + position: absolute; + top: 50%; + left: -0.25rem; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--color-brand); + box-shadow: 0 0 12px var(--color-brand); + content: ""; +} + +.security-orbit__ring--outer { width: 29rem; height: 29rem; } +.security-orbit__ring--middle { width: 22rem; height: 22rem; border-color: color-mix(in srgb, var(--color-accent) 27%, transparent); } +.security-orbit__ring--inner { width: 15rem; height: 15rem; } + +.security-orbit__core { + z-index: 2; + display: grid; + width: 10rem; + height: 10rem; + place-items: center; + align-content: center; + border: 1px solid color-mix(in srgb, var(--color-brand-soft) 55%, transparent); + border-radius: 50%; + background: radial-gradient(circle, var(--color-surface-raised), var(--color-surface) 72%); + box-shadow: 0 0 48px color-mix(in srgb, var(--color-brand) 26%, transparent); + color: var(--color-brand-soft); + text-align: center; +} + +.security-orbit__core strong, +.security-orbit__core span { + display: block; +} + +.security-orbit__core strong { margin-top: 0.45rem; font-size: var(--text-sm); } +.security-orbit__core span { color: var(--color-text-muted); font-size: var(--text-sm); } + +.security-orbit__label { + position: absolute; + z-index: 3; + color: var(--color-text-muted); + font-size: var(--text-sm); + font-weight: 700; +} + +.security-orbit__label--top { top: 2.2rem; left: 50%; transform: translateX(-50%); } +.security-orbit__label--right { top: 50%; right: 0.5rem; } +.security-orbit__label--bottom { bottom: 2rem; left: 50%; transform: translateX(-50%); } + +.security-orbit__outside { + position: absolute; + z-index: 3; + top: 50%; + right: -1.2rem; + display: flex; + gap: 0.45rem; + align-items: center; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.security-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0; +} + +.security-control { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 1rem; + min-height: 0; + padding: 1.1rem 0; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.security-control__icon { + display: grid; + width: 2.7rem; + height: 2.7rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-brand-soft) 38%, transparent); + border-radius: 50%; + color: var(--color-brand-soft); + box-shadow: 0 0 20px color-mix(in srgb, var(--color-brand) 10%, transparent); +} + +.security-control h3 { + margin-bottom: 0.25rem; + font-size: var(--text-body); +} + +.security-control p { + margin: 0; + font-size: var(--text-sm); +} + +.boundary-callout { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2rem; + align-items: center; + margin-top: 3.5rem; + padding-block: 1.5rem; + border: 0; + border-block: 1px solid color-mix(in srgb, var(--color-brand-soft) 24%, transparent); + border-radius: 0; + background: transparent; +} + +.boundary-callout > div:first-child { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +/* Scope and documentation stay deliberately flat */ +.scope-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + border-block: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); +} + +.scope-column { + padding: clamp(1.5rem, 3vw, 2.5rem); + border: 0; + border-right: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + border-radius: 0; + background: transparent; +} + +.scope-column:last-child { + border-right: 0; +} + +.scope-column--included, +.scope-column--excluded { + border-color: color-mix(in srgb, var(--color-accent) 13%, transparent); +} + +.docs-section { + position: relative; + overflow: clip; +} + +.docs-section::before { + position: absolute; + top: -12rem; + left: -15rem; + width: 38rem; + height: 38rem; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 66%); + content: ""; +} + +.docs-grid { + position: relative; + display: block; + border-top: 1px solid color-mix(in srgb, var(--color-brand-soft) 24%, transparent); +} + +.doc-link { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 1.2rem; + min-height: 5.75rem; + align-items: start; + padding: 1rem 0.4rem; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-brand-soft) 20%, transparent); + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.doc-link:hover { + border-color: color-mix(in srgb, var(--color-brand-soft) 50%, transparent); + background: linear-gradient(90deg, color-mix(in srgb, var(--color-brand) 10%, transparent), transparent 68%); + color: var(--color-text); + transform: none; +} + +.doc-link__icon { + display: grid; + width: 3rem; + min-height: 1.5rem; + padding-top: 0.1rem; + place-items: start center; + color: var(--color-brand-soft); + filter: drop-shadow(0 0 10px color-mix(in srgb, var(--color-brand) 48%, transparent)); +} + +.doc-link__copy { + display: grid; + gap: 0.15rem; +} + +.doc-link__copy > span { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.doc-link strong::after { + content: none; +} + +.doc-link > .icon { + align-self: center; + color: var(--color-brand-soft); + transition: transform 180ms ease; +} + +.doc-link:hover > .icon { + transform: translateX(0.35rem); +} + +.final-cta { + position: relative; + overflow: hidden; + background: + radial-gradient(circle at 50% 100%, color-mix(in srgb, var(--color-brand) 30%, transparent), transparent 34rem), + radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 28rem); +} + +.final-cta::before { + position: absolute; + bottom: -17rem; + left: 50%; + width: 38rem; + height: 38rem; + border: 1px solid color-mix(in srgb, var(--color-brand-soft) 16%, transparent); + border-radius: 50%; + box-shadow: 0 0 70px color-mix(in srgb, var(--color-brand) 12%, transparent); + content: ""; + transform: translateX(-50%); +} + +.final-cta__inner { + position: relative; + z-index: 1; +} + +@media (min-width: 72.01rem) { + .hero-map { + transform: translateY(-3rem); + } +} + +@media (max-width: 72rem) { + .hero__grid { + grid-template-columns: minmax(0, 1fr); + } + + .hero__copy { + max-width: 44rem; + } + + .hero-map { + width: min(100%, 44rem); + margin-inline: auto; + } + + .architecture-constellation { + grid-template-columns: 7rem minmax(2rem, 1fr) 19rem minmax(2rem, 1fr) 7rem; + } + + .security-layout { + grid-template-columns: 1fr; + } + + .security-orbit { + width: min(100%, 32rem); + margin-inline: auto; + } +} + +@media (max-width: 56.25rem) { + .feature-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .feature-item:nth-child(3n) { + border-right: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + } + + .feature-item:nth-child(2n) { + border-right: 0; + } + + .feature-item:nth-last-child(-n + 3) { + border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + } + + .feature-item:nth-last-child(-n + 2) { + border-bottom: 0; + } + + .workflow-list { + grid-template-columns: 1fr; + gap: 1.3rem; + } + + .workflow-list::before { + content: none; + } + + .workflow-list li { + grid-template-columns: 4.5rem minmax(0, 1fr); + gap: 1.1rem; + justify-items: stretch; + align-items: center; + text-align: left; + } + + .workflow-list li:not(:last-child)::after { + top: 5.05rem; + right: auto; + left: calc(2.25rem - 1px); + width: 2px; + height: calc(100% - 4.3rem); + background: repeating-linear-gradient(180deg, var(--color-brand) 0 3px, transparent 3px 7px); + filter: none; + transform: scaleY(var(--motion-connector-progress, 1)); + transform-origin: center top; + } + + .workflow-list__index { + top: -0.4rem; + left: -0.45rem; + } + + .workflow-list p { + margin: 0; + } + + .architecture-constellation { + min-height: 40rem; + grid-template-columns: 1fr; + align-content: center; + } + + .architecture-signal { + height: 2.5rem; + flex-direction: column; + } + + .architecture-signal i:nth-child(n + 4) { + display: none; + } + + .architecture-core { + min-height: 21rem; + } + + .architecture-rail { + grid-template-columns: 1fr; + } + + .architecture-rail li { + display: grid; + grid-template-columns: 2.5rem 1fr; + align-items: center; + padding: 0.75rem; + border-right: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + text-align: left; + } + + .architecture-rail li:last-child { + border-bottom: 0; + } + + .architecture-rail strong { + margin-top: 0; + } + + .security-grid, + .scope-grid, + .architecture-figure figcaption, + .boundary-callout { + grid-template-columns: 1fr; + } + + .boundary-callout { + gap: 1rem; + } +} + +@media (max-width: 43.75rem) { + .hero { + background-size: cover, cover, auto 92rem; + } + + .hero h1 { + max-width: 100%; + font-size: var(--text-title); + } + + .hero-map { + min-height: 29rem; + transform: scale(0.86); + transform-origin: top center; + margin-bottom: -4rem; + } + + .hero-map__cluster { + width: 6.8rem; + gap: 0.28rem; + padding: 0.55rem; + } + + .hero-map__cluster > span:not(.hero-map__label) { + gap: 0.25rem; + padding: 0.38rem 0.25rem; + font-size: var(--text-sm); + } + + .hero-map__label { + font-size: var(--text-sm); + } + + .hero-map__core { + width: 8.4rem; + min-height: 10.2rem; + } + + .hero-map__core .icon { + width: 3.8rem; + height: 3.8rem; + } + + .hero-map__gateway { + top: 18.8rem; + width: 12rem; + } + + .hero-map__consumer--left { left: 2rem; } + .hero-map__consumer--right { right: 2rem; } + + .product-shot { + border-radius: 0; + transform: none; + } + + .product-shot img { + border-radius: 0; + } + + .problem-grid, + .feature-grid, + .scope-grid { + grid-template-columns: 1fr; + } + + .problem-item, + .feature-item, + .feature-item--featured, + .scope-column { + min-height: 0; + border-right: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + } + + .problem-item:last-child, + .feature-item:last-child, + .scope-column:last-child { + border-bottom: 0; + } + + .feature-item:nth-child(2n), + .feature-item:nth-child(3n), + .feature-item:nth-last-child(-n + 2) { + border-right: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 13%, transparent); + } + + .feature-item:last-child { + border-bottom: 0; + } + + .provider-note { + grid-template-columns: auto minmax(0, 1fr); + gap: 0.85rem; + } + + .architecture-core { + transform: scale(0.88); + } + + .security-orbit { + min-height: 24rem; + transform: scale(0.75); + margin-block: -3.5rem; + } + + .doc-link { + gap: 0.7rem; + } + + .doc-link__icon { + width: 2.5rem; + } +} + +@media (prefers-reduced-motion: no-preference) { + .architecture-core__center { + animation: soft-float 5s ease-in-out infinite; + } + + .security-orbit__ring--middle { + animation: slow-spin 28s linear infinite; + } +} + +@keyframes soft-float { + 0%, 100% { translate: 0 0; } + 50% { translate: 0 -0.45rem; } +} + +@keyframes slow-spin { + to { transform: rotate(360deg); } +} + +/* Motion background and clarity pass */ +.hero { + isolation: isolate; +} + +.hero__video { + position: absolute; + z-index: 0; + inset: -0.4rem; + width: calc(100% + 0.8rem); + height: calc(100% + 0.8rem); + object-fit: cover; + object-position: center top; + opacity: 0.58; + filter: blur(3px) brightness(0.72) saturate(0.88); + transform: scale(1.025); + pointer-events: none; +} + +.hero::before { + z-index: 1; + background: + linear-gradient(90deg, color-mix(in srgb, var(--color-canvas) 82%, transparent) 0%, color-mix(in srgb, var(--color-canvas) 38%, transparent) 48%, color-mix(in srgb, var(--color-canvas) 34%, transparent) 100%), + linear-gradient(180deg, color-mix(in srgb, var(--color-canvas) 12%, transparent), transparent 42%, var(--color-canvas) 98%), + radial-gradient(circle at 64% 22%, color-mix(in srgb, var(--color-accent) 13%, transparent), transparent 20rem); +} + +.hero__grid, +.product-shot-wrap { + position: relative; + z-index: 2; +} + +.hero-map__consumer { + border: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; +} + +.hero-map__consumer .icon { + width: 2.5rem; + height: 2.5rem; + padding: 0.48rem; + border: 1px solid color-mix(in srgb, var(--color-accent) 36%, transparent); + border-radius: 0.7rem; + background: color-mix(in srgb, var(--color-surface) 62%, transparent); + box-shadow: 0 0 22px color-mix(in srgb, var(--color-accent) 13%, transparent); +} + +.feature-item, +.feature-item--featured { + min-height: 13.5rem; +} + +.feature-item p { + max-width: 38ch; +} + +.workflow-list li { + grid-template-rows: 4.5rem auto; + align-items: start; +} + +.workflow-list li > div { + display: grid; + width: 100%; + grid-template-rows: 3.15rem auto; +} + +.workflow-list h3 { + display: flex; + min-height: 3.15rem; + align-items: flex-start; + justify-content: center; +} + +.workflow-list p { + max-width: 25ch; +} + +/* Architecture is a directional pipeline, not an orbit. */ +.architecture-flow { + position: relative; + overflow: hidden; + padding: 5.5rem 0 3rem; +} + +.architecture-flow__boundary { + position: absolute; + z-index: 0; + top: 1.25rem; + right: 13.5%; + bottom: 1rem; + left: 13.5%; + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 0.9rem 1rem; + border: 1px solid color-mix(in srgb, var(--color-brand-soft) 25%, transparent); + border-radius: 1.1rem; + background: linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 7%, transparent), transparent 48%); + color: var(--color-brand-soft); +} + +.architecture-flow__boundary strong { + font-size: var(--text-sm); +} + +.architecture-flow__boundary small { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.architecture-flow ol { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0.7rem; + margin: 0; + padding: 0; + list-style: none; +} + +.architecture-flow ol::before { + position: absolute; + z-index: -1; + top: 2.3rem; + right: 5%; + left: 5%; + height: 2px; + background: linear-gradient(90deg, var(--color-accent), var(--color-brand) 22% 78%, var(--color-accent)); + box-shadow: 0 0 14px color-mix(in srgb, var(--color-brand) 48%, transparent); + content: ""; + transform: scaleX(var(--motion-line-progress, 1)); + transform-origin: left center; +} + +.architecture-flow li { + position: relative; + display: grid; + gap: 0.7rem; + justify-items: center; + text-align: center; +} + +.architecture-flow__index { + position: absolute; + top: -1.75rem; + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +.architecture-flow__icon { + display: grid; + width: 4.6rem; + height: 4.6rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--color-accent) 34%, transparent); + border-radius: 1rem; + background: var(--color-surface); + box-shadow: 0 0 26px color-mix(in srgb, var(--color-accent) 11%, transparent); + color: var(--color-accent); +} + +.architecture-flow li.is-inside .architecture-flow__icon { + border-color: color-mix(in srgb, var(--color-brand-soft) 44%, transparent); + color: var(--color-brand-soft); +} + +.architecture-flow__icon--brand { + border-color: color-mix(in srgb, var(--color-brand-soft) 68%, transparent); + background: radial-gradient(circle, color-mix(in srgb, var(--color-brand) 27%, transparent), var(--color-surface) 72%); + box-shadow: 0 0 34px color-mix(in srgb, var(--color-brand) 25%, transparent); +} + +.architecture-flow__icon--brand img { + width: 3rem; + height: 3rem; +} + +.architecture-flow li > strong { + max-width: 17ch; + color: var(--color-text); + font-size: var(--text-sm); + line-height: 1.4; +} + +/* Security boundary is a readable request/response path. */ +.security-boundary { + display: grid; + min-height: 24rem; + grid-template-columns: minmax(7rem, 1fr) 3.4rem minmax(7rem, 1fr) 3.4rem minmax(7rem, 1fr); + gap: 0.45rem; + align-items: center; +} + +.security-boundary__stage { + display: grid; + gap: 0.5rem; + justify-items: center; + text-align: center; +} + +.security-boundary__stage > img, +.security-boundary__stage > .icon { + width: 4rem; + height: 4rem; +} + +.security-boundary__stage > .icon { + padding: 0.95rem; + border: 1px solid color-mix(in srgb, var(--color-brand-soft) 37%, transparent); + border-radius: 0.9rem; + background: color-mix(in srgb, var(--color-surface) 70%, transparent); + color: var(--color-brand-soft); +} + +.security-boundary__stage--gateway > .icon { + border-color: color-mix(in srgb, var(--color-brand-soft) 65%, transparent); + box-shadow: 0 0 30px color-mix(in srgb, var(--color-brand) 20%, transparent); +} + +.security-boundary__stage strong { + font-size: var(--text-sm); +} + +.security-boundary__stage span { + color: var(--color-text-muted); + font-size: var(--text-sm); + line-height: 1.45; +} + +.security-boundary__arrow { + display: grid; + gap: 0.55rem; + justify-items: center; +} + +.security-boundary__arrow i { + position: relative; + width: 100%; + height: 1px; + background: linear-gradient(90deg, var(--color-accent), var(--color-brand)); + box-shadow: 0 0 10px color-mix(in srgb, var(--color-brand) 45%, transparent); +} + +.security-boundary__arrow i::after { + position: absolute; + top: -0.2rem; + right: -0.05rem; + width: 0.4rem; + height: 0.4rem; + border-top: 1px solid var(--color-brand-soft); + border-right: 1px solid var(--color-brand-soft); + content: ""; + transform: rotate(45deg); +} + +.security-boundary__arrow b { + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); + font-weight: 600; + line-height: 1.3; + text-align: center; +} + +.security-boundary__rule { + grid-column: 1 / -1; + margin: 1.5rem 0 0; + padding-block: 1rem; + border-block: 1px solid color-mix(in srgb, var(--color-brand-soft) 20%, transparent); + color: var(--color-text-muted); + font-size: var(--text-sm); + text-align: center; +} + +/* The mascot peeks into the final invitation. */ +.final-cta { + min-height: 36rem; + isolation: isolate; +} + +.final-cta::before { + display: none; +} + +.final-cta::after { + position: absolute; + z-index: 1; + inset: 38% 0 0; + background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--color-canvas) 72%, transparent)); + content: ""; + pointer-events: none; +} + +.final-cta__art { + position: absolute; + z-index: 0; + inset: 0; + pointer-events: none; +} + +.final-cta__octopus { + position: absolute; + z-index: 0; + bottom: -20rem; + left: 50%; + width: min(46rem, 88vw); + max-width: none; + opacity: 0.5; + filter: saturate(0.9) drop-shadow(0 0 38px color-mix(in srgb, var(--color-brand) 30%, transparent)); + mix-blend-mode: screen; + transform: translateX(-50%); + -webkit-mask-image: linear-gradient(180deg, transparent 0%, var(--color-canvas) 18%, var(--color-canvas) 100%); + mask-image: linear-gradient(180deg, transparent 0%, var(--color-canvas) 18%, var(--color-canvas) 100%); +} + +.final-cta__inner { + z-index: 2; +} + +@media (max-width: 56.25rem) { + .workflow-list li { + grid-template-rows: auto; + } + + .workflow-list li > div { + grid-template-rows: auto; + } + + .workflow-list h3 { + display: block; + min-height: 0; + text-align: left; + } + + .workflow-list p { + max-width: 58ch; + } + + .architecture-flow { + display: grid; + gap: 1rem; + padding: 2.8rem 0 2rem; + } + + .architecture-flow__boundary { + position: relative; + top: auto; + right: auto; + bottom: auto; + left: auto; + display: block; + padding: 0.75rem 0.25rem; + border-inline: 0; + border-radius: 0; + background: transparent; + } + + .architecture-flow__boundary small { + display: block; + margin-top: 0.2rem; + } + + .architecture-flow ol { + grid-template-columns: 1fr; + gap: 1rem; + } + + .architecture-flow ol::before { + top: 2rem; + bottom: 2rem; + left: 5.05rem; + width: 2px; + height: auto; + transform: scaleY(var(--motion-line-progress, 1)); + transform-origin: center top; + } + + .architecture-flow li { + grid-template-columns: 2rem 4.6rem minmax(0, 1fr); + gap: 0.75rem; + align-items: center; + justify-items: start; + text-align: left; + } + + .architecture-flow__index { + position: static; + z-index: 2; + justify-self: start; + } + + .architecture-flow li > strong { + max-width: 30ch; + } + + .security-boundary { + min-height: 0; + grid-template-columns: 1fr; + gap: 0.7rem; + padding-block: 1rem; + } + + .security-boundary__arrow { + min-height: 2.8rem; + } + + .security-boundary__arrow i { + width: 1px; + height: 2.2rem; + background: linear-gradient(180deg, var(--color-accent), var(--color-brand)); + } + + .security-boundary__arrow i::after { + top: auto; + right: -0.18rem; + bottom: 0; + transform: rotate(135deg); + } + + .security-boundary__arrow b { + display: none; + } + + .security-boundary__rule { + margin-top: 0.7rem; + } +} + +@media (max-width: 43.75rem) { + .hero__video { + opacity: 0.48; + filter: blur(2px) brightness(0.66) saturate(0.82); + } + + .feature-item, + .feature-item--featured { + min-height: 0; + } + + .final-cta { + min-height: 34rem; + } + + .final-cta__octopus { + bottom: -10rem; + width: 96vw; + opacity: 0.42; + } +} + +@media (prefers-reduced-motion: reduce) { + .hero__video { + display: none; + } +} + +@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) { + .button, + .problem-item, + .feature-item, + .doc-link, + .item-icon, + .security-control__icon { + transition: + transform var(--motion-fast) var(--motion-ease-out), + background-color var(--motion-fast) ease, + border-color var(--motion-fast) ease, + box-shadow var(--motion-fast) ease, + filter var(--motion-fast) ease; + } + + .problem-item:hover, + .feature-item:hover { + transform: translateY(-3px); + } + + .problem-item:hover .item-icon, + .feature-item:hover .item-icon, + .security-control:hover .security-control__icon { + transform: scale(1.04); + filter: drop-shadow(0 0 16px color-mix(in srgb, var(--color-brand) 58%, transparent)); + } + + .button:active { + transform: translateY(0) scale(0.985); + } +} diff --git a/apps/landing/src/styles/tokens.css b/apps/landing/src/styles/tokens.css new file mode 100644 index 0000000..d7f251c --- /dev/null +++ b/apps/landing/src/styles/tokens.css @@ -0,0 +1,39 @@ +:root { + color-scheme: dark; + + /* A compact palette: surfaces, content, brand and status. */ + --color-canvas: #040712; + --color-surface: #080d20; + --color-surface-raised: #101936; + --color-border: #3b4771; + --color-text: #f5f7fa; + --color-text-muted: #a7afbc; + --color-brand: #7c3aed; + --color-brand-soft: #c4b5fd; + --color-accent: #60a5fa; + --color-focus: #22d3ee; + --color-success: #4ade80; + --color-warning: #fbbf24; + + /* Four type sizes cover metadata, body, section titles and hero copy. */ + --text-sm: 0.75rem; + --text-body: 1rem; + --text-title: clamp(2rem, 4vw, 3.4rem); + --text-display: clamp(2.5rem, 6vw, 4.7rem); + + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + + --container: 80rem; + --reading-width: 70ch; + --header-height: 4.5rem; + --radius-sm: 0.5rem; + --radius-md: 0.875rem; + --radius-lg: 1.25rem; + --shadow-card: 0 18px 40px color-mix(in srgb, var(--color-canvas) 68%, transparent); + --shadow-raised: 0 24px 80px color-mix(in srgb, var(--color-canvas) 52%, transparent); + + --motion-fast: 180ms; + --motion-base: 520ms; + --motion-ease-out: cubic-bezier(0.22, 1, 0.36, 1); +} diff --git a/apps/landing/tests/landing.test.ts b/apps/landing/tests/landing.test.ts new file mode 100644 index 0000000..3c0aebe --- /dev/null +++ b/apps/landing/tests/landing.test.ts @@ -0,0 +1,382 @@ +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; +import rootPackage from "../../../package.json"; + +const APP_ROOT = process.cwd(); +const DIST_ROOT = join(APP_ROOT, "dist"); +const BASE_PATH = "/MemoRepo"; +const DEPLOYMENT_URL = "https://abelmaro.github.io/MemoRepo/"; +const EXPECTED_TITLE = "MemoRepo — Cross-repository context for coding agents"; +const EXPECTED_VERSION = rootPackage.version; +const PRIMARY_CTA = "View MemoRepo on GitHub"; + +type BuiltPage = { + readonly document: Document; + readonly html: string; +}; + +let indexPage: BuiltPage; +let notFoundPage: BuiltPage; + +function builtPath(relativePath: string): string { + return join(DIST_ROOT, ...relativePath.split("/")); +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function readBuiltFile(relativePath: string): Promise { + return readFile(builtPath(relativePath), "utf8"); +} + +async function loadBuiltPage(relativePath: string): Promise { + const html = await readBuiltFile(relativePath); + const document = new DOMParser().parseFromString(html, "text/html"); + return { document, html }; +} + +function normalizedText(value: string | null | undefined): string { + return value?.replace(/\s+/g, " ").trim() ?? ""; +} + +function metaContent(document: Document, selector: string): string { + return document.querySelector(selector)?.content.trim() ?? ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function visitJson(value: unknown, visitor: (entry: Record) => void): void { + if (Array.isArray(value)) { + value.forEach((entry) => visitJson(entry, visitor)); + return; + } + + if (!isRecord(value)) { + return; + } + + visitor(value); + Object.values(value).forEach((entry) => visitJson(entry, visitor)); +} + +function collectAssetUrls(document: Document): string[] { + const urls: string[] = []; + const attributes = [ + ["img[src]", "src"], + ["script[src]", "src"], + ["source[src]", "src"], + ["video[poster]", "poster"], + ["link[href]:not([rel~='canonical'])", "href"], + ] as const; + + for (const [selector, attribute] of attributes) { + document.querySelectorAll(selector).forEach((element) => { + const value = element.getAttribute(attribute)?.trim(); + if (value) { + urls.push(value); + } + }); + } + + document.querySelectorAll("[srcset]").forEach((element) => { + const value = element.getAttribute("srcset") ?? ""; + value.split(",").forEach((candidate) => { + const url = candidate.trim().split(/\s+/, 1)[0]; + if (url) { + urls.push(url); + } + }); + }); + + return urls; +} + +function expectBaseAwareAssets(document: Document): void { + const urls = collectAssetUrls(document); + const localUrls = urls.filter((value) => { + if (/^(?:data|blob):/i.test(value)) { + return false; + } + + if (/^https?:/i.test(value)) { + return new URL(value).origin === new URL(DEPLOYMENT_URL).origin; + } + + return true; + }); + + expect(localUrls.length, "Expected generated HTML to reference local assets").toBeGreaterThan(0); + + for (const value of localUrls) { + const path = /^https?:/i.test(value) ? new URL(value).pathname : value; + expect(path, `Asset path is not scoped to ${BASE_PATH}: ${value}`).toMatch(/^\/MemoRepo\//); + } +} + +function outputPathForUrl(url: URL): string { + expect(url.origin, `Unexpected internal-link origin for ${url.toString()}`).toBe( + new URL(DEPLOYMENT_URL).origin, + ); + expect( + url.pathname === BASE_PATH || url.pathname.startsWith(`${BASE_PATH}/`), + `Internal link escaped ${BASE_PATH}: ${url.toString()}`, + ).toBe(true); + + const route = decodeURIComponent(url.pathname.slice(BASE_PATH.length)).replace(/^\/+/, ""); + if (!route) { + return builtPath("index.html"); + } + + if (route.endsWith("/")) { + return builtPath(`${route}index.html`); + } + + return builtPath(route); +} + +async function expectResolvableInternalLinks(page: BuiltPage, pageUrl: string): Promise { + const anchors = Array.from(page.document.querySelectorAll("a[href]")); + let internalCount = 0; + + for (const anchor of anchors) { + const href = anchor.getAttribute("href")?.trim() ?? ""; + expect(href, `Anchor has an empty href: ${normalizedText(anchor.textContent)}`).not.toBe(""); + expect(href.toLowerCase(), "javascript: links are not allowed").not.toMatch(/^javascript:/); + + const resolved = new URL(href, pageUrl); + if (!['http:', 'https:'].includes(resolved.protocol)) { + continue; + } + if (resolved.origin !== new URL(DEPLOYMENT_URL).origin) { + continue; + } + + internalCount += 1; + const outputPath = outputPathForUrl(resolved); + expect( + await fileExists(outputPath), + `Broken internal link ${href} from ${pageUrl}; expected ${outputPath}`, + ).toBe(true); + + if (resolved.hash) { + const targetHtml = await readFile(outputPath, "utf8"); + const targetDocument = new DOMParser().parseFromString(targetHtml, "text/html"); + const targetId = decodeURIComponent(resolved.hash.slice(1)); + expect( + targetDocument.getElementById(targetId), + `Missing fragment target ${resolved.hash} for ${href}`, + ).not.toBeNull(); + } + } + + expect(internalCount, "Expected at least one internal landing link").toBeGreaterThan(0); +} + +async function firstExistingFile(candidates: readonly string[]): Promise { + for (const candidate of candidates) { + if (await fileExists(builtPath(candidate))) { + return candidate; + } + } + + throw new Error(`None of the expected files exist: ${candidates.join(", ")}`); +} + +beforeAll(async () => { + [indexPage, notFoundPage] = await Promise.all([ + loadBuiltPage("index.html"), + loadBuiltPage("404.html"), + ]); +}); + +describe("landing build output", () => { + it("keeps the visual system on a compact token scale", async () => { + const [tokens, globalStyles, heroMap, iconComponent] = await Promise.all([ + readFile(join(APP_ROOT, "src/styles/tokens.css"), "utf8"), + readFile(join(APP_ROOT, "src/styles/global.css"), "utf8"), + readFile(join(APP_ROOT, "src/components/HeroMap.astro"), "utf8"), + readFile(join(APP_ROOT, "src/components/Icon.astro"), "utf8"), + ]); + + expect(tokens.match(/^\s*--text-[\w-]+:/gm) ?? []).toHaveLength(4); + expect(tokens.match(/^\s*--color-[\w-]+:/gm) ?? []).toHaveLength(12); + expect(globalStyles).not.toMatch(/#[\da-f]{3,8}\b/i); + expect(globalStyles).not.toMatch(/rgb\(/i); + expect(heroMap).not.toMatch(/#[\da-f]{3,8}\b/i); + expect(iconComponent).toContain('from "react-icons/lu"'); + expect(iconComponent).not.toMatch(/ { + expect(declaration).toMatch(/^font-size:\s*var\(--text-(?:sm|body|title|display)\);$/); + }); + }); + + it("emits the index and branded 404 documents", () => { + expect(indexPage.html).toMatch(/^/i); + expect(notFoundPage.html).toMatch(/^/i); + expect(notFoundPage.document.querySelector("title")?.textContent).toContain("MemoRepo"); + expect(notFoundPage.document.querySelectorAll("h1")).toHaveLength(1); + }); + + it("contains complete metadata and structured data", () => { + const { document } = indexPage; + const description = metaContent(document, 'meta[name="description"]'); + const canonical = document.querySelector('link[rel~="canonical"]')?.href ?? ""; + const ogTitle = metaContent(document, 'meta[property="og:title"]'); + const ogDescription = metaContent(document, 'meta[property="og:description"]'); + const ogUrl = metaContent(document, 'meta[property="og:url"]'); + const ogImage = metaContent(document, 'meta[property="og:image"]'); + + expect(normalizedText(document.querySelector("title")?.textContent)).toBe(EXPECTED_TITLE); + expect(description.length, "Meta description should be substantive").toBeGreaterThan(50); + expect(canonical).toBe(DEPLOYMENT_URL); + expect(ogTitle).toBe(EXPECTED_TITLE); + expect(ogDescription.length, "Open Graph description should be substantive").toBeGreaterThan(50); + expect(metaContent(document, 'meta[property="og:type"]')).toBe("website"); + expect(ogUrl).toBe(DEPLOYMENT_URL); + expect(new URL(ogImage).pathname).toMatch(/^\/MemoRepo\//); + + const blocks = Array.from( + document.querySelectorAll('script[type="application/ld+json"]'), + ); + expect(blocks.length, "Expected JSON-LD metadata").toBeGreaterThan(0); + + const records: Record[] = []; + for (const block of blocks) { + const parsed: unknown = JSON.parse(block.textContent || "null"); + visitJson(parsed, (entry) => records.push(entry)); + } + + expect(records.some((entry) => entry["@context"] === "https://schema.org")).toBe(true); + for (const expectedType of ["WebSite", "WebPage", "SoftwareApplication"]) { + expect( + records.some((entry) => { + const type = entry["@type"]; + return Array.isArray(type) ? type.includes(expectedType) : type === expectedType; + }), + `Missing ${expectedType} structured data`, + ).toBe(true); + } + + const application = records.find((entry) => entry["@type"] === "SoftwareApplication"); + expect(application?.["softwareVersion"]).toBe(EXPECTED_VERSION); + }); + + it("renders one H1, the primary CTA, and the repository version", () => { + const { document } = indexPage; + const bodyText = normalizedText(document.body.textContent); + expect(document.querySelectorAll("h1")).toHaveLength(1); + + const primaryCta = Array.from(document.querySelectorAll("a[href]")).find( + (anchor) => normalizedText(anchor.textContent) === PRIMARY_CTA, + ); + expect(primaryCta, `Missing primary CTA: ${PRIMARY_CTA}`).toBeDefined(); + expect(primaryCta?.href).toBe("https://github.com/abelmaro/MemoRepo"); + expect(bodyText).toMatch( + new RegExp(`\\b(?:v)?${EXPECTED_VERSION.replaceAll(".", "\\.")}\\b`), + ); + expect(bodyText).toContain("Free and open source under the MIT License."); + const generateTokenIndex = bodyText.indexOf("Generate the control token"); + const setTokenIndex = bodyText.indexOf("Set the generated value in .env"); + const startIndex = bodyText.indexOf("Start MemoRepo"); + expect(generateTokenIndex).toBeGreaterThanOrEqual(0); + expect(setTokenIndex).toBeGreaterThan(generateTokenIndex); + expect(startIndex).toBeGreaterThan(setTokenIndex); + }); + + it("ships motion as progressive enhancement over complete static content", async () => { + const groups = indexPage.document.querySelectorAll("[data-motion-group]"); + const items = indexPage.document.querySelectorAll("[data-motion-item]"); + const wires = indexPage.document.querySelectorAll("[data-motion-wire]"); + const motionSource = await readFile(join(APP_ROOT, "src/scripts/motion.ts"), "utf8"); + const hiddenInitialItems = Array.from(items).filter((item) => { + const style = item.getAttribute("style") ?? ""; + return /(?:opacity\s*:\s*0|visibility\s*:\s*hidden|display\s*:\s*none)/i.test(style); + }); + + expect(groups.length).toBeGreaterThanOrEqual(15); + expect(items.length).toBeGreaterThanOrEqual(40); + expect(wires).toHaveLength(9); + expect(hiddenInitialItems).toHaveLength(0); + expect(motionSource).not.toMatch(/strokeDash(?:array|offset)/); + expect(motionSource).toContain("opacity: [0.8, 1]"); + expect(indexPage.document.querySelector('[data-wire-from="space"][data-wire-to="gateway"]')?.getAttribute("data-wire-axis")).toBe("vertical"); + expect(indexPage.document.querySelector("#wire-gradient-vertical")?.getAttribute("gradientUnits")).toBe("userSpaceOnUse"); + expect(indexPage.document.querySelector("#wire-glow")?.getAttribute("filterUnits")).toBe("userSpaceOnUse"); + expect(indexPage.document.querySelector('script[type="module"][src^="/MemoRepo/assets/"]')).not.toBeNull(); + expect(notFoundPage.document.querySelector('script[type="module"][src]')).toBeNull(); + }); + + it("keeps generated asset and internal-link URLs under the Pages base path", async () => { + expectBaseAwareAssets(indexPage.document); + expectBaseAwareAssets(notFoundPage.document); + await expectResolvableInternalLinks(indexPage, DEPLOYMENT_URL); + await expectResolvableInternalLinks(notFoundPage, `${DEPLOYMENT_URL}404.html`); + }); + + it("emits sitemap, manifest, security, and crawler metadata", async () => { + const sitemapName = await firstExistingFile(["sitemap.xml", "sitemap-index.xml"]); + const [sitemap, manifestSource, security, robots] = await Promise.all([ + readBuiltFile(sitemapName), + readBuiltFile("site.webmanifest"), + readBuiltFile(".well-known/security.txt"), + readBuiltFile("robots.txt"), + ]); + + expect(sitemap).toMatch(/<(?:urlset|sitemapindex)\b/i); + const sitemapDocument = new DOMParser().parseFromString(sitemap, "application/xml"); + expect(sitemapDocument.querySelector("parsererror")).toBeNull(); + const sitemapLocations = Array.from(sitemapDocument.querySelectorAll("loc"), (element) => + normalizedText(element.textContent), + ); + if (sitemapDocument.documentElement.localName === "sitemapindex") { + expect(sitemapLocations.length, "Sitemap index should reference a child sitemap").toBeGreaterThan(0); + const childUrl = new URL(sitemapLocations[0] ?? "", DEPLOYMENT_URL); + const childSitemap = await readFile(outputPathForUrl(childUrl), "utf8"); + const childDocument = new DOMParser().parseFromString(childSitemap, "application/xml"); + const childLocations = Array.from(childDocument.querySelectorAll("loc"), (element) => + normalizedText(element.textContent), + ); + expect(childLocations).toContain(DEPLOYMENT_URL); + } else { + expect(sitemapLocations).toContain(DEPLOYMENT_URL); + } + + const manifest: unknown = JSON.parse(manifestSource); + expect(isRecord(manifest)).toBe(true); + if (!isRecord(manifest)) { + throw new Error("site.webmanifest must contain a JSON object"); + } + + expect(String(manifest["name"])).toContain("MemoRepo"); + expect(String(manifest["start_url"])).toMatch(/^\/MemoRepo\//); + const icons = manifest["icons"]; + expect(Array.isArray(icons) && icons.length > 0).toBe(true); + if (Array.isArray(icons)) { + icons.forEach((icon) => { + expect(isRecord(icon)).toBe(true); + if (isRecord(icon)) { + expect(String(icon["src"])).toMatch(/^\/MemoRepo\//); + } + }); + } + + expect(security).toMatch(/^Contact:\s*https:\/\//im); + expect(security).toMatch(/^Expires:\s*\S+/im); + expect(security).toMatch(/^Canonical:\s*https:\/\/abelmaro\.github\.io\/MemoRepo\//im); + + expect(robots).toMatch(/^User-agent:\s*\*/im); + expect(robots).toContain(`Sitemap: ${DEPLOYMENT_URL}${sitemapName}`); + }); +}); diff --git a/apps/landing/tsconfig.json b/apps/landing/tsconfig.json new file mode 100644 index 0000000..de95e9b --- /dev/null +++ b/apps/landing/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true + }, + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/apps/landing/vitest.config.ts b/apps/landing/vitest.config.ts new file mode 100644 index 0000000..2ddb643 --- /dev/null +++ b/apps/landing/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["tests/**/*.test.ts"], + restoreMocks: true, + testTimeout: 15_000, + }, +}); diff --git a/apps/web/package.json b/apps/web/package.json index ddf2cf3..0ee122f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@memorepo/web", - "version": "0.3.2", + "version": "0.3.3", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7e77e08..a918cab 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Activity, Boxes, Filter, HeartPulse, Layers, Loader2, Plus, RefreshCw, Search, Settings2 } from "lucide-react"; +import { Activity, Boxes, Check, Filter, HeartPulse, Layers, Loader2, Plus, RefreshCw, Search, Settings2 } from "lucide-react"; import { AddRepoModal } from "./components/AddRepoModal"; import { AskSpacePanel } from "./components/AskSpacePanel"; import { GitHubConnectionPanel, type GitHubSignInRequest } from "./components/GitHubConnectionPanel"; @@ -559,5 +559,5 @@ export function App() { } function CheckCircleMarker() { - return ; + return