mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat: add init tailwind flag (#577)
## Problem Users who want Tailwind utilities in a plain HyperFrames composition currently have to know which Tailwind browser script to add and where to place it. The first pass added `--tailwind`, but review caught three production-facing gaps: the CDN version was major-only, the insertion helper could silently no-op on compact HTML, and the render pipeline did not explicitly wait for Tailwind's async browser compilation before capturing frame 0. There is also a version-specific agent risk: HyperFrames `init --tailwind` uses Tailwind v4.2 through `@tailwindcss/browser@4.2.4`, while `packages/studio` still uses Tailwind v3. Without a dedicated skill, agents can easily mix v3 `tailwind.config.js` / `@tailwind` patterns into v4 browser-runtime composition HTML. ## What this fixes - Adds `hyperframes init --tailwind`. - Pins the Tailwind browser runtime to `@tailwindcss/browser@4.2.4/dist/index.global.js` with SRI and `crossorigin="anonymous"`. - Injects a `window.__tailwindReady` promise next to the browser runtime. - Makes frame capture wait for `window.__tailwindReady` in both screenshot and BeginFrame capture modes before capturing frame 0. - Inserts Tailwind support before `</head>` case-insensitively, including single-line/minified heads, and falls back to prepending when there is no head tag. - Skips recursive Tailwind injection under `.git`, `dist`, and `node_modules`. - Tracks whether init used Tailwind in the existing `init_template` telemetry event. - Adds a first-party `/tailwind` skill for Tailwind v4.2 browser-runtime HyperFrames composition work. - Updates README, docs, generated project agent files, CLI skill guidance, and plugin metadata so the Tailwind skill is discoverable. - Documents the browser-runtime tradeoff and production/offline guidance. ## Root cause `scaffoldProject()` copied the selected example and patched media placeholders, then immediately wrote project metadata and `package.json`. There was no optional post-copy step for framework-specific HTML support. The initial Tailwind post-copy step also treated the browser runtime like a static script, but Tailwind compiles utilities asynchronously after scanning the DOM, so the capture engine needed an explicit readiness contract. On the agent side, the repo exposed HyperFrames, CLI, GSAP, registry, and runtime adapter skills, but had no Tailwind-specific instruction to separate the v4 browser-runtime composition path from Studio's v3 internal setup. ## Verification ### Local checks - `bunx vitest run packages/cli/src/commands/init.test.ts` - `bun run --filter @hyperframes/cli test src/commands/init.test.ts` - `bun run --filter @hyperframes/cli typecheck` - `bun run --filter @hyperframes/engine typecheck` - `bun run lint:skills` - `bun run lint` - `npx skills add . --list` showed 12 local skills, including `tailwind`. - `bunx oxfmt --check packages/cli/src/commands/init.ts packages/cli/src/commands/init.test.ts packages/cli/src/telemetry/events.ts packages/engine/src/services/frameCapture.ts docs/packages/cli.mdx` - `bunx oxfmt --check README.md docs/quickstart.mdx docs/packages/cli.mdx CLAUDE.md packages/cli/src/templates/_shared/CLAUDE.md packages/cli/src/templates/_shared/AGENTS.md skills/hyperframes-cli/SKILL.md skills/tailwind/SKILL.md .codex-plugin/plugin.json .cursor-plugin/plugin.json` - `bunx oxlint packages/cli/src/commands/init.ts packages/cli/src/commands/init.test.ts packages/cli/src/telemetry/events.ts packages/engine/src/services/frameCapture.ts` - `git diff --check` - Lefthook pre-commit: lint/format/typecheck for code commit; format for docs/skill commit - Lefthook commit-msg: commitlint Generated-project render proof at `/tmp/hf-tailwind-render-proof`: - `bun packages/cli/src/cli.ts init /tmp/hf-tailwind-render-proof --example blank --tailwind --non-interactive --skip-skills` - Added a temporary Tailwind-only card using `flex`, `h-full`, `w-full`, `items-center`, `justify-center`, `bg-slate-950`, `rounded-3xl`, `bg-white`, `px-20`, `py-12`, `text-8xl`, `font-black`, `text-black`, and `shadow-2xl`. - `bun packages/cli/src/cli.ts lint /tmp/hf-tailwind-render-proof` → 0 errors, 0 warnings. - `bun packages/cli/src/cli.ts validate /tmp/hf-tailwind-render-proof` → 0 errors, 0 regular warnings; the temp proof still reports validator contrast warnings even though the rendered/browser pixels show black text on white background. - `bun packages/cli/src/cli.ts render /tmp/hf-tailwind-render-proof --workers 1 --fps 24 --quality draft --output /tmp/hf-tailwind-render-proof-artifacts/output.mp4` - Render compiler inlined both GSAP and `https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.2.4/dist/index.global.js`. - `ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,r_frame_rate,duration -of default=noprint_wrappers=1 /tmp/hf-tailwind-render-proof-artifacts/output.mp4` → H.264, 1920x1080, 24fps, 10s. - Extracted frame-0 proof: `/tmp/hf-tailwind-render-proof-artifacts/frame-000.png`. ### Browser verification - Started Studio preview for `/tmp/hf-tailwind-render-proof`. - Used `agent-browser` to open `http://localhost:5194`. - Verified the Tailwind-styled composition rendered in Studio preview. - Captured screenshot: `/tmp/hf-tailwind-render-proof-artifacts/browser/tailwind-preview.png`. - Captured agent-browser-driven recording: `/tmp/hf-tailwind-render-proof-artifacts/browser/tailwind-preview.webm`. - Served the PR worktree locally and used `agent-browser` to open the new Tailwind skill proof page. - Verified the browser-visible skill content includes `@tailwindcss/browser@4.2.4`. - Captured screenshot: `/Users/miguel07code/.codex/worktrees/pr-577-tailwind-comments/tmp/agent-browser-proof/tailwind-skill.png`. - Captured agent-browser-driven recording: `/Users/miguel07code/.codex/worktrees/pr-577-tailwind-comments/tmp/agent-browser-proof/tailwind-skill.webm`. ## Notes - This still intentionally uses Tailwind's browser runtime rather than adding a generated Tailwind build pipeline. That keeps `hyperframes init --tailwind` small and compatible with the current no-install generated project workflow. - The `/tailwind` skill cites official Tailwind v4 docs plus community skill references, but its instructions are HyperFrames-specific and tuned for the pinned v4.2 browser runtime. - Browser proof artifacts are local-only under `/tmp/hf-tailwind-render-proof-artifacts/` and `tmp/agent-browser-proof/` and intentionally not committed.
This commit is contained in:
@@ -4,8 +4,11 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { injectTailwindBrowserScript } from "./init.js";
|
||||
|
||||
const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts");
|
||||
const tailwindScript =
|
||||
'<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.2.4/dist/index.global.js" integrity="sha384-v5YF9xS+gLRWdvrQ0u/WRbCkjSIH0NjHIPe8tBL1ZRrmI7PiSH6LLdzs0aAIMCuh" crossorigin="anonymous"></script>';
|
||||
|
||||
// Spawns `bun` directly because the CLI entry is a .ts file that needs a
|
||||
// TypeScript-aware runtime. vitest runs under node, so `process.execPath`
|
||||
@@ -55,6 +58,80 @@ describe("hyperframes init flag rename", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("--tailwind enables Tailwind utilities in scaffolded HTML", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
|
||||
const target = join(dir, "proj");
|
||||
try {
|
||||
const res = runInit([
|
||||
target,
|
||||
"--example",
|
||||
"blank",
|
||||
"--tailwind",
|
||||
"--non-interactive",
|
||||
"--skip-skills",
|
||||
]);
|
||||
expect(res.status).toBe(0);
|
||||
|
||||
const html = readFileSync(join(target, "index.html"), "utf-8");
|
||||
expect(html).toContain(tailwindScript);
|
||||
expect(html).toContain("window.__tailwindReady");
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
expect(pkg.scripts).toMatchObject({
|
||||
dev: "npx --yes hyperframes preview",
|
||||
check:
|
||||
"npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect",
|
||||
render: "npx --yes hyperframes render",
|
||||
publish: "npx --yes hyperframes publish",
|
||||
});
|
||||
expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("inserts Tailwind before uppercase closing head tags", () => {
|
||||
const html = [
|
||||
"<!doctype html>",
|
||||
"<html>",
|
||||
"<head>",
|
||||
' <SCRIPT src="./runtime.global.js"></SCRIPT>',
|
||||
"</HEAD>",
|
||||
"</html>",
|
||||
].join("\n");
|
||||
|
||||
const injected = injectTailwindBrowserScript(html);
|
||||
expect(injected.indexOf(' <SCRIPT src="./runtime.global.js"></SCRIPT>')).toBeLessThan(
|
||||
injected.indexOf(tailwindScript),
|
||||
);
|
||||
expect(injected.indexOf(tailwindScript)).toBeLessThan(injected.indexOf("</HEAD>"));
|
||||
});
|
||||
|
||||
it("inserts Tailwind into single-line HTML heads", () => {
|
||||
const html = "<!doctype html><html><head><title>x</title></head><body></body></html>";
|
||||
|
||||
expect(injectTailwindBrowserScript(html)).toContain(`${tailwindScript}\n</head>`);
|
||||
});
|
||||
|
||||
it("does not duplicate Tailwind support when it is already present", () => {
|
||||
const html = ["<!doctype html>", "<html>", "<head>", tailwindScript, "</head>", "</html>"].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
expect(injectTailwindBrowserScript(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("keeps the readiness shim free of render-loop APIs", () => {
|
||||
const html = "<!doctype html><html><head></head><body></body></html>";
|
||||
const injected = injectTailwindBrowserScript(html);
|
||||
|
||||
expect(injected).not.toContain("Date.now");
|
||||
expect(injected).not.toContain("requestAnimationFrame");
|
||||
expect(injected).not.toContain("setTimeout");
|
||||
});
|
||||
|
||||
it("--template prints a rename hint and exits non-zero", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
|
||||
const target = join(dir, "proj");
|
||||
|
||||
@@ -6,6 +6,7 @@ export const examples: Example[] = [
|
||||
["Pick a starter example", "hyperframes init my-video --example warm-grain"],
|
||||
["Start from an existing video file", "hyperframes init my-video --video clip.mp4"],
|
||||
["Start from an audio file", "hyperframes init my-video --audio track.mp3"],
|
||||
["Scaffold with Tailwind CSS", "hyperframes init my-video --example blank --tailwind"],
|
||||
["Non-interactive mode (for CI or AI agents)", "hyperframes init my-video --non-interactive"],
|
||||
["Skip AI coding skills installation", "hyperframes init my-video --skip-skills"],
|
||||
];
|
||||
@@ -54,6 +55,13 @@ const DEFAULT_META: VideoMeta = {
|
||||
videoCodec: "h264",
|
||||
};
|
||||
|
||||
// Pin the browser runtime exactly so repeated renders do not drift as Tailwind
|
||||
// ships JIT/preflight changes on the CDN.
|
||||
const TAILWIND_BROWSER_VERSION = "4.2.4";
|
||||
const TAILWIND_BROWSER_SRC = `https://cdn.jsdelivr.net/npm/@tailwindcss/browser@${TAILWIND_BROWSER_VERSION}/dist/index.global.js`;
|
||||
const TAILWIND_BROWSER_INTEGRITY =
|
||||
"sha384-v5YF9xS+gLRWdvrQ0u/WRbCkjSIH0NjHIPe8tBL1ZRrmI7PiSH6LLdzs0aAIMCuh";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ffprobe helper — shells out to ffprobe to avoid engine dependency
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -189,6 +197,17 @@ function hyperframesScript(command: string): string {
|
||||
return `npx --yes ${getHyperframesPackageSpecifier()} ${command}`;
|
||||
}
|
||||
|
||||
function buildPackageScripts(): Record<string, string> {
|
||||
return {
|
||||
dev: hyperframesScript("preview"),
|
||||
check:
|
||||
`${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ` +
|
||||
`${hyperframesScript("inspect")}`,
|
||||
render: hyperframesScript("render"),
|
||||
publish: hyperframesScript("publish"),
|
||||
};
|
||||
}
|
||||
|
||||
function writeDefaultPackageJson(destDir: string, projectName: string): void {
|
||||
const packageJsonPath = resolve(destDir, "package.json");
|
||||
if (existsSync(packageJsonPath)) return;
|
||||
@@ -200,12 +219,7 @@ function writeDefaultPackageJson(destDir: string, projectName: string): void {
|
||||
name: toPackageName(projectName),
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
dev: hyperframesScript("preview"),
|
||||
check: `${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ${hyperframesScript("inspect")}`,
|
||||
render: hyperframesScript("render"),
|
||||
publish: hyperframesScript("publish"),
|
||||
},
|
||||
scripts: buildPackageScripts(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -214,6 +228,60 @@ function writeDefaultPackageJson(destDir: string, projectName: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
function listHtmlFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
const ignoredDirs = new Set([".git", "dist", "node_modules"]);
|
||||
|
||||
function walk(currentDir: string): void {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const entryPath = join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirs.has(entry.name)) walk(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(".html")) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
export function injectTailwindBrowserScript(html: string): string {
|
||||
if (html.includes(TAILWIND_BROWSER_SRC)) return html;
|
||||
|
||||
const script = [
|
||||
`<script>`,
|
||||
`window.__tailwindReady=new Promise(function(resolve){`,
|
||||
`var loaded=document.readyState==="complete";`,
|
||||
`var resolved=false;`,
|
||||
`var observer;`,
|
||||
`function readTailwindCss(){var styles=document.querySelectorAll("style");for(var i=styles.length-1;i>=0;i--){var text=styles[i].textContent||"";if(text.indexOf("tailwindcss v")!==-1)return text;}return "";}`,
|
||||
`function finish(){if(resolved||!loaded||!readTailwindCss())return;resolved=true;if(observer)observer.disconnect();resolve(true);}`,
|
||||
`observer=new MutationObserver(finish);`,
|
||||
`observer.observe(document.documentElement,{childList:true,subtree:true,characterData:true});`,
|
||||
`if(loaded){finish();}else{window.addEventListener("load",function(){loaded=true;finish();},{once:true});}`,
|
||||
`});`,
|
||||
`</script>`,
|
||||
`<script src="${TAILWIND_BROWSER_SRC}" integrity="${TAILWIND_BROWSER_INTEGRITY}" crossorigin="anonymous"></script>`,
|
||||
].join("\n");
|
||||
|
||||
if (/<\/head>/i.test(html)) {
|
||||
return html.replace(/<\/head>/i, (closingHead) => `\n${script}\n${closingHead}`);
|
||||
}
|
||||
|
||||
return `${script}\n${html}`;
|
||||
}
|
||||
|
||||
function writeTailwindSupport(destDir: string): void {
|
||||
for (const file of listHtmlFiles(destDir)) {
|
||||
const html = readFileSync(file, "utf-8");
|
||||
writeFileSync(file, injectTailwindBrowserScript(html), "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
function patchVideoSrc(
|
||||
dir: string,
|
||||
videoFilename: string | undefined,
|
||||
@@ -358,6 +426,7 @@ async function scaffoldProject(
|
||||
templateId: string,
|
||||
localVideoName: string | undefined,
|
||||
durationSeconds?: number,
|
||||
tailwind = false,
|
||||
): Promise<void> {
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
|
||||
@@ -369,6 +438,7 @@ async function scaffoldProject(
|
||||
await fetchRemoteTemplate(templateId, destDir);
|
||||
}
|
||||
patchVideoSrc(destDir, localVideoName, durationSeconds);
|
||||
if (tailwind) writeTailwindSupport(destDir);
|
||||
|
||||
writeFileSync(
|
||||
resolve(destDir, "meta.json"),
|
||||
@@ -466,6 +536,10 @@ export default defineCommand({
|
||||
type: "boolean",
|
||||
description: "Skip AI coding skills installation",
|
||||
},
|
||||
tailwind: {
|
||||
type: "boolean",
|
||||
description: "Add Tailwind CSS browser-runtime support",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.template !== undefined) {
|
||||
@@ -483,6 +557,7 @@ export default defineCommand({
|
||||
const audioFlag = args.audio;
|
||||
const skipTranscribe = args["skip-transcribe"] === true;
|
||||
const skipSkills = args["skip-skills"] === true;
|
||||
const tailwind = args.tailwind === true;
|
||||
const nonInteractive = args["non-interactive"] === true;
|
||||
const modelFlag = args.model;
|
||||
const languageFlag = args.language;
|
||||
@@ -569,6 +644,7 @@ export default defineCommand({
|
||||
templateId,
|
||||
localVideoName,
|
||||
videoDuration,
|
||||
tailwind,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@@ -579,7 +655,7 @@ export default defineCommand({
|
||||
console.error(c.dim("Use --example blank for offline use."));
|
||||
process.exit(1);
|
||||
}
|
||||
trackInitTemplate(templateId);
|
||||
trackInitTemplate(templateId, { tailwind });
|
||||
const transcriptFile = resolve(destDir, "transcript.json");
|
||||
if (existsSync(transcriptFile)) {
|
||||
await patchTranscript(destDir, transcriptFile);
|
||||
@@ -764,7 +840,7 @@ export default defineCommand({
|
||||
spin.start(`Downloading example ${c.accent(templateId)}...`);
|
||||
}
|
||||
try {
|
||||
await scaffoldProject(destDir, name, templateId, localVideoName, videoDuration);
|
||||
await scaffoldProject(destDir, name, templateId, localVideoName, videoDuration, tailwind);
|
||||
if (!isBundled) {
|
||||
spin.stop(c.success(`Downloaded ${templateId}`));
|
||||
}
|
||||
@@ -777,7 +853,7 @@ export default defineCommand({
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
trackInitTemplate(templateId);
|
||||
trackInitTemplate(templateId, { tailwind });
|
||||
|
||||
// 4b. Patch captions with transcript if available
|
||||
const transcriptFile = resolve(destDir, "transcript.json");
|
||||
|
||||
@@ -105,8 +105,8 @@ export function trackRenderError(props: {
|
||||
});
|
||||
}
|
||||
|
||||
export function trackInitTemplate(templateId: string): void {
|
||||
trackEvent("init_template", { template: templateId });
|
||||
export function trackInitTemplate(templateId: string, props?: { tailwind?: boolean }): void {
|
||||
trackEvent("init_template", { template: templateId, tailwind: props?.tailwind });
|
||||
}
|
||||
|
||||
export function trackBrowserInstall(): void {
|
||||
|
||||
@@ -8,7 +8,7 @@ This project uses AI agent skills for framework-specific patterns. Install them
|
||||
npx skills add heygen-com/hyperframes
|
||||
```
|
||||
|
||||
Skills encode patterns like `window.__timelines` registration, `data-*` attribute semantics, and shader-compatible CSS rules that are not in generic web docs. Using them produces correct compositions from the start.
|
||||
Skills encode patterns like `window.__timelines` registration, `data-*` attribute semantics, Tailwind v4 browser-runtime styling for `--tailwind` projects, and shader-compatible CSS rules that are not in generic web docs. Using them produces correct compositions from the start.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| **hyperframes-cli** | `/hyperframes-cli` | CLI commands: init, lint, preview, render, transcribe, tts |
|
||||
| **hyperframes-registry** | `/hyperframes-registry` | Installing blocks and components via `hyperframes add` |
|
||||
| **website-to-hyperframes** | `/website-to-hyperframes` | Capturing a URL and turning it into a video — full website-to-video pipeline |
|
||||
| **tailwind** | `/tailwind` | Tailwind v4 browser-runtime styles for projects created with `hyperframes init --tailwind` |
|
||||
| **gsap** | `/gsap` | GSAP animations for HyperFrames — tweens, timelines, easing, performance |
|
||||
| **animejs** | `/animejs` | Anime.js animations registered on `window.__hfAnime` |
|
||||
| **css-animations** | `/css-animations` | CSS keyframes that HyperFrames can pause and seek |
|
||||
|
||||
@@ -260,6 +260,26 @@ async function applyVideoMetadataHints(
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Promise<void> {
|
||||
const hasTailwindReady = await page.evaluate(
|
||||
`(() => { const ready = window.__tailwindReady; return !!ready && typeof ready.then === "function"; })()`,
|
||||
);
|
||||
if (!hasTailwindReady) return;
|
||||
|
||||
const ready = await Promise.race([
|
||||
page.evaluate(
|
||||
`Promise.resolve(window.__tailwindReady).then(() => true, () => false)`,
|
||||
) as Promise<boolean>,
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
|
||||
]);
|
||||
|
||||
if (!ready) {
|
||||
throw new Error(
|
||||
`[FrameCapture] window.__tailwindReady not resolved after ${timeoutMs}ms. Tailwind browser runtime must finish before frame capture starts.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeSession(session: CaptureSession): Promise<void> {
|
||||
const { page, serverUrl } = session;
|
||||
|
||||
@@ -350,6 +370,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
}
|
||||
|
||||
await page.evaluate(`document.fonts?.ready`);
|
||||
await waitForOptionalTailwindReady(page, pageReadyTimeout);
|
||||
|
||||
// For PNG captures, force the page background fully transparent so the
|
||||
// captured screenshots carry a real alpha channel. Must run AFTER
|
||||
@@ -443,6 +464,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
|
||||
// Font check (no rAF dependency — uses fonts.ready API directly)
|
||||
await page.evaluate(`document.fonts?.ready`);
|
||||
await waitForOptionalTailwindReady(page, pageReadyTimeout);
|
||||
|
||||
// Stop warmup
|
||||
warmupRunning = false;
|
||||
|
||||
Reference in New Issue
Block a user