Skip to content

Commit 27fc379

Browse files
NarratorDomscribe Staff SWE (bot)claude
authored
feat: build-time component-style attribution (RFC 0001 — Task A) (#49)
* feat(core): add StyleSource + ComponentStyles schemas; bump ANNOTATION_SCHEMA_VERSION to 2 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 <noreply@anthropic.com> * feat(transform): build-time style attribution behind captureStyles flag 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 <noreply@anthropic.com> * feat(relay): surface styleSource through resolve + manifest.query MCP tools `ManifestEntrySchema` now carries an optional `styleSource`, so `manifest.query` returns it automatically via the existing schema-driven output. `resolve` extracts individual fields and previously dropped unknown ones — extended to forward `styleSource` alongside `file`/ `start`/`end`/`componentName`/`tagName`. Tool descriptions are updated so agents know they can request styling context via the static `styleSource` (build-time className tokens + CSS- in-JS source-block location) before reaching for the runtime `query.bySource` path. Description text deliberately conditions on "when build-time style capture is enabled" — the field is absent when the transform was run with the default `captureStyles: false`. Storage-layer specs are updated to reference `ANNOTATION_SCHEMA_VERSION` rather than hard-coding `1`, so the v2 bump in @domscribe/core doesn't silently regress the on-read migration assertions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Domscribe Staff SWE (bot) <staff-swe-bot@domscribe.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d15c077 commit 27fc379

23 files changed

Lines changed: 1638 additions & 20 deletions

packages/domscribe-core/src/lib/migrations/annotation-migrations.spec.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,16 @@ describe('migrateAnnotation', () => {
5656
expect(result.metadata.schemaVersion).toBe(ANNOTATION_SCHEMA_VERSION);
5757
});
5858

59-
it('should default to version 1 when metadata has no schemaVersion', () => {
60-
// With ANNOTATION_SCHEMA_VERSION === 1 and no field, readVersion returns 1.
61-
// Since 1 === ANNOTATION_SCHEMA_VERSION, no migration steps run — it just stamps.
59+
it('should walk legacy (unversioned) data forward to the current version', () => {
60+
// readVersion defaults missing metadata.schemaVersion to 1, so a legacy
61+
// annotation is migrated through every registered step up to the current
62+
// version and then stamped.
6263
const raw = buildRawAnnotation();
6364
delete (raw['metadata'] as Record<string, unknown>)['schemaVersion'];
6465

6566
const result = migrateAnnotation(raw);
6667

67-
expect(result.metadata.schemaVersion).toBe(1);
68+
expect(result.metadata.schemaVersion).toBe(ANNOTATION_SCHEMA_VERSION);
6869
});
6970

7071
it('should default to version 1 when metadata is missing entirely', () => {
@@ -102,4 +103,42 @@ describe('migrateAnnotation', () => {
102103
expect(result.context.pageUrl).toBe('http://localhost:3000');
103104
expect(result.context.viewport).toEqual({ width: 1920, height: 1080 });
104105
});
106+
107+
it('should migrate a v1 annotation up to v2 (additive, no field rewrite)', () => {
108+
// Simulates a v1 annotation persisted before RFC 0001 schema bump.
109+
// The v1 → v2 step is purely additive (componentStyles + styleSource
110+
// are new optional fields) — the migration must not rewrite or delete
111+
// any existing payload data.
112+
const raw = buildRawAnnotation({
113+
metadata: { schemaVersion: 1 },
114+
context: {
115+
pageUrl: 'http://localhost:3000',
116+
pageTitle: 'Test',
117+
viewport: { width: 1920, height: 1080 },
118+
userAgent: 'test-agent',
119+
runtimeContext: {
120+
componentProps: { foo: 'bar' },
121+
},
122+
},
123+
});
124+
125+
const result = migrateAnnotation(raw);
126+
127+
expect(result.metadata.schemaVersion).toBe(ANNOTATION_SCHEMA_VERSION);
128+
expect(result.context.runtimeContext).toEqual({
129+
componentProps: { foo: 'bar' },
130+
});
131+
});
132+
133+
it('should leave v1 payloads structurally untouched (forward-compat for v1-pinned clients)', () => {
134+
// Forward-compat guarantee for annotation-process.tool: a v1-pinned
135+
// downstream consumer reading a v2-migrated annotation sees exactly the
136+
// v1 fields it already understood. The new componentStyles slot is
137+
// optional and is absent (`undefined`) on migrated v1 payloads.
138+
const raw = buildRawAnnotation({ metadata: { schemaVersion: 1 } });
139+
140+
const result = migrateAnnotation(raw);
141+
142+
expect(result.context.runtimeContext).toBeUndefined();
143+
});
105144
});

packages/domscribe-core/src/lib/migrations/annotation-migrations.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,22 @@ import {
1616
/**
1717
* Registry of migration functions keyed by the version they migrate FROM.
1818
* e.g. migrationSteps[1] migrates v1 → v2.
19-
*
20-
* Currently empty — only v1 exists. When a v2 schema is introduced, add:
21-
* migrationSteps[1] = (data: Record<string, unknown>) => { … mutate … };
2219
*/
2320
const migrationSteps: Record<number, (data: Record<string, unknown>) => void> =
24-
{};
21+
{
22+
/**
23+
* v1 → v2: additive only (per RFC 0001).
24+
*
25+
* v2 adds optional `runtimeContext.componentStyles` and optional
26+
* `manifestSnapshot[].styleSource`. Both are optional and absent on v1
27+
* payloads, so no field rewriting is required — the migration step
28+
* exists purely to satisfy the version-walk contract and to let v1
29+
* annotations be stamped as v2 on next write without throwing.
30+
*/
31+
1: () => {
32+
// No-op: v1 → v2 is purely additive.
33+
},
34+
};
2535

2636
/**
2737
* Read `metadata.schemaVersion` from raw JSON, defaulting to 1 for

packages/domscribe-core/src/lib/types/annotation.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,46 @@ export const EnvironmentSchema = z.object({
5454
packageManager: z.string().optional().describe('Package manager used'),
5555
});
5656

57+
/**
58+
* Runtime style snapshot for the annotated element.
59+
*
60+
* Captured by `@domscribe/runtime` at interaction time when
61+
* `domscribe.config.captureStyles` is enabled. Two slots:
62+
*
63+
* - `computed`: a fixed allowlist of computed-style properties (≤32 entries
64+
* covering layout, spacing, typography, visual, and positioning). This is
65+
* the ground truth for "what the user sees" — survives conditional class
66+
* names, responsive variants, and runtime theme switches that build-time
67+
* attribution alone cannot resolve.
68+
* - `customProperties`: resolved CSS custom properties (`--*` vars) on the
69+
* element and its ancestors up to `:root`. Lets the agent recover the
70+
* design-system token boundary without re-resolving Tailwind config or a
71+
* styled-components theme.
72+
*
73+
* Both fields are optional. Capture is best-effort and respects the
74+
* existing per-element serialization budget (≤4 KB).
75+
*/
76+
export const ComponentStylesSchema = z.object({
77+
computed: z
78+
.record(z.string(), z.string())
79+
.optional()
80+
.describe(
81+
'Subset of computed CSS properties for the element, drawn from a fixed ≤32-property allowlist',
82+
),
83+
customProperties: z
84+
.record(z.string(), z.string())
85+
.optional()
86+
.describe(
87+
'Resolved CSS custom properties (--* variables) inherited by the element from itself up to :root',
88+
),
89+
});
90+
5791
export const RuntimeContextSchema = z.object({
5892
componentProps: z.unknown().optional().describe('Component props snapshot'),
5993
componentState: z.unknown().optional().describe('Component state snapshot'),
94+
componentStyles: ComponentStylesSchema.optional().describe(
95+
'Computed-style allowlist + resolved CSS custom properties (captured when domscribe.config.captureStyles is enabled)',
96+
),
6097
eventFlow: z.unknown().optional().describe('Event flow breadcrumbs'),
6198
performance: z.unknown().optional().describe('Performance metrics'),
6299
});
@@ -68,8 +105,14 @@ export const AnnotationIdSchema = z
68105

69106
/**
70107
* Current annotation schema version. Bump when the Annotation shape changes.
108+
*
109+
* Version history:
110+
* - v1: initial schema.
111+
* - v2: added optional `runtimeContext.componentStyles` (computed-style
112+
* allowlist + CSS custom properties) and optional `styleSource` on
113+
* embedded `manifestSnapshot` entries, per RFC 0001.
71114
*/
72-
export const ANNOTATION_SCHEMA_VERSION = 1;
115+
export const ANNOTATION_SCHEMA_VERSION = 2;
73116

74117
export const AnnotationMetadataSchema = z.object({
75118
id: AnnotationIdSchema,
@@ -196,3 +239,57 @@ export type BoundingRect = z.infer<typeof BoundingRectSchema>;
196239
export type Viewport = z.infer<typeof ViewportSchema>;
197240
export type Environment = z.infer<typeof EnvironmentSchema>;
198241
export type RuntimeContext = z.infer<typeof RuntimeContextSchema>;
242+
export type ComponentStyles = z.infer<typeof ComponentStylesSchema>;
243+
244+
/**
245+
* Allowlist of computed-style property names captured by the runtime
246+
* `StyleCapturer` (≤32 entries). Lives in `@domscribe/core` so the
247+
* runtime, the relay, and downstream tools agree on the contract.
248+
*
249+
* Covers layout, spacing, typography, visual, and positioning — chosen to
250+
* be a useful styling-debug subset without bloating the per-element
251+
* serialization budget.
252+
*/
253+
export const COMPONENT_STYLES_ALLOWLIST = [
254+
// Layout
255+
'display',
256+
'position',
257+
'flex-direction',
258+
'flex-wrap',
259+
'align-items',
260+
'justify-content',
261+
'gap',
262+
'grid-template-columns',
263+
'grid-template-rows',
264+
// Spacing
265+
'margin',
266+
'padding',
267+
'width',
268+
'height',
269+
'min-width',
270+
'min-height',
271+
'max-width',
272+
'max-height',
273+
// Typography
274+
'font-family',
275+
'font-size',
276+
'font-weight',
277+
'line-height',
278+
'letter-spacing',
279+
'text-align',
280+
'color',
281+
// Visual
282+
'background-color',
283+
'border',
284+
'border-radius',
285+
'box-shadow',
286+
'opacity',
287+
// Positioning
288+
'top',
289+
'right',
290+
'bottom',
291+
'left',
292+
] as const satisfies readonly string[];
293+
294+
export type ComponentStylesAllowlist =
295+
(typeof COMPONENT_STYLES_ALLOWLIST)[number];

packages/domscribe-core/src/lib/types/manifest.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,86 @@ export const StyleInfoSchema = z.object({
2525
inline: z.string().optional().describe('Inline style content'),
2626
});
2727

28+
/**
29+
* Source-block location for a CSS-in-JS declaration (styled-components or emotion).
30+
*
31+
* Recorded at transform time for elements rendered by a locally-declared
32+
* styled-component (e.g. `const StyledDiv = styled.div\`...\`;` used as
33+
* `<StyledDiv>`). Lets agents jump from the runtime DOM back to the
34+
* authoring source without re-deriving the binding from a hashed class name.
35+
*/
36+
export const CssInJsSourceLocationSchema = z.object({
37+
file: z
38+
.string()
39+
.describe('Source file path containing the styled declaration'),
40+
line: z
41+
.number()
42+
.int()
43+
.nonnegative()
44+
.describe('Line number of the styled declaration (1-indexed)'),
45+
column: z
46+
.number()
47+
.int()
48+
.nonnegative()
49+
.describe('Column number of the styled declaration (0-indexed)'),
50+
blockText: z
51+
.string()
52+
.describe(
53+
'Verbatim source text of the styled template literal or css block (truncated to 4 KB)',
54+
),
55+
library: z
56+
.enum(['styled-components', 'emotion', 'unknown'])
57+
.optional()
58+
.describe('Detected CSS-in-JS library, when statically inferable'),
59+
kind: z
60+
.enum(['styled-tag', 'styled-call', 'css-template'])
61+
.optional()
62+
.describe(
63+
'AST pattern that matched: styled.div (styled-tag), styled(Component) (styled-call), or css`...` (css-template)',
64+
),
65+
});
66+
67+
/**
68+
* Build-time style attribution for a manifest entry.
69+
*
70+
* Captured by `@domscribe/transform` on the same AST visit that injects
71+
* `data-ds` attributes. Provides the agent with a static link from the
72+
* rendered element to the styling source that produced it, so styling-shaped
73+
* annotations can be resolved without a runtime round trip for the
74+
* source-attribution step.
75+
*
76+
* @remarks
77+
* - `className` is the raw value of the JSX `className` attribute when it is
78+
* statically extractable (string literal or single template literal with
79+
* no interpolations). Computed expressions yield `undefined`.
80+
* - `classes` is the array of utility-class tokens statically derivable from
81+
* the `className` expression, including tokens reachable through `clsx`,
82+
* `cn`, `tw`, conditional expressions, and string-only template literals.
83+
* Tokens reachable only through runtime values are silently dropped.
84+
* - `cssInJs` is set when the JSX tag references a locally-declared
85+
* styled-component in the same source file.
86+
*
87+
* All fields are optional: a partial attribution is preferable to a throw,
88+
* and absence is the correct signal to fall back to reading the source.
89+
*/
90+
export const StyleSourceSchema = z.object({
91+
className: z
92+
.string()
93+
.optional()
94+
.describe(
95+
'Statically-resolvable className literal as it appears in source; undefined when className is fully computed at runtime',
96+
),
97+
classes: z
98+
.array(z.string())
99+
.optional()
100+
.describe(
101+
'Parsed utility-class tokens reachable statically from the className expression',
102+
),
103+
cssInJs: CssInJsSourceLocationSchema.optional().describe(
104+
'Source-block location for the CSS-in-JS declaration backing this element',
105+
),
106+
});
107+
28108
/**
29109
* Framework-specific component metadata
30110
*/
@@ -73,6 +153,9 @@ export const ManifestEntrySchema = z.object({
73153
.string()
74154
.optional()
75155
.describe('xxhash64 hash of file content at transform time (16 hex chars)'),
156+
styleSource: StyleSourceSchema.optional().describe(
157+
'Build-time style attribution (className tokens + CSS-in-JS source-block location)',
158+
),
76159
});
77160

78161
export const ManifestMetadataSchema = z.object({
@@ -107,6 +190,8 @@ export const ManifestIndexSchema = z.object({
107190
export type SourcePosition = z.infer<typeof SourcePositionSchema>;
108191
export type StyleInfo = z.infer<typeof StyleInfoSchema>;
109192
export type ComponentMetadata = z.infer<typeof ComponentMetadataSchema>;
193+
export type CssInJsSourceLocation = z.infer<typeof CssInJsSourceLocationSchema>;
194+
export type StyleSource = z.infer<typeof StyleSourceSchema>;
110195

111196
export type ManifestEntryId = z.infer<typeof ManifestEntryIdSchema>;
112197
export type ManifestEntry = z.infer<typeof ManifestEntrySchema>;

packages/domscribe-relay/src/mcp/tools/manifest-query.tool.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ export class ManifestQueryTool implements McpToolDefinition<
5151
'Query the Domscribe manifest to find UI elements by file, component, or tag name. ' +
5252
'Use to explore what elements exist in a file ("what\'s in Button.tsx?"), ' +
5353
'find all instances of a component ("find all Modal elements"), ' +
54-
'or list elements by tag ("show all input elements").';
54+
'or list elements by tag ("show all input elements"). ' +
55+
'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.';
5556
inputSchema = ManifestQueryToolInputSchema;
5657
outputSchema = ManifestQueryToolOutputSchema;
5758

packages/domscribe-relay/src/mcp/tools/resolve.tool.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,52 @@ describe('ResolveTool', () => {
3838
end: { line: 10, column: 30 },
3939
componentName: 'Button',
4040
tagName: 'button',
41+
styleSource: undefined,
4142
error: undefined,
4243
});
4344
expect(JSON.parse(getResultText(result))).toEqual(
4445
result.structuredContent,
4546
);
4647
});
4748

49+
it('should forward styleSource when present on the manifest entry', async () => {
50+
// Build-time style capture path (RFC 0001): when the transform was
51+
// run with captureStyles enabled, the entry carries className tokens
52+
// and any CSS-in-JS source-block info. The tool must pass it through.
53+
const mockClient = createMockRelayClient({
54+
resolveManifestEntry: vi.fn().mockResolvedValue({
55+
success: true,
56+
entry: {
57+
file: 'src/components/Button.tsx',
58+
start: { line: 10, column: 5 },
59+
end: { line: 10, column: 30 },
60+
componentName: 'Button',
61+
tagName: 'button',
62+
styleSource: {
63+
className: 'px-4 py-2 bg-blue-500',
64+
classes: ['px-4', 'py-2', 'bg-blue-500'],
65+
},
66+
},
67+
}),
68+
});
69+
const tool = new ResolveTool(mockClient);
70+
71+
const result: CallToolResult = await tool.toolCallback({
72+
entryId: 'ds_abc123',
73+
});
74+
75+
expect(
76+
(
77+
result.structuredContent as {
78+
styleSource?: { classes?: string[]; className?: string };
79+
}
80+
)?.styleSource,
81+
).toEqual({
82+
className: 'px-4 py-2 bg-blue-500',
83+
classes: ['px-4', 'py-2', 'bg-blue-500'],
84+
});
85+
});
86+
4887
it('should handle not-found entries', async () => {
4988
// Arrange
5089
const mockClient = createMockRelayClient({
@@ -68,6 +107,7 @@ describe('ResolveTool', () => {
68107
end: undefined,
69108
componentName: undefined,
70109
tagName: undefined,
110+
styleSource: undefined,
71111
error: 'Entry not found',
72112
});
73113
});

0 commit comments

Comments
 (0)