Skip to content

Commit 08b6559

Browse files
authored
Merge branch 'main' into governance/prebuild-hardening
2 parents 30679ce + a102651 commit 08b6559

19 files changed

Lines changed: 280 additions & 66 deletions

package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@
3232
"@chbrain/khai-engine-gender": "^0.1.7",
3333
"@chbrain/khai-engine-language": "^0.1.4",
3434
"@chbrain/khai-methods": "^0.0.6",
35-
"@chbrain/khai-misfits": "^0.3.0",
35+
"@chbrain/khai-misfits": "^0.101.1",
3636
"@chbrain/khai-plays": "^0.0.12",
3737
"@chbrain/khai-plays-buechner": "^0.20.6",
38-
"@chbrain/khai-plays-grimm": "^0.616.0",
38+
"@chbrain/khai-plays-grimm": "^0.840.0",
3939
"@chbrain/khai-plays-hcandersen": "^0.212.0",
4040
"@chbrain/khai-plays-kleist": "^0.22.7",
4141
"@chbrain/khai-writing": "^0.1.1",

src/components/Book.astro

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const groups: BookTocGroup[] = tocGroups ?? [
7575
click. Esc closes. Groups by role so the hierarchy reads. */
7676
}
7777
<div class="pb-overlay" role="dialog" aria-modal="true" aria-label="Contents" hidden>
78-
<div class="pb-overlay-card">
78+
<div class="pb-overlay-card" tabindex="-1">
7979
<div class="pb-overlay-head">
8080
<span class="pb-overlay-eyebrow">{tocEyebrow}</span>
8181
<button class="pb-overlay-close" type="button" aria-label="Close contents">×</button>
@@ -130,7 +130,7 @@ const groups: BookTocGroup[] = tocGroups ?? [
130130
</header>
131131

132132
{/* The spreads. One snap-stop per spread; mobile scrolls naturally. */}
133-
<main class="pb-spreads">
133+
<div class="pb-spreads">
134134
{
135135
spreads.map((s) => {
136136
// Every spread is stacked: the numbered running head, then the hero
@@ -270,7 +270,7 @@ const groups: BookTocGroup[] = tocGroups ?? [
270270
);
271271
})
272272
}
273-
</main>
273+
</div>
274274

275275
<SiteFooter />
276276
</div>
@@ -288,7 +288,11 @@ const groups: BookTocGroup[] = tocGroups ?? [
288288
scroll-snap-type: y mandatory;
289289
scroll-padding-top: 4rem;
290290
scroll-padding-bottom: 4rem;
291-
scroll-behavior: smooth;
291+
}
292+
@media (prefers-reduced-motion: no-preference) {
293+
html:has(.playbook) {
294+
scroll-behavior: smooth;
295+
}
292296
}
293297
@media (max-width: 640px) {
294298
html:has(.playbook) {
@@ -974,6 +978,7 @@ const groups: BookTocGroup[] = tocGroups ?? [
974978
(function () {
975979
const strip = document.querySelector(".pb-strip") as HTMLButtonElement | null;
976980
const overlay = document.querySelector(".pb-overlay") as HTMLElement | null;
981+
const overlayCard = document.querySelector(".pb-overlay-card") as HTMLElement | null;
977982
const closeBtn = document.querySelector(".pb-overlay-close") as HTMLButtonElement | null;
978983
const bars = Array.from(document.querySelectorAll(".pb-strip-bar")) as HTMLElement[];
979984
const tocLinks = Array.from(document.querySelectorAll(".pb-toc-link")) as HTMLAnchorElement[];
@@ -987,6 +992,24 @@ const groups: BookTocGroup[] = tocGroups ?? [
987992
const stops: HTMLElement[] = cover ? [cover, ...spreads] : [...spreads];
988993
let curIdx = 0;
989994

995+
// Respects the user's motion preference: smooth turns become instant
996+
// jumps when they've asked the OS/browser for reduced motion.
997+
function scrollBehavior(): ScrollBehavior {
998+
return matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
999+
}
1000+
1001+
// The element that had focus before the overlay opened, so closing it
1002+
// (button, scrim, or Esc) can put focus back where the reader was.
1003+
let lastFocused: HTMLElement | null = null;
1004+
1005+
function focusableIn(container: HTMLElement): HTMLElement[] {
1006+
return Array.from(
1007+
container.querySelectorAll(
1008+
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])',
1009+
),
1010+
) as HTMLElement[];
1011+
}
1012+
9901013
function setCurrent(idx: number) {
9911014
curIdx = idx;
9921015
const barIdx = idx - 1;
@@ -997,13 +1020,21 @@ const groups: BookTocGroup[] = tocGroups ?? [
9971020

9981021
function openOverlay() {
9991022
if (!overlay || !strip) return;
1023+
lastFocused = document.activeElement as HTMLElement | null;
10001024
overlay.hidden = false;
10011025
strip.setAttribute("aria-expanded", "true");
1026+
// Move focus into the dialog -- the close button when there is one,
1027+
// else the card itself (tabindex="-1", focusable but not tab-stopped).
1028+
(closeBtn ?? overlayCard)?.focus();
10021029
}
10031030
function closeOverlay() {
10041031
if (!overlay || !strip) return;
10051032
overlay.hidden = true;
10061033
strip.setAttribute("aria-expanded", "false");
1034+
// Restore focus to wherever it came from -- normally the strip that
1035+
// opened the overlay -- so the reader isn't dropped back at <body>.
1036+
(lastFocused ?? strip).focus();
1037+
lastFocused = null;
10071038
}
10081039

10091040
if (strip) strip.addEventListener("click", openOverlay);
@@ -1012,6 +1043,23 @@ const groups: BookTocGroup[] = tocGroups ?? [
10121043
overlay.addEventListener("click", (e) => {
10131044
if (e.target === overlay) closeOverlay();
10141045
});
1046+
// Trap Tab/Shift+Tab inside the card while the overlay is open, so
1047+
// focus can't leak to the (visually covered, but still tabbable)
1048+
// page behind it.
1049+
overlay.addEventListener("keydown", (e) => {
1050+
if (e.key !== "Tab" || !overlayCard) return;
1051+
const focusable = focusableIn(overlayCard);
1052+
if (!focusable.length) return;
1053+
const first = focusable[0];
1054+
const last = focusable[focusable.length - 1];
1055+
if (e.shiftKey && document.activeElement === first) {
1056+
e.preventDefault();
1057+
last.focus();
1058+
} else if (!e.shiftKey && document.activeElement === last) {
1059+
e.preventDefault();
1060+
first.focus();
1061+
}
1062+
});
10151063
}
10161064
tocLinks.forEach((link) => {
10171065
link.addEventListener("click", () => {
@@ -1021,24 +1069,33 @@ const groups: BookTocGroup[] = tocGroups ?? [
10211069
});
10221070

10231071
// Keyboard navigation: ←/→ and ↑/↓ step between stops. Skipped
1024-
// when the overlay is open (Esc closes that instead) and when
1025-
// focus is in a form field. The hash CTAs on cover + TOC handle
1026-
// direct jumps; this gives the book a one-keystroke turn.
1072+
// when the overlay is open (Esc closes that instead), when focus is
1073+
// in a form field or content-editable, when a modifier is held (so
1074+
// browser/OS shortcuts on those same keys still work), and when
1075+
// focus is on a <select> (arrows change its value, not the page).
10271076
document.addEventListener("keydown", (e) => {
10281077
if (overlay && !overlay.hidden) {
10291078
if (e.key === "Escape") closeOverlay();
10301079
return;
10311080
}
10321081
const active = document.activeElement as HTMLElement | null;
1033-
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) return;
1082+
if (
1083+
active &&
1084+
(active.tagName === "INPUT" ||
1085+
active.tagName === "TEXTAREA" ||
1086+
active.tagName === "SELECT" ||
1087+
active.isContentEditable)
1088+
)
1089+
return;
1090+
if (e.altKey || e.metaKey || e.ctrlKey) return;
10341091

10351092
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
10361093
const next = stops[Math.min(curIdx + 1, stops.length - 1)];
1037-
if (next) next.scrollIntoView({ behavior: "smooth", block: "start" });
1094+
if (next) next.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
10381095
e.preventDefault();
10391096
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
10401097
const prev = stops[Math.max(curIdx - 1, 0)];
1041-
if (prev) prev.scrollIntoView({ behavior: "smooth", block: "start" });
1098+
if (prev) prev.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
10421099
e.preventDefault();
10431100
}
10441101
});

src/components/ChapterCard.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ const restOfName = name.slice(1);
1010
---
1111

1212
<article class="chapter-card">
13-
<h3 class="chapter-name">
13+
<h2 class="chapter-name">
1414
<span class="chapter-letter">{letter}</span>{restOfName}
15-
</h3>
15+
</h2>
1616
<div class="chapter-body" set:html={bodyHtml} />
1717
</article>
1818

src/components/Mark.astro

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,21 @@
1515
// "khai-monogram-ink", "khai-icon-reverse"
1616
// width: rendered width in px (height follows the file's viewBox)
1717
// class: optional extra class on the wrapper
18+
// label: optional accessible name. The full-lockup SVGs carry live <text>
19+
// glyphs that screen readers otherwise announce as stray letters, so
20+
// by default the mark is aria-hidden (decorative). Pass `label` when
21+
// a given mark IS the accessible content (e.g. a bare wordmark used
22+
// as a link's only content) -- it swaps in role="img" + aria-label
23+
// instead of hiding the mark.
1824
1925
interface Props {
2026
name: string;
2127
width: number;
2228
class?: string;
29+
label?: string;
2330
}
2431
25-
const { name, width, class: className } = Astro.props;
32+
const { name, width, class: className, label } = Astro.props;
2633
2734
// Build-time map of every canonical mark. eager + ?raw inlines the file
2835
// contents as strings; the lookup is static.
@@ -48,7 +55,14 @@ if (!raw) {
4855
const svg = raw.replace(/<svg\b[^>]*>/, (tag) => tag.replace(/\s(width|height)="[^"]*"/g, ""));
4956
---
5057

51-
<span class:list={["mark", className]} style={`--mark-w:${width}px`} set:html={svg} />
58+
<span
59+
class:list={["mark", className]}
60+
style={`--mark-w:${width}px`}
61+
aria-hidden={label ? undefined : "true"}
62+
role={label ? "img" : undefined}
63+
aria-label={label ?? undefined}
64+
set:html={svg}
65+
/>
5266

5367
<style>
5468
.mark {

src/components/MnemonicStrip.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const parts = mnemonic.startsWith("TO ")
99
: { prefix: "", suffix: mnemonic };
1010
---
1111

12-
<div class="mnemonic-strip" aria-label={`Mnemonic: ${mnemonic}`}>
12+
<div class="mnemonic-strip">
1313
{parts.prefix && <span class="mnemonic-prefix">{parts.prefix}</span>}
1414
<span class="mnemonic-suffix">{parts.suffix}</span>
1515
</div>

src/components/Shelf.astro

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,11 @@ for (let i = 0; i < cards.length; i += per) cardPanels.push(cards.slice(i, i + p
192192
scroll-snap-type: y mandatory;
193193
scroll-padding-top: 4rem;
194194
scroll-padding-bottom: 4rem;
195-
scroll-behavior: smooth;
195+
}
196+
@media (prefers-reduced-motion: no-preference) {
197+
html:has(.shelf) {
198+
scroll-behavior: smooth;
199+
}
196200
}
197201
@media (max-width: 640px) {
198202
html:has(.shelf) {
@@ -380,14 +384,22 @@ for (let i = 0; i < cards.length; i += per) cardPanels.push(cards.slice(i, i + p
380384
border: 1px solid var(--color-ink);
381385
background: var(--color-paper-warm);
382386
overflow: hidden;
383-
transition:
384-
transform 0.16s,
385-
box-shadow 0.16s;
387+
}
388+
@media (prefers-reduced-motion: no-preference) {
389+
.shelf-card {
390+
transition:
391+
transform 0.16s,
392+
box-shadow 0.16s;
393+
}
386394
}
387395
.shelf-card:hover {
388-
transform: translateY(-2px);
389396
box-shadow: 0 8px 24px -16px var(--color-ink);
390397
}
398+
@media (prefers-reduced-motion: no-preference) {
399+
.shelf-card:hover {
400+
transform: translateY(-2px);
401+
}
402+
}
391403
.shelf-card-hue {
392404
height: 6px;
393405
background: var(--color-brick);

src/components/SiteFooter.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ const { hideContact = false } = Astro.props;
122122
.footfed-identity a,
123123
.footfed-contact-link,
124124
.footfed-legal-link {
125-
color: var(--color-ink-mute);
125+
color: var(--color-ink-soft);
126126
text-decoration: none;
127127
border-bottom: 1px solid var(--color-rule);
128128
}

src/components/SiteHeader.astro

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,7 @@ const useLocationLayout = Boolean(location);
6363
{
6464
useLocationLayout ? (
6565
<>
66-
<span class="topbar-location" aria-label="Current section">
67-
{location}
68-
</span>
66+
<span class="topbar-location">{location}</span>
6967
<div class="topbar-right">
7068
{version && <span class="topbar-version">khai · {version}</span>}
7169
{/* prettier-ignore */}

0 commit comments

Comments
 (0)