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
+8 -2
View File
@@ -52,6 +52,11 @@ const COMPONENT_ITEM: RegistryItem = {
target: "compositions/components/my-component/my-component.css",
type: "hyperframes:style",
},
{
path: "assets/mask.png",
target: "assets/my-component/mask.png",
type: "hyperframes:asset",
},
],
};
@@ -190,7 +195,7 @@ describe("runAdd (integration, mocked registry)", () => {
}
});
it("remaps component targets per hyperframes.json paths.components", async () => {
it("remaps component snippet/style targets while leaving asset targets stable", async () => {
const dir = tmp();
try {
const baseUrl = uniqueBase();
@@ -206,9 +211,10 @@ describe("runAdd (integration, mocked registry)", () => {
projectDir: dir,
skipClipboard: true,
});
expect(result.written.length).toBe(2);
expect(result.written.length).toBe(3);
expect(existsSync(join(dir, "src/fx/my-component/my-component.html"))).toBe(true);
expect(existsSync(join(dir, "src/fx/my-component/my-component.css"))).toBe(true);
expect(existsSync(join(dir, "assets/my-component/mask.png"))).toBe(true);
expect(result.snippet).toContain("src/fx/my-component/my-component.html");
} finally {
rmSync(dir, { recursive: true, force: true });
+1 -1
View File
@@ -82,7 +82,7 @@ describe("renderLocal browser GPU config", () => {
browserGpuMode: "software",
resolved: true,
});
});
}, 15_000);
it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
+104
View File
@@ -391,6 +391,110 @@ describe("audio_src_not_found", () => {
});
});
describe("texture_mask_asset_not_found", () => {
it("errors when CSS mask-image references a missing local texture", () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div class="hf-texture-text hf-texture-lava">TEXT</div>
</div>
<style>
.hf-texture-lava {
-webkit-mask-image: url("masks/lava.png");
mask-image: url("masks/lava.png");
}
</style>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const project = makeProject(html);
const { totalErrors, results } = lintProject(project);
const finding = results[0]?.result.findings.find(
(item) => item.code === "texture_mask_asset_not_found",
);
expect(totalErrors).toBeGreaterThan(0);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("masks/lava.png");
});
it("does not error when the referenced texture mask exists", () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div class="hf-texture-text hf-texture-lava">TEXT</div>
</div>
<style>
.hf-texture-lava {
-webkit-mask-image: url("masks/lava.png");
mask-image: url("masks/lava.png");
}
</style>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const project = makeProject(html);
mkdirSync(join(project.dir, "masks"), { recursive: true });
writeFileSync(join(project.dir, "masks", "lava.png"), "fake");
const { results } = lintProject(project);
const finding = results[0]?.result.findings.find(
(item) => item.code === "texture_mask_asset_not_found",
);
expect(finding).toBeUndefined();
});
it("resolves mask-image URLs inside linked sub-composition stylesheets", () => {
const project = makeProject(validHtml(), {
"scene.html": `<html><head><link rel="stylesheet" href="scene.css"></head><body>
<div data-composition-id="scene" data-width="1920" data-height="1080">
<div class="hf-texture-text hf-texture-lava">TEXT</div>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
</body></html>`,
});
writeFileSync(
join(project.dir, "compositions", "scene.css"),
'.hf-texture-lava { mask-image: url("masks/lava.png"); }',
);
mkdirSync(join(project.dir, "compositions", "masks"), { recursive: true });
writeFileSync(join(project.dir, "compositions", "masks", "lava.png"), "fake");
const { results } = lintProject(project);
const finding = results[0]?.result.findings.find(
(item) => item.code === "texture_mask_asset_not_found",
);
expect(finding).toBeUndefined();
});
it("resolves root-absolute mask-image URLs from the project root", () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div class="hf-texture-text hf-texture-lava">TEXT</div>
</div>
<style>
.hf-texture-lava {
-webkit-mask-image: url("/assets/texture-mask-text/masks/lava.png");
mask-image: url("/assets/texture-mask-text/masks/lava.png");
}
</style>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const project = makeProject(html);
mkdirSync(join(project.dir, "assets", "texture-mask-text", "masks"), {
recursive: true,
});
writeFileSync(join(project.dir, "assets", "texture-mask-text", "masks", "lava.png"), "fake");
const { results } = lintProject(project);
const finding = results[0]?.result.findings.find(
(item) => item.code === "texture_mask_asset_not_found",
);
expect(finding).toBeUndefined();
});
});
describe("multiple_root_compositions", () => {
it("fires when two HTML files have data-composition-id", () => {
const project = makeProject(validHtml());
+116
View File
@@ -18,6 +18,12 @@ interface HtmlSource {
compSrcPath?: string;
}
interface CssSource {
content: string;
/** Root-relative path to the CSS file. Undefined means inline HTML CSS. */
rootRelativePath?: string;
}
export interface ProjectLintResult {
results: Array<{ file: string; result: HyperframeLintResult }>;
totalErrors: number;
@@ -26,6 +32,16 @@ export interface ProjectLintResult {
}
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
const STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
const OPEN_TAG_RE = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
const MASK_IMAGE_URL_RE =
/\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi;
function readHtmlAttr(tag: string, name: string): string | null {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = tag.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
return match?.[1] ?? match?.[2] ?? null;
}
function isLocalStylesheetHref(href: string): boolean {
return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href);
@@ -53,6 +69,62 @@ function collectExternalStyles(
return styles;
}
function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] {
const sources: CssSource[] = [];
let styleMatch: RegExpExecArray | null;
const stylePattern = new RegExp(STYLE_BLOCK_RE.source, STYLE_BLOCK_RE.flags);
while ((styleMatch = stylePattern.exec(html)) !== null) {
sources.push({ content: styleMatch[1] ?? "" });
}
const linkRe = /<link\b[^>]*>/gi;
let linkMatch: RegExpExecArray | null;
while ((linkMatch = linkRe.exec(html)) !== null) {
const tag = linkMatch[0];
const rel = readHtmlAttr(tag, "rel") ?? "";
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
const href = readHtmlAttr(tag, "href") ?? "";
if (!isLocalStylesheetHref(href)) continue;
const rootRelativePath = compSrcPath ? join(dirname(compSrcPath), href) : href;
const resolved = resolve(projectDir, rootRelativePath);
if (!existsSync(resolved)) continue;
sources.push({ content: readFileSync(resolved, "utf-8"), rootRelativePath });
}
let tagMatch: RegExpExecArray | null;
const tagPattern = new RegExp(OPEN_TAG_RE.source, OPEN_TAG_RE.flags);
while ((tagMatch = tagPattern.exec(html)) !== null) {
const tag = tagMatch[0];
const style = readHtmlAttr(tag, "style");
if (!style) continue;
sources.push({ content: style });
}
return sources;
}
function isRemoteOrInlineUrl(url: string): boolean {
return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
}
function cleanAssetUrl(url: string): string {
return url.trim().split(/[?#]/, 1)[0] ?? "";
}
function resolveCssAssetPath(
projectDir: string,
url: string,
htmlCompSrcPath?: string,
cssRootRelativePath?: string,
): string {
if (url.startsWith("/")) return resolve(projectDir, url.slice(1));
if (cssRootRelativePath) return resolve(projectDir, join(dirname(cssRootRelativePath), url));
if (htmlCompSrcPath) return resolve(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
return resolve(projectDir, url);
}
/**
* Lint the root index.html and all sub-compositions in the compositions/ directory.
* Returns aggregated results across all files.
@@ -101,6 +173,7 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
const projectFindings = [
...lintProjectAudioFiles(project.dir, allHtmlSources),
...lintAudioSrcNotFound(project.dir, allHtmlSources),
...lintTextureMaskAssetNotFound(project.dir, allHtmlSources),
...lintMultipleRootCompositions(project.dir),
...lintDuplicateAudioTracks(allHtmlSources),
];
@@ -215,6 +288,49 @@ function lintAudioSrcNotFound(
return findings;
}
function lintTextureMaskAssetNotFound(
projectDir: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const missing = new Map<string, string>();
for (const { html, compSrcPath } of htmlSources) {
for (const cssSource of collectCssSources(projectDir, html, compSrcPath)) {
let match: RegExpExecArray | null;
const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags);
while ((match = pattern.exec(cssSource.content)) !== null) {
const rawUrl = match[1] ?? match[2] ?? match[3] ?? "";
const url = cleanAssetUrl(rawUrl);
if (!url || isRemoteOrInlineUrl(url)) continue;
if (/^__[A-Z_]+__$/.test(url)) continue;
const resolved = resolveCssAssetPath(
projectDir,
url,
compSrcPath,
cssSource.rootRelativePath,
);
if (existsSync(resolved)) continue;
missing.set(url, resolved);
}
}
}
if (missing.size === 0) return [];
const urls = [...missing.keys()];
return [
{
code: "texture_mask_asset_not_found",
severity: "error",
message: `CSS mask-image references file(s) not found in the project: ${urls.join(", ")}.`,
fixHint:
urls.length === 1
? `Add "${urls[0]}" to the project, or update the mask-image URL to point to an existing texture mask.`
: "Add the missing texture mask files to the project, or update the mask-image URLs to point to existing files.",
},
];
}
/**
* Error if multiple root-level HTML files with data-composition-id exist.
* Scans the project directory filesystem (not just what lintProject chose to read)
@@ -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;
},
];
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = dirname(fileURLToPath(import.meta.url));
const generatorSource = readFileSync(
resolve(here, "../../../../scripts/generate-catalog-pages.ts"),
"utf-8",
);
describe("catalog generator texture instructions", () => {
it("pins the unambiguous texture style-block copy instruction", () => {
expect(generatorSource).toContain("paste the real <style> block");
expect(generatorSource).toContain("near the bottom into the composition once");
expect(generatorSource).toContain("real \\`<style>\\` element near the bottom");
});
});