diff --git a/astro.config.mjs b/astro.config.mjs
@@ -9,6 +9,7 @@ import satteriGuard from './src/lib/satteri-guard.mjs';
import satteriProse, { satteriHeadingScan } from './src/lib/satteri-prose.mjs';
import satteriDiagram from './src/lib/satteri-diagram.mjs';
import satteriKorean from './src/lib/satteri-korean.mjs';
+import satteriLang from './src/lib/satteri-lang.mjs';
import { diagramCopy, diagramKey, findMermaidFences, readDiagram } from './src/lib/diagrams.mjs';
/**
@@ -198,12 +199,13 @@ export default defineConfig({
// hast: satteriHeadingScan — whether the post has an h1 or an h6; must precede satteriProse
// hast: satteriProse — heading demotion, intrinsic image size, table scrollers
// hast: satteriDiagram — a mermaid fence -> the SVG baked from it before the build
+ // hast: satteriLang — lang on sections and headings not in the post's language
// Ordering matters: hastPlugins run after highlighting and BEFORE heading-id
// collection, so `headings` from render() reports the demoted depths. satteriGuard
// goes first, before satteriDiagram adds the one raw node the site trusts.
processor: satteri({
mdastPlugins: [satteriKorean(), satteriFigure()],
- hastPlugins: [satteriGuard(), satteriHeadingScan(), satteriProse({ publicDir: 'public' }), satteriDiagram()],
+ hastPlugins: [satteriGuard(), satteriHeadingScan(), satteriProse({ publicDir: 'public' }), satteriDiagram(), satteriLang()],
/*
* Parser flags. Astro hands Sätteri only `{ gfm, smartPunctuation }`. Every other
* entry in `Features` defaults off except `frontmatter`, which Sätteri turns on unless
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
@@ -119,9 +119,12 @@ const buildYear = new Date().getUTCFullYear();
exactly this word, and a second one inside the banner landmark would duplicate
it. */}
<span class="page-name">{pageName}</span>
- <nav>
+ {/* Named, as the page's other navs are. "page" only on the page itself: on an
+ article, Writing is the section it belongs to but a link to another page, and
+ "true" says exactly that. */}
+ <nav aria-label="Primary">
{NAV.map((n) => (
- <a href={n.href} aria-current={isCurrent(n.href) ? 'page' : undefined}>{n.label}</a>
+ <a href={n.href} aria-current={here === n.href ? 'page' : isCurrent(n.href) ? 'true' : undefined}>{n.label}</a>
))}
</nav>
</header>
diff --git a/src/lib/hangul.ts b/src/lib/hangul.ts
@@ -14,3 +14,27 @@ export const langOf = (s: string, doc: 'en' | 'ko'): 'en' | 'ko' | undefined =>
const l: 'en' | 'ko' = HANGUL.test(s) ? 'ko' : 'en';
return l === doc ? undefined : l;
};
+
+const HANGUL_ALL = new RegExp(HANGUL.source, 'g');
+
+/**
+ * The language a post is written in: the article's lang, and the baseline satteriLang
+ * compares passages and headings against. Both call this on the same body, so they cannot
+ * disagree.
+ *
+ * It follows the body, not the title, and is measured on prose only. The label drives
+ * leading, line breaking and speech, so it has to describe what is read. Keying off the
+ * title would mislabel AI Odyssey and VTZero; counting Hangul against every character
+ * would mislabel the Korean technical posts, whose shell transcripts and URLs swamp the
+ * prose — measured that way sysmon lands at 0.14 and journalctl at 0.29, both plainly
+ * Korean documents. With fences and link targets dropped and Hangul weighed against Latin
+ * letters, the corpus splits with nothing near the line: the two English essays sit at
+ * 0.25 and 0.28, and the other 28 posts all sit at 0.50 or above.
+ */
+export function docLang(md: string): 'en' | 'ko' {
+ // Linear on any input; see the note on LINK in prose.ts.
+ const prose = md.replace(/```[\s\S]*?```/g, '').replace(/\[[^\][]*\]\((?:[^()]|\([^()]*\))*\)/g, '');
+ const kor = (prose.match(HANGUL_ALL) ?? []).length;
+ const lat = (prose.match(/[A-Za-z]/g) ?? []).length;
+ return kor / Math.max(1, kor + lat) > 0.4 ? 'ko' : 'en';
+}
diff --git a/src/lib/satteri-diagram.mjs b/src/lib/satteri-diagram.mjs
@@ -100,10 +100,12 @@ export default function satteriDiagram() {
properties: {
className: ['diagram'],
'data-diagram': kind || 'diagram',
- // Not for scrolling any more - see the header. A labelled region a reader can
+ // Not for scrolling any more - see the header. A labelled group a reader can
// reach, and stop on, before zooming into the one thing on the page that rewards it.
+ // A group, not a region: every region is a landmark, and a post with two
+ // sequence diagrams listed two landmarks with the same name.
tabindex: '0',
- role: 'region',
+ role: 'group',
'aria-label': label,
},
children: [{ type: 'raw', value: markup.trim() }],
diff --git a/src/lib/satteri-lang.mjs b/src/lib/satteri-lang.mjs
@@ -0,0 +1,51 @@
+/**
+ * satteri-lang — `lang` on the passages of a post that are not in the post's language.
+ *
+ * The article element carries one lang, docLang() of the body. A screen reader picks its
+ * voice from lang (WCAG 3.1.2), so two kinds of passage were read in the wrong one:
+ *
+ * sections The bilingual essays split themselves with a heading that reads exactly
+ * EN or KR. Everything after it, up to the next heading of the same level or
+ * higher, is in that language. AI Odyssey is an English document by its prose
+ * ratio, so its KR half — 1,765 Hangul characters — was read by an English
+ * voice.
+ * headings A heading in the other script, like the English h3s of a Korean post.
+ *
+ * Only top-level blocks and headings are marked, and only when they differ from the
+ * article: a matching lang would make the reader switch voices for nothing. Paragraphs are
+ * not judged one by one, because a single quoted Korean word would flip an English
+ * paragraph. Code is left alone; it is not prose in either language.
+ *
+ * It runs last among the hast plugins, so it sees demoted headings and baked diagrams.
+ */
+import { docLang, langOf } from './hangul.ts';
+
+const MARKERS = { EN: 'en', KR: 'ko' };
+const isCode = (node) =>
+ node.tagName === 'pre' || (node.tagName === 'figure' && [].concat(node.properties?.className ?? []).includes('code'));
+
+export default function satteriLang() {
+ return {
+ name: 'lang',
+ after(root, ctx) {
+ const doc = docLang(ctx.source);
+ let section = null;
+ for (const node of root.children) {
+ if (node.type !== 'element') continue;
+ const depth = /^h[1-6]$/.test(node.tagName) ? Number(node.tagName[1]) : 0;
+ if (depth) {
+ if (section && depth <= section.depth) section = null;
+ const text = ctx.textContent(node).trim();
+ if (MARKERS[text]) {
+ section = { lang: MARKERS[text], depth };
+ continue;
+ }
+ const own = langOf(text, doc);
+ if (own) ctx.setProperty(node, 'lang', own);
+ continue;
+ }
+ if (section && section.lang !== doc && !isCode(node)) ctx.setProperty(node, 'lang', section.lang);
+ }
+ },
+ };
+}
diff --git a/src/lib/satteri-prose.mjs b/src/lib/satteri-prose.mjs
@@ -111,7 +111,9 @@ export default function satteriProse({ publicDir = 'public' } = {}) {
if (parent?.type === 'element' && parent.tagName === 'div') return;
ctx.wrapNode(node, {
type: 'element', tagName: 'div',
- properties: { className: ['scroller'], tabindex: '0', role: 'region', 'aria-label': 'Table' },
+ // A group, as the code plates are: a region is a landmark, and the Hitchhiker post
+ // listed three landmarks all named "Table".
+ properties: { className: ['scroller'], tabindex: '0', role: 'group', 'aria-label': 'Table' },
children: [],
});
},
diff --git a/src/pages/writing/[year]/[slug].astro b/src/pages/writing/[year]/[slug].astro
@@ -4,7 +4,7 @@ import { getCollection, render } from 'astro:content';
import type { GetStaticPaths } from 'astro';
import { postUrl } from '../../../lib/order';
import { proseShape, excerpt, longDate } from '../../../lib/prose';
-import { hasHangul, langOf } from '../../../lib/hangul';
+import { docLang, hasHangul, langOf } from '../../../lib/hangul';
import { assertRendered } from '../../../lib/rendered';
export const getStaticPaths: GetStaticPaths = async () => {
@@ -47,23 +47,8 @@ const AUTHOR = 'Hyukjin An';
const year = post.data.date.slice(0, 4);
-/**
- * lang follows the body, not the title, and is measured on prose only.
- *
- * The label drives leading, line breaking and speech, so it has to describe what is read.
- * Keying off the title would mislabel AI Odyssey and VTZero; counting Hangul against every
- * character would mislabel the Korean technical posts, whose shell transcripts and URLs
- * swamp the prose \u2014 measured that way sysmon lands at 0.14 and journalctl at 0.29, both
- * plainly Korean documents. With fences and link targets dropped and Hangul weighed
- * against Latin letters, the corpus splits with nothing near the line: the two English
- * essays sit at 0.25 and 0.28, and the other 28 posts all sit at 0.50 or above.
- */
-const HANGUL = /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7A3]/g;
-// Linear on any input; see the note on LINK in src/lib/prose.ts.
-const prose = md.replace(/```[\s\S]*?```/g, '').replace(/\[[^\][]*\]\((?:[^()]|\([^()]*\))*\)/g, '');
-const kor = (prose.match(HANGUL) ?? []).length;
-const lat = (prose.match(/[A-Za-z]/g) ?? []).length;
-const lang = kor / Math.max(1, kor + lat) > 0.4 ? 'ko' : 'en';
+// The body's language, measured on prose only; see docLang in src/lib/hangul.ts.
+const lang = docLang(md);
/*
* Which font to preload is a different question from which language to declare, and the
@@ -233,7 +218,7 @@ const ld = {
<span class="post__sep" aria-hidden="true">·</span>
<a href={`/writing/#${year}`}>{year}</a>
</p>
- <h1 class="post__title">{post.data.title}</h1>
+ <h1 class="post__title" lang={langOf(post.data.title, lang)}>{post.data.title}</h1>
{post.data.deck && <p class="post__deck">{post.data.deck}</p>}
{/* What stood here was `{minutes} min read`. It is gone at the reader's request, and
the reference is the reason it has nothing to be replaced by: its header is a
@@ -241,7 +226,10 @@ const ld = {
will take. The byline takes the slot. lang="en" because the word "by" and the
Latin-script name are the same on all 30 posts, 21 of which declare lang="ko" on
<article> — the same reason the contents rail labels its entries one by one. */}
- <p class="post__byline" lang="en">by <span class="post__author">{AUTHOR}</span><time class="post__date" datetime={post.data.date}>{longDate(post.data.date)}</time></p>
+ {/* The gap between name and date is padding, so without the hidden comma the text
+ itself read "Hyukjin AnJune 27, 2026" to a screen reader, to Reader View (which
+ takes this paragraph as the byline) and in anything copied from it. */}
+ <p class="post__byline" lang="en">by <span class="post__author">{AUTHOR}</span><span class="sr-only">, </span><time class="post__date" datetime={post.data.date}>{longDate(post.data.date)}</time></p>
</header>
{showToc && (
@@ -249,7 +237,7 @@ const ld = {
<summary class="toc__summary">Contents</summary>
<ol class="toc__list">
{toc.map((h) => (
- <li class="toc__item" data-level={tocLevel(h.depth)}><a href={`#${h.slug}`}>{h.text}</a></li>
+ <li class="toc__item" data-level={tocLevel(h.depth)}><a href={`#${h.slug}`} lang={langOf(h.text, lang)}>{h.text}</a></li>
))}
</ol>
</details>
@@ -446,11 +434,18 @@ const ld = {
status.setAttribute('role', 'status');
document.body.append(status);
+ // Emptied again two seconds later: the region sits after the footer, and a message
+ // left in it was read out of context by anyone reading on to the end of the page.
let frame = 0;
+ let clear = 0;
const announce = (message) => {
cancelAnimationFrame(frame);
+ clearTimeout(clear);
status.textContent = '';
- frame = requestAnimationFrame(() => { status.textContent = message; });
+ frame = requestAnimationFrame(() => {
+ status.textContent = message;
+ clear = setTimeout(() => { status.textContent = ''; }, 2000);
+ });
};
/*
@@ -1183,7 +1178,9 @@ const ld = {
clamp-lift two rules down is deliberately left ungated -- it is a way to read a
clipped heading, not a way to mark one, and gating it would take that away from a
touch reader entirely. */
- .toc-rail__link:focus-visible { color: var(--crimson); }
+ /* The ring is drawn inside the link: the rail scrolls, and a ring outside it was cut off
+ by the scrollport on the last entry and on any entry at the edge of a scrolled rail. */
+ .toc-rail__link:focus-visible { color: var(--crimson); outline-offset: -3px; }
@media (hover: hover) {
.toc-rail__link:hover { color: var(--crimson); }
}
diff --git a/src/pages/writing/index.astro b/src/pages/writing/index.astro
@@ -242,9 +242,19 @@ const ko = posts.some((p) => hasHangul(p.data.title, p.data.deck));
text-wrap: balance;
}
+ /* Focus moving backwards (Shift+Tab) scrolls the link only just into view, and the sticky
+ year label above covered it and the row's focus ring completely. The margin is the
+ label's own height (padding, rule, rule's gap, one line) plus the row's padding, plus
+ 1rem for the ring (3px offset, 2px outline) and for the link's inline box sitting a
+ little inside its taller line; with only the ring's 5px, the ring's top still touched
+ the label. Below 36rem the date sits above the title in the row, and the container
+ rule adds its line. */
.entry__title a {
color: inherit;
text-decoration: none;
+ scroll-margin-top: calc(
+ 3 * var(--gap-kicker) + 4px + var(--fs-kicker) * var(--lh-medium) + var(--entry-pad) + 1rem
+ );
}
/* The whole row is the hit area, ported from .featured-article__title a:before */
@@ -343,6 +353,12 @@ const ko = posts.some((p) => hasHangul(p.data.title, p.data.deck));
}
.entry__title { grid-column: 1; grid-row: 2; }
+ .entry__title a {
+ scroll-margin-top: calc(
+ 3 * var(--gap-kicker) + 4px + var(--fs-kicker) * var(--lh-medium) + var(--entry-pad) + 1rem
+ + var(--fs-small) * var(--lh-base) + 0.25rem
+ );
+ }
.entry__deck { grid-column: 1; }
}
diff --git a/src/styles/base.css b/src/styles/base.css
@@ -752,7 +752,7 @@ body > header nav a:focus-visible { color: var(--crimson-dark); }
/* ── Diagrams ─────────────────────────────────────────────────────────────────
DOM contract, emitted by src/lib/satteri-diagram.mjs:
- figure.diagram[data-diagram][tabindex=0][role=region][aria-label]
+ figure.diagram[data-diagram][tabindex=0][role=group][aria-label]
> svg[width][height][viewBox] (baked by scripts/render-diagrams.mjs)
The SVG carries its own <style>, scoped to its own #mmd-<hash> id and holding the palette
|