Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cf84de1
feat(icons): add dark mode generation via Figma Variable Modes
alexey1312 Mar 27, 2026
8902ef3
docs: document variable-mode dark generation for icons
alexey1312 Mar 27, 2026
eb5849a
refactor(config)!: wrap dark mode config into nested objects
alexey1312 Mar 27, 2026
c9d93c8
fix: variable-mode dark icon generation
alexey1312 Mar 27, 2026
a2e7519
fix(icons): register VariablesDarkMode in PKL types + fix verbose spi…
alexey1312 Mar 27, 2026
b71e3ad
fix(docs): correct registerPklTypes docs + add dark mode deserializat…
alexey1312 Mar 27, 2026
3a4618b
feat(icons): add cross-file variable resolution for dark mode generation
alexey1312 Mar 27, 2026
75fd3b1
feat(icons): show dark variant count in export success message
alexey1312 Mar 27, 2026
92529da
perf(icons): cache Variables API responses across parallel entries
alexey1312 Mar 27, 2026
5adbda2
docs: document variablesFileId field and Variable Mode dark generation
alexey1312 Mar 27, 2026
9d816f6
feat(icons): support alpha/opacity in dark mode SVG color replacement
alexey1312 Mar 27, 2026
9d31bc1
fix(icons): add logging, tests, and safety improvements for variable …
alexey1312 Mar 27, 2026
baffba9
fix(icons): conform ColorReplacement to Equatable
alexey1312 Mar 27, 2026
fc81128
fix(icons): prevent CSS partial hex match and warn on duplicate libra…
alexey1312 Mar 27, 2026
579a0a3
perf(icons): cache Components API responses across parallel entries
alexey1312 Mar 27, 2026
7eed0fe
chore: update project tools and docs for variable dark mode
alexey1312 Mar 27, 2026
8ca5143
fix(ci): fix trailing newline issues in generated and workflow files
alexey1312 Mar 27, 2026
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
1 change: 0 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -319,4 +319,3 @@ jobs:
git add Formula/exfig.rb
git diff --staged --quiet || git commit -m "chore: bump to v${{ steps.version.outputs.version }}"
git push

