blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit a98503bae4f1154612c339dfb2bcf66715b4b28d
parent 090cf1c7ea202381a588aff79c675b1d942bcaad
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 20:02:54 +0900

fix: mark passages in the other language, and six smaller accessibility gaps

Language (WCAG 3.1.2). The article carries one lang, measured on its
prose, so the KR half of the two bilingual essays was read by an English
voice and the English headings of Korean posts by a Korean one. That
measurement moves to docLang() in hangul.ts, and a new satteriLang hast
plugin, run last, uses it: blocks under a heading that reads exactly EN
or KR take that section's lang, and a heading in the other script takes
its own. Only values that differ from the article are written, and code
is left alone. The post title and the inline contents list get the same
per-string lang the rail already had. Measured: 17 paragraphs of AI
Odyssey and 5 of We need to talk now carry lang="ko"; how-to-win's 25
English headings carry lang="en".

- Tables and diagrams were role="region", so a post with several listed
landmarks with the same name. They are groups now, like code plates.
- The primary nav has a name, and aria-current is "page" only on the
page itself; on an article, Writing is "true".
- The byline's name and date were separated only by padding and read
"Hyukjin AnNovember 5, 2024"; a visually hidden comma fixes the text.
- The copy status region is emptied two seconds after it speaks.
- The rail's focus ring is drawn inside the link, where the rail's
scrollport can no longer clip it.
- On /writing/, Shift+Tab could leave the focused row entirely under the
sticky year label. The link's scroll-margin now clears the label, the
row padding and the ring; worst overlap went from 100% of the row to
7%, which is on rows already in view that the browser does not scroll.

Diffstat:
Mastro.config.mjs | 4+++-
Msrc/layouts/Base.astro | 7+++++--
Msrc/lib/hangul.ts | 24++++++++++++++++++++++++
Msrc/lib/satteri-diagram.mjs | 6++++--
Asrc/lib/satteri-lang.mjs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/satteri-prose.mjs | 4+++-
Msrc/pages/writing/[year]/[slug].astro | 43++++++++++++++++++++-----------------------
Msrc/pages/writing/index.astro | 16++++++++++++++++
Msrc/styles/base.css | 2+-
9 files changed, 127 insertion(+), 30 deletion(-)

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