From ddf5146c933553ac32b9b7a3ac083f67aea66152 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 27 May 2026 10:59:21 +0200 Subject: [PATCH 01/11] feat(ios-renderer): add on-device payload resolver package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces packages/ios-renderer — a self-contained JS bundle that runs inside JavaScriptCore/Hermes in the widget extension and resolves variant-aware values in the Voltra payload against current device state before handing off to the existing Swift interpreter. Resolves: light-dark(, ) color expressions via colorScheme, and {{ appIntent. }} template expressions via AppIntent parameters. Bundle output is 894B minified (esbuild, ES2019 target). The bundle/ directory is now git-ignored alongside build/. --- .gitignore | 1 + packages/ios-renderer/package.json | 45 ++++++++ .../ios-renderer/scripts/build-bundle.mjs | 21 ++++ packages/ios-renderer/src/bundle-entry.ts | 5 + packages/ios-renderer/src/index.ts | 101 ++++++++++++++++++ packages/ios-renderer/tsconfig.base.json | 15 +++ packages/ios-renderer/tsconfig.cjs.json | 9 ++ packages/ios-renderer/tsconfig.esm.json | 9 ++ packages/ios-renderer/tsconfig.typecheck.json | 15 +++ packages/ios-renderer/tsconfig.types.json | 10 ++ 10 files changed, 231 insertions(+) create mode 100644 packages/ios-renderer/package.json create mode 100644 packages/ios-renderer/scripts/build-bundle.mjs create mode 100644 packages/ios-renderer/src/bundle-entry.ts create mode 100644 packages/ios-renderer/src/index.ts create mode 100644 packages/ios-renderer/tsconfig.base.json create mode 100644 packages/ios-renderer/tsconfig.cjs.json create mode 100644 packages/ios-renderer/tsconfig.esm.json create mode 100644 packages/ios-renderer/tsconfig.typecheck.json create mode 100644 packages/ios-renderer/tsconfig.types.json diff --git a/.gitignore b/.gitignore index 34294cb3..b103380a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ android.iml android/app/libs android/keystores/debug.keystore build/ +bundle/ ## Node.js node_modules/ diff --git a/packages/ios-renderer/package.json b/packages/ios-renderer/package.json new file mode 100644 index 00000000..e79b81c8 --- /dev/null +++ b/packages/ios-renderer/package.json @@ -0,0 +1,45 @@ +{ + "name": "@use-voltra/ios-renderer", + "version": "1.4.1", + "description": "On-device Voltra payload resolver for iOS widget extensions", + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + ".": { + "types": "./build/types/index.d.ts", + "require": "./build/cjs/index.js", + "import": "./build/esm/index.js", + "default": "./build/esm/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "build", + "bundle" + ], + "scripts": { + "build": "node ../../scripts/build-package.mjs packages/ios-renderer && node scripts/build-bundle.mjs", + "build:bundle": "node scripts/build-bundle.mjs", + "clean": "rm -rf build bundle", + "lint": "oxlint src", + "typecheck": "tsc -p tsconfig.typecheck.json --noEmit" + }, + "keywords": [ + "voltra", + "ios", + "widget", + "renderer" + ], + "author": "Saúl Sharma (https://x.com/saul_sharma), Szymon Chmal (https://x.com/chmalszymon)", + "repository": { + "type": "git", + "url": "git+https://github.com/callstackincubator/voltra.git", + "directory": "packages/ios-renderer" + }, + "bugs": { + "url": "https://github.com/callstackincubator/voltra/issues" + }, + "license": "MIT", + "homepage": "https://use-voltra.dev" +} \ No newline at end of file diff --git a/packages/ios-renderer/scripts/build-bundle.mjs b/packages/ios-renderer/scripts/build-bundle.mjs new file mode 100644 index 00000000..3a119126 --- /dev/null +++ b/packages/ios-renderer/scripts/build-bundle.mjs @@ -0,0 +1,21 @@ +import { build } from 'esbuild'; +import { fileURLToPath } from 'url'; +import path from 'path'; +import fs from 'fs/promises'; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = path.join(packageDir, 'bundle'); + +await fs.mkdir(outDir, { recursive: true }); + +await build({ + entryPoints: [path.join(packageDir, 'src/bundle-entry.ts')], + bundle: true, + platform: 'neutral', // no Node or browser globals + target: ['es2019'], // Hermes and JavaScriptCore both support ES2019 + outfile: path.join(outDir, 'ios-renderer.js'), + minify: true, + treeShaking: true, +}); + +console.log('ios-renderer bundle written to bundle/ios-renderer.js'); \ No newline at end of file diff --git a/packages/ios-renderer/src/bundle-entry.ts b/packages/ios-renderer/src/bundle-entry.ts new file mode 100644 index 00000000..2645d475 --- /dev/null +++ b/packages/ios-renderer/src/bundle-entry.ts @@ -0,0 +1,5 @@ +import { resolve } from './index'; + +// Expose for JavaScriptCore / Hermes evaluation in the widget extension. +// Swift calls: context["VoltraRenderer"].resolve(payload, deviceState, appIntentParams) +(globalThis as unknown as Record)['VoltraRenderer'] = { resolve }; \ No newline at end of file diff --git a/packages/ios-renderer/src/index.ts b/packages/ios-renderer/src/index.ts new file mode 100644 index 00000000..a44adc7b --- /dev/null +++ b/packages/ios-renderer/src/index.ts @@ -0,0 +1,101 @@ +export type ColorScheme = 'light' | 'dark'; +export type WidgetRenderingMode = 'fullColor' | 'accented' | 'vibrant'; + +export type DeviceState = { + colorScheme: ColorScheme; + widgetRenderingMode: WidgetRenderingMode; +}; + +export type AppIntentParams = Record; + +// Keys whose values are never traversed for resolution +const PASSTHROUGH_KEYS = new Set(['v', 's', 'e']); + +/** + * Resolves the CSS light-dark(, ) function against the current color scheme. + * Handles nested parentheses (e.g. rgba() or hsl() arguments). + */ +function resolveLightDark(value: string, colorScheme: ColorScheme): string { + const PREFIX = 'light-dark('; + if (!value.startsWith(PREFIX)) return value; + + const inner = value.slice(PREFIX.length); + let depth = 0; + let commaAt = -1; + let closeAt = -1; + + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + if (depth === 0) { + closeAt = i; + break; + } + depth--; + } else if (ch === ',' && depth === 0 && commaAt === -1) { + commaAt = i; + } + } + + if (commaAt === -1 || closeAt === -1) return value; + + const light = inner.slice(0, commaAt).trim(); + const dark = inner.slice(commaAt + 1, closeAt).trim(); + return colorScheme === 'light' ? light : dark; +} + +/** + * Substitutes {{ appIntent.paramName }} template expressions. + * Unknown parameters are replaced with an empty string. + */ +function resolveTemplate(value: string, appIntentParams: AppIntentParams): string { + return value.replace(/\{\{\s*appIntent\.(\w+)\s*\}\}/g, (_, key: string) => appIntentParams[key] ?? ''); +} + +function resolveString(value: string, deviceState: DeviceState, appIntentParams: AppIntentParams): string { + return resolveTemplate(resolveLightDark(value, deviceState.colorScheme), appIntentParams); +} + +function resolveValue(value: unknown, deviceState: DeviceState, appIntentParams: AppIntentParams): unknown { + if (typeof value === 'string') { + return resolveString(value, deviceState, appIntentParams); + } + if (Array.isArray(value)) { + return value.map((item) => resolveValue(item, deviceState, appIntentParams)); + } + if (value !== null && typeof value === 'object') { + const obj = value as Record; + // Element refs ($r) are resolved by the Swift layer after resolution — pass through unchanged + if ('$r' in obj) return obj; + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, resolveValue(v, deviceState, appIntentParams)]) + ); + } + return value; +} + +/** + * Resolves all variant-aware values in a Voltra payload against the current device state + * and AppIntent parameters, returning a fully resolved payload ready for the Swift interpreter. + * + * Variant-aware values currently supported: + * - light-dark(, ) strings in any prop — resolved via colorScheme + * - {{ appIntent. }} template expressions — substituted from appIntentParams + * + * The version (v), shared stylesheet (s), and shared elements (e) keys are passed through + * unchanged so the Swift interpreter can still resolve element refs and style indices. + */ +export function resolve( + payload: Record, + deviceState: DeviceState, + appIntentParams: AppIntentParams +): Record { + return Object.fromEntries( + Object.entries(payload).map(([key, value]) => [ + key, + PASSTHROUGH_KEYS.has(key) ? value : resolveValue(value, deviceState, appIntentParams), + ]) + ); +} \ No newline at end of file diff --git a/packages/ios-renderer/tsconfig.base.json b/packages/ios-renderer/tsconfig.base.json new file mode 100644 index 00000000..79ed79a9 --- /dev/null +++ b/packages/ios-renderer/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["ES2019"], + "rootDir": "./src", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["./src"], + "exclude": ["**/__tests__/*"] +} \ No newline at end of file diff --git a/packages/ios-renderer/tsconfig.cjs.json b/packages/ios-renderer/tsconfig.cjs.json new file mode 100644 index 00000000..bef34b5d --- /dev/null +++ b/packages/ios-renderer/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./build/cjs", + "declaration": false, + "sourceMap": true + } +} \ No newline at end of file diff --git a/packages/ios-renderer/tsconfig.esm.json b/packages/ios-renderer/tsconfig.esm.json new file mode 100644 index 00000000..91d4efa9 --- /dev/null +++ b/packages/ios-renderer/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./build/esm", + "declaration": false, + "sourceMap": true + } +} \ No newline at end of file diff --git a/packages/ios-renderer/tsconfig.typecheck.json b/packages/ios-renderer/tsconfig.typecheck.json new file mode 100644 index 00000000..00a1992b --- /dev/null +++ b/packages/ios-renderer/tsconfig.typecheck.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "rootDir": "../..", + "baseUrl": "../..", + "paths": { + "voltra": ["packages/voltra/src/index.ts"], + "@use-voltra/core": ["packages/core/src/index.ts"], + "@use-voltra/ios": ["packages/ios/src/index.ts"], + "@use-voltra/ios-renderer": ["packages/ios-renderer/src/index.ts"], + "@use-voltra/server": ["packages/server/src/index.ts"] + } + } +} \ No newline at end of file diff --git a/packages/ios-renderer/tsconfig.types.json b/packages/ios-renderer/tsconfig.types.json new file mode 100644 index 00000000..9339c396 --- /dev/null +++ b/packages/ios-renderer/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./build/types", + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true + } +} \ No newline at end of file From b90fe73260dafb680ee23a9ea53637ed286baf17 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 27 May 2026 11:54:40 +0200 Subject: [PATCH 02/11] =?UTF-8?q?feat(ios):=20add=20VoltraJSRenderer=20?= =?UTF-8?q?=E2=80=94=20JSC=20evaluation=20layer=20for=20on-device=20payloa?= =?UTF-8?q?d=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads the ios-renderer bundle into a cached JavaScriptCore context and exposes a resolve() method that rewrites variant-aware payload values (light-dark() colors, AppIntent templates) against current device state before the existing Swift interpreter renders the result. Returns nil on any failure so callers can fall back to the unmodified payload — no change to existing rendering behaviour until wired in. --- .../ios/shared/VoltraJSRenderer.swift | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/ios-client/ios/shared/VoltraJSRenderer.swift diff --git a/packages/ios-client/ios/shared/VoltraJSRenderer.swift b/packages/ios-client/ios/shared/VoltraJSRenderer.swift new file mode 100644 index 00000000..fb82a677 --- /dev/null +++ b/packages/ios-client/ios/shared/VoltraJSRenderer.swift @@ -0,0 +1,91 @@ +import Foundation +import JavaScriptCore + +/// Runs the ios-renderer bundle inside a JavaScriptCore context to resolve variant-aware +/// values in a Voltra payload (light-dark() colors, AppIntent template expressions) against +/// the current device state before the existing Swift interpreter renders the result. +/// +/// The JSContext is initialised lazily and cached for the process lifetime so the bundle +/// is only evaluated once per extension process. +public enum VoltraJSRenderer { + private static var _context: JSContext? + private static let lock = NSLock() + + // MARK: - Public API + + /// Resolves variant-aware values in a raw JSON payload string and returns the resolved + /// JSON string, or nil if the bundle is missing or the JS engine fails. + /// + /// On nil the caller should fall back to the original payload — the existing interpreter + /// handles it correctly, it just won't be reactive to device state. + public static func resolve( + payloadJSON: String, + colorScheme: String, + widgetRenderingMode: String, + appIntentParams: [String: String] + ) -> String? { + guard let ctx = context else { return nil } + + guard let data = payloadJSON.data(using: .utf8), + let payloadObj = try? JSONSerialization.jsonObject(with: data) + else { + VoltraLogger.widget.error("[VoltraJSRenderer] Failed to parse payload JSON") + return nil + } + + guard let renderer = ctx.objectForKeyedSubscript("VoltraRenderer"), + let resolveFn = renderer.objectForKeyedSubscript("resolve"), + !resolveFn.isUndefined + else { + VoltraLogger.widget.error("[VoltraJSRenderer] VoltraRenderer.resolve not found in context") + return nil + } + + let deviceState: NSDictionary = [ + "colorScheme": colorScheme, + "widgetRenderingMode": widgetRenderingMode, + ] + + let result = resolveFn.call(withArguments: [payloadObj, deviceState, appIntentParams as NSDictionary]) + + guard let result, + !result.isNull, + !result.isUndefined, + let resultObj = result.toObject(), + JSONSerialization.isValidJSONObject(resultObj), + let resultData = try? JSONSerialization.data(withJSONObject: resultObj), + let resultJSON = String(data: resultData, encoding: .utf8) + else { + VoltraLogger.widget.error("[VoltraJSRenderer] Failed to serialise resolved payload") + return nil + } + + return resultJSON + } + + // MARK: - Context lifecycle + + private static var context: JSContext? { + lock.lock() + defer { lock.unlock() } + + if let existing = _context { return existing } + + guard let bundleURL = Bundle.main.url(forResource: "ios-renderer", withExtension: "js"), + let source = try? String(contentsOf: bundleURL, encoding: .utf8) + else { + VoltraLogger.widget.error("[VoltraJSRenderer] ios-renderer.js not found in extension bundle — rebuild the ios-renderer package") + return nil + } + + let ctx = JSContext()! + ctx.exceptionHandler = { _, exception in + VoltraLogger.widget.error("[VoltraJSRenderer] JS exception: \(exception?.toString() ?? "unknown")") + } + ctx.evaluateScript(source) + _context = ctx + + VoltraLogger.widget.info("[VoltraJSRenderer] JSContext initialised") + return ctx + } +} \ No newline at end of file From 229e1964b8ffb5fb5ec3efce0017255b5f3058eb Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 27 May 2026 12:38:16 +0200 Subject: [PATCH 03/11] feat(ios): add appIntentParam helper and reactive widget demo (Track 2 Step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export appIntentParam(name) from @use-voltra/ios → ios-server → voltra; returns {{ appIntent.name }} template expression that passes through the server renderer unchanged and is resolved in-extension by VoltraJSRenderer - Add IosReactiveWeatherWidget.tsx demonstrating appIntentParam + light-dark() colors - Add end-to-end tests for resolve() covering template substitution, light-dark resolution, passthrough keys, and JSON round-trip --- .../widgets/ios/IosReactiveWeatherWidget.tsx | 42 ++++++ packages/ios-renderer/jest.config.js | 14 ++ packages/ios-renderer/package.json | 7 + .../src/__tests__/resolve.node.test.ts | 123 ++++++++++++++++++ packages/ios-renderer/tsconfig.jest.json | 9 ++ packages/ios-server/src/index.ts | 2 +- packages/ios/src/app-intent.ts | 14 ++ packages/ios/src/index.ts | 1 + 8 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 example/widgets/ios/IosReactiveWeatherWidget.tsx create mode 100644 packages/ios-renderer/jest.config.js create mode 100644 packages/ios-renderer/src/__tests__/resolve.node.test.ts create mode 100644 packages/ios-renderer/tsconfig.jest.json create mode 100644 packages/ios/src/app-intent.ts diff --git a/example/widgets/ios/IosReactiveWeatherWidget.tsx b/example/widgets/ios/IosReactiveWeatherWidget.tsx new file mode 100644 index 00000000..e0c6ae0d --- /dev/null +++ b/example/widgets/ios/IosReactiveWeatherWidget.tsx @@ -0,0 +1,42 @@ +import { Voltra, appIntentParam } from 'voltra' + +// Track 2 PoC — demonstrates reactive widget rendering: +// - appIntentParam('city'): user-configurable city name via iOS widget settings +// - light-dark() colors: resolved to the device color scheme inside the extension +// +// The server renders this JSX to a compact JSON payload that includes the raw +// template expressions. At render time inside the widget extension, VoltraJSRenderer +// resolves them against the current device state — no server push required when +// the user reconfigures the widget or switches dark/light mode. + +export const IosReactiveWeatherWidget = () => ( + + + {appIntentParam('city')} + + + Reactive Weather + + + Edit widget to set your city + + +) \ No newline at end of file diff --git a/packages/ios-renderer/jest.config.js b/packages/ios-renderer/jest.config.js new file mode 100644 index 00000000..e5193e9f --- /dev/null +++ b/packages/ios-renderer/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('jest').Config} */ +module.exports = { + testEnvironment: 'node', + testMatch: ['/src/**/*.node.test.ts'], + modulePathIgnorePatterns: ['/build', '/bundle'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.jest.json', + }, + ], + }, +} \ No newline at end of file diff --git a/packages/ios-renderer/package.json b/packages/ios-renderer/package.json index e79b81c8..2d2d5587 100644 --- a/packages/ios-renderer/package.json +++ b/packages/ios-renderer/package.json @@ -23,8 +23,15 @@ "build:bundle": "node scripts/build-bundle.mjs", "clean": "rm -rf build bundle", "lint": "oxlint src", + "test": "jest --config jest.config.js", "typecheck": "tsc -p tsconfig.typecheck.json --noEmit" }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^20.19.25", + "jest": "^29.7.0", + "ts-jest": "^29.3.4" + }, "keywords": [ "voltra", "ios", diff --git a/packages/ios-renderer/src/__tests__/resolve.node.test.ts b/packages/ios-renderer/src/__tests__/resolve.node.test.ts new file mode 100644 index 00000000..28a98f3b --- /dev/null +++ b/packages/ios-renderer/src/__tests__/resolve.node.test.ts @@ -0,0 +1,123 @@ +import { resolve } from '../index' +import type { AppIntentParams, DeviceState } from '../index' + +// Simulates the compact JSON payload the server renderer produces from: +// +// {appIntentParam('city')} +// +// +// In practice the payload is rendered by packages/ios/src/widgets/renderer.ts, +// but since light-dark() and {{ appIntent.X }} pass through the server unchanged +// (confirmed in renderer.ts → transformProps / compressStyleObject), we test +// resolve() directly against a representative payload shape. + +const makePayload = (): Record => ({ + v: 1, + systemSmall: { + t: 11, // VStack + c: [ + { + t: 0, // Text + c: '{{ appIntent.city }}', + p: { c: 'light-dark(#111111, #eeeeee)', fs: 22, fw: '700' }, + }, + { + t: 0, + c: 'Reactive Weather', + p: { c: 'light-dark(#666666, #999999)', fs: 14, mt: 6 }, + }, + ], + p: { pad: 16, al: 'leading', fl: 1 }, + }, + systemMedium: { + t: 11, + c: [ + { + t: 0, + c: '{{ appIntent.city }}', + p: { c: 'light-dark(#111111, #eeeeee)', fs: 22, fw: '700' }, + }, + ], + p: { pad: 16, al: 'leading', fl: 1 }, + }, +}) + +const lightState: DeviceState = { colorScheme: 'light', widgetRenderingMode: 'fullColor' } +const darkState: DeviceState = { colorScheme: 'dark', widgetRenderingMode: 'fullColor' } +const accentedState: DeviceState = { colorScheme: 'light', widgetRenderingMode: 'accented' } + +const cityParams: AppIntentParams = { city: 'Warsaw' } +const emptyParams: AppIntentParams = {} + +describe('resolve — appIntentParam template substitution', () => { + test('replaces {{ appIntent.city }} with the configured city', () => { + const result = resolve(makePayload(), lightState, cityParams) + const small = result['systemSmall'] as any + expect(small.c[0].c).toBe('Warsaw') + }) + + test('replaces template in all families', () => { + const result = resolve(makePayload(), lightState, cityParams) + const medium = result['systemMedium'] as any + expect(medium.c[0].c).toBe('Warsaw') + }) + + test('replaces unknown param with empty string', () => { + const result = resolve(makePayload(), lightState, emptyParams) + const small = result['systemSmall'] as any + expect(small.c[0].c).toBe('') + }) +}) + +describe('resolve — light-dark() color resolution', () => { + test('picks light color in light mode', () => { + const result = resolve(makePayload(), lightState, cityParams) + const small = result['systemSmall'] as any + expect(small.c[0].p.c).toBe('#111111') + expect(small.c[1].p.c).toBe('#666666') + }) + + test('picks dark color in dark mode', () => { + const result = resolve(makePayload(), darkState, cityParams) + const small = result['systemSmall'] as any + expect(small.c[0].p.c).toBe('#eeeeee') + expect(small.c[1].p.c).toBe('#999999') + }) + + test('accented rendering mode does not break light-dark resolution', () => { + const result = resolve(makePayload(), accentedState, cityParams) + const small = result['systemSmall'] as any + expect(small.c[0].p.c).toBe('#111111') + }) +}) + +describe('resolve — passthrough keys', () => { + test('v (version) is not traversed', () => { + const result = resolve(makePayload(), lightState, cityParams) + expect(result['v']).toBe(1) + }) + + test('non-string numeric props are preserved', () => { + const result = resolve(makePayload(), lightState, cityParams) + const small = result['systemSmall'] as any + expect(small.c[0].p.fs).toBe(22) + }) +}) + +describe('resolve — end-to-end round-trip', () => { + test('resolved payload can be serialised to JSON and back', () => { + const result = resolve(makePayload(), lightState, cityParams) + const json = JSON.stringify(result) + const parsed = JSON.parse(json) + expect(parsed.systemSmall.c[0].c).toBe('Warsaw') + expect(parsed.systemSmall.c[0].p.c).toBe('#111111') + }) + + test('same payload, different device states, produce different outputs', () => { + const lightResult = resolve(makePayload(), lightState, cityParams) + const darkResult = resolve(makePayload(), darkState, cityParams) + const lightSmall = lightResult['systemSmall'] as any + const darkSmall = darkResult['systemSmall'] as any + expect(lightSmall.c[0].p.c).not.toBe(darkSmall.c[0].p.c) + }) +}) \ No newline at end of file diff --git a/packages/ios-renderer/tsconfig.jest.json b/packages/ios-renderer/tsconfig.jest.json new file mode 100644 index 00000000..88f34aa0 --- /dev/null +++ b/packages/ios-renderer/tsconfig.jest.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["jest", "node"], + "module": "commonjs" + }, + "include": ["./src/**/*.ts"] +} \ No newline at end of file diff --git a/packages/ios-server/src/index.ts b/packages/ios-server/src/index.ts index 5a15ace5..6d41dee6 100644 --- a/packages/ios-server/src/index.ts +++ b/packages/ios-server/src/index.ts @@ -18,7 +18,7 @@ import { } from '@use-voltra/server' import type { ReactNode } from 'react' -export { Voltra } from '@use-voltra/ios' +export { Voltra, appIntentParam } from '@use-voltra/ios' export type { LiveActivityVariants, WidgetVariants } export type { WidgetRenderRequest, diff --git a/packages/ios/src/app-intent.ts b/packages/ios/src/app-intent.ts new file mode 100644 index 00000000..1074b871 --- /dev/null +++ b/packages/ios/src/app-intent.ts @@ -0,0 +1,14 @@ +/** + * Returns a template expression that the ios-renderer JS resolver will replace + * at render time with the widget's AppIntent parameter value. + * + * Usage in a widget JSX component: + * {appIntentParam('city')} + * + * This string passes through the server renderer unchanged and is resolved + * inside the widget extension process — no server push required when the + * user reconfigures the widget. + */ +export function appIntentParam(name: string): string { + return `{{ appIntent.${name} }}` +} \ No newline at end of file diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index 98b89a67..f5d369aa 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -5,6 +5,7 @@ export { COMPONENT_ID_TO_NAME, COMPONENT_NAME_TO_ID, } from './payload/component-ids.js' +export { appIntentParam } from './app-intent.js' export { renderLiveActivityToJson, renderLiveActivityToString } from './live-activity/renderer.js' export type { DismissalPolicy, From f65f701b26177283967473a31071c47d5046d233 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 27 May 2026 13:47:28 +0200 Subject: [PATCH 04/11] feat(example): serve reactive widget via Track 2 PoC endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds widgetId=reactive handler to the example server that renders IosReactiveWeatherWidget — a payload containing {{ appIntent.city }} template expressions and light-dark() colors that the widget extension resolves on-device without a server push. --- example/server/widget-server.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/example/server/widget-server.tsx b/example/server/widget-server.tsx index 043b58c8..16e10f83 100644 --- a/example/server/widget-server.tsx +++ b/example/server/widget-server.tsx @@ -13,6 +13,7 @@ import { renderWidgetToString } from '@use-voltra/ios-server' import { createWidgetUpdateNodeHandler } from '@use-voltra/server' import React from 'react' import { IosPortfolioWidget } from '../widgets/ios/IosPortfolioWidget' +import { IosReactiveWeatherWidget } from '../widgets/ios/IosReactiveWeatherWidget' import { AndroidMaterialColorsServerWidget } from '../widgets/android/AndroidMaterialColorsWidget' import { AndroidPortfolioWidget } from '../widgets/android/AndroidPortfolioWidget' @@ -50,6 +51,15 @@ function generatePortfolioData() { const handler = createWidgetUpdateNodeHandler({ renderIos: async (req: any) => { + if (req.widgetId === 'reactive') { + // Track 2 PoC: server renders the widget with variant-aware values preserved. + // appIntentParam('city') → "{{ appIntent.city }}" in the payload. + // light-dark() colors pass through unchanged. + // The extension resolves both against live device state + AppIntent params. + const content = + return { systemSmall: content, systemMedium: content } + } + if (req.widgetId !== 'portfolio') { return null } @@ -117,6 +127,8 @@ createServer(handler).listen(PORT, () => { console.log(`\n Portfolio chart:`) console.log(` iOS: GET http://localhost:${PORT}?widgetId=portfolio&platform=ios&family=systemSmall`) console.log(` Android: GET http://10.0.2.2:${PORT}?widgetId=portfolio&platform=android`) + console.log(`\n Reactive weather (Track 2 PoC — variant-aware payload):`) + console.log(` iOS: GET http://localhost:${PORT}?widgetId=reactive&platform=ios&family=systemSmall`) console.log(`\n Material colors:`) console.log(` Android: GET http://10.0.2.2:${PORT}?widgetId=material_colors&platform=android`) console.log(`\n (Android emulator uses 10.0.2.2 to reach the host machine)`) From 8fb454cd81e866398dc4e4e8f6385e2e09ca0cde Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 27 May 2026 13:47:42 +0200 Subject: [PATCH 05/11] =?UTF-8?q?feat(ios):=20add=20VoltraReactiveSupport?= =?UTF-8?q?=20=E2=80=94=20shared=20helpers=20for=20generated=20AppIntent?= =?UTF-8?q?=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds VoltraReactiveRenderer (extractNode, jsRenderingMode, voltraRenderingMode) and VoltraReactiveContainerBackground so the config plugin can generate minimal per-widget Swift without duplicating rendering logic. Also adds poc/VoltraReactiveWidget.swift as a hand-written reference showing the pattern that the config plugin will generate automatically. --- .../ios/shared/VoltraReactiveSupport.swift | 61 +++++ .../voltra/ios/poc/VoltraReactiveWidget.swift | 222 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 packages/ios-client/ios/shared/VoltraReactiveSupport.swift create mode 100644 packages/voltra/ios/poc/VoltraReactiveWidget.swift diff --git a/packages/ios-client/ios/shared/VoltraReactiveSupport.swift b/packages/ios-client/ios/shared/VoltraReactiveSupport.swift new file mode 100644 index 00000000..7a057c36 --- /dev/null +++ b/packages/ios-client/ios/shared/VoltraReactiveSupport.swift @@ -0,0 +1,61 @@ +import Foundation +import SwiftUI +import WidgetKit + +/// Shared rendering helpers for AppIntentConfiguration widgets generated by the Voltra config plugin. +/// Generated widget code calls into these so the boilerplate stays minimal. +@available(iOS 17.0, *) +public enum VoltraReactiveRenderer { + /// Deserialises the resolved JSON payload, extracts the family-specific element, + /// and parses it into a VoltraNode ready for the Swift interpreter. + public static func extractNode(from resolvedJSON: String, family: WidgetFamily) -> VoltraNode { + guard let data = resolvedJSON.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let familyContent = root[familyKey(family)] ?? root["systemSmall"], + let familyData = try? JSONSerialization.data(withJSONObject: familyContent), + let familyStr = String(data: familyData, encoding: .utf8), + let jsonValue = try? JSONValue.parse(from: familyStr) + else { return .empty } + + return VoltraNode.parse(from: jsonValue) + } + + public static func familyKey(_ family: WidgetFamily) -> String { + switch family { + case .systemSmall: return "systemSmall" + case .systemMedium: return "systemMedium" + case .systemLarge: return "systemLarge" + default: return "systemSmall" + } + } + + public static func jsRenderingMode(_ mode: WidgetRenderingMode) -> String { + switch mode { + case .accented: return "accented" + case .vibrant: return "vibrant" + default: return "fullColor" + } + } + + public static func voltraRenderingMode(_ mode: WidgetRenderingMode) -> VoltraWidgetRenderingMode { + switch mode { + case .accented: return .accented + case .vibrant: return .vibrant + default: return .fullColor + } + } +} + +/// Applies `.containerBackground(.clear, for: .widget)` on iOS 17+ extension targets. +/// Used by all generated AppIntentConfiguration widget views. +public struct VoltraReactiveContainerBackground: ViewModifier { + public init() {} + + public func body(content: Content) -> some View { + if #available(iOSApplicationExtension 17.0, *) { + content.containerBackground(.clear, for: .widget) + } else { + content + } + } +} \ No newline at end of file diff --git a/packages/voltra/ios/poc/VoltraReactiveWidget.swift b/packages/voltra/ios/poc/VoltraReactiveWidget.swift new file mode 100644 index 00000000..194a9b69 --- /dev/null +++ b/packages/voltra/ios/poc/VoltraReactiveWidget.swift @@ -0,0 +1,222 @@ +// VoltraReactiveWidget.swift — Track 2 PoC +// +// AppIntentConfiguration + JS-in-extension rendering. +// +// The widget stores an unresolved payload (light-dark() colors, {{ appIntent.X }} templates) +// and resolves it at render time via VoltraJSRenderer so SwiftUI environment changes +// (color scheme, widget rendering mode) and user-configured AppIntent parameters +// always produce a fresh, correct render — no server push required. +// +// ── Integration steps (after expo prebuild) ──────────────────────────────────── +// +// 1. In VoltraWidgetBundle.swift, add to the bundle body: +// +// if #available(iOS 17.0, *) { +// VoltraWidget_reactive() +// } +// +// 2. In Xcode, select the widget extension target → Build Phases → +// Copy Bundle Resources → add: +// packages/ios-renderer/bundle/ios-renderer.js +// (run `npm run build:bundle -w @use-voltra/ios-renderer` first if missing) +// +// ─────────────────────────────────────────────────────────────────────────────── + +import AppIntents +import Foundation +import SwiftUI +import WidgetKit + +// MARK: - Hardcoded initial payload +// +// Inline styles only (no shared stylesheet) so the family element can be extracted +// and fed to VoltraNode.parse without needing stylesheet index resolution. +// {{ appIntent.teamName }} is substituted by VoltraJSRenderer at render time. +// light-dark() colors are resolved against the current colorScheme at render time. + +private let reactiveWidgetPayloadJSON = """ +{ + "v": 1, + "systemSmall": { + "t": 11, + "c": [ + {"t": 0, "c": "{{ appIntent.teamName }}", "p": {"c": "light-dark(#111111, #eeeeee)", "fs": 22, "fw": "700"}}, + {"t": 0, "c": "Track 2 PoC", "p": {"c": "light-dark(#666666, #999999)", "fs": 12, "mt": 6}} + ], + "p": {"pad": 16, "al": "leading", "fl": 1} + }, + "systemMedium": { + "t": 11, + "c": [ + {"t": 0, "c": "{{ appIntent.teamName }}", "p": {"c": "light-dark(#111111, #eeeeee)", "fs": 22, "fw": "700"}}, + {"t": 0, "c": "Track 2 PoC", "p": {"c": "light-dark(#666666, #999999)", "fs": 14, "mt": 6}}, + {"t": 0, "c": "Edit widget to configure team name", "p": {"c": "light-dark(#999999, #666666)", "fs": 11, "mt": 8}} + ], + "p": {"pad": 16, "al": "leading", "fl": 1} + } +} +""" + +// MARK: - AppIntent + +@available(iOS 17.0, *) +struct ReactiveWidgetIntent: AppIntent { + static var title: LocalizedStringResource = "Configure Reactive Widget" + + @Parameter(title: "Team Name", default: "My Team") + var teamName: String + + init() {} + + init(teamName: String) { + self.teamName = teamName + } +} + +// MARK: - Timeline entry + +@available(iOS 17.0, *) +struct ReactiveWidgetEntry: TimelineEntry { + let date: Date + /// Full unresolved payload — family selection + JS resolution happen at render time. + let rawPayloadJSON: String + let appIntentParams: [String: String] +} + +// MARK: - Provider + +@available(iOS 17.0, *) +struct ReactiveWidgetProvider: AppIntentTimelineProvider { + typealias Intent = ReactiveWidgetIntent + typealias Entry = ReactiveWidgetEntry + + func placeholder(in _: Context) -> ReactiveWidgetEntry { + ReactiveWidgetEntry( + date: Date(), + rawPayloadJSON: reactiveWidgetPayloadJSON, + appIntentParams: ["teamName": "My Team"] + ) + } + + func snapshot(for configuration: Intent, in _: Context) async -> ReactiveWidgetEntry { + ReactiveWidgetEntry( + date: Date(), + rawPayloadJSON: reactiveWidgetPayloadJSON, + appIntentParams: ["teamName": configuration.teamName] + ) + } + + func timeline(for configuration: Intent, in _: Context) async -> Timeline { + let entry = ReactiveWidgetEntry( + date: Date(), + rawPayloadJSON: reactiveWidgetPayloadJSON, + appIntentParams: ["teamName": configuration.teamName] + ) + return Timeline(entries: [entry], policy: .never) + } +} + +// MARK: - View + +@available(iOS 17.0, *) +struct ReactiveWidgetView: View { + let entry: ReactiveWidgetEntry + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.widgetRenderingMode) private var widgetRenderingMode + @Environment(\.widgetFamily) private var widgetFamily + @Environment(\.showsWidgetContainerBackground) private var showsWidgetContainerBackground + + var body: some View { + let resolvedJSON = VoltraJSRenderer.resolve( + payloadJSON: entry.rawPayloadJSON, + colorScheme: colorScheme == .dark ? "dark" : "light", + widgetRenderingMode: jsRenderingMode(widgetRenderingMode), + appIntentParams: entry.appIntentParams + ) ?? entry.rawPayloadJSON + + let rootNode = extractNode(from: resolvedJSON, family: widgetFamily) + + Voltra( + root: rootNode, + activityId: "reactive-widget", + widget: VoltraWidgetEnvironment( + isHomeScreenWidget: true, + renderingMode: voltraRenderingMode(widgetRenderingMode), + showsContainerBackground: showsWidgetContainerBackground + ) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .modifier(ReactiveWidgetContainerBackground()) + } + + // MARK: - Helpers + + private func extractNode(from resolvedJSON: String, family: WidgetFamily) -> VoltraNode { + guard let data = resolvedJSON.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let familyContent = root[familyKey(family)] ?? root["systemSmall"], + let familyData = try? JSONSerialization.data(withJSONObject: familyContent), + let familyStr = String(data: familyData, encoding: .utf8), + let jsonValue = try? JSONValue.parse(from: familyStr) + else { return .empty } + + return VoltraNode.parse(from: jsonValue) + } + + private func familyKey(_ family: WidgetFamily) -> String { + switch family { + case .systemSmall: return "systemSmall" + case .systemMedium: return "systemMedium" + case .systemLarge: return "systemLarge" + default: return "systemSmall" + } + } + + private func jsRenderingMode(_ mode: WidgetRenderingMode) -> String { + switch mode { + case .accented: return "accented" + case .vibrant: return "vibrant" + default: return "fullColor" + } + } + + private func voltraRenderingMode(_ mode: WidgetRenderingMode) -> VoltraWidgetRenderingMode { + switch mode { + case .accented: return .accented + case .vibrant: return .vibrant + default: return .fullColor + } + } +} + +private struct ReactiveWidgetContainerBackground: ViewModifier { + func body(content: Content) -> some View { + if #available(iOSApplicationExtension 17.0, *) { + content.containerBackground(.clear, for: .widget) + } else { + content + } + } +} + +// MARK: - Widget definition + +@available(iOS 17.0, *) +public struct VoltraWidget_reactive: Widget { + public init() {} + + public var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: "Voltra_Widget_reactive", + intent: ReactiveWidgetIntent.self, + provider: ReactiveWidgetProvider() + ) { entry in + ReactiveWidgetView(entry: entry) + } + .configurationDisplayName("Reactive Widget") + .description("JS-in-extension PoC: AppIntent + dark/light mode via on-device JS resolver") + .supportedFamilies([.systemSmall, .systemMedium]) + .contentMarginsDisabled() + } +} \ No newline at end of file From 01518d31e909aba64e81beeb096624fcfe9ce655 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 27 May 2026 14:06:47 +0200 Subject: [PATCH 06/11] feat(expo-plugin): generate AppIntent widget Swift from config (Track 2 automation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the config plugin so declaring appIntent in app.json produces a fully working iOS 17+ AppIntentConfiguration widget — no Swift required from the developer. - types: add AppIntentParameter, WidgetAppIntentConfig, appIntent on WidgetConfig, bundleResources on WidgetFiles - swift.ts: generate Intent struct, entry, provider, reactive view, and widget struct for each appIntent widget; bundle them in VoltraWidgetBundle behind @available(iOS 17.0, *) - fileDiscovery: collect .js files into bundleResources - buildPhases: include bundleResources in Xcode Resources phase - files/index.ts: copy ios-renderer.js bundle to extension target when appIntent widgets present - ios-renderer: add ./bundle/ios-renderer.js subpath export for plugin resolution - example: add reactive widget with city AppIntent param + initial state file --- example/app.json | 20 ++ .../ios/ios-reactive-weather-initial.tsx | 8 + .../expo-plugin/src/ios-widget/files/index.ts | 28 +++ .../expo-plugin/src/ios-widget/files/swift.ts | 194 +++++++++++++++++- .../src/ios-widget/xcode/buildPhases.ts | 8 +- packages/ios-client/expo-plugin/src/types.ts | 26 +++ .../expo-plugin/src/utils/fileDiscovery.ts | 4 + packages/ios-renderer/package.json | 1 + 8 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 example/widgets/ios/ios-reactive-weather-initial.tsx diff --git a/example/app.json b/example/app.json index 752d545d..a85ab92e 100644 --- a/example/app.json +++ b/example/app.json @@ -66,6 +66,26 @@ "intervalMinutes": 15, "refresh": true } + }, + { + "id": "reactive", + "displayName": { + "en": "Reactive Weather Widget" + }, + "description": { + "en": "Track 2 PoC: city name and colors resolved on-device via AppIntent + JS renderer" + }, + "supportedFamilies": ["systemSmall", "systemMedium"], + "initialStatePath": "./widgets/ios/ios-reactive-weather-initial.tsx", + "appIntent": { + "parameters": [ + { + "name": "city", + "title": "City", + "default": "New York" + } + ] + } } ], "fonts": [ diff --git a/example/widgets/ios/ios-reactive-weather-initial.tsx b/example/widgets/ios/ios-reactive-weather-initial.tsx new file mode 100644 index 00000000..41a95e85 --- /dev/null +++ b/example/widgets/ios/ios-reactive-weather-initial.tsx @@ -0,0 +1,8 @@ +import React from 'react' + +import { IosReactiveWeatherWidget } from './IosReactiveWeatherWidget' + +export default { + systemSmall: , + systemMedium: , +} \ No newline at end of file diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts index 1a59927c..01eeffc6 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts @@ -3,6 +3,7 @@ import * as fs from 'fs' import * as path from 'path' import type { IOSWidgetConfig } from '../../types' +import { logger } from '../../utils/logger' import { generateAssets } from './assets' import { generateEntitlements } from './entitlements' import { generateInfoPlist } from './infoPlist' @@ -29,6 +30,28 @@ export interface GenerateWidgetExtensionFilesProps { * * This should run before configureXcodeProject so the files exist when Xcode project is configured. */ +function copyRendererBundle(targetPath: string, projectRoot: string): void { + const dest = path.join(targetPath, 'ios-renderer.js') + const candidates = [ + // Standard npm install location + path.join(projectRoot, 'node_modules', '@use-voltra', 'ios-renderer', 'bundle', 'ios-renderer.js'), + // Monorepo workspace (packages/ sibling) + path.join(projectRoot, '..', '..', 'packages', 'ios-renderer', 'bundle', 'ios-renderer.js'), + ] + + for (const src of candidates) { + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest) + logger.info('Copied ios-renderer.js to widget extension target') + return + } + } + + logger.warn( + 'ios-renderer.js not found — run `npm run build:bundle -w @use-voltra/ios-renderer` then re-run prebuild' + ) +} + export const generateWidgetExtensionFiles: ConfigPlugin = (config, props) => { const { targetName, widgets, groupIdentifier, keychainGroup, version, buildNumber } = props @@ -53,6 +76,11 @@ export const generateWidgetExtensionFiles: ConfigPlugin w.appIntent)) { + copyRendererBundle(targetPath, projectRoot) + } + // Generate Swift files (widget bundle, initial states) await generateSwiftFiles({ targetPath, diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts index 2fef4df8..59b7c07e 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts @@ -12,7 +12,7 @@ import { } from '@use-voltra/expo-plugin' import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants' -import type { IOSWidgetConfig } from '../../types' +import type { AppIntentParameter, IOSWidgetConfig } from '../../types' import { VOLTRA_WIDGET_STRINGS_BASENAME } from '../../utils/fileDiscovery' export interface GenerateSwiftFilesOptions { @@ -304,14 +304,30 @@ function generateWidgetStruct(widget: IOSWidgetConfig): string { * Generates the VoltraWidgetBundle.swift file content with configured widgets */ function generateWidgetBundleSwift(widgets: IOSWidgetConfig[]): string { - // Generate widget structs - const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n') + const staticWidgets = widgets.filter((w) => !w.appIntent) + const appIntentWidgets = widgets.filter((w) => w.appIntent) + + const staticStructs = staticWidgets.map(generateWidgetStruct).join('\n\n') + const appIntentStructs = appIntentWidgets.map(generateAppIntentWidgetCode).join('\n\n') + const widgetStructs = [staticStructs, appIntentStructs].filter(Boolean).join('\n\n') + + // Static widgets are instantiated directly; AppIntent widgets are wrapped in @available + const staticInstances = staticWidgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ') + const appIntentInstances = appIntentWidgets.length > 0 + ? dedent` + // AppIntent widgets (iOS 17+) + if #available(iOS 17.0, *) { + ${appIntentWidgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ')} + } + ` + : '' - // Generate widget bundle body entries - const widgetInstances = widgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ') + const widgetInstances = [staticInstances, appIntentInstances].filter(Boolean).join('\n\n ') const needsFoundation = widgets.some(widgetUsesGalleryLocalization) + const needsAppIntents = appIntentWidgets.length > 0 const foundationImport = needsFoundation ? 'import Foundation\n' : '' + const appIntentsImport = needsAppIntents ? 'import AppIntents\n' : '' return dedent` // @@ -321,7 +337,7 @@ function generateWidgetBundleSwift(widgets: IOSWidgetConfig[]): string { // This file defines which Voltra widgets are available in your app. // - ${foundationImport}import SwiftUI + ${foundationImport}${appIntentsImport}import SwiftUI import WidgetKit import VoltraWidget @@ -471,6 +487,172 @@ function getSwiftRawStringDelimiter(str: string): string { return '#'.repeat(maxHashes + 1) } +// ============================================================================ +// AppIntent Widget Code Generation +// ============================================================================ + +function swiftParamDefault(param: AppIntentParameter): string { + return param.default !== undefined ? `"${escapeForSwiftStringLiteral(param.default)}"` : '""' +} + +function generateAppIntentStruct(widget: WidgetConfig): string { + const params = widget.appIntent!.parameters + const intentName = `VoltraWidget_${widget.id}_Intent` + const intentTitle = escapeForSwiftStringLiteral( + `Configure ${widgetLabelEnglish(widget.displayName)}` + ) + + const paramDecls = params + .map((p) => ` @Parameter(title: "${escapeForSwiftStringLiteral(p.title)}", default: ${swiftParamDefault(p)})\n var ${p.name}: String`) + .join('\n\n') + + const initParams = params.map((p) => `${p.name}: String`).join(', ') + const initBody = params.map((p) => ` self.${p.name} = ${p.name}`).join('\n') + + return dedent` + @available(iOS 17.0, *) + private struct ${intentName}: AppIntent { + static var title: LocalizedStringResource = "${intentTitle}" + + ${paramDecls} + + init() {} + init(${initParams}) { + ${initBody} + } + } + ` +} + +function generateAppIntentEntry(widget: WidgetConfig): string { + return dedent` + @available(iOS 17.0, *) + private struct VoltraWidget_${widget.id}_Entry: TimelineEntry { + let date: Date + let rawPayloadJSON: String + let appIntentParams: [String: String] + } + ` +} + +function generateAppIntentProvider(widget: WidgetConfig): string { + const params = widget.appIntent!.parameters + const intentName = `VoltraWidget_${widget.id}_Intent` + const entryName = `VoltraWidget_${widget.id}_Entry` + + const paramsDict = params.map((p) => `"${p.name}": configuration.${p.name}`).join(', ') + const emptyDict = params.map((p) => `"${p.name}": ${swiftParamDefault(p)}`).join(', ') + + return dedent` + @available(iOS 17.0, *) + private struct VoltraWidget_${widget.id}_Provider: AppIntentTimelineProvider { + typealias Intent = ${intentName} + typealias Entry = ${entryName} + + private let widgetId = "${widget.id}" + private var initialJSON: String { + VoltraWidgetInitialStates.getInitialState(for: widgetId).flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + } + + func placeholder(in _: Context) -> ${entryName} { + ${entryName}(date: Date(), rawPayloadJSON: initialJSON, appIntentParams: [${emptyDict}]) + } + + func snapshot(for configuration: ${intentName}, in _: Context) async -> ${entryName} { + ${entryName}(date: Date(), rawPayloadJSON: initialJSON, appIntentParams: [${paramsDict}]) + } + + func timeline(for configuration: ${intentName}, in _: Context) async -> Timeline<${entryName}> { + let entry = ${entryName}(date: Date(), rawPayloadJSON: initialJSON, appIntentParams: [${paramsDict}]) + return Timeline(entries: [entry], policy: .never) + } + } + ` +} + +function generateAppIntentView(widget: WidgetConfig): string { + const entryName = `VoltraWidget_${widget.id}_Entry` + + return dedent` + @available(iOS 17.0, *) + private struct VoltraWidget_${widget.id}_ReactiveView: View { + let entry: ${entryName} + + @Environment(\\.colorScheme) private var colorScheme + @Environment(\\.widgetRenderingMode) private var widgetRenderingMode + @Environment(\\.widgetFamily) private var widgetFamily + @Environment(\\.showsWidgetContainerBackground) private var showsWidgetContainerBackground + + var body: some View { + let resolvedJSON = VoltraJSRenderer.resolve( + payloadJSON: entry.rawPayloadJSON, + colorScheme: colorScheme == .dark ? "dark" : "light", + widgetRenderingMode: VoltraReactiveRenderer.jsRenderingMode(widgetRenderingMode), + appIntentParams: entry.appIntentParams + ) ?? entry.rawPayloadJSON + + let rootNode = VoltraReactiveRenderer.extractNode(from: resolvedJSON, family: widgetFamily) + + Voltra( + root: rootNode, + activityId: "${widget.id}", + widget: VoltraWidgetEnvironment( + isHomeScreenWidget: true, + renderingMode: VoltraReactiveRenderer.voltraRenderingMode(widgetRenderingMode), + showsContainerBackground: showsWidgetContainerBackground + ) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .modifier(VoltraReactiveContainerBackground()) + } + } + ` +} + +function generateAppIntentWidgetStruct(widget: WidgetConfig): string { + const families = widget.supportedFamilies ?? DEFAULT_WIDGET_FAMILIES + const familiesSwift = families.map((f) => WIDGET_FAMILY_MAP[f]).join(', ') + const displayNameExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'displayName', widget.displayName) + const descriptionExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'description', widget.description) + + return dedent` + @available(iOS 17.0, *) + public struct VoltraWidget_${widget.id}: Widget { + public init() {} + + public var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: "Voltra_Widget_${widget.id}", + intent: VoltraWidget_${widget.id}_Intent.self, + provider: VoltraWidget_${widget.id}_Provider() + ) { entry in + VoltraWidget_${widget.id}_ReactiveView(entry: entry) + } + .configurationDisplayName(${displayNameExpr}) + .description(${descriptionExpr}) + .supportedFamilies([${familiesSwift}]) + .contentMarginsDisabled() + } + } + ` +} + +/** + * Generates the full AppIntent widget Swift code for a widget with appIntent config: + * Intent struct, timeline entry, provider, view, and widget struct. + */ +function generateAppIntentWidgetCode(widget: WidgetConfig): string { + return [ + `// MARK: - AppIntent widget: ${widget.id}`, + generateAppIntentStruct(widget), + generateAppIntentEntry(widget), + generateAppIntentProvider(widget), + generateAppIntentView(widget), + generateAppIntentWidgetStruct(widget), + ].join('\n\n') +} + export const __test__ = { generateInitialStatesSwift, + generateAppIntentWidgetCode, } diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts index da554df2..7e6bd4a0 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts @@ -29,8 +29,8 @@ export function addBuildPhases(xcodeProject: XcodeProject, options: AddBuildPhas const buildPath = `""` const folderType = 'app_extension' - const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles - const resourcePaths = [...assetDirectories, ...localizedStringResources] + const { swiftFiles, intentFiles, assetDirectories, localizedStringResources, bundleResources } = widgetFiles + const resourcePaths = [...assetDirectories, ...localizedStringResources, ...bundleResources] // Sources build phase xcodeProject.addBuildPhase( @@ -74,8 +74,8 @@ export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBui const folderType = 'app_extension' const mainTargetUuid = options.mainTargetUuid ?? xcodeProject.getFirstTarget().uuid - const { swiftFiles, intentFiles, assetDirectories, localizedStringResources } = widgetFiles - const resourcePaths = [...assetDirectories, ...localizedStringResources] + const { swiftFiles, intentFiles, assetDirectories, localizedStringResources, bundleResources } = widgetFiles + const resourcePaths = [...assetDirectories, ...localizedStringResources, ...bundleResources] dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXSourcesBuildPhase', 'Sources') dedupeBuildPhasesForTarget(xcodeProject, targetUuid, 'PBXFrameworksBuildPhase', 'Frameworks') diff --git a/packages/ios-client/expo-plugin/src/types.ts b/packages/ios-client/expo-plugin/src/types.ts index 26f85ce3..42bdd715 100644 --- a/packages/ios-client/expo-plugin/src/types.ts +++ b/packages/ios-client/expo-plugin/src/types.ts @@ -14,6 +14,26 @@ export type IOSWidgetFamily = | 'accessoryRectangular' | 'accessoryInline' +/** + * A single user-configurable parameter exposed via AppIntent. + */ +export interface AppIntentParameter { + /** Swift property name and key used in appIntentParam() template expressions */ + name: string + /** Label shown to the user in the widget configuration sheet */ + title: string + /** Default value used before the user configures the widget */ + default?: string +} + +/** + * AppIntent configuration for a reactive widget (iOS 17+). + */ +export interface IOSWidgetAppIntentConfig { + /** Parameters the user can configure in the widget gallery. */ + parameters: AppIntentParameter[] +} + /** * Configuration for a single iOS home screen widget. */ @@ -28,6 +48,12 @@ export interface IOSWidgetConfig { supportedFamilies?: IOSWidgetFamily[] initialStatePath?: WidgetInitialStatePath serverUpdate?: IOSWidgetServerUpdateConfig + /** + * AppIntent configuration for user-configurable widgets (iOS 17+). + * When set, the plugin generates an AppIntentConfiguration widget so users can + * configure parameters directly in the widget gallery — no server push required. + */ + appIntent?: IOSWidgetAppIntentConfig } /** diff --git a/packages/ios-client/expo-plugin/src/utils/fileDiscovery.ts b/packages/ios-client/expo-plugin/src/utils/fileDiscovery.ts index b25ad50a..d6737d22 100644 --- a/packages/ios-client/expo-plugin/src/utils/fileDiscovery.ts +++ b/packages/ios-client/expo-plugin/src/utils/fileDiscovery.ts @@ -25,6 +25,7 @@ export function getIOSWidgetExtensionFiles(targetPath: string, targetName: strin assetDirectories: [], intentFiles: [], localizedStringResources: [], + bundleResources: [], } if (!fs.existsSync(targetPath)) { @@ -77,6 +78,9 @@ export function getIOSWidgetExtensionFiles(targetPath: string, targetName: strin case '.intentdefinition': widgetFiles.intentFiles.push(file) break + case '.js': + widgetFiles.bundleResources.push(file) + break } } diff --git a/packages/ios-renderer/package.json b/packages/ios-renderer/package.json index 2d2d5587..245a34c3 100644 --- a/packages/ios-renderer/package.json +++ b/packages/ios-renderer/package.json @@ -12,6 +12,7 @@ "import": "./build/esm/index.js", "default": "./build/esm/index.js" }, + "./bundle/ios-renderer.js": "./bundle/ios-renderer.js", "./package.json": "./package.json" }, "files": [ From 39c62970ac5153ff1d70f5bba708bcf5eead62eb Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 28 May 2026 07:26:56 +0200 Subject: [PATCH 07/11] fix: export appIntentParam from main voltra entry; fix ios-renderer bundle path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - voltra/src/index.ts: export appIntentParam so widget JSX components can import it from 'voltra' (not just 'voltra/server') — needed for prebuild prerendering - expo-plugin files/index.ts: fix monorepo bundle search path (one level up from project root, not two); add workspace root node_modules as candidate location --- .../ios-client/expo-plugin/src/ios-widget/files/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts index 01eeffc6..fa8aa7a1 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts @@ -33,10 +33,12 @@ export interface GenerateWidgetExtensionFilesProps { function copyRendererBundle(targetPath: string, projectRoot: string): void { const dest = path.join(targetPath, 'ios-renderer.js') const candidates = [ - // Standard npm install location + // Standard npm install in the project path.join(projectRoot, 'node_modules', '@use-voltra', 'ios-renderer', 'bundle', 'ios-renderer.js'), - // Monorepo workspace (packages/ sibling) - path.join(projectRoot, '..', '..', 'packages', 'ios-renderer', 'bundle', 'ios-renderer.js'), + // Monorepo: workspace root node_modules (one level above the app) + path.join(projectRoot, '..', 'node_modules', '@use-voltra', 'ios-renderer', 'bundle', 'ios-renderer.js'), + // Monorepo: packages/ built locally (bundle/ output) + path.join(projectRoot, '..', 'packages', 'ios-renderer', 'bundle', 'ios-renderer.js'), ] for (const src of candidates) { From ffa83c355f0df5b444ec58241aa6457905c4d264 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 28 May 2026 08:11:46 +0200 Subject: [PATCH 08/11] fix: resolve Xcode build errors in generated AppIntent widget code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WidgetConfigurationIntent instead of AppIntent for the intent struct (AppIntentTimelineProvider requires WidgetConfigurationIntent) - Fix @Environment key paths — dedent uses raw template strings so \\. produced double-backslash in output; use kp() interpolation instead - Add @available(iOS 17.0, *) to VoltraReactiveContainerBackground - Include bundleResources in ensurePbxGroup (was missing; addPbxGroup already had it) --- .../expo-plugin/src/ios-widget/files/swift.ts | 13 ++++++++----- .../expo-plugin/src/ios-widget/xcode/groups.ts | 6 ++++-- .../ios/shared/VoltraReactiveSupport.swift | 11 ++++------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts index 59b7c07e..728254e9 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts @@ -511,7 +511,7 @@ function generateAppIntentStruct(widget: WidgetConfig): string { return dedent` @available(iOS 17.0, *) - private struct ${intentName}: AppIntent { + private struct ${intentName}: WidgetConfigurationIntent { static var title: LocalizedStringResource = "${intentTitle}" ${paramDecls} @@ -572,16 +572,19 @@ function generateAppIntentProvider(widget: WidgetConfig): string { function generateAppIntentView(widget: WidgetConfig): string { const entryName = `VoltraWidget_${widget.id}_Entry` + // Use a variable so the backslash is a string value, not a raw template character. + // dedent processes raw template strings, so `\\.` in source → `\\.` in output (double backslash). + const kp = (key: string) => `\\.${key}` return dedent` @available(iOS 17.0, *) private struct VoltraWidget_${widget.id}_ReactiveView: View { let entry: ${entryName} - @Environment(\\.colorScheme) private var colorScheme - @Environment(\\.widgetRenderingMode) private var widgetRenderingMode - @Environment(\\.widgetFamily) private var widgetFamily - @Environment(\\.showsWidgetContainerBackground) private var showsWidgetContainerBackground + @Environment(${kp('colorScheme')}) private var colorScheme + @Environment(${kp('widgetRenderingMode')}) private var widgetRenderingMode + @Environment(${kp('widgetFamily')}) private var widgetFamily + @Environment(${kp('showsWidgetContainerBackground')}) private var showsWidgetContainerBackground var body: some View { let resolvedJSON = VoltraJSRenderer.resolve( diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts index f5f76a24..d61aa6d6 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/groups.ts @@ -14,7 +14,7 @@ export interface AddPbxGroupOptions { */ export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void { const { targetName, widgetFiles } = options - const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles, localizedStringResources } = + const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles, localizedStringResources, bundleResources } = widgetFiles // Add PBX group with all widget files @@ -26,6 +26,7 @@ export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOpti ...plistFiles, ...assetDirectories, ...localizedStringResources, + ...bundleResources, ], targetName, targetName @@ -47,7 +48,7 @@ export function addPbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOpti */ export function ensurePbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupOptions): void { const { targetName, widgetFiles } = options - const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles, localizedStringResources } = + const { swiftFiles, intentFiles, assetDirectories, entitlementFiles, plistFiles, localizedStringResources, bundleResources } = widgetFiles const allFiles = [ ...swiftFiles, @@ -56,6 +57,7 @@ export function ensurePbxGroup(xcodeProject: XcodeProject, options: AddPbxGroupO ...plistFiles, ...assetDirectories, ...localizedStringResources, + ...bundleResources, ] const existingGroup = xcodeProject.pbxGroupByName(targetName) diff --git a/packages/ios-client/ios/shared/VoltraReactiveSupport.swift b/packages/ios-client/ios/shared/VoltraReactiveSupport.swift index 7a057c36..2a12cb90 100644 --- a/packages/ios-client/ios/shared/VoltraReactiveSupport.swift +++ b/packages/ios-client/ios/shared/VoltraReactiveSupport.swift @@ -46,16 +46,13 @@ public enum VoltraReactiveRenderer { } } -/// Applies `.containerBackground(.clear, for: .widget)` on iOS 17+ extension targets. -/// Used by all generated AppIntentConfiguration widget views. +/// Applies `.containerBackground(.clear, for: .widget)`. +/// Used by all generated AppIntentConfiguration widget views (always iOS 17+). +@available(iOS 17.0, *) public struct VoltraReactiveContainerBackground: ViewModifier { public init() {} public func body(content: Content) -> some View { - if #available(iOSApplicationExtension 17.0, *) { - content.containerBackground(.clear, for: .widget) - } else { - content - } + content.containerBackground(.clear, for: .widget) } } \ No newline at end of file From b2e6043324b50cc6a4c527dd61b6be0a60fe4830 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 28 May 2026 09:04:01 +0200 Subject: [PATCH 09/11] fix: resolve widget rendering and AppIntent configuration issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `private` from generated Intent struct — AppIntents metadata extraction cannot register private types, leaving the widget stuck in placeholder state indefinitely - Fix extractNode to carry shared stylesheet ("s") and elements ("e") from the payload root into the family-specific node so VoltraNode.parse can resolve style index references (mirrors VoltraHomeWidget behaviour) --- .../expo-plugin/src/ios-widget/files/swift.ts | 2 +- .../ios-client/ios/shared/VoltraReactiveSupport.swift | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts index 728254e9..c2b1fe6f 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts @@ -511,7 +511,7 @@ function generateAppIntentStruct(widget: WidgetConfig): string { return dedent` @available(iOS 17.0, *) - private struct ${intentName}: WidgetConfigurationIntent { + struct ${intentName}: WidgetConfigurationIntent { static var title: LocalizedStringResource = "${intentTitle}" ${paramDecls} diff --git a/packages/ios-client/ios/shared/VoltraReactiveSupport.swift b/packages/ios-client/ios/shared/VoltraReactiveSupport.swift index 2a12cb90..b8c46b9d 100644 --- a/packages/ios-client/ios/shared/VoltraReactiveSupport.swift +++ b/packages/ios-client/ios/shared/VoltraReactiveSupport.swift @@ -12,7 +12,16 @@ public enum VoltraReactiveRenderer { guard let data = resolvedJSON.data(using: .utf8), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let familyContent = root[familyKey(family)] ?? root["systemSmall"], - let familyData = try? JSONSerialization.data(withJSONObject: familyContent), + var reconstructed = familyContent as? [String: Any] + else { return .empty } + + // Mirror VoltraHomeWidget.reconstructWithSharedData: copy the shared stylesheet ("s") + // and shared elements ("e") into the family-specific object so VoltraNode.parse can + // resolve style index references and element deduplication. + if let stylesheet = root["s"] { reconstructed["s"] = stylesheet } + if let elements = root["e"] { reconstructed["e"] = elements } + + guard let familyData = try? JSONSerialization.data(withJSONObject: reconstructed), let familyStr = String(data: familyData, encoding: .utf8), let jsonValue = try? JSONValue.parse(from: familyStr) else { return .empty } From d6bef6716573f034dbb283d8adffa13a91dbefff Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Fri, 29 May 2026 11:05:34 +0200 Subject: [PATCH 10/11] feat(ios): support light-dark() text colors via adaptive ShapeStyle for WidgetKit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds native dark/light mode adaptation for light-dark() color strings in VoltraText. The JS resolver no longer resolves light-dark() strings — it passes them through unchanged so Swift can handle adaptation at draw time. On the Swift side: - JSColorParser gains parseLightDarkComponents() which splits the string into separate light/dark Color values - TextStyle gains a lightDarkColors field for the parsed pair - StyleConverter.parseText() routes light-dark() strings to lightDarkColors instead of the flat color field - LightDarkForeground: ShapeStyle resolves to the correct color in resolve(in: EnvironmentValues), called by the rendering engine at draw time - VoltraText uses .foregroundStyle(LightDarkForeground(...)) when lightDarkColors is set, falling back to the existing resolvedColor path otherwise Note: iOS simulator does not re-render widgets on appearance toggle (confirmed by native Calendar widget also being unaffected). Real device verification required. --- .../ios/ui/Style/JSColorParser.swift | 60 ++++++++++++++++++- .../ios/ui/Style/StyleConverter.swift | 9 ++- .../ios-client/ios/ui/Style/TextStyle.swift | 14 +++++ .../ios-client/ios/ui/Views/VoltraText.swift | 8 ++- packages/ios-renderer/src/index.ts | 9 ++- 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/packages/ios-client/ios/ui/Style/JSColorParser.swift b/packages/ios-client/ios/ui/Style/JSColorParser.swift index 7407d769..402f2bdc 100644 --- a/packages/ios-client/ios/ui/Style/JSColorParser.swift +++ b/packages/ios-client/ios/ui/Style/JSColorParser.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit enum JSColorParser { /// Parses Hex, RGB, RGBA, HSL, HSLA, and named color strings into SwiftUI Color. @@ -10,7 +11,12 @@ enum JSColorParser { if trimmed.isEmpty { return nil } - // 1. Hex (with or without #) + // 1. light-dark() — adaptive color, resolved natively by UIKit trait system + if trimmed.hasPrefix("light-dark(") { + return parseLightDark(trimmed) + } + + // 2. Hex (with or without #) if trimmed.hasPrefix("#") { return parseHex(trimmed) } @@ -187,6 +193,58 @@ enum JSColorParser { } } + // 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() + /// + @Environment(\.colorScheme) 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 } + 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) + }) + } + // MARK: - Hex Parser /// Supports #RGB, #RGBA, #RRGGBB, #RRGGBBAA diff --git a/packages/ios-client/ios/ui/Style/StyleConverter.swift b/packages/ios-client/ios/ui/Style/StyleConverter.swift index cabacce6..c9705446 100644 --- a/packages/ios-client/ios/ui/Style/StyleConverter.swift +++ b/packages/ios-client/ios/ui/Style/StyleConverter.swift @@ -153,9 +153,14 @@ 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..604c3839 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)? = nil 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-renderer/src/index.ts b/packages/ios-renderer/src/index.ts index a44adc7b..5e32829a 100644 --- a/packages/ios-renderer/src/index.ts +++ b/packages/ios-renderer/src/index.ts @@ -8,8 +8,11 @@ export type DeviceState = { export type AppIntentParams = Record; -// Keys whose values are never traversed for resolution -const PASSTHROUGH_KEYS = new Set(['v', 's', 'e']); +// Keys whose values are never traversed for resolution. +// 'v' (version) and 'e' (shared elements / $r refs) are structural — leave untouched. +// 's' (shared stylesheet) is NOT a passthrough: it contains light-dark() color strings +// that must be resolved against the current colorScheme. +const PASSTHROUGH_KEYS = new Set(['v', 'e']); /** * Resolves the CSS light-dark(, ) function against the current color scheme. @@ -55,7 +58,7 @@ function resolveTemplate(value: string, appIntentParams: AppIntentParams): strin } function resolveString(value: string, deviceState: DeviceState, appIntentParams: AppIntentParams): string { - return resolveTemplate(resolveLightDark(value, deviceState.colorScheme), appIntentParams); + return resolveTemplate(value, appIntentParams); } function resolveValue(value: unknown, deviceState: DeviceState, appIntentParams: AppIntentParams): unknown { From 1e6b37444c569e677c6a5544a9436c48d966e154 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Fri, 29 May 2026 11:49:18 +0200 Subject: [PATCH 11/11] =?UTF-8?q?refactor(track-2):=20remove=20light-dark(?= =?UTF-8?q?)=20handling=20=E2=80=94=20moved=20to=20Track=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit light-dark() color adaptation is a standalone rendering concern that belongs on Track 1 (server-driven rendering improvements), not Track 2 (AppIntent reactivity). Keeping it here conflated two independent features. - Revert Swift changes from 82da057: JSColorParser, StyleConverter, TextStyle, VoltraText — light-dark() strings now pass through parse() as unrecognised and fall back to the default color - index.ts unchanged: PASSTHROUGH_KEYS without 's', resolveString without resolveLightDark — correct for AppIntent-only resolution - Example widget updated to use 'primary' semantic color --- .../widgets/ios/IosReactiveWeatherWidget.tsx | 13 ++-- .../ios/ui/Style/JSColorParser.swift | 60 +------------------ .../ios/ui/Style/StyleConverter.swift | 9 +-- .../ios-client/ios/ui/Style/TextStyle.swift | 14 ----- .../ios-client/ios/ui/Views/VoltraText.swift | 8 +-- 5 files changed, 11 insertions(+), 93 deletions(-) diff --git a/example/widgets/ios/IosReactiveWeatherWidget.tsx b/example/widgets/ios/IosReactiveWeatherWidget.tsx index e0c6ae0d..60bed9ad 100644 --- a/example/widgets/ios/IosReactiveWeatherWidget.tsx +++ b/example/widgets/ios/IosReactiveWeatherWidget.tsx @@ -1,13 +1,12 @@ import { Voltra, appIntentParam } from 'voltra' -// Track 2 PoC — demonstrates reactive widget rendering: +// Track 2 PoC — demonstrates AppIntent reactivity: // - appIntentParam('city'): user-configurable city name via iOS widget settings -// - light-dark() colors: resolved to the device color scheme inside the extension // // The server renders this JSX to a compact JSON payload that includes the raw // template expressions. At render time inside the widget extension, VoltraJSRenderer -// resolves them against the current device state — no server push required when -// the user reconfigures the widget or switches dark/light mode. +// resolves them against the current AppIntent parameters — no server push required +// when the user reconfigures the widget. export const IosReactiveWeatherWidget = () => ( @@ -15,7 +14,7 @@ export const IosReactiveWeatherWidget = () => ( style={{ fontSize: 22, fontWeight: '700', - color: 'light-dark(#111111, #eeeeee)', + color: 'primary', }} > {appIntentParam('city')} @@ -23,7 +22,7 @@ export const IosReactiveWeatherWidget = () => ( @@ -32,7 +31,7 @@ export const IosReactiveWeatherWidget = () => ( diff --git a/packages/ios-client/ios/ui/Style/JSColorParser.swift b/packages/ios-client/ios/ui/Style/JSColorParser.swift index 402f2bdc..7407d769 100644 --- a/packages/ios-client/ios/ui/Style/JSColorParser.swift +++ b/packages/ios-client/ios/ui/Style/JSColorParser.swift @@ -1,5 +1,4 @@ import SwiftUI -import UIKit enum JSColorParser { /// Parses Hex, RGB, RGBA, HSL, HSLA, and named color strings into SwiftUI Color. @@ -11,12 +10,7 @@ enum JSColorParser { if trimmed.isEmpty { return nil } - // 1. light-dark() — adaptive color, resolved natively by UIKit trait system - if trimmed.hasPrefix("light-dark(") { - return parseLightDark(trimmed) - } - - // 2. Hex (with or without #) + // 1. Hex (with or without #) if trimmed.hasPrefix("#") { return parseHex(trimmed) } @@ -193,58 +187,6 @@ enum JSColorParser { } } - // 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() - /// + @Environment(\.colorScheme) 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 } - 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) - }) - } - // MARK: - Hex Parser /// Supports #RGB, #RGBA, #RRGGBB, #RRGGBBAA diff --git a/packages/ios-client/ios/ui/Style/StyleConverter.swift b/packages/ios-client/ios/ui/Style/StyleConverter.swift index c9705446..cabacce6 100644 --- a/packages/ios-client/ios/ui/Style/StyleConverter.swift +++ b/packages/ios-client/ios/ui/Style/StyleConverter.swift @@ -153,14 +153,9 @@ enum StyleConverter { private static func parseText(_ js: [String: Any]) -> TextStyle { var style = TextStyle() - 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) { + if let color = JSColorParser.parse(js["color"]) { style.color = color - style.usesPrimaryColorInReducedPresentation = JSColorParser.shouldUsePrimaryColorInReducedPresentation(colorValue) + style.usesPrimaryColorInReducedPresentation = JSColorParser.shouldUsePrimaryColorInReducedPresentation(js["color"]) } 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 604c3839..c00091e7 100644 --- a/packages/ios-client/ios/ui/Style/TextStyle.swift +++ b/packages/ios-client/ios/ui/Style/TextStyle.swift @@ -2,7 +2,6 @@ import SwiftUI struct TextStyle { var color: Color = .primary - var lightDarkColors: (light: Color, dark: Color)? = nil var usesPrimaryColorInReducedPresentation = false var fontSize: CGFloat = 17 var fontWeight: Font.Weight = .regular @@ -16,19 +15,6 @@ 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 5a7fd846..a532b393 100644 --- a/packages/ios-client/ios/ui/Views/VoltraText.swift +++ b/packages/ios-client/ios/ui/Views/VoltraText.swift @@ -74,13 +74,9 @@ 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) - .foregroundStyle({ - if let ld = textStyle.lightDarkColors { - return AnyShapeStyle(LightDarkForeground(light: ld.light, dark: ld.dark)) - } - return AnyShapeStyle(resolvedColor) - }()) + .foregroundColor(resolvedColor) .multilineTextAlignment(alignment) .lineSpacing(textStyle.lineSpacing) .voltraIfLet(params.numberOfLines) { view, numberOfLines in