From 693d59a3e2a08aa793875f41e7c7e3d684978bb3 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 22 Jul 2026 16:50:13 +0100 Subject: [PATCH] feat: Marketing site --- .gitea/workflows/server-deploy.yml | 16 + .gitignore | 3 +- claude.md | 89 +- deploy/docker-compose.site.yml | 48 + site/.dockerignore | 5 + site/Dockerfile | 48 + site/app/about/page.tsx | 131 + site/app/contact/page.tsx | 62 + site/app/globals.css | 1184 +++++ site/app/layout.tsx | 41 + site/app/page.tsx | 210 + site/app/platform/page.tsx | 184 + site/app/pricing/page.tsx | 154 + site/app/start/page.tsx | 75 + site/assets/hero_bg.source.png | Bin 0 -> 4234871 bytes site/components/ContactForm.tsx | 111 + site/components/Footer.tsx | 18 + site/components/Honeypot.tsx | 14 + site/components/InstrumentPanel.tsx | 166 + site/components/Logo.tsx | 11 + site/components/Nav.tsx | 76 + site/components/OrgForm.tsx | 120 + site/components/ThemeScript.tsx | 16 + site/components/ThemeToggle.tsx | 38 + site/components/nav-links.ts | 6 + site/lib/submit.ts | 87 + site/next-env.d.ts | 6 + site/next.config.ts | 9 + site/package-lock.json | 6170 +++++++++++++++++++++++ site/package.json | 24 + site/public/images/hero_bg-fallback.png | Bin 0 -> 236224 bytes site/public/images/hero_bg-sm.avif | Bin 0 -> 13539 bytes site/public/images/hero_bg-sm.webp | Bin 0 -> 19070 bytes site/public/images/hero_bg.avif | Bin 0 -> 21392 bytes site/public/images/hero_bg.webp | Bin 0 -> 37282 bytes site/public/images/vantage_logo.svg | 52 + site/tsconfig.json | 41 + sitesvc/Dockerfile | 26 + sitesvc/cmd/main.go | 89 + sitesvc/go.mod | 20 + sitesvc/go.sum | 50 + sitesvc/internal/api/api.go | 258 + sitesvc/internal/api/ratelimit.go | 66 + sitesvc/internal/api/signup.go | 233 + sitesvc/internal/api/validate.go | 69 + sitesvc/internal/mail/mail.go | 174 + sitesvc/internal/models/models.go | 54 + sitesvc/internal/provision/provision.go | 66 + sitesvc/internal/store/store.go | 290 ++ web/Dockerfile | 6 +- web/public/images/vantage_logo.svg | 52 + 51 files changed, 10656 insertions(+), 12 deletions(-) create mode 100644 deploy/docker-compose.site.yml create mode 100644 site/.dockerignore create mode 100644 site/Dockerfile create mode 100644 site/app/about/page.tsx create mode 100644 site/app/contact/page.tsx create mode 100644 site/app/globals.css create mode 100644 site/app/layout.tsx create mode 100644 site/app/page.tsx create mode 100644 site/app/platform/page.tsx create mode 100644 site/app/pricing/page.tsx create mode 100644 site/app/start/page.tsx create mode 100644 site/assets/hero_bg.source.png create mode 100644 site/components/ContactForm.tsx create mode 100644 site/components/Footer.tsx create mode 100644 site/components/Honeypot.tsx create mode 100644 site/components/InstrumentPanel.tsx create mode 100644 site/components/Logo.tsx create mode 100644 site/components/Nav.tsx create mode 100644 site/components/OrgForm.tsx create mode 100644 site/components/ThemeScript.tsx create mode 100644 site/components/ThemeToggle.tsx create mode 100644 site/components/nav-links.ts create mode 100644 site/lib/submit.ts create mode 100644 site/next-env.d.ts create mode 100644 site/next.config.ts create mode 100644 site/package-lock.json create mode 100644 site/package.json create mode 100644 site/public/images/hero_bg-fallback.png create mode 100644 site/public/images/hero_bg-sm.avif create mode 100644 site/public/images/hero_bg-sm.webp create mode 100644 site/public/images/hero_bg.avif create mode 100644 site/public/images/hero_bg.webp create mode 100644 site/public/images/vantage_logo.svg create mode 100644 site/tsconfig.json create mode 100644 sitesvc/Dockerfile create mode 100644 sitesvc/cmd/main.go create mode 100644 sitesvc/go.mod create mode 100644 sitesvc/go.sum create mode 100644 sitesvc/internal/api/api.go create mode 100644 sitesvc/internal/api/ratelimit.go create mode 100644 sitesvc/internal/api/signup.go create mode 100644 sitesvc/internal/api/validate.go create mode 100644 sitesvc/internal/mail/mail.go create mode 100644 sitesvc/internal/models/models.go create mode 100644 sitesvc/internal/provision/provision.go create mode 100644 sitesvc/internal/store/store.go create mode 100644 web/public/images/vantage_logo.svg diff --git a/.gitea/workflows/server-deploy.yml b/.gitea/workflows/server-deploy.yml index 27be513..6f27bd8 100644 --- a/.gitea/workflows/server-deploy.yml +++ b/.gitea/workflows/server-deploy.yml @@ -35,3 +35,19 @@ jobs: -t "$IMAGE" \ -f web/Dockerfile web/ docker push "$IMAGE" + + - name: Build and push site image + run: | + IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/site:latest" + docker build \ + --build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \ + --build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \ + -t "$IMAGE" \ + -f site/Dockerfile site/ + docker push "$IMAGE" + + - name: Build and push sitesvc image + run: | + IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest" + docker build -t "$IMAGE" -f sitesvc/Dockerfile sitesvc/ + docker push "$IMAGE" diff --git a/.gitignore b/.gitignore index d55b96d..5519071 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ installer/vantage-agent-windows-amd64.exe installer/*.msi installer/nssm.zip installer/checksums-msi.txt -.next \ No newline at end of file +.next +*.tsbuildinfo \ No newline at end of file diff --git a/claude.md b/claude.md index 9651849..6d9b69c 100644 --- a/claude.md +++ b/claude.md @@ -63,11 +63,24 @@ vantage/ │ ├── monitorsched/ # server-side monitor scheduler │ ├── notify/ # smtp, http, templating, dispatch │ └── services/ # business logic + migrations -├── web/ +├── web/ # the application UI (authenticated) │ ├── app/(app)/ # authed routes │ ├── app/login, app/setup # unauthed routes │ ├── components/ # ui/, workflows/, monitors/, Sidebar │ └── lib/ # api client, guac console, query client +├── site/ # public marketing site +│ ├── app/ # one directory per route +│ ├── components/ # Nav, Footer, Logo, InstrumentPanel, forms +│ ├── assets/ # image sources, not served +│ └── Dockerfile # same shape as web/: standalone, node, 3000 +├── sitesvc/ # public forms: contact mail + signup +│ ├── cmd/main.go +│ └── internal/ +│ ├── api/ # contact, signup, verify +│ ├── mail/ # SMTP +│ ├── models/ # mirrors server org/user + pending signup +│ ├── provision/ # slug rules mirrored from the control plane +│ └── store/ # mongo: pending signups, org/user creation ├── proto/vantage/v1/vantage.proto ├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs ├── deploy/ # docker-compose.yml, agent.service @@ -104,6 +117,46 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat ### Agent self-update `UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself. +### Marketing site and sitesvc +`site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3001`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint. + +`sitesvc/` (port `8082`) owns both flows end to end: + +| Form | Endpoint | Effect | +| --- | --- | --- | +| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. | +| Create organisation | `POST /api/signup` | Records a pending signup and emails a verification link. | +| Verification link | `GET /api/verify?token=…` | Creates the org and its owner, then redirects to `APP_LOGIN_URL`. | + +All three are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead. + +```bash +# self-hosted install — no marketing site, no sitesvc +docker compose up -d + +# vantage.sh — control plane plus the public site +docker compose -f docker-compose.yml -f docker-compose.site.yml up -d +``` + +### Signup and verification + +**Nothing is written to `orgs` or `users` until the emailed link is opened.** A signup lands in sitesvc's own `site_pending_signups` collection holding the org name, the address, and the password already bcrypt-hashed at cost 12. The consequence is worth stating: an address nobody controls can never occupy an email, hold an organisation slug, or produce an account that can sign in. It also means the control plane's login path needs no concept of "unverified". + +- The token is 32 random bytes; only its **SHA-256 hash** is stored, so a leaked database yields no working links. +- `Verify` deletes the pending record **atomically before provisioning** (`FindOneAndDelete`), so a double-clicked link cannot create two organisations — the second delete matches nothing. +- Links expire after 24 hours, and a **TTL index** lets Mongo drop abandoned signups so password hashes do not linger. +- Re-submitting the form for the same address replaces the previous pending record, so only the newest link works. +- If the owner insert fails after the org is created, the org is rolled back rather than stranded holding a slug. The rollback refuses to touch an org that has users. +- Rate limited to 3 signups per client IP per hour, plus a honeypot field. + +### The one piece of duplicated logic + +`sitesvc/internal/provision` and `sitesvc/internal/models` mirror the control plane's slug rules, reserved names, bcrypt cost and document shapes. They are duplicated rather than imported because sitesvc is a separate module that deliberately does not depend on the server. + +**Nothing enforces the match automatically.** If the control plane's `Slugify`, `reservedSlugs`, `CreateOrg` or `CreateUser` change, update `sitesvc/internal/provision` in the same commit — a divergence would provision tenants under rules the app does not agree with. + +sitesvc also (re)declares the unique indexes on `users.email` and `orgs.slug` at boot so it does not depend on the server having started first. Creating an existing index is a no-op. + --- ## Auth and Orgs @@ -189,6 +242,8 @@ org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`. +`site_pending_signups` is written only by sitesvc and holds unverified signups; the control plane neither reads nor knows about it. + Notes that are not obvious from the structs: - `servers.agent_token_hash` stores SHA-256 of the token, never plaintext. `pre_reg_token` is cleared after `Register()`. `status` is `pending` → `active` on register, `offline` when `last_seen` passes the threshold (swept every 2 min). @@ -280,7 +335,21 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a | `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard | | `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written | -`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. +**sitesvc** (`deploy/docker-compose.site.yml` only): + +| Name | Required | Notes | +| --- | --- | --- | +| `MONGO_URI` | yes | **must point at the control plane's database**, or the app will not see organisations created here. The database name is read from the URI path (`mongodb://user:pass@host:27017/vantage?authSource=vantage`); a URI without one is refused at boot rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. | +| `PUBLIC_URL` | yes | sitesvc's own public base URL; verification links are built from it | +| `APP_LOGIN_URL` | no | where a verified owner is sent to sign in; without it they get a plain confirmation page | +| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (503) rather than silently dropping | +| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only | +| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS | +| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty | +| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request | +| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter | + +`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds the public marketing site on `3001` and is only used on vantage.sh. --- @@ -331,12 +400,15 @@ GOOS=linux GOARCH=amd64 go build \ -o dist/vantage-agent-linux-amd64 ./cmd ``` -### `server-deploy.yml` — triggered on push to `main` +### `server-deploy.yml` — triggered on every push to `main` -Builds and pushes the `server` and `web` images to the Gitea container registry, then deploys over SSH: +Builds and pushes four images to the Gitea container registry: `server`, `web`, `site` and `sitesvc`. + +Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step and no path filter; every push to `main` rebuilds all three images. Rolling them out is a separate manual step on the host: ```bash -cd /opt/vantage && docker compose pull && docker compose up -d --remove-orphans +cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \ + docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans ``` ### Tagging @@ -353,10 +425,11 @@ git push origin main # server + web deploy | `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` | | `REGISTRY_USER` | Secret | Gitea username | | `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` | -| `DEPLOY_HOST` | Secret | server VM host | -| `DEPLOY_USER` | Secret | SSH user for deploy | -| `DEPLOY_SSH_KEY` | Secret | deploy private key | | `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` | +| `DOCKER_HOST` | Variable | registry host used for image tags | +| `API_URL` | Variable | baked into the `web` image at build time | +| `SITE_API_URL` | Variable | sitesvc base URL, baked into the `site` image (contact form) | +| `SITE_CONTACT_EMAIL` | Variable | optional; mailto fallback address | --- diff --git a/deploy/docker-compose.site.yml b/deploy/docker-compose.site.yml new file mode 100644 index 0000000..e27afd9 --- /dev/null +++ b/deploy/docker-compose.site.yml @@ -0,0 +1,48 @@ +# Public marketing site and its backend. Deliberately kept out of +# docker-compose.yml so a self-hosted install never runs either of them: +# +# self-hosted: docker compose up -d +# vantage.sh: docker compose -f docker-compose.yml -f docker-compose.site.yml up -d +# +# sitesvc owns both public forms end to end. It shares MongoDB with the control +# plane — that is how a new tenant becomes visible to the app — but shares no +# code and no process with it. The control plane has no public signup endpoint. +services: + site: + image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest + restart: unless-stopped + ports: + - 3001:3000 + depends_on: + - sitesvc + + sitesvc: + image: gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc:latest + restart: unless-stopped + ports: + - 8082:8082 + environment: + PORT: "8082" + # Must point at the same database the control plane uses, or the app + # will not see organisations created here. The database name comes + # from the URI path: + # mongodb://user:pass@host:27017/vantage?authSource=vantage + # A URI with no database is refused at boot rather than defaulted. + MONGO_URI: ${MONGO_URI:-} + # Public base URL of this service. Verification links are built from + # it, so an unset or wrong value produces links that go nowhere. + PUBLIC_URL: ${SITE_PUBLIC_URL:-} + # Where a verified owner is sent to sign in. + APP_LOGIN_URL: ${SITE_APP_LOGIN_URL:-} + # Origins allowed to POST the forms. Unset means every cross-origin + # browser request is refused. + SITE_ORIGIN: ${SITE_ORIGIN:-} + # Only enable behind a proxy that overwrites X-Forwarded-For; + # otherwise clients can spoof their way past the rate limiter. + TRUST_PROXY: ${SITE_TRUST_PROXY:-false} + SMTP_HOST: ${SITE_SMTP_HOST:-} + SMTP_PORT: ${SITE_SMTP_PORT:-587} + SMTP_USERNAME: ${SITE_SMTP_USERNAME:-} + SMTP_PASSWORD: ${SITE_SMTP_PASSWORD:-} + SMTP_FROM: ${SITE_SMTP_FROM:-} + SMTP_TO: ${SITE_SMTP_TO:-support@hostxtra.co.uk} diff --git a/site/.dockerignore b/site/.dockerignore new file mode 100644 index 0000000..2a344d4 --- /dev/null +++ b/site/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +out +.env* +npm-debug.log* diff --git a/site/Dockerfile b/site/Dockerfile new file mode 100644 index 0000000..d94004d --- /dev/null +++ b/site/Dockerfile @@ -0,0 +1,48 @@ +# Dependencies stage +FROM node:26-alpine AS deps + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +# Build stage +FROM node:26-alpine AS builder + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Baked in at build time: NEXT_PUBLIC_* values are inlined into the client +# bundle. SITE_API points at sitesvc, which serves both forms. Leave it empty +# and contact falls back to mailto while signup reports it is unavailable. +ARG NEXT_PUBLIC_SITE_API="" +ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" +ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API +ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL + +RUN npm run build + +# Runtime stage +FROM node:26-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/site/app/about/page.tsx b/site/app/about/page.tsx new file mode 100644 index 0000000..06578f2 --- /dev/null +++ b/site/app/about/page.tsx @@ -0,0 +1,131 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "About", + description: + "Vantage began as a weekend fix for a lost laptop and grew into a fleet control plane. How it is built, and what it deliberately does not do.", +}; + +export default function AboutPage() { + return ( + <> +
+ About +

+ Built for the fleet nobody was given a budget to manage. +

+ +
+
+

+ Vantage started as a weekend fix for a bad afternoon. A laptop was lost, and finding every server that + trusted its key meant SSHing into each one with a text editor open. The list lived in someone's head. + Two of the boxes were not on it. +

+

+ The obvious tools were all heavier than the problem. A configuration management stack to write one file. A + bastion host that becomes the thing you now have to keep alive. A certificate authority with a rotation + story nobody wanted to own. +

+

+ So it began with one job done properly: hold authorized_keys to a known state. Then the same + agent turned out to be the right place to run a deploy script, check whether a service was answering, and + open a shell when something was on fire. Each addition had to earn its place by riding the connection that + already existed. +

+

+ Today it runs across homelabs, small hosting providers, and agencies who inherit client servers and need + to prove who can reach them. +

+
+ +
+ How it is built +
+
+ SERVER +
+

Go, MongoDB, Redis

+

+ One Go binary serving REST for the interface and gRPC for agents. MongoDB holds everything durable; + Redis holds sessions and nothing else. +

+
+
+
+ WEB +
+

Next.js

+

+ An operations interface, not a brochure: dense tables, live log streams, and state you can read at a + glance. +

+
+
+
+ AGENT +
+

Go, Linux and Windows

+

+ A single static binary under systemd or as a Windows service. No runtime, no dependencies, no + package manager involved. +

+
+
+
+ CONSOLE +
+

Guacamole

+

+ Protocol handling is a solved problem. We proxy the connection and manage the credentials around it. +

+
+
+
+
+
+
+ +
+ Security posture +

+ The parts worth being specific about. +

+
+
+ Tokens +

Hashed, never stored plain

+

+ Agent tokens and the secrets read token are held as SHA-256 hashes. The plaintext exists on the + agent's own disk at 0600 and nowhere else. +

+
+
+ At rest +

AES-256-GCM

+

+ Private keys, passphrases, vault secrets, identity provider secrets and console credentials are encrypted + with a key held only by your deployment. +

+
+
+ One-time +

Tokens that expire and spend

+

+ Pre-registration tokens last an hour and work once. Console session tokens are consumed the moment the + tunnel opens. +

+
+
+ Recorded +

Every mutation is audited

+

+ Assignments, revocations, runs, console sessions, secret reveals and settings changes are attributed and + kept. +

+
+
+
+ + ); +} diff --git a/site/app/contact/page.tsx b/site/app/contact/page.tsx new file mode 100644 index 0000000..b80f043 --- /dev/null +++ b/site/app/contact/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from "next"; +import { ContactForm } from "@/components/ContactForm"; + +export const metadata: Metadata = { + title: "Contact", + description: "Sales questions, self-hosted licensing, security disclosures and bug reports.", +}; + +const CHANNELS = [ + { + title: "Support", + body: "Everything else, including anything urgent.", + link: "support@hostxtra.co.uk", + href: "mailto:support@hostxtra.co.uk", + }, + { + title: "Security disclosure", + body: "Encrypted reports, acknowledged within 72 hours.", + link: "support@hostxtra.co.uk", + href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure", + }, + { + title: "Bugs and feature requests", + body: "Public tracker, read by the people who write the code.", + link: "git.vantage.sh/vantage", + href: "https://git.vantage.sh/vantage", + }, + { + title: "Status", + body: "Control plane uptime and incident history.", + link: "status.vantage.sh", + href: "https://status.vantage.sh", + }, +]; + +export default function ContactPage() { + return ( +
+ Contact +

+ Tell us what your fleet looks like. +

+ +
+
+ +
+ +
+

Pick the right door and you will get a faster answer.

+ {CHANNELS.map((channel) => ( +
+

{channel.title}

+

{channel.body}

+ {channel.link} +
+ ))} +
+
+
+ ); +} diff --git a/site/app/globals.css b/site/app/globals.css new file mode 100644 index 0000000..527e62e --- /dev/null +++ b/site/app/globals.css @@ -0,0 +1,1184 @@ +/* ========================================================================== + Vantage marketing site — design tokens + Palette is anchored on the logo navy (#0B2A58). The accent IS the brand + navy, lifting to a readable blue on dark grounds; status colours (up/down/ + pending) are semantic and deliberately never reused as the accent. + ========================================================================== */ + +:root { + color-scheme: light dark; + + --ground: #eaedf3; + --panel: #ffffff; + --panel-2: #f4f6fa; + --ink: #0a1b33; + --ink-2: #41556f; + --ink-3: #6c7f96; + --rule: #cdd6e2; + --rule-soft: #e0e6ef; + --accent: #0b2a58; + --accent-ink: #ffffff; + --up: #2f8a60; + --down: #c6462f; + --pend: #b0801f; + --shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45); + --logo: #0b2a58; + + --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; + + --s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem); + --s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem); + --s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem); + --s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem); + --s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem); + --s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem); + + --rail: 1200px; +} + +/* Dark tokens are defined once and applied through three selectors: the OS + preference, and both explicit values of data-theme so the in-page toggle + wins in either direction. */ +@media (prefers-color-scheme: dark) { + :root { + --ground: #071628; + --panel: #0d2138; + --panel-2: #102842; + --ink: #e4ecf6; + --ink-2: #9fb3ca; + --ink-3: #71879f; + --rule: #1e3855; + --rule-soft: #172c44; + --accent: #5b9be8; + --accent-ink: #04101f; + --up: #4fb484; + --down: #e2705a; + --pend: #d6a63f; + --shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85); + --logo: #7fb2f0; + } +} + +:root[data-theme="dark"] { + --ground: #071628; + --panel: #0d2138; + --panel-2: #102842; + --ink: #e4ecf6; + --ink-2: #9fb3ca; + --ink-3: #71879f; + --rule: #1e3855; + --rule-soft: #172c44; + --accent: #5b9be8; + --accent-ink: #04101f; + --up: #4fb484; + --down: #e2705a; + --pend: #d6a63f; + --shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85); + --logo: #7fb2f0; +} + +:root[data-theme="light"] { + --ground: #eaedf3; + --panel: #ffffff; + --panel-2: #f4f6fa; + --ink: #0a1b33; + --ink-2: #41556f; + --ink-3: #6c7f96; + --rule: #cdd6e2; + --rule-soft: #e0e6ef; + --accent: #0b2a58; + --accent-ink: #ffffff; + --up: #2f8a60; + --down: #c6462f; + --pend: #b0801f; + --shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45); + --logo: #0b2a58; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--ground); + color: var(--ink); + font-family: var(--sans); + font-size: var(--s-0); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +h1, +h2, +h3 { + margin: 0; + font-weight: 800; + line-height: 1.03; + letter-spacing: -0.03em; + text-wrap: balance; +} + +p { + margin: 0; +} + +a { + color: inherit; +} + +code { + font-family: var(--mono); + font-size: 0.92em; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + border-radius: 2px; +} + +.skip { + position: absolute; + left: -9999px; + top: 0; + background: var(--panel); + color: var(--ink); + padding: 0.6rem 1rem; + border: 1px solid var(--rule); + z-index: 100; +} + +.skip:focus { + left: 1rem; + top: 0.6rem; +} + +/* ---------- layout primitives ---------- */ + +.rail { + width: min(100% - 2.5rem, var(--rail)); + margin-inline: auto; +} + +.tag { + font-family: var(--mono); + font-size: var(--s--1); + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-3); +} + +.lede { + max-width: 60ch; + color: var(--ink-2); + font-size: var(--s-1); + line-height: 1.45; +} + +.prose { + max-width: 65ch; + color: var(--ink-2); +} + +.prose p + p { + margin-top: 1rem; +} + +.band { + padding-block: clamp(3.2rem, 6.5vw, 6rem); + border-top: 1px solid var(--rule-soft); +} + +.band--open { + border-top: none; +} + +.band--flush { + padding-bottom: clamp(2.6rem, 4vw, 3.5rem); +} + +.split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.75fr); + gap: clamp(2rem, 4vw, 3.4rem); + align-items: start; +} + +.split--even { + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); +} + +@media (max-width: 840px) { + .split, + .split--even { + grid-template-columns: 1fr; + } +} + +/* ---------- nav ---------- */ + +.nav { + position: sticky; + top: 0; + z-index: 60; + background: color-mix(in srgb, var(--ground) 88%, transparent); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--rule-soft); +} + +.nav__in { + display: flex; + align-items: center; + gap: 1.4rem; + height: 66px; +} + +.brand { + display: flex; + align-items: center; + gap: 0.55rem; + text-decoration: none; + margin-right: auto; + color: var(--ink); +} + +.brand svg { + height: 26px; + width: auto; + display: block; + color: var(--logo); +} + +.brand b { + font-weight: 800; + letter-spacing: -0.035em; + font-size: 1.14rem; +} + +.nav__links { + display: flex; + gap: 0.15rem; +} + +.nav__links a { + text-decoration: none; + color: var(--ink-2); + font-size: 0.92rem; + font-weight: 500; + padding: 0.38rem 0.68rem; + border-radius: 4px; +} + +.nav__links a:hover { + color: var(--ink); + background: var(--rule-soft); +} + +.nav__links a[aria-current="page"] { + color: var(--ink); + font-weight: 650; +} + +.nav__menu { + display: none; +} + +@media (max-width: 900px) { + .nav__links, + .nav__cta { + display: none; + } + + .nav__menu { + display: inline-flex; + } +} + +.drawer { + border-bottom: 1px solid var(--rule-soft); + background: var(--panel); +} + +.drawer nav { + display: flex; + flex-direction: column; + padding-block: 0.5rem 1rem; +} + +.drawer a { + text-decoration: none; + color: var(--ink-2); + padding: 0.6rem 0; + border-bottom: 1px solid var(--rule-soft); + font-size: 0.98rem; +} + +.drawer a[aria-current="page"] { + color: var(--ink); + font-weight: 650; +} + +.drawer .btn { + margin-top: 1rem; + justify-content: center; +} + +/* ---------- buttons ---------- */ + +.btn { + display: inline-flex; + align-items: center; + gap: 0.45rem; + text-decoration: none; + font-family: var(--sans); + font-weight: 600; + font-size: 0.94rem; + padding: 0.6rem 1.05rem; + border-radius: 4px; + border: 1px solid transparent; + cursor: pointer; + transition: transform 0.1s ease, filter 0.15s ease, border-color 0.15s ease; +} + +.btn:active { + transform: translateY(1px); +} + +.btn--solid { + background: var(--accent); + color: var(--accent-ink); + border-color: var(--accent); +} + +.btn--solid:hover { + filter: brightness(1.12); +} + +.btn--line { + border-color: var(--rule); + background: var(--panel); + color: var(--ink); +} + +.btn--line:hover { + border-color: var(--ink-3); +} + +.btn--sm { + padding: 0.4rem 0.78rem; + font-size: 0.86rem; +} + +.icon-btn { + background: none; + border: 1px solid var(--rule); + color: var(--ink-3); + border-radius: 4px; + padding: 0.36rem 0.5rem; + cursor: pointer; + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.icon-btn:hover { + color: var(--ink); + border-color: var(--ink-3); +} + +/* ---------- hero ---------- */ + +/* The hero artwork is committed to a dark navy field, so the band stays dark in + both themes rather than fighting it. Tokens are redefined locally so every + child renders against the dark ground; everything below the band returns to + the page theme. */ +.heroband { + --ground: #071628; + --panel: #0d2138; + --panel-2: #102842; + --ink: #e4ecf6; + --ink-2: #9fb3ca; + --ink-3: #71879f; + --rule: #1e3855; + --rule-soft: #172c44; + --accent: #5b9be8; + --accent-ink: #04101f; + + position: relative; + background-color: #071628; + color: var(--ink); + overflow: hidden; +} + +/* Decorative, so it lives in CSS rather than the document. image-set lets the + browser take AVIF and fall back on its own. */ +.heroband::before { + content: ""; + position: absolute; + inset: 0; + background-image: image-set(url("/images/hero_bg.avif") type("image/avif"), url("/images/hero_bg.webp") type("image/webp")); + background-repeat: no-repeat; + background-position: right center; + background-size: cover; + z-index: 0; +} + +/* Fades the artwork out under the text so the headline never sits on top of a + server rack. */ +.heroband::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 100deg, + #071628 0%, + #071628 26%, + rgba(7, 22, 40, 0.86) 42%, + rgba(7, 22, 40, 0.35) 62%, + rgba(7, 22, 40, 0.1) 100% + ); + z-index: 0; +} + +.heroband > * { + position: relative; + z-index: 1; +} + +@media (max-width: 900px) { + .heroband::before { + background-position: 72% center; + opacity: 0.4; + } + + .heroband::after { + background: linear-gradient(180deg, rgba(7, 22, 40, 0.72) 0%, rgba(7, 22, 40, 0.94) 70%, #071628 100%); + } +} + +.hero { + padding-block: clamp(3.4rem, 7vw, 6rem) clamp(2.6rem, 5vw, 4.2rem); +} + +.hero h1 { + font-size: var(--s-4); + max-width: 15ch; + margin-block: 1rem 0.9rem; +} + +.hero__acts { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + align-items: center; + margin-top: 1.6rem; +} + +.hero__foot { + font-family: var(--mono); + font-size: var(--s--1); + color: var(--ink-3); + margin-top: 1rem; +} + +/* ---------- instrument band (the signature element) ---------- */ + +/* The band already provides the breathing room above the panel. */ +.heroband + .instrument { + margin-top: 0; + border-top: none; +} + +.instrument { + margin-top: clamp(2.2rem, 4vw, 3.4rem); + border-top: 1px solid var(--rule); + border-bottom: 1px solid var(--rule); + background: var(--panel); +} + +.instrument__bar { + display: flex; + align-items: center; + gap: 1.2rem; + flex-wrap: wrap; + padding: 0.6rem 0; + border-bottom: 1px solid var(--rule-soft); + font-family: var(--mono); + font-size: 0.73rem; + letter-spacing: 0.06em; + color: var(--ink-3); +} + +.instrument__bar b { + color: var(--ink); + font-weight: 600; +} + +.instrument__clock { + margin-left: auto; +} + +.panes { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + border-top: 1px solid var(--rule-soft); +} + +@media (max-width: 900px) { + .panes { + grid-template-columns: 1fr; + } +} + +.pane { + padding: 1.1rem 0 1.3rem; + min-width: 0; +} + +.pane + .pane { + border-left: 1px solid var(--rule-soft); + padding-left: 1.6rem; +} + +@media (max-width: 900px) { + .pane + .pane { + border-left: none; + border-top: 1px solid var(--rule-soft); + padding-left: 0; + } +} + +.pane__h { + font-family: var(--mono); + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-3); + margin-bottom: 0.85rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.pane__h span { + margin-left: auto; +} + +.frow { + display: grid; + grid-template-columns: 9px minmax(0, 1fr) auto auto; + gap: 0.8rem; + align-items: center; + padding: 0.52rem 0; + border-bottom: 1px dotted var(--rule-soft); + font-family: var(--mono); + font-size: 0.79rem; +} + +.frow:last-child { + border-bottom: none; +} + +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--up); + justify-self: center; + transition: background 0.4s ease; +} + +.dot--down { + background: var(--down); +} + +.dot--pend { + background: var(--pend); +} + +.frow__host { + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.frow__sub { + color: var(--ink-3); + font-size: 0.71rem; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.chip { + font-family: var(--mono); + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.14rem 0.4rem; + border-radius: 3px; + border: 1px solid currentColor; + white-space: nowrap; +} + +.chip--up { + color: var(--up); +} + +.chip--down { + color: var(--down); +} + +.chip--pend { + color: var(--pend); +} + +.stream { + font-family: var(--mono); + font-size: 0.76rem; + line-height: 1.75; + color: var(--ink-2); + background: var(--panel-2); + border: 1px solid var(--rule-soft); + border-radius: 4px; + padding: 0.7rem 0.8rem; + height: 172px; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.stream div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.stream .t { + color: var(--ink-3); +} + +.stream .ok { + color: var(--up); +} + +.stream .er { + color: var(--down); +} + +.caret { + display: inline-block; + width: 0.5em; + background: var(--accent); + color: transparent; + animation: blink 1.1s steps(1) infinite; +} + +@keyframes blink { + 0%, + 49% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .caret { + animation: none; + opacity: 0.6; + } + + * { + transition-duration: 0.01ms !important; + } +} + +/* ---------- capability grid ---------- */ + +.caps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(255px, 1fr)); + gap: 2rem 2.4rem; + margin-top: 2.4rem; +} + +.cap { + border-top: 1px solid var(--rule); + padding-top: 1rem; +} + +.cap__k { + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent); + display: block; + margin-bottom: 0.7rem; +} + +.cap h3 { + font-size: var(--s-1); + margin-bottom: 0.45rem; +} + +.cap p { + color: var(--ink-2); + font-size: 0.94rem; +} + +.cap ul { + margin: 0.7rem 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.3rem; + font-family: var(--mono); + font-size: 0.75rem; + color: var(--ink-3); +} + +/* ---------- ordered flow ---------- */ + +.flow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 1px; + background: var(--rule-soft); + border: 1px solid var(--rule-soft); + margin-top: 2.4rem; +} + +.flow__c { + background: var(--panel); + padding: 1.3rem 1.2rem; +} + +.flow__n { + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.14em; + color: var(--ink-3); + display: block; + margin-bottom: 0.6rem; +} + +.flow__c h3 { + font-size: 1rem; + margin-bottom: 0.35rem; + letter-spacing: -0.015em; +} + +.flow__c p { + font-size: 0.9rem; + color: var(--ink-2); +} + +/* ---------- code ---------- */ + +.code { + font-family: var(--mono); + font-size: 0.78rem; + background: var(--panel); + border: 1px solid var(--rule); + border-radius: 4px; + padding: 0.8rem 0.95rem; + overflow-x: auto; + white-space: pre; + color: var(--ink-2); + line-height: 1.7; + margin: 0; +} + +.code b { + font-weight: 400; + color: var(--accent); +} + +.code i { + font-style: normal; + color: var(--ink-3); +} + +/* ---------- keyed spec list ---------- */ + +.specs { + display: flex; + flex-direction: column; + gap: 1.4rem; + margin-top: 1.6rem; +} + +.specs--flush { + margin-top: 0; +} + +.spec { + display: grid; + grid-template-columns: auto 1fr; + gap: 1.1rem; + align-items: start; +} + +.spec__k { + font-family: var(--mono); + font-size: 0.69rem; + letter-spacing: 0.11em; + color: var(--accent); + padding-top: 0.3rem; + white-space: nowrap; + min-width: 8ch; +} + +.spec h3 { + font-size: 1rem; + margin-bottom: 0.25rem; + letter-spacing: -0.015em; +} + +.spec p { + color: var(--ink-2); + font-size: 0.93rem; +} + +/* ---------- pricing ---------- */ + +.plans { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(265px, 1fr)); + gap: 1px; + background: var(--rule); + border: 1px solid var(--rule); + margin-top: 2.6rem; +} + +.plan { + background: var(--panel); + padding: 1.7rem 1.5rem 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.plan--pick { + background: var(--panel-2); + position: relative; +} + +.plan--pick::after { + content: ""; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 3px; + background: var(--accent); +} + +.plan__n { + font-weight: 750; + font-size: 1.02rem; +} + +.plan__p { + font-size: var(--s-3); + font-weight: 800; + letter-spacing: -0.045em; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.plan__p span { + font-size: 0.82rem; + font-weight: 500; + letter-spacing: 0; + color: var(--ink-3); +} + +.plan__d { + color: var(--ink-2); + font-size: 0.9rem; + min-height: 3.2em; +} + +.plan ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--ink-2); +} + +.plan li { + display: grid; + grid-template-columns: 0.9rem 1fr; + gap: 0.5rem; +} + +.plan li::before { + content: "·"; + color: var(--accent); + font-family: var(--mono); + font-weight: 700; +} + +.plan .btn { + justify-content: center; + margin-top: auto; +} + +.scroll { + overflow-x: auto; + margin-top: 2.4rem; +} + +table.cmp { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + min-width: 620px; +} + +.cmp th, +.cmp td { + text-align: left; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--rule-soft); +} + +.cmp thead th { + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.13em; + text-transform: uppercase; + color: var(--ink-3); +} + +.cmp td:not(:first-child) { + color: var(--ink-2); + font-variant-numeric: tabular-nums; +} + +.cmp tbody tr:hover { + background: var(--panel-2); +} + +/* ---------- forms ---------- */ + +.card { + background: var(--panel); + border: 1px solid var(--rule); + padding: clamp(1.4rem, 3vw, 2rem); + box-shadow: var(--shadow); +} + +.card--cta { + display: flex; + flex-wrap: wrap; + gap: 1.4rem; + align-items: center; + justify-content: space-between; +} + +.form { + display: flex; + flex-direction: column; + gap: 1.1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.38rem; +} + +.field label { + font-size: 0.84rem; + font-weight: 600; +} + +.field small { + color: var(--ink-3); + font-size: 0.79rem; +} + +.field input, +.field select, +.field textarea { + font: inherit; + font-size: 0.94rem; + color: var(--ink); + background: var(--ground); + border: 1px solid var(--rule); + border-radius: 3px; + padding: 0.6rem 0.72rem; + width: 100%; +} + +.field textarea { + min-height: 120px; + resize: vertical; +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.hostline { + font-family: var(--mono); + font-size: 0.8rem; + color: var(--ink-3); +} + +.hostline b { + color: var(--accent); + font-weight: 600; +} + +.opts { + display: grid; + gap: 0.55rem; +} + +.opt { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.65rem; + align-items: start; + border: 1px solid var(--rule); + border-radius: 4px; + padding: 0.75rem 0.85rem; + cursor: pointer; +} + +.opt:hover { + border-color: var(--ink-3); +} + +.opt input { + accent-color: var(--accent); + margin-top: 0.2rem; +} + +.opt strong { + font-size: 0.92rem; + font-weight: 650; + display: block; +} + +.opt em { + font-style: normal; + color: var(--ink-3); + font-size: 0.82rem; +} + +.opt:has(input:checked) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 7%, transparent); +} + +.form__note { + color: var(--up); +} + +.field__err { + color: var(--down); + font-size: 0.8rem; +} + +/* A fieldset needs its browser defaults stripped before it lays out like the + other .field blocks around it. */ +.field--set { + border: 0; + margin: 0; + padding: 0; + min-width: 0; +} + +.field--set legend { + font-size: 0.84rem; + font-weight: 600; + padding: 0; + margin-bottom: 0.38rem; +} + +/* ---------- contact channels ---------- */ + +.chan { + border-top: 1px solid var(--rule); + padding-top: 0.9rem; + margin-top: 1.4rem; +} + +.chan h3 { + font-size: 0.95rem; + margin-bottom: 0.2rem; +} + +.chan p { + font-size: 0.88rem; + color: var(--ink-2); +} + +.chan a { + font-family: var(--mono); + font-size: 0.83rem; + color: var(--accent); + text-decoration: none; +} + +.chan a:hover { + text-decoration: underline; +} + +/* ---------- footer ---------- */ + +.foot { + border-top: 1px solid var(--rule); + padding-block: 2.2rem 3rem; + margin-top: 1rem; +} + +.foot__in { + display: flex; + flex-wrap: wrap; + gap: 1.4rem; + align-items: center; +} + +.foot__in p { + color: var(--ink-3); + font-size: 0.84rem; + margin-right: auto; +} + +.foot__in a { + color: var(--ink-3); + font-size: 0.84rem; + text-decoration: none; +} + +.foot__in a:hover { + color: var(--ink); +} diff --git a/site/app/layout.tsx b/site/app/layout.tsx new file mode 100644 index 0000000..5c6df26 --- /dev/null +++ b/site/app/layout.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Footer } from "@/components/Footer"; +import { Nav } from "@/components/Nav"; +import { ThemeScript } from "@/components/ThemeScript"; + +export const metadata: Metadata = { + metadataBase: new URL("https://vantage.sh"), + title: { + default: "Vantage — one control plane for the whole fleet", + template: "%s — Vantage", + }, + description: + "Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.", + openGraph: { + type: "website", + siteName: "Vantage", + title: "Vantage — one control plane for the whole fleet", + description: + "Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.", + }, + icons: { icon: "/images/vantage_logo.svg" }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + Skip to content + +