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:
1 parent
002845c880
commit
59caa8e838
14 files changed
+225
-17
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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/"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+4
-3
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Pre-generates responsive AVIF + WebP variants of the site's raster images, plus a manifest the
|
||||||
|
// components read (src/utils/images.ts, the rehype plugin in astro.config.mjs).
|
||||||
|
//
|
||||||
|
// Why not astro:assets: every image here is referenced by a /public path string — from pages,
|
||||||
|
// content-collection frontmatter, and Markdown bodies — and og:image needs those stable JPEG URLs.
|
||||||
|
// This keeps the originals untouched (fallback + social cards) and adds the modern formats beside them.
|
||||||
|
//
|
||||||
|
// Output (generated, gitignored): public/assets/opt/*.{avif,webp} and src/data/image-manifest.json.
|
||||||
|
// File names carry a content hash, so nginx's 1-year immutable cache on /assets can never serve a
|
||||||
|
// stale variant after a source image changes. Existing outputs are reused, so reruns are fast.
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||||
|
const publicDir = path.join(root, "public");
|
||||||
|
const outDir = path.join(publicDir, "assets", "opt");
|
||||||
|
const manifestPath = path.join(root, "src", "data", "image-manifest.json");
|
||||||
|
|
||||||
|
// Sources: every photo in live-site/ (not icons/ — tiny PNGs, nothing to gain) and the logo.
|
||||||
|
const sources = [
|
||||||
|
...(await readdir(path.join(publicDir, "assets", "live-site")))
|
||||||
|
.filter((f) => /\.(jpe?g|png)$/i.test(f))
|
||||||
|
.map((f) => `/assets/live-site/${f}`),
|
||||||
|
"/assets/bluecap-logo.png",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Candidate widths; any at or above the original's width are replaced by the original width.
|
||||||
|
const widths = [480, 800, 1200, 1600];
|
||||||
|
// The logo only ever renders at <=190px wide, so one 2x-retina size is enough. Keyed by file name:
|
||||||
|
// live-site/ also holds a copy of it.
|
||||||
|
const widthOverrides = { "bluecap-logo.png": [480] };
|
||||||
|
// Tuned 2026-10-23 on the busiest photo (img_0062): AVIF q42 is visually indistinguishable from
|
||||||
|
// a q90 JPEG at 800px and ~half the bytes of WebP; effort 2 encodes ~2x faster than 4 for ~3%
|
||||||
|
// more bytes, which keeps CI builds quick.
|
||||||
|
const formats = {
|
||||||
|
avif: { quality: 42, effort: 2 },
|
||||||
|
webp: { quality: 65 },
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
const manifest = {};
|
||||||
|
|
||||||
|
for (const src of sources) {
|
||||||
|
const input = await readFile(path.join(publicDir, src));
|
||||||
|
const { width, height } = await sharp(input).metadata();
|
||||||
|
const hash = createHash("sha256").update(input).digest("hex").slice(0, 8);
|
||||||
|
const base = path.basename(src).replace(/\.[^.]+$/, "");
|
||||||
|
const sizes = widthOverrides[path.basename(src)] ?? [...new Set([...widths.filter((w) => w < width), width])];
|
||||||
|
|
||||||
|
const entry = { width, height, avif: [], webp: [] };
|
||||||
|
for (const w of sizes) {
|
||||||
|
for (const [format, options] of Object.entries(formats)) {
|
||||||
|
const file = `${base}.${hash}-${w}.${format}`;
|
||||||
|
const dest = path.join(outDir, file);
|
||||||
|
if (!existsSync(dest)) {
|
||||||
|
await sharp(input).resize({ width: w }).toFormat(format, options).toFile(dest);
|
||||||
|
}
|
||||||
|
entry[format].push({ src: `/assets/opt/${file}`, width: w });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifest[src] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
console.log(`optimize-images: ${sources.length} images -> ${path.relative(root, outDir)}`);
|
||||||
@@ -1,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.
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
|
import Picture from "@components/Picture.astro";
|
||||||
import { nav, serviceNav } from "@data/site";
|
import { nav, serviceNav } from "@data/site";
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<a class="brand" href="/" aria-label="BlueCap Strategies home">
|
<a class="brand" href="/" aria-label="BlueCap Strategies home">
|
||||||
<img src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="220" height="66" />
|
<Picture src="/assets/bluecap-logo.png" alt="BlueCap Strategies" width="220" height="66" sizes="190px" />
|
||||||
</a>
|
</a>
|
||||||
<nav class="desktop-nav" aria-label="Primary navigation">
|
<nav class="desktop-nav" aria-label="Primary navigation">
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
|
|||||||
@@ -1,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user