Compare commits
26 Commits
ce314adf2d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
183a92123c | ||
|
|
2ec4a86e3d | ||
|
|
147a813119 | ||
|
|
db337ed9c6 | ||
|
|
3773dcc1f8 | ||
|
|
07abb101d4 | ||
|
|
4c98caabc8 | ||
|
|
d0bacdd622 | ||
|
|
49fdd96f4f | ||
|
|
c3511a4c1b | ||
|
|
9b0c36b5e0 | ||
|
|
37897f07c3 | ||
| 8f5e84523e | |||
|
|
fa12df8ec6 | ||
| 3b184488bb | |||
|
|
203e4bfb41 | ||
|
|
8dec3e792a | ||
|
|
9cded54ce3 | ||
|
|
21c1122369 | ||
|
|
5f06e5ccad | ||
|
|
04b930de62 | ||
|
|
b1b6b52f3f | ||
|
|
0ad067847c | ||
|
|
d67a89bacb | ||
|
|
54bbb46755 | ||
|
|
29c2d13776 |
@@ -1,5 +1,5 @@
|
|||||||
# Logto auth endpoints — the marketing site only performs <a href> navigations to these.
|
# Logto auth endpoints — the marketing site only performs <a href> navigations to these.
|
||||||
# No tokens, no cookies, no XHR — these are plain hyperlinks.
|
# No tokens, no cookies, no XHR — these are plain hyperlinks.
|
||||||
PUBLIC_AUTH_SIGNIN_URL=https://auth.cameleer.io/sign-in
|
PUBLIC_AUTH_SIGNIN_URL=https://app.cameleer.io/sign-in
|
||||||
PUBLIC_AUTH_SIGNUP_URL=https://auth.cameleer.io/sign-in?first_screen=register
|
PUBLIC_AUTH_SIGNUP_URL=https://app.cameleer.io/sign-in?first_screen=register
|
||||||
PUBLIC_SALES_EMAIL=sales@cameleer.io
|
PUBLIC_SALES_EMAIL=sales@cameleer.io
|
||||||
|
|||||||
103
.gitea/workflows/deploy-placeholder.yml
Normal file
103
.gitea/workflows/deploy-placeholder.yml
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# cameleer-website — Deploy under-construction placeholder
|
||||||
|
#
|
||||||
|
# MANUAL TRIGGER ONLY. Replaces the live cameleer.io docroot with a static
|
||||||
|
# "back shortly" page. Recovery: trigger Actions → deploy → Run workflow on
|
||||||
|
# the desired main commit.
|
||||||
|
#
|
||||||
|
# Shares the deploy-production concurrency group with deploy.yml so the two
|
||||||
|
# workflows queue rather than race on the same docroot.
|
||||||
|
#
|
||||||
|
# This workflow does NOT run npm/astro. The placeholder is hand-authored
|
||||||
|
# static HTML in placeholder/, deliberately decoupled from the main build so
|
||||||
|
# it can ship even when the main build is broken (which is the worst case in
|
||||||
|
# which a placeholder is needed).
|
||||||
|
#
|
||||||
|
# Required secrets (repo settings → Actions → Secrets):
|
||||||
|
# SFTP_HOST, SFTP_USER, SFTP_PATH, SFTP_KEY, SFTP_KNOWN_HOSTS
|
||||||
|
# PUBLIC_SALES_EMAIL
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
name: deploy-placeholder
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Substitute sales email into placeholder
|
||||||
|
env:
|
||||||
|
PUBLIC_SALES_EMAIL: ${{ secrets.PUBLIC_SALES_EMAIL }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
: "${PUBLIC_SALES_EMAIL:?PUBLIC_SALES_EMAIL secret must be set}"
|
||||||
|
sed -i "s|__SALES_EMAIL__|${PUBLIC_SALES_EMAIL}|g" placeholder/index.html
|
||||||
|
if grep -q '__SALES_EMAIL__' placeholder/index.html; then
|
||||||
|
echo "Token __SALES_EMAIL__ still present after substitution — refusing to ship."
|
||||||
|
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
|
||||||
|
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: |
|
||||||
|
: "${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" \
|
||||||
|
placeholder/ "$SFTP_USER@$SFTP_HOST:$SFTP_PATH/"
|
||||||
|
|
||||||
|
- name: Post-deploy smoke test
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
echo "Confirming the placeholder is live on www.cameleer.io..."
|
||||||
|
# Cache-bust per run so Cloudflare's edge can't serve a stale response
|
||||||
|
# that masks a failed deploy. ?cb=$GITHUB_RUN_ID forces a fresh cache key;
|
||||||
|
# the no-cache request header tells any well-behaved cache to revalidate.
|
||||||
|
CB="$GITHUB_RUN_ID"
|
||||||
|
BODY=$(curl -sf -H 'Cache-Control: no-cache' "https://www.cameleer.io/?cb=$CB")
|
||||||
|
echo "$BODY" | grep -qF 'Routes are remapping' \
|
||||||
|
|| { echo "Sentinel string missing — placeholder did not land."; exit 1; }
|
||||||
|
echo "$BODY" | grep -qF 'mailto:' \
|
||||||
|
|| { echo "mailto: link missing — sales email substitution may have failed."; exit 1; }
|
||||||
|
curl -sfI -H 'Cache-Control: no-cache' "https://www.cameleer.io/cameleer-logo.png?cb=$CB" > /dev/null \
|
||||||
|
|| { echo "cameleer-logo.png not reachable on the live origin."; exit 1; }
|
||||||
|
echo "Placeholder is live."
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
.astro/
|
.astro/
|
||||||
|
.lighthouseci/
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ Add these under Repository settings → Actions → Secrets (or variables):
|
|||||||
| `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_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_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`) |
|
| `SFTP_KNOWN_HOSTS` | secret | Contents of `hetzner-known-hosts.txt` (captured via `ssh-keyscan`) |
|
||||||
| `PUBLIC_AUTH_SIGNIN_URL` | secret | `https://auth.cameleer.io/sign-in` |
|
| `PUBLIC_AUTH_SIGNIN_URL` | secret | `https://app.cameleer.io/sign-in` |
|
||||||
| `PUBLIC_AUTH_SIGNUP_URL` | secret | `https://auth.cameleer.io/sign-in?first_screen=register` |
|
| `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) |
|
| `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
|
These three are not actually secret (they end up in the built HTML), but Gitea's
|
||||||
@@ -87,8 +87,7 @@ workflows read them via the `${{ secrets.* }}` context.
|
|||||||
|
|
||||||
- [ ] Fill in `src/pages/imprint.astro` `operator` object with real legal details.
|
- [ ] Fill in `src/pages/imprint.astro` `operator` object with real legal details.
|
||||||
- [ ] Fill in `operatorContact` in `src/pages/privacy.astro`.
|
- [ ] Fill in `operatorContact` in `src/pages/privacy.astro`.
|
||||||
- [ ] Review the "Why us" / nJAMS wording in `src/components/sections/WhyUs.astro` for trademark safety.
|
- [ ] Confirm Starter-tier retention: spec says **7 days**; `cameleer-saas/HOWTO.md` says **30 days**. Reconcile one side or the other.
|
||||||
- [ ] Confirm MID-tier retention: spec says **7 days**; `cameleer-saas/HOWTO.md` says **30 days**. Reconcile one side or the other.
|
|
||||||
|
|
||||||
## 5. First deploy
|
## 5. First deploy
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ See `.env.example`. All are `PUBLIC_*` (build-time, embedded in HTML).
|
|||||||
|
|
||||||
Rollback: trigger the deploy workflow on the previous `main` commit (Actions UI lets you pick a ref).
|
Rollback: trigger the deploy workflow on the previous `main` commit (Actions UI lets you pick a ref).
|
||||||
|
|
||||||
|
### Placeholder mode
|
||||||
|
|
||||||
|
To put the site into "back shortly" mode, trigger Gitea → **Actions → deploy-placeholder → Run workflow**. To bring the real site back, trigger **Actions → deploy → Run workflow** on the desired `main` commit. Both workflows share the `deploy-production` concurrency group, so they can never run simultaneously.
|
||||||
|
|
||||||
|
The placeholder is hand-authored static HTML in `placeholder/` and does NOT depend on `npm`/`astro build` — it is deliberately decoupled from the main build so it can ship even when that build is broken.
|
||||||
|
|
||||||
|
**Scope note.** The placeholder serves HTTP 200 (not 503), so Cloudflare's edge will cache it normally. This is fine for short planned maintenance windows. For longer outages or incident fallback, purge Cloudflare's cache (or set a short-TTL Cache Rule for the maintenance window) before triggering recovery via `deploy.yml`, otherwise the edge may serve the placeholder past recovery until TTL expires.
|
||||||
|
|
||||||
**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.
|
**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.
|
See [`OPERATOR-CHECKLIST.md`](./OPERATOR-CHECKLIST.md) for the one-time Hetzner + Cloudflare setup.
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
# Under-construction placeholder Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Ship a branded "back shortly" page for cameleer.io plus a manual-trigger Gitea workflow that swaps it onto the live origin on demand, recoverable by re-running the existing `deploy.yml`.
|
||||||
|
|
||||||
|
**Architecture:** Standalone HTML in a top-level `placeholder/` directory, plus two PNG asset copies. A new `.gitea/workflows/deploy-placeholder.yml` rsyncs that directory to the same Hetzner docroot used by `deploy.yml` (`--delete` enabled, `deploy-production` concurrency group shared so the two workflows queue rather than race). No Astro build dependency, so the placeholder still ships when the main build is broken — which is the worst case where one is needed.
|
||||||
|
|
||||||
|
**Tech Stack:** Plain HTML5 + inlined CSS, Google Fonts (DM Sans, JetBrains Mono), bash + rsync over SSH:222, Vitest 1 for static-content assertions.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-25-under-construction-placeholder-design.md` (commit `9b0c36b`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Status | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `placeholder/index.html` | Create | Static under-construction page. Single self-contained file, references the two sibling PNGs by relative path. Contains `__SALES_EMAIL__` substitution token (used twice — `mailto:` href and link text). |
|
||||||
|
| `placeholder/cameleer-logo.png` | Create (copy) | Hero logo. Copy of `public/icons/cameleer-192.png` (~36 KB). |
|
||||||
|
| `placeholder/favicon.png` | Create (copy) | Browser tab icon. Copy of `public/icons/cameleer-32.png` (~2.4 KB). |
|
||||||
|
| `src/placeholder.test.ts` | Create | Static assertions that the placeholder HTML has the contract the deploy workflow depends on (sentinel string, token, references, no JS, etc.). Lives in `src/` because `vitest.config.ts` only discovers `src/**/*.test.ts`. |
|
||||||
|
| `.gitea/workflows/deploy-placeholder.yml` | Create | Manual-dispatch workflow: substitute sales email → rsync `placeholder/` to docroot → smoke-test the live origin. |
|
||||||
|
| `README.md` | Modify | Append a "Placeholder mode" subsection under "Deployment". |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add tests for the placeholder HTML
|
||||||
|
|
||||||
|
**Why first:** Establishes the contract the workflow depends on — sentinel string, substitution token, asset references — before any markup is written, so we can't accidentally drop one of them later.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/placeholder.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test file**
|
||||||
|
|
||||||
|
Create `src/placeholder.test.ts` with this exact content:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const placeholderDir = join(process.cwd(), 'placeholder');
|
||||||
|
const indexPath = join(placeholderDir, 'index.html');
|
||||||
|
|
||||||
|
describe('placeholder/index.html', () => {
|
||||||
|
const html = readFileSync(indexPath, 'utf8');
|
||||||
|
|
||||||
|
it('starts with the HTML5 doctype', () => {
|
||||||
|
expect(html.toLowerCase().trimStart()).toMatch(/^<!doctype html>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the back-shortly title', () => {
|
||||||
|
expect(html).toContain('<title>Cameleer — Back shortly</title>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not indexable by search engines', () => {
|
||||||
|
expect(html).toContain('<meta name="robots" content="noindex">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares the dark color-scheme matching the live site', () => {
|
||||||
|
expect(html).toContain('<meta name="color-scheme" content="dark">');
|
||||||
|
expect(html).toContain('<meta name="theme-color" content="#060a13">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains the sentinel string the deploy workflow greps for', () => {
|
||||||
|
// The workflow's post-deploy smoke test fails if this string is missing.
|
||||||
|
expect(html).toContain('Routes are remapping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the live hero subhead verbatim', () => {
|
||||||
|
expect(html).toContain(
|
||||||
|
'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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains __SALES_EMAIL__ tokens at both the mailto href and the link text', () => {
|
||||||
|
const matches = html.match(/__SALES_EMAIL__/g) ?? [];
|
||||||
|
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains no other __TOKEN__ style placeholders', () => {
|
||||||
|
// Guard against a forgotten token that would survive the sed substitution.
|
||||||
|
const allTokens = html.match(/__[A-Z][A-Z0-9_]+__/g) ?? [];
|
||||||
|
const nonSales = allTokens.filter((t) => t !== '__SALES_EMAIL__');
|
||||||
|
expect(nonSales).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('references the sibling cameleer-logo.png by relative path', () => {
|
||||||
|
expect(html).toContain('src="./cameleer-logo.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('references the sibling favicon.png by relative path', () => {
|
||||||
|
expect(html).toContain('href="./favicon.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no <script> tags (placeholder must work without JS)', () => {
|
||||||
|
expect(html).not.toMatch(/<script[\s>]/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placeholder/ asset siblings', () => {
|
||||||
|
it('cameleer-logo.png exists on disk', () => {
|
||||||
|
expect(existsSync(join(placeholderDir, 'cameleer-logo.png'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('favicon.png exists on disk', () => {
|
||||||
|
expect(existsSync(join(placeholderDir, 'favicon.png'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test suite to verify the new tests fail**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
|
||||||
|
Expected: vitest fails when loading `src/placeholder.test.ts` because `readFileSync` throws `ENOENT` on the missing `placeholder/index.html`. The pre-existing `src/middleware.test.ts` suite must still pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit the failing tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/placeholder.test.ts
|
||||||
|
git commit -m "test(placeholder): add static-content tests for under-construction page"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: this commit is intentionally a red bar. Task 2 turns it green in a single follow-up commit. If you prefer a green-only history, fold this commit into Task 2's commit at the end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Create the placeholder page and copy assets
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `placeholder/index.html`
|
||||||
|
- Create: `placeholder/cameleer-logo.png` (copy of `public/icons/cameleer-192.png`)
|
||||||
|
- Create: `placeholder/favicon.png` (copy of `public/icons/cameleer-32.png`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Copy the PNG assets into `placeholder/`**
|
||||||
|
|
||||||
|
The repo's full-resolution `public/cameleer-logo.svg` is 1.5 MB (embedded raster data) and is not used here. The 192 px PNG is the correct size and weight for the placeholder hero.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p placeholder
|
||||||
|
cp public/icons/cameleer-192.png placeholder/cameleer-logo.png
|
||||||
|
cp public/icons/cameleer-32.png placeholder/favicon.png
|
||||||
|
ls -la placeholder/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both files present. `cameleer-logo.png` ~36 KB; `favicon.png` ~2.4 KB.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write `placeholder/index.html`**
|
||||||
|
|
||||||
|
Create `placeholder/index.html` with this exact content. Note both occurrences of `__SALES_EMAIL__` — the deploy workflow substitutes them via `sed ... -g`.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<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="robots" content="noindex">
|
||||||
|
<meta name="description" content="Cameleer is briefly offline. We'll be back on the trail in a moment.">
|
||||||
|
|
||||||
|
<title>Cameleer — Back shortly</title>
|
||||||
|
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="./favicon.png">
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono&display=swap">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #060a13;
|
||||||
|
--accent: #f0b429;
|
||||||
|
--text: #e8eaed;
|
||||||
|
--text-muted: #9aa3b2;
|
||||||
|
--text-faint: #828b9b;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background-color: var(--bg);
|
||||||
|
background-image: radial-gradient(60% 60% at 50% 50%, rgba(240, 180, 41, 0.10), transparent 70%);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2.5rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: clamp(2.25rem, 4.5vw, 4rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
max-width: 18ch;
|
||||||
|
}
|
||||||
|
.subhead {
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
max-width: 42rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.micro {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.micro a { color: inherit; text-decoration: none; }
|
||||||
|
.micro a:hover, .micro a:focus { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<img class="logo" src="./cameleer-logo.png" alt="Cameleer" width="96" height="96">
|
||||||
|
<p class="eyebrow">✦ Routes are remapping.</p>
|
||||||
|
<h1>We're back on the trail<br>in a moment.</h1>
|
||||||
|
<p class="subhead">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>
|
||||||
|
<p class="micro">cameleer.io · <a href="mailto:__SALES_EMAIL__">__SALES_EMAIL__</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the placeholder tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npm test -- placeholder`
|
||||||
|
|
||||||
|
Expected: all tests in `src/placeholder.test.ts` pass. (`-- placeholder` is a vitest test-name filter that runs only the new file's `describe` blocks for fast feedback.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the full test suite to verify no regressions**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
|
||||||
|
Expected: every existing test still passes (the middleware/CSP suite is the only other one).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Visual verification in a browser**
|
||||||
|
|
||||||
|
The `__SALES_EMAIL__` token will be visible in the rendered page — that is expected; it's substituted at deploy time. Confirm visual treatment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx serve placeholder -l 4322
|
||||||
|
# then open http://localhost:4322 in a browser
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm by eye:
|
||||||
|
1. Centered single-column layout, logo on top.
|
||||||
|
2. Dark background (#060a13) with a faint amber radial glow centered.
|
||||||
|
3. Italic amber eyebrow `✦ Routes are remapping.`.
|
||||||
|
4. Bold display heading wraps onto two lines on desktop ("We're back on the trail" / "in a moment.").
|
||||||
|
5. Subhead reads as muted body text below.
|
||||||
|
6. Mono microcopy at the bottom shows `cameleer.io · __SALES_EMAIL__` in faint grey, with the token rendered as a `mailto:` link.
|
||||||
|
7. Resize the window to ~360 px wide — layout stays centered, heading scales down via `clamp()`, no horizontal scroll.
|
||||||
|
|
||||||
|
Stop the server with Ctrl-C when done.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the placeholder page and assets**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add placeholder/index.html placeholder/cameleer-logo.png placeholder/favicon.png
|
||||||
|
git commit -m "feat(placeholder): add under-construction page with branded teaser
|
||||||
|
|
||||||
|
Standalone HTML + two sibling PNGs, no Astro build dependency.
|
||||||
|
Carries __SALES_EMAIL__ substitution tokens that the new deploy
|
||||||
|
workflow replaces at deploy time."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add the deploy-placeholder workflow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.gitea/workflows/deploy-placeholder.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `.gitea/workflows/deploy-placeholder.yml`**
|
||||||
|
|
||||||
|
Create with this exact content. Mirrors `deploy.yml`'s SSH/rsync pattern but skips `npm ci`/`astro build`/Lighthouse — the placeholder is hand-authored static and must be deployable when the main build is broken.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# cameleer-website — Deploy under-construction placeholder
|
||||||
|
#
|
||||||
|
# MANUAL TRIGGER ONLY. Replaces the live cameleer.io docroot with a static
|
||||||
|
# "back shortly" page. Recovery: trigger Actions → deploy → Run workflow on
|
||||||
|
# the desired main commit.
|
||||||
|
#
|
||||||
|
# Shares the deploy-production concurrency group with deploy.yml so the two
|
||||||
|
# workflows queue rather than race on the same docroot.
|
||||||
|
#
|
||||||
|
# This workflow does NOT run npm/astro. The placeholder is hand-authored
|
||||||
|
# static HTML in placeholder/, deliberately decoupled from the main build so
|
||||||
|
# it can ship even when the main build is broken (which is the worst case in
|
||||||
|
# which a placeholder is needed).
|
||||||
|
#
|
||||||
|
# Required secrets (repo settings → Actions → Secrets):
|
||||||
|
# SFTP_HOST, SFTP_USER, SFTP_PATH, SFTP_KEY, SFTP_KNOWN_HOSTS
|
||||||
|
# PUBLIC_SALES_EMAIL
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
name: deploy-placeholder
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Substitute sales email into placeholder
|
||||||
|
env:
|
||||||
|
PUBLIC_SALES_EMAIL: ${{ secrets.PUBLIC_SALES_EMAIL }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
: "${PUBLIC_SALES_EMAIL:?PUBLIC_SALES_EMAIL secret must be set}"
|
||||||
|
sed -i "s|__SALES_EMAIL__|${PUBLIC_SALES_EMAIL}|g" placeholder/index.html
|
||||||
|
if grep -q '__SALES_EMAIL__' placeholder/index.html; then
|
||||||
|
echo "Token __SALES_EMAIL__ still present after substitution — refusing to ship."
|
||||||
|
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
|
||||||
|
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: |
|
||||||
|
: "${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" \
|
||||||
|
placeholder/ "$SFTP_USER@$SFTP_HOST:$SFTP_PATH/"
|
||||||
|
|
||||||
|
- name: Post-deploy smoke test
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
echo "Confirming the placeholder is live on www.cameleer.io..."
|
||||||
|
BODY=$(curl -sf https://www.cameleer.io/)
|
||||||
|
echo "$BODY" | grep -qF 'Routes are remapping' \
|
||||||
|
|| { echo "Sentinel string missing — placeholder did not land."; exit 1; }
|
||||||
|
echo "$BODY" | grep -qF 'mailto:' \
|
||||||
|
|| { echo "mailto: link missing — sales email substitution may have failed."; exit 1; }
|
||||||
|
curl -sfI https://www.cameleer.io/cameleer-logo.png > /dev/null \
|
||||||
|
|| { echo "cameleer-logo.png not reachable on the live origin."; exit 1; }
|
||||||
|
echo "Placeholder is live."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the YAML parses**
|
||||||
|
|
||||||
|
Run a quick Node-based parse check (no extra dep needed; Node ships with no YAML parser, so use a one-off `npx`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx --yes js-yaml .gitea/workflows/deploy-placeholder.yml > /dev/null && echo "YAML OK"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `YAML OK`. If `js-yaml` errors, re-read the file for stray tabs or unbalanced quoting.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the existing deploy.yml is unchanged**
|
||||||
|
|
||||||
|
Run: `git diff .gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
|
Expected: empty output (the new workflow is additive only).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the workflow**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitea/workflows/deploy-placeholder.yml
|
||||||
|
git commit -m "ci(deploy): add deploy-placeholder workflow
|
||||||
|
|
||||||
|
Manual-trigger workflow that substitutes PUBLIC_SALES_EMAIL into
|
||||||
|
placeholder/index.html, rsyncs placeholder/ to the Hetzner docroot
|
||||||
|
over SSH:222, then smoke-tests the live origin for the sentinel
|
||||||
|
string, mailto link, and logo URL.
|
||||||
|
|
||||||
|
Shares the deploy-production concurrency group with deploy.yml so
|
||||||
|
the two workflows can never race on the same docroot. Recovery is
|
||||||
|
the regular deploy.yml — no separate un-placeholder workflow."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Document the placeholder mode in the README
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md` (append a subsection under "## Deployment", after the existing "Rollback" paragraph and before "**Security headers**")
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current README to locate the insertion point**
|
||||||
|
|
||||||
|
Run: `cat README.md`
|
||||||
|
|
||||||
|
Locate the line `Rollback: trigger the deploy workflow on the previous \`main\` commit (Actions UI lets you pick a ref).`. The new subsection goes immediately after it, separated by a blank line, before the `**Security headers**` paragraph.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Insert the placeholder-mode subsection**
|
||||||
|
|
||||||
|
Use the `Edit` tool with these exact arguments. `old_string` is the existing two-paragraph boundary; `new_string` reproduces it with the new `### Placeholder mode` subsection wedged in between.
|
||||||
|
|
||||||
|
`file_path`: `README.md`
|
||||||
|
|
||||||
|
`old_string`:
|
||||||
|
````
|
||||||
|
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.
|
||||||
|
````
|
||||||
|
|
||||||
|
`new_string`:
|
||||||
|
````
|
||||||
|
Rollback: trigger the deploy workflow on the previous `main` commit (Actions UI lets you pick a ref).
|
||||||
|
|
||||||
|
### Placeholder mode
|
||||||
|
|
||||||
|
To put the site into "back shortly" mode, trigger Gitea → **Actions → deploy-placeholder → Run workflow**. To bring the real site back, trigger **Actions → deploy → Run workflow** on the desired `main` commit. Both workflows share the `deploy-production` concurrency group, so they can never run simultaneously.
|
||||||
|
|
||||||
|
The placeholder is hand-authored static HTML in `placeholder/` and does NOT depend on `npm`/`astro build` — it is deliberately decoupled from the main build so it can ship even when that build is broken.
|
||||||
|
|
||||||
|
**Security headers** (HSTS, CSP, X-Frame-Options, etc.) are owned by **Cloudflare Transform Rules**, not by anything in this repo.
|
||||||
|
````
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the README still renders cleanly**
|
||||||
|
|
||||||
|
Run: `cat README.md | head -60`
|
||||||
|
|
||||||
|
Confirm by eye that the new subsection appears under "Deployment", the surrounding paragraphs are intact, and there is exactly one blank line between adjacent blocks.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the README update**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs(readme): add placeholder mode section
|
||||||
|
|
||||||
|
Documents the deploy-placeholder workflow trigger and the recovery
|
||||||
|
path back to the real site via deploy.yml."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] **Run the full test suite one more time**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
|
||||||
|
Expected: all tests pass — both `src/middleware.test.ts` and `src/placeholder.test.ts`.
|
||||||
|
|
||||||
|
- [ ] **Confirm the four commits are in place**
|
||||||
|
|
||||||
|
Run: `git log --oneline -5`
|
||||||
|
|
||||||
|
Expected (top-down): README docs commit, deploy-placeholder.yml commit, placeholder feat commit, placeholder test commit, then the spec commit (`9b0c36b docs(spec): ...`).
|
||||||
|
|
||||||
|
- [ ] **Sanity-check the placeholder directory ships only what it should**
|
||||||
|
|
||||||
|
Run: `ls -la placeholder/`
|
||||||
|
|
||||||
|
Expected: exactly three files — `index.html`, `cameleer-logo.png`, `favicon.png`. No stray `.test.ts`, `.DS_Store`, etc. (If anything else appears, remove it before merging — `rsync --delete` would otherwise push it to the live origin.)
|
||||||
|
|
||||||
|
- [ ] **Push and trigger the first real run** (operator step, not part of the implementation)
|
||||||
|
|
||||||
|
Push the branch, merge to `main` once reviewed, then in Gitea: **Actions → deploy-placeholder → Run workflow** on `main`. Verify by visiting `https://www.cameleer.io/` that the placeholder renders, then trigger **Actions → deploy → Run workflow** to restore the real site.
|
||||||
@@ -53,7 +53,7 @@ The relaunch's H1 leans Manager-outcome on purpose — the IT Manager is the che
|
|||||||
|
|
||||||
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:
|
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** (nJAMS lineage). Subject to trademark clearance — same gating as `WhyUs.astro`'s existing comment. Until cleared, the wording is held in a `<!-- PENDING -->` HTML comment.
|
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.
|
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.
|
Both anchors live in a new dedicated **Social Proof Strip** section (§6.2) immediately below the hero.
|
||||||
@@ -118,14 +118,12 @@ Order is deliberate — see §11 for the rationale (proof-first arc: hero → wh
|
|||||||
- **Eyebrow** (mono, amber, ~12px): `// Built by people who've done this before`
|
- **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):
|
- **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."*
|
> *"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 · ex-nJAMS`
|
- **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 →`
|
- **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):
|
**`<!-- PENDING -->` gates** (do not ship without resolving):
|
||||||
|
|
||||||
- `[Founder Name]` is a placeholder.
|
- `[Founder Name]` is a placeholder. Must be filled in pre-publish.
|
||||||
- `ex-nJAMS` mention is gated on trademark clearance (same as `WhyUs.astro`'s existing §10 caveat).
|
|
||||||
- Either gate may be deferred by removing the affected line — the section still works as "pedigree quote, design-partner CTA" without the nJAMS-specific phrase.
|
|
||||||
|
|
||||||
**Design-partner CTA target**: built inline in `SocialProofStrip.astro` using `auth.salesEmail` (not `auth.salesMailto`, which has no subject helper):
|
**Design-partner CTA target**: built inline in `SocialProofStrip.astro` using `auth.salesEmail` (not `auth.salesMailto`, which has no subject helper):
|
||||||
|
|
||||||
@@ -372,15 +370,13 @@ A traditional SaaS layout (features → benefits → how → pricing) would put
|
|||||||
- [ ] `prefers-reduced-motion: reduce` disables the hero-mark sway and any tile-rise animations (existing handling preserved).
|
- [ ] `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.
|
- [ ] 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).
|
- [ ] All `mailto:` links open with the correct subject (design-partner CTA + sales contacts).
|
||||||
- [ ] Trademark `<!-- PENDING -->` gate in `SocialProofStrip.astro` and `WhyUs.astro` is reviewed before publish.
|
|
||||||
- [ ] Founder name placeholder is filled in `SocialProofStrip.astro` before publish.
|
- [ ] Founder name placeholder is filled in `SocialProofStrip.astro` before publish.
|
||||||
|
|
||||||
**Pre-publish blockers** (recorded in code as `<!-- PENDING -->` HTML comments):
|
**Pre-publish blockers** (recorded in code as `<!-- PENDING -->` HTML comments):
|
||||||
|
|
||||||
1. `[Founder Name]` placeholder in `SocialProofStrip.astro` — must be replaced with a real name.
|
1. `[Founder Name]` placeholder in `SocialProofStrip.astro` — must be replaced with a real name.
|
||||||
2. nJAMS / `ex-nJAMS` wording — must clear trademark review (existing pattern from `WhyUs.astro`).
|
|
||||||
|
|
||||||
These are deliberately surfaced as code-level TODOs rather than spec-level open questions so the operator can't accidentally publish with the placeholders intact.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Under-construction placeholder — design
|
||||||
|
|
||||||
|
**Date:** 2026-04-25
|
||||||
|
**Status:** approved (pending user spec review)
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
A branded "back shortly" page for cameleer.io that the operator can swap into the live origin on demand from Gitea Actions. Used during planned maintenance, incident fallback, or any moment the real site needs to come down without leaving visitors on a broken page.
|
||||||
|
|
||||||
|
## 2. Constraints & non-goals
|
||||||
|
|
||||||
|
- **Same target as the real site.** Deploys to the same Hetzner Webhosting L docroot used by `deploy.yml`. Replaces the live `index.html` and assets at the docroot root.
|
||||||
|
- **Must work when the main build is broken.** This is the worst-case in which a placeholder is needed. Therefore the placeholder MUST NOT depend on `npm ci`, `astro build`, or any other step that could fail along with the main site's build.
|
||||||
|
- **Manual trigger only.** Same pattern as `deploy.yml` — `workflow_dispatch` from the Gitea UI. No push/auto-deploy.
|
||||||
|
- **Cannot race the main deploy.** Both workflows write to the same docroot via `rsync --delete`; concurrent runs would clobber each other.
|
||||||
|
- **Recovery is the regular deploy.** Triggering `deploy.yml` on any `main` commit restores the site. No bespoke "un-placeholder" workflow.
|
||||||
|
- **Origin-side headers are not in scope.** Hetzner Webhosting L runs `AllowOverride None`; all response headers are owned by Cloudflare Transform Rules (see `OPERATOR-CHECKLIST.md` §2). The placeholder workflow does NOT need to assert HSTS/CSP/XFO — those headers are origin-agnostic.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
- Per-environment placeholder variants (staging, etc.). Same target, same content.
|
||||||
|
- A status page, ETA, or live incident feed.
|
||||||
|
- Cookie banner, analytics, or any third-party JS.
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
cameleer-website/
|
||||||
|
├── placeholder/
|
||||||
|
│ ├── index.html # standalone HTML, inlined CSS
|
||||||
|
│ ├── cameleer-logo.png # copy of public/icons/cameleer-192.png (~36 KB)
|
||||||
|
│ └── favicon.png # copy of public/icons/cameleer-32.png (~2.4 KB)
|
||||||
|
├── .gitea/workflows/
|
||||||
|
│ ├── ci.yml # unchanged
|
||||||
|
│ ├── deploy.yml # unchanged
|
||||||
|
│ └── deploy-placeholder.yml # NEW
|
||||||
|
└── README.md # NEW section: "Placeholder mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why standalone HTML, not Astro
|
||||||
|
|
||||||
|
The placeholder lives outside `src/`, is not picked up by `astro build`, and never enters `dist/`. It is a single self-contained `index.html` with inlined CSS. Two PNG assets ship alongside the HTML in `placeholder/` and are referenced by relative paths (`./cameleer-logo.png`, `./favicon.png`) so the page renders correctly after `rsync --delete` clears the docroot. The repo's full-resolution `public/cameleer-logo.svg` is 1.5 MB (embedded raster data) and is therefore not used here; the 192 px PNG is the right size and weight for a placeholder hero.
|
||||||
|
|
||||||
|
Trade-off accepted: brand tokens (colors, fonts) are hand-mirrored from `tailwind.config.mjs` rather than imported. If those tokens change, the placeholder may visibly drift. Acceptable because (a) the placeholder is rarely shown, (b) the file is short enough to re-sync in two minutes, (c) the alternative — coupling the placeholder to the Astro build — defeats the placeholder's whole reason for existing.
|
||||||
|
|
||||||
|
### Workflow shape
|
||||||
|
|
||||||
|
`deploy-placeholder.yml`:
|
||||||
|
|
||||||
|
- **Trigger:** `workflow_dispatch` only.
|
||||||
|
- **Concurrency:** `group: deploy-production`, `cancel-in-progress: false` — same group as `deploy.yml`. Gitea will queue, never overlap.
|
||||||
|
- **Runner:** `ubuntu-latest` (matches `deploy.yml`).
|
||||||
|
- **Secrets used:** `SFTP_HOST`, `SFTP_USER`, `SFTP_PATH`, `SFTP_KEY`, `SFTP_KNOWN_HOSTS`, `PUBLIC_SALES_EMAIL`.
|
||||||
|
- **Steps:**
|
||||||
|
1. `actions/checkout@v4`
|
||||||
|
2. Configure SSH (key + known_hosts; install rsync/openssh if missing) — same logic as `deploy.yml` lines 70–88.
|
||||||
|
3. **Inject `PUBLIC_SALES_EMAIL` into the placeholder.** The HTML contains a single literal token `__SALES_EMAIL__` (no hyphens, no other instances anywhere); `sed -i "s|__SALES_EMAIL__|$PUBLIC_SALES_EMAIL|g" placeholder/index.html`. Fail loudly with `: "${PUBLIC_SALES_EMAIL:?...}"` first. Verify replacement by grepping that the token no longer appears.
|
||||||
|
4. `rsync -avz --delete --rsync-path=/usr/bin/rsync` over `ssh -p 222` of `placeholder/` → `$SFTP_PATH/` — same flags and SSH options as `deploy.yml` lines 107–109.
|
||||||
|
5. **Smoke test:** `curl -s https://www.cameleer.io/ | grep -q 'Routes are remapping'` — placeholder-unique sentinel. Fail the workflow if absent. (Skip the security-headers grep from `deploy.yml`; those headers come from Cloudflare and apply equally to placeholder responses, so they're already covered.)
|
||||||
|
|
||||||
|
### Why `rsync --delete`
|
||||||
|
|
||||||
|
Matches `deploy.yml` behaviour. The docroot reflects exactly what the placeholder ships, with no leftover assets from a previous real-site deploy lingering and being indexed.
|
||||||
|
|
||||||
|
## 4. Placeholder content
|
||||||
|
|
||||||
|
### Markup outline
|
||||||
|
|
||||||
|
Single `<!doctype html>` document. Sections, in order:
|
||||||
|
|
||||||
|
1. `<head>`:
|
||||||
|
- `<title>Cameleer — Back shortly</title>`
|
||||||
|
- `<meta name="description" content="Cameleer is briefly offline. We'll be back on the trail in a moment.">`
|
||||||
|
- `<meta name="robots" content="noindex">`
|
||||||
|
- `<meta name="color-scheme" content="dark">` and `<meta name="theme-color" content="#060a13">` (matches `BaseLayout.astro`)
|
||||||
|
- `<link rel="icon" type="image/png" sizes="32x32" href="./favicon.png">` — the 32 px PNG that ships in `placeholder/`. Relative path so it resolves against the docroot root after rsync.
|
||||||
|
- Google Fonts `<link>` for DM Sans (400, 700) and JetBrains Mono (400). Single preconnect.
|
||||||
|
- Inlined `<style>` block with the design tokens below.
|
||||||
|
|
||||||
|
2. `<body>`:
|
||||||
|
- Centered `<main>` (flex, full viewport height, items/justify center).
|
||||||
|
- `<img src="./cameleer-logo.png" alt="Cameleer" width="96" height="96">` — references the sibling PNG that ships in `placeholder/`. Relative path so it resolves against the docroot root regardless of what `rsync --delete` cleared.
|
||||||
|
- Eyebrow: `<p>` with `✦ Routes are remapping.` — italic, accent color, small.
|
||||||
|
- Heading: `<h1>` with `We're back on the trail<br>in a moment.` — display size, tight tracking. Two-line cadence echoes the live hero's "Ship Camel integrations. Sleep through the night."
|
||||||
|
- Subhead: lifted verbatim from `src/components/sections/Hero.astro` line 42 — `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.`
|
||||||
|
- Mono microcopy: `<p>` with `cameleer.io · <a href="mailto:__SALES_EMAIL__">__SALES_EMAIL__</a>` — JetBrains Mono, faint color. The token is replaced at deploy time.
|
||||||
|
|
||||||
|
### Design tokens (mirrored from `tailwind.config.mjs`)
|
||||||
|
|
||||||
|
```css
|
||||||
|
--bg: #060a13;
|
||||||
|
--bg-elevated: #0c111a;
|
||||||
|
--border: #1e2535;
|
||||||
|
--accent: #f0b429;
|
||||||
|
--text: #e8eaed;
|
||||||
|
--text-muted: #9aa3b2;
|
||||||
|
--text-faint: #828b9b;
|
||||||
|
```
|
||||||
|
|
||||||
|
Background: solid `--bg` with a single `radial-gradient(60% 60% at 50% 50%, rgba(240,180,41,0.10), transparent 70%)` overlay to echo the hero's amber glow. No topographic SVG — too much weight for a fallback page.
|
||||||
|
|
||||||
|
Typography:
|
||||||
|
- Eyebrow: DM Sans italic, 14px, `--accent`, letter-spacing 0.
|
||||||
|
- H1: DM Sans 700, `clamp(2.25rem, 4.5vw, 4rem)`, line-height 1.05, `letter-spacing: -0.02em` — same numbers as the hero `.hero-h1` rule.
|
||||||
|
- Subhead: DM Sans 400, 1.125rem, `--text-muted`, max-width ~42rem (matches `maxWidth.prose`).
|
||||||
|
- Microcopy: JetBrains Mono 400, 12px, `--text-faint`. Underline on hover only.
|
||||||
|
|
||||||
|
`@media (prefers-reduced-motion: reduce)` is not relevant because the page has no animations.
|
||||||
|
|
||||||
|
### File size budget
|
||||||
|
|
||||||
|
Target ≤ 6 KB for `index.html` itself (markup + inlined CSS, no inlined image data). The two PNG siblings (`cameleer-logo.png` ~36 KB, `favicon.png` ~2.4 KB) ship as separate files. No JS, no external CSS, no fonts other than the Google Fonts CSS link (the actual font files are fetched lazily by the browser).
|
||||||
|
|
||||||
|
## 5. README update
|
||||||
|
|
||||||
|
Append a "Placeholder mode" section under "Deployment":
|
||||||
|
|
||||||
|
> **Placeholder mode.** To put the site into "back shortly" mode, trigger `Actions → deploy-placeholder → Run workflow`. To bring the real site back, trigger `Actions → deploy → Run workflow` on the desired `main` commit. Because both workflows share the `deploy-production` concurrency group, they can never run simultaneously.
|
||||||
|
|
||||||
|
## 6. Verification
|
||||||
|
|
||||||
|
After implementation:
|
||||||
|
|
||||||
|
1. Local visual check: open `placeholder/index.html` in a browser (the `__SALES_EMAIL__` token will be visible, that is expected) and confirm centered layout, brand colors, logo render, and copy render correctly at 360px / 768px / 1440px viewport widths.
|
||||||
|
2. Run a dry rsync against an alternate path (e.g. a throwaway docroot folder) before flipping cameleer.io.
|
||||||
|
3. First real run: trigger `deploy-placeholder`, confirm sales email substituted (`curl -s https://www.cameleer.io/ | grep -F 'mailto:'`), confirm sentinel string present, confirm `curl -sI https://www.cameleer.io/cameleer-logo.png` returns HTTP 200. Then trigger `deploy.yml` to restore.
|
||||||
|
|
||||||
|
## 7. Open questions
|
||||||
|
|
||||||
|
None. All clarifying questions answered during brainstorming:
|
||||||
|
- Same target as real site (Hetzner cameleer.io docroot).
|
||||||
|
- Branded teaser using existing hero subhead.
|
||||||
|
- Contact line uses `PUBLIC_SALES_EMAIL` secret.
|
||||||
|
- Smoke test grep is in.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"recurse": true,
|
"recurse": true,
|
||||||
"silent": true,
|
"silent": true,
|
||||||
"skip": [
|
"skip": [
|
||||||
|
"^https://app\\.cameleer\\.io",
|
||||||
"^https://auth\\.cameleer\\.io",
|
"^https://auth\\.cameleer\\.io",
|
||||||
"^https://platform\\.cameleer\\.io",
|
"^https://platform\\.cameleer\\.io",
|
||||||
"^https://www\\.cameleer\\.io",
|
"^https://www\\.cameleer\\.io",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint:html": "html-validate \"dist/**/*.html\"",
|
"lint:html": "html-validate \"dist/**/*.html\"",
|
||||||
"lint:links": "linkinator dist --recurse --silent",
|
"lint:links": "linkinator dist --recurse --silent",
|
||||||
|
"optimize:images": "node scripts/optimize-product-images.mjs",
|
||||||
"lh": "lhci autorun"
|
"lh": "lhci autorun"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
BIN
placeholder/cameleer-logo.png
Normal file
BIN
placeholder/cameleer-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
placeholder/favicon.png
Normal file
BIN
placeholder/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
93
placeholder/index.html
Normal file
93
placeholder/index.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<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="robots" content="noindex">
|
||||||
|
<meta name="description" content="Cameleer is briefly offline. We'll be back on the trail in a moment.">
|
||||||
|
|
||||||
|
<title>Cameleer — Back shortly</title>
|
||||||
|
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="./favicon.png">
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono&display=swap">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #060a13;
|
||||||
|
--accent: #f0b429;
|
||||||
|
--text: #e8eaed;
|
||||||
|
--text-muted: #9aa3b2;
|
||||||
|
--text-faint: #828b9b;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background-color: var(--bg);
|
||||||
|
background-image: radial-gradient(60% 60% at 50% 50%, rgba(240, 180, 41, 0.10), transparent 70%);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2.5rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: clamp(2.25rem, 4.5vw, 4rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
max-width: 18ch;
|
||||||
|
}
|
||||||
|
.subhead {
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
max-width: 42rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.micro {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.micro a { color: inherit; text-decoration: none; }
|
||||||
|
.micro a:hover, .micro a:focus { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<img class="logo" src="./cameleer-logo.png" alt="Cameleer" width="96" height="96">
|
||||||
|
<p class="eyebrow">✦ Routes are remapping.</p>
|
||||||
|
<h1>We're back on the trail<br>in a moment.</h1>
|
||||||
|
<p class="subhead">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>
|
||||||
|
<p class="micro">cameleer.io · <a href="mailto:__SALES_EMAIL__">__SALES_EMAIL__</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
public/product/error-detail-1280.webp
Normal file
BIN
public/product/error-detail-1280.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
public/product/error-detail-1920.webp
Normal file
BIN
public/product/error-detail-1920.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
public/product/exchange-detail-1280.webp
Normal file
BIN
public/product/exchange-detail-1280.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
public/product/exchange-detail-1920.webp
Normal file
BIN
public/product/exchange-detail-1920.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
48
scripts/optimize-product-images.mjs
Normal file
48
scripts/optimize-product-images.mjs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Generate WebP variants of source PNGs in public/product/.
|
||||||
|
// Run after replacing/adding a source PNG; outputs are committed.
|
||||||
|
//
|
||||||
|
// For each <name>.png we emit:
|
||||||
|
// <name>-1280.webp (q=82, used as inline srcset for desktop ≤ ~1280 px)
|
||||||
|
// <name>-1920.webp (q=80, used as inline srcset for retina/wide viewports
|
||||||
|
// and as the lightbox-modal full-size source)
|
||||||
|
//
|
||||||
|
// The original .png is kept as a <picture> fallback for the rare browser
|
||||||
|
// without WebP support (~2 % globally).
|
||||||
|
|
||||||
|
import { readdir, stat } from 'node:fs/promises';
|
||||||
|
import { join, parse } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const SRC_DIR = fileURLToPath(new URL('../public/product/', import.meta.url));
|
||||||
|
|
||||||
|
const VARIANTS = [
|
||||||
|
{ width: 1280, quality: 82, suffix: '-1280' },
|
||||||
|
{ width: 1920, quality: 80, suffix: '-1920' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = await readdir(SRC_DIR);
|
||||||
|
const pngs = entries.filter((f) => f.toLowerCase().endsWith('.png'));
|
||||||
|
|
||||||
|
if (pngs.length === 0) {
|
||||||
|
console.error(`No PNGs found in ${SRC_DIR}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of pngs) {
|
||||||
|
const { name } = parse(file);
|
||||||
|
const inputPath = join(SRC_DIR, file);
|
||||||
|
const inputBytes = (await stat(inputPath)).size;
|
||||||
|
console.log(`\n${file} (${(inputBytes / 1024).toFixed(0)} KiB)`);
|
||||||
|
|
||||||
|
for (const v of VARIANTS) {
|
||||||
|
const outName = `${name}${v.suffix}.webp`;
|
||||||
|
const outPath = join(SRC_DIR, outName);
|
||||||
|
const info = await sharp(inputPath)
|
||||||
|
.resize({ width: v.width, withoutEnlargement: true })
|
||||||
|
.webp({ quality: v.quality, effort: 6 })
|
||||||
|
.toFile(outPath);
|
||||||
|
const pct = ((1 - info.size / inputBytes) * 100).toFixed(0);
|
||||||
|
console.log(` → ${outName} ${(info.size / 1024).toFixed(0)} KiB (-${pct}%)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,14 @@ interface Props {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
loading?: 'eager' | 'lazy';
|
loading?: 'eager' | 'lazy';
|
||||||
|
fetchpriority?: 'high' | 'low' | 'auto';
|
||||||
caption?: string;
|
caption?: string;
|
||||||
triggerClass?: string;
|
triggerClass?: string;
|
||||||
imgClass?: string;
|
imgClass?: string;
|
||||||
|
/** Set to false for sources without sibling .webp variants. */
|
||||||
|
optimized?: boolean;
|
||||||
|
/** CSS sizes attribute for the WebP srcset. Defaults to a mobile-first guess. */
|
||||||
|
sizes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -16,11 +21,20 @@ const {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
loading = 'lazy',
|
loading = 'lazy',
|
||||||
|
fetchpriority,
|
||||||
caption,
|
caption,
|
||||||
triggerClass = '',
|
triggerClass = '',
|
||||||
imgClass = 'block w-full h-auto',
|
imgClass = 'block w-full h-auto',
|
||||||
|
optimized = true,
|
||||||
|
sizes = '(min-width: 1024px) 56vw, 100vw',
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
|
// Auto-derive WebP variant paths from the PNG src. The optimize-product-images
|
||||||
|
// script emits <name>-1280.webp and <name>-1920.webp next to each <name>.png.
|
||||||
|
const webpBase = optimized && /\.png$/i.test(src) ? src.replace(/\.png$/i, '') : null;
|
||||||
|
const webpSrcset = webpBase ? `${webpBase}-1280.webp 1280w, ${webpBase}-1920.webp 1920w` : null;
|
||||||
|
const webpDialogSrc = webpBase ? `${webpBase}-1920.webp` : src;
|
||||||
|
|
||||||
// Unique per-instance id so multiple lightboxes on a page do not collide.
|
// Unique per-instance id so multiple lightboxes on a page do not collide.
|
||||||
const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
---
|
---
|
||||||
@@ -30,6 +44,9 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
|||||||
data-lightbox-open={dialogId}
|
data-lightbox-open={dialogId}
|
||||||
aria-label={`Enlarge: ${alt}`}
|
aria-label={`Enlarge: ${alt}`}
|
||||||
>
|
>
|
||||||
|
{webpSrcset ? (
|
||||||
|
<picture>
|
||||||
|
<source type="image/webp" srcset={webpSrcset} sizes={sizes} />
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
@@ -37,8 +54,22 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
|||||||
height={height}
|
height={height}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
fetchpriority={fetchpriority}
|
||||||
class={imgClass}
|
class={imgClass}
|
||||||
/>
|
/>
|
||||||
|
</picture>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
loading={loading}
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority={fetchpriority}
|
||||||
|
class={imgClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span aria-hidden="true" class="lightbox-zoom-badge">
|
<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">
|
<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"/>
|
<circle cx="11" cy="11" r="7.5"/>
|
||||||
@@ -59,7 +90,7 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<img src={src} alt={alt} class="lightbox-image" />
|
<img src={webpDialogSrc} alt={alt} class="lightbox-image" loading="lazy" decoding="async" />
|
||||||
{caption && <p class="lightbox-caption">{caption}</p>}
|
{caption && <p class="lightbox-caption">{caption}</p>}
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const year = new Date().getFullYear();
|
|||||||
<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="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">
|
<div class="flex items-center gap-3">
|
||||||
<img
|
<img
|
||||||
src="/cameleer-logo.svg"
|
src="/icons/cameleer-32.png"
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import CTAButtons from './CTAButtons.astro';
|
|||||||
<div class="max-w-content mx-auto px-6 h-16 flex items-center justify-between gap-6">
|
<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">
|
<a href="/" class="flex items-center gap-2 group" aria-label="Cameleer home">
|
||||||
<img
|
<img
|
||||||
src="/cameleer-logo.svg"
|
src="/icons/cameleer-48.png"
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
---
|
|
||||||
interface Tile {
|
|
||||||
outcome: string;
|
|
||||||
capability: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// tile.capability is a compile-time constant defined below — never feed
|
|
||||||
// user-supplied or CMS content into set:html further down (XSS risk).
|
|
||||||
const tiles: Tile[] = [
|
|
||||||
{
|
|
||||||
outcome: 'Ship integrations, then sleep.',
|
|
||||||
capability:
|
|
||||||
'Every route, every processor, every exchange — traced automatically. When something breaks at 3 AM, the answer is already waiting for you. So you do not have to be.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
outcome: 'Debug in daylight.',
|
|
||||||
capability:
|
|
||||||
'Replay the exact exchange that failed. Follow a single request across services. See payloads before and after each processor. The pieces your ops team needs at 3 AM, captured already — so 3 AM stays quiet.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
outcome: 'Keep what you built. Keep what you chose.',
|
|
||||||
capability:
|
|
||||||
'You picked Apache Camel on purpose — open, portable, yours. Cameleer runs and understands your Camel apps as they are. No code changes, no SDK, no rewrite, no lock-in.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
---
|
|
||||||
<section class="border-b border-border">
|
|
||||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
|
||||||
<div class="grid md:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{tiles.map((tile, i) => (
|
|
||||||
<div
|
|
||||||
class="tile rounded-lg border border-border bg-bg-elevated p-7 md: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)]"
|
|
||||||
style={`--tile-delay:${i * 110}ms`}
|
|
||||||
>
|
|
||||||
<h2 class="text-xl md:text-2xl font-bold text-text mb-3 leading-snug">
|
|
||||||
{tile.outcome}
|
|
||||||
</h2>
|
|
||||||
<p class="text-text-muted leading-relaxed" set:html={tile.capability.replace(/`([^`]+)`/g, '<code class="font-mono text-accent bg-bg border border-border rounded px-1 py-0.5 text-sm">$1</code>')}></p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes tile-rise {
|
|
||||||
from { opacity: 0; transform: translateY(18px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.tile {
|
|
||||||
opacity: 0;
|
|
||||||
animation: tile-rise 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
animation-delay: var(--tile-delay, 0ms);
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.tile {
|
|
||||||
opacity: 1;
|
|
||||||
animation: none;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -6,13 +6,13 @@ import TopographicBg from '../TopographicBg.astro';
|
|||||||
<TopographicBg opacity={0.18} lines={6} />
|
<TopographicBg opacity={0.18} lines={6} />
|
||||||
<div class="relative max-w-content mx-auto px-6 py-24 md:py-32 text-center">
|
<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">
|
<h2 class="text-display font-bold text-text mb-6">
|
||||||
Your camels called. Time to ride.
|
Ship integrations. Sleep through the night.
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-lg md:text-xl text-text-muted max-w-prose mx-auto mb-10">
|
<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. No camels harmed.
|
14-day free trial. Your first Camel app, hosted, traced, and running in under ten minutes. No code changes.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<CTAButtons size="lg" />
|
<CTAButtons size="lg" showSecondary={false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
import CTAButtons from '../CTAButtons.astro';
|
import CTAButtons from '../CTAButtons.astro';
|
||||||
import TopographicBg from '../TopographicBg.astro';
|
import TopographicBg from '../TopographicBg.astro';
|
||||||
import Lightbox from '../Lightbox.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">
|
<section class="relative overflow-hidden border-b border-border">
|
||||||
<TopographicBg opacity={0.22} lines={11} />
|
<TopographicBg opacity={0.22} lines={11} />
|
||||||
@@ -9,7 +22,7 @@ import Lightbox from '../Lightbox.astro';
|
|||||||
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 items-center">
|
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 items-center">
|
||||||
<div class="lg:col-span-5">
|
<div class="lg:col-span-5">
|
||||||
<img
|
<img
|
||||||
src="/cameleer-logo.svg"
|
src="/icons/cameleer-192.png"
|
||||||
width="64"
|
width="64"
|
||||||
height="64"
|
height="64"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -22,19 +35,20 @@ import Lightbox from '../Lightbox.astro';
|
|||||||
<span aria-hidden="true" class="text-base">✦</span>
|
<span aria-hidden="true" class="text-base">✦</span>
|
||||||
Your camels called. They want a GPS.
|
Your camels called. They want a GPS.
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1 class="font-bold text-text mb-6 hero-h1">
|
||||||
class="font-bold text-text mb-6 hero-rotator"
|
Ship Camel integrations. Sleep through the night.
|
||||||
aria-live="off"
|
|
||||||
data-hero-rotator
|
|
||||||
>
|
|
||||||
<span class="hero-line" data-active aria-hidden="false">Run Apache Camel without running Apache Camel.</span>
|
|
||||||
<span class="hero-line" aria-hidden="true">Camel integrations, minus the baggage.</span>
|
|
||||||
<span class="hero-line" aria-hidden="true">Your camels, our caravan. You just ride.</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg md:text-xl text-text-muted max-w-prose leading-relaxed mb-10">
|
<p class="text-lg md:text-xl text-text-muted max-w-prose leading-relaxed mb-8">
|
||||||
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.
|
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>
|
</p>
|
||||||
<CTAButtons size="lg" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="lg:col-span-7 relative">
|
<div class="lg:col-span-7 relative">
|
||||||
<div class="hero-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
<div class="hero-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
||||||
@@ -44,9 +58,27 @@ import Lightbox from '../Lightbox.astro';
|
|||||||
width={1920}
|
width={1920}
|
||||||
height={945}
|
height={945}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
/>
|
/>
|
||||||
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
<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>
|
</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 aria-hidden="true" class="hero-shot-glow"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,36 +86,21 @@ import Lightbox from '../Lightbox.astro';
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Rotating H1 — fluid size + fade transition */
|
.hero-h1 {
|
||||||
.hero-rotator {
|
|
||||||
font-size: clamp(2.25rem, 4.5vw, 4rem);
|
font-size: clamp(2.25rem, 4.5vw, 4rem);
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
/* Reserve enough vertical space that a 2-line wrap of the longest line
|
|
||||||
does not push the page on swap (mobile wraps line 1 to 2 lines). */
|
|
||||||
min-height: 2.5em;
|
|
||||||
}
|
}
|
||||||
.hero-line {
|
|
||||||
display: block;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 700ms ease-in-out;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
.hero-line[data-active] {
|
|
||||||
opacity: 1;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Product screenshot frame — subtle dropshadow + amber glow behind */
|
|
||||||
.hero-shot {
|
.hero-shot {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 0 rgba(240, 180, 41, 0.08) inset,
|
0 1px 0 rgba(240, 180, 41, 0.08) inset,
|
||||||
0 30px 60px -20px rgba(0, 0, 0, 0.6),
|
0 30px 60px -20px rgba(0, 0, 0, 0.6),
|
||||||
0 10px 25px -10px rgba(0, 0, 0, 0.5);
|
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 {
|
.hero-shot-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 10% -5% 10% -5%;
|
inset: 10% -5% 10% -5%;
|
||||||
@@ -96,7 +113,6 @@ import Lightbox from '../Lightbox.astro';
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slow sway on the mark — tasteful, not distracting */
|
|
||||||
@keyframes hero-mark-sway {
|
@keyframes hero-mark-sway {
|
||||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||||
50% { transform: translateY(-2px) rotate(-1.5deg); }
|
50% { transform: translateY(-2px) rotate(-1.5deg); }
|
||||||
@@ -107,34 +123,6 @@ import Lightbox from '../Lightbox.astro';
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.hero-line { transition: none; }
|
|
||||||
.hero-mark { animation: none; }
|
.hero-mark { animation: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Hero rotating headline. Bundled by Astro (CSP: script-src 'self').
|
|
||||||
const rotator = document.querySelector<HTMLElement>('[data-hero-rotator]');
|
|
||||||
if (rotator) {
|
|
||||||
const lines = Array.from(rotator.querySelectorAll<HTMLElement>('.hero-line'));
|
|
||||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
if (!reduced && lines.length > 1) {
|
|
||||||
let index = 0;
|
|
||||||
let paused = false;
|
|
||||||
const pause = () => { paused = true; };
|
|
||||||
const resume = () => { paused = false; };
|
|
||||||
rotator.addEventListener('mouseenter', pause);
|
|
||||||
rotator.addEventListener('mouseleave', resume);
|
|
||||||
rotator.addEventListener('focusin', pause);
|
|
||||||
rotator.addEventListener('focusout', resume);
|
|
||||||
setInterval(() => {
|
|
||||||
if (paused) return;
|
|
||||||
lines[index].removeAttribute('data-active');
|
|
||||||
lines[index].setAttribute('aria-hidden', 'true');
|
|
||||||
index = (index + 1) % lines.length;
|
|
||||||
lines[index].setAttribute('data-active', '');
|
|
||||||
lines[index].setAttribute('aria-hidden', 'false');
|
|
||||||
}, 10000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const steps: Step[] = [
|
|||||||
{
|
{
|
||||||
n: '01',
|
n: '01',
|
||||||
title: 'Point us at your Camel app',
|
title: 'Point us at your Camel app',
|
||||||
body: 'Drop it in, or connect one you already run. No code changes. No SDK. Nothing to rewrite.',
|
body: 'Drop it in, or connect one you already run. No code changes.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
n: '02',
|
n: '02',
|
||||||
@@ -20,7 +20,7 @@ const steps: Step[] = [
|
|||||||
{
|
{
|
||||||
n: '03',
|
n: '03',
|
||||||
title: 'Watch it run',
|
title: 'Watch it run',
|
||||||
body: 'Browse executions, tap live traffic, replay failed exchanges, follow flows across services. Nothing to instrument. Nothing to maintain.',
|
body: 'Browse executions, tap live traffic, replay failed exchanges, follow flows across services.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
@@ -29,7 +29,7 @@ const steps: Step[] = [
|
|||||||
<div class="max-w-2xl mb-16">
|
<div class="max-w-2xl mb-16">
|
||||||
<p class="text-cyan font-mono text-xs tracking-[0.25em] uppercase mb-4">For engineers</p>
|
<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>
|
<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. Nothing to maintain.</p>
|
<p class="text-text-muted text-lg">Three steps. Nothing to maintain.</p>
|
||||||
</div>
|
</div>
|
||||||
<ol class="grid md:grid-cols-3 gap-6 md:gap-8">
|
<ol class="grid md:grid-cols-3 gap-6 md:gap-8">
|
||||||
{steps.map((step) => (
|
{steps.map((step) => (
|
||||||
|
|||||||
@@ -21,27 +21,13 @@ const tiers: Tier[] = [
|
|||||||
cta: 'Start free trial',
|
cta: 'Start free trial',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'MID',
|
name: 'Starter',
|
||||||
price: '20 € /mo',
|
price: '20 € /mo',
|
||||||
sub: '2 environments · 10 apps · 7-day retention',
|
sub: '2 environments · 10 apps · 7-day retention',
|
||||||
href: auth.signUpUrl,
|
href: auth.signUpUrl,
|
||||||
cta: 'Start on MID',
|
cta: 'Start on Starter',
|
||||||
highlight: true,
|
highlight: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'HIGH',
|
|
||||||
price: 'Contact',
|
|
||||||
sub: 'Unlimited envs · 50 apps · 90-day retention · Debugger, Replay',
|
|
||||||
href: auth.salesMailto,
|
|
||||||
cta: 'Talk to sales',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'BUSINESS',
|
|
||||||
price: 'Contact',
|
|
||||||
sub: 'Unlimited everything · 365-day retention · all features',
|
|
||||||
href: auth.salesMailto,
|
|
||||||
cta: 'Talk to sales',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
<section class="border-b border-border">
|
<section class="border-b border-border">
|
||||||
@@ -51,15 +37,14 @@ const tiers: Tier[] = [
|
|||||||
<h2 class="text-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">
|
<p class="text-text-muted text-lg">
|
||||||
No credit card. No sales call. Just a working trial in ten minutes.
|
No credit card. No sales call. Just a working trial in ten minutes.
|
||||||
<a href="/pricing" class="text-accent hover:underline">See full comparison →</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-5 lg:items-stretch">
|
<div class="grid md:grid-cols-2 gap-5 lg:items-stretch max-w-3xl">
|
||||||
{tiers.map((tier) => (
|
{tiers.map((tier) => (
|
||||||
<div
|
<div
|
||||||
class={`relative rounded-lg bg-bg-elevated p-6 flex flex-col transition-all duration-200 ease-out hover:-translate-y-0.5
|
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
|
${tier.highlight
|
||||||
? 'ring-2 ring-accent shadow-[0_20px_50px_-20px_rgba(240,180,41,0.35)] lg:-translate-y-2 lg:pt-8 lg:pb-7'
|
? '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)]'}`}
|
: 'border border-border hover:border-accent/40 hover:shadow-[0_12px_32px_-12px_rgba(240,180,41,0.18)]'}`}
|
||||||
>
|
>
|
||||||
{tier.highlight && (
|
{tier.highlight && (
|
||||||
@@ -85,5 +70,10 @@ const tiers: Tier[] = [
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
---
|
|
||||||
import TopographicBg from '../TopographicBg.astro';
|
|
||||||
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 — 610 ms later.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Runtime detail, not guesswork.',
|
|
||||||
body: 'Circuit breaker tripped. Fallback path ran. The request tried to reach backend:80. The kind of 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. No SSH into the pod.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
---
|
|
||||||
<section class="relative overflow-hidden border-b border-border bg-bg">
|
|
||||||
<TopographicBg opacity={0.14} lines={7} />
|
|
||||||
<div class="relative 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 it breaks</p>
|
|
||||||
<h2 class="text-hero font-bold text-text mb-5">
|
|
||||||
When something breaks, the answer is already waiting.
|
|
||||||
</h2>
|
|
||||||
<p class="text-lg text-text-muted leading-relaxed">
|
|
||||||
Follow a single exchange from ingestion to failure. See the route it took, the fallback that ran, the stack trace, the correlated downstream work — in one place. Without writing a line of tracing code.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid lg:grid-cols-12 gap-10 lg:gap-14 items-start">
|
|
||||||
<figure class="lg:col-span-8 relative">
|
|
||||||
<div class="showcase-shot relative rounded-lg border border-border-strong bg-bg-elevated overflow-hidden">
|
|
||||||
<Lightbox
|
|
||||||
src="/product/error-detail.png"
|
|
||||||
alt="Cameleer Mission Control — complex fulfillment route with circuit breaker, fallback, correlated audit route, and full error context"
|
|
||||||
width={1920}
|
|
||||||
height={945}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
<div aria-hidden="true" class="showcase-shot-glow"></div>
|
|
||||||
<figcaption class="sr-only">Screenshot of a failed exchange in Cameleer, showing the full execution graph, fallback path, and exception context.</figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
<ul class="lg:col-span-4 space-y-7 lg:pt-4">
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.showcase-shot {
|
|
||||||
box-shadow:
|
|
||||||
0 1px 0 rgba(240, 180, 41, 0.08) inset,
|
|
||||||
0 40px 80px -30px rgba(0, 0, 0, 0.7),
|
|
||||||
0 15px 35px -15px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
.showcase-shot-glow {
|
|
||||||
position: absolute;
|
|
||||||
inset: -5% -8% -5% -8%;
|
|
||||||
background: radial-gradient(
|
|
||||||
55% 55% at 45% 50%,
|
|
||||||
rgba(92, 200, 255, 0.10),
|
|
||||||
rgba(240, 180, 41, 0.08) 40%,
|
|
||||||
transparent 75%
|
|
||||||
);
|
|
||||||
filter: blur(50px);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
33
src/components/sections/SocialProofStrip.astro
Normal file
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
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>
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
---
|
---
|
||||||
// Final nJAMS-legacy wording is subject to Hendrik's trademark review before go-live
|
|
||||||
// (see docs/superpowers/specs/2026-04-24-cameleer-website-design.md §10).
|
|
||||||
---
|
---
|
||||||
<section class="border-b border-border">
|
<section class="border-b border-border">
|
||||||
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
<div class="max-w-content mx-auto px-6 py-20 md:py-24">
|
||||||
@@ -20,32 +18,15 @@
|
|||||||
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.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative overflow-hidden 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)]">
|
<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)]">
|
||||||
<div
|
<h3 class="text-xl font-bold text-text mb-4">Built by people who've operated integration in production for 15 years.</h3>
|
||||||
aria-hidden="true"
|
|
||||||
class="pointer-events-none select-none absolute -top-6 -right-4 font-mono font-bold leading-none tracking-tight text-accent/[0.04] text-[7rem] md:text-[9rem]"
|
|
||||||
>
|
|
||||||
03:00
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="flex items-center gap-2.5 mb-5 font-mono text-xs">
|
|
||||||
<span aria-hidden="true" class="relative inline-flex w-1.5 h-1.5">
|
|
||||||
<span class="absolute inset-0 rounded-full bg-accent"></span>
|
|
||||||
<span class="absolute inset-0 rounded-full bg-accent/60 animate-ping [animation-duration:2.4s]"></span>
|
|
||||||
</span>
|
|
||||||
<span class="text-accent tabular-nums tracking-wide">03:00:47.218</span>
|
|
||||||
<span class="text-text-faint">·</span>
|
|
||||||
<span class="text-text-faint uppercase tracking-[0.2em]">ops desk</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-bold text-text mb-4">Built by people who know what 3 AM looks like.</h3>
|
|
||||||
<p class="text-text-muted leading-relaxed mb-4">
|
<p class="text-text-muted leading-relaxed mb-4">
|
||||||
We 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.
|
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>
|
||||||
<p class="text-text-muted leading-relaxed">
|
<p class="text-text-muted leading-relaxed">
|
||||||
Cameleer is what we would build today, purpose-built for Apache Camel. No legacy, no retrofit, no assumptions about a generic middleware platform.
|
Cameleer is what we'd build today, purpose-built for Apache Camel. No legacy, no retrofit, no assumptions about a generic middleware platform.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,57 +4,57 @@ import { resolveAuthConfig } from './auth';
|
|||||||
describe('resolveAuthConfig', () => {
|
describe('resolveAuthConfig', () => {
|
||||||
it('returns both URLs and sales email from env', () => {
|
it('returns both URLs and sales email from env', () => {
|
||||||
const cfg = resolveAuthConfig({
|
const cfg = resolveAuthConfig({
|
||||||
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||||
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
});
|
});
|
||||||
expect(cfg.signInUrl).toBe('https://auth.cameleer.io/sign-in');
|
expect(cfg.signInUrl).toBe('https://app.cameleer.io/sign-in');
|
||||||
expect(cfg.signUpUrl).toBe('https://auth.cameleer.io/sign-in?first_screen=register');
|
expect(cfg.signUpUrl).toBe('https://app.cameleer.io/sign-in?first_screen=register');
|
||||||
expect(cfg.salesEmail).toBe('sales@cameleer.io');
|
expect(cfg.salesEmail).toBe('sales@cameleer.io');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if PUBLIC_AUTH_SIGNIN_URL is missing', () => {
|
it('throws if PUBLIC_AUTH_SIGNIN_URL is missing', () => {
|
||||||
expect(() => resolveAuthConfig({
|
expect(() => resolveAuthConfig({
|
||||||
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
})).toThrow(/PUBLIC_AUTH_SIGNIN_URL/);
|
})).toThrow(/PUBLIC_AUTH_SIGNIN_URL/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if a URL is not https', () => {
|
it('throws if a URL is not https', () => {
|
||||||
expect(() => resolveAuthConfig({
|
expect(() => resolveAuthConfig({
|
||||||
PUBLIC_AUTH_SIGNIN_URL: 'http://auth.cameleer.io/sign-in',
|
PUBLIC_AUTH_SIGNIN_URL: 'http://app.cameleer.io/sign-in',
|
||||||
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
})).toThrow(/must be https/);
|
})).toThrow(/must be https/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if sales email is not a valid mailto target', () => {
|
it('throws if sales email is not a valid mailto target', () => {
|
||||||
expect(() => resolveAuthConfig({
|
expect(() => resolveAuthConfig({
|
||||||
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||||
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||||
PUBLIC_SALES_EMAIL: 'not-an-email',
|
PUBLIC_SALES_EMAIL: 'not-an-email',
|
||||||
})).toThrow(/PUBLIC_SALES_EMAIL/);
|
})).toThrow(/PUBLIC_SALES_EMAIL/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if PUBLIC_AUTH_SIGNUP_URL is missing', () => {
|
it('throws if PUBLIC_AUTH_SIGNUP_URL is missing', () => {
|
||||||
expect(() => resolveAuthConfig({
|
expect(() => resolveAuthConfig({
|
||||||
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
})).toThrow(/PUBLIC_AUTH_SIGNUP_URL/);
|
})).toThrow(/PUBLIC_AUTH_SIGNUP_URL/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if PUBLIC_AUTH_SIGNUP_URL is not https', () => {
|
it('throws if PUBLIC_AUTH_SIGNUP_URL is not https', () => {
|
||||||
expect(() => resolveAuthConfig({
|
expect(() => resolveAuthConfig({
|
||||||
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||||
PUBLIC_AUTH_SIGNUP_URL: 'http://auth.cameleer.io/sign-in?first_screen=register',
|
PUBLIC_AUTH_SIGNUP_URL: 'http://app.cameleer.io/sign-in?first_screen=register',
|
||||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
})).toThrow(/must be https/);
|
})).toThrow(/must be https/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes signUpUrl distinct from signInUrl', () => {
|
it('exposes signUpUrl distinct from signInUrl', () => {
|
||||||
const cfg = resolveAuthConfig({
|
const cfg = resolveAuthConfig({
|
||||||
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
PUBLIC_AUTH_SIGNIN_URL: 'https://app.cameleer.io/sign-in',
|
||||||
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
PUBLIC_AUTH_SIGNUP_URL: 'https://app.cameleer.io/sign-in?first_screen=register',
|
||||||
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
});
|
});
|
||||||
expect(cfg.signUpUrl).not.toBe(cfg.signInUrl);
|
expect(cfg.signUpUrl).not.toBe(cfg.signInUrl);
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ const ogUrl = new URL(ogImage, Astro.site ?? 'https://www.cameleer.io').toString
|
|||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<link rel="canonical" href={canonical} />
|
<link rel="canonical" href={canonical} />
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/cameleer-logo.svg" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/cameleer-32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/cameleer-32.png" />
|
||||||
<link rel="apple-touch-icon" href="/icons/cameleer-180.png" />
|
<link rel="apple-touch-icon" href="/icons/cameleer-180.png" />
|
||||||
|
|
||||||
@@ -48,6 +47,8 @@ const ogUrl = new URL(ogImage, Astro.site ?? 'https://www.cameleer.io').toString
|
|||||||
<meta name="twitter:image" content={ogUrl} />
|
<meta name="twitter:image" content={ogUrl} />
|
||||||
|
|
||||||
<meta name="robots" content="index,follow" />
|
<meta name="robots" content="index,follow" />
|
||||||
|
|
||||||
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-bg text-text font-sans antialiased">
|
<body class="min-h-screen bg-bg text-text font-sans antialiased">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function buildSecurityHeaders(): Record<string, string> {
|
|||||||
"connect-src 'self'",
|
"connect-src 'self'",
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
// No forms on this marketing site today (all auth redirects go to auth.cameleer.io
|
// 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.
|
// as plain <a> navigations). If a future form is added, relax to 'self' or an allow-list.
|
||||||
"form-action 'none'",
|
"form-action 'none'",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
|
|||||||
@@ -3,22 +3,33 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
import SiteHeader from '../components/SiteHeader.astro';
|
import SiteHeader from '../components/SiteHeader.astro';
|
||||||
import SiteFooter from '../components/SiteFooter.astro';
|
import SiteFooter from '../components/SiteFooter.astro';
|
||||||
import Hero from '../components/sections/Hero.astro';
|
import Hero from '../components/sections/Hero.astro';
|
||||||
import DualValueProps from '../components/sections/DualValueProps.astro';
|
import SocialProofStrip from '../components/sections/SocialProofStrip.astro';
|
||||||
import ProductShowcase from '../components/sections/ProductShowcase.astro';
|
import ThreeAmWalkthrough from '../components/sections/ThreeAmWalkthrough.astro';
|
||||||
import HowItWorks from '../components/sections/HowItWorks.astro';
|
import HowItWorks from '../components/sections/HowItWorks.astro';
|
||||||
import WhyUs from '../components/sections/WhyUs.astro';
|
import WhyUs from '../components/sections/WhyUs.astro';
|
||||||
import PricingTeaser from '../components/sections/PricingTeaser.astro';
|
import PricingTeaser from '../components/sections/PricingTeaser.astro';
|
||||||
import FinalCTA from '../components/sections/FinalCTA.astro';
|
import FinalCTA from '../components/sections/FinalCTA.astro';
|
||||||
---
|
---
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
title="Cameleer — Run Apache Camel without running Apache Camel"
|
title="Cameleer — Ship Camel integrations. Sleep through the night."
|
||||||
description="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."
|
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."
|
||||||
>
|
>
|
||||||
|
<Fragment slot="head">
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="image"
|
||||||
|
type="image/webp"
|
||||||
|
href="/product/exchange-detail-1280.webp"
|
||||||
|
imagesrcset="/product/exchange-detail-1280.webp 1280w, /product/exchange-detail-1920.webp 1920w"
|
||||||
|
imagesizes="(min-width: 1024px) 56vw, 100vw"
|
||||||
|
fetchpriority="high"
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
<DualValueProps />
|
<SocialProofStrip />
|
||||||
<ProductShowcase />
|
<ThreeAmWalkthrough />
|
||||||
<HowItWorks />
|
<HowItWorks />
|
||||||
<WhyUs />
|
<WhyUs />
|
||||||
<PricingTeaser />
|
<PricingTeaser />
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const tiers: FullTier[] = [
|
|||||||
cta: 'Start free trial',
|
cta: 'Start free trial',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'MID',
|
name: 'Starter',
|
||||||
price: '20 €',
|
price: '20 €',
|
||||||
priceNote: 'per month',
|
priceNote: 'per month',
|
||||||
envs: '2 environments',
|
envs: '2 environments',
|
||||||
@@ -41,28 +41,28 @@ const tiers: FullTier[] = [
|
|||||||
retention: '7-day retention',
|
retention: '7-day retention',
|
||||||
features: ['Everything in Trial', 'Data flow lineage', 'Cross-service correlation'],
|
features: ['Everything in Trial', 'Data flow lineage', 'Cross-service correlation'],
|
||||||
href: auth.signUpUrl,
|
href: auth.signUpUrl,
|
||||||
cta: 'Start on MID',
|
cta: 'Start on Starter',
|
||||||
highlight: true,
|
highlight: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'HIGH',
|
name: 'Scale',
|
||||||
price: 'Contact',
|
price: 'Contact',
|
||||||
priceNote: 'sales',
|
priceNote: 'sales',
|
||||||
envs: 'Unlimited environments',
|
envs: 'Unlimited environments',
|
||||||
apps: '50 apps',
|
apps: '50 apps',
|
||||||
retention: '90-day retention',
|
retention: '90-day retention',
|
||||||
features: ['Everything in MID', 'Live debugger', 'Exchange replay', 'Live tap'],
|
features: ['Everything in Starter', 'Live debugger', 'Exchange replay', 'Live tap'],
|
||||||
href: auth.salesMailto,
|
href: auth.salesMailto,
|
||||||
cta: 'Talk to sales',
|
cta: 'Talk to sales',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'BUSINESS',
|
name: 'Enterprise',
|
||||||
price: 'Contact',
|
price: 'Contact',
|
||||||
priceNote: 'sales',
|
priceNote: 'sales',
|
||||||
envs: 'Unlimited environments',
|
envs: 'Unlimited environments',
|
||||||
apps: 'Unlimited apps',
|
apps: 'Unlimited apps',
|
||||||
retention: '365-day retention',
|
retention: '365-day retention',
|
||||||
features: ['Everything in HIGH', 'Priority support', 'SLA', 'Dedicated success contact'],
|
features: ['Everything in Scale', 'Priority support', 'SLA', 'Dedicated success contact'],
|
||||||
href: auth.salesMailto,
|
href: auth.salesMailto,
|
||||||
cta: 'Talk to sales',
|
cta: 'Talk to sales',
|
||||||
},
|
},
|
||||||
@@ -117,7 +117,7 @@ const tiers: FullTier[] = [
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-text-faint text-sm mt-10">
|
<p class="text-center text-text-faint text-sm mt-10">
|
||||||
Prices in EUR, excluding VAT. Billed monthly. Annual billing available for HIGH and BUSINESS — <a href={auth.salesMailto} class="text-accent hover:underline">talk to sales</a>.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const lastUpdated = '2026-04-24';
|
|||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<h2 class="text-lg font-bold text-text mb-3">6. External links</h2>
|
<h2 class="text-lg font-bold text-text mb-3">6. External links</h2>
|
||||||
<p class="text-text-muted leading-relaxed">
|
<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">auth.cameleer.io</span> (Logto identity service) and subsequently <span class="font-mono text-accent">platform.cameleer.io</span>. Those services have their own privacy policies, which apply from the moment you arrive there.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
72
src/placeholder.test.ts
Normal file
72
src/placeholder.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const placeholderDir = join(process.cwd(), 'placeholder');
|
||||||
|
const indexPath = join(placeholderDir, 'index.html');
|
||||||
|
|
||||||
|
describe('placeholder/index.html', () => {
|
||||||
|
const html = readFileSync(indexPath, 'utf8');
|
||||||
|
|
||||||
|
it('starts with the HTML5 doctype', () => {
|
||||||
|
expect(html.toLowerCase().trimStart()).toMatch(/^<!doctype html>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the back-shortly title', () => {
|
||||||
|
expect(html).toContain('<title>Cameleer — Back shortly</title>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not indexable by search engines', () => {
|
||||||
|
expect(html).toContain('<meta name="robots" content="noindex">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares the dark color-scheme matching the live site', () => {
|
||||||
|
expect(html).toContain('<meta name="color-scheme" content="dark">');
|
||||||
|
expect(html).toContain('<meta name="theme-color" content="#060a13">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains the sentinel string the deploy workflow greps for', () => {
|
||||||
|
// The workflow's post-deploy smoke test fails if this string is missing.
|
||||||
|
expect(html).toContain('Routes are remapping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the live hero subhead verbatim', () => {
|
||||||
|
expect(html).toContain(
|
||||||
|
'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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains __SALES_EMAIL__ tokens at both the mailto href and the link text', () => {
|
||||||
|
const matches = html.match(/__SALES_EMAIL__/g) ?? [];
|
||||||
|
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains no other __TOKEN__ style placeholders', () => {
|
||||||
|
// Guard against a forgotten token that would survive the sed substitution.
|
||||||
|
const allTokens = html.match(/__[A-Z][A-Z0-9_]+__/g) ?? [];
|
||||||
|
const nonSales = allTokens.filter((t) => t !== '__SALES_EMAIL__');
|
||||||
|
expect(nonSales).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('references the sibling cameleer-logo.png by relative path', () => {
|
||||||
|
expect(html).toContain('src="./cameleer-logo.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('references the sibling favicon.png by relative path', () => {
|
||||||
|
expect(html).toContain('href="./favicon.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no <script> tags (placeholder must work without JS)', () => {
|
||||||
|
expect(html).not.toMatch(/<script[\s>]/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('placeholder/ asset siblings', () => {
|
||||||
|
it('cameleer-logo.png exists on disk', () => {
|
||||||
|
expect(existsSync(join(placeholderDir, 'cameleer-logo.png'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('favicon.png exists on disk', () => {
|
||||||
|
expect(existsSync(join(placeholderDir, 'favicon.png'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user