Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions docs/src/components/home/Chapter.astro
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,21 @@ if (aside && presentation !== "tabs") {
style={`--chapter: var(--color-chapter-${color})`}
>
<!-- Each part rises into place as it scrolls in; the intro follows the
title (src/styles/animations.css). -->
title (src/styles/animations.css). Beside the intro, the title sizes
to the intro's height (the script below). -->
<div
data-chapter-heading
class="grid items-end gap-x-14 gap-y-5 md:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]"
>
<div data-reveal>
<p class="mb-3 text-intro font-semibold text-(--chapter)">{name}</p>
<h2 class="max-w-[14em] text-title-1 text-balance">
<h2 class="max-w-[14em] text-title-1 text-balance" data-chapter-title>
{title}
</h2>
</div>
<div
class="max-w-[32em] text-muted [&_p]:text-intro [&_strong]:text-text-dark"
data-chapter-intro
data-reveal
style="--reveal-order: 1"
>
Expand Down Expand Up @@ -93,3 +96,53 @@ if (aside && presentation !== "tabs") {
)
}
</section>

<script>
import { fitFontSize } from "@/lib/utils/fitFontSize";

// Between the phone layout's stacked title and the type scale's largest
// title-1 (src/styles/main.css).
const bounds = { min: 32, max: 64 };

// Side by side, each title takes the size that sets it closest to the
// height of its intro, so the two start and end level. Stacked, the title
// keeps the type scale's size.
function fitTitle(heading: HTMLElement) {
const title = heading.querySelector<HTMLElement>("[data-chapter-title]");
const intro = heading.querySelector<HTMLElement>("[data-chapter-intro]");
if (!title || !intro) return;

title.style.fontSize = "";
const columns = getComputedStyle(heading).gridTemplateColumns.split(" ");
if (columns.length < 2) return;

const size = fitFontSize(
(size) => {
title.style.fontSize = `${size}px`;
return title.offsetHeight;
},
intro.offsetHeight,
bounds,
);
title.style.fontSize = `${size}px`;
}

let observer: ResizeObserver | undefined;

// The intro's height follows the column width, so a resize that rewraps
// it refits the title. The title's size never feeds back into the intro.
function initTitleFit() {
observer?.disconnect();
observer = new ResizeObserver((entries) => {
entries.forEach(({ target }) => {
const heading = target.closest<HTMLElement>("[data-chapter-heading]");
if (heading) fitTitle(heading);
});
});
document
.querySelectorAll<HTMLElement>("[data-chapter-intro]")
.forEach((intro) => observer?.observe(intro));
}

document.addEventListener("astro:page-load", initTitleFit);
</script>
34 changes: 34 additions & 0 deletions docs/src/lib/utils/fitFontSize.ts
Original file line number Diff line number Diff line change
@@ -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;
}
34 changes: 34 additions & 0 deletions docs/tests/fit-font-size.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading