From 86111da1cce30294fd26e63d95f5baf2ae64892e Mon Sep 17 00:00:00 2001 From: umiyosh Date: Sun, 25 Oct 2026 12:25:24 +0900 Subject: [PATCH] feat(check): report small and low-contrast text and short stops in html films --- skills/motion-render/references/html-film.md | 22 ++--- skills/motion-render/scripts/layout.ts | 57 +++++++++++-- skills/motion-render/scripts/snapshot.js | 85 +++++++++++++++++++- tests/html-film.test.ts | 51 +++++++++++- tests/layout.test.ts | 18 +++++ 5 files changed, 214 insertions(+), 19 deletions(-) diff --git a/skills/motion-render/references/html-film.md b/skills/motion-render/references/html-film.md index daf7601..95f4d66 100644 --- a/skills/motion-render/references/html-film.md +++ b/skills/motion-render/references/html-film.md @@ -107,14 +107,17 @@ Claude は `/film/index.html` だけを書く。描画中はネットワ motion-render check /film ``` -0.2 秒ごとに全体を調べ、止まっている部品について次を出す。動いている途中の重なりは数えない。 - -| 出力 | 意味と直し方 | -| --------------------------- | ------------------------------------------------------------------- | -| `A and B partly overlap` | 2 つが中途半端に重なっている。片方をもう片方の中に入れるか、離す | -| `A sticks out of B` | 子の A が親の B からはみ出している。中身を減らすか B を広げる | -| `A is cut off by the frame` | 画面の端で切れている。安全域(左右 144px、上下 81px)の中へ入れる | -| `A is hidden under B` | A の大部分が B の下に隠れている。層のクラスを見直すか、位置をずらす | +0.1 秒ごとに全体を調べ、止まっている部品について次を出す。動いている途中の重なりは数えない。 +0.2 秒以上同じ状態で止まっていれば出る。 + +| 出力 | 意味と直し方 | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `A and B partly overlap` | 2 つが中途半端に重なっている。片方をもう片方の中に入れるか、離す | +| `A sticks out of B` | 子の A が親の B からはみ出している。中身を減らすか B を広げる | +| `A is cut off by the frame` | 画面の端で切れている。安全域(左右 144px、上下 81px)の中へ入れる | +| `A is hidden under B` | A の大部分が B の下に隠れている。層のクラスを見直すか、位置をずらす | +| `A is 20px, smaller than 24px` | 文字が小さすぎる。24px 以上にする | +| `A has contrast 2.1:1, needs 4.5:1` | 文字が背景に対して薄い。40px 未満は 4.5:1、40px 以上は 3:1 を超えるよう、文字か背景の色を変える。明るい色の丸やチップに乗せる数字は、地の色(暗い色)で書く | `layout: ok` になるまで直してから `stills` で見る。`build` は問題が残っていると止まる。 @@ -156,7 +159,8 @@ motion-render check /film - `` と `kit/base.css` で、見出しは文節の切れ目で改行される。長いカタカナ語 (セキュリティトークンサービス)は途中で割れるので、短い言葉か英語の表記にする -- 本文 28px 以上、字幕 52〜60px、見出し 40px 以上。数字には `class="num"`(等幅の数字) +- 本文 28px 以上、字幕 52〜60px、見出し 40px 以上。出典や小さなラベルも 24px 以上。数字には `class="num"`(等幅の数字) +- 不透明度を 0.9 未満にした文字(わざと暗くした部品)はコントラストを測らない。読ませたい文字は薄くしない - 色は `:root` の変数で決め、意味で使い分ける(正常・強調・危険) ## 決まった絵になるように diff --git a/skills/motion-render/scripts/layout.ts b/skills/motion-render/scripts/layout.ts index 565c395..f7de3d2 100644 --- a/skills/motion-render/scripts/layout.ts +++ b/skills/motion-render/scripts/layout.ts @@ -1,6 +1,7 @@ // Layout check of an html film: samples the page through the film and reports what a viewer would // see as a mistake: two things partly on top of each other, something sticking out of the box it -// sits in or out of the frame, and something hidden under another thing. +// sits in or out of the frame, something hidden under another thing, and text too small or too +// faint to read. // Only what stays still is reported, so a chip passing over a card on its way is not a mistake. import type { Film } from "./render.ts"; import { isRecord } from "./json.ts"; @@ -17,6 +18,12 @@ export interface LayoutBox { anc: number[]; // keys of the painted boxes it sits inside in the DOM, nearest first ok: boolean; // data-layout-ok on it or an ancestor: the overlap is intended under: number | null; // key of a solid box drawn over most of it, if any + text: BoxText | null; // its own text, if it has any +} + +interface BoxText { + px: number; // font size as drawn, after transforms + contrast: number | null; // WCAG ratio over its background; null when faded or not measurable } export interface LayoutSample { @@ -25,7 +32,7 @@ export interface LayoutSample { } export interface LayoutIssue { - kind: "overlap" | "outside" | "frame" | "hidden"; + kind: "overlap" | "outside" | "frame" | "hidden" | "small" | "faint"; from: number; to: number; message: string; @@ -36,6 +43,11 @@ const SLACK = 2; // px of overlap that reads as touching, not overlapping // moves 1-2 px per sample and is still to the eye; a chip on its way moves tens of px. const MOVED = 3; +// Text on a 1080p frame. Below MIN_PX it is hard to read on a phone. Contrast follows WCAG: 4.5:1 +// for body text, 3:1 for large text (headings and big numbers, LARGE_PX and up). +const MIN_PX = 24; +const LARGE_PX = 40; + type Rect = Pick; function overlaps(a: Rect, b: Rect): boolean { @@ -65,6 +77,26 @@ interface Finding { message: string; } +function textFindings(b: LayoutBox): Finding[] { + const out: Finding[] = []; + if (!b.text) return out; + const { px, contrast } = b.text; + if (px < MIN_PX) + out.push({ + id: `small:${b.key}`, + kind: "small", + message: `${b.label} is ${px}px, smaller than ${MIN_PX}px`, + }); + const need = px >= LARGE_PX ? 3 : 4.5; + if (contrast !== null && contrast < need) + out.push({ + id: `faint:${b.key}`, + kind: "faint", + message: `${b.label} has contrast ${contrast.toFixed(1)}:1, needs ${need}:1`, + }); + return out; +} + // What is wrong with one still box on its own: cut by the frame, out of its box, or hidden. function boxFindings(b: LayoutBox, byKey: Map, quiet: Set): Finding[] { const out: Finding[] = []; @@ -74,6 +106,7 @@ function boxFindings(b: LayoutBox, byKey: Map, quiet: Set typeof v === "number" && Number.isFinite(v); +function parseText(v: unknown): BoxText | null | undefined { + if (v === null) return null; + if (isRecord(v) && num(v.px) && (v.contrast === null || num(v.contrast))) + return { px: v.px, contrast: v.contrast }; + return undefined; +} + function parseBox(v: unknown): LayoutBox { if (isRecord(v)) { const { key, label, x, y, w, h, cut, anc, ok, under } = v; + const text = parseText(v.text); if ( num(key) && typeof label === "string" && @@ -173,9 +214,10 @@ function parseBox(v: unknown): LayoutBox { typeof ok === "boolean" && Array.isArray(anc) && anc.every(num) && - (under === null || num(under)) + (under === null || num(under)) && + text !== undefined ) - return { key, label, x, y, w, h, cut, anc, ok, under }; + return { key, label, x, y, w, h, cut, anc, ok, under, text }; } throw new Error("the layout snapshot of the film page is malformed"); } @@ -186,9 +228,10 @@ export function parseBoxes(raw: unknown): LayoutBox[] { return raw.map(parseBox); } -// Samples the whole film every `step` seconds. Motion films draw inside their own player and -// cannot be inspected, so they get null. -export async function checkLayout(film: Film, step = 0.2): Promise { +// Samples the whole film every `step` seconds. A finding needs two samples in a row, so at 0.1 s a +// pose held for 0.2 s is reported. Motion films draw inside their own player and cannot be +// inspected, so they get null. +export async function checkLayout(film: Film, step = 0.1): Promise { if (!film.inspect) return null; const samples: LayoutSample[] = []; for (let i = 0; i * step < film.meta.duration; i++) { diff --git a/skills/motion-render/scripts/snapshot.js b/skills/motion-render/scripts/snapshot.js index 5ade3f1..fd863d3 100644 --- a/skills/motion-render/scripts/snapshot.js +++ b/skills/motion-render/scripts/snapshot.js @@ -19,6 +19,85 @@ return p.length > 3 ? parseFloat(p[3]) : 1; } + // [r, g, b, a] with r, g, b in 0-255, from rgb()/rgba() or color(srgb ...) (what color-mix() + // computes to). null for any other color space. + function rgba(color) { + var m = /^rgba?\(([^)]*)\)/.exec(color); + var p, k; + if (m) { + p = m[1] + .split(/[\s,/]+/) + .filter(Boolean) + .map(parseFloat); + k = 1; + } else { + m = /^color\(srgb ([^)]*)\)/.exec(color); + if (!m) return null; + p = m[1] + .split(/[\s/]+/) + .filter(Boolean) + .map(parseFloat); + k = 255; + } + return [p[0] * k, p[1] * k, p[2] * k, p.length > 3 ? p[3] : 1]; + } + + function over(top, under) { + var a = top[3]; + return [0, 1, 2].map(function (i) { + return top[i] * a + under[i] * (1 - a); + }); + } + + // What the element's own background looks like: its background and its ancestors' composited + // from the page down. null when one of them is an image or gradient, or in an unknown space. + function backdrop(el) { + var layers = []; + for (var e = el; e; e = e.parentElement) { + var cs = getComputedStyle(e); + if (cs.backgroundImage !== "none") return null; + var c = rgba(cs.backgroundColor); + if (!c) return null; + if (c[3] > 0) layers.push(c); + } + return layers.reduceRight( + function (under, c) { + return over(c, under); + }, + [255, 255, 255] + ); + } + + function luminance(c) { + var v = c.map(function (x) { + var s = x / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }); + return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2]; + } + + // WCAG contrast ratio of the text color over its backdrop, or null when it cannot be told. + function contrast(el, cs) { + var fg = rgba(cs.color), + bg = backdrop(el); + if (!fg || !bg) return null; + var a = luminance(over(fg, bg)), + b = luminance(bg); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + } + + // The size its own text is drawn at, after transforms, and its contrast. Contrast is left out + // for text drawn faded (opacity under 0.9): dimmed on purpose, or fading in or out. + function textOf(el, cs, shown) { + if (!ownText(el) || el instanceof SVGElement) return null; + var h = el.offsetHeight, + scale = h > 0 ? el.getBoundingClientRect().height / h : 1; + return { + px: Math.round(parseFloat(cs.fontSize) * scale * 10) / 10, + contrast: shown < 0.9 ? null : contrast(el, cs), + }; + } + function ownText(el) { return Array.prototype.some.call(el.childNodes, function (n) { return n.nodeType === 3 && n.textContent.trim() !== ""; @@ -101,8 +180,9 @@ if (el instanceof SVGElement && !(el instanceof SVGSVGElement)) return; var cs = getComputedStyle(el); if (cs.visibility !== "visible" || cs.display === "none") return; - var kind = paint(el, cs); - if (!kind || opacity(el) < 0.05) return; + var kind = paint(el, cs), + shown = opacity(el); + if (!kind || shown < 0.05) return; var r = rectOf(el, kind, cs); var v = cross(r, clipOf(el.parentElement)); if (v.right - v.left < 1 || v.bottom - v.top < 1) return; @@ -119,6 +199,7 @@ ok: !!el.closest("[data-layout-ok]"), solid: kind === "solid", under: null, + text: textOf(el, cs, shown), }); }); return { boxes: boxes, keyOf: keyOf }; diff --git a/tests/html-film.test.ts b/tests/html-film.test.ts index 410185e..ad1878a 100644 --- a/tests/html-film.test.ts +++ b/tests/html-film.test.ts @@ -66,6 +66,35 @@ function page(wrong: boolean, lively = false): string { `; } +// Small and faint text in the intro; in the deny scene a chip rests on a card's text for 0.3 s. +const TEXT_PAGE = ` + + + + +
+
+

