Compare commits

...
37 Commits
Author SHA1 Message Date
gitea-actions[bot] a5dd5f47ea ci: deploy 550392aa17 [skip ci] 2026-10-23 21:27:21 +00:00
bmr_bluecap 550392aa17 fonts: load only Latin + Latin Extended Open Sans subsets
CI / ci (push) Successful in 1m55s
The full fontsource import also declared math/symbols subsets, so the homepage's single
arrow glyph pulled ~77 KB of extra font files. The arrow now renders in the system font.
2026-10-23 17:25:14 -04:00
gitea-actions[bot] 556e8ce874 ci: deploy dbd77b3bb5 [skip ci] 2026-10-23 21:17:18 +00:00
bmr_bluecap dbd77b3bb5 nginx: immutable cache only for hashed assets; 7d for fixed-URL files; add avif
CI / ci (push) Successful in 1m55s
Also repeats the security headers inside each location, since a location-level add_header
drops the server-level ones.
2026-10-23 17:15:11 -04:00
gitea-actions[bot] 523f7303e0 ci: deploy be55c4a27e [skip ci] 2026-10-23 21:01:50 +00:00
bmr_bluecap be55c4a27e favicon: restore the original site icon (water drop in circle)
CI / ci (push) Successful in 3m15s
Recovered from the Wayback Machine capture of the WordPress site; adds /favicon.ico,
32/192 PNG icons, apple-touch-icon and the MS tile image.
2026-10-23 16:58:13 -04:00
bmr_bluecap 59caa8e838 perf: responsive AVIF/WebP images; a11y contrast and link-text fixes
- scripts/optimize-images.mjs generates hashed AVIF/WebP variants at build time (sharp)
- Picture.astro + rehype plugin serve them via <picture> with srcset/sizes; hero gets fetchpriority=high
- footer CTA and active service-nav item meet WCAG AA contrast
- homepage Learn More link gets screen-reader-only context
- llms.txt: insights described as coming soon
2026-10-23 16:58:13 -04:00
bmr_bluecap 002845c880 chore: untrack Playwright MCP captures and root screenshot; trim docker build context
Also records the gitea runner durable fix in the launch checklist.
2026-10-23 16:58:13 -04:00
gitea-actions[bot] 76326da0ca ci: deploy 04e0c4d9cd [skip ci] 2026-10-23 19:40:07 +00:00
bmr_bluecap 04e0c4d9cd insights: coming-soon page; block AI training crawlers; fix stale e2e
CI / ci (push) Successful in 1m34s
- /insights/ is a reader-facing Coming soon page; developer cards removed
- robots.txt: Disallow GPTBot, ClaudeBot, Google-Extended, CCBot,
  Applebot-Extended, meta-externalagent, Bytespider. Search and AI
  answer crawlers stay allowed. Corrects the old Google-Extended
  comment: it does not affect Search or AI Overviews.
- e2e: assert the real headings and the header Services link (48/48)
2026-10-23 14:40:51 -04:00
bmr_bluecap e07cb8545e docs: external uptime check via UptimeRobot -> Pushover 2026-10-23 14:33:54 -04:00
bmr_bluecap 5c825b08e9 docs: stats internal cert issued after DNS cache expiry 2026-10-23 14:32:54 -04:00
bmr_bluecap 450bba8bea docs: rewrite launch checklist as post-cutover status
Records the live architecture, launch-day findings (Pangolin cert retry,
health-check Host header, nginx 404 loop, CI rebase, homelab DNS
interception, runner socket) and the remaining open items.
2026-10-23 14:19:52 -04:00
gitea-actions[bot] c7033140f0 ci: deploy 0c2b4a6ac6 [skip ci] 2026-10-23 18:10:59 +00:00
bmr_bluecap 0c2b4a6ac6 feat: add privacy policy; wire GA4 and Umami at build time
CI / ci (push) Successful in 2m13s
- /privacy/ page covering the contact form (Formspree), Umami, GA4 and
  server logs; linked from the footer
- Analytics.astro: GA4 alongside Umami, both limited to the production
  host so staging and local builds are not counted
- Dockerfile ARGs + CI build args from repo Actions variables
  GA_MEASUREMENT_ID and UMAMI_WEBSITE_ID (non-secret)
2026-10-23 13:49:56 -04:00
gitea-actions[bot] 375ee48ab1 ci: deploy 93415c6852 [skip ci] 2026-10-23 17:42:42 +00:00
bmr_bluecap 93415c6852 ci: rebase and retry the image-tag commit when main has moved
CI / ci (push) Successful in 1m34s
A re-run of an older commit (or any push landing mid-build) made the
final push non-fast-forward, so the image was built but never deployed.
2026-10-23 12:37:42 -04:00
bmr_bluecap da1043e6f3 k8s: make www canonical; 301 the apex to www
Adds a Traefik redirectRegex middleware and a dedicated apex ingress
that reuses bluecap-website-tls, and drops the apex from the main
ingress rules.
2026-10-23 11:56:15 -04:00
bmr_bluecap d1c1726796 fix(nginx): return a real 404 instead of a 500 redirect loop
CI / ci (push) Failing after 1m50s
The 404 fallback pointed at /404/index.html, but Astro emits 404.html,
so every unknown URL looped internally and nginx answered 500. The
try_files fallback also served the error page with a 200 status.
Fall through to =404 and point error_page at /404.html.
2026-10-23 11:36:00 -04:00
bmr_bluecap e85edbfe34 docs: production cert issued; record the DNS-01 stall and resolver hardening
Marks the k3s half of section 1.1 done: bluecap-website-tls is Ready for both
production hostnames, valid to 2027-01-07, and the cluster serves it. Verified
by resolving straight to a control-plane node, so the public site stayed on
Cloudflare/Bluehost throughout.

Also records the Pangolin target table for step 2, and adds section 2.7 for
cert-manager validating DNS-01 propagation through CoreDNS rather than public
resolvers. That is not confirmed as the cause of the four-hour stall -
Cloudflare's own authoritative nameservers were returning NXDOMAIN for records
its API reported as present - but it is standard hardening worth doing before
renewals matter.
2026-10-09 15:19:12 -04:00
bmr_bluecap 6f802826df k8s: pre-issue the production TLS cert for the internal hop
Public traffic terminates TLS at the Pangolin VPS, which then proxies to the
control-plane nodes over https:443 — the pattern every other k3s-backed
Pangolin resource follows — so the cluster needs its own valid certificate for
the production hostnames, not just the staging one.

