Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)['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', () => {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => { … mutate … };
*/
const migrationSteps: Record<number, (data: Record<string, unknown>) => 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
Expand Down
99 changes: 98 additions & 1 deletion packages/domscribe-core/src/lib/types/annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});
Expand All @@ -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,
Expand Down Expand Up @@ -196,3 +239,57 @@ export type BoundingRect = z.infer<typeof BoundingRectSchema>;
export type Viewport = z.infer<typeof ViewportSchema>;
export type Environment = z.infer<typeof EnvironmentSchema>;
export type RuntimeContext = z.infer<typeof RuntimeContextSchema>;
export type ComponentStyles = z.infer<typeof ComponentStylesSchema>;

/**
* 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];
85 changes: 85 additions & 0 deletions packages/domscribe-core/src/lib/types/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<StyledDiv>`). 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
*/
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -107,6 +190,8 @@ export const ManifestIndexSchema = z.object({
export type SourcePosition = z.infer<typeof SourcePositionSchema>;
export type StyleInfo = z.infer<typeof StyleInfoSchema>;
export type ComponentMetadata = z.infer<typeof ComponentMetadataSchema>;
export type CssInJsSourceLocation = z.infer<typeof CssInJsSourceLocationSchema>;
export type StyleSource = z.infer<typeof StyleSourceSchema>;

export type ManifestEntryId = z.infer<typeof ManifestEntryIdSchema>;
export type ManifestEntry = z.infer<typeof ManifestEntrySchema>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export class ManifestQueryTool implements McpToolDefinition<
'Query the Domscribe manifest to find UI elements by file, component, or tag name. ' +
'Use to explore what elements exist in a file ("what\'s in Button.tsx?"), ' +
'find all instances of a component ("find all Modal elements"), ' +
'or list elements by tag ("show all input elements").';
'or list elements by tag ("show all input elements"). ' +
'Entries include `styleSource` when build-time style capture is enabled (statically-resolvable className tokens + CSS-in-JS source-block location) — useful when triaging a styling annotation before requesting live runtime context.';
inputSchema = ManifestQueryToolInputSchema;
outputSchema = ManifestQueryToolOutputSchema;

Expand Down
40 changes: 40 additions & 0 deletions packages/domscribe-relay/src/mcp/tools/resolve.tool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,52 @@ describe('ResolveTool', () => {
end: { line: 10, column: 30 },
componentName: 'Button',
tagName: 'button',
styleSource: undefined,
error: undefined,
});
expect(JSON.parse(getResultText(result))).toEqual(
result.structuredContent,
);
});

it('should forward styleSource when present on the manifest entry', async () => {
// Build-time style capture path (RFC 0001): when the transform was
// run with captureStyles enabled, the entry carries className tokens
// and any CSS-in-JS source-block info. The tool must pass it through.
const mockClient = createMockRelayClient({
resolveManifestEntry: vi.fn().mockResolvedValue({
success: true,
entry: {
file: 'src/components/Button.tsx',
start: { line: 10, column: 5 },
end: { line: 10, column: 30 },
componentName: 'Button',
tagName: 'button',
styleSource: {
className: 'px-4 py-2 bg-blue-500',
classes: ['px-4', 'py-2', 'bg-blue-500'],
},
},
}),
});
const tool = new ResolveTool(mockClient);

const result: CallToolResult = await tool.toolCallback({
entryId: 'ds_abc123',
});

expect(
(
result.structuredContent as {
styleSource?: { classes?: string[]; className?: string };
}
)?.styleSource,
).toEqual({
className: 'px-4 py-2 bg-blue-500',
classes: ['px-4', 'py-2', 'bg-blue-500'],
});
});

it('should handle not-found entries', async () => {
// Arrange
const mockClient = createMockRelayClient({
Expand All @@ -68,6 +107,7 @@ describe('ResolveTool', () => {
end: undefined,
componentName: undefined,
tagName: undefined,
styleSource: undefined,
error: 'Entry not found',
});
});
Expand Down
Loading
Loading