Files
cameleer-website/docs/superpowers/plans/2026-04-25-under-construction-placeholder.md
hsiegeln c3511a4c1b docs(plan): implementation plan for under-construction placeholder
Four-task plan: tests → page+assets → workflow → README. TDD via
src/placeholder.test.ts (vitest only discovers src/**/*.test.ts).
Atomic commits per task; the plan is wired off the spec at commit
9b0c36b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:03:27 +02:00

21 KiB

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:

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
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.

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.

<!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.

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
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.

# -----------------------------------------------------------------------------
# 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):

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
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
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.