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 }