diff --git a/example/widgets/ios/IosWeatherWidget.tsx b/example/widgets/ios/IosWeatherWidget.tsx
index e07a294f..bffd529a 100644
--- a/example/widgets/ios/IosWeatherWidget.tsx
+++ b/example/widgets/ios/IosWeatherWidget.tsx
@@ -145,6 +145,35 @@ export const IosWeatherWidget = ({ weather = DEFAULT_WEATHER }: WeatherWidgetPro
🕒 {formatTime(weather.lastUpdated)}
) : null}
+
+ {/* Track 1 PoC verification
+ - Background flips with dark/light mode (light-dark)
+ - Text receives system accent tint in accented rendering mode (widgetAccentable) */}
+
+
+
+ light-dark
+
+
+
+ accentable
+
+
+
)
diff --git a/packages/android-client/android/src/main/java/voltra/generated/ShortNames.kt b/packages/android-client/android/src/main/java/voltra/generated/ShortNames.kt
index 0d7ef2e7..2e650c90 100644
--- a/packages/android-client/android/src/main/java/voltra/generated/ShortNames.kt
+++ b/packages/android-client/android/src/main/java/voltra/generated/ShortNames.kt
@@ -165,6 +165,7 @@ object ShortNames {
"ul" to "underline",
"v" to "value",
"valig" to "verticalAlignment",
+ "wa" to "widgetAccentable",
"wt" to "weight",
"w" to "width",
"xgs" to "xAxisGridStyle",
diff --git a/packages/core/src/payload/short-names.ts b/packages/core/src/payload/short-names.ts
index 7beec772..48e2e063 100644
--- a/packages/core/src/payload/short-names.ts
+++ b/packages/core/src/payload/short-names.ts
@@ -159,6 +159,7 @@ export const NAME_TO_SHORT: Record = {
value: 'v',
verticalAlignment: 'valig',
weight: 'wt',
+ widgetAccentable: 'wa',
width: 'w',
xAxisGridStyle: 'xgs',
xAxisVisibility: 'xav',
@@ -322,6 +323,7 @@ export const SHORT_TO_NAME: Record = {
v: 'value',
valig: 'verticalAlignment',
wt: 'weight',
+ wa: 'widgetAccentable',
w: 'width',
xgs: 'xAxisGridStyle',
xav: 'xAxisVisibility',
diff --git a/packages/generator/data/components.json b/packages/generator/data/components.json
index 64dddb3e..76ae085f 100644
--- a/packages/generator/data/components.json
+++ b/packages/generator/data/components.json
@@ -2,6 +2,7 @@
"version": "1.0.0",
"shortNames": {
"style": "s",
+ "widgetAccentable": "wa",
"alignment": "al",
"buttonStyle": "bs",
"colors": "cls",
diff --git a/packages/ios-client/ios/Package.swift b/packages/ios-client/ios/Package.swift
index 4262726f..e0cba09d 100644
--- a/packages/ios-client/ios/Package.swift
+++ b/packages/ios-client/ios/Package.swift
@@ -61,8 +61,8 @@ let package = Package(
.testTarget(
name: "VoltraStyleTests",
dependencies: ["VoltraStyleCore"],
- path: "tests",
- sources: ["JSGradientParserTests.swift"]
+ path: "Tests",
+ sources: ["JSGradientParserTests.swift", "JSColorParserTests.swift"]
),
]
)
diff --git a/packages/ios-client/ios/Tests/JSColorParserTests.swift b/packages/ios-client/ios/Tests/JSColorParserTests.swift
new file mode 100644
index 00000000..c1065864
--- /dev/null
+++ b/packages/ios-client/ios/Tests/JSColorParserTests.swift
@@ -0,0 +1,118 @@
+import SwiftUI
+@testable import VoltraStyleCore
+import XCTest
+
+final class JSColorParserTests: XCTestCase {
+ // MARK: - Helpers
+
+ private func assertParsed(_ value: String, file: StaticString = #filePath, line: UInt = #line) {
+ XCTAssertNotNil(JSColorParser.parse(value), "Expected non-nil Color for: \(value)", file: file, line: line)
+ }
+
+ private func assertNil(_ value: String, file: StaticString = #filePath, line: UInt = #line) {
+ XCTAssertNil(JSColorParser.parse(value), "Expected nil Color for: \(value)", file: file, line: line)
+ }
+
+ // MARK: - Existing formats (smoke)
+
+ func testHexColors() {
+ assertParsed("#fff")
+ assertParsed("#ffffff")
+ assertParsed("#ffffffff")
+ assertParsed("ffffff")
+ }
+
+ func testRGBColors() {
+ assertParsed("rgb(255, 0, 0)")
+ assertParsed("rgba(255, 0, 0, 0.5)")
+ assertParsed("rgb(255 0 0 / 80%)")
+ assertParsed("rgba(255 0 0 / 0.8)")
+ }
+
+ func testTrailingGarbageRejected() {
+ assertNil("rgba(255,0,0,0.8)garbage")
+ assertNil("rgb(255,0)")
+ assertNil("hsl(120, 100, 50%)")
+ }
+
+ func testReducedPresentationNeutralColors() {
+ XCTAssertTrue(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#F9FAFB"))
+ XCTAssertTrue(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#6B7280"))
+ XCTAssertTrue(JSColorParser.shouldUsePrimaryColorInReducedPresentation("white"))
+ }
+
+ func testReducedPresentationSemanticAccents() {
+ XCTAssertFalse(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#34D399"))
+ XCTAssertFalse(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#F87171"))
+ XCTAssertFalse(JSColorParser.shouldUsePrimaryColorInReducedPresentation("green"))
+ }
+
+ func testHSLColors() {
+ assertParsed("hsl(120, 100%, 50%)")
+ assertParsed("hsla(120, 100%, 50%, 0.5)")
+ assertParsed("hsl(120 100% 50% / 30%)")
+ }
+
+ func testNamedColors() {
+ assertParsed("red")
+ assertParsed("blue")
+ assertParsed("primary")
+ assertParsed("transparent")
+ }
+
+ // MARK: - light-dark()
+
+ func testLightDarkWithHexColors() {
+ assertParsed("light-dark(#ffffff, #000000)")
+ assertParsed("light-dark(#fff, #000)")
+ }
+
+ func testLightDarkWithRGBColors() {
+ assertParsed("light-dark(rgb(255, 255, 255), rgb(0, 0, 0))")
+ assertParsed("light-dark(rgba(255, 255, 255, 1), rgba(0, 0, 0, 0.9))")
+ }
+
+ func testLightDarkWithHSLColors() {
+ assertParsed("light-dark(hsl(0, 0%, 100%), hsl(0, 0%, 0%))")
+ }
+
+ func testLightDarkWithNamedColors() {
+ assertParsed("light-dark(white, black)")
+ assertParsed("light-dark(primary, secondary)")
+ }
+
+ func testLightDarkMixedFormats() {
+ assertParsed("light-dark(#ffffff, rgb(0, 0, 0))")
+ assertParsed("light-dark(white, #1a1a1a)")
+ }
+
+ func testLightDarkWithWhitespace() {
+ assertParsed("light-dark( #fff , #000 )")
+ assertParsed("light-dark( white , black )")
+ }
+
+ func testLightDarkReturnsAdaptiveColor() {
+ // light-dark() must return a non-nil Color — the adaptive UIColor wrapping
+ // is an implementation detail; what matters is that the result is usable.
+ let result = JSColorParser.parse("light-dark(#ffffff, #000000)")
+ XCTAssertNotNil(result)
+ }
+
+ func testLightDarkInvalidLightColor() {
+ assertNil("light-dark(notacolor, #000000)")
+ }
+
+ func testLightDarkInvalidDarkColor() {
+ assertNil("light-dark(#ffffff, notacolor)")
+ }
+
+ func testLightDarkMissingArgument() {
+ assertNil("light-dark(#ffffff)")
+ assertNil("light-dark()")
+ }
+
+ func testLightDarkNestedCommasInRGB() {
+ // The top-level comma split must not break on commas inside rgb()
+ assertParsed("light-dark(rgb(255, 255, 255), rgb(0, 0, 0))")
+ }
+}
diff --git a/packages/ios-client/ios/Tests/JSGradientParserTests.swift b/packages/ios-client/ios/Tests/JSGradientParserTests.swift
index 1d1b1549..d28f357d 100644
--- a/packages/ios-client/ios/Tests/JSGradientParserTests.swift
+++ b/packages/ios-client/ios/Tests/JSGradientParserTests.swift
@@ -167,35 +167,3 @@ final class JSGradientParserTests: XCTestCase {
}
}
}
-
-final class JSColorParserTests: XCTestCase {
- func testNamedColorsParse() {
- XCTAssertNotNil(JSColorParser.parse("red"))
- XCTAssertNotNil(JSColorParser.parse("green"))
- XCTAssertNotNil(JSColorParser.parse("blue"))
- }
-
- func testRGBSlashSyntaxParses() {
- XCTAssertNotNil(JSColorParser.parse("rgb(255 0 0 / 80%)"))
- XCTAssertNotNil(JSColorParser.parse("rgba(255 0 0 / 0.8)"))
- XCTAssertNotNil(JSColorParser.parse("hsl(240 100% 50% / 30%)"))
- }
-
- func testTrailingGarbageRejected() {
- XCTAssertNil(JSColorParser.parse("rgba(255,0,0,0.8)garbage"))
- XCTAssertNil(JSColorParser.parse("rgb(255,0)"))
- XCTAssertNil(JSColorParser.parse("hsl(120, 100, 50%)"))
- }
-
- func testReducedPresentationPrimaryColorDetectionTreatsNeutralColorsAsAdaptive() {
- XCTAssertTrue(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#F9FAFB"))
- XCTAssertTrue(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#6B7280"))
- XCTAssertTrue(JSColorParser.shouldUsePrimaryColorInReducedPresentation("white"))
- }
-
- func testReducedPresentationPrimaryColorDetectionPreservesSemanticAccents() {
- XCTAssertFalse(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#34D399"))
- XCTAssertFalse(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#F87171"))
- XCTAssertFalse(JSColorParser.shouldUsePrimaryColorInReducedPresentation("green"))
- }
-}
diff --git a/packages/ios-client/ios/shared/ShortNames.swift b/packages/ios-client/ios/shared/ShortNames.swift
index 7aa94116..48d5139b 100644
--- a/packages/ios-client/ios/shared/ShortNames.swift
+++ b/packages/ios-client/ios/shared/ShortNames.swift
@@ -162,6 +162,7 @@ public enum ShortNames {
"ul": "underline",
"v": "value",
"valig": "verticalAlignment",
+ "wa": "widgetAccentable",
"wt": "weight",
"w": "width",
"xgs": "xAxisGridStyle",
diff --git a/packages/ios-client/ios/shared/VoltraNode.swift b/packages/ios-client/ios/shared/VoltraNode.swift
index e69e2422..44434458 100644
--- a/packages/ios-client/ios/shared/VoltraNode.swift
+++ b/packages/ios-client/ios/shared/VoltraNode.swift
@@ -118,77 +118,82 @@ struct VoltraElementView: View {
let element: VoltraElement
var body: some View {
- switch element.type {
- case "Button":
- VoltraButton(element)
+ Group {
+ switch element.type {
+ case "Button":
+ VoltraButton(element)
- case "Link":
- VoltraLink(element)
+ case "Link":
+ VoltraLink(element)
- case "VStack":
- VoltraVStack(element)
+ case "VStack":
+ VoltraVStack(element)
- case "HStack":
- VoltraHStack(element)
+ case "HStack":
+ VoltraHStack(element)
- case "View":
- VoltraFlexView(element)
+ case "View":
+ VoltraFlexView(element)
- case "ZStack":
- VoltraZStack(element)
+ case "ZStack":
+ VoltraZStack(element)
- case "Text":
- VoltraText(element)
+ case "Text":
+ VoltraText(element)
- case "Image":
- VoltraImage(element)
+ case "Image":
+ VoltraImage(element)
- case "Symbol":
- VoltraSymbol(element)
+ case "Symbol":
+ VoltraSymbol(element)
- case "Divider":
- VoltraDivider(element)
+ case "Divider":
+ VoltraDivider(element)
- case "Spacer":
- VoltraSpacer(element)
+ case "Spacer":
+ VoltraSpacer(element)
- case "Label":
- VoltraLabel(element)
+ case "Label":
+ VoltraLabel(element)
- case "Toggle":
- VoltraToggle(element)
+ case "Toggle":
+ VoltraToggle(element)
- case "Gauge":
- VoltraGauge(element)
+ case "Gauge":
+ VoltraGauge(element)
- case "LinearProgressView":
- VoltraLinearProgressView(element)
+ case "LinearProgressView":
+ VoltraLinearProgressView(element)
- case "CircularProgressView":
- VoltraCircularProgressView(element)
+ case "CircularProgressView":
+ VoltraCircularProgressView(element)
- case "Timer":
- VoltraTimer(element)
+ case "Timer":
+ VoltraTimer(element)
- case "GroupBox":
- VoltraGroupBox(element)
+ case "GroupBox":
+ VoltraGroupBox(element)
- case "LinearGradient":
- VoltraLinearGradient(element)
+ case "LinearGradient":
+ VoltraLinearGradient(element)
- case "GlassContainer":
- VoltraGlassContainer(element)
+ case "GlassContainer":
+ VoltraGlassContainer(element)
- case "Mask":
- VoltraMask(element)
+ case "Mask":
+ VoltraMask(element)
- case "Chart":
- if #available(iOS 16.0, macOS 13.0, *) {
- VoltraChart(element)
- }
+ case "Chart":
+ if #available(iOS 16.0, macOS 13.0, *) {
+ VoltraChart(element)
+ }
- default:
- EmptyView()
+ default:
+ EmptyView()
+ }
+ }
+ .voltraIf(element.props?["widgetAccentable"]?.boolValue == true) { view in
+ view.widgetAccentable()
}
}
}
diff --git a/packages/ios-client/ios/ui/Style/JSColorParser.swift b/packages/ios-client/ios/ui/Style/JSColorParser.swift
index 7407d769..497053b0 100644
--- a/packages/ios-client/ios/ui/Style/JSColorParser.swift
+++ b/packages/ios-client/ios/ui/Style/JSColorParser.swift
@@ -1,4 +1,7 @@
import SwiftUI
+#if canImport(UIKit)
+ import UIKit
+#endif
enum JSColorParser {
/// Parses Hex, RGB, RGBA, HSL, HSLA, and named color strings into SwiftUI Color.
@@ -30,7 +33,12 @@ enum JSColorParser {
return parseHSL(trimmed)
}
- // 4. Named colors
+ // 4. light-dark() — adaptive color, resolved natively by UIKit trait system
+ if trimmed.hasPrefix("light-dark(") {
+ return parseLightDark(trimmed)
+ }
+
+ // 5. Named colors
if let namedColor = parseNamedColor(trimmed) {
return namedColor
}
@@ -87,6 +95,62 @@ enum JSColorParser {
return saturation <= 0.2
}
+ // MARK: - light-dark() Parser
+
+ /// Splits a `light-dark(, )` string into its two component strings.
+ private static func splitLightDark(_ trimmed: String) -> (lightStr: String, darkStr: String)? {
+ let prefix = "light-dark("
+ guard trimmed.hasPrefix(prefix) else { return nil }
+ let inner = String(trimmed.dropFirst(prefix.count))
+ guard inner.hasSuffix(")") else { return nil }
+ let body = String(inner.dropLast())
+
+ var depth = 0
+ var commaIndex: String.Index? = nil
+ for idx in body.indices {
+ switch body[idx] {
+ case "(": depth += 1
+ case ")": depth -= 1
+ case "," where depth == 0 && commaIndex == nil: commaIndex = idx
+ default: break
+ }
+ }
+
+ guard let comma = commaIndex else { return nil }
+ return (
+ lightStr: String(body[.. (light: Color, dark: Color)? {
+ guard let string = value as? String else { return nil }
+ let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard let split = splitLightDark(trimmed) else { return nil }
+ guard let lightColor = parse(split.lightStr),
+ let darkColor = parse(split.darkStr) else { return nil }
+ return (light: lightColor, dark: darkColor)
+ }
+
+ /// Fallback used by `parse()` for non-text contexts (e.g. borders, backgrounds).
+ /// Uses UIColor dynamic provider — best-effort; prefer parseLightDarkComponents()
+ /// + LightDarkForeground: ShapeStyle for text foreground colors.
+ private static func parseLightDark(_ string: String) -> Color? {
+ guard let split = splitLightDark(string),
+ let lightC = parseColorComponents(split.lightStr),
+ let darkC = parseColorComponents(split.darkStr) else { return nil }
+ #if canImport(UIKit)
+ return Color(uiColor: UIColor { traitCollection in
+ let c = traitCollection.userInterfaceStyle == .dark ? darkC : lightC
+ return UIColor(red: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
+ })
+ #else
+ return Color(.sRGB, red: lightC.red, green: lightC.green, blue: lightC.blue, opacity: lightC.alpha)
+ #endif
+ }
+
/// Parse named color strings
private static func parseNamedColor(_ name: String) -> Color? {
switch name {
diff --git a/packages/ios-client/ios/ui/Style/StyleConverter.swift b/packages/ios-client/ios/ui/Style/StyleConverter.swift
index cabacce6..c880b74f 100644
--- a/packages/ios-client/ios/ui/Style/StyleConverter.swift
+++ b/packages/ios-client/ios/ui/Style/StyleConverter.swift
@@ -153,9 +153,15 @@ enum StyleConverter {
private static func parseText(_ js: [String: Any]) -> TextStyle {
var style = TextStyle()
- if let color = JSColorParser.parse(js["color"]) {
+ let colorValue = js["color"]
+ if let colorStr = colorValue as? String,
+ colorStr.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().hasPrefix("light-dark("),
+ let components = JSColorParser.parseLightDarkComponents(colorStr)
+ {
+ style.lightDarkColors = components
+ } else if let color = JSColorParser.parse(colorValue) {
style.color = color
- style.usesPrimaryColorInReducedPresentation = JSColorParser.shouldUsePrimaryColorInReducedPresentation(js["color"])
+ style.usesPrimaryColorInReducedPresentation = JSColorParser.shouldUsePrimaryColorInReducedPresentation(colorValue)
}
if let size = JSStyleParser.number(js["fontSize"]) {
diff --git a/packages/ios-client/ios/ui/Style/TextStyle.swift b/packages/ios-client/ios/ui/Style/TextStyle.swift
index c00091e7..b3072e6e 100644
--- a/packages/ios-client/ios/ui/Style/TextStyle.swift
+++ b/packages/ios-client/ios/ui/Style/TextStyle.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct TextStyle {
var color: Color = .primary
+ var lightDarkColors: (light: Color, dark: Color)?
var usesPrimaryColorInReducedPresentation = false
var fontSize: CGFloat = 17
var fontWeight: Font.Weight = .regular
@@ -15,6 +16,19 @@ struct TextStyle {
var fontVariant: Set = []
}
+/// A ShapeStyle whose resolve(in:) is called by SwiftUI's rendering engine at draw time,
+/// not during body evaluation. This is the correct hook for adaptive colors in WidgetKit
+/// because the rendering engine passes the correct dark/light environment to resolve(in:)
+/// even though @Environment(\.colorScheme) in body always reads as .light.
+struct LightDarkForeground: ShapeStyle {
+ let light: Color
+ let dark: Color
+
+ func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
+ environment.colorScheme == .dark ? dark : light
+ }
+}
+
struct TextStyleModifier: ViewModifier {
let style: TextStyle
@Environment(\.voltraEnvironment) private var voltraEnvironment
diff --git a/packages/ios-client/ios/ui/Views/VoltraText.swift b/packages/ios-client/ios/ui/Views/VoltraText.swift
index a532b393..5a7fd846 100644
--- a/packages/ios-client/ios/ui/Views/VoltraText.swift
+++ b/packages/ios-client/ios/ui/Views/VoltraText.swift
@@ -74,9 +74,13 @@ public struct VoltraText: VoltraView {
.kerning(textStyle.letterSpacing)
.underline(textStyle.decoration == .underline || textStyle.decoration == .underlineLineThrough)
.strikethrough(textStyle.decoration == .lineThrough || textStyle.decoration == .underlineLineThrough)
- // These technically work on View, but good to keep close
.font(font)
- .foregroundColor(resolvedColor)
+ .foregroundStyle({
+ if let ld = textStyle.lightDarkColors {
+ return AnyShapeStyle(LightDarkForeground(light: ld.light, dark: ld.dark))
+ }
+ return AnyShapeStyle(resolvedColor)
+ }())
.multilineTextAlignment(alignment)
.lineSpacing(textStyle.lineSpacing)
.voltraIfLet(params.numberOfLines) { view, numberOfLines in
diff --git a/packages/ios/src/jsx/baseProps.tsx b/packages/ios/src/jsx/baseProps.tsx
index a66e92d3..22309074 100644
--- a/packages/ios/src/jsx/baseProps.tsx
+++ b/packages/ios/src/jsx/baseProps.tsx
@@ -6,4 +6,5 @@ export type VoltraBaseProps = {
id?: string
style?: VoltraStyleProp
children?: ReactNode
+ widgetAccentable?: boolean
}