diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts
index bb9a05d0d..758215fbe 100644
--- a/packages/cli/src/server/studioServer.ts
+++ b/packages/cli/src/server/studioServer.ts
@@ -282,9 +282,13 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// CLI-specific routes (before shared API)
app.get("/api/runtime.js", (c) => {
const serve = async () => {
- const runtimeSource = existsSync(runtimePath)
- ? readFileSync(runtimePath, "utf-8")
- : await loadRuntimeSourceFallback();
+ // Prefer the runtime generated from the current core source over a
+ // potentially stale copied artifact. This keeps local studio/preview
+ // sessions aligned with source edits without requiring a manual
+ // rebuild of the CLI runtime bundle first.
+ const runtimeSource =
+ (await loadRuntimeSourceFallback()) ??
+ (existsSync(runtimePath) ? readFileSync(runtimePath, "utf-8") : null);
if (!runtimeSource) return c.text("runtime not available", 404);
return c.body(runtimeSource, 200, {
"Content-Type": "text/javascript",
diff --git a/packages/core/src/lint/rules/media.test.ts b/packages/core/src/lint/rules/media.test.ts
index 2d52cc965..684d58676 100644
--- a/packages/core/src/lint/rules/media.test.ts
+++ b/packages/core/src/lint/rules/media.test.ts
@@ -106,4 +106,55 @@ describe("media rules", () => {
const finding = result.findings.find((f) => f.code === "video_nested_in_timed_element");
expect(finding).toBeUndefined();
});
+
+ it("reports imperative play() control on managed media ids", () => {
+ const html = `
+
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "imperative_media_control");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ expect(finding?.elementId).toBe("demo-video");
+ });
+
+ it("reports imperative currentTime writes on query-selected managed media", () => {
+ const html = `
+
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "imperative_media_control");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ });
+
+ it("does not flag play() on non-media elements", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "imperative_media_control");
+ expect(finding).toBeUndefined();
+ });
});
diff --git a/packages/core/src/lint/rules/media.ts b/packages/core/src/lint/rules/media.ts
index 5aeb7bf3d..33066b751 100644
--- a/packages/core/src/lint/rules/media.ts
+++ b/packages/core/src/lint/rules/media.ts
@@ -1,6 +1,139 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, truncateSnippet, isMediaTag } from "../utils";
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function selectorTargetsManagedMedia(selector: string, mediaIds: Set): boolean {
+ const normalized = selector.trim();
+ if (!normalized) return false;
+ if (/\b(video|audio)\b/i.test(normalized)) return true;
+ for (const mediaId of mediaIds) {
+ if (
+ normalized.includes(`#${mediaId}`) ||
+ normalized.includes(`[id="${mediaId}"]`) ||
+ normalized.includes(`[id='${mediaId}']`)
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] {
+ const findings: HyperframeLintFinding[] = [];
+ const managedMediaIds = new Set(
+ ctx.tags
+ .filter((tag) => tag.name === "video" || tag.name === "audio")
+ .map((tag) => readAttr(tag.raw, "id"))
+ .filter((id): id is string => Boolean(id)),
+ );
+
+ if (managedMediaIds.size === 0 || ctx.scripts.length === 0) return findings;
+
+ for (const script of ctx.scripts) {
+ const mediaVars = new Map();
+ const assignmentPatterns = [
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)/g,
+ ];
+
+ for (const pattern of assignmentPatterns) {
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(script.content)) !== null) {
+ const variableName = match[1];
+ const target = match[2];
+ if (!variableName || !target) continue;
+ if (managedMediaIds.has(target) || selectorTargetsManagedMedia(target, managedMediaIds)) {
+ mediaVars.set(variableName, managedMediaIds.has(target) ? target : undefined);
+ }
+ }
+ }
+
+ const directIdPatterns = [
+ {
+ pattern:
+ /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
+ kind: "play()",
+ },
+ {
+ pattern:
+ /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
+ kind: "pause()",
+ },
+ {
+ pattern:
+ /\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
+ kind: "currentTime",
+ },
+ {
+ pattern:
+ /\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
+ kind: "play()",
+ },
+ {
+ pattern:
+ /\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
+ kind: "pause()",
+ },
+ {
+ pattern:
+ /\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
+ kind: "currentTime",
+ },
+ ];
+
+ for (const { pattern, kind } of directIdPatterns) {
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(script.content)) !== null) {
+ const target = match[1];
+ if (!target) continue;
+ const elementId = managedMediaIds.has(target)
+ ? target
+ : selectorTargetsManagedMedia(target, managedMediaIds)
+ ? undefined
+ : null;
+ if (elementId === null) continue;
+ findings.push({
+ code: "imperative_media_control",
+ severity: "error",
+ message: `Inline