Skip to content

feat: local Figma Code Connect pipeline for RescriptBlend components - #128

Open
jagguji wants to merge 8 commits into
mainfrom
worktree-feat+figma-code-connect
Open

feat: local Figma Code Connect pipeline for RescriptBlend components#128
jagguji wants to merge 8 commits into
mainfrom
worktree-feat+figma-code-connect

Conversation

@jagguji

@jagguji jagguji commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Figma Code Connect for RescriptBlend components. blend-design-system is the sole source of truth -- this repo does not hand-maintain Figma property knowledge. Every component blend-design-system has already published a *.figma.tsx Code Connect file for gets its mapping synced automatically from there; nothing is guessed.

Two consumers, one shared data source per component (figma/componentMaps/<Component>.mjs):

  • Local Code Connect templates (figma/<Component>.figma.ts) -- resolved by an AI coding assistant with the Figma Dev Mode MCP server connected.
  • Generated ReScript modules (src/Figma/<Component>CodeConnect.res, shipped in the npm package) -- RescriptBlend.<Component>CodeConnect.fromFigmaProps(props), for consumers that already have Figma property data some other way (e.g. a standalone Figma plugin like juspay-portal's apps/code-connect), without hand-copying mapping tables into another repo.

The primary workflow

npm run figma:sync       # scripts/sync-figma-code-connect.mjs -- pulls every *.figma.tsx from
                          # github.com/juspay/blend-design-system, parses figma.connect(...) with
                          # the real TypeScript compiler API, cross-references against our own
                          # generated src/*.res, writes figma/componentMaps/*.mjs
npm run figma:generate    # -> src/Figma/*CodeConnect.res
npm run build

18 of 28 available blend-design-system Code Connect files sync cleanly end-to-end (Button, ButtonGroup, AvatarGroup, Charts, Popover, Radio, StatCard, SplitTag, Tag, the TextInput family). The other 10 hit real, documented parser limitations (complex JSX expressions; some .res files spread their real props from a separate Types module instead of inlining them) -- never wrong data, just conservatively skipped. A component with no upstream .figma.tsx has no Code Connect here either -- correct, not a gap to fill by hand.

Runs on a weekly schedule (.github/workflows/sync-figma-code-connect.yml) and opens a PR on drift -- never auto-merges, same posture as sync-bindings.yml. This also folds in what would've been Figma-side rename detection: since we consume blend's own published mapping rather than reading Figma directly, a Figma-side change blend's team has already reflected in their .figma.tsx gets picked up on next sync, with no Figma API token needed at all.

CI

scripts/check-figma-map-drift.mjs re-scaffolds every component into a temp dir and diffs its prop-key set against the committed map. Runs on every push/PR. Only fails for verified maps (figmaComponentName set) -- drift in an unfinished scaffold is reported but doesn't fail CI, since it drifts on essentially every blend dependency bump and nothing was relying on it yet.

Verification

  • npm run build compiles cleanly (454 modules, zero errors)
  • npm run format:check passes
  • npm run figma:check-drift passes (zero drift in any verified map)
  • Runtime check: ButtonCodeConnect.fromFigmaProps reproduces the exact real snippet for two independently-checked real Button instances
  • Button's mapping cross-verified against two independent sources: Figma Desktop's live Inspect panel, and blend-design-system's own published .figma.tsx -- they agree exactly
  • Real bugs found and fixed via the drift check while building the sync script (kind-agreement gaps in the resolver, stale generated-file cleanup) -- see commit messages
  • The other ~185 components remain unverified scaffolds (scripts/scaffold-figma-component-maps.mjs), pending either an upstream .figma.tsx from blend-design-system or manual verification
  • Not yet wired into juspay-portal's plugin (consumption path documented in figma/README.md)

🤖 Generated with Claude Code

Comment thread scripts/scaffold-figma-component-maps.mjs Fixed
Adds a self-contained Code Connect setup (Button.figma.ts + a generic
engine) resolved live by Figma's Dev Mode MCP server, plus a codegen
script that derives an equivalent ReScript module
(src/Figma/<Component>CodeConnect.res, published as part of the npm
package) so non-Figma consumers -- e.g. a standalone Figma plugin -- can
call the same rendering logic directly instead of re-deriving it.

Each component's Figma<->RescriptBlend property mapping lives in one
data file (figma/componentMaps/<Component>.mjs), the single source both
the template and the codegen script read from. This repo is public, so
no real Figma file URL/key/node-id is recorded anywhere -- only the
property-mapping knowledge (property names, variant values) is committed;
see figma/README.md's "No Figma URLs/file keys/node-ids in this repo" note.

Button is verified end-to-end: real property values were harvested
interactively from its existing React Code Connect mapping, and the
generated ButtonCodeConnect.fromFigmaProps compiles and reproduces the
expected <Button .../> snippet.

The remaining 208 components are scaffolded (scripts/scaffold-figma-component-maps.mjs
derives prop names/kinds/enum constructors from our own bindings) but
NOT Figma-verified -- every scaffold's figmaComponentName/figmaProp is an
explicit null/TODO, and the codegen script skips unverified scaffolds
rather than emitting placeholder ReScript from them.

CI now runs scripts/check-figma-map-drift.mjs on every push/PR: it
re-scaffolds every component into a temp dir and diffs its prop-key set
against the committed maps, catching props Blend gains/removes that a map
doesn't account for -- local-only, no secrets, no network, no human.
Verified end-to-end, including that it catches a real prop gap.

Props a human has reviewed and deliberately excluded from Code Connect
can be recorded as `{ mapped: false, reason: '...' }`, respected by
engine.mjs, the codegen script, and the drift check so a rejected prop
isn't silently re-flagged or re-rendered.

Figma-side rename detection (a designer renames a property and nothing
notices) needs the Figma REST API polled in CI, which requires a token
and breaks this pipeline's current no-secrets property -- documented in
figma/README.md as a deliberate non-goal pending that tradeoff decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jagguji
jagguji force-pushed the worktree-feat+figma-code-connect branch from e5ef475 to da378fc Compare July 21, 2026 11:01
jagguji and others added 3 commits July 22, 2026 21:31
…olds

Discovered by syncing main: a routine bindgen bump (^1.2.6-beta.10 -> ^1.3.0,
PR #129) changed the .res shape for ~50 of the 208 still-unverified
component scaffolds at once. Since none of those were ever relying on real
Figma data (figmaComponentName: null), treating their drift as a CI failure
would make the check noisy on essentially every dependency bump, training
reviewers to ignore it.

Split severity instead: drift in a map with figmaComponentName set (real
Figma data exists, e.g. Button.mjs) is a genuine regression and fails the
check. Drift in a map still at figmaComponentName: null is reported (so
it's visible when someone gets to finishing that component) but doesn't
fail CI. Button.mjs itself had zero drift from this bump -- confirmed
before and after this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This repo no longer hand-maintains Figma property knowledge.
blend-design-system already publishes its own Code Connect *.figma.tsx
files (github.com/juspay/blend-design-system) -- those are now the sole
source of truth, consumed automatically the same way npm run generate
already treats blend-design-system as the source of truth for the
ReScript bindings themselves.

scripts/sync-figma-code-connect.mjs:
  1. Lists every *.figma.tsx in blend-design-system (public GitHub repo,
     no secrets -- GITHUB_TOKEN in the env just raises the rate limit).
  2. Parses each figma.connect(...) call with the real TypeScript
     compiler API (scripts/shared/parse-figma-tsx.mjs) -- not regex,
     since these are hand-written by different people with real
     structural variety. Correctly resolves the actual React prop name
     from the example callback's JSX (not the props: object's local
     variable names, which can differ -- e.g. Button's real prop is
     leadingIcon, not the leftIcon local name used internally).
  3. Cross-references against our OWN generated src/*.res
     (scripts/shared/rescript-component-parser.mjs, shared with the
     scaffolder) to resolve enum values by bound-value match (Figma
     "primary" <-> @as("primary") Primary) -- never by parsing blend's
     React enum member names.
  4. Writes figma/componentMaps/<Component>.mjs -- fully generated now,
     never hand-edited.

18 of 28 available blend-design-system Code Connect files sync cleanly
end-to-end (Button, ButtonGroup, AvatarGroup, Charts, Popover, Radio,
StatCard, SplitTag, Tag, TextInput family); the other 10 hit real,
documented parser limitations (complex JSX expressions, and some .res
files spreading their real props from a separate Types module rather
than inlining them) -- never wrong data, just conservatively skipped.

Runs on a weekly schedule (.github/workflows/sync-figma-code-connect.yml)
and opens a PR on drift, same posture as sync-bindings.yml: never
auto-merges. This also folds in what would have been "Drift B" (Figma-side
renames) from the PR #128 review -- since we consume blend's own published
mapping rather than reading Figma directly, a Figma-side change blend's
team has already reflected in their .figma.tsx is picked up on next sync,
with no Figma API token needed at all.

Also fixes real bugs the drift check caught while building this:
- The resolver only cross-checked kind agreement for enum props;
  instanceSwap/string/bool were trusted from blend's shape alone. A
  generic Tooltip `slot` prop (React.element, not icon-shaped) slipped
  through as instanceSwap even though our own classifier marks it
  unsupported. Now requires exact kind agreement across the board.
- The literal-boolean fast path (for derived booleans like Button's
  disabled<-state) didn't require the prop to exist on our side at all,
  just the value shape -- let a Switch prop through with nothing backing
  it (Switch.res's real props are invisible to our parser, see above).
- Rejected (`mapped: false`) entries were only recorded for props blend
  never mentions, not ones blend mentions but that fail resolution --
  dropped such props entirely instead of recording why, causing false
  drift-check positives against a fresh scaffold.
- generate-figma-code-connect.mjs never removed stale *CodeConnect.res
  output for a component that regressed to unverified -- now cleans up
  orphaned generated files every run.

scripts/lib/ was renamed to scripts/shared/ -- .gitignore's `lib/` entry
(for ReScript's own build output) was silently swallowing the new
directory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread .github/workflows/sync-figma-code-connect.yml Fixed
Comment thread scripts/sync-figma-code-connect.mjs Fixed
`;

await mkdir(MAPS_DIR, { recursive: true });
await writeFile(join(MAPS_DIR, `${ourComponentName}.mjs`), source_);
@pkg-pr-new

pkg-pr-new Bot commented Jul 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@juspay/rescript-blend@128

commit: 40ba16f

@jagguji

jagguji commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Notes from wiring this into juspay-portal's apps/code-connect (the exact consumer this PR's "Consuming from another repo" section names). Three things, one of them a real API gap.

1. Docs: namespace is JuspayRescriptBlend, not RescriptBlend

figma/README.md and the PR body write the call as RescriptBlend.<Component>CodeConnect.fromFigmaProps(props). The published namespace is JuspayRescriptBlend — derived from "namespace": true + the scoped name @juspay/rescript-blend (node_modules/@juspay/rescript-blend/lib/ocaml/Accordion-JuspayRescriptBlend.cmi). juspay-portal has 905 JuspayRescriptBlend. references and 0 bare RescriptBlend..

Docs-only fix. Worth being explicit that setting "namespace": "RescriptBlend" to make the docs true is not the fix — that renames it for every consumer.

2. The emitted string hardcodes a bare tag

ButtonCodeConnect.res ends with:

"<Button" ++ CodeConnectUtils.strAttr("text", text) ++++ " />"

juspay-portal's convention is <Blend.Button …/>, via the packages/common/src/Blend.res facade ("Use Blend.X … instead of the verbose JuspayRescriptBlend.X"). Counts in the portal: 1694 <Blend.X JSX usages, 95 of them <Blend.Button, 0 bare <Button. So the generated snippet isn't pasteable into the portal as-is.

3. Returning a finished string collapses composition the plugin owns

This is the bigger one. In apps/code-connect the converter never owns the tag:

  • ButtonUtils.convertToButtonProps returns props onlyarray<option<(string, string)>>
  • entry.res passes the tag name separately ((props, "Button", ""))
  • FigmaUtils.convertPropsNodeStateVariable (FigmaUtils.res:932-950) composes <${nodeName} …/>, optionally wraps it (convertPropsNodeStateVariable("Form")), and returns a (constants, hookState, jsx) triple

A finished string gives up the tag name, the wrapper, and the constants/hookState slots.

Proposal

Have scripts/generate-figma-code-connect.mjs also emit a props-level function, with fromFigmaProps kept as a thin wrapper over it:

let toProps: CodeConnectUtils.figmaProps => array<option<(string, string)>>

Consumer side then stays idiomatic, and the Blend. prefix stays a consumer policy decision rather than being baked into the package:

| "Button" =>
  (JuspayRescriptBlend.ButtonCodeConnect.toProps(componentProps), "Blend.Button", "")
  ->convertPropsNodeStateVariable("")

Alternatives considered and preferred against: a ~prefix arg on fromFigmaProps (fixes the prefix but still loses wrapper/constants), and string-replacing "<Button""<Blend.Button" at the call site (works with no package change, but brittle).

Unrelated note, not a request

Migrating the plugin to these modules also changes which Button it targets: today it emits the legacy packages/common/src/components/Button.res API (buttonSize / buttonState / buttonVariant / leftIcon), whereas blend's Button.res takes text / buttonType / size / subType / disabled / leadingIcon. Expected, just recording it.

## Namespace bug (docs + generated code)

Every reference to the ReScript namespace was wrong: it's
`JuspayRescriptBlend` (namespace: true + the scoped package name
@juspay/rescript-blend), not `RescriptBlend`. Confirmed independently
against this repo's own compiled output (lib/ocaml/*-JuspayRescriptBlend.cmi)
and against juspay-portal's 905 real references, 0 of which are bare
`RescriptBlend`. Fixed in figma/README.md, figma/engine.mjs, all three
figma:* scripts, and all 203 componentMaps/*.mjs `open` statements. The
pre-existing root README.md has the same bug but predates this PR --
left alone, flagged separately.

## toProps: consumers own tag/wrapper/state composition

Per review: returning a finished string collapsed composition
juspay-portal's plugin already owns (entry.res supplies the tag name
separately from the props array; FigmaUtils.convertPropsNodeStateVariable
composes the tag, optional wrapper, and constants/hookState). Each
generated module now also exports:

  let toProps: CodeConnectUtils.figmaProps => array<option<(string, string)>>

the same (propName, propValueText) shape a hand-written converter like
ButtonUtils.convertToButtonProps already returns. fromFigmaProps stays as
a thin wrapper over toProps, now with an optional ~tagName (defaults to
the fully-qualified JuspayRescriptBlend.<Component>) for callers who just
want a finished snippet with a custom tag/prefix without composing the
array themselves.

Enum values are now fully qualified (JuspayRescriptBlend.<Module>.<Constructor>)
instead of bare (<Module>.<Constructor>), so both toProps' values and
fromFigmaProps' default tag compile standalone, regardless of what's
`open` in the caller's file -- no import assumption needed for this
consumption path. (The imports metadata in componentMaps/*.mjs stays --
it's still used by the separate .figma.ts template path in engine.mjs,
a genuinely different, human-facing use case.)

CodeConnectUtils.res: added strPropEntry/propEntry/boolPropEntry (build
the (name, value) pairs) and joinTag (composes a tag + props array into a
finished string, used to implement fromFigmaProps). Removed the now-unused
attr/strAttr/boolAttr element-wise string builders.

## CodeQL findings (4)

- scaffold-figma-component-maps.mjs: replaced the existsSync-then-write
  check with an atomic `wx`-flag write (fails with EEXIST if the file
  already exists), closing the TOCTOU window between the check and the
  write. Re-verified create/skip-without-force/overwrite-with-force all
  still behave correctly.
- sync-figma-code-connect.yml: pinned peter-evans/create-pull-request to
  its v8 tag's actual commit SHA instead of the mutable tag. The same
  unpinned pattern also exists in the pre-existing sync-bindings.yml,
  untouched here since it's out of this PR's scope.
- sync-figma-code-connect.mjs: new jsStringLiteral() helper properly
  escapes backslashes before quotes (the previous code only escaped
  quotes, which would produce broken output for any blend-fetched text
  containing a literal backslash) -- applied everywhere untrusted
  blend-fetched text gets embedded in generated JS (figmaProp names, enum
  keys, rejection reasons).
- sync-figma-code-connect.mjs: resolveOurComponentName now validates
  reactComponentName (parsed from blend's fetched TSX) is a plain
  ReScript-identifier-shaped string before it touches any filesystem path,
  rather than relying only on the indirect existsSync check to rule out
  anything unexpected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jagguji and others added 3 commits July 24, 2026 19:12
… by prop

Follow-up to the review on #128 (#130): toProps
removed the need to hand-copy *property* mapping tables into a consumer,
but the consumer still hand-maintains a *component* mapping -- a switch
from Figma component name to the right module, one arm added per new
synced component. Same duplication problem, one level up.

scripts/generate-figma-code-connect.mjs now also emits
src/Figma/CodeConnectRegistry.res:

  let resolve: (string, CodeConnectUtils.figmaProps) =>
    option<(array<option<(string, string)>>, string)>

One arm per component actually generated (same not-ready gate as
everywhere else -- an unverified scaffold never leaks in). A consumer
gets Some((props, codeComponent)) for anything synced, None otherwise,
and supplies its own tag prefix/wrapper -- new synced components light up
on a version bump with zero downstream edits.

Verified for real, not just build-passes:
- resolve("Button", props) -> Some((toPropsResult, "Button"))
- resolve("NotAThing", props) -> None
- regressing one component's map (tested live: Radio, then restored)
  correctly drops just that component's arm, not the whole registry
- no figmaComponentName collisions across all 18 currently-synced maps
  (would be a ReScript switch compile error anyway, but checked up front)

Documented the one real naming caveat, both in the generated file's own
header and in figma/README.md: figmaComponentName is whichever identifier
blend-design-system's own figma.connect() call uses (a React identifier),
NOT necessarily the literal name in Figma's layers panel -- Button's real
Figma component set is "Buttons" (plural, confirmed via Figma Desktop),
while the registry key is "Button" (singular). Reconciling a live node's
actual Figma name against this registry's keys is the consumer's problem,
same principle as ~tagName on fromFigmaProps -- this package supplies the
mapping, not Figma-node-name resolution. (Considered dispatching by Figma
node-id instead, to mirror how blend's own Code Connect resolves -- not
done: that would mean storing fileKey/nodeId in this public repo, against
the no-Figma-identifiers policy from earlier in this PR. That precision
belongs in the consumer's own, presumably-private, node-id lookup.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ler-side normalization

CodeConnectRegistry.resolve("Buttons", ...) returned None even though the
mapping exists under "Button" -- blend-design-system's figma.connect() call
uses the code identifier "Button", but the real published Figma component
set is named "Buttons" (plural, confirmed via Figma Desktop's Inspect
panel). figma/figmaComponentAliases.mjs records verified Figma-display-name
aliases and generate-figma-code-connect.mjs folds them into extra resolve
patterns, so a consumer never has to hand-normalize Figma names itself.
Kept separate from figma/componentMaps/*.mjs so figma:sync never overwrites
it (sync only knows blend's code identifier, not Figma's display name).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously workflow_dispatch-only, so a new preview required remembering to
trigger it manually after each push. Now runs automatically on push to any
branch except main (main goes through the regular release path instead),
while keeping workflow_dispatch for on-demand re-runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants