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/
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
+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/"),
+6 -1
View File
@@ -140,7 +140,12 @@ not worth a force-push).
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.
- [ ] 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
conversion signal).
- [ ] 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",
"@playwright/test": "^1.60.0",
"prettier": "^3.5.3",
"sharp": "0.34.5",
"typescript": "^5.8.3"
}
},
@@ -773,8 +774,8 @@
"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"
}
@@ -2626,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"
}
@@ -4952,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",
+4 -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",
@@ -26,6 +27,7 @@
"@astrojs/check": "^0.9.4",
"@playwright/test": "^1.60.0",
"prettier": "^3.5.3",
"sharp": "0.34.5",
"typescript": "^5.8.3"
}
}
+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.
+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";
---
<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.
+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>
+7 -2
View File
@@ -1,4 +1,6 @@
---
import Picture from "@components/Picture.astro";
type Props = {
eyebrow?: string;
title: string;
@@ -24,7 +26,7 @@ const hasPhoto = Boolean(image);
{
variant === "cover" ? (
<section class="page-cover">
<img
<Picture
class="hero-bg"
src={image ?? "/assets/live-site/hero-mangrove.jpg"}
alt=""
@@ -32,6 +34,7 @@ const hasPhoto = Boolean(image);
width="1140"
height="713"
loading="eager"
fetchpriority="high"
/>
<div class="page-cover-inner">
{eyebrow && <p class="eyebrow light">{eyebrow}</p>}
@@ -64,7 +67,9 @@ const hasPhoto = Boolean(image);
<h1>{title}</h1>
{lead && <p>{lead}</p>}
</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>
)
}
+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 BaseLayout from "@layouts/BaseLayout.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);
@@ -27,7 +28,7 @@ const description =
jsonLd={[pageSchema({ title: "BlueCap Strategies", description, pathname: "/" })]}
>
<section class="home-hero">
<img
<Picture
class="hero-bg"
src="/assets/live-site/hero-mangrove.jpg"
alt=""
@@ -35,6 +36,7 @@ const description =
width="1140"
height="713"
loading="eager"
fetchpriority="high"
/>
<div class="hero-inner">
<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
action.
</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 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>
@@ -172,11 +174,13 @@ const description =
))}
</div>
<div class="services-media">
<img
<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>
@@ -202,7 +206,7 @@ const description =
<article class="focus-card">
{img && (
<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>
)}
<h3><a href={`/${area.slug}/`}>{area.data.title}</a></h3>
+14 -2
View File
@@ -3,6 +3,7 @@
--blue: #0074a9; /* exact original azure */
--box: #f3f5f9; /* light box background (matches original .et_pb_column) */
--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)) */
--box-blue: #a9d2eb; /* light-blue metric boxes (exact original rgb(169,210,235)) */
--ink: #1b2b36;
@@ -42,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);
}
@@ -939,7 +951,7 @@ p {
.detail-nav li a[aria-current="page"] {
background: var(--green);
color: #fff;
color: var(--navy); /* white on sage is 2.4:1; navy is 5.6:1 (WCAG AA) */
}
.page-detail {
@@ -1139,7 +1151,7 @@ textarea:focus {
.footer-cta {
width: fit-content;
margin-top: 1rem !important;
background: var(--cyan);
background: var(--cyan-deep);
}
.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);
}