Add www.bluecapstrategies.com and bluecapstrategies.com to the tls block as
bluecap-website-tls. Safe to do before cutover, and deliberately done now:
letsencrypt-prod solves DNS-01 through Cloudflare, so issuance only needs a
temporary TXT record in the zone. No A record has to point at the cluster and
the live WordPress site is untouched. The Cloudflare token was extended to
cover the zone on 2026-10-09.

Both hostnames are listed because the canonical-host decision is still open;
holding a cert for each keeps either choice available, and the redirect is
configured separately.

Pangolin's edge certificate is HTTP-01 and cannot be pre-issued this way; it
is obtained on the first HTTPS request after DNS moves.
2026-10-09 08:05:19 -04:00
bmr_bluecap a76a6da26c docs: replace section 1.1 with a verified cutover runbook
The old entry assumed the cluster could be pointed at directly and that
DNS-01 removed the certificate ordering problem. Both were wrong once the
real traffic path was checked.

Public traffic has to transit the Pangolin VPS, so two certificates are
needed from two different systems: the edge cert from Pangolin's own Traefik
resolver, and the internal-hop cert from cert-manager, because every
k3s-backed Pangolin resource proxies to https:443 (the pattern Authentik,
paperless, immich and dav all follow).

Pangolin's resolver uses HTTP-01 on entrypoint web, not DNS-01, so the edge
cert cannot be pre-issued and a short window at cutover is unavoidable.
cert-manager can still pre-issue the internal cert. There is also only one
Cloudflare token, not two: Pangolin holds no Cloudflare credentials at all.

Records the proxied-vs-DNS-only decision, which earlier advice got backwards
(this zone is already orange-clouded while the homelab zones are not), the
Flexible-SSL redirect-loop risk, the ~5 minute rollback from ttl=auto, and
that mail is unaffected because the MX records are not proxied.

Cloudflare token now covers bluecapstrategies.com.
2026-10-09 08:01:20 -04:00
gitea-actions[bot] b51a243728 ci: deploy 55add6ba17 [skip ci] 2026-10-09 11:07:13 +00:00
bmr_bluecap 55add6ba17 fix(contact): use Formspree's reserved _subject field for the subject line
CI / ci (push) Successful in 2m6s
The hidden field was named `subject`, which Formspree treats as ordinary form
data: notifications would have arrived under a generic "New submission"
subject with "subject" showing up as just another row in the body. The
reserved field is `_subject`, with the underscore.

build 0/0, contact e2e 4 passed.
2026-10-09 07:04:59 -04:00
bmr_bluecap 120ffb7445 docs: correct section 1.4 - the form endpoint is a build-time value, not runtime
The entry said to set PUBLIC_CONTACT_FORM_ENDPOINT in the ConfigMap. That
ConfigMap is not consumed by the Deployment, and a static Astro build inlines
PUBLIC_* at image build time regardless, so there is no runtime knob to set.
Record the build-arg wiring that replaced it and the two steps that still
need the owner: creating the Formspree form and adding the
CONTACT_FORM_ENDPOINT Actions secret.
2026-10-09 07:04:59 -04:00
bmr_bluecap 0f769d7bdd feat(contact): wire the form to a real endpoint at build time
The contact form could not be made to work by configuration. Two reasons,
both fixed here.

The endpoint never reached the browser. k8s/configmap.yaml carried
PUBLIC_CONTACT_FORM_ENDPOINT, but the Deployment has no env/envFrom pointing
at that ConfigMap, so it was dead config. Wiring it up would not have helped
either: this is a static Astro build, so import.meta.env.PUBLIC_* is inlined
when the image is built and the pod only serves pre-built HTML. Anything the
browser needs has to arrive as a build arg. Add ARG/ENV to the Dockerfile and
pass --build-arg from CI, sourced from a CONTACT_FORM_ENDPOINT Actions
secret. Rewrite the ConfigMap as documentation of what the build expects,
with an explicit warning not to add envFrom.

Submission behaviour was also wrong for visitors. A native POST would have
navigated them to the form provider's page; post in the background with
fetch() and send them to our own /thank-you/ instead. On failure the form now
stays put, re-enables the button and offers the phone and email fallback,
rather than silently losing the inquiry. Add a honeypot (_gotcha, which
Formspree treats as spam) positioned off-screen rather than display:none, so
bots that skip hidden fields still fill it.

Read data-endpoint at submit time rather than caching it at init, keeping the
attribute the single source of truth.

Tests: replace the old contact assertion, which faked a native GET
navigation, with one that mocks the endpoint and exercises the real fetch
path including the posted payload. Add a case for endpoint failure.

typecheck 0/0/0, build 0/0, seo:lint clean on 20 pages, e2e 44 passed.
The 4 remaining failures are the pre-existing stale nav assertions in
docs/launch-checklist.md section 2.1, untouched here.
2026-10-09 07:04:59 -04:00
gitea-actions[bot] 8a4c4bccec ci: deploy 5bfc5981cd [skip ci] 2026-09-23 13:50:52 +00:00
bmr_bluecap 5bfc5981cd docs: record contact/draft fixes, staging URL, and DNS-01 cutover finding
CI / ci (push) Successful in 1m29s
Marks sections 1.3 and 1.5 fixed, adds section 2.6 for the staging URL, and
records that letsencrypt-prod solves via DNS-01 against Cloudflare - which
removes the HTTP-01 ordering problem from section 1.1, leaving only the
question of whether bluecapstrategies.com is reachable by the cert-manager
Cloudflare token.

Adds section 2.5 for developer-facing copy found on the /insights/ index
while fixing the draft leak. Left unfixed on purpose: what that page should
say depends on the owner decision in section 1.5.
2026-09-23 09:48:40 -04:00
bmr_bluecap 4e354d2721 fix: stop publishing draft insights; remove developer copy from contact page
Two defects that would have shipped to visitors at cutover.

Draft posts were reaching the build. The insights schema declares a `draft`
flag and rss.xml.ts filters on it, but [slug].astro called getCollection()
unfiltered, so /insights/example-draft/ was generated and listed in
sitemap-0.xml. "Example draft insight" would have been crawled and indexed.
Filter in getStaticPaths, matching the existing pattern in rss.xml.ts. Build
drops from 21 to 20 pages and the URL is gone from the sitemap.

