diff --git a/packages/domscribe-next/README.md b/packages/domscribe-next/README.md index 107b5bc..333ed8e 100644 --- a/packages/domscribe-next/README.md +++ b/packages/domscribe-next/README.md @@ -12,6 +12,22 @@ npm install @domscribe/next Drop-in integration for Next.js 15+ projects. +## Style Capture (RFC 0001) + +```js +// next.config.js +import { withDomscribe } from '@domscribe/next'; + +export default withDomscribe({ + overlay: true, + captureStyles: true, // build-time styleSource + runtime componentStyles +})({}); +``` + +`captureStyles: true` enables both style-capture tiers with one flag. The +`runtime` option accepts the full `DomscribeRuntimeOptions` (serialization +constraints, `styleOptions`, per-tier `captureStyles` override). + ## Links Part of [Domscribe](https://github.com/patchorbit/domscribe). diff --git a/packages/domscribe-next/src/auto-init/auto-init.spec.ts b/packages/domscribe-next/src/auto-init/auto-init.spec.ts index 950687d..816ba22 100644 --- a/packages/domscribe-next/src/auto-init/auto-init.spec.ts +++ b/packages/domscribe-next/src/auto-init/auto-init.spec.ts @@ -28,6 +28,9 @@ describe('auto-init', () => { delete (globalThis as Record)[ '__DOMSCRIBE_OVERLAY_OPTIONS__' ]; + delete (globalThis as Record)[ + '__DOMSCRIBE_RUNTIME_OPTIONS__' + ]; }); it('should initialize runtime and react adapter', async () => { @@ -41,6 +44,22 @@ describe('auto-init', () => { }); }); + it('should spread __DOMSCRIBE_RUNTIME_OPTIONS__ into initialize', async () => { + (globalThis as Record)['__DOMSCRIBE_RUNTIME_OPTIONS__'] = { + captureStyles: true, + serialization: { maxDepth: 3 }, + }; + + await import('./index.js'); + await vi.dynamicImportSettled(); + + expect(mockInitialize).toHaveBeenCalledWith({ + captureStyles: true, + serialization: { maxDepth: 3 }, + adapter: { name: 'react-adapter' }, + }); + }); + it('should initialize overlay when __DOMSCRIBE_OVERLAY_OPTIONS__ is set', async () => { (globalThis as Record)['__DOMSCRIBE_OVERLAY_OPTIONS__'] = { initialMode: 'collapsed', diff --git a/packages/domscribe-next/src/auto-init/index.ts b/packages/domscribe-next/src/auto-init/index.ts index 87b7c0b..37ddd8c 100644 --- a/packages/domscribe-next/src/auto-init/index.ts +++ b/packages/domscribe-next/src/auto-init/index.ts @@ -11,10 +11,16 @@ */ if (typeof window !== 'undefined') { - // Initialize runtime + React adapter + // Initialize runtime + React adapter. Runtime options (serialization, + // captureStyles, styleOptions, ...) come from the loader preamble global. Promise.all([import('@domscribe/runtime'), import('@domscribe/react')]) .then(([{ RuntimeManager }, { createReactAdapter }]) => { + const win = globalThis as Record; + const runtimeOptions = + (win['__DOMSCRIBE_RUNTIME_OPTIONS__'] as Record) ?? + {}; RuntimeManager.getInstance().initialize({ + ...runtimeOptions, adapter: createReactAdapter(), }); }) diff --git a/packages/domscribe-next/src/types.ts b/packages/domscribe-next/src/types.ts index de74f54..e7e4a1c 100644 --- a/packages/domscribe-next/src/types.ts +++ b/packages/domscribe-next/src/types.ts @@ -6,7 +6,27 @@ * * @module @domscribe/next/types */ +import type { DomscribeRuntimeOptions } from '@domscribe/runtime'; + export interface DomscribeNextOptions { + /** + * Enable RFC 0001 style capture on both tiers with one flag: + * build-time `styleSource` attribution in the manifest (transform tier) + * and runtime `componentStyles` snapshots (runtime tier). + * + * `runtime.captureStyles` overrides the runtime tier when set explicitly. + * + * @default false + */ + captureStyles?: boolean; + + /** + * RuntimeManager configuration (phase, PII redaction, block selectors, + * serialization constraints, style capture). Must be JSON-serializable — + * it is forwarded to the browser via a loader-injected global. + */ + runtime?: DomscribeRuntimeOptions; + /** * Enable transformation. * Set to false in production builds. diff --git a/packages/domscribe-next/src/with-domscribe.spec.ts b/packages/domscribe-next/src/with-domscribe.spec.ts index c167856..babf987 100644 --- a/packages/domscribe-next/src/with-domscribe.spec.ts +++ b/packages/domscribe-next/src/with-domscribe.spec.ts @@ -335,10 +335,59 @@ describe('withDomscribe', () => { enabled: true, relay: { port: 5000, host: '0.0.0.0', bodyLimit: 5242880 }, overlay: { initialMode: 'expanded', debug: true }, + captureStyles: false, + runtime: { captureStyles: false }, autoInitPath: '/resolved/@domscribe/next/auto-init', }); }); + it('should thread captureStyles and runtime options into the loader', () => { + const result = withDomscribe({ + captureStyles: true, + runtime: { serialization: { maxDepth: 3 } }, + })({}); + const webpackFn = result.webpack as ( + config: WebpackConfig, + context: WebpackConfigContext, + ) => WebpackConfig; + const config = createMockWebpackConfig(); + + webpackFn(config, createMockWebpackContext()); + + const rules = config.module?.rules ?? []; + const use = rules[0].use ?? []; + const loaderEntry = use[0]; + expect(loaderEntry.options).toMatchObject({ + captureStyles: true, + runtime: { + serialization: { maxDepth: 3 }, + captureStyles: true, + }, + }); + }); + + it('should let runtime.captureStyles override the top-level flag', () => { + const result = withDomscribe({ + captureStyles: true, + runtime: { captureStyles: false }, + })({}); + const webpackFn = result.webpack as ( + config: WebpackConfig, + context: WebpackConfigContext, + ) => WebpackConfig; + const config = createMockWebpackConfig(); + + webpackFn(config, createMockWebpackContext()); + + const rules = config.module?.rules ?? []; + const use = rules[0].use ?? []; + const loaderEntry = use[0]; + expect(loaderEntry.options).toMatchObject({ + captureStyles: true, + runtime: { captureStyles: false }, + }); + }); + it('should chain existing webpack function', () => { const existingWebpack = vi.fn((config: WebpackConfig) => config); const result = withDomscribe()({ diff --git a/packages/domscribe-next/src/with-domscribe.ts b/packages/domscribe-next/src/with-domscribe.ts index 3f8f16e..b0c15a4 100644 --- a/packages/domscribe-next/src/with-domscribe.ts +++ b/packages/domscribe-next/src/with-domscribe.ts @@ -160,17 +160,34 @@ function applyDevTransforms( debug = false, relay = {}, overlay = true, + captureStyles = false, + runtime = {}, } = options; + // One flag drives both RFC 0001 tiers; `runtime.captureStyles` can + // override the runtime tier independently. + const runtimeOptions = { + ...runtime, + captureStyles: runtime.captureStyles ?? captureStyles, + }; + return { ...nextConfig, - turbopack: buildTurbopackConfig(nextConfig, { debug, relay, overlay }), + turbopack: buildTurbopackConfig(nextConfig, { + debug, + relay, + overlay, + captureStyles, + runtime: runtimeOptions, + }), webpack: buildWebpackFn(nextConfig, { include, exclude, debug, relay, overlay, + captureStyles, + runtime: runtimeOptions, }), }; } @@ -181,6 +198,8 @@ function buildTurbopackConfig( debug: boolean; relay: DomscribeNextOptions['relay']; overlay: DomscribeNextOptions['overlay']; + captureStyles: boolean; + runtime: DomscribeNextOptions['runtime']; }, ): NextConfig['turbopack'] { let turbopackLoaderPath: string; @@ -197,6 +216,8 @@ function buildTurbopackConfig( enabled: true, relay: options.relay as JSONValue, overlay: options.overlay as JSONValue, + captureStyles: options.captureStyles, + runtime: options.runtime as JSONValue, autoInitPath: resolveAutoInitPath(), }; @@ -270,6 +291,8 @@ function buildWebpackFn( debug: boolean; relay: DomscribeNextOptions['relay']; overlay: DomscribeNextOptions['overlay']; + captureStyles: boolean; + runtime: DomscribeNextOptions['runtime']; }, ): NextConfig['webpack'] { const existingWebpack = nextConfig.webpack; @@ -286,6 +309,8 @@ function buildWebpackFn( enabled: true, relay: options.relay as JSONValue, overlay: options.overlay as JSONValue, + captureStyles: options.captureStyles, + runtime: options.runtime as JSONValue, autoInitPath: resolveAutoInitPath(), }; diff --git a/packages/domscribe-nuxt/README.md b/packages/domscribe-nuxt/README.md index 855b21d..d765da4 100644 --- a/packages/domscribe-nuxt/README.md +++ b/packages/domscribe-nuxt/README.md @@ -12,6 +12,23 @@ npm install @domscribe/nuxt Drop-in Nuxt 3+ module. +## Style Capture (RFC 0001) + +```ts +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['@domscribe/nuxt'], + domscribe: { + overlay: true, + captureStyles: true, // build-time styleSource + runtime componentStyles + }, +}); +``` + +`captureStyles: true` enables both style-capture tiers with one flag. The +`runtime` option accepts the full `DomscribeRuntimeOptions` (serialization +constraints, `styleOptions`, per-tier `captureStyles` override). + ## Links Part of [Domscribe](https://github.com/patchorbit/domscribe). diff --git a/packages/domscribe-nuxt/src/module.spec.ts b/packages/domscribe-nuxt/src/module.spec.ts index 952110b..db795f3 100644 --- a/packages/domscribe-nuxt/src/module.spec.ts +++ b/packages/domscribe-nuxt/src/module.spec.ts @@ -323,6 +323,47 @@ describe('domscribeModule', () => { const innerHTML = nuxt.options.app.head.script[0].innerHTML; expect(innerHTML.split(';').length).toBeGreaterThanOrEqual(3); }); + + it('should inject runtime options when captureStyles is enabled', async () => { + const nuxt = createMockNuxt(); + + await callSetup( + { debug: false, relay: {}, overlay: false, captureStyles: true }, + nuxt, + ); + + const innerHTML = nuxt.options.app.head.script[0].innerHTML; + expect(innerHTML).toContain('__DOMSCRIBE_RUNTIME_OPTIONS__='); + expect(innerHTML).toContain('"captureStyles":true'); + }); + + it('should let runtime.captureStyles override the top-level flag', async () => { + const nuxt = createMockNuxt(); + + await callSetup( + { + debug: false, + relay: {}, + overlay: false, + captureStyles: true, + runtime: { captureStyles: false, serialization: { maxDepth: 3 } }, + }, + nuxt, + ); + + const innerHTML = nuxt.options.app.head.script[0].innerHTML; + expect(innerHTML).toContain('"captureStyles":false'); + expect(innerHTML).toContain('"serialization":{"maxDepth":3}'); + }); + + it('should not inject runtime options when none are configured', async () => { + const nuxt = createMockNuxt(); + + await callSetup({ debug: false, relay: {}, overlay: false }, nuxt); + + const innerHTML = nuxt.options.app.head.script[0].innerHTML; + expect(innerHTML).not.toContain('__DOMSCRIBE_RUNTIME_OPTIONS__'); + }); }); describe('vite plugin registration', () => { diff --git a/packages/domscribe-nuxt/src/module.ts b/packages/domscribe-nuxt/src/module.ts index 26e9cfe..c9bd9e9 100644 --- a/packages/domscribe-nuxt/src/module.ts +++ b/packages/domscribe-nuxt/src/module.ts @@ -92,6 +92,21 @@ export const domscribeModule = defineNuxtModule({ ); } + // Runtime options for the client plugin (RFC 0001 style capture etc.). + // One `captureStyles` flag drives both tiers; `runtime.captureStyles` + // can override the runtime tier independently. Only emitted when the + // user configured something — the plugin defaults handle absence. + if (options.runtime !== undefined || options.captureStyles !== undefined) { + const runtimeOptions = { + ...options.runtime, + captureStyles: + options.runtime?.captureStyles ?? options.captureStyles ?? false, + }; + parts.push( + `window.__DOMSCRIBE_RUNTIME_OPTIONS__=${JSON.stringify(runtimeOptions)}`, + ); + } + if (parts.length > 0) { const head = nuxt.options.app.head; head.script = head.script || []; @@ -150,6 +165,7 @@ export const domscribeModule = defineNuxtModule({ debug, relay: { ...options.relay, autoStart: false }, overlay: options.overlay, + captureStyles: options.captureStyles, }), ); }, diff --git a/packages/domscribe-nuxt/src/runtime/plugin.spec.ts b/packages/domscribe-nuxt/src/runtime/plugin.spec.ts index c255578..759da7d 100644 --- a/packages/domscribe-nuxt/src/runtime/plugin.spec.ts +++ b/packages/domscribe-nuxt/src/runtime/plugin.spec.ts @@ -64,6 +64,9 @@ describe('runtime/plugin', () => { delete (globalThis as unknown as Record)[ '__DOMSCRIBE_OVERLAY_OPTIONS__' ]; + delete (globalThis as unknown as Record)[ + '__DOMSCRIBE_RUNTIME_OPTIONS__' + ]; }); it('should register a plugin function via defineNuxtPlugin', () => { @@ -81,6 +84,20 @@ describe('runtime/plugin', () => { }); }); + it('should spread __DOMSCRIBE_RUNTIME_OPTIONS__ into initialize', async () => { + (globalThis as unknown as Record)[ + '__DOMSCRIBE_RUNTIME_OPTIONS__' + ] = { captureStyles: true, serialization: { maxDepth: 3 } }; + + await getPluginFn()(); + + expect(mockInitialize).toHaveBeenCalledWith({ + captureStyles: true, + serialization: { maxDepth: 3 }, + adapter: { name: 'vue-adapter' }, + }); + }); + it('should initialize overlay when __DOMSCRIBE_OVERLAY_OPTIONS__ is set', async () => { (globalThis as unknown as Record)[ '__DOMSCRIBE_OVERLAY_OPTIONS__' diff --git a/packages/domscribe-nuxt/src/runtime/plugin.ts b/packages/domscribe-nuxt/src/runtime/plugin.ts index d76b44f..54d96ef 100644 --- a/packages/domscribe-nuxt/src/runtime/plugin.ts +++ b/packages/domscribe-nuxt/src/runtime/plugin.ts @@ -9,7 +9,14 @@ import { RuntimeManager } from '@domscribe/runtime'; import { createVueAdapter } from '@domscribe/vue'; export default defineNuxtPlugin(async () => { + // Runtime options (serialization, captureStyles, styleOptions, ...) are + // injected by the module's head script before any bundle executes. + const win = globalThis as Record; + const runtimeOptions = + (win['__DOMSCRIBE_RUNTIME_OPTIONS__'] as Record) ?? {}; + RuntimeManager.getInstance().initialize({ + ...runtimeOptions, adapter: createVueAdapter({}), }); diff --git a/packages/domscribe-nuxt/src/types.ts b/packages/domscribe-nuxt/src/types.ts index fe9977a..9413003 100644 --- a/packages/domscribe-nuxt/src/types.ts +++ b/packages/domscribe-nuxt/src/types.ts @@ -6,7 +6,27 @@ * * @module @domscribe/nuxt/types */ +import type { DomscribeRuntimeOptions } from '@domscribe/runtime'; + export interface DomscribeNuxtOptions { + /** + * Enable RFC 0001 style capture on both tiers with one flag: + * build-time `styleSource` attribution in the manifest (transform tier) + * and runtime `componentStyles` snapshots (runtime tier). + * + * `runtime.captureStyles` overrides the runtime tier when set explicitly. + * + * @default false + */ + captureStyles?: boolean; + + /** + * RuntimeManager configuration (phase, PII redaction, block selectors, + * serialization constraints, style capture). Must be JSON-serializable — + * it is forwarded to the browser via a head-script global. + */ + runtime?: DomscribeRuntimeOptions; + /** * File pattern to include for transformation. * diff --git a/packages/domscribe-react/README.md b/packages/domscribe-react/README.md index 598e16e..da0fe72 100644 --- a/packages/domscribe-react/README.md +++ b/packages/domscribe-react/README.md @@ -43,6 +43,7 @@ interface DomscribeReactPluginOptions { overlay?: | boolean | { initialMode?: 'collapsed' | 'expanded'; debug?: boolean }; + captureStyles?: boolean; // React-specific runtime?: DomscribeRuntimeOptions; @@ -50,6 +51,16 @@ interface DomscribeReactPluginOptions { } ``` +### Style Capture (RFC 0001) + +`captureStyles: true` enables both style-capture tiers with one flag: build-time +`styleSource` attribution in the manifest and runtime `componentStyles` +snapshots (computed-style allowlist + CSS custom properties). This powers +styling-aware agent workflows — `domscribe.query.bySource` returns the +element's resolved styles, and the `domscribe.verify.*` MCP tools can compute +per-property deltas after an edit. Off by default in v0.x. +`runtime.captureStyles` overrides the runtime tier independently. + ### Runtime Options | Option | Type | Default | Description | @@ -58,6 +69,8 @@ interface DomscribeReactPluginOptions { | `redactPII` | `boolean` | `true` | Redact sensitive values in captured data | | `blockSelectors` | `string[]` | `[]` | CSS selectors to exclude from capture | | `serialization` | `SerializationConstraints` | `undefined` | Serialization bounds (maxDepth, maxArrayLength, maxTotalBytes, etc.) — see `@domscribe/runtime` README | +| `captureStyles` | `boolean` | `false` | Runtime-tier style capture (overrides the top-level flag when set) | +| `styleOptions` | `StyleCaptureOptions` | `undefined` | Style capturer tuning (allowlist, maxCustomProperties, maxBytes) | ### Capture Options diff --git a/packages/domscribe-react/src/vite/types.ts b/packages/domscribe-react/src/vite/types.ts index bba01ca..cc94c2a 100644 --- a/packages/domscribe-react/src/vite/types.ts +++ b/packages/domscribe-react/src/vite/types.ts @@ -34,6 +34,16 @@ export interface DomscribeReactPluginOptions { include?: RegExp; exclude?: RegExp; debug?: boolean; + /** + * Enable RFC 0001 style capture on both tiers with one flag: + * build-time `styleSource` attribution in the manifest (transform tier) + * and runtime `componentStyles` snapshots (runtime tier). + * + * `runtime.captureStyles` overrides the runtime tier when set explicitly. + * + * @default false + */ + captureStyles?: boolean; relay?: { autoStart?: boolean; port?: number; diff --git a/packages/domscribe-react/src/vite/vite-plugin.spec.ts b/packages/domscribe-react/src/vite/vite-plugin.spec.ts index 2629be1..093cd5c 100644 --- a/packages/domscribe-react/src/vite/vite-plugin.spec.ts +++ b/packages/domscribe-react/src/vite/vite-plugin.spec.ts @@ -67,10 +67,11 @@ describe('domscribe (react/vite)', () => { const result = load.call({}, '/@domscribe/react-init.js'); - expect(result).toContain('phase: 1'); - expect(result).toContain('debug: false'); - expect(result).toContain('redactPII: true'); - expect(result).toContain('blockSelectors: []'); + expect(result).toContain('"phase":1'); + expect(result).toContain('"debug":false'); + expect(result).toContain('"redactPII":true'); + expect(result).toContain('"blockSelectors":[]'); + expect(result).toContain('"captureStyles":false'); expect(result).toContain("strategy: 'best-effort'"); expect(result).toContain('maxTreeDepth: 50'); expect(result).toContain('includeWrappers: true'); @@ -84,9 +85,46 @@ describe('domscribe (react/vite)', () => { const result = load.call({}, '/@domscribe/react-init.js'); - expect(result).toContain('phase: 2'); - expect(result).toContain('redactPII: false'); - expect(result).toContain('blockSelectors: [".secret"]'); + expect(result).toContain('"phase":2'); + expect(result).toContain('"redactPII":false'); + expect(result).toContain('"blockSelectors":[".secret"]'); + }); + + it('should enable runtime style capture from the top-level captureStyles flag', () => { + const plugin = domscribe({ captureStyles: true }); + const load = plugin.load as (id: string) => string | null; + + const result = load.call({}, '/@domscribe/react-init.js'); + + expect(result).toContain('"captureStyles":true'); + }); + + it('should let runtime.captureStyles override the top-level flag', () => { + const plugin = domscribe({ + captureStyles: true, + runtime: { captureStyles: false }, + }); + const load = plugin.load as (id: string) => string | null; + + const result = load.call({}, '/@domscribe/react-init.js'); + + expect(result).toContain('"captureStyles":false'); + }); + + it('should serialize serialization and styleOptions when provided', () => { + const plugin = domscribe({ + runtime: { + serialization: { maxDepth: 3 }, + captureStyles: true, + styleOptions: { maxCustomProperties: 16 }, + }, + }); + const load = plugin.load as (id: string) => string | null; + + const result = load.call({}, '/@domscribe/react-init.js'); + + expect(result).toContain('"serialization":{"maxDepth":3}'); + expect(result).toContain('"styleOptions":{"maxCustomProperties":16}'); }); it('should serialize custom capture options', () => { @@ -112,9 +150,9 @@ describe('domscribe (react/vite)', () => { const result = load.call({}, '/@domscribe/react-init.js'); - // debug appears in both initialize() and createReactAdapter() - const debugMatches = result.match(/debug: true/g); - expect(debugMatches).toHaveLength(2); + // debug appears in both initialize() (runtime JSON) and createReactAdapter() + expect(result).toContain('"debug":true'); + expect(result).toContain('debug: true'); }); it('should serialize hookNameResolvers into Map reconstruction', () => { diff --git a/packages/domscribe-react/src/vite/vite-plugin.ts b/packages/domscribe-react/src/vite/vite-plugin.ts index 51659d8..8a662ae 100644 --- a/packages/domscribe-react/src/vite/vite-plugin.ts +++ b/packages/domscribe-react/src/vite/vite-plugin.ts @@ -65,6 +65,18 @@ export function domscribe(options?: DomscribeReactPluginOptions): Plugin { const cap = options?.capture ?? {}; const debug = options?.debug ?? false; + // JSON.stringify drops undefined fields, so optional runtime options + // (serialization, styleOptions) only appear when configured. + const runtimeJson = JSON.stringify({ + phase: rt.phase ?? 1, + debug, + redactPII: rt.redactPII ?? true, + blockSelectors: rt.blockSelectors ?? [], + serialization: rt.serialization, + captureStyles: rt.captureStyles ?? options?.captureStyles ?? false, + styleOptions: rt.styleOptions, + }); + // Build hookNameResolvers reconstruction if provided const resolversLine = cap.hookNameResolvers ? `const _r = ${JSON.stringify(cap.hookNameResolvers)};\n` + @@ -82,10 +94,7 @@ export function domscribe(options?: DomscribeReactPluginOptions): Plugin { resolversLine, ``, `RuntimeManager.getInstance().initialize({`, - ` phase: ${rt.phase ?? 1},`, - ` debug: ${debug},`, - ` redactPII: ${rt.redactPII ?? true},`, - ` blockSelectors: ${JSON.stringify(rt.blockSelectors ?? [])},`, + ` ...${runtimeJson},`, ` adapter: createReactAdapter({`, ` strategy: '${cap.strategy ?? 'best-effort'}',`, ` maxTreeDepth: ${cap.maxTreeDepth ?? 50},`, diff --git a/packages/domscribe-react/src/webpack/webpack-plugin.spec.ts b/packages/domscribe-react/src/webpack/webpack-plugin.spec.ts index 4dc5144..7eda4ff 100644 --- a/packages/domscribe-react/src/webpack/webpack-plugin.spec.ts +++ b/packages/domscribe-react/src/webpack/webpack-plugin.spec.ts @@ -155,6 +155,9 @@ describe('DomscribeWebpackPlugin (react)', () => { debug: false, redactPII: undefined, blockSelectors: undefined, + serialization: undefined, + captureStyles: false, + styleOptions: undefined, }), __DOMSCRIBE_ADAPTER_OPTIONS__: JSON.stringify({ strategy: undefined, @@ -167,6 +170,41 @@ describe('DomscribeWebpackPlugin (react)', () => { expect(mockDefinePluginApply).toHaveBeenCalledWith(compiler); }); + it('should forward captureStyles and style options into runtime globals', () => { + const plugin = new DomscribeWebpackPlugin({ + captureStyles: true, + runtime: { + serialization: { maxDepth: 3 }, + styleOptions: { maxCustomProperties: 16 }, + }, + }); + const compiler = createMockCompiler(); + + plugin.apply(compiler); + + const runtimeJson = MockDefinePlugin.calls[0][ + '__DOMSCRIBE_RUNTIME_OPTIONS__' + ] as string; + expect(runtimeJson).toContain('"captureStyles":true'); + expect(runtimeJson).toContain('"serialization":{"maxDepth":3}'); + expect(runtimeJson).toContain('"styleOptions":{"maxCustomProperties":16}'); + }); + + it('should let runtime.captureStyles override the top-level flag', () => { + const plugin = new DomscribeWebpackPlugin({ + captureStyles: true, + runtime: { captureStyles: false }, + }); + const compiler = createMockCompiler(); + + plugin.apply(compiler); + + const runtimeJson = MockDefinePlugin.calls[0][ + '__DOMSCRIBE_RUNTIME_OPTIONS__' + ] as string; + expect(runtimeJson).toContain('"captureStyles":false'); + }); + it('should inject custom runtime options', () => { const plugin = new DomscribeWebpackPlugin({ runtime: { phase: 2, redactPII: false, blockSelectors: ['.secret'] }, diff --git a/packages/domscribe-react/src/webpack/webpack-plugin.ts b/packages/domscribe-react/src/webpack/webpack-plugin.ts index 9f31794..ffd6ca6 100644 --- a/packages/domscribe-react/src/webpack/webpack-plugin.ts +++ b/packages/domscribe-react/src/webpack/webpack-plugin.ts @@ -50,12 +50,14 @@ export class DomscribeWebpackPlugin implements WebpackPluginInstance { private readonly basePlugin: BaseDomscribeWebpackPlugin; private readonly runtimeOptions: DomscribeRuntimeOptions; private readonly captureOptions: DomscribeReactCaptureOptions; + private readonly captureStyles: boolean; private readonly debug: boolean; constructor(options?: DomscribeReactWebpackPluginOptions) { this.basePlugin = new BaseDomscribeWebpackPlugin(options); this.runtimeOptions = options?.runtime ?? {}; this.captureOptions = options?.capture ?? {}; + this.captureStyles = options?.captureStyles ?? false; this.debug = options?.debug ?? false; } @@ -67,6 +69,9 @@ export class DomscribeWebpackPlugin implements WebpackPluginInstance { debug: this.debug, redactPII: this.runtimeOptions.redactPII, blockSelectors: this.runtimeOptions.blockSelectors, + serialization: this.runtimeOptions.serialization, + captureStyles: this.runtimeOptions.captureStyles ?? this.captureStyles, + styleOptions: this.runtimeOptions.styleOptions, }), __DOMSCRIBE_ADAPTER_OPTIONS__: JSON.stringify({ strategy: this.captureOptions.strategy, diff --git a/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.spec.ts b/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.spec.ts index bc49bb0..5d4eee9 100644 --- a/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.spec.ts +++ b/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.spec.ts @@ -618,6 +618,48 @@ describe('Turbopack Loader', () => { expect(outputCode).toContain('"initialMode":"expanded"'); }); + it('should inject runtime options when configured', async () => { + // Arrange + const source = 'export function App() { return
Hello
; }'; + const context = createLoaderContext('/test/App.tsx', { + runtime: { captureStyles: true, serialization: { maxDepth: 3 } }, + }); + const asyncCallback = vi.fn(); + context.async = vi.fn(() => asyncCallback); + mockInjector.inject.mockReturnValue( + createMockInjectorResult('transformed', 1), + ); + + // Act + loaderModule.default.call(context, source); + await vi.waitFor(() => expect(asyncCallback).toHaveBeenCalled()); + + // Assert + const outputCode = asyncCallback.mock.calls[0][1] as string; + expect(outputCode).toContain('__DOMSCRIBE_RUNTIME_OPTIONS__'); + expect(outputCode).toContain('"captureStyles":true'); + expect(outputCode).toContain('"serialization":{"maxDepth":3}'); + }); + + it('should omit the runtime global when runtime options are not provided', async () => { + // Arrange + const source = 'export function App() { return
Hello
; }'; + const context = createLoaderContext('/test/App.tsx'); + const asyncCallback = vi.fn(); + context.async = vi.fn(() => asyncCallback); + mockInjector.inject.mockReturnValue( + createMockInjectorResult('transformed', 1), + ); + + // Act + loaderModule.default.call(context, source); + await vi.waitFor(() => expect(asyncCallback).toHaveBeenCalled()); + + // Assert + const outputCode = asyncCallback.mock.calls[0][1] as string; + expect(outputCode).not.toContain('__DOMSCRIBE_RUNTIME_OPTIONS__'); + }); + it('should skip empty source', async () => { // Arrange const context = createLoaderContext('/test/App.tsx'); diff --git a/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.ts b/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.ts index 47fd3ee..b8d6d1b 100644 --- a/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.ts +++ b/packages/domscribe-transform/src/plugins/turbopack/turbopack.loader.ts @@ -212,6 +212,12 @@ function buildClientGlobalsPreamble( ); } + if (options.runtime !== undefined) { + parts.push( + `window.__DOMSCRIBE_RUNTIME_OPTIONS__=${JSON.stringify(options.runtime)}`, + ); + } + const autoInitSpecifier = resolveAutoInitSpecifier( options.autoInitPath, sourceFile, diff --git a/packages/domscribe-transform/src/plugins/turbopack/types.ts b/packages/domscribe-transform/src/plugins/turbopack/types.ts index 971d591..e60654e 100644 --- a/packages/domscribe-transform/src/plugins/turbopack/types.ts +++ b/packages/domscribe-transform/src/plugins/turbopack/types.ts @@ -66,6 +66,18 @@ export interface TurbopackLoaderOptions { * @default false */ captureStyles?: boolean; + + /** + * Serializable runtime options forwarded verbatim to the browser as + * `window.__DOMSCRIBE_RUNTIME_OPTIONS__`, where the meta-framework's + * auto-init module spreads them into `RuntimeManager.initialize()`. + * + * Typed loosely because this package cannot depend on + * `@domscribe/runtime` (module boundary) — meta-framework wrappers + * (e.g. `@domscribe/next`) type their public option surface as + * `DomscribeRuntimeOptions` and pass it through here. + */ + runtime?: Record; } /** diff --git a/packages/domscribe-vue/README.md b/packages/domscribe-vue/README.md index 8e3181d..67b3091 100644 --- a/packages/domscribe-vue/README.md +++ b/packages/domscribe-vue/README.md @@ -43,6 +43,7 @@ interface DomscribeVuePluginOptions { overlay?: | boolean | { initialMode?: 'collapsed' | 'expanded'; debug?: boolean }; + captureStyles?: boolean; // Vue-specific runtime?: DomscribeRuntimeOptions; @@ -50,6 +51,16 @@ interface DomscribeVuePluginOptions { } ``` +### Style Capture (RFC 0001) + +`captureStyles: true` enables both style-capture tiers with one flag: build-time +`styleSource` attribution in the manifest (JSX only in v0.x) and runtime +`componentStyles` snapshots (computed-style allowlist + CSS custom properties). +This powers styling-aware agent workflows — `domscribe.query.bySource` returns +the element's resolved styles, and the `domscribe.verify.*` MCP tools can +compute per-property deltas after an edit. Off by default in v0.x. +`runtime.captureStyles` overrides the runtime tier independently. + ### Runtime Options | Option | Type | Default | Description | diff --git a/packages/domscribe-vue/src/vite/types.ts b/packages/domscribe-vue/src/vite/types.ts index 30be3b3..12bf394 100644 --- a/packages/domscribe-vue/src/vite/types.ts +++ b/packages/domscribe-vue/src/vite/types.ts @@ -24,6 +24,16 @@ export interface DomscribeVuePluginOptions { include?: RegExp; exclude?: RegExp; debug?: boolean; + /** + * Enable RFC 0001 style capture on both tiers with one flag: + * build-time `styleSource` attribution in the manifest (transform tier) + * and runtime `componentStyles` snapshots (runtime tier). + * + * `runtime.captureStyles` overrides the runtime tier when set explicitly. + * + * @default false + */ + captureStyles?: boolean; relay?: { autoStart?: boolean; port?: number; diff --git a/packages/domscribe-vue/src/vite/vite-plugin.spec.ts b/packages/domscribe-vue/src/vite/vite-plugin.spec.ts index 2dc5337..22ce298 100644 --- a/packages/domscribe-vue/src/vite/vite-plugin.spec.ts +++ b/packages/domscribe-vue/src/vite/vite-plugin.spec.ts @@ -68,10 +68,11 @@ describe('domscribe (vue/vite)', () => { const result = load.call({}, '/@domscribe/vue-init.js'); - expect(result).toContain('phase: 1'); - expect(result).toContain('debug: false'); - expect(result).toContain('redactPII: true'); - expect(result).toContain('blockSelectors: []'); + expect(result).toContain('"phase":1'); + expect(result).toContain('"debug":false'); + expect(result).toContain('"redactPII":true'); + expect(result).toContain('"blockSelectors":[]'); + expect(result).toContain('"captureStyles":false'); expect(result).toContain('maxTreeDepth: 50'); }); @@ -83,9 +84,46 @@ describe('domscribe (vue/vite)', () => { const result = load.call({}, '/@domscribe/vue-init.js'); - expect(result).toContain('phase: 2'); - expect(result).toContain('redactPII: false'); - expect(result).toContain('blockSelectors: [".secret"]'); + expect(result).toContain('"phase":2'); + expect(result).toContain('"redactPII":false'); + expect(result).toContain('"blockSelectors":[".secret"]'); + }); + + it('should enable runtime style capture from the top-level captureStyles flag', () => { + const plugin = domscribe({ captureStyles: true }); + const load = plugin.load as (id: string) => string | null; + + const result = load.call({}, '/@domscribe/vue-init.js'); + + expect(result).toContain('"captureStyles":true'); + }); + + it('should let runtime.captureStyles override the top-level flag', () => { + const plugin = domscribe({ + captureStyles: true, + runtime: { captureStyles: false }, + }); + const load = plugin.load as (id: string) => string | null; + + const result = load.call({}, '/@domscribe/vue-init.js'); + + expect(result).toContain('"captureStyles":false'); + }); + + it('should serialize serialization and styleOptions when provided', () => { + const plugin = domscribe({ + runtime: { + serialization: { maxDepth: 3 }, + captureStyles: true, + styleOptions: { maxCustomProperties: 16 }, + }, + }); + const load = plugin.load as (id: string) => string | null; + + const result = load.call({}, '/@domscribe/vue-init.js'); + + expect(result).toContain('"serialization":{"maxDepth":3}'); + expect(result).toContain('"styleOptions":{"maxCustomProperties":16}'); }); it('should serialize custom capture options', () => { @@ -105,9 +143,9 @@ describe('domscribe (vue/vite)', () => { const result = load.call({}, '/@domscribe/vue-init.js'); - // debug appears in both initialize() and createVueAdapter() - const debugMatches = result.match(/debug: true/g); - expect(debugMatches).toHaveLength(2); + // debug appears in both initialize() (runtime JSON) and createVueAdapter() + expect(result).toContain('"debug":true'); + expect(result).toContain('debug: true'); }); it('should return null for unrelated IDs', () => { diff --git a/packages/domscribe-vue/src/vite/vite-plugin.ts b/packages/domscribe-vue/src/vite/vite-plugin.ts index 79a3a4d..8831e56 100644 --- a/packages/domscribe-vue/src/vite/vite-plugin.ts +++ b/packages/domscribe-vue/src/vite/vite-plugin.ts @@ -64,6 +64,18 @@ export function domscribe(options?: DomscribeVuePluginOptions): Plugin { const cap = options?.capture ?? {}; const debug = options?.debug ?? false; + // JSON.stringify drops undefined fields, so optional runtime options + // (serialization, styleOptions) only appear when configured. + const runtimeJson = JSON.stringify({ + phase: rt.phase ?? 1, + debug, + redactPII: rt.redactPII ?? true, + blockSelectors: rt.blockSelectors ?? [], + serialization: rt.serialization, + captureStyles: rt.captureStyles ?? options?.captureStyles ?? false, + styleOptions: rt.styleOptions, + }); + // Bare specifiers here get rewritten by Vite's transform pipeline // to pre-bundled paths, matching what the overlay resolves internally return [ @@ -71,10 +83,7 @@ export function domscribe(options?: DomscribeVuePluginOptions): Plugin { `import { createVueAdapter } from '@domscribe/vue';`, ``, `RuntimeManager.getInstance().initialize({`, - ` phase: ${rt.phase ?? 1},`, - ` debug: ${debug},`, - ` redactPII: ${rt.redactPII ?? true},`, - ` blockSelectors: ${JSON.stringify(rt.blockSelectors ?? [])},`, + ` ...${runtimeJson},`, ` adapter: createVueAdapter({`, ` maxTreeDepth: ${cap.maxTreeDepth ?? 50},`, ` debug: ${debug},`, diff --git a/packages/domscribe-vue/src/webpack/webpack-plugin.spec.ts b/packages/domscribe-vue/src/webpack/webpack-plugin.spec.ts index 77d643a..184b0a9 100644 --- a/packages/domscribe-vue/src/webpack/webpack-plugin.spec.ts +++ b/packages/domscribe-vue/src/webpack/webpack-plugin.spec.ts @@ -139,6 +139,9 @@ describe('DomscribeWebpackPlugin (vue)', () => { debug: false, redactPII: undefined, blockSelectors: undefined, + serialization: undefined, + captureStyles: false, + styleOptions: undefined, }), __DOMSCRIBE_ADAPTER_OPTIONS__: JSON.stringify({ maxTreeDepth: undefined, @@ -148,6 +151,41 @@ describe('DomscribeWebpackPlugin (vue)', () => { expect(mockDefinePluginApply).toHaveBeenCalledWith(compiler); }); + it('should forward captureStyles and style options into runtime globals', () => { + const plugin = new DomscribeWebpackPlugin({ + captureStyles: true, + runtime: { + serialization: { maxDepth: 3 }, + styleOptions: { maxCustomProperties: 16 }, + }, + }); + const compiler = createMockCompiler(); + + plugin.apply(compiler); + + const runtimeJson = MockDefinePlugin.calls[0][ + '__DOMSCRIBE_RUNTIME_OPTIONS__' + ] as string; + expect(runtimeJson).toContain('"captureStyles":true'); + expect(runtimeJson).toContain('"serialization":{"maxDepth":3}'); + expect(runtimeJson).toContain('"styleOptions":{"maxCustomProperties":16}'); + }); + + it('should let runtime.captureStyles override the top-level flag', () => { + const plugin = new DomscribeWebpackPlugin({ + captureStyles: true, + runtime: { captureStyles: false }, + }); + const compiler = createMockCompiler(); + + plugin.apply(compiler); + + const runtimeJson = MockDefinePlugin.calls[0][ + '__DOMSCRIBE_RUNTIME_OPTIONS__' + ] as string; + expect(runtimeJson).toContain('"captureStyles":false'); + }); + it('should inject custom runtime options', () => { const plugin = new DomscribeWebpackPlugin({ runtime: { phase: 2, redactPII: false, blockSelectors: ['.secret'] }, diff --git a/packages/domscribe-vue/src/webpack/webpack-plugin.ts b/packages/domscribe-vue/src/webpack/webpack-plugin.ts index 86f974f..4a2bae6 100644 --- a/packages/domscribe-vue/src/webpack/webpack-plugin.ts +++ b/packages/domscribe-vue/src/webpack/webpack-plugin.ts @@ -50,12 +50,14 @@ export class DomscribeWebpackPlugin implements WebpackPluginInstance { private readonly basePlugin: BaseDomscribeWebpackPlugin; private readonly runtimeOptions: DomscribeRuntimeOptions; private readonly captureOptions: DomscribeVueCaptureOptions; + private readonly captureStyles: boolean; private readonly debug: boolean; constructor(options?: DomscribeVueWebpackPluginOptions) { this.basePlugin = new BaseDomscribeWebpackPlugin(options); this.runtimeOptions = options?.runtime ?? {}; this.captureOptions = options?.capture ?? {}; + this.captureStyles = options?.captureStyles ?? false; this.debug = options?.debug ?? false; } @@ -67,6 +69,9 @@ export class DomscribeWebpackPlugin implements WebpackPluginInstance { debug: this.debug, redactPII: this.runtimeOptions.redactPII, blockSelectors: this.runtimeOptions.blockSelectors, + serialization: this.runtimeOptions.serialization, + captureStyles: this.runtimeOptions.captureStyles ?? this.captureStyles, + styleOptions: this.runtimeOptions.styleOptions, }), __DOMSCRIBE_ADAPTER_OPTIONS__: JSON.stringify({ maxTreeDepth: this.captureOptions.maxTreeDepth,