5 Commits

Author SHA1 Message Date
hsiegeln
183a92123c perf(homepage): serve product screenshots as resized WebP to lift Lighthouse perf above 0.95
All checks were successful
ci / build-test (push) Successful in 4m56s
The homepage Lighthouse perf score dropped to 0.94 in CI (threshold 0.95)
because Hero and ThreeAmWalkthrough each load a 1920×945 PNG screenshot
straight out of public/product/ — together ~1.2 MiB and the LCP element.
Other pages have no product imagery and pass with 1.0.

Adds a sharp-based generator (scripts/optimize-product-images.mjs, run via
`npm run optimize:images`) that emits 1280w and 1920w WebP variants beside
each source PNG. Lightbox.astro now wraps the trigger in a <picture> with
a WebP <source srcset> auto-derived from the PNG path (PNG kept as
fallback for the ~2% of clients without WebP), exposes a fetchpriority
prop, and points the dialog modal at the 1920w WebP. The Hero passes
fetchpriority="high" on the LCP image; index.astro injects a matching
<link rel="preload" as="image" imagesrcset> via a new BaseLayout head
slot so the WebP is discovered before the body parses.

Effect on the LCP image: 551 KiB PNG → 46 KiB WebP at 1280w (-92%).
Local Lighthouse perf: 0.95 → 1.00 across 3 runs on /index.html;
pricing/imprint stay 1.00; privacy unchanged at 0.98 (pre-existing CLS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:20:29 +02:00
hsiegeln
2ec4a86e3d ci: skip app.cameleer.io in linkinator (auth host)
Some checks failed
ci / build-test (push) Failing after 5m24s
CI's lint:links step started failing while app.cameleer.io is in
maintenance — linkinator follows the sign-in/sign-up CTAs out to
the Logto host and surfaces transient 502s as build failures.

The skip list already covered auth.cameleer.io but missed the
app.cameleer.io rename from commit fa12df8. Add it alongside
the other internal hosts.

Trade-off: CI no longer catches a real breakage of the auth URLs.
Acceptable because (a) the auth host has its own deploy gates,
(b) maintenance windows there should not red-bar marketing-site
deploys, (c) the site's middleware/header tests still verify the
URL shape via the auth.test.ts schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:52:37 +02:00
hsiegeln
147a813119 Merge branch 'relaunch-2026-04-25' into main
Some checks failed
ci / build-test (push) Failing after 1m40s
Brings in:
- chore: remove all nJAMS references from the live site
- under-construction placeholder feature (spec, plan, tests, page, workflow, README)
2026-04-25 18:44:50 +02:00
8f5e84523e Merge pull request 'chore(auth): redirect sign-in/sign-up to app.cameleer.io' (#5) from relaunch-2026-04-25 into main
All checks were successful
ci / build-test (push) Successful in 4m17s
Reviewed-on: #5
2026-04-25 09:33:23 +02:00
3b184488bb Merge pull request 'relaunch-2026-04-25' (#4) from relaunch-2026-04-25 into main
All checks were successful
ci / build-test (push) Successful in 4m21s
Reviewed-on: #4
2026-04-25 08:08:54 +02:00
12 changed files with 106 additions and 10 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Build output # Build output
dist/ dist/
.astro/ .astro/
.lighthouseci/
# Dependencies # Dependencies
node_modules/ node_modules/

View File

@@ -2,6 +2,7 @@
"recurse": true, "recurse": true,
"silent": true, "silent": true,
"skip": [ "skip": [
"^https://app\\.cameleer\\.io",
"^https://auth\\.cameleer\\.io", "^https://auth\\.cameleer\\.io",
"^https://platform\\.cameleer\\.io", "^https://platform\\.cameleer\\.io",
"^https://www\\.cameleer\\.io", "^https://www\\.cameleer\\.io",

View File

@@ -13,6 +13,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"lint:html": "html-validate \"dist/**/*.html\"", "lint:html": "html-validate \"dist/**/*.html\"",
"lint:links": "linkinator dist --recurse --silent", "lint:links": "linkinator dist --recurse --silent",
"optimize:images": "node scripts/optimize-product-images.mjs",
"lh": "lhci autorun" "lh": "lhci autorun"
}, },
"dependencies": { "dependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -0,0 +1,48 @@
// Generate WebP variants of source PNGs in public/product/.
// Run after replacing/adding a source PNG; outputs are committed.
//
// For each <name>.png we emit:
// <name>-1280.webp (q=82, used as inline srcset for desktop ≤ ~1280 px)
// <name>-1920.webp (q=80, used as inline srcset for retina/wide viewports
// and as the lightbox-modal full-size source)
//
// The original .png is kept as a <picture> fallback for the rare browser
// without WebP support (~2 % globally).
import { readdir, stat } from 'node:fs/promises';
import { join, parse } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const SRC_DIR = fileURLToPath(new URL('../public/product/', import.meta.url));
const VARIANTS = [
{ width: 1280, quality: 82, suffix: '-1280' },
{ width: 1920, quality: 80, suffix: '-1920' },
];
const entries = await readdir(SRC_DIR);
const pngs = entries.filter((f) => f.toLowerCase().endsWith('.png'));
if (pngs.length === 0) {
console.error(`No PNGs found in ${SRC_DIR}`);
process.exit(1);
}
for (const file of pngs) {
const { name } = parse(file);
const inputPath = join(SRC_DIR, file);
const inputBytes = (await stat(inputPath)).size;
console.log(`\n${file} (${(inputBytes / 1024).toFixed(0)} KiB)`);
for (const v of VARIANTS) {
const outName = `${name}${v.suffix}.webp`;
const outPath = join(SRC_DIR, outName);
const info = await sharp(inputPath)
.resize({ width: v.width, withoutEnlargement: true })
.webp({ quality: v.quality, effort: 6 })
.toFile(outPath);
const pct = ((1 - info.size / inputBytes) * 100).toFixed(0);
console.log(`${outName} ${(info.size / 1024).toFixed(0)} KiB (-${pct}%)`);
}
}

View File

@@ -5,9 +5,14 @@ interface Props {
width: number; width: number;
height: number; height: number;
loading?: 'eager' | 'lazy'; loading?: 'eager' | 'lazy';
fetchpriority?: 'high' | 'low' | 'auto';
caption?: string; caption?: string;
triggerClass?: string; triggerClass?: string;
imgClass?: string; imgClass?: string;
/** Set to false for sources without sibling .webp variants. */
optimized?: boolean;
/** CSS sizes attribute for the WebP srcset. Defaults to a mobile-first guess. */
sizes?: string;
} }
const { const {
@@ -16,11 +21,20 @@ const {
width, width,
height, height,
loading = 'lazy', loading = 'lazy',
fetchpriority,
caption, caption,
triggerClass = '', triggerClass = '',
imgClass = 'block w-full h-auto', imgClass = 'block w-full h-auto',
optimized = true,
sizes = '(min-width: 1024px) 56vw, 100vw',
} = Astro.props; } = Astro.props;
// Auto-derive WebP variant paths from the PNG src. The optimize-product-images
// script emits <name>-1280.webp and <name>-1920.webp next to each <name>.png.
const webpBase = optimized && /\.png$/i.test(src) ? src.replace(/\.png$/i, '') : null;
const webpSrcset = webpBase ? `${webpBase}-1280.webp 1280w, ${webpBase}-1920.webp 1920w` : null;
const webpDialogSrc = webpBase ? `${webpBase}-1920.webp` : src;
// Unique per-instance id so multiple lightboxes on a page do not collide. // Unique per-instance id so multiple lightboxes on a page do not collide.
const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`; const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
--- ---
@@ -30,6 +44,9 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
data-lightbox-open={dialogId} data-lightbox-open={dialogId}
aria-label={`Enlarge: ${alt}`} aria-label={`Enlarge: ${alt}`}
> >
{webpSrcset ? (
<picture>
<source type="image/webp" srcset={webpSrcset} sizes={sizes} />
<img <img
src={src} src={src}
alt={alt} alt={alt}
@@ -37,8 +54,22 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
height={height} height={height}
loading={loading} loading={loading}
decoding="async" decoding="async"
fetchpriority={fetchpriority}
class={imgClass} class={imgClass}
/> />
</picture>
) : (
<img
src={src}
alt={alt}
width={width}
height={height}
loading={loading}
decoding="async"
fetchpriority={fetchpriority}
class={imgClass}
/>
)}
<span aria-hidden="true" class="lightbox-zoom-badge"> <span aria-hidden="true" class="lightbox-zoom-badge">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7.5"/> <circle cx="11" cy="11" r="7.5"/>
@@ -59,7 +90,7 @@ const dialogId = `lb-${Math.random().toString(36).slice(2, 10)}`;
</svg> </svg>
</button> </button>
</form> </form>
<img src={src} alt={alt} class="lightbox-image" /> <img src={webpDialogSrc} alt={alt} class="lightbox-image" loading="lazy" decoding="async" />
{caption && <p class="lightbox-caption">{caption}</p>} {caption && <p class="lightbox-caption">{caption}</p>}
</dialog> </dialog>

View File

@@ -58,6 +58,7 @@ const pins: Pin[] = [
width={1920} width={1920}
height={945} height={945}
loading="eager" loading="eager"
fetchpriority="high"
/> />
<div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div> <div class="absolute inset-0 ring-1 ring-inset ring-accent/10 pointer-events-none rounded-lg"></div>
{pins.map((pin) => ( {pins.map((pin) => (

View File

@@ -47,6 +47,8 @@ const ogUrl = new URL(ogImage, Astro.site ?? 'https://www.cameleer.io').toString
<meta name="twitter:image" content={ogUrl} /> <meta name="twitter:image" content={ogUrl} />
<meta name="robots" content="index,follow" /> <meta name="robots" content="index,follow" />
<slot name="head" />
</head> </head>
<body class="min-h-screen bg-bg text-text font-sans antialiased"> <body class="min-h-screen bg-bg text-text font-sans antialiased">
<slot /> <slot />

View File

@@ -14,6 +14,17 @@ import FinalCTA from '../components/sections/FinalCTA.astro';
title="Cameleer — Ship Camel integrations. Sleep through the night." title="Cameleer — Ship Camel integrations. Sleep through the night."
description="The hosted runtime and observability platform for Apache Camel. Auto-traced, replay-ready, cross-service correlated — so the 3 AM page becomes a 30-second answer." description="The hosted runtime and observability platform for Apache Camel. Auto-traced, replay-ready, cross-service correlated — so the 3 AM page becomes a 30-second answer."
> >
<Fragment slot="head">
<link
rel="preload"
as="image"
type="image/webp"
href="/product/exchange-detail-1280.webp"
imagesrcset="/product/exchange-detail-1280.webp 1280w, /product/exchange-detail-1920.webp 1920w"
imagesizes="(min-width: 1024px) 56vw, 100vw"
fetchpriority="high"
/>
</Fragment>
<SiteHeader /> <SiteHeader />
<main> <main>
<Hero /> <Hero />