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 {