From 3a5bc586544c22b406d50c2893ac3f6120d586cf Mon Sep 17 00:00:00 2001 From: Patternflow Bot Date: Tue, 27 Oct 2026 23:38:30 +0900 Subject: [PATCH 01/11] web: journal archive dates stay on one line on a desktop From Patternflow-lab/Patternflow#306 --- web/src/app/globals.css | 6 +- web/src/components/journal/journalCss.test.ts | 62 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 web/src/components/journal/journalCss.test.ts diff --git a/web/src/app/globals.css b/web/src/app/globals.css index ddce3f83e..ff94aa9d2 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1294,9 +1294,12 @@ .journal-post-list li { border-bottom: 0; } + /* The date column is at least 120px and as wide as its date. At 120px a + two-digit Korean date (130px, "2026년 10월 29일") and a long English + one (159px, "SEPTEMBER 29, 2026") broke onto a second line. */ .journal-post-list a { display: grid; - grid-template-columns: 1fr 120px 80px; + grid-template-columns: minmax(0, 1fr) minmax(120px, max-content) 80px; gap: 24px; align-items: baseline; padding-right: 104px; @@ -1346,6 +1349,7 @@ } .journal-list-meta { text-align: right; + white-space: nowrap; } .journal-list-body .pf-row-d { max-width: none; diff --git a/web/src/components/journal/journalCss.test.ts b/web/src/components/journal/journalCss.test.ts new file mode 100644 index 000000000..68df2697a --- /dev/null +++ b/web/src/components/journal/journalCss.test.ts @@ -0,0 +1,62 @@ +// jsdom applies no stylesheet, so the journal's rules are read from +// app/globals.css. Above 720px each archive row is a grid, and its date +// column was a fixed 120px. On 27 October, at 1440, 27 of the 37 Korean dates +// ("2026년 9월 29일" is 122px) and 17 of the 37 English ones ("SEPTEMBER 29, +// 2026" is 159px) broke onto a second line, leaving 일 or 2026 alone under it. + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const css = readFileSync(path.resolve(__dirname, "../../app/globals.css"), "utf8"); + +/** Every rule with no braces inside it; an @media block's rules come out on their own. */ +function rules(source: string) { + const bare = source.replace(/\/\*[\s\S]*?\*\//g, ""); + return [...bare.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ + selectors: m[1].split(",").map((s) => s.trim().replace(/\s+/g, " ")), + body: m[2], + })); +} + +function declaration(body: string, property: string) { + return body.match(new RegExp(`(?:^|;)\\s*${property}:\\s*([^;]+)`))?.[1].trim(); +} + +/** "minmax(0, 1fr) 120px 80px" → ["minmax(0, 1fr)", "120px", "80px"]. */ +function tracks(value: string) { + const out: string[] = []; + let depth = 0; + let current = ""; + for (const ch of value) { + if (ch === "(") depth++; + if (ch === ")") depth--; + if (/\s/.test(ch) && depth === 0) { + if (current) out.push(current); + current = ""; + } else { + current += ch; + } + } + if (current) out.push(current); + return out; +} + +describe("journal archive dates", () => { + const all = rules(css); + + it("sizes the date column to its date", () => { + const row = all.find( + (r) => r.selectors.includes(".journal-post-list a") && declaration(r.body, "display") === "grid", + ); + expect(row, "no .journal-post-list a grid rule").toBeDefined(); + const columns = tracks(declaration(row!.body, "grid-template-columns") ?? ""); + expect(columns, "title, date, reading time").toHaveLength(3); + expect(columns[1], "the date track").toMatch(/max-content/); + }); + + it("keeps a date on one line", () => { + const own = all.filter((r) => r.selectors.includes(".journal-list-meta")); + expect(own.some((r) => declaration(r.body, "white-space") === "nowrap")).toBe(true); + }); +}); From 68b4032f3393d473ea5aeae2cc13ad35d4b779da Mon Sep 17 00:00:00 2001 From: Patternflow Bot Date: Wed, 28 Oct 2026 00:00:32 +0900 Subject: [PATCH 02/11] web: journal dates are the Seoul day a post was written, wherever the From Patternflow-lab/Patternflow#307 --- web/src/lib/journal.test.ts | 44 +++++++++++++++++++++++++++++++++++++ web/src/lib/journal.ts | 3 +++ 2 files changed, 47 insertions(+) create mode 100644 web/src/lib/journal.test.ts diff --git a/web/src/lib/journal.test.ts b/web/src/lib/journal.test.ts new file mode 100644 index 000000000..5d198a067 --- /dev/null +++ b/web/src/lib/journal.test.ts @@ -0,0 +1,44 @@ +// Every journal date is a Seoul time ("2026-05-14T01:30:00+09:00"), and the +// pages are built wherever the build runs. On 27 October a build in UTC printed +// 60 of the 520 dates on /journal a day early: headache, stopped, +// i-hope-it-works-out and negative were written between midnight and 9 a.m. in +// Seoul, which is still the day before in UTC. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { formatJournalDate, getAllJournalPosts, type JournalLang } from "./journal"; + +// UTC is a build server, Los Angeles is nine hours further behind it, and +// Kiritimati (UTC+14) is the one that runs ahead of Seoul. +const ZONES = ["UTC", "America/Los_Angeles", "Pacific/Kiritimati", "Asia/Seoul"]; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("formatJournalDate", () => { + it.each(ZONES)("prints the Seoul day when the process runs in %s", (zone) => { + vi.stubEnv("TZ", zone); + + expect(formatJournalDate("2026-05-14T01:30:00+09:00", "ko")).toBe("2026년 5월 14일"); + expect(formatJournalDate("2026-05-14T01:30:00+09:00", "en")).toBe("May 14, 2026"); + expect(formatJournalDate("2026-05-23T07:00:00+09:00", "en")).toBe("May 23, 2026"); + expect(formatJournalDate("2026-10-04T23:30:00+09:00", "ko")).toBe("2026년 10월 4일"); + }); + + it("gives every post the day written in its frontmatter", () => { + vi.stubEnv("TZ", "UTC"); + const wrong: string[] = []; + + for (const lang of ["ko", "en"] satisfies JournalLang[]) { + for (const post of getAllJournalPosts({ lang, includeDrafts: true })) { + const day = post.date.match(/^(\d{4})-(\d{2})-(\d{2})T[^+]*\+09:00$/); + if (!day) continue; + const want = formatJournalDate(`${day[1]}-${day[2]}-${day[3]}T12:00:00+09:00`, lang); + const shown = formatJournalDate(post.date, lang); + if (shown !== want) wrong.push(`${post.slug} (${lang}): ${shown}, written ${want}`); + } + } + + expect(wrong).toEqual([]); + }); +}); diff --git a/web/src/lib/journal.ts b/web/src/lib/journal.ts index 191c088c1..6f0ec59e1 100644 --- a/web/src/lib/journal.ts +++ b/web/src/lib/journal.ts @@ -128,10 +128,13 @@ export function getAdjacentJournalPosts(slug: string, lang: JournalLang = "ko") }; } +// The dates are written in Seoul (+09:00). Without a zone the day is the build +// machine's, and a post written at 01:30 in Seoul is the day before in UTC. export function formatJournalDate(date: string, lang: JournalLang = "ko") { return new Intl.DateTimeFormat(lang === "ko" ? "ko-KR" : "en", { month: "long", day: "numeric", year: "numeric", + timeZone: "Asia/Seoul", }).format(new Date(date)); } From 4d3193838e06b6eff35dc6def39a058496d584c5 Mon Sep 17 00:00:00 2001 From: Patternflow Bot Date: Wed, 28 Oct 2026 00:23:59 +0900 Subject: [PATCH 03/11] web: the journal's language switch and Archive label are readable From Patternflow-lab/Patternflow#308 --- web/src/app/globals.css | 7 ++- web/src/components/journal/journalCss.test.ts | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/web/src/app/globals.css b/web/src/app/globals.css index ff94aa9d2..efe1870e4 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1081,6 +1081,9 @@ align-items: flex-start; gap: 14px; } + /* The language a page is not in: 5.0:1 on the cream, 5.8:1 on the white + panel, and 3.2:1 from the current one in --ink. --ink-faint was 2.3:1 + and 2.6:1. */ .journal-lang-switch { position: fixed; top: 28px; @@ -1092,7 +1095,7 @@ font-size: var(--pf-fs-mono); letter-spacing: 0.08em; text-transform: uppercase; - color: var(--ink-faint); + color: var(--ink-muted); } .journal-lang-switch a { color: inherit; @@ -1285,7 +1288,7 @@ } .journal-archive-label { margin-bottom: 16px; - color: var(--ink-faint); + color: var(--ink-muted); } .journal-post-list { list-style: none; diff --git a/web/src/components/journal/journalCss.test.ts b/web/src/components/journal/journalCss.test.ts index 68df2697a..a06149853 100644 --- a/web/src/components/journal/journalCss.test.ts +++ b/web/src/components/journal/journalCss.test.ts @@ -60,3 +60,57 @@ describe("journal archive dates", () => { expect(own.some((r) => declaration(r.body, "white-space") === "nowrap")).toBe(true); }); }); + +// The language a page is not in and the index's "Archive" label were +// --ink-faint (#A69F90): 2.29:1 on the cream behind a post's switch and +// 2.63:1 on the index's white panel. axe flagged them on all 76 journal pages. + +/** The sRGB contrast ratio WCAG 2 defines, for two "#rrggbb" colours. */ +function contrast(a: string, b: string) { + const luminance = (hex: string) => { + const [r, g, bl] = [1, 3, 5].map((i) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * bl; + }; + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +describe("journal faint text", () => { + const all = rules(css); + const root = all.find((r) => r.selectors.includes(":root") && declaration(r.body, "--cream")); + const resolve = (value: string | undefined) => { + const token = value?.match(/^var\((--[\w-]+)\)$/)?.[1]; + const hex = token ? declaration(root?.body ?? "", token) : value; + expect(hex, `${value} is not a #rrggbb colour`).toMatch(/^#[0-9a-f]{6}$/i); + return hex!; + }; + const own = (selector: string, property: string) => + all + .filter((r) => r.selectors.includes(selector)) + .map((r) => declaration(r.body, property)) + .filter(Boolean) + .at(-1); + + const cream = resolve(own(".journal-shell", "background")); + const panel = resolve(own(".journal-panel", "background")); + + it("shows the other language at 4.5:1 on the cream and on the panel", () => { + const other = resolve(own(".journal-lang-switch", "color")); + expect(contrast(other, cream)).toBeGreaterThanOrEqual(4.5); + expect(contrast(other, panel)).toBeGreaterThanOrEqual(4.5); + }); + + it("keeps the current language 3:1 apart from the other", () => { + const other = resolve(own(".journal-lang-switch", "color")); + const current = resolve(own(".journal-lang-switch a.active", "color")); + expect(contrast(current, other)).toBeGreaterThanOrEqual(3); + }); + + it("shows the Archive label at 4.5:1 on the panel", () => { + const label = resolve(own(".journal-archive-label", "color")); + expect(contrast(label, panel)).toBeGreaterThanOrEqual(4.5); + }); +}); From 1c5a232042d93bfbc81b11b3b58678bfb4244041 Mon Sep 17 00:00:00 2001 From: Patternflow Bot Date: Wed, 28 Oct 2026 00:49:25 +0900 Subject: [PATCH 04/11] web: a journal post's language switch no longer sits over the text on a From Patternflow-lab/Patternflow#309 --- web/src/app/globals.css | 6 +++ web/src/components/journal/journalCss.test.ts | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/web/src/app/globals.css b/web/src/app/globals.css index efe1870e4..144e80da3 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1858,6 +1858,12 @@ top: 18px; right: 22px; } + /* A post's switch scrolls away with the page. Fixed, it sat over the + right end of each line of text that scrolled under it: 8,801 lines + across the 74 posts at 390px. It starts in the same place. */ + .journal-article .journal-lang-switch { + position: absolute; + } .journal-shell { padding: 36px 24px 96px; } diff --git a/web/src/components/journal/journalCss.test.ts b/web/src/components/journal/journalCss.test.ts index a06149853..df99e6597 100644 --- a/web/src/components/journal/journalCss.test.ts +++ b/web/src/components/journal/journalCss.test.ts @@ -114,3 +114,44 @@ describe("journal faint text", () => { expect(contrast(label, panel)).toBeGreaterThanOrEqual(4.5); }); }); + +// A post's language switch is position: fixed, and below 721px it sits 22px +// from the right edge, inside the column of text. On 27 October, at 390, all +// 74 posts scrolled 8,801 lines of text under it. + +/** The inside of each `@media { … }` block. */ +function mediaBlocks(source: string, query: string) { + const bare = source.replace(/\/\*[\s\S]*?\*\//g, ""); + const out: string[] = []; + const head = `@media ${query}`; + for (let at = bare.indexOf(head); at !== -1; at = bare.indexOf(head, at + 1)) { + const open = bare.indexOf("{", at); + let depth = 0; + for (let i = open; i < bare.length; i++) { + if (bare[i] === "{") depth++; + if (bare[i] === "}" && --depth === 0) { + out.push(bare.slice(open + 1, i)); + break; + } + } + } + return out; +} + +describe("journal language switch on a phone", () => { + const phone = mediaBlocks(css, "(max-width: 720px)").flatMap(rules); + const position = (selector: string) => + phone + .filter((r) => r.selectors.includes(selector)) + .map((r) => declaration(r.body, "position")) + .filter(Boolean) + .at(-1); + + it("scrolls away with a post instead of staying over its text", () => { + expect(position(".journal-article .journal-lang-switch")).toBe("absolute"); + }); + + it("keeps the index's switch in the panel's flow", () => { + expect(position(".journal-panel .journal-lang-switch")).toBe("static"); + }); +}); From f7b7dbecd5a898aaea469e0052dc4ed4466499dc Mon Sep 17 00:00:00 2001 From: Patternflow Bot Date: Wed, 28 Oct 2026 01:17:09 +0900 Subject: [PATCH 05/11] web: dates in the list under a journal post stay clear of the post From Patternflow-lab/Patternflow#310 --- web/src/app/globals.css | 5 +- web/src/components/journal/journalCss.test.ts | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 144e80da3..2bd596d45 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1738,12 +1738,15 @@ letter-spacing: normal; text-transform: none; } + /* 104px clear for the post number, as the index rows keep. At 56px the + end of a date ran under the 64px number: 350 of the 370 rows on the + 74 posts, from 761px up. */ .journal-context-list .pf-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 24px; align-items: baseline; - padding-right: 56px; + padding-right: 104px; border-top: 0; color: var(--ink-muted); text-decoration: none; diff --git a/web/src/components/journal/journalCss.test.ts b/web/src/components/journal/journalCss.test.ts index df99e6597..f3afb509f 100644 --- a/web/src/components/journal/journalCss.test.ts +++ b/web/src/components/journal/journalCss.test.ts @@ -155,3 +155,49 @@ describe("journal language switch on a phone", () => { expect(position(".journal-panel .journal-lang-switch")).toBe("static"); }); }); + +// Each row of the list under a post has its post number at the right, 64px +// type, and the row kept 56px clear for it. On 27 October, from 761px up, the +// end of 350 of the 370 dates on the 74 posts ran under the number, by up to +// 18.7px; from 721px, where the number is 52px, 228 of them. The index's rows +// keep 104px clear and none of their text meets the number. + +/** The stylesheet with every `@media` block taken out: the rules that hold at any width. */ +function withoutMedia(source: string) { + const bare = source.replace(/\/\*[\s\S]*?\*\//g, ""); + let out = ""; + let from = 0; + for (let at = bare.indexOf("@media", from); at !== -1; at = bare.indexOf("@media", from)) { + out += bare.slice(from, at); + let depth = 0; + let i = bare.indexOf("{", at); + for (; i < bare.length; i++) { + if (bare[i] === "{") depth++; + if (bare[i] === "}" && --depth === 0) break; + } + from = i + 1; + } + return out + bare.slice(from); +} + +describe("journal post numbers", () => { + const all = rules(withoutMedia(css)); + const paddingRight = (selector: string) => + all + .filter((r) => r.selectors.includes(selector)) + .map((r) => { + const own = declaration(r.body, "padding-right"); + if (own) return own; + const sides = declaration(r.body, "padding")?.split(/\s+/); + return sides && (sides[1] ?? sides[0]); + }) + .filter(Boolean) + .at(-1); + + it("keeps the list under a post as clear of the number as the index", () => { + const index = parseFloat(paddingRight(".journal-post-list a") ?? ""); + const underPost = parseFloat(paddingRight(".journal-context-list .pf-row") ?? ""); + expect(index, "the index row's padding-right").toBeGreaterThan(0); + expect(underPost).toBeGreaterThanOrEqual(index); + }); +}); From be1b44701f41780b4baf7a752fe52c13e2c9b057 Mon Sep 17 00:00:00 2001 From: Patternflow Bot Date: Wed, 28 Oct 2026 01:43:38 +0900 Subject: [PATCH 06/11] web: the footers under a journal post and the journal index line up with From Patternflow-lab/Patternflow#311 --- web/src/app/globals.css | 16 ++++++++ web/src/components/journal/journalCss.test.ts | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 2bd596d45..e0dbada15 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1364,9 +1364,16 @@ .journal-post-list a.journal-anchor-post .journal-list-meta:last-child { color: var(--pf-led); } + /* Both journal footers are