From cf84de12e9da236b5418f7a21c23fbf9415481dc Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 10:51:37 +0500 Subject: [PATCH 01/17] feat(icons): add dark mode generation via Figma Variable Modes 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 --- Package.resolved | 6 +- .../Config/AndroidIconsEntry.swift | 8 +- .../Config/FlutterIconsEntry.swift | 8 +- Sources/ExFig-Web/Config/WebIconsEntry.swift | 8 +- Sources/ExFig-iOS/Config/iOSIconsEntry.swift | 8 +- .../Loaders/VariableModeDarkGenerator.swift | 297 ++++++++++++++++++ .../ExFigCLI/Output/SVGColorReplacer.swift | 55 ++++ Sources/ExFigCLI/Resources/Schemas/Common.pkl | 15 + .../Source/FigmaComponentsSource.swift | 20 ++ .../ExFigConfig/Generated/Android.pkl.swift | 46 +++ .../ExFigConfig/Generated/Common.pkl.swift | 31 ++ .../ExFigConfig/Generated/Flutter.pkl.swift | 46 +++ Sources/ExFigConfig/Generated/Web.pkl.swift | 46 +++ Sources/ExFigConfig/Generated/iOS.pkl.swift | 46 +++ .../Protocol/IconsExportContext.swift | 22 +- .../ExFigTests/Input/EnumBridgingTests.swift | 32 ++ .../Input/PenpotDesignSourceTests.swift | 28 ++ .../Output/SVGColorReplacerTests.swift | 99 ++++++ 18 files changed, 809 insertions(+), 12 deletions(-) create mode 100644 Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift create mode 100644 Sources/ExFigCLI/Output/SVGColorReplacer.swift create mode 100644 Tests/ExFigTests/Output/SVGColorReplacerTests.swift diff --git a/Package.resolved b/Package.resolved index bbbb1b1b..06e95b13 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "31011d74ecc5927c563e211c66d6bb120f64c77db99e16e9faf7b2c17b959053", + "originHash" : "0174127c4f0a77235f54c6f12dac004075c2f8b505e4e338cfb70e60ba26609d", "pins" : [ { "identity" : "aexml", @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DesignPipe/swift-figma-api.git", "state" : { - "revision" : "028e774bc9b7ff23021a684852076dc2e48da316", - "version" : "0.2.0" + "revision" : "1dd19cf2e3aa38641552d7542bea5e68375d98ab", + "version" : "0.3.0" } }, { diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index 577638ae..65db3317 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -20,12 +20,16 @@ public extension Android.IconsEntry { frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, format: .svg, - useSingleFile: darkFileId == nil, + useSingleFile: darkFileId == nil && variablesCollectionName == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, - penpotBaseURL: resolvedPenpotBaseURL + penpotBaseURL: resolvedPenpotBaseURL, + variablesCollectionName: variablesCollectionName, + variablesLightModeName: variablesLightModeName, + variablesDarkModeName: variablesDarkModeName, + variablesPrimitivesModeName: variablesPrimitivesModeName ) } diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index 3c78d853..d5302652 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -16,12 +16,16 @@ public extension Flutter.IconsEntry { darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, - useSingleFile: darkFileId == nil, + useSingleFile: darkFileId == nil && variablesCollectionName == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, - penpotBaseURL: resolvedPenpotBaseURL + penpotBaseURL: resolvedPenpotBaseURL, + variablesCollectionName: variablesCollectionName, + variablesLightModeName: variablesLightModeName, + variablesDarkModeName: variablesDarkModeName, + variablesPrimitivesModeName: variablesPrimitivesModeName ) } diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index 02cbd0f2..f1480414 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -16,12 +16,16 @@ public extension Web.IconsEntry { darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, - useSingleFile: darkFileId == nil, + useSingleFile: darkFileId == nil && variablesCollectionName == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, - penpotBaseURL: resolvedPenpotBaseURL + penpotBaseURL: resolvedPenpotBaseURL, + variablesCollectionName: variablesCollectionName, + variablesLightModeName: variablesLightModeName, + variablesDarkModeName: variablesDarkModeName, + variablesPrimitivesModeName: variablesPrimitivesModeName ) } diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index a7267494..cb241c0e 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -19,7 +19,7 @@ public extension iOS.IconsEntry { frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, format: coreVectorFormat, - useSingleFile: darkFileId == nil, + useSingleFile: darkFileId == nil && variablesCollectionName == nil, darkModeSuffix: "_dark", renderMode: coreRenderMode, renderModeDefaultSuffix: renderModeDefaultSuffix, @@ -28,7 +28,11 @@ public extension iOS.IconsEntry { rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, - penpotBaseURL: resolvedPenpotBaseURL + penpotBaseURL: resolvedPenpotBaseURL, + variablesCollectionName: variablesCollectionName, + variablesLightModeName: variablesLightModeName, + variablesDarkModeName: variablesDarkModeName, + variablesPrimitivesModeName: variablesPrimitivesModeName ) } diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift new file mode 100644 index 00000000..5bbf4194 --- /dev/null +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -0,0 +1,297 @@ +import ExFigCore +import FigmaAPI +import Foundation +import Logging + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Generates dark SVG variants from light SVGs by resolving Figma Variable bindings. +/// +/// Given light icon packs and a variables collection with light/dark modes, this generator: +/// 1. Fetches variable definitions from the Figma Variables API +/// 2. Fetches icon nodes to discover `boundVariables` on fills/strokes +/// 3. Resolves each variable's dark mode value (following alias chains) +/// 4. Downloads light SVGs, replaces hex colors, and writes dark SVGs to temp files +struct VariableModeDarkGenerator { + struct Config { + let fileId: String + let collectionName: String + let lightModeName: String + let darkModeName: String + let primitivesModeName: String? + } + + /// Resolved mode IDs for variable resolution. + private struct ModeContext { + let lightModeId: String + let darkModeId: String + let primitivesModeId: String? + } + + private let client: Client + private let logger: Logger + + init(client: Client, logger: Logger) { + self.client = client + self.logger = logger + } + + /// Generates dark SVG variants by resolving variable bindings and replacing colors. + /// + /// - Parameters: + /// - lightPacks: Light mode icon packs (must be SVG format with Figma URLs). + /// - config: Variables collection configuration. + /// - Returns: Dark mode icon packs with modified SVGs saved to temp files. + func generateDarkVariants( + lightPacks: [ImagePack], + config: Config + ) async throws -> [ImagePack] { + guard !lightPacks.isEmpty else { return [] } + + // 1. Fetch variable definitions + let variablesMeta = try await loadVariables(fileId: config.fileId) + + // 2. Find collection and extract mode IDs + guard let modes = findModeIds(in: variablesMeta, config: config) else { + logger.warning("Variables collection '\(config.collectionName)' not found or missing modes") + return [] + } + + // 3. Fetch nodes to discover boundVariables on paints + let nodeIds = lightPacks.compactMap(\.nodeId) + + guard !nodeIds.isEmpty else { return [] } + + let nodeMap = try await fetchNodesBatched(fileId: config.fileId, nodeIds: nodeIds) + + // 4. For each icon, build light→dark color map from boundVariables + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("exfig-variable-dark-\(ProcessInfo.processInfo.processIdentifier)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + var darkPacks: [ImagePack] = [] + + for pack in lightPacks { + guard let nodeId = pack.nodeId, + let node = nodeMap[nodeId] + else { continue } + + // Collect all bound variable colors from the node tree + let colorMap = buildColorMap( + node: node, + variablesMeta: variablesMeta, + modes: modes + ) + + guard !colorMap.isEmpty else { + // No bound variables — skip dark generation for this icon + continue + } + + // Download light SVG and replace colors + guard let svgImage = pack.images.first, + let svgData = try? Data(contentsOf: svgImage.url), + let svgContent = String(data: svgData, encoding: .utf8) + else { continue } + + let darkSVG = SVGColorReplacer.replaceColors(in: svgContent, colorMap: colorMap) + + // Write dark SVG to temp file + let safeName = pack.name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: " ", with: "_") + let tempURL = tempDir.appendingPathComponent("\(safeName)_dark.svg") + try Data(darkSVG.utf8).write(to: tempURL) + + darkPacks.append(ImagePack( + name: pack.name, + images: [Image( + name: pack.name, + scale: .all, + url: tempURL, + format: "svg" + )], + platform: pack.platform, + nodeId: pack.nodeId, + fileId: pack.fileId + )) + } + + return darkPacks + } + + // MARK: - Private + + private func loadVariables(fileId: String) async throws -> VariablesMeta { + let endpoint = VariablesEndpoint(fileId: fileId) + return try await client.request(endpoint) + } + + private func findModeIds(in meta: VariablesMeta, config: Config) -> ModeContext? { + for collection in meta.variableCollections.values { + guard collection.name == config.collectionName else { continue } + + var lightModeId: String? + var darkModeId: String? + var primitivesModeId: String? + + for mode in collection.modes { + if mode.name == config.lightModeName { + lightModeId = mode.modeId + } else if mode.name == config.darkModeName { + darkModeId = mode.modeId + } else if mode.name == config.primitivesModeName { + primitivesModeId = mode.modeId + } + } + + guard let light = lightModeId, let dark = darkModeId else { continue } + return ModeContext(lightModeId: light, darkModeId: dark, primitivesModeId: primitivesModeId) + } + return nil + } + + /// Fetches nodes in batches of 100 (Figma API limit). + private func fetchNodesBatched( + fileId: String, + nodeIds: [String] + ) async throws -> [String: Node] { + var allNodes: [String: Node] = [:] + let batchSize = 100 + + for batchStart in stride(from: 0, to: nodeIds.count, by: batchSize) { + let batchEnd = min(batchStart + batchSize, nodeIds.count) + let batch = Array(nodeIds[batchStart ..< batchEnd]) + let endpoint = NodesEndpoint(fileId: fileId, nodeIds: batch) + let nodes = try await client.request(endpoint) + for (key, value) in nodes { + allNodes[key] = value + } + } + + return allNodes + } + + /// Walks a node tree and collects light→dark color mappings from boundVariables on paints. + private func buildColorMap( + node: Node, + variablesMeta: VariablesMeta, + modes: ModeContext + ) -> [String: String] { + var colorMap: [String: String] = [:] + collectBoundColors( + from: node.document, + variablesMeta: variablesMeta, + modes: modes, + colorMap: &colorMap + ) + return colorMap + } + + private func collectBoundColors( + from document: Document, + variablesMeta: VariablesMeta, + modes: ModeContext, + colorMap: inout [String: String] + ) { + // Check fills + for paint in document.fills { + collectFromPaint(paint, variablesMeta: variablesMeta, modes: modes, colorMap: &colorMap) + } + + // Check strokes + if let strokes = document.strokes { + for paint in strokes { + collectFromPaint(paint, variablesMeta: variablesMeta, modes: modes, colorMap: &colorMap) + } + } + + // Recurse into children + if let children = document.children { + for child in children { + collectBoundColors(from: child, variablesMeta: variablesMeta, modes: modes, colorMap: &colorMap) + } + } + } + + private func collectFromPaint( + _ paint: Paint, + variablesMeta: VariablesMeta, + modes: ModeContext, + colorMap: inout [String: String] + ) { + guard let boundVars = paint.boundVariables, + let colorAlias = boundVars["color"], + let lightColor = paint.color + else { return } + + let lightHex = SVGColorReplacer.normalizeColor( + r: lightColor.r, + g: lightColor.g, + b: lightColor.b + ) + + // Resolve dark value for this variable + if let darkHex = resolveDarkColor( + variableId: colorAlias.id, + modeId: modes.darkModeId, + variablesMeta: variablesMeta, + primitivesModeId: modes.primitivesModeId + ) { + if lightHex != darkHex { + colorMap[lightHex] = darkHex + } + } + } + + /// Resolves a variable to its concrete color value in the given mode, following alias chains. + private func resolveDarkColor( + variableId: String, + modeId: String, + variablesMeta: VariablesMeta, + primitivesModeId: String?, + depth: Int = 0 + ) -> String? { + guard depth < 10 else { return nil } + + guard let variable = variablesMeta.variables[variableId] else { return nil } + + // Try the requested mode first, fall back to default mode of the collection + let value = variable.valuesByMode[modeId] + ?? variablesMeta.variableCollections[variable.variableCollectionId] + .flatMap { collection in + variable.valuesByMode[collection.defaultModeId] + } + + switch value { + case let .color(color): + return SVGColorReplacer.normalizeColor(r: color.r, g: color.g, b: color.b) + + case let .variableAlias(alias): + // Resolve alias — use primitives mode if available, else use the same mode + let resolvedVariable = variablesMeta.variables[alias.id] + let resolveModeId: String = if let primId = primitivesModeId { + primId + } else if let resolvedVar = resolvedVariable, + let collection = variablesMeta.variableCollections[resolvedVar.variableCollectionId] + { + collection.defaultModeId + } else { + modeId + } + + return resolveDarkColor( + variableId: alias.id, + modeId: resolveModeId, + variablesMeta: variablesMeta, + primitivesModeId: primitivesModeId, + depth: depth + 1 + ) + + default: + return nil + } + } +} diff --git a/Sources/ExFigCLI/Output/SVGColorReplacer.swift b/Sources/ExFigCLI/Output/SVGColorReplacer.swift new file mode 100644 index 00000000..4787204c --- /dev/null +++ b/Sources/ExFigCLI/Output/SVGColorReplacer.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Replaces hex colors in SVG content based on a light→dark color map. +/// +/// Used by ``VariableModeDarkGenerator`` to create dark SVG variants +/// by substituting resolved light colors with their dark counterparts. +enum SVGColorReplacer { + /// Replaces hex colors in SVG content using the provided color map. + /// + /// - Parameters: + /// - svgContent: The SVG string to process. + /// - colorMap: A mapping of normalized 6-digit lowercase hex (no `#`) from light to dark. + /// - Returns: The SVG string with colors replaced. + static func replaceColors(in svgContent: String, colorMap: [String: String]) -> String { + guard !colorMap.isEmpty else { return svgContent } + + var result = svgContent + + // Replace hex colors in SVG attributes: fill="#RRGGBB", stroke="#RRGGBB", stop-color="#RRGGBB" + // and inline CSS: fill:#RRGGBB, stroke:#RRGGBB + for (lightHex, darkHex) in colorMap { + // Match both with and without # prefix, case-insensitive + let patterns = [ + // Attribute style: fill="#aabbcc" or stroke="#AABBCC" + "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*=\\s*[\"'])#\(lightHex)([\"'])", + // CSS property style: fill:#aabbcc or stroke:#AABBCC (in style attributes) + "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*:\\s*)#\(lightHex)", + ] + + for pattern in patterns { + if let regex = try? NSRegularExpression( + pattern: pattern, + options: .caseInsensitive + ) { + let range = NSRange(result.startIndex..., in: result) + result = regex.stringByReplacingMatches( + in: result, + range: range, + withTemplate: "$1$2#\(darkHex)$3" + ) + } + } + } + + return result + } + + /// Normalizes a ``FigmaAPI.PaintColor`` (RGBA 0–1) to a 6-digit lowercase hex string without `#`. + static func normalizeColor(r: Double, g: Double, b: Double) -> String { + let ri = min(255, max(0, Int(round(r * 255)))) + let gi = min(255, max(0, Int(round(g * 255)))) + let bi = min(255, max(0, Int(round(b * 255)))) + return String(format: "%02x%02x%02x", ri, gi, bi) + } +} diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 494d1628..464b77d2 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -152,6 +152,21 @@ open class FrameSource extends NameProcessing { /// mirrored at runtime by the platform (iOS languageDirection, Android autoMirrored). /// Set to null to disable variant-based RTL detection. rtlProperty: String? = "RTL" + + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + variablesPrimitivesModeName: String? } // MARK: - Common Settings diff --git a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift index 5c8db06a..42979d04 100644 --- a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift +++ b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift @@ -34,6 +34,26 @@ struct FigmaComponentsSource: ComponentsSource { let result = try await loader.load(filter: filter) + // Variable-mode dark generation: resolve variable bindings and replace colors in SVGs + if let collectionName = input.variablesCollectionName, + let lightModeName = input.variablesLightModeName, + let darkModeName = input.variablesDarkModeName + { + let fileId = input.figmaFileId ?? params.figma?.lightFileId ?? "" + let generator = VariableModeDarkGenerator(client: client, logger: logger) + let darkPacks = try await generator.generateDarkVariants( + lightPacks: result.light, + config: .init( + fileId: fileId, + collectionName: collectionName, + lightModeName: lightModeName, + darkModeName: darkModeName, + primitivesModeName: input.variablesPrimitivesModeName + ) + ) + return IconsLoadOutput(light: result.light, dark: darkPacks) + } + return IconsLoadOutput( light: result.light, dark: result.dark ?? [] diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index 3baf3828..41ef790f 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -284,6 +284,21 @@ extension Android { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + public var variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + public var variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + public var variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + public var variablesPrimitivesModeName: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -308,6 +323,10 @@ extension Android { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesCollectionName: String?, + variablesLightModeName: String?, + variablesDarkModeName: String?, + variablesPrimitivesModeName: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -328,6 +347,10 @@ extension Android { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -394,6 +417,21 @@ extension Android { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + public var variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + public var variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + public var variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + public var variablesPrimitivesModeName: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -416,6 +454,10 @@ extension Android { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesCollectionName: String?, + variablesLightModeName: String?, + variablesDarkModeName: String?, + variablesPrimitivesModeName: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -434,6 +476,10 @@ extension Android { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index 2297a715..9b955133 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -43,6 +43,14 @@ public protocol Common_FrameSource: Common_NameProcessing { var figmaFileId: String? { get } var rtlProperty: String? { get } + + var variablesCollectionName: String? { get } + + var variablesLightModeName: String? { get } + + var variablesDarkModeName: String? { get } + + var variablesPrimitivesModeName: String? { get } } extension Common { @@ -291,6 +299,21 @@ extension Common { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + public var variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + public var variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + public var variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + public var variablesPrimitivesModeName: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -304,6 +327,10 @@ extension Common { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesCollectionName: String?, + variablesLightModeName: String?, + variablesDarkModeName: String?, + variablesPrimitivesModeName: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -313,6 +340,10 @@ extension Common { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index ac91b0ae..db41771b 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -154,6 +154,21 @@ extension Flutter { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + public var variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + public var variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + public var variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + public var variablesPrimitivesModeName: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -172,6 +187,10 @@ extension Flutter { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesCollectionName: String?, + variablesLightModeName: String?, + variablesDarkModeName: String?, + variablesPrimitivesModeName: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -186,6 +205,10 @@ extension Flutter { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -251,6 +274,21 @@ extension Flutter { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + public var variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + public var variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + public var variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + public var variablesPrimitivesModeName: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -273,6 +311,10 @@ extension Flutter { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesCollectionName: String?, + variablesLightModeName: String?, + variablesDarkModeName: String?, + variablesPrimitivesModeName: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -291,6 +333,10 @@ extension Flutter { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Web.pkl.swift b/Sources/ExFigConfig/Generated/Web.pkl.swift index eb744d5e..3842dada 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -166,6 +166,21 @@ extension Web { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + public var variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + public var variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + public var variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + public var variablesPrimitivesModeName: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -185,6 +200,10 @@ extension Web { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesCollectionName: String?, + variablesLightModeName: String?, + variablesDarkModeName: String?, + variablesPrimitivesModeName: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -200,6 +219,10 @@ extension Web { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -253,6 +276,21 @@ extension Web { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + public var variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + public var variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + public var variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + public var variablesPrimitivesModeName: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -271,6 +309,10 @@ extension Web { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesCollectionName: String?, + variablesLightModeName: String?, + variablesDarkModeName: String?, + variablesPrimitivesModeName: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -285,6 +327,10 @@ extension Web { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/iOS.pkl.swift b/Sources/ExFigConfig/Generated/iOS.pkl.swift index c79edd63..e08deb19 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -253,6 +253,21 @@ extension iOS { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + public var variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + public var variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + public var variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + public var variablesPrimitivesModeName: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -279,6 +294,10 @@ extension iOS { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesCollectionName: String?, + variablesLightModeName: String?, + variablesDarkModeName: String?, + variablesPrimitivesModeName: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -301,6 +320,10 @@ extension iOS { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -385,6 +408,21 @@ extension iOS { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Variable collection name for dark mode generation via Figma Variables. + /// When set (along with light/dark mode names), dark SVG variants are generated + /// by resolving variable bindings and replacing colors in the light SVG. + public var variablesCollectionName: String? + + /// Light mode name in the variables collection (e.g. "Light"). + public var variablesLightModeName: String? + + /// Dark mode name in the variables collection (e.g. "Dark"). + public var variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases (e.g. "Value"). + /// Used when variables reference other variables through alias chains. + public var variablesPrimitivesModeName: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -413,6 +451,10 @@ extension iOS { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + variablesCollectionName: String?, + variablesLightModeName: String?, + variablesDarkModeName: String?, + variablesPrimitivesModeName: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -437,6 +479,10 @@ extension iOS { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index b080c7f3..a578a120 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -103,6 +103,18 @@ public struct IconsSourceInput: Sendable { /// Penpot instance base URL (used when sourceKind == .penpot). public let penpotBaseURL: String? + /// Variable collection name for dark mode generation via Figma Variables. + public let variablesCollectionName: String? + + /// Light mode name in the variables collection. + public let variablesLightModeName: String? + + /// Dark mode name in the variables collection. + public let variablesDarkModeName: String? + + /// Primitives mode name for resolving variable aliases. + public let variablesPrimitivesModeName: String? + public init( sourceKind: DesignSourceKind = .figma, figmaFileId: String? = nil, @@ -119,7 +131,11 @@ public struct IconsSourceInput: Sendable { rtlProperty: String? = "RTL", nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil, - penpotBaseURL: String? = nil + penpotBaseURL: String? = nil, + variablesCollectionName: String? = nil, + variablesLightModeName: String? = nil, + variablesDarkModeName: String? = nil, + variablesPrimitivesModeName: String? = nil ) { self.sourceKind = sourceKind self.figmaFileId = figmaFileId @@ -137,6 +153,10 @@ public struct IconsSourceInput: Sendable { self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp self.penpotBaseURL = penpotBaseURL + self.variablesCollectionName = variablesCollectionName + self.variablesLightModeName = variablesLightModeName + self.variablesDarkModeName = variablesDarkModeName + self.variablesPrimitivesModeName = variablesPrimitivesModeName } } diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index 851bcda3..c3cd2bdb 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -120,6 +120,10 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -165,6 +169,10 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -206,6 +214,10 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -235,6 +247,10 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -270,6 +286,10 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -297,6 +317,10 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -361,6 +385,10 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -395,6 +423,10 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) diff --git a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift index 5a2e0081..43aa6f39 100644 --- a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift +++ b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift @@ -28,6 +28,10 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -57,6 +61,10 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -86,6 +94,10 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -115,6 +127,10 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -142,6 +158,10 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -171,6 +191,10 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -198,6 +222,10 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + variablesCollectionName: nil, + variablesLightModeName: nil, + variablesDarkModeName: nil, + variablesPrimitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) diff --git a/Tests/ExFigTests/Output/SVGColorReplacerTests.swift b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift new file mode 100644 index 00000000..0f37732b --- /dev/null +++ b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift @@ -0,0 +1,99 @@ +@testable import ExFigCLI +import XCTest + +final class SVGColorReplacerTests: XCTestCase { + // MARK: - Basic Replacement + + func testReplacesHexInFillAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": "00ff00"]) + XCTAssertEqual(result, "") + } + + func testReplacesHexInStrokeAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": "112233"]) + XCTAssertEqual(result, "") + } + + func testReplacesHexInStopColorAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff00ff": "00ffff"]) + XCTAssertEqual(result, "") + } + + // MARK: - Case Insensitive + + func testCaseInsensitiveMatch() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": "112233"]) + XCTAssertEqual(result, "") + } + + // MARK: - CSS Style Properties + + func testReplacesCSSFillProperty() { + let svg = "" + let result = SVGColorReplacer.replaceColors( + in: svg, + colorMap: ["ff0000": "111111", "00ff00": "222222"] + ) + XCTAssertTrue(result.contains("fill:#111111")) + XCTAssertTrue(result.contains("stroke:#222222")) + } + + // MARK: - Multiple Colors + + func testReplacesMultipleColors() { + let svg = """ + + + + + + """ + let result = SVGColorReplacer.replaceColors( + in: svg, + colorMap: ["ff0000": "111111", "00ff00": "222222", "0000ff": "333333"] + ) + XCTAssertTrue(result.contains("#111111")) + XCTAssertTrue(result.contains("#222222")) + XCTAssertTrue(result.contains("#333333")) + XCTAssertFalse(result.contains("#ff0000")) + XCTAssertFalse(result.contains("#00ff00")) + XCTAssertFalse(result.contains("#0000ff")) + } + + // MARK: - No Match + + func testEmptyColorMapReturnsOriginal() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: [:]) + XCTAssertEqual(result, svg) + } + + func testNoMatchingColorsReturnsOriginal() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": "112233"]) + XCTAssertEqual(result, svg) + } + + // MARK: - Color Normalization + + func testNormalizeColorFromRGBA() { + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 1.0, g: 0.0, b: 0.0), "ff0000") + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 0.0, g: 1.0, b: 0.0), "00ff00") + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 0.0, g: 0.0, b: 1.0), "0000ff") + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 0.0, g: 0.0, b: 0.0), "000000") + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 1.0, g: 1.0, b: 1.0), "ffffff") + } + + func testNormalizeColorClampsValues() { + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 1.5, g: -0.1, b: 0.5), "ff0080") + } + + func testNormalizeColorFractionalValues() { + // 0.2 * 255 = 51 = 0x33 + XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 0.2, g: 0.2, b: 0.2), "333333") + } +} From 8902ef31000e98e530a753a4b934aef99b706f98 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 10:55:53 +0500 Subject: [PATCH 02/17] docs: document variable-mode dark generation for icons - 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 --- CLAUDE.md | 19 ++++++ CONFIG.md | 20 ++++--- Sources/ExFig-iOS/CLAUDE.md | 6 ++ Sources/ExFigCLI/CLAUDE.md | 58 ++++++++++--------- .../Resources/Schemas/examples/exfig-ios.pkl | 10 ++++ Sources/ExFigCore/CLAUDE.md | 1 + 6 files changed, 78 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 252e391b..dfc29bf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,8 @@ Twelve modules in `Sources/`: **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 +**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 `useSingleFile+darkModeSuffix`. + **Batch mode:** Single `@TaskLocal` via `BatchSharedState` actor — see `ExFigCLI/CLAUDE.md`. **Entry-level parallelism:** All exporters use `parallelMapEntries()` (max 5 concurrent) — see `ExFigCore/CLAUDE.md`. @@ -229,6 +231,23 @@ 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 +2. `useSingleFile` + `darkModeSuffix` — dark icons in same file, split by name suffix +3. `variablesCollectionName` + mode names — resolve Figma Variable bindings to generate dark SVGs + +Approach 3 is configured via `FrameSource` fields: `variablesCollectionName`, `variablesLightModeName`, +`variablesDarkModeName`, `variablesPrimitivesModeName`. 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`. + ### Module Boundaries ExFigCore does NOT import FigmaAPI. Constants on `Component` (FigmaAPI, extended in ExFigCLI) are diff --git a/CONFIG.md b/CONFIG.md index 18d4fc5b..1c7c2d9a 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -319,14 +319,18 @@ 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 | +| `variablesCollectionName` | `String?` | — | Variables collection name for dark mode via Figma Variables | +| `variablesLightModeName` | `String?` | — | Light mode name in the variables collection (e.g. "Light") | +| `variablesDarkModeName` | `String?` | — | Dark mode name in the variables collection (e.g. "Dark") | +| `variablesPrimitivesModeName` | `String?` | — | Primitives mode for resolving variable aliases (e.g. "Value") | +| `nameValidateRegexp` | `String?` | — | Regex pattern for name validation | +| `nameReplaceRegexp` | `String?` | — | Replacement pattern using captured groups | **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 diff --git a/Sources/ExFig-iOS/CLAUDE.md b/Sources/ExFig-iOS/CLAUDE.md index 48408b65..4fef039e 100644 --- a/Sources/ExFig-iOS/CLAUDE.md +++ b/Sources/ExFig-iOS/CLAUDE.md @@ -85,6 +85,12 @@ All asset types support dark variants via `AssetPair`. 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: diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 1cf2ea25..0e6d4a1a 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -148,34 +148,36 @@ Converter factories (`WebpConverterFactory`, `HeicConverterFactory`) handle plat ## Key Files -| File | Role | -| ---------------------------------------- | ------------------------------------------------------------------- | -| `ExFigCommand.swift` | Entry point, version, shared instances, subcommand registration | -| `Input/ExFigOptions.swift` | PKL config loading, token validation, auto-detection | -| `Batch/BatchContext.swift` | `BatchContext`, `ConfigExecutionContext`, `BatchSharedState` actor | -| `Batch/BatchExecutor.swift` | Parallel config execution with rate limiting | -| `Plugin/PluginRegistry.swift` | Platform plugin routing (config key → plugin → exporters) | -| `TerminalUI/TerminalUI.swift` | Output facade (info/success/warning/error, spinners, progress) | -| `TerminalUI/TerminalOutputManager.swift` | Thread-safe output synchronization, animation coordination | -| `TerminalUI/BatchProgressView.swift` | Multi-config progress display with log queuing | -| `Cache/GranularCacheManager.swift` | Per-node change detection with FNV-1a hashing | -| `Pipeline/SharedDownloadQueue.swift` | Cross-config download pipelining actor | -| `Output/FileWriter.swift` | Sequential and parallel file writing with directory creation | -| `Shared/ComponentPreFetcher.swift` | Pre-fetch components for multi-entry exports | -| `Input/TokensFileSource.swift` | W3C DTCG .tokens.json parser (local file → ExFigCore models) | -| `Output/W3CTokensExporter.swift` | W3C design token JSON exporter (v1/v2025 formats) | -| `Loaders/NumberVariablesLoader.swift` | Figma number variables → dimension/number tokens | -| `Subcommands/DownloadTokens.swift` | Unified `download tokens` subcommand | -| `MCP/ExFigMCPServer.swift` | MCP server setup and lifecycle (stdio transport) | -| `MCP/MCPToolDefinitions.swift` | MCP tool schemas (export colors, icons, images, etc.) | -| `MCP/MCPToolHandlers.swift` | MCP tool request handlers | -| `MCP/MCPResources.swift` | MCP resource providers (config, schemas) | -| `MCP/MCPPrompts.swift` | MCP prompt templates | -| `MCP/MCPServerState.swift` | MCP server shared state | -| `Source/SourceFactory.swift` | Centralized factory creating source instances by `DesignSourceKind` | -| `Source/Figma*Source.swift` | Figma source implementations wrapping existing loaders | -| `Source/Penpot*Source.swift` | Penpot source implementations (colors, components, typography) | -| `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | +| File | Role | +| ----------------------------------------- | ------------------------------------------------------------------- | +| `ExFigCommand.swift` | Entry point, version, shared instances, subcommand registration | +| `Input/ExFigOptions.swift` | PKL config loading, token validation, auto-detection | +| `Batch/BatchContext.swift` | `BatchContext`, `ConfigExecutionContext`, `BatchSharedState` actor | +| `Batch/BatchExecutor.swift` | Parallel config execution with rate limiting | +| `Plugin/PluginRegistry.swift` | Platform plugin routing (config key → plugin → exporters) | +| `TerminalUI/TerminalUI.swift` | Output facade (info/success/warning/error, spinners, progress) | +| `TerminalUI/TerminalOutputManager.swift` | Thread-safe output synchronization, animation coordination | +| `TerminalUI/BatchProgressView.swift` | Multi-config progress display with log queuing | +| `Cache/GranularCacheManager.swift` | Per-node change detection with FNV-1a hashing | +| `Pipeline/SharedDownloadQueue.swift` | Cross-config download pipelining actor | +| `Output/FileWriter.swift` | Sequential and parallel file writing with directory creation | +| `Shared/ComponentPreFetcher.swift` | Pre-fetch components for multi-entry exports | +| `Input/TokensFileSource.swift` | W3C DTCG .tokens.json parser (local file → ExFigCore models) | +| `Output/W3CTokensExporter.swift` | W3C design token JSON exporter (v1/v2025 formats) | +| `Loaders/NumberVariablesLoader.swift` | Figma number variables → dimension/number tokens | +| `Subcommands/DownloadTokens.swift` | Unified `download tokens` subcommand | +| `MCP/ExFigMCPServer.swift` | MCP server setup and lifecycle (stdio transport) | +| `MCP/MCPToolDefinitions.swift` | MCP tool schemas (export colors, icons, images, etc.) | +| `MCP/MCPToolHandlers.swift` | MCP tool request handlers | +| `MCP/MCPResources.swift` | MCP resource providers (config, schemas) | +| `MCP/MCPPrompts.swift` | MCP prompt templates | +| `MCP/MCPServerState.swift` | MCP server shared state | +| `Source/SourceFactory.swift` | Centralized factory creating source instances by `DesignSourceKind` | +| `Source/Figma*Source.swift` | Figma source implementations wrapping existing loaders | +| `Source/Penpot*Source.swift` | Penpot source implementations (colors, components, typography) | +| `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | +| `Loaders/VariableModeDarkGenerator.swift` | Generates dark SVGs from light via Figma Variable bindings | +| `Output/SVGColorReplacer.swift` | Hex color replacement in SVG content (fill, stroke, stop-color) | ### MCP Server Architecture diff --git a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl index ee0a4bc5..d3866e12 100644 --- a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl @@ -49,5 +49,15 @@ ios = new iOS.iOSConfig { templatesPath = "BrandKit/Templates" // rtlProperty = "RTL" // default; set to null to disable variant-based RTL detection } + / Variable-mode dark: generate dark SVGs from Figma Variable bindings + new iOS.IconsEntry { + figmaFrameName = "DoubleColor" + format = "svg" + assetsFolder = "DoubleColorIcons" + nameStyle = "camelCase" + variablesCollectionName = "DesignTokens" + variablesLightModeName = "Light" + variablesDarkModeName = "Dark" + } } } diff --git a/Sources/ExFigCore/CLAUDE.md b/Sources/ExFigCore/CLAUDE.md index 120ae25a..a7fa9675 100644 --- a/Sources/ExFigCore/CLAUDE.md +++ b/Sources/ExFigCore/CLAUDE.md @@ -35,6 +35,7 @@ Exporter.export*(entries, platformConfig, context) - `TokensFileColorsConfig.ignoredModeNames`: carries Figma-specific mode field names set by user for warning - `IconsSourceInput`, `ImagesSourceInput`, `TypographySourceInput` have `sourceKind` field (default `.figma`) - `IconsSourceInput`, `ImagesSourceInput`, `TypographySourceInput` have `penpotBaseURL: String?` field for Penpot base URL +- `IconsSourceInput` has variable-mode dark fields: `variablesCollectionName`, `variablesLightModeName`, `variablesDarkModeName`, `variablesPrimitivesModeName` - When adding a new `ColorsSourceConfig` subtype: update `spinnerLabel` switch in `ExportContext.swift` Implementations live in `Sources/ExFigCLI/Source/` — `FigmaColorsSource`, `TokensFileColorsSource`, `PenpotColorsSource`, `PenpotComponentsSource`, `PenpotTypographySource`, `FigmaComponentsSource`, `FigmaTypographySource`, `SourceFactory`. From eb5849a9042ca1fb7a76faf324e0d5d5acf9a834 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 11:57:05 +0500 Subject: [PATCH 03/17] refactor(config)!: wrap dark mode config into nested objects 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 --- CLAUDE.md | 10 +- CONFIG.md | 56 ++++----- .../Config/AndroidIconsEntry.swift | 10 +- .../Config/FlutterIconsEntry.swift | 10 +- Sources/ExFig-Web/Config/WebIconsEntry.swift | 10 +- Sources/ExFig-iOS/Config/iOSIconsEntry.swift | 10 +- .../ExFig.docc/Android/AndroidColors.md | 3 +- .../ExFig.docc/Android/AndroidIcons.md | 3 +- .../ExFig.docc/Android/AndroidImages.md | 3 +- Sources/ExFigCLI/ExFig.docc/Configuration.md | 18 +-- .../ExFigCLI/ExFig.docc/DesignRequirements.md | 3 +- Sources/ExFigCLI/ExFig.docc/PKLGuide.md | 6 +- Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md | 3 +- Sources/ExFigCLI/ExFig.docc/iOS/iOSImages.md | 3 +- .../Loaders/Colors/ColorsLoader.swift | 4 +- Sources/ExFigCLI/Loaders/IconsLoader.swift | 8 +- Sources/ExFigCLI/Loaders/ImagesLoader.swift | 8 +- Sources/ExFigCLI/Resources/Schemas/Common.pkl | 64 +++++----- .../Resources/Schemas/examples/exfig-ios.pkl | 8 +- .../ExFigCLI/Resources/androidConfig.swift | 18 +-- .../ExFigCLI/Resources/flutterConfig.swift | 18 +-- Sources/ExFigCLI/Resources/iOSConfig.swift | 18 +-- Sources/ExFigCLI/Resources/webConfig.swift | 18 +-- .../ExFigConfig/Generated/Android.pkl.swift | 56 ++------- .../ExFigConfig/Generated/Common.pkl.swift | 118 +++++++++--------- .../ExFigConfig/Generated/Flutter.pkl.swift | 56 ++------- Sources/ExFigConfig/Generated/Web.pkl.swift | 56 ++------- Sources/ExFigConfig/Generated/iOS.pkl.swift | 56 ++------- Tests/ExFigTests/Helpers/TestHelpers.swift | 30 ++--- .../ExFigTests/Input/EnumBridgingTests.swift | 40 ++---- .../Input/PenpotDesignSourceTests.swift | 35 ++---- .../Loaders/ColorsLoaderTests.swift | 4 +- ...IconsLoaderGranularCachePairingTests.swift | 8 +- llms-full.txt | 21 +--- 34 files changed, 280 insertions(+), 512 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dfc29bf7..89d9ee19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,12 +235,12 @@ When relocating a type (e.g., `Android.WebpOptions` → `Common.WebpOptions`), u Three dark mode approaches for icons (mutually exclusive): -1. `darkFileId` — separate Figma file for dark icons -2. `useSingleFile` + `darkModeSuffix` — dark icons in same file, split by name suffix -3. `variablesCollectionName` + mode names — resolve Figma Variable bindings to generate dark SVGs +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 `FrameSource` fields: `variablesCollectionName`, `variablesLightModeName`, -`variablesDarkModeName`, `variablesPrimitivesModeName`. Integration point: `FigmaComponentsSource.loadIcons()`. +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()`) diff --git a/CONFIG.md b/CONFIG.md index 1c7c2d9a..e9fa66d2 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -205,10 +205,8 @@ 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" @@ -216,14 +214,13 @@ colors = new Common.Colors { } ``` -| 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) @@ -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 } @@ -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" } } ``` @@ -319,18 +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 | -| `variablesCollectionName` | `String?` | — | Variables collection name for dark mode via Figma Variables | -| `variablesLightModeName` | `String?` | — | Light mode name in the variables collection (e.g. "Light") | -| `variablesDarkModeName` | `String?` | — | Dark mode name in the variables collection (e.g. "Dark") | -| `variablesPrimitivesModeName` | `String?` | — | Primitives mode for resolving variable aliases (e.g. "Value") | -| `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). **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 diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index 65db3317..0d94da8b 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -20,16 +20,16 @@ public extension Android.IconsEntry { frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, format: .svg, - useSingleFile: darkFileId == nil && variablesCollectionName == nil, + useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL, - variablesCollectionName: variablesCollectionName, - variablesLightModeName: variablesLightModeName, - variablesDarkModeName: variablesDarkModeName, - variablesPrimitivesModeName: variablesPrimitivesModeName + variablesCollectionName: variablesDarkMode?.collectionName, + variablesLightModeName: variablesDarkMode?.lightModeName, + variablesDarkModeName: variablesDarkMode?.darkModeName, + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName ) } diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index d5302652..5a119616 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -16,16 +16,16 @@ public extension Flutter.IconsEntry { darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, - useSingleFile: darkFileId == nil && variablesCollectionName == nil, + useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL, - variablesCollectionName: variablesCollectionName, - variablesLightModeName: variablesLightModeName, - variablesDarkModeName: variablesDarkModeName, - variablesPrimitivesModeName: variablesPrimitivesModeName + variablesCollectionName: variablesDarkMode?.collectionName, + variablesLightModeName: variablesDarkMode?.lightModeName, + variablesDarkModeName: variablesDarkMode?.darkModeName, + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName ) } diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index f1480414..2c217f18 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -16,16 +16,16 @@ public extension Web.IconsEntry { darkFileId: darkFileId, frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, - useSingleFile: darkFileId == nil && variablesCollectionName == nil, + useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL, - variablesCollectionName: variablesCollectionName, - variablesLightModeName: variablesLightModeName, - variablesDarkModeName: variablesDarkModeName, - variablesPrimitivesModeName: variablesPrimitivesModeName + variablesCollectionName: variablesDarkMode?.collectionName, + variablesLightModeName: variablesDarkMode?.lightModeName, + variablesDarkModeName: variablesDarkMode?.darkModeName, + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName ) } diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index cb241c0e..19d20584 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -19,7 +19,7 @@ public extension iOS.IconsEntry { frameName: figmaFrameName ?? "Icons", pageName: figmaPageName, format: coreVectorFormat, - useSingleFile: darkFileId == nil && variablesCollectionName == nil, + useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", renderMode: coreRenderMode, renderModeDefaultSuffix: renderModeDefaultSuffix, @@ -29,10 +29,10 @@ public extension iOS.IconsEntry { nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL, - variablesCollectionName: variablesCollectionName, - variablesLightModeName: variablesLightModeName, - variablesDarkModeName: variablesDarkModeName, - variablesPrimitivesModeName: variablesPrimitivesModeName + variablesCollectionName: variablesDarkMode?.collectionName, + variablesLightModeName: variablesDarkMode?.lightModeName, + variablesDarkModeName: variablesDarkMode?.darkModeName, + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName ) } diff --git a/Sources/ExFigCLI/ExFig.docc/Android/AndroidColors.md b/Sources/ExFigCLI/ExFig.docc/Android/AndroidColors.md index 8a156ff4..780ae8ba 100644 --- a/Sources/ExFigCLI/ExFig.docc/Android/AndroidColors.md +++ b/Sources/ExFigCLI/ExFig.docc/Android/AndroidColors.md @@ -207,8 +207,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { colors = new Common.Colors { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md b/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md index 0972166f..d788382e 100644 --- a/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md +++ b/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md @@ -260,8 +260,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { icons = new Common.Icons { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/Android/AndroidImages.md b/Sources/ExFigCLI/ExFig.docc/Android/AndroidImages.md index b69b3c5f..e4149f3b 100644 --- a/Sources/ExFigCLI/ExFig.docc/Android/AndroidImages.md +++ b/Sources/ExFigCLI/ExFig.docc/Android/AndroidImages.md @@ -229,8 +229,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { images = new Common.Images { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/Configuration.md b/Sources/ExFigCLI/ExFig.docc/Configuration.md index 452120df..bca76d4c 100644 --- a/Sources/ExFigCLI/ExFig.docc/Configuration.md +++ b/Sources/ExFigCLI/ExFig.docc/Configuration.md @@ -76,11 +76,7 @@ common = new Common.CommonConfig { // Regex replacement for color names nameReplaceRegexp = "$1" - // Extract light and dark mode colors from a single file - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -213,11 +209,7 @@ common = new Common.CommonConfig { // Regex replacement for icon names nameReplaceRegexp = "ic_$1" - // Use single file for light/dark (default: false) - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -238,11 +230,7 @@ common = new Common.CommonConfig { // Regex replacement for image names nameReplaceRegexp = "$1" - // Use single file for light/dark (default: false) - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md index c73c8b9c..40d5e936 100644 --- a/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md +++ b/Sources/ExFigCLI/ExFig.docc/DesignRequirements.md @@ -169,8 +169,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { icons = new Common.Icons { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/PKLGuide.md b/Sources/ExFigCLI/ExFig.docc/PKLGuide.md index 8afb2590..7efe4a62 100755 --- a/Sources/ExFigCLI/ExFig.docc/PKLGuide.md +++ b/Sources/ExFigCLI/ExFig.docc/PKLGuide.md @@ -483,16 +483,14 @@ common = new Common.CommonConfig { // Shared icons settings icons = new Common.Icons { figmaFrameName = "Icons/24" - useSingleFile = false - darkModeSuffix = "-dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "-dark" } strictPathValidation = true } // Shared images settings images = new Common.Images { figmaFrameName = "Illustrations" - useSingleFile = false - darkModeSuffix = "-dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "-dark" } } // Name processing (applies to all) diff --git a/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md b/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md index f75b09c1..890c63e8 100644 --- a/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md +++ b/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md @@ -291,8 +291,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { icons = new Common.Icons { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/ExFig.docc/iOS/iOSImages.md b/Sources/ExFigCLI/ExFig.docc/iOS/iOSImages.md index 6c8ebd8e..97223822 100644 --- a/Sources/ExFigCLI/ExFig.docc/iOS/iOSImages.md +++ b/Sources/ExFigCLI/ExFig.docc/iOS/iOSImages.md @@ -311,8 +311,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { images = new Common.Images { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` diff --git a/Sources/ExFigCLI/Loaders/Colors/ColorsLoader.swift b/Sources/ExFigCLI/Loaders/Colors/ColorsLoader.swift index b9775613..a1242ded 100644 --- a/Sources/ExFigCLI/Loaders/Colors/ColorsLoader.swift +++ b/Sources/ExFigCLI/Loaders/Colors/ColorsLoader.swift @@ -30,7 +30,7 @@ final class ColorsLoader: Sendable { "Use common.variablesColors or multi-entry colors format instead." ) } - guard let useSingleFile = colorParams?.useSingleFile, useSingleFile else { + guard colorParams?.suffixDarkMode != nil else { return try await loadColorsFromLightAndDarkFile() } return try await loadColorsFromSingleFile() @@ -87,7 +87,7 @@ final class ColorsLoader: Sendable { // swiftlint:disable:next force_unwrapping let colors = try await loadColors(fileId: figmaParams.lightFileId!) - let darkSuffix = colorParams?.darkModeSuffix ?? "_dark" + let darkSuffix = colorParams?.suffixDarkMode?.suffix ?? "_dark" let lightHCSuffix = colorParams?.lightHCModeSuffix ?? "_lightHC" let darkHCSuffix = colorParams?.darkHCModeSuffix ?? "_darkHC" diff --git a/Sources/ExFigCLI/Loaders/IconsLoader.swift b/Sources/ExFigCLI/Loaders/IconsLoader.swift index 3036f445..354d8a59 100644 --- a/Sources/ExFigCLI/Loaders/IconsLoader.swift +++ b/Sources/ExFigCLI/Loaders/IconsLoader.swift @@ -151,7 +151,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> IconsLoaderOutput { - if let useSingleFile = params.common?.icons?.useSingleFile, useSingleFile { + if params.common?.icons?.suffixDarkMode != nil { try await loadFromSingleFile(filter: filter, onBatchProgress: onBatchProgress) } else { try await loadFromLightAndDarkFile(filter: filter, onBatchProgress: onBatchProgress) @@ -167,7 +167,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> IconsLoaderResultWithHashes { - if let useSingleFile = params.common?.icons?.useSingleFile, useSingleFile { + if params.common?.icons?.suffixDarkMode != nil { try await loadFromSingleFileWithGranularCache( filter: filter, onBatchProgress: onBatchProgress ) @@ -197,7 +197,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { onBatchProgress: onBatchProgress ) - let darkSuffix = params.common?.icons?.darkModeSuffix ?? "_dark" + let darkSuffix = params.common?.icons?.suffixDarkMode?.suffix ?? "_dark" let (lightIcons, darkIcons) = splitByDarkMode(icons, darkSuffix: darkSuffix) return ( @@ -298,7 +298,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { ) async throws -> IconsLoaderResultWithHashes { let formatParams = makeFormatParams() let fileId = try requireLightFileId(entryFileId: config.entryFileId) - let darkSuffix = params.common?.icons?.darkModeSuffix ?? "_dark" + let darkSuffix = params.common?.icons?.suffixDarkMode?.suffix ?? "_dark" // Use pairing-aware method to ensure light/dark pairs are exported together let result = try await loadVectorImagesWithGranularCacheAndPairing( diff --git a/Sources/ExFigCLI/Loaders/ImagesLoader.swift b/Sources/ExFigCLI/Loaders/ImagesLoader.swift index b52adb2f..53c83205 100644 --- a/Sources/ExFigCLI/Loaders/ImagesLoader.swift +++ b/Sources/ExFigCLI/Loaders/ImagesLoader.swift @@ -222,7 +222,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesLoaderOutput { - if let useSingleFile = params.common?.images?.useSingleFile, useSingleFile { + if params.common?.images?.suffixDarkMode != nil { try await loadFromSingleFile(filter: filter, onBatchProgress: onBatchProgress) } else { try await loadFromLightAndDarkFile(filter: filter, onBatchProgress: onBatchProgress) @@ -242,7 +242,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesLoaderResultWithHashes { - if let useSingleFile = params.common?.images?.useSingleFile, useSingleFile { + if params.common?.images?.suffixDarkMode != nil { try await loadFromSingleFileWithGranularCache( filter: filter, onBatchProgress: onBatchProgress ) @@ -259,7 +259,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: String? = nil, onBatchProgress: @escaping BatchProgressCallback ) async throws -> ImagesLoaderOutput { - let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" + let darkSuffix = params.common?.images?.suffixDarkMode?.suffix ?? "_dark" let fileId = try requireLightFileId(entryFileId: config.entryFileId) if isRasterFormat, !useSVGSource { @@ -409,7 +409,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di onBatchProgress: @escaping BatchProgressCallback ) async throws -> ImagesLoaderResultWithHashes { let fileId = try requireLightFileId(entryFileId: config.entryFileId) - let darkSuffix = params.common?.images?.darkModeSuffix ?? "_dark" + let darkSuffix = params.common?.images?.suffixDarkMode?.suffix ?? "_dark" if isRasterFormat, !useSVGSource { // PNG source: Raster images (PNG/WebP) with granular cache diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 464b77d2..4fb1b1cf 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -60,6 +60,31 @@ class PenpotSource { pathFilter: String? } +// MARK: - Dark Mode + +/// Dark mode via Figma Variable bindings (per-entry). +/// Resolves variable values in dark mode and replaces colors in exported SVGs. +class VariablesDarkMode { + /// Variable collection name (e.g., "DesignTokens"). + collectionName: String(isNotEmpty) + + /// Light mode name in the collection (e.g., "Light"). + lightModeName: String(isNotEmpty) + + /// Dark mode name in the collection (e.g., "Dark"). + darkModeName: String(isNotEmpty) + + /// Primitives mode for resolving variable aliases (e.g., "Value"). + primitivesModeName: String? +} + +/// Dark mode via name suffix splitting (global, on common.icons/images/colors). +/// Icons/images with the suffix are treated as dark variants. +class SuffixDarkMode { + /// Dark mode name suffix (e.g., "_dark"). + suffix: String(isNotEmpty) = "_dark" +} + // MARK: - Cache /// Cache configuration for tracking Figma file versions. @@ -153,31 +178,18 @@ open class FrameSource extends NameProcessing { /// Set to null to disable variant-based RTL detection. rtlProperty: String? = "RTL" - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + variablesDarkMode: VariablesDarkMode? } // MARK: - Common Settings /// Common colors settings shared across platforms. class Colors extends NameProcessing { - /// Use single file for all color modes. - useSingleFile: Boolean? - - /// Suffix for dark mode colors. - darkModeSuffix: String? + /// Dark mode via name suffix splitting. + suffixDarkMode: SuffixDarkMode? /// Suffix for light high contrast colors. lightHCModeSuffix: String? @@ -194,11 +206,8 @@ class Icons extends NameProcessing { /// Figma page name to filter icons by. figmaPageName: String? - /// Use single file for all icon modes. - useSingleFile: Boolean? - - /// Suffix for dark mode icons. - darkModeSuffix: String? + /// Dark mode via name suffix splitting. + suffixDarkMode: SuffixDarkMode? /// If true, exit with error when pathData exceeds 32,767 bytes (AAPT limit). strictPathValidation: Boolean? @@ -212,11 +221,8 @@ class Images extends NameProcessing { /// Figma page name to filter images by. figmaPageName: String? - /// Use single file for all image modes. - useSingleFile: Boolean? - - /// Suffix for dark mode images. - darkModeSuffix: String? + /// Dark mode via name suffix splitting. + suffixDarkMode: SuffixDarkMode? } /// Common typography settings shared across platforms. diff --git a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl index d3866e12..952add34 100644 --- a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl @@ -55,9 +55,11 @@ ios = new iOS.iOSConfig { format = "svg" assetsFolder = "DoubleColorIcons" nameStyle = "camelCase" - variablesCollectionName = "DesignTokens" - variablesLightModeName = "Light" - variablesDarkModeName = "Dark" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" + lightModeName = "Light" + darkModeName = "Dark" + } } } } diff --git a/Sources/ExFigCLI/Resources/androidConfig.swift b/Sources/ExFigCLI/Resources/androidConfig.swift index d7a93b89..1e773157 100644 --- a/Sources/ExFigCLI/Resources/androidConfig.swift +++ b/Sources/ExFigCLI/Resources/androidConfig.swift @@ -31,10 +31,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^([a-zA-Z_]+)$" // RegExp pattern for: background, background_primary, widget_primary_background // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "color_$1" - // [optional] Extract light and dark mode colors from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode color. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] Use variablesColors instead of colors to export colors from Figma Variables. Cannot be used together with colors. // variablesColors = new Common.VariablesColors { @@ -68,10 +66,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // RegExp pattern for: ic_24_icon_name, ic_24_icon // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "icon_$2_$1" - // [optional] Extract light and dark mode icons from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode icons. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode icons from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] images = new Common.Images { @@ -84,10 +80,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // RegExp pattern for: img_image_name // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "image_$2" - // [optional] Extract light and dark mode images from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode images. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode images from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] typography = new Common.Typography { diff --git a/Sources/ExFigCLI/Resources/flutterConfig.swift b/Sources/ExFigCLI/Resources/flutterConfig.swift index 42c0a552..f86c455e 100644 --- a/Sources/ExFigCLI/Resources/flutterConfig.swift +++ b/Sources/ExFigCLI/Resources/flutterConfig.swift @@ -27,10 +27,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^([a-zA-Z_]+)$" // RegExp pattern for: background, background_primary, widget_primary_background // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "color_$1" - // [optional] Extract light and dark mode colors from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode color. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] Use variablesColors instead of colors to export colors from Figma Variables. Cannot be used together with colors. // variablesColors = new Common.VariablesColors { @@ -60,10 +58,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // RegExp pattern for: ic_24_icon_name, ic_24_icon // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "icon_$2_$1" - // [optional] Extract light and dark mode icons from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode icons. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode icons from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] images = new Common.Images { @@ -76,10 +72,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // RegExp pattern for: img_image_name // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "image_$2" - // [optional] Extract light and dark mode images from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode images. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode images from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } diff --git a/Sources/ExFigCLI/Resources/iOSConfig.swift b/Sources/ExFigCLI/Resources/iOSConfig.swift index 6d28aeed..c41fbd47 100644 --- a/Sources/ExFigCLI/Resources/iOSConfig.swift +++ b/Sources/ExFigCLI/Resources/iOSConfig.swift @@ -31,10 +31,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^([a-zA-Z_]+)$" // RegExp pattern for: background, background_primary, widget_primary_background // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "color_$1" - // [optional] Extract light and dark mode colors from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode color. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } // [optional] If useSingleFile is true, customize the suffix to denote a light high contrast color. Defaults to '_lightHC' // lightHCModeSuffix = "_lightHC" // [optional] If useSingleFile is true, customize the suffix to denote a dark high contrast color. Defaults to '_darkHC' @@ -72,10 +70,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // RegExp pattern for: ic_24_icon_name, ic_24_icon // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "icon_$2_$1" - // [optional] Extract light and dark mode icons from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode icons. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode icons from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] images = new Common.Images { @@ -88,10 +84,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // RegExp pattern for: img_image_name // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "image_$2" - // [optional] Extract light and dark mode images from the lightFileId specified in the figma config. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix to denote a dark mode images. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode images from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] typography = new Common.Typography { diff --git a/Sources/ExFigCLI/Resources/webConfig.swift b/Sources/ExFigCLI/Resources/webConfig.swift index 6588a9cb..326950a1 100644 --- a/Sources/ExFigCLI/Resources/webConfig.swift +++ b/Sources/ExFigCLI/Resources/webConfig.swift @@ -28,10 +28,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^([a-zA-Z_]+)$" // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "color_$1" - // [optional] Extract light and dark mode colors from the lightFileId. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix for dark mode. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] Use variablesColors to export colors from Figma Variables. // variablesColors = new Common.VariablesColors { @@ -61,10 +59,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(ic)_(\\d\\d)_([a-z0-9_]+)$" // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "icon_$2_$1" - // [optional] Extract light and dark mode icons from the lightFileId. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix for dark mode. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode icons from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } // [optional] images = new Common.Images { @@ -77,10 +73,8 @@ common = new Common.CommonConfig { nameValidateRegexp = "^(img)_([a-z0-9_]+)$" // [optional] RegExp pattern for replacing. Supports only $n nameReplaceRegexp = "image_$2" - // [optional] Extract light and dark mode images from the lightFileId. Defaults to false - useSingleFile = false - // [optional] If useSingleFile is true, customize the suffix for dark mode. Defaults to '_dark' - darkModeSuffix = "_dark" + // [optional] Extract light and dark mode images from the lightFileId using name suffix splitting + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index 41ef790f..c676aefc 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -284,20 +284,10 @@ extension Android { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - public var variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - public var variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - public var variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - public var variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -323,10 +313,7 @@ extension Android { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, - variablesCollectionName: String?, - variablesLightModeName: String?, - variablesDarkModeName: String?, - variablesPrimitivesModeName: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -347,10 +334,7 @@ extension Android { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty - self.variablesCollectionName = variablesCollectionName - self.variablesLightModeName = variablesLightModeName - self.variablesDarkModeName = variablesDarkModeName - self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -417,20 +401,10 @@ extension Android { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - public var variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - public var variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - public var variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - public var variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -454,10 +428,7 @@ extension Android { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, - variablesCollectionName: String?, - variablesLightModeName: String?, - variablesDarkModeName: String?, - variablesPrimitivesModeName: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -476,10 +447,7 @@ extension Android { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty - self.variablesCollectionName = variablesCollectionName - self.variablesLightModeName = variablesLightModeName - self.variablesDarkModeName = variablesDarkModeName - self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index 9b955133..b9e631cf 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -44,13 +44,7 @@ public protocol Common_FrameSource: Common_NameProcessing { var rtlProperty: String? { get } - var variablesCollectionName: String? { get } - - var variablesLightModeName: String? { get } - - var variablesDarkModeName: String? { get } - - var variablesPrimitivesModeName: String? { get } + var variablesDarkMode: Common.VariablesDarkMode? { get } } extension Common { @@ -230,6 +224,49 @@ extension Common { } } + /// Dark mode via Figma Variable bindings (per-entry). + /// Resolves variable values in dark mode and replaces colors in exported SVGs. + public struct VariablesDarkMode: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#VariablesDarkMode" + + /// Variable collection name (e.g., "DesignTokens"). + public var collectionName: String + + /// Light mode name in the collection (e.g., "Light"). + public var lightModeName: String + + /// Dark mode name in the collection (e.g., "Dark"). + public var darkModeName: String + + /// Primitives mode for resolving variable aliases (e.g., "Value"). + public var primitivesModeName: String? + + public init( + collectionName: String, + lightModeName: String, + darkModeName: String, + primitivesModeName: String? + ) { + self.collectionName = collectionName + self.lightModeName = lightModeName + self.darkModeName = darkModeName + self.primitivesModeName = primitivesModeName + } + } + + /// Dark mode via name suffix splitting (global, on common.icons/images/colors). + /// Icons/images with the suffix are treated as dark variants. + public struct SuffixDarkMode: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#SuffixDarkMode" + + /// Dark mode name suffix (e.g., "_dark"). + public var suffix: String + + public init(suffix: String) { + self.suffix = suffix + } + } + /// Cache configuration for tracking Figma file versions. public struct Cache: PklRegisteredType, Decodable, Hashable, Sendable { public static let registeredIdentifier: String = "Common#Cache" @@ -299,20 +336,10 @@ extension Common { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - public var variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - public var variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - public var variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - public var variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: VariablesDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -327,10 +354,7 @@ extension Common { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, - variablesCollectionName: String?, - variablesLightModeName: String?, - variablesDarkModeName: String?, - variablesPrimitivesModeName: String?, + variablesDarkMode: VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -340,10 +364,7 @@ extension Common { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty - self.variablesCollectionName = variablesCollectionName - self.variablesLightModeName = variablesLightModeName - self.variablesDarkModeName = variablesDarkModeName - self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -353,11 +374,8 @@ extension Common { public struct Colors: NameProcessing { public static let registeredIdentifier: String = "Common#Colors" - /// Use single file for all color modes. - public var useSingleFile: Bool? - - /// Suffix for dark mode colors. - public var darkModeSuffix: String? + /// Dark mode via name suffix splitting. + public var suffixDarkMode: SuffixDarkMode? /// Suffix for light high contrast colors. public var lightHCModeSuffix: String? @@ -372,15 +390,13 @@ extension Common { public var nameReplaceRegexp: String? public init( - useSingleFile: Bool?, - darkModeSuffix: String?, + suffixDarkMode: SuffixDarkMode?, lightHCModeSuffix: String?, darkHCModeSuffix: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { - self.useSingleFile = useSingleFile - self.darkModeSuffix = darkModeSuffix + self.suffixDarkMode = suffixDarkMode self.lightHCModeSuffix = lightHCModeSuffix self.darkHCModeSuffix = darkHCModeSuffix self.nameValidateRegexp = nameValidateRegexp @@ -398,11 +414,8 @@ extension Common { /// Figma page name to filter icons by. public var figmaPageName: String? - /// Use single file for all icon modes. - public var useSingleFile: Bool? - - /// Suffix for dark mode icons. - public var darkModeSuffix: String? + /// Dark mode via name suffix splitting. + public var suffixDarkMode: SuffixDarkMode? /// If true, exit with error when pathData exceeds 32,767 bytes (AAPT limit). public var strictPathValidation: Bool? @@ -416,16 +429,14 @@ extension Common { public init( figmaFrameName: String?, figmaPageName: String?, - useSingleFile: Bool?, - darkModeSuffix: String?, + suffixDarkMode: SuffixDarkMode?, strictPathValidation: Bool?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName - self.useSingleFile = useSingleFile - self.darkModeSuffix = darkModeSuffix + self.suffixDarkMode = suffixDarkMode self.strictPathValidation = strictPathValidation self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp @@ -442,11 +453,8 @@ extension Common { /// Figma page name to filter images by. public var figmaPageName: String? - /// Use single file for all image modes. - public var useSingleFile: Bool? - - /// Suffix for dark mode images. - public var darkModeSuffix: String? + /// Dark mode via name suffix splitting. + public var suffixDarkMode: SuffixDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -457,15 +465,13 @@ extension Common { public init( figmaFrameName: String?, figmaPageName: String?, - useSingleFile: Bool?, - darkModeSuffix: String?, + suffixDarkMode: SuffixDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName - self.useSingleFile = useSingleFile - self.darkModeSuffix = darkModeSuffix + self.suffixDarkMode = suffixDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index db41771b..871b6aa4 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -154,20 +154,10 @@ extension Flutter { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - public var variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - public var variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - public var variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - public var variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -187,10 +177,7 @@ extension Flutter { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, - variablesCollectionName: String?, - variablesLightModeName: String?, - variablesDarkModeName: String?, - variablesPrimitivesModeName: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -205,10 +192,7 @@ extension Flutter { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty - self.variablesCollectionName = variablesCollectionName - self.variablesLightModeName = variablesLightModeName - self.variablesDarkModeName = variablesDarkModeName - self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -274,20 +258,10 @@ extension Flutter { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - public var variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - public var variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - public var variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - public var variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -311,10 +285,7 @@ extension Flutter { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, - variablesCollectionName: String?, - variablesLightModeName: String?, - variablesDarkModeName: String?, - variablesPrimitivesModeName: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -333,10 +304,7 @@ extension Flutter { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty - self.variablesCollectionName = variablesCollectionName - self.variablesLightModeName = variablesLightModeName - self.variablesDarkModeName = variablesDarkModeName - self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Web.pkl.swift b/Sources/ExFigConfig/Generated/Web.pkl.swift index 3842dada..5ae9e0cf 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -166,20 +166,10 @@ extension Web { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - public var variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - public var variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - public var variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - public var variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -200,10 +190,7 @@ extension Web { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, - variablesCollectionName: String?, - variablesLightModeName: String?, - variablesDarkModeName: String?, - variablesPrimitivesModeName: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -219,10 +206,7 @@ extension Web { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty - self.variablesCollectionName = variablesCollectionName - self.variablesLightModeName = variablesLightModeName - self.variablesDarkModeName = variablesDarkModeName - self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -276,20 +260,10 @@ extension Web { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - public var variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - public var variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - public var variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - public var variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -309,10 +283,7 @@ extension Web { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, - variablesCollectionName: String?, - variablesLightModeName: String?, - variablesDarkModeName: String?, - variablesPrimitivesModeName: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -327,10 +298,7 @@ extension Web { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty - self.variablesCollectionName = variablesCollectionName - self.variablesLightModeName = variablesLightModeName - self.variablesDarkModeName = variablesDarkModeName - self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/iOS.pkl.swift b/Sources/ExFigConfig/Generated/iOS.pkl.swift index e08deb19..bfc3dbc8 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -253,20 +253,10 @@ extension iOS { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - public var variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - public var variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - public var variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - public var variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -294,10 +284,7 @@ extension iOS { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, - variablesCollectionName: String?, - variablesLightModeName: String?, - variablesDarkModeName: String?, - variablesPrimitivesModeName: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -320,10 +307,7 @@ extension iOS { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty - self.variablesCollectionName = variablesCollectionName - self.variablesLightModeName = variablesLightModeName - self.variablesDarkModeName = variablesDarkModeName - self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -408,20 +392,10 @@ extension iOS { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? - /// Variable collection name for dark mode generation via Figma Variables. - /// When set (along with light/dark mode names), dark SVG variants are generated - /// by resolving variable bindings and replacing colors in the light SVG. - public var variablesCollectionName: String? - - /// Light mode name in the variables collection (e.g. "Light"). - public var variablesLightModeName: String? - - /// Dark mode name in the variables collection (e.g. "Dark"). - public var variablesDarkModeName: String? - - /// Primitives mode name for resolving variable aliases (e.g. "Value"). - /// Used when variables reference other variables through alias chains. - public var variablesPrimitivesModeName: String? + /// Dark mode generation via Figma Variable bindings. + /// When set, dark SVG variants are generated by resolving variable bindings + /// and replacing colors in the light SVG. + public var variablesDarkMode: Common.VariablesDarkMode? /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -451,10 +425,7 @@ extension iOS { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, - variablesCollectionName: String?, - variablesLightModeName: String?, - variablesDarkModeName: String?, - variablesPrimitivesModeName: String?, + variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -479,10 +450,7 @@ extension iOS { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty - self.variablesCollectionName = variablesCollectionName - self.variablesLightModeName = variablesLightModeName - self.variablesDarkModeName = variablesDarkModeName - self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Tests/ExFigTests/Helpers/TestHelpers.swift b/Tests/ExFigTests/Helpers/TestHelpers.swift index 84d63366..d58141be 100644 --- a/Tests/ExFigTests/Helpers/TestHelpers.swift +++ b/Tests/ExFigTests/Helpers/TestHelpers.swift @@ -33,17 +33,13 @@ extension PKLConfig.Figma { extension PKLConfig.Common.Colors { /// Creates a PKLConfig.Common.Colors for testing. static func make( - useSingleFile: Bool? = nil, - darkModeSuffix: String? = nil, + suffixDarkMode: String? = nil, lightHCModeSuffix: String? = nil, darkHCModeSuffix: String? = nil ) -> PKLConfig.Common.Colors { var components: [String] = [] - if let useSingleFile { - components.append("\"useSingleFile\": \(useSingleFile)") - } - if let darkModeSuffix { - components.append("\"darkModeSuffix\": \"\(darkModeSuffix)\"") + if let suffixDarkMode { + components.append("\"suffixDarkMode\": { \"suffix\": \"\(suffixDarkMode)\" }") } if let lightHCModeSuffix { components.append("\"lightHCModeSuffix\": \"\(lightHCModeSuffix)\"") @@ -191,13 +187,12 @@ extension PKLConfig { iconsPageName: String? = nil, imagesFrameName: String? = nil, imagesPageName: String? = nil, - useSingleFileIcons: Bool? = nil, - useSingleFileImages: Bool? = nil, - iconsDarkModeSuffix: String? = nil + iconsSuffixDarkMode: String? = nil, + imagesSuffixDarkMode: String? = nil ) -> PKLConfig { var commonComponents: [String] = [] - if iconsFrameName != nil || iconsPageName != nil || useSingleFileIcons != nil || iconsDarkModeSuffix != nil { + if iconsFrameName != nil || iconsPageName != nil || iconsSuffixDarkMode != nil { var iconParts: [String] = [] if let frameName = iconsFrameName { iconParts.append("\"figmaFrameName\": \"\(frameName)\"") @@ -205,16 +200,13 @@ extension PKLConfig { if let pageName = iconsPageName { iconParts.append("\"figmaPageName\": \"\(pageName)\"") } - if let useSingle = useSingleFileIcons { - iconParts.append("\"useSingleFile\": \(useSingle)") - } - if let darkSuffix = iconsDarkModeSuffix { - iconParts.append("\"darkModeSuffix\": \"\(darkSuffix)\"") + if let suffix = iconsSuffixDarkMode { + iconParts.append("\"suffixDarkMode\": { \"suffix\": \"\(suffix)\" }") } commonComponents.append("\"icons\": { \(iconParts.joined(separator: ", ")) }") } - if imagesFrameName != nil || imagesPageName != nil || useSingleFileImages != nil { + if imagesFrameName != nil || imagesPageName != nil || imagesSuffixDarkMode != nil { var imageParts: [String] = [] if let frameName = imagesFrameName { imageParts.append("\"figmaFrameName\": \"\(frameName)\"") @@ -222,8 +214,8 @@ extension PKLConfig { if let pageName = imagesPageName { imageParts.append("\"figmaPageName\": \"\(pageName)\"") } - if let useSingle = useSingleFileImages { - imageParts.append("\"useSingleFile\": \(useSingle)") + if let suffix = imagesSuffixDarkMode { + imageParts.append("\"suffixDarkMode\": { \"suffix\": \"\(suffix)\" }") } commonComponents.append("\"images\": { \(imageParts.joined(separator: ", ")) }") } diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index c3cd2bdb..3651a2b2 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -120,10 +120,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -169,10 +166,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -214,10 +208,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -247,10 +238,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -286,10 +274,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -317,10 +302,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -385,10 +367,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -423,10 +402,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) diff --git a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift index 43aa6f39..a24184b6 100644 --- a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift +++ b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift @@ -28,10 +28,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -61,10 +58,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -94,10 +88,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -127,10 +118,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -158,10 +146,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -191,10 +176,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -222,10 +204,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, - variablesCollectionName: nil, - variablesLightModeName: nil, - variablesDarkModeName: nil, - variablesPrimitivesModeName: nil, + variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) diff --git a/Tests/ExFigTests/Loaders/ColorsLoaderTests.swift b/Tests/ExFigTests/Loaders/ColorsLoaderTests.swift index d694217c..cc1775fd 100644 --- a/Tests/ExFigTests/Loaders/ColorsLoaderTests.swift +++ b/Tests/ExFigTests/Loaders/ColorsLoaderTests.swift @@ -98,7 +98,7 @@ final class ColorsLoaderTests: XCTestCase { mockClient.setResponse(styles, for: StylesEndpoint.self) mockClient.setResponse(nodes, for: NodesEndpoint.self) - let colorParams = PKLConfig.Common.Colors.make(useSingleFile: true) + let colorParams = PKLConfig.Common.Colors.make(suffixDarkMode: "_dark") let loader = ColorsLoader( client: mockClient, figmaParams: .make(lightFileId: "single-file"), @@ -134,7 +134,7 @@ final class ColorsLoaderTests: XCTestCase { mockClient.setResponse(styles, for: StylesEndpoint.self) mockClient.setResponse(nodes, for: NodesEndpoint.self) - let colorParams = PKLConfig.Common.Colors.make(useSingleFile: true, darkModeSuffix: "-night") + let colorParams = PKLConfig.Common.Colors.make(suffixDarkMode: "-night") let loader = ColorsLoader( client: mockClient, figmaParams: .make(lightFileId: "single-file"), diff --git a/Tests/ExFigTests/Loaders/IconsLoaderGranularCachePairingTests.swift b/Tests/ExFigTests/Loaders/IconsLoaderGranularCachePairingTests.swift index 94cf4ee3..88143ed1 100644 --- a/Tests/ExFigTests/Loaders/IconsLoaderGranularCachePairingTests.swift +++ b/Tests/ExFigTests/Loaders/IconsLoaderGranularCachePairingTests.swift @@ -48,7 +48,7 @@ final class IconsLoaderGranularCachePairingTests: XCTestCase { let granularManager = GranularCacheManager(client: mockClient, cache: cache) let params = PKLConfig.make( lightFileId: "file123", iconsFrameName: "Icons", - useSingleFileIcons: true, iconsDarkModeSuffix: "-dark" + iconsSuffixDarkMode: "-dark" ) let loader = IconsLoader(client: mockClient, params: params, platform: .ios, logger: logger) loader.granularCacheManager = granularManager @@ -111,7 +111,7 @@ final class IconsLoaderGranularCachePairingTests: XCTestCase { let granularManager = GranularCacheManager(client: mockClient, cache: cache) let params = PKLConfig.make( lightFileId: "file123", iconsFrameName: "Icons", - useSingleFileIcons: true, iconsDarkModeSuffix: "-dark" + iconsSuffixDarkMode: "-dark" ) let loader = IconsLoader(client: mockClient, params: params, platform: .ios, logger: logger) loader.granularCacheManager = granularManager @@ -159,7 +159,7 @@ final class IconsLoaderGranularCachePairingTests: XCTestCase { let granularManager = GranularCacheManager(client: mockClient, cache: cache) let params = PKLConfig.make( lightFileId: "file123", iconsFrameName: "Icons", - useSingleFileIcons: true, iconsDarkModeSuffix: "-dark" + iconsSuffixDarkMode: "-dark" ) let loader = IconsLoader(client: mockClient, params: params, platform: .ios, logger: logger) loader.granularCacheManager = granularManager @@ -207,7 +207,7 @@ final class IconsLoaderGranularCachePairingTests: XCTestCase { let granularManager = GranularCacheManager(client: mockClient, cache: cache) let params = PKLConfig.make( lightFileId: "file123", iconsFrameName: "Icons", - useSingleFileIcons: true, iconsDarkModeSuffix: "-dark" + iconsSuffixDarkMode: "-dark" ) let loader = IconsLoader(client: mockClient, params: params, platform: .ios, logger: logger) loader.granularCacheManager = granularManager diff --git a/llms-full.txt b/llms-full.txt index f5330e04..565fa90a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -629,11 +629,7 @@ common = new Common.CommonConfig { // Regex replacement for color names nameReplaceRegexp = "$1" - // Extract light and dark mode colors from a single file - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -766,11 +762,7 @@ common = new Common.CommonConfig { // Regex replacement for icon names nameReplaceRegexp = "ic_$1" - // Use single file for light/dark (default: false) - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -791,11 +783,7 @@ common = new Common.CommonConfig { // Regex replacement for image names nameReplaceRegexp = "$1" - // Use single file for light/dark (default: false) - useSingleFile = false - - // Suffix for dark mode variants (when useSingleFile is true) - darkModeSuffix = "_dark" + // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` @@ -1330,8 +1318,7 @@ import ".exfig/schemas/Common.pkl" common = new Common.CommonConfig { icons = new Common.Icons { - useSingleFile = true - darkModeSuffix = "_dark" + suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } } } ``` From c9d93c898373415c9edb40777c56d946bf211afd Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 12:58:48 +0500 Subject: [PATCH 04/17] fix: variable-mode dark icon generation --- CLAUDE.md | 5 +- .../Context/IconsExportContextImpl.swift | 42 ++++- .../Loaders/VariableModeDarkGenerator.swift | 178 +++++++++++++----- .../ExFigCLI/Output/SVGColorReplacer.swift | 29 ++- .../Resources/Schemas/examples/exfig-ios.pkl | 2 +- Sources/ExFigCLI/Resources/iOSConfig.swift | 4 +- .../Source/FigmaComponentsSource.swift | 15 +- Sources/ExFigCLI/TerminalUI/ProgressBar.swift | 10 +- Sources/ExFigCLI/TerminalUI/Spinner.swift | 11 +- 9 files changed, 225 insertions(+), 71 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 89d9ee19..b683892d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ Twelve modules in `Sources/`: **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 -**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 `useSingleFile+darkModeSuffix`. +**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`. @@ -248,6 +248,8 @@ Integration point: `FigmaComponentsSource.loadIcons()`. 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). + ### Module Boundaries ExFigCore does NOT import FigmaAPI. Constants on `Component` (FigmaAPI, extended in ExFigCLI) are @@ -474,6 +476,7 @@ 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 | ## Additional Rules diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index ddfdcbe9..3652fe64 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -167,13 +167,14 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { config: config ) + let output: IconsLoadOutputWithHashes if let manager = granularCacheManager { loader.granularCacheManager = manager let result = try await loader.loadWithGranularCache( filter: filter, onBatchProgress: onProgress ?? { _, _ in } ) - return IconsLoadOutputWithHashes( + output = IconsLoadOutputWithHashes( light: result.light, dark: result.dark ?? [], computedHashes: result.computedHashes, @@ -182,7 +183,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { ) } else { let result = try await loader.load(filter: filter, onBatchProgress: onProgress ?? { _, _ in }) - return IconsLoadOutputWithHashes( + output = IconsLoadOutputWithHashes( light: result.light, dark: result.dark ?? [], computedHashes: [:], @@ -190,6 +191,43 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { allAssetMetadata: [] ) } + + return try await applyVariableModeDark(to: output, source: source) + } + + /// Applies variable-mode dark generation to granular cache output. + private func applyVariableModeDark( + to output: IconsLoadOutputWithHashes, + source: IconsSourceInput + ) async throws -> IconsLoadOutputWithHashes { + guard let collectionName = source.variablesCollectionName, + let lightModeName = source.variablesLightModeName, + let darkModeName = source.variablesDarkModeName + else { return output } + + let logger = ExFigCommand.logger + guard let fileId = source.figmaFileId ?? params.figma?.lightFileId, !fileId.isEmpty else { + logger.warning("Variable-mode dark generation requires a Figma file ID, skipping") + return output + } + let generator = VariableModeDarkGenerator(client: client, logger: logger) + let darkPacks = try await generator.generateDarkVariants( + lightPacks: output.light, + config: .init( + fileId: fileId, + collectionName: collectionName, + lightModeName: lightModeName, + darkModeName: darkModeName, + primitivesModeName: source.variablesPrimitivesModeName + ) + ) + return IconsLoadOutputWithHashes( + light: output.light, + dark: darkPacks, + computedHashes: output.computedHashes, + allSkipped: output.allSkipped, + allAssetMetadata: output.allAssetMetadata + ) } func processIconNames( diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift index 5bbf4194..155ba9f4 100644 --- a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -55,14 +55,22 @@ struct VariableModeDarkGenerator { // 2. Find collection and extract mode IDs guard let modes = findModeIds(in: variablesMeta, config: config) else { - logger.warning("Variables collection '\(config.collectionName)' not found or missing modes") + let collectionNames = variablesMeta.variableCollections.values.map(\.name) + logger.warning(""" + Variables dark mode: collection '\(config.collectionName)' not found or missing \ + modes '\(config.lightModeName)'/'\(config.darkModeName)'. \ + Available collections: \(collectionNames.sorted().joined(separator: ", ")) + """) return [] } // 3. Fetch nodes to discover boundVariables on paints let nodeIds = lightPacks.compactMap(\.nodeId) - guard !nodeIds.isEmpty else { return [] } + guard !nodeIds.isEmpty else { + logger.warning("Variable-mode dark generation: none of the light packs have node IDs, skipping") + return [] + } let nodeMap = try await fetchNodesBatched(fileId: config.fileId, nodeIds: nodeIds) @@ -71,58 +79,114 @@ struct VariableModeDarkGenerator { .appendingPathComponent("exfig-variable-dark-\(ProcessInfo.processInfo.processIdentifier)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + var cleanupNeeded = true + defer { + if cleanupNeeded { + try? FileManager.default.removeItem(at: tempDir) + } + } + + let darkPacks = try processLightPacks( + lightPacks, + nodeMap: nodeMap, + variablesMeta: variablesMeta, + modes: modes, + tempDir: tempDir + ) + + // Keep temp dir alive — caller consumes the URLs during export, then OS cleans /tmp + cleanupNeeded = false + return darkPacks + } + + // MARK: - Private + + // swiftlint:disable cyclomatic_complexity + + private func processLightPacks( + _ lightPacks: [ImagePack], + nodeMap: [String: Node], + variablesMeta: VariablesMeta, + modes: ModeContext, + tempDir: URL + ) throws -> [ImagePack] { var darkPacks: [ImagePack] = [] for pack in lightPacks { - guard let nodeId = pack.nodeId, - let node = nodeMap[nodeId] - else { continue } + guard let nodeId = pack.nodeId else { continue } + + guard let node = nodeMap[nodeId] else { + logger.debug( + "Node '\(nodeId)' for icon '\(pack.name)' not returned by Figma API, skipping dark generation" + ) + continue + } // Collect all bound variable colors from the node tree let colorMap = buildColorMap( node: node, variablesMeta: variablesMeta, - modes: modes + modes: modes, + iconName: pack.name ) - guard !colorMap.isEmpty else { - // No bound variables — skip dark generation for this icon + guard !colorMap.isEmpty else { continue } + + guard let darkPack = try buildDarkPack(for: pack, colorMap: colorMap, tempDir: tempDir) else { continue } + darkPacks.append(darkPack) + } - // Download light SVG and replace colors - guard let svgImage = pack.images.first, - let svgData = try? Data(contentsOf: svgImage.url), - let svgContent = String(data: svgData, encoding: .utf8) - else { continue } + return darkPacks + } - let darkSVG = SVGColorReplacer.replaceColors(in: svgContent, colorMap: colorMap) + // swiftlint:enable cyclomatic_complexity - // Write dark SVG to temp file - let safeName = pack.name - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: " ", with: "_") - let tempURL = tempDir.appendingPathComponent("\(safeName)_dark.svg") - try Data(darkSVG.utf8).write(to: tempURL) + private func buildDarkPack( + for pack: ImagePack, + colorMap: [String: String], + tempDir: URL + ) throws -> ImagePack? { + guard let svgImage = pack.images.first else { + logger.warning("Icon '\(pack.name)' has no images, skipping dark generation") + return nil + } - darkPacks.append(ImagePack( - name: pack.name, - images: [Image( - name: pack.name, - scale: .all, - url: tempURL, - format: "svg" - )], - platform: pack.platform, - nodeId: pack.nodeId, - fileId: pack.fileId - )) + let svgData: Data + do { + svgData = try Data(contentsOf: svgImage.url) + } catch { + logger.warning("Failed to read SVG for icon '\(pack.name)': \(error.localizedDescription)") + return nil } - return darkPacks - } + guard let svgContent = String(data: svgData, encoding: .utf8) else { + logger.warning("Icon '\(pack.name)' SVG is not valid UTF-8, skipping dark generation") + return nil + } - // MARK: - Private + let darkSVG = SVGColorReplacer.replaceColors(in: svgContent, colorMap: colorMap) + + let safeName = pack.name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: " ", with: "_") + let tempURL = tempDir.appendingPathComponent("\(safeName)_dark.svg") + try Data(darkSVG.utf8).write(to: tempURL) + + return ImagePack( + name: pack.name, + images: [Image( + name: pack.name, + scale: .all, + url: tempURL, + format: "svg" + )], + platform: pack.platform, + nodeId: pack.nodeId, + fileId: pack.fileId + ) + } private func loadVariables(fileId: String) async throws -> VariablesMeta { let endpoint = VariablesEndpoint(fileId: fileId) @@ -178,14 +242,16 @@ struct VariableModeDarkGenerator { private func buildColorMap( node: Node, variablesMeta: VariablesMeta, - modes: ModeContext + modes: ModeContext, + iconName: String ) -> [String: String] { var colorMap: [String: String] = [:] collectBoundColors( from: node.document, variablesMeta: variablesMeta, modes: modes, - colorMap: &colorMap + colorMap: &colorMap, + iconName: iconName ) return colorMap } @@ -194,24 +260,37 @@ struct VariableModeDarkGenerator { from document: Document, variablesMeta: VariablesMeta, modes: ModeContext, - colorMap: inout [String: String] + colorMap: inout [String: String], + iconName: String ) { // Check fills for paint in document.fills { - collectFromPaint(paint, variablesMeta: variablesMeta, modes: modes, colorMap: &colorMap) + collectFromPaint(paint, variablesMeta: variablesMeta, modes: modes, colorMap: &colorMap, iconName: iconName) } // Check strokes if let strokes = document.strokes { for paint in strokes { - collectFromPaint(paint, variablesMeta: variablesMeta, modes: modes, colorMap: &colorMap) + collectFromPaint( + paint, + variablesMeta: variablesMeta, + modes: modes, + colorMap: &colorMap, + iconName: iconName + ) } } // Recurse into children if let children = document.children { for child in children { - collectBoundColors(from: child, variablesMeta: variablesMeta, modes: modes, colorMap: &colorMap) + collectBoundColors( + from: child, + variablesMeta: variablesMeta, + modes: modes, + colorMap: &colorMap, + iconName: iconName + ) } } } @@ -220,7 +299,8 @@ struct VariableModeDarkGenerator { _ paint: Paint, variablesMeta: VariablesMeta, modes: ModeContext, - colorMap: inout [String: String] + colorMap: inout [String: String], + iconName: String ) { guard let boundVars = paint.boundVariables, let colorAlias = boundVars["color"], @@ -241,6 +321,11 @@ struct VariableModeDarkGenerator { primitivesModeId: modes.primitivesModeId ) { if lightHex != darkHex { + if let existing = colorMap[lightHex], existing != darkHex { + logger.warning( + "Icon '\(iconName)': #\(lightHex) maps to multiple dark values (#\(existing) and #\(darkHex))" + ) + } colorMap[lightHex] = darkHex } } @@ -254,9 +339,14 @@ struct VariableModeDarkGenerator { primitivesModeId: String?, depth: Int = 0 ) -> String? { - guard depth < 10 else { return nil } + guard depth < 10 else { + logger.warning("Variable alias chain exceeded depth limit (variableId: \(variableId))") + return nil + } - guard let variable = variablesMeta.variables[variableId] else { return nil } + guard let variable = variablesMeta.variables[variableId], + variable.deletedButReferenced != true + else { return nil } // Try the requested mode first, fall back to default mode of the collection let value = variable.valuesByMode[modeId] diff --git a/Sources/ExFigCLI/Output/SVGColorReplacer.swift b/Sources/ExFigCLI/Output/SVGColorReplacer.swift index 4787204c..1a553697 100644 --- a/Sources/ExFigCLI/Output/SVGColorReplacer.swift +++ b/Sources/ExFigCLI/Output/SVGColorReplacer.swift @@ -19,25 +19,34 @@ enum SVGColorReplacer { // Replace hex colors in SVG attributes: fill="#RRGGBB", stroke="#RRGGBB", stop-color="#RRGGBB" // and inline CSS: fill:#RRGGBB, stroke:#RRGGBB for (lightHex, darkHex) in colorMap { - // Match both with and without # prefix, case-insensitive - let patterns = [ + // Match both attribute and CSS property styles, case-insensitive + let replacements: [(pattern: String, template: String)] = [ // Attribute style: fill="#aabbcc" or stroke="#AABBCC" - "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*=\\s*[\"'])#\(lightHex)([\"'])", + ( + "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*=\\s*[\"'])#\(lightHex)([\"'])", + "$1$2#\(darkHex)$3" + ), // CSS property style: fill:#aabbcc or stroke:#AABBCC (in style attributes) - "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*:\\s*)#\(lightHex)", + ( + "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*:\\s*)#\(lightHex)", + "$1$2#\(darkHex)" + ), ] - for pattern in patterns { - if let regex = try? NSRegularExpression( - pattern: pattern, - options: .caseInsensitive - ) { + for (pattern, template) in replacements { + do { + let regex = try NSRegularExpression( + pattern: pattern, + options: .caseInsensitive + ) let range = NSRange(result.startIndex..., in: result) result = regex.stringByReplacingMatches( in: result, range: range, - withTemplate: "$1$2#\(darkHex)$3" + withTemplate: template ) + } catch { + assertionFailure("Invalid regex pattern: \(pattern), error: \(error)") } } } diff --git a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl index 952add34..2c9c8228 100644 --- a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl @@ -49,7 +49,7 @@ ios = new iOS.iOSConfig { templatesPath = "BrandKit/Templates" // rtlProperty = "RTL" // default; set to null to disable variant-based RTL detection } - / Variable-mode dark: generate dark SVGs from Figma Variable bindings + // Variable-mode dark: generate dark SVGs from Figma Variable bindings new iOS.IconsEntry { figmaFrameName = "DoubleColor" format = "svg" diff --git a/Sources/ExFigCLI/Resources/iOSConfig.swift b/Sources/ExFigCLI/Resources/iOSConfig.swift index c41fbd47..bf76bf71 100644 --- a/Sources/ExFigCLI/Resources/iOSConfig.swift +++ b/Sources/ExFigCLI/Resources/iOSConfig.swift @@ -33,9 +33,9 @@ common = new Common.CommonConfig { nameReplaceRegexp = "color_$1" // [optional] Extract light and dark mode from the lightFileId using name suffix splitting // suffixDarkMode = new Common.SuffixDarkMode { suffix = "_dark" } - // [optional] If useSingleFile is true, customize the suffix to denote a light high contrast color. Defaults to '_lightHC' + // [optional] If suffixDarkMode is set, customize the suffix to denote a light high contrast color. Defaults to '_lightHC' // lightHCModeSuffix = "_lightHC" - // [optional] If useSingleFile is true, customize the suffix to denote a dark high contrast color. Defaults to '_darkHC' + // [optional] If suffixDarkMode is set, customize the suffix to denote a dark high contrast color. Defaults to '_darkHC' // darkHCModeSuffix = "_darkHC" } // [optional] Use variablesColors instead of colors to export colors from Figma Variables. Cannot be used together with colors. diff --git a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift index 42979d04..0b4260fb 100644 --- a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift +++ b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift @@ -35,11 +35,17 @@ struct FigmaComponentsSource: ComponentsSource { let result = try await loader.load(filter: filter) // Variable-mode dark generation: resolve variable bindings and replace colors in SVGs + let hasPartialConfig = input.variablesCollectionName != nil + || input.variablesLightModeName != nil + || input.variablesDarkModeName != nil if let collectionName = input.variablesCollectionName, let lightModeName = input.variablesLightModeName, let darkModeName = input.variablesDarkModeName { - let fileId = input.figmaFileId ?? params.figma?.lightFileId ?? "" + guard let fileId = input.figmaFileId ?? params.figma?.lightFileId, !fileId.isEmpty else { + logger.warning("Variable-mode dark generation requires a Figma file ID, skipping") + return IconsLoadOutput(light: result.light, dark: []) + } let generator = VariableModeDarkGenerator(client: client, logger: logger) let darkPacks = try await generator.generateDarkVariants( lightPacks: result.light, @@ -52,6 +58,13 @@ struct FigmaComponentsSource: ComponentsSource { ) ) return IconsLoadOutput(light: result.light, dark: darkPacks) + } else if hasPartialConfig { + let col = input.variablesCollectionName ?? "nil" + let light = input.variablesLightModeName ?? "nil" + let dark = input.variablesDarkModeName ?? "nil" + logger.warning( + "Variable-mode dark: incomplete config — collection=\(col) light=\(light) dark=\(dark)" + ) } return IconsLoadOutput( diff --git a/Sources/ExFigCLI/TerminalUI/ProgressBar.swift b/Sources/ExFigCLI/TerminalUI/ProgressBar.swift index b1975004..d26736bf 100644 --- a/Sources/ExFigCLI/TerminalUI/ProgressBar.swift +++ b/Sources/ExFigCLI/TerminalUI/ProgressBar.swift @@ -77,12 +77,12 @@ final class ProgressBar: @unchecked Sendable { // Build initial frame synchronously let initialFrame = buildFrame(currentValue: 0) - // Set animation flag and initial frame synchronously BEFORE dispatching - // This ensures log messages see the animation state immediately - TerminalOutputManager.shared.startAnimation(initialFrame: initialFrame) + if useAnimations { + // Set animation flag and initial frame synchronously BEFORE dispatching + // This ensures log messages see the animation state immediately + TerminalOutputManager.shared.startAnimation(initialFrame: initialFrame) - Self.renderQueue.async { [self] in - if useAnimations { + Self.renderQueue.async { [self] in TerminalOutputManager.shared.writeDirect(ANSICodes.hideCursor) // First frame already rendered by startAnimation(), start timer for next frames diff --git a/Sources/ExFigCLI/TerminalUI/Spinner.swift b/Sources/ExFigCLI/TerminalUI/Spinner.swift index 00e9c7bc..dcd8fa16 100644 --- a/Sources/ExFigCLI/TerminalUI/Spinner.swift +++ b/Sources/ExFigCLI/TerminalUI/Spinner.swift @@ -56,10 +56,10 @@ final class Spinner: @unchecked Sendable { // Set animation flag and initial frame synchronously BEFORE dispatching // This ensures log messages see the animation state immediately - TerminalOutputManager.shared.startAnimation(initialFrame: "\(coloredFrame) \(initialMessage)") + if useAnimations { + TerminalOutputManager.shared.startAnimation(initialFrame: "\(coloredFrame) \(initialMessage)") - Self.renderQueue.async { [self] in - if useAnimations { + Self.renderQueue.async { [self] in TerminalOutputManager.shared.writeDirect(ANSICodes.hideCursor) // First frame already rendered by startAnimation(), start timer for next frames @@ -73,9 +73,10 @@ final class Spinner: @unchecked Sendable { } self.timer = timer timer.resume() - } else { - TerminalOutputManager.shared.writeDirect("\(initialMessage)\n") } + } else { + // Non-animated mode: just print the message once with newline + TerminalOutputManager.shared.print("\(coloredFrame) \(initialMessage)") } } From a2e7519535a004628085c002700717ce77e82cb2 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 13:51:36 +0500 Subject: [PATCH 05/17] fix(icons): register VariablesDarkMode in PKL types + fix verbose spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 6 ++++++ Sources/ExFigCLI/CLAUDE.md | 2 ++ Sources/ExFigCLI/TerminalUI/Spinner.swift | 4 +--- Sources/ExFigConfig/CLAUDE.md | 2 ++ Sources/ExFigConfig/PKL/PKLEvaluator.swift | 2 ++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b683892d..12660468 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,6 +250,10 @@ Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaCo **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). +**PKL registration:** `Common.VariablesDarkMode` and `Common.SuffixDarkMode` must be listed in `registerPklTypes()` in `PKLEvaluator.swift`. Omitting a `PklRegisteredType` struct causes pkl-swift to silently return `nil` for that field — the PKL config appears correct but the feature never activates. + +**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 @@ -477,6 +481,8 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | 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` | New `PklRegisteredType` structs (e.g. `Common.VariablesDarkMode`) MUST be added to `registerPklTypes()` in `PKLEvaluator.swift` — pkl-swift silently returns `nil` without registration, no error thrown | +| Granular cache skips dark gen | `loadIconsWithGranularCache()` in `IconsExportContextImpl` bypasses `FigmaComponentsSource` — must call `VariableModeDarkGenerator` explicitly via `applyVariableModeDark()` helper | ## Additional Rules diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 0e6d4a1a..28a5599b 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -129,6 +129,8 @@ Granular cache flow: pre-fetch nodes → compute hashes → compare with cached - `warning()` / `error()` — NEVER suppressed; queued to `BatchProgressView.queueLogMessage()` for coordinated rendering - `TerminalOutputManager` singleton prevents race conditions between animations and log output via `hasActiveAnimation` flag - `withParallelEntries()` creates parent spinner suppressing all inner output (except warnings/errors) +- `OutputMode.useAnimations` is `true` only for `.normal` — `.verbose` and `.quiet` are non-animated +- `Spinner`/`ProgressBar` non-animated mode: `start()` emits NO output; only `stop()` prints the final `✓`/`✗` line. Do NOT call `startAnimation()` or `writeDirect()` in non-animated mode — causes duplicated output ### Image Conversion Pipeline diff --git a/Sources/ExFigCLI/TerminalUI/Spinner.swift b/Sources/ExFigCLI/TerminalUI/Spinner.swift index dcd8fa16..fd813dfb 100644 --- a/Sources/ExFigCLI/TerminalUI/Spinner.swift +++ b/Sources/ExFigCLI/TerminalUI/Spinner.swift @@ -74,10 +74,8 @@ final class Spinner: @unchecked Sendable { self.timer = timer timer.resume() } - } else { - // Non-animated mode: just print the message once with newline - TerminalOutputManager.shared.print("\(coloredFrame) \(initialMessage)") } + // Non-animated mode: no start output — stop() prints the final result } /// Update the spinner message diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index a70120c0..8a046d05 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -75,6 +75,8 @@ Uses `static let _typeRegistration` for thread-safe dispatch_once semantics. When adding new PKL types to schemas, regenerate with `codegen:pkl` and add the new type to the registration list in `PKLEvaluator.swift`. `registerPklTypes` has a hard `precondition(_shared == nil)` — must be called before any `TypeRegistry.get()`. +**Silent failure:** If a `PklRegisteredType` struct is missing from the list, pkl-swift silently returns `nil` for that field — no error is thrown and no warning logged. The PKL config evaluates successfully but the feature simply never activates. This is especially hard to debug for optional nested types like `variablesDarkMode`. After adding a new class to the PKL schema, ALWAYS check that the generated Swift struct is added to `registerPklTypes()` in `PKLEvaluator.swift`. + ## Codegen Gotchas - PKL `"kebab-case"` raw values become `.kebabCase` in Swift (not `.kebab_case`) diff --git a/Sources/ExFigConfig/PKL/PKLEvaluator.swift b/Sources/ExFigConfig/PKL/PKLEvaluator.swift index 01788ad1..e23243d7 100644 --- a/Sources/ExFigConfig/PKL/PKLEvaluator.swift +++ b/Sources/ExFigConfig/PKL/PKLEvaluator.swift @@ -46,6 +46,8 @@ public enum PKLEvaluator { Common.TokensFile.self, Common.PenpotSource.self, Common.WebpOptions.self, + Common.VariablesDarkMode.self, + Common.SuffixDarkMode.self, Common.Cache.self, Common.Colors.self, Common.Icons.self, From b71e3adfd946848deaa601ba6dade971e29d4357 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 14:30:45 +0500 Subject: [PATCH 06/17] fix(docs): correct registerPklTypes docs + add dark mode deserialization test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 4 ++-- .../Source/FigmaComponentsSource.swift | 6 +++++ Sources/ExFigConfig/CLAUDE.md | 11 ++++++++- .../ExFigTests/Fixtures/PKL/valid-config.pkl | 13 +++++++++++ Tests/ExFigTests/PKL/PKLEvaluatorTests.swift | 23 +++++++++++++++++++ 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 12660468..5a3bcebe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,7 +250,7 @@ Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaCo **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). -**PKL registration:** `Common.VariablesDarkMode` and `Common.SuffixDarkMode` must be listed in `registerPklTypes()` in `PKLEvaluator.swift`. Omitting a `PklRegisteredType` struct causes pkl-swift to silently return `nil` for that field — the PKL config appears correct but the feature never activates. +**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. **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:)`. @@ -481,7 +481,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | 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` | New `PklRegisteredType` structs (e.g. `Common.VariablesDarkMode`) MUST be added to `registerPklTypes()` in `PKLEvaluator.swift` — pkl-swift silently returns `nil` without registration, no error thrown | +| 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 | ## Additional Rules diff --git a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift index 0b4260fb..0e79c9d9 100644 --- a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift +++ b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift @@ -34,6 +34,12 @@ struct FigmaComponentsSource: ComponentsSource { let result = try await loader.load(filter: filter) + // Diagnostic: trace variablesDarkMode config reaching the source + let col = input.variablesCollectionName ?? "nil" + let lm = input.variablesLightModeName ?? "nil" + let dm = input.variablesDarkModeName ?? "nil" + logger.info("Variable-mode dark config: collection=\(col) light=\(lm) dark=\(dm)") + // Variable-mode dark generation: resolve variable bindings and replace colors in SVGs let hasPartialConfig = input.variablesCollectionName != nil || input.variablesLightModeName != nil diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index 8a046d05..92d1d7ce 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -75,7 +75,16 @@ Uses `static let _typeRegistration` for thread-safe dispatch_once semantics. When adding new PKL types to schemas, regenerate with `codegen:pkl` and add the new type to the registration list in `PKLEvaluator.swift`. `registerPklTypes` has a hard `precondition(_shared == nil)` — must be called before any `TypeRegistry.get()`. -**Silent failure:** If a `PklRegisteredType` struct is missing from the list, pkl-swift silently returns `nil` for that field — no error is thrown and no warning logged. The PKL config evaluates successfully but the feature simply never activates. This is especially hard to debug for optional nested types like `variablesDarkMode`. After adding a new class to the PKL schema, ALWAYS check that the generated Swift struct is added to `registerPklTypes()` in `PKLEvaluator.swift`. +**Scope:** `registerPklTypes` is a performance optimization that bypasses O(N) type scanning. It does NOT affect decoding of concrete `Decodable` types — pkl-swift decodes those via synthesized `init(from:)` directly. Missing entries only affect polymorphic `PklAny` decoding. Still list all types for completeness and the exhaustive count test in `PKLEvaluatorTests`. + +### Debugging PKL Deserialization + +When a PKL field appears as `nil` at runtime but `pkl eval --format json` shows it: + +1. Write a `PKLEvaluatorTests` test that evaluates a fixture with the field and asserts non-nil +2. If test passes: issue is downstream (entry bridge, source input, or runtime binary mismatch) +3. If test fails: issue is in pkl-swift decoding (check CodingKeys, property name mismatch) +4. Add diagnostic log in `FigmaComponentsSource` or bridge layer to trace values at runtime ## Codegen Gotchas diff --git a/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl b/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl index a9ff2401..e9418358 100644 --- a/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl +++ b/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl @@ -26,4 +26,17 @@ ios = new iOS.iOSConfig { nameStyle = "camelCase" } } + + icons = new Listing { + new iOS.IconsEntry { + figmaFrameName = "TestIcons" + format = "svg" + assetsFolder = "Icons" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "TestCollection" + lightModeName = "Light" + darkModeName = "Dark" + } + } + } } diff --git a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift index 5e8ef5f7..0c41be77 100644 --- a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift +++ b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift @@ -52,6 +52,29 @@ struct PKLEvaluatorTests { } } + @Test("Evaluates variablesDarkMode nested object") + func evaluatesVariablesDarkMode() async throws { + let configPath = Self.fixturesPath.appendingPathComponent("valid-config.pkl") + + let module = try await PKLEvaluator.evaluate(configPath: configPath) + + let icons = module.ios?.icons + #expect(icons?.count == 1) + + let entry = try #require(icons?.first) + #expect(entry.figmaFrameName == "TestIcons") + + // This is the critical assertion: variablesDarkMode must NOT be nil + let darkMode = try #require( + entry.variablesDarkMode, + "variablesDarkMode is nil — pkl-swift failed to deserialize nested object" + ) + #expect(darkMode.collectionName == "TestCollection") + #expect(darkMode.lightModeName == "Light") + #expect(darkMode.darkModeName == "Dark") + #expect(darkMode.primitivesModeName == nil) + } + @Test("All generated PKL types are registered") func allGeneratedPklTypesRegistered() { // Every registeredIdentifier in Generated/*.pkl.swift must be listed here AND From 3a4618bf82c31297d82d5aa450e29ed28638dda7 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 15:48:35 +0500 Subject: [PATCH 07/17] feat(icons): add cross-file variable resolution for dark mode generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 3 + .../Config/AndroidIconsEntry.swift | 3 +- .../Config/FlutterIconsEntry.swift | 3 +- Sources/ExFig-Web/Config/WebIconsEntry.swift | 3 +- Sources/ExFig-iOS/Config/iOSIconsEntry.swift | 3 +- Sources/ExFigCLI/CLAUDE.md | 60 +++--- .../Context/IconsExportContextImpl.swift | 3 +- .../Loaders/VariableModeDarkGenerator.swift | 183 ++++++++++++------ Sources/ExFigCLI/Resources/Schemas/Common.pkl | 5 + .../Source/FigmaComponentsSource.swift | 9 +- .../ExFigConfig/Generated/Common.pkl.swift | 9 +- .../Protocol/IconsExportContext.swift | 7 +- .../ExFigTests/Fixtures/PKL/valid-config.pkl | 1 + Tests/ExFigTests/PKL/PKLEvaluatorTests.swift | 1 + 14 files changed, 187 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a3bcebe..4dcfc989 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,6 +252,8 @@ Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaCo **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). + **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 @@ -483,6 +485,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | 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 | ## Additional Rules diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index 0d94da8b..a71223a9 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -29,7 +29,8 @@ public extension Android.IconsEntry { variablesCollectionName: variablesDarkMode?.collectionName, variablesLightModeName: variablesDarkMode?.lightModeName, variablesDarkModeName: variablesDarkMode?.darkModeName, - variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName, + variablesFileId: variablesDarkMode?.variablesFileId ) } diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index 5a119616..c97975a5 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -25,7 +25,8 @@ public extension Flutter.IconsEntry { variablesCollectionName: variablesDarkMode?.collectionName, variablesLightModeName: variablesDarkMode?.lightModeName, variablesDarkModeName: variablesDarkMode?.darkModeName, - variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName, + variablesFileId: variablesDarkMode?.variablesFileId ) } diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index 2c217f18..0fd37917 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -25,7 +25,8 @@ public extension Web.IconsEntry { variablesCollectionName: variablesDarkMode?.collectionName, variablesLightModeName: variablesDarkMode?.lightModeName, variablesDarkModeName: variablesDarkMode?.darkModeName, - variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName, + variablesFileId: variablesDarkMode?.variablesFileId ) } diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index 19d20584..7cb04627 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -32,7 +32,8 @@ public extension iOS.IconsEntry { variablesCollectionName: variablesDarkMode?.collectionName, variablesLightModeName: variablesDarkMode?.lightModeName, variablesDarkModeName: variablesDarkMode?.darkModeName, - variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName + variablesPrimitivesModeName: variablesDarkMode?.primitivesModeName, + variablesFileId: variablesDarkMode?.variablesFileId ) } diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 28a5599b..84f02518 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -150,36 +150,36 @@ Converter factories (`WebpConverterFactory`, `HeicConverterFactory`) handle plat ## Key Files -| File | Role | -| ----------------------------------------- | ------------------------------------------------------------------- | -| `ExFigCommand.swift` | Entry point, version, shared instances, subcommand registration | -| `Input/ExFigOptions.swift` | PKL config loading, token validation, auto-detection | -| `Batch/BatchContext.swift` | `BatchContext`, `ConfigExecutionContext`, `BatchSharedState` actor | -| `Batch/BatchExecutor.swift` | Parallel config execution with rate limiting | -| `Plugin/PluginRegistry.swift` | Platform plugin routing (config key → plugin → exporters) | -| `TerminalUI/TerminalUI.swift` | Output facade (info/success/warning/error, spinners, progress) | -| `TerminalUI/TerminalOutputManager.swift` | Thread-safe output synchronization, animation coordination | -| `TerminalUI/BatchProgressView.swift` | Multi-config progress display with log queuing | -| `Cache/GranularCacheManager.swift` | Per-node change detection with FNV-1a hashing | -| `Pipeline/SharedDownloadQueue.swift` | Cross-config download pipelining actor | -| `Output/FileWriter.swift` | Sequential and parallel file writing with directory creation | -| `Shared/ComponentPreFetcher.swift` | Pre-fetch components for multi-entry exports | -| `Input/TokensFileSource.swift` | W3C DTCG .tokens.json parser (local file → ExFigCore models) | -| `Output/W3CTokensExporter.swift` | W3C design token JSON exporter (v1/v2025 formats) | -| `Loaders/NumberVariablesLoader.swift` | Figma number variables → dimension/number tokens | -| `Subcommands/DownloadTokens.swift` | Unified `download tokens` subcommand | -| `MCP/ExFigMCPServer.swift` | MCP server setup and lifecycle (stdio transport) | -| `MCP/MCPToolDefinitions.swift` | MCP tool schemas (export colors, icons, images, etc.) | -| `MCP/MCPToolHandlers.swift` | MCP tool request handlers | -| `MCP/MCPResources.swift` | MCP resource providers (config, schemas) | -| `MCP/MCPPrompts.swift` | MCP prompt templates | -| `MCP/MCPServerState.swift` | MCP server shared state | -| `Source/SourceFactory.swift` | Centralized factory creating source instances by `DesignSourceKind` | -| `Source/Figma*Source.swift` | Figma source implementations wrapping existing loaders | -| `Source/Penpot*Source.swift` | Penpot source implementations (colors, components, typography) | -| `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | -| `Loaders/VariableModeDarkGenerator.swift` | Generates dark SVGs from light via Figma Variable bindings | -| `Output/SVGColorReplacer.swift` | Hex color replacement in SVG content (fill, stroke, stop-color) | +| File | Role | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `ExFigCommand.swift` | Entry point, version, shared instances, subcommand registration | +| `Input/ExFigOptions.swift` | PKL config loading, token validation, auto-detection | +| `Batch/BatchContext.swift` | `BatchContext`, `ConfigExecutionContext`, `BatchSharedState` actor | +| `Batch/BatchExecutor.swift` | Parallel config execution with rate limiting | +| `Plugin/PluginRegistry.swift` | Platform plugin routing (config key → plugin → exporters) | +| `TerminalUI/TerminalUI.swift` | Output facade (info/success/warning/error, spinners, progress) | +| `TerminalUI/TerminalOutputManager.swift` | Thread-safe output synchronization, animation coordination | +| `TerminalUI/BatchProgressView.swift` | Multi-config progress display with log queuing | +| `Cache/GranularCacheManager.swift` | Per-node change detection with FNV-1a hashing | +| `Pipeline/SharedDownloadQueue.swift` | Cross-config download pipelining actor | +| `Output/FileWriter.swift` | Sequential and parallel file writing with directory creation | +| `Shared/ComponentPreFetcher.swift` | Pre-fetch components for multi-entry exports | +| `Input/TokensFileSource.swift` | W3C DTCG .tokens.json parser (local file → ExFigCore models) | +| `Output/W3CTokensExporter.swift` | W3C design token JSON exporter (v1/v2025 formats) | +| `Loaders/NumberVariablesLoader.swift` | Figma number variables → dimension/number tokens | +| `Subcommands/DownloadTokens.swift` | Unified `download tokens` subcommand | +| `MCP/ExFigMCPServer.swift` | MCP server setup and lifecycle (stdio transport) | +| `MCP/MCPToolDefinitions.swift` | MCP tool schemas (export colors, icons, images, etc.) | +| `MCP/MCPToolHandlers.swift` | MCP tool request handlers | +| `MCP/MCPResources.swift` | MCP resource providers (config, schemas) | +| `MCP/MCPPrompts.swift` | MCP prompt templates | +| `MCP/MCPServerState.swift` | MCP server shared state | +| `Source/SourceFactory.swift` | Centralized factory creating source instances by `DesignSourceKind` | +| `Source/Figma*Source.swift` | Figma source implementations wrapping existing loaders | +| `Source/Penpot*Source.swift` | Penpot source implementations (colors, components, typography) | +| `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | +| `Loaders/VariableModeDarkGenerator.swift` | Generates dark SVGs via Figma Variables; supports cross-file resolution (name-based) when `variablesFileId` set | +| `Output/SVGColorReplacer.swift` | Hex color replacement in SVG content (fill, stroke, stop-color) | ### MCP Server Architecture diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index 3652fe64..e0fd4e0f 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -218,7 +218,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { collectionName: collectionName, lightModeName: lightModeName, darkModeName: darkModeName, - primitivesModeName: source.variablesPrimitivesModeName + primitivesModeName: source.variablesPrimitivesModeName, + variablesFileId: source.variablesFileId ) ) return IconsLoadOutputWithHashes( diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift index 155ba9f4..c45e3544 100644 --- a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import ExFigCore import FigmaAPI import Foundation @@ -7,6 +8,8 @@ import Logging import FoundationNetworking #endif +// swiftlint:disable type_body_length + /// Generates dark SVG variants from light SVGs by resolving Figma Variable bindings. /// /// Given light icon packs and a variables collection with light/dark modes, this generator: @@ -21,6 +24,8 @@ struct VariableModeDarkGenerator { let lightModeName: String let darkModeName: String let primitivesModeName: String? + /// Separate file ID for loading variables (when primitives are in a library file). + let variablesFileId: String? } /// Resolved mode IDs for variable resolution. @@ -51,21 +56,42 @@ struct VariableModeDarkGenerator { guard !lightPacks.isEmpty else { return [] } // 1. Fetch variable definitions - let variablesMeta = try await loadVariables(fileId: config.fileId) + let localMeta = try await loadVariables(fileId: config.fileId) + logger.debug("Variable-mode dark: loaded \(localMeta.variables.count) local variables") + + // When library file specified, load its variables for name-based cross-file resolution. + // Variable IDs are file-scoped — alias targets from the icons file don't exist in the + // library file by ID. We resolve by matching variable NAME across files. + let libMeta: VariablesMeta? + if let libFileId = config.variablesFileId, libFileId != config.fileId { + let lib = try await loadVariables(fileId: libFileId) + logger.debug("Variable-mode dark: loaded \(lib.variables.count) library variables from \(libFileId)") + libMeta = lib + } else { + libMeta = nil + } + + // Use local meta for ID-based lookups (matches node boundVariables) + let variablesMeta = localMeta // 2. Find collection and extract mode IDs guard let modes = findModeIds(in: variablesMeta, config: config) else { - let collectionNames = variablesMeta.variableCollections.values.map(\.name) + let names = variablesMeta.variableCollections.values.map(\.name).sorted() + logger.debug("Variable-mode dark: available collections: \(names)") logger.warning(""" Variables dark mode: collection '\(config.collectionName)' not found or missing \ modes '\(config.lightModeName)'/'\(config.darkModeName)'. \ - Available collections: \(collectionNames.sorted().joined(separator: ", ")) + Available collections: \(variablesMeta.variableCollections.values.map(\.name).sorted() + .joined(separator: ", ")) """) return [] } + logger.debug("Variable-mode dark: modes light=\(modes.lightModeId) dark=\(modes.darkModeId)") + // 3. Fetch nodes to discover boundVariables on paints let nodeIds = lightPacks.compactMap(\.nodeId) + logger.debug("Variable-mode dark: \(nodeIds.count)/\(lightPacks.count) packs have nodeIds") guard !nodeIds.isEmpty else { logger.warning("Variable-mode dark generation: none of the light packs have node IDs, skipping") @@ -73,6 +99,7 @@ struct VariableModeDarkGenerator { } let nodeMap = try await fetchNodesBatched(fileId: config.fileId, nodeIds: nodeIds) + logger.debug("Variable-mode dark: fetched \(nodeMap.count) nodes") // 4. For each icon, build light→dark color map from boundVariables let tempDir = FileManager.default.temporaryDirectory @@ -86,12 +113,17 @@ struct VariableModeDarkGenerator { } } - let darkPacks = try processLightPacks( - lightPacks, - nodeMap: nodeMap, + let ctx = ResolutionContext( variablesMeta: variablesMeta, + libMeta: libMeta, + libNameIndex: libMeta.map { meta in + Dictionary(meta.variables.values.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) + }, modes: modes, - tempDir: tempDir + darkModeName: config.darkModeName + ) + let darkPacks = try processLightPacks( + lightPacks, nodeMap: nodeMap, ctx: ctx, tempDir: tempDir ) // Keep temp dir alive — caller consumes the URLs during export, then OS cleans /tmp @@ -106,8 +138,7 @@ struct VariableModeDarkGenerator { private func processLightPacks( _ lightPacks: [ImagePack], nodeMap: [String: Node], - variablesMeta: VariablesMeta, - modes: ModeContext, + ctx: ResolutionContext, tempDir: URL ) throws -> [ImagePack] { var darkPacks: [ImagePack] = [] @@ -122,13 +153,7 @@ struct VariableModeDarkGenerator { continue } - // Collect all bound variable colors from the node tree - let colorMap = buildColorMap( - node: node, - variablesMeta: variablesMeta, - modes: modes, - iconName: pack.name - ) + let colorMap = buildColorMap(node: node, ctx: ctx, iconName: pack.name) guard !colorMap.isEmpty else { continue } @@ -138,6 +163,7 @@ struct VariableModeDarkGenerator { darkPacks.append(darkPack) } + logger.debug("Variable-mode dark: generated \(darkPacks.count)/\(lightPacks.count) dark packs") return darkPacks } @@ -238,67 +264,50 @@ struct VariableModeDarkGenerator { return allNodes } + /// Context for cross-file variable resolution. + private struct ResolutionContext { + let variablesMeta: VariablesMeta + let libMeta: VariablesMeta? + let libNameIndex: [String: VariableValue]? + let modes: ModeContext + let darkModeName: String + } + /// Walks a node tree and collects light→dark color mappings from boundVariables on paints. private func buildColorMap( node: Node, - variablesMeta: VariablesMeta, - modes: ModeContext, + ctx: ResolutionContext, iconName: String ) -> [String: String] { var colorMap: [String: String] = [:] - collectBoundColors( - from: node.document, - variablesMeta: variablesMeta, - modes: modes, - colorMap: &colorMap, - iconName: iconName - ) + collectBoundColors(from: node.document, ctx: ctx, colorMap: &colorMap, iconName: iconName) return colorMap } private func collectBoundColors( from document: Document, - variablesMeta: VariablesMeta, - modes: ModeContext, + ctx: ResolutionContext, colorMap: inout [String: String], iconName: String ) { - // Check fills for paint in document.fills { - collectFromPaint(paint, variablesMeta: variablesMeta, modes: modes, colorMap: &colorMap, iconName: iconName) + collectFromPaint(paint, ctx: ctx, colorMap: &colorMap, iconName: iconName) } - - // Check strokes if let strokes = document.strokes { for paint in strokes { - collectFromPaint( - paint, - variablesMeta: variablesMeta, - modes: modes, - colorMap: &colorMap, - iconName: iconName - ) + collectFromPaint(paint, ctx: ctx, colorMap: &colorMap, iconName: iconName) } } - - // Recurse into children if let children = document.children { for child in children { - collectBoundColors( - from: child, - variablesMeta: variablesMeta, - modes: modes, - colorMap: &colorMap, - iconName: iconName - ) + collectBoundColors(from: child, ctx: ctx, colorMap: &colorMap, iconName: iconName) } } } private func collectFromPaint( _ paint: Paint, - variablesMeta: VariablesMeta, - modes: ModeContext, + ctx: ResolutionContext, colorMap: inout [String: String], iconName: String ) { @@ -313,24 +322,72 @@ struct VariableModeDarkGenerator { b: lightColor.b ) - // Resolve dark value for this variable - if let darkHex = resolveDarkColor( + // Try local resolution first (same file) + var darkHex = resolveDarkColor( variableId: colorAlias.id, - modeId: modes.darkModeId, - variablesMeta: variablesMeta, - primitivesModeId: modes.primitivesModeId - ) { - if lightHex != darkHex { - if let existing = colorMap[lightHex], existing != darkHex { - logger.warning( - "Icon '\(iconName)': #\(lightHex) maps to multiple dark values (#\(existing) and #\(darkHex))" - ) - } - colorMap[lightHex] = darkHex + modeId: ctx.modes.darkModeId, + variablesMeta: ctx.variablesMeta, + primitivesModeId: ctx.modes.primitivesModeId + ) + + // Cross-file fallback: find variable by name in library, resolve there + if darkHex == nil, let libMeta = ctx.libMeta, let libNameIndex = ctx.libNameIndex { + if let localVar = ctx.variablesMeta.variables[colorAlias.id] { + darkHex = resolveViaLibrary( + variableName: localVar.name, + libMeta: libMeta, + libNameIndex: libNameIndex, + darkModeName: ctx.darkModeName + ) + } + } + + if let darkHex, lightHex != darkHex { + if let existing = colorMap[lightHex], existing != darkHex { + logger.warning( + "Icon '\(iconName)': #\(lightHex) maps to multiple dark values (#\(existing) and #\(darkHex))" + ) } + colorMap[lightHex] = darkHex } } + /// Resolves a variable's dark color by finding it by name in the library file. + private func resolveViaLibrary( + variableName: String, + libMeta: VariablesMeta, + libNameIndex: [String: VariableValue], + darkModeName: String + ) -> String? { + guard let libVar = libNameIndex[variableName] else { + logger.debug("Variable-mode dark: library fallback miss — '\(variableName)' not found in library") + return nil + } + guard let libCollection = libMeta.variableCollections[libVar.variableCollectionId] else { + return nil + } + + // Match mode by NAME (mode IDs are file-scoped and differ between files) + guard let libDarkModeId = libCollection.modes.first(where: { $0.name == darkModeName })?.modeId else { + logger + .debug( + "Variable-mode dark: library mode '\(darkModeName)' not found in collection '\(libCollection.name)'" + ) + return nil + } + + let result = resolveDarkColor( + variableId: libVar.id, + modeId: libDarkModeId, + variablesMeta: libMeta, + primitivesModeId: nil + ) + if result != nil { + logger.debug("Variable-mode dark: resolved '\(variableName)' via library → #\(result!)") + } + return result + } + /// Resolves a variable to its concrete color value in the given mode, following alias chains. private func resolveDarkColor( variableId: String, @@ -385,3 +442,5 @@ struct VariableModeDarkGenerator { } } } + +// swiftlint:enable type_body_length diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 4fb1b1cf..488a8017 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -76,6 +76,11 @@ class VariablesDarkMode { /// Primitives mode for resolving variable aliases (e.g., "Value"). primitivesModeName: String? + + /// Figma file ID containing the full variable chain (including primitives). + /// When variables reference an external library, specify the library file ID here. + /// Defaults to the entry's figmaFileId if not set. + variablesFileId: String? } /// Dark mode via name suffix splitting (global, on common.icons/images/colors). diff --git a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift index 0e79c9d9..f5f80346 100644 --- a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift +++ b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift @@ -34,12 +34,6 @@ struct FigmaComponentsSource: ComponentsSource { let result = try await loader.load(filter: filter) - // Diagnostic: trace variablesDarkMode config reaching the source - let col = input.variablesCollectionName ?? "nil" - let lm = input.variablesLightModeName ?? "nil" - let dm = input.variablesDarkModeName ?? "nil" - logger.info("Variable-mode dark config: collection=\(col) light=\(lm) dark=\(dm)") - // Variable-mode dark generation: resolve variable bindings and replace colors in SVGs let hasPartialConfig = input.variablesCollectionName != nil || input.variablesLightModeName != nil @@ -60,7 +54,8 @@ struct FigmaComponentsSource: ComponentsSource { collectionName: collectionName, lightModeName: lightModeName, darkModeName: darkModeName, - primitivesModeName: input.variablesPrimitivesModeName + primitivesModeName: input.variablesPrimitivesModeName, + variablesFileId: input.variablesFileId ) ) return IconsLoadOutput(light: result.light, dark: darkPacks) diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index b9e631cf..9f2f875a 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -241,16 +241,23 @@ extension Common { /// Primitives mode for resolving variable aliases (e.g., "Value"). public var primitivesModeName: String? + /// Figma file ID containing the full variable chain (including primitives). + /// When variables reference an external library, specify the library file ID here. + /// Defaults to the entry's figmaFileId if not set. + public var variablesFileId: String? + public init( collectionName: String, lightModeName: String, darkModeName: String, - primitivesModeName: String? + primitivesModeName: String?, + variablesFileId: String? ) { self.collectionName = collectionName self.lightModeName = lightModeName self.darkModeName = darkModeName self.primitivesModeName = primitivesModeName + self.variablesFileId = variablesFileId } } diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index a578a120..3b192b1d 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -115,6 +115,9 @@ public struct IconsSourceInput: Sendable { /// Primitives mode name for resolving variable aliases. public let variablesPrimitivesModeName: String? + /// Figma file ID for loading variables (when primitives are in a separate library file). + public let variablesFileId: String? + public init( sourceKind: DesignSourceKind = .figma, figmaFileId: String? = nil, @@ -135,7 +138,8 @@ public struct IconsSourceInput: Sendable { variablesCollectionName: String? = nil, variablesLightModeName: String? = nil, variablesDarkModeName: String? = nil, - variablesPrimitivesModeName: String? = nil + variablesPrimitivesModeName: String? = nil, + variablesFileId: String? = nil ) { self.sourceKind = sourceKind self.figmaFileId = figmaFileId @@ -157,6 +161,7 @@ public struct IconsSourceInput: Sendable { self.variablesLightModeName = variablesLightModeName self.variablesDarkModeName = variablesDarkModeName self.variablesPrimitivesModeName = variablesPrimitivesModeName + self.variablesFileId = variablesFileId } } diff --git a/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl b/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl index e9418358..cf596e45 100644 --- a/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl +++ b/Tests/ExFigTests/Fixtures/PKL/valid-config.pkl @@ -36,6 +36,7 @@ ios = new iOS.iOSConfig { collectionName = "TestCollection" lightModeName = "Light" darkModeName = "Dark" + variablesFileId = "lib-file-123" } } } diff --git a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift index 0c41be77..207e9d90 100644 --- a/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift +++ b/Tests/ExFigTests/PKL/PKLEvaluatorTests.swift @@ -73,6 +73,7 @@ struct PKLEvaluatorTests { #expect(darkMode.lightModeName == "Light") #expect(darkMode.darkModeName == "Dark") #expect(darkMode.primitivesModeName == nil) + #expect(darkMode.variablesFileId == "lib-file-123") } @Test("All generated PKL types are registered") From 75fd3b1a5767a69546ff8c3457af4e0a5617d71e Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 15:56:43 +0500 Subject: [PATCH 08/17] feat(icons): show dark variant count in export success message Add darkCount to IconsExportResult so the success message reflects generated dark variants: "Exported 47 icons (47 dark variants)". --- Sources/ExFig-iOS/Export/iOSIconsExporter.swift | 6 +++++- Sources/ExFigCore/Protocol/IconsExportContext.swift | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Sources/ExFig-iOS/Export/iOSIconsExporter.swift b/Sources/ExFig-iOS/Export/iOSIconsExporter.swift index 9b2ea374..5617175d 100644 --- a/Sources/ExFig-iOS/Export/iOSIconsExporter.swift +++ b/Sources/ExFig-iOS/Export/iOSIconsExporter.swift @@ -58,7 +58,8 @@ public struct iOSIconsExporter: IconsExporter { let merged = IconsExportResult.merge(results) if !context.isBatchMode { - context.success("Done! Exported \(merged.count) icons to Xcode project.") + let darkSuffix = merged.darkCount > 0 ? " (\(merged.darkCount) dark variants)" : "" + context.success("Done! Exported \(merged.count) icons\(darkSuffix) to Xcode project.") } return merged @@ -204,8 +205,11 @@ public struct iOSIconsExporter: IconsExporter { ? loadResult.allAssetMetadata.count - iconPairs.count : 0 + let darkCount = iconPairs.count { $0.dark != nil } + return IconsExportResult( count: iconPairs.count, + darkCount: darkCount, skippedCount: skippedCount, computedHashes: loadResult.computedHashes, allAssetMetadata: loadResult.allAssetMetadata diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index 3b192b1d..740e24e4 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -298,6 +298,9 @@ public struct IconsExportResult: Sendable { /// Number of icons successfully exported. public let count: Int + /// Number of dark mode variants generated. + public let darkCount: Int + /// Number of icons skipped due to granular cache (unchanged). public let skippedCount: Int @@ -309,30 +312,34 @@ public struct IconsExportResult: Sendable { public init( count: Int, + darkCount: Int = 0, skippedCount: Int = 0, computedHashes: [String: [String: String]] = [:], allAssetMetadata: [AssetMetadata] = [] ) { self.count = count + self.darkCount = darkCount self.skippedCount = skippedCount self.computedHashes = computedHashes self.allAssetMetadata = allAssetMetadata } /// Creates a simple result with just count (no granular cache). - public static func simple(count: Int) -> IconsExportResult { - IconsExportResult(count: count) + public static func simple(count: Int, darkCount: Int = 0) -> IconsExportResult { + IconsExportResult(count: count, darkCount: darkCount) } /// Merges multiple results into one. public static func merge(_ results: [IconsExportResult]) -> IconsExportResult { var totalCount = 0 + var totalDark = 0 var totalSkipped = 0 var allHashes: [String: [String: String]] = [:] var allMetadata: [AssetMetadata] = [] for result in results { totalCount += result.count + totalDark += result.darkCount totalSkipped += result.skippedCount // Merge hashes @@ -349,6 +356,7 @@ public struct IconsExportResult: Sendable { return IconsExportResult( count: totalCount, + darkCount: totalDark, skippedCount: totalSkipped, computedHashes: allHashes, allAssetMetadata: allMetadata From 92529daf23b1db46a3727d4c4ad08a492f018c03 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 16:19:00 +0500 Subject: [PATCH 09/17] perf(icons): cache Variables API responses across parallel entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 3 ++ Sources/ExFigCLI/CLAUDE.md | 1 + .../Context/IconsExportContextImpl.swift | 7 +++-- .../Loaders/VariableModeDarkGenerator.swift | 12 ++++++-- Sources/ExFigCLI/Loaders/VariablesCache.swift | 24 ++++++++++++++++ .../Source/FigmaComponentsSource.swift | 3 +- Sources/ExFigCLI/Source/SourceFactory.swift | 6 ++-- .../Export/PluginIconsExport.swift | 28 +++++++++++++------ 8 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 Sources/ExFigCLI/Loaders/VariablesCache.swift diff --git a/CLAUDE.md b/CLAUDE.md index 4dcfc989..f4a264a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,6 +254,8 @@ Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaCo **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. + **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 @@ -486,6 +488,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | 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 | ## Additional Rules diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 84f02518..4d0c5423 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -179,6 +179,7 @@ Converter factories (`WebpConverterFactory`, `HeicConverterFactory`) handle plat | `Source/Penpot*Source.swift` | Penpot source implementations (colors, components, typography) | | `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | | `Loaders/VariableModeDarkGenerator.swift` | Generates dark SVGs via Figma Variables; supports cross-file resolution (name-based) when `variablesFileId` set | +| `Loaders/VariablesCache.swift` | Lock+Task dedup cache for Variables API responses across parallel entries | | `Output/SVGColorReplacer.swift` | Hex color replacement in SVG content (fill, stroke, stop-color) | ### MCP Server Architecture diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index e0fd4e0f..e0a84f8b 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -23,6 +23,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { let configExecutionContext: ConfigExecutionContext? let granularCacheManager: GranularCacheManager? let platform: Platform + let variablesCache: VariablesCache? init( client: Client, @@ -34,7 +35,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { fileDownloader: FileDownloader = FileDownloader(), configExecutionContext: ConfigExecutionContext? = nil, granularCacheManager: GranularCacheManager? = nil, - platform: Platform + platform: Platform, + variablesCache: VariablesCache? = nil ) { self.client = client self.componentsSource = componentsSource @@ -46,6 +48,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { self.configExecutionContext = configExecutionContext self.granularCacheManager = granularCacheManager self.platform = platform + self.variablesCache = variablesCache } var isGranularCacheEnabled: Bool { @@ -210,7 +213,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { logger.warning("Variable-mode dark generation requires a Figma file ID, skipping") return output } - let generator = VariableModeDarkGenerator(client: client, logger: logger) + let generator = VariableModeDarkGenerator(client: client, logger: logger, variablesCache: variablesCache) let darkPacks = try await generator.generateDarkVariants( lightPacks: output.light, config: .init( diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift index c45e3544..1a44e625 100644 --- a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -37,10 +37,12 @@ struct VariableModeDarkGenerator { private let client: Client private let logger: Logger + private let variablesCache: VariablesCache? - init(client: Client, logger: Logger) { + init(client: Client, logger: Logger, variablesCache: VariablesCache? = nil) { self.client = client self.logger = logger + self.variablesCache = variablesCache } /// Generates dark SVG variants by resolving variable bindings and replacing colors. @@ -215,8 +217,12 @@ struct VariableModeDarkGenerator { } private func loadVariables(fileId: String) async throws -> VariablesMeta { - let endpoint = VariablesEndpoint(fileId: fileId) - return try await client.request(endpoint) + if let cache = variablesCache { + return try await cache.get(fileId: fileId) { [client] in + try await client.request(VariablesEndpoint(fileId: fileId)) + } + } + return try await client.request(VariablesEndpoint(fileId: fileId)) } private func findModeIds(in meta: VariablesMeta, config: Config) -> ModeContext? { diff --git a/Sources/ExFigCLI/Loaders/VariablesCache.swift b/Sources/ExFigCLI/Loaders/VariablesCache.swift new file mode 100644 index 00000000..de29f54b --- /dev/null +++ b/Sources/ExFigCLI/Loaders/VariablesCache.swift @@ -0,0 +1,24 @@ +import FigmaAPI +import Foundation + +/// Deduplicating cache for Figma Variables API responses. +/// +/// Concurrent callers requesting the same `fileId` share a single in-flight `Task`. +/// First caller triggers the fetch; subsequent callers await the same result. +final class VariablesCache: @unchecked Sendable { + private let lock = NSLock() + private var tasks: [String: Task] = [:] + + func get( + fileId: String, + fetch: @escaping @Sendable () async throws -> VariablesMeta + ) async throws -> VariablesMeta { + let task: Task = lock.withLock { + if let existing = tasks[fileId] { return existing } + let newTask = Task { try await fetch() } + tasks[fileId] = newTask + return newTask + } + return try await task.value + } +} diff --git a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift index f5f80346..55bfc238 100644 --- a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift +++ b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift @@ -10,6 +10,7 @@ struct FigmaComponentsSource: ComponentsSource { let platform: Platform let logger: Logger let filter: String? + let variablesCache: VariablesCache? func loadIcons(from input: IconsSourceInput) async throws -> IconsLoadOutput { let config = IconsLoaderConfig( @@ -46,7 +47,7 @@ struct FigmaComponentsSource: ComponentsSource { logger.warning("Variable-mode dark generation requires a Figma file ID, skipping") return IconsLoadOutput(light: result.light, dark: []) } - let generator = VariableModeDarkGenerator(client: client, logger: logger) + let generator = VariableModeDarkGenerator(client: client, logger: logger, variablesCache: variablesCache) let darkPacks = try await generator.generateDarkVariants( lightPacks: result.light, config: .init( diff --git a/Sources/ExFigCLI/Source/SourceFactory.swift b/Sources/ExFigCLI/Source/SourceFactory.swift index 0032ba52..1d58c17d 100644 --- a/Sources/ExFigCLI/Source/SourceFactory.swift +++ b/Sources/ExFigCLI/Source/SourceFactory.swift @@ -32,7 +32,8 @@ enum SourceFactory { platform: Platform, logger: Logger, filter: String?, - ui: TerminalUI + ui: TerminalUI, + variablesCache: VariablesCache? = nil ) throws -> any ComponentsSource { switch sourceKind { case .figma: @@ -42,7 +43,8 @@ enum SourceFactory { params: params, platform: platform, logger: logger, - filter: filter + filter: filter, + variablesCache: variablesCache ) case .penpot: return PenpotComponentsSource(ui: ui) diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index 3653d005..96db4c78 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -45,6 +45,7 @@ extension ExFigCommand.ExportIcons { guard let sourceKind = entries.first?.resolvedSourceKind else { throw ExFigError.configurationError("No entries provided for icons export") } + let variablesCache = VariablesCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -52,7 +53,8 @@ extension ExFigCommand.ExportIcons { platform: .ios, logger: ExFigCommand.logger, filter: filter, - ui: ui + ui: ui, + variablesCache: variablesCache ) let context = IconsExportContextImpl( @@ -64,7 +66,8 @@ extension ExFigCommand.ExportIcons { isBatchMode: batchMode, fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, - platform: .ios + platform: .ios, + variablesCache: variablesCache ) // Export via plugin (returns IconsExportResult with hashes) @@ -130,6 +133,7 @@ extension ExFigCommand.ExportIcons { guard let sourceKind = entries.first?.resolvedSourceKind else { throw ExFigError.configurationError("No entries provided for icons export") } + let variablesCache = VariablesCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -137,7 +141,8 @@ extension ExFigCommand.ExportIcons { platform: .android, logger: ExFigCommand.logger, filter: filter, - ui: ui + ui: ui, + variablesCache: variablesCache ) let context = IconsExportContextImpl( @@ -149,7 +154,8 @@ extension ExFigCommand.ExportIcons { isBatchMode: batchMode, fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, - platform: .android + platform: .android, + variablesCache: variablesCache ) let exporter = AndroidIconsExporter() @@ -189,6 +195,7 @@ extension ExFigCommand.ExportIcons { guard let sourceKind = entries.first?.resolvedSourceKind else { throw ExFigError.configurationError("No entries provided for icons export") } + let variablesCache = VariablesCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -196,7 +203,8 @@ extension ExFigCommand.ExportIcons { platform: .flutter, logger: ExFigCommand.logger, filter: filter, - ui: ui + ui: ui, + variablesCache: variablesCache ) let context = IconsExportContextImpl( @@ -208,7 +216,8 @@ extension ExFigCommand.ExportIcons { isBatchMode: batchMode, fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, - platform: .flutter + platform: .flutter, + variablesCache: variablesCache ) let exporter = FlutterIconsExporter() @@ -248,6 +257,7 @@ extension ExFigCommand.ExportIcons { guard let sourceKind = entries.first?.resolvedSourceKind else { throw ExFigError.configurationError("No entries provided for icons export") } + let variablesCache = VariablesCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -255,7 +265,8 @@ extension ExFigCommand.ExportIcons { platform: .web, logger: ExFigCommand.logger, filter: filter, - ui: ui + ui: ui, + variablesCache: variablesCache ) let context = IconsExportContextImpl( @@ -267,7 +278,8 @@ extension ExFigCommand.ExportIcons { isBatchMode: batchMode, fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, - platform: .web + platform: .web, + variablesCache: variablesCache ) let exporter = WebIconsExporter() From 5adbda29fcfdf277bc45f5fbe42448ab17a8656c Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 16:32:25 +0500 Subject: [PATCH 10/17] docs: document variablesFileId field and Variable Mode dark generation - 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 --- CONFIG.md | 2 +- .../ExFig.docc/Android/AndroidIcons.md | 22 +++++++++++++++++++ Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md | 22 +++++++++++++++++++ .../Resources/Schemas/examples/exfig-ios.pkl | 4 +++- 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CONFIG.md b/CONFIG.md index e9fa66d2..a6d374a8 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -322,7 +322,7 @@ All Icons and Images entries across platforms extend `Common.FrameSource`, which | `nameValidateRegexp` | `String?` | — | Regex pattern for name validation | | `nameReplaceRegexp` | `String?` | — | Replacement pattern using captured groups | -**VariablesDarkMode** fields: `collectionName` (required), `lightModeName` (required), `darkModeName` (required), `primitivesModeName` (optional). +**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 diff --git a/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md b/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md index d788382e..b32901d8 100644 --- a/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md +++ b/Sources/ExFigCLI/ExFig.docc/Android/AndroidIcons.md @@ -272,6 +272,28 @@ ic/24/logo ic/24/logo_dark ``` +### Variable Mode + +For icons using Figma Variable bindings (e.g., double-color icons), ExFig resolves dark colors automatically from the Variables API: + +```pkl +new Android.IconsEntry { + figmaFrameName = "DoubleColor" + format = "svg" + output = "./app/src/main/res" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" + lightModeName = "Light" + darkModeName = "Dark" + variablesFileId = "LIBRARY_FILE_ID" + } +} +``` + +ExFig fetches variable definitions, resolves alias chains to concrete colors, and replaces hex values in SVGs to produce dark variants. Light icons go to `drawable/`, dark to `drawable-night/`. + +`variablesFileId` is required when your variables alias to primitives in an external Figma library. + ## Tips 1. **Keep icons simple**: Complex paths may not convert well diff --git a/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md b/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md index 890c63e8..400da5f1 100644 --- a/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md +++ b/Sources/ExFigCLI/ExFig.docc/iOS/iOSIcons.md @@ -304,6 +304,28 @@ Icons frame └── ic/24/logo_dark ``` +### Variable Mode + +For icons using Figma Variable bindings (e.g., double-color icons), ExFig resolves dark colors automatically from the Variables API: + +```pkl +new iOS.IconsEntry { + figmaFrameName = "DoubleColor" + format = "svg" + assetsFolder = "DoubleColorIcons" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" + lightModeName = "Light" + darkModeName = "Dark" + variablesFileId = "LIBRARY_FILE_ID" + } +} +``` + +ExFig fetches variable definitions, resolves alias chains to concrete colors, and replaces hex values in SVGs to produce dark variants. + +`variablesFileId` is required when your variables alias to primitives in an external Figma library. It specifies the library file where the full variable chain (including primitives) can be resolved. If all variables are local to the icons file, omit this field. + ## See Also - diff --git a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl index 2c9c8228..d1c8b6a4 100644 --- a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl @@ -49,7 +49,8 @@ ios = new iOS.iOSConfig { templatesPath = "BrandKit/Templates" // rtlProperty = "RTL" // default; set to null to disable variant-based RTL detection } - // Variable-mode dark: generate dark SVGs from Figma Variable bindings + // Variable-mode dark: generate dark SVGs from Figma Variable bindings. + // variablesFileId is required when variables alias to primitives in an external library. new iOS.IconsEntry { figmaFrameName = "DoubleColor" format = "svg" @@ -59,6 +60,7 @@ ios = new iOS.iOSConfig { collectionName = "DesignTokens" lightModeName = "Light" darkModeName = "Dark" + variablesFileId = "LIBRARY_FILE_ID" } } } From 9d816f6e8efc6633b396ceb0d9ef1d441924d268 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 18:51:44 +0500 Subject: [PATCH 11/17] feat(icons): support alpha/opacity in dark mode SVG color replacement 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 --- CLAUDE.md | 2 + .../Loaders/VariableModeDarkGenerator.swift | 41 ++++---- .../ExFigCLI/Output/SVGColorReplacer.swift | 94 ++++++++++++++----- .../Output/SVGColorReplacerTests.swift | 92 ++++++++++++++++-- 4 files changed, 179 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f4a264a4..cc5b7bdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -256,6 +256,8 @@ Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaCo **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. +**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 diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift index 1a44e625..588971f3 100644 --- a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -173,7 +173,7 @@ struct VariableModeDarkGenerator { private func buildDarkPack( for pack: ImagePack, - colorMap: [String: String], + colorMap: [String: ColorReplacement], tempDir: URL ) throws -> ImagePack? { guard let svgImage = pack.images.first else { @@ -284,8 +284,8 @@ struct VariableModeDarkGenerator { node: Node, ctx: ResolutionContext, iconName: String - ) -> [String: String] { - var colorMap: [String: String] = [:] + ) -> [String: ColorReplacement] { + var colorMap: [String: ColorReplacement] = [:] collectBoundColors(from: node.document, ctx: ctx, colorMap: &colorMap, iconName: iconName) return colorMap } @@ -293,7 +293,7 @@ struct VariableModeDarkGenerator { private func collectBoundColors( from document: Document, ctx: ResolutionContext, - colorMap: inout [String: String], + colorMap: inout [String: ColorReplacement], iconName: String ) { for paint in document.fills { @@ -314,7 +314,7 @@ struct VariableModeDarkGenerator { private func collectFromPaint( _ paint: Paint, ctx: ResolutionContext, - colorMap: inout [String: String], + colorMap: inout [String: ColorReplacement], iconName: String ) { guard let boundVars = paint.boundVariables, @@ -329,7 +329,7 @@ struct VariableModeDarkGenerator { ) // Try local resolution first (same file) - var darkHex = resolveDarkColor( + var darkColor = resolveDarkColor( variableId: colorAlias.id, modeId: ctx.modes.darkModeId, variablesMeta: ctx.variablesMeta, @@ -337,9 +337,9 @@ struct VariableModeDarkGenerator { ) // Cross-file fallback: find variable by name in library, resolve there - if darkHex == nil, let libMeta = ctx.libMeta, let libNameIndex = ctx.libNameIndex { + if darkColor == nil, let libMeta = ctx.libMeta, let libNameIndex = ctx.libNameIndex { if let localVar = ctx.variablesMeta.variables[colorAlias.id] { - darkHex = resolveViaLibrary( + darkColor = resolveViaLibrary( variableName: localVar.name, libMeta: libMeta, libNameIndex: libNameIndex, @@ -348,13 +348,12 @@ struct VariableModeDarkGenerator { } } - if let darkHex, lightHex != darkHex { - if let existing = colorMap[lightHex], existing != darkHex { - logger.warning( - "Icon '\(iconName)': #\(lightHex) maps to multiple dark values (#\(existing) and #\(darkHex))" - ) + if let darkColor, lightHex != darkColor.hex || darkColor.changesOpacity { + if let existing = colorMap[lightHex], existing.hex != darkColor.hex { + let msg = "#\(lightHex) → multiple dark: #\(existing.hex), #\(darkColor.hex)" + logger.warning("Icon '\(iconName)': \(msg)") } - colorMap[lightHex] = darkHex + colorMap[lightHex] = darkColor } } @@ -364,7 +363,7 @@ struct VariableModeDarkGenerator { libMeta: VariablesMeta, libNameIndex: [String: VariableValue], darkModeName: String - ) -> String? { + ) -> ColorReplacement? { guard let libVar = libNameIndex[variableName] else { logger.debug("Variable-mode dark: library fallback miss — '\(variableName)' not found in library") return nil @@ -388,8 +387,9 @@ struct VariableModeDarkGenerator { variablesMeta: libMeta, primitivesModeId: nil ) - if result != nil { - logger.debug("Variable-mode dark: resolved '\(variableName)' via library → #\(result!)") + if let result { + logger + .debug("Variable-mode dark: resolved '\(variableName)' via library → #\(result.hex) a=\(result.alpha)") } return result } @@ -401,7 +401,7 @@ struct VariableModeDarkGenerator { variablesMeta: VariablesMeta, primitivesModeId: String?, depth: Int = 0 - ) -> String? { + ) -> ColorReplacement? { guard depth < 10 else { logger.warning("Variable alias chain exceeded depth limit (variableId: \(variableId))") return nil @@ -420,7 +420,10 @@ struct VariableModeDarkGenerator { switch value { case let .color(color): - return SVGColorReplacer.normalizeColor(r: color.r, g: color.g, b: color.b) + return ColorReplacement( + hex: SVGColorReplacer.normalizeColor(r: color.r, g: color.g, b: color.b), + alpha: color.a + ) case let .variableAlias(alias): // Resolve alias — use primitives mode if available, else use the same mode diff --git a/Sources/ExFigCLI/Output/SVGColorReplacer.swift b/Sources/ExFigCLI/Output/SVGColorReplacer.swift index 1a553697..573ec81d 100644 --- a/Sources/ExFigCLI/Output/SVGColorReplacer.swift +++ b/Sources/ExFigCLI/Output/SVGColorReplacer.swift @@ -1,5 +1,16 @@ import Foundation +/// A resolved dark color with optional alpha override. +struct ColorReplacement { + let hex: String + let alpha: Double + + /// Whether this replacement changes opacity (not fully opaque). + var changesOpacity: Bool { + alpha < 0.999 + } +} + /// Replaces hex colors in SVG content based on a light→dark color map. /// /// Used by ``VariableModeDarkGenerator`` to create dark SVG variants @@ -7,53 +18,90 @@ import Foundation enum SVGColorReplacer { /// Replaces hex colors in SVG content using the provided color map. /// + /// Handles both RGB hex replacement and opacity changes. When the dark color + /// has alpha < 1.0, adds `-opacity` attributes to the SVG elements. + /// /// - Parameters: /// - svgContent: The SVG string to process. - /// - colorMap: A mapping of normalized 6-digit lowercase hex (no `#`) from light to dark. + /// - colorMap: A mapping of normalized 6-digit lowercase hex (no `#`) to dark replacement. /// - Returns: The SVG string with colors replaced. - static func replaceColors(in svgContent: String, colorMap: [String: String]) -> String { + static func replaceColors(in svgContent: String, colorMap: [String: ColorReplacement]) -> String { guard !colorMap.isEmpty else { return svgContent } var result = svgContent - // Replace hex colors in SVG attributes: fill="#RRGGBB", stroke="#RRGGBB", stop-color="#RRGGBB" - // and inline CSS: fill:#RRGGBB, stroke:#RRGGBB - for (lightHex, darkHex) in colorMap { - // Match both attribute and CSS property styles, case-insensitive + for (lightHex, replacement) in colorMap { + result = replaceHex(in: result, lightHex: lightHex, replacement: replacement) + } + + return result + } + + // swiftlint:disable function_body_length + + private static func replaceHex(in svg: String, lightHex: String, replacement: ColorReplacement) -> String { + var result = svg + let darkHex = replacement.hex + + if replacement.changesOpacity { + let opacityStr = String(format: "%.2g", replacement.alpha) + + // Attribute style: fill="#aabbcc" → fill="#darkHex" fill-opacity="0" + result = regexReplace( + in: result, + pattern: "(fill|stroke)(\\s*=\\s*[\"'])#\(lightHex)([\"'])", + template: "$1$2#\(darkHex)$3 $1-opacity=\"\(opacityStr)\"" + ) + // stop-color in gradients: stop-color="#aabbcc" → stop-color="#darkHex" stop-opacity="0" + result = regexReplace( + in: result, + pattern: "(stop-color)(\\s*=\\s*[\"'])#\(lightHex)([\"'])", + template: "$1$2#\(darkHex)$3 stop-opacity=\"\(opacityStr)\"" + ) + // CSS property style: fill:#aabbcc → fill:#darkHex;fill-opacity:0 + result = regexReplace( + in: result, + pattern: "(fill|stroke)(\\s*:\\s*)#\(lightHex)", + template: "$1$2#\(darkHex);$1-opacity:\(opacityStr)" + ) + result = regexReplace( + in: result, + pattern: "(stop-color)(\\s*:\\s*)#\(lightHex)", + template: "$1$2#\(darkHex);stop-opacity:\(opacityStr)" + ) + } else { + // Simple hex-only replacement (no opacity change) let replacements: [(pattern: String, template: String)] = [ - // Attribute style: fill="#aabbcc" or stroke="#AABBCC" ( "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*=\\s*[\"'])#\(lightHex)([\"'])", "$1$2#\(darkHex)$3" ), - // CSS property style: fill:#aabbcc or stroke:#AABBCC (in style attributes) ( "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*:\\s*)#\(lightHex)", "$1$2#\(darkHex)" ), ] - for (pattern, template) in replacements { - do { - let regex = try NSRegularExpression( - pattern: pattern, - options: .caseInsensitive - ) - let range = NSRange(result.startIndex..., in: result) - result = regex.stringByReplacingMatches( - in: result, - range: range, - withTemplate: template - ) - } catch { - assertionFailure("Invalid regex pattern: \(pattern), error: \(error)") - } + result = regexReplace(in: result, pattern: pattern, template: template) } } return result } + // swiftlint:enable function_body_length + + private static func regexReplace(in string: String, pattern: String, template: String) -> String { + do { + let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive) + let range = NSRange(string.startIndex..., in: string) + return regex.stringByReplacingMatches(in: string, range: range, withTemplate: template) + } catch { + assertionFailure("Invalid regex pattern: \(pattern), error: \(error)") + return string + } + } + /// Normalizes a ``FigmaAPI.PaintColor`` (RGBA 0–1) to a 6-digit lowercase hex string without `#`. static func normalizeColor(r: Double, g: Double, b: Double) -> String { let ri = min(255, max(0, Int(round(r * 255)))) diff --git a/Tests/ExFigTests/Output/SVGColorReplacerTests.swift b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift index 0f37732b..c0918f8f 100644 --- a/Tests/ExFigTests/Output/SVGColorReplacerTests.swift +++ b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift @@ -2,23 +2,33 @@ import XCTest final class SVGColorReplacerTests: XCTestCase { - // MARK: - Basic Replacement + // MARK: - Helpers + + private func opaque(_ hex: String) -> ColorReplacement { + ColorReplacement(hex: hex, alpha: 1.0) + } + + private func transparent(_ hex: String, alpha: Double = 0.0) -> ColorReplacement { + ColorReplacement(hex: hex, alpha: alpha) + } + + // MARK: - Basic Replacement (opaque) func testReplacesHexInFillAttribute() { let svg = "" - let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": "00ff00"]) + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": opaque("00ff00")]) XCTAssertEqual(result, "") } func testReplacesHexInStrokeAttribute() { let svg = "" - let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": "112233"]) + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) XCTAssertEqual(result, "") } func testReplacesHexInStopColorAttribute() { let svg = "" - let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff00ff": "00ffff"]) + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff00ff": opaque("00ffff")]) XCTAssertEqual(result, "") } @@ -26,7 +36,7 @@ final class SVGColorReplacerTests: XCTestCase { func testCaseInsensitiveMatch() { let svg = "" - let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": "112233"]) + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) XCTAssertEqual(result, "") } @@ -36,7 +46,7 @@ final class SVGColorReplacerTests: XCTestCase { let svg = "" let result = SVGColorReplacer.replaceColors( in: svg, - colorMap: ["ff0000": "111111", "00ff00": "222222"] + colorMap: ["ff0000": opaque("111111"), "00ff00": opaque("222222")] ) XCTAssertTrue(result.contains("fill:#111111")) XCTAssertTrue(result.contains("stroke:#222222")) @@ -54,7 +64,7 @@ final class SVGColorReplacerTests: XCTestCase { """ let result = SVGColorReplacer.replaceColors( in: svg, - colorMap: ["ff0000": "111111", "00ff00": "222222", "0000ff": "333333"] + colorMap: ["ff0000": opaque("111111"), "00ff00": opaque("222222"), "0000ff": opaque("333333")] ) XCTAssertTrue(result.contains("#111111")) XCTAssertTrue(result.contains("#222222")) @@ -74,7 +84,7 @@ final class SVGColorReplacerTests: XCTestCase { func testNoMatchingColorsReturnsOriginal() { let svg = "" - let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": "112233"]) + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) XCTAssertEqual(result, svg) } @@ -96,4 +106,70 @@ final class SVGColorReplacerTests: XCTestCase { // 0.2 * 255 = 51 = 0x33 XCTAssertEqual(SVGColorReplacer.normalizeColor(r: 0.2, g: 0.2, b: 0.2), "333333") } + + // MARK: - Alpha / Opacity + + func testFillWithZeroAlphaAddsFillOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["d6fb94": transparent("d6fb94")]) + XCTAssertTrue(result.contains("fill-opacity=\"0\""), "Expected fill-opacity=\"0\" in: \(result)") + } + + func testStrokeWithZeroAlphaAddsStrokeOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": transparent("112233")]) + XCTAssertTrue(result.contains("stroke=\"#112233\""), "Hex should be replaced") + XCTAssertTrue(result.contains("stroke-opacity=\"0\""), "Expected stroke-opacity in: \(result)") + } + + func testPartialAlphaAddsFillOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": transparent("00ff00", alpha: 0.5)]) + XCTAssertTrue(result.contains("fill=\"#00ff00\"")) + XCTAssertTrue(result.contains("fill-opacity=\"0.5\""), "Expected fill-opacity in: \(result)") + } + + func testStopColorWithZeroAlphaAddsStopOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff00ff": transparent("00ffff")]) + XCTAssertTrue(result.contains("stop-color=\"#00ffff\"")) + XCTAssertTrue(result.contains("stop-opacity=\"0\""), "Expected stop-opacity in: \(result)") + } + + func testCSSFillWithAlphaAddsFillOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": transparent("00ff00")]) + XCTAssertTrue(result.contains("fill:#00ff00"), "Hex should be replaced") + XCTAssertTrue(result.contains("fill-opacity:0"), "Expected fill-opacity in CSS: \(result)") + } + + func testCSSStrokeWithAlphaAddsStrokeOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": transparent("112233")]) + XCTAssertTrue(result.contains("stroke:#112233")) + XCTAssertTrue(result.contains("stroke-opacity:0"), "Expected stroke-opacity in CSS: \(result)") + } + + func testOpaqueAlphaDoesNotAddOpacity() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": opaque("00ff00")]) + XCTAssertEqual(result, "") + XCTAssertFalse(result.contains("opacity")) + } + + func testSameHexDifferentAlphaStillReplaces() { + // Same hex but dark has alpha=0: should add opacity even though hex matches + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["d6fb94": transparent("d6fb94")]) + XCTAssertTrue(result.contains("fill-opacity=\"0\""), "Should add opacity even when hex is the same: \(result)") + } + + // MARK: - ColorReplacement + + func testColorReplacementChangesOpacity() { + XCTAssertTrue(ColorReplacement(hex: "ff0000", alpha: 0.0).changesOpacity) + XCTAssertTrue(ColorReplacement(hex: "ff0000", alpha: 0.5).changesOpacity) + XCTAssertFalse(ColorReplacement(hex: "ff0000", alpha: 1.0).changesOpacity) + XCTAssertFalse(ColorReplacement(hex: "ff0000", alpha: 0.999).changesOpacity) + } } From 9d31bc16e69b79f175a77bf154e735071740e546 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 19:40:34 +0500 Subject: [PATCH 12/17] fix(icons): add logging, tests, and safety improvements for variable 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) --- CLAUDE.md | 7 +- .../Loaders/VariableModeDarkGenerator.swift | 82 ++- Sources/ExFigCLI/Loaders/VariablesCache.swift | 9 +- .../ExFigCLI/Output/SVGColorReplacer.swift | 3 + .../VariableModeDarkGeneratorTests.swift | 630 ++++++++++++++++++ .../Output/SVGColorReplacerTests.swift | 14 + 6 files changed, 722 insertions(+), 23 deletions(-) create mode 100644 Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index cc5b7bdf..12e3d938 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,11 +250,15 @@ Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaCo **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. +**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. **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). @@ -491,6 +495,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | 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 | ## Additional Rules diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift index 588971f3..6e930ad4 100644 --- a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -18,6 +18,9 @@ import Logging /// 3. Resolves each variable's dark mode value (following alias chains) /// 4. Downloads light SVGs, replaces hex colors, and writes dark SVGs to temp files struct VariableModeDarkGenerator { + /// Maximum depth for resolving variable alias chains (prevents infinite recursion). + private static let maxAliasDepth = 10 + struct Config { let fileId: String let collectionName: String @@ -26,18 +29,35 @@ struct VariableModeDarkGenerator { let primitivesModeName: String? /// Separate file ID for loading variables (when primitives are in a library file). let variablesFileId: String? + + init( + fileId: String, + collectionName: String, + lightModeName: String, + darkModeName: String, + primitivesModeName: String? = nil, + variablesFileId: String? = nil + ) { + precondition(!fileId.isEmpty, "VariableModeDarkGenerator.Config.fileId must not be empty") + self.fileId = fileId + self.collectionName = collectionName + self.lightModeName = lightModeName + self.darkModeName = darkModeName + self.primitivesModeName = primitivesModeName + self.variablesFileId = variablesFileId + } } /// Resolved mode IDs for variable resolution. - private struct ModeContext { + struct ModeContext { let lightModeId: String let darkModeId: String let primitivesModeId: String? } - private let client: Client - private let logger: Logger - private let variablesCache: VariablesCache? + let client: Client + let logger: Logger + let variablesCache: VariablesCache? init(client: Client, logger: Logger, variablesCache: VariablesCache? = nil) { self.client = client @@ -105,7 +125,7 @@ struct VariableModeDarkGenerator { // 4. For each icon, build light→dark color map from boundVariables let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("exfig-variable-dark-\(ProcessInfo.processInfo.processIdentifier)") + .appendingPathComponent("exfig-variable-dark-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) var cleanupNeeded = true @@ -133,11 +153,11 @@ struct VariableModeDarkGenerator { return darkPacks } - // MARK: - Private + // MARK: - Internal (testable) // swiftlint:disable cyclomatic_complexity - private func processLightPacks( + func processLightPacks( _ lightPacks: [ImagePack], nodeMap: [String: Node], ctx: ResolutionContext, @@ -146,10 +166,13 @@ struct VariableModeDarkGenerator { var darkPacks: [ImagePack] = [] for pack in lightPacks { - guard let nodeId = pack.nodeId else { continue } + guard let nodeId = pack.nodeId else { + logger.warning("Icon '\(pack.name)' has no node ID, skipping dark generation") + continue + } guard let node = nodeMap[nodeId] else { - logger.debug( + logger.warning( "Node '\(nodeId)' for icon '\(pack.name)' not returned by Figma API, skipping dark generation" ) continue @@ -157,7 +180,10 @@ struct VariableModeDarkGenerator { let colorMap = buildColorMap(node: node, ctx: ctx, iconName: pack.name) - guard !colorMap.isEmpty else { continue } + guard !colorMap.isEmpty else { + logger.debug("Icon '\(pack.name)' has no variable-bound colors, skipping dark generation") + continue + } guard let darkPack = try buildDarkPack(for: pack, colorMap: colorMap, tempDir: tempDir) else { continue @@ -171,6 +197,8 @@ struct VariableModeDarkGenerator { // swiftlint:enable cyclomatic_complexity + // MARK: - Private + private func buildDarkPack( for pack: ImagePack, colorMap: [String: ColorReplacement], @@ -225,7 +253,7 @@ struct VariableModeDarkGenerator { return try await client.request(VariablesEndpoint(fileId: fileId)) } - private func findModeIds(in meta: VariablesMeta, config: Config) -> ModeContext? { + func findModeIds(in meta: VariablesMeta, config: Config) -> ModeContext? { for collection in meta.variableCollections.values { guard collection.name == config.collectionName else { continue } @@ -271,7 +299,7 @@ struct VariableModeDarkGenerator { } /// Context for cross-file variable resolution. - private struct ResolutionContext { + struct ResolutionContext { let variablesMeta: VariablesMeta let libMeta: VariablesMeta? let libNameIndex: [String: VariableValue]? @@ -280,7 +308,7 @@ struct VariableModeDarkGenerator { } /// Walks a node tree and collects light→dark color mappings from boundVariables on paints. - private func buildColorMap( + func buildColorMap( node: Node, ctx: ResolutionContext, iconName: String @@ -290,7 +318,7 @@ struct VariableModeDarkGenerator { return colorMap } - private func collectBoundColors( + func collectBoundColors( from document: Document, ctx: ResolutionContext, colorMap: inout [String: ColorReplacement], @@ -311,7 +339,7 @@ struct VariableModeDarkGenerator { } } - private func collectFromPaint( + func collectFromPaint( _ paint: Paint, ctx: ResolutionContext, colorMap: inout [String: ColorReplacement], @@ -358,7 +386,7 @@ struct VariableModeDarkGenerator { } /// Resolves a variable's dark color by finding it by name in the library file. - private func resolveViaLibrary( + func resolveViaLibrary( variableName: String, libMeta: VariablesMeta, libNameIndex: [String: VariableValue], @@ -369,6 +397,8 @@ struct VariableModeDarkGenerator { return nil } guard let libCollection = libMeta.variableCollections[libVar.variableCollectionId] else { + let collId = libVar.variableCollectionId + logger.debug("Variable-mode dark: library collection '\(collId)' not found for '\(variableName)'") return nil } @@ -395,21 +425,26 @@ struct VariableModeDarkGenerator { } /// Resolves a variable to its concrete color value in the given mode, following alias chains. - private func resolveDarkColor( + func resolveDarkColor( variableId: String, modeId: String, variablesMeta: VariablesMeta, primitivesModeId: String?, depth: Int = 0 ) -> ColorReplacement? { - guard depth < 10 else { + guard depth < Self.maxAliasDepth else { logger.warning("Variable alias chain exceeded depth limit (variableId: \(variableId))") return nil } - guard let variable = variablesMeta.variables[variableId], - variable.deletedButReferenced != true - else { return nil } + guard let variable = variablesMeta.variables[variableId] else { + logger.debug("Variable '\(variableId)' not found in variables meta during dark resolution") + return nil + } + guard variable.deletedButReferenced != true else { + logger.debug("Variable '\(variable.name)' (\(variableId)) is deleted but referenced, skipping") + return nil + } // Try the requested mode first, fall back to default mode of the collection let value = variable.valuesByMode[modeId] @@ -446,7 +481,12 @@ struct VariableModeDarkGenerator { depth: depth + 1 ) + case .none: + logger.debug("Variable '\(variable.name)' has no value for mode '\(modeId)'") + return nil + default: + logger.debug("Variable '\(variable.name)' has non-color type, cannot resolve dark color") return nil } } diff --git a/Sources/ExFigCLI/Loaders/VariablesCache.swift b/Sources/ExFigCLI/Loaders/VariablesCache.swift index de29f54b..d3f0a6c1 100644 --- a/Sources/ExFigCLI/Loaders/VariablesCache.swift +++ b/Sources/ExFigCLI/Loaders/VariablesCache.swift @@ -5,6 +5,7 @@ import Foundation /// /// Concurrent callers requesting the same `fileId` share a single in-flight `Task`. /// First caller triggers the fetch; subsequent callers await the same result. +/// Failed tasks are evicted so subsequent callers can retry. final class VariablesCache: @unchecked Sendable { private let lock = NSLock() private var tasks: [String: Task] = [:] @@ -19,6 +20,12 @@ final class VariablesCache: @unchecked Sendable { tasks[fileId] = newTask return newTask } - return try await task.value + do { + return try await task.value + } catch { + // Evict failed tasks so subsequent callers can retry (e.g., transient 429 rate limit) + lock.withLock { tasks[fileId] = nil } + throw error + } } } diff --git a/Sources/ExFigCLI/Output/SVGColorReplacer.swift b/Sources/ExFigCLI/Output/SVGColorReplacer.swift index 573ec81d..baa158e7 100644 --- a/Sources/ExFigCLI/Output/SVGColorReplacer.swift +++ b/Sources/ExFigCLI/Output/SVGColorReplacer.swift @@ -98,6 +98,9 @@ enum SVGColorReplacer { return regex.stringByReplacingMatches(in: string, range: range, withTemplate: template) } catch { assertionFailure("Invalid regex pattern: \(pattern), error: \(error)") + FileHandle.standardError.write( + Data("[SVGColorReplacer] Invalid regex pattern: \(pattern), error: \(error)\n".utf8) + ) return string } } diff --git a/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift b/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift new file mode 100644 index 00000000..c534ed72 --- /dev/null +++ b/Tests/ExFigTests/Loaders/VariableModeDarkGeneratorTests.swift @@ -0,0 +1,630 @@ +// swiftlint:disable file_length +@testable import ExFigCLI +import ExFigCore +import FigmaAPI +import Logging +import XCTest + +// MARK: - Mock Client + +private final class MockFigmaClient: Client, @unchecked Sendable { + var requestCount = 0 + + func request(_: T) async throws -> T.Content { + requestCount += 1 + fatalError("MockFigmaClient should not make real requests in unit tests") + } +} + +private func makeGenerator() -> VariableModeDarkGenerator { + VariableModeDarkGenerator( + client: MockFigmaClient(), + logger: Logger(label: "test") + ) +} + +// MARK: - findModeIds & resolveDarkColor Tests + +final class VariableModeDarkGeneratorResolutionTests: XCTestCase { + private var generator: VariableModeDarkGenerator! + + override func setUp() { + super.setUp() + generator = makeGenerator() + } + + // MARK: - findModeIds + + func testFindModeIdsMatchesCollectionAndModes() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("m1", "Light"), ("m2", "Dark")], + variables: [] + ) + let config = VariableModeDarkGenerator.Config( + fileId: "file1", + collectionName: "Theme", + lightModeName: "Light", + darkModeName: "Dark" + ) + + let result = generator.findModeIds(in: meta, config: config) + XCTAssertNotNil(result) + XCTAssertEqual(result?.lightModeId, "m1") + XCTAssertEqual(result?.darkModeId, "m2") + XCTAssertNil(result?.primitivesModeId) + } + + func testFindModeIdsReturnsNilWhenCollectionMissing() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("m1", "Light"), ("m2", "Dark")], + variables: [] + ) + let config = VariableModeDarkGenerator.Config( + fileId: "file1", + collectionName: "NonExistent", + lightModeName: "Light", + darkModeName: "Dark" + ) + + XCTAssertNil(generator.findModeIds(in: meta, config: config)) + } + + func testFindModeIdsReturnsNilWhenModeMissing() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("m1", "Light"), ("m2", "Dim")], + variables: [] + ) + let config = VariableModeDarkGenerator.Config( + fileId: "file1", + collectionName: "Theme", + lightModeName: "Light", + darkModeName: "Dark" + ) + + XCTAssertNil(generator.findModeIds(in: meta, config: config)) + } + + func testFindModeIdsWithPrimitivesMode() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("m1", "Light"), ("m2", "Dark"), ("m3", "Primitives")], + variables: [] + ) + let config = VariableModeDarkGenerator.Config( + fileId: "file1", + collectionName: "Theme", + lightModeName: "Light", + darkModeName: "Dark", + primitivesModeName: "Primitives" + ) + + let result = generator.findModeIds(in: meta, config: config) + XCTAssertNotNil(result) + XCTAssertEqual(result?.primitivesModeId, "m3") + } + + // MARK: - resolveDarkColor: Direct color + + func testResolveDarkColorDirectColor() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "primary", valuesByMode: [ + "light": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "dark": (r: 0.0, g: 0.0, b: 1.0, a: 1.0), + ]), + ] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:v1", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "0000ff") + XCTAssertEqual(result?.alpha, 1.0) + } + + // MARK: - resolveDarkColor: Alias chain + + func testResolveDarkColorFollowsAlias() { + let meta = VariablesMeta.makeWithAliases( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "semantic", name: "primary", collectionId: nil, valuesByMode: [ + "light": .color(r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "dark": .alias("primitive"), + ]), + (id: "primitive", name: "blue-500", collectionId: nil, valuesByMode: [ + "light": .color(r: 0.0, g: 0.0, b: 1.0, a: 1.0), + "dark": .color(r: 0.0, g: 0.0, b: 0.8, a: 1.0), + ]), + ], + primitiveCollections: [] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:semantic", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + // Alias target resolves using collection's defaultModeId ("light") since no primitivesModeId set + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "0000ff") + } + + func testResolveDarkColorMultiHopAlias() { + let meta = VariablesMeta.makeWithAliases( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "a", name: "a", collectionId: nil, valuesByMode: ["dark": .alias("b")]), + (id: "b", name: "b", collectionId: nil, valuesByMode: [ + "light": .alias("c"), + "dark": .alias("c"), + ]), + (id: "c", name: "c", collectionId: nil, valuesByMode: [ + "light": .color(r: 0.0, g: 1.0, b: 0.0, a: 0.5), + ]), + ], + primitiveCollections: [] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:a", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "00ff00") + XCTAssertEqual(result?.alpha, 0.5) + } + + // MARK: - resolveDarkColor: Depth limit + + func testResolveDarkColorDepthLimitReturnsNil() { + var variables: [(id: String, name: String, collectionId: String?, valuesByMode: [String: TestVariableValue])] = + [] + for i in 0 ..< 12 { + let nextId = i < 11 ? "\(i + 1)" : "end" + variables.append((id: "\(i)", name: "v\(i)", collectionId: nil, valuesByMode: ["dark": .alias(nextId)])) + } + variables.append((id: "end", name: "end", collectionId: nil, valuesByMode: [ + "dark": .color(r: 1.0, g: 0.0, b: 0.0, a: 1.0), + ])) + + let meta = VariablesMeta.makeWithAliases( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: variables, + primitiveCollections: [] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:0", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNil(result, "Should return nil when alias chain exceeds depth limit") + } + + // MARK: - resolveDarkColor: Deleted variable + + func testResolveDarkColorSkipsDeletedVariable() { + let meta = VariablesMeta.makeWithAliases( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "deleted", name: "old-color", collectionId: nil, valuesByMode: [ + "dark": .color(r: 1.0, g: 0.0, b: 0.0, a: 1.0), + ]), + ], + primitiveCollections: [], + deletedVariableIds: ["deleted"] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:deleted", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNil(result, "Should skip deleted variables") + } + + func testResolveDarkColorReturnsNilForUnknownVariable() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:nonexistent", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNil(result) + } + + // MARK: - resolveDarkColor: Fallback to defaultModeId + + func testResolveDarkColorFallsBackToDefaultMode() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("default", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "bg", valuesByMode: [ + "default": (r: 0.5, g: 0.5, b: 0.5, a: 1.0), + ]), + ] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:v1", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "808080") + } + + // MARK: - resolveDarkColor: Alpha / opacity + + func testResolveDarkColorWithAlpha() { + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "transparent-bg", valuesByMode: [ + "dark": (r: 214.0 / 255.0, g: 251.0 / 255.0, b: 148.0 / 255.0, a: 0.0), + ]), + ] + ) + + let result = generator.resolveDarkColor( + variableId: "VariableID:v1", + modeId: "dark", + variablesMeta: meta, + primitivesModeId: nil + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "d6fb94") + XCTAssertEqual(result?.alpha, 0.0) + XCTAssertTrue(result?.changesOpacity == true) + } +} + +// MARK: - buildColorMap & resolveViaLibrary Tests + +final class VariableModeDarkGeneratorColorMapTests: XCTestCase { + private var generator: VariableModeDarkGenerator! + + override func setUp() { + super.setUp() + generator = makeGenerator() + } + + func testBuildColorMapExtractsFromBoundVariables() throws { + let nodeJson = """ + { + "document": { + "id": "node1", + "name": "icon", + "fills": [ + { + "type": "SOLID", + "color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 }, + "boundVariables": { + "color": { "id": "VariableID:v1", "type": "VARIABLE_ALIAS" } + } + } + ] + } + } + """ + let node = try JSONCodec.decode(Node.self, from: Data(nodeJson.utf8)) + + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "red", valuesByMode: [ + "light": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "dark": (r: 0.0, g: 0.0, b: 1.0, a: 1.0), + ]), + ] + ) + + let ctx = VariableModeDarkGenerator.ResolutionContext( + variablesMeta: meta, + libMeta: nil, + libNameIndex: nil, + modes: .init(lightModeId: "light", darkModeId: "dark", primitivesModeId: nil), + darkModeName: "Dark" + ) + + let colorMap = generator.buildColorMap(node: node, ctx: ctx, iconName: "test-icon") + XCTAssertEqual(colorMap.count, 1) + XCTAssertEqual(colorMap["ff0000"]?.hex, "0000ff") + } + + func testBuildColorMapExtractsFromStrokes() throws { + let nodeJson = """ + { + "document": { + "id": "node1", + "name": "icon", + "fills": [], + "strokes": [ + { + "type": "SOLID", + "color": { "r": 0.0, "g": 1.0, "b": 0.0, "a": 1.0 }, + "boundVariables": { + "color": { "id": "VariableID:v1", "type": "VARIABLE_ALIAS" } + } + } + ] + } + } + """ + let node = try JSONCodec.decode(Node.self, from: Data(nodeJson.utf8)) + + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "green", valuesByMode: [ + "light": (r: 0.0, g: 1.0, b: 0.0, a: 1.0), + "dark": (r: 1.0, g: 1.0, b: 0.0, a: 1.0), + ]), + ] + ) + + let ctx = VariableModeDarkGenerator.ResolutionContext( + variablesMeta: meta, + libMeta: nil, + libNameIndex: nil, + modes: .init(lightModeId: "light", darkModeId: "dark", primitivesModeId: nil), + darkModeName: "Dark" + ) + + let colorMap = generator.buildColorMap(node: node, ctx: ctx, iconName: "test-icon") + XCTAssertEqual(colorMap["00ff00"]?.hex, "ffff00") + } + + func testBuildColorMapRecursesIntoChildren() throws { + let nodeJson = """ + { + "document": { + "id": "parent", + "name": "group", + "fills": [], + "children": [ + { + "id": "child1", + "name": "rect", + "fills": [ + { + "type": "SOLID", + "color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 }, + "boundVariables": { + "color": { "id": "VariableID:v1", "type": "VARIABLE_ALIAS" } + } + } + ] + } + ] + } + } + """ + let node = try JSONCodec.decode(Node.self, from: Data(nodeJson.utf8)) + + let meta = VariablesMeta.make( + collectionName: "Theme", + modes: [("light", "Light"), ("dark", "Dark")], + variables: [ + (id: "v1", name: "red", valuesByMode: [ + "light": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "dark": (r: 0.5, g: 0.5, b: 0.5, a: 1.0), + ]), + ] + ) + + let ctx = VariableModeDarkGenerator.ResolutionContext( + variablesMeta: meta, + libMeta: nil, + libNameIndex: nil, + modes: .init(lightModeId: "light", darkModeId: "dark", primitivesModeId: nil), + darkModeName: "Dark" + ) + + let colorMap = generator.buildColorMap(node: node, ctx: ctx, iconName: "test-icon") + XCTAssertEqual(colorMap.count, 1) + XCTAssertEqual(colorMap["ff0000"]?.hex, "808080") + } + + // MARK: - resolveViaLibrary + + func testResolveViaLibraryMatchesByName() { + let libMeta = VariablesMeta.make( + collectionName: "Primitives", + modes: [("plight", "Light"), ("pdark", "Dark")], + variables: [ + (id: "lib-v1", name: "brand/primary", valuesByMode: [ + "plight": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "pdark": (r: 0.0, g: 1.0, b: 0.0, a: 1.0), + ]), + ] + ) + + let libNameIndex = Dictionary( + libMeta.variables.values.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + let result = generator.resolveViaLibrary( + variableName: "brand/primary", + libMeta: libMeta, + libNameIndex: libNameIndex, + darkModeName: "Dark" + ) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.hex, "00ff00") + } + + func testResolveViaLibraryReturnsNilWhenNameNotFound() { + let libMeta = VariablesMeta.make( + collectionName: "Primitives", + modes: [("plight", "Light"), ("pdark", "Dark")], + variables: [] + ) + + let result = generator.resolveViaLibrary( + variableName: "nonexistent", + libMeta: libMeta, + libNameIndex: [:], + darkModeName: "Dark" + ) + + XCTAssertNil(result) + } + + func testResolveViaLibraryReturnsNilWhenDarkModeNotFound() { + let libMeta = VariablesMeta.make( + collectionName: "Primitives", + modes: [("plight", "Light"), ("pdim", "Dim")], + variables: [ + (id: "lib-v1", name: "color", valuesByMode: [ + "plight": (r: 1.0, g: 0.0, b: 0.0, a: 1.0), + "pdim": (r: 0.5, g: 0.5, b: 0.5, a: 1.0), + ]), + ] + ) + + let libNameIndex = Dictionary( + libMeta.variables.values.map { ($0.name, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + let result = generator.resolveViaLibrary( + variableName: "color", + libMeta: libMeta, + libNameIndex: libNameIndex, + darkModeName: "Dark" + ) + + XCTAssertNil(result, "Should return nil when dark mode name doesn't match") + } +} + +// MARK: - VariablesCache Tests + +final class VariablesCacheTests: XCTestCase { + func testDeduplicatesParallelRequests() async throws { + let cache = VariablesCache() + let fetchCount = Lock(0) + + let meta = VariablesMeta.make( + collectionName: "Test", + modes: [("m1", "Mode1")], + variables: [] + ) + + try await withThrowingTaskGroup(of: VariablesMeta.self) { group in + for _ in 0 ..< 5 { + group.addTask { + try await cache.get(fileId: "file1") { + fetchCount.withLock { $0 += 1 } + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + return meta + } + } + } + for try await _ in group {} + } + + XCTAssertEqual(fetchCount.withLock { $0 }, 1, "Should only fetch once for the same fileId") + } + + func testDifferentFileIdsGetSeparateFetches() async throws { + let cache = VariablesCache() + let fetchCount = Lock(0) + + let meta = VariablesMeta.make( + collectionName: "Test", + modes: [("m1", "Mode1")], + variables: [] + ) + + try await withThrowingTaskGroup(of: VariablesMeta.self) { group in + for i in 0 ..< 3 { + group.addTask { + try await cache.get(fileId: "file\(i)") { + fetchCount.withLock { $0 += 1 } + return meta + } + } + } + for try await _ in group {} + } + + XCTAssertEqual(fetchCount.withLock { $0 }, 3, "Each unique fileId should trigger a separate fetch") + } + + func testFailedTaskIsEvictedForRetry() async throws { + let cache = VariablesCache() + let fetchCount = Lock(0) + + struct TestError: Error {} + + do { + _ = try await cache.get(fileId: "file1") { + fetchCount.withLock { $0 += 1 } + throw TestError() + } + XCTFail("Should have thrown") + } catch { + // Expected + } + + XCTAssertEqual(fetchCount.withLock { $0 }, 1) + + let meta = VariablesMeta.make( + collectionName: "Test", + modes: [("m1", "Mode1")], + variables: [] + ) + + let result = try await cache.get(fileId: "file1") { + fetchCount.withLock { $0 += 1 } + return meta + } + + XCTAssertEqual(fetchCount.withLock { $0 }, 2, "Failed task should be evicted, allowing retry") + XCTAssertEqual(result.variableCollections.count, 1) + } +} + +// swiftlint:enable file_length diff --git a/Tests/ExFigTests/Output/SVGColorReplacerTests.swift b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift index c0918f8f..052f7a7b 100644 --- a/Tests/ExFigTests/Output/SVGColorReplacerTests.swift +++ b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift @@ -164,6 +164,20 @@ final class SVGColorReplacerTests: XCTestCase { XCTAssertTrue(result.contains("fill-opacity=\"0\""), "Should add opacity even when hex is the same: \(result)") } + // MARK: - flood-color / lighting-color + + func testReplacesFloodColorAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["ff0000": opaque("00ff00")]) + XCTAssertEqual(result, "") + } + + func testReplacesLightingColorAttribute() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) + XCTAssertEqual(result, "") + } + // MARK: - ColorReplacement func testColorReplacementChangesOpacity() { From baffba9720301a0658895905d67342df0d79559d Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 19:45:19 +0500 Subject: [PATCH 13/17] fix(icons): conform ColorReplacement to Equatable --- Sources/ExFigCLI/Output/SVGColorReplacer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ExFigCLI/Output/SVGColorReplacer.swift b/Sources/ExFigCLI/Output/SVGColorReplacer.swift index baa158e7..ce3027ac 100644 --- a/Sources/ExFigCLI/Output/SVGColorReplacer.swift +++ b/Sources/ExFigCLI/Output/SVGColorReplacer.swift @@ -1,7 +1,7 @@ import Foundation /// A resolved dark color with optional alpha override. -struct ColorReplacement { +struct ColorReplacement: Equatable { let hex: String let alpha: Double From fc811281cbfcc805dff0f946c4884c321e370c08 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 19:49:29 +0500 Subject: [PATCH 14/17] fix(icons): prevent CSS partial hex match and warn on duplicate library 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 --- .../Loaders/VariableModeDarkGenerator.swift | 17 ++++++++++++++--- Sources/ExFigCLI/Output/SVGColorReplacer.swift | 7 ++++--- .../Output/SVGColorReplacerTests.swift | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift index 6e930ad4..f7aad85d 100644 --- a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -138,9 +138,7 @@ struct VariableModeDarkGenerator { let ctx = ResolutionContext( variablesMeta: variablesMeta, libMeta: libMeta, - libNameIndex: libMeta.map { meta in - Dictionary(meta.variables.values.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) - }, + libNameIndex: libMeta.map { buildLibNameIndex(from: $0) }, modes: modes, darkModeName: config.darkModeName ) @@ -244,6 +242,19 @@ struct VariableModeDarkGenerator { ) } + /// Builds a name→variable index from library meta, warning on duplicate names. + private func buildLibNameIndex(from meta: VariablesMeta) -> [String: VariableValue] { + let grouped = Dictionary(grouping: meta.variables.values, by: \.name) + var index: [String: VariableValue] = [:] + for (name, vars) in grouped { + if vars.count > 1 { + logger.warning("Library file has \(vars.count) variables named '\(name)', using first match") + } + index[name] = vars[0] + } + return index + } + private func loadVariables(fileId: String) async throws -> VariablesMeta { if let cache = variablesCache { return try await cache.get(fileId: fileId) { [client] in diff --git a/Sources/ExFigCLI/Output/SVGColorReplacer.swift b/Sources/ExFigCLI/Output/SVGColorReplacer.swift index ce3027ac..64759978 100644 --- a/Sources/ExFigCLI/Output/SVGColorReplacer.swift +++ b/Sources/ExFigCLI/Output/SVGColorReplacer.swift @@ -59,14 +59,15 @@ enum SVGColorReplacer { template: "$1$2#\(darkHex)$3 stop-opacity=\"\(opacityStr)\"" ) // CSS property style: fill:#aabbcc → fill:#darkHex;fill-opacity:0 + // Lookahead prevents partial match on 8-digit hex (e.g., #aabbccdd) result = regexReplace( in: result, - pattern: "(fill|stroke)(\\s*:\\s*)#\(lightHex)", + pattern: "(fill|stroke)(\\s*:\\s*)#\(lightHex)(?=[;\"'\\s]|$)", template: "$1$2#\(darkHex);$1-opacity:\(opacityStr)" ) result = regexReplace( in: result, - pattern: "(stop-color)(\\s*:\\s*)#\(lightHex)", + pattern: "(stop-color)(\\s*:\\s*)#\(lightHex)(?=[;\"'\\s]|$)", template: "$1$2#\(darkHex);stop-opacity:\(opacityStr)" ) } else { @@ -77,7 +78,7 @@ enum SVGColorReplacer { "$1$2#\(darkHex)$3" ), ( - "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*:\\s*)#\(lightHex)", + "(fill|stroke|stop-color|flood-color|lighting-color)(\\s*:\\s*)#\(lightHex)(?=[;\"'\\s]|$)", "$1$2#\(darkHex)" ), ] diff --git a/Tests/ExFigTests/Output/SVGColorReplacerTests.swift b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift index 052f7a7b..862971cd 100644 --- a/Tests/ExFigTests/Output/SVGColorReplacerTests.swift +++ b/Tests/ExFigTests/Output/SVGColorReplacerTests.swift @@ -164,6 +164,21 @@ final class SVGColorReplacerTests: XCTestCase { XCTAssertTrue(result.contains("fill-opacity=\"0\""), "Should add opacity even when hex is the same: \(result)") } + // MARK: - CSS Partial Hex Match + + func testCSSDoesNotPartialMatch8DigitHex() { + // #aabbcc should NOT match inside #aabbccdd + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) + XCTAssertEqual(result, svg, "Should not partially match 8-digit hex") + } + + func testCSSMatchesHexFollowedBySemicolon() { + let svg = "" + let result = SVGColorReplacer.replaceColors(in: svg, colorMap: ["aabbcc": opaque("112233")]) + XCTAssertTrue(result.contains("fill:#112233")) + } + // MARK: - flood-color / lighting-color func testReplacesFloodColorAttribute() { From 579a0a314e9b41bd620b6a3bd015597de5518134 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 20:22:59 +0500 Subject: [PATCH 15/17] perf(icons): cache Components API responses across parallel entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 5 ++- Sources/ExFigCLI/CLAUDE.md | 1 + .../Context/IconsExportContextImpl.swift | 6 +++- .../ExFigCLI/Loaders/ComponentsCache.swift | 34 +++++++++++++++++++ .../ExFigCLI/Loaders/ImageLoaderBase.swift | 12 ++++++- .../Source/FigmaComponentsSource.swift | 3 ++ Sources/ExFigCLI/Source/SourceFactory.swift | 6 ++-- .../Export/PluginIconsExport.swift | 28 ++++++++++----- 8 files changed, 82 insertions(+), 13 deletions(-) create mode 100644 Sources/ExFigCLI/Loaders/ComponentsCache.swift diff --git a/CLAUDE.md b/CLAUDE.md index 12e3d938..9d2a8edb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -260,6 +260,8 @@ Key files: `VariableModeDarkGenerator.swift`, `SVGColorReplacer.swift`, `FigmaCo **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:)`. @@ -274,7 +276,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. @@ -496,6 +498,7 @@ NooraUI.formatLink("url", useColors: true) // underlined primary | 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 diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 4d0c5423..c2788f43 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -180,6 +180,7 @@ Converter factories (`WebpConverterFactory`, `HeicConverterFactory`) handle plat | `Source/TokensFileColorsSource.swift` | Local .tokens.json source (extracted from ColorsExportContextImpl) | | `Loaders/VariableModeDarkGenerator.swift` | Generates dark SVGs via Figma Variables; supports cross-file resolution (name-based) when `variablesFileId` set | | `Loaders/VariablesCache.swift` | Lock+Task dedup cache for Variables API responses across parallel entries | +| `Loaders/ComponentsCache.swift` | Lock+Task dedup cache for Components API responses across parallel entries (standalone mode) | | `Output/SVGColorReplacer.swift` | Hex color replacement in SVG content (fill, stroke, stop-color) | ### MCP Server Architecture diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index e0a84f8b..d6b297cf 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -24,6 +24,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { let granularCacheManager: GranularCacheManager? let platform: Platform let variablesCache: VariablesCache? + let componentsCache: ComponentsCache? init( client: Client, @@ -36,7 +37,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { configExecutionContext: ConfigExecutionContext? = nil, granularCacheManager: GranularCacheManager? = nil, platform: Platform, - variablesCache: VariablesCache? = nil + variablesCache: VariablesCache? = nil, + componentsCache: ComponentsCache? = nil ) { self.client = client self.componentsSource = componentsSource @@ -49,6 +51,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { self.granularCacheManager = granularCacheManager self.platform = platform self.variablesCache = variablesCache + self.componentsCache = componentsCache } var isGranularCacheEnabled: Bool { @@ -169,6 +172,7 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { logger: ExFigCommand.logger, config: config ) + loader.componentsCache = componentsCache let output: IconsLoadOutputWithHashes if let manager = granularCacheManager { diff --git a/Sources/ExFigCLI/Loaders/ComponentsCache.swift b/Sources/ExFigCLI/Loaders/ComponentsCache.swift new file mode 100644 index 00000000..b02cfe46 --- /dev/null +++ b/Sources/ExFigCLI/Loaders/ComponentsCache.swift @@ -0,0 +1,34 @@ +import FigmaAPI +import Foundation + +/// Deduplicating cache for Figma Components API responses. +/// +/// Concurrent callers requesting the same `fileId` share a single in-flight `Task`. +/// First caller triggers the fetch; subsequent callers await the same result. +/// Failed tasks are evicted so subsequent callers can retry. +/// +/// Same pattern as ``VariablesCache``. Used in standalone (non-batch) mode +/// when multiple icon/image entries share the same Figma file. +final class ComponentsCache: @unchecked Sendable { + private let lock = NSLock() + private var tasks: [String: Task<[Component], Error>] = [:] + + func get( + fileId: String, + fetch: @escaping @Sendable () async throws -> [Component] + ) async throws -> [Component] { + let task: Task<[Component], Error> = lock.withLock { + if let existing = tasks[fileId] { return existing } + let newTask = Task { try await fetch() } + tasks[fileId] = newTask + return newTask + } + do { + return try await task.value + } catch { + // Evict failed tasks so subsequent callers can retry (e.g., transient 429 rate limit) + lock.withLock { tasks[fileId] = nil } + throw error + } + } +} diff --git a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift index b4a0b524..5aef264b 100644 --- a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift +++ b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift @@ -40,6 +40,9 @@ class ImageLoaderBase: @unchecked Sendable { /// Optional granular cache manager for per-node change detection. var granularCacheManager: GranularCacheManager? + /// Optional components cache for deduplicating Components API calls across entries. + var componentsCache: ComponentsCache? + init(client: Client, params: PKLConfig, platform: Platform, logger: Logger) { self.client = client self.params = params @@ -696,7 +699,14 @@ class ImageLoaderBase: @unchecked Sendable { return components } - // Fall back to API request (standalone mode or missing pre-fetch) + // Check components cache (standalone multi-entry dedup) + if let cache = componentsCache { + return try await cache.get(fileId: fileId) { + try await self.client.request(ComponentsEndpoint(fileId: fileId)) + } + } + + // Fall back to direct API request (single entry) let endpoint = ComponentsEndpoint(fileId: fileId) return try await client.request(endpoint) } diff --git a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift index 55bfc238..7b1cfff5 100644 --- a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift +++ b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift @@ -11,6 +11,7 @@ struct FigmaComponentsSource: ComponentsSource { let logger: Logger let filter: String? let variablesCache: VariablesCache? + let componentsCache: ComponentsCache? func loadIcons(from input: IconsSourceInput) async throws -> IconsLoadOutput { let config = IconsLoaderConfig( @@ -32,6 +33,7 @@ struct FigmaComponentsSource: ComponentsSource { logger: logger, config: config ) + loader.componentsCache = componentsCache let result = try await loader.load(filter: filter) @@ -95,6 +97,7 @@ struct FigmaComponentsSource: ComponentsSource { logger: logger, config: config ) + loader.componentsCache = componentsCache let result = try await loader.load(filter: filter) diff --git a/Sources/ExFigCLI/Source/SourceFactory.swift b/Sources/ExFigCLI/Source/SourceFactory.swift index 1d58c17d..ff2a9df9 100644 --- a/Sources/ExFigCLI/Source/SourceFactory.swift +++ b/Sources/ExFigCLI/Source/SourceFactory.swift @@ -33,7 +33,8 @@ enum SourceFactory { logger: Logger, filter: String?, ui: TerminalUI, - variablesCache: VariablesCache? = nil + variablesCache: VariablesCache? = nil, + componentsCache: ComponentsCache? = nil ) throws -> any ComponentsSource { switch sourceKind { case .figma: @@ -44,7 +45,8 @@ enum SourceFactory { platform: platform, logger: logger, filter: filter, - variablesCache: variablesCache + variablesCache: variablesCache, + componentsCache: componentsCache ) case .penpot: return PenpotComponentsSource(ui: ui) diff --git a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift index 96db4c78..3b9bf5d4 100644 --- a/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift +++ b/Sources/ExFigCLI/Subcommands/Export/PluginIconsExport.swift @@ -46,6 +46,7 @@ extension ExFigCommand.ExportIcons { throw ExFigError.configurationError("No entries provided for icons export") } let variablesCache = VariablesCache() + let componentsCache = ComponentsCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -54,7 +55,8 @@ extension ExFigCommand.ExportIcons { logger: ExFigCommand.logger, filter: filter, ui: ui, - variablesCache: variablesCache + variablesCache: variablesCache, + componentsCache: componentsCache ) let context = IconsExportContextImpl( @@ -67,7 +69,8 @@ extension ExFigCommand.ExportIcons { fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, platform: .ios, - variablesCache: variablesCache + variablesCache: variablesCache, + componentsCache: componentsCache ) // Export via plugin (returns IconsExportResult with hashes) @@ -134,6 +137,7 @@ extension ExFigCommand.ExportIcons { throw ExFigError.configurationError("No entries provided for icons export") } let variablesCache = VariablesCache() + let componentsCache = ComponentsCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -142,7 +146,8 @@ extension ExFigCommand.ExportIcons { logger: ExFigCommand.logger, filter: filter, ui: ui, - variablesCache: variablesCache + variablesCache: variablesCache, + componentsCache: componentsCache ) let context = IconsExportContextImpl( @@ -155,7 +160,8 @@ extension ExFigCommand.ExportIcons { fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, platform: .android, - variablesCache: variablesCache + variablesCache: variablesCache, + componentsCache: componentsCache ) let exporter = AndroidIconsExporter() @@ -196,6 +202,7 @@ extension ExFigCommand.ExportIcons { throw ExFigError.configurationError("No entries provided for icons export") } let variablesCache = VariablesCache() + let componentsCache = ComponentsCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -204,7 +211,8 @@ extension ExFigCommand.ExportIcons { logger: ExFigCommand.logger, filter: filter, ui: ui, - variablesCache: variablesCache + variablesCache: variablesCache, + componentsCache: componentsCache ) let context = IconsExportContextImpl( @@ -217,7 +225,8 @@ extension ExFigCommand.ExportIcons { fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, platform: .flutter, - variablesCache: variablesCache + variablesCache: variablesCache, + componentsCache: componentsCache ) let exporter = FlutterIconsExporter() @@ -258,6 +267,7 @@ extension ExFigCommand.ExportIcons { throw ExFigError.configurationError("No entries provided for icons export") } let variablesCache = VariablesCache() + let componentsCache = ComponentsCache() let componentsSource = try SourceFactory.createComponentsSource( for: sourceKind, client: client, @@ -266,7 +276,8 @@ extension ExFigCommand.ExportIcons { logger: ExFigCommand.logger, filter: filter, ui: ui, - variablesCache: variablesCache + variablesCache: variablesCache, + componentsCache: componentsCache ) let context = IconsExportContextImpl( @@ -279,7 +290,8 @@ extension ExFigCommand.ExportIcons { fileDownloader: fileDownloader, granularCacheManager: granularCacheManager, platform: .web, - variablesCache: variablesCache + variablesCache: variablesCache, + componentsCache: componentsCache ) let exporter = WebIconsExporter() From 7eed0fe173462dd0978d7620fb75d4c4537ae060 Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 21:27:13 +0500 Subject: [PATCH 16/17] chore: update project tools and docs for variable dark mode 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. --- CLAUDE.md | 1 + README.md | 2 +- Sources/ExFigCLI/CLAUDE.md | 3 + .../Context/ColorsExportContextImpl.swift | 8 +- .../Context/IconsExportContextImpl.swift | 8 +- .../Context/ImagesExportContextImpl.swift | 8 +- .../Context/TypographyExportContextImpl.swift | 8 +- .../Loaders/VariableModeDarkGenerator.swift | 2 +- Sources/ExFigCLI/MCP/MCPPrompts.swift | 4 + Sources/ExFigCLI/MCP/MCPToolHandlers.swift | 36 ++- .../Resources/Guides/DesignRequirements.md | 34 +++ Sources/ExFigCLI/TerminalUI/TerminalUI.swift | 10 - .../TerminalUI/TerminalUITests.swift | 31 --- bin/mise | 42 ++-- mise.lock | 226 +++++++++++++++--- mise.toml | 14 +- 16 files changed, 318 insertions(+), 119 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d2a8edb..8e78462c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,7 @@ 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`. diff --git a/README.md b/README.md index 3b49b0b2..c04316d4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index c2788f43..7103f813 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -198,6 +198,9 @@ reserved for MCP JSON-RPC protocol. **Guide resources:** `exfig://guides/` serves markdown files from `Resources/Guides/` (copied from DocC articles). DocC `.docc` articles are NOT accessible via `Bundle.module` at runtime — must be separately copied to `Resources/Guides/`. +**Guide sync:** `Resources/Guides/DesignRequirements.md` is served via MCP `exfig://guides/` resource. Must be updated alongside DocC articles when adding dark mode approaches or other user-facing features. + +**MCP validate dark_mode field:** `ValidateSummary.darkMode` reports active dark mode approaches (`darkFileId`, `suffixDarkMode`, `variablesDarkMode`). `buildDarkModeSummary()` checks `config.figma?.darkFileId`, `config.common?.icons?.suffixDarkMode`, and `Common_FrameSource.variablesDarkMode` across all platform icon entries. **Tool handler order:** Validate input parameters BEFORE expensive operations (PKL eval, API client creation). diff --git a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift index 07358e4a..11b07183 100644 --- a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift @@ -82,9 +82,13 @@ struct ColorsExportContextImpl: ColorsExportContext { darkHC: colors.darkHC.isEmpty ? nil : colors.darkHC ) + if let warning = result.warning { + let formatted = WarningFormatter().format(warning, compact: isBatchMode) + ExFigCommand.logger.debug("\(formatted)") + } + return try ColorsProcessResult( - colorPairs: result.get(), - warning: result.warning.map { WarningFormatter().format($0, compact: isBatchMode) } + colorPairs: result.get() ) } } diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index d6b297cf..d0cf0f0a 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -108,9 +108,13 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { dark: icons.dark.isEmpty ? nil : icons.dark ) + if let warning = result.warning { + let formatted = WarningFormatter().format(warning, compact: isBatchMode) + ExFigCommand.logger.debug("\(formatted)") + } + return try IconsProcessResult( - iconPairs: result.get(), - warning: result.warning.map { WarningFormatter().format($0, compact: isBatchMode) } + iconPairs: result.get() ) } diff --git a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift index 1ab10df8..775f53db 100644 --- a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift @@ -107,9 +107,13 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { dark: images.dark.isEmpty ? nil : images.dark ) + if let warning = result.warning { + let formatted = WarningFormatter().format(warning, compact: isBatchMode) + ExFigCommand.logger.debug("\(formatted)") + } + return try ImagesProcessResult( - imagePairs: result.get(), - warning: result.warning.map { WarningFormatter().format($0, compact: isBatchMode) } + imagePairs: result.get() ) } diff --git a/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift b/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift index cddc6fcd..9cc72380 100644 --- a/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/TypographyExportContextImpl.swift @@ -77,9 +77,13 @@ struct TypographyExportContextImpl: TypographyExportContext { let result = processor.process(assets: textStyles.textStyles) + if let warning = result.warning { + let formatted = WarningFormatter().format(warning, compact: isBatchMode) + ExFigCommand.logger.debug("\(formatted)") + } + return try TypographyProcessResult( - textStyles: result.get(), - warning: result.warning.map { WarningFormatter().format($0, compact: isBatchMode) } + textStyles: result.get() ) } } diff --git a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift index f7aad85d..61d2d660 100644 --- a/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift +++ b/Sources/ExFigCLI/Loaders/VariableModeDarkGenerator.swift @@ -248,7 +248,7 @@ struct VariableModeDarkGenerator { var index: [String: VariableValue] = [:] for (name, vars) in grouped { if vars.count > 1 { - logger.warning("Library file has \(vars.count) variables named '\(name)', using first match") + logger.debug("Library file has \(vars.count) variables named '\(name)', using first match") } index[name] = vars[0] } diff --git a/Sources/ExFigCLI/MCP/MCPPrompts.swift b/Sources/ExFigCLI/MCP/MCPPrompts.swift index a3fc4c96..99bde2a6 100644 --- a/Sources/ExFigCLI/MCP/MCPPrompts.swift +++ b/Sources/ExFigCLI/MCP/MCPPrompts.swift @@ -97,6 +97,10 @@ - Figma file ID(s) for my design files - Output paths matching my project structure - Entry configurations for colors, icons, and/or images + - Dark mode approach for icons (if applicable): + * `darkFileId` — separate Figma file for dark icons + * `suffixDarkMode` — name suffix splitting (e.g., "_dark") + * `variablesDarkMode` — Figma Variable Modes (recommended when icons use variable bindings) First, validate the config with exfig_validate after creating it. """ diff --git a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift index 184c69b8..3c526145 100644 --- a/Sources/ExFigCLI/MCP/MCPToolHandlers.swift +++ b/Sources/ExFigCLI/MCP/MCPToolHandlers.swift @@ -54,12 +54,14 @@ let platforms = buildPlatformSummary(config: config) let fileIDs = Array(config.getFileIds()).sorted() + let darkModes = buildDarkModeSummary(config: config) let summary = ValidateSummary( configPath: configPath, valid: true, platforms: platforms.isEmpty ? nil : platforms, - figmaFileIds: fileIDs.isEmpty ? nil : fileIDs + figmaFileIds: fileIDs.isEmpty ? nil : fileIDs, + darkMode: darkModes.isEmpty ? nil : darkModes ) return try .init(content: [.text(text: encodeJSON(summary), annotations: nil, _meta: nil)]) @@ -96,6 +98,36 @@ return platforms } + private static func buildDarkModeSummary(config: PKLConfig) -> [String] { + var approaches: Set = [] + + if config.figma?.darkFileId != nil { + approaches.insert("darkFileId") + } + + if config.common?.icons?.suffixDarkMode != nil { + approaches.insert("suffixDarkMode (icons)") + } + if config.common?.images?.suffixDarkMode != nil { + approaches.insert("suffixDarkMode (images)") + } + + func checkIconEntries(_ entries: [any Common_FrameSource]?) { + guard let entries else { return } + for entry in entries where entry.variablesDarkMode != nil { + approaches.insert("variablesDarkMode") + return + } + } + + checkIconEntries(config.ios?.icons) + checkIconEntries(config.android?.icons) + checkIconEntries(config.flutter?.icons) + checkIconEntries(config.web?.icons) + + return approaches.sorted() + } + // MARK: - Tokens Info private static func handleTokensInfo(params: CallTool.Parameters) async throws -> CallTool.Result { @@ -748,12 +780,14 @@ let valid: Bool var platforms: [String: EntrySummary]? var figmaFileIds: [String]? + var darkMode: [String]? enum CodingKeys: String, CodingKey { case configPath = "config_path" case valid case platforms case figmaFileIds = "figma_file_ids" + case darkMode = "dark_mode" } } diff --git a/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md index 6f6c9499..a3ef4896 100644 --- a/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md +++ b/Sources/ExFigCLI/Resources/Guides/DesignRequirements.md @@ -177,6 +177,40 @@ Icons frame └── ic/24/close_dark ``` +**Variable Modes (per-entry, recommended for Figma Variables):** + +When icons use Figma Variable bindings for colors (e.g., fill bound to a `DesignTokens` collection +with Light/Dark modes), ExFig can auto-generate dark SVGs by resolving variable values: + +```pkl +import ".exfig/schemas/Common.pkl" + +// Single-file mode: all variables in the same file as icons +new iOS.IconsEntry { + figmaFrameName = "Icons" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" // exact collection name (case-sensitive) + lightModeName = "Light" // exact mode name + darkModeName = "Dark" // exact mode name + } +} + +// Cross-file mode: icon variables reference an external library +new iOS.IconsEntry { + figmaFrameName = "Icons" + variablesDarkMode = new Common.VariablesDarkMode { + collectionName = "DesignTokens" + lightModeName = "Light" + darkModeName = "Dark" + variablesFileId = "LIB_FILE_ID" // library containing primitive values + primitivesModeName = "Value" // mode in primitives collection (optional) + } +} +``` + +No naming conventions required — ExFig reads variable bindings directly from Figma nodes. +Supports alpha/opacity in color replacements. + ### Images #### Component Structure diff --git a/Sources/ExFigCLI/TerminalUI/TerminalUI.swift b/Sources/ExFigCLI/TerminalUI/TerminalUI.swift index f9c94e28..2c35ac42 100644 --- a/Sources/ExFigCLI/TerminalUI/TerminalUI.swift +++ b/Sources/ExFigCLI/TerminalUI/TerminalUI.swift @@ -77,16 +77,6 @@ final class TerminalUI: Sendable { } } - /// Print a formatted AssetsValidatorWarning - func warning(_ warning: AssetsValidatorWarning) { - let formatter = WarningFormatter() - let formattedMessage = formatter.format(warning) - - guard !formattedMessage.isEmpty else { return } - - self.warning(formattedMessage) - } - /// Print a formatted ExFigWarning func warning(_ warning: ExFigWarning) { let formatter = ExFigWarningFormatter() diff --git a/Tests/ExFigTests/TerminalUI/TerminalUITests.swift b/Tests/ExFigTests/TerminalUI/TerminalUITests.swift index 0a82b5f4..b6d3f976 100644 --- a/Tests/ExFigTests/TerminalUI/TerminalUITests.swift +++ b/Tests/ExFigTests/TerminalUI/TerminalUITests.swift @@ -327,37 +327,6 @@ final class TerminalUITests: XCTestCase { ui.warning("Line 1\nassets[3]: a,b,c\n item1\n item2") } - func testWarningWithAssetsValidatorWarning() { - let ui = TerminalUI(outputMode: .plain) - let warning = AssetsValidatorWarning.lightAssetsNotFoundInDarkPalette( - assets: ["icon-a", "icon-b"] - ) - - // Should not crash - will print multi-line formatted output - ui.warning(warning) - } - - func testWarningWithLargeAssetsValidatorWarning() { - let ui = TerminalUI(outputMode: .plain) - let assets = (1 ... 50).map { "asset-\($0)" } - let warning = AssetsValidatorWarning.lightAssetsNotFoundInDarkPalette( - assets: assets - ) - - // Should handle large lists without crashing - ui.warning(warning) - } - - func testWarningWithEmptyAssetsValidatorWarning() { - let ui = TerminalUI(outputMode: .plain) - let warning = AssetsValidatorWarning.lightAssetsNotFoundInDarkPalette( - assets: [] - ) - - // Should handle empty list gracefully - ui.warning(warning) - } - func testWarningWithColorsMultiline() { let ui = TerminalUI(outputMode: .normal) diff --git a/bin/mise b/bin/mise index e5595a6c..e15b52ca 100755 --- a/bin/mise +++ b/bin/mise @@ -3,7 +3,7 @@ set -eu __mise_bootstrap() { local cache_home="${XDG_CACHE_HOME:-$HOME/.cache}/mise" - export MISE_INSTALL_PATH="$cache_home/mise-2026.3.15" + export MISE_INSTALL_PATH="$cache_home/mise-2026.3.17" install() { local initial_working_dir="$PWD" #!/bin/sh @@ -118,28 +118,28 @@ __mise_bootstrap() { arch=$3 ext=$4 url="https://github.com/jdx/mise/releases/download/v${version}/SHASUMS256.txt" - current_version="v2026.3.15" + current_version="v2026.3.17" current_version="${current_version#v}" # For current version use static checksum otherwise # use checksum from releases if [ "$version" = "$current_version" ]; then - checksum_linux_x86_64="29b128db8b597103220b645560e544ccc4561f7f82cc0fc6e7ceff9f316e71a5 ./mise-v2026.3.15-linux-x64.tar.gz" - checksum_linux_x86_64_musl="4e70734eeef3e664f1616be83ed0d2ee6114ecd10539ca6abdb1f6d66c29559d ./mise-v2026.3.15-linux-x64-musl.tar.gz" - checksum_linux_arm64="5fe63efe1c57dadd1403e595e4de169e21ad38161f6ab7128461018f6b10eb86 ./mise-v2026.3.15-linux-arm64.tar.gz" - checksum_linux_arm64_musl="25d1f0d880e47f7478d93ee0e8344e25b7eb9cbd841ddb6231836c9ff86868bc ./mise-v2026.3.15-linux-arm64-musl.tar.gz" - checksum_linux_armv7="7e403628c73f90dd6f350321bcd5d905ddbf92b6a52b97b495180fbd107ee762 ./mise-v2026.3.15-linux-armv7.tar.gz" - checksum_linux_armv7_musl="e1c828424ad9449d410c36857cf1e2a8713e4d7ea7a8a45623aec63b234cc4be ./mise-v2026.3.15-linux-armv7-musl.tar.gz" - checksum_macos_x86_64="4dbc8750ce3833050321b0c0deb61db7fc76681aa958df6786b999b588e42d1d ./mise-v2026.3.15-macos-x64.tar.gz" - checksum_macos_arm64="e500c437e4b8679b4c65e91925f86c17e6be76d0e218012bd40ec695ae4cf78e ./mise-v2026.3.15-macos-arm64.tar.gz" - checksum_linux_x86_64_zstd="b22e759742d805e87e7c17c4fc4aa1eb5927ac3c989a38b6cbe64718dcf8dcc8 ./mise-v2026.3.15-linux-x64.tar.zst" - checksum_linux_x86_64_musl_zstd="766887aa6f08d209116e4ddf0f7c49aa2c08b7119d64c8485574ed0013b75164 ./mise-v2026.3.15-linux-x64-musl.tar.zst" - checksum_linux_arm64_zstd="d1f4275548c836f90c70822405847bbf912295937c78076e1f5d33eb1995f8cb ./mise-v2026.3.15-linux-arm64.tar.zst" - checksum_linux_arm64_musl_zstd="74fc0386cd28044bbb91e7d745642f4c834dbd0d844374b4bbc314aa8fca2aca ./mise-v2026.3.15-linux-arm64-musl.tar.zst" - checksum_linux_armv7_zstd="80993247d85fdb5ba5b3c129775a16a42e5d06889963e232113ff1817f1e4445 ./mise-v2026.3.15-linux-armv7.tar.zst" - checksum_linux_armv7_musl_zstd="53da25e2ba78aee29f12da96a63fec36b2cabdc5d8d23e771de18b663c0d3aa6 ./mise-v2026.3.15-linux-armv7-musl.tar.zst" - checksum_macos_x86_64_zstd="76b1ed712ea60582eab109b25915cc52e123efb2304a70c73a0a4c17c1596c91 ./mise-v2026.3.15-macos-x64.tar.zst" - checksum_macos_arm64_zstd="5e655ab772fde67faa2bf69af45737f5b9fec32bd62fbd4a60921c7195f13fd8 ./mise-v2026.3.15-macos-arm64.tar.zst" + checksum_linux_x86_64="2b40c42ac5653f6cb9526054fa37d56d6830f4fe9c14e242a6dad227d19ac4aa ./mise-v2026.3.17-linux-x64.tar.gz" + checksum_linux_x86_64_musl="f416a2d27f69173b22551429d2bc712c40fc3fb639112cf76ae817baae3772e2 ./mise-v2026.3.17-linux-x64-musl.tar.gz" + checksum_linux_arm64="47a549b15313f2115b9d74da08f72dae63ec8c93d0caceffc661026ec8cde35a ./mise-v2026.3.17-linux-arm64.tar.gz" + checksum_linux_arm64_musl="7ef20440c3e1d9f0db69e57e68e6833a9cade44fd1e97384a279660e48091f97 ./mise-v2026.3.17-linux-arm64-musl.tar.gz" + checksum_linux_armv7="c772c604030198087ddad4507eb6d8d5b9a8f8c437bd067c6c54271bc22dc63b ./mise-v2026.3.17-linux-armv7.tar.gz" + checksum_linux_armv7_musl="6f0bd634c82617dc6f9ded8ef31d5c54dc9c478438286d3324975f08c829392b ./mise-v2026.3.17-linux-armv7-musl.tar.gz" + checksum_macos_x86_64="bd120908c1476f6f27ae27f4214bdbc3fb0e81b9671aaba720b994a217d28e39 ./mise-v2026.3.17-macos-x64.tar.gz" + checksum_macos_arm64="6d98cedb9c92bd7a1694c9efedc035288da87f6e19c79d15bf91449474c37ce9 ./mise-v2026.3.17-macos-arm64.tar.gz" + checksum_linux_x86_64_zstd="3c5ee98309d98867a3c2b8d26c03e39730a2cae9130c86d455d2226bbd798788 ./mise-v2026.3.17-linux-x64.tar.zst" + checksum_linux_x86_64_musl_zstd="081545be54a1a0379439f3c0cb67023ac45692ec0abb27c9fa13daade144b7d0 ./mise-v2026.3.17-linux-x64-musl.tar.zst" + checksum_linux_arm64_zstd="6245c051d725becbc40b2ea4e6bd910aa9fb2467ac121121cd2260e47c67de94 ./mise-v2026.3.17-linux-arm64.tar.zst" + checksum_linux_arm64_musl_zstd="a7b404013efe773f8063f64d0bf365575131e0681120a5d6f608df1fc59aeb17 ./mise-v2026.3.17-linux-arm64-musl.tar.zst" + checksum_linux_armv7_zstd="ae0ff69470b4ef7b5ef1ec2da53d679743df869568d599c3bcba02290716318b ./mise-v2026.3.17-linux-armv7.tar.zst" + checksum_linux_armv7_musl_zstd="409593cc3db6e1519c619990694ebc1b047a085c85716bd8eb7eae8b94bc53a4 ./mise-v2026.3.17-linux-armv7-musl.tar.zst" + checksum_macos_x86_64_zstd="8822864bc2edef2ba7888f09be2a66e82474e08e68d94d426a1cda90b950af03 ./mise-v2026.3.17-macos-x64.tar.zst" + checksum_macos_arm64_zstd="c64e7e28db2eb5fe0492d940ad58e30214a1e21faa40faf91e4dfa120cf05f3a ./mise-v2026.3.17-macos-arm64.tar.zst" # TODO: refactor this, it's a bit messy if [ "$ext" = "tar.zst" ]; then @@ -250,9 +250,9 @@ __mise_bootstrap() { } install_mise() { - version="${MISE_VERSION:-v2026.3.15}" + version="${MISE_VERSION:-v2026.3.17}" version="${version#v}" - current_version="v2026.3.15" + current_version="v2026.3.17" current_version="${current_version#v}" os="${MISE_INSTALL_OS:-$(get_os)}" arch="${MISE_INSTALL_ARCH:-$(get_arch)}" @@ -335,4 +335,4 @@ __mise_bootstrap() { test -f "$MISE_INSTALL_PATH" || install } __mise_bootstrap -exec "$MISE_INSTALL_PATH" "$@" +exec -a "$0" "$MISE_INSTALL_PATH" "$@" diff --git a/mise.lock b/mise.lock index c3e08cf7..6e120ebf 100644 --- a/mise.lock +++ b/mise.lock @@ -1,56 +1,151 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + [[tools.actionlint]] -version = "1.7.9" +version = "1.7.11" backend = "aqua:rhysd/actionlint" -"platforms.linux-arm64" = { checksum = "sha256:6b82a3b8c808bf1bcd39a95aced22fc1a026eef08ede410f81e274af8deadbbc", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_linux_arm64.tar.gz"} -"platforms.linux-x64" = { checksum = "sha256:233b280d05e100837f4af1433c7b40a5dcb306e3aa68fb4f17f8a7f45a7df7b4", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_linux_amd64.tar.gz"} -"platforms.macos-arm64" = { checksum = "sha256:855e49e823fc68c6371fd6967e359cde11912d8d44fed343283c8e6e943bd789", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_darwin_arm64.tar.gz"} -"platforms.macos-x64" = { checksum = "sha256:f89a910e90e536f60df7c504160247db01dd67cab6f08c064c1c397b76c91a79", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_darwin_amd64.tar.gz"} -"platforms.windows-x64" = { checksum = "sha256:7c8b10a93723838bc3533f6e1886d868fdbb109b81606ebe6d1a533d11d8e978", url = "https://github.com/rhysd/actionlint/releases/download/v1.7.9/actionlint_1.7.9_windows_amd64.zip"} + +[tools.actionlint."platforms.linux-arm64"] +checksum = "sha256:21bc0dfb57a913fe175298c2a9e906ee630f747cb66d0a934d0d4b69f4ee1235" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_linux_arm64.tar.gz" + +[tools.actionlint."platforms.linux-arm64-musl"] +checksum = "sha256:21bc0dfb57a913fe175298c2a9e906ee630f747cb66d0a934d0d4b69f4ee1235" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_linux_arm64.tar.gz" + +[tools.actionlint."platforms.linux-x64"] +checksum = "sha256:900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_linux_amd64.tar.gz" + +[tools.actionlint."platforms.linux-x64-musl"] +checksum = "sha256:900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_linux_amd64.tar.gz" + +[tools.actionlint."platforms.macos-arm64"] +checksum = "sha256:a21ba7366d8329e7223faee0ed69eb13da27fe8acabb356bb7eb0b7f1e1cb6d8" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_darwin_arm64.tar.gz" +provenance = "github-attestations" + +[tools.actionlint."platforms.macos-x64"] +checksum = "sha256:17ffc17fed8f0258ef6ad4aed932d3272464c7ef7d64e1cb0d65aa97c9752107" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_darwin_amd64.tar.gz" + +[tools.actionlint."platforms.windows-x64"] +checksum = "sha256:5414b7124a91f4b5abee62e5c9d84802237734f8d15b9b7032732a32c3ebffa3" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.11/actionlint_1.7.11_windows_amd64.zip" [[tools.dprint]] -version = "0.50.2" +version = "0.53.1" backend = "aqua:dprint/dprint" -"platforms.linux-arm64" = { checksum = "sha256:a4982964a68aefc2720b4c79c51a57e49b32f8944c1641fd9e714503fcf01847", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-aarch64-unknown-linux-musl.zip"} -"platforms.linux-x64" = { checksum = "sha256:4b0e7911262049ccb8e1ac5968bf7a66dc490968fe1552a123bb2d6dadf2ad95", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-x86_64-unknown-linux-musl.zip"} -"platforms.macos-arm64" = { checksum = "sha256:f534bcc054947ab2a42c069b5f6027914d252729bd15c1109812313b35a662a5", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-aarch64-apple-darwin.zip"} -"platforms.macos-x64" = { checksum = "sha256:61becbf8d1b16540e364a4f00be704266ae322ee0ff3ba66a4a21033f66a8d55", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-x86_64-apple-darwin.zip"} -"platforms.windows-x64" = { checksum = "sha256:2dbdb57106818acd930a00bc0c2c33370bd4c7265f78a6cda000e3621f2d3c1c", url = "https://github.com/dprint/dprint/releases/download/0.50.2/dprint-x86_64-pc-windows-msvc.zip"} + +[tools.dprint."platforms.linux-arm64"] +checksum = "sha256:559cbd7aff707d461627c46f817cd06c28828458ef4a21eb18801f588bc94e89" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-aarch64-unknown-linux-musl.zip" + +[tools.dprint."platforms.linux-arm64-musl"] +checksum = "sha256:559cbd7aff707d461627c46f817cd06c28828458ef4a21eb18801f588bc94e89" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-aarch64-unknown-linux-musl.zip" + +[tools.dprint."platforms.linux-x64"] +checksum = "sha256:f2815a5c217bb63ff54356c4a6e1e5393a126b29c46116ae57a08ec97c29cb85" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-x86_64-unknown-linux-musl.zip" + +[tools.dprint."platforms.linux-x64-musl"] +checksum = "sha256:f2815a5c217bb63ff54356c4a6e1e5393a126b29c46116ae57a08ec97c29cb85" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-x86_64-unknown-linux-musl.zip" + +[tools.dprint."platforms.macos-arm64"] +checksum = "sha256:4c822f9d4c692b0f0cd53d14bc82057488e1c4db2df1122b1aceb0a660795ac8" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-aarch64-apple-darwin.zip" + +[tools.dprint."platforms.macos-x64"] +checksum = "sha256:b35e91afe0f7f2217128b8edcebd63d789b00f9c1b0300e4907a115106d857fd" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-x86_64-apple-darwin.zip" + +[tools.dprint."platforms.windows-x64"] +checksum = "sha256:db21e2f7a09dc9b3a2fbe2faac34a9f0733a4cc3cb8c78dd13dae6f081e13d4c" +url = "https://github.com/dprint/dprint/releases/download/0.53.1/dprint-x86_64-pc-windows-msvc.zip" [[tools.git-cliff]] -version = "2.10.1" +version = "2.12.0" backend = "aqua:orhun/git-cliff" -"platforms.linux-arm64" = { checksum = "sha256:218a25c728df98337541013218660eeb571a464daea7612b35bb4e97b22b97db", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-aarch64-unknown-linux-musl.tar.gz"} -"platforms.linux-x64" = { checksum = "sha256:55ed8495e8c18e51e42182e17772013d6d2a7156a462d6b30f1adf17e54b465e", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-x86_64-unknown-linux-musl.tar.gz"} -"platforms.macos-arm64" = { checksum = "sha256:98cf636ca6a66d84e0ba6202a990028ac45cf0dde331d18169397ae59cc6e41b", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-aarch64-apple-darwin.tar.gz"} -"platforms.macos-x64" = { checksum = "sha256:c3111dddaf866a986085f22ff22fa3003645fc69a3b9302c4e1352c4676c398a", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-x86_64-apple-darwin.tar.gz"} -"platforms.windows-x64" = { checksum = "sha256:073c8027da2e055ec83c3609c4195284bd10b2771fcbd806ff0f94e48c310c77", url = "https://github.com/orhun/git-cliff/releases/download/v2.10.1/git-cliff-2.10.1-x86_64-pc-windows-msvc.zip"} [[tools."github:alexey1312/swift-index"]] version = "0.3.0" backend = "github:alexey1312/swift-index" -"platforms.macos-arm64" = { checksum = "sha256:67dd926c16b1447d8314d0106332a3dd3bb66d05e897de9800e69cfa2597f081", url = "https://github.com/alexey1312/swift-index/releases/download/v0.3.0/swiftindex-macos.zip", url_api = "https://api.github.com/repos/alexey1312/swift-index/releases/assets/349689493"} -"platforms.macos-x64" = { checksum = "sha256:67dd926c16b1447d8314d0106332a3dd3bb66d05e897de9800e69cfa2597f081", url = "https://github.com/alexey1312/swift-index/releases/download/v0.3.0/swiftindex-macos.zip", url_api = "https://api.github.com/repos/alexey1312/swift-index/releases/assets/349689493"} + +[tools."github:alexey1312/swift-index"."platforms.macos-arm64"] +checksum = "sha256:67dd926c16b1447d8314d0106332a3dd3bb66d05e897de9800e69cfa2597f081" +url = "https://github.com/alexey1312/swift-index/releases/download/v0.3.0/swiftindex-macos.zip" +url_api = "https://api.github.com/repos/alexey1312/swift-index/releases/assets/349689493" + +[tools."github:alexey1312/swift-index"."platforms.macos-x64"] +checksum = "sha256:67dd926c16b1447d8314d0106332a3dd3bb66d05e897de9800e69cfa2597f081" +url = "https://github.com/alexey1312/swift-index/releases/download/v0.3.0/swiftindex-macos.zip" +url_api = "https://api.github.com/repos/alexey1312/swift-index/releases/assets/349689493" [[tools.hk]] -version = "1.36.0" +version = "1.39.0" backend = "aqua:jdx/hk" -"platforms.linux-arm64" = { checksum = "sha256:51cf51e2035038ee64d772e71f8daf4978380ef82faffee2b12c413f009341ab", url = "https://github.com/jdx/hk/releases/download/v1.36.0/hk-aarch64-unknown-linux-gnu.tar.gz"} -"platforms.linux-x64" = { checksum = "sha256:d20fa0be3f1135abc74471670306ae0353ad2336804595362293f0c8460952e7", url = "https://github.com/jdx/hk/releases/download/v1.36.0/hk-x86_64-unknown-linux-gnu.tar.gz"} -"platforms.macos-arm64" = { checksum = "sha256:553ff3c7c18d91f1c1dcdbae44315db0324b52336b5a02ed5b9c382f08499b3b", url = "https://github.com/jdx/hk/releases/download/v1.36.0/hk-aarch64-apple-darwin.tar.gz"} -"platforms.windows-x64" = { checksum = "sha256:1f12218ccad806a3f49bcf521f86b6825fbd89486e830c1c04f72e3796f84589", url = "https://github.com/jdx/hk/releases/download/v1.36.0/hk-x86_64-pc-windows-msvc.zip"} + +[tools.hk."platforms.linux-arm64"] +checksum = "sha256:209b73effe9f36fc5eee5a6ec0e833a501ed67f1041fbb3aeac9923ee252eb53" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-aarch64-unknown-linux-gnu.tar.gz" + +[tools.hk."platforms.linux-arm64-musl"] +checksum = "sha256:209b73effe9f36fc5eee5a6ec0e833a501ed67f1041fbb3aeac9923ee252eb53" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-aarch64-unknown-linux-gnu.tar.gz" + +[tools.hk."platforms.linux-x64"] +checksum = "sha256:559c72b512f7ec3df48f7a1e0f4d1c1082dd24a26f7d40865805dcc12dbf2a7c" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-x86_64-unknown-linux-gnu.tar.gz" + +[tools.hk."platforms.linux-x64-musl"] +checksum = "sha256:559c72b512f7ec3df48f7a1e0f4d1c1082dd24a26f7d40865805dcc12dbf2a7c" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-x86_64-unknown-linux-gnu.tar.gz" + +[tools.hk."platforms.macos-arm64"] +checksum = "sha256:0df1d19a8a7b052def3b1048a38681218ce4e04579c1805fa74457f8e634ee82" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-aarch64-apple-darwin.tar.gz" + +[tools.hk."platforms.windows-x64"] +checksum = "sha256:4fdbc4dfe17191fdc2157e2ddb9cf32d0e9fb4c6c0cddae2f8f2ea4b0b351fca" +url = "https://github.com/jdx/hk/releases/download/v1.39.0/hk-x86_64-pc-windows-msvc.zip" [[tools."npm:@mixedbread/mgrep"]] version = "0.1.8" backend = "npm:@mixedbread/mgrep" [[tools.pkl]] -version = "0.31.0" +version = "0.31.1" backend = "aqua:apple/pkl" -"platforms.linux-arm64" = { checksum = "sha256:471460cdd11e1cb9ac0a5401fdb05277ae3adb3a4573cc0a9c63ee087c1f93c8", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-linux-aarch64"} -"platforms.linux-x64" = { checksum = "sha256:5a5c2a889b68ca92ff4258f9d277f92412b98dfef5057daef7564202a20870b6", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-linux-amd64"} -"platforms.macos-arm64" = { checksum = "sha256:349402ae32c35382c034b0c0af744ffb0d53a213888c44deec94a7810e144889", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-macos-aarch64"} -"platforms.macos-x64" = { checksum = "sha256:9f1cc8e3ac2327bc483b90d0c220da20eb785c3ba3fe92e021f47d3d56768282", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-macos-amd64"} -"platforms.windows-x64" = { checksum = "sha256:37d35ce8a165766502fb13799071d4cefa84d39fb455c75c471b47b9b5d12b04", url = "https://github.com/apple/pkl/releases/download/0.31.0/pkl-windows-amd64.exe"} + +[tools.pkl."platforms.linux-arm64"] +checksum = "sha256:7ef10e743daa921fb94ae7bdb9ec6986f362bf250c55814b9ea2aeb13f2d083e" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-aarch64" + +[tools.pkl."platforms.linux-arm64-musl"] +checksum = "sha256:7ef10e743daa921fb94ae7bdb9ec6986f362bf250c55814b9ea2aeb13f2d083e" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-aarch64" + +[tools.pkl."platforms.linux-x64"] +checksum = "sha256:618f13955d755cafbfe8c9cba1d27635848cd49dbc6abffd398d2751db1231bf" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-amd64" + +[tools.pkl."platforms.linux-x64-musl"] +checksum = "sha256:618f13955d755cafbfe8c9cba1d27635848cd49dbc6abffd398d2751db1231bf" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-amd64" + +[tools.pkl."platforms.macos-arm64"] +checksum = "sha256:1b6a5438d9624cd2798a7530721bbbfa27ef72efe5c878a1b6c546c6e7ca0e8f" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-macos-aarch64" + +[tools.pkl."platforms.macos-x64"] +checksum = "sha256:22123ed4ae4c03afa8c54c69f77f0bec39b0fa0f67b09d6d148e0a376a2a471d" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-macos-amd64" + +[tools.pkl."platforms.windows-x64"] +checksum = "sha256:a8834481667325b44c539dbb758d7365a16389070f343c04b639bbe525ede013" +url = "https://github.com/apple/pkl/releases/download/0.31.1/pkl-windows-amd64.exe" [[tools.swift]] version = "6.2.3" @@ -63,17 +158,70 @@ backend = "asdf:swiftformat" [[tools.swiftlint]] version = "0.63.2" backend = "aqua:realm/SwiftLint" -"platforms.linux-arm64" = { checksum = "sha256:104dedff762157f5cff7752f1cc2a289b60f3ea677e72d651c6f3a3287fdd948", url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/swiftlint_linux_arm64.zip"} -"platforms.linux-x64" = { checksum = "sha256:dd1017cfd20a1457f264590bcb5875a6ee06cd75b9a9d4f77cd43a552499143b", url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/swiftlint_linux_amd64.zip"} -"platforms.macos-arm64" = { checksum = "sha256:c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29", url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/portable_swiftlint.zip"} -"platforms.macos-x64" = { checksum = "sha256:c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29", url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/portable_swiftlint.zip"} + +[tools.swiftlint."platforms.linux-arm64"] +checksum = "sha256:104dedff762157f5cff7752f1cc2a289b60f3ea677e72d651c6f3a3287fdd948" +url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/swiftlint_linux_arm64.zip" + +[tools.swiftlint."platforms.linux-x64"] +checksum = "sha256:dd1017cfd20a1457f264590bcb5875a6ee06cd75b9a9d4f77cd43a552499143b" +url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/swiftlint_linux_amd64.zip" + +[tools.swiftlint."platforms.macos-arm64"] +checksum = "sha256:c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29" +url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/portable_swiftlint.zip" + +[tools.swiftlint."platforms.macos-x64"] +checksum = "sha256:c59a405c85f95b92ced677a500804e081596a4cae4a6a485af76065557d6ed29" +url = "https://github.com/realm/SwiftLint/releases/download/0.63.2/portable_swiftlint.zip" [[tools.usage]] -version = "3.0.0" +version = "3.2.0" backend = "aqua:jdx/usage" -"platforms.macos-arm64" = { checksum = "sha256:4de86e95923dd57d8171a5ae40ca65492ef84b69175fefd7ab14442ab3ea0fc2", url = "https://github.com/jdx/usage/releases/download/v3.0.0/usage-universal-apple-darwin.tar.gz"} + +[tools.usage."platforms.linux-arm64"] +checksum = "sha256:9cc22814763948582a31b054e6aa3adfc7a820a3cafb82b317dd4a8931277c0f" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-aarch64-unknown-linux-musl.tar.gz" + +[tools.usage."platforms.linux-arm64-musl"] +checksum = "sha256:9cc22814763948582a31b054e6aa3adfc7a820a3cafb82b317dd4a8931277c0f" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-aarch64-unknown-linux-musl.tar.gz" + +[tools.usage."platforms.linux-x64"] +checksum = "sha256:7f48ee228ae95b07c193ec61def326575682b37032473c5af4b3b93a114aec81" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-x86_64-unknown-linux-musl.tar.gz" + +[tools.usage."platforms.linux-x64-musl"] +checksum = "sha256:7f48ee228ae95b07c193ec61def326575682b37032473c5af4b3b93a114aec81" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-x86_64-unknown-linux-musl.tar.gz" + +[tools.usage."platforms.macos-arm64"] +checksum = "sha256:f0a566bc05fc485b23a339abddbaef04225e7730574dbb603fdc021e084d26f9" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-universal-apple-darwin.tar.gz" + +[tools.usage."platforms.macos-x64"] +checksum = "sha256:f0a566bc05fc485b23a339abddbaef04225e7730574dbb603fdc021e084d26f9" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-universal-apple-darwin.tar.gz" + +[tools.usage."platforms.windows-x64"] +checksum = "sha256:9aba23a5d549695cfb5f034849e143205ccf972e5b5d42c24acd32e084d7a165" +url = "https://github.com/jdx/usage/releases/download/v3.2.0/usage-x86_64-pc-windows-msvc.zip" [[tools.xcsift]] -version = "1.1.6" +version = "1.2.0" backend = "github:ldomaradzki/xcsift" -"platforms.macos-arm64" = { checksum = "sha256:120aedc672be232e7d58fbc600b01c06095e433f24d2212a414ae569f522371c", url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.1.6/xcsift-v1.1.6-macos-arm64.tar.gz", url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/373660599"} + +[tools.xcsift."platforms.linux-x64"] +checksum = "sha256:ddb2ba80434bbe89889b0ab1d2c459b9f68e98a936d412ab4052a87488b8262a" +url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.2.0/xcsift-v1.2.0-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/379050208" + +[tools.xcsift."platforms.linux-x64-musl"] +checksum = "sha256:ddb2ba80434bbe89889b0ab1d2c459b9f68e98a936d412ab4052a87488b8262a" +url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.2.0/xcsift-v1.2.0-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/379050208" + +[tools.xcsift."platforms.macos-arm64"] +checksum = "sha256:11e93bdc74232a03d7676535f9e7f908d3d0a3c3991aa998af80de0e4a208e1b" +url = "https://github.com/ldomaradzki/xcsift/releases/download/v1.2.0/xcsift-v1.2.0-macos-arm64.tar.gz" +url_api = "https://api.github.com/repos/ldomaradzki/xcsift/releases/assets/379050209" diff --git a/mise.toml b/mise.toml index 7d008225..1c79f7c3 100644 --- a/mise.toml +++ b/mise.toml @@ -36,21 +36,21 @@ git config core.hooksPath .githooks 2>/dev/null || true # --- Swift Development --- swiftformat = "0.60.1" # Swift code formatting swiftlint = "0.63.2" # Swift linting -xcsift = "1.1.6" # xcodebuild output filtering +xcsift = "1.2.0" # xcodebuild output filtering # --- Documentation & Formatting --- -dprint = "0.50.2" # MD/JSON/YAML formatting (Rust, fast) +dprint = "0.53.1" # MD/JSON/YAML formatting (Rust, fast) # --- Git & CI --- -hk = "1.36.0" # Git hooks manager -actionlint = "1.7.9" # GitHub Actions linting -git-cliff = "2.10.1" # Changelog generation +hk = "1.39.0" # Git hooks manager +actionlint = "1.7.11" # GitHub Actions linting +git-cliff = "2.12.0" # Changelog generation # --- Configuration --- -pkl = "0.31.0" # Configuration language (for hk.pkl) +pkl = "0.31.1" # Configuration language (for hk.pkl) # --- CLI Spec --- -usage = "3.0.0" # CLI spec → shell completions, docs, man pages +usage = "3.2.0" # CLI spec → shell completions, docs, man pages # ============================================================================= From 8ca51435e0eda9a96820a1c33d22c27451adbc9d Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Fri, 27 Mar 2026 21:43:39 +0500 Subject: [PATCH 17/17] fix(ci): fix trailing newline issues in generated and workflow files 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. --- .github/workflows/release.yml | 1 - Scripts/generate-llms.sh | 4 ++-- hk.pkl | 1 + llms-full.txt | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6559b2f..df6677e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 - diff --git a/Scripts/generate-llms.sh b/Scripts/generate-llms.sh index 1691319d..c2d31189 100755 --- a/Scripts/generate-llms.sh +++ b/Scripts/generate-llms.sh @@ -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 ' ') diff --git a/hk.pkl b/hk.pkl index c2cf3946..a9aeee76 100644 --- a/hk.pkl +++ b/hk.pkl @@ -79,6 +79,7 @@ local builtin_checks = new Mapping { } ["newlines"] = (Builtins.newlines) { exclude = generated_excludes + template_excludes + depends = "llms-check" hide = true } ["actionlint"] { diff --git a/llms-full.txt b/llms-full.txt index 565fa90a..70bc59e9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2843,4 +2843,3 @@ For lossless encoding, quality is not required. - Configuration - DesignRequirements - CustomTemplates -