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 .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{
"name": "adhd",
"source": "./plugins/adhd",
"description": "Push, pull, and lint design tokens between Tailwind v4 and Figma; push React components with preflight validation."
"description": "Push, pull, and lint design tokens between Tailwind v4 and Figma; push and pull React components with preflight validation."
}
]
}
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ jobs:
run: node --test plugins/adhd/lib/design-system/__tests__/
- name: Run push-component tests
run: node --test plugins/adhd/lib/push-component/__tests__/
- name: Run pull-component tests
run: node --test plugins/adhd/lib/pull-component/__tests__/

hygiene:
name: project hygiene
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Plugin source lives at the repo root (`plugins/`, `docs/`, `scripts/`, etc.). Th

Both commands are persistent — Claude Code remembers the marketplace and the enabled plugin across sessions. Run them once per machine.

After install, five slash commands are available:
After install, six slash commands are available:

| Command | Args | Direction | What it does |
|---|---|---|---|
Expand All @@ -25,6 +25,7 @@ After install, five slash commands are available:
| `/adhd:push-design-system` | — | code → Figma | Pushes globals.css variables + named styles into Figma directly via the remote MCP |
| `/adhd:pull-design-system` | — | Figma → code | Pulls Figma variables + named styles into globals.css |
| `/adhd:push-component` | `<path> [--max-variants <n>]` | code → Figma | Pushes a React component to Figma as a structured Component Set with variant properties + variable bindings, plus a preflight lint check |
| `/adhd:pull-component` | `<path \| figma-url> [--allow-unbound]` | Figma → code | Pulls a Figma Component Set into a React source file; updates lookup tables and union types only (function body untouched) |

`/adhd:push-design-system`, `/adhd:pull-design-system`, `/adhd:lint`, and `/adhd:push-component` all require the official Figma plugin — install it with:

Expand Down Expand Up @@ -87,6 +88,22 @@ The scoped report covers the same rules (STRUCT001–010 + variable mismatches),

The skill parses the component's TypeScript prop unions, generates a temp preview route, auto-starts the Next.js dev server if needed, captures via `generate_figma_design`, wraps the captured frames into a Component Set with variant properties, rebinds raw values to existing design-system variables, and runs the same lint engine `/adhd:lint` uses as a preflight check before finalizing. If the Cartesian product would exceed 30 variants, pass `--max-variants <n>` to cap with coverage-first selection.

### Pull a component

```
# From the consumer repo, with a mapping already established by /adhd:push-component:
/adhd:pull-component app/components/avatar/index.tsx

# Or by Figma URL — reverse-resolves to the path via adhd.config.ts:
/adhd:pull-component https://www.figma.com/design/<KEY>?node-id=91-18

# Pre-flight is strict by default — if Figma has unbound raw values, pull aborts and asks the designer to bind them.
# To accept hardcoded fallbacks anyway (with adhd:off-system comments for greppability):
/adhd:pull-component app/components/avatar/index.tsx --allow-unbound
```

The skill reads the Figma Component Set, diffs it against the React file's `Record<Union, string>` lookup tables, prompts on each divergence, and rewrites only those tables (plus union type members). Function body, JSX, hooks, handlers, and imports are never modified.

### Figma file structure

The Figma file must follow the structure mandated in the spec — a `Primitives` collection (no modes) and a `Semantic` collection (Light + Dark modes). The skill validates this and surfaces fix-up guidance on failure.
Expand Down
1,160 changes: 1,160 additions & 0 deletions docs/superpowers/plans/2026-05-10-adhd-pull-component.md

Large diffs are not rendered by default.

498 changes: 498 additions & 0 deletions docs/superpowers/specs/2026-05-10-adhd-pull-component.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions plugins/adhd/lib/pull-component/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# lib/pull-component

Deterministic config-writer for `/adhd:pull-component`. The skill itself
(at `plugins/adhd/skills/pull-component/SKILL.md`) is the orchestrator
and handles all the LLM-driven work — reading the React source,
extracting the Figma Component Set, computing the diff, prompting the
user, applying Edit-tool changes.

This library is intentionally tiny: it only contains the schema-level
mutation of `adhd.config.ts` (adding/reading component mappings under
`components.<path>.figma.url`). Anything more intelligent lives in
the SKILL prompt where the LLM can reason about it.

