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
This commit is contained in:
bmr_bluecap committed 2026-10-23 16:58:13 -04:00
1 parent 002845c880
commit 59caa8e838
14 files changed
+225 -17

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
node_modules/ node_modules/
dist/ dist/
.astro/ .astro/
# Generated by scripts/optimize-images.mjs at build time
public/assets/opt/
src/data/image-manifest.json
# Secrets — only .env.example is committed # Secrets — only .env.example is committed
.env .env
+5
View File
@@ -1,6 +1,7 @@
// Static Astro site for BlueCap Strategies. CI: .gitea/workflows/ci.yml (Gitea Actions). // Static Astro site for BlueCap Strategies. CI: .gitea/workflows/ci.yml (Gitea Actions).
import { defineConfig } from "astro/config"; import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap"; import sitemap from "@astrojs/sitemap";
import { rehypeResponsiveImages } from "./src/utils/images.mjs";
const site = process.env.PUBLIC_SITE_URL ?? "https://www.bluecapstrategies.com"; const site = process.env.PUBLIC_SITE_URL ?? "https://www.bluecapstrategies.com";
@@ -8,6 +9,10 @@ export default defineConfig({
site, site,
output: "static", output: "static",
trailingSlash: "always", trailingSlash: "always",
markdown: {
// Article column is ~880px wide on desktop, full width below the 960px breakpoint.
rehypePlugins: [[rehypeResponsiveImages, { sizes: "(max-width: 960px) 100vw, 880px" }]],
},
integrations: [ integrations: [
sitemap({ sitemap({
filter: (page) => !page.includes("/thank-you/"), filter: (page) => !page.includes("/thank-you/"),
+6 -1
View File
@@ -140,7 +140,12 @@ not worth a force-push).
sitemap but emits no `noindex` meta tag. sitemap but emits no `noindex` meta tag.
- [ ] `article:published_time` / `article:modified_time` OG tags on insights posts. - [ ] `article:published_time` / `article:modified_time` OG tags on insights posts.
- [ ] `lastmod` in the sitemap from content dates. - [ ] `lastmod` in the sitemap from content dates.
- [ ] Lighthouse ≥ 95 on SEO / Perf / Best-Practices / Accessibility. - [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 - [ ] Umami/GA custom event for contact-form submits (today the `/thank-you/` pageview is the
conversion signal). conversion signal).
- [ ] Optional: move `bluecapstrategies.com` into Pangolin's `config.yml` `domains:` so it shows - [ ] Optional: move `bluecapstrategies.com` into Pangolin's `config.yml` `domains:` so it shows
+4 -3
View File
@@ -20,6 +20,7 @@
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"sharp": "0.34.5",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
}, },
@@ -773,8 +774,8 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2626,8 +2627,8 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -4952,9 +4953,9 @@
"version": "0.34.5", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.0.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
+4 -2
View File
@@ -4,9 +4,10 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev --host 0.0.0.0", "dev": "npm run images && ASTRO_TELEMETRY_DISABLED=1 astro dev --host 0.0.0.0",
"build": "ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build", "build": "npm run images && ASTRO_TELEMETRY_DISABLED=1 astro check && ASTRO_TELEMETRY_DISABLED=1 astro build",
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview --host 0.0.0.0", "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview --host 0.0.0.0",
"images": "node scripts/optimize-images.mjs",
"typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check", "typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check",
"lint": "ASTRO_TELEMETRY_DISABLED=1 astro check", "lint": "ASTRO_TELEMETRY_DISABLED=1 astro check",
"seo:lint": "node scripts/seo-lint.mjs", "seo:lint": "node scripts/seo-lint.mjs",
@@ -26,6 +27,7 @@
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"sharp": "0.34.5",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }
+1 -1
View File
@@ -10,7 +10,7 @@ Public sections:
- /focus-areas/: Core focus areas. - /focus-areas/: Core focus areas.
- /about-us/: Firm vision, mission, values, and approach. - /about-us/: Firm vision, mission, values, and approach.
- /contact/: Contact form, phone, and email. - /contact/: Contact form, phone, and email.
- /insights/: Prepared future publishing section. - /insights/: Articles and analysis (coming soon).
This file is supplemental. The complete public content is available in crawlable HTML pages with This file is supplemental. The complete public content is available in crawlable HTML pages with
metadata and structured data. metadata and structured data.
+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)}`);
+2 -1
View File
@@ -1,11 +1,12 @@
--- ---
import Picture from "@components/Picture.astro";
import { nav, serviceNav, site } from "@data/site"; import { nav, serviceNav, site } from "@data/site";
--- ---
<footer class="site-footer"> <footer class="site-footer">
<div class="footer-inner"> <div class="footer-inner">
<section class="footer-brand" aria-label="BlueCap Strategies summary"> <section class="footer-brand" aria-label="BlueCap Strategies summary">
<img src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="190" height="57" /> <Picture src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="190" height="57" sizes="175px" loading="lazy" />
<p> <p>
Transforming environmental challenges into investable opportunities through strategy, Transforming environmental challenges into investable opportunities through strategy,
capital, and implementation support for aquatic resources. capital, and implementation support for aquatic resources.
+2 -1
View File
@@ -1,11 +1,12 @@
--- ---
import Picture from "@components/Picture.astro";
import { nav, serviceNav } from "@data/site"; import { nav, serviceNav } from "@data/site";
--- ---
<header class="site-header"> <header class="site-header">
<div class="header-inner"> <div class="header-inner">
<a class="brand" href="/" aria-label="BlueCap Strategies home"> <a class="brand" href="/" aria-label="BlueCap Strategies home">
<img src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="220" height="66" /> <Picture src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="220" height="66" sizes="190px" />
</a> </a>
<nav class="desktop-nav" aria-label="Primary navigation"> <nav class="desktop-nav" aria-label="Primary navigation">
<a href="/">Home</a> <a href="/">Home</a>
+7 -2
View File
@@ -1,4 +1,6 @@
--- ---
import Picture from "@components/Picture.astro";
type Props = { type Props = {
eyebrow?: string; eyebrow?: string;
title: string; title: string;
@@ -24,7 +26,7 @@ const hasPhoto = Boolean(image);
{ {
variant === "cover" ? ( variant === "cover" ? (
<section class="page-cover"> <section class="page-cover">
<img <Picture
class="hero-bg" class="hero-bg"
src={image ?? "/assets/live-site/hero-mangrove.jpg"} src={image ?? "/assets/live-site/hero-mangrove.jpg"}
alt="" alt=""
@@ -32,6 +34,7 @@ const hasPhoto = Boolean(image);
width="1140" width="1140"
height="713" height="713"
loading="eager" loading="eager"
fetchpriority="high"
/> />
<div class="page-cover-inner"> <div class="page-cover-inner">
{eyebrow && <p class="eyebrow light">{eyebrow}</p>} {eyebrow && <p class="eyebrow light">{eyebrow}</p>}
@@ -64,7 +67,9 @@ const hasPhoto = Boolean(image);
<h1>{title}</h1> <h1>{title}</h1>
{lead && <p>{lead}</p>} {lead && <p>{lead}</p>}
</div> </div>
{hasPhoto && <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> </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} />
)
}
+8 -4
View File
@@ -2,6 +2,7 @@
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import BaseLayout from "@layouts/BaseLayout.astro"; import BaseLayout from "@layouts/BaseLayout.astro";
import ContactCta from "@components/ContactCta.astro"; import ContactCta from "@components/ContactCta.astro";
import Picture from "@components/Picture.astro";
import { pageSchema } from "@utils/schema"; import { pageSchema } from "@utils/schema";
const services = (await getCollection("services")).sort((a, b) => a.data.order - b.data.order); const services = (await getCollection("services")).sort((a, b) => a.data.order - b.data.order);
@@ -27,7 +28,7 @@ const description =
jsonLd={[pageSchema({ title: "BlueCap Strategies", description, pathname: "/" })]} jsonLd={[pageSchema({ title: "BlueCap Strategies", description, pathname: "/" })]}
> >
<section class="home-hero"> <section class="home-hero">
<img <Picture
class="hero-bg" class="hero-bg"
src="/assets/live-site/hero-mangrove.jpg" src="/assets/live-site/hero-mangrove.jpg"
alt="" alt=""
@@ -35,6 +36,7 @@ const description =
width="1140" width="1140"
height="713" height="713"
loading="eager" loading="eager"
fetchpriority="high"
/> />
<div class="hero-inner"> <div class="hero-inner">
<div class="hero-card"> <div class="hero-card">
@@ -138,7 +140,7 @@ const description =
allows us to adapt and solve the most complex challenges with both insight and decisive allows us to adapt and solve the most complex challenges with both insight and decisive
action. action.
</p> </p>
<a class="button" href="/about-us/">Learn More</a> <a class="button" href="/about-us/">Learn More<span class="sr-only"> about BlueCap Strategies</span></a>
</div> </div>
<div class="stats-grid" aria-label="BlueCap Strategies experience metrics"> <div class="stats-grid" aria-label="BlueCap Strategies experience metrics">
<div class="stat"><strong>27 Years</strong><span>of senior-level experience working with institutional capital providers</span></div> <div class="stat"><strong>27 Years</strong><span>of senior-level experience working with institutional capital providers</span></div>
@@ -172,11 +174,13 @@ const description =
))} ))}
</div> </div>
<div class="services-media"> <div class="services-media">
<img <Picture
src="/assets/live-site/img_0067.jpg" src="/assets/live-site/img_0067.jpg"
alt="Aquaculture baskets suspended in clear coastal water" alt="Aquaculture baskets suspended in clear coastal water"
width="1080" width="1080"
height="721" height="721"
loading="lazy"
sizes="(max-width: 960px) 100vw, 590px"
/> />
</div> </div>
</div> </div>
@@ -202,7 +206,7 @@ const description =
<article class="focus-card"> <article class="focus-card">
{img && ( {img && (
<a class="focus-media" href={`/${area.slug}/`}> <a class="focus-media" href={`/${area.slug}/`}>
<img src={img.src} alt={area.data.title} width={img.width} height={img.height} loading="lazy" /> <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> </a>
)} )}
<h3><a href={`/${area.slug}/`}>{area.data.title}</a></h3> <h3><a href={`/${area.slug}/`}>{area.data.title}</a></h3>
+14 -2
View File
@@ -3,6 +3,7 @@
--blue: #0074a9; /* exact original azure */ --blue: #0074a9; /* exact original azure */
--box: #f3f5f9; /* light box background (matches original .et_pb_column) */ --box: #f3f5f9; /* light box background (matches original .et_pb_column) */
--cyan: #3ca2bd; --cyan: #3ca2bd;
--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)) */ --green: #81b29a; /* muted sage — subheads (exact original rgb(129,178,154)) */
--box-blue: #a9d2eb; /* light-blue metric boxes (exact original rgb(169,210,235)) */ --box-blue: #a9d2eb; /* light-blue metric boxes (exact original rgb(169,210,235)) */
--ink: #1b2b36; --ink: #1b2b36;
@@ -42,6 +43,17 @@ img {
height: auto; height: auto;
} }
/* <picture> wrappers (components/Picture.astro, Markdown images) must not affect layout: the
<img> inside keeps behaving as a direct child (absolute hero backgrounds, sticky media). */
picture {
display: contents;
}
/* ...which would otherwise promote the <source> tags to grid/flex items (extra gaps). */
picture > source {
display: none;
}
a { a {
color: var(--blue); color: var(--blue);
} }
@@ -939,7 +951,7 @@ p {
.detail-nav li a[aria-current="page"] { .detail-nav li a[aria-current="page"] {
background: var(--green); background: var(--green);
color: #fff; color: var(--navy); /* white on sage is 2.4:1; navy is 5.6:1 (WCAG AA) */
} }
.page-detail { .page-detail {
@@ -1139,7 +1151,7 @@ textarea:focus {
.footer-cta { .footer-cta {
width: fit-content; width: fit-content;
margin-top: 1rem !important; margin-top: 1rem !important;
background: var(--cyan); background: var(--cyan-deep);
} }
.footer-bottom { .footer-bottom {
+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);
}