diff --git a/README.md b/README.md index 436e7d1..618c790 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,9 @@ never sees the page. pi install npm:@getpipher/keystone ``` -Exposes the `keystone` skill (Build + Audit verbs) and the `keystone_render` -tool in one pi package. +Exposes the `keystone` skill (Build + Audit verbs) and the render layer in one +package: the `keystone_render` tool on pi hosts, omp's built-in browser on omp +(eval-kernel import — no bundled Chromium driver), and `--render` CLI mode. ## What you get diff --git a/engine/audit.mjs b/engine/audit.mjs index 1fe7f6c..17fe16f 100644 --- a/engine/audit.mjs +++ b/engine/audit.mjs @@ -236,11 +236,9 @@ async function main() { async function loadRender() { try { - const { tsImport } = await import("tsx/esm/api") - return await tsImport(new URL("../extensions/render.ts", import.meta.url).href, import.meta.url) + return await import("./render.mjs") } catch (e) { - console.error("audit: failed to load the render extension:", e instanceof Error ? e.message : String(e)) - console.error("(requires the tsx runtime — run via pi, or: npm i tsx)") + console.error("audit: failed to load the render engine:", e instanceof Error ? e.message : String(e)) process.exit(1) } } diff --git a/engine/check-gates.mjs b/engine/check-gates.mjs index 39e9160..862dd2b 100644 --- a/engine/check-gates.mjs +++ b/engine/check-gates.mjs @@ -40,23 +40,20 @@ if (args.log) { } async function main() { - // --render: bootstrap the TS render extension via tsx, run Chromium + // --render: run headless Chromium via the playwright driver (child process — + // no omp extension-loader involvement; v1.1.0 dropped the tsx bootstrap). if (args.render) { - let renderModule + let render try { - const { tsImport } = await import("tsx/esm/api") - // check-gates.mjs lives in engine/, so resolve UP to the repo root's extensions/ - renderModule = await tsImport(new URL("../extensions/render.ts", import.meta.url).href, import.meta.url) + ;({ render } = await import("./render.mjs")) } catch (e) { - // Log the real error so a path/config issue isn't masked as a missing-tsx message. - console.error("--render failed to load the render extension:", e instanceof Error ? e.message : String(e)) - console.error("(requires the tsx runtime — run via pi, or: npm i tsx)") + console.error("--render failed to load the render engine:", e instanceof Error ? e.message : String(e)) process.exit(1) } let out2 try { - out2 = await renderModule.render({ + out2 = await render({ htmlPath: args.html, viewports: viewportsArg, outDir: join(out, "keystone-render"), diff --git a/engine/driver-omp-native.d.mts b/engine/driver-omp-native.d.mts new file mode 100644 index 0000000..111f893 --- /dev/null +++ b/engine/driver-omp-native.d.mts @@ -0,0 +1,4 @@ +// Type declarations for engine/driver-omp-native.mjs. + +export declare function ompBrowserAvailable(): boolean +export declare function createOmpNativeDriver(): import("./driver-playwright.mjs").RenderDriver diff --git a/engine/driver-omp-native.mjs b/engine/driver-omp-native.mjs new file mode 100644 index 0000000..239eb90 --- /dev/null +++ b/engine/driver-omp-native.mjs @@ -0,0 +1,72 @@ +// engine/driver-omp-native.mjs — RenderDriver over omp's built-in browser. +// +// omp bundles a Puppeteer-backed browser exposed as the `browser` global in its +// eval kernels (can1357/oh-my-pi#11091 tracks exposing it to extensions). This +// driver runs INSIDE that eval kernel — the skill imports engine/render-omp.mjs +// from the installed package path and calls render(); no playwright-core, no +// second Chromium stack, nothing for omp's extension loader to preload. +// +// Lifecycle: omp owns the browser (shared, headless, launch-on-first-use). +// Each openPage maps to a uniquely named tab; close() releases that tab only. +// Tab profile isolation is weaker than playwright's per-context isolation +// (tabs share the browser profile); keystone renders local files and one-off +// audit URLs, so cross-viewport cookie bleed is out of scope. + +/** @returns {boolean} */ +export function ompBrowserAvailable() { + return typeof globalThis.browser === "object" && globalThis.browser !== null + && typeof globalThis.browser.open === "function" +} +async function writeScreenshot(tab, destPath) { + const { copyFileSync, writeFileSync } = await import("node:fs") + const r = await tab.screenshot() + if (typeof r === "string") { + if (r.startsWith("data:")) { + const comma = r.indexOf(",") + writeFileSync(destPath, Buffer.from(r.slice(comma + 1), "base64")) + return + } + // omp writes captures to a temp file and returns its path — copy it over. + copyFileSync(r, destPath) + return + } + if (r instanceof Uint8Array) { writeFileSync(destPath, r); return } + if (r && typeof r.arrayBuffer === "function") { writeFileSync(destPath, new Uint8Array(await r.arrayBuffer())); return } + throw new Error(`omp-native driver: unhandled screenshot return type ${Object.prototype.toString.call(r)}`) +} +/** @returns {import("./driver-playwright.mjs").RenderDriver} */ +export function createOmpNativeDriver() { + if (!ompBrowserAvailable()) { + throw new Error("omp-native driver requires the omp eval-kernel `browser` global (run via omp's eval kernel, not node)") + } + const b = globalThis.browser + const tabs = [] + let seq = 0 + return { + async openPage({ width, height }) { + const name = `keystone-${width}-${Date.now().toString(36)}-${seq++}` + const tab = await b.open({ name, viewport: { width, height }, url: "about:blank" }) + tabs.push(name) + return { + async goto(url) { + await tab.goto(url) + // settle: playwright's networkidle isn't exposed on the tab helper; + // waiting for covers late-arriving document paint. + await tab.waitForSelector("body", { timeout: 10_000 }).catch(() => {}) + }, + url: () => tab.url(), + screenshot: (path) => writeScreenshot(tab, path), + // tab.run serializes the fn — closures don't cross; pass the expr via args. + evaluate: (expr) => tab.run(async ({ page }, code) => page.evaluate(code), { args: [expr] }), + content: () => tab.run(async ({ page }) => page.content()), + async close() { + await b.close({ name }).catch(() => {}) + }, + } + }, + async close() { + // tabs are closed individually by render-core (page.close); nothing to do — + // the browser itself is omp-owned and shared. + }, + } +} diff --git a/engine/driver-playwright.d.mts b/engine/driver-playwright.d.mts new file mode 100644 index 0000000..b9d1322 --- /dev/null +++ b/engine/driver-playwright.d.mts @@ -0,0 +1,19 @@ +// Type declarations for engine/driver-playwright.mjs (consumed by +// extensions/render.ts under strict tsc; the implementation is plain JS and +// stays the source of truth). + +export interface RenderPage { + goto(url: string): Promise + url(): Promise + screenshot(path: string): Promise + evaluate(expr: string): Promise + content(): Promise + close(): Promise +} + +export interface RenderDriver { + openPage(viewport: { width: number; height: number }): Promise + close(): Promise +} + +export declare function createPlaywrightDriver(): Promise diff --git a/engine/driver-playwright.mjs b/engine/driver-playwright.mjs new file mode 100644 index 0000000..cabd1f2 --- /dev/null +++ b/engine/driver-playwright.mjs @@ -0,0 +1,64 @@ +// engine/driver-playwright.mjs — RenderDriver over playwright-core. +// +// Used by (a) the CLI child process (check-gates.mjs --render, audit.mjs) and +// (b) the pi extension tool. NOT imported by omp's extension path — omp skips +// tool registration there and the skill renders via driver-omp-native.mjs +// through omp's built-in browser. +// +// The playwright-core specifier is assembled at runtime (["playwright","-core"].join("")) +// because omp's guarded extension loader preloads every statically resolvable +// import specifier at startup — a static reference costs ~20s per omp start +// (getpipher/keystone#21). Harmless in a plain node child process; kept uniform. + +/** + * @typedef {Object} RenderPage + * @property {(url: string) => Promise} goto + * @property {() => Promise} url + * @property {(path: string) => Promise} screenshot PNG to path + * @property {(expr: string) => Promise} evaluate page-context expression (string!) + * @property {() => Promise} content serialized DOM + * @property {() => Promise} close + * + * @typedef {Object} RenderDriver + * @property {(viewport: {width: number, height: number}) => Promise} openPage + * @property {() => Promise} close + */ + +let cachedChromium + +async function loadChromium() { + const spec = ["playwright", "-core"].join("") + const mod = await import(spec) + return mod.chromium +} + +async function getChromium() { + cachedChromium ??= await loadChromium() + return cachedChromium +} + +/** @returns {Promise} */ +export async function createPlaywrightDriver() { + const chromium = await getChromium() + const browser = await chromium.launch({ headless: true }) + return { + async openPage({ width, height }) { + // fresh context per viewport: cookies/storage isolated between passes + const ctx = await browser.newContext({ viewport: { width, height } }) + const page = await ctx.newPage() + return { + goto: (url) => page.goto(url, { waitUntil: "networkidle" }), + url: () => Promise.resolve(page.url()), + screenshot: (path) => page.screenshot({ path, fullPage: false }), + evaluate: (expr) => page.evaluate(expr), + content: () => page.content(), + async close() { + await ctx.close() + }, + } + }, + async close() { + await browser.close() + }, + } +} diff --git a/engine/host.d.mts b/engine/host.d.mts new file mode 100644 index 0000000..7df31d9 --- /dev/null +++ b/engine/host.d.mts @@ -0,0 +1,8 @@ +// Type declarations for engine/host.mjs. + +export interface HostIdentity { + argv0?: string + entry?: string +} + +export declare function isOmpRuntime(identity?: HostIdentity): boolean diff --git a/engine/host.mjs b/engine/host.mjs new file mode 100644 index 0000000..7f4251d --- /dev/null +++ b/engine/host.mjs @@ -0,0 +1,21 @@ +// engine/host.mjs — which host is this module running in? +// +// omp and pi share the extension API, so the keystone extension needs a hard +// signal for "this is omp" to skip tool registration there (omp renders via the +// omp-native driver from its eval kernel instead — see render-omp.mjs). +// +// Env vars are useless: omp mirrors OMP_* to PI_* and neither leaves reliable +// markers in extension runtime env. What IS stable: omp ships as a bun-compiled +// single binary — argv0 is the bare "omp" name and argv[1] is the embedded +// bunfs entry point ("/$bunfs/root/omp-"). pi (node-based) matches +// neither. Verified against omp 18.1.12 on darwin-arm64. + +/** + * @param {{ argv0?: string, entry?: string }} [identity] defaults to the real + * process identity; injectable for tests (process.argv0 is read-only). + * @returns {boolean} true when running inside the omp host + */ +export function isOmpRuntime(identity = { argv0: process.argv0, entry: process.argv[1] }) { + if (identity.argv0 === "omp") return true + return (identity.entry ?? "").startsWith("/$bunfs/root/omp-") +} diff --git a/engine/render-core.d.mts b/engine/render-core.d.mts new file mode 100644 index 0000000..7de6882 --- /dev/null +++ b/engine/render-core.d.mts @@ -0,0 +1,37 @@ +// Type declarations for engine/render-core.mjs. + +export interface RenderInput { + htmlPath: string + url?: string + viewports?: number[] + outDir?: string +} + +export interface HeroRect { + eyebrow: { top: number; bottom: number } | null + headline: { top: number; bottom: number } + lede: { top: number; bottom: number } | null + cta: { top: number; bottom: number } | null +} + +export interface ViewportMetric { + width: number + scrollWidth: number + innerWidth: number + innerHeight: number + hero?: HeroRect +} + +export interface RenderOutput { + screenshots: { width: number; path: string }[] + computedStylesPath: string + domSnapshotPath: string + viewportMetrics: ViewportMetric[] + finalUrl: string + clickableMetrics: { viewport: number; selector: string; offsetHeight: number; lineHeight: number }[] +} + +export declare function renderWithDriver( + input: RenderInput, + driver: import("./driver-playwright.mjs").RenderDriver, +): Promise diff --git a/engine/render-core.mjs b/engine/render-core.mjs new file mode 100644 index 0000000..e3f7c00 --- /dev/null +++ b/engine/render-core.mjs @@ -0,0 +1,175 @@ +// engine/render-core.mjs — the render flow, driver-parameterized. +// +// Split out of extensions/render.ts (v1.0.x) in v1.1.0 so the same orchestration +// runs behind three drivers: playwright (CLI child process + the pi extension +// tool) and omp-native (omp's built-in browser via the eval kernel — see +// driver-omp-native.mjs). The flow is a behavioral port: viewports loop, hero +// metrics at 1280 only, computed-pairs + DOM dump on the 1280 pass, clickable +// metrics at 1280 + 375, OKLCH-canonicalized colors, artifacts on disk. +// +// NOTE: every page.evaluate body is a STRING (not an arrow fn) on purpose — +// transpilers inject __name() helpers that don't exist in the page context +// (ReferenceError: __name is not defined). Raw strings are not transpiled. +import { writeFileSync, mkdirSync } from "node:fs" +import { join } from "node:path" +import { pathToFileURL } from "node:url" +import { toOklchString } from "./color.mjs" + +/** + * @typedef {Object} RenderInput + * @property {string} htmlPath + * @property {string} [url] audit URL mode: goto this live URL instead of htmlPath + * @property {number[]} [viewports] CSS px widths; default [1280, 375, 320, 414, 768] + * @property {string} [outDir] default ./keystone-render + * + * @typedef {Object} HeroRect + * @property {{top:number,bottom:number}|null} eyebrow + * @property {{top:number,bottom:number}} headline + * @property {{top:number,bottom:number}|null} lede + * @property {{top:number,bottom:number}|null} cta + * + * @typedef {Object} ViewportMetric + * @property {number} width + * @property {number} scrollWidth + * @property {number} innerWidth + * @property {number} innerHeight + * @property {HeroRect} [hero] attached at the 1280px pass only (G44 is desktop-only) + * + * @typedef {Object} RenderOutput + * @property {{width:number,path:string}[]} screenshots + * @property {string} computedStylesPath + * @property {string} domSnapshotPath + * @property {ViewportMetric[]} viewportMetrics + * @property {string} finalUrl URL the browser ended on after redirects (audit re-check) + * @property {{viewport:number,selector:string,offsetHeight:number,lineHeight:number}[]} clickableMetrics + */ + +// Page-context probe: viewport metrics + hero band rects (G44, G34). +const METRICS_EXPR = `(() => { + const rect = (el) => el ? { top: Math.round(el.getBoundingClientRect().top), bottom: Math.round(el.getBoundingClientRect().bottom) } : null + const scrollWidth = document.documentElement.scrollWidth + const innerWidth = window.innerWidth + const innerHeight = window.innerHeight + const h1 = document.querySelector("h1") + if (!h1) return { scrollWidth, innerWidth, innerHeight, hero: null } + const headline = rect(h1) + let eyebrow = null + const prev = h1.previousElementSibling + if ((prev && prev.offsetHeight < 60 && /^(P|SPAN|DIV|SMALL|B)$/.test(prev.tagName)) || (prev && /eyebrow|kicker|tag/i.test(prev.className))) { + eyebrow = rect(prev) + } + let lede = null + const next = h1.nextElementSibling + if (next && next.tagName === "P") lede = rect(next) + const section = h1.closest("section, header, article, main") + const ctaEl = section ? section.querySelector("a[href], button") : null + const cta = rect(ctaEl) + return { scrollWidth, innerWidth, innerHeight, hero: { eyebrow, headline, lede, cta } } +})()` + +// Page-context probe: computed color pairs for G40-41 contrast + G23 accent area. +// body * skips children (style/meta/title/link/script) — they have no +// visible text but produce computed styles, which spuriously fail G40 (APCA Lc 0 +// on transparent/empty pairs). Plan 1b-1 CF1. Background resolves up the tree +// while transparent so text-on-transparent contrasts against the nearest +// painting ancestor (usually the body page color). Capped at 200 pairs. +const PAIRS_EXPR = `(() => { + const out = [] + for (const el of document.querySelectorAll("body *")) { + const cs = getComputedStyle(el) + let bg = cs.backgroundColor + let node = el + while (bg === "transparent" || /,\\s*0\\)$/.test(bg)) { + node = node.parentElement + if (!node) break + bg = getComputedStyle(node).backgroundColor + } + if (cs.color || bg) { + const r = el.getBoundingClientRect() + out.push({ selector: el.tagName.toLowerCase(), color: cs.color, backgroundColor: bg, width: Math.round(r.width), height: Math.round(r.height) }) + } + } + return out.slice(0, 200) +})()` + +// Page-context probe: clickable line-metrics for G49 (two-line clickable text). +const CLICKABLES_EXPR = `(() => { + const sel = "button, a.btn, a.cta, [role=button], nav a" + const out = [] + for (const el of document.querySelectorAll(sel)) { + const cs = getComputedStyle(el) + out.push({ selector: el.tagName.toLowerCase() + (el.className ? "." + el.className.split(" ")[0] : ""), offsetHeight: el.offsetHeight, lineHeight: parseFloat(cs.lineHeight) || 0 }) + } + return out +})()` + +/** + * Run the render flow against a driver. + * + * @param {RenderInput} input + * @param {import("./driver-playwright.mjs").RenderDriver} driver + * @returns {Promise} + */ +export async function renderWithDriver(input, driver) { + const viewports = input.viewports ?? [1280, 375, 320, 414, 768] + const outDir = input.outDir ?? "./keystone-render" + mkdirSync(outDir, { recursive: true }) + const screenshots = [] + const computedPairs = [] + const viewportMetrics = [] + const clickableMetrics = [] + let domSnapshot = "" + let finalUrl = "" // captured after the first navigation (reflects redirects) + + for (const w of viewports) { + const page = await driver.openPage({ width: w, height: Math.round(w * 0.625) }) + // audit URL mode: goto the live URL; otherwise the file:// path (build flow). + const target = input.url ?? pathToFileURL(input.htmlPath).href + await page.goto(target) + if (!finalUrl) finalUrl = await page.url() + const shotPath = join(outDir, `screenshot-${w}.png`) + await page.screenshot(shotPath) + screenshots.push({ width: w, path: shotPath }) + + const metrics = await page.evaluate(METRICS_EXPR) + viewportMetrics.push({ + width: w, + scrollWidth: metrics.scrollWidth, + innerWidth: metrics.innerWidth, + innerHeight: metrics.innerHeight, + ...(w === 1280 ? { hero: metrics.hero } : {}), + }) + + // On the 1280 pass, dump computed color pairs + DOM (G40-41, G23, dom.html). + if (w === 1280) { + const pairs = await page.evaluate(PAIRS_EXPR) + for (const p of pairs) { + computedPairs.push({ + selector: p.selector, + color: toOklchString(p.color) ?? p.color, + backgroundColor: toOklchString(p.backgroundColor) ?? p.backgroundColor, + width: p.width, + height: p.height, + }) + } + domSnapshot = await page.content() + } + // Clickable line-metrics for G49 at 1280 + 375 only. + if (w === 1280 || w === 375) { + const clickables = await page.evaluate(CLICKABLES_EXPR) + for (const c of clickables) clickableMetrics.push({ viewport: w, ...c }) + } + await page.close() + } + await driver.close() + + const computedStylesPath = join(outDir, "computed.json") + writeFileSync(computedStylesPath, JSON.stringify(computedPairs, null, 2)) + const domSnapshotPath = join(outDir, "dom.html") + writeFileSync(domSnapshotPath, domSnapshot) + const viewportsPath = join(outDir, "viewports.json") + writeFileSync(viewportsPath, JSON.stringify(viewportMetrics, null, 2)) + const clickablePath = join(outDir, "clickable.json") + writeFileSync(clickablePath, JSON.stringify(clickableMetrics, null, 2)) + return { screenshots, computedStylesPath, domSnapshotPath, viewportMetrics, finalUrl, clickableMetrics } +} diff --git a/engine/render-omp.mjs b/engine/render-omp.mjs new file mode 100644 index 0000000..4593f97 --- /dev/null +++ b/engine/render-omp.mjs @@ -0,0 +1,13 @@ +// engine/render-omp.mjs — render() bound to omp's built-in browser. +// +// Invoked from omp's eval kernel by the keystone skill (SKILL.md § 7.2): +// const { render } = await import("/@getpipher/keystone/engine/render-omp.mjs") +// await render({ htmlPath, viewports: [1280, 375], outDir }) +// The omp host owns the browser; no playwright-core is loaded on this path. +import { renderWithDriver } from "./render-core.mjs" +import { createOmpNativeDriver } from "./driver-omp-native.mjs" + +/** @param {import("./render-core.mjs").RenderInput} input */ +export async function render(input) { + return renderWithDriver(input, createOmpNativeDriver()) +} diff --git a/engine/render.mjs b/engine/render.mjs new file mode 100644 index 0000000..edf495e --- /dev/null +++ b/engine/render.mjs @@ -0,0 +1,10 @@ +// engine/render.mjs — render() bound to the playwright driver. +// The CLI (check-gates.mjs --render, audit.mjs) and the pi extension import +// this. Runs headless Chromium from playwright-core in THIS (child) process. +import { renderWithDriver } from "./render-core.mjs" +import { createPlaywrightDriver } from "./driver-playwright.mjs" + +/** @param {import("./render-core.mjs").RenderInput} input */ +export async function render(input) { + return renderWithDriver(input, await createPlaywrightDriver()) +} diff --git a/extensions/render.ts b/extensions/render.ts index ad1cb42..6b687e9 100644 --- a/extensions/render.ts +++ b/extensions/render.ts @@ -1,31 +1,21 @@ -// extensions/render.ts -// NOTE: playwright-core must not be referenced in ANY static import — not +// extensions/render.ts — registers the `keystone_render` tool on pi hosts. +// +// omp hosts SKIP registration (engine/host.mjs): omp's extension runtime has no +// browser API (can1357/oh-my-pi#11091), so the tool can't render there without +// bundling a second Chromium stack. On omp the skill renders through omp's +// built-in browser instead — SKILL.md § 7.2 imports engine/render-omp.mjs from +// the eval kernel. The render flow itself lives in engine/render-core.mjs, +// shared by every driver. +// +// NOTE: no module in this file may statically reference playwright-core — not // even `import type`. omp's guarded extension loader preloads every import -// specifier it sees at load time; the ~9 MB graph cost ~20 s per startup (#21). -// Types flow via inference from the dynamic import below. +// specifier it sees at load time; the ~9 MB graph cost ~20 s per startup +// (getpipher/keystone#21). driver-playwright.mjs keeps the import lazy. import { Type } from "typebox" -import { pathToFileURL } from "node:url" -import { writeFileSync, mkdirSync } from "node:fs" -import { join } from "node:path" -import { toOklchString } from "../engine/color.mjs" -// Memoized lazy loader. The `ReturnType`-derived annotation is deliberate: -// naming a concrete type would require a static playwright-core import, -// which omp's loader forbids here (#21) — this stays module-private. -let cachedChromium: Awaited> | undefined -async function loadChromium() { - // Indirected specifier: omp's guarded loader preloads every statically - // resolvable import — including dynamic import() literals — at startup - // (#21). Assembling the name at runtime keeps the ~9 MB graph out of the - // preload set; it loads once, on first actual render. - const spec = ["playwright", "-core"].join("") - const mod = await import(spec) - return mod.chromium -} -async function getChromium() { - cachedChromium ??= await loadChromium() - return cachedChromium -} +import { renderWithDriver } from "../engine/render-core.mjs" +import { createPlaywrightDriver } from "../engine/driver-playwright.mjs" +import { isOmpRuntime } from "../engine/host.mjs" interface RenderInput { htmlPath: string @@ -34,177 +24,10 @@ interface RenderInput { outDir?: string // default ./keystone-render } -interface HeroRect { - eyebrow: { top: number; bottom: number } | null - headline: { top: number; bottom: number } - lede: { top: number; bottom: number } | null - cta: { top: number; bottom: number } | null -} - -interface ViewportMetric { - width: number - scrollWidth: number - innerWidth: number - innerHeight: number - hero?: HeroRect | null -} - -interface RenderOutput { - screenshots: { width: number; path: string }[] - computedStylesPath: string - domSnapshotPath: string - viewportMetrics: ViewportMetric[] - finalUrl: string // the URL Playwright ended on after redirects (audit redirect-to-internal re-check) - clickableMetrics: { viewport: number; selector: string; offsetHeight: number; lineHeight: number }[] // Plan 1b-2: G49 -} - -export async function render(input: RenderInput): Promise { - const viewports = input.viewports ?? [1280, 375, 320, 414, 768] - const outDir = input.outDir ?? "./keystone-render" - mkdirSync(outDir, { recursive: true }) - const browser = await (await getChromium()).launch({ headless: true }) - const screenshots: { width: number; path: string }[] = [] - const computedPairs: { selector: string; color: string; backgroundColor: string; width: number; height: number }[] = [] - const viewportMetrics: ViewportMetric[] = [] - let domSnapshot = "" - let finalUrl = "" // the URL Playwright ended on after redirects (first viewport's goto) - const clickableMetrics: { viewport: number; selector: string; offsetHeight: number; lineHeight: number }[] = [] // Plan 1b-2: G49 // the URL Playwright ended on after redirects (first viewport's goto) - - for (const w of viewports) { - const ctx = await browser.newContext({ viewport: { width: w, height: Math.round(w * 0.625) } }) - const page = await ctx.newPage() - // audit URL mode: goto the live URL; otherwise the Plan-3 file:// path (unchanged). - const target = input.url ?? pathToFileURL(input.htmlPath).href - await page.goto(target, { waitUntil: "networkidle" }) - if (!finalUrl) finalUrl = page.url() // capture after the first navigation (reflects redirects) - const shotPath = join(outDir, `screenshot-${w}.png`) - await page.screenshot({ path: shotPath, fullPage: false }) - screenshots.push({ width: w, path: shotPath }) - - // Capture viewport metrics (scrollWidth, innerHeight, hero) at every viewport. - // Hero rects are captured at every viewport but only attached to the metric - // at the 1280px pass (G44 is desktop-only); see the spread below. - // - // NOTE: the evaluate body is a STRING (not an arrow fn) on purpose. tsx/esbuild - // transpiles arrow-fn evaluates with keepNames, injecting __name() helpers that - // don't exist in the browser page.evaluate context (ReferenceError: __name is - // not defined). A raw JS string is not transpiled, so no __name is injected. - // The `)` after the closing backtick closes page.evaluate(. - const metrics = (await page.evaluate(`(() => { - const rect = (el) => el ? { top: Math.round(el.getBoundingClientRect().top), bottom: Math.round(el.getBoundingClientRect().bottom) } : null - const scrollWidth = document.documentElement.scrollWidth - const innerWidth = window.innerWidth - const innerHeight = window.innerHeight - const h1 = document.querySelector("h1") - if (!h1) return { scrollWidth, innerWidth, innerHeight, hero: null } - const headline = rect(h1) - let eyebrow = null - const prev = h1.previousElementSibling - if ((prev && prev.offsetHeight < 60 && /^(P|SPAN|DIV|SMALL|B)$/.test(prev.tagName)) || (prev && /eyebrow|kicker|tag/i.test(prev.className))) { - eyebrow = rect(prev) - } - let lede = null - const next = h1.nextElementSibling - if (next && next.tagName === "P") lede = rect(next) - const section = h1.closest("section, header, article, main") - const ctaEl = section ? section.querySelector("a[href], button") : null - const cta = rect(ctaEl) - return { scrollWidth, innerWidth, innerHeight, hero: { eyebrow, headline, lede, cta } } - })()`)) as { - scrollWidth: number - innerWidth: number - innerHeight: number - hero: HeroRect | null - } - - // Hero is only meaningful at the 1280px pass; omit it from other viewports. - const metric: ViewportMetric = { - width: w, - scrollWidth: metrics.scrollWidth, - innerWidth: metrics.innerWidth, - innerHeight: metrics.innerHeight, - ...(w === 1280 ? { hero: metrics.hero } : {}), - } - viewportMetrics.push(metric) - - // On the 1280 pass, dump computed color pairs + DOM. This evaluate has NO - // named inner functions, so the arrow-fn form does not trigger __name. - if (w === 1280) { - const pairs = await page.evaluate(() => { - const out: { selector: string; color: string; backgroundColor: string; width: number; height: number }[] = [] - // body * skips children (style/meta/title/link/script) — they have - // no visible text but produce computed styles, which spuriously fail G40 - // contrast (APCA Lc 0 on transparent/empty pairs). Plan 1b-1 CF1. - const els = document.querySelectorAll("body *") - for (const el of els) { - const cs = getComputedStyle(el) - // Resolve the effective background: walk up while the element's own bg - // is transparent (rgba(...,0) or "transparent"), so text-on-transparent - // is contrasted against the nearest ancestor that paints a bg (usually - // the body's page color). Without this, transparent bg → oklch(0 0 0) - // (black) and every text-on-transparent pair spuriously fails contrast. - let bg = cs.backgroundColor - let node: Element | null = el - while (bg === "transparent" || /,\s*0\)$/.test(bg)) { - node = node.parentElement - if (!node) break - bg = getComputedStyle(node).backgroundColor - } - if (cs.color || bg) { - // Plan 1b-2: bounding-box (width/height) for G23 accent-area. Additive. - const r = el.getBoundingClientRect() - out.push({ selector: el.tagName.toLowerCase(), color: cs.color, backgroundColor: bg, width: Math.round(r.width), height: Math.round(r.height) }) - } - } - return out.slice(0, 200) // cap - }) - // Convert RGB computed styles to canonical OKLCH strings (Plan 3 — G40-41). - for (const p of pairs) { - const colorOk = toOklchString(p.color) - const bgOk = toOklchString(p.backgroundColor) - computedPairs.push({ - selector: p.selector, - color: colorOk ?? p.color, - backgroundColor: bgOk ?? p.backgroundColor, - width: p.width, // Plan 1b-2: bounding-box for G23 accent-area (additive) - height: p.height, - }) - } - domSnapshot = await page.content() - } - // Plan 1b-2: clickable line-metrics for G49 (two-line clickable text). Additive. - // Capture offsetHeight + lineHeight for buttons/CTAs/nav links at 1280 + 375. - if (w === 1280 || w === 375) { - const clickables = (await page.evaluate(`(() => { - const sel = "button, a.btn, a.cta, [role=button], nav a" - const out = [] - for (const el of document.querySelectorAll(sel)) { - const cs = getComputedStyle(el) - out.push({ selector: el.tagName.toLowerCase() + (el.className ? "." + el.className.split(" ")[0] : ""), offsetHeight: el.offsetHeight, lineHeight: parseFloat(cs.lineHeight) || 0 }) - } - return out - })()`)) as { selector: string; offsetHeight: number; lineHeight: number }[] - for (const c of clickables) clickableMetrics.push({ viewport: w, ...c }) - } - await ctx.close() - } - // Capture the final URL after the LAST viewport's navigation (reflects redirects). - // (page is closed; re-derive from the last goto — simpler: track it during the loop.) - await browser.close() - - const computedStylesPath = join(outDir, "computed.json") - writeFileSync(computedStylesPath, JSON.stringify(computedPairs, null, 2)) - const domSnapshotPath = join(outDir, "dom.html") - writeFileSync(domSnapshotPath, domSnapshot) - const viewportsPath = join(outDir, "viewports.json") - writeFileSync(viewportsPath, JSON.stringify(viewportMetrics, null, 2)) - const clickablePath = join(outDir, "clickable.json") - writeFileSync(clickablePath, JSON.stringify(clickableMetrics, null, 2)) - return { screenshots, computedStylesPath, domSnapshotPath, viewportMetrics, finalUrl, clickableMetrics } -} - // pi extension registration (the pi extension API — see getpipher/AGENTS.md for gotchas) export default function (pi: any) { + if (isOmpRuntime()) return // omp renders via engine/render-omp.mjs in its eval kernel + pi.registerTool({ name: "keystone_render", description: "Render an HTML file with headless Chromium at given viewports. Returns screenshots + computed styles + DOM snapshot for the Keystone gate engine.", @@ -215,7 +38,7 @@ export default function (pi: any) { outDir: Type.Optional(Type.String({ description: "Directory to write outputs" })), }), async execute(_toolCallId: string, input: RenderInput) { - const result = await render(input) + const result = await renderWithDriver(input, await createPlaywrightDriver()) return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], details: result } }, }) diff --git a/package.json b/package.json index fd058ed..3f10dc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getpipher/keystone", - "version": "1.0.3", + "version": "1.1.0", "description": "Anti-AI-slop design skill with an executable gate engine. Beats Hallmark by enforcing its gates instead of imagining them.", "keywords": [ "pi-package", @@ -40,15 +40,14 @@ "test": "node --test test/engine/*.mjs test/engine/gates/*.mjs", "test:run": "node --test --test-reporter=spec test/engine/*.mjs test/engine/gates/*.mjs", "test:lint": "node --test test/lint-skill.mjs", - "test:render": "node --import tsx --test test/extensions/render.test.mjs", + "test:render": "node --test test/engine/render.test.mjs", "test:examples": "node --test test/examples.test.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { "linkedom": "^0.18.13", "playwright-core": "1.62.0", - "postcss": "^8.5.23", - "tsx": "^4.23.1" + "postcss": "^8.5.23" }, "devDependencies": { "@types/node": "^24.13.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e23119f..baec228 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: postcss: specifier: ^8.5.23 version: 8.5.23 - tsx: - specifier: ^4.23.1 - version: 4.23.1 devDependencies: '@types/node': specifier: ^24.13.3 @@ -33,162 +30,6 @@ importers: packages: - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -248,21 +89,11 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -308,11 +139,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} - engines: {node: '>=18.0.0'} - hasBin: true - typebox@1.3.28: resolution: {integrity: sha512-5hTNlwzKj7BPPqhT91TPa4fK9bh/ZOei7mzweMpNidwLTxtduXHgr/OAbwx4mRW8aKFYu1I8fT1+0+PQtst1/g==} @@ -324,84 +150,6 @@ packages: snapshots: - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - '@types/node@24.13.3': dependencies: undici-types: 7.18.2 @@ -462,41 +210,9 @@ snapshots: entities@8.0.0: {} - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - fsevents@2.3.2: optional: true - fsevents@2.3.3: - optional: true - html-escaper@3.0.3: {} htmlparser2@10.1.0: @@ -538,12 +254,6 @@ snapshots: source-map-js@1.2.1: {} - tsx@4.23.1: - dependencies: - esbuild: 0.28.1 - optionalDependencies: - fsevents: 2.3.3 - typebox@1.3.28: {} uhyphen@0.2.0: {} diff --git a/skills/keystone/SKILL.md b/skills/keystone/SKILL.md index f8f5285..541612c 100644 --- a/skills/keystone/SKILL.md +++ b/skills/keystone/SKILL.md @@ -163,10 +163,14 @@ Always: **Cap: 3 deterministic iterations.** (Fast path: drop `--render` to run only the 11 source-only gates between full renders — cheaper iteration for pure-token fixes.) - **7.2 VISION PASS** - `keystone_render({ htmlPath, viewports: [1280, 375] })` → `describe_image({ image_paths: [<1280.png>, <375.png>], prompt: })`. + Render at **[1280, 375]** for the vision model, then `describe_image({ image_paths: [<1280.png>, <375.png>], prompt: })`. + - **omp host** (no `keystone_render` tool — the extension skips registration there): render via the eval kernel with omp's built-in browser — one import, never author render code yourself: + `const { render } = await import(`${HOME}/.omp/plugins/node_modules/@getpipher/keystone/engine/render-omp.mjs`)` (if that path doesn't exist, locate `engine/render-omp.mjs` inside the installed `@getpipher/keystone` package or the repo, then `await render({ htmlPath, viewports: [1280, 375], outDir: "./keystone-render" })`). + - **pi host**: call the tool — `keystone_render({ htmlPath, viewports: [1280, 375] })`. Read each verdict. Any FAIL (except G46, which flags rather than auto-fails) → apply the fix, re-render, re-vision. **Cap: 2 vision iterations.** S1 (*"does this look AI-generated?"*) is the thesis gate Hallmark cannot ask. + - **7.3 RESOLUTION** - 58/58 pass → preview row: `Slop test · 58/58 ✓ (engine-verified) — ./keystone-report.html` - Failures remain → preview row: `Slop test · N/58 — fails: (engine-verified)` — **ship with declared failures, never silently claim pass.** diff --git a/skills/keystone/references/engine.md b/skills/keystone/references/engine.md index 01d754c..299ab63 100644 --- a/skills/keystone/references/engine.md +++ b/skills/keystone/references/engine.md @@ -2,7 +2,7 @@ How the executable gate engine works. The model reads this to orchestrate Step 7 (the engine-verified slop test). For the gate definitions themselves, -see gates.md. For the render extension, see the render tool. +see gates.md. For the render layer, see § The render layer below. ## Three tiers (recap) @@ -10,7 +10,7 @@ see gates.md. For the render extension, see the render tool. |---|---|---|---| | Skill | rule-set, catalog, gate definitions | skills/keystone/ | the model reads it | | Engine | deterministic gate checkers (math + DOM + CSS parse) | engine/ | `node engine/check-gates.mjs` (bash) | -| Extension | headless render + screenshot + computed-styles dump | extensions/render.ts | the `keystone_render` tool | +| Render | headless render + screenshot + computed-styles dump | engine/render-core.mjs + drivers | `keystone_render` tool (pi) · eval-kernel import (omp) · CLI `--render` | ## The deterministic gates (Phase 1 — shipped) @@ -53,8 +53,9 @@ Flags: - `--out` — output directory (default: `.`) - `--log` — path to `.keystone/log.json`; feeds G8/G32 (diversification) the prior macrostructure log so reuse is detected -- `--render` — runs headless Chromium via the render extension; without it - only the 11 CSS/HTML-only gates run (G34/G44/G40-41 need the render dump) +- `--render` — runs headless Chromium via the playwright driver (child + process); without it only the 11 CSS/HTML-only gates run + (G34/G44/G40-41 need the render dump) - `--viewports` — csv CSS pixel widths (default: `1280,375,320,414,768` when `--render` is set; `[]` otherwise) @@ -80,19 +81,27 @@ Each detector is a pure function `(ctx: DetectorContext) => GateResult[]` (or single `GateResult`). Detectors use `pass(gate, name)` and `fail(gate, name, evidence, fix, file?, line?)` from `engine/types.mjs`. -## The render extension +## The render layer + +One flow — `engine/render-core.mjs` — behind three drivers: + +| Driver | File | Used by | +|---|---|---| +| playwright-core (lazy import) | `engine/driver-playwright.mjs` | the CLI (`--render`, audit) and the `keystone_render` pi extension tool | +| omp built-in browser | `engine/driver-omp-native.mjs` | omp hosts — imported from omp's eval kernel as `engine/render-omp.mjs` | ``` -keystone_render({ htmlPath, viewports?: [1280, 375, 320, 414, 768], outDir?: string }) - -> { screenshots: [{ width, path }], computedStylesPath, domSnapshotPath, viewportMetrics } +render({ htmlPath, url?, viewports?: [1280, 375, 320, 414, 768], outDir?: string }) + -> { screenshots: [{ width, path }], computedStylesPath, domSnapshotPath, viewportMetrics, finalUrl, clickableMetrics } ``` -Headless Chromium (via `playwright-core`) at exact CSS px widths. Returns -screenshot paths + a computed-styles JSON dump (`computed.json` — up to 200 -`{ selector, color, backgroundColor }` pairs from the 1280px pass, converted -to canonical OKLCH strings via `engine/color.mjs`) + a DOM snapshot -(`dom.html`) + `viewportMetrics` (one per viewport: `{ width, scrollWidth, -innerWidth, innerHeight, hero? }`). +Headless Chromium at exact CSS px widths. Returns screenshot paths + a +computed-styles JSON dump (`computed.json` — up to 200 +`{ selector, color, backgroundColor, width, height }` pairs from the 1280px +pass, converted to canonical OKLCH strings via `engine/color.mjs`) + a DOM +snapshot (`dom.html`) + `viewportMetrics` (one per viewport: `{ width, +scrollWidth, innerWidth, innerHeight, hero? }`) + `clickable.json` (G49 +line-metrics at 1280 + 375) + `finalUrl` (post-redirect, audit re-check). `viewports.json` is written to `outDir` with the full `viewportMetrics` array. The `hero` object is captured only at the 1280px pass (omitted from @@ -102,15 +111,19 @@ headline; preceding short-text sibling (`

//

//` with following `

` = lede; first ``/`