See `docs/superpowers/specs/2026-05-10-adhd-pull-component.md` for the
authoritative spec.
69 changes: 69 additions & 0 deletions plugins/adhd/lib/pull-component/__tests__/cli.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');

const CLI = path.resolve(__dirname, '..', 'cli.js');

function tmp(filename, content) {
const p = path.join(os.tmpdir(), 'adhd-pull-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8) + '-' + filename);
fs.writeFileSync(p, content);
return p;
}

test('cli with --help prints subcommand usage and exits 0', () => {
const r = spawnSync('node', [CLI, '--help'], { encoding: 'utf8' });
assert.equal(r.status, 0);
assert.match(r.stdout, /Usage:/);
assert.match(r.stdout, /config-write/);
assert.match(r.stdout, /config-read/);
assert.match(r.stdout, /config-reverse/);
});

test('cli with no args exits 2', () => {
assert.equal(spawnSync('node', [CLI], { encoding: 'utf8' }).status, 2);
});

test('cli with unknown subcommand exits 2', () => {
assert.equal(spawnSync('node', [CLI, 'unknown'], { encoding: 'utf8' }).status, 2);
});

test('config-write subcommand adds a components entry to the config file', () => {
const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`);
const r = spawnSync('node', [CLI, 'config-write', '--config', cfgPath, '--path', 'app/components/x.tsx', '--figma-url', 'https://figma.com/design/ABC/?node-id=1-1'], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
const after = fs.readFileSync(cfgPath, 'utf8');
assert.match(after, /"app\/components\/x\.tsx":/);
});

test('config-read subcommand prints the figma url to stdout', () => {
const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n components: {\n "app/components/x.tsx": { figma: { url: "https://figma.com/design/ABC/?node-id=1-1" } },\n },\n};\n\nexport default config;\n`);
const r = spawnSync('node', [CLI, 'config-read', '--config', cfgPath, '--path', 'app/components/x.tsx'], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /node-id=1-1/);
});

test('config-read exits 1 with empty stdout when path is not mapped', () => {
const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`);
const r = spawnSync('node', [CLI, 'config-read', '--config', cfgPath, '--path', 'app/components/missing.tsx'], { encoding: 'utf8' });
assert.equal(r.status, 1);
assert.equal(r.stdout, '');
});

test('config-reverse subcommand prints the path for a given URL', () => {
const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n components: {\n "app/components/x.tsx": { figma: { url: "https://figma.com/design/ABC/?node-id=1-1" } },\n },\n};\n\nexport default config;\n`);
const r = spawnSync('node', [CLI, 'config-reverse', '--config', cfgPath, '--figma-url', 'https://figma.com/design/ABC/?node-id=1-1'], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /app\/components\/x\.tsx/);
});

test('config-reverse exits 1 with empty stdout when URL has no mapping', () => {
const cfgPath = tmp('adhd.config.ts', `const config = {\n figma: { url: "https://figma.com/design/ABC/" },\n};\n\nexport default config;\n`);
const r = spawnSync('node', [CLI, 'config-reverse', '--config', cfgPath, '--figma-url', 'https://figma.com/design/ABC/?node-id=9-9'], { encoding: 'utf8' });
assert.equal(r.status, 1);
assert.equal(r.stdout, '');
});
71 changes: 71 additions & 0 deletions plugins/adhd/lib/pull-component/__tests__/config-writer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const { readComponentMapping, addComponentMapping, reverseLookupPath } = require('../config-writer');

const MINIMAL_CONFIG = `const config = {
figma: { url: "https://figma.com/design/ABC/" },
};

export default config;
`;

const WITH_COMPONENTS = `const config = {
figma: { url: "https://figma.com/design/ABC/" },
components: {
"app/components/avatar/index.tsx": {
figma: { url: "https://figma.com/design/ABC/?node-id=91-18" },
},
},
};

export default config;
`;

