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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ Available profiles: `designer-first`, `code-first`, `balanced`, `strict-review`.
| `af design pull` | Pull design data (tokens + components + styles) |
| `af design screenshot` | Capture design screenshot |
| `af design component [name]` | List or inspect components |
| `af design drift [name]` | Cross-surface drift analysis (Figma vs Storybook vs code) |
| `af design drift [name]` | Cross-surface drift analysis (Figma vs Storybook vs code vs dspack contract) |

## Project Structure

Expand Down
1 change: 1 addition & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ This repository implements an AI-driven Code → Design synchronization system.
- Storybook MCP adapter: `af design drift [component]` for cross-surface analysis
- Storybook adapter requires dev server running (`pnpm dev:storybook`)
- Cross-surface drift is a separate analysis pass — it does NOT modify reconciliation
- dspack contract surface (`af design drift --dspack <file>`) is read-only; AF never generates or modifies dspack files, and the contract surface is never registered as a DesignAdapter

## Design Token Rules
- Prefer semantic tokens over raw values
Expand Down
45 changes: 45 additions & 0 deletions docs/adapter-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ AF uses design adapters to read data from external design systems. Adapters are
| **Figma REST** | HTTP (Figma API) | Tokens, components, styles | 16A |
| **Figma Console MCP** | stdio / SSE / REST fallback | Screenshots, component inspection | 16B |
| **Storybook MCP** | StreamableHTTP / SSE / HTTP fallback | Component metadata, stories, props | 16C |
| **dspack Contract Surface** | Local file read | Declared components, props, enum variants | — |

## Safety Model

Expand Down Expand Up @@ -58,6 +59,50 @@ Same default-deny pattern as Figma Console MCP:

The adapter validates that the Storybook instance is React-based. Non-React frameworks (Vue, Angular, Svelte) cause `isAvailable()` to return `false` with an explicit error.

## Contract Surface (dspack)

