Compare commits
93 Commits
75f20d5367
...
relaunch-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37897f07c3 | ||
|
|
fa12df8ec6 | ||
|
|
203e4bfb41 | ||
|
|
8dec3e792a | ||
|
|
9cded54ce3 | ||
|
|
21c1122369 | ||
|
|
5f06e5ccad | ||
|
|
04b930de62 | ||
|
|
b1b6b52f3f | ||
|
|
0ad067847c | ||
|
|
d67a89bacb | ||
|
|
54bbb46755 | ||
|
|
29c2d13776 | ||
|
|
ce314adf2d | ||
|
|
ad4288c3ed | ||
|
|
e3383471d1 | ||
|
|
b7b58dd948 | ||
|
|
4d4c072834 | ||
|
|
c4395eb245 | ||
|
|
073ff2ad48 | ||
|
|
ad8312b7f0 | ||
|
|
8c77db02ac | ||
|
|
af7c61c203 | ||
|
|
0f02a62e6f | ||
|
|
47142051c4 | ||
|
|
135a6246d9 | ||
|
|
3bb71942dc | ||
|
|
62c77a8dc5 | ||
|
|
77bf0bfa74 | ||
|
|
518d7a8afc | ||
|
|
84ff83303a | ||
|
|
eff1ba6b8e | ||
|
|
03573b2ac1 | ||
|
|
6f0268ebea | ||
|
|
2526b1f0fc | ||
|
|
01cf23f2f6 | ||
|
|
3a1fe5f2c7 | ||
|
|
d6851cd5aa | ||
|
|
ca2a725953 | ||
|
|
fdb0411c35 | ||
|
|
461b5e0cd6 | ||
|
|
0d743402ac | ||
|
|
28fcaf16c5 | ||
|
|
e3fbbbada7 | ||
|
|
cb21be71f0 | ||
|
|
5417565e34 | ||
|
|
60813e44d9 | ||
|
|
64aa8f426b | ||
|
|
c438d67469 | ||
|
|
bbd68eca1f | ||
| bb6b8e63d7 | |||
|
|
2fde385ecf | ||
|
|
95977c8d6c | ||
| b9b17df0ea | |||
| d772048fb4 | |||
| 259871d34a | |||
|
|
295e2bcfff | ||
|
|
93131461b8 | ||
| ba6069f14e | |||
|
|
9a4644bada | ||
| 65667d9b50 | |||
|
|
7ecd1ff871 | ||
|
|
ea6267d6f7 | ||
|
|
d98d73b14a | ||
|
|
7e0d341c89 | ||
|
|
92bef08357 | ||
|
|
cc7802e461 | ||
|
|
04a1bd0aaf | ||
|
|
dfb8419b08 | ||
|
|
ecbf1f90d7 | ||
|
|
07de57dda5 | ||
|
|
d4449bb404 | ||
|
|
6f70e1a642 | ||
|
|
94b9b844ac | ||
|
|
9795c633c9 | ||
|
|
5af7e0079f | ||
|
|
6f9e98aeb6 | ||
|
|
754333226b | ||
|
|
6b27d8f013 | ||
|
|
8b4b1ae699 | ||
|
|
e084177acf | ||
|
|
e0a7ec4651 | ||
|
|
2945c63f2a | ||
|
|
3432d509df | ||
|
|
7f8a41fd34 | ||
|
|
3a155efa69 | ||
|
|
8ab30ca8fc | ||
|
|
4759f88780 | ||
|
|
d0e7d13f09 | ||
|
|
ec320f7ae4 | ||
|
|
902d48a80a | ||
|
|
4afe37ee1a | ||
|
|
b9b0dcb6ec |
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Logto auth endpoints — the marketing site only performs <a href> navigations to these.
|
||||
# No tokens, no cookies, no XHR — these are plain hyperlinks.
|
||||
PUBLIC_AUTH_SIGNIN_URL=https://app.cameleer.io/sign-in
|
||||
PUBLIC_AUTH_SIGNUP_URL=https://app.cameleer.io/sign-in?first_screen=register
|
||||
PUBLIC_SALES_EMAIL=sales@cameleer.io
|
||||
106
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,106 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# cameleer-website — CI (build + test + lint + Lighthouse)
|
||||
#
|
||||
# Runs automatically on every push and every PR against main. Does NOT deploy —
|
||||
# see deploy.yml for that. This workflow exists so every commit gets the full
|
||||
# quality gate before it can reach production.
|
||||
#
|
||||
# Runner: self-hosted arm64 Gitea runner (act_runner).
|
||||
# Adjust `runs-on` labels if your runner is registered under different tags.
|
||||
# Architecture note: arm64 build, amd64 deploy is fine — Astro's output is
|
||||
# plain static HTML/CSS/JS with no arch-specific bits.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
PUBLIC_AUTH_SIGNIN_URL: ${{ secrets.PUBLIC_AUTH_SIGNIN_URL }}
|
||||
PUBLIC_AUTH_SIGNUP_URL: ${{ secrets.PUBLIC_AUTH_SIGNUP_URL }}
|
||||
PUBLIC_SALES_EMAIL: ${{ secrets.PUBLIC_SALES_EMAIL }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
# Lighthouse CI needs a Chrome/Chromium binary at runtime. Google Chrome
|
||||
# has no Linux/arm64 build, so we use distro Chromium when available and
|
||||
# fall back to Playwright's bundled Chromium (which supports linux/arm64)
|
||||
# when not. The Ubuntu runner ships /usr/bin/chromium-browser as a snap
|
||||
# forwarder stub that is on PATH but only prints "install via snap" when
|
||||
# invoked — so we MUST probe each candidate by actually running it,
|
||||
# not just `command -v`.
|
||||
- name: Install Chromium for Lighthouse CI
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
probe() {
|
||||
local bin="${1:-}"
|
||||
[ -n "$bin" ] && [ -x "$bin" ] && "$bin" --version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
CHROME_BIN=""
|
||||
for cand in \
|
||||
"$(command -v chromium 2>/dev/null || true)" \
|
||||
"$(command -v chromium-browser 2>/dev/null || true)" \
|
||||
"$(command -v google-chrome 2>/dev/null || true)"; do
|
||||
if probe "$cand"; then CHROME_BIN="$cand"; break; fi
|
||||
done
|
||||
|
||||
if [ -z "$CHROME_BIN" ]; then
|
||||
echo "No working system Chromium — installing Playwright-bundled Chromium."
|
||||
# --with-deps apt-installs the system libraries Chromium needs
|
||||
# (libnss3, libatk1.0-0, etc.). Playwright handles sudo internally.
|
||||
npx -y playwright@latest install --with-deps chromium
|
||||
CHROME_BIN="$(find "$HOME/.cache/ms-playwright" \
|
||||
-type f -name chrome -executable 2>/dev/null | head -n1)"
|
||||
fi
|
||||
|
||||
if ! probe "$CHROME_BIN"; then
|
||||
echo "Failed to install a working Chromium binary." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "CHROME_PATH=$CHROME_BIN" >> "$GITHUB_ENV"
|
||||
"$CHROME_BIN" --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Build site
|
||||
run: npm run build
|
||||
|
||||
- name: Guard — no TBD markers may ship in built HTML
|
||||
run: |
|
||||
if grep -rlE '(TBD):' dist 2>/dev/null | grep -E '\.(html|svg)$'; then
|
||||
echo "Built output contains unfilled <TBD:...>) markers."
|
||||
echo "Fill in imprint.astro and privacy.astro operator fields before merging to main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate HTML
|
||||
run: npm run lint:html
|
||||
|
||||
- name: Check internal links
|
||||
run: npm run lint:links
|
||||
|
||||
- name: Lighthouse CI
|
||||
env:
|
||||
CHROME_PATH: ${{ env.CHROME_PATH }}
|
||||
run: npx lhci autorun
|
||||
119
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,119 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# cameleer-website — Deploy to Hetzner Webhosting L
|
||||
#
|
||||
# MANUAL TRIGGER ONLY. Runs exclusively on workflow_dispatch from the Gitea UI
|
||||
# (Actions → deploy → Run workflow). Does NOT auto-deploy on push to main —
|
||||
# merges to main must be explicitly promoted to production.
|
||||
#
|
||||
# Build and deploy run in a single job; rsync uploads dist/ directly. No
|
||||
# upload-artifact round-trip (v3 strips dotfiles, v4 isn't supported on Gitea).
|
||||
#
|
||||
# Security headers (HSTS, CSP, X-Frame-Options, etc.) are NOT set by this
|
||||
# deploy. Hetzner Webhosting L runs Apache with AllowOverride None on the
|
||||
# user docroot, so file-based .htaccess is silently ignored. All response
|
||||
# headers are owned by Cloudflare Transform Rules — see OPERATOR-CHECKLIST.md
|
||||
# §2 "Cloudflare". Apache config exposed via konsoleH UI is the only origin-
|
||||
# side override path and is not managed from this repo.
|
||||
#
|
||||
# Runner: self-hosted arm64 Gitea runner. Adjust `runs-on` if your runner's
|
||||
# labels differ. Deploy target is Hetzner amd64 — arch mismatch is a non-issue
|
||||
# because the bundle is static HTML/CSS/JS.
|
||||
#
|
||||
# Required secrets (repo settings → Actions → Secrets):
|
||||
# SFTP_HOST, SFTP_USER, SFTP_PATH, SFTP_KEY, SFTP_KNOWN_HOSTS
|
||||
# PUBLIC_AUTH_SIGNIN_URL, PUBLIC_AUTH_SIGNUP_URL, PUBLIC_SALES_EMAIL
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
PUBLIC_AUTH_SIGNIN_URL: ${{ secrets.PUBLIC_AUTH_SIGNIN_URL }}
|
||||
PUBLIC_AUTH_SIGNUP_URL: ${{ secrets.PUBLIC_AUTH_SIGNUP_URL }}
|
||||
PUBLIC_SALES_EMAIL: ${{ secrets.PUBLIC_SALES_EMAIL }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests (sanity check)
|
||||
run: npm test
|
||||
|
||||
- name: Build site
|
||||
run: npm run build
|
||||
|
||||
- name: Guard — no TBD markers may ship in built HTML
|
||||
run: |
|
||||
if grep -rlE '(TBD):' dist 2>/dev/null | grep -E '\.(html|svg)$'; then
|
||||
echo "Built output contains unfilled <TBD:...>) markers."
|
||||
echo "Fill in imprint.astro and privacy.astro operator fields before merging to main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure SSH
|
||||
env:
|
||||
SFTP_KEY: ${{ secrets.SFTP_KEY }}
|
||||
SFTP_KNOWN_HOSTS: ${{ secrets.SFTP_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -e
|
||||
: "${SFTP_KEY:?SFTP_KEY secret must be set}"
|
||||
: "${SFTP_KNOWN_HOSTS:?SFTP_KNOWN_HOSTS secret must be set}"
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SFTP_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
printf '%s\n' "$SFTP_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
# Ensure rsync + openssh are present even on a minimal runner image.
|
||||
if ! command -v rsync >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y --no-install-recommends rsync openssh-client
|
||||
fi
|
||||
|
||||
- name: Deploy via rsync
|
||||
env:
|
||||
SFTP_USER: ${{ secrets.SFTP_USER }}
|
||||
SFTP_HOST: ${{ secrets.SFTP_HOST }}
|
||||
SFTP_PATH: ${{ secrets.SFTP_PATH }}
|
||||
run: |
|
||||
# Fail loudly if any secret is missing — otherwise rsync --delete
|
||||
# could be directed at the SSH user's home root.
|
||||
: "${SFTP_USER:?SFTP_USER secret must be set}"
|
||||
: "${SFTP_HOST:?SFTP_HOST secret must be set}"
|
||||
: "${SFTP_PATH:?SFTP_PATH secret must be set}"
|
||||
# Hetzner Webhosting splits SSH into two ports:
|
||||
# port 22 — SFTP only, no remote command exec
|
||||
# port 222 — full SSH with shell exec (rsync needs this)
|
||||
# `--rsync-path=/usr/bin/rsync` tells the local rsync where to find
|
||||
# the remote binary on Hetzner's locked-down PATH.
|
||||
# `BatchMode=yes` disables interactive prompts.
|
||||
rsync -avz --delete --rsync-path=/usr/bin/rsync \
|
||||
-e "ssh -p 222 -i $HOME/.ssh/id_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts" \
|
||||
dist/ "$SFTP_USER@$SFTP_HOST:$SFTP_PATH/"
|
||||
|
||||
- name: Post-deploy smoke test
|
||||
run: |
|
||||
set -e
|
||||
echo "Checking security headers on www.cameleer.io..."
|
||||
HEADERS=$(curl -sI https://www.cameleer.io/ || echo "")
|
||||
echo "$HEADERS" | grep -i '^strict-transport-security:' || { echo "HSTS missing"; exit 1; }
|
||||
echo "$HEADERS" | grep -i '^content-security-policy:' || { echo "CSP missing"; exit 1; }
|
||||
echo "$HEADERS" | grep -i '^x-frame-options:' || { echo "XFO missing"; exit 1; }
|
||||
echo "All required headers present on the live origin."
|
||||
3
.gitignore
vendored
@@ -22,6 +22,9 @@ Thumbs.db
|
||||
# Brainstorming / visual companion previews
|
||||
.superpowers/
|
||||
|
||||
# Claude Code session state (local tooling)
|
||||
.claude/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
8
.htmlvalidate.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": ["html-validate:recommended"],
|
||||
"rules": {
|
||||
"require-sri": "off",
|
||||
"no-inline-style": "off",
|
||||
"void-style": "off"
|
||||
}
|
||||
}
|
||||
103
OPERATOR-CHECKLIST.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Operator Checklist — `cameleer-website`
|
||||
|
||||
One-time setup that lives outside code. Do these before the first `main` merge that ships live.
|
||||
|
||||
## 1. Hetzner Webhosting L
|
||||
|
||||
- [ ] Provision Webhosting L plan. Note the SSH hostname (e.g. `wwwNNN.your-server.de`) and the user login.
|
||||
- [ ] In konsoleH, **enable SSH access** for the user.
|
||||
- [ ] In konsoleH → Domainverwaltung, register the production domain (`www.cameleer.io`) on this hosting and confirm what document root Apache uses for it. On Webhosting L, the Apache vhost docroot for the addon domain is typically the bare `~/public_html/` (NOT a subdirectory). The `SFTP_PATH` secret must match this exactly — wrong path = 404 from origin.
|
||||
- [ ] Generate an ed25519 SSH key pair locally (once):
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/cameleer-website-deploy -C "cameleer-website CI"
|
||||
```
|
||||
- [ ] Add the **public** key to `~/.ssh/authorized_keys` on the Hetzner account (or via konsoleH SSH-Schlüssel UI).
|
||||
- [ ] Test SSH on **port 222** (Hetzner Webhosting splits SFTP=22 / SSH-shell=222; rsync needs 222):
|
||||
```bash
|
||||
ssh -p 222 -i ~/.ssh/cameleer-website-deploy user@wwwNNN.your-server.de "ls -la"
|
||||
```
|
||||
- [ ] Grab the SSH host key for pinning, also on **port 222**:
|
||||
```bash
|
||||
ssh-keyscan -p 222 -t rsa,ed25519,ecdsa wwwNNN.your-server.de > hetzner-known-hosts.txt
|
||||
```
|
||||
Verify the fingerprint against what your manual SSH session displayed before saving the secret — `ssh-keyscan` doesn't authenticate.
|
||||
- [ ] **Origin TLS:** Cloudflare Full (strict) requires a valid origin cert. Hetzner Webhosting auto-issues Let's Encrypt — confirm the cert is active in konsoleH → SSL → SSL-Zertifikate.
|
||||
- [ ] **`.htaccess` caveat (important):** Hetzner Webhosting L runs Apache with `AllowOverride None` on the user docroot, so any `.htaccess` file you `rsync` is **silently ignored** by Apache. The only way to set Apache directives on this tier is via konsoleH → Einstellungen → Serverkonfiguration (per-directory wrench panel). This repo therefore owns no `.htaccess`; all response headers live in Cloudflare (see §2). The konsoleH `.htaccess` panel is left empty by default; defense-in-depth header copies there are optional and survive rsync deploys (different storage location).
|
||||
|
||||
## 2. Cloudflare (zone: cameleer.io)
|
||||
|
||||
### DNS
|
||||
- [ ] `A` record `www.cameleer.io` → Hetzner IP. **Proxied (orange).**
|
||||
- [ ] `A` record `@` (apex) → Hetzner IP. **Proxied (orange).**
|
||||
- [ ] `A`/`CNAME` for `auth.cameleer.io` → SaaS ingress. **Proxied.**
|
||||
- [ ] `A`/`CNAME` for `platform.cameleer.io` → SaaS ingress. **Proxied.**
|
||||
- [ ] NO bare MX. If email is needed at `@cameleer.io`, use **Cloudflare Email Routing** or a distinct hostname on a different provider.
|
||||
|
||||
### SSL/TLS
|
||||
- [ ] Mode: **Full (strict)**.
|
||||
- [ ] Minimum TLS: **1.2**.
|
||||
- [ ] TLS 1.3: **on**.
|
||||
- [ ] Always Use HTTPS: **on**.
|
||||
- [ ] Automatic HTTPS Rewrites: **on**.
|
||||
- [ ] HSTS: `max-age=31536000; includeSubDomains; preload`. (Add the domain to `https://hstspreload.org/` only after the site is stable and serving HSTS cleanly for a couple of weeks.)
|
||||
|
||||
### Security
|
||||
- [ ] WAF → **Cloudflare Managed Ruleset**: enabled (Free plan includes this since 2024).
|
||||
- [ ] Bot Fight Mode: **on**.
|
||||
- [ ] Browser Integrity Check: **on**.
|
||||
- [ ] Security Level: **medium**.
|
||||
- [ ] Email Obfuscation: **on**.
|
||||
- [ ] Rate Limiting rule: 20 req/min per IP on `/*` (marketing pages).
|
||||
|
||||
### Transform Rules (edge-level security headers)
|
||||
|
||||
Create a Transform Rule — "Modify Response Header" — matching `http.host eq "www.cameleer.io"`:
|
||||
|
||||
| Operation | Header | Value |
|
||||
|-----------|--------|-------|
|
||||
| Set | `Content-Security-Policy` | `default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'` |
|
||||
| Set | `X-Content-Type-Options` | `nosniff` |
|
||||
| Set | `X-Frame-Options` | `DENY` |
|
||||
| Set | `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||
| Set | `Permissions-Policy` | `geolocation=(), microphone=(), camera=(), payment=(), usb=()` |
|
||||
|
||||
### Page Rules / Redirect
|
||||
- [ ] `cameleer.io/*` → `https://www.cameleer.io/$1` (301 permanent).
|
||||
|
||||
## 3. Gitea Actions secrets (in the repo settings)
|
||||
|
||||
Add these under Repository settings → Actions → Secrets (or variables):
|
||||
|
||||
| Name | Type | Value |
|
||||
|------|------|-------|
|
||||
| `SFTP_HOST` | secret | Hetzner SSH hostname |
|
||||
| `SFTP_USER` | secret | Hetzner SSH user |
|
||||
| `SFTP_PATH` | secret | Absolute path to the Apache vhost docroot configured in konsoleH (typically `/usr/www/users/<login>/public_html`). Mismatch → 404 on origin. |
|
||||
| `SFTP_KEY` | secret | Contents of `~/.ssh/cameleer-website-deploy` (private key, PEM) |
|
||||
| `SFTP_KNOWN_HOSTS` | secret | Contents of `hetzner-known-hosts.txt` (captured via `ssh-keyscan`) |
|
||||
| `PUBLIC_AUTH_SIGNIN_URL` | secret | `https://app.cameleer.io/sign-in` |
|
||||
| `PUBLIC_AUTH_SIGNUP_URL` | secret | `https://app.cameleer.io/sign-in?first_screen=register` |
|
||||
| `PUBLIC_SALES_EMAIL` | secret | `sales@cameleer.io` (or whatever sales alias you set up) |
|
||||
|
||||
These three are not actually secret (they end up in the built HTML), but Gitea's
|
||||
Actions UI puts them in the **Secrets** tab alongside the SFTP credentials. The
|
||||
workflows read them via the `${{ secrets.* }}` context.
|
||||
|
||||
## 4. Content TODO — before go-live
|
||||
|
||||
- [ ] Fill in `src/pages/imprint.astro` `operator` object with real legal details.
|
||||
- [ ] Fill in `operatorContact` in `src/pages/privacy.astro`.
|
||||
- [ ] Confirm Starter-tier retention: spec says **7 days**; `cameleer-saas/HOWTO.md` says **30 days**. Reconcile one side or the other.
|
||||
|
||||
## 5. First deploy
|
||||
|
||||
The `deploy` workflow is **manual-only** — it does NOT auto-fire on push to `main`. After merging, trigger it explicitly.
|
||||
|
||||
1. Merge a PR to `main`.
|
||||
2. In Gitea: **Actions → deploy → Run workflow** on `main`.
|
||||
3. Watch the single `deploy` job (build + tests + rsync + smoke test in one step).
|
||||
4. The workflow's post-deploy smoke check verifies HSTS / CSP / X-Frame-Options on the live response. If any fail, the deploy step exits non-zero — debug at the **Cloudflare Transform Rule** layer (§2 above), since headers no longer come from the origin.
|
||||
5. Manually verify:
|
||||
- `curl -sI https://www.cameleer.io/` returns all 5 security headers (HSTS, CSP, XFO, X-Content-Type-Options, Referrer-Policy, Permissions-Policy).
|
||||
- `https://cameleer.io/` → `https://www.cameleer.io/` 301 redirect.
|
||||
- Open the site in an incognito window on desktop + mobile.
|
||||
48
README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# cameleer-website
|
||||
|
||||
Marketing site for [cameleer.io](https://www.cameleer.io) — zero-code observability for Apache Camel.
|
||||
|
||||
This is a **static** Astro 5 site. Hosted on Hetzner Webhosting L, fronted by Cloudflare, deployed via Gitea Actions.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev # http://localhost:4321
|
||||
npm run test # vitest — auth config + middleware header tests
|
||||
npm run build # produces dist/
|
||||
npm run preview # serves dist/
|
||||
```
|
||||
|
||||
## Quality gates (run in CI)
|
||||
|
||||
```bash
|
||||
npm run lint:html # html-validate on dist/
|
||||
npm run lint:links # linkinator on dist/
|
||||
npm run lh # Lighthouse CI (>=0.95 on all 4 categories)
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
See `.env.example`. All are `PUBLIC_*` (build-time, embedded in HTML).
|
||||
|
||||
| Var | Purpose |
|
||||
|-----|---------|
|
||||
| `PUBLIC_AUTH_SIGNIN_URL` | Logto sign-in URL (redirected to by "Sign in" buttons) |
|
||||
| `PUBLIC_AUTH_SIGNUP_URL` | Logto sign-up URL (redirected to by "Start free trial") |
|
||||
| `PUBLIC_SALES_EMAIL` | Sales email (`mailto:` target for "Talk to sales") |
|
||||
|
||||
## Deployment
|
||||
|
||||
**Manual trigger only.** Merging to `main` does NOT auto-deploy. To ship: Gitea → **Actions → deploy → Run workflow** on `main`. The workflow runs tests, builds, then `rsync`s `dist/` to Hetzner over SSH (ed25519 key on port 222, host-key-pinned), and post-deploy curls the live site to verify security headers.
|
||||
|
||||
Rollback: trigger the deploy workflow on the previous `main` commit (Actions UI lets you pick a ref).
|
||||
|
||||
**Security headers** (HSTS, CSP, X-Frame-Options, etc.) are owned by **Cloudflare Transform Rules**, not by anything in this repo. Hetzner Webhosting L ignores file-based `.htaccess` (`AllowOverride None`), so origin-side header config is impossible from code. See `OPERATOR-CHECKLIST.md` §2.
|
||||
|
||||
See [`OPERATOR-CHECKLIST.md`](./OPERATOR-CHECKLIST.md) for the one-time Hetzner + Cloudflare setup.
|
||||
|
||||
## Design & plan
|
||||
|
||||
- `docs/superpowers/specs/2026-04-24-cameleer-website-design.md` — the approved spec.
|
||||
- `docs/superpowers/plans/2026-04-24-cameleer-website.md` — the implementation plan that built this repo.
|
||||
29
astro.config.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://www.cameleer.io',
|
||||
output: 'static',
|
||||
trailingSlash: 'ignore',
|
||||
build: {
|
||||
// 'directory' outputs <page>/index.html so extensionless URLs like /pricing
|
||||
// resolve natively under Apache without MultiViews or rewrite rules.
|
||||
format: 'directory',
|
||||
assets: 'assets',
|
||||
inlineStylesheets: 'auto',
|
||||
},
|
||||
compressHTML: true,
|
||||
integrations: [
|
||||
tailwind({ applyBaseStyles: false }),
|
||||
],
|
||||
vite: {
|
||||
build: {
|
||||
cssMinify: 'lightningcss',
|
||||
// Prevent Astro from inlining small scripts into the HTML.
|
||||
// Without this, the hero rotator script (< 4 KB) gets inlined as a
|
||||
// <script type="module"> tag, which violates CSP script-src 'self'
|
||||
// (no 'unsafe-inline'). Setting 0 forces all scripts to external files.
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
1088
docs/superpowers/plans/2026-04-24-cameleer-website-copy-refresh.md
Normal file
@@ -113,6 +113,7 @@ Testing strategy by file type:
|
||||
"@lhci/cli": "^0.14.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"html-validate": "^8.18.0",
|
||||
"lightningcss": "^1.27.0",
|
||||
"linkinator": "^6.1.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.6.0"
|
||||
@@ -378,7 +379,7 @@ export default defineConfig({
|
||||
- [ ] **Step 2: Write the failing test `src/config/auth.test.ts`**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveAuthConfig } from './auth';
|
||||
|
||||
describe('resolveAuthConfig', () => {
|
||||
@@ -416,6 +417,21 @@ describe('resolveAuthConfig', () => {
|
||||
})).toThrow(/PUBLIC_SALES_EMAIL/);
|
||||
});
|
||||
|
||||
it('throws if PUBLIC_AUTH_SIGNUP_URL is missing', () => {
|
||||
expect(() => resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||
})).toThrow(/PUBLIC_AUTH_SIGNUP_URL/);
|
||||
});
|
||||
|
||||
it('throws if PUBLIC_AUTH_SIGNUP_URL is not https', () => {
|
||||
expect(() => resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||
PUBLIC_AUTH_SIGNUP_URL: 'http://auth.cameleer.io/sign-in?first_screen=register',
|
||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||
})).toThrow(/must be https/);
|
||||
});
|
||||
|
||||
it('exposes signUpUrl distinct from signInUrl', () => {
|
||||
const cfg = resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||
@@ -519,7 +535,7 @@ git commit -m "Add auth URL config module with validation (TDD)"
|
||||
- Create: `src/middleware.ts`
|
||||
- Create: `src/middleware.test.ts`
|
||||
|
||||
**Why a middleware and headers at the edge?** Defense in depth — Cloudflare Transform Rules will also emit these headers at the edge, but emitting them from the origin means (a) they're visible in local `preview` and (b) the site stays hardened even if the CF config drifts.
|
||||
**Why a middleware and headers at the edge?** Defense in depth. Astro's `output: 'static'` does NOT exercise middleware at `astro preview` time (preview serves static files directly) — so the middleware will only activate if we ever switch to SSR or Cloudflare Workers output. The real runtime header enforcement comes from Cloudflare Transform Rules (see spec §5.3). The middleware is kept so (a) we retain the invariant in version control, (b) it activates automatically if the output target ever changes, and (c) the pure `buildSecurityHeaders` function is unit-testable.
|
||||
|
||||
- [ ] **Step 1: Write the failing test `src/middleware.test.ts`**
|
||||
|
||||
@@ -651,17 +667,13 @@ npm test
|
||||
|
||||
Expected: all 7 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Smoke test against `astro preview`**
|
||||
- [ ] **Step 5: Verification note (no live smoke — headers come from Cloudflare)**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview &
|
||||
sleep 2
|
||||
curl -sI http://localhost:4321/ | grep -iE '^(content-security-policy|x-frame-options|strict-transport-security|referrer-policy|permissions-policy|x-content-type-options)'
|
||||
kill %1
|
||||
```
|
||||
Astro's `output: 'static'` does not run middleware at `astro preview` time, so a `curl` against the local preview server will NOT show these headers. This is expected, not a bug.
|
||||
|
||||
Expected: all six headers printed. If any are missing, the middleware is not wired — check `astro.config.mjs` doesn't disable middleware.
|
||||
The middleware exists to (a) keep the header contract in version control, (b) activate automatically if the output target switches to SSR or Cloudflare Workers, and (c) make the pure `buildSecurityHeaders` function unit-testable.
|
||||
|
||||
Production header enforcement is handled by Cloudflare Transform Rules (see `OPERATOR-CHECKLIST.md` Task 21). The post-deploy smoke test in the Gitea Actions workflow (Task 20) validates the headers are actually present on the live `https://www.cameleer.io/`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
@@ -1120,7 +1132,7 @@ import TopographicBg from '../TopographicBg.astro';
|
||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-6">
|
||||
Observability · Apache Camel
|
||||
</p>
|
||||
<h1 class="font-display font-bold text-text mb-6">
|
||||
<h1 class="text-display font-bold text-text mb-6">
|
||||
See every route.<br />
|
||||
Reach into every flow.
|
||||
</h1>
|
||||
@@ -1261,7 +1273,7 @@ const steps: Step[] = [
|
||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||
<div class="max-w-2xl mb-16">
|
||||
<p class="text-cyan font-mono text-xs tracking-[0.25em] uppercase mb-4">For engineers</p>
|
||||
<h2 class="font-hero font-bold text-text mb-4">How it works</h2>
|
||||
<h2 class="text-hero font-bold text-text mb-4">How it works</h2>
|
||||
<p class="text-text-muted text-lg">Three steps. No code changes. Works across Camel 4.x.</p>
|
||||
</div>
|
||||
<ol class="grid md:grid-cols-3 gap-6 md:gap-8">
|
||||
@@ -1313,7 +1325,7 @@ git commit -m "Add HowItWorks section — 3-step engineer-facing walkthrough"
|
||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||
<div class="max-w-2xl mb-16">
|
||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Why Cameleer</p>
|
||||
<h2 class="font-hero font-bold text-text mb-4">
|
||||
<h2 class="text-hero font-bold text-text mb-4">
|
||||
A purpose-built tool, from the team that has built integration observability before.
|
||||
</h2>
|
||||
</div>
|
||||
@@ -1416,7 +1428,7 @@ const tiers: Tier[] = [
|
||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
||||
<h2 class="font-hero font-bold text-text mb-4">Start free. Grow when you need to.</h2>
|
||||
<h2 class="text-hero font-bold text-text mb-4">Start free. Grow when you need to.</h2>
|
||||
<p class="text-text-muted text-lg">
|
||||
No credit card for the trial.
|
||||
<a href="/pricing" class="text-accent hover:underline">See full comparison →</a>
|
||||
@@ -1477,7 +1489,7 @@ import TopographicBg from '../TopographicBg.astro';
|
||||
<section class="relative overflow-hidden">
|
||||
<TopographicBg opacity={0.18} lines={6} />
|
||||
<div class="relative max-w-content mx-auto px-6 py-24 md:py-32 text-center">
|
||||
<h2 class="font-display font-bold text-text mb-6">
|
||||
<h2 class="text-display font-bold text-text mb-6">
|
||||
Start seeing your routes.
|
||||
</h2>
|
||||
<p class="text-lg md:text-xl text-text-muted max-w-prose mx-auto mb-10">
|
||||
@@ -1630,7 +1642,7 @@ const tiers: FullTier[] = [
|
||||
<TopographicBg opacity={0.12} lines={8} />
|
||||
<div class="relative max-w-content mx-auto px-6 pt-20 pb-12">
|
||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
||||
<h1 class="font-display font-bold text-text mb-6">Priced so engineers can say yes.</h1>
|
||||
<h1 class="text-display font-bold text-text mb-6">Priced so engineers can say yes.</h1>
|
||||
<p class="text-lg text-text-muted max-w-prose">
|
||||
Start free for 14 days. No credit card required. Move up when you need more apps, longer retention, or enterprise features.
|
||||
</p>
|
||||
@@ -1732,7 +1744,7 @@ const operator = {
|
||||
>
|
||||
<SiteHeader />
|
||||
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
||||
<h1 class="font-hero font-bold text-text mb-8">Imprint</h1>
|
||||
<h1 class="text-hero font-bold text-text mb-8">Imprint</h1>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">Information pursuant to § 5 TMG / § 5 DDG</h2>
|
||||
@@ -1833,7 +1845,7 @@ const lastUpdated = '2026-04-24';
|
||||
>
|
||||
<SiteHeader />
|
||||
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
||||
<h1 class="font-hero font-bold text-text mb-2">Privacy Policy</h1>
|
||||
<h1 class="text-hero font-bold text-text mb-2">Privacy Policy</h1>
|
||||
<p class="text-text-faint text-sm mb-10">Last updated: {lastUpdated}</p>
|
||||
|
||||
<section class="mb-10">
|
||||
|
||||
1131
docs/superpowers/plans/2026-04-25-cameleer-website-relaunch.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Cameleer website — copy + brand refresh spec
|
||||
|
||||
**Date**: 2026-04-24
|
||||
**Status**: Approved for implementation planning
|
||||
**Project**: `cameleer-website`
|
||||
**Supersedes sections of**: `2026-04-24-cameleer-website-design.md` — copy, logo, and tone only. Architecture, auth flow, hosting, and tech stack from the original spec remain in force.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The current marketing site sells Cameleer as **"zero-code observability for Apache Camel"**. This underrepresents the product: Cameleer is a **hosted runtime platform for Apache Camel** — the place you actually run your integrations, with deep tracing, replay, and live control baked in. Comparable to Mulesoft CloudHub in category, for teams who chose Apache Camel specifically to stay open and avoid vendor lock-in.
|
||||
|
||||
This spec refreshes the homepage copy, tone, and brand mark to match that positioning. Three goals:
|
||||
|
||||
1. Reposition from "observability tool" to "hosted Camel runtime with observability baked in".
|
||||
2. Lighten the tone — less technical jargon, more outcome-focused language, two strategic humor pops.
|
||||
3. Replace the placeholder topographic-wave icon with the real Cameleer product logo (camel + cameleer figure + compass rose on desert waves, amber).
|
||||
|
||||
No architecture changes. No new pages. No new dependencies. Pure content + asset refresh.
|
||||
|
||||
---
|
||||
|
||||
## 2. Positioning
|
||||
|
||||
### 2.1 New thesis
|
||||
|
||||
**Run Apache Camel without running Apache Camel.**
|
||||
|
||||
Cameleer is the hosted home for your Camel integrations — with deep tracing, replay, and live control built in. Because you chose Camel to stay free, not to stay up all night.
|
||||
|
||||
### 2.2 Rotating Hero positioning lines
|
||||
|
||||
Three lines rotate slowly in the Hero subline area:
|
||||
|
||||
1. **Run Apache Camel without running Apache Camel.**
|
||||
2. **Camel integrations, minus the baggage.**
|
||||
3. **Your camels, our caravan. You just ride.**
|
||||
|
||||
**Rotation behavior:**
|
||||
- Fade swap every ~10 seconds (8s static + 2s fade).
|
||||
- Paused on hover and on focus within the Hero.
|
||||
- If `prefers-reduced-motion: reduce` is set — render the first line only, no rotation, no JS.
|
||||
- Initial render shows line 1 so the critical content is meaningful without JS.
|
||||
- Implementation: small inline `<script type="module">` in the Hero component. No third-party animation library.
|
||||
|
||||
**Accessibility:**
|
||||
- The rotating container is the H1 — it is the primary page heading and the accessibility name of the document. Use `aria-live="off"` on the rotation wrapper so assistive technology does not announce every swap (announcing a different H1 every 10 seconds is hostile). All three lines render in the DOM; only one is visible. Visually-hidden siblings should have `aria-hidden="true"` to avoid duplicate heading announcements.
|
||||
- Tab-focus anywhere inside the Hero pauses rotation (same pause rule as hover).
|
||||
|
||||
### 2.3 Terms to avoid on landing page
|
||||
|
||||
Remove from all landing page copy:
|
||||
|
||||
- `-javaagent`, `java agent`, `Docker`, `ByteBuddy`, `Spring Boot`, `Quarkus`, `JAR`
|
||||
- `Prometheus`, `OpenTelemetry`, `CDI`, `JMX`
|
||||
- "nanosecond", "45+ EIP node types" (acceptable in headings-off detail, not in headlines/H2s)
|
||||
- "zero-code instrumentation" → replace with "no code changes"
|
||||
|
||||
Keep (audience vocabulary):
|
||||
|
||||
- Apache Camel, routes, processors, exchanges
|
||||
- Replay, trace, correlation ID
|
||||
- EIP — allowed once as a credibility signal, not a feature
|
||||
|
||||
Out-of-scope for this spec: the `/install` or `/docs` routes where `-javaagent` snippets belong. The existing homepage's bash snippet moves off the homepage — see §4.3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Brand mark refresh
|
||||
|
||||
### 3.1 Assets to import
|
||||
|
||||
From `C:/Users/Hendrik/Documents/projects/design-system/assets/` into `cameleer-website/public/`:
|
||||
|
||||
- `cameleer-16.png` → `public/icons/cameleer-16.png`
|
||||
- `cameleer-32.png` → `public/icons/cameleer-32.png`
|
||||
- `cameleer-48.png` → `public/icons/cameleer-48.png`
|
||||
- `cameleer-180.png` → `public/icons/cameleer-180.png` (Apple touch)
|
||||
- `cameleer-192.png` → `public/icons/cameleer-192.png` (PWA/Android)
|
||||
- `cameleer-512.png` → `public/icons/cameleer-512.png` (PWA/Android)
|
||||
- `cameleer-logo.svg` → `public/cameleer-logo.svg` (vector, used in header)
|
||||
- `cameleer-logo.png` → `public/cameleer-logo.png` (raster fallback for OG)
|
||||
|
||||
The SVG is 1.5 MB (not SVGO'd). It's used only in the header at ~32px. If the unminified size becomes a concern, run it through SVGO at build time — not in this spec's scope. For now, ship as-is and measure.
|
||||
|
||||
### 3.2 Header icon swap
|
||||
|
||||
`src/components/SiteHeader.astro`:
|
||||
|
||||
- Remove the inline 3-wavy-lines SVG.
|
||||
- Replace with `<img src="/cameleer-logo.svg" width="32" height="32" alt="" decoding="async">`.
|
||||
- Keep the existing `<a href="/" aria-label="Cameleer home">` wrapper and the "Cameleer" wordmark beside it.
|
||||
- Keep the `group-hover:text-accent` transition on the wordmark only.
|
||||
|
||||
### 3.3 Favicon chain
|
||||
|
||||
Update `src/layouts/BaseLayout.astro` `<head>`:
|
||||
|
||||
- Primary: `<link rel="icon" href="/cameleer-logo.svg" type="image/svg+xml">`
|
||||
- Fallback: `<link rel="icon" href="/icons/cameleer-32.png" sizes="32x32" type="image/png">`
|
||||
- Apple: `<link rel="apple-touch-icon" href="/icons/cameleer-180.png">`
|
||||
- Remove the existing `public/favicon.svg` (placeholder topographic lines).
|
||||
|
||||
### 3.4 Hero brand mark
|
||||
|
||||
In the Hero section, add a distinct small mark (48–64px) next to or just above the rotating positioning line. Amber on transparent. Tasteful, not a watermark. The mark anchors the camel/desert/GPS humor visually — the humor lands harder when the camel is literally present.
|
||||
|
||||
### 3.5 OG image
|
||||
|
||||
Replace `public/og-image.svg` content with a new design built around the real logo + the new thesis "Run Apache Camel without running Apache Camel." Same dimensions (1200×630), same solid dark background. Exact layout is a creative execution task, not spec'd here.
|
||||
|
||||
---
|
||||
|
||||
## 4. Section-by-section copy changes
|
||||
|
||||
Each section below lists the current headline → new headline and notes on body copy changes. Specific body copy wording is implementation detail — the writer should follow the tone and term-use rules in §2.
|
||||
|
||||
### 4.1 Hero (`src/components/sections/Hero.astro`)
|
||||
|
||||
**Kicker** (small uppercase line above headline):
|
||||
- Current: `Observability · Apache Camel`
|
||||
- New: `Your camels called. They want a GPS.`
|
||||
|
||||
**Headline** (H1):
|
||||
- Current: `See every route. / Reach into every flow.`
|
||||
- New: `Run Apache Camel without running Apache Camel.` (the primary of three rotating lines)
|
||||
- The H1 is the rotation target — it's the single most important piece of copy on the page and what gets indexed/shared.
|
||||
|
||||
**Subline**:
|
||||
- Current: "Zero-code tracing, processor-level detail, and live control for Apache Camel — from a single `-javaagent` flag."
|
||||
- New: "The hosted home for your Camel integrations — with deep tracing, replay, and live control built in. Because you chose Camel to stay free, not to stay up all night."
|
||||
- Static — does not rotate.
|
||||
|
||||
**`-javaagent` code inline**: removed from Hero.
|
||||
|
||||
**CTAButtons**: unchanged.
|
||||
|
||||
**RouteDiagram**: unchanged (visual, not copy).
|
||||
|
||||
### 4.2 DualValueProps (`src/components/sections/DualValueProps.astro`)
|
||||
|
||||
Current three tiles lead with outcomes but bodies are tech-heavy. Rewrite bodies to be outcome- and feeling-led. No humor in this section.
|
||||
|
||||
Tile 1 — **Ship integrations, then sleep.**
|
||||
Body reframe: you can see every route, every processor, every exchange, without writing a single line of tracing code. When something breaks, you know where and why.
|
||||
|
||||
Tile 2 — **Debug in daylight, not at 3 AM.**
|
||||
Body reframe: replay failed exchanges, follow a single request across services, capture exactly what went wrong — with the pieces ops actually needs at 3 AM already captured so you don't need to be up at 3 AM.
|
||||
|
||||
Tile 3 — **Own your stack. No lock-in.**
|
||||
Body reframe: you chose Apache Camel on purpose — open, portable, standard. Cameleer runs and observes your Camel apps as they are. No SDK, no code changes, no rewrite.
|
||||
|
||||
All three tile bodies: strip `-javaagent`, `45+ EIP`, "nanosecond". Keep them plain, outcome-first, ~2-3 sentences each.
|
||||
|
||||
### 4.3 HowItWorks (`src/components/sections/HowItWorks.astro`)
|
||||
|
||||
Section kicker unchanged: `For engineers`. Section H2 unchanged: `How it works`.
|
||||
|
||||
Three steps:
|
||||
|
||||
01 — **Point us at your Camel app**
|
||||
Body: drop your app in, or connect an existing one. No code changes, no SDK, nothing to rewrite.
|
||||
**Remove** the `java -javaagent:...` bash snippet entirely. The install detail belongs in docs, not on the landing page.
|
||||
|
||||
02 — **We take it from there**
|
||||
Body: every route, processor, exchange, and dependency is discovered and traced automatically. Sensitive fields are masked by default.
|
||||
|
||||
03 — **Watch it run**
|
||||
Body: browse executions, tap live traffic, replay failed exchanges, follow flows across services. Nothing to instrument, nothing to maintain.
|
||||
|
||||
Subtitle under "How it works" — current: "Three steps. No code changes. Works across Camel 4.x." — softens to: "Three steps. No code changes. Nothing to maintain."
|
||||
|
||||
### 4.4 WhyUs (`src/components/sections/WhyUs.astro`)
|
||||
|
||||
Section unchanged structurally. Kicker `Why Cameleer` and H2 stay. Body softening only:
|
||||
|
||||
Tile 1 — headline stays: "Generic APMs do not understand Camel. Cameleer does."
|
||||
Body: rewrite to drop "45+ Apache Camel EIP node types", "bidirectional protocol", "signed config changes". Translate to plain value: "Most monitoring tools see your app as a Java process and a pile of HTTP calls. Cameleer understands that you're running a Camel app — it speaks choices, splits, multicasts, error handlers, and every EIP pattern as first-class citizens. When you ask 'why did this exchange fail?', you get an answer, not a log tail."
|
||||
|
||||
Tile 2 — headline tweak: "Built by people who know what 3 AM looks like."
|
||||
Body: keep the nJAMS-legacy story but warmer. "The Cameleer team spent years building integration monitoring for banks, insurers, and logistics operators — the kind of shops where a stuck exchange at 3 AM means someone's phone is ringing. We know what integration teams actually need then, and what they never use. Cameleer is what we'd build today, purpose-built for Apache Camel."
|
||||
|
||||
Retain the trademark review note (nJAMS legacy).
|
||||
|
||||
### 4.5 PricingTeaser (`src/components/sections/PricingTeaser.astro`)
|
||||
|
||||
Kicker `Pricing` unchanged. H2 `Start free. Grow when you need to.` unchanged.
|
||||
|
||||
Subline current: "No credit card for the trial. See full comparison →"
|
||||
Subline new: "No credit card. No sales call. Just a working trial in ten minutes."
|
||||
|
||||
Tier cards and prices unchanged.
|
||||
|
||||
### 4.6 FinalCTA (`src/components/sections/FinalCTA.astro`)
|
||||
|
||||
**Headline:**
|
||||
- Current: `Start seeing your routes.`
|
||||
- New: `Your camels called. Time to ride.`
|
||||
|
||||
**Subline:**
|
||||
- Current: "14-day free trial. Your first app, instrumented and live in under 10 minutes."
|
||||
- New: "14-day free trial. Your first Camel app, hosted, traced, and running in under ten minutes. No code changes. No camels harmed."
|
||||
|
||||
**CTAButtons**: unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 5. Files touched
|
||||
|
||||
- `src/components/SiteHeader.astro` — logo swap (§3.2)
|
||||
- `src/layouts/BaseLayout.astro` — favicon chain + OG image reference (§3.3)
|
||||
- `src/components/sections/Hero.astro` — kicker, rotating H1, subline, brand mark (§4.1, §3.4, §2.2)
|
||||
- `src/components/sections/DualValueProps.astro` — three tile copy rewrites (§4.2)
|
||||
- `src/components/sections/HowItWorks.astro` — step copy rewrites + bash snippet removal (§4.3)
|
||||
- `src/components/sections/WhyUs.astro` — tile body softening (§4.4)
|
||||
- `src/components/sections/PricingTeaser.astro` — subline tweak (§4.5)
|
||||
- `src/components/sections/FinalCTA.astro` — headline + subline (§4.6)
|
||||
- `public/cameleer-logo.svg`, `public/cameleer-logo.png` — new assets (§3.1)
|
||||
- `public/icons/cameleer-{16,32,48,180,192,512}.png` — new assets (§3.1)
|
||||
- `public/favicon.svg` — deleted (§3.3)
|
||||
- `public/og-image.svg` — redesigned around new thesis (§3.5)
|
||||
|
||||
---
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
- Docs/install page creation — the `-javaagent` snippet moves off the homepage but this spec does not create its new home. Follow-up.
|
||||
- Logo SVG optimization (1.5 MB ship-as-is; measure before optimizing).
|
||||
- A/B testing the positioning lines. Rotation serves all three; we read analytics separately if/when added.
|
||||
- Pricing page (`/pricing`), imprint, privacy copy changes — this spec is homepage-only.
|
||||
- i18n — English only. No translation work.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions / risks
|
||||
|
||||
**Trademark — nJAMS legacy wording**: §4.4 Tile 2 mentions "the kind of shops where a stuck exchange at 3 AM means someone's phone is ringing." This is legacy nJAMS muscle memory, not new IP. Hendrik's trademark review gate from the original spec's §10 still applies before go-live.
|
||||
|
||||
**Positioning tension with docs**: once the landing page stops saying "`-javaagent`", SEO signals around that specific technical term weaken. Acceptable trade-off for the target audience (SMB+ Camel-chooser decision-makers), but if engineer-skewed traffic matters for discovery, consider a technical sub-page (`/how-it-runs`) or docs page carrying those keywords.
|
||||
|
||||
**Logo SVG weight**: 1.5 MB is large for a ~32px header render. If Lighthouse performance regresses, swap the header to reference `cameleer-logo.png` or an SVGO-minified copy.
|
||||
|
||||
**SEO title/description**: the BaseLayout `<title>` + meta description currently read "Zero-code observability for Apache Camel." These should update to match the new thesis ("Run Apache Camel without running Apache Camel."). Include this in the Hero section implementation task.
|
||||
@@ -0,0 +1,413 @@
|
||||
# Cameleer website — relaunch design spec
|
||||
|
||||
**Date**: 2026-04-25
|
||||
**Status**: Approved for implementation planning
|
||||
**Project**: `cameleer-website`
|
||||
**Supersedes (homepage composition only)**: `2026-04-24-cameleer-website-design.md` §6 (homepage layout) and `2026-04-24-cameleer-website-copy-refresh-design.md` §2 (rotating hero)
|
||||
**Preserved from prior specs**: architecture, hosting, security posture, auth flow, audience model, tech stack, brand mark, design tokens
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The current marketing site, while well-crafted at the component level, is strategically under-powered: a rotating H1 fails the 5-second test, the page leans 5+ camel puns deep, "3 AM" is repeated to slogan-status, there's no social proof anywhere, the screenshot is unannotated, and the homepage shows four pricing tiers (two of which say "Contact"). This spec rebuilds the homepage composition and copy to address those gaps.
|
||||
|
||||
**Three goals:**
|
||||
|
||||
1. Pass the 5-second test for two audiences in parallel (DevOps Engineers + IT Managers).
|
||||
2. Carry the page on credible trust anchors despite having zero customer logos to ship.
|
||||
3. Ship within the same static-only Astro 5 stack — no new dependencies, no backend, no forms, no analytics.
|
||||
|
||||
The pricing page (`/pricing.astro`) gets a tier-naming refresh; everything else outside the homepage stays as-is.
|
||||
|
||||
---
|
||||
|
||||
## 2. Non-goals (preserved from prior spec)
|
||||
|
||||
These remain explicitly **out of scope** — same list as `2026-04-24-cameleer-website-design.md` §3:
|
||||
|
||||
- Blog, docs, features page, about page, changelog, case studies (Docs/Changelog nav stubs deferred to a future phase — see §10)
|
||||
- Contact forms, newsletter signup, lead-capture, email automation
|
||||
- Interactive product demos, video players
|
||||
- Analytics, tracking pixels, cookie consent banners
|
||||
- Custom sign-up or sign-in forms (all auth redirects to Logto)
|
||||
- Backend code on the marketing host
|
||||
- Customer logos, attributed customer quotes (no customers ready to publish yet — see §4)
|
||||
|
||||
---
|
||||
|
||||
## 3. Audiences (unchanged)
|
||||
|
||||
Same two-audience model as the prior spec. Every section continues to label its primary audience lean:
|
||||
|
||||
| Audience | Role | What they need from the page |
|
||||
|----------|------|------------------------------|
|
||||
| **Managers** (amber) | Integration leads, architects who sign the check | Business outcomes, risk reduction, pedigree, pricing clarity |
|
||||
| **Engineers** (cyan) | Camel developers, DevOps, SREs | Capability, mechanism, coverage, zero-friction mechanics |
|
||||
|
||||
The relaunch's H1 leans Manager-outcome on purpose — the IT Manager is the check-signer, "sleep through the night" speaks to both audiences (one runs the pager, one carries the cost of a bad pager night).
|
||||
|
||||
---
|
||||
|
||||
## 4. Trust anchors (the social proof problem)
|
||||
|
||||
The original site shipped with no social proof. This relaunch can't fix that with logos or attributed customer quotes — **none are ready to publish**. The two anchors we *can* lean on:
|
||||
|
||||
1. **Founder pedigree** — "15 years building integration monitoring for banks, insurers, and logistics operators." No prior-product name is used. The pedigree claim stands on the years and the customer-segment, not on a brand reference.
|
||||
2. **Design-partner program**. Reframes the pre-customer state as a feature ("hand-picked early partners"), with a `mailto:` CTA to `PUBLIC_SALES_EMAIL` that the visitor can use to apply.
|
||||
|
||||
Both anchors live in a new dedicated **Social Proof Strip** section (§6.2) immediately below the hero.
|
||||
|
||||
---
|
||||
|
||||
## 5. Page structure — final 7 sections
|
||||
|
||||
| # | Section | Status | Source files |
|
||||
|---|---------|--------|--------------|
|
||||
| 1 | **Hero** | Rebuilt-in-place | `Hero.astro` |
|
||||
| 2 | **Social proof strip** | NEW | `SocialProofStrip.astro` (new) |
|
||||
| 3 | **3 AM walkthrough** | Replaces DualValueProps + ProductShowcase | `ThreeAmWalkthrough.astro` (new); old files deleted |
|
||||
| 4 | **How it works** | Rebuilt-in-place | `HowItWorks.astro` |
|
||||
| 5 | **Why Cameleer** | Rebuilt-in-place | `WhyUs.astro` |
|
||||
| 6 | **Pricing teaser** | Rebuilt-in-place | `PricingTeaser.astro` |
|
||||
| 7 | **Final CTA** | Rebuilt-in-place | `FinalCTA.astro` |
|
||||
|
||||
Order is deliberate — see §11 for the rationale (proof-first arc: hero → who built it → product walkthrough → how → why → pricing → close).
|
||||
|
||||
---
|
||||
|
||||
## 6. Section content
|
||||
|
||||
### 6.1 Hero (rebuilt)
|
||||
|
||||
**Audience lean**: Manager-outcome, with engineer-readable proof on the right.
|
||||
|
||||
**Layout**: existing 5/7 grid retained — left column copy, right column screenshot.
|
||||
|
||||
**Left column copy:**
|
||||
|
||||
- **Eyebrow pill** (mono, accent, italic — kept from current): `✦ Your camels called. They want a GPS.` — this is the *only* surviving camel pun on the homepage.
|
||||
- **H1** (single, no rotator): **Ship Camel integrations. Sleep through the night.**
|
||||
- **Sub** (~18px, muted): "Cameleer is the hosted runtime and observability platform for Apache Camel — auto-traced, replay-ready, cross-service correlated. The 3 AM page becomes a 30-second answer."
|
||||
- **Primary CTA**: `Start free trial` (existing `CTAButtons` primary, no changes).
|
||||
- **Secondary CTA**: `See it in action ↓` — anchor link to `#walkthrough`. New, replaces the existing `Sign in` button which moves to top-right nav only.
|
||||
- **Microline** (mono, muted, ~12px) under CTAs: `14-day trial · from €20/mo · no credit card`.
|
||||
|
||||
**Right column**:
|
||||
|
||||
- Existing `Lightbox` of `/product/exchange-detail.png` is retained.
|
||||
- Three numbered annotation pins overlaid on the image via positioned absolute spans (no PNG re-bake). Pins coordinate with three short callout labels rendered *below* the image (not on it):
|
||||
1. **Correlation ID** — "Click to follow one exchange across services."
|
||||
2. **Failure in context** — "Circuit breaker tripped. Fallback ran. Tried `backend:80`."
|
||||
3. **Full error pinned** — "Exception, stack trace, headers, payload — all here."
|
||||
- The pin-to-callout mapping is achieved with `aria-describedby` so screen-readers reach the explanation.
|
||||
- The hero-mark sway and topographic background are retained.
|
||||
|
||||
**Hero rotator removed.** The three rotating lines are not rehomed — the eyebrow pill carries the brand voice, the H1 carries the product claim. This is a deliberate reduction in pun count from 5+ to 1.
|
||||
|
||||
### 6.2 Social proof strip (NEW)
|
||||
|
||||
**Audience lean**: Both — managers see pedigree, engineers see honest framing.
|
||||
|
||||
**Component**: `SocialProofStrip.astro` (new, in `src/components/sections/`).
|
||||
|
||||
**Layout**: full-width section, dark, no card-style border. Visually quieter than the surrounding sections so it reads as a *trust line*, not a feature block. Vertical padding ~py-16.
|
||||
|
||||
**Content** (single content column, `max-w-3xl mx-auto px-6`):
|
||||
|
||||
- **Eyebrow** (mono, amber, ~12px): `// Built by people who've done this before`
|
||||
- **Quote block** (italic, ~17px, max 62ch, accent-colored 3px left border, padding-left ~20px):
|
||||
> *"We spent 15 years building integration monitoring for banks that couldn't afford downtime. Cameleer is what we'd build today — purpose-built for Apache Camel, no retrofit."*
|
||||
- **Attribution** (~13px, muted, mono): `— [Founder Name], co-founder`
|
||||
- **Below attribution** (~24px gap, then a single `mailto:`-styled CTA in mono+cyan): `Apply to the design-partner program →`
|
||||
|
||||
**`<!-- PENDING -->` gates** (do not ship without resolving):
|
||||
|
||||
- `[Founder Name]` is a placeholder. Must be filled in pre-publish.
|
||||
|
||||
**Design-partner CTA target**: built inline in `SocialProofStrip.astro` using `auth.salesEmail` (not `auth.salesMailto`, which has no subject helper):
|
||||
|
||||
```astro
|
||||
href={`mailto:${auth.salesEmail}?subject=${encodeURIComponent('Design partner enquiry — Cameleer')}`}
|
||||
```
|
||||
|
||||
No body — let the applicant introduce themselves.
|
||||
|
||||
### 6.3 3 AM walkthrough (replaces DualValueProps + ProductShowcase)
|
||||
|
||||
**Audience lean**: Engineer-pain on the left, Engineer-resolution on the right; Manager reads the contrast.
|
||||
|
||||
**Component**: `ThreeAmWalkthrough.astro` (new). Old `DualValueProps.astro` and `ProductShowcase.astro` are deleted.
|
||||
|
||||
**Section id**: `walkthrough` (anchor target for the hero secondary CTA).
|
||||
|
||||
**Layout**: a two-column responsive split that collapses to stacked at `md` and below. Below the split, a single row of three short callouts.
|
||||
|
||||
**Header block**:
|
||||
|
||||
- **Eyebrow** (mono, cyan): `// When something breaks`
|
||||
- **H2**: **The 3 AM page. With and without Cameleer.**
|
||||
- **Sub** (one line): "Same Camel app. Same failed exchange. Different night."
|
||||
|
||||
**Split — left column ("Without Cameleer")**:
|
||||
|
||||
- Container: rounded card, `bg-bg`, dashed border, mono font.
|
||||
- Top tag (`text-text-faint`, mono, uppercase, tracking): `Without Cameleer · 03:12 AM`.
|
||||
- Body: a styled `<pre>` block with realistic Camel-ish log content. Approximate content (final wording in implementation):
|
||||
```
|
||||
$ kubectl logs camel-router-7d4f8c
|
||||
... [stack-trace excerpt — Camel-style line refs] ...
|
||||
|
||||
$ grep "order-842" *.log
|
||||
router-3.log: WARN exchange order-842 stuck in saga-fulfillment
|
||||
router-3.log: ERROR processor backend:80 → connect timeout
|
||||
|
||||
$ ssh prod-integration-3
|
||||
prod-integration-3 $ kubectl logs ...
|
||||
|
||||
> slack #integration-team
|
||||
"anyone know why order-842 is stuck??"
|
||||
[3 of 4 reactions]
|
||||
|
||||
~47 min later: someone wakes up an SRE.
|
||||
```
|
||||
- Tone: muted/red-tinged for the stack trace lines, neutral muted for the rest, accent (sad amber) on the "47 min" closer.
|
||||
|
||||
**Split — right column ("With Cameleer")**:
|
||||
|
||||
- Container: rounded card, `bg-bg-elevated`, accent-tinted border, retains the same `box-shadow` glow as the existing showcase.
|
||||
- Top tag (mono, accent): `With Cameleer · 30 sec`.
|
||||
- Body: existing `Lightbox` of `/product/error-detail.png` (or a tighter crop — implementation may use the existing image). No annotation pins here — the hero already does the annotated-image pattern; this section is about the *contrast*, not the deep-dive.
|
||||
- Below the image, a single line: `▸ Open exchange order-842 → see the failure pinned → click Replay after fix.`
|
||||
|
||||
**Below the split — three short callouts** (3-up grid at `lg`, stacks below):
|
||||
|
||||
1. **Cross-service correlation** — "Every exchange carries its correlation ID forward. One click jumps to what the downstream route did with the same message."
|
||||
2. **Runtime detail, not guesswork** — "Circuit breaker tripped. Fallback path ran. Request tried `backend:80`. The pieces a 3 AM page actually needs — already captured."
|
||||
3. **The whole story of a failure** — "Exception class, message, stack trace, headers, payload — all pinned to the exchange. No log-grepping tour."
|
||||
|
||||
(These three are the existing `ProductShowcase.astro` callouts, kept verbatim. They earn their place after the dramatic split — they explain *what* you saw on the right.)
|
||||
|
||||
**Asset note**: the "Without Cameleer" content is implemented as a styled `<pre>` block within the Astro component — no PNG asset required, no Photoshop, no licensing. A future phase may swap to a recorded terminal screenshot if it tests better; the swap is a one-component change.
|
||||
|
||||
### 6.4 How it works (rebuilt-in-place)
|
||||
|
||||
**Audience lean**: Engineer.
|
||||
|
||||
Same 3-step layout, same component. Slim the copy:
|
||||
|
||||
| Step | Title | Body (final) |
|
||||
|------|-------|--------------|
|
||||
| 01 | Point us at your Camel app | Drop it in, or connect one you already run. No code changes. |
|
||||
| 02 | We take it from there | Every route, every processor, every exchange — discovered and traced automatically. Sensitive fields are masked by default. |
|
||||
| 03 | Watch it run | Browse executions, tap live traffic, replay failed exchanges, follow flows across services. |
|
||||
|
||||
**Cuts**: the redundant "No SDK. Nothing to rewrite." line on step 1 (already said in Hero), and the duplicate "Nothing to instrument. Nothing to maintain." at the end of step 3 (two consecutive sentences saying the same thing).
|
||||
|
||||
### 6.5 Why Cameleer (rebuilt-in-place)
|
||||
|
||||
**Audience lean**: Manager.
|
||||
|
||||
Same 2-card layout. Two changes:
|
||||
|
||||
1. **Cut the giant `03:00` decorative watermark** on card 2. The 3 AM beat is now told in the walkthrough; repeating it as a wall-decal here is the "five hits on one metaphor" the roast flagged.
|
||||
2. **Reword card 2** to remove the second 3 AM reference. New body (replacing both paragraphs):
|
||||
|
||||
- **H3**: **Built by people who've operated integration in production for 15 years.**
|
||||
- **P1**: "We spent over a decade building integration monitoring for banks, insurers, and logistics operators — the kind of shops where a stuck exchange is a regulatory event, not just an inconvenience."
|
||||
- **P2**: "Cameleer is what we'd build today, purpose-built for Apache Camel. No legacy, no retrofit, no assumptions about a generic middleware platform."
|
||||
|
||||
The "3 AM" phrasing is dropped; the pedigree claim is now told once in §6.2 (with the founder face) and again here (with the philosophy). Two distinct beats, not the same beat twice.
|
||||
|
||||
Card 1 (Generic APMs) stays as-is.
|
||||
|
||||
### 6.6 Pricing teaser (rebuilt-in-place)
|
||||
|
||||
**Audience lean**: Both.
|
||||
|
||||
**Tier rename across both `index.astro` and `/pricing.astro`**:
|
||||
|
||||
| Old name | New name | Price (unchanged) | Homepage teaser? |
|
||||
|----------|----------|--------------------|-------------------|
|
||||
| Trial | **Trial** | Free · 14 days | ✓ |
|
||||
| MID | **Starter** | 20 € /mo | ✓ (highlight) |
|
||||
| HIGH | **Scale** | Contact | — link only |
|
||||
| BUSINESS | **Enterprise** | Contact | — link only |
|
||||
|
||||
The first tier keeps the name **Trial** (not "Free") because it's time-limited; the price column shows "Free" but the tier name stays honest about the 14-day cap.
|
||||
|
||||
**Homepage teaser layout**: 2 cards (Trial + Starter), Starter retains the `★ MOST POPULAR` ribbon.
|
||||
|
||||
**CTA labels** on the two cards:
|
||||
|
||||
- Trial card: `Start free trial` → `auth.signUpUrl`
|
||||
- Starter card: `Start on Starter` → `auth.signUpUrl` (was: `Start on MID`)
|
||||
|
||||
**Below the cards**: a single line link: `See all plans (Scale, Enterprise) →` to `/pricing`. Replaces the inline "See full comparison →" link in the heading area.
|
||||
|
||||
**`/pricing.astro` page**: identical structure to today, but tier names updated to the renamed taxonomy and CTA labels updated accordingly.
|
||||
|
||||
### 6.7 Final CTA (rebuilt-in-place)
|
||||
|
||||
- **H2**: **Ship integrations. Sleep through the night.** (echoes H1 — bookend pattern, intentional repetition).
|
||||
- **Sub**: "14-day free trial. Your first Camel app, hosted, traced, and running in under ten minutes. No code changes."
|
||||
- **CTA**: `Start free trial` (single, primary). `CTAButtons` rendered with `showSecondary={false}`.
|
||||
|
||||
**Cut**: the entire `Your camels called. Time to ride.` line and the `No camels harmed.` aside.
|
||||
|
||||
---
|
||||
|
||||
## 7. Navigation (unchanged)
|
||||
|
||||
`SiteHeader.astro` keeps its current structure: Logo · Pricing · `[Sign in]` · `[Start free trial]`.
|
||||
|
||||
Docs / Changelog nav stubs are deferred to a follow-up phase. A "coming soon" stub page is worse than no nav entry. When real Docs or a real Changelog exists, this nav grows.
|
||||
|
||||
---
|
||||
|
||||
## 8. Voice and pun budget
|
||||
|
||||
**One camel pun on the homepage.** The eyebrow pill `Your camels called. They want a GPS.` is retained — it sits under the H1 (doesn't compete), it's witty without being a punchline, and it gives the brand voice one signature beat.
|
||||
|
||||
**Removed from the homepage:**
|
||||
|
||||
- `Camel integrations, minus the baggage.` (rotator line 2)
|
||||
- `Your camels, our caravan. You just ride.` (rotator line 3)
|
||||
- `Your camels called. Time to ride.` (FinalCTA H2 — replaced)
|
||||
- `No camels harmed.` (FinalCTA sub — removed)
|
||||
|
||||
**3 AM mentions** are now told as one beat in §6.3 (the walkthrough) and one micro-mention in the H1 sub. The `03:00` decorative watermark on `WhyUs.astro` card 2 is cut. Three references → two. Slogan-status → narrative status.
|
||||
|
||||
---
|
||||
|
||||
## 9. Component-level changes
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/components/sections/SocialProofStrip.astro`
|
||||
- `src/components/sections/ThreeAmWalkthrough.astro`
|
||||
|
||||
**Deleted files:**
|
||||
|
||||
- `src/components/sections/DualValueProps.astro`
|
||||
- `src/components/sections/ProductShowcase.astro`
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/components/sections/Hero.astro` — rotator removed, eyebrow + H1 + sub + secondary CTA + microline + annotation pins
|
||||
- `src/components/sections/HowItWorks.astro` — body copy slimmed (steps 1, 3)
|
||||
- `src/components/sections/WhyUs.astro` — `03:00` watermark removed, card 2 reworded, founder/3-AM language refactored
|
||||
- `src/components/sections/PricingTeaser.astro` — tier renaming + 2-card homepage layout + "See all plans" link
|
||||
- `src/components/sections/FinalCTA.astro` — H2 + sub + single-CTA reworked
|
||||
- `src/components/CTAButtons.astro` — accept new optional `secondaryLabel`/`secondaryHref` overrides for hero's `See it in action ↓` (already supported by existing prop interface — no API change required)
|
||||
- `src/pages/index.astro` — section order updated, deleted/new components wired
|
||||
- `src/pages/pricing.astro` — tier renaming applied to the full table
|
||||
|
||||
**Unchanged:**
|
||||
|
||||
- `src/components/SiteHeader.astro`
|
||||
- `src/components/SiteFooter.astro`
|
||||
- `src/components/Lightbox.astro`
|
||||
- `src/components/RouteDiagram.astro`
|
||||
- `src/components/TopographicBg.astro`
|
||||
- `src/config/auth.ts`
|
||||
- `src/middleware.ts`
|
||||
- `tailwind.config.mjs` (no new tokens needed)
|
||||
- `astro.config.mjs`
|
||||
|
||||
**Also modified:**
|
||||
|
||||
- `src/styles/global.css` — add `html { scroll-behavior: smooth; }` plus a `@media (prefers-reduced-motion: reduce)` override resetting it to `auto`. Required for the hero secondary CTA's anchor scroll to feel natural.
|
||||
|
||||
---
|
||||
|
||||
## 10. Asset deliverables
|
||||
|
||||
**No new image assets required for v1 of the relaunch.** Specifically:
|
||||
|
||||
- The "Without Cameleer" panel in §6.3 is a styled `<pre>` block. No screenshot needed.
|
||||
- The "With Cameleer" panel reuses the existing `/product/error-detail.png`.
|
||||
- Hero annotation pins are HTML/CSS overlays on the existing `/product/exchange-detail.png` — no PNG re-bake.
|
||||
|
||||
**Future enhancement candidates** (not blocking this relaunch):
|
||||
|
||||
- Replace the `<pre>` "without" panel with a recorded terminal screencap (PNG/SVG).
|
||||
- Re-bake `/product/exchange-detail.png` with annotations baked in if HTML overlays prove brittle on responsive breakpoints.
|
||||
|
||||
---
|
||||
|
||||
## 11. Why this order works (proof-first arc)
|
||||
|
||||
The H1 makes a strong claim ("30-second answer at 3 AM"). The page below has to deliver proof in order:
|
||||
|
||||
1. **Hero** — claim + annotated screenshot (claim + a thumbnail of the proof).
|
||||
2. **Social proof strip** — *who* is making this claim, why you should believe them.
|
||||
3. **3 AM walkthrough** — *show* the 30-second answer side-by-side with the alternative.
|
||||
4. **How it works** — by now the visitor wants to know "ok how do I get this".
|
||||
5. **Why Cameleer** — manager-voiced reassurance: "purpose-built, not generic."
|
||||
6. **Pricing teaser** — clarity, two cards, no contact-sales wall.
|
||||
7. **Final CTA** — the bookend.
|
||||
|
||||
A traditional SaaS layout (features → benefits → how → pricing) would put proof in third-party logos before the product walkthrough. We don't have those, so the walkthrough *is* the proof, and the strip + Why Cameleer carry the credibility load.
|
||||
|
||||
---
|
||||
|
||||
## 12. Validation
|
||||
|
||||
**CI gates that must pass on the branch before merge** (existing infrastructure):
|
||||
|
||||
- `npm run test` — vitest passes (auth config + middleware tests; the relaunch shouldn't touch these).
|
||||
- `npm run build` — Astro static build completes with no errors.
|
||||
- `npm run lint:html` — html-validate passes on `dist/`.
|
||||
- `npm run lint:links` — linkinator passes on `dist/` (the new `#walkthrough` anchor is internal — link-checker should accept it).
|
||||
- `npm run lh` — Lighthouse CI ≥ 0.95 on all 4 categories.
|
||||
|
||||
**Manual QA checklist** (operator runs before publish):
|
||||
|
||||
- [ ] Hero secondary CTA "See it in action ↓" scrolls smoothly to `#walkthrough` (requires adding `html { scroll-behavior: smooth; }` to `src/styles/global.css`, with a `@media (prefers-reduced-motion: reduce)` override that sets it back to `auto` — see §9).
|
||||
- [ ] At `<= md` breakpoint, the walkthrough split stacks with no horizontal scroll.
|
||||
- [ ] Annotation pins on the hero screenshot remain positioned correctly across the breakpoints we currently support.
|
||||
- [ ] `prefers-reduced-motion: reduce` disables the hero-mark sway and any tile-rise animations (existing handling preserved).
|
||||
- [ ] Tab focus order on the homepage is: nav → hero primary → hero secondary → social-proof CTA → walkthrough CTA targets … → final CTA.
|
||||
- [ ] All `mailto:` links open with the correct subject (design-partner CTA + sales contacts).
|
||||
- [ ] Founder name placeholder is filled in `SocialProofStrip.astro` before publish.
|
||||
|
||||
**Pre-publish blockers** (recorded in code as `<!-- PENDING -->` HTML comments):
|
||||
|
||||
1. `[Founder Name]` placeholder in `SocialProofStrip.astro` — must be replaced with a real name.
|
||||
|
||||
This is deliberately surfaced as a code-level TODO rather than a spec-level open question so the operator can't accidentally publish with the placeholder intact.
|
||||
|
||||
---
|
||||
|
||||
## 13. Out-of-scope future enhancements
|
||||
|
||||
Listed here so they aren't lost — none are part of this spec:
|
||||
|
||||
- Customer logo strip in the social-proof section once at least one named pilot agrees to publish.
|
||||
- Attributed customer quote + concrete metric (replaces the founder quote when available).
|
||||
- Competitive comparison table (cut from this relaunch — revisit after a clear competitive narrative emerges with real customers).
|
||||
- Docs and Changelog nav entries (gated on real content existing).
|
||||
- Cloudflare Web Analytics opt-in (pending DSGVO review and a need that isn't met by Cloudflare's own zone analytics).
|
||||
- Recorded terminal screencap for the "Without Cameleer" panel.
|
||||
- A second variant of the H1 (or different secondary CTA) once enough traffic exists for an honest A/B test.
|
||||
|
||||
---
|
||||
|
||||
## 14. Decision log (for future reference)
|
||||
|
||||
Decisions made during the brainstorming session that aren't otherwise visible in the document:
|
||||
|
||||
| Decision | Choice | Why |
|
||||
|----------|--------|-----|
|
||||
| Relaunch ambition | Structural relaunch (composition + copy, not full reposition) | Static stack works; the gap is composition, not infrastructure |
|
||||
| H1 voice | Manager-outcome ("Sleep through the night") | Check-signer audience; "sleep" works for both engineers and managers |
|
||||
| Hero rotator | Killed | Two of three rotator lines were vibe-only — coin-flip on the 5-second test |
|
||||
| Trust anchor | Founder quote + design-partner CTA | No customer logos available; pedigree + honest pre-customer framing |
|
||||
| Comparison table | Cut | Not enough competitive narrative to build it honestly today |
|
||||
| Tier names | Trial / Starter / Scale / Enterprise | Procurement-friendly, no internally-coded labels |
|
||||
| Hero secondary CTA | `See it in action ↓` to `#walkthrough` | Low-commitment escape hatch; keeps visitor on-page |
|
||||
| 3 AM walkthrough | Before/after split with real-feeling content | Strongest dramatic contrast; no competitor exposure |
|
||||
| "Without Cameleer" image format | Styled `<pre>` block | No asset production blocking the relaunch |
|
||||
| Nav additions (Docs/Changelog) | Deferred | Stub pages worse than no entry; revisit when real content exists |
|
||||
| Pun budget | 1 (eyebrow pill) | Cut from 5+ to 1; voice survives, novelty tax doesn't |
|
||||
32
lighthouserc.cjs
Normal file
@@ -0,0 +1,32 @@
|
||||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
staticDistDir: './dist',
|
||||
url: [
|
||||
'http://localhost/index.html',
|
||||
'http://localhost/pricing/index.html',
|
||||
'http://localhost/imprint/index.html',
|
||||
'http://localhost/privacy/index.html',
|
||||
],
|
||||
numberOfRuns: 3,
|
||||
settings: {
|
||||
preset: 'desktop',
|
||||
// Flags required when Chromium runs inside a CI container or as root
|
||||
// (Gitea act_runner on arm64 uses containers). --headless=new is the
|
||||
// modern Chromium headless mode. CHROME_PATH is set by the workflow.
|
||||
chromeFlags: '--no-sandbox --headless=new --disable-gpu --disable-dev-shm-usage',
|
||||
},
|
||||
},
|
||||
assert: {
|
||||
assertions: {
|
||||
'categories:performance': ['error', { minScore: 0.95 }],
|
||||
'categories:accessibility': ['error', { minScore: 0.95 }],
|
||||
'categories:best-practices': ['error', { minScore: 0.95 }],
|
||||
'categories:seo': ['error', { minScore: 0.95 }],
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
target: 'temporary-public-storage',
|
||||
},
|
||||
},
|
||||
};
|
||||
13
linkinator.config.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"recurse": true,
|
||||
"silent": true,
|
||||
"skip": [
|
||||
"^https://auth\\.cameleer\\.io",
|
||||
"^https://platform\\.cameleer\\.io",
|
||||
"^https://www\\.cameleer\\.io",
|
||||
"^mailto:",
|
||||
"^https://ec\\.europa\\.eu"
|
||||
],
|
||||
"retry": true,
|
||||
"concurrency": 10
|
||||
}
|
||||
13289
package-lock.json
generated
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "cameleer-website",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint:html": "html-validate \"dist/**/*.html\"",
|
||||
"lint:links": "linkinator dist --recurse --silent",
|
||||
"lh": "lhci autorun"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fontsource/dm-sans": "^5.0.21",
|
||||
"@fontsource/jetbrains-mono": "^5.0.21",
|
||||
"astro": "^5.0.0",
|
||||
"tailwindcss": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lhci/cli": "^0.14.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"html-validate": "^8.18.0",
|
||||
"lightningcss": "^1.27.0",
|
||||
"linkinator": "^6.1.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
BIN
public/cameleer-logo.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
112
public/cameleer-logo.svg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/icons/cameleer-16.png
Normal file
|
After Width: | Height: | Size: 941 B |
BIN
public/icons/cameleer-180.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/icons/cameleer-192.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/icons/cameleer-32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/icons/cameleer-48.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/icons/cameleer-512.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
20
public/og-image.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
|
||||
<rect width="1200" height="630" fill="#060a13"/>
|
||||
<!-- Subtle topographic lines -->
|
||||
<g fill="none" stroke="#f0b429" stroke-width="1.2" stroke-linecap="round" opacity="0.10">
|
||||
<path d="M0 140 Q200 80 400 160 T800 140 T1200 160"/>
|
||||
<path d="M0 250 Q200 190 400 270 T800 250 T1200 270"/>
|
||||
<path d="M0 360 Q200 300 400 380 T800 360 T1200 380"/>
|
||||
<path d="M0 470 Q200 410 400 490 T800 470 T1200 490"/>
|
||||
</g>
|
||||
<!-- Product mark (fallback: amber circle w/ wordmark — real PNG logo embedded would exceed OG size budget) -->
|
||||
<g transform="translate(90, 110)">
|
||||
<circle cx="60" cy="60" r="58" fill="none" stroke="#f0b429" stroke-width="2"/>
|
||||
<text x="60" y="74" text-anchor="middle" font-family="DM Sans, system-ui, sans-serif" font-size="40" font-weight="700" fill="#f0b429">C</text>
|
||||
</g>
|
||||
<text x="220" y="190" font-family="DM Sans, system-ui, sans-serif" font-size="28" font-weight="600" fill="#f0b429" letter-spacing="3">CAMELEER</text>
|
||||
<text x="90" y="340" font-family="DM Sans, system-ui, sans-serif" font-size="64" font-weight="700" fill="#f4f5f7">Run Apache Camel</text>
|
||||
<text x="90" y="420" font-family="DM Sans, system-ui, sans-serif" font-size="64" font-weight="700" fill="#f4f5f7">without running Apache Camel.</text>
|
||||
<text x="90" y="500" font-family="DM Sans, system-ui, sans-serif" font-size="28" font-weight="400" fill="#a0a8b8">The hosted home for your Camel integrations.</text>
|
||||
<text x="90" y="540" font-family="DM Sans, system-ui, sans-serif" font-size="28" font-weight="400" fill="#a0a8b8">Deep tracing, replay, and live control — built in.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/product/error-detail.png
Normal file
|
After Width: | Height: | Size: 644 KiB |
BIN
public/product/exchange-detail.png
Normal file
|
After Width: | Height: | Size: 551 KiB |
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
4
src/__mocks__/astro-middleware.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Minimal stub for astro:middleware used in Vitest only. */
|
||||
export function defineMiddleware(fn: unknown) {
|
||||
return fn;
|
||||
}
|
||||
47
src/components/CTAButtons.astro
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import { getAuthConfig } from '../config/auth';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'inverted';
|
||||
size?: 'md' | 'lg';
|
||||
showSecondary?: boolean;
|
||||
primaryLabel?: string;
|
||||
secondaryLabel?: string;
|
||||
secondaryHref?: string;
|
||||
}
|
||||
|
||||
const auth = getAuthConfig();
|
||||
|
||||
const {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
showSecondary = true,
|
||||
primaryLabel = 'Start free trial',
|
||||
secondaryLabel = 'Sign in',
|
||||
secondaryHref = auth.signInUrl,
|
||||
} = Astro.props;
|
||||
|
||||
const padY = size === 'lg' ? 'py-3' : 'py-2';
|
||||
const padX = size === 'lg' ? 'px-6' : 'px-5';
|
||||
const fontSize = size === 'lg' ? 'text-base' : 'text-sm';
|
||||
---
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={auth.signUpUrl}
|
||||
class={`inline-flex items-center justify-center rounded ${padX} ${padY} ${fontSize} font-semibold transition-colors
|
||||
${variant === 'primary'
|
||||
? 'bg-accent text-bg hover:bg-accent-muted focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg'
|
||||
: 'bg-bg text-accent border border-accent hover:bg-accent hover:text-bg'
|
||||
}`}
|
||||
>
|
||||
{primaryLabel}
|
||||
</a>
|
||||
{showSecondary && (
|
||||
<a
|
||||
href={secondaryHref}
|
||||
class={`inline-flex items-center justify-center rounded ${padX} ${padY} ${fontSize} font-medium text-text border border-border-strong hover:border-accent hover:text-accent transition-colors`}
|
||||
>
|
||||
{secondaryLabel}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
233
src/components/Lightbox.astro
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
loading?: 'eager' | 'lazy';
|
||||
caption?: string;
|
||||
triggerClass?: string;
|
||||
imgClass?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
loading = 'lazy',
|
||||
caption,
|
||||
triggerClass = '',
|
||||
imgClass = 'block w-full h-auto',
|
||||
} = Astro.props;
|
||||
|
||||
// Unique per-instance id so multiple lightboxes on a page do not collide.
|
||||
const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
||||
---
|
||||
<button
|
||||
type="button"
|
||||
class={`lightbox-trigger group ${triggerClass}`.trim()}
|
||||
data-lightbox-open={dialogId}
|
||||
aria-label={`Enlarge: ${alt}`}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
class={imgClass}
|
||||
/>
|
||||
<span aria-hidden="true" class="lightbox-zoom-badge">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="7.5"/>
|
||||
<path d="m20 20-3.5-3.5"/>
|
||||
<line x1="11" y1="8" x2="11" y2="14"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<dialog id={dialogId} class="lightbox-dialog" aria-modal="true" aria-labelledby={`${dialogId}-title`}>
|
||||
<h2 id={`${dialogId}-title`} class="sr-only">{alt}</h2>
|
||||
<form method="dialog" class="lightbox-close-form">
|
||||
<button type="submit" class="lightbox-close" aria-label="Close">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18"/>
|
||||
<path d="m6 6 12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<img src={src} alt={alt} class="lightbox-image" />
|
||||
{caption && <p class="lightbox-caption">{caption}</p>}
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
/* Trigger: reset native button chrome, keep block layout matching the old <img>. */
|
||||
.lightbox-trigger {
|
||||
all: unset;
|
||||
display: block;
|
||||
position: relative;
|
||||
cursor: zoom-in;
|
||||
width: 100%;
|
||||
}
|
||||
.lightbox-trigger:focus-visible {
|
||||
outline: 2px solid #f0b429;
|
||||
outline-offset: 4px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Zoom pill (fades in on hover / keyboard focus) */
|
||||
.lightbox-zoom-badge {
|
||||
position: absolute;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(240, 180, 41, 0.92);
|
||||
color: #060a13;
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
transition: opacity 180ms ease-out, transform 180ms ease-out;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.lightbox-trigger:hover .lightbox-zoom-badge,
|
||||
.lightbox-trigger:focus-visible .lightbox-zoom-badge {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Dialog */
|
||||
.lightbox-dialog {
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: min(98vw, 1800px);
|
||||
height: min(96vh, 1200px);
|
||||
background: #060a13;
|
||||
color: #e8eaed;
|
||||
border: 1px solid rgba(240, 180, 41, 0.25);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 40px 100px -30px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.lightbox-dialog::backdrop {
|
||||
background: rgba(6, 10, 19, 0.82);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.lightbox-close-form {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
margin: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.lightbox-close {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
color: #f0b429;
|
||||
background: rgba(12, 17, 26, 0.92);
|
||||
border: 1px solid rgba(240, 180, 41, 0.35);
|
||||
transition: background 160ms ease-out, transform 160ms ease-out, border-color 160ms ease-out;
|
||||
}
|
||||
.lightbox-close:hover {
|
||||
background: rgba(240, 180, 41, 0.18);
|
||||
border-color: rgba(240, 180, 41, 0.7);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.lightbox-close:focus-visible {
|
||||
outline: 2px solid #f0b429;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #060a13;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
.lightbox-caption {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0.5rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: #9aa3b2;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Open animation */
|
||||
@keyframes lightbox-in {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes lightbox-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.lightbox-dialog[open] {
|
||||
animation: lightbox-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.lightbox-dialog[open]::backdrop {
|
||||
animation: lightbox-backdrop-in 220ms ease-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lightbox-zoom-badge {
|
||||
transition: none;
|
||||
}
|
||||
.lightbox-dialog[open],
|
||||
.lightbox-dialog[open]::backdrop {
|
||||
animation: none;
|
||||
}
|
||||
.lightbox-close {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Open lightbox on trigger click. Close on click of the image itself or
|
||||
// the backdrop. Bundled by Astro — CSP script-src 'self'.
|
||||
const triggers = document.querySelectorAll<HTMLButtonElement>('[data-lightbox-open]');
|
||||
triggers.forEach((trigger) => {
|
||||
trigger.addEventListener('click', () => {
|
||||
const id = trigger.getAttribute('data-lightbox-open');
|
||||
if (!id) return;
|
||||
const dialog = document.getElementById(id);
|
||||
if (dialog instanceof HTMLDialogElement) {
|
||||
dialog.showModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.lightbox-dialog');
|
||||
dialogs.forEach((dialog) => {
|
||||
dialog.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
// Click on the dialog element itself (the backdrop area) or the image
|
||||
// closes. Clicks on the close button are already handled by the native
|
||||
// form[method=dialog].
|
||||
if (target === dialog || target.classList.contains('lightbox-image')) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
95
src/components/RouteDiagram.astro
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
// A stylized Camel route topology. Two linked routes with a cross-route
|
||||
// (cyan, dashed) reference — the signature Cameleer diagnostic that a
|
||||
// generic APM cannot produce.
|
||||
---
|
||||
<div class="relative w-full aspect-[16/10] max-w-3xl mx-auto">
|
||||
<svg viewBox="0 0 800 500" class="w-full h-full" role="img" aria-label="Camel route topology with cross-route reference">
|
||||
<defs>
|
||||
<marker id="arrow-amber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#f0b429"/>
|
||||
</marker>
|
||||
<marker id="arrow-cyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#5cc8ff"/>
|
||||
</marker>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Route labels */}
|
||||
<text x="40" y="80" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="13" letter-spacing="1">▸ order-ingest</text>
|
||||
<text x="40" y="340" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="13" letter-spacing="1">▸ pricing-enrich</text>
|
||||
|
||||
{/* Route 1 edges */}
|
||||
<line x1="100" y1="130" x2="230" y2="130" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
<line x1="280" y1="130" x2="380" y2="90" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
<line x1="280" y1="130" x2="380" y2="170" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
<line x1="430" y1="90" x2="560" y2="90" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
<line x1="430" y1="170" x2="560" y2="170" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
<line x1="610" y1="130" x2="720" y2="130" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
|
||||
{/* Route 1 nodes */}
|
||||
<g>
|
||||
<circle cx="90" cy="130" r="18" fill="#060a13" stroke="#f0b429" stroke-width="2" filter="url(#glow)"/>
|
||||
<text x="90" y="110" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">kafka</text>
|
||||
<text x="90" y="160" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="10" text-anchor="middle">from</text>
|
||||
|
||||
<rect x="240" y="115" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||
<text x="265" y="134" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">choice</text>
|
||||
|
||||
<rect x="380" y="75" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||
<text x="405" y="94" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">bean</text>
|
||||
<text x="405" y="65" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="9" text-anchor="middle">premium</text>
|
||||
|
||||
<rect x="380" y="155" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||
<text x="405" y="174" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">bean</text>
|
||||
<text x="405" y="206" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="9" text-anchor="middle">standard</text>
|
||||
|
||||
<rect x="560" y="75" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||
<text x="585" y="94" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">log</text>
|
||||
|
||||
<rect x="560" y="155" width="50" height="30" rx="4" fill="#060a13" stroke="#5cc8ff" stroke-width="1.8"/>
|
||||
<text x="585" y="174" fill="#5cc8ff" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">http</text>
|
||||
|
||||
<circle cx="735" cy="130" r="14" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||
<text x="735" y="160" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="10" text-anchor="middle">to</text>
|
||||
</g>
|
||||
|
||||
{/* Cross-route dashed cyan link */}
|
||||
<path
|
||||
d="M 585 185 C 585 290, 300 250, 230 340"
|
||||
stroke="#5cc8ff"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="6 4"
|
||||
fill="none"
|
||||
marker-end="url(#arrow-cyan)"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<text x="430" y="255" fill="#5cc8ff" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">cross-route correlation</text>
|
||||
|
||||
{/* Route 2 edges */}
|
||||
<line x1="100" y1="390" x2="230" y2="390" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
<line x1="280" y1="390" x2="380" y2="390" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
<line x1="430" y1="390" x2="560" y2="390" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
<line x1="610" y1="390" x2="720" y2="390" stroke="#2a3242" stroke-width="2" marker-end="url(#arrow-amber)"/>
|
||||
|
||||
{/* Route 2 nodes */}
|
||||
<g>
|
||||
<circle cx="230" cy="390" r="14" fill="#060a13" stroke="#5cc8ff" stroke-width="1.8"/>
|
||||
<text x="230" y="418" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="10" text-anchor="middle">rest</text>
|
||||
<text x="230" y="370" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">from</text>
|
||||
|
||||
<rect x="330" y="375" width="50" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||
<text x="355" y="394" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">enrich</text>
|
||||
|
||||
<rect x="480" y="375" width="80" height="30" rx="4" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||
<text x="520" y="394" fill="#f0b429" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">transform</text>
|
||||
|
||||
<circle cx="735" cy="390" r="14" fill="#060a13" stroke="#f0b429" stroke-width="1.6"/>
|
||||
<text x="735" y="418" fill="#6b7280" font-family="'JetBrains Mono', monospace" font-size="10" text-anchor="middle">to</text>
|
||||
<text x="735" y="370" fill="#9aa3b2" font-family="'JetBrains Mono', monospace" font-size="11" text-anchor="middle">kafka</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
23
src/components/SiteFooter.astro
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
<footer class="border-t border-border mt-24">
|
||||
<div class="max-w-content mx-auto px-6 py-12 flex flex-col md:flex-row md:items-center md:justify-between gap-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
src="/icons/cameleer-32.png"
|
||||
width="24"
|
||||
height="24"
|
||||
alt=""
|
||||
decoding="async"
|
||||
class="shrink-0 opacity-80"
|
||||
/>
|
||||
<span class="text-text-muted text-sm">© {year} Cameleer</span>
|
||||
</div>
|
||||
<nav class="flex items-center gap-8 text-sm text-text-muted" aria-label="Footer">
|
||||
<a href="/pricing" class="hover:text-text transition-colors">Pricing</a>
|
||||
<a href="/imprint" class="hover:text-text transition-colors">Imprint</a>
|
||||
<a href="/privacy" class="hover:text-text transition-colors">Privacy</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
22
src/components/SiteHeader.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import CTAButtons from './CTAButtons.astro';
|
||||
---
|
||||
<header class="sticky top-0 z-40 backdrop-blur-md bg-bg/80 border-b border-border">
|
||||
<div class="max-w-content mx-auto px-6 h-16 flex items-center justify-between gap-6">
|
||||
<a href="/" class="flex items-center gap-2 group" aria-label="Cameleer home">
|
||||
<img
|
||||
src="/icons/cameleer-48.png"
|
||||
width="32"
|
||||
height="32"
|
||||
alt=""
|
||||
decoding="async"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="font-sans font-bold text-lg tracking-tight text-text group-hover:text-accent transition-colors">Cameleer</span>
|
||||
</a>
|
||||
<nav class="flex items-center gap-8 text-sm" aria-label="Primary">
|
||||
<a href="/pricing" class="text-text-muted hover:text-text transition-colors">Pricing</a>
|
||||
</nav>
|
||||
<CTAButtons size="md" />
|
||||
</div>
|
||||
</header>
|
||||
68
src/components/TopographicBg.astro
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
interface Props {
|
||||
opacity?: number;
|
||||
lines?: number;
|
||||
}
|
||||
const { opacity = 0.35, lines = 9 } = Astro.props;
|
||||
|
||||
interface Line {
|
||||
d: string;
|
||||
width: number; // stroke width in CSS px (non-scaling)
|
||||
lineOpacity: number; // per-line opacity (0..1) — varies depth
|
||||
tone: 'amber' | 'cyan';
|
||||
}
|
||||
|
||||
const out: Line[] = [];
|
||||
const stepY = 100 / (lines + 1);
|
||||
for (let i = 1; i <= lines; i++) {
|
||||
const y = i * stepY;
|
||||
// Mix two frequencies so adjacent lines don't read parallel.
|
||||
const amp = 3 + (i % 3) * 2 + Math.sin(i * 1.7) * 1.2;
|
||||
const phase = (i * 13) % 25; // shift crests horizontally
|
||||
const d = `M0,${y} Q${25 + phase / 3},${y - amp} ${50 + phase / 5},${y + amp * 0.6} T100,${y + (i % 2 ? 1 : -1)}`;
|
||||
// Vary stroke weight with a triangle wave — gives the feel of cartographic contour intervals.
|
||||
const triangle = Math.abs(((i + 2) % 4) - 2) / 2;
|
||||
const width = 0.6 + triangle * 0.9;
|
||||
// Depth: middle lines darker, edges lighter.
|
||||
const depth = 1 - Math.abs((i - (lines + 1) / 2)) / ((lines + 1) / 2);
|
||||
const lineOpacity = 0.35 + depth * 0.65;
|
||||
// One cyan line roughly every 4th — echo of the cross-route correlation color.
|
||||
const tone: 'amber' | 'cyan' = i % 4 === 2 ? 'cyan' : 'amber';
|
||||
out.push({ d, width, lineOpacity, tone });
|
||||
}
|
||||
---
|
||||
<div
|
||||
class="topo-wrap absolute inset-0 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
style={`--topo-opacity:${opacity}`}
|
||||
>
|
||||
<svg
|
||||
class="topo-svg absolute inset-0 w-full h-full"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<g fill="none" vector-effect="non-scaling-stroke" stroke-linecap="round">
|
||||
{out.map((l) => (
|
||||
<path
|
||||
d={l.d}
|
||||
stroke={l.tone === 'cyan' ? '#5cc8ff' : '#f0b429'}
|
||||
stroke-width={l.width}
|
||||
stroke-opacity={l.lineOpacity}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.topo-wrap {
|
||||
opacity: var(--topo-opacity, 0.35);
|
||||
/* Soft edge fade — lines should feel like they dissolve at the section
|
||||
boundaries rather than hit them hard. */
|
||||
-webkit-mask-image: radial-gradient(ellipse at 50% 45%, black 55%, transparent 95%);
|
||||
mask-image: radial-gradient(ellipse at 50% 45%, black 55%, transparent 95%);
|
||||
}
|
||||
.topo-svg {
|
||||
filter: blur(0.15px);
|
||||
}
|
||||
</style>
|
||||
18
src/components/sections/FinalCTA.astro
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import CTAButtons from '../CTAButtons.astro';
|
||||
import TopographicBg from '../TopographicBg.astro';
|
||||
---
|
||||
<section class="relative overflow-hidden">
|
||||
<TopographicBg opacity={0.18} lines={6} />
|
||||
<div class="relative max-w-content mx-auto px-6 py-24 md:py-32 text-center">
|
||||
<h2 class="text-display font-bold text-text mb-6">
|
||||
Ship integrations. Sleep through the night.
|
||||
</h2>
|
||||
<p class="text-lg md:text-xl text-text-muted max-w-prose mx-auto mb-10">
|
||||
14-day free trial. Your first Camel app, hosted, traced, and running in under ten minutes. No code changes.
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<CTAButtons size="lg" showSecondary={false} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
127
src/components/sections/Hero.astro
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
import CTAButtons from '../CTAButtons.astro';
|
||||
import TopographicBg from '../TopographicBg.astro';
|
||||
import Lightbox from '../Lightbox.astro';
|
||||
|
||||
interface Pin {
|
||||
label: string;
|
||||
body: string;
|
||||
top: string;
|
||||
left: string;
|
||||
}
|
||||
|
||||
const pins: Pin[] = [
|
||||
{ label: '01', body: 'Correlation ID — click to follow one exchange across services.', top: '14%', left: '12%' },
|
||||
{ label: '02', body: 'Failure in context — circuit breaker tripped, fallback ran, tried backend:80.', top: '46%', left: '52%' },
|
||||
{ label: '03', body: 'Full error pinned — exception, stack trace, headers, payload.', top: '78%', left: '78%' },
|
||||
];
|
||||
---
|
||||
<section class="relative overflow-hidden border-b border-border">
|
||||
<TopographicBg opacity={0.22} lines={11} />
|
||||
<div class="relative max-w-content mx-auto px-6 pt-16 pb-20 md:pt-24 md:pb-24 lg:pt-28">
|
||||
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 items-center">
|
||||
<div class="lg:col-span-5">
|
||||
<img
|
||||
src="/icons/cameleer-192.png"
|
||||
width="64"
|
||||
height="64"
|
||||
alt=""
|
||||
decoding="async"
|
||||
class="shrink-0 mb-5 hero-mark"
|
||||
/>
|
||||
<p
|
||||
class="inline-flex items-center gap-2 mb-7 rounded-full border border-accent/30 bg-accent/[0.08] text-accent px-3.5 py-1 text-sm italic font-medium"
|
||||
>
|
||||
<span aria-hidden="true" class="text-base">✦</span>
|
||||
Your camels called. They want a GPS.
|
||||
</p>
|
||||
<h1 class="font-bold text-text mb-6 hero-h1">
|
||||
Ship Camel integrations. Sleep through the night.
|
||||
</h1>
|
||||
<p class="text-lg md:text-xl text-text-muted max-w-prose leading-relaxed mb-8">
|
||||
Cameleer is the hosted runtime and observability platform for Apache Camel — auto-traced, replay-ready, cross-service correlated. The 3 AM page becomes a 30-second answer.
|
||||
</p>
|
||||
<CTAButtons
|
||||
size="lg"
|
||||
secondaryLabel="See it in action ↓"
|
||||
secondaryHref="#walkthrough"
|
||||
/>
|
||||
<p class="mt-4 font-mono text-xs text-text-faint">
|
||||
14-day trial · from €20/mo · no credit card
|
||||
</p>
|
||||
</div>
|
||||
<div class="lg:col-span-7 relative">
|
||||
<div class="hero-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
||||
<Lightbox
|
||||
src="/product/exchange-detail.png"
|
||||
alt="Cameleer Mission Control — route execution detail with processor-level trace"
|
||||
width={1920}
|
||||
height={945}
|
||||
loading="eager"
|
||||
/>
|
||||
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
||||
{pins.map((pin) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="hero-pin absolute inline-flex items-center justify-center w-7 h-7 rounded-full bg-accent text-bg font-mono text-xs font-bold pointer-events-none"
|
||||
style={`top:${pin.top};left:${pin.left}`}
|
||||
>
|
||||
{pin.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ul class="hero-pin-legend mt-5 grid sm:grid-cols-3 gap-3 text-text-muted">
|
||||
{pins.map((pin) => (
|
||||
<li class="flex items-start gap-2 text-sm leading-snug">
|
||||
<span class="font-mono text-accent text-xs mt-0.5">{pin.label}</span>
|
||||
<span>{pin.body}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div aria-hidden="true" class="hero-shot-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero-h1 {
|
||||
font-size: clamp(2.25rem, 4.5vw, 4rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.hero-shot {
|
||||
box-shadow:
|
||||
0 1px 0 rgba(240, 180, 41, 0.08) inset,
|
||||
0 30px 60px -20px rgba(0, 0, 0, 0.6),
|
||||
0 10px 25px -10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.hero-pin {
|
||||
box-shadow: 0 0 0 4px rgba(240, 180, 41, 0.22), 0 4px 10px -2px rgba(0, 0, 0, 0.5);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.hero-shot-glow {
|
||||
position: absolute;
|
||||
inset: 10% -5% 10% -5%;
|
||||
background: radial-gradient(
|
||||
60% 60% at 50% 50%,
|
||||
rgba(240, 180, 41, 0.18),
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(40px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes hero-mark-sway {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-2px) rotate(-1.5deg); }
|
||||
}
|
||||
.hero-mark {
|
||||
animation: hero-mark-sway 7s ease-in-out infinite;
|
||||
transform-origin: 50% 90%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-mark { animation: none; }
|
||||
}
|
||||
</style>
|
||||
47
src/components/sections/HowItWorks.astro
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
interface Step {
|
||||
n: string;
|
||||
title: string;
|
||||
body: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
n: '01',
|
||||
title: 'Point us at your Camel app',
|
||||
body: 'Drop it in, or connect one you already run. No code changes.',
|
||||
},
|
||||
{
|
||||
n: '02',
|
||||
title: 'We take it from there',
|
||||
body: 'Every route, every processor, every exchange — discovered and traced automatically. Sensitive fields are masked by default.',
|
||||
},
|
||||
{
|
||||
n: '03',
|
||||
title: 'Watch it run',
|
||||
body: 'Browse executions, tap live traffic, replay failed exchanges, follow flows across services.',
|
||||
},
|
||||
];
|
||||
---
|
||||
<section class="border-b border-border">
|
||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||
<div class="max-w-2xl mb-16">
|
||||
<p class="text-cyan font-mono text-xs tracking-[0.25em] uppercase mb-4">For engineers</p>
|
||||
<h2 class="text-hero font-bold text-text mb-4">How it works</h2>
|
||||
<p class="text-text-muted text-lg">Three steps. Nothing to maintain.</p>
|
||||
</div>
|
||||
<ol class="grid md:grid-cols-3 gap-6 md:gap-8">
|
||||
{steps.map((step) => (
|
||||
<li class="relative rounded-lg border border-border bg-bg-elevated p-7 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-accent/40 hover:shadow-[0_12px_32px_-12px_rgba(240,180,41,0.18)]">
|
||||
<div class="font-mono text-accent text-sm tracking-wider mb-3">{step.n}</div>
|
||||
<h3 class="text-lg font-bold text-text mb-3">{step.title}</h3>
|
||||
<p class="text-text-muted leading-relaxed mb-4">{step.body}</p>
|
||||
{step.code && (
|
||||
<pre class="font-mono text-xs md:text-sm bg-bg border border-border rounded px-4 py-3 text-text-muted overflow-x-auto leading-relaxed"><code>{step.code}</code></pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
79
src/components/sections/PricingTeaser.astro
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
import { getAuthConfig } from '../../config/auth';
|
||||
|
||||
interface Tier {
|
||||
name: string;
|
||||
price: string;
|
||||
sub: string;
|
||||
href: string;
|
||||
cta: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const auth = getAuthConfig();
|
||||
|
||||
const tiers: Tier[] = [
|
||||
{
|
||||
name: 'Trial',
|
||||
price: 'Free',
|
||||
sub: '14 days · 1 environment · 2 apps',
|
||||
href: auth.signUpUrl,
|
||||
cta: 'Start free trial',
|
||||
},
|
||||
{
|
||||
name: 'Starter',
|
||||
price: '20 € /mo',
|
||||
sub: '2 environments · 10 apps · 7-day retention',
|
||||
href: auth.signUpUrl,
|
||||
cta: 'Start on Starter',
|
||||
highlight: true,
|
||||
},
|
||||
];
|
||||
---
|
||||
<section class="border-b border-border">
|
||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||
<div class="max-w-2xl mb-12">
|
||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
||||
<h2 class="text-hero font-bold text-text mb-4">Start free. Grow when you need to.</h2>
|
||||
<p class="text-text-muted text-lg">
|
||||
No credit card. No sales call. Just a working trial in ten minutes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-5 lg:items-stretch max-w-3xl">
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
class={`relative rounded-lg bg-bg-elevated p-6 flex flex-col transition-all duration-200 ease-out hover:-translate-y-0.5
|
||||
${tier.highlight
|
||||
? 'ring-2 ring-accent shadow-[0_20px_50px_-20px_rgba(240,180,41,0.35)] md:-translate-y-2 md:pt-8 md:pb-7'
|
||||
: 'border border-border hover:border-accent/40 hover:shadow-[0_12px_32px_-12px_rgba(240,180,41,0.18)]'}`}
|
||||
>
|
||||
{tier.highlight && (
|
||||
<span class="absolute -top-3 left-6 inline-flex items-center gap-1.5 rounded-full bg-accent text-bg px-3 py-0.5 text-[11px] font-bold tracking-wide font-mono">
|
||||
<span aria-hidden="true">★</span>
|
||||
MOST POPULAR
|
||||
</span>
|
||||
)}
|
||||
<div class="mb-4">
|
||||
<div class={`font-mono text-xs tracking-wider mb-2 ${tier.highlight ? 'text-accent' : 'text-text-muted'}`}>{tier.name.toUpperCase()}</div>
|
||||
<div class="text-2xl font-bold text-text">{tier.price}</div>
|
||||
</div>
|
||||
<p class="text-text-muted text-sm leading-relaxed flex-grow mb-5">{tier.sub}</p>
|
||||
<a
|
||||
href={tier.href}
|
||||
class={`inline-flex items-center justify-center rounded px-4 py-2 text-sm font-semibold transition-colors
|
||||
${tier.highlight
|
||||
? 'bg-accent text-bg hover:bg-accent-muted'
|
||||
: 'border border-border-strong text-text hover:border-accent hover:text-accent'}`}
|
||||
>
|
||||
{tier.cta}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p class="mt-8 font-mono text-sm text-text-muted">
|
||||
<a href="/pricing" class="text-cyan hover:text-accent transition-colors">
|
||||
See all plans (Scale, Enterprise) →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
33
src/components/sections/SocialProofStrip.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import { getAuthConfig } from '../../config/auth';
|
||||
|
||||
const auth = getAuthConfig();
|
||||
|
||||
// PENDING — [Founder Name] placeholder must be filled in before publish.
|
||||
const founderName = '[Founder Name]';
|
||||
const designPartnerSubject = 'Design partner enquiry — Cameleer';
|
||||
const designPartnerHref = `mailto:${auth.salesEmail}?subject=${encodeURIComponent(designPartnerSubject)}`;
|
||||
---
|
||||
<section class="border-b border-border">
|
||||
<div class="max-w-content mx-auto px-6 py-16 md:py-20">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">
|
||||
// Built by people who've done this before
|
||||
</p>
|
||||
<blockquote class="border-l-[3px] border-accent pl-5 max-w-[62ch]">
|
||||
<p class="text-lg md:text-xl text-text italic leading-relaxed mb-3">
|
||||
“We spent 15 years building integration monitoring for banks that couldn’t afford downtime. Cameleer is what we’d build today — purpose-built for Apache Camel, no retrofit.”
|
||||
</p>
|
||||
<footer class="text-sm font-mono text-text-muted">
|
||||
— <span class="text-text">{founderName}</span>, co-founder
|
||||
</footer>
|
||||
</blockquote>
|
||||
<a
|
||||
href={designPartnerHref}
|
||||
class="inline-flex items-center gap-2 mt-7 font-mono text-sm text-cyan hover:text-accent transition-colors"
|
||||
>
|
||||
Apply to the design-partner program <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
104
src/components/sections/ThreeAmWalkthrough.astro
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
import Lightbox from '../Lightbox.astro';
|
||||
|
||||
interface Callout {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const callouts: Callout[] = [
|
||||
{
|
||||
title: 'Cross-service correlation.',
|
||||
body: 'Every exchange carries its correlation ID forward. One click jumps to what the downstream route did with the same message.',
|
||||
},
|
||||
{
|
||||
title: 'Runtime detail, not guesswork.',
|
||||
body: 'Circuit breaker tripped. Fallback path ran. Request tried backend:80. The pieces a 3 AM page actually needs — already captured.',
|
||||
},
|
||||
{
|
||||
title: 'The whole story of a failure.',
|
||||
body: 'Exception class, message, stack trace, headers, payload — all pinned to the exchange. No log-grepping tour.',
|
||||
},
|
||||
];
|
||||
---
|
||||
<section id="walkthrough" class="border-b border-border bg-bg">
|
||||
<div class="max-w-content mx-auto px-6 py-24 md:py-32">
|
||||
<div class="max-w-3xl mb-14 md:mb-20">
|
||||
<p class="text-cyan font-mono text-xs tracking-[0.25em] uppercase mb-4">// When something breaks</p>
|
||||
<h2 class="text-hero font-bold text-text mb-5">
|
||||
The 3 AM page. With and without Cameleer.
|
||||
</h2>
|
||||
<p class="text-lg text-text-muted leading-relaxed">
|
||||
Same Camel app. Same failed exchange. Different night.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6 md:gap-8 items-stretch">
|
||||
<div class="without-card relative rounded-lg border border-dashed border-border-strong bg-bg overflow-hidden">
|
||||
<div class="px-5 pt-5 pb-3 font-mono text-[11px] tracking-[0.2em] uppercase text-text-faint border-b border-border">
|
||||
Without Cameleer · 03:12 AM
|
||||
</div>
|
||||
<pre class="font-mono text-[13px] leading-[1.65] text-text-muted px-5 py-5 overflow-x-auto whitespace-pre"><code><span class="text-text">$</span> kubectl logs camel-router-7d4f8c
|
||||
<span class="text-rose">ERROR</span> org.apache.camel.CamelExecutionException
|
||||
at org.apache.camel.processor.SendProcessor.process
|
||||
at org.apache.camel.processor.Pipeline.process
|
||||
...
|
||||
|
||||
<span class="text-text">$</span> grep "order-842" *.log
|
||||
router-3.log: <span class="text-accent">WARN</span> exchange order-842 stuck in saga-fulfillment
|
||||
router-3.log: <span class="text-rose">ERROR</span> processor backend:80 → connect timeout
|
||||
|
||||
<span class="text-text">$</span> ssh prod-integration-3
|
||||
prod-integration-3 $ kubectl logs ...
|
||||
|
||||
> <span class="text-cyan">slack #integration-team</span>
|
||||
"anyone know why order-842 is stuck??"
|
||||
<span class="text-text-faint">[3 of 4 reactions, no answer]</span>
|
||||
|
||||
<span class="text-accent">~47 min later: someone wakes up an SRE.</span></code></pre>
|
||||
</div>
|
||||
|
||||
<figure class="with-card relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
||||
<div class="px-5 pt-5 pb-3 font-mono text-[11px] tracking-[0.2em] uppercase text-accent border-b border-border">
|
||||
With Cameleer · 30 sec
|
||||
</div>
|
||||
<Lightbox
|
||||
src="/product/error-detail.png"
|
||||
alt="Cameleer Mission Control — failed exchange order-842 with full execution context"
|
||||
width={1920}
|
||||
height={945}
|
||||
loading="lazy"
|
||||
imgClass="block w-full h-auto"
|
||||
/>
|
||||
<div class="px-5 py-4 font-mono text-[13px] leading-[1.6] text-text-muted border-t border-border">
|
||||
<span class="text-accent">▸</span> Open exchange <span class="text-text">order-842</span> → see the failure pinned → click <span class="text-text">Replay</span> after fix.
|
||||
</div>
|
||||
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<ul class="grid md:grid-cols-3 gap-6 md:gap-8 mt-14">
|
||||
{callouts.map((c, i) => (
|
||||
<li class="relative pl-10">
|
||||
<span
|
||||
class="absolute left-0 top-0 inline-flex items-center justify-center w-7 h-7 rounded-full border border-accent/40 bg-accent/10 text-accent font-mono text-xs"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h3 class="text-text font-semibold mb-1.5">{c.title}</h3>
|
||||
<p class="text-text-muted leading-relaxed">{c.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.with-card {
|
||||
box-shadow:
|
||||
0 1px 0 rgba(240, 180, 41, 0.08) inset,
|
||||
0 30px 60px -25px rgba(0, 0, 0, 0.7),
|
||||
0 12px 30px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
32
src/components/sections/WhyUs.astro
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
---
|
||||
<section class="border-b border-border">
|
||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||
<div class="max-w-2xl mb-16">
|
||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Why Cameleer</p>
|
||||
<h2 class="text-hero font-bold text-text mb-4">
|
||||
A purpose-built tool, from the team that has built integration observability before.
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-8 md:gap-12">
|
||||
<div class="rounded-lg border border-border bg-bg-elevated p-8 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-accent/40 hover:shadow-[0_12px_32px_-12px_rgba(240,180,41,0.18)]">
|
||||
<h3 class="text-xl font-bold text-text mb-4">Generic APMs do not understand Camel. Cameleer does.</h3>
|
||||
<p class="text-text-muted leading-relaxed mb-4">
|
||||
Most monitoring tools see your app as a Java process and a pile of HTTP calls. Cameleer understands that you are running a Camel app — choices, splits, multicasts, error handlers, and every other EIP pattern as first-class citizens.
|
||||
</p>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
So when you ask "why did this exchange fail?", you get an answer, not a log tail. And you can reach back into a running app to replay a message, deep-trace a correlation ID, or toggle recording — observability that does things, not just shows them.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-bg-elevated p-8 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-accent/40 hover:shadow-[0_12px_32px_-12px_rgba(240,180,41,0.18)]">
|
||||
<h3 class="text-xl font-bold text-text mb-4">Built by people who've operated integration in production for 15 years.</h3>
|
||||
<p class="text-text-muted leading-relaxed mb-4">
|
||||
We spent over a decade building integration monitoring for banks, insurers, and logistics operators — the kind of shops where a stuck exchange is a regulatory event, not just an inconvenience.
|
||||
</p>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
Cameleer is what we'd build today, purpose-built for Apache Camel. No legacy, no retrofit, no assumptions about a generic middleware platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
62
src/config/auth.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveAuthConfig } from './auth';
|
||||
|
||||
describe('resolveAuthConfig', () => {
|
||||
it('returns both URLs and sales email from env', () => {
|
||||
const cfg = resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||
});
|
||||
expect(cfg.signInUrl).toBe('https://app.cameleer.io/sign-in');
|
||||
expect(cfg.signUpUrl).toBe('https://app.cameleer.io/sign-in?first_screen=register');
|
||||
expect(cfg.salesEmail).toBe('sales@cameleer.io');
|
||||
});
|
||||
|
||||
it('throws if PUBLIC_AUTH_SIGNIN_URL is missing', () => {
|
||||
expect(() => resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||
})).toThrow(/PUBLIC_AUTH_SIGNIN_URL/);
|
||||
});
|
||||
|
||||
it('throws if a URL is not https', () => {
|
||||
expect(() => resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNIN_URL: 'http://app.cameleer.io/sign-in',
|
||||
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||
})).toThrow(/must be https/);
|
||||
});
|
||||
|
||||
it('throws if sales email is not a valid mailto target', () => {
|
||||
expect(() => resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||
PUBLIC_SALES_EMAIL: 'not-an-email',
|
||||
})).toThrow(/PUBLIC_SALES_EMAIL/);
|
||||
});
|
||||
|
||||
it('throws if PUBLIC_AUTH_SIGNUP_URL is missing', () => {
|
||||
expect(() => resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||
})).toThrow(/PUBLIC_AUTH_SIGNUP_URL/);
|
||||
});
|
||||
|
||||
it('throws if PUBLIC_AUTH_SIGNUP_URL is not https', () => {
|
||||
expect(() => resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||
PUBLIC_AUTH_SIGNUP_URL: 'http://app.cameleer.io/sign-in?first_screen=register',
|
||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||
})).toThrow(/must be https/);
|
||||
});
|
||||
|
||||
it('exposes signUpUrl distinct from signInUrl', () => {
|
||||
const cfg = resolveAuthConfig({
|
||||
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||
});
|
||||
expect(cfg.signUpUrl).not.toBe(cfg.signInUrl);
|
||||
});
|
||||
});
|
||||
56
src/config/auth.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export interface AuthConfig {
|
||||
signInUrl: string;
|
||||
signUpUrl: string;
|
||||
salesEmail: string;
|
||||
salesMailto: string;
|
||||
}
|
||||
|
||||
interface EnvLike {
|
||||
PUBLIC_AUTH_SIGNIN_URL?: string;
|
||||
PUBLIC_AUTH_SIGNUP_URL?: string;
|
||||
PUBLIC_SALES_EMAIL?: string;
|
||||
}
|
||||
|
||||
function requireHttps(name: string, value: string | undefined): string {
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
if (!value.startsWith('https://')) {
|
||||
throw new Error(`${name} must be https (got: ${value})`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireEmail(name: string, value: string | undefined): string {
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
// RFC-5322-ish minimal check — we just want to catch typos, not validate against RFC.
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
throw new Error(`${name} must look like an email (got: ${value})`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveAuthConfig(env: EnvLike): AuthConfig {
|
||||
const signInUrl = requireHttps('PUBLIC_AUTH_SIGNIN_URL', env.PUBLIC_AUTH_SIGNIN_URL);
|
||||
const signUpUrl = requireHttps('PUBLIC_AUTH_SIGNUP_URL', env.PUBLIC_AUTH_SIGNUP_URL);
|
||||
const salesEmail = requireEmail('PUBLIC_SALES_EMAIL', env.PUBLIC_SALES_EMAIL);
|
||||
return {
|
||||
signInUrl,
|
||||
signUpUrl,
|
||||
salesEmail,
|
||||
salesMailto: `mailto:${salesEmail}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Lazy accessor for Astro usage. Not evaluated at module load (so vitest can
|
||||
// import this file without the PUBLIC_* env vars being set). Each call after
|
||||
// the first returns the cached config.
|
||||
let _cached: AuthConfig | null = null;
|
||||
export function getAuthConfig(): AuthConfig {
|
||||
if (_cached === null) {
|
||||
_cached = resolveAuthConfig(import.meta.env as unknown as EnvLike);
|
||||
}
|
||||
return _cached;
|
||||
}
|
||||
23
src/content/config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
// Scaffold only — no entries yet. Added now so future blog/docs work is pure content, not refactor.
|
||||
const blog = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
publishedAt: z.date(),
|
||||
draft: z.boolean().default(true),
|
||||
}),
|
||||
});
|
||||
|
||||
const docs = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
order: z.number().default(99),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog, docs };
|
||||
0
src/content/en/.gitkeep
Normal file
54
src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath?: string;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
canonicalPath = Astro.url.pathname,
|
||||
ogImage = '/og-image.svg',
|
||||
} = Astro.props;
|
||||
|
||||
const canonical = new URL(canonicalPath, Astro.site ?? 'https://www.cameleer.io').toString();
|
||||
const ogUrl = new URL(ogImage, Astro.site ?? 'https://www.cameleer.io').toString();
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="en" class="bg-bg">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#060a13" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/cameleer-32.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/cameleer-180.png" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Cameleer" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogUrl} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogUrl} />
|
||||
|
||||
<meta name="robots" content="index,follow" />
|
||||
</head>
|
||||
<body class="min-h-screen bg-bg text-text font-sans antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
54
src/middleware.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSecurityHeaders } from './middleware';
|
||||
|
||||
describe('buildSecurityHeaders', () => {
|
||||
const headers = buildSecurityHeaders();
|
||||
|
||||
it('sets a strict Content-Security-Policy', () => {
|
||||
const csp = headers['Content-Security-Policy'];
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
expect(csp).toContain("script-src 'self'");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
expect(csp).toContain("form-action 'none'");
|
||||
expect(csp).toContain("base-uri 'self'");
|
||||
expect(csp).toContain("object-src 'none'");
|
||||
});
|
||||
|
||||
it('denies framing', () => {
|
||||
expect(headers['X-Frame-Options']).toBe('DENY');
|
||||
});
|
||||
|
||||
it('disables MIME sniffing', () => {
|
||||
expect(headers['X-Content-Type-Options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
it('sets a strict referrer policy', () => {
|
||||
expect(headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin');
|
||||
});
|
||||
|
||||
it('disables sensitive browser features', () => {
|
||||
const pp = headers['Permissions-Policy'];
|
||||
expect(pp).toContain('geolocation=()');
|
||||
expect(pp).toContain('microphone=()');
|
||||
expect(pp).toContain('camera=()');
|
||||
expect(pp).toContain('payment=()');
|
||||
});
|
||||
|
||||
it('sets HSTS with long max-age, subdomains, and preload', () => {
|
||||
const hsts = headers['Strict-Transport-Security'];
|
||||
expect(hsts).toContain('max-age=31536000');
|
||||
expect(hsts).toContain('includeSubDomains');
|
||||
expect(hsts).toContain('preload');
|
||||
});
|
||||
|
||||
it('does not allow inline scripts', () => {
|
||||
// Script directive must not include 'unsafe-inline' — find it explicitly and assert.
|
||||
const scriptDirective = headers['Content-Security-Policy']
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.find(s => s.startsWith('script-src')) ?? '';
|
||||
expect(scriptDirective).toContain("'self'");
|
||||
expect(scriptDirective).not.toContain("'unsafe-inline'");
|
||||
expect(scriptDirective).not.toContain("'unsafe-eval'");
|
||||
});
|
||||
});
|
||||
55
src/middleware.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
|
||||
/**
|
||||
* Emits the site-wide security header set. Astro does not attach these to
|
||||
* statically-built responses at runtime on a shared host — but `preview` uses
|
||||
* them locally, and when Cloudflare Transform Rules are configured (per
|
||||
* docs/superpowers/specs/2026-04-24-cameleer-website-design.md §5.3) the
|
||||
* edge re-emits the same set for the prod origin. Having both is defense
|
||||
* in depth.
|
||||
*/
|
||||
export function buildSecurityHeaders(): Record<string, string> {
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"img-src 'self' data:",
|
||||
// Astro's scoped-style system injects inline style attributes at build time;
|
||||
// 'unsafe-inline' here is required until we migrate to a hash/nonce strategy.
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"font-src 'self'",
|
||||
"script-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
// No forms on this marketing site today (all auth redirects go to app.cameleer.io
|
||||
// as plain <a> navigations). If a future form is added, relax to 'self' or an allow-list.
|
||||
"form-action 'none'",
|
||||
"object-src 'none'",
|
||||
].join('; ');
|
||||
|
||||
// Must match .htaccess and the Cloudflare Transform Rule in OPERATOR-CHECKLIST.md.
|
||||
const permissionsPolicy = [
|
||||
'geolocation=()',
|
||||
'microphone=()',
|
||||
'camera=()',
|
||||
'payment=()',
|
||||
'usb=()',
|
||||
].join(', ');
|
||||
|
||||
return {
|
||||
'Content-Security-Policy': csp,
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Permissions-Policy': permissionsPolicy,
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
|
||||
};
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (_context, next) => {
|
||||
const response = await next();
|
||||
const headers = buildSecurityHeaders();
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
84
src/pages/imprint.astro
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import SiteHeader from '../components/SiteHeader.astro';
|
||||
import SiteFooter from '../components/SiteFooter.astro';
|
||||
|
||||
// Imprint (Impressum) per TMG §5 / DDG §5.
|
||||
// Values prefixed "<TODO:" MUST be replaced with real operator data before go-live.
|
||||
// See docs/superpowers/specs/2026-04-24-cameleer-website-design.md §6.4.
|
||||
const operator = {
|
||||
legalName: '<TODO:legal name of operating entity>',
|
||||
streetAddress: '<TODO:street and number>',
|
||||
postalCity: '<TODO:postal code and city>',
|
||||
country: 'Germany',
|
||||
email: '<TODO:contact email>',
|
||||
phone: '<TODO:phone (optional but recommended)>',
|
||||
vatId: '<TODO:VAT ID per §27a UStG, or "not applicable">',
|
||||
registerEntry: '<TODO:commercial register + court, or "not applicable">',
|
||||
responsibleForContent: '<TODO:responsible party per §18 Abs. 2 MStV>',
|
||||
};
|
||||
---
|
||||
<BaseLayout
|
||||
title="Imprint — Cameleer"
|
||||
description="Legal imprint (Impressum) for Cameleer per German TMG §5 / DDG §5."
|
||||
>
|
||||
<SiteHeader />
|
||||
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
||||
<h1 class="text-hero font-bold text-text mb-8">Imprint</h1>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">Information pursuant to § 5 TMG / § 5 DDG</h2>
|
||||
<address class="not-italic text-text-muted leading-relaxed">
|
||||
{operator.legalName}<br />
|
||||
{operator.streetAddress}<br />
|
||||
{operator.postalCity}<br />
|
||||
{operator.country}
|
||||
</address>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">Contact</h2>
|
||||
<ul class="text-text-muted space-y-1">
|
||||
<li>Email: <span class="font-mono text-accent">{operator.email}</span></li>
|
||||
<li>Phone: {operator.phone}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">VAT ID</h2>
|
||||
<p class="text-text-muted">
|
||||
VAT identification number pursuant to § 27 a UStG: <span class="font-mono">{operator.vatId}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">Commercial register</h2>
|
||||
<p class="text-text-muted">{operator.registerEntry}</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">Responsible for content per § 18 Abs. 2 MStV</h2>
|
||||
<p class="text-text-muted">{operator.responsibleForContent}</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">EU dispute resolution</h2>
|
||||
<p class="text-text-muted">
|
||||
The European Commission provides a platform for online dispute resolution (ODR) at
|
||||
<a href="https://ec.europa.eu/consumers/odr/" class="text-accent hover:underline">https://ec.europa.eu/consumers/odr/</a>.
|
||||
We are not obligated and do not participate in dispute resolution proceedings before a consumer arbitration board.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-text mb-3">Liability for content and links</h2>
|
||||
<p class="text-text-muted leading-relaxed mb-4">
|
||||
As a service provider, we are responsible for our own content on these pages in accordance with § 7 para. 1 TMG and general laws. According to §§ 8 to 10 TMG, however, we are not obligated to monitor transmitted or stored third-party information or to investigate circumstances that indicate unlawful activity.
|
||||
</p>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
Our website contains links to external websites of third parties, on whose contents we have no influence. Therefore, we cannot assume any liability for these external contents. The respective provider or operator of the pages is always responsible for the content of linked pages.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
28
src/pages/index.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import SiteHeader from '../components/SiteHeader.astro';
|
||||
import SiteFooter from '../components/SiteFooter.astro';
|
||||
import Hero from '../components/sections/Hero.astro';
|
||||
import SocialProofStrip from '../components/sections/SocialProofStrip.astro';
|
||||
import ThreeAmWalkthrough from '../components/sections/ThreeAmWalkthrough.astro';
|
||||
import HowItWorks from '../components/sections/HowItWorks.astro';
|
||||
import WhyUs from '../components/sections/WhyUs.astro';
|
||||
import PricingTeaser from '../components/sections/PricingTeaser.astro';
|
||||
import FinalCTA from '../components/sections/FinalCTA.astro';
|
||||
---
|
||||
<BaseLayout
|
||||
title="Cameleer — Ship Camel integrations. Sleep through the night."
|
||||
description="The hosted runtime and observability platform for Apache Camel. Auto-traced, replay-ready, cross-service correlated — so the 3 AM page becomes a 30-second answer."
|
||||
>
|
||||
<SiteHeader />
|
||||
<main>
|
||||
<Hero />
|
||||
<SocialProofStrip />
|
||||
<ThreeAmWalkthrough />
|
||||
<HowItWorks />
|
||||
<WhyUs />
|
||||
<PricingTeaser />
|
||||
<FinalCTA />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
126
src/pages/pricing.astro
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import SiteHeader from '../components/SiteHeader.astro';
|
||||
import SiteFooter from '../components/SiteFooter.astro';
|
||||
import TopographicBg from '../components/TopographicBg.astro';
|
||||
import { getAuthConfig } from '../config/auth';
|
||||
|
||||
interface FullTier {
|
||||
name: string;
|
||||
price: string;
|
||||
priceNote: string;
|
||||
envs: string;
|
||||
apps: string;
|
||||
retention: string;
|
||||
features: string[];
|
||||
href: string;
|
||||
cta: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const auth = getAuthConfig();
|
||||
|
||||
const tiers: FullTier[] = [
|
||||
{
|
||||
name: 'Trial',
|
||||
price: 'Free',
|
||||
priceNote: '14 days',
|
||||
envs: '1 environment',
|
||||
apps: '2 apps',
|
||||
retention: '1-day retention',
|
||||
features: ['Route topology', 'Processor-level tracing', 'Payload capture with redaction'],
|
||||
href: auth.signUpUrl,
|
||||
cta: 'Start free trial',
|
||||
},
|
||||
{
|
||||
name: 'Starter',
|
||||
price: '20 €',
|
||||
priceNote: 'per month',
|
||||
envs: '2 environments',
|
||||
apps: '10 apps',
|
||||
retention: '7-day retention',
|
||||
features: ['Everything in Trial', 'Data flow lineage', 'Cross-service correlation'],
|
||||
href: auth.signUpUrl,
|
||||
cta: 'Start on Starter',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
name: 'Scale',
|
||||
price: 'Contact',
|
||||
priceNote: 'sales',
|
||||
envs: 'Unlimited environments',
|
||||
apps: '50 apps',
|
||||
retention: '90-day retention',
|
||||
features: ['Everything in Starter', 'Live debugger', 'Exchange replay', 'Live tap'],
|
||||
href: auth.salesMailto,
|
||||
cta: 'Talk to sales',
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: 'Contact',
|
||||
priceNote: 'sales',
|
||||
envs: 'Unlimited environments',
|
||||
apps: 'Unlimited apps',
|
||||
retention: '365-day retention',
|
||||
features: ['Everything in Scale', 'Priority support', 'SLA', 'Dedicated success contact'],
|
||||
href: auth.salesMailto,
|
||||
cta: 'Talk to sales',
|
||||
},
|
||||
];
|
||||
---
|
||||
<BaseLayout
|
||||
title="Pricing — Cameleer"
|
||||
description="Simple pricing for Apache Camel observability. Free 14-day trial, then 20 €/month or enterprise plans."
|
||||
>
|
||||
<SiteHeader />
|
||||
<main>
|
||||
<section class="relative overflow-hidden border-b border-border">
|
||||
<TopographicBg opacity={0.12} lines={8} />
|
||||
<div class="relative max-w-content mx-auto px-6 pt-20 pb-12">
|
||||
<p class="text-accent font-mono text-xs tracking-[0.25em] uppercase mb-4">Pricing</p>
|
||||
<h1 class="text-display font-bold text-text mb-6">Priced so engineers can say yes.</h1>
|
||||
<p class="text-lg text-text-muted max-w-prose">
|
||||
Start free for 14 days. No credit card required. Move up when you need more apps, longer retention, or enterprise features.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="max-w-content mx-auto px-6 py-16">
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{tiers.map((tier) => (
|
||||
<div class={`rounded-lg border bg-bg-elevated p-7 flex flex-col ${tier.highlight ? 'border-accent' : 'border-border'}`}>
|
||||
<div class="mb-6">
|
||||
<div class="font-mono text-xs tracking-wider text-text-muted mb-3">{tier.name.toUpperCase()}</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-3xl font-bold text-text">{tier.price}</span>
|
||||
<span class="text-sm text-text-muted">{tier.priceNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="text-sm space-y-2 mb-6 text-text-muted flex-grow">
|
||||
<li><span class="text-text">{tier.envs}</span></li>
|
||||
<li><span class="text-text">{tier.apps}</span></li>
|
||||
<li><span class="text-text">{tier.retention}</span></li>
|
||||
<li class="pt-3 border-t border-border mt-3"></li>
|
||||
{tier.features.map((f) => <li>• {f}</li>)}
|
||||
</ul>
|
||||
<a
|
||||
href={tier.href}
|
||||
class={`inline-flex items-center justify-center rounded px-4 py-2.5 text-sm font-semibold transition-colors
|
||||
${tier.highlight
|
||||
? 'bg-accent text-bg hover:bg-accent-muted'
|
||||
: 'border border-border-strong text-text hover:border-accent hover:text-accent'}`}
|
||||
>
|
||||
{tier.cta}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p class="text-center text-text-faint text-sm mt-10">
|
||||
Prices in EUR, excluding VAT. Billed monthly. Annual billing available for Scale and Enterprise — <a href={auth.salesMailto} class="text-accent hover:underline">talk to sales</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
100
src/pages/privacy.astro
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import SiteHeader from '../components/SiteHeader.astro';
|
||||
import SiteFooter from '../components/SiteFooter.astro';
|
||||
|
||||
const operatorContact = '<TODO:controller contact email (same as imprint)>';
|
||||
const lastUpdated = '2026-04-24';
|
||||
---
|
||||
<BaseLayout
|
||||
title="Privacy Policy — Cameleer"
|
||||
description="Privacy policy for www.cameleer.io — what personal data we process (and don't), legal basis, and your rights under GDPR."
|
||||
>
|
||||
<SiteHeader />
|
||||
<main class="max-w-prose mx-auto px-6 py-16 md:py-24">
|
||||
<h1 class="text-hero font-bold text-text mb-2">Privacy Policy</h1>
|
||||
<p class="text-text-faint text-sm mb-10">Last updated: {lastUpdated}</p>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">1. Overview</h2>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
This policy describes what personal data is processed when you visit <span class="font-mono text-accent">www.cameleer.io</span>. Our goal is to collect as little data as technically possible.
|
||||
<strong class="text-text">We do not set cookies. We do not run analytics scripts. We have no forms on this site.</strong>
|
||||
If and when that changes, this policy will be updated and the change noted in the "Last updated" date above.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">2. Controller</h2>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
The data controller responsible for processing on this site is the operator listed in our
|
||||
<a href="/imprint" class="text-accent hover:underline">imprint</a>.
|
||||
Contact for privacy matters: <span class="font-mono text-accent">{operatorContact}</span>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">3. Server access logs</h2>
|
||||
<p class="text-text-muted leading-relaxed mb-3">
|
||||
When you access this site, our hosting provider (Hetzner Online GmbH, Germany) automatically records standard access log data in order to operate and secure the service:
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-text-muted space-y-1 ml-2">
|
||||
<li>IP address</li>
|
||||
<li>Date and time of the request</li>
|
||||
<li>HTTP method, requested path, and response status</li>
|
||||
<li>User-agent string and referrer</li>
|
||||
</ul>
|
||||
<p class="text-text-muted leading-relaxed mt-3">
|
||||
Legal basis: Art. 6(1)(f) GDPR (legitimate interest in operating and securing the service). Logs are retained for the duration applied by our hosting provider and are not combined with other data sources.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">4. Content delivery via Cloudflare</h2>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
This site is delivered through Cloudflare, Inc. (101 Townsend St, San Francisco, CA 94107, USA). Cloudflare inspects incoming traffic for security and performance purposes (DDoS protection, WAF, caching). Processing is governed by a Data Processing Agreement and the EU Standard Contractual Clauses.
|
||||
Legal basis: Art. 6(1)(f) GDPR (legitimate interest in availability and security).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">5. Cookies</h2>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
This site sets no cookies and uses no browser storage of any kind. No consent banner is required because no consent-requiring technology is in use.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">6. External links</h2>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
Sign-in and sign-up links on this site navigate you to <span class="font-mono text-accent">app.cameleer.io</span> (the Cameleer app, where authentication is handled by Logto). That service has its own privacy policy, which applies from the moment you arrive there.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-bold text-text mb-3">7. Your rights</h2>
|
||||
<p class="text-text-muted leading-relaxed mb-3">
|
||||
Under the GDPR, you have the right to:
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-text-muted space-y-1 ml-2">
|
||||
<li>request access to personal data we process about you (Art. 15)</li>
|
||||
<li>request rectification of inaccurate data (Art. 16)</li>
|
||||
<li>request erasure of your data (Art. 17)</li>
|
||||
<li>request restriction of processing (Art. 18)</li>
|
||||
<li>object to processing based on legitimate interest (Art. 21)</li>
|
||||
<li>lodge a complaint with a supervisory authority (Art. 77)</li>
|
||||
</ul>
|
||||
<p class="text-text-muted leading-relaxed mt-3">
|
||||
Contact us at <span class="font-mono text-accent">{operatorContact}</span> to exercise any of these rights.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-text mb-3">8. Changes to this policy</h2>
|
||||
<p class="text-text-muted leading-relaxed">
|
||||
We may update this policy as our data processing changes (for example, if we later add analytics). The "Last updated" date at the top of this page reflects the most recent revision.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
41
src/styles/global.css
Normal file
@@ -0,0 +1,41 @@
|
||||
@import '@fontsource/dm-sans/400.css';
|
||||
@import '@fontsource/dm-sans/500.css';
|
||||
@import '@fontsource/dm-sans/600.css';
|
||||
@import '@fontsource/dm-sans/700.css';
|
||||
@import '@fontsource/jetbrains-mono/400.css';
|
||||
@import '@fontsource/jetbrains-mono/700.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html {
|
||||
@apply bg-bg text-text font-sans;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
body {
|
||||
@apply min-h-screen;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
@apply font-mono;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-accent text-bg;
|
||||
}
|
||||
:focus-visible {
|
||||
@apply outline outline-2 outline-offset-2 outline-accent;
|
||||
}
|
||||
}
|
||||
46
tailwind.config.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
DEFAULT: '#060a13',
|
||||
elevated: '#0c111a',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#1e2535',
|
||||
strong: '#2a3242',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#f0b429',
|
||||
muted: '#c89321',
|
||||
},
|
||||
cyan: {
|
||||
DEFAULT: '#5cc8ff',
|
||||
muted: '#3a9dd1',
|
||||
},
|
||||
rose: '#f43f5e',
|
||||
green: '#10b981',
|
||||
text: {
|
||||
DEFAULT: '#e8eaed',
|
||||
muted: '#9aa3b2',
|
||||
faint: '#828b9b',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"DM Sans"', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
'display': ['clamp(2.5rem, 5vw, 4.5rem)', { lineHeight: '1.05', letterSpacing: '-0.02em' }],
|
||||
'hero': ['clamp(1.75rem, 3vw, 2.75rem)', { lineHeight: '1.15', letterSpacing: '-0.015em' }],
|
||||
},
|
||||
maxWidth: {
|
||||
content: '72rem',
|
||||
prose: '42rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
5
tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": ["src/**/*", "astro.config.mjs", "tailwind.config.mjs"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'astro:middleware': path.resolve('./src/__mocks__/astro-middleware.ts'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||