出典

+

薄い文字

+
+
+
条件の文字
+
チップ
+
+
+ +`; + function htmlFilm(wrong: boolean, lively = false): string { const work = tempDir("mr-html-"); writeFileSync(join(work, "timing.json"), JSON.stringify(lively ? LONG : TIMING)); @@ -106,7 +135,7 @@ describe("html film", () => { const wrong = await openFilm(htmlFilm(true)); try { expect((await checkLayout(wrong))?.map(formatIssue)).toEqual([ - `1.2-1.8s: #cond "assertion.repository == " and #deny "✕" partly overlap`, + `1.1-1.9s: #cond "assertion.repository == " and #deny "✕" partly overlap`, ]); } finally { await wrong.close(); @@ -147,6 +176,26 @@ describe("html film", () => { } }); + it("reports small and faint text, and a chip resting on text for only 0.3 s", async () => { + const work = tempDir("mr-html-"); + writeFileSync(join(work, "timing.json"), JSON.stringify(TIMING)); + const dir = writeKit(work); + writeFileSync(join(dir, "index.html"), TEXT_PAGE); + const film = await openFilm(dir); + try { + const issues = ((await checkLayout(film)) ?? []).map(formatIssue); + expect(issues).toEqual( + expect.arrayContaining([ + expect.stringMatching(/#tiny "出典" is 18px, smaller than 24px/), + expect.stringMatching(/#faint "薄い文字" has contrast 1\.\d:1, needs 4\.5:1/), + expect.stringMatching(/^1\.2-1\.4s: #words "条件の文字" is hidden under div\.chip/), + ]) + ); + } finally { + await film.close(); + } + }); + it("fails to load with a pointer to the kit when the font is missing", async () => { const dir = htmlFilm(false); rmSync(join(dir, "kit", "fonts"), { recursive: true }); diff --git a/tests/layout.test.ts b/tests/layout.test.ts index 0df99b5..b922a2a 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -22,6 +22,7 @@ const box = ( anc: [], ok: false, under: null, + text: null, ...more, }); @@ -85,6 +86,19 @@ describe("findLayoutIssues", () => { expect(findLayoutIssues(held(boxes))).toEqual([]); }); + it("reports text that is too small or too faint, with the bar for large text lower", () => { + const boxes = [ + box(1, [0, 0, 100, 30], { text: { px: 20, contrast: 7 } }), + box(2, [0, 100, 100, 30], { text: { px: 30, contrast: 3.7 } }), + box(3, [0, 200, 300, 60], { text: { px: 48, contrast: 3.7 } }), // large: 3:1 is enough + box(4, [0, 300, 100, 30], { text: { px: 30, contrast: null } }), // faded: not measured + ]; + expect(findLayoutIssues(held(boxes)).map((i) => i.message)).toEqual([ + "#b1 is 20px, smaller than 24px", + "#b2 has contrast 3.7:1, needs 4.5:1", + ]); + }); + it("skips boxes marked data-layout-ok", () => { const boxes = [box(1, [0, 0, 100, 50]), box(2, [80, 10, 100, 50], { ok: true })]; expect(findLayoutIssues(held(boxes))).toEqual([]); @@ -98,5 +112,9 @@ describe("parseBoxes", () => { expect(() => parseBoxes({})).toThrow("malformed"); expect(() => parseBoxes([{ ...b, x: "0" }])).toThrow("malformed"); expect(() => parseBoxes([{ ...b, anc: ["1"] }])).toThrow("malformed"); + const t = { ...b, text: { px: 30, contrast: null } }; + expect(parseBoxes([t])).toEqual([t]); + expect(() => parseBoxes([{ ...b, text: { px: "30", contrast: 4 } }])).toThrow("malformed"); + expect(() => parseBoxes([{ ...b, text: undefined }])).toThrow("malformed"); }); });