feat(lint): warn when <html dir> can blank render output (#1893)

* feat(lint): flag dir="rtl" on <html> as a confirmed silent render failure

Two independent reports diagnosed the same exact bug: dir="rtl" (or any
non-ltr value) 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) was the only tell for both reporters. Both independently
confirmed the same fix: drop dir from <html>, keep lang, and scope
direction: rtl to individual text-containing elements via CSS instead.

Could not empirically verify the render pipeline's own root cause in this
session (headless Chrome screenshot capture is unreliable in this
sandboxed environment - even a baseline, non-RTL capture timed out), so
this ships the safe, already-confirmed advisory rather than guessing at a
runtime fix. Both reporters explicitly asked for exactly this: "deserves
a lint rule or render-time warning."

* fix(lint): only flag valid non-ltr html dir values
This commit is contained in:
Miguel Ángel
2026-07-06 18:16:40 -04:00
committed by GitHub
parent 769dc702c1
commit afa7f292fb
2 changed files with 96 additions and 0 deletions
@@ -1009,6 +1009,70 @@ 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
@@ -634,6 +634,38 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// 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.
({ source }) => {
const htmlTag = findHtmlTag(source);
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 — 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