fix(lint): stop flagging dir on <html>; a render test now guards RTL export (#5744)

This commit is contained in:
Miguel Ángel
2026-10-17 03:25:12 +00:00
committed by GitHub
parent 40c64255e8
commit e6ce733796
8 changed files with 107 additions and 103 deletions
-6
View File
@@ -108,12 +108,6 @@ Source: `packages/lint/src/rules/composition.ts`.
**Fix:** Keep `data-composition-id` on exactly one element: the composition root. Remove it from metadata or duplicate hosts.
### `html_dir_attribute_breaks_render`
**Error.** `<html dir="rtl">` or `<html dir="auto">` renders correctly in preview and snapshot but produces a fully blank or black video from render — a confirmed, silent failure. `dir="ltr"` is not flagged.
**Fix:** Remove `dir` from `<html>`, keep `lang`, and scope the direction to individual text-containing elements instead — `direction: rtl` in CSS, or `dir="auto"` on the element.
### `invalid_parent_traversal_in_asset_path`
**Error.** Asset paths use `../` to traverse above the project root; render and preview clamp or misresolve them.
@@ -1594,70 +1594,6 @@ describe("composition rules", () => {
});
});
describe("html_dir_attribute_breaks_render", () => {
const CODE = "html_dir_attribute_breaks_render";
const find = (findings: { code: string }[]) => findings.find((f) => f.code === CODE);
it('flags dir="rtl" on <html>', async () => {
const html = `<!DOCTYPE html>
<html lang="ar" dir="rtl">
<body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5">مرحبا</div>
</body>
</html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain('dir="rtl"');
expect(finding?.fixHint).toContain("direction: rtl");
});
it('flags dir="auto" on <html>', async () => {
const html = `<html dir="AUTO"><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.fixHint).toContain('dir="auto"');
});
it('does not flag dir="ltr"', async () => {
const html = `<html dir="ltr"><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not flag invalid dir values that browsers treat as ltr", async () => {
const html = `<html dir="bogus"><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not flag when <html> has no dir attribute", async () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it('does not flag dir="rtl" scoped to an individual element (the documented fix)', async () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5">
<p style="direction: rtl;">مرحبا</p>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
});
describe("subcomposition_blanks_before_host", () => {
const find = (findings: Array<{ code: string }>) =>
findings.find((f) => f.code === "subcomposition_blanks_before_host");
-32
View File
@@ -1224,38 +1224,6 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
.filter((tag) => readJsonAttr(tag.raw, "data-composition-variables"))
.flatMap((tag) => variablesDeclarationFindings(tag, tags)),
// html_dir_attribute_breaks_render — valid non-LTR dir values on
// <html> renders correctly in preview/snapshot but produces a fully
// blank/black video from render, with no other lint/validate/inspect
// check catching it (output file size, far smaller than expected, is the
// only tell). Confirmed independently by two separate reports, both
// diagnosing the same exact trigger and the same fix: drop dir from
// <html>, keep lang, and scope `direction: rtl` to individual
// text-containing elements via CSS instead (text still bidi-shapes
// correctly). Advisory-only — this does not attempt to fix the render
// pipeline's own root cause (suspected to be a capture step that clips a
// fixed top-left-origin screenshot region, which RTL layout can shift the
// actual content away from), only surfaces the already-confirmed footgun
// before someone hits it blind.
({ tags }) => {
const htmlTag = findHtmlTag(tags);
if (!htmlTag) return [];
const dir = readAttr(htmlTag.raw, "dir");
if (!dir) return [];
const normalizedDir = dir.toLowerCase();
if (normalizedDir !== "rtl" && normalizedDir !== "auto") return [];
const scopedDirection = normalizedDir === "auto" ? 'dir="auto"' : `direction: ${normalizedDir}`;
return [
{
code: "html_dir_attribute_breaks_render",
severity: "error",
message: `<html dir="${dir}"> renders correctly in preview/snapshot but produces a fully blank/black video from render. This is a confirmed, silent failure.`,
fixHint: `Remove dir="${dir}" from <html>. Keep lang, and scope ${scopedDirection} to individual text-containing elements instead. Text still shapes correctly via the browser's own bidi algorithm.`,
snippet: truncateSnippet(htmlTag.raw),
},
];
},
// subcomposition_blanks_before_host
// Warns when a full-bleed sub-composition slot ends before the host composition
// does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
@@ -0,0 +1,13 @@
{
"name": "html-dir-rtl",
"description": "Regression guard for right-to-left documents: dir=\"rtl\" on <html> must export the same frame preview shows. A magenta page background sits behind the root, so a blank export or a capture shifted by the RTL scroll origin collapses PSNR.",
"tags": ["regression", "rtl", "edge"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 12,
"workers": 1
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75a439558edcfd99b92e196713c40eae5f992c3a8b155f6dc8f573a7e14e1440
size 250280
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:398c7814fb49799c6720a510270a87cc9246a7c30c86c0fc355473e7008d86e0
size 8106
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=640, height=360" />
<title>HTML dir rtl</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
html,
body {
margin: 0;
width: 640px;
height: 360px;
overflow: hidden;
background: rgb(255, 0, 255);
font-family: system-ui, sans-serif;
}
#root {
position: relative;
width: 640px;
height: 360px;
overflow: hidden;
background: #0b3d91;
}
#start-block {
position: absolute;
top: 32px;
left: 32px;
width: 160px;
height: 96px;
background: #f5c400;
}
#end-block {
position: absolute;
top: 32px;
right: 32px;
width: 96px;
height: 96px;
background: #19c37d;
}
#headline {
position: absolute;
top: 160px;
right: 32px;
margin: 0;
color: #ffffff;
font-size: 48px;
}
#progress {
position: absolute;
right: 32px;
bottom: 32px;
width: 576px;
height: 24px;
background: #ff4d4d;
transform-origin: right center;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-width="640"
data-height="360"
data-start="0"
data-duration="2"
>
<div id="start-block"></div>
<div id="end-block"></div>
<h1 id="headline">مرحبا بالعالم</h1>
<div id="progress"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#progress", { scaleX: 0 }, { scaleX: 1, duration: 2, ease: "none" }, 0);
window.__timelines["main"] = tl;
</script>
</body>
</html>
+2 -1
View File
@@ -67,7 +67,8 @@
"vfr-screen-recording": 6,
"vignelli-stacking": 40,
"webm-transparency": 60,
"webm-vp9": 4
"webm-vp9": 4,
"html-dir-rtl": 8
},
"excluded": {
"chrome-screenshot-bottom-edge": "Was absent from the hand-written shard matrix, so it has never run in CI and its cost and pass state are unknown. Triage and either schedule it or record a real reason here.",