diff --git a/docs/src/components/home/Chapter.astro b/docs/src/components/home/Chapter.astro
index eaa1bb5f..878c9708 100644
--- a/docs/src/components/home/Chapter.astro
+++ b/docs/src/components/home/Chapter.astro
@@ -33,18 +33,21 @@ if (aside && presentation !== "tabs") {
style={`--chapter: var(--color-chapter-${color})`}
>
+ title (src/styles/animations.css). Beside the intro, the title sizes
+ to the intro's height (the script below). -->
@@ -93,3 +96,53 @@ if (aside && presentation !== "tabs") {
)
}
+
+
diff --git a/docs/src/lib/utils/fitFontSize.ts b/docs/src/lib/utils/fitFontSize.ts
new file mode 100644
index 00000000..2cd396f3
--- /dev/null
+++ b/docs/src/lib/utils/fitFontSize.ts
@@ -0,0 +1,34 @@
+// Sizes a homepage chapter title to the height of the intro beside it
+// (src/components/home/Chapter.astro). Kept free of the DOM so it can be
+// unit-tested with `node --test`.
+
+interface Bounds {
+ min: number;
+ max: number;
+ // How close the search gets before it stops, in px.
+ precision?: number;
+}
+
+// The font size, within the bounds, whose height lands closest to `target`.
+// `heightAt` lays the text out at a size and returns its height. Height grows
+// with the size, jumping by a line wherever the text rewraps, so the closest
+// fit sits either just below or just above the size where it first overshoots.
+export function fitFontSize(
+ heightAt: (size: number) => number,
+ target: number,
+ { min, max, precision = 0.5 }: Bounds,
+): number {
+ if (heightAt(max) <= target) return max;
+ if (heightAt(min) >= target) return min;
+
+ let fits = min;
+ let overshoots = max;
+ while (overshoots - fits > precision) {
+ const size = (fits + overshoots) / 2;
+ if (heightAt(size) <= target) fits = size;
+ else overshoots = size;
+ }
+ const under = target - heightAt(fits);
+ const over = heightAt(overshoots) - target;
+ return under <= over ? fits : overshoots;
+}
diff --git a/docs/tests/fit-font-size.test.js b/docs/tests/fit-font-size.test.js
new file mode 100644
index 00000000..b87115dc
--- /dev/null
+++ b/docs/tests/fit-font-size.test.js
@@ -0,0 +1,34 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { fitFontSize } from "../src/lib/utils/fitFontSize.ts";
+
+// A title that sets on one line up to `wrapsAt`, and on two lines above it.
+const title =
+ (wrapsAt, leading = 1) =>
+ (size) =>
+ (size <= wrapsAt ? 1 : 2) * size * leading;
+
+const bounds = { min: 32, max: 64, precision: 0.25 };
+
+test("shrinks the title until it matches the target height", () => {
+ const size = fitFontSize(title(40), 110, bounds);
+ assert.ok(Math.abs(size - 55) <= 0.25, `got ${size}`);
+});
+
+test("wraps onto another line when that lands closer to the target", () => {
+ // One line tops out at 50px tall; two lines at 51px are 102px tall.
+ const size = fitFontSize(title(50), 100, bounds);
+ assert.ok(size > 50 && size <= 50.25, `got ${size}`);
+});
+
+test("stays on fewer lines when rewrapping overshoots by more", () => {
+ // One line at 60px is 60px tall; two lines at 61px are 122px tall.
+ const size = fitFontSize(title(60), 70, bounds);
+ assert.ok(size >= 59.75 && size <= 60, `got ${size}`);
+});
+
+test("keeps within the bounds", () => {
+ assert.equal(fitFontSize(title(40), 400, bounds), 64);
+ assert.equal(fitFontSize(title(64), 10, bounds), 32);
+});