Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5dd5f47ea | ||
|
|
550392aa17 | ||
|
|
556e8ce874 | ||
|
|
dbd77b3bb5 | ||
|
|
523f7303e0 | ||
|
|
be55c4a27e | ||
|
|
59caa8e838 | ||
|
|
002845c880 | ||
|
|
76326da0ca | ||
|
|
04e0c4d9cd | ||
|
|
e07cb8545e | ||
|
|
5c825b08e9 | ||
|
|
450bba8bea | ||
|
|
c7033140f0 | ||
|
|
0c2b4a6ac6 | ||
|
|
375ee48ab1 | ||
|
|
93415c6852 | ||
|
|
da1043e6f3 | ||
|
|
d1c1726796 | ||
|
|
e85edbfe34 | ||
|
|
6f802826df | ||
|
|
a76a6da26c | ||
|
|
b51a243728 | ||
|
|
55add6ba17 | ||
|
|
120ffb7445 | ||
|
|
0f769d7bdd | ||
|
|
8a4c4bccec | ||
|
|
5bfc5981cd | ||
|
|
4e354d2721 | ||
|
|
171c8c96aa | ||
|
|
5331e7d717 | ||
|
|
c19b2b2af4 | ||
|
|
79e7fd4b2b | ||
|
|
9f1cbca1b1 | ||
|
|
042e80ac2c | ||
|
|
fc64909f3d | ||
|
|
99d4a3bea9 |
No files matched your search
@@ -10,3 +10,11 @@ k8s
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
# Not needed to build the site; keeps the build context small
|
||||||
|
.playwright-mcp
|
||||||
|
# root-level screenshots only (dockerignore *.png does not match in subdirectories)
|
||||||
|
*.png
|
||||||
|
planning
|
||||||
|
tests
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
PUBLIC_SITE_URL=https://www.bluecapstrategies.com
|
PUBLIC_SITE_URL=https://www.bluecapstrategies.com
|
||||||
PUBLIC_CONTACT_FORM_ENDPOINT=
|
PUBLIC_CONTACT_FORM_ENDPOINT=
|
||||||
|
|
||||||
# Analytics — side-by-side trial (ADR-0005). Leave blank to disable each.
|
# Analytics — leave blank to disable each. Production values come from CI (Dockerfile ARGs).
|
||||||
|
# Google Analytics 4:
|
||||||
|
PUBLIC_GA_MEASUREMENT_ID=
|
||||||
# PostHog (Cloud free tier recommended for the trial):
|
# PostHog (Cloud free tier recommended for the trial):
|
||||||
PUBLIC_POSTHOG_KEY=
|
PUBLIC_POSTHOG_KEY=
|
||||||
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
# jobs can't see it, enable socket passthrough in act_runner config or switch this to kaniko.
|
# jobs can't see it, enable socket passthrough in act_runner config or switch this to kaniko.
|
||||||
# 2. The RUNNER HOST's dockerd must treat 10.66.15.22:3000 as an insecure (HTTP) registry
|
# 2. The RUNNER HOST's dockerd must treat 10.66.15.22:3000 as an insecure (HTTP) registry
|
||||||
# ("insecure-registries": ["10.66.15.22:3000"] in /etc/docker/daemon.json).
|
# ("insecure-registries": ["10.66.15.22:3000"] in /etc/docker/daemon.json).
|
||||||
# 3. K3S must trust the same HTTP registry — add a registries.yaml entry (k3s_registry_mirrors)
|
# 3. K3S does NOT need registries.yaml — it pulls the same package over public HTTPS
|
||||||
# for 10.66.15.22:3000, then restart k3s, or the pods can't pull the image.
|
# (git.7tl-homelab.com, valid cert). See ADR-0008. No node config, no cluster restart.
|
||||||
# 4. The Gitea container package should be PUBLIC (simplest). If PRIVATE, add an imagePullSecret
|
# 4. The Gitea package is PRIVATE; k3s pull auth comes from External Secrets ->
|
||||||
# (see k8s/deployment.yaml) and grant the run token package:write.
|
# k8s/externalsecret-registry.yaml -> `gitea-registry` imagePullSecret. See ADR-0008.
|
||||||
# 5. secrets.GITEA_TOKEN (auto-injected per run) needs write access to this repo (tag-bump commit)
|
# 5. secrets.GITEA_TOKEN (auto-injected per run) needs write access to this repo (tag-bump commit)
|
||||||
# and the package registry.
|
# and the package registry.
|
||||||
|
|
||||||
@@ -61,12 +61,32 @@ jobs:
|
|||||||
id: tag
|
id: tag
|
||||||
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-12)" >> "$GITHUB_OUTPUT"
|
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-12)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Both tools are fetched as STATIC BINARIES, never from distro packages.
|
||||||
|
# History: this step used `apt-get install -y docker.io`, which broke CI on 2026-08-03 when
|
||||||
|
# Debian 11 (bullseye — the runner's job image is node:18-bullseye) went EOL and the
|
||||||
|
# debian-security pool stopped serving those package versions. `apt-get update` still
|
||||||
|
# succeeds, so the failure surfaces only at install time as a wall of 404s. Static binaries
|
||||||
|
# have no distro-archive dependency and pin an exact version, so this cannot rot the same way.
|
||||||
- name: Ensure docker + kustomize are available
|
- name: Ensure docker + kustomize are available
|
||||||
|
env:
|
||||||
|
DOCKER_CLI_VERSION: '28.3.2'
|
||||||
|
KUSTOMIZE_VERSION: 'v5.4.3'
|
||||||
run: |
|
run: |
|
||||||
command -v docker >/dev/null 2>&1 || { apt-get update && apt-get install -y docker.io; }
|
set -euo pipefail
|
||||||
command -v kustomize >/dev/null 2>&1 || \
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
curl -sSL "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz" \
|
curl -sSL --retry 3 --retry-delay 5 \
|
||||||
|
"https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_CLI_VERSION}.tgz" -o /tmp/docker.tgz
|
||||||
|
# We only need the client; the daemon is the runner host's, via the mounted socket.
|
||||||
|
tar xzf /tmp/docker.tgz -C /tmp --strip-components=1 docker/docker
|
||||||
|
install -m 0755 /tmp/docker /usr/local/bin/docker
|
||||||
|
fi
|
||||||
|
if ! command -v kustomize >/dev/null 2>&1; then
|
||||||
|
curl -sSL --retry 3 --retry-delay 5 \
|
||||||
|
"https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2F${KUSTOMIZE_VERSION}/kustomize_${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" \
|
||||||
| tar xz -C /usr/local/bin
|
| tar xz -C /usr/local/bin
|
||||||
|
fi
|
||||||
|
docker version --format 'docker client={{.Client.Version}} server={{.Server.Version}}'
|
||||||
|
kustomize version
|
||||||
|
|
||||||
# CI_TOKEN: a Gitea Personal Access Token (scopes: write:package, write:repository) owned by
|
# CI_TOKEN: a Gitea Personal Access Token (scopes: write:package, write:repository) owned by
|
||||||
# the repo owner, stored as a repo Actions secret. The auto GITEA_TOKEN lacks reliable package
|
# the repo owner, stored as a repo Actions secret. The auto GITEA_TOKEN lacks reliable package
|
||||||
@@ -74,7 +94,18 @@ jobs:
|
|||||||
- name: Build & push image to Gitea Packages
|
- name: Build & push image to Gitea Packages
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.CI_TOKEN }}" | docker login 10.66.15.22:3000 -u "${{ github.repository_owner }}" --password-stdin
|
echo "${{ secrets.CI_TOKEN }}" | docker login 10.66.15.22:3000 -u "${{ github.repository_owner }}" --password-stdin
|
||||||
docker build -t "$PUSH_IMAGE:${{ steps.tag.outputs.sha }}" -t "$PUSH_IMAGE:latest" .
|
# PUBLIC_* values are inlined at build time (static Astro) — they cannot be supplied at
|
||||||
|
# deploy time. CONTACT_FORM_ENDPOINT is the Formspree URL; set it as a repo Actions
|
||||||
|
# secret. If it is empty the build still succeeds and the contact form degrades to a
|
||||||
|
# mailto draft, so a missing secret is survivable but should be caught in review.
|
||||||
|
# GA_MEASUREMENT_ID / UMAMI_WEBSITE_ID are non-secret repo Actions *variables*
|
||||||
|
# (Settings → Actions → Variables); empty means that tracker is simply left out.
|
||||||
|
docker build \
|
||||||
|
--build-arg PUBLIC_CONTACT_FORM_ENDPOINT="${{ secrets.CONTACT_FORM_ENDPOINT }}" \
|
||||||
|
--build-arg PUBLIC_SITE_URL="https://www.bluecapstrategies.com" \
|
||||||
|
--build-arg PUBLIC_GA_MEASUREMENT_ID="${{ vars.GA_MEASUREMENT_ID }}" \
|
||||||
|
--build-arg PUBLIC_UMAMI_WEBSITE_ID="${{ vars.UMAMI_WEBSITE_ID }}" \
|
||||||
|
-t "$PUSH_IMAGE:${{ steps.tag.outputs.sha }}" -t "$PUSH_IMAGE:latest" .
|
||||||
# Registry pushes can time out on a slow blob; docker push is resumable, so retry.
|
# Registry pushes can time out on a slow blob; docker push is resumable, so retry.
|
||||||
push_retry() {
|
push_retry() {
|
||||||
for i in 1 2 3 4 5; do
|
for i in 1 2 3 4 5; do
|
||||||
@@ -97,4 +128,12 @@ jobs:
|
|||||||
git diff --cached --quiet && { echo "no image change"; exit 0; }
|
git diff --cached --quiet && { echo "no image change"; exit 0; }
|
||||||
git commit -m "ci: deploy ${{ steps.tag.outputs.sha }} [skip ci]"
|
git commit -m "ci: deploy ${{ steps.tag.outputs.sha }} [skip ci]"
|
||||||
git remote set-url origin "http://${{ github.repository_owner }}:${{ secrets.CI_TOKEN }}@10.66.15.22:3000/bmr_bluecap/bcs-website.git"
|
git remote set-url origin "http://${{ github.repository_owner }}:${{ secrets.CI_TOKEN }}@10.66.15.22:3000/bmr_bluecap/bcs-website.git"
|
||||||
git push origin HEAD:main
|
# main may have moved since checkout (a later push, or a re-run of an older commit).
|
||||||
|
# Only kustomization.yaml changes here, so rebasing onto the new tip is safe.
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
git push origin HEAD:main && exit 0
|
||||||
|
echo "push rejected (main moved); rebasing onto origin/main, attempt $attempt"
|
||||||
|
git fetch --unshallow origin main 2>/dev/null || git fetch origin main
|
||||||
|
git rebase origin/main
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.astro/
|
.astro/
|
||||||
|
# Generated by scripts/optimize-images.mjs at build time
|
||||||
|
public/assets/opt/
|
||||||
|
src/data/image-manifest.json
|
||||||
|
|
||||||
# Secrets — only .env.example is committed
|
# Secrets — only .env.example is committed
|
||||||
.env
|
.env
|
||||||
@@ -13,9 +16,11 @@ dist/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts and ad-hoc browser captures (Playwright MCP snapshots, root screenshots)
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
.playwright-mcp/
|
||||||
|
/*.png
|
||||||
|
|
||||||
# Working/planning docs — kept on disk, not version-controlled (see docs/ for committed records)
|
# Working/planning docs — kept on disk, not version-controlled (see docs/ for committed records)
|
||||||
planning/
|
planning/
|
||||||
@@ -6,6 +6,20 @@ RUN npm ci
|
|||||||
|
|
||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Astro is a STATIC build: import.meta.env.PUBLIC_* is inlined into the HTML here, at image build
|
||||||
|
# time. A runtime env var on the pod can never reach it (the pod only runs nginx over pre-built
|
||||||
|
# files), so anything the browser needs must arrive as a build arg. CI passes these; see
|
||||||
|
# .gitea/workflows/ci.yml.
|
||||||
|
ARG PUBLIC_CONTACT_FORM_ENDPOINT=""
|
||||||
|
ARG PUBLIC_SITE_URL="https://www.bluecapstrategies.com"
|
||||||
|
ARG PUBLIC_GA_MEASUREMENT_ID=""
|
||||||
|
ARG PUBLIC_UMAMI_SRC="https://stats.bluecapstrategies.com/script.js"
|
||||||
|
ARG PUBLIC_UMAMI_WEBSITE_ID=""
|
||||||
|
ENV PUBLIC_CONTACT_FORM_ENDPOINT=$PUBLIC_CONTACT_FORM_ENDPOINT \
|
||||||
|
PUBLIC_SITE_URL=$PUBLIC_SITE_URL \
|
||||||
|
PUBLIC_GA_MEASUREMENT_ID=$PUBLIC_GA_MEASUREMENT_ID \
|
||||||
|
PUBLIC_UMAMI_SRC=$PUBLIC_UMAMI_SRC \
|
||||||
|
PUBLIC_UMAMI_WEBSITE_ID=$PUBLIC_UMAMI_WEBSITE_ID
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Static Astro site for BlueCap Strategies. CI: .gitea/workflows/ci.yml (Gitea Actions).
|
// Static Astro site for BlueCap Strategies. CI: .gitea/workflows/ci.yml (Gitea Actions).
|
||||||
import { defineConfig } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
import sitemap from "@astrojs/sitemap";
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
import { rehypeResponsiveImages } from "./src/utils/images.mjs";
|
||||||
|
|
||||||
const site = process.env.PUBLIC_SITE_URL ?? "https://www.bluecapstrategies.com";
|
const site = process.env.PUBLIC_SITE_URL ?? "https://www.bluecapstrategies.com";
|
||||||
|
|
||||||
@@ -8,6 +9,10 @@ export default defineConfig({
|
|||||||
site,
|
site,
|
||||||
output: "static",
|
output: "static",
|
||||||
trailingSlash: "always",
|
trailingSlash: "always",
|
||||||
|
markdown: {
|
||||||
|
// Article column is ~880px wide on desktop, full width below the 960px breakpoint.
|
||||||
|
rehypePlugins: [[rehypeResponsiveImages, { sizes: "(max-width: 960px) 100vw, 880px" }]],
|
||||||
|
},
|
||||||
integrations: [
|
integrations: [
|
||||||
sitemap({
|
sitemap({
|
||||||
filter: (page) => !page.includes("/thank-you/"),
|
filter: (page) => !page.includes("/thank-you/"),
|
||||||
|
|||||||
@@ -14,13 +14,29 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /404/index.html;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 404 /404/index.html;
|
error_page 404 /404.html;
|
||||||
|
|
||||||
location ~* \.(?:css|js|jpg|jpeg|png|webp|svg|ico|woff2)$ {
|
# NOTE: an add_header inside a location drops ALL server-level add_headers, so each block below
|
||||||
|
# repeats the security headers.
|
||||||
|
|
||||||
|
# Content-hashed file names (Astro bundles, scripts/optimize-images.mjs output): a changed file
|
||||||
|
# gets a new URL, so these can be cached forever.
|
||||||
|
location ~* ^/(?:_astro|assets/opt)/ {
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Other static files keep a fixed URL when replaced (favicon, original JPEGs, logo), so cache
|
||||||
|
# them for a week, not forever.
|
||||||
|
location ~* \.(?:css|js|jpg|jpeg|png|webp|avif|svg|ico|woff2)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
**Status:** Accepted — 2026-07-15 · **Supersedes** the GitHub Actions + GHCR specifics of
|
**Status:** Accepted — 2026-07-15 · **Supersedes** the GitHub Actions + GHCR specifics of
|
||||||
[ADR-0001](0001-gitops-with-argocd.md) (the ArgoCD/GitOps model itself stands).
|
[ADR-0001](0001-gitops-with-argocd.md) (the ArgoCD/GitOps model itself stands).
|
||||||
|
|
||||||
|
> ⚠️ **Partially superseded by [ADR-0008](0008-private-package-external-secrets-pull.md).** The
|
||||||
|
> registry-visibility (public → **private**), image-pull path (registries.yaml/k3s-restart →
|
||||||
|
> **public-HTTPS pull, no restart**), CI auth (auto token → **`CI_TOKEN` PAT**), and pipeline
|
||||||
|
> (**Playwright e2e removed from CI**) below were changed once built. The "CI on Gitea Actions,
|
||||||
|
> images in Gitea Packages" core still holds. Read ADR-0008 for the as-built details.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The repo lives on the homelab's self-hosted **Gitea** (`bmr_bluecap/bcs-website`), not GitHub, so
|
The repo lives on the homelab's self-hosted **Gitea** (`bmr_bluecap/bcs-website`), not GitHub, so
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# ADR-0008: Private package, External-Secrets pull over public HTTPS (no k3s restart)
|
||||||
|
|
||||||
|
**Status:** Accepted — 2026-07-17 · **Supersedes** the registry-visibility, image-pull, CI-auth,
|
||||||
|
and pipeline specifics of [ADR-0007](0007-gitea-actions-and-packages.md). (ADR-0007's core — CI on
|
||||||
|
Gitea Actions, images in Gitea Packages — still stands; only the details below changed once we
|
||||||
|
actually built and debugged it.)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0007 proposed a **public** package pulled by k3s from the internal HTTP registry
|
||||||
|
(`10.66.15.22:3000`), which required `registries.yaml` on every node + a **k3s restart**, and CI
|
||||||
|
auth via the runner's automatic token. In practice:
|
||||||
|
- The automatic Gitea Actions token **lacked reliable package-write scope** → `docker login`
|
||||||
|
returned `unauthorized`.
|
||||||
|
- k3s trusting an HTTP registry means a **rolling cluster restart** — unacceptable disruption for
|
||||||
|
a static site, and the user wanted zero risk to the (still-live) old site and other workloads.
|
||||||
|
- The user chose a **private** package (least exposure) managed via the existing **External
|
||||||
|
Secrets** + OpenBao stack.
|
||||||
|
- Testing showed a cluster pod **can reach the registry over public HTTPS** (`git.7tl-homelab.com`,
|
||||||
|
valid cert) — so no node-level registry config is needed at all.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- **Package is PRIVATE.** (Gitea user packages are private by default; no public/anon pull.)
|
||||||
|
- **Split push/pull, same Gitea package, two access paths:**
|
||||||
|
- **CI pushes** to the **internal** endpoint `10.66.15.22:3000/bmr_bluecap/bcs-website` (HTTP,
|
||||||
|
fast, no edge). The runner host's dockerd trusts it via `insecure-registries` (managed in the
|
||||||
|
gitea Ansible role + SIGHUP live-reload — no container restart).
|
||||||
|
- **k3s pulls** the **public HTTPS** endpoint `git.7tl-homelab.com/bmr_bluecap/bcs-website`
|
||||||
|
(valid TLS) → **no `registries.yaml`, no node insecure config, no k3s restart.**
|
||||||
|
- `.gitea/workflows/ci.yml` uses `PUSH_IMAGE` (internal) and `DEPLOY_IMAGE` (public); the tag
|
||||||
|
bump / k8s image ref use `DEPLOY_IMAGE`.
|
||||||
|
- **Pull auth via External Secrets:** `k8s/externalsecret-registry.yaml` (ClusterSecretStore
|
||||||
|
`openbao-cluster`, OpenBao KV v2 mount `cluster`, path `bluecap-registry` → fields
|
||||||
|
`username`/`password`) materializes a `kubernetes.io/dockerconfigjson` secret `gitea-registry`;
|
||||||
|
the Deployment references it via `imagePullSecrets`. No static secret in Git.
|
||||||
|
- **CI auth via a dedicated PAT** `CI_TOKEN` (repo Actions secret; scopes `write:package` +
|
||||||
|
`write:repository`) for both the registry push and the tag-bump commit-back — not the auto token.
|
||||||
|
- **Playwright e2e removed from CI** (heaviest step, headless Chrome, unneeded for a static site).
|
||||||
|
CI runs `npm ci` → build (typecheck) → `seo:lint`. Run e2e locally: `npm run test:e2e`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **No cluster restart, no per-node registry config** — deploys never disturb k3s.
|
||||||
|
- Two tokens by role (least privilege): `CI_TOKEN` (write) as a repo secret; a separate
|
||||||
|
`read:package` PAT in OpenBao for cluster pulls.
|
||||||
|
- The internal-push insecure-registry trust is the one host-level bit (gitea role), needed only on
|
||||||
|
the runner host, applied by live-reload.
|
||||||
|
- Verified 2026-07-17: full loop green, 2/2 pods pulling the private image over HTTPS and serving.
|
||||||
@@ -14,4 +14,5 @@ Format: Status · Context · Decision · Consequences. Statuses: Proposed · Acc
|
|||||||
| [0004](0004-reuse-monylog-for-monitoring.md) | Reuse the monylog stack for ops monitoring (not Uptime Kuma) | Accepted |
|
| [0004](0004-reuse-monylog-for-monitoring.md) | Reuse the monylog stack for ops monitoring (not Uptime Kuma) | Accepted |
|
||||||
| [0005](0005-analytics-posthog-and-umami-trial.md) | Visitor analytics: PostHog + Umami side-by-side trial | Accepted |
|
| [0005](0005-analytics-posthog-and-umami-trial.md) | Visitor analytics: PostHog + Umami side-by-side trial | Accepted |
|
||||||
| [0006](0006-launch-insights-blog.md) | Launch a blog at /insights/ | Accepted |
|
| [0006](0006-launch-insights-blog.md) | Launch a blog at /insights/ | Accepted |
|
||||||
| [0007](0007-gitea-actions-and-packages.md) | CI on Gitea Actions, images in Gitea Packages | Accepted |
|
| [0007](0007-gitea-actions-and-packages.md) | CI on Gitea Actions, images in Gitea Packages | Accepted (registry/pull/auth specifics superseded by 0008) |
|
||||||
|
| [0008](0008-private-package-external-secrets-pull.md) | Private package, External-Secrets pull over public HTTPS (no k3s restart) | Accepted |
|
||||||
@@ -17,6 +17,15 @@ migration to Markdown collections (ADR-0002).
|
|||||||
Areas" section — the only live copy that exists for these (the live `/focus-areas/` page is empty).
|
Areas" section — the only live copy that exists for these (the live `/focus-areas/` page is empty).
|
||||||
No additional copy was invented.
|
No additional copy was invented.
|
||||||
|
|
||||||
|
## Detail-page heroes — no hero image (2026-07-17)
|
||||||
|
|
||||||
|
- Service and focus-area **detail** pages use a **clean text hero** (eyebrow + title), **no hero
|
||||||
|
image**. This matches the originals, which had no hero photo on those pages. An interim version
|
||||||
|
put a shared photo (looked repetitive) then the per-service 64px icon (too small / orphaned) — both
|
||||||
|
rejected; the text hero is the final call. Service **icons remain on the `/services/` overview
|
||||||
|
cards**, at their intended small size. The `/services/`, `/focus-areas/`, and About pages keep
|
||||||
|
their real photos.
|
||||||
|
|
||||||
## Metadata (not visible body copy)
|
## Metadata (not visible body copy)
|
||||||
|
|
||||||
- **SEO meta descriptions** (`description` frontmatter, 120–158 chars) are newly written marketing
|
- **SEO meta descriptions** (`description` frontmatter, 120–158 chars) are newly written marketing
|
||||||
|
|||||||
@@ -30,41 +30,54 @@ docker build -t bluecap-strategies-website:latest .
|
|||||||
docker run --rm -p 8080:80 bluecap-strategies-website:latest
|
docker run --rm -p 8080:80 bluecap-strategies-website:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## k3s
|
## k3s (as-built — deployed & serving in-cluster since 2026-07-17)
|
||||||
|
|
||||||
Production deployment should use the pull-based GitOps model documented in
|
Live GitOps flow (all working; see `docs/gitops-deployment-strategy.md` + ADR-0001/0007/0008):
|
||||||
`docs/gitops-deployment-strategy.md`.
|
|
||||||
|
|
||||||
The intended flow is:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
local edit -> git push -> GitHub Actions test/build/publish -> Git manifest image tag update -> ArgoCD reconciles k3s
|
local edit -> git push (Gitea main) -> Gitea Actions: build + seo:lint -> push image (internal
|
||||||
|
10.66.15.22:3000) -> bump DEPLOY_IMAGE tag + commit back [skip ci] -> ArgoCD reconciles k3s ->
|
||||||
|
k3s pulls git.7tl-homelab.com/... (private, ES pull secret, public HTTPS) -> pods serve
|
||||||
```
|
```
|
||||||
|
|
||||||
ArgoCD is already running in the homelab k3s cluster, so it is the reconciler (not Flux). GitHub
|
- ArgoCD is the reconciler (not Flux); the CI runner never gets kubeconfig or cluster access.
|
||||||
Actions should not receive kubeconfig or direct access to the homelab cluster.
|
- **Not yet public** — the ingress names `bluecapstrategies.com`/`www` but DNS still points at the
|
||||||
|
old WordPress site. Going public = the DNS Cutover below, a deliberate step. See the
|
||||||
|
`keep-old-site-live` rule: do nothing that affects the old site until cutover.
|
||||||
|
|
||||||
Manual `kubectl` use should be limited to bootstrapping the ArgoCD Application, read-only
|
### Operate
|
||||||
inspection, and emergency operations. If you intentionally choose to apply the current manifests
|
|
||||||
by hand during a controlled bootstrap test, the command would be:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl apply -k k8s/
|
# Review the running site locally (no cluster/DNS impact):
|
||||||
|
kubectl -n bluecap-strategies port-forward svc/bluecap-website 18080:80 # then http://localhost:18080
|
||||||
|
# or purely off-cluster from source: npm run dev
|
||||||
|
|
||||||
|
# Health / status:
|
||||||
|
kubectl -n argocd get application bcs-website # Synced / Healthy
|
||||||
|
kubectl -n bluecap-strategies get pods,deploy # 2/2 Running
|
||||||
|
kubectl -n bluecap-strategies get externalsecret gitea-registry # SecretSynced
|
||||||
|
|
||||||
|
# Rollback: `git revert` the tag-bump commit and push (ArgoCD reconciles), or use ArgoCD history.
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not treat that as the normal production deployment process.
|
### Troubleshoot (things that actually bit us)
|
||||||
|
|
||||||
The manifests assume Traefik ingress is available. Read-only research confirmed Traefik and
|
- **CI job never runs / "no matching runner"** → runner offline or mis-scoped. Must be a **global**
|
||||||
cert-manager are installed, with a ready `letsencrypt-prod` ClusterIssuer. However, TLS and ingress
|
(owner_id=0) runner on `gitea_network` with instance URL `http://gitea:3000`; token must be an
|
||||||
hardening should remain documented rather than applied until the Pangolin/Newt edge path is
|
**instance-level** registration token (Site Admin → Actions → Runners).
|
||||||
confirmed.
|
- **Push: "server gave HTTP response to HTTPS client"** → runner host dockerd missing
|
||||||
|
`insecure-registries: ["10.66.15.22:3000"]` (gitea Ansible role handles it).
|
||||||
|
- **Push: `unauthorized`** → `CI_TOKEN` repo secret missing/insufficient (`write:package` + `write:repository`).
|
||||||
|
- **Pod `ImagePullBackOff`** → ExternalSecret not synced (check OpenBao `cluster/bluecap-registry`
|
||||||
|
fields) or the `read:package` PAT.
|
||||||
|
- **`git push` rejected (fetch first)** → CI committed a tag bump to `main`; `git pull --rebase` first
|
||||||
|
(`git config pull.rebase true`).
|
||||||
|
|
||||||
Items to confirm before production ingress changes:
|
Manual `kubectl apply -k k8s/` is for emergency/bootstrap only, not normal deploys.
|
||||||
|
|
||||||
- Whether Pangolin/Newt terminates TLS, passes TLS through, or proxies HTTPS to Traefik.
|
Ingress/TLS items to confirm before cutover: whether Pangolin/Newt terminates/passes/proxies TLS
|
||||||
- Whether Pangolin/Newt preserves `Host` headers for the BlueCap hostnames.
|
to Traefik and preserves `Host` headers; whether cert-manager's Cloudflare DNS-01 token can issue
|
||||||
- Whether cert-manager's Cloudflare DNS-01 token can issue certificates for `bluecapstrategies.com`.
|
for `bluecapstrategies.com`; whether the canonical host is apex or `www`.
|
||||||
- Whether the canonical production host should be apex or `www`.
|
|
||||||
|
|
||||||
## DNS Cutover
|
## DNS Cutover
|
||||||
|
|
||||||
|
|||||||
@@ -30,22 +30,27 @@ pulls from Git and the image registry.
|
|||||||
> (`10.66.15.22:3000/bmr_bluecap/bcs-website`); ArgoCD Application in `argocd/bcs-website-application.yaml`.
|
> (`10.66.15.22:3000/bmr_bluecap/bcs-website`); ArgoCD Application in `argocd/bcs-website-application.yaml`.
|
||||||
> Any lingering GitHub/GHCR wording below is the generic model; substitute Gitea/Gitea-Packages.
|
> Any lingering GitHub/GHCR wording below is the generic model; substitute Gitea/Gitea-Packages.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites & as-built registry flow
|
||||||
|
|
||||||
Done: repo pushed to Gitea (`bmr_bluecap/bcs-website`, private); `k8s/deployment.yaml` image set to
|
> **As built (2026-07-17) — see [ADR-0008](adr/0008-private-package-external-secrets-pull.md).**
|
||||||
the Gitea Packages path; ArgoCD Application manifest written.
|
> The earlier "k3s trusts the HTTP registry + restart" plan was dropped. Final flow avoids any k3s
|
||||||
|
> restart or per-node config.
|
||||||
|
|
||||||
**Still to do before the first deploy works (homelab infra):**
|
**Registry flow (split push/pull, same private Gitea package, two access paths):**
|
||||||
- **k3s must trust the HTTP registry.** Add a `registries.yaml` entry for `10.66.15.22:3000` as
|
- **CI pushes** to the **internal** `10.66.15.22:3000/bmr_bluecap/bcs-website` (HTTP, fast). The
|
||||||
insecure/HTTP (via the k3s role `k3s_registry_mirrors`), then restart k3s — otherwise pods can't
|
runner host's dockerd trusts it via `insecure-registries` (gitea Ansible role + SIGHUP
|
||||||
pull the image.
|
live-reload — no restart). Docker in jobs works because act_runner's config pins jobs to
|
||||||
- **Runner host dockerd** needs `10.66.15.22:3000` in `insecure-registries` to push over HTTP.
|
`gitea_network` and exposes the docker socket.
|
||||||
- **Docker usable from job containers** (act_runner socket passthrough) — or switch the build to
|
- **k3s pulls** the **public HTTPS** `git.7tl-homelab.com/bmr_bluecap/bcs-website` (valid TLS) →
|
||||||
kaniko.
|
**no `registries.yaml`, no node config, no k3s restart.** (`k3s_registry_mirrors` stays empty.)
|
||||||
- **Package visibility:** make the container package **public** (simplest; image is only the public
|
- **Pull auth:** package is **private**; a `read:package` PAT lives in OpenBao (`cluster/bluecap-registry`)
|
||||||
site), or keep it private and add an imagePullSecret (`k8s/deployment.yaml`), ideally via the
|
and External Secrets (`k8s/externalsecret-registry.yaml`) materializes the `gitea-registry`
|
||||||
installed External Secrets operator.
|
dockerconfigjson used by the Deployment's `imagePullSecrets`.
|
||||||
- Apply the ArgoCD Application (once) or add it to the homelab-gitops app-of-apps.
|
- **CI auth:** dedicated PAT `CI_TOKEN` (repo Actions secret; `write:package` + `write:repository`).
|
||||||
|
|
||||||
|
**One-time setup (all done):** repo on Gitea; runner registered (global/instance, on `gitea_network`,
|
||||||
|
correct instance URL); `CI_TOKEN` secret; OpenBao `cluster/bluecap-registry`; ArgoCD Application
|
||||||
|
applied. Deploys are now push-to-main → CI → ArgoCD, hands-off.
|
||||||
|
|
||||||
## Cluster Findings
|
## Cluster Findings
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# Launch Checklist — Post-Cutover Status
|
||||||
|
|
||||||
|
`www.bluecapstrategies.com` went **live on the Astro rebuild on 2026-10-23**. This file is now the
|
||||||
|
single source of truth for what is running, how it is wired, and what is left. The pre-launch
|
||||||
|
runbook it replaced is in git history (`git log -- docs/launch-checklist.md`, before 2026-10-23).
|
||||||
|
|
||||||
|
Status legend: `[ ]` open · `[x]` done · `[?]` needs verification · 🛑 owner decision
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. As-built state (2026-10-23)
|
||||||
|
|
||||||
|
| Thing | State |
|
||||||
|
|-------|-------|
|
||||||
|
| Public site | **https://www.bluecapstrategies.com** — canonical. Apex 301s to www, path preserved |
|
||||||
|
| Old WordPress / Bluehost | **Gone.** Access lost and it was returning 404 before cutover; nothing to roll back to |
|
||||||
|
| DNS | Cloudflare, **DNS-only (grey cloud)** for apex, `www`, `stats` → `23.94.111.133` |
|
||||||
|
| Edge | Pangolin VPS 23.94.111.133 (Traefik + CrowdSec), org `rhh-homelab`, site `7tl_lab` |
|
||||||
|
| Pangolin resources | `Bluecap-www`, `Bluecap-base`, `stats` — 3 https targets `10.66.15.30/.31/.32:443` each |
|
||||||
|
| Edge certs | Let's Encrypt HTTP-01 via Pangolin's `letsencrypt` resolver, issued 2026-10-23 |
|
||||||
|
| Cluster certs | cert-manager `letsencrypt-prod` DNS-01: `bluecap-website-tls` (www + apex), `bcs-int-tls` |
|
||||||
|
| ArgoCD | `bcs-website` and `umami` — Synced / Healthy |
|
||||||
|
| Deployed image | tracks `main` via CI tag-bump (`ci: deploy <sha>` commits) |
|
||||||
|
| Staging | https://bcs.int.7tl-homelab.com — LAN only |
|
||||||
|
| Contact form | Formspree, verified end to end 2026-10-09 |
|
||||||
|
| Analytics | **Umami** (self-hosted, `stats.bluecapstrategies.com`) + **GA4** `G-Z273QL1F0N`, both verified recording 2026-10-23 |
|
||||||
|
| Monitoring | monylog blackbox probes on www + apex → Alertmanager → **Pushover**; UptimeRobot (external) → **Pushover** |
|
||||||
|
| Search | Google Search Console domain property verified; sitemap submitted and fetched by Googlebot |
|
||||||
|
| Mail | MXroute. SPF pass, DKIM pass (selector `x`), DMARC `p=none` — mail-tester 9.5/10 |
|
||||||
|
| `npm run build` / `typecheck` / `seo:lint` | green, 21 pages |
|
||||||
|
| `npm run test:e2e` | **48/48 green** |
|
||||||
|
|
||||||
|
### Traffic path
|
||||||
|
```
|
||||||
|
visitor → Cloudflare DNS (DNS-only) → Pangolin VPS 23.94.111.133 (TLS #1, CrowdSec)
|
||||||
|
→ gerbil/newt tunnel → k3s traefik 10.66.15.30/.31/.32 :443 (TLS #2)
|
||||||
|
→ bluecap-website (nginx, 2 replicas) ns bluecap-strategies
|
||||||
|
→ umami (+ umami-postgres) ns umami
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Open — do soon
|
||||||
|
|
||||||
|
### 1.1 External uptime check ✅ 2026-10-23
|
||||||
|
monylog runs inside the homelab, so a homelab power or internet outage takes the site **and** its
|
||||||
|
monitor down together, and nothing pages. Needs a vantage point outside the homelab.
|
||||||
|
- [x] **UptimeRobot** (free tier, 5-min interval) probes `https://www.bluecapstrategies.com/`.
|
||||||
|
- [x] Alerts go to Pushover via a separate *email alert contact* (the `@pomail.net` gateway
|
||||||
|
address), not the account email, so password resets etc. stay in the normal inbox.
|
||||||
|
- Reading alerts: UptimeRobot **and** monylog both firing = site/VPS problem; UptimeRobot
|
||||||
|
alone (monylog silent) usually means the homelab itself is down.
|
||||||
|
|
||||||
|
### 1.2 Homelab intercepts port-53 DNS
|
||||||
|
Found 2026-10-23. Queries from the homelab — including k3s nodes — to *any* DNS server, even
|
||||||
|
Cloudflare's authoritative NS, are answered by a local recursive resolver (`dig @arely.ns.cloudflare.com`
|
||||||
|
returns `rd ra` with no `aa`). A name looked up before its record exists gets **NXDOMAIN cached for
|
||||||
|
30 minutes** (the zone's SOA negative TTL).
|
||||||
|
|
||||||
|
This, not Cloudflare, explains the 4-hour cert-manager DNS-01 stall of 2026-10-09 and the
|
||||||
|
`stats` NXDOMAIN at launch. Consequences:
|
||||||
|
- Pointing cert-manager at `1.1.1.1/8.8.8.8` (the fix previously suggested here) **will not help**
|
||||||
|
while port 53 is intercepted.
|
||||||
|
- To see true public DNS, query from the VPS or via DoH:
|
||||||
|
`curl -H 'accept: application/dns-json' 'https://cloudflare-dns.com/dns-query?name=<name>&type=A'`
|
||||||
|
- Create DNS records *before* anything in the homelab (cert-manager included) looks the name up.
|
||||||
|
|
||||||
|
- [ ] 🛑 Owner decision: leave as is, or exempt the k3s nodes from the redirect. The redirect rule
|
||||||
|
is not in `~/Homelab/mikrotik/*.rsc`; find it on the router (or Pi-hole/AdGuard, if any).
|
||||||
|
- [x] `umami-stats-tls` (internal hop for `stats`) stalled on the stale NXDOMAIN at launch and
|
||||||
|
issued by itself once the cache expired (~18:35 UTC, 2026-10-23) — the expected pattern.
|
||||||
|
|
||||||
|
### 1.3 Gitea runner loses Docker after a dockerd restart
|
||||||
|
`dockerd` on `gitea-1` runs with `live-restore=true`. When it restarts, containers keep running
|
||||||
|
but `act_runner`'s bind mount of `/var/run/docker.sock` points at the dead socket, so every CI
|
||||||
|
job fails in its first second with `Cannot connect to the Docker daemon`. Happened 2026-10-21 →
|
||||||
|
discovered 2026-10-23.
|
||||||
|
- **Workaround:** `sudo docker restart act_runner` on gitea-1.
|
||||||
|
- [x] Durable fix in the `gitea` ansible role (2026-10-23): a `docker.service` drop-in
|
||||||
|
(`/etc/systemd/system/docker.service.d/restart-act-runner.conf`, template
|
||||||
|
`docker-restart-act-runner.conf.j2`) whose `ExecStartPost` restarts `act_runner` whenever
|
||||||
|
dockerd starts. Live-restore stays on, so Gitea itself keeps running through dockerd restarts.
|
||||||
|
- [ ] Apply it: run `gitea-deploy.yml` (daemon-reload only, nothing restarts), then confirm with
|
||||||
|
`systemctl show docker -p DropInPaths` on gitea-1. Optional proof, when no CI job is running:
|
||||||
|
`sudo systemctl restart docker` and check `docker inspect -f '{{.State.StartedAt}}' act_runner`
|
||||||
|
moved forward.
|
||||||
|
|
||||||
|
### 1.4 `/insights/` ✅ 2026-10-23
|
||||||
|
Now a reader-facing "Coming soon" page (hero + contact CTA); the developer-facing cards are gone.
|
||||||
|
It stays in the nav. When the first real article lands, restore a listing on this page.
|
||||||
|
|
||||||
|
### 1.5 Privacy policy ✅ 2026-10-23
|
||||||
|
`/privacy/` reviewed and accepted by the owner. No consent banner (owner's call).
|
||||||
|
Anything added to `src/components/Analytics.astro` must be disclosed on `/privacy/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Should fix
|
||||||
|
|
||||||
|
### 2.1 e2e suite ✅ green 2026-10-23 — 48/48
|
||||||
|
The stale assertions (invented copy removed by the exact-copy work) now check the real headings
|
||||||
|
and use the header Services link; the homepage body has no direct `/services/` link.
|
||||||
|
|
||||||
|
### 2.2 Repo hygiene ✅ 2026-10-23
|
||||||
|
Commit `9f1cbca` had committed 51 `.playwright-mcp/` screenshots/snapshots plus a 2.8 MB
|
||||||
|
`home-full.png` at the repo root. Now untracked (files kept on disk) and ignored:
|
||||||
|
`.gitignore` has `.playwright-mcp/` and `/*.png`; `.dockerignore` also drops them plus
|
||||||
|
`planning/` and `tests/` from the build context. History was not rewritten (`.git` is 17 MB —
|
||||||
|
not worth a force-push).
|
||||||
|
|
||||||
|
### 2.3 DMARC tightening
|
||||||
|
- [ ] Tighten DMARC from `p=none` to `p=quarantine` after a few weeks of clean reports
|
||||||
|
to info@bluecapstrategies.com.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Owner decisions (Brad)
|
||||||
|
|
||||||
|
- [x] **Canonical host** — `www` (2026-10-23). Apex 301s via Traefik middleware in `k8s/ingress.yaml`.
|
||||||
|
- [x] **Analytics** — Umami + GA4 side by side (2026-10-23).
|
||||||
|
- [ ] 🛑 **Contact details** — confirm `804-332-6618` / `info@bluecapstrategies.com` are final.
|
||||||
|
- [x] **AI training-bot policy** — training crawlers blocked (2026-10-23): GPTBot, ClaudeBot,
|
||||||
|
Google-Extended, CCBot, Applebot-Extended, meta-externalagent, Bytespider. Search and
|
||||||
|
AI answer/search crawlers (Googlebot, Bingbot, OAI-SearchBot, Claude-SearchBot, …) stay allowed.
|
||||||
|
- [ ] 🛑 **Brad Rodgers bio + credentials + portrait** — gates `Person` schema, the About
|
||||||
|
buildout, and principal-led E-E-A-T/GEO positioning.
|
||||||
|
- [ ] 🛑 **Real insights articles** — no fabricated or unreviewed AI content. `/insights/` is
|
||||||
|
"Coming soon" until the first one exists (§1.4).
|
||||||
|
- [ ] 🛑 **Social profiles** — provide real profiles or omit the footer block.
|
||||||
|
- [ ] 🛑 **Proof modules / representative engagements** — no fabricated client work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Post-launch improvements (not blocking)
|
||||||
|
|
||||||
|
- [ ] Bing Webmaster Tools — import from Search Console (covers Bing, DuckDuckGo, ChatGPT search).
|
||||||
|
- [ ] Check Search Console in ~1 week: sitemap "Success", pages indexed, no 5xx in Coverage.
|
||||||
|
- [x] Favicon restored from the original WordPress site icon via the Wayback Machine (2026-10-23):
|
||||||
|
`favicon.ico` (16/32/48), `favicon-32x32.png`, `icon-192.png`, `apple-touch-icon.png` (180),
|
||||||
|
`icon-270.png` in `public/`. Previously the wide logo was used, and `/favicon.ico` 404'd.
|
||||||
|
- [ ] Per-page 1200×630 OG images; `BaseLayout.astro` emits one shared fallback `og:image`.
|
||||||
|
- [ ] `robots` meta wired to a `noindex` frontmatter flag. `/thank-you/` is excluded from the
|
||||||
|
sitemap but emits no `noindex` meta tag.
|
||||||
|
- [ ] `article:published_time` / `article:modified_time` OG tags on insights posts.
|
||||||
|
- [ ] `lastmod` in the sitemap from content dates.
|
||||||
|
- [x] Lighthouse ≥ 95 (2026-10-23). Before, live mobile: Perf 82–86, A11y 95, SEO 92–100, LCP 4.3–4.9 s.
|
||||||
|
After (local build, mobile): Perf 96–99, A11y 100, SEO 100, LCP 1.8–2.4 s. Responsive AVIF/WebP via
|
||||||
|
`scripts/optimize-images.mjs` (runs in `npm run build`) + `components/Picture.astro` + a rehype plugin
|
||||||
|
for Markdown images; contrast fixes on the footer CTA and the active service-nav item; homepage
|
||||||
|
"Learn More" gets screen-reader context. New photos need no extra step: anything in
|
||||||
|
`public/assets/live-site/` gets variants; render it with `<Picture>` (or Markdown) to use them.
|
||||||
|
- [ ] Umami/GA custom event for contact-form submits (today the `/thank-you/` pageview is the
|
||||||
|
conversion signal).
|
||||||
|
- [ ] Optional: move `bluecapstrategies.com` into Pangolin's `config.yml` `domains:` so it shows
|
||||||
|
"Config Managed" like the other three (needs a Pangolin restart; purely cosmetic).
|
||||||
|
- [ ] Deferred: VPS static failover for homelab outages (see memory note / earlier discussion).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Launch-day record (2026-10-23)
|
||||||
|
|
||||||
|
What happened, so it does not have to be rediscovered.
|
||||||
|
|
||||||
|
- **Pangolin certs need a real config change to retry.** HTTP-01 failed while `www` was still
|
||||||
|
proxied (Let's Encrypt reached Cloudflare's IPv6, got 404) and hit the 5-failures/hour limit.
|
||||||
|
Clicking Save with no changes does nothing; toggling **Enable Resource** off/on (saving between)
|
||||||
|
re-adds the router and triggers issuance.
|
||||||
|
- **Pangolin health checks** probe the target IP with no hostname, which k3s traefik answers with
|
||||||
|
404. Set the health check's Host header and TLS server name to the resource's hostname, path
|
||||||
|
`/` (or `/api/heartbeat` for Umami), expect 200.
|
||||||
|
- **nginx 404 loop** — `error_page` pointed at `/404/index.html` but Astro emits `404.html`, so
|
||||||
|
every unknown URL returned 500. Fixed in `d1c1726`; unknown URLs now return a real 404.
|
||||||
|
- **CI tag-bump push rejected** when `main` moved during a build (or on a re-run of an older
|
||||||
|
commit). The step now rebases onto `origin/main` and retries (`93415c6`). Do not "re-run" old
|
||||||
|
runs to deploy — push instead.
|
||||||
|
- **Cloudflare cleanup** — removed all Bluehost/cPanel leftovers (autoconfig/autodiscover,
|
||||||
|
cpanel/whm/webdisk/ftp, cal/carddav SRV, `localhost`), replaced Bluehost's `default._domainkey`
|
||||||
|
with MXroute's `x._domainkey`, added `_dmarc`.
|
||||||
|
- **Monitoring safety** — the monylog playbook only renders Pushover routing when `VAULT_TOKEN`
|
||||||
|
is set; without it Alertmanager silently falls back to ntfy. Always run it with the token.
|
||||||
|
- **Umami** ships with `admin` / `umami`. It was brought up LAN-only
|
||||||
|
(`umami.int.7tl-homelab.com`), the password changed, and only then exposed via Pangolin.
|
||||||
|
Manifests live in `homelab-gitops` (`apps/umami.yaml`, `analytics/umami/`); credentials in
|
||||||
|
OpenBao `cluster/umami`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Already done — do not redo
|
||||||
|
|
||||||
|
- [x] Full copy parity with the WordPress site, verbatim in content collections (ADR-0002).
|
||||||
|
- [x] Real imagery in `public/assets/live-site/`; placeholders deleted.
|
||||||
|
- [x] Zod `seoSchema`; `scripts/seo-lint.mjs` (`npm run seo:lint`).
|
||||||
|
- [x] `BreadcrumbList` schema across detail pages.
|
||||||
|
- [x] Per-agent `public/robots.txt`; `@astrojs/sitemap` excluding `/thank-you/`.
|
||||||
|
- [x] Draft insights excluded from routes and sitemap.
|
||||||
|
- [x] Contact form: build-time Formspree endpoint, background submit, honeypot, failure fallback.
|
||||||
|
- [x] GitOps: Gitea Actions CI → Gitea Packages → tag-bump commit → ArgoCD (ADR-0007, ADR-0008).
|
||||||
|
- [x] CI job image `node:22-bookworm`; docker CLI + kustomize as pinned static binaries.
|
||||||
|
- [x] Production TLS in-cluster (DNS-01) and at the edge (HTTP-01).
|
||||||
|
- [x] Canonical www + apex 301; real 404s.
|
||||||
|
- [x] Monitoring: blackbox probes + `WebsiteDown` / `WebsiteCertExpiringSoon` / `WebsiteSlow` /
|
||||||
|
`BlackboxExporterDown` rules in the monylog role (ADR-0004: monylog, not Uptime Kuma).
|
||||||
|
- [x] Analytics: Umami + GA4, production-host only; `/privacy/` page.
|
||||||
|
- [x] Search Console verified; sitemap submitted.
|
||||||
|
- [x] Mail authentication: SPF, DKIM, DMARC passing.
|
||||||
@@ -80,15 +80,21 @@ Already implemented in `src/layouts/BaseLayout.astro` (keep + enforce):
|
|||||||
|
|
||||||
To add/verify:
|
To add/verify:
|
||||||
|
|
||||||
- [ ] **Per-page OG images** — currently every page falls back to `/assets/water-strategy.jpg`.
|
> **Tracked in [`launch-checklist.md`](./launch-checklist.md) §4.** The list that used to live
|
||||||
Generate topic-specific OG images (services, focus areas, insights) at 1200×630.
|
> here went stale (breadcrumbs are done). This section keeps the *reference* detail below; the
|
||||||
- [ ] **`robots` meta** wired to the `noindex` frontmatter flag (for `/thank-you/`, staging).
|
> live task state is in the launch checklist only.
|
||||||
- [ ] **`article:published_time` / `modified_time`** OG tags on insights posts.
|
|
||||||
- [ ] **Breadcrumbs** (visible + `BreadcrumbList` schema) on services/focus/insights detail pages.
|
- **Per-page OG images** — every page currently falls back to one shared image
|
||||||
- [ ] **Sitemap** already via `@astrojs/sitemap` (excludes `/thank-you/`); add `lastmod` from
|
(`BaseLayout.astro` emits a single `og:image`). Generate topic-specific 1200×630 images for
|
||||||
content dates for insights.
|
services, focus areas, and insights.
|
||||||
- [ ] **Trailing-slash consistency** — config uses `trailingSlash: "always"`; ensure canonical +
|
- **`robots` meta** wired to a `noindex` frontmatter flag (for `/thank-you/`, staging). Not wired
|
||||||
internal links all match to avoid duplicate-URL dilution.
|
today — `/thank-you/` is sitemap-excluded but emits no `noindex`.
|
||||||
|
- **`article:published_time` / `modified_time`** OG tags on insights posts.
|
||||||
|
- **Breadcrumbs** — DONE. `BreadcrumbList` in `src/utils/schema.ts`, applied on detail pages.
|
||||||
|
- **Sitemap** — via `@astrojs/sitemap`, excludes `/thank-you/`. Still to add: `lastmod` from
|
||||||
|
content dates for insights.
|
||||||
|
- **Trailing-slash consistency** — config uses `trailingSlash: "always"`; ensure canonical +
|
||||||
|
internal links all match to avoid duplicate-URL dilution.
|
||||||
|
|
||||||
## 3. Structured data (JSON-LD) matrix
|
## 3. Structured data (JSON-LD) matrix
|
||||||
|
|
||||||
@@ -214,16 +220,14 @@ Decided: **run PostHog and Umami side-by-side initially** and keep whichever fit
|
|||||||
|
|
||||||
## 7. Pre-launch SEO checklist
|
## 7. Pre-launch SEO checklist
|
||||||
|
|
||||||
- [ ] Zod SEO schema in place; build fails on title/description/OG/canonical violations.
|
> **Moved.** This list is no longer maintained here — it duplicated, and disagreed with, reality
|
||||||
- [ ] `scripts/seo-lint.mjs` (single H1, heading order, canonical, alt text, JSON-LD parses) green in CI.
|
> (it listed the Zod schema, `seo-lint`, and breadcrumbs as open when all three are done, and
|
||||||
- [ ] Every page: unique title, description in window, canonical, OG image, valid JSON-LD.
|
> called for Uptime Kuma, which ADR-0004 replaced with `monylog`).
|
||||||
- [ ] Organization/WebSite/Service/Person/Article/Breadcrumb schema per §3, all visible-content-backed.
|
>
|
||||||
- [ ] `robots.txt` per §5 (owner-decided); `sitemap-index.xml` correct; `/thank-you/` + staging `noindex`.
|
> **The maintained checklist is [`launch-checklist.md`](./launch-checklist.md).** SEO items are
|
||||||
- [ ] Canonical host decided (apex vs `www`) + redirect configured at the edge.
|
> in §3 (owner decisions: canonical host, robots policy) and §4 (post-cutover: OG images,
|
||||||
- [ ] Search Console + Bing verified, sitemap submitted.
|
> `noindex` meta, article times, `lastmod`, Lighthouse, trailing slashes). What is already
|
||||||
- [ ] Analytics deployed (Umami) + consultation/CTA events firing.
|
> complete is recorded in §5.
|
||||||
- [ ] Uptime Kuma + external check live and alerting.
|
|
||||||
- [ ] Lighthouse ≥ 95 across the board; no CLS from unsized images.
|
|
||||||
|
|
||||||
## Sources (verified 2026-07-15)
|
## Sources (verified 2026-07-15)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
# NOTE: this ConfigMap is NOT consumed by the Deployment, and wiring it up would not help.
|
||||||
|
# The site is a STATIC Astro build served by nginx — every PUBLIC_* value is inlined into the HTML
|
||||||
|
# when the image is built, so a runtime env var cannot change it. Configure these as build args in
|
||||||
|
# .gitea/workflows/ci.yml instead (PUBLIC_CONTACT_FORM_ENDPOINT comes from the CONTACT_FORM_ENDPOINT
|
||||||
|
# Actions secret).
|
||||||
|
#
|
||||||
|
# Kept as documentation of the values the build expects. Do not add envFrom pointing at it — that
|
||||||
|
# would imply a runtime knob that does not exist.
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
name: bluecap-website-config
|
name: bluecap-website-config
|
||||||
namespace: bluecap-strategies
|
namespace: bluecap-strategies
|
||||||
data:
|
data:
|
||||||
|
# Mirrors the build arg default in the Dockerfile.
|
||||||
PUBLIC_SITE_URL: "https://www.bluecapstrategies.com"
|
PUBLIC_SITE_URL: "https://www.bluecapstrategies.com"
|
||||||
# Set to a Formspree, webhook, ntfy, or self-hosted form endpoint before production cutover.
|
# Real value lives in the CONTACT_FORM_ENDPOINT Actions secret, injected at image build.
|
||||||
PUBLIC_CONTACT_FORM_ENDPOINT: ""
|
PUBLIC_CONTACT_FORM_ENDPOINT: ""
|
||||||
@@ -1,3 +1,14 @@
|
|||||||
|
# Two audiences, deliberately separated:
|
||||||
|
#
|
||||||
|
# 1. bcs.int.7tl-homelab.com — STAGING. LAN-only (*.int resolves to the internal traefik LB at
|
||||||
|
# 10.66.15.30) with a real Let's Encrypt cert. This is how the rebuild is reviewed before
|
||||||
|
# launch. Safe: it is not reachable from the internet and does not touch the real domain.
|
||||||
|
#
|
||||||
|
# 2. www.bluecapstrategies.com — PRODUCTION (live since 2026-10-23, via Pangolin). The apex
|
||||||
|
# bluecapstrategies.com only 301s to www (separate ingress at the bottom of this file).
|
||||||
|
#
|
||||||
|
# TLS: ClusterIssuer is `letsencrypt-prod` (NOTE: not "letsencrypt-production"). It solves via
|
||||||
|
# DNS-01 against Cloudflare, so certs for these names issue independently of where DNS points.
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
@@ -5,16 +16,40 @@ metadata:
|
|||||||
namespace: bluecap-strategies
|
namespace: bluecap-strategies
|
||||||
annotations:
|
annotations:
|
||||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
# If cert-manager is installed, uncomment and set your ClusterIssuer.
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
# cert-manager.io/cluster-issuer: letsencrypt-production
|
|
||||||
spec:
|
spec:
|
||||||
# If cert-manager is installed, uncomment tls and set the secret name.
|
tls:
|
||||||
# tls:
|
- hosts:
|
||||||
# - hosts:
|
- bcs.int.7tl-homelab.com
|
||||||
# - www.bluecapstrategies.com
|
secretName: bcs-int-tls
|
||||||
# - bluecapstrategies.com
|
# Production hostnames. This is the INTERNAL hop only: public traffic terminates TLS at the
|
||||||
# secretName: bluecap-website-tls
|
# Pangolin VPS, which then proxies to these nodes over https:443 (the pattern every other
|
||||||
|
# k3s-backed Pangolin resource uses), so the cluster needs its own valid cert for these names.
|
||||||
|
#
|
||||||
|
# Safe to issue before cutover and deliberately done so: letsencrypt-prod solves DNS-01 via
|
||||||
|
# Cloudflare, which only needs a temporary TXT record in the zone — no A record has to point
|
||||||
|
# here, and the live WordPress site is unaffected. Pangolin's own edge cert is HTTP-01 and
|
||||||
|
# CANNOT be pre-issued; it is obtained on the first request after DNS moves. See
|
||||||
|
# docs/launch-checklist.md section 1.1.
|
||||||
|
#
|
||||||
|
# Both names stay on this one cert. www is canonical; the apex is served by the
|
||||||
|
# redirect ingress below, which reuses this secret (no cert-manager annotation there, so
|
||||||
|
# the two ingresses never compete over the Certificate).
|
||||||
|
- hosts:
|
||||||
|
- www.bluecapstrategies.com
|
||||||
|
- bluecapstrategies.com
|
||||||
|
secretName: bluecap-website-tls
|
||||||
rules:
|
rules:
|
||||||
|
- host: bcs.int.7tl-homelab.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: bluecap-website
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
- host: www.bluecapstrategies.com
|
- host: www.bluecapstrategies.com
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
@@ -25,6 +60,34 @@ spec:
|
|||||||
name: bluecap-website
|
name: bluecap-website
|
||||||
port:
|
port:
|
||||||
name: http
|
name: http
|
||||||
|
---
|
||||||
|
# Canonical host is www (2026-10-23). The apex answers only with a permanent redirect to the
|
||||||
|
# same path on www, so search engines consolidate on one host.
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: redirect-apex-to-www
|
||||||
|
namespace: bluecap-strategies
|
||||||
|
spec:
|
||||||
|
redirectRegex:
|
||||||
|
regex: ^https?://bluecapstrategies\.com/(.*)
|
||||||
|
replacement: https://www.bluecapstrategies.com/${1}
|
||||||
|
permanent: true
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: bluecap-website-apex-redirect
|
||||||
|
namespace: bluecap-strategies
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: bluecap-strategies-redirect-apex-to-www@kubernetescrd
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- bluecapstrategies.com
|
||||||
|
secretName: bluecap-website-tls
|
||||||
|
rules:
|
||||||
- host: bluecapstrategies.com
|
- host: bluecapstrategies.com
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
- configmap.yaml
|
- configmap.yaml
|
||||||
- externalsecret-registry.yaml
|
- externalsecret-registry.yaml
|
||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- service.yaml
|
- service.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
|
|
||||||
# CI updates newTag to the immutable git-sha via `kustomize edit set image` (see .gitea/workflows/ci.yml).
|
# CI updates newTag to the immutable git-sha via `kustomize edit set image` (see .gitea/workflows/ci.yml).
|
||||||
# Name is the public HTTPS pull endpoint that k3s uses.
|
# Name is the public HTTPS pull endpoint that k3s uses.
|
||||||
images:
|
images:
|
||||||
- name: git.7tl-homelab.com/bmr_bluecap/bcs-website
|
- name: git.7tl-homelab.com/bmr_bluecap/bcs-website
|
||||||
newTag: latest
|
newName: git.7tl-homelab.com/bmr_bluecap/bcs-website
|
||||||
|
newTag: 550392aa17c5
|
||||||
@@ -10,12 +10,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/rss": "^4.0.12",
|
"@astrojs/rss": "^4.0.12",
|
||||||
"@astrojs/sitemap": "^3.6.0",
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/open-sans": "^5.3.0",
|
||||||
|
"@fontsource-variable/work-sans": "^5.3.0",
|
||||||
|
"@fontsource/poppins": "^5.3.0",
|
||||||
"astro": "^5.9.2"
|
"astro": "^5.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.4",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
|
"sharp": "0.34.5",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -729,12 +734,48 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/inter": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/open-sans": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/open-sans/-/open-sans-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-VOfhZfLIOBVa6K/dTEu7scqQXYdGa4jBX8quFpQ0S6574B+W2EOBJf9PkqyVtMEI7WWIWNEbpvPc3vY1mN2mmQ==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/work-sans": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/work-sans/-/work-sans-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-le8h/OIIuhm5Z7TdvuMw0yKg2gaCZUiMec8zaumsVVjzeJRBPEF0bQI/MkaZDHCVzZDvZoP/O2LiGOSfDLSbaw==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/poppins": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-cms1nM7U6SN8epIV1WMVOV8VsA645aSm3wHfzIQnkk188U341ThbJGSl2Sv54V29gnDbT2arMPfLzvVIIVCceQ==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@img/colour": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -2586,8 +2627,8 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -4912,9 +4953,9 @@
|
|||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev --host 0.0.0.0",
|
"dev": "npm run images && ASTRO_TELEMETRY_DISABLED=1 astro dev --host 0.0.0.0",
|
||||||
"build": "ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build",
|
"build": "npm run images && ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build",
|
||||||
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview --host 0.0.0.0",
|
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview --host 0.0.0.0",
|
||||||
|
"images": "node scripts/optimize-images.mjs",
|
||||||
"typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check",
|
"typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check",
|
||||||
"lint": "ASTRO_TELEMETRY_DISABLED=1 astro check",
|
"lint": "ASTRO_TELEMETRY_DISABLED=1 astro check",
|
||||||
"seo:lint": "node scripts/seo-lint.mjs",
|
"seo:lint": "node scripts/seo-lint.mjs",
|
||||||
@@ -16,12 +17,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/rss": "^4.0.12",
|
"@astrojs/rss": "^4.0.12",
|
||||||
"@astrojs/sitemap": "^3.6.0",
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/open-sans": "^5.3.0",
|
||||||
|
"@fontsource-variable/work-sans": "^5.3.0",
|
||||||
|
"@fontsource/poppins": "^5.3.0",
|
||||||
"astro": "^5.9.2"
|
"astro": "^5.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.4",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
|
"sharp": "0.34.5",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -10,7 +10,7 @@ Public sections:
|
|||||||
- /focus-areas/: Core focus areas.
|
- /focus-areas/: Core focus areas.
|
||||||
- /about-us/: Firm vision, mission, values, and approach.
|
- /about-us/: Firm vision, mission, values, and approach.
|
||||||
- /contact/: Contact form, phone, and email.
|
- /contact/: Contact form, phone, and email.
|
||||||
- /insights/: Prepared future publishing section.
|
- /insights/: Articles and analysis (coming soon).
|
||||||
|
|
||||||
This file is supplemental. The complete public content is available in crawlable HTML pages with
|
This file is supplemental. The complete public content is available in crawlable HTML pages with
|
||||||
metadata and structured data.
|
metadata and structured data.
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
# BlueCap Strategies robots.txt — see docs/seo-aeo-optimization.md §5
|
# BlueCap Strategies robots.txt — see docs/seo-aeo-optimization.md §5
|
||||||
# Policy: allow classic search + AI answer/search crawlers for maximum discoverability and
|
# Policy: allow classic search + AI answer/search crawlers for maximum discoverability and
|
||||||
# answer-engine citation. Training-crawler policy (GPTBot, ClaudeBot, Google-Extended) is an
|
# answer-engine citation. Model-TRAINING crawlers (GPTBot, ClaudeBot, Google-Extended, …) are BLOCKED (owner decision, 2026-10-23).
|
||||||
# owner decision — currently ALLOWED for maximum reach. To opt out of model training, uncomment
|
|
||||||
# the "Disallow: /" lines in the training-crawler block below.
|
|
||||||
|
|
||||||
# --- Classic search engines ---
|
# --- Classic search engines ---
|
||||||
User-agent: Googlebot
|
User-agent: Googlebot
|
||||||
@@ -27,19 +25,29 @@ Allow: /
|
|||||||
User-agent: PerplexityBot
|
User-agent: PerplexityBot
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
# Google-Extended controls Gemini + Google AI Overviews (this is also a training signal for
|
# --- AI model-training crawlers — blocked (owner decision, 2026-10-23) ---
|
||||||
# Google — treated as answer-visibility here; move to the training block to opt out).
|
|
||||||
User-agent: Google-Extended
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
# --- AI model-training crawlers (owner decision — allowed by default) ---
|
|
||||||
User-agent: GPTBot
|
User-agent: GPTBot
|
||||||
Allow: /
|
Disallow: /
|
||||||
# Disallow: /
|
|
||||||
|
|
||||||
User-agent: ClaudeBot
|
User-agent: ClaudeBot
|
||||||
Allow: /
|
Disallow: /
|
||||||
# Disallow: /
|
|
||||||
|
User-agent: CCBot
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Applebot-Extended
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: meta-externalagent
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: Bytespider
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
# Google-Extended is Google's opt-out for Gemini training/grounding only. Google Search and AI
|
||||||
|
# Overviews are governed by Googlebot (allowed above), so blocking this does not affect search.
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
# --- Everything else ---
|
# --- Everything else ---
|
||||||
User-agent: *
|
User-agent: *
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Pre-generates responsive AVIF + WebP variants of the site's raster images, plus a manifest the
|
||||||
|
// components read (src/utils/images.ts, the rehype plugin in astro.config.mjs).
|
||||||
|
//
|
||||||
|
// Why not astro:assets: every image here is referenced by a /public path string — from pages,
|
||||||
|
// content-collection frontmatter, and Markdown bodies — and og:image needs those stable JPEG URLs.
|
||||||
|
// This keeps the originals untouched (fallback + social cards) and adds the modern formats beside them.
|
||||||
|
//
|
||||||
|
// Output (generated, gitignored): public/assets/opt/*.{avif,webp} and src/data/image-manifest.json.
|
||||||
|
// File names carry a content hash, so nginx's 1-year immutable cache on /assets can never serve a
|
||||||
|
// stale variant after a source image changes. Existing outputs are reused, so reruns are fast.
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||||
|
const publicDir = path.join(root, "public");
|
||||||
|
const outDir = path.join(publicDir, "assets", "opt");
|
||||||
|
const manifestPath = path.join(root, "src", "data", "image-manifest.json");
|
||||||
|
|
||||||
|
// Sources: every photo in live-site/ (not icons/ — tiny PNGs, nothing to gain) and the logo.
|
||||||
|
const sources = [
|
||||||
|
...(await readdir(path.join(publicDir, "assets", "live-site")))
|
||||||
|
.filter((f) => /\.(jpe?g|png)$/i.test(f))
|
||||||
|
.map((f) => `/assets/live-site/${f}`),
|
||||||
|
"/assets/bluecap-logo.png",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Candidate widths; any at or above the original's width are replaced by the original width.
|
||||||
|
const widths = [480, 800, 1200, 1600];
|
||||||
|
// The logo only ever renders at <=190px wide, so one 2x-retina size is enough. Keyed by file name:
|
||||||
|
// live-site/ also holds a copy of it.
|
||||||
|
const widthOverrides = { "bluecap-logo.png": [480] };
|
||||||
|
// Tuned 2026-10-23 on the busiest photo (img_0062): AVIF q42 is visually indistinguishable from
|
||||||
|
// a q90 JPEG at 800px and ~half the bytes of WebP; effort 2 encodes ~2x faster than 4 for ~3%
|
||||||
|
// more bytes, which keeps CI builds quick.
|
||||||
|
const formats = {
|
||||||
|
avif: { quality: 42, effort: 2 },
|
||||||
|
webp: { quality: 65 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
const manifest = {};
|
||||||
|
|
||||||
|
for (const src of sources) {
|
||||||
|
const input = await readFile(path.join(publicDir, src));
|
||||||
|
const { width, height } = await sharp(input).metadata();
|
||||||
|
const hash = createHash("sha256").update(input).digest("hex").slice(0, 8);
|
||||||
|
const base = path.basename(src).replace(/\.[^.]+$/, "");
|
||||||
|
const sizes = widthOverrides[path.basename(src)] ?? [...new Set([...widths.filter((w) => w < width), width])];
|
||||||
|
|
||||||
|
const entry = { width, height, avif: [], webp: [] };
|
||||||
|
for (const w of sizes) {
|
||||||
|
for (const [format, options] of Object.entries(formats)) {
|
||||||
|
const file = `${base}.${hash}-${w}.${format}`;
|
||||||
|
const dest = path.join(outDir, file);
|
||||||
|
if (!existsSync(dest)) {
|
||||||
|
await sharp(input).resize({ width: w }).toFormat(format, options).toFile(dest);
|
||||||
|
}
|
||||||
|
entry[format].push({ src: `/assets/opt/${file}`, width: w });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifest[src] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
console.log(`optimize-images: ${sources.length} images -> ${path.relative(root, outDir)}`);
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
---
|
---
|
||||||
/**
|
/**
|
||||||
* Side-by-side visitor analytics (ADR-0005): PostHog + Umami, each gated by env vars so this is a
|
* Side-by-side visitor analytics, each gated by env vars so this is a no-op until configured.
|
||||||
* no-op until configured. Set the PUBLIC_* vars at build time to enable either or both.
|
* Set the PUBLIC_* vars at build time (Dockerfile ARGs, fed by CI) to enable any of them.
|
||||||
|
* - Umami: PUBLIC_UMAMI_SRC (script URL), PUBLIC_UMAMI_WEBSITE_ID — self-hosted, cookieless
|
||||||
|
* - GA4: PUBLIC_GA_MEASUREMENT_ID (G-XXXXXXXXXX)
|
||||||
* - PostHog: PUBLIC_POSTHOG_KEY, PUBLIC_POSTHOG_HOST (default https://us.i.posthog.com)
|
* - PostHog: PUBLIC_POSTHOG_KEY, PUBLIC_POSTHOG_HOST (default https://us.i.posthog.com)
|
||||||
* - Umami: PUBLIC_UMAMI_SRC (script URL), PUBLIC_UMAMI_WEBSITE_ID
|
* Umami and GA4 only fire on the production host, so staging/LAN/local visits are not counted.
|
||||||
* Track the same event set in both to compare (see docs/seo-aeo-optimization.md §6b).
|
* Anything enabled here must be disclosed on /privacy/.
|
||||||
*/
|
*/
|
||||||
const posthogKey = import.meta.env.PUBLIC_POSTHOG_KEY;
|
const posthogKey = import.meta.env.PUBLIC_POSTHOG_KEY;
|
||||||
const posthogHost = import.meta.env.PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
const posthogHost = import.meta.env.PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
||||||
const umamiSrc = import.meta.env.PUBLIC_UMAMI_SRC;
|
const umamiSrc = import.meta.env.PUBLIC_UMAMI_SRC;
|
||||||
const umamiWebsiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
|
const umamiWebsiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
|
||||||
|
const gaId = import.meta.env.PUBLIC_GA_MEASUREMENT_ID;
|
||||||
|
const prodHost = new URL(import.meta.env.PUBLIC_SITE_URL ?? "https://www.bluecapstrategies.com").hostname;
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -23,6 +27,23 @@ const umamiWebsiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
|
|||||||
|
|
||||||
{
|
{
|
||||||
umamiSrc && umamiWebsiteId && (
|
umamiSrc && umamiWebsiteId && (
|
||||||
<script is:inline defer src={umamiSrc} data-website-id={umamiWebsiteId} />
|
<script is:inline defer src={umamiSrc} data-website-id={umamiWebsiteId} data-domains={prodHost} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
gaId && (
|
||||||
|
<script is:inline define:vars={{ gaId, prodHost }}>
|
||||||
|
if (location.hostname === prodHost) {
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
window.gtag = function () { window.dataLayer.push(arguments); };
|
||||||
|
window.gtag("js", new Date());
|
||||||
|
window.gtag("config", gaId);
|
||||||
|
var s = document.createElement("script");
|
||||||
|
s.async = true;
|
||||||
|
s.src = "https://www.googletagmanager.com/gtag/js?id=" + encodeURIComponent(gaId);
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -10,19 +10,20 @@ type Props = {
|
|||||||
items: Item[];
|
items: Item[];
|
||||||
label?: string;
|
label?: string;
|
||||||
heading?: string;
|
heading?: string;
|
||||||
|
hideHeading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { items, label = "Related pages", heading } = Astro.props;
|
const { items, label = "Related pages", heading, hideHeading = false } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
{heading && <h2 class="card-grid-heading">{heading}</h2>}
|
{heading && <h2 class={hideHeading ? "sr-only" : "card-grid-heading"}>{heading}</h2>}
|
||||||
<div class="card-grid" aria-label={label}>
|
<div class="card-grid" aria-label={label}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<article class="link-card">
|
<article class="link-card">
|
||||||
{item.icon && <img class="card-icon" src={item.icon} alt="" aria-hidden="true" width="48" height="48" loading="lazy" />}
|
{item.icon && <img class="card-icon" src={item.icon} alt="" aria-hidden="true" width="48" height="48" loading="lazy" />}
|
||||||
<h3><a href={item.href}>{item.title}</a></h3>
|
<h3><a href={item.href}>{item.title}</a></h3>
|
||||||
<p>{item.description}</p>
|
<p>{item.description}</p>
|
||||||
<a class="card-action" href={item.href}>Learn more</a>
|
<a class="card-action" href={item.href}>Learn More ›</a>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
|
import Picture from "@components/Picture.astro";
|
||||||
import { nav, serviceNav, site } from "@data/site";
|
import { nav, serviceNav, site } from "@data/site";
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<section class="footer-brand" aria-label="BlueCap Strategies summary">
|
<section class="footer-brand" aria-label="BlueCap Strategies summary">
|
||||||
<img src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="190" height="57" />
|
<Picture src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="190" height="57" sizes="175px" loading="lazy" />
|
||||||
<p>
|
<p>
|
||||||
Transforming environmental challenges into investable opportunities through strategy,
|
Transforming environmental challenges into investable opportunities through strategy,
|
||||||
capital, and implementation support for aquatic resources.
|
capital, and implementation support for aquatic resources.
|
||||||
@@ -28,6 +29,6 @@ import { nav, serviceNav, site } from "@data/site";
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>(c) 2026 BlueCap Strategies, LLC. All rights reserved.</p>
|
<p>(c) 2026 BlueCap Strategies, LLC. All rights reserved. · <a href="/privacy/">Privacy Policy</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
|
import Picture from "@components/Picture.astro";
|
||||||
import { nav, serviceNav } from "@data/site";
|
import { nav, serviceNav } from "@data/site";
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<a class="brand" href="/" aria-label="BlueCap Strategies home">
|
<a class="brand" href="/" aria-label="BlueCap Strategies home">
|
||||||
<img src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="220" height="66" />
|
<Picture src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="220" height="66" sizes="190px" />
|
||||||
</a>
|
</a>
|
||||||
<nav class="desktop-nav" aria-label="Primary navigation">
|
<nav class="desktop-nav" aria-label="Primary navigation">
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
|
|||||||
@@ -1,26 +1,75 @@
|
|||||||
---
|
---
|
||||||
|
import Picture from "@components/Picture.astro";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
title: string;
|
title: string;
|
||||||
lead?: string;
|
lead?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
imageAlt?: string;
|
imageAlt?: string;
|
||||||
|
icon?: string;
|
||||||
|
variant?: "cover" | "text";
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
eyebrow,
|
eyebrow,
|
||||||
title,
|
title,
|
||||||
lead,
|
lead,
|
||||||
image = "/assets/live-site/img_0067.jpg",
|
image,
|
||||||
imageAlt = "Water resources and coastal landscape",
|
imageAlt = "Water resources and coastal landscape",
|
||||||
|
icon,
|
||||||
|
variant = "text",
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
const hasPhoto = Boolean(image);
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="page-hero">
|
{
|
||||||
<div class="hero-copy">
|
variant === "cover" ? (
|
||||||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
<section class="page-cover">
|
||||||
<h1>{title}</h1>
|
<Picture
|
||||||
{lead && <p>{lead}</p>}
|
class="hero-bg"
|
||||||
</div>
|
src={image ?? "/assets/live-site/hero-mangrove.jpg"}
|
||||||
<img src={image} alt={imageAlt} width="1280" height="854" loading="eager" />
|
alt=""
|
||||||
</section>
|
aria-hidden="true"
|
||||||
|
width="1140"
|
||||||
|
height="713"
|
||||||
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
/>
|
||||||
|
<div class="page-cover-inner">
|
||||||
|
{eyebrow && <p class="eyebrow light">{eyebrow}</p>}
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{lead && <p>{lead}</p>}
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="hero-wave"
|
||||||
|
width="100%"
|
||||||
|
height="100"
|
||||||
|
viewBox="0 0 1280 140"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g fill="#ffffff">
|
||||||
|
<path d="M0 51.76c36.21-2.25 77.57-3.58 126.42-3.58 320 0 320 57 640 57 271.15 0 312.58-40.91 513.58-53.4V0H0z" fill-opacity=".3" />
|
||||||
|
<path d="M0 24.31c43.46-5.69 94.56-9.25 158.42-9.25 320 0 320 89.24 640 89.24 256.13 0 307.28-57.16 481.58-80V0H0z" fill-opacity=".5" />
|
||||||
|
<path d="M0 0v3.4C28.2 1.6 59.4.59 94.42.59c320 0 320 84.3 640 84.3 285 0 316.17-66.85 545.58-81.49V0z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section class:list={["page-hero", { "page-hero--compact": !hasPhoto }]}>
|
||||||
|
<div class="hero-copy">
|
||||||
|
{icon && (
|
||||||
|
<img class="hero-icon" src={icon} alt="" aria-hidden="true" width="64" height="64" loading="eager" />
|
||||||
|
)}
|
||||||
|
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{lead && <p>{lead}</p>}
|
||||||
|
</div>
|
||||||
|
{hasPhoto && image && (
|
||||||
|
<Picture src={image} alt={imageAlt} width="1280" height="854" loading="eager" fetchpriority="high" sizes="(max-width: 960px) 100vw, 560px" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
// <img> for a /public image path, upgraded to <picture> with AVIF/WebP srcsets when
|
||||||
|
// scripts/optimize-images.mjs has variants for it. The original stays as the <img> fallback.
|
||||||
|
// `sizes` should describe the rendered width so phones fetch a phone-sized file.
|
||||||
|
import type { HTMLAttributes } from "astro/types";
|
||||||
|
import { imageVariants } from "@utils/images.mjs";
|
||||||
|
|
||||||
|
type Props = Omit<HTMLAttributes<"img">, "src" | "srcset" | "sizes"> & {
|
||||||
|
src: string;
|
||||||
|
sizes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { src, sizes = "100vw", width, height, ...attrs } = Astro.props;
|
||||||
|
const variants = imageVariants(src);
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
variants ? (
|
||||||
|
<picture>
|
||||||
|
<source type="image/avif" srcset={variants.avif} sizes={sizes} />
|
||||||
|
<source type="image/webp" srcset={variants.webp} sizes={sizes} />
|
||||||
|
<img src={src} width={width ?? variants.width} height={height ?? variants.height} decoding="async" {...attrs} />
|
||||||
|
</picture>
|
||||||
|
) : (
|
||||||
|
<img src={src} width={width} height={height} {...attrs} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import Analytics from "@components/Analytics.astro";
|
|||||||
import { site } from "@data/site";
|
import { site } from "@data/site";
|
||||||
import { assetUrl, canonical } from "@utils/url";
|
import { assetUrl, canonical } from "@utils/url";
|
||||||
import { organizationSchema, websiteSchema } from "@utils/schema";
|
import { organizationSchema, websiteSchema } from "@utils/schema";
|
||||||
|
// Self-hosted fonts (no external requests): Work Sans headings, Open Sans body — matching the original.
|
||||||
|
import "@fontsource-variable/work-sans";
|
||||||
|
import "../styles/fonts.css";
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -37,7 +40,12 @@ const schemas = [organizationSchema(), websiteSchema(), ...jsonLd];
|
|||||||
<title>{pageTitle}</title>
|
<title>{pageTitle}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<link rel="canonical" href={canonicalUrl} />
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
<link rel="icon" href="/assets/bluecap-logo.png" />
|
<!-- Site icon restored from the original WordPress site (water drop in a circle). -->
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||||
|
<link rel="icon" href="/favicon-32x32.png" type="image/png" sizes="32x32" />
|
||||||
|
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="msapplication-TileImage" content="/icon-270.png" />
|
||||||
<link rel="alternate" type="application/rss+xml" title="BlueCap Strategies Insights" href="/rss.xml" />
|
<link rel="alternate" type="application/rss+xml" title="BlueCap Strategies Insights" href="/rss.xml" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content={pageTitle} />
|
<meta property="og:title" content={pageTitle} />
|
||||||
|
|||||||
@@ -6,22 +6,40 @@ import ContactCta from "@components/ContactCta.astro";
|
|||||||
import { breadcrumbSchema, pageSchema, serviceSchema } from "@utils/schema";
|
import { breadcrumbSchema, pageSchema, serviceSchema } from "@utils/schema";
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const services = await getCollection("services");
|
const services = (await getCollection("services")).sort((a, b) => a.data.order - b.data.order);
|
||||||
const focusAreas = await getCollection("focus-areas");
|
const focusAreas = (await getCollection("focus-areas")).sort((a, b) => a.data.order - b.data.order);
|
||||||
|
|
||||||
|
const siblingsOf = (collection: Array<{ slug: string; data: { title: string } }>) =>
|
||||||
|
collection.map((e) => ({ title: e.data.title, href: `/${e.slug}/` }));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...services.map((entry) => ({
|
...services.map((entry) => ({
|
||||||
params: { slug: entry.slug },
|
params: { slug: entry.slug },
|
||||||
props: { entry, kind: "Service", parentTitle: "Services", parentHref: "/services/" },
|
props: {
|
||||||
|
entry,
|
||||||
|
kind: "Service",
|
||||||
|
parentTitle: "Services",
|
||||||
|
parentHref: "/services/",
|
||||||
|
siblings: siblingsOf(services),
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
...focusAreas.map((entry) => ({
|
...focusAreas.map((entry) => ({
|
||||||
params: { slug: entry.slug },
|
params: { slug: entry.slug },
|
||||||
props: { entry, kind: "Core Focus Area", parentTitle: "Focus Areas", parentHref: "/focus-areas/" },
|
props: {
|
||||||
|
entry,
|
||||||
|
kind: "Core Focus Area",
|
||||||
|
parentTitle: "Focus Areas",
|
||||||
|
parentHref: "/focus-areas/",
|
||||||
|
siblings: siblingsOf(focusAreas),
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { entry, kind, parentTitle, parentHref } = Astro.props;
|
// Faithful clone of the original detail pages: mangrove cover hero with the page title, then a
|
||||||
const { title, description, image } = entry.data;
|
// left sidebar listing the sibling pages (current one highlighted sage) with the article at right.
|
||||||
|
const { entry, kind, parentTitle, parentHref, siblings } = Astro.props;
|
||||||
|
const { title, description } = entry.data;
|
||||||
const pathname = `/${entry.slug}/`;
|
const pathname = `/${entry.slug}/`;
|
||||||
const isService = kind === "Service";
|
const isService = kind === "Service";
|
||||||
const { Content } = await entry.render();
|
const { Content } = await entry.render();
|
||||||
@@ -38,23 +56,22 @@ const jsonLd = [
|
|||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={title} description={description} pathname={pathname} jsonLd={jsonLd}>
|
<BaseLayout title={title} description={description} pathname={pathname} jsonLd={jsonLd}>
|
||||||
<PageHero eyebrow={kind} title={title} image={image ?? "/assets/live-site/img_0070.jpg"} />
|
<PageHero variant="cover" title={title} image="/assets/live-site/hero-mangrove.jpg" />
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="detail-layout">
|
<div class="detail-layout">
|
||||||
<div class="article-body">
|
<aside class="detail-nav" aria-label={`${parentTitle} navigation`}>
|
||||||
<p><a class="text-link" href={parentHref}>← {parentTitle} overview</a></p>
|
<h2 class="detail-nav-title"><a href={parentHref}>{parentTitle}</a></h2>
|
||||||
|
<ul>
|
||||||
|
{siblings.map((s) => (
|
||||||
|
<li>
|
||||||
|
<a href={s.href} aria-current={s.href === pathname ? "page" : undefined}>{s.title}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
<div class="article-body page-detail">
|
||||||
<Content />
|
<Content />
|
||||||
</div>
|
</div>
|
||||||
<aside class="sidebar" aria-label={`${title} — related`}>
|
|
||||||
<div class="note-card">
|
|
||||||
<h2>Related</h2>
|
|
||||||
<a class="text-link" href={parentHref}>{parentTitle} overview</a>
|
|
||||||
</div>
|
|
||||||
<div class="note-card">
|
|
||||||
<h2>Start a conversation</h2>
|
|
||||||
<a class="text-link" href="/contact/">Request a consultation</a>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<ContactCta />
|
<ContactCta />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { breadcrumbSchema, pageSchema } from "@utils/schema";
|
|||||||
|
|
||||||
const about = await getEntry("pages", "about");
|
const about = await getEntry("pages", "about");
|
||||||
if (!about) throw new Error("Missing content entry: pages/about");
|
if (!about) throw new Error("Missing content entry: pages/about");
|
||||||
const { title, description, eyebrow, heroTitle, heroLead, heroImage, heroImageAlt } = about.data;
|
const { title, description, heroImage } = about.data;
|
||||||
const { Content, headings } = await about.render();
|
const { Content, headings } = await about.render();
|
||||||
const sectionNav = headings.filter((h) => h.depth === 2);
|
const sectionNav = headings.filter((h) => h.depth === 2);
|
||||||
const pathname = "/about-us/";
|
const pathname = "/about-us/";
|
||||||
@@ -26,28 +26,17 @@ const pathname = "/about-us/";
|
|||||||
]),
|
]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<PageHero
|
<PageHero variant="cover" title={title} image="/assets/live-site/hero-mangrove.jpg" />
|
||||||
eyebrow={eyebrow}
|
|
||||||
title={heroTitle}
|
|
||||||
lead={heroLead}
|
|
||||||
image={heroImage ?? "/assets/live-site/img_0062.jpg"}
|
|
||||||
imageAlt={heroImageAlt}
|
|
||||||
/>
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="detail-layout">
|
<nav class="pill-nav" aria-label="Section navigation">
|
||||||
<div class="article-body">
|
{sectionNav
|
||||||
<Content />
|
.filter((h) => !/^join us/i.test(h.text))
|
||||||
</div>
|
.map((h) => (
|
||||||
<aside class="sidebar" aria-label="On this page">
|
<a class="pill" href={`#${h.slug}`}>{h.text}</a>
|
||||||
<nav class="note-card anchor-nav" aria-label="Section navigation">
|
))}
|
||||||
<h2>On this page</h2>
|
</nav>
|
||||||
<ul>
|
<div class="article-body page-article">
|
||||||
{sectionNav.map((h) => (
|
<Content />
|
||||||
<li><a href={`#${h.slug}`}>{h.text}</a></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<ContactCta />
|
<ContactCta />
|
||||||
|
|||||||
@@ -21,32 +21,24 @@ const endpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";
|
|||||||
]),
|
]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<PageHero
|
<PageHero variant="cover" title="Contact Us" image="/assets/live-site/hero-mangrove.jpg" />
|
||||||
eyebrow="Contact"
|
|
||||||
title="Get in touch with BlueCap Strategies"
|
|
||||||
lead="Share the opportunity, project, or water-sector challenge you are working on. BlueCap will respond through the most appropriate contact path."
|
|
||||||
image="/assets/live-site/img_0058.jpg"
|
|
||||||
imageAlt="Coastal water and marine resource landscape"
|
|
||||||
/>
|
|
||||||
<section class="section contact-layout">
|
<section class="section contact-layout">
|
||||||
<aside class="contact-panel">
|
<aside class="contact-panel">
|
||||||
<h2>Contact Info</h2>
|
<h2>Contact Info</h2>
|
||||||
<p><strong>Phone</strong><br /><a href={`tel:${site.phone.replaceAll("-", "")}`}>{site.phone}</a></p>
|
<p><strong>Phone</strong><br /><a href={`tel:${site.phone.replaceAll("-", "")}`}>{site.phone}</a></p>
|
||||||
<p><strong>Email</strong><br /><a href={site.contactEmailHref}>{site.email}</a></p>
|
<p><strong>Email</strong><br /><a href={site.contactEmailHref}>{site.email}</a></p>
|
||||||
<p class="form-note">
|
|
||||||
Discovery note: the current WordPress contact page includes placeholder phone and email
|
|
||||||
values. This rebuild uses the verified homepage/footer contact details.
|
|
||||||
</p>
|
|
||||||
</aside>
|
</aside>
|
||||||
<div class="form-panel">
|
<div class="form-panel">
|
||||||
<h2>Request a consultation</h2>
|
<h2>Request a consultation</h2>
|
||||||
<p class="form-note">
|
<p class="form-note">
|
||||||
The form is static-first and posts to the configured endpoint when
|
Tell us about your project and we will follow up within two business days.
|
||||||
<code>PUBLIC_CONTACT_FORM_ENDPOINT</code> is set. Without an endpoint, it opens an email
|
|
||||||
draft after validating required fields.
|
|
||||||
</p>
|
</p>
|
||||||
<form class="contact-form" method="post" action={endpoint || "/thank-you/"} data-contact-form data-endpoint={endpoint}>
|
<form class="contact-form" method="post" action={endpoint || "/thank-you/"} data-contact-form data-endpoint={endpoint}>
|
||||||
<input type="hidden" name="subject" value="BlueCap Strategies website inquiry" />
|
{/* `_subject` (underscore) is Formspree's reserved field for the notification subject
|
||||||
|
line. Without it the mail arrives under a generic "New submission" subject. */}
|
||||||
|
<input type="hidden" name="_subject" value="BlueCap Strategies website inquiry" />
|
||||||
|
{/* Formspree treats a filled _gotcha field as spam. Hidden from people, not from bots. */}
|
||||||
|
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" aria-hidden="true" class="gotcha" />
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<label>
|
<label>
|
||||||
Name *
|
Name *
|
||||||
@@ -71,23 +63,69 @@ const endpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";
|
|||||||
Message *
|
Message *
|
||||||
<textarea name="message" required minlength="20"></textarea>
|
<textarea name="message" required minlength="20"></textarea>
|
||||||
</label>
|
</label>
|
||||||
<button class="button" type="submit">Submit</button>
|
<button class="button" type="submit" data-submit>Submit</button>
|
||||||
|
<p class="form-status" data-form-status role="status" aria-live="polite" hidden></p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script>
|
<script>
|
||||||
const form = document.querySelector("[data-contact-form]");
|
const form = document.querySelector("[data-contact-form]");
|
||||||
form?.addEventListener("submit", (event) => {
|
|
||||||
const endpoint = form.getAttribute("data-endpoint");
|
if (form instanceof HTMLFormElement) {
|
||||||
if (endpoint) return;
|
const status = form.querySelector("[data-form-status]");
|
||||||
event.preventDefault();
|
const submit = form.querySelector("[data-submit]");
|
||||||
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
|
|
||||||
const data = new FormData(form);
|
const say = (message: string, isError: boolean) => {
|
||||||
const subject = encodeURIComponent("BlueCap Strategies website inquiry");
|
if (!(status instanceof HTMLElement)) return;
|
||||||
const body = encodeURIComponent(
|
status.textContent = message;
|
||||||
`Name: ${data.get("name")}\nOrganization: ${data.get("organization") ?? ""}\nEmail: ${data.get("email")}\nPhone: ${data.get("phone") ?? ""}\n\n${data.get("message")}`,
|
status.classList.toggle("is-error", isError);
|
||||||
);
|
status.hidden = false;
|
||||||
window.location.href = `mailto:${"info@bluecapstrategies.com"}?subject=${subject}&body=${body}`;
|
};
|
||||||
});
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
if (!form.reportValidity()) return;
|
||||||
|
// Read at submit time, not at init, so the attribute stays the single source of truth.
|
||||||
|
const endpoint = form.getAttribute("data-endpoint") ?? "";
|
||||||
|
|
||||||
|
// No endpoint configured (local dev, or a build that never received the build arg):
|
||||||
|
// fall back to opening a mail draft rather than silently posting nowhere.
|
||||||
|
if (!endpoint) {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(form);
|
||||||
|
const subject = encodeURIComponent("BlueCap Strategies website inquiry");
|
||||||
|
const body = encodeURIComponent(
|
||||||
|
`Name: ${data.get("name")}\nOrganization: ${data.get("organization") ?? ""}\nEmail: ${data.get("email")}\nPhone: ${data.get("phone") ?? ""}\n\n${data.get("message")}`,
|
||||||
|
);
|
||||||
|
window.location.href = `mailto:${"info@bluecapstrategies.com"}?subject=${subject}&body=${body}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post in the background so the visitor lands on our own /thank-you/ page rather than
|
||||||
|
// the form provider's. Native POST would navigate away to Formspree.
|
||||||
|
event.preventDefault();
|
||||||
|
if (submit instanceof HTMLButtonElement) submit.disabled = true;
|
||||||
|
say("Sending…", false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
body: new FormData(form),
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = "/thank-you/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Form endpoint returned ${response.status}`);
|
||||||
|
} catch {
|
||||||
|
if (submit instanceof HTMLButtonElement) submit.disabled = false;
|
||||||
|
say(
|
||||||
|
"Sorry — that didn't send. Please email info@bluecapstrategies.com or call 804-332-6618.",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
@@ -25,16 +25,11 @@ const description =
|
|||||||
]),
|
]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<PageHero
|
<PageHero variant="cover" title="Core Focus Areas" image="/assets/live-site/hero-mangrove.jpg" />
|
||||||
eyebrow="Core Focus Areas"
|
|
||||||
title="Focused expertise for high-stakes aquatic resource challenges"
|
|
||||||
lead="BlueCap concentrates on areas where finance, policy, innovation, and implementation can unlock measurable progress for aquatic ecosystems and coastal economies."
|
|
||||||
image="/assets/live-site/img_0070.jpg"
|
|
||||||
imageAlt="Coastal water resource landscape"
|
|
||||||
/>
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<CardGrid
|
<CardGrid
|
||||||
heading="Our core focus areas"
|
heading="Our core focus areas"
|
||||||
|
hideHeading
|
||||||
items={focusAreas.map((area) => ({
|
items={focusAreas.map((area) => ({
|
||||||
title: area.data.title,
|
title: area.data.title,
|
||||||
description: area.data.summary,
|
description: area.data.summary,
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
---
|
---
|
||||||
import { getCollection } from "astro:content";
|
import { getCollection } from "astro:content";
|
||||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||||
import CardGrid from "@components/CardGrid.astro";
|
|
||||||
import ContactCta from "@components/ContactCta.astro";
|
import ContactCta from "@components/ContactCta.astro";
|
||||||
|
import Picture from "@components/Picture.astro";
|
||||||
import { pageSchema } from "@utils/schema";
|
import { pageSchema } from "@utils/schema";
|
||||||
|
|
||||||
const services = (await getCollection("services")).sort((a, b) => a.data.order - b.data.order);
|
const services = (await getCollection("services")).sort((a, b) => a.data.order - b.data.order);
|
||||||
const focusAreas = (await getCollection("focus-areas")).sort((a, b) => a.data.order - b.data.order);
|
const focusAreas = (await getCollection("focus-areas")).sort((a, b) => a.data.order - b.data.order);
|
||||||
|
|
||||||
|
// Focus-area card images (matches the original homepage — one photo per card).
|
||||||
|
const focusImages: Record<string, { src: string; width: number; height: number }> = {
|
||||||
|
"the-economics-of-pfas-remediation": { src: "/assets/live-site/img_0053.jpg", width: 1024, height: 679 },
|
||||||
|
"structuring-marine-conservation-finance": { src: "/assets/live-site/img_0054.jpg", width: 1000, height: 750 },
|
||||||
|
"sustainable-living-resource-utilization": { src: "/assets/live-site/img_0062.jpg", width: 1000, height: 750 },
|
||||||
|
"economic-development-for-island-and-coastal-communities": { src: "/assets/live-site/island-coastal.jpg", width: 800, height: 800 },
|
||||||
|
};
|
||||||
|
|
||||||
const description =
|
const description =
|
||||||
"BlueCap Strategies leverages capital, strategy, and data-driven insight to restore, preserve, and responsibly use aquatic resources.";
|
"BlueCap Strategies leverages capital, strategy, and data-driven insight to restore, preserve, and responsibly use aquatic resources.";
|
||||||
---
|
---
|
||||||
@@ -20,33 +28,43 @@ const description =
|
|||||||
jsonLd={[pageSchema({ title: "BlueCap Strategies", description, pathname: "/" })]}
|
jsonLd={[pageSchema({ title: "BlueCap Strategies", description, pathname: "/" })]}
|
||||||
>
|
>
|
||||||
<section class="home-hero">
|
<section class="home-hero">
|
||||||
<div class="hero-copy">
|
<Picture
|
||||||
<p class="eyebrow">Water-sector strategy and capital formation</p>
|
class="hero-bg"
|
||||||
<h1>Leveraging capital to drive the restoration, preservation and responsible use of aquatic resources</h1>
|
src="/assets/live-site/hero-mangrove.jpg"
|
||||||
<p>
|
alt=""
|
||||||
At BlueCap Strategies, we combine financial expertise, innovative strategies, and
|
aria-hidden="true"
|
||||||
data-driven insights to accelerate the restoration and responsible use of aquatic
|
width="1140"
|
||||||
resources. We empower our clients and partners to shape a sustainable future where
|
height="713"
|
||||||
ecosystems and economies thrive together.
|
loading="eager"
|
||||||
</p>
|
fetchpriority="high"
|
||||||
<div class="hero-actions">
|
/>
|
||||||
|
<div class="hero-inner">
|
||||||
|
<div class="hero-card">
|
||||||
|
<h1>
|
||||||
|
Leveraging capital to drive the <span class="emph">restoration, preservation</span> and
|
||||||
|
<span class="emph">responsible use</span> of aquatic resources
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
At BlueCap Strategies, we combine financial expertise, innovative strategies, and
|
||||||
|
data-driven insights to accelerate the restoration and responsible use of aquatic
|
||||||
|
resources. We empower our clients and partners to shape a sustainable future where
|
||||||
|
ecosystems and economies thrive together.
|
||||||
|
</p>
|
||||||
<a class="button" href="/contact/">Request a Consultation</a>
|
<a class="button" href="/contact/">Request a Consultation</a>
|
||||||
<a class="text-link" href="/services/">Explore services</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-media">
|
<svg class="hero-wave" width="100%" height="100" viewBox="0 0 1280 140" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
<img
|
<g fill="#ffffff">
|
||||||
src="/assets/live-site/img_0067.jpg"
|
<path d="M0 51.76c36.21-2.25 77.57-3.58 126.42-3.58 320 0 320 57 640 57 271.15 0 312.58-40.91 513.58-53.4V0H0z" fill-opacity=".3"></path>
|
||||||
alt="Blue water and coastal landscape representing aquatic resource strategy"
|
<path d="M0 24.31c43.46-5.69 94.56-9.25 158.42-9.25 320 0 320 89.24 640 89.24 256.13 0 307.28-57.16 481.58-80V0H0z" fill-opacity=".5"></path>
|
||||||
width="1280"
|
<path d="M0 0v3.4C28.2 1.6 59.4.59 94.42.59c320 0 320 84.3 640 84.3 285 0 316.17-66.85 545.58-81.49V0z"></path>
|
||||||
height="854"
|
</g>
|
||||||
/>
|
</svg>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section alt" aria-labelledby="how-work">
|
<section class="section" aria-labelledby="how-work">
|
||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<div class="section-header">
|
<div class="section-header centered">
|
||||||
<p class="eyebrow">How We Work</p>
|
<p class="eyebrow">How We Work</p>
|
||||||
<h2 id="how-work">Unlocking Capital and Innovation to Solve Global Water Challenges</h2>
|
<h2 id="how-work">Unlocking Capital and Innovation to Solve Global Water Challenges</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -56,7 +74,7 @@ const description =
|
|||||||
ecosystems and economies.
|
ecosystems and economies.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="three-grid">
|
<div class="work-grid">
|
||||||
<article class="work-card">
|
<article class="work-card">
|
||||||
<h3>Increasing the Supply of Capital</h3>
|
<h3>Increasing the Supply of Capital</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -106,12 +124,11 @@ const description =
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section" aria-labelledby="about-home">
|
<section class="section tint" aria-labelledby="about-home">
|
||||||
<div class="media-band">
|
<div class="section-inner about-band">
|
||||||
<img src="/assets/live-site/img_0053.jpg" alt="Aquatic ecosystem and coastal water" width="1024" height="679" />
|
<div class="about-copy">
|
||||||
<div class="section-header">
|
<h2 id="about-home" class="about-title">About Us</h2>
|
||||||
<p class="eyebrow">About Us</p>
|
<p class="about-sub">Innovating towards a sustainable future</p>
|
||||||
<h2 id="about-home">Innovating towards a sustainable future</h2>
|
|
||||||
<p>
|
<p>
|
||||||
At BlueCap Strategies, we transform environmental challenges into profitable, investable
|
At BlueCap Strategies, we transform environmental challenges into profitable, investable
|
||||||
opportunities. By driving catalytic capital into sustainable projects, we not only ensure
|
opportunities. By driving catalytic capital into sustainable projects, we not only ensure
|
||||||
@@ -123,20 +140,20 @@ const description =
|
|||||||
allows us to adapt and solve the most complex challenges with both insight and decisive
|
allows us to adapt and solve the most complex challenges with both insight and decisive
|
||||||
action.
|
action.
|
||||||
</p>
|
</p>
|
||||||
<a class="text-link" href="/about-us/">Learn More</a>
|
<a class="button" href="/about-us/">Learn More<span class="sr-only"> about BlueCap Strategies</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="stats-grid" aria-label="BlueCap Strategies experience metrics">
|
||||||
|
<div class="stat"><strong>27 Years</strong><span>of senior-level experience working with institutional capital providers</span></div>
|
||||||
|
<div class="stat"><strong>$10+ billion</strong><span>of capital deployed across real-asset, infrastructure and environmental sectors.</span></div>
|
||||||
|
<div class="stat"><strong>$3.5+ billion</strong><span>of public-private partnerships structured in key ESG and environmental infrastructure sectors.</span></div>
|
||||||
|
<div class="stat"><strong>275+ Projects and Initiatives</strong><span>advised on across real-asset, environmental, and water resource sectors.</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="stats-grid" aria-label="BlueCap Strategies experience metrics">
|
|
||||||
<div class="stat"><strong>27 Years</strong><span>of senior-level experience working with institutional capital providers</span></div>
|
|
||||||
<div class="stat"><strong>$10+ billion</strong><span>of capital deployed across real-asset, infrastructure and environmental sectors.</span></div>
|
|
||||||
<div class="stat"><strong>$3.5+ billion</strong><span>of public-private partnerships structured in key ESG and environmental infrastructure sectors.</span></div>
|
|
||||||
<div class="stat"><strong>275+ Projects and Initiatives</strong><span>advised on across real-asset, environmental, and water resource sectors.</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section alt" aria-labelledby="services-home">
|
<section class="section" aria-labelledby="services-home">
|
||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<div class="section-header">
|
<div class="section-header centered">
|
||||||
<p class="eyebrow">Our Services</p>
|
<p class="eyebrow">Our Services</p>
|
||||||
<h2 id="services-home">Strategy, capital, partnerships, and implementation</h2>
|
<h2 id="services-home">Strategy, capital, partnerships, and implementation</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -144,37 +161,61 @@ const description =
|
|||||||
market intelligence and capital strategy through project delivery and reporting.
|
market intelligence and capital strategy through project delivery and reporting.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CardGrid
|
<div class="services-band">
|
||||||
items={services.map((service) => ({
|
<div class="services-accordion">
|
||||||
title: service.data.title,
|
{services.map((service, i) => (
|
||||||
description: service.data.summary,
|
<details class="svc-toggle" open={i === 0}>
|
||||||
href: `/${service.slug}/`,
|
<summary>{service.data.title}</summary>
|
||||||
icon: service.data.icon,
|
<div class="svc-body">
|
||||||
}))}
|
<p>{service.data.summary}</p>
|
||||||
label="BlueCap services"
|
<a class="text-link" href={`/${service.slug}/`}>Learn More →</a>
|
||||||
/>
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="services-media">
|
||||||
|
<Picture
|
||||||
|
src="/assets/live-site/img_0067.jpg"
|
||||||
|
alt="Aquaculture baskets suspended in clear coastal water"
|
||||||
|
width="1080"
|
||||||
|
height="721"
|
||||||
|
loading="lazy"
|
||||||
|
sizes="(max-width: 960px) 100vw, 590px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section" aria-labelledby="focus-home">
|
<section class="section tint" aria-labelledby="focus-home">
|
||||||
<div class="section-header">
|
<div class="section-inner">
|
||||||
<p class="eyebrow">Our Core Focus Areas</p>
|
<div class="section-header centered">
|
||||||
<h2 id="focus-home">Where strategy, capital, and innovation shape the future of water resources</h2>
|
<p class="eyebrow">Our Core Focus Areas</p>
|
||||||
<p>
|
<h2 id="focus-home" class="focus-heading">Where strategy, capital, and innovation shape the future of water resources</h2>
|
||||||
At BlueCap Strategies, we tackle pressing water challenges by combining innovation, capital
|
<p>
|
||||||
mobilization, and data-driven insights. From securing grant funding to guiding investments,
|
At BlueCap Strategies, we tackle pressing water challenges by combining innovation, capital
|
||||||
we help clients unlock sustainable opportunities in marine and freshwater sectors, bridging
|
mobilization, and data-driven insights. From securing grant funding to guiding investments,
|
||||||
the gap between capital providers and project sponsors to drive impactful solutions.
|
we help clients unlock sustainable opportunities in marine and freshwater sectors, bridging
|
||||||
</p>
|
the gap between capital providers and project sponsors to drive impactful solutions.
|
||||||
</div>
|
</p>
|
||||||
<div class="focus-grid">
|
</div>
|
||||||
{focusAreas.map((area) => (
|
<div class="focus-grid">
|
||||||
<article class="focus-card">
|
{focusAreas.map((area) => {
|
||||||
<h3><a href={`/${area.slug}/`}>{area.data.title}</a></h3>
|
const img = focusImages[area.slug];
|
||||||
<p>{area.data.summary}</p>
|
return (
|
||||||
<a class="text-link" href={`/${area.slug}/`}>Learn more</a>
|
<article class="focus-card">
|
||||||
</article>
|
{img && (
|
||||||
))}
|
<a class="focus-media" href={`/${area.slug}/`}>
|
||||||
|
<Picture src={img.src} alt={area.data.title} width={img.width} height={img.height} loading="lazy" sizes="(max-width: 640px) 100vw, (max-width: 960px) 50vw, 290px" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<h3><a href={`/${area.slug}/`}>{area.data.title}</a></h3>
|
||||||
|
<p>{area.data.summary}</p>
|
||||||
|
<a class="text-link" href={`/${area.slug}/`}>Learn More →</a>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import ContactCta from "@components/ContactCta.astro";
|
|||||||
import { breadcrumbSchema, pageSchema } from "@utils/schema";
|
import { breadcrumbSchema, pageSchema } from "@utils/schema";
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await getCollection("insights");
|
// Drafts must never reach the build: an unfiltered getCollection() published
|
||||||
|
// /insights/example-draft/ AND listed it in the sitemap. Matches rss.xml.ts.
|
||||||
|
const posts = await getCollection("insights", ({ data }) => !data.draft);
|
||||||
return posts.map((post) => ({
|
return posts.map((post) => ({
|
||||||
params: { slug: post.slug },
|
params: { slug: post.slug },
|
||||||
props: { post },
|
props: { post },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ContactCta from "@components/ContactCta.astro";
|
|||||||
import { breadcrumbSchema, pageSchema } from "@utils/schema";
|
import { breadcrumbSchema, pageSchema } from "@utils/schema";
|
||||||
|
|
||||||
const description =
|
const description =
|
||||||
"A prepared future publishing section for BlueCap Strategies articles, essays, case studies, market notes, and announcements.";
|
"Articles, analysis, and updates from BlueCap Strategies are coming soon.";
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -22,26 +22,9 @@ const description =
|
|||||||
>
|
>
|
||||||
<PageHero
|
<PageHero
|
||||||
eyebrow="Insights"
|
eyebrow="Insights"
|
||||||
title="Future publishing home for BlueCap Strategies"
|
title="Coming soon"
|
||||||
lead="This section is prepared for articles, essays, case studies, market notes, and announcements when BlueCap migrates thought leadership from Substack, Medium, or other channels onto its own domain."
|
lead="Articles, analysis, and updates from BlueCap Strategies will be published here."
|
||||||
image="/assets/live-site/img_0067.jpg"
|
image="/assets/live-site/img_0067.jpg"
|
||||||
/>
|
/>
|
||||||
<section class="insights-shell">
|
|
||||||
<article class="note-card">
|
|
||||||
<h2>Publishing path</h2>
|
|
||||||
<p>
|
|
||||||
Future posts can be added as Markdown or MDX in <code>src/content/insights</code> with
|
|
||||||
typed frontmatter for title, description, publish date, modified date, tags, and optional
|
|
||||||
canonical URL.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article class="note-card">
|
|
||||||
<h2>RSS support</h2>
|
|
||||||
<p>
|
|
||||||
RSS is available at <a href="/rss.xml">/rss.xml</a>. The feed is ready for future article
|
|
||||||
imports even though no public posts are published yet.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
<ContactCta />
|
<ContactCta />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from "@layouts/BaseLayout.astro";
|
||||||
|
import { site } from "@data/site";
|
||||||
|
import { pageSchema } from "@utils/schema";
|
||||||
|
|
||||||
|
const description =
|
||||||
|
"How BlueCap Strategies collects, uses, and protects information when you visit this website or contact us.";
|
||||||
|
const effectiveDate = "October 23, 2026";
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="Privacy Policy"
|
||||||
|
description={description}
|
||||||
|
pathname="/privacy/"
|
||||||
|
jsonLd={[pageSchema({ title: "Privacy Policy", description, pathname: "/privacy/" })]}
|
||||||
|
>
|
||||||
|
<section class="insights-shell">
|
||||||
|
<article class="note-card privacy-policy">
|
||||||
|
<p class="eyebrow">Effective {effectiveDate}</p>
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p>
|
||||||
|
This policy explains what information BlueCap Strategies, LLC ("BlueCap Strategies," "we,"
|
||||||
|
"us") collects through www.bluecapstrategies.com, how we use it, and the choices you have.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Information you give us</h2>
|
||||||
|
<p>
|
||||||
|
When you use our contact form, we collect the details you enter: your name, email address,
|
||||||
|
phone number, organization, and message. The form is delivered to us by Formspree, a
|
||||||
|
form-processing service that receives the submission and forwards it to our inbox. We use
|
||||||
|
this information only to respond to your inquiry and to follow up about the work you asked
|
||||||
|
about. You can also reach us directly by email or phone instead of using the form.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Website analytics</h2>
|
||||||
|
<p>We use two analytics tools to understand how the site is used and to improve it.</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Umami</strong>, which we host on our own infrastructure. It does not use cookies,
|
||||||
|
does not track you across other websites, and does not store your IP address. It records
|
||||||
|
aggregate information such as the pages viewed, the referring website, browser and device
|
||||||
|
type, and approximate country.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Google Analytics</strong>, provided by Google LLC. It uses cookies to distinguish
|
||||||
|
visitors and measure visits, and the data it collects is processed by Google under
|
||||||
|
<a href="https://policies.google.com/privacy">Google's Privacy Policy</a>. You can opt out
|
||||||
|
by installing the
|
||||||
|
<a href="https://tools.google.com/dlpage/gaoptout">Google Analytics opt-out browser
|
||||||
|
add-on</a> or by blocking cookies in your browser settings.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Server and security logs</h2>
|
||||||
|
<p>
|
||||||
|
Like most websites, our servers automatically record technical details of each request,
|
||||||
|
such as IP address, browser type, the page requested, and the time of the request. We use
|
||||||
|
these logs only to keep the site secure and running, for example to block abusive traffic,
|
||||||
|
and keep them for a limited time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>What we do not do</h2>
|
||||||
|
<ul>
|
||||||
|
<li>We do not sell or rent your personal information.</li>
|
||||||
|
<li>We do not use your information for advertising, and we do not show ads on this site.</li>
|
||||||
|
<li>We do not knowingly collect information from children under 13.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Sharing</h2>
|
||||||
|
<p>
|
||||||
|
We share information only with the service providers named above that help us operate
|
||||||
|
this site, or when required by law.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Your choices</h2>
|
||||||
|
<p>
|
||||||
|
You may ask us to tell you what information we hold about you from a contact form
|
||||||
|
submission, or to correct or delete it, by emailing
|
||||||
|
<a href={site.contactEmailHref}>{site.email}</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Changes to this policy</h2>
|
||||||
|
<p>
|
||||||
|
If we change how we handle information, we will update this page and its effective date.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions about this policy can be sent to <a href={site.contactEmailHref}>{site.email}</a>
|
||||||
|
or by phone at <a href={`tel:${site.phone.replaceAll("-", "")}`}>{site.phone}</a>.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.privacy-policy {
|
||||||
|
max-width: 52rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
.privacy-policy h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin: 2rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.privacy-policy li + li {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,16 +24,11 @@ const description =
|
|||||||
]),
|
]),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<PageHero
|
<PageHero variant="cover" title="Services" image="/assets/live-site/hero-mangrove.jpg" />
|
||||||
eyebrow="Services"
|
|
||||||
title="Advisory services for capital-ready water-sector initiatives"
|
|
||||||
lead="BlueCap Strategies helps clients identify opportunities, structure capital, build partnerships, implement projects, measure outcomes, and shape supportive policy."
|
|
||||||
image="/assets/live-site/img_0075.jpg"
|
|
||||||
imageAlt="Marine and freshwater resources viewed from above"
|
|
||||||
/>
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<CardGrid
|
<CardGrid
|
||||||
heading="Our services"
|
heading="Our services"
|
||||||
|
hideHeading
|
||||||
items={services.map((service) => ({
|
items={services.map((service) => ({
|
||||||
title: service.data.title,
|
title: service.data.title,
|
||||||
description: service.data.summary,
|
description: service.data.summary,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Open Sans Variable, Latin + Latin Extended only. Replaces `import "@fontsource-variable/open-sans"`,
|
||||||
|
* whose index.css also declares math and symbols subsets: a single "→" on the homepage made browsers
|
||||||
|
* download ~77 KB of those font files. Characters outside these ranges now render in the system
|
||||||
|
* fallback font. Rules copied from @fontsource-variable/open-sans/index.css (re-copy after upgrades).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* open-sans-latin-ext-wght-normal */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Open Sans Variable';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 300 800;
|
||||||
|
src: url(@fontsource-variable/open-sans/files/open-sans-latin-ext-wght-normal.woff2) format('woff2-variations');
|
||||||
|
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* open-sans-latin-wght-normal */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Open Sans Variable';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 300 800;
|
||||||
|
src: url(@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2) format('woff2-variations');
|
||||||
|
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||||
|
}
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
:root {
|
:root {
|
||||||
--navy: #123047;
|
--navy: #12324a;
|
||||||
--blue: #1e6d91;
|
--blue: #0074a9; /* exact original azure */
|
||||||
|
--box: #f3f5f9; /* light box background (matches original .et_pb_column) */
|
||||||
--cyan: #3ca2bd;
|
--cyan: #3ca2bd;
|
||||||
--green: #4f7f65;
|
--cyan-deep: #287a90; /* --cyan darkened to 4.9:1 against white text (WCAG AA) */
|
||||||
--ink: #18242d;
|
--green: #81b29a; /* muted sage — subheads (exact original rgb(129,178,154)) */
|
||||||
--muted: #5d6b75;
|
--box-blue: #a9d2eb; /* light-blue metric boxes (exact original rgb(169,210,235)) */
|
||||||
--line: #d9e2e7;
|
--ink: #1b2b36;
|
||||||
--wash: #f3f7f8;
|
--muted: #222222; /* original body copy is near-black, not slate gray */
|
||||||
|
--line: #dbe4e9;
|
||||||
|
--wash: #f4f8f9;
|
||||||
|
--tint: #f0f4f7; /* ~6% navy over white — original's About/Focus section tint */
|
||||||
--paper: #ffffff;
|
--paper: #ffffff;
|
||||||
--accent: #c47f43;
|
--accent: #c47f43;
|
||||||
--shadow: 0 18px 45px rgba(18, 48, 71, 0.12);
|
--shadow: 0 18px 45px rgba(18, 48, 71, 0.12);
|
||||||
font-family:
|
--radius: 15px 0 15px 15px; /* water-droplet shape (top-right sharp) — the original's site-wide motif */
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--btn-radius: 50px 0 50px 50px; /* pill droplet — original's primary CTA shape */
|
||||||
|
--font-heading: "Work Sans Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
--font-body: "Open Sans Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -36,6 +43,17 @@ img {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* <picture> wrappers (components/Picture.astro, Markdown images) must not affect layout: the
|
||||||
|
<img> inside keeps behaving as a direct child (absolute hero backgrounds, sticky media). */
|
||||||
|
picture {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ...which would otherwise promote the <source> tags to grid/flex items (extra gaps). */
|
||||||
|
picture > source {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
@@ -46,26 +64,35 @@ a:hover {
|
|||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3 {
|
h3,
|
||||||
|
h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--navy);
|
color: var(--blue); /* original section headings are azure #0074a9, not navy */
|
||||||
line-height: 1.15;
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
max-width: 16ch;
|
max-width: 18ch;
|
||||||
font-size: 3.35rem;
|
font-size: clamp(2.3rem, 4.4vw, 3.35rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 2rem;
|
font-size: clamp(1.7rem, 3vw, 2.15rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -98,7 +125,6 @@ p {
|
|||||||
.footer-inner,
|
.footer-inner,
|
||||||
.section,
|
.section,
|
||||||
.page-hero,
|
.page-hero,
|
||||||
.home-hero,
|
|
||||||
.contact-cta,
|
.contact-cta,
|
||||||
.detail-layout,
|
.detail-layout,
|
||||||
.insights-shell {
|
.insights-shell {
|
||||||
@@ -138,13 +164,24 @@ p {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 44px;
|
min-height: 46px;
|
||||||
border-radius: 6px;
|
border-radius: var(--btn-radius);
|
||||||
padding: 0.68rem 1rem;
|
padding: 0.7rem 1.9rem;
|
||||||
background: var(--navy);
|
background: var(--blue);
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 800;
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta:hover,
|
||||||
|
.button:hover,
|
||||||
|
.footer-cta:hover {
|
||||||
|
background: var(--navy);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-menu {
|
.nav-menu {
|
||||||
@@ -217,24 +254,74 @@ p {
|
|||||||
background: var(--wash);
|
background: var(--wash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full-bleed image hero with a dark overlay card + wave divider (matches the original). */
|
||||||
.home-hero {
|
.home-hero {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
|
|
||||||
gap: 3rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3.25rem 0 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-hero .hero-media {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: min(78vh, 640px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero .hero-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero .hero-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(1180px, calc(100% - 40px));
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 3rem 0 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
max-width: 620px;
|
||||||
|
background: rgba(15, 40, 60, 0.82);
|
||||||
|
color: #fff;
|
||||||
|
padding: clamp(1.9rem, 4vw, 3rem);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card h1 {
|
||||||
|
color: #fff;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card .emph {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card p {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 1.3rem 0 1.8rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-wave {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
z-index: 2;
|
||||||
|
display: block;
|
||||||
|
transform: scaleY(-1); /* Divi flips the bottom divider: solid edge to the bottom, waves rise up. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-hero img,
|
|
||||||
.page-hero img {
|
.page-hero img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 4 / 3;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +374,47 @@ p {
|
|||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tinted sections (About, Focus) — full-bleed light-blue tint with white wave
|
||||||
|
dividers top and bottom, cloning the original's Divi inside-dividers. */
|
||||||
|
.section.tint {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
background: var(--tint);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-block: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section.tint > .section-inner {
|
||||||
|
width: min(1180px, calc(100% - 40px));
|
||||||
|
margin-inline: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section.tint::before,
|
||||||
|
.section.tint::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 70px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 70px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section.tint::before {
|
||||||
|
top: -1px;
|
||||||
|
background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDBweCIgdmlld0JveD0iMCAwIDEyODAgMTQwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGZpbGw9IiNmZmZmZmYiPjxwYXRoIGQ9Ik0wIDUxLjc2YzM2LjIxLTIuMjUgNzcuNTctMy41OCAxMjYuNDItMy41OCAzMjAgMCAzMjAgNTcgNjQwIDU3IDI3MS4xNSAwIDMxMi41OC00MC45MSA1MTMuNTgtNTMuNFYwSDB6IiBmaWxsLW9wYWNpdHk9Ii4zIi8+PHBhdGggZD0iTTAgMjQuMzFjNDMuNDYtNS42OSA5NC41Ni05LjI1IDE1OC40Mi05LjI1IDMyMCAwIDMyMCA4OS4yNCA2NDAgODkuMjQgMjU2LjEzIDAgMzA3LjI4LTU3LjE2IDQ4MS41OC04MFYwSDB6IiBmaWxsLW9wYWNpdHk9Ii41Ii8+PHBhdGggZD0iTTAgMHYzLjRDMjguMiAxLjYgNTkuNC41OSA5NC40Mi41OWMzMjAgMCAzMjAgODQuMyA2NDAgODQuMyAyODUgMCAzMTYuMTctNjYuODUgNTQ1LjU4LTgxLjQ5VjB6Ii8+PC9nPjwvc3ZnPg==");
|
||||||
|
}
|
||||||
|
|
||||||
|
.section.tint::after {
|
||||||
|
bottom: -1px;
|
||||||
|
background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDBweCIgdmlld0JveD0iMCAwIDEyODAgMTQwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGZpbGw9IiNmZmZmZmYiPjxwYXRoIGQ9Ik0xMjgwIDMuNEMxMDUwLjU5IDE4IDEwMTkuNCA4NC44OSA3MzQuNDIgODQuODljLTMyMCAwLTMyMC04NC4zLTY0MC04NC4zQzU5LjQuNTkgMjguMiAxLjYgMCAzLjRWMTQwaDEyODB6IiBmaWxsLW9wYWNpdHk9Ii4zIi8+PHBhdGggZD0iTTAgMjQuMzFjNDMuNDYtNS42OSA5NC41Ni05LjI1IDE1OC40Mi05LjI1IDMyMCAwIDMyMCA4OS4yNCA2NDAgODkuMjQgMjU2LjEzIDAgMzA3LjI4LTU3LjE2IDQ4MS41OC04MFYxNDBIMHoiIGZpbGwtb3BhY2l0eT0iLjUiLz48cGF0aCBkPSJNMTI4MCA1MS43NmMtMjAxIDEyLjQ5LTI0Mi40MyA1My40LTUxMy41OCA1My40LTMyMCAwLTMyMC01Ny02NDAtNTctNDguODUuMDEtOTAuMjEgMS4zNS0xMjYuNDIgMy42VjE0MGgxMjgweiIvPjwvZz48L3N2Zz4=");
|
||||||
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -298,10 +426,29 @@ p {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header.centered {
|
||||||
|
max-width: 860px;
|
||||||
|
margin-inline: auto;
|
||||||
|
text-align: center;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.card-grid-heading {
|
.card-grid-heading {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
@@ -344,20 +491,165 @@ p {
|
|||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat,
|
|
||||||
.link-card,
|
.link-card,
|
||||||
.focus-card,
|
.focus-card,
|
||||||
.work-card,
|
.work-card,
|
||||||
.note-card {
|
.note-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* About band — text left, 2x2 light-blue metric cards right (clone of original) */
|
||||||
|
.about-band {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: clamp(2rem, 5vw, 4.5rem);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-title {
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: clamp(2rem, 4vw, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-sub {
|
||||||
|
color: var(--green);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.4rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-copy > p:not(.about-sub) {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-band .stats-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-band .stat {
|
||||||
|
background: var(--box-blue);
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px 0 20px 20px;
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-band .stat strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-band .stat span {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Our Services — accordion left, fixed image right (clone of original Divi toggle module) */
|
||||||
|
.services-band {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: clamp(2rem, 4vw, 3.5rem);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-accordion {
|
||||||
|
display: grid;
|
||||||
|
gap: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-toggle {
|
||||||
|
background: var(--box);
|
||||||
|
border: 1px solid var(--blue);
|
||||||
|
border-radius: 10px 0 10px 10px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-toggle summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--blue);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-toggle summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-toggle summary::after {
|
||||||
|
content: "+";
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--blue);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-toggle[open] summary::after {
|
||||||
|
content: "\2013"; /* en dash */
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-body {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-body p {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-media img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
position: sticky;
|
||||||
|
top: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-grid > .work-card:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.work-card {
|
.work-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.9rem;
|
||||||
|
background: var(--box);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 40px 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card h3 {
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-card p {
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat strong {
|
.stat strong {
|
||||||
@@ -367,28 +659,103 @@ p {
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-grid,
|
.card-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus Areas — 4-up photo cards (clone of original homepage) */
|
||||||
|
.focus-heading {
|
||||||
|
color: var(--blue); /* section titles are azure like every other h2 (original) */
|
||||||
|
}
|
||||||
|
|
||||||
.focus-grid {
|
.focus-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 26px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-card,
|
|
||||||
.focus-card {
|
.focus-card {
|
||||||
|
background: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0 0 1.6rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-media {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-media img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-card h3 {
|
||||||
|
padding: 0 1.4rem;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-card h3 a,
|
|
||||||
.focus-card h3 a {
|
.focus-card h3 a {
|
||||||
color: var(--navy);
|
color: var(--blue);
|
||||||
|
font-size: 1.25rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.focus-card > p {
|
||||||
|
padding: 0 1.4rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-card .text-link {
|
||||||
|
padding: 0 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
background: var(--tint);
|
||||||
|
border: none;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card h3 {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card h3 a {
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card > p {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.link-card .card-action {
|
.link-card .card-action {
|
||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
font-weight: 800;
|
font-family: var(--font-heading);
|
||||||
text-decoration-thickness: 2px;
|
font-weight: 600;
|
||||||
text-underline-offset: 4px;
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card .card-action:hover {
|
||||||
|
color: var(--navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-card .card-icon {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-band {
|
.media-band {
|
||||||
@@ -399,14 +766,14 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.media-band img {
|
.media-band img {
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-body img {
|
.article-body img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
margin: 0.5rem 0 1.5rem;
|
margin: 0.5rem 0 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -419,13 +786,178 @@ p {
|
|||||||
padding: 3.5rem 0;
|
padding: 3.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Detail pages (services/focus): no hero photo — single column, with an optional icon accent. */
|
||||||
|
.page-hero--compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 820px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interior cover hero — full-bleed image + white title overlay + wave (clone of original). */
|
||||||
|
.page-cover {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 340px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-cover .hero-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-cover::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, rgba(15, 40, 60, 0.62) 0%, rgba(15, 40, 60, 0.25) 45%, rgba(15, 40, 60, 0.05) 100%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-cover-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: min(1180px, calc(100% - 40px));
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 2rem 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-cover-inner h1 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: clamp(2.4rem, 5vw, 3.4rem);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-cover-inner p:not(.eyebrow) {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
max-width: 46ch;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow.light {
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-cover .hero-wave {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
z-index: 2;
|
||||||
|
transform: scaleY(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal droplet-pill section nav (About page) */
|
||||||
|
.pill-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--blue);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 10px 0 10px 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill:hover {
|
||||||
|
background: var(--navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interior long-form article — single centered column. */
|
||||||
|
.page-article {
|
||||||
|
max-width: 880px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More specific than `.page-hero img` so the 64px icon isn't stretched to full width. */
|
||||||
|
.page-hero .hero-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-layout {
|
.detail-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 320px;
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
gap: 3rem;
|
gap: clamp(2rem, 5vw, 4rem);
|
||||||
padding: 0 0 4.5rem;
|
padding: 0 0 4.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Left sibling nav — clone of the original detail-page menu */
|
||||||
|
.detail-nav {
|
||||||
|
align-self: start;
|
||||||
|
position: sticky;
|
||||||
|
top: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-nav-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-nav-title a {
|
||||||
|
color: var(--blue);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-nav ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-nav li {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-nav li a {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 15px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-nav li a:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-nav li a[aria-current="page"] {
|
||||||
|
background: var(--green);
|
||||||
|
color: var(--navy); /* white on sage is 2.4:1; navy is 5.6:1 (WCAG AA) */
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-detail {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
.article-body {
|
.article-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
@@ -558,6 +1090,27 @@ textarea:focus {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Honeypot: kept in the layout but out of sight and out of the tab order. Not display:none —
|
||||||
|
some bots skip hidden fields, which would defeat the point. */
|
||||||
|
.gotcha {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status.is-error {
|
||||||
|
color: #b3261e;
|
||||||
|
}
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
background: #10293d;
|
background: #10293d;
|
||||||
color: rgba(255, 255, 255, 0.78);
|
color: rgba(255, 255, 255, 0.78);
|
||||||
@@ -598,7 +1151,7 @@ textarea:focus {
|
|||||||
.footer-cta {
|
.footer-cta {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
margin-top: 1rem !important;
|
margin-top: 1rem !important;
|
||||||
background: var(--cyan);
|
background: var(--cyan-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-bottom {
|
.footer-bottom {
|
||||||
@@ -608,6 +1161,11 @@ textarea:focus {
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-bottom a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.insights-shell {
|
.insights-shell {
|
||||||
padding: 4rem 0;
|
padding: 4rem 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -643,11 +1201,25 @@ textarea:focus {
|
|||||||
|
|
||||||
.three-grid,
|
.three-grid,
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.footer-inner {
|
.footer-inner,
|
||||||
|
.card-grid,
|
||||||
|
.focus-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.about-band,
|
||||||
|
.services-band,
|
||||||
|
.work-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-media img {
|
||||||
|
position: static;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.detail-nav {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Responsive-image lookup over the manifest written by scripts/optimize-images.mjs.
|
||||||
|
// Plain JS (not TS) because astro.config.mjs loads it too, for the Markdown rehype plugin.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const manifestPath = path.join(process.cwd(), "src", "data", "image-manifest.json");
|
||||||
|
|
||||||
|
/** @type {Record<string, { width: number; height: number; avif: { src: string; width: number }[]; webp: { src: string; width: number }[] }> | undefined} */
|
||||||
|
let manifest;
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (!manifest) {
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
// No manifest (optimize-images not run): callers fall back to the plain original image.
|
||||||
|
manifest = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsive variants for a /public image path, or undefined if it has none.
|
||||||
|
* @param {string} src
|
||||||
|
*/
|
||||||
|
export function imageVariants(src) {
|
||||||
|
const entry = load()[src];
|
||||||
|
if (!entry) return undefined;
|
||||||
|
/** @param {{ src: string; width: number }[]} list */
|
||||||
|
const srcset = (list) => list.map((v) => `${v.src} ${v.width}w`).join(", ");
|
||||||
|
return { width: entry.width, height: entry.height, avif: srcset(entry.avif), webp: srcset(entry.webp) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rehype plugin: wraps Markdown <img> tags that have variants in a <picture> with AVIF/WebP
|
||||||
|
* sources, and adds intrinsic dimensions (no layout shift) plus lazy loading.
|
||||||
|
* @param {{ sizes?: string }} [options]
|
||||||
|
*/
|
||||||
|
export function rehypeResponsiveImages({ sizes = "(max-width: 900px) 100vw, 50vw" } = {}) {
|
||||||
|
/** @param {any} node */
|
||||||
|
const walk = (node) => {
|
||||||
|
if (!node.children) return;
|
||||||
|
node.children = node.children.map((child) => {
|
||||||
|
walk(child);
|
||||||
|
if (child.type !== "element" || child.tagName !== "img") return child;
|
||||||
|
const variants = imageVariants(String(child.properties?.src ?? ""));
|
||||||
|
if (!variants) return child;
|
||||||
|
const img = {
|
||||||
|
...child,
|
||||||
|
properties: {
|
||||||
|
...child.properties,
|
||||||
|
width: variants.width,
|
||||||
|
height: variants.height,
|
||||||
|
loading: "lazy",
|
||||||
|
decoding: "async",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const source = (/** @type {string} */ type, /** @type {string} */ srcset) => ({
|
||||||
|
type: "element",
|
||||||
|
tagName: "source",
|
||||||
|
properties: { type, srcSet: srcset, sizes },
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: "element",
|
||||||
|
tagName: "picture",
|
||||||
|
properties: {},
|
||||||
|
children: [source("image/avif", variants.avif), source("image/webp", variants.webp), img],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (/** @type {any} */ tree) => walk(tree);
|
||||||
|
}
|
||||||
@@ -64,14 +64,14 @@ test("desktop navigation and CTA links are clickable", async ({ page }) => {
|
|||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await page.getByRole("link", { name: "Explore services" }).click();
|
await page.getByRole("banner").getByRole("link", { name: "Services", exact: true }).first().click();
|
||||||
await expect(page).toHaveURL(/\/services\/$/);
|
await expect(page).toHaveURL(/\/services\/$/);
|
||||||
await expect(page.getByRole("heading", { name: /Advisory services/ })).toBeVisible();
|
await expect(page.getByRole("heading", { level: 1, name: "Services" })).toBeVisible();
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("link", { name: "Request a consultation" }).first().click();
|
await page.getByRole("link", { name: "Request a consultation" }).first().click();
|
||||||
await expect(page).toHaveURL(/\/contact\/$/);
|
await expect(page).toHaveURL(/\/contact\/$/);
|
||||||
await expect(page.getByRole("heading", { name: /Get in touch/ })).toBeVisible();
|
await expect(page.getByRole("heading", { level: 1, name: "Contact Us" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("service and focus-area learn more links are wired to detail pages", async ({ page }) => {
|
test("service and focus-area learn more links are wired to detail pages", async ({ page }) => {
|
||||||
@@ -98,7 +98,7 @@ test("mobile menu opens and navigates", async ({ page }) => {
|
|||||||
await expect(page.locator(".mobile-nav nav")).toBeVisible();
|
await expect(page.locator(".mobile-nav nav")).toBeVisible();
|
||||||
await page.locator(".mobile-nav").getByRole("link", { name: "Focus Areas" }).click();
|
await page.locator(".mobile-nav").getByRole("link", { name: "Focus Areas" }).click();
|
||||||
await expect(page).toHaveURL(/\/focus-areas\/$/);
|
await expect(page).toHaveURL(/\/focus-areas\/$/);
|
||||||
await expect(page.getByRole("heading", { name: /Focused expertise/ })).toBeVisible();
|
await expect(page.getByRole("heading", { level: 1, name: "Core Focus Areas" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("contact form validates required, invalid, and valid paths", async ({ page }) => {
|
test("contact form validates required, invalid, and valid paths", async ({ page }) => {
|
||||||
@@ -123,12 +123,42 @@ test("contact form validates required, invalid, and valid paths", async ({ page
|
|||||||
|
|
||||||
await page.locator('input[name="email"]').fill("test@example.com");
|
await page.locator('input[name="email"]').fill("test@example.com");
|
||||||
await page.locator('textarea[name="message"]').fill("This is a valid test inquiry for BlueCap Strategies.");
|
await page.locator('textarea[name="message"]').fill("This is a valid test inquiry for BlueCap Strategies.");
|
||||||
await page.locator("form").evaluate((form) => {
|
|
||||||
form.setAttribute("data-endpoint", "/thank-you/");
|
// Exercise the real submit path: the form POSTs to the configured endpoint in the background
|
||||||
form.setAttribute("action", "/thank-you/");
|
// and only then sends the visitor to our own /thank-you/. Mock the endpoint so the test never
|
||||||
form.setAttribute("method", "get");
|
// touches the live form provider.
|
||||||
|
const endpoint = "https://forms.test.invalid/f/mock";
|
||||||
|
let posted: string | null = null;
|
||||||
|
await page.route(endpoint, async (route) => {
|
||||||
|
posted = route.request().postData();
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/json", body: '{"ok":true}' });
|
||||||
});
|
});
|
||||||
|
await page.locator("form").evaluate((form, url) => {
|
||||||
|
form.setAttribute("data-endpoint", url);
|
||||||
|
}, endpoint);
|
||||||
|
|
||||||
await submit.click();
|
await submit.click();
|
||||||
await expect(page).toHaveURL(/\/thank-you\/(?:\?.*)?$/);
|
await expect(page).toHaveURL(/\/thank-you\/$/);
|
||||||
await expect(page.getByRole("heading", { name: "Thank you" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Thank you" })).toBeVisible();
|
||||||
|
expect(posted, "form fields should reach the endpoint").toContain("test@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contact form surfaces an error when the endpoint fails", async ({ page }) => {
|
||||||
|
await page.goto("/contact/");
|
||||||
|
const endpoint = "https://forms.test.invalid/f/mock";
|
||||||
|
await page.route(endpoint, (route) => route.fulfill({ status: 500, body: "nope" }));
|
||||||
|
await page.locator("form").evaluate((form, url) => {
|
||||||
|
form.setAttribute("data-endpoint", url);
|
||||||
|
}, endpoint);
|
||||||
|
|
||||||
|
await page.locator('input[name="name"]').fill("Test User");
|
||||||
|
await page.locator('input[name="email"]').fill("test@example.com");
|
||||||
|
await page.locator('textarea[name="message"]').fill("This is a valid test inquiry for BlueCap Strategies.");
|
||||||
|
await page.getByRole("button", { name: "Submit" }).click();
|
||||||
|
|
||||||
|
// A failed send must not strand the visitor: it stays on the page, says so, and offers the
|
||||||
|
// phone/email fallback rather than silently losing the inquiry.
|
||||||
|
await expect(page).toHaveURL(/\/contact\/$/);
|
||||||
|
await expect(page.locator("[data-form-status]")).toContainText("didn't send");
|
||||||
|
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled();
|
||||||
});
|
});
|
||||||