Skip to content

feat(icons): Add dark mode generation via Figma Variable Modes#78

Merged
alexey1312 merged 17 commits intomainfrom
feature/variable-dark-mode
Mar 27, 2026
Merged

feat(icons): Add dark mode generation via Figma Variable Modes#78
alexey1312 merged 17 commits intomainfrom
feature/variable-dark-mode

Conversation

@alexey1312
Copy link
Copy Markdown
Collaborator

Description

  • Add dark mode SVG generation from Figma Variable bindings (variablesDarkMode on FrameSource)
  • Wrap dark mode config into nested VariablesDarkMode and SuffixDarkMode PKL objects
  • Implement cross-file variable resolution when primitives live in an external Figma library (variablesFileId)
  • Cache Variables API responses across parallel entries via VariablesCache (Lock + Task dedup)
  • Handle alpha/opacity in SVG color replacement — transparent dark values now correctly add fill-opacity/stroke-opacity
  • Show dark variant count in export success message ("Exported 47 icons (47 dark variants)")
  • Add debug-level logs for verbose mode tracing of variable resolution

How it works

  1. Fetch variable definitions from the icons file (semantic variables) and optionally from a library file (primitives)
  2. Fetch icon nodes to discover boundVariables on fills/strokes
  3. Resolve each variable's dark mode value following alias chains — cross-file matching uses variable name and mode name (Figma variable IDs are file-scoped)
  4. Replace hex colors in downloaded SVGs and add opacity attributes where needed
  5. Write dark SVG variants to temp files, paired with light variants in asset catalogs

Additional notes

  • Figma variable IDs are file-scoped — alias targets from one file can't be found by ID in another. This required name-based cross-file resolution (resolveViaLibrary)
  • registerPklTypes in pkl-swift is a performance optimization, not a correctness requirement for concrete Decodable types — corrected misleading docs from initial debugging
  • PKL test fixture and PKLEvaluatorTests updated to cover variablesDarkMode deserialization
  • SVGColorReplacerTests expanded from 11 to 20 test cases (alpha/opacity coverage)

Enable dark SVG variant generation by resolving Figma Variable bindings
instead of requiring separate dark files or suffix-based splitting.

- Add `boundVariables` to Paint in swift-figma-api (0.3.0)
- Add 4 PKL fields to FrameSource: variablesCollectionName,
  variablesLightModeName, variablesDarkModeName, variablesPrimitivesModeName
- Propagate fields through IconsSourceInput and all platform entry bridges
- Create SVGColorReplacer for hex color replacement in SVG content
- Create VariableModeDarkGenerator for resolving variable alias chains
  and generating dark SVGs via color substitution
- Integrate as third dark mode approach in FigmaComponentsSource.loadIcons
- CLAUDE.md: add variable-mode pattern, data flow, and three dark mode approaches
- CONFIG.md: add 4 new FrameSource fields to configuration reference
- ExFigCLI/CLAUDE.md: add VariableModeDarkGenerator and SVGColorReplacer to key files
- ExFigCore/CLAUDE.md: document new IconsSourceInput variable-mode fields
- ExFig-iOS/CLAUDE.md: add variable modes as third dark mode approach
- PKL examples: add DoubleColor icons entry with variable-mode config
BREAKING CHANGE: Replace flat `useSingleFile`/`darkModeSuffix` fields
with nested `suffixDarkMode: SuffixDarkMode?` on Common.Icons/Images/Colors.
Replace 4 flat variable-mode fields on FrameSource with nested
`variablesDarkMode: VariablesDarkMode?`.

- Add `Common.VariablesDarkMode` class (collectionName, lightModeName,
  darkModeName, primitivesModeName)
- Add `Common.SuffixDarkMode` class (suffix with default "_dark")
- Update all loaders to read from nested objects
- Update all 4 platform entry bridges
- Update init templates, PKL examples, DocC articles
- Update CONFIG.md, CLAUDE.md, and llms-full.txt
…nner

- Add Common.VariablesDarkMode and Common.SuffixDarkMode to registerPklTypes()
  in PKLEvaluator.swift — missing registration caused pkl-swift to silently
  return nil for variablesDarkMode field, preventing dark icon generation
- Fix Spinner non-animated mode (verbose/quiet): remove start() output to avoid
  duplicated messages; only stop() prints the final ✓/✗ result
- Apply VariableModeDarkGenerator in granular cache path via applyVariableModeDark()
- Document all three root causes in CLAUDE.md and module CLAUDE.md files
…ion test

pkl-swift TypeRegistry is only for PklAny polymorphic decoding and
performance — concrete Decodable structs decode via synthesized init(from:)
regardless of registerPklTypes. Corrected misleading "silent nil" claims.

- Add PKLEvaluatorTests.evaluatesVariablesDarkMode proving deserialization works
- Add diagnostic log in FigmaComponentsSource to trace variablesDarkMode values
- Add PKL deserialization debugging guide to ExFigConfig CLAUDE.md
Figma variable IDs are file-scoped — alias targets from the icons file
don't exist in library files by ID. When variables reference an external
library (common in design systems with semantic + primitive layers),
dark mode generation failed silently because alias chains couldn't be
resolved.

Added `variablesFileId` to `VariablesDarkMode` PKL config. When set,
variables are loaded from BOTH files: icons file (semantic variables
matching node boundVariables) + library file (primitives). Cross-file
matching uses variable NAME and mode NAME (not file-scoped IDs).

- Add `variablesFileId: String?` to Common.VariablesDarkMode PKL schema
- Add `variablesFileId` to IconsSourceInput and all platform entry bridges
- Implement `resolveViaLibrary()` name-based fallback in VariableModeDarkGenerator
- Add debug-level logs for verbose mode tracing
- Update PKL test fixture and PKLEvaluatorTests
Add darkCount to IconsExportResult so the success message reflects
generated dark variants: "Exported 47 icons (47 dark variants)".
Add VariablesCache (Lock + Task dedup) to avoid redundant Figma
Variables API calls when multiple icon entries share the same fileId.
With 15 DC entries, this reduces 30 API calls to exactly 2.

- New VariablesCache class using NSLock + Task for concurrent dedup
- Injected per platform section: PluginIconsExport → SourceFactory →
  FigmaComponentsSource → VariableModeDarkGenerator
- Also threaded through IconsExportContextImpl for granular cache path
- CONFIG.md: add variablesFileId to VariablesDarkMode field list
- iOSIcons.md: add "Variable Mode" section with PKL example
- AndroidIcons.md: add "Variable Mode" section with PKL example
- exfig-ios.pkl example: add variablesFileId usage
SVG hex replacement now handles opacity changes via ColorReplacement(hex, alpha).
When the dark variable value has alpha < 1.0 (e.g., transparent background),
the replacer adds fill-opacity/stroke-opacity attributes to SVG elements.

This fixes double-color icons where the dark mode background should be
fully transparent (alpha: 0) but was rendered as opaque.

- Add ColorReplacement struct with hex + alpha fields
- SVGColorReplacer adds -opacity attributes when alpha < 1.0
- Update VariableModeDarkGenerator to propagate alpha from PaintColor
- Add 9 new test cases for alpha/opacity handling
@alexey1312
Copy link
Copy Markdown
Collaborator Author

@gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new 'Variable-mode' dark icon generation feature, which resolves Figma Variable bindings to automatically generate dark SVG variants. Key changes include the addition of VariableModeDarkGenerator and VariablesCache to handle variable resolution and caching, updates to the IconsExportContext to support this generation flow, and modifications to the PKL configuration schemas to support variablesDarkMode and suffixDarkMode configurations. I have reviewed the code and suggest conforming ColorReplacement to Equatable to improve comparison robustness.

@gemini-code-assist
Copy link
Copy Markdown

Warning

Gemini is experiencing higher than usual traffic and was unable to create the review. Please try again in a few hours by commenting /gemini review.

…dark mode

- Add warning/debug logs to all guard/continue paths in processLightPacks
- Add debug logs in resolveDarkColor for deleted, not-found, and non-color variables
- Add precondition for empty fileId in Config.init
- Extract maxAliasDepth constant, use UUID in temp directory name
- Add failed-task eviction in VariablesCache for transient error retry
- Add stderr fallback in SVGColorReplacer for release-build regex errors
- Add 18 unit tests for VariableModeDarkGenerator (alias chains, cross-file, depth limit)
- Add 3 tests for VariablesCache (dedup, separate fileIds, eviction)
- Add 2 tests for SVGColorReplacer (flood-color, lighting-color)
@alexey1312
Copy link
Copy Markdown
Collaborator Author

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new variable-mode dark icon generation feature, allowing dark SVG variants to be created by resolving Figma Variable bindings and performing hex color replacement. It includes the new VariableModeDarkGenerator and SVGColorReplacer utilities, updates to the PKL configuration schemas to support variablesDarkMode, and integration across all platform exporters. My feedback focuses on optimizing the VariablesCache lifecycle to enable cross-platform sharing, hardening the SVG color replacement regex to prevent partial hex matches, and reducing code duplication in the source loading logic.

…ry variable names

- Add lookahead (?=[;"'\s]|$) to CSS regex patterns to prevent matching
  #aabbcc inside #aabbccdd (8-digit hex)
- Warn when library file has duplicate variable names across collections
- Extract buildLibNameIndex helper to keep function body under 60 lines
ComponentPreFetcher only works in batch mode (BatchSharedState is nil
in standalone). Add ComponentsCache (same Lock+Task dedup pattern as
VariablesCache) to deduplicate Components API calls when multiple icon
entries share the same fileId — reduces 15 identical API calls to 1.
Update mise tools to latest versions (dprint 0.53.1, hk 1.39.0,
git-cliff 2.12.0, actionlint 1.7.11, pkl 0.31.1, usage 3.2.0,
xcsift 1.2.0) and regenerate mise bootstrap (2026.3.17).

Add plugin sync checklist, MCP guide sync docs, and design
requirements for variable-mode dark icons. Improve TerminalUI
with formatLink helper and update MCP tool handlers with
variablesDarkMode support.
Strip trailing blank lines in generate-llms.sh output and add
depends = "llms-check" to hk newlines hook to prevent race condition
where newlines check runs before llms regeneration completes.
@alexey1312 alexey1312 merged commit 1b0d0e6 into main Mar 27, 2026
4 checks passed
@alexey1312 alexey1312 deleted the feature/variable-dark-mode branch March 27, 2026 16:49
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.

1 participant