Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions skills/motion-render/references/html-film.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,17 @@ Claude は `<work>/film/index.html` だけを書く。描画中はネットワ
motion-render check <work>/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` は問題が残っていると止まる。

Expand Down Expand Up @@ -156,7 +159,8 @@ motion-render check <work>/film

- `<html lang="ja">` と `kit/base.css` で、見出しは文節の切れ目で改行される。長いカタカナ語
(セキュリティトークンサービス)は途中で割れるので、短い言葉か英語の表記にする
- 本文 28px 以上、字幕 52〜60px、見出し 40px 以上。数字には `class="num"`(等幅の数字)
- 本文 28px 以上、字幕 52〜60px、見出し 40px 以上。出典や小さなラベルも 24px 以上。数字には `class="num"`(等幅の数字)
- 不透明度を 0.9 未満にした文字(わざと暗くした部品)はコントラストを測らない。読ませたい文字は薄くしない
- 色は `:root` の変数で決め、意味で使い分ける(正常・強調・危険)

## 決まった絵になるように
Expand Down
57 changes: 50 additions & 7 deletions skills/motion-render/scripts/layout.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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;
Expand All @@ -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<LayoutBox, "x" | "y" | "w" | "h">;

function overlaps(a: Rect, b: Rect): boolean {
Expand Down Expand Up @@ -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<number, LayoutBox>, quiet: Set<number>): Finding[] {
const out: Finding[] = [];
Expand All @@ -74,6 +106,7 @@ function boxFindings(b: LayoutBox, byKey: Map<number, LayoutBox>, quiet: Set<num
kind: "frame",
message: `${b.label} is cut off by the frame`,
});
out.push(...textFindings(b));
if (b.ok) return out;
const parent = byKey.get(b.anc[0] ?? -1);
if (parent && !contains(parent, b))
Expand Down Expand Up @@ -159,9 +192,17 @@ export function formatIssue(i: LayoutIssue): string {

const num = (v: unknown): v is number => 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" &&
Expand All @@ -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");
}
Expand All @@ -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<LayoutIssue[] | null> {
// 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<LayoutIssue[] | null> {
if (!film.inspect) return null;
const samples: LayoutSample[] = [];
for (let i = 0; i * step < film.meta.duration; i++) {
Expand Down
85 changes: 83 additions & 2 deletions skills/motion-render/scripts/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() !== "";
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
Expand Down
51 changes: 50 additions & 1 deletion tests/html-film.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,35 @@ function page(wrong: boolean, lively = false): string {
</body></html>`;
}

// 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 = `<!doctype html>
<html lang="ja"><head><meta charset="utf-8">
<link rel="stylesheet" href="kit/fonts.css"><link rel="stylesheet" href="kit/base.css">
<script src="kit/gsap.min.js"></script><script src="kit/timing.js"></script><script src="kit/player.js"></script>
<style>
body { background: #0b1622; color: #e9f0f7; }
#card { position:absolute; left:400px; top:300px; width:600px; height:200px; border:3px solid #2a4466; }
#words { position:absolute; left:40px; top:80px; font-size:30px; }
.chip { position:absolute; left:1300px; top:700px; padding:20px 60px; background:#3a1a22; font-size:32px; }
</style></head>
<body><div id="root">
<section class="scene" id="intro">
<p id="tiny" style="font-size:18px">出典</p>
<p id="faint" style="color:#34465a; font-size:30px">薄い文字</p>
</section>
<section class="scene" id="deny">
<div id="card"><span id="words">条件の文字</span></div>
<div class="chip">チップ</div>
</section>
</div>
<script>
film.build(function (tl) {
tl.to(".chip", { ...film.spot(".chip", "#words"), duration: 0.1 }, film.at("deny", 0));
tl.to(".chip", { x: 0, y: 0, duration: 0.1 }, film.at("deny", 0.4));
});
</script>
</body></html>`;

function htmlFilm(wrong: boolean, lively = false): string {
const work = tempDir("mr-html-");
writeFileSync(join(work, "timing.json"), JSON.stringify(lively ? LONG : TIMING));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
Expand Down
18 changes: 18 additions & 0 deletions tests/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const box = (
anc: [],
ok: false,
under: null,
text: null,
...more,
});

Expand Down Expand Up @@ -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([]);
Expand All @@ -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");
});
});
Loading