From ea777a03f0a252256dfffc90d49cfb5ebb8501ef Mon Sep 17 00:00:00 2001 From: "Domscribe Staff SWE (bot)" Date: Sun, 7 Jun 2026 06:34:17 -0700 Subject: [PATCH 1/3] feat(core): add StyleSource + ComponentStyles schemas; bump ANNOTATION_SCHEMA_VERSION to 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces optional `styleSource` on `ManifestEntry` (build-time className tokens + CSS-in-JS source-block location) and optional `componentStyles` on `RuntimeContextSchema` (≤32-property computed allowlist + resolved CSS custom properties), per RFC 0001. Lands the schema half so transform, runtime, and relay packages can build against it without circular work. The v1 → v2 migration is purely additive — fields are optional, so v1 payloads pass through untouched. A no-op `migrationSteps[1]` is registered so `migrateAnnotation` can stamp persisted v1 annotations to v2 without throwing on the version walk. Forward-compat guarantee for v1-pinned clients reading v2 annotations is asserted in the migration spec. Also adds `COMPONENT_STYLES_ALLOWLIST` as the authoritative computed-style property list so the runtime, the relay, and downstream tooling agree on the capture contract. Co-Authored-By: Claude Opus 4.7 --- .../migrations/annotation-migrations.spec.ts | 47 ++++++++- .../lib/migrations/annotation-migrations.ts | 18 +++- .../src/lib/types/annotation.ts | 99 ++++++++++++++++++- .../domscribe-core/src/lib/types/manifest.ts | 85 ++++++++++++++++ 4 files changed, 240 insertions(+), 9 deletions(-) diff --git a/packages/domscribe-core/src/lib/migrations/annotation-migrations.spec.ts b/packages/domscribe-core/src/lib/migrations/annotation-migrations.spec.ts index c519dd8..27b6171 100644 --- a/packages/domscribe-core/src/lib/migrations/annotation-migrations.spec.ts +++ b/packages/domscribe-core/src/lib/migrations/annotation-migrations.spec.ts @@ -56,15 +56,16 @@ describe('migrateAnnotation', () => { expect(result.metadata.schemaVersion).toBe(ANNOTATION_SCHEMA_VERSION); }); - it('should default to version 1 when metadata has no schemaVersion', () => { - // With ANNOTATION_SCHEMA_VERSION === 1 and no field, readVersion returns 1. - // Since 1 === ANNOTATION_SCHEMA_VERSION, no migration steps run — it just stamps. + it('should walk legacy (unversioned) data forward to the current version', () => { + // readVersion defaults missing metadata.schemaVersion to 1, so a legacy + // annotation is migrated through every registered step up to the current + // version and then stamped. const raw = buildRawAnnotation(); delete (raw['metadata'] as Record)['schemaVersion']; const result = migrateAnnotation(raw); - expect(result.metadata.schemaVersion).toBe(1); + expect(result.metadata.schemaVersion).toBe(ANNOTATION_SCHEMA_VERSION); }); it('should default to version 1 when metadata is missing entirely', () => { @@ -102,4 +103,42 @@ describe('migrateAnnotation', () => { expect(result.context.pageUrl).toBe('http://localhost:3000'); expect(result.context.viewport).toEqual({ width: 1920, height: 1080 }); }); + + it('should migrate a v1 annotation up to v2 (additive, no field rewrite)', () => { + // Simulates a v1 annotation persisted before RFC 0001 schema bump. + // The v1 → v2 step is purely additive (componentStyles + styleSource + // are new optional fields) — the migration must not rewrite or delete + // any existing payload data. + const raw = buildRawAnnotation({ + metadata: { schemaVersion: 1 }, + context: { + pageUrl: 'http://localhost:3000', + pageTitle: 'Test', + viewport: { width: 1920, height: 1080 }, + userAgent: 'test-agent', + runtimeContext: { + componentProps: { foo: 'bar' }, + }, + }, + }); + + const result = migrateAnnotation(raw); + + expect(result.metadata.schemaVersion).toBe(ANNOTATION_SCHEMA_VERSION); + expect(result.context.runtimeContext).toEqual({ + componentProps: { foo: 'bar' }, + }); + }); + + it('should leave v1 payloads structurally untouched (forward-compat for v1-pinned clients)', () => { + // Forward-compat guarantee for annotation-process.tool: a v1-pinned + // downstream consumer reading a v2-migrated annotation sees exactly the + // v1 fields it already understood. The new componentStyles slot is + // optional and is absent (`undefined`) on migrated v1 payloads. + const raw = buildRawAnnotation({ metadata: { schemaVersion: 1 } }); + + const result = migrateAnnotation(raw); + + expect(result.context.runtimeContext).toBeUndefined(); + }); }); diff --git a/packages/domscribe-core/src/lib/migrations/annotation-migrations.ts b/packages/domscribe-core/src/lib/migrations/annotation-migrations.ts index 6457af3..8ade356 100644 --- a/packages/domscribe-core/src/lib/migrations/annotation-migrations.ts +++ b/packages/domscribe-core/src/lib/migrations/annotation-migrations.ts @@ -16,12 +16,22 @@ import { /** * Registry of migration functions keyed by the version they migrate FROM. * e.g. migrationSteps[1] migrates v1 → v2. - * - * Currently empty — only v1 exists. When a v2 schema is introduced, add: - * migrationSteps[1] = (data: Record) => { … mutate … }; */ const migrationSteps: Record) => void> = - {}; + { + /** + * v1 → v2: additive only (per RFC 0001). + * + * v2 adds optional `runtimeContext.componentStyles` and optional + * `manifestSnapshot[].styleSource`. Both are optional and absent on v1 + * payloads, so no field rewriting is required — the migration step + * exists purely to satisfy the version-walk contract and to let v1 + * annotations be stamped as v2 on next write without throwing. + */ + 1: () => { + // No-op: v1 → v2 is purely additive. + }, + }; /** * Read `metadata.schemaVersion` from raw JSON, defaulting to 1 for diff --git a/packages/domscribe-core/src/lib/types/annotation.ts b/packages/domscribe-core/src/lib/types/annotation.ts index 8e426ff..e41f38e 100644 --- a/packages/domscribe-core/src/lib/types/annotation.ts +++ b/packages/domscribe-core/src/lib/types/annotation.ts @@ -54,9 +54,46 @@ export const EnvironmentSchema = z.object({ packageManager: z.string().optional().describe('Package manager used'), }); +/** + * Runtime style snapshot for the annotated element. + * + * Captured by `@domscribe/runtime` at interaction time when + * `domscribe.config.captureStyles` is enabled. Two slots: + * + * - `computed`: a fixed allowlist of computed-style properties (≤32 entries + * covering layout, spacing, typography, visual, and positioning). This is + * the ground truth for "what the user sees" — survives conditional class + * names, responsive variants, and runtime theme switches that build-time + * attribution alone cannot resolve. + * - `customProperties`: resolved CSS custom properties (`--*` vars) on the + * element and its ancestors up to `:root`. Lets the agent recover the + * design-system token boundary without re-resolving Tailwind config or a + * styled-components theme. + * + * Both fields are optional. Capture is best-effort and respects the + * existing per-element serialization budget (≤4 KB). + */ +export const ComponentStylesSchema = z.object({ + computed: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Subset of computed CSS properties for the element, drawn from a fixed ≤32-property allowlist', + ), + customProperties: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Resolved CSS custom properties (--* variables) inherited by the element from itself up to :root', + ), +}); + export const RuntimeContextSchema = z.object({ componentProps: z.unknown().optional().describe('Component props snapshot'), componentState: z.unknown().optional().describe('Component state snapshot'), + componentStyles: ComponentStylesSchema.optional().describe( + 'Computed-style allowlist + resolved CSS custom properties (captured when domscribe.config.captureStyles is enabled)', + ), eventFlow: z.unknown().optional().describe('Event flow breadcrumbs'), performance: z.unknown().optional().describe('Performance metrics'), }); @@ -68,8 +105,14 @@ export const AnnotationIdSchema = z /** * Current annotation schema version. Bump when the Annotation shape changes. + * + * Version history: + * - v1: initial schema. + * - v2: added optional `runtimeContext.componentStyles` (computed-style + * allowlist + CSS custom properties) and optional `styleSource` on + * embedded `manifestSnapshot` entries, per RFC 0001. */ -export const ANNOTATION_SCHEMA_VERSION = 1; +export const ANNOTATION_SCHEMA_VERSION = 2; export const AnnotationMetadataSchema = z.object({ id: AnnotationIdSchema, @@ -196,3 +239,57 @@ export type BoundingRect = z.infer; export type Viewport = z.infer; export type Environment = z.infer; export type RuntimeContext = z.infer; +export type ComponentStyles = z.infer; + +/** + * Allowlist of computed-style property names captured by the runtime + * `StyleCapturer` (≤32 entries). Lives in `@domscribe/core` so the + * runtime, the relay, and downstream tools agree on the contract. + * + * Covers layout, spacing, typography, visual, and positioning — chosen to + * be a useful styling-debug subset without bloating the per-element + * serialization budget. + */ +export const COMPONENT_STYLES_ALLOWLIST = [ + // Layout + 'display', + 'position', + 'flex-direction', + 'flex-wrap', + 'align-items', + 'justify-content', + 'gap', + 'grid-template-columns', + 'grid-template-rows', + // Spacing + 'margin', + 'padding', + 'width', + 'height', + 'min-width', + 'min-height', + 'max-width', + 'max-height', + // Typography + 'font-family', + 'font-size', + 'font-weight', + 'line-height', + 'letter-spacing', + 'text-align', + 'color', + // Visual + 'background-color', + 'border', + 'border-radius', + 'box-shadow', + 'opacity', + // Positioning + 'top', + 'right', + 'bottom', + 'left', +] as const satisfies readonly string[]; + +export type ComponentStylesAllowlist = + (typeof COMPONENT_STYLES_ALLOWLIST)[number]; diff --git a/packages/domscribe-core/src/lib/types/manifest.ts b/packages/domscribe-core/src/lib/types/manifest.ts index 022e4e3..a4040d8 100644 --- a/packages/domscribe-core/src/lib/types/manifest.ts +++ b/packages/domscribe-core/src/lib/types/manifest.ts @@ -25,6 +25,86 @@ export const StyleInfoSchema = z.object({ inline: z.string().optional().describe('Inline style content'), }); +/** + * Source-block location for a CSS-in-JS declaration (styled-components or emotion). + * + * Recorded at transform time for elements rendered by a locally-declared + * styled-component (e.g. `const StyledDiv = styled.div\`...\`;` used as + * ``). Lets agents jump from the runtime DOM back to the + * authoring source without re-deriving the binding from a hashed class name. + */ +export const CssInJsSourceLocationSchema = z.object({ + file: z + .string() + .describe('Source file path containing the styled declaration'), + line: z + .number() + .int() + .nonnegative() + .describe('Line number of the styled declaration (1-indexed)'), + column: z + .number() + .int() + .nonnegative() + .describe('Column number of the styled declaration (0-indexed)'), + blockText: z + .string() + .describe( + 'Verbatim source text of the styled template literal or css block (truncated to 4 KB)', + ), + library: z + .enum(['styled-components', 'emotion', 'unknown']) + .optional() + .describe('Detected CSS-in-JS library, when statically inferable'), + kind: z + .enum(['styled-tag', 'styled-call', 'css-template']) + .optional() + .describe( + 'AST pattern that matched: styled.div (styled-tag), styled(Component) (styled-call), or css`...` (css-template)', + ), +}); + +/** + * Build-time style attribution for a manifest entry. + * + * Captured by `@domscribe/transform` on the same AST visit that injects + * `data-ds` attributes. Provides the agent with a static link from the + * rendered element to the styling source that produced it, so styling-shaped + * annotations can be resolved without a runtime round trip for the + * source-attribution step. + * + * @remarks + * - `className` is the raw value of the JSX `className` attribute when it is + * statically extractable (string literal or single template literal with + * no interpolations). Computed expressions yield `undefined`. + * - `classes` is the array of utility-class tokens statically derivable from + * the `className` expression, including tokens reachable through `clsx`, + * `cn`, `tw`, conditional expressions, and string-only template literals. + * Tokens reachable only through runtime values are silently dropped. + * - `cssInJs` is set when the JSX tag references a locally-declared + * styled-component in the same source file. + * + * All fields are optional: a partial attribution is preferable to a throw, + * and absence is the correct signal to fall back to reading the source. + */ +export const StyleSourceSchema = z.object({ + className: z + .string() + .optional() + .describe( + 'Statically-resolvable className literal as it appears in source; undefined when className is fully computed at runtime', + ), + classes: z + .array(z.string()) + .optional() + .describe( + 'Parsed utility-class tokens reachable statically from the className expression', + ), + cssInJs: CssInJsSourceLocationSchema.optional().describe( + 'Source-block location for the CSS-in-JS declaration backing this element', + ), +}); + /** * Framework-specific component metadata */ @@ -73,6 +153,9 @@ export const ManifestEntrySchema = z.object({ .string() .optional() .describe('xxhash64 hash of file content at transform time (16 hex chars)'), + styleSource: StyleSourceSchema.optional().describe( + 'Build-time style attribution (className tokens + CSS-in-JS source-block location)', + ), }); export const ManifestMetadataSchema = z.object({ @@ -107,6 +190,8 @@ export const ManifestIndexSchema = z.object({ export type SourcePosition = z.infer; export type StyleInfo = z.infer; export type ComponentMetadata = z.infer; +export type CssInJsSourceLocation = z.infer; +export type StyleSource = z.infer; export type ManifestEntryId = z.infer; export type ManifestEntry = z.infer; From fe942f0cf6eb295864205c54b0bfe0d6be9a3596 Mon Sep 17 00:00:00 2001 From: "Domscribe Staff SWE (bot)" Date: Sun, 7 Jun 2026 06:34:34 -0700 Subject: [PATCH 2/3] feat(transform): build-time style attribution behind captureStyles flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a parser-agnostic style extractor that piggybacks on the existing JSX AST visit: - `extractClassNameFromJSX` resolves className tokens through string literals, no-interpolation template literals, conditional/logical expressions, array/object expressions, and clsx/cn/tw/classNames/twMerge/ cva helpers (incl. member-expression helpers like `utils.cn`). Unknown helpers and bare identifiers yield an empty token set rather than guessing — the agent can read source if static reasoning fails. - `collectCssInJsDeclarations` scans top-level `const X = styled.foo\`\``, `styled(Y)\`\``, and emotion `css\`\`` declarations and records source location + verbatim block text (4 KB cap). Library is inferred from the imported module specifier; no theme or config resolution at transform time (per RFC 0001). - The injector calls both on the same AST pass when `captureStyles` is on, resolves the JSX tag name to its binding via `resolveTagToBindingName`, and attaches an optional `styleSource` to each manifest entry. `captureStyles` defaults off in v0.x per RFC 0001 reversibility plan and is plumbed through `InjectorOptions`, `InjectorRegistry`, Vite, webpack, and turbopack plugins. Tests: - 33-case real-world corpus under `test/className-corpus/` exercising every pattern the RFC review flagged (literals, helpers, templates, conditionals, spread, CSS-in-JS). - End-to-end injector spec runs the real Babel parser with `captureStyles: true` to verify className tokens and styled-component source-block linkage land on manifest entries. Co-Authored-By: Claude Opus 4.7 --- .../src/core/injector.style-source.spec.ts | 145 ++++ .../domscribe-transform/src/core/injector.ts | 32 +- .../src/core/style-extractor.ts | 670 ++++++++++++++++++ .../domscribe-transform/src/core/types.ts | 13 + .../src/plugins/turbopack/turbopack.loader.ts | 7 +- .../src/plugins/turbopack/types.ts | 8 + .../src/plugins/vite/types.ts | 12 + .../src/plugins/vite/vite.plugin.spec.ts | 1 + .../src/plugins/vite/vite.plugin.ts | 6 +- .../src/plugins/webpack/types.ts | 8 + .../plugins/webpack/webpack.plugin.spec.ts | 2 +- .../src/plugins/webpack/webpack.plugin.ts | 4 + .../className-extractor.spec.ts | 435 ++++++++++++ .../domscribe-transform/tsconfig.spec.json | 6 +- packages/domscribe-transform/vite.config.ts | 5 +- 15 files changed, 1347 insertions(+), 7 deletions(-) create mode 100644 packages/domscribe-transform/src/core/injector.style-source.spec.ts create mode 100644 packages/domscribe-transform/src/core/style-extractor.ts create mode 100644 packages/domscribe-transform/test/className-corpus/className-extractor.spec.ts diff --git a/packages/domscribe-transform/src/core/injector.style-source.spec.ts b/packages/domscribe-transform/src/core/injector.style-source.spec.ts new file mode 100644 index 0000000..4a574fe --- /dev/null +++ b/packages/domscribe-transform/src/core/injector.style-source.spec.ts @@ -0,0 +1,145 @@ +/** + * End-to-end integration tests for the build-time `styleSource` capture + * pipeline (RFC 0001). + * + * These tests drive the real `BabelParser` through the injector so the full + * AST → manifest path is exercised. The `captureStyles` flag is the only + * thing differentiating these from the existing injector unit tests; with + * the flag off (default), no `styleSource` is attached and behavior matches + * the v0 baseline. + * + * @module @domscribe/transform/core/injector.style-source.spec + */ +import { describe, expect, it, vi } from 'vitest'; +import { DomscribeInjector } from './injector.js'; +import { BabelParser } from '../parsers/babel/babel.parser.js'; +import type { IDGenerator } from '@domscribe/manifest'; + +function createIdGenerator(): IDGenerator { + let counter = 0; + return { + initialize: vi.fn().mockResolvedValue(undefined), + getStableId: vi.fn().mockImplementation(() => `id_${counter++}`), + getFileHash: vi.fn().mockReturnValue('deadbeefcafebabe'), + saveCache: vi.fn(), + }; +} + +describe('Injector with captureStyles flag', () => { + it('does not attach styleSource when the flag is off (default)', () => { + const parser = new BabelParser(); + const injector = new DomscribeInjector(parser, createIdGenerator()); + + const source = 'const App = () =>
;'; + const result = injector.inject(source, { sourceFile: '/src/App.tsx' }); + + expect(result.manifestEntries).toHaveLength(1); + expect(result.manifestEntries[0].styleSource).toBeUndefined(); + }); + + it('attaches className tokens to manifest entries when the flag is on', () => { + const parser = new BabelParser(); + const injector = new DomscribeInjector(parser, createIdGenerator(), { + captureStyles: true, + }); + + const source = 'const App = () =>
;'; + const result = injector.inject(source, { sourceFile: '/src/App.tsx' }); + + expect(result.manifestEntries).toHaveLength(1); + const styleSource = result.manifestEntries[0].styleSource; + expect(styleSource).toBeDefined(); + expect(styleSource?.className).toBe('p-4 bg-blue-500'); + expect(styleSource?.classes).toEqual(['p-4', 'bg-blue-500']); + expect(styleSource?.cssInJs).toBeUndefined(); + }); + + it('walks clsx helpers and emits token union from all static branches', () => { + const parser = new BabelParser(); + const injector = new DomscribeInjector(parser, createIdGenerator(), { + captureStyles: true, + }); + + const source = ` + const App = (props) => +