test('readComponentMapping returns null when no components field exists', () => {
assert.equal(readComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx'), null);
});

test('readComponentMapping returns entry when path matches', () => {
const r = readComponentMapping(WITH_COMPONENTS, 'app/components/avatar/index.tsx');
assert.equal(r && r.figma.url, 'https://figma.com/design/ABC/?node-id=91-18');
});

test('readComponentMapping returns null for an absent path even if components exists', () => {
assert.equal(readComponentMapping(WITH_COMPONENTS, 'app/components/nope.tsx'), null);
});

test('addComponentMapping creates components field if missing', () => {
const out = addComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1');
assert.match(out, /components:\s*\{/);
assert.match(out, /"app\/components\/badge\.tsx":/);
assert.match(out, /url:\s*"https:\/\/figma\.com\/design\/ABC\/\?node-id=200-1"/);
});

test('addComponentMapping is idempotent — re-adding same entry returns identical source', () => {
const out1 = addComponentMapping(MINIMAL_CONFIG, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1');
const out2 = addComponentMapping(out1, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1');
assert.equal(out2, out1);
});

test('addComponentMapping appends to existing components field', () => {
const out = addComponentMapping(WITH_COMPONENTS, 'app/components/badge.tsx', 'https://figma.com/design/ABC/?node-id=200-1');
assert.match(out, /"app\/components\/avatar\/index\.tsx":/);
assert.match(out, /"app\/components\/badge\.tsx":/);
});

test('addComponentMapping updates existing entry if URL differs', () => {
const out = addComponentMapping(WITH_COMPONENTS, 'app/components/avatar/index.tsx', 'https://figma.com/design/ABC/?node-id=999-1');
assert.match(out, /node-id=999-1/);
assert.doesNotMatch(out, /node-id=91-18/);
});

test('reverseLookupPath finds the path for a given figma URL', () => {
const path = reverseLookupPath(WITH_COMPONENTS, 'https://figma.com/design/ABC/?node-id=91-18');
assert.equal(path, 'app/components/avatar/index.tsx');
});

test('reverseLookupPath returns null for unknown URL', () => {
assert.equal(reverseLookupPath(WITH_COMPONENTS, 'https://figma.com/design/ABC/?node-id=999-1'), null);
});
70 changes: 70 additions & 0 deletions plugins/adhd/lib/pull-component/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env node
'use strict';

const fs = require('node:fs');
const { readComponentMapping, addComponentMapping, reverseLookupPath } = require('./config-writer');

function parseArgs(argv) {
const args = { _: [] };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--help' || a === '-h') { args.help = true; continue; }
if (a.startsWith('--')) { args[a.slice(2)] = argv[++i]; }
else { args._.push(a); }
}
return args;
}

function printUsage() {
console.log(`Usage:
cli.js config-write --config <adhd.config.ts> --path <relative-path> --figma-url <url>
cli.js config-read --config <adhd.config.ts> --path <relative-path>
cli.js config-reverse --config <adhd.config.ts> --figma-url <url>`);
}

function main() {
const args = parseArgs(process.argv);
if (args.help) { printUsage(); process.exit(0); }
if (args._.length === 0) { printUsage(); process.exit(2); }
const cmd = args._[0];

if (cmd === 'config-write') {
if (!args.config || !args.path || !args['figma-url']) {
console.error('Usage: config-write --config <path> --path <rel> --figma-url <url>');
process.exit(2);
}
const source = fs.readFileSync(args.config, 'utf8');
const out = addComponentMapping(source, args.path, args['figma-url']);
fs.writeFileSync(args.config, out);
process.exit(0);
}

if (cmd === 'config-read') {
if (!args.config || !args.path) {
console.error('Usage: config-read --config <path> --path <rel>');
process.exit(2);
}
const source = fs.readFileSync(args.config, 'utf8');
const r = readComponentMapping(source, args.path);
if (!r) { process.exit(1); }
process.stdout.write(r.figma.url);
process.exit(0);
}

if (cmd === 'config-reverse') {
if (!args.config || !args['figma-url']) {
console.error('Usage: config-reverse --config <path> --figma-url <url>');
process.exit(2);
}
const source = fs.readFileSync(args.config, 'utf8');
const r = reverseLookupPath(source, args['figma-url']);
if (!r) { process.exit(1); }
process.stdout.write(r);
process.exit(0);
}

console.error('Unknown subcommand: ' + cmd);
process.exit(2);
}

main();
Loading
Loading