47 changes: 46 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ Twelve modules in `Sources/`:
**MCP data flow:** `exfig mcp` → StdioTransport (JSON-RPC on stdin/stdout) → tool handlers → PKLEvaluator / TokensFileSource / FigmaAPI
**MCP stdout safety:** `OutputMode.mcp` + `TerminalOutputManager.setStderrMode(true)` — all CLI output goes to stderr
**Claude Code plugins:** [exfig-plugins](https://github.com/DesignPipe/exfig-plugins) marketplace — MCP integration, setup wizard, export commands, config review, troubleshooting
**Plugin sync checklist:** When adding features visible to end users (new dark mode approach, new CLI flag, new MCP tool), update DesignPipe/exfig-plugins skills: `exfig-mcp-usage`, `exfig-config-review` (common-issues.md), `exfig-troubleshooting` (error-catalog.md), `exfig-setup`. Clone to `/tmp/exfig-plugins`, branch, commit, PR.

**Variable-mode dark icons:** `FigmaComponentsSource.loadIcons()` → `VariableModeDarkGenerator` — fetches Variables API, resolves alias chains, replaces hex colors in SVG via `SVGColorReplacer`. Third dark mode approach alongside `darkFileId` and `suffixDarkMode`.

**Batch mode:** Single `@TaskLocal` via `BatchSharedState` actor — see `ExFigCLI/CLAUDE.md`.

Expand Down Expand Up @@ -229,6 +232,41 @@ When relocating a type (e.g., `Android.WebpOptions` → `Common.WebpOptions`), u
5. PKL examples (`Schemas/examples/*.pkl`)
6. DocC docs (`ExFig.docc/**/*.md`), CONFIG.md

### Variable-Mode Dark Icons (VariableModeDarkGenerator)

Three dark mode approaches for icons (mutually exclusive):

1. `darkFileId` — separate Figma file for dark icons (global `figma` section)
2. `suffixDarkMode` — `Common.SuffixDarkMode` on `Common.Icons`/`Images`/`Colors`, splits by name suffix
3. `variablesDarkMode` — `Common.VariablesDarkMode` on `FrameSource` (per-entry), resolves Figma Variable bindings

Approach 3 is configured via nested `variablesDarkMode: VariablesDarkMode?` on `FrameSource`.
Integration point: `FigmaComponentsSource.loadIcons()`.

**Algorithm:** fetch `VariablesMeta` → fetch icon nodes → walk children tree to find `Paint.boundVariables["color"]`
→ resolve dark value via alias chain (depth limit 10, same pattern as `ColorsVariablesLoader.handleColorMode()`)
→ build `lightHex → darkHex` map → `SVGColorReplacer.replaceColors()` → write dark SVG to temp file.

Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaComponentsSource.swift`.

**Logging requirements:** Every `guard ... else { continue }` in the generation loop must log a warning — silent skips cause invisible data loss. `resolveDarkColor` must check `deletedButReferenced != true` (same as all other variable loaders). `SVGColorReplacer` uses separate regex replacement templates per pattern (attribute patterns have 3 capture groups, CSS patterns have 2 — never share a single template).

**Config validation:** `Config.init` uses `precondition(!fileId.isEmpty)` — catches the documented empty-fileId bug at construction time instead of relying on call-site guards.

**Alias resolution behaviour:** `resolveDarkColor` alias targets resolve using the target collection's `defaultModeId`, NOT the requested modeId — test expectations must account for this (e.g., alias to primitive resolves via "light" default mode, not "dark").

**pkl-swift decoding:** pkl-swift uses **keyed** decoding (by property name, not positional). TypeRegistry is only for `PklAny` polymorphic types and performance — concrete `Decodable` structs (like `VariablesDarkMode`) decode via synthesized `init(from:)` regardless of `registerPklTypes`. New types should still be added to `registerPklTypes()` for completeness, but missing entries do NOT cause silent nil for concrete typed fields.

**Cross-file variable resolution:** Figma variable IDs are file-scoped — alias targets from the icons file don't exist in library files by ID. When `variablesFileId` is set, variables are loaded from BOTH files: icons file (semantic variables matching node boundVariables) + library file (primitives for alias resolution). Matching is by variable **name** across files and mode **name** across collections (not IDs).

**VariablesCache pattern:** `VariablesCache` (Lock + Task dedup) caches Variables API responses by fileId across parallel entries. Created per platform section in `PluginIconsExport`, injected through `SourceFactory` → `FigmaComponentsSource` → `VariableModeDarkGenerator`. Same pattern applicable to `ColorsVariablesLoader` if needed. Failed tasks are evicted (`lock.withLock { tasks[fileId] = nil }` in catch) — transient Figma API errors (429) don't permanently poison the cache.

**ComponentsCache pattern:** `ComponentsCache` (same Lock + Task dedup) caches Components API responses by fileId across parallel entries in standalone mode. Solves the problem that `ComponentPreFetcher` only works in batch mode (`BatchSharedState` is nil in standalone). Created per platform section in `PluginIconsExport`, injected through `SourceFactory` → `FigmaComponentsSource` → `ImageLoaderBase`.

**Alpha handling:** `SVGColorReplacer` supports opacity via `ColorReplacement(hex, alpha)`. When `alpha < 1.0`, replacement adds `fill-opacity`/`stroke-opacity` attributes (SVG) or `;fill-opacity:N` (CSS). Same hex with different alpha IS a valid replacement (e.g., `#D6FB94` opaque → `#D6FB94` transparent).

**Granular cache path:** `IconsExportContextImpl.loadIconsWithGranularCache()` creates its own `IconsLoader` and bypasses `FigmaComponentsSource` entirely. Variable-mode dark generation must be applied explicitly at the end of that method via `applyVariableModeDark(to:source:)`.

### Module Boundaries

ExFigCore does NOT import FigmaAPI. Constants on `Component` (FigmaAPI, extended in ExFigCLI) are
Expand All @@ -239,7 +277,7 @@ ExFigConfig imports ExFigCore but NOT ExFigCLI — `ExFigError` is not available

### Modifying SourceFactory Signatures

`createComponentsSource` has 8 call sites (4 in `PluginIconsExport` + 4 in `PluginImagesExport`) plus tests in `PenpotSourceTests.swift`.
`createComponentsSource` has 8 call sites (4 in `PluginIconsExport` + 4 in `PluginImagesExport`) plus tests in `PenpotSourceTests.swift`. Icons sites pass `componentsCache:`, Images sites use default `nil`.
`createTypographySource` call sites: only tests (not yet wired to production export flow).
Use `replace_all` on the trailing parameter pattern (e.g., `filter: filter\n )`) to update all sites at once.

Expand Down Expand Up @@ -455,6 +493,13 @@ NooraUI.formatLink("url", useColors: true) // underlined primary
| SwiftFormat `#if` indent | SwiftFormat 0.60.1 indents content inside `#if canImport()` — this is intentional project style, do not "fix" |
| SPM `from:` too loose | When code uses APIs from version X, set `from: "X"` not older — SPM may resolve an incompatible earlier version |
| Granular cache "Access denied" | `GranularCacheManager.filterChangedComponents` degrades gracefully — returns all components on node fetch error instead of failing config |
| Empty `fileId` in variable dark | `FigmaComponentsSource` must guard `fileId` not empty before calling `VariableModeDarkGenerator` — `?? ""` causes cryptic Figma API 404 |
| PKL field always `nil` | `registerPklTypes()` is a performance optimization, NOT a correctness requirement for concrete typed fields. For optional nested PKL objects returning `nil`, check: (1) `pkl eval --format json` confirms field present, (2) unit test with `PKLEvaluator.evaluate()` decodes correctly, (3) trace values at bridge layer (`iconsSourceInput()`) with diagnostic log |
| Granular cache skips dark gen | `loadIconsWithGranularCache()` in `IconsExportContextImpl` bypasses `FigmaComponentsSource` — must call `VariableModeDarkGenerator` explicitly via `applyVariableModeDark()` helper |
| Variable dark always empty maps | Alias targets are external library variables — set `variablesFileId` in `VariablesDarkMode` PKL config to the library file ID containing primitives |
| Figma variable IDs file-scoped | Variable IDs differ between files — alias targets from file A can't be found by ID in file B. Use name-based matching (`resolveViaLibrary`) + mode name matching (not modeId) for cross-file resolution |
| `assertionFailure` in release | `assertionFailure` is stripped in release builds — add `FileHandle.standardError.write()` as production fallback for truly-impossible-but-must-not-be-silent error paths |
| Components API called N times | `ComponentPreFetcher` only works in batch mode — use `ComponentsCache` via `SourceFactory(componentsCache:)` for standalone multi-entry dedup |

## Additional Rules

Expand Down
52 changes: 24 additions & 28 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,25 +205,22 @@ colors = new Common.Colors {
nameValidateRegexp = "^([a-zA-Z_]+)$"
// [optional] Replacement pattern using captured groups ($n).
nameReplaceRegexp = "color_$1"
// [optional] Extract light/dark from a single file. Default: false
useSingleFile = false
// [optional] Suffix for dark mode. Default: "_dark"
darkModeSuffix = "_dark"
// [optional] Extract light/dark from a single file using name suffix splitting
// suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" }
// [optional] Suffix for light high contrast. Default: "_lightHC"
// lightHCModeSuffix = "_lightHC"
// [optional] Suffix for dark high contrast. Default: "_darkHC"
// darkHCModeSuffix = "_darkHC"
}
```

| Field | Type | Description |
| -------------------- | ---------- | ------------------------------------------- |
| `nameValidateRegexp` | `String?` | RegExp for validating/capturing color names |
| `nameReplaceRegexp` | `String?` | Replacement pattern with captured groups |
| `useSingleFile` | `Boolean?` | Extract all modes from lightFileId |
| `darkModeSuffix` | `String?` | Dark mode name suffix (default: `_dark`) |
| `lightHCModeSuffix` | `String?` | Light HC suffix (default: `_lightHC`) |
| `darkHCModeSuffix` | `String?` | Dark HC suffix (default: `_darkHC`) |
| Field | Type | Description |
| -------------------- | ----------------- | ------------------------------------------- |
| `nameValidateRegexp` | `String?` | RegExp for validating/capturing color names |
| `nameReplaceRegexp` | `String?` | Replacement pattern with captured groups |
| `suffixDarkMode` | `SuffixDarkMode?` | Dark mode via name suffix splitting |
| `lightHCModeSuffix` | `String?` | Light HC suffix (default: `_lightHC`) |
| `darkHCModeSuffix` | `String?` | Dark HC suffix (default: `_darkHC`) |

### VariablesColors (Variables API)

Expand Down Expand Up @@ -276,10 +273,8 @@ icons = new Common.Icons {
nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$"
// [optional] Replacement pattern
nameReplaceRegexp = "icon_$2_$1"
// [optional] Extract light/dark from a single file. Default: false
useSingleFile = false
// [optional] Suffix for dark mode icons. Default: "_dark"
darkModeSuffix = "_dark"
// [optional] Extract light/dark from a single file using name suffix splitting
// suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" }
// [optional] Exit with error when pathData exceeds 32,767 bytes (AAPT limit). Default: false
// strictPathValidation = true
}
Expand All @@ -297,10 +292,8 @@ images = new Common.Images {
nameValidateRegexp = "^(img)_([a-z0-9_]+)$"
// [optional] Replacement pattern
nameReplaceRegexp = "image_$2"
// [optional] Extract light/dark from a single file. Default: false
useSingleFile = false
// [optional] Suffix for dark mode images. Default: "_dark"
darkModeSuffix = "_dark"
// [optional] Extract light/dark from a single file using name suffix splitting
// suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" }
}
```

Expand All @@ -319,14 +312,17 @@ typography = new Common.Typography {

All Icons and Images entries across platforms extend `Common.FrameSource`, which provides:

| Field | Type | Default | Description |
| -------------------- | --------- | ------- | ------------------------------------------------------- |
| `figmaFrameName` | `String?` | — | Override Figma frame name for this entry |
| `figmaPageName` | `String?` | — | Filter by Figma page name for this entry |
| `figmaFileId` | `String?` | — | Override Figma file ID for this entry |
| `rtlProperty` | `String?` | `"RTL"` | Figma component property name for RTL variant detection |
| `nameValidateRegexp` | `String?` | — | Regex pattern for name validation |
| `nameReplaceRegexp` | `String?` | — | Replacement pattern using captured groups |
| Field | Type | Default | Description |
| -------------------- | -------------------- | ------- | ------------------------------------------------------- |
| `figmaFrameName` | `String?` | — | Override Figma frame name for this entry |
| `figmaPageName` | `String?` | — | Filter by Figma page name for this entry |
| `figmaFileId` | `String?` | — | Override Figma file ID for this entry |
| `rtlProperty` | `String?` | `"RTL"` | Figma component property name for RTL variant detection |
| `variablesDarkMode` | `VariablesDarkMode?` | — | Dark mode via Figma Variable bindings (see below) |
| `nameValidateRegexp` | `String?` | — | Regex pattern for name validation |
| `nameReplaceRegexp` | `String?` | — | Replacement pattern using captured groups |

**VariablesDarkMode** fields: `collectionName` (required), `lightModeName` (required), `darkModeName` (required), `primitivesModeName` (optional), `variablesFileId` (optional — Figma file ID of the library containing the full variable chain including primitives; required when variables alias to an external library).

**RTL Detection:** When `rtlProperty` is set (default `"RTL"`), ExFig detects RTL support via Figma
COMPONENT_SET variant properties. Components with `RTL=On` variant are automatically skipped (iOS/Android
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![CI](https://github.com/DesignPipe/exfig/actions/workflows/ci.yml/badge.svg)](https://github.com/DesignPipe/exfig/actions/workflows/ci.yml)
[![Release](https://github.com/DesignPipe/exfig/actions/workflows/release.yml/badge.svg)](https://github.com/DesignPipe/exfig/actions/workflows/release.yml)
[![Docs](https://github.com/DesignPipe/exfig/actions/workflows/deploy-docc.yml/badge.svg)](https://DesignPipe.github.io/exfig/documentation/exfigcli)
![Coverage](https://img.shields.io/badge/coverage-43.65%25-yellow)
![Coverage](https://img.shields.io/badge/coverage-43.55%25-yellow)
[![License](https://img.shields.io/github/license/DesignPipe/exfig.svg)](LICENSE)

Export colors, typography, icons, and images from Figma and Penpot to Xcode, Android Studio, Flutter, and Web projects — automatically. Runs on macOS, Linux, and Windows.
Expand Down
4 changes: 2 additions & 2 deletions Scripts/generate-llms.sh
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ generate_llms_full() {

# ── Main ──────────────────────────────────────────────────────────────────────

generate_llms_txt > "$LLMS_TXT"
generate_llms_full > "$LLMS_FULL"
generate_llms_txt | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' > "$LLMS_TXT"
generate_llms_full | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' > "$LLMS_FULL"

LINES_TXT=$(wc -l < "$LLMS_TXT" | tr -d ' ')
LINES_FULL=$(wc -l < "$LLMS_FULL" | tr -d ' ')
Expand Down
9 changes: 7 additions & 2 deletions Sources/ExFig-Android/Config/AndroidIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,17 @@ public extension Android.IconsEntry {
frameName: figmaFrameName ?? "Icons",
pageName: figmaPageName,
format: .svg,
useSingleFile: darkFileId == nil,
useSingleFile: darkFileId == nil && variablesDarkMode == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp,
penpotBaseURL: resolvedPenpotBaseURL
penpotBaseURL: resolvedPenpotBaseURL,
variablesCollectionName: variablesDarkMode?.collectionName,
variablesLightModeName: variablesDarkMode?.lightModeName,
variablesDarkModeName: variablesDarkMode?.darkModeName,
variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName,
variablesFileId: variablesDarkMode?.variablesFileId
)
}

Expand Down
9 changes: 7 additions & 2 deletions Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@ public extension Flutter.IconsEntry {
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Icons",
pageName: figmaPageName,
useSingleFile: darkFileId == nil,
useSingleFile: darkFileId == nil && variablesDarkMode == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp,
penpotBaseURL: resolvedPenpotBaseURL
penpotBaseURL: resolvedPenpotBaseURL,
variablesCollectionName: variablesDarkMode?.collectionName,
variablesLightModeName: variablesDarkMode?.lightModeName,
variablesDarkModeName: variablesDarkMode?.darkModeName,
variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName,
variablesFileId: variablesDarkMode?.variablesFileId
)
}

Expand Down
9 changes: 7 additions & 2 deletions Sources/ExFig-Web/Config/WebIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@ public extension Web.IconsEntry {
darkFileId: darkFileId,
frameName: figmaFrameName ?? "Icons",
pageName: figmaPageName,
useSingleFile: darkFileId == nil,
useSingleFile: darkFileId == nil && variablesDarkMode == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp,
penpotBaseURL: resolvedPenpotBaseURL
penpotBaseURL: resolvedPenpotBaseURL,
variablesCollectionName: variablesDarkMode?.collectionName,
variablesLightModeName: variablesDarkMode?.lightModeName,
variablesDarkModeName: variablesDarkMode?.darkModeName,
variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName,
variablesFileId: variablesDarkMode?.variablesFileId
)
}

Expand Down
6 changes: 6 additions & 0 deletions Sources/ExFig-iOS/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ All asset types support dark variants via `AssetPair<T>`. Pattern:
- Icons: dark variant filenames suffixed with `D` (e.g., `iconD.pdf`)
- Images: dark variants in imageset with `appearances: [luminosity: dark]`

**Icons dark mode approaches** (mutually exclusive):

1. `darkFileId` — separate Figma file for dark icons
2. `useSingleFile` + `darkModeSuffix` — dark icons in same file, split by name suffix
3. Variable modes — `variablesCollectionName` + mode names → SVG color replacement via `VariableModeDarkGenerator`

### Output Cleanup

Exporters remove old output dirs (`FileManager.removeItem`) before writing when:
Expand Down
Loading
Loading