The contact page rendered two notes written for developers, not visitors: one
explaining that the form "posts to the configured endpoint when
PUBLIC_CONTACT_FORM_ENDPOINT is set", and a "Discovery note" about the
WordPress page containing placeholder contact values. Replace the first with
a line addressed to the reader and drop the second; the deviation it
described is already recorded in docs/content-deviations.md, which is where
it belongs.

build 0/0, seo:lint clean on 20 pages. e2e unchanged at 42/46 — the 4
failures are the pre-existing stale assertions tracked in
docs/launch-checklist.md section 2.1.
2026-09-23 09:48:07 -04:00
bmr_bluecap 171c8c96aa k8s: add LAN-only staging host bcs.int.7tl-homelab.com with TLS
There was no way to view the rebuild in a browser: the ingress answered only
on websecure for the production hostnames, which have no cert and still
belong to the live WordPress site. Review meant a port-forward.

Add bcs.int.7tl-homelab.com as a staging route. The *.int zone resolves to
the internal traefik LB (10.66.15.30) and is not reachable from the internet,
so this exposes nothing publicly and leaves the real domain untouched. It
follows the same pattern as the existing argocd/lidarr/tautulli ingresses.

Enable cert-manager on the ingress, but scope the tls block to the staging
host only. The production hostnames stay out of it deliberately: requesting a
cert for names the live site still serves is a cutover step, not a
prerequisite.

Fix the issuer name while here. The commented-out placeholder said
"letsencrypt-production"; the ClusterIssuer is actually "letsencrypt-prod",
so the old hint would have silently failed to issue.

Note for cutover: letsencrypt-prod solves via DNS-01 against Cloudflare, so
the production cert can be issued before DNS moves, avoiding the HTTP-01
ordering problem. Conditional on bluecapstrategies.com being reachable by the
cert-manager Cloudflare token, which is not yet verified.
2026-09-22 15:57:09 -04:00
gitea-actions[bot] 5331e7d717 ci: deploy c19b2b2af4 [skip ci] 2026-09-22 19:49:14 +00:00
bmr_bluecap c19b2b2af4 ci: install docker + kustomize as static binaries, not distro packages
CI / ci (push) Successful in 2m37s
CI has been failing silently since 2026-08-03. The run for 9f1cbca did
execute (Gitea action_run #6, status=failure, 13m) but died before pushing an
image, so no package version exists for that commit and ArgoCD has been
serving the 2026-07-20 build ever since.

Cause: the "Ensure docker + kustomize are available" step ran
`apt-get install -y docker.io`. The runner's job image was node:18-bullseye
and Debian 11 went EOL, so debian-security stopped serving those package
versions. `apt-get update` still succeeds, so the failure surfaces only at
install time as 404s on docker.io, containerd, systemd and libcap2.

Fetch both tools as pinned static binaries instead. These have no distro
archive dependency, so the same rot cannot recur when bookworm ages out.
Verified in the real job environment: the static client installs and reaches
the host daemon through the mounted socket.

The runner job image has separately been moved to node:22-bookworm, which
also resolves the immediate breakage, but the static-binary approach is kept
as the durable fix.

Also refresh the prerequisites header: items 3 and 4 still described the
pre-ADR-0008 registry design (k3s now pulls over public HTTPS, and the
private package's pull auth comes from External Secrets).
2026-09-22 15:45:57 -04:00
bmr_bluecap 79e7fd4b2b docs: add maintained launch checklist; de-duplicate stale task lists
Cutover tasks were scattered across three sources that disagreed with each
other and with reality: planning/parity-checklist.md (git-ignored, content
only), docs/seo-aeo-optimization.md (SEO only, listed the Zod schema,
seo-lint and breadcrumbs as open when all three are done, and called for
Uptime Kuma which ADR-0004 replaced with monylog), and session notes.

Add docs/launch-checklist.md as the single source of truth, verified against
the working tree, the cluster and ArgoCD rather than inherited from the older
docs. Point the SEO doc's two checkbox blocks at it and keep only reference
detail there.

Also records blockers that were not tracked anywhere: TLS never wired on the
ingress, developer-facing copy rendered on the contact page, the contact form
having no endpoint, and the draft insight post building into the sitemap
because the draft flag is declared in Zod but never filtered on.
2026-09-22 15:45:48 -04:00
bmr_bluecap 9f1cbca1b1 Website cnages trying to get it to be an exact copy of what is currently out on the web via Bluehost
CI / ci (push) Failing after 13m9s
2026-08-03 09:45:10 -04:00
gitea-actions[bot] 042e80ac2c ci: deploy fc64909f3d [skip ci] 2026-07-20 12:30:05 +00:00
gitea_admin fc64909f3d docs: reconcile to as-built (ADR-0008, deploy runbook); clean text hero on detail pages
CI / ci (push) Successful in 1m56s
- ADR-0008: private package, External-Secrets pull over public HTTPS (no k3s restart);
  CI auth via CI_TOKEN PAT; Playwright dropped from CI. Marks ADR-0007 partially superseded.
- gitops-deployment-strategy + deployment.md updated to as-built flow + operate/troubleshoot runbook
- content-deviations: detail-page heroes are text-only (no image)
- service/focus detail pages: clean text hero (drop shared photo/icon)
2026-07-20 08:27:35 -04:00
gitea-actions[bot] 99d4a3bea9 ci: deploy b9f2c72bd4 [skip ci] 2026-07-17 11:38:52 +00:00
50 changed files with 1861 additions and 306 deletions

No files matched your search

+8
View File
@@ -10,3 +10,11 @@ k8s
.DS_Store
.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
+3 -1
View File
@@ -1,7 +1,9 @@
PUBLIC_SITE_URL=https://www.bluecapstrategies.com
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):
PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
+48 -9
View File
@@ -8,10 +8,10 @@
# 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
# ("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)
# for 10.66.15.22:3000, then restart k3s, or the pods can't pull the image.
# 4. The Gitea container package should be PUBLIC (simplest). If PRIVATE, add an imagePullSecret
# (see k8s/deployment.yaml) and grant the run token package:write.
# 3. K3S does NOT need registries.yaml — it pulls the same package over public HTTPS
# (git.7tl-homelab.com, valid cert). See ADR-0008. No node config, no cluster restart.
# 4. The Gitea package is PRIVATE; k3s pull auth comes from External Secrets ->
# 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)
# and the package registry.
@@ -61,12 +61,32 @@ jobs:
id: tag
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
env:
DOCKER_CLI_VERSION: '28.3.2'
KUSTOMIZE_VERSION: 'v5.4.3'
run: |
command -v docker >/dev/null 2>&1 || { apt-get update && apt-get install -y docker.io; }
command -v kustomize >/dev/null 2>&1 || \
curl -sSL "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz" \
set -euo pipefail
if ! command -v docker >/dev/null 2>&1; then
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
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
# 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
run: |
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.
push_retry() {
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 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 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
+6 -1
View File
@@ -2,6 +2,9 @@
node_modules/
dist/
.astro/
# Generated by scripts/optimize-images.mjs at build time
public/assets/opt/
src/data/image-manifest.json
# Secrets — only .env.example is committed
.env
@@ -13,9 +16,11 @@ dist/
npm-debug.log*
.DS_Store
# Test artifacts
# Test artifacts and ad-hoc browser captures (Playwright MCP snapshots, root screenshots)
test-results/
playwright-report/
.playwright-mcp/
/*.png
# Working/planning docs — kept on disk, not version-controlled (see docs/ for committed records)
planning/
+14
View File
@@ -6,6 +6,20 @@ RUN npm ci
FROM node:22-alpine AS build
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 . .
RUN npm run build
+5
View File
@@ -1,6 +1,7 @@
// Static Astro site for BlueCap Strategies. CI: .gitea/workflows/ci.yml (Gitea Actions).
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
import { rehypeResponsiveImages } from "./src/utils/images.mjs";
const site = process.env.PUBLIC_SITE_URL ?? "https://www.bluecapstrategies.com";
@@ -8,6 +9,10 @@ export default defineConfig({
site,
output: "static",
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: [
sitemap({
filter: (page) => !page.includes("/thank-you/"),
+19 -3
View File
@@ -14,13 +14,29 @@ server {
}
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;
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";
}
# 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
[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
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.
+2 -1
View File
@@ -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 |
| [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 |
| [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 |
+9
View File
@@ -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).
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)
- **SEO meta descriptions** (`description` frontmatter, 120–158 chars) are newly written marketing
+35 -22
View File
@@ -30,41 +30,54 @@ docker build -t 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
`docs/gitops-deployment-strategy.md`.
The intended flow is:
Live GitOps flow (all working; see `docs/gitops-deployment-strategy.md` + ADR-0001/0007/0008):
```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
Actions should not receive kubeconfig or direct access to the homelab cluster.
- ArgoCD is the reconciler (not Flux); the CI runner never gets kubeconfig or cluster access.
- **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
inspection, and emergency operations. If you intentionally choose to apply the current manifests
by hand during a controlled bootstrap test, the command would be:
### Operate
```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
cert-manager are installed, with a ready `letsencrypt-prod` ClusterIssuer. However, TLS and ingress
hardening should remain documented rather than applied until the Pangolin/Newt edge path is
confirmed.
- **CI job never runs / "no matching runner"** → runner offline or mis-scoped. Must be a **global**
(owner_id=0) runner on `gitea_network` with instance URL `http://gitea:3000`; token must be an
**instance-level** registration token (Site Admin → Actions → Runners).
- **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.
- Whether Pangolin/Newt preserves `Host` headers for the BlueCap hostnames.
- Whether cert-manager's Cloudflare DNS-01 token can issue certificates for `bluecapstrategies.com`.
- Whether the canonical production host should be apex or `www`.
Ingress/TLS items to confirm before cutover: whether Pangolin/Newt terminates/passes/proxies TLS
to Traefik and preserves `Host` headers; whether cert-manager's Cloudflare DNS-01 token can issue
for `bluecapstrategies.com`; whether the canonical host is apex or `www`.
## DNS Cutover
+19 -14
View File
@@ -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`.
> 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
the Gitea Packages path; ArgoCD Application manifest written.
> **As built (2026-07-17) — see [ADR-0008](adr/0008-private-package-external-secrets-pull.md).**
> 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):**
- **k3s must trust the HTTP registry.** Add a `registries.yaml` entry for `10.66.15.22:3000` as
insecure/HTTP (via the k3s role `k3s_registry_mirrors`), then restart k3s — otherwise pods can't
pull the image.
- **Runner host dockerd** needs `10.66.15.22:3000` in `insecure-registries` to push over HTTP.
- **Docker usable from job containers** (act_runner socket passthrough) — or switch the build to
kaniko.
- **Package visibility:** make the container package **public** (simplest; image is only the public
site), or keep it private and add an imagePullSecret (`k8s/deployment.yaml`), ideally via the
installed External Secrets operator.
- Apply the ArgoCD Application (once) or add it to the homelab-gitops app-of-apps.
**Registry flow (split push/pull, same private Gitea package, two access paths):**
- **CI pushes** to the **internal** `10.66.15.22:3000/bmr_bluecap/bcs-website` (HTTP, fast). The
runner host's dockerd trusts it via `insecure-registries` (gitea Ansible role + SIGHUP
live-reload — no restart). Docker in jobs works because act_runner's config pins jobs to
`gitea_network` and exposes the docker socket.
- **k3s pulls** the **public HTTPS** `git.7tl-homelab.com/bmr_bluecap/bcs-website` (valid TLS) →
**no `registries.yaml`, no node config, no k3s restart.** (`k3s_registry_mirrors` stays empty.)
- **Pull auth:** package is **private**; a `read:package` PAT lives in OpenBao (`cluster/bluecap-registry`)
and External Secrets (`k8s/externalsecret-registry.yaml`) materializes the `gitea-registry`
dockerconfigjson used by the Deployment's `imagePullSecrets`.
- **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
+205
View File
@@ -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.
+21 -17
View File
@@ -80,14 +80,20 @@ Already implemented in `src/layouts/BaseLayout.astro` (keep + enforce):
To add/verify:
- [ ] **Per-page OG images** — currently every page falls back to `/assets/water-strategy.jpg`.
Generate topic-specific OG images (services, focus areas, insights) at 1200×630.
- [ ] **`robots` meta** wired to the `noindex` frontmatter flag (for `/thank-you/`, staging).
- [ ] **`article:published_time` / `modified_time`** OG tags on insights posts.
- [ ] **Breadcrumbs** (visible + `BreadcrumbList` schema) on services/focus/insights detail pages.
- [ ] **Sitemap** already via `@astrojs/sitemap` (excludes `/thank-you/`); add `lastmod` from
> **Tracked in [`launch-checklist.md`](./launch-checklist.md) §4.** The list that used to live
> here went stale (breadcrumbs are done). This section keeps the *reference* detail below; the
> live task state is in the launch checklist only.
- **Per-page OG images** — every page currently falls back to one shared image
(`BaseLayout.astro` emits a single `og:image`). Generate topic-specific 1200×630 images for
services, focus areas, and insights.
- **`robots` meta** wired to a `noindex` frontmatter flag (for `/thank-you/`, staging). Not wired
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 +
- **Trailing-slash consistency** — config uses `trailingSlash: "always"`; ensure canonical +
internal links all match to avoid duplicate-URL dilution.
## 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
- [ ] Zod SEO schema in place; build fails on title/description/OG/canonical violations.
- [ ] `scripts/seo-lint.mjs` (single H1, heading order, canonical, alt text, JSON-LD parses) green in CI.
- [ ] Every page: unique title, description in window, canonical, OG image, valid JSON-LD.
- [ ] 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`.
- [ ] Canonical host decided (apex vs `www`) + redirect configured at the edge.
- [ ] Search Console + Bing verified, sitemap submitted.
- [ ] Analytics deployed (Umami) + consultation/CTA events firing.
- [ ] Uptime Kuma + external check live and alerting.
- [ ] Lighthouse ≥ 95 across the board; no CLS from unsized images.
> **Moved.** This list is no longer maintained here — it duplicated, and disagreed with, reality
> (it listed the Zod schema, `seo-lint`, and breadcrumbs as open when all three are done, and
> called for Uptime Kuma, which ADR-0004 replaced with `monylog`).
>
> **The maintained checklist is [`launch-checklist.md`](./launch-checklist.md).** SEO items are
> in §3 (owner decisions: canonical host, robots policy) and §4 (post-cutover: OG images,
> `noindex` meta, article times, `lastmod`, Lighthouse, trailing slashes). What is already
> complete is recorded in §5.
## Sources (verified 2026-07-15)
+10 -1
View File
@@ -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
kind: ConfigMap
metadata:
name: bluecap-website-config
namespace: bluecap-strategies
data:
# Mirrors the build arg default in the Dockerfile.
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: ""
+71 -8
View File
@@ -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
kind: Ingress
metadata:
@@ -5,16 +16,40 @@ metadata:
namespace: bluecap-strategies
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
# If cert-manager is installed, uncomment and set your ClusterIssuer.
# cert-manager.io/cluster-issuer: letsencrypt-production
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
# If cert-manager is installed, uncomment tls and set the secret name.
# tls:
# - hosts:
# - www.bluecapstrategies.com
# - bluecapstrategies.com
# secretName: bluecap-website-tls
tls:
- hosts:
- bcs.int.7tl-homelab.com
secretName: bcs-int-tls
# Production hostnames. This is the INTERNAL hop only: public traffic terminates TLS at the
# 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:
- host: bcs.int.7tl-homelab.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bluecap-website
port:
name: http
- host: www.bluecapstrategies.com
http:
paths:
@@ -25,6 +60,34 @@ spec:
name: bluecap-website
port:
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
http:
paths:
+2 -1
View File
@@ -12,4 +12,5 @@ resources:
# Name is the public HTTPS pull endpoint that k3s uses.
images:
- name: git.7tl-homelab.com/bmr_bluecap/bcs-website
newTag: latest
newName: git.7tl-homelab.com/bmr_bluecap/bcs-website
newTag: 550392aa17c5
+44 -3
View File
@@ -10,12 +10,17 @@
"dependencies": {
"@astrojs/rss": "^4.0.12",
"@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"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@playwright/test": "^1.60.0",
"prettier": "^3.5.3",
"sharp": "0.34.5",
"typescript": "^5.8.3"
}
},
@@ -729,12 +734,48 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"devOptional": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -2586,8 +2627,8 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -4912,9 +4953,9 @@
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
+8 -2
View File
@@ -4,9 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev --host 0.0.0.0",
"build": "ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build",
"dev": "npm run images && ASTRO_TELEMETRY_DISABLED=1 astro dev --host 0.0.0.0",
"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",
"images": "node scripts/optimize-images.mjs",
"typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check",
"lint": "ASTRO_TELEMETRY_DISABLED=1 astro check",
"seo:lint": "node scripts/seo-lint.mjs",
@@ -16,12 +17,17 @@
"dependencies": {
"@astrojs/rss": "^4.0.12",
"@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"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@playwright/test": "^1.60.0",
"prettier": "^3.5.3",
"sharp": "0.34.5",
"typescript": "^5.8.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+1 -1
View File
@@ -10,7 +10,7 @@ Public sections:
- /focus-areas/: Core focus areas.
- /about-us/: Firm vision, mission, values, and approach.
- /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
metadata and structured data.
+21 -13
View File
@@ -1,8 +1,6 @@
# BlueCap Strategies robots.txt — see docs/seo-aeo-optimization.md §5
# Policy: allow classic search + AI answer/search crawlers for maximum discoverability and
# answer-engine citation. Training-crawler policy (GPTBot, ClaudeBot, Google-Extended) is an
# owner decision — currently ALLOWED for maximum reach. To opt out of model training, uncomment
# the "Disallow: /" lines in the training-crawler block below.
# answer-engine citation. Model-TRAINING crawlers (GPTBot, ClaudeBot, Google-Extended, …) are BLOCKED (owner decision, 2026-10-23).
# --- Classic search engines ---
User-agent: Googlebot
@@ -27,19 +25,29 @@ Allow: /
User-agent: PerplexityBot
Allow: /
# Google-Extended controls Gemini + Google AI Overviews (this is also a training signal for
# 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) ---
# --- AI model-training crawlers — blocked (owner decision, 2026-10-23) ---
User-agent: GPTBot
Allow: /
# Disallow: /
Disallow: /
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 ---
User-agent: *
+68
View File
@@ -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)}`);
+26 -5
View File
@@ -1,15 +1,19 @@
---
/**
* Side-by-side visitor analytics (ADR-0005): PostHog + Umami, each gated by env vars so this is a
* no-op until configured. Set the PUBLIC_* vars at build time to enable either or both.
* Side-by-side visitor analytics, each gated by env vars so this is a no-op until configured.
* 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)
* - Umami: PUBLIC_UMAMI_SRC (script URL), PUBLIC_UMAMI_WEBSITE_ID
* Track the same event set in both to compare (see docs/seo-aeo-optimization.md §6b).
* Umami and GA4 only fire on the production host, so staging/LAN/local visits are not counted.
* Anything enabled here must be disclosed on /privacy/.
*/
const posthogKey = import.meta.env.PUBLIC_POSTHOG_KEY;
const posthogHost = import.meta.env.PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
const umamiSrc = import.meta.env.PUBLIC_UMAMI_SRC;
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 && (
<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>
)
}
+4 -3
View File
@@ -10,19 +10,20 @@ type Props = {
items: Item[];
label?: 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}>
{items.map((item) => (
<article class="link-card">
{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>
<p>{item.description}</p>
<a class="card-action" href={item.href}>Learn more</a>
<a class="card-action" href={item.href}>Learn More &rsaquo;</a>
</article>
))}
</div>
+3 -2
View File
@@ -1,11 +1,12 @@
---
import Picture from "@components/Picture.astro";
import { nav, serviceNav, site } from "@data/site";
---
<footer class="site-footer">
<div class="footer-inner">
<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>
Transforming environmental challenges into investable opportunities through strategy,
capital, and implementation support for aquatic resources.
@@ -28,6 +29,6 @@ import { nav, serviceNav, site } from "@data/site";
</section>
</div>
<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>
</footer>
+2 -1
View File
@@ -1,11 +1,12 @@
---
import Picture from "@components/Picture.astro";
import { nav, serviceNav } from "@data/site";
---
<header class="site-header">
<div class="header-inner">
<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>
<nav class="desktop-nav" aria-label="Primary navigation">
<a href="/">Home</a>
+52 -3
View File
@@ -1,26 +1,75 @@
---
import Picture from "@components/Picture.astro";
type Props = {
eyebrow?: string;
title: string;
lead?: string;
image?: string;
imageAlt?: string;
icon?: string;
variant?: "cover" | "text";
};
const {
eyebrow,
title,
lead,
image = "/assets/live-site/img_0067.jpg",
image,
imageAlt = "Water resources and coastal landscape",
icon,
variant = "text",
} = Astro.props;
const hasPhoto = Boolean(image);
---
<section class="page-hero">
{
variant === "cover" ? (
<section class="page-cover">
<Picture
class="hero-bg"
src={image ?? "/assets/live-site/hero-mangrove.jpg"}
alt=""
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>
<img src={image} alt={imageAlt} width="1280" height="854" loading="eager" />
{hasPhoto && image && (
<Picture src={image} alt={imageAlt} width="1280" height="854" loading="eager" fetchpriority="high" sizes="(max-width: 960px) 100vw, 560px" />
)}
</section>
)
}
+27
View File
@@ -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} />
)
}
+9 -1
View File
@@ -5,6 +5,9 @@ import Analytics from "@components/Analytics.astro";
import { site } from "@data/site";
import { assetUrl, canonical } from "@utils/url";
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";
type Props = {
@@ -37,7 +40,12 @@ const schemas = [organizationSchema(), websiteSchema(), ...jsonLd];
<title>{pageTitle}</title>
<meta name="description" content={description} />
<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" />
<meta property="og:type" content="website" />
<meta property="og:title" content={pageTitle} />
+36 -19
View File
@@ -6,22 +6,40 @@ import ContactCta from "@components/ContactCta.astro";
import { breadcrumbSchema, pageSchema, serviceSchema } from "@utils/schema";
export async function getStaticPaths() {
const services = await getCollection("services");
const focusAreas = await getCollection("focus-areas");
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 siblingsOf = (collection: Array<{ slug: string; data: { title: string } }>) =>
collection.map((e) => ({ title: e.data.title, href: `/${e.slug}/` }));
return [
...services.map((entry) => ({
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) => ({
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;
const { title, description, image } = entry.data;
// Faithful clone of the original detail pages: mangrove cover hero with the page title, then a
// 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 isService = kind === "Service";
const { Content } = await entry.render();
@@ -38,23 +56,22 @@ const 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">
<div class="detail-layout">
<div class="article-body">
<p><a class="text-link" href={parentHref}>&larr; {parentTitle} overview</a></p>
<aside class="detail-nav" aria-label={`${parentTitle} navigation`}>
<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 />
</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>
</section>
<ContactCta />
+9 -20
View File
@@ -7,7 +7,7 @@ import { breadcrumbSchema, pageSchema } from "@utils/schema";
const about = await getEntry("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 sectionNav = headings.filter((h) => h.depth === 2);
const pathname = "/about-us/";
@@ -26,28 +26,17 @@ const pathname = "/about-us/";
]),
]}
>
<PageHero
eyebrow={eyebrow}
title={heroTitle}
lead={heroLead}
image={heroImage ?? "/assets/live-site/img_0062.jpg"}
imageAlt={heroImageAlt}
/>
<PageHero variant="cover" title={title} image="/assets/live-site/hero-mangrove.jpg" />
<section class="section">
<div class="detail-layout">
<div class="article-body">
<Content />
</div>
<aside class="sidebar" aria-label="On this page">
<nav class="note-card anchor-nav" aria-label="Section navigation">
<h2>On this page</h2>
<ul>
{sectionNav.map((h) => (
<li><a href={`#${h.slug}`}>{h.text}</a></li>
<nav class="pill-nav" aria-label="Section navigation">
{sectionNav
.filter((h) => !/^join us/i.test(h.text))
.map((h) => (
<a class="pill" href={`#${h.slug}`}>{h.text}</a>
))}
</ul>
</nav>
</aside>
<div class="article-body page-article">
<Content />
</div>
</section>
<ContactCta />
+58 -20
View File
@@ -21,32 +21,24 @@ const endpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";
]),
]}
>
<PageHero
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"
/>
<PageHero variant="cover" title="Contact Us" image="/assets/live-site/hero-mangrove.jpg" />
<section class="section contact-layout">
<aside class="contact-panel">
<h2>Contact Info</h2>
<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 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>
<div class="form-panel">
<h2>Request a consultation</h2>
<p class="form-note">
The form is static-first and posts to the configured endpoint when
<code>PUBLIC_CONTACT_FORM_ENDPOINT</code> is set. Without an endpoint, it opens an email
draft after validating required fields.
Tell us about your project and we will follow up within two business days.
</p>
<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">
<label>
Name *
@@ -71,23 +63,69 @@ const endpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";
Message *
<textarea name="message" required minlength="20"></textarea>
</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>
</div>
</section>
<script>
const form = document.querySelector("[data-contact-form]");
form?.addEventListener("submit", (event) => {
const endpoint = form.getAttribute("data-endpoint");
if (endpoint) return;
if (form instanceof HTMLFormElement) {
const status = form.querySelector("[data-form-status]");
const submit = form.querySelector("[data-submit]");
const say = (message: string, isError: boolean) => {
if (!(status instanceof HTMLElement)) return;
status.textContent = message;
status.classList.toggle("is-error", isError);
status.hidden = false;
};
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();
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
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>
</BaseLayout>
+2 -7
View File
@@ -25,16 +25,11 @@ const description =
]),
]}
>
<PageHero
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"
/>
<PageHero variant="cover" title="Core Focus Areas" image="/assets/live-site/hero-mangrove.jpg" />
<section class="section">
<CardGrid
heading="Our core focus areas"
hideHeading
items={focusAreas.map((area) => ({
title: area.data.title,
description: area.data.summary,
+82 -41
View File
@@ -1,13 +1,21 @@
---
import { getCollection } from "astro:content";
import BaseLayout from "@layouts/BaseLayout.astro";
import CardGrid from "@components/CardGrid.astro";
import ContactCta from "@components/ContactCta.astro";
import Picture from "@components/Picture.astro";
import { pageSchema } from "@utils/schema";
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);
// 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 =
"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: "/" })]}
>
<section class="home-hero">
<div class="hero-copy">
<p class="eyebrow">Water-sector strategy and capital formation</p>
<h1>Leveraging capital to drive the restoration, preservation and responsible use of aquatic resources</h1>
<Picture
class="hero-bg"
src="/assets/live-site/hero-mangrove.jpg"
alt=""
aria-hidden="true"
width="1140"
height="713"
loading="eager"
fetchpriority="high"
/>
<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>
<div class="hero-actions">
<a class="button" href="/contact/">Request a Consultation</a>
<a class="text-link" href="/services/">Explore services</a>
</div>
</div>
<div class="hero-media">
<img
src="/assets/live-site/img_0067.jpg"
alt="Blue water and coastal landscape representing aquatic resource strategy"
width="1280"
height="854"
/>
</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>
<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>
<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>
</g>
</svg>
</section>
<section class="section alt" aria-labelledby="how-work">
<section class="section" aria-labelledby="how-work">
<div class="section-inner">
<div class="section-header">
<div class="section-header centered">
<p class="eyebrow">How We Work</p>
<h2 id="how-work">Unlocking Capital and Innovation to Solve Global Water Challenges</h2>
<p>
@@ -56,7 +74,7 @@ const description =
ecosystems and economies.
</p>
</div>
<div class="three-grid">
<div class="work-grid">
<article class="work-card">
<h3>Increasing the Supply of Capital</h3>
<p>
@@ -106,12 +124,11 @@ const description =
</div>
</section>
<section class="section" aria-labelledby="about-home">
<div class="media-band">
<img src="/assets/live-site/img_0053.jpg" alt="Aquatic ecosystem and coastal water" width="1024" height="679" />
<div class="section-header">
<p class="eyebrow">About Us</p>
<h2 id="about-home">Innovating towards a sustainable future</h2>
<section class="section tint" aria-labelledby="about-home">
<div class="section-inner about-band">
<div class="about-copy">
<h2 id="about-home" class="about-title">About Us</h2>
<p class="about-sub">Innovating towards a sustainable future</p>
<p>
At BlueCap Strategies, we transform environmental challenges into profitable, investable
opportunities. By driving catalytic capital into sustainable projects, we not only ensure
@@ -123,8 +140,7 @@ const description =
allows us to adapt and solve the most complex challenges with both insight and decisive
action.
</p>
<a class="text-link" href="/about-us/">Learn More</a>
</div>
<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>
@@ -132,11 +148,12 @@ const description =
<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 class="section alt" aria-labelledby="services-home">
<section class="section" aria-labelledby="services-home">
<div class="section-inner">
<div class="section-header">
<div class="section-header centered">
<p class="eyebrow">Our Services</p>
<h2 id="services-home">Strategy, capital, partnerships, and implementation</h2>
<p>
@@ -144,22 +161,37 @@ const description =
market intelligence and capital strategy through project delivery and reporting.
</p>
</div>
<CardGrid
items={services.map((service) => ({
title: service.data.title,
description: service.data.summary,
href: `/${service.slug}/`,
icon: service.data.icon,
}))}
label="BlueCap services"
<div class="services-band">
<div class="services-accordion">
{services.map((service, i) => (
<details class="svc-toggle" open={i === 0}>
<summary>{service.data.title}</summary>
<div class="svc-body">
<p>{service.data.summary}</p>
<a class="text-link" href={`/${service.slug}/`}>Learn More &rarr;</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>
</section>
<section class="section" aria-labelledby="focus-home">
<div class="section-header">
<section class="section tint" aria-labelledby="focus-home">
<div class="section-inner">
<div class="section-header centered">
<p class="eyebrow">Our Core Focus Areas</p>
<h2 id="focus-home">Where strategy, capital, and innovation shape the future of water resources</h2>
<h2 id="focus-home" class="focus-heading">Where strategy, capital, and innovation shape the future of water resources</h2>
<p>
At BlueCap Strategies, we tackle pressing water challenges by combining innovation, capital
mobilization, and data-driven insights. From securing grant funding to guiding investments,
@@ -168,13 +200,22 @@ const description =
</p>
</div>
<div class="focus-grid">
{focusAreas.map((area) => (
{focusAreas.map((area) => {
const img = focusImages[area.slug];
return (
<article class="focus-card">
{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>
<a class="text-link" href={`/${area.slug}/`}>Learn More &rarr;</a>
</article>
))}
);
})}
</div>
</div>
</section>
+3 -1
View File
@@ -6,7 +6,9 @@ import ContactCta from "@components/ContactCta.astro";
import { breadcrumbSchema, pageSchema } from "@utils/schema";
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) => ({
params: { slug: post.slug },
props: { post },
+3 -20
View File
@@ -5,7 +5,7 @@ import ContactCta from "@components/ContactCta.astro";
import { breadcrumbSchema, pageSchema } from "@utils/schema";
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
@@ -22,26 +22,9 @@ const description =
>
<PageHero
eyebrow="Insights"
title="Future publishing home for BlueCap Strategies"
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."
title="Coming soon"
lead="Articles, analysis, and updates from BlueCap Strategies will be published here."
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 />
</BaseLayout>
+108
View File
@@ -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>
+2 -7
View File
@@ -24,16 +24,11 @@ const description =
]),
]}
>
<PageHero
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"
/>
<PageHero variant="cover" title="Services" image="/assets/live-site/hero-mangrove.jpg" />
<section class="section">
<CardGrid
heading="Our services"
hideHeading
items={services.map((service) => ({
title: service.data.title,
description: service.data.summary,
+26
View File
@@ -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;
}
+622 -50
View File
@@ -1,17 +1,24 @@
:root {
--navy: #123047;
--blue: #1e6d91;
--navy: #12324a;
--blue: #0074a9; /* exact original azure */
--box: #f3f5f9; /* light box background (matches original .et_pb_column) */
--cyan: #3ca2bd;
--green: #4f7f65;
--ink: #18242d;
--muted: #5d6b75;
--line: #d9e2e7;
--wash: #f3f7f8;
--cyan-deep: #287a90; /* --cyan darkened to 4.9:1 against white text (WCAG AA) */
--green: #81b29a; /* muted sage — subheads (exact original rgb(129,178,154)) */
--box-blue: #a9d2eb; /* light-blue metric boxes (exact original rgb(169,210,235)) */
--ink: #1b2b36;
--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;
--accent: #c47f43;
--shadow: 0 18px 45px rgba(18, 48, 71, 0.12);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--radius: 15px 0 15px 15px; /* water-droplet shape (top-right sharp) — the original's site-wide motif */
--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;
}
/* <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 {
color: var(--blue);
}
@@ -46,26 +64,35 @@ a:hover {
h1,
h2,
h3 {
h3,
h4 {
margin: 0;
color: var(--navy);
line-height: 1.15;
color: var(--blue); /* original section headings are azure #0074a9, not navy */
font-family: var(--font-heading);
font-weight: 600;
line-height: 1.2;
letter-spacing: 0;
}
h1 {
max-width: 16ch;
font-size: 3.35rem;
max-width: 18ch;
font-size: clamp(2.3rem, 4.4vw, 3.35rem);
font-weight: 700;
line-height: 1.12;
}
h2 {
font-size: 2rem;
font-size: clamp(1.7rem, 3vw, 2.15rem);
}
h3 {
font-size: 1.2rem;
}
h4 {
font-size: 1.05rem;
}
p {
margin: 0;
}
@@ -98,7 +125,6 @@ p {
.footer-inner,
.section,
.page-hero,
.home-hero,
.contact-cta,
.detail-layout,
.insights-shell {
@@ -138,13 +164,24 @@ p {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
border-radius: 6px;
padding: 0.68rem 1rem;
background: var(--navy);
min-height: 46px;
border-radius: var(--btn-radius);
padding: 0.7rem 1.9rem;
background: var(--blue);
color: #fff !important;
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 {
@@ -217,24 +254,74 @@ p {
background: var(--wash);
}
/* Full-bleed image hero with a dark overlay card + wave divider (matches the original). */
.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;
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 {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
@@ -287,6 +374,47 @@ p {
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 {
max-width: 760px;
display: grid;
@@ -298,10 +426,29 @@ p {
color: var(--muted);
}
.section-header.centered {
max-width: 860px;
margin-inline: auto;
text-align: center;
justify-items: center;
}
.card-grid-heading {
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 {
width: 48px;
height: 48px;
@@ -344,20 +491,165 @@ p {
grid-template-columns: repeat(4, 1fr);
}
.stat,
.link-card,
.focus-card,
.work-card,
.note-card {
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: var(--radius);
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 {
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 {
@@ -367,28 +659,103 @@ p {
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 {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 26px;
align-items: start;
}
.link-card,
.focus-card {
background: #fff;
border: none;
border-radius: var(--radius);
padding: 0 0 1.6rem;
overflow: hidden;
box-shadow: var(--shadow);
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 {
color: var(--navy);
color: var(--blue);
font-size: 1.25rem;
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 {
color: var(--blue);
font-weight: 800;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
font-family: var(--font-heading);
font-weight: 600;
text-decoration: none;
}
.link-card .card-action:hover {
color: var(--navy);
}
.link-card .card-icon {
margin-bottom: 0.25rem;
}
.media-band {
@@ -399,14 +766,14 @@ p {
}
.media-band img {
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.article-body img {
width: 100%;
height: auto;
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin: 0.5rem 0 1.5rem;
}
@@ -419,13 +786,178 @@ p {
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 {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 3rem;
grid-template-columns: 260px minmax(0, 1fr);
gap: clamp(2rem, 5vw, 4rem);
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 {
display: grid;
gap: 2rem;
@@ -558,6 +1090,27 @@ textarea:focus {
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 {
background: #10293d;
color: rgba(255, 255, 255, 0.78);
@@ -598,7 +1151,7 @@ textarea:focus {
.footer-cta {
width: fit-content;
margin-top: 1rem !important;
background: var(--cyan);
background: var(--cyan-deep);
}
.footer-bottom {
@@ -608,6 +1161,11 @@ textarea:focus {
font-size: 0.92rem;
}
.footer-bottom a {
color: inherit;
text-decoration: underline;
}
.insights-shell {
padding: 4rem 0;
display: grid;
@@ -643,11 +1201,25 @@ textarea:focus {
.three-grid,
.stats-grid,
.footer-inner {
.footer-inner,
.card-grid,
.focus-grid {
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;
}
}
+74
View File
@@ -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);
}
+39 -9
View File
@@ -64,14 +64,14 @@ test("desktop navigation and CTA links are clickable", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
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.getByRole("heading", { name: /Advisory services/ })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: "Services" })).toBeVisible();
await page.goto("/");
await page.getByRole("link", { name: "Request a consultation" }).first().click();
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 }) => {
@@ -98,7 +98,7 @@ test("mobile menu opens and navigates", async ({ page }) => {
await expect(page.locator(".mobile-nav nav")).toBeVisible();
await page.locator(".mobile-nav").getByRole("link", { name: "Focus Areas" }).click();
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 }) => {
@@ -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('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/");
form.setAttribute("action", "/thank-you/");
form.setAttribute("method", "get");
// Exercise the real submit path: the form POSTs to the configured endpoint in the background
// and only then sends the visitor to our own /thank-you/. Mock the endpoint so the test never
// 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 expect(page).toHaveURL(/\/thank-you\/(?:\?.*)?$/);
await expect(page).toHaveURL(/\/thank-you\/$/);
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();
});