Skip to content

Commit e24d43e

Browse files
ryandmonkclaude
andcommitted
feat(phase-16d): normalization layer + demo surface docs
Phase 16D — Cross-Surface Drift Normalization: - normalize.ts: deterministic pre-comparison normalization (filter design-only fields, alias prop names, deduplicate) - analyze.ts: wire normalization into cross-surface drift pipeline - crossSurfaceDrift.ts (shared): export NormalizationConfig type - normalize.test.ts + analyze.test.ts: test coverage for normalization layer Docs: - architecture-reference.md: Phase 16D entry, normalization layer section - adapter-model.md: normalization layer summary - README.md: update demo-app project structure (SDS-aligned Button/Input/Card, markers consolidated in App.tsx only) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0d8a3d6 commit e24d43e

8 files changed

Lines changed: 785 additions & 7 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,13 @@ aesthetic-function/
216216
│ ├── server/ # WebSocket/HTTP relay, audit logging
217217
│ ├── figma-plugin/ # Figma sandbox plugin (mutation executor)
218218
│ └── cli/ # `af` CLI control surface
219-
├── demo-app/ # Sample React app with @figma markers + Storybook
220-
│ └── .storybook/ # Storybook config (addon-mcp enabled)
219+
├── demo-app/ # Demo React app for AF reconciliation and drift demos
220+
│ ├── src/
221+
│ │ ├── App.tsx # Sign-in composition panel — sole source of @figma markers
222+
│ │ ├── Button.tsx # SDS-faithful Button (Primary variant, no markers)
223+
│ │ ├── Input.tsx # SDS-faithful Input Field (no markers)
224+
│ │ └── Card.tsx # SDS-faithful Card container (no markers)
225+
│ └── .storybook/ # Storybook config (addon-mcp enabled, Components/Button|Input|Card)
221226
├── docs/
222227
│ └── architecture-reference.md # Full internal reference
223228
├── .github/

docs/adapter-model.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ The `af design drift` command compares component data across available surfaces:
9191

9292
Drift findings use **corroboration rules** to filter noise: a story-derived variant is only reported if (1) the variant axis maps to a real prop, (2) the value appears in the prop's type definition. Findings carry `confidence: 'high'` (constrained union match) or `'low'` (unconstrained type like `string`).
9393

94+
#### Normalization Layer (Phase 16D)
95+
96+
Before comparison, surface snapshots pass through a **deterministic normalization layer** (`crossSurfaceDrift/normalize.ts`) that:
97+
98+
1. **Filters design-only fields** from Figma (e.g., `fills`, `cornerRadius`, `width`, `height`) that have no API-level counterpart
99+
2. **Normalizes prop aliases** across surfaces (e.g., Figma `State` → canonical `variant`, Figma `text` → canonical `label`)
100+
3. **Deduplicates** props that collide after renaming
101+
102+
This eliminates false-positive drift caused by naming differences between surfaces. The normalization config is deterministic and configurable via `NormalizationConfig` — see [architecture-reference.md](architecture-reference.md) for full details.
103+
94104
## Detailed Reference
95105

96106
The full adapter model, including MCP transport configuration, tool policies, and Phase 16A/16B/16C implementation details, is documented in [architecture-reference.md](architecture-reference.md).

docs/architecture-reference.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,7 @@ The system follows a **three-legged stool** design with strict runtime boundarie
535535
| **Phase 16A.1** | Surface Classification Metadata (adapter taxonomy) ||
536536
| **Phase 16B** | Figma Console MCP Adapter (read-only) ||
537537
| **Phase 16C** | Storybook MCP Adapter + Cross-Surface Drift Analysis ||
538+
| **Phase 16D** | Cross-Surface Drift Normalization Layer ||
538539

539540
### Not Implemented Yet
540541

@@ -558,7 +559,7 @@ The reconciliation system is **feature-complete through Phase 14F**. Key capabil
558559
- **Unified Reconcile CLI (14A–14F)**: `figma:reconcile` entry point, profiles (local/record/ci), bundle artifacts, GitHub Actions matrix workflow, multi-source discovery
559560
- **Configuration & Profiles (15A–15B)**: `af.config.json`, named policy profiles (designer-first, code-first, balanced, strict-review)
560561
- **CLI & Inspector (15C–15D)**: Unified `af` CLI (control surface, not runtime), artifact listing/inspection/trace
561-
- **Design Adapters (16A–16C)**: Read-only design adapter interface, Figma Console MCP adapter, Storybook MCP adapter, surface classification metadata, cross-surface drift analysis
562+
- **Design Adapters (16A–16D)**: Read-only design adapter interface, Figma Console MCP adapter, Storybook MCP adapter, surface classification metadata, cross-surface drift analysis, drift normalization layer
562563

563564
Echo suppression prevents feedback loops when AST writes trigger file save events.
564565

@@ -5589,6 +5590,62 @@ Props are extracted from `reactDocgen` metadata via `extractProps()` in `storybo
55895590

55905591
`demo-app/src/DemoButton.tsx` and `demo-app/src/stories/DemoButton.stories.tsx` provide a minimal component with typed props and matching Storybook stories for verifying drift analysis end-to-end.
55915592

5593+
### Cross-Surface Drift: Normalization Layer (Phase 16D)
5594+
5595+
Different surfaces use different names for the same concepts, causing false-positive drift findings. Phase 16D adds a **deterministic, configurable normalization layer** that aligns equivalent concepts before comparison.
5596+
5597+
#### Problem
5598+
5599+
| Surface | Variant Axis | Text Prop | Layout Props |
5600+
|---------|-------------|-----------|-------------|
5601+
| Figma | `State` (VARIANT) | `text` (TEXT) | `fills`, `cornerRadius`, `width`, `height` |
5602+
| Storybook | `variant` | `label` | (none) |
5603+
| Code | `variant` | `label` | (none) |
5604+
5605+
Without normalization, `State` vs `variant` and `text` vs `label` appear as false drift, and Figma-only layout properties generate noise.
5606+
5607+
#### Solution
5608+
5609+
`normalize.ts` in `crossSurfaceDrift/` applies three passes to each surface snapshot **before** comparison:
5610+
5611+
1. **Design-only filtering** (Figma only): Removes layout/visual properties (`fills`, `cornerRadius`, `width`, `height`, `padding`, `gap`, `fontSize`, `fontWeight`, `textContent`) that have no API-level counterpart
5612+
2. **Alias normalization** (all surfaces): Renames equivalent prop names to a canonical form using a configurable alias map
5613+
3. **Deduplication**: Merges props that collide after renaming
5614+
5615+
Default alias rules:
5616+
5617+
| Canonical | Aliases |
5618+
|-----------|---------|
5619+
| `variant` | `state`, `variant` |
5620+
| `label` | `text`, `label` |
5621+
5622+
#### Configuration
5623+
5624+
Normalization is configured via `NormalizationConfig` (defined in `@aesthetic-function/shared/crossSurfaceDrift`):
5625+
5626+
```typescript
5627+
interface NormalizationConfig {
5628+
propAliases: Array<{ canonical: string; aliases: string[] }>;
5629+
designOnlyFields: { names: string[]; strategy: 'exclude' | 'tag' };
5630+
}
5631+
```
5632+
5633+
The default config (`DEFAULT_NORMALIZATION_CONFIG` in `normalize.ts`) handles the DemoButton demo. Custom configs can be passed via `DriftAnalysisOptions.normalizationConfig` for per-project overrides.
5634+
5635+
#### Traceability
5636+
5637+
Normalization is fully explainable:
5638+
5639+
- **`SurfaceProp.normalizedFrom`**: When a prop is renamed (e.g., `State``variant`), the original name is preserved in `normalizedFrom`
5640+
- **`CrossSurfaceDriftReport.normalization`**: The report includes metadata listing all applied alias rules and excluded design-only props, with surface attribution
5641+
5642+
#### Design Decisions
5643+
5644+
- Normalization runs **before** comparison, transforming snapshots rather than patching findings. The existing `comparePropInventory` and `compareVariantCoverage` functions require zero changes.
5645+
- Design-only filtering applies only to the Figma surface — if Code/Storybook happens to have a prop named `fills`, it is not filtered.
5646+
- Alias matching is case-insensitive but the canonical name is always lowercase.
5647+
- The `strategy: 'tag'` option (alternative to `'exclude'`) is defined but not yet implemented — it would keep design-only props but mark them for separate "visual drift" reporting.
5648+
55925649
---
55935650

55945651
## Protocol Version

packages/shared/src/crossSurfaceDrift.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export interface CrossSurfaceDriftReport {
4141

4242
/** When the analysis was performed */
4343
analyzedAt: string;
44+
45+
/** Normalization metadata showing what was renamed/excluded before comparison */
46+
normalization?: NormalizationMetadata;
4447
}
4548

4649
// =============================================================================
@@ -74,6 +77,8 @@ export interface SurfaceProp {
7477
name: string;
7578
type?: string;
7679
values?: string[];
80+
/** Original name before alias normalization (set only when name was changed) */
81+
normalizedFrom?: string;
7782
}
7883

7984
// =============================================================================
@@ -144,4 +149,53 @@ export interface DriftAnalysisOptions {
144149

145150
/** Surfaces that were queried by the caller (used to distinguish "not checked" from "checked, not found") */
146151
queriedSurfaces?: ('figma' | 'storybook' | 'code')[];
152+
153+
/** Override the default normalization config (alias mappings + design-only filters) */
154+
normalizationConfig?: NormalizationConfig;
155+
}
156+
157+
// =============================================================================
158+
// NORMALIZATION
159+
// =============================================================================
160+
161+
/**
162+
* Configuration for the pre-comparison normalization layer.
163+
* Deterministic and configurable — no LLM or fuzzy matching.
164+
*/
165+
export interface NormalizationConfig {
166+
/** Alias groups: equivalent prop names across surfaces mapped to a canonical name */
167+
propAliases: Array<{
168+
/** The canonical name all surfaces will be normalized TO */
169+
canonical: string;
170+
/** Surface-specific names that map to this canonical name (matched case-insensitively) */
171+
aliases: string[];
172+
}>;
173+
174+
/** Figma-only layout/visual properties to filter from drift comparison */
175+
designOnlyFields: {
176+
/** Property names to treat as design-only (matched case-insensitively) */
177+
names: string[];
178+
/** 'exclude' removes from snapshot; 'tag' keeps but marks (future use) */
179+
strategy: 'exclude' | 'tag';
180+
};
181+
}
182+
183+
/**
184+
* Metadata about what normalization was applied before comparison.
185+
* Provides explainability/traceability for the drift report.
186+
*/
187+
export interface NormalizationMetadata {
188+
/** Prop names that were renamed via alias rules */
189+
appliedRules: Array<{
190+
originalName: string;
191+
canonicalName: string;
192+
surface: 'figma' | 'storybook' | 'code';
193+
}>;
194+
195+
/** Props excluded from comparison as design-only */
196+
excludedProps: Array<{
197+
name: string;
198+
surface: 'figma' | 'storybook' | 'code';
199+
reason: 'design-only';
200+
}>;
147201
}

packages/watcher/src/crossSurfaceDrift/__tests__/analyze.test.ts

Lines changed: 170 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -365,10 +365,12 @@ describe('figma componentPropertyDefinitions', () => {
365365
expect(report.surfaces.figma).toBeDefined();
366366
expect(report.surfaces.figma!.variants).toContain('Default');
367367
expect(report.surfaces.figma!.variants).toContain('Hover');
368-
const stateProp = report.surfaces.figma!.props.find(p => p.name === 'State');
369-
expect(stateProp).toBeDefined();
370-
expect(stateProp!.type).toBe('VARIANT');
371-
expect(stateProp!.values).toEqual(['Default', 'Hover']);
368+
// After normalization, "State" is renamed to canonical "variant"
369+
const variantProp = report.surfaces.figma!.props.find(p => p.name === 'variant');
370+
expect(variantProp).toBeDefined();
371+
expect(variantProp!.type).toBe('VARIANT');
372+
expect(variantProp!.values).toEqual(['Default', 'Hover']);
373+
expect(variantProp!.normalizedFrom).toBe('State');
372374
});
373375

374376
it('extracts TEXT property definitions into props', () => {
@@ -471,6 +473,170 @@ describe('storybook story name fallback', () => {
471473
});
472474
});
473475

476+
// =============================================================================
477+
// NORMALIZATION — FALSE POSITIVE ELIMINATION (Phase 16D)
478+
// =============================================================================
479+
480+
describe('normalization — DemoButton false positive elimination', () => {
481+
it('eliminates State/variant false positive (Figma "State" ↔ Code/Storybook "variant")', () => {
482+
const report = analyzeCrossSurfaceDrift(
483+
'DemoButton',
484+
makeFigmaComponent({
485+
name: 'DemoButton',
486+
type: 'component-set',
487+
variants: [],
488+
componentPropertyDefinitions: {
489+
State: {
490+
type: 'VARIANT',
491+
defaultValue: 'Default',
492+
variantOptions: ['Default', 'Hover'],
493+
},
494+
},
495+
}),
496+
makeStorybookComponent({
497+
name: 'DemoButton',
498+
props: [
499+
{ name: 'variant', type: "'Default' | 'Hover'", required: false },
500+
],
501+
stories: [
502+
{ id: 'demobutton--default', name: 'Default', variantAxes: { variant: 'Default' } },
503+
{ id: 'demobutton--hover', name: 'Hover', variantAxes: { variant: 'Hover' } },
504+
],
505+
}),
506+
{ props: ['variant'], variants: ['Default', 'Hover'] },
507+
{ queriedSurfaces: ['figma', 'storybook', 'code'] },
508+
);
509+
510+
// Should NOT have findings about "state" missing in Storybook/Code
511+
// or "variant" missing in Figma — they're the same concept
512+
const propFindings = report.findings.filter(f => f.field.startsWith('prop:'));
513+
const stateFindings = propFindings.filter(f => f.field.includes('state'));
514+
const variantFindings = propFindings.filter(f => f.field.includes('variant'));
515+
expect(stateFindings).toHaveLength(0);
516+
expect(variantFindings).toHaveLength(0);
517+
518+
// Figma snapshot should show "variant" (normalized from "State")
519+
const figmaVariantProp = report.surfaces.figma!.props.find(p => p.name === 'variant');
520+
expect(figmaVariantProp).toBeDefined();
521+
expect(figmaVariantProp!.normalizedFrom).toBe('State');
522+
});
523+
524+
it('eliminates text/label false positive (Figma "text" ↔ Code/Storybook "label")', () => {
525+
const report = analyzeCrossSurfaceDrift(
526+
'DemoButton',
527+
makeFigmaComponent({
528+
name: 'DemoButton',
529+
type: 'component-set',
530+
variants: [],
531+
componentPropertyDefinitions: {
532+
'text#12:34': {
533+
type: 'TEXT',
534+
defaultValue: 'Click me',
535+
},
536+
},
537+
}),
538+
makeStorybookComponent({
539+
name: 'DemoButton',
540+
props: [
541+
{ name: 'label', type: 'string', required: false },
542+
],
543+
stories: [],
544+
}),
545+
{ props: ['label'], variants: [] },
546+
{ queriedSurfaces: ['figma', 'storybook', 'code'] },
547+
);
548+
549+
// "text" cleaned to "text" by CPD handler, then normalized to "label"
550+
// Should NOT have findings about text/label mismatch
551+
const propFindings = report.findings.filter(f => f.field.startsWith('prop:'));
552+
const textFindings = propFindings.filter(f => f.field.includes('text'));
553+
const labelFindings = propFindings.filter(f =>
554+
f.field.includes('label') && (f.type === 'missing-in-figma' || f.type === 'missing-in-storybook'),
555+
);
556+
expect(textFindings).toHaveLength(0);
557+
expect(labelFindings).toHaveLength(0);
558+
});
559+
560+
it('suppresses design-only properties from Figma (fills, cornerRadius, width, height)', () => {
561+
const report = analyzeCrossSurfaceDrift(
562+
'DemoButton',
563+
makeFigmaComponent({
564+
name: 'DemoButton',
565+
type: 'component-set',
566+
variants: [],
567+
properties: {
568+
fills: ['#3B82F6'],
569+
cornerRadius: 8,
570+
width: 200,
571+
height: 48,
572+
},
573+
}),
574+
makeStorybookComponent({
575+
name: 'DemoButton',
576+
props: [
577+
{ name: 'variant', type: "'Default' | 'Hover'", required: false },
578+
],
579+
stories: [],
580+
}),
581+
{ props: ['variant'], variants: [] },
582+
{ queriedSurfaces: ['figma', 'storybook', 'code'] },
583+
);
584+
585+
// Should NOT have findings about fills, cornerRadius, width, height
586+
const layoutFindings = report.findings.filter(f =>
587+
f.field.includes('fills') ||
588+
f.field.includes('cornerradius') ||
589+
f.field.includes('width') ||
590+
f.field.includes('height'),
591+
);
592+
expect(layoutFindings).toHaveLength(0);
593+
594+
// Figma snapshot should not contain these props
595+
const figmaProps = report.surfaces.figma!.props.map(p => p.name.toLowerCase());
596+
expect(figmaProps).not.toContain('fills');
597+
expect(figmaProps).not.toContain('cornerradius');
598+
expect(figmaProps).not.toContain('width');
599+
expect(figmaProps).not.toContain('height');
600+
});
601+
602+
it('includes normalization metadata in the report', () => {
603+
const report = analyzeCrossSurfaceDrift(
604+
'DemoButton',
605+
makeFigmaComponent({
606+
name: 'DemoButton',
607+
type: 'component-set',
608+
variants: [],
609+
componentPropertyDefinitions: {
610+
State: {
611+
type: 'VARIANT',
612+
defaultValue: 'Default',
613+
variantOptions: ['Default', 'Hover'],
614+
},
615+
},
616+
properties: {
617+
fills: ['#3B82F6'],
618+
cornerRadius: 8,
619+
},
620+
}),
621+
null,
622+
null,
623+
);
624+
625+
expect(report.normalization).toBeDefined();
626+
627+
// "State" → "variant" should be in appliedRules
628+
const stateRule = report.normalization!.appliedRules.find(r => r.originalName === 'State');
629+
expect(stateRule).toBeDefined();
630+
expect(stateRule!.canonicalName).toBe('variant');
631+
expect(stateRule!.surface).toBe('figma');
632+
633+
// fills and cornerRadius should be in excludedProps
634+
const excluded = report.normalization!.excludedProps.map(e => e.name);
635+
expect(excluded).toContain('fills');
636+
expect(excluded).toContain('cornerRadius');
637+
});
638+
});
639+
474640
// =============================================================================
475641
// CROSS-SURFACE DRIFT WITH RICH METADATA
476642
// =============================================================================

0 commit comments

Comments
 (0)