The contract surface (`packages/watcher/src/contractSurface/`) lets
`af design drift` compare live surfaces against a **declared design-system
contract**: a [dspack](https://github.com/aestheticfunction/dspack) v0.1/v0.2
file committed to source control. Enable it with
`af design drift --dspack <file>` or `contract.dspackPath` in `af.config.json`.

### Why it is NOT a DesignAdapter

The contract surface deliberately does not implement the `DesignAdapter`
interface and is **never registered in the design adapter registry**:

- The drift CLI treats the registry's first available adapter as the *Figma*
surface; registering a contract loader there would let a static file be
mistaken for live design state.
- The `DesignAdapter` interface (styles, file data, screenshots) is the wrong
shape for a versioned document.

It still carries a `SurfaceMetadata` descriptor for classification:

| Dimension | Value |
|-----------|-------|
| Surface type | `contract` — declared contract artifact, not a live tool |
| Access mode | `read-only` |
| Authority role | `external-non-authoritative` |
| Stability | `canonical` — it *is* the declared canonical state |

### Behavior

- The file is Ajv-validated against the vendored dspack JSON Schemas
(`contractSurface/schema/`) at load time; invalid contracts are rejected
with instance paths, never repaired. Loader semantics mirror ds-mcp, the
dspack reference implementation.
- Comparison scope (first slice): component presence, prop inventory vs.
code, and enum-derived variant coverage vs. code and Figma.
- Direction semantics: the contract declaring something a live surface lacks
is genuine drift (warn); code having something the contract lacks is a
**staleness signal** (`contract-staleness:` findings, info) — regenerate
the snapshot with dspack-export.
- AF never generates, modifies, or writes dspack files. Token-level contract
drift, CI gating on contract findings, and any reconciliation involvement
are explicitly out of scope.

## Figma Console MCP Adapter — Component Search

`getComponent(name)` searches the entire Figma file tree recursively across all pages.
Expand Down
47 changes: 47 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,53 @@ af design component
af design component ButtonPrimary
```

### `af design drift [name]`

Cross-surface drift analysis — compares component metadata across Figma,
Storybook, code (AST), and an optional dspack contract file. Read-only: it
does not modify reconciliation, and the contract file is never written.

```bash
af design drift [component-name] [options]
```

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--json` | boolean | `false` | Output JSON format |
| `--verbose`, `-v` | boolean | `false` | Verbose output with trace details |
| `--include-uncorroborated` | boolean | `false` | Include uncorroborated story-derived variants |
| `--dspack <file>` | string | — | dspack contract file to compare against (overrides `contract.dspackPath` in `af.config.json`) |

**Contract surface:** when a dspack file is supplied (flag or config), its
declared components, props, and enum variants participate in the comparison.
Findings where the contract declares something code lacks are genuine drift
(warn). Findings where code has something the contract lacks are tagged
`contract-staleness:` (info) — the snapshot may be out of date; regenerate it
with [dspack-export](https://github.com/aestheticfunction/dspack-export).
Relative paths resolve against the current directory, then the repo root.

**Examples:**

```bash
# Compare a component across all available surfaces
af design drift Button

# Compare against a dspack contract (works even with Figma/Storybook down)
af design drift Button --dspack ./my-system.dspack.json

# Analyze every component the contract declares
af design drift --dspack ./my-system.dspack.json --json
```

```json
// af.config.json
{
"contract": {
"dspackPath": "./my-system.dspack.json"
}
}
```

---

## pnpm Scripts
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/design.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Subcommands:
inspect --all Inspect all design components
screenshot Capture a design screenshot
component [name] List or inspect design components
drift [component] Cross-surface drift analysis (Figma vs Storybook vs Code)
drift [component] Cross-surface drift analysis (Figma vs Storybook vs Code vs Contract)

Options (all subcommands):
--json Output JSON format
Expand All @@ -54,6 +54,7 @@ Examples:
af design screenshot --node 1:100
af design component Button
af design drift Button
af design drift Button --dspack ./my-system.dspack.json
af design drift --json --include-uncorroborated`);
}

Expand Down
20 changes: 20 additions & 0 deletions packages/shared/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,21 @@ export interface AfConfig {
/** Expected framework. Adapter validates at startup and rejects non-matching. Default: 'react' */
framework?: 'react';
};

/**
* Contract surface configuration (dspack).
*
* Points `af design drift` at a dspack contract file so the declared
* design-system contract participates in cross-surface drift analysis.
* Read-only — AF never generates or modifies dspack files.
*/
contract?: {
/**
* Path to a dspack v0.1/v0.2 file. Relative paths are resolved against
* the current working directory, then the repo root. Default: unset.
*/
dspackPath?: string;
};
}

// =============================================================================
Expand Down Expand Up @@ -221,6 +236,11 @@ export interface ResolvedAfConfig {
framework: 'react';
};

contract: {
/** Path to a dspack contract file, or null when no contract is configured */
dspackPath: string | null;
};

/** Where the config was loaded from, or null if using defaults */
_source: string | null;
}
15 changes: 15 additions & 0 deletions packages/shared/src/configLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export const DEFAULT_CONFIG: ResolvedAfConfig = {
enabled: false,
framework: 'react',
},
contract: {
dspackPath: null,
},
_source: null,
};

Expand Down Expand Up @@ -282,6 +285,15 @@ function validateAfConfig(raw: Record<string, unknown>): AfConfig {
}
}

// Contract surface (dspack)
if (typeof raw.contract === 'object' && raw.contract !== null && !Array.isArray(raw.contract)) {
const ct = raw.contract as Record<string, unknown>;
config.contract = {};
if (typeof ct.dspackPath === 'string' && ct.dspackPath.length > 0) {
config.contract.dspackPath = ct.dspackPath;
}
}

return config;
}

Expand Down Expand Up @@ -442,6 +454,9 @@ function mergeFileConfig(defaults: ResolvedAfConfig, file: AfConfig, source: str
if (file.storybook?.enabled !== undefined) config.storybook.enabled = file.storybook.enabled;
if (file.storybook?.framework !== undefined) config.storybook.framework = file.storybook.framework;

// Contract surface (dspack)
if (file.contract?.dspackPath !== undefined) config.contract.dspackPath = file.contract.dspackPath;

return config;
}

Expand Down
59 changes: 55 additions & 4 deletions packages/shared/src/crossSurfaceDrift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@
* where surfaces disagree.
*/

// =============================================================================
// SURFACE IDENTIFIERS
// =============================================================================

/**
* Identifier for a comparable surface in cross-surface drift analysis.
*
* - 'figma': design tool state (Figma Console MCP adapter)
* - 'storybook': code-adjacent runtime metadata (Storybook MCP adapter)
* - 'code': source AST extraction
* - 'contract': declared design-system contract (a dspack file) — a versioned,
* read-only artifact, not a live tool
*/
export type DriftSurfaceId = 'figma' | 'storybook' | 'code' | 'contract';

// =============================================================================
// DRIFT REPORT
// =============================================================================
Expand All @@ -28,6 +43,7 @@ export interface CrossSurfaceDriftReport {
figma?: SurfaceSnapshot;
storybook?: SurfaceSnapshot;
code?: SurfaceSnapshot;
contract?: SurfaceSnapshot;
};

/** Individual drift findings */
Expand All @@ -37,7 +53,7 @@ export interface CrossSurfaceDriftReport {
severity: DriftSeverity;

/** Surfaces that were actually queried (regardless of whether data was found) */
queriedSurfaces: ('figma' | 'storybook' | 'code')[];
queriedSurfaces: DriftSurfaceId[];

/** When the analysis was performed */
analyzedAt: string;
Expand Down Expand Up @@ -110,6 +126,9 @@ export interface DriftFinding {
/** Value from code surface (if available) */
codeValue?: string;

/** Value from the contract surface (if available) */
contractValue?: string;

/** Optional Storybook story reference for the mismatched item */
storyRef?: string;

Expand All @@ -129,6 +148,8 @@ export type DriftType =
| 'missing-in-figma'
| 'missing-in-storybook'
| 'missing-in-code'
| 'missing-in-contract'
| 'contract-mismatch'
| 'value-mismatch'
| 'name-mismatch';

Expand All @@ -148,10 +169,40 @@ export interface DriftAnalysisOptions {
includeUncorroborated?: boolean;

/** Surfaces that were queried by the caller (used to distinguish "not checked" from "checked, not found") */
queriedSurfaces?: ('figma' | 'storybook' | 'code')[];
queriedSurfaces?: DriftSurfaceId[];

/** Override the default normalization config (alias mappings + design-only filters) */
normalizationConfig?: NormalizationConfig;

/**
* Component data from the dspack contract surface, if queried.
* Read-only — supplied by the watcher's contractSurface module.
* Passed via options (not a positional parameter) to keep the
* analyzeCrossSurfaceDrift() signature stable for existing callers.
*/
contractData?: ContractComponentData | null;
}

// =============================================================================
// CONTRACT SURFACE
// =============================================================================

/**
* Component data extracted from a dspack contract file for drift comparison.
* Produced by the watcher's contractSurface module — read-only.
*/
export interface ContractComponentData {
/** dspack component ID (kebab-case, e.g., "alert-dialog") */
id: string;

/** Display name from the contract entry (e.g., "AlertDialog") */
name: string;

/** Props declared by the contract */
props: SurfaceProp[];

/** Variant values: union of all enum-prop values declared by the contract */
variants: string[];
}

// =============================================================================
Expand Down Expand Up @@ -189,13 +240,13 @@ export interface NormalizationMetadata {
appliedRules: Array<{
originalName: string;
canonicalName: string;
surface: 'figma' | 'storybook' | 'code';
surface: DriftSurfaceId;
}>;

/** Props excluded from comparison as design-only */
excludedProps: Array<{
name: string;
surface: 'figma' | 'storybook' | 'code';
surface: DriftSurfaceId;
reason: 'design-only';
}>;
}
5 changes: 4 additions & 1 deletion packages/shared/src/surfaceMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@
* - "runtime": Framework runtime or code analysis (Vuetify, AntD, Storybook, AST)
* - "generation": AI/code generation source (UXPilot, v0, etc.)
* - "inspection": Observation/monitoring tool (DevTools, visual regression, etc.)
* - "contract": Declared design-system contract artifact (e.g., a dspack file) —
* versioned and reviewed in source control, not a live tool
*/
export type SurfaceType =
| 'design'
| 'runtime'
| 'generation'
| 'inspection';
| 'inspection'
| 'contract';

// =============================================================================
// ACCESS MODE — Whether the adapter can mutate anything
Expand Down
1 change: 1 addition & 0 deletions packages/watcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"dependencies": {
"@aesthetic-function/shared": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"ajv": "^8.17.0",
"chokidar": "^3.5.3",
"ws": "^8.14.0"
},
Expand Down
9 changes: 9 additions & 0 deletions packages/watcher/src/__fixtures__/contract/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Contract surface test fixtures

- `shadcn-demo.dspack.json` — copy of the dspack-export golden fixture
(`dspack-export/fixtures/shadcn-demo/shadcn-demo.dspack.json`, exporter
0.1.0-alpha.1). Committed locally so AF tests are self-contained; do not
reference the dspack-export repo at test time.
- `invalid-version.dspack.json` — unsupported `dspack` version, loader must reject.
- `invalid-schema.dspack.json` — violates the v0.2 schema (`values` not an
array), loader must reject with instance paths.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"dspack": "0.2",
"name": "Schema Invalid",
"components": {
"button": {
"name": "Button",
"props": {
"variant": {
"type": "enum",
"values": "not-an-array"
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"dspack": "0.9",
"name": "Bad Version"
}
Loading
Loading