docs: add texture mask text catalog entry (#650)

* feat(registry): add texture mask PNGs for texture-mask-text component

* feat(registry): add texture-mask-text CSS snippet

* feat(registry): add registry-item.json for texture-mask-text

* feat(registry): add texture-mask-text demo composition

* feat(registry): register texture-mask-text component in manifest

* style: format texture-mask-text files with oxfmt

* fix: set mask-image directly on texture classes instead of via CSS custom property

url() inside CSS custom properties doesn't resolve correctly with mask-image
in some browsers. Move mask-image declarations to each texture class directly.

* docs: add texture mask text catalog entry

* test: lint texture mask text usage

* fix: harden texture mask text docs and lint

* fix: stabilize texture mask asset paths

* fix: address texture catalog review feedback

* fix: harden texture mask text instructions

* docs: remove texture catalog intro copy

* docs: use canonical texture preview URL

* docs: use cdn texture mask assets

* fix: escape catalog frontmatter safely

* test: stabilize windows render cli test

* test: pin texture catalog instructions
This commit is contained in:
Vance Ingalls
2026-05-07 00:23:36 -07:00
committed by GitHub
parent 73966cd53b
commit edac92b431
85 changed files with 2779 additions and 33 deletions
@@ -7,6 +7,7 @@ import { gsapRules } from "./rules/gsap";
import { captionRules } from "./rules/captions";
import { compositionRules } from "./rules/composition";
import { adapterRules } from "./rules/adapters";
import { textureRules } from "./rules/textures";
const ALL_RULES = [
...coreRules,
@@ -15,6 +16,7 @@ const ALL_RULES = [
...captionRules,
...compositionRules,
...adapterRules,
...textureRules,
];
export function lintHyperframeHtml(
@@ -35,6 +35,21 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
it("does not count inline style block internals as structural lines", () => {
const style = `<style>\n${Array.from({ length: 320 }, (_, i) => `.rule-${i} { color: red; }`).join("\n")}\n</style>`;
const html = `<!doctype html>
<html>
<head>${style}</head>
<body>
<div data-composition-id="main" data-start="0" data-duration="1">TEXT</div>
</body>
</html>`;
const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});
it("does not warn for large registry source block files", () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
+5 -1
View File
@@ -16,6 +16,10 @@ function countPhysicalLines(source: string): number {
return withoutFinalNewline.split("\n").length;
}
function countStructuralLines(source: string): number {
return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
}
function isRegistrySourceFile(filePath?: string): boolean {
if (!filePath) return false;
@@ -38,7 +42,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
({ rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const lineCount = countPhysicalLines(rawSource);
const lineCount = countStructuralLines(rawSource);
if (lineCount <= MAX_COMPOSITION_LINES) return [];
const splitTarget = options.isSubComposition
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
function baseHtml(body: string, style = ""): string {
return `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
${body}
</div>
<style>${style}</style>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
const textureCss = `
.hf-texture-text {
color: #fff;
-webkit-mask-size: var(--mask-size, cover);
mask-size: var(--mask-size, cover);
}
.hf-texture-lava {
-webkit-mask-image: url("masks/lava.png");
mask-image: url("masks/lava.png");
}
`;
describe("texture rules", () => {
it("does not warn for a valid texture mask text usage", () => {
const html = baseHtml(
'<div class="shadow"><div class="hf-texture-text hf-texture-lava">TEXT</div></div>',
`${textureCss}.shadow { filter: drop-shadow(1px 2px 1px rgba(0,0,0,.48)); }`,
);
const result = lintHyperframeHtml(html);
expect(result.findings.filter((finding) => finding.code.startsWith("texture_"))).toEqual([]);
});
it("warns when a material class is used without hf-texture-text", () => {
const html = baseHtml('<div class="hf-texture-lava">TEXT</div>', textureCss);
const result = lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_class_missing_base");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.fixHint).toContain("hf-texture-text");
});
it("warns when hf-texture-text has no material class or custom mask image", () => {
const html = baseHtml('<div class="hf-texture-text">TEXT</div>', textureCss);
const result = lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("allows hf-texture-text with an inline custom mask image", () => {
const html = baseHtml(
'<div class="hf-texture-text" style="-webkit-mask-image:url(custom.png); mask-image:url(custom.png)">TEXT</div>',
textureCss,
);
const result = lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
expect(finding).toBeUndefined();
});
it("warns when a texture material class is not defined by local CSS", () => {
const html = baseHtml('<div class="hf-texture-text hf-texture-marbel">TEXT</div>', textureCss);
const result = lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_class_unknown");
expect(finding).toBeDefined();
expect(finding?.message).toContain("hf-texture-marbel");
});
it("warns when drop-shadow is applied inline to the textured text element", () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava" style="filter: drop-shadow(1px 2px 1px black)">TEXT</div>',
textureCss,
);
const result = lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.fixHint).toContain("wrapper");
});
it("warns when drop-shadow is applied by CSS directly to hf-texture-text", () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
`${textureCss}.hf-texture-text { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".hf-texture-text");
});
it("warns when drop-shadow targets a material class before the mask rule is declared", () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
`.hf-texture-lava { filter: drop-shadow(1px 2px 1px black); }
${textureCss}`,
);
const result = lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".hf-texture-lava");
});
it("warns when drop-shadow targets another class on the textured text element", () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
`${textureCss}.headline { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".headline");
});
it("does not warn when another-class drop-shadow selector needs an unmatched ancestor", () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
`${textureCss}.card .headline { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeUndefined();
});
});
+223
View File
@@ -0,0 +1,223 @@
import postcss from "postcss";
import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
import { readAttr, truncateSnippet } from "../utils";
const TEXTURE_BASE_CLASS = "hf-texture-text";
const TEXTURE_CLASS_PREFIX = "hf-texture-";
type DropShadowRule = {
selector: string;
directlyTargetsTexture: boolean;
};
function classNames(tag: OpenTag): string[] {
return (readAttr(tag.raw, "class") ?? "").split(/\s+/).filter(Boolean);
}
function isTextureMaterialClass(className: string): boolean {
return className.startsWith(TEXTURE_CLASS_PREFIX) && className !== TEXTURE_BASE_CLASS;
}
function hasInlineMaskImage(tag: OpenTag): boolean {
const style = readAttr(tag.raw, "style") ?? "";
return /\b(?:-webkit-)?mask-image\s*:/i.test(style);
}
function hasInlineDropShadow(tag: OpenTag): boolean {
const style = readAttr(tag.raw, "style") ?? "";
return /\bfilter\s*:\s*[^;]*\bdrop-shadow\s*\(/i.test(style);
}
function classNamesInSelector(selector: string): string[] {
const classes = new Set<string>();
const pattern = /\.([A-Za-z_][\w-]*)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(selector)) !== null) {
const className = match[1];
if (!className) continue;
classes.add(className);
}
return [...classes];
}
function textureClassesInSelector(selector: string): string[] {
return classNamesInSelector(selector).filter(isTextureMaterialClass);
}
function simpleSelectorMatchesTag(selector: string, tag: OpenTag, tagClasses: string[]): boolean {
const trimmed = selector.trim();
const simpleSelectorPattern = /^(?:[A-Za-z][\w-]*)?(?:\.[A-Za-z_][\w-]*)+$/;
if (!simpleSelectorPattern.test(trimmed)) return false;
const typeMatch = /^([A-Za-z][\w-]*)/.exec(trimmed);
if (typeMatch && typeMatch[1]!.toLowerCase() !== tag.name) return false;
const selectorClasses = classNamesInSelector(trimmed);
return (
selectorClasses.length > 0 &&
selectorClasses.every((className) => tagClasses.includes(className))
);
}
function collectTextureCss(styles: LintContext["styles"]): {
definedTextureClasses: Set<string>;
dropShadowRules: DropShadowRule[];
} {
const definedTextureClasses = new Set<string>();
const dropShadowRules: DropShadowRule[] = [];
const roots: postcss.Root[] = [];
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
roots.push(root);
root.walkRules((rule) => {
const selectors = rule.selectors ?? [];
let hasMaskImage = false;
for (const node of rule.nodes ?? []) {
if (node.type !== "decl") continue;
const prop = node.prop.toLowerCase();
if (prop === "mask-image" || prop === "-webkit-mask-image") hasMaskImage = true;
}
if (hasMaskImage) {
for (const selector of selectors) {
for (const className of textureClassesInSelector(selector)) {
definedTextureClasses.add(className);
}
}
}
});
}
for (const root of roots) {
root.walkRules((rule) => {
const selectors = rule.selectors ?? [];
let hasDropShadow = false;
for (const node of rule.nodes ?? []) {
if (node.type !== "decl") continue;
if (node.prop.toLowerCase() === "filter" && /\bdrop-shadow\s*\(/i.test(node.value)) {
hasDropShadow = true;
}
}
if (hasDropShadow) {
for (const selector of selectors) {
const targetsBaseClass = /\.hf-texture-text\b/.test(selector);
const targetsDefinedTextureClass = textureClassesInSelector(selector).some((className) =>
definedTextureClasses.has(className),
);
dropShadowRules.push({
selector,
directlyTargetsTexture: targetsBaseClass || targetsDefinedTextureClass,
});
}
}
});
}
return { definedTextureClasses, dropShadowRules };
}
export const textureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const { definedTextureClasses, dropShadowRules } = collectTextureCss(styles);
for (const { selector, directlyTargetsTexture } of dropShadowRules) {
if (!directlyTargetsTexture) continue;
findings.push({
code: "texture_drop_shadow_on_text",
severity: "warning",
message: "Drop shadow is applied directly to textured text.",
selector,
fixHint:
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
});
}
for (const tag of tags) {
if (tag.name === "style" || tag.name === "script") continue;
const classes = classNames(tag);
if (classes.length === 0) continue;
const hasBaseClass = classes.includes(TEXTURE_BASE_CLASS);
const textureClasses = classes.filter(isTextureMaterialClass);
if (textureClasses.length > 0 && !hasBaseClass) {
findings.push({
code: "texture_class_missing_base",
severity: "warning",
message: `Texture material class \`${textureClasses[0]}\` is used without \`${TEXTURE_BASE_CLASS}\`.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint: `Add \`${TEXTURE_BASE_CLASS}\` alongside the material class, for example \`class="${TEXTURE_BASE_CLASS} ${textureClasses[0]}"\`.`,
snippet: truncateSnippet(tag.raw),
});
}
if (hasBaseClass && textureClasses.length === 0 && !hasInlineMaskImage(tag)) {
findings.push({
code: "texture_text_missing_mask",
severity: "warning",
message: `\`${TEXTURE_BASE_CLASS}\` is used without a texture material class or custom mask image.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Add a material class such as `hf-texture-lava`, or set `mask-image` and `-webkit-mask-image` on the element.",
snippet: truncateSnippet(tag.raw),
});
}
for (const textureClass of textureClasses) {
if (definedTextureClasses.has(textureClass)) continue;
findings.push({
code: "texture_class_unknown",
severity: "warning",
message: `Texture material class \`${textureClass}\` is not defined by local CSS.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Paste the Texture Mask Text component `<style>...</style>` block into the composition, or fix the texture class typo.",
snippet: truncateSnippet(tag.raw),
});
}
if (hasBaseClass) {
for (const rule of dropShadowRules) {
if (rule.directlyTargetsTexture) continue;
if (!simpleSelectorMatchesTag(rule.selector, tag, classes)) continue;
findings.push({
code: "texture_drop_shadow_on_text",
severity: "warning",
message: "Drop shadow is applied directly to textured text.",
selector: rule.selector,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
snippet: truncateSnippet(tag.raw),
});
}
}
if (hasBaseClass && hasInlineDropShadow(tag)) {
findings.push({
code: "texture_drop_shadow_on_text",
severity: "warning",
message: "Drop shadow is applied directly to textured text.",
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
];