# SwiftBrowser documentation > Run SwiftUI apps in the browser: an iOS app compiles unchanged to WebAssembly against an API-compatible SwiftUI shim and runs inside an iPhone frame, or full screen on a phone. Every page of https://swiftbrowser-docs.pages.dev, generated from https://github.com/noya-app/SwiftBrowser at `5b9bac10dc2aee05bf74a5242d6896c43409e6dd`. Pages: - Overview (https://swiftbrowser-docs.pages.dev/): What SwiftBrowser is, the four CLI commands, and where everything is documented. - Getting started (https://swiftbrowser-docs.pages.dev/getting-started): From an Xcode project to a deployed static site: doctor, check, dev and build. - Compatibility (https://swiftbrowser-docs.pages.dev/compatibility): The SwiftUI, SwiftData, Foundation and framework APIs the browser build supports, and what is not there yet. - CLI (https://swiftbrowser-docs.pages.dev/cli): The @swiftbrowser/cli commands, swiftbrowser.json, Foundation and ICU data, frameworks, Core ML and Swift packages. - @swiftbrowser/xcodeproj (https://swiftbrowser-docs.pages.dev/xcodeproj): A dependency-free TypeScript reader for Xcode projects: targets, sources, resources and build settings. - Architecture (https://swiftbrowser-docs.pages.dev/architecture): How the SwiftUI shim, the render ops and the TypeScript renderer fit together. - Ops protocol (https://swiftbrowser-docs.pages.dev/ops-protocol): The messages between the Swift runtime and the renderer: ops, events, layout and state snapshots. - Web renderer (https://swiftbrowser-docs.pages.dev/web-renderer): The @swiftbrowser/web renderer, dev server, app registry and static site build. - Contributing (https://swiftbrowser-docs.pages.dev/contributing): Building and testing SwiftBrowser itself, CI, deploying the examples and releasing to npm. - History (https://swiftbrowser-docs.pages.dev/history): What each development phase added, oldest first. --- # SwiftBrowser Run SwiftUI apps in the browser, so you can build and iterate on iOS apps without a Mac for most of the day. An app's source imports `SwiftUI` and compiles unchanged for both iOS and the browser. On Apple platforms it uses Apple's SwiftUI. In the browser it compiles to WebAssembly against the API-compatible shim in `Sources/SwiftUI`, which turns the view tree into render ops that a small TypeScript renderer draws inside an iPhone frame, or full screen on a phone. ```sh npx @swiftbrowser/cli doctor --install # Swift 6.4's wasm SDK and wasm-opt npx @swiftbrowser/cli check ~/Projects/MyApp # what the app imports, and a fast compile against the shim npx @swiftbrowser/cli dev ~/Projects/MyApp # http://localhost:5173, rebuilds on save npx @swiftbrowser/cli build ~/Projects/MyApp # ./dist: a static site to deploy anywhere ``` Eighteen of the nineteen Hacking with SwiftUI projects run from their Xcode projects without a change, and so does a trimmed version of Apple's Food Truck sample (`Examples/FoodTruck`). ## Documentation For app developers: - [Getting started](https://swiftbrowser-docs.pages.dev/getting-started): from an Xcode project to a deployed site. - [CLI reference](https://swiftbrowser-docs.pages.dev/cli): commands, `swiftbrowser.json`, Foundation, frameworks, Core ML, Swift packages. - [Compatibility](https://swiftbrowser-docs.pages.dev/compatibility): the SwiftUI, SwiftData and framework APIs the browser build supports. - [`@swiftbrowser/xcodeproj`](https://swiftbrowser-docs.pages.dev/xcodeproj): the Xcode project reader, usable on its own. How it works: - [Architecture](https://swiftbrowser-docs.pages.dev/architecture) and the [ops protocol](https://swiftbrowser-docs.pages.dev/ops-protocol) between Swift and the renderer. - [The web renderer](https://swiftbrowser-docs.pages.dev/web-renderer) and its dev server. - [History](https://swiftbrowser-docs.pages.dev/history): what each development phase added. Working on SwiftBrowser itself: [CONTRIBUTING.md](https://swiftbrowser-docs.pages.dev/contributing) (local development, CI, deploying the examples, releasing to npm, repository layout). All of it is also published at ([`DocsSite/`](https://github.com/noya-app/SwiftBrowser/blob/main/DocsSite/README.md)), where every page, or the whole site at once, can be copied as Markdown. ## Packages | Package | What it is | |---|---| | [`@swiftbrowser/cli`](https://swiftbrowser-docs.pages.dev/cli) | `doctor`, `check`, `dev` and `build` for an Xcode project or a folder of Swift sources | | [`@swiftbrowser/web`](https://swiftbrowser-docs.pages.dev/web-renderer) | the renderer, the dev server and the static site build | | [`@swiftbrowser/xcodeproj`](https://swiftbrowser-docs.pages.dev/xcodeproj) | a dependency-free `project.pbxproj` reader | | [`@swiftbrowser/swift`](https://github.com/noya-app/SwiftBrowser/blob/main/packages/swift/README.md) | the Swift package (the SwiftUI shim, Charts, SwiftData, FoundationBridge and the framework stand-ins) the CLI builds against | --- # Getting started SwiftBrowser runs an iOS SwiftUI app in the browser from its unchanged sources. The app compiles to WebAssembly against an API-compatible SwiftUI shim and draws inside an iPhone frame on a desktop, or full screen on a phone. You keep building the same project in Xcode for iOS; the browser build is a second target that needs no Mac. This page takes an existing app from zero to a deployed static site. The [CLI reference](https://swiftbrowser-docs.pages.dev/cli) has every option, and [Compatibility](https://swiftbrowser-docs.pages.dev/compatibility) lists what the shim supports. ## Requirements - Node 22 or later. - Swift 6.4, installed with [swiftly](https://www.swift.org/install/) (`swiftly install 6.4.0`), or any 6.4 toolchain whose `usr/bin` is in `SWIFT_TOOLCHAIN_BIN`. - The Swift SDK for WebAssembly and Binaryen's `wasm-opt`. `doctor --install` fetches both. Linux and macOS both work. Nothing is installed globally besides the Swift SDK; Binaryen and cached data live in `~/.cache/swiftbrowser`. ```sh npx @swiftbrowser/cli doctor --install ``` `doctor` without `--install` only reports what is missing. ## 1. Check the app Point the CLI at an `.xcodeproj`, a folder that holds one (directly or one level down), or a plain folder of Swift sources: ```sh npx @swiftbrowser/cli check ~/Projects/MyApp ``` `check` lists every module the app imports as supported, rewritten or unsupported (with the usual way around each), then compiles the app natively against the shim. It takes seconds and builds no WebAssembly. Every error names your file and line. Anything the shim lacks is a compile error, never a wrong rendering at runtime. When a project has several app targets, pick one with `--target `. ## 2. Work around what is missing Code that cannot run in the browser goes behind the `SWIFTBROWSER` compilation condition, which the CLI defines, much like `#if os(iOS)`: ```swift #if SWIFTBROWSER // A stand-in for the browser. Text("Maps are not available in the browser yet") #else Map(position: $position) #endif ``` Xcode never defines `SWIFTBROWSER`, so the iOS build is unaffected. ## 3. Run it with live reload ```sh npx @swiftbrowser/cli dev ~/Projects/MyApp ``` The dev server opens a landing page at with a QR code, and serves the app at `/app/?app=`. Saving a Swift file rebuilds the module and reloads the page with the app's `@State` carried over where the view tree still matches. To try it on a phone, add `--host 0.0.0.0` to expose the server on your network and scan the QR code. On a phone the app drops the iPhone frame and uses the real screen and safe areas; in Safari, Share → Add to Home Screen runs it full screen. Other useful flags: `--port`, `--open`, and `--verbose` for the full compiler output. ## 4. Build a static site ```sh npx @swiftbrowser/cli build ~/Projects/MyApp -o dist ``` `build` makes a release module, optimized and stripped with `wasm-opt`, and a complete static site around it: a landing page, the app page at `/app/?app=`, the asset catalogs and a web app manifest. A small app is about 17 MB, 4.5 MB with brotli. Serve `dist/` from any static host that sends `.wasm` files as `application/wasm` (Cloudflare Pages, Netlify, GitHub Pages and S3 all do). ## What the CLI does to your project Nothing outside `/.swiftbrowser/`, which you should add to `.gitignore`. The CLI reads the Xcode project, copies the app target's sources into a generated SwiftPM package there, adjusts the copies for the shim (keeping line numbers, so diagnostics point at your files) and builds that. See [What happens to your project](https://swiftbrowser-docs.pages.dev/cli#what-happens-to-your-project). ## Configuration Most projects need none. When the Xcode project is not enough, or the app is a plain folder of sources, an optional `swiftbrowser.json` next to it names the sources, resources, asset catalogs, icon, ICU locales and extra compilation conditions. See [swiftbrowser.json](https://swiftbrowser-docs.pages.dev/cli#swiftbrowserjson-optional). ## Data that persists What an iOS app keeps on the device, the browser keeps per app: | On iOS | In the browser | Reset with | |---|---|---| | `UserDefaults`, `@AppStorage` | localStorage | clearing site data | | SwiftData | IndexedDB, `sb-swiftdata:` | `?swiftdata=reset` | | Files in the app's home (`URL.documentsDirectory`, …) | IndexedDB, `sb-files:` | `?files=reset` | ## Next - [CLI reference](https://swiftbrowser-docs.pages.dev/cli): commands, Foundation and ICU data, frameworks, Core ML, Swift packages. - [Compatibility](https://swiftbrowser-docs.pages.dev/compatibility): the SwiftUI and framework APIs that work. - [Architecture](https://swiftbrowser-docs.pages.dev/architecture): how the shim, the render ops and the renderer fit together. --- # Compatibility What the browser build of an app supports today, area by area. Foundation, the other frameworks and Swift packages are covered in more depth in the [CLI reference](https://swiftbrowser-docs.pages.dev/cli). | Area | Supported | Not yet | |------|-----------|---------| | Views | `Text`, `Image(systemName:)`, `Image("asset")`, `VStack`, `HStack`, `ZStack`, `Spacer`, `Divider`, `ScrollView`, `List` (incl. `selection:`), `Section`, `Form`, `ForEach`, `Button`, `TextField`, `SecureField`, `Toggle`, `Picker` (segmented, menu, inline), `Menu`, `TabView`, `GeometryReader`, `Label` (+ `LabelStyle`), `Gauge`, `ProgressView`, `Grid`/`GridRow`, `LazyVGrid`/`LazyHGrid`, `TimelineView`, `ViewThatFits`, custom `Layout`, `Chart` (`Charts` module: bar, line, area, point, rule and rectangle marks, axes, legends), `AsyncImage`, `Image(uiImage:)` and `Image(_:scale:label:)` (a `CGImage`), `Color`, shapes (with `fill`/`stroke`/`strokeBorder`), gradients, `Group`, `AnyView`, `EmptyView`, `Slider`, `Stepper`, `DatePicker`, `TextEditor`, `EditButton`, `ContentUnavailableView`, `ShareLink` (Web Share API, else a download) | `LazyVStack`, `Table`, `Canvas`, `Map` | | Navigation and presentation | `NavigationStack` (destination- and path-based, nested stacks flatten), `NavigationSplitView` (two columns from the regular size class), `NavigationPath`, `NavigationLink` (destination and value), `navigationDestination(for:)`, `navigationTitle`, `navigationBarTitleDisplayMode`, `toolbar`/`ToolbarItem`, `searchable`, `dismiss`, `sheet(isPresented:)`, `sheet(item:)`, `presentationDetents`, `fullScreenCover`, `alert`, `confirmationDialog`, swipe-back | `popover`, `inspector` | | State and concurrency | `@State`, `@Binding`, `@Observable`, `@Bindable`, `@StateObject`, `@ObservedObject`, `@EnvironmentObject`, `ObservableObject`, `@Environment` (including `colorScheme`, `dynamicTypeSize`, `horizontalSizeClass`, `dismiss`, `scenePhase`, `editMode`, the accessibility flags), `@ScaledMetric`, `onChange(of:)`, `Task`, `.task`, `.task(id:)`, `Task.sleep`, clocks, `Timer.publish` + `onReceive`, `Timer.scheduledTimer`, `@FocusState`, `@Environment(Type.self)` + `.environment(_:)`, `@AppStorage` (kept in localStorage) | `@SceneStorage` | | Control flow in `body` | `if`/`else`, `if let`, `switch`, optionals, `ForEach` | `for` | | Modifiers | `padding`, `font`, `bold`, `fontWeight`, `foregroundStyle` (any `ShapeStyle`), `foregroundColor`, `background` (colors, styles, shapes, views), `overlay`, `frame`, `cornerRadius`, `clipShape`, `clipped`, `opacity`, `hidden`, `mask` (gradients and colors), `disabled`, `layoutPriority`, `lineLimit`, `multilineTextAlignment`, `fixedSize`, `aspectRatio`, `offset`, `position`, `rotationEffect`, `scaleEffect`, `shadow`, `badge`, `tint`, `imageScale`, `animation(_:value:)`, `transition`, `buttonStyle`, `textFieldStyle`, `listStyle`, `pickerStyle`, `labelStyle`, `gaugeStyle`, `tabItem`, `tag`, `environment`, `dynamicTypeSize`, `onAppear`, `onTapGesture`, `task`, `gesture` (`DragGesture`, `LongPressGesture`, `TapGesture`), `onLongPressGesture`, `allowsHitTesting`, `swipeActions`, `contextMenu`, `onDelete`, `onMove`, `refreshable`, `rotation3DEffect`, `blur`, `ignoresSafeArea`, `ViewModifier` via `modifier`, `focused`, `onSubmit`, `submitLabel`, `preferredColorScheme`, `scrollTargetBehavior`, `scrollTargetLayout`, `containerRelativeFrame`, `visualEffect` (static) | `MagnifyGesture`, `RotateGesture`, `matchedGeometryEffect`, view masks | | Types | `Font` (text styles scale with Dynamic Type), `Color`, `ShapeStyle` (hierarchical, gradients, materials), `Animation` (`withAnimation`, springs, `repeatCount`/`repeatForever`, `Binding.animation`), `AnyTransition` (incl. asymmetric, `modifier(active:identity:)`), `Alignment`, `EdgeInsets`, `Angle`, `UnitPoint`, `StrokeStyle`, `Shape`s, `EnvironmentValues`, `ColorScheme`, `DynamicTypeSize`, `UserInterfaceSizeClass`, `PresentationDetent`, `LocalizedStringKey`, `ScenePhase`, `EditMode`, `ButtonRole`, `IndexSet`, `CharacterSet` and Foundation's `FormatStyle`s, `FocusState`, `SubmitLabel`, `ScrollTargetBehavior`, `VisualEffect`, `Transaction` (accepted), `URLSession`, `URLRequest`, `HTTPURLResponse`, `URLError` (over the browser's `fetch`), `UIImage` (from data, a `CGImage` or `CIImage`, a symbol or a catalog name), `CGImage` (`cropping(to:)`) | `Path`, `GraphicsContext`, `UIImage.pngData()` / `jpegData` | | SwiftData | `@Model`, `@Attribute` (`.unique`), `@Relationship` (delete rules, inverses), `@Transient`, `ModelContainer`, `ModelConfiguration` (named, in memory), `ModelContext` (insert, delete, fetch, `fetchCount`, save, rollback, autosave), `FetchDescriptor`, `@Query`, `#Predicate`, `SortDescriptor`, `.modelContainer`, `.modelContext`, `\.modelContext`, kept in IndexedDB | `@ModelActor`, history, CloudKit sync, undo | | Files | `URL.documentsDirectory`, `.applicationSupportDirectory`, `.cachesDirectory`, `.libraryDirectory`, `FileManager` and `Data` / `String` file reading and writing in the app's home, kept in IndexedDB; `/tmp`; `Bundle.main` resources (read only) | file coordination, iCloud Drive, `fileImporter` / `fileExporter` | | Other frameworks | StoreKit's `requestReview` (a no-op), Core Location's `CLLocationCoordinate2D`, LocalAuthentication's `LAContext` (always succeeds), UserNotifications (Notification API, while the page is open), the CodeScanner package (simulated data; the camera with `BarcodeDetector`), pure-Swift packages from the Xcode project, Core ML's Create ML tabular models (GLMs, tree ensembles and their pipelines, compiled from the target's `.mlmodel` files), PhotosUI's `PhotosPicker`, `PhotosPickerItem` (`loadTransferable` for `Data` and `Image`), `PHPickerFilter` and `.photosPicker(isPresented:)` (the browser's file chooser), UniformTypeIdentifiers' `UTType`, Core Image: `CIImage` (`cropped`, `clampedToExtent`, `applyingFilter`, `applyingGaussianBlur`), `CIContext.createCGImage`, `CIFilter` (by name or `CIFilterBuiltins`: sepia tone, crystallize, edges, Gaussian blur, pixellate, unsharp mask, vignette, QR code generator) | in-app purchases, location services, real biometrics, neural network, image and text models, the photo library itself (`PHAsset`, `PhotosPicker` styles), other Core Image filters, custom kernels, notification actions and responses | | App | `@main`, `App`, `WindowGroup` | multiple scenes | Anything outside the matrix is a compile error in the browser build, never a silent misrender. The example apps are type-checked against Apple's SwiftUI in CI, so everything in the "Supported" column is known to be source-compatible. --- # @swiftbrowser/cli Run an iOS SwiftUI app in the browser, from its Xcode project, without a Mac. ```sh npx @swiftbrowser/cli doctor --install # Swift 6.4, the wasm SDK, wasm-opt npx @swiftbrowser/cli dev ~/Projects/MyApp # http://localhost:5173, rebuilds on save npx @swiftbrowser/cli build ~/Projects/MyApp # ./dist: a static site to deploy anywhere ``` The app's sources compile unchanged against [SwiftBrowser](https://github.com/dabbott/SwiftBrowser)'s SwiftUI shim and run as WebAssembly inside an iPhone frame (or full screen on a phone: open the dev server's QR code, then Share → Add to Home Screen). What the shim does not provide is a compile error, never a wrong rendering. ## Commands | Command | What it does | |---|---| | `doctor [--install]` | Checks Node ≥ 22, Swift 6.4, the Swift SDK for WebAssembly and `wasm-opt`; `--install` fetches the SDK (`swift sdk install`) and Binaryen into `~/.cache/swiftbrowser`. Swift itself comes from [swiftly](https://www.swift.org/install/) (`swiftly install 6.4.0`), or point `SWIFT_TOOLCHAIN_BIN` at a toolchain's `usr/bin`. | | `check ` | Lists what the app imports (supported / rewritten / unsupported, with the usual way around each), then compiles the app natively against the shim. Seconds, no wasm; every error names your file and line. | | `dev ` | Builds the app for WebAssembly and serves it with the SwiftBrowser dev server: landing page with a QR code at `/`, the app at `/app/?app=`; saving a Swift file rebuilds and reloads with the app's state carried over. `--port`, `--host` (expose on the LAN for phones), `--open`. | | `build [-o dist]` | A release build, optimized and stripped with `wasm-opt` (17.4 MB for a small app, 4.5 MB with brotli), and a complete static site around it. `--no-optimize` skips wasm-opt. | `` is an `.xcodeproj`, a folder holding one (directly or one level down), or a plain folder of Swift sources. Options for all three: `--target ` when the project has several app targets, `--config `, `--verbose`. Building several apps in a row (a CI job, a script) can compile the SwiftBrowser package once for all of them: set `SWIFTBROWSER_BUILD_ROOT` to a directory, and every app is staged at `//` and built with one SwiftPM scratch path, `/.build`. After the first app, a `check` takes a few seconds instead of compiling the shim again. SwiftPM recompiles a dependency when the staging package's parent directory changes, which is why apps staged in their own projects cannot share it. One build at a time per root (SwiftPM locks the scratch path), and two apps with the same name take turns in the same staging directory. ## What happens to your project Nothing, outside `/.swiftbrowser/` (add it to `.gitignore`), or outside `$SWIFTBROWSER_BUILD_ROOT` when that is set. The CLI reads the Xcode project (`project.pbxproj`, including Xcode 16 synchronized folders) for the app target's Swift sources, asset catalogs, display name and app icon, then writes a SwiftPM package under `.swiftbrowser//` whose one executable target is a copy of those sources, adjusted for the shim: - `import SwiftUI` also imports Foundation and the shim's `FoundationBridge` (Apple's SwiftUI re-exports Foundation and Combine, so `UUID`, `JSONEncoder`, `UserDefaults`, `Timer.publish` are in scope without an import). Both it and `import Foundation` also import Observation, so `@Observable` works in a file that imports only Foundation, as it does under Xcode. - A generated `_SwiftBrowserFoundation.swift` settles the names both the shim and Foundation declare (`CGFloat`, `CGPoint`, `CGSize`, `CGRect`, `Timer`, `RunLoop`, `UserDefaults`, `Bundle`) with typealiases, in the shim's favor. - The target's Copy Bundle Resources (or `resources` in `swiftbrowser.json`) are served next to the module and mounted at `/bundle` before the app starts, so `Bundle.main.url(forResource:withExtension:)` and `Data(contentsOf:)` read them. Asset catalogs are handled separately (`Image("name")`, `Color("name")`). - Each Core ML model becomes `.mlmodel.swift`, the class Xcode would generate for it with the model embedded (see "Core ML" below). - `#Preview { … }` blocks are blanked (the shim has no preview macro). - `SWIFTBROWSER` is defined, so platform-specific code can go behind `#if SWIFTBROWSER` / `#if !SWIFTBROWSER`, like `#if os(iOS)`. Every change keeps line numbers, so compiler diagnostics point at your files. The package depends on `@swiftbrowser/swift` (the shim at the version this CLI was released with). ## Foundation Apps link the real Foundation (swift-corelibs-foundation, as the wasm SDK ships it): `DateFormatter`, `NumberFormatter`, `NSString`, `Locale`-aware `formatted()`, `Measurement`, `NSRegularExpression`, `JSONEncoder`, and the rest compile as on iOS. Measured through the pipeline (release, `wasm-opt -Oz`, stripped) on the Counter example: | ICU data | Module | gzipped | brotli | |---|---|---|---| | trimmed to English (default), 3.9 MB of it | 17.4 MB | 6.5 MB | 4.5 MB | | `"locales": ["*"]`, the whole 34 MB package | 47 MB | 19 MB | 12 MB | The SDK's Foundation carries ICU's complete 34 MB locale data package; the CLI pulls that package out of the SDK's archive, keeps the locale-independent tables plus the bundles of the languages in `"locales"` (default `["en"]`; the region part of a tag is ignored, every regional variant of a language stays), and links the trimmed package in the original's place. Formatting for a language that was dropped falls back to ICU's root locale (`11/14/2023, 22:13` and `€ 0.00` for French instead of `14/11/2023 22:13` and `0,00 €`); it never fails. Each language adds 100 to 300 kB, Chinese 1.7 MB (its collation table); `["*"]` keeps everything. Dropped for good: the legacy character-set converters (`String(data:encoding:)` with Shift JIS, Windows-1252 and the like returns nil; UTF-8/16/32, ASCII and Latin-1 need none), the StringPrep profiles and the spoof-checker data. `"collation": false` also drops the collation tables (0.9 MB) when the app never compares strings in a locale: `localizedCompare`, `localizedStandardCompare` and `.localizedStandard` sorting then order by Unicode scalar. The trimmed package is cached per SDK, language set and collation choice under `~/.cache/swiftbrowser/icu/`; the SDK itself is not modified. What Foundation lacks on WASI, the shim's `FoundationBridge` supplies: `Timer` (corelibs' needs a run loop WASI does not have) with `Timer.publish` + `onReceive`, and `RunLoop.main` for its signature; `UserDefaults`, which Foundation's cannot persist on WASI, saved by the browser per app (localStorage) and restored at the next launch; `Bundle`, whose Foundation counterpart traps, reading the app's resources; and `URLSession`, `URLRequest`, `HTTPURLResponse`, `URLError` and `AsyncImage` over the browser's `fetch` (corelibs moved networking to FoundationNetworking, which the SDK does not ship). Known gaps of corelibs Foundation on WASI: `NotificationCenter`'s closure observers and `OperationQueue` are missing. Earlier releases had a `lean` mode (`FoundationEssentials` plus a small formatting layer, about a quarter of the size). It is gone: `--foundation` and `"foundation"` in `swiftbrowser.json` are accepted and ignored with a warning, and an `import FoundationLite` becomes `import FoundationBridge`. ### Locale and time zone The app runs in the viewer's time zone and, within the languages it ships, the viewer's language, as an iOS app follows the device: `Locale.current`, `TimeZone.current`, `formatted()`, `DateFormatter` and `Text(_:format:)` all follow. The browser's first preferred language whose data the module carries (`"locales"`) is used with its region, so a British visitor to an app shipping `["en"]` gets `14/11/2023`; a visitor whose language is not shipped gets the first shipped language rather than a half-formatted root locale. Add `?locale=fr-FR` to the app URL to try a locale without changing the browser (an Xcode scheme's "App Language"). The headless harness pins `en_001` and GMT unless `SB_LOCALE` / `TZ` are set, so snapshots stay stable. Lean mode formats in US English regardless. The hook behind this: swift-foundation hard-codes `en_001` on WASI, so the CLI redirects that one function to FoundationBridge at link time (`--wrap`); it depends on the SDK's swift-foundation, which the CLI pins and CI checks. ## swiftbrowser.json (optional) ```json { "name": "MyApp", "displayName": "My App", "target": "MyApp", "sources": ["MyApp"], "exclude": ["MyApp/Watch"], "catalogs": ["MyApp/Assets.xcassets"], "resources": ["MyApp/missions.json", "MyApp/Data"], "icon": "MyApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png", "swiftLanguageMode": "5", "locales": ["en"], "collation": true, "defines": ["DEMO"] } ``` Everything is optional; `sources`, `catalogs` and `resources` replace what the Xcode project says when set (a plain folder has resources only through the config); `locales` and `collation` pick the ICU data that ships (see above). ## Frameworks Supported: SwiftUI (see the shim's [compatibility matrix](https://swiftbrowser-docs.pages.dev/compatibility)), Observation, Swift Charts, Foundation, networking (`URLSession`, `URLRequest`, `HTTPURLResponse`, `URLError` and `AsyncImage` run over the browser's `fetch`, so the servers an app talks to must allow its origin, CORS), and SwiftData: `@Model`, `ModelContainer`, `ModelContext`, `@Query`, `#Predicate`, relationships with their delete rules, autosave. The models live in memory while the app runs and the page keeps them in IndexedDB, one database per app (`sb-swiftdata:`); `?swiftdata=reset` on the app's URL starts it with no data. An app that imports SwiftData also builds SwiftData's macros, which need swift-syntax: SwiftPM fetches it on the first build and uses its prebuilt release where swift.org publishes one (Swift 6.4 on Linux and macOS), else compiles it once (about a minute). Files an app writes in its home (`URL.documentsDirectory` and the other iOS directories, through `Data`, `String` or `FileManager`) are kept the same way, in `sb-files:`; `?files=reset` starts with none. `check` names the frameworks it knows have no browser counterpart yet (UIKit, Core Data, Combine, MapKit, AVFoundation, WidgetKit, WebKit, …) with the usual way out: keep that code behind `#if !SWIFTBROWSER` and give the browser a stand-in behind `#if SWIFTBROWSER`. StoreKit, Core Location and LocalAuthentication import fine, as far as apps usually use them: `requestReview` does nothing, `CLLocationCoordinate2D` is a plain struct, and an `LAContext` evaluation always succeeds. PhotosUI's `PhotosPicker` opens the browser's file chooser, and the picked files' bytes load with `loadTransferable(type: Data.self)`. Core Image (`import CoreImage`, or `import CoreImage.CIFilterBuiltins`, which the staging turns into `import CoreImage`) has its common built-in filters and the QR code generator, drawn by the page; `UIImage` and `CGImage` come with SwiftUI, as on iOS. UserNotifications runs over the browser's Notification API: the app asks for permission with the browser's prompt, schedules its requests with timers of its own and the page shows each one as it comes due. Notifications are delivered only while the page is open (a closed tab delivers nothing), and Safari on iPhone shows them only for an app added to the Home Screen. ### Core ML The target's Core ML models (`.mlmodel` files and `.mlpackage` bundles in its Sources phase or synchronized folders; in a plain folder, every one found, or those `"sources"` names) compile into the classes Xcode generates for them: `SleepCalculator.mlmodel` becomes `SleepCalculator`, `SleepCalculatorInput` and `SleepCalculatorOutput`, with `init(configuration:)`, `prediction(wake:estimatedSleep:coffee:)` (each input typed as Xcode types it), `prediction(input:)`, `predictions(inputs:)`, the `load` functions, `model` and `urlOfModelInThisBundle`. The CLI reads the model's protobuf and embeds its specification in the class; the shim's `CoreML` module evaluates it (`MLModel`, `MLFeatureValue`, `MLMultiArray`, `MLDictionaryFeatureProvider` and friends). That covers what Create ML makes from tables: linear and logistic regression, boosted trees, random forests and decision trees, and the one-hot encoders, imputers, normalizers, scalers and vectorizers around them. Any other model (a neural network, an ML program, an image, sound or text model) stops the build with its type named, for example `Classifier.mlmodel: the model is a neural network (neuralNetwork), which the browser cannot run`. `dev` rebuilds when a Swift file changes; a retrained model is picked up by the next rebuild. ## Swift packages The Xcode target's package dependencies (File › Add Package Dependencies) are added to the staging package: each remote package with the version rule Xcode records (up to next major or minor, exact, a range, a branch or a revision), each local package by path. SwiftPM fetches and builds them for the browser with the app, so a pure-Swift package works if it builds for WASI. A package that cannot work in the browser and that SwiftBrowser knows is replaced by a module of the same name instead, and never fetched; today that is [CodeScanner](https://github.com/twostraws/CodeScanner), whose `CodeScannerView` sends back its `simulatedData` (as in the iOS simulator) and scans with the camera where the browser has `BarcodeDetector` (Chrome on Android and macOS). `check` lists each package and which way it went: ``` ✓ package CodeScanner (github.com/twostraws/CodeScanner, up to next major from 2.4.1): SwiftBrowser's stand-in, not fetched (the package wraps AVFoundation and UIKit) ✓ package swift-algorithms (github.com/apple/swift-algorithms, up to next major from 1.2.0): a SwiftPM dependency of the build (Algorithms) ``` A package that needs a platform framework fails to compile with an error naming it; keep its uses behind `#if !SWIFTBROWSER`. ## Example Eighteen of the nineteen "Hacking with SwiftUI" projects run from their Xcode projects without a change (WeSplit, Guess the Flag, Views and Modifiers, BetterRest, WordScramble, Animations, iExpense, Moonshot, Navigation, Cupcake Corner, Bookworm, SwiftDataProject, Instafilter, Accessibility, Hot Prospects, Flashzilla, Layout and Geometry, SnowSeeker); `check` names what stops the other, Bucket List, which needs MapKit, a framework the browser has no counterpart for yet: ```sh git clone --depth 1 https://github.com/twostraws/HackingWithSwift npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project4 # BetterRest: Core ML npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project13 # Instafilter: PhotosPicker, Core Image npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project16 # Hot Prospects: CodeScanner, notifications npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project17 # Flashzilla npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project11 # Bookworm: SwiftData ``` --- # @swiftbrowser/xcodeproj Reads Xcode projects without Xcode: a pure-TypeScript parser for `project.pbxproj` (Node built-ins only) that answers the questions a CLI has about an iOS app: which targets exist, which Swift files and asset catalogs they compile, and what their build settings are. ```ts import { findXcodeProjects, readXcodeProject } from '@swiftbrowser/xcodeproj'; const [xcodeproj] = findXcodeProjects(process.cwd()); // ./*.xcodeproj, else one level down const project = readXcodeProject(xcodeproj); const [app] = project.appTargets(); // targets whose product is an application app.name; // 'GuessTheFlag' app.productName; // PRODUCT_NAME with $(TARGET_NAME) resolved app.bundleIdentifier; // PRODUCT_BUNDLE_IDENTIFIER app.displayName; // INFOPLIST_KEY_CFBundleDisplayName, else CFBundleDisplayName from INFOPLIST_FILE app.appIconName; // ASSETCATALOG_COMPILER_APPICON_NAME app.swiftVersion; // SWIFT_VERSION app.deploymentTarget; // IPHONEOS_DEPLOYMENT_TARGET app.sources; // absolute paths of the .swift files, in navigator order app.models; // the Core ML models (.mlmodel, .mlpackage) it compiles app.resources; // absolute paths of the Resources build phase entries app.assetCatalogs; // the *.xcassets among them (and in synchronized folders) app.dependencies; // names of target dependencies app.packageProducts; // Swift package products the target links app.packageDependencies; // the same, each with its package (URL and version rule, or local path) project.packages; // the project's Swift package references app.settings('Release'); // merged build settings for a configuration ``` ## API - `readXcodeProject(xcodeprojPath)` parses `/project.pbxproj` and returns an `XcodeProject`: `path`, `root` (the directory containing the bundle, what `$(SRCROOT)` means), `objectVersion`, `targets` (every `PBXNativeTarget`), `packages` (its `XCRemoteSwiftPackageReference`s, with the dependency rule, and `XCLocalSwiftPackageReference`s, with the absolute path; each with SwiftPM's identity, see `packageIdentity`) and `appTargets()`. - `parsePbxproj(text)` returns the raw object graph: `{ objectVersion, rootObject, objects }`, where each object has an `isa` plus arbitrary fields (strings, arrays, nested dictionaries). `parseOldStylePlist(text)` parses any old-style property list fragment. - `xcodeProjectFromDocument(doc, xcodeprojPath)` interprets an already parsed document as the project at that path. - `findXcodeProjects(dir)` lists the `*.xcodeproj` bundles directly in `dir`, else one level down (sorted), skipping `Pods/`, `Carthage/`, `DerivedData/`, `.build/`, `node_modules/` and dot-directories. - `parseXmlPlist(text)` is the minimal XML plist reader used for `Info.plist`. ## How paths are resolved A file's absolute path is the project root plus the chain of group `path`s leading to it, following each object's `sourceTree`: `` is relative to the parent group, `SOURCE_ROOT` to the root, `` is absolute; others (`BUILT_PRODUCTS_DIR`, `SDKROOT`, ...) fall back to the root. Groups without a `path` contribute nothing. Build phase files are listed in the order of the group tree (what the Xcode navigator shows), not the arbitrary order of the build phase. Paths are resolved exactly as written; a project whose group `path` differs from the folder on disk only in letter case works on macOS but not on a case-sensitive filesystem. Xcode 16 "synchronized folders" (`PBXFileSystemSynchronizedRootGroup` referenced from a target's `fileSystemSynchronizedGroups`) are read from disk: every `*.swift` under the folder (recursive, sorted) is a source of the target and every `*.xcassets` an asset catalog, minus the paths listed in the target's `PBXFileSystemSynchronizedBuildFileExceptionSet.membershipExceptions`. A folder missing on disk contributes nothing. ## Build settings `target.settings(configuration?)` merges the project-level `buildSettings` of the named `XCBuildConfiguration` with the target-level ones on top. The default configuration is `Debug` when the target has one, else the configuration list's `defaultConfigurationName`. Array-valued settings are space-joined. `$(TARGET_NAME)`, `$(PRODUCT_NAME)`, `$(SRCROOT)` and `$(PROJECT_DIR)` (also in `${...}` form) are expanded; every other `$(...)` reference, including `$(inherited)` and modifiers such as `$(X:rfc1034identifier)`, is left as is. ## Not handled - `.xcconfig` files referenced by `baseConfigurationReference` are not read, so settings defined only there are absent. - Binary `Info.plist` files are skipped (only XML plists are read). - `PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet` (Xcode 16 per-build-phase exceptions) and `PBXBuildRule`s are ignored. - Workspaces (`.xcworkspace`), referenced projects (`PBXReferenceProxy`) and `PBXAggregateTarget`/`PBXLegacyTarget` are not modelled; only `PBXNativeTarget`s appear in `targets`. --- # Architecture SwiftBrowser runs SwiftUI apps in the browser so iOS development does not require a Mac for day-to-day iteration. The invariant everything else protects: **an app's source imports `SwiftUI` and compiles unchanged for both iOS and the browser.** ``` Examples/Counter/Sources/*.swift the app: plain SwiftUI, no SwiftBrowser imports │ │ │ swift build --swift-sdk wasm │ xcrun swiftc -typecheck (macOS CI) ▼ ▼ Sources/SwiftUI (shim module) Apple's real SwiftUI view tree → element tree → ops │ ▼ docs/ops-protocol.md Web/ (TypeScript renderer) or headless JSON Lines (wasmtime, CI snapshots) ``` ## Pieces - `Sources/SwiftUI`: a SwiftPM library target literally named `SwiftUI`. It implements an API-compatible subset of SwiftUI (see the compatibility matrix in the README), state storage, and a reconciler that diffs element trees into ops. It has no dependency on Foundation or JavaScriptKit, so it also builds natively on Linux for fast unit tests. - `Examples/Counter`: the first app. Its sources are compiled two ways: into a Wasm executable against the shim, and type-checked against Apple's SwiftUI on a macOS runner so the shim can never drift from the real API. - `Web/`: a Vite + TypeScript project. It instantiates the Wasm module with a WASI shim, applies ops to the DOM inside an iPhone-shaped frame styled with iOS design tokens, and forwards taps back to Swift. - `scripts/`: `build-wasm.sh` builds the example for Wasm and copies it into `Web/public/`; `snapshot-test.sh` runs the example headless under wasmtime and diffs against `Examples/Counter/__snapshots__/`. ## Why this shape - Emitting ops instead of touching the DOM keeps the Swift core testable without a browser and lets the renderer be swapped (a canvas renderer with a faithful SwiftUI layout pass is the Phase 2 option). - The package is only ever built for Wasm and Linux. Building it on macOS would collide with the system `SwiftUI` module, which is intentional: on Apple platforms the app uses the real thing. ## Phase 2 notes - **Keyed children.** `ForEach` gives its children identities. Both the mounted tree and the reconciler match keyed children by identity, so a row keeps its `@State` and its element id across inserts, removes and reorders, and a reorder becomes a single `insert` (move) op. - **Navigation.** `NavigationStack` keeps the pushed screens in a `@State` array inside its own view struct, so it survives re-renders like any state. The runtime wraps the root and each pushed view in a `navscreen` element, lifts `navigationTitle` into the screen's props, supplies the push handler for `NavigationLink` and the pop handler for the screen, and puts a matching `dismiss` into the environment. - **Environment.** Values flow down during reconciliation. `@Environment` properties are found by reflection, like `@State`, and resolved before the view's body runs. `.environment(_:_:)` and style modifiers write into the same context. - **Observation.** A render pass runs inside `withObservationTracking`; a change to any observed property marks the tree dirty, and the next event (or the end of the current one) re-renders. Mutations outside events, such as from timers or tasks, are not yet driven to the screen. - **Hot reload.** The runtime can snapshot primitive `@State` values keyed by each view's path (position and type from the root) and restore them from the `SB_STATE` environment variable at the next boot. Values whose path or type no longer matches are dropped silently. ## Phase 3 notes - **Layout lives in the renderer.** The Swift side still emits only the element tree. The renderer's layout engine (`Web/src/layout`) implements SwiftUI's propose-and-report protocol per element kind, measures text with the real font, and positions every element absolutely. Keeping layout in TypeScript avoids a synchronous Swift-to-JavaScript measurement bridge, keeps the module runnable under wasmtime with no custom imports, and lets the engine be unit-tested in Node with a deterministic text measurer. - **Skipping unchanged views.** A node re-runs its body when it is dirty (its own state or an observed value changed). When its parent re-runs it receives a new view value; if that value and the environment it receives are the same as last time, it is skipped with its subtree, like SwiftUI does for views whose stored properties are equal (docs/ops-protocol.md, "Phase 16"). Which of the runtime's protocols a view conforms to is worked out once per type (`_ViewTraits`), not cast for on every pass ("Phase 17"). - **Animation.** `withAnimation` records a transaction on the main actor; the render pass it causes stamps its `commit` with the animation and the renderer animates every frame and style change in that pass. `.animation(_:value:)` is a styled wrapper whose mounted node remembers the last value and bumps an `animationToken` prop when it changes; only that subtree animates. Transitions are a style field read when the element is inserted or removed inside an animated commit. A pass that moves an element whose frame is still tweening (a custom `Layout` or a `GeometryReader` answering the animated pass a frame later) retargets the tween from where the element is drawn to its new frame, in the time the tween had left; it never leaves the old tween drawing a stale frame. - **Sheets.** `.sheet` mounts the content as a second child of the presenting node while presented, so its state survives, but emits it as a root-level `sheet` element after the app's tree. Its handler flips the binding; the content's environment carries a matching `dismiss`. - **Dynamic Type.** Fonts travel as text-style names and the renderer resolves them against Apple's Dynamic Type table for the current size, which arrives through the environment event like the color scheme. ## Phase 4 notes - **Executor.** Swift concurrency on Wasm has no event loop of its own. The shim installs a cooperative executor through the `ExecutorFactory` SPI (the same route JavaScriptKit takes on Swift 6.4): jobs queue until the host calls `sb_run_jobs`, and sleeps become timers against a clock the host sets. The browser passes real time; headless mode jumps a virtual clock from timer to timer, so timer-driven apps snapshot deterministically. Native test processes never install it, because the test runner drives the main actor itself. - **Stable state storage.** A long-lived task captures the view value it was started from. State boxes therefore share one persistent storage object across re-renders instead of copying values, so a closure captured before a re-render still writes to the live value. - **Tabs and pickers.** The runtime flattens the content into options, reading `tag`, `tabItem` and `ForEach` ids through the wrapper views, and emits one `tab` element per option (all mounted, one selected) or the option labels on a `picker`. - **Value-based navigation.** `navigationDestination(for:)` registers a builder on the enclosing stack's node while the root screen reconciles; path screens are placeholders resolved right after, so a value appended to the path binding becomes a screen in the same pass. - **GeometryReader.** The renderer reports the laid-out size through a `geometry` event only when it changes; the node stores it and the content re-renders with the real size, so the loop converges in one extra pass. ## Foundation The shim imports Foundation and apps link the wasm SDK's swift-corelibs Foundation, with ICU's locale data trimmed to the app's languages by the CLI (see `packages/cli/src/icu.ts` and docs/ops-protocol.md, "Phase 11"). What corelibs lacks on WASI lives in `Sources/FoundationBridge`: `Timer` and `RunLoop`, a `UserDefaults` the browser persists, a `Bundle` over the staged resources, `URLSession` and `AsyncImage` over the page's `fetch`, and the viewer's `Locale`. An app file says `import Foundation` and nothing else, so the same file compiles against Apple's SDKs. Earlier phases had a lean mode on `FoundationEssentials` plus a small formatting layer; Phase 14 removed it (README, "Phase 14"). Phase 15 trimmed further (docs/ops-protocol.md, "Phase 15", with a breakdown of where the bytes go and why dead-code elimination keeps them); a small app is 17.4 MB, 4.5 MB with brotli. The sizes below are from the lean era and remain a fair picture of what each piece costs before trimming. | Build (release, `-Osize`, stripped) | Raw | gzip | |---|---|---| | Settings example (no Foundation) | 9.6 MB | 2.6 MB | | FoundationEssentials probe | 12.7 MB | 3.7 MB | | Full Foundation probe | 53.8 MB | 19.1 MB | ## SwiftData `Sources/SwiftData` is SwiftData over the page's IndexedDB (docs/ops-protocol.md, "Phase 18"). The store is in Swift memory for the run, handed in at launch (`SB_SWIFTDATA`) and written back save by save (`swiftdata` ops), because SwiftData's API is synchronous and IndexedDB's is not: fetching from memory keeps `context.fetch` and `@Query` synchronous, as on iOS. `@Model` and `#Predicate` are compiler plugins (`Sources/SwiftDataMacros`, swift-syntax), so only apps that import SwiftData pay for them; the CLI links the module when a source imports it. ## Files An app's home directory (`/data`, `HOME`) is an in-memory WASI directory the page fills from IndexedDB before the module starts and writes back after any event or executor run that wrote to a file (docs/ops-protocol.md, "Phase 19"). Nothing in the protocol changes: the page notices writes by watching the WASI calls that can change a file, then diffs the tree against what it last saved. ## Bitmaps `UIImage`, `CGImage` and Core Image's `CIImage` hold recipes, never pixels (docs/ops-protocol.md, "Bitmaps and Core Image"): an image file's bytes (or a generator's pixel buffer, such as a QR code's modules) plus the filter steps to run. Swift reads sizes from file headers, so `UIImage(data:)` stays synchronous as on iOS; the page decodes the file with the browser and runs the steps in TypeScript (`Web/src/imageOps.ts`) at the size the image is shown. No image codec is compiled into the module. `UIImage` and `CGImage` are in the shim because SwiftUI re-exports them on iOS; `Sources/CoreImage` is linked only into apps that import it. --- # Render ops protocol The Swift core (the `SwiftUI` shim module) never touches the DOM. It turns a SwiftUI view tree into an **element tree** and emits a stream of **ops** that a renderer applies. Two renderers exist: - the browser renderer in `Web/` (TypeScript, applies ops to the DOM inside an iPhone frame), and - headless mode, used by CI under wasmtime, which prints one op per line as JSON Lines so the output can be snapshot-tested. Element ids are integers assigned by the Swift core and are stable across re-renders for an element that keeps its position and kind. Id `0` is the root container (the device screen) and is never created or removed. ## Ops Each op is a JSON object with an `op` field. | op | fields | meaning | |----------|-------------------------------------|---------| | `create` | `id`, `kind`, `props` | Create a detached element. | | `update` | `id`, `props` | Replace the element's props entirely. | | `insert` | `parent`, `id`, `index` | Insert element `id` as child `index` of `parent` (moving it if already attached). | | `remove` | `id` | Detach and destroy the element and its subtree. | | `commit` | | End of a render pass. Renderers may batch DOM work until this. | Ops within a pass are ordered so that applying them sequentially is always valid: an element is created before it is inserted, and parents exist before children are inserted into them. ## Element kinds and props | kind | props | children | |----------|-------|----------| | `text` | `text: string` | none | | `vstack` | `spacing: number \| null`, `alignment: "leading" \| "center" \| "trailing"` | any | | `hstack` | `spacing: number \| null`, `alignment: "top" \| "center" \| "bottom"` | any | | `spacer` | `minLength: number \| null` | none | | `button` | none | the label | | `styled` | `style: Style` | exactly one | `spacing: null` means "system default" (the renderer uses 8px). ### Style Every field is optional. A `styled` element applies the style to the box around its single child. Nested `styled` elements preserve SwiftUI modifier order (`.padding().background(...)` is a `styled{background}` wrapping a `styled{padding}` wrapping the content). ```jsonc { "padding": { "top": 16, "leading": 16, "bottom": 16, "trailing": 16 }, "font": { "size": 17, "weight": "ultraLight" | "thin" | "light" | "regular" | "medium" | "semibold" | "bold" | "heavy" | "black", "design": "default" | "serif" | "rounded" | "monospaced" }, // every font field is optional: {"weight":"bold"} changes only the weight "foreground": Color, "background": Color, "frame": { "width": 200, "height": 44, "minWidth": 0, "maxWidth": "infinity", "minHeight": 0, "maxHeight": "infinity", "alignment": "center" }, // every frame field is optional; a max/min value is a number or the string "infinity"; // alignment is center | leading | trailing | top | bottom | topLeading | topTrailing | bottomLeading | bottomTrailing "cornerRadius": 12, "opacity": 0.5 } ``` ### Color Either an explicit sRGB color or a semantic name the renderer resolves for the current color scheme. ```jsonc { "r": 0.0, "g": 0.478, "b": 1.0, "a": 1.0 } { "name": "primary" | "secondary" | "accent" | "systemBackground" | "secondarySystemBackground" | "clear", "a": 0.5 } ``` `a` on a semantic color is an optional opacity multiplier (`Color.secondary.opacity(0.5)`). Named iOS palette colors (`red`, `orange`, `yellow`, `green`, `mint`, `teal`, `cyan`, `blue`, `indigo`, `purple`, `pink`, `brown`, `gray`, `black`, `white`) are emitted as explicit RGB by the Swift core using the iOS light-mode values. ## Events (renderer → Swift) The Wasm module exports these C-ABI functions: | export | meaning | |--------|---------| | `sb_event(id: i32)` | A `button` element with this id was tapped. Runs the action and a render pass; the resulting ops are appended to the pending buffer. | | `sb_ops_ptr() -> i32`, `sb_ops_len() -> i32` | Address and byte length of the pending ops buffer in linear memory: a UTF-8 JSON **array** of ops. | | `sb_ops_clear()` | Empty the pending buffer after the renderer has applied it. | The module is a WASI command: the renderer instantiates it with a WASI shim, calls `_start` (which runs the app's `main`, mounts the root view and performs the first render pass into the pending buffer), then reads and applies the buffer. After every `sb_event` call the renderer reads and applies the buffer again. ## Headless mode When the environment variable `SB_HEADLESS=1` is set, the app prints each op as one JSON line to stdout instead of buffering. `SB_EVENTS` may contain a script of events to replay after the first render, separated by commas or line breaks, e.g. `tap:"Add", tap:"Add", tap:5`. Each event is echoed as `{"op":"event","type":"tap","id":5}`, with the id its target resolved to, before its ops. Keys in every JSON object are emitted in sorted order so the output is byte-stable. ### Event scripts An event's target is an element id or a **selector**, resolved against what is on screen when the event is replayed. Ids shift whenever an element is added above the target, so scripts that live in the repository (`Examples/*/__snapshots__/events.txt`, the CI relaunch steps) use selectors: | selector | names | |----------|-------| | `"Add"` | the element showing that text: a `text`, an image's `systemName` or `name`, a screen `title`, a control's `label` or `placeholder`, or an `accessibilityLabel` (a `Label`'s title is one, so an icon-only toolbar button matches its title, as in XCUITest) | | `@save` | the element with `.accessibilityIdentifier("save")` | | `[tabview]` | an element of that kind; with text (`[navscreen]"Detail"`) both must hold | | `…#2` | the second of several matches, in document order | A match resolves to the nearest element at or above it that has a handler and suits the event: a `button`, `navlink`, `listrow` or `tapgesture` for `tap` (so `tap:"Save"` reaches the button around its label), a `textfield`, `searchfield` or `texteditor` for `text`, a `toggle`, `picker` or `tabview`, `stepper`, `slider`, `datepicker`, `gesture` (`drag`, `longpress`), `listrow` (`delete`) or `geometry` for the others. `[kind]` replaces that list. Only what is on screen matches: the top `sheet` or `alert` when one is presented, else everything except the screens a stack has pushed over and the unselected tabs. A selector that matches nothing, or several elements without `#n`, prints `{"op":"error","message":"tap:\"Edit\" matches 3 elements (button 12, button 40, button 77); pick one with #1…#3"}` and the run exits with status 1, so a script never taps the wrong element. Two events need no target: `back` taps the visible screen's back button and `dismiss` the top sheet or alert. `select:"Flavor":"Chocolate"` and `select:[tabview]:"Settings"` name the option or tab instead of its index. A value may be quoted (`text:"Name":"Smith, Jo"`), and a line whose first character is `#` is a comment: ``` # Add a todo and open it text:"New todo":Walk the dog tap:"Add" tap:"Walk the dog" toggle:"Done":true back ``` --- # Phase 2 additions Everything above still holds. Phase 2 adds element kinds, a JSON event channel from the renderer to Swift, an environment event, and a state snapshot used by hot reload. ## New element kinds | kind | props | children | |---------------|-------|----------| | `zstack` | `alignment: Alignment` (same names as `frame.alignment`) | any; later children draw on top | | `image` | `systemName: string` | none | | `divider` | none | none | | `scrollview` | `axes: "vertical" \| "horizontal" \| "both"` | any | | `list` | none | rows, or `section`s | | `section` | `header: string \| null`, `footer: string \| null` | rows | | `textfield` | `text: string`, `placeholder: string`, `style: "plain" \| "roundedBorder"`, `secure: bool` | none | | `toggle` | `isOn: bool` | the label | | `navstack` | none | `navscreen`s, bottom to top; only the last is visible | | `navscreen` | `title: string`, `displayMode: "automatic" \| "large" \| "inline"`, `depth: number` | the screen content | | `navlink` | none | the label | Notes for renderers: - `image`: `systemName` is an SF Symbol name. Render the closest glyph from an open icon set at the current font size and color (`1em`), and a visible placeholder for unknown names. The Swift side never sends pixels. (The web renderer maps about 570 names to lucide glyphs in `Web/src/symbols.ts`; an unlisted name is drawn as its closest listed base, dropping a `.badge…` suffix and then trailing parts, with filled forms first, so `person.crop.circle.badge.questionmark` draws as `person.crop.circle`. A `.slash` variant never falls back to its base, which would mean the opposite. SF Symbols themselves are licensed for Apple platforms only, so they are not used.) - `list`: iOS inset-grouped style. Every direct child that is not a `section` is one row. Rows get separators and the standard 44pt minimum height. `navlink` and `toggle` inside a list render as full-width rows (a `navlink` row shows a trailing chevron). - `navstack`: push and pop are expressed purely as children being inserted or removed at the end. The renderer animates the slide and draws the navigation bar: the title (large for `automatic` at depth 0 and for `large`, inline otherwise) and, for `depth > 0`, a back button labelled with the previous screen's title. Tapping back sends a `tap` event with the **`navscreen`'s own id**; Swift pops to it. - `navlink`: a tap on it (its own id) pushes. Inside a list it is a row. - `textfield`: when an `update` carries the same `text` the field already shows, leave the caret alone. ### `button` props (changed) `button` now carries `style: "automatic" | "plain" | "bordered" | "borderedProminent"` and `role: "destructive" | null`. `bordered` is a gray capsule, `borderedProminent` a filled accent capsule, `automatic` and `plain` are text-only. ### `styled` style (added fields) | field | meaning | |-------|---------| | `disabled: bool` | dims the subtree and blocks input (`opacity: 0.4`, `pointer-events: none`) | ## JSON events (renderer → Swift) Taps keep using `sb_event(id)`. Everything else goes through a JSON event: | export | meaning | |--------|---------| | `sb_alloc(len: i32) -> i32` | Allocate `len` bytes in linear memory for the renderer to write into. Ownership passes to Swift on the next `sb_event_json` call. | | `sb_event_json(ptr: i32, len: i32)` | Deliver one UTF-8 JSON event written at `ptr`. Swift frees the buffer, handles the event, renders, and appends ops to the pending buffer (read it as after `sb_event`). | Event shapes: ```jsonc { "type": "tap", "id": 12 } // same as sb_event(12) { "type": "text", "id": 7, "value": "Milk" } // textfield input { "type": "toggle", "id": 9, "value": true } // toggle changed { "type": "environment", "colorScheme": "dark" } // device appearance changed ``` The renderer sends `environment` once after `_start` (before applying the first ops is fine) and again whenever the theme toggle changes. Swift re-renders views that read `@Environment(\.colorScheme)`. In headless mode `SB_EVENTS` accepts the same events in a compact form: `tap:12`, `text:7:Milk`, `toggle:9:true`, `env:dark`, where the id may be a selector (`tap:"Add"`, `text:"New todo":Milk`; see "Event scripts" above). Each is echoed as `{"op":"event",...}` with the same fields as the JSON event. ## State snapshot (hot reload) | export | meaning | |--------|---------| | `sb_state_ptr() -> i32`, `sb_state_len() -> i32` | A JSON object describing the current `@State` values that can be restored: `{ "": value, ... }`, where `path` identifies a mounted view by its position and type, and `value` is a number, string or bool. Only such values are included. | To restore, the renderer passes the same JSON in the WASI environment variable `SB_STATE` when instantiating the next build of the module. The runtime applies the values when it first mounts a view whose path matches and whose state type matches; everything else starts fresh. Mismatches are ignored silently, so a reload across a source change that reshapes the tree simply loses the state that no longer fits. The dev server (`npm run dev`) watches `Sources/` and `Examples/`, rebuilds the Wasm module with `scripts/build-wasm.sh`, and reloads the page. Before reloading, the page stores the snapshot in `sessionStorage` and feeds it back through `SB_STATE` on boot. --- # Phase 3 additions Phase 3 moves layout out of CSS flexbox and into a SwiftUI-style layout engine inside the renderer, and adds animation, sheets and Dynamic Type. The Swift side keeps sending the same element tree; the renderer now computes a frame for every element. ## Font (changed) Text styles are no longer resolved to point sizes by Swift, so the renderer can scale them for Dynamic Type: ```jsonc { "textStyle": "largeTitle" | "title" | "title2" | "title3" | "headline" | "subheadline" | "body" | "callout" | "footnote" | "caption" | "caption2", "size": 64, // fixed size instead of a text style (does not scale) "weight": "...", "design": "...", "relativeTo": "body" } // a fixed size that scales like this text style ``` Every field is optional; `size` and `textStyle` are mutually exclusive. `headline` implies `semibold` unless `weight` is given. The renderer resolves a text style for the current `dynamicTypeSize` with Apple's Dynamic Type table (Large is the default: largeTitle 34, title 28, title2 22, title3 20, headline 17, body 17, callout 16, subheadline 15, footnote 13, caption 12, caption2 11) and uses the matching iOS line heights. ## New element kinds | kind | props | children | |---------|-------|----------| | `color` | `color: Color` | none; fills its proposal | | `shape` | `shape: "rectangle" \| "roundedRectangle" \| "circle" \| "capsule" \| "ellipse"`, `cornerRadius: number`, `fill: Color \| null`, `stroke: { "color": Color, "lineWidth": n } \| null` | none; fills its proposal, `fill: null` means the current foreground color | | `sheet` | `detents: ["medium", "large"]` | the sheet content | A `sheet` is always a **root-level** element (a child of id 0) placed after the app's tree; the Swift side appends it while it is presented and removes it when dismissed. A `tap` on the sheet's own id is a dismiss request (scrim tap or drag down); Swift then flips the binding and removes the element. ## `styled` style (added fields) | field | meaning | |-------|---------| | `layoutPriority: number` | stack space distribution priority (default 0) | | `lineLimit: number \| null` | maximum lines of text in the subtree; extra text is truncated with an ellipsis | | `multilineTextAlignment: "leading" \| "center" \| "trailing"` | line alignment for wrapped text | | `fixedSize: { "horizontal": bool, "vertical": bool }` | the child is proposed `nil` on the fixed axes and takes its ideal size | | `animation: Animation`, `animationToken: number` | `.animation(_:value:)`: whenever `animationToken` changes in an `update`, the layout and style changes of this subtree in that commit animate with `animation` | | `transition: Transition` | how this subtree appears and disappears inside an animated commit | | `labelIcon: true` | the box is a `Label`'s icon: inside a `list` it takes the tint, while the row's label (a `navlink` row's too, however it is wrapped) keeps the primary color; an explicit foreground on or around it wins | ```jsonc Animation: { "type": "default" | "linear" | "easeIn" | "easeOut" | "easeInOut" | "spring", "duration": 0.35, "bounce": 0.0, "delay": 0.0 } Transition: { "kind": "opacity" | "scale" | "slide" | "move" | "identity", "edge": "top" | "leading" | "bottom" | "trailing", "scale": 0.5 } // or an array of these, combined ``` ## `commit` (changed) A commit may carry a transaction animation from `withAnimation`: ```jsonc { "op": "commit", "animation": { "type": "easeInOut", "duration": 0.35 } } ``` In an animated commit the renderer animates every frame and style change of the pass, inserted elements transition in and removed elements transition out (keeping a visual copy until the transition ends). Without `animation` on the commit, only subtrees whose `animationToken` changed animate. ## Environment event (extended) ```jsonc { "type": "environment", "colorScheme": "dark", "dynamicTypeSize": "xLarge" } ``` `dynamicTypeSize` is one of `xSmall`, `small`, `medium`, `large`, `xLarge`, `xxLarge`, `xxxLarge`, `accessibility1` … `accessibility5`. Either field may be omitted. Headless replay: `env:dark`, `env:light`, `type:xLarge`. ## Layout engine semantics The renderer lays the tree out the way SwiftUI does: a parent **proposes** a size to each child, the child **reports** the size it wants, and the parent **places** it. A proposal has an optional width and height; `null` means "whatever you want", `0` asks for the minimum and `Infinity` for the maximum. The root is proposed the screen's safe area and centered in it. Frames are in screen coordinates; the renderer positions every element absolutely from the computed frame. | kind | size for a proposal | placement | |------|---------------------|-----------| | `text` | measured with the resolved font. Width proposed: wrap greedily at word boundaries to that width (never narrower than the longest word unless `lineLimit` truncates); unspecified width: one line. Height = lines × line height. | lines aligned by `multilineTextAlignment` | | `image` | a square of the font's line height (symbol glyphs scale with the font) | | | `spacer` | along its stack's axis: minimum `minLength` (default 8), maximum the proposal; 0 on the cross axis. Outside a stack it fills both axes. | | | `divider` | height 1 (0.5 on 2× displays), width = proposal (in an `hstack`: width 1, height = proposal) | | | `vstack` / `hstack` | SwiftUI's stack algorithm: measure each child's minimum and maximum along the axis (proposals 0 and Infinity) to get its flexibility; hand out the remaining space to children in order of **highest layoutPriority first, then least flexible first**, proposing `remaining / childrenLeft` to each; sum the results plus `spacing` (default 8) between children; cross size = largest child. | children in order along the axis, cross-aligned by `alignment` | | `zstack` | proposes its size to every child; size = the union of children | all children aligned by `alignment` | | `styled` padding | proposes (proposal − insets) to the child; size = child + insets | child inset | | `styled` frame | fixed `width`/`height` are proposed to the child and reported as is. Flexible `min`/`max`: the proposal is clamped and proposed; the reported size is the clamped proposal when one was given, else the child's size clamped. `maxWidth: "infinity"` therefore fills the proposal. | child aligned by `frame.alignment` inside the frame | | `styled` other | pass-through; `font`, `foreground` and text settings change the context the subtree is measured with | | | `button` | `automatic`/`plain`: the label's size. `bordered`/`borderedProminent`: label + padding 7 vertical, 14 horizontal | | | `toggle` | width = proposal width (the switch is 51×31 at the trailing edge, the label leads); height = max(label, 31) | | | `textfield` | width = proposal width; height 34 (`roundedBorder`) or 22 (`plain`) | | | `color` / `shape` | the proposal (nil axes → 10, like SwiftUI's default for unconstrained shapes) | | | `scrollview` | fills its proposal; proposes `null` to its content along the scroll axes and its own size on the others | content at the scroll offset | | `list` | fills its proposal and scrolls. Inset grouped: sections inset 16 with 10px corners; rows are proposed the row width minus 16 padding on each side, height = max(content, 44); section headers 13pt uppercase with 16px top and 6px bottom padding; 35px between sections | | | `navstack` | fills the whole screen (ignores safe areas) | the top screen | | `navscreen` | the navigation bar occupies the status bar height plus 44 (plus 52 for a large title); the content is proposed the rest | | | `sheet` | `large`: screen height − 10 − status bar; `medium`: half the screen; the content is proposed the sheet size minus the grabber row | slides up from the bottom; the presenting tree scales to 0.92 and dims behind a `large` sheet | Device pixel snapping: frames are rounded to whole CSS pixels after placement, consistently (origins floor, sizes round), so adjacent elements never overlap by a fraction. --- # Phase 4 additions Phase 4 adds tabs, pickers, forms, value-based navigation, a host-driven executor so `Task`, `.task` and sleeps reach the screen, and `GeometryReader`. ## New element kinds | kind | props | children | |------------|-------|----------| | `tabview` | `selected: number` (index) | `tab`s | | `tab` | `title: string`, `systemImage: string \| null` | the tab's content | | `picker` | `style: "segmented" \| "menu"`, `label: string`, `options: string[]`, `selected: number` | none | | `geometry` | none | exactly one | - `tabview` fills the screen. The tab bar is 49pt tall plus the bottom safe area, drawn at the bottom with each `tab`'s icon and title (10pt text, accent color when selected, secondary otherwise). Only the selected `tab`'s content is laid out and visible; the others stay in the tree so their state survives. Content is proposed the screen minus the tab bar. Tapping a bar item sends a `select` event with the `tabview`'s id and the index. - `picker` with `segmented` style is an iOS segmented control (height 32, width = proposal, equal segments, the selected one raised on a white / dark-gray pill); with `menu` style it is a row with the `label` at the leading edge and the selected option plus a `chevron.up.chevron.down` glyph at the trailing edge (height 44 in a list row, 34 elsewhere); tapping it opens a simple menu of the options anchored to the control, and choosing one sends a `select` event with the picker's id and the index. - `list` props gain `style: "plain" | "insetGrouped" | "grouped"`. `Form` is a `list` with `grouped` style: full-width sections with hairlines instead of inset cards. - `geometry` is transparent for layout (it fills its proposal, like SwiftUI's `GeometryReader`, and proposes that size to its child, aligned top leading). After every layout pass the renderer sends `{ "type": "geometry", "id": N, "width": w, "height": h }` for each `geometry` element whose size changed since it last reported; Swift re-renders the content with the new size. ## Events (added) ```jsonc { "type": "select", "id": 12, "value": 1 } // tab or picker selection { "type": "geometry", "id": 7, "width": 393, "height": 120 } ``` Headless: `select:12:1`, `geometry:7:393x120`. ## Executor (added exports) Swift concurrency on Wasm needs the host to drive it. The module installs a cooperative executor; jobs created by `Task`, `.task`, `Task.sleep` and `ContinuousClock` wait until the host calls in: | export | meaning | |--------|---------| | `sb_run_jobs(now_ms: f64) -> f64` | Sets the executor's clock to `now_ms` (milliseconds, any monotonic origin), runs every queued job and every timer that is due, renders if state changed (ops land in the pending buffer, read it afterwards), and returns the milliseconds until the next timer, or `-1` when none is pending. | The renderer calls `sb_run_jobs` after `_start`, after every event it delivers, and then whenever the returned delay elapses (via `setTimeout`, clamped to at least 4 ms). It reads and applies the pending ops after each call. Headless mode drives the same executor with a virtual clock: after replaying `SB_EVENTS`, if `SB_RUN_MS` is set the runtime repeatedly runs jobs and jumps the clock to the next timer until no timer remains or the clock passes `SB_RUN_MS`, printing ops as usual. This makes timer-driven apps deterministic in snapshots. --- # Phase 5 additions Phase 5 adds Xcode asset catalogs: images and colors a SwiftUI app refers to by name (`Image("dough/plain-full")`, `Color("dough/plain-bg")`), plus `.aspectRatio` / `.scaledToFit()` / `.scaledToFill()`. The Swift side keeps sending names only; the web side turns every `*.xcassets` under `Examples//` into a manifest the page loads before the app. ## Asset catalog manifest `Web/plugins/asset-catalogs.ts` serves `/catalog/.json` (dev server and build) and the image files at `/catalog//.`, where `name` has every `/` of a namespace replaced by `__` (`dough__plain-full.png`). The page fetches the manifest before loading the Wasm module; a 404 or a broken manifest means "no catalog" and never an error. ```ts interface AssetCatalog { images: Record; colors: Record; } ``` - Keys are catalog names. A folder whose `Contents.json` has `"properties": { "provides-namespace": true }` prefixes its contents with `/` (nested folders chain); other folders add nothing. - `*.imageset`: the `2x` entry is published when present, else `1x`, else `3x`, else the first entry with a file (no scale → 1). `width`/`height` are **points** (pixels ÷ `scale`); PNG, JPEG and SVG are measured, other formats are skipped with a warning. `kind: "image"`. - `*.symbolset`: the SVG is published as `kind: "symbol"` (a template image; size irrelevant). - `*.colorset`: `light` is the entry without appearances, `dark` the one with `luminosity: dark`; idiom and contrast variants are ignored. Components may be hex (`"0xC2"`), floats (`"0.439"`) or 0…255 integers (`"255"`); the values are CSS `rgba(r, g, b, a)` strings. ## `image` props (changed) `image` props are now one of two forms: ```jsonc { "systemName": "checkmark.circle.fill" } // an SF Symbol, as before { "name": "dough/plain-full", "resizable": false } // an asset catalog image ``` | catalog entry | size for a proposal | rendering | |---------------|---------------------|-----------| | `kind: "image"`, `resizable: false` | always the intrinsic point size | `` filling the frame (`object-fit: fill`; fitting is `aspectRatio`'s job) | | `kind: "image"`, `resizable: true` | `width: proposal.width ?? intrinsic`, `height: proposal.height ?? intrinsic` (fills what is proposed, like SwiftUI; `Infinity` passes through so it is maximally flexible in stacks) | same | | `kind: "symbol"` | a square of the font's line height, like an SF Symbol | a span with `mask-image: url(src)` filled with `currentColor` (tints like a symbol) | | not in the manifest | 44×44 | the dashed placeholder unknown symbols get, with the name in `title` | ## `styled` style (added field) | field | meaning | |-------|---------| | `aspectRatio: { "ratio": number \| null, "contentMode": "fit" \| "fill" }` | `.aspectRatio(_:contentMode:)`; `.scaledToFit()` is `{ ratio: null, contentMode: "fit" }`, `.scaledToFill()` the `fill` form | Layout: let the child's proposal (after the frame, padding and fixedSize steps of the same `styled`, which nest as frame > padding > fixedSize > aspectRatio > child) be `(pw, ph)`, where either may be nil or infinite. When `ratio` is null the child is measured with a nil proposal and its ideal width ÷ height is the ratio (1 when degenerate). Then: - both finite: `fit` → the largest size with that ratio inside `(pw, ph)`; `fill` → the smallest size with that ratio covering it; - one finite: the other axis is derived from the ratio; - both infinite: Infinity × Infinity (a flexible child fills an unbounded box, so a custom layout probing the maximum sees it as flexible); - neither otherwise: the child's ideal size. That size is proposed to the child and the child's **reported** size is what the `styled` box reports, for `fill` too (clipping is a visual concern: SwiftUI needs `.clipped()` as well). The child is centered (or aligned by `frame.alignment` when the same box has a frame). ## Color (added form) ```jsonc { "named": "dough/plain-bg", "opacity": 1.0 } ``` A `.colorset` of the catalog, resolved for the current color scheme. `opacity` (0…1) multiplies the catalog alpha. The renderer declares every catalog color as a `--sb-asset-color-` custom property under `.device[data-theme="light"]` and `.device[data-theme="dark"]` (the slug is the name with `/` → `__` and anything but `[A-Za-z0-9_-]` → `_`; a colorset without a dark variant keeps its light value) and renders a named color as `var(--sb-asset-color-, magenta)`, wrapped in `color-mix(... , transparent)` when `opacity` < 1. A name the catalog lacks therefore shows up magenta. ## Transition addition `transition` parts gain `{ "kind": "offset", "x": number, "y": number }` (`AnyTransition.offset(x:y:)`): the view enters from, and exits to, that translation in points. Combines with the other parts like `move`. --- # Phase 6 additions Phase 6 runs the app-level screens of Apple's Food Truck sample: custom `Layout`s evaluated on the Swift side with host-measured subviews, `Grid`, `LazyVGrid`, `Gauge`, overlays and backgrounds with views, menus, toolbars, search fields, badges and row insets, shape styles (hierarchical levels, gradients, materials) and visual effects. The three sub-sections were written with their implementations. ## Phase 6: layout containers Phase 6 adds SwiftUI's `Grid`, `LazyVGrid`, `Gauge`, `.overlay { }` / `.background { }` with a view, `.position(x:y:)`, `.tint` and the `Layout` protocol: a custom layout is evaluated on the Swift side with subviews the renderer measures. Everything in `docs/ops-protocol.md` still holds; the renderer lays these kinds out like the others (propose / report / place) and positions every child absolutely from its frame. ### New element kinds | kind | props | children | |-------------|-------|----------| | `layout` | `proposal: Proposal \| null`, `size: {width, height} \| null`, `frames: [{x, y, width, height}] \| null`, `sizes: {min, ideal, max} \| null` (each `{width, height}`, a dimension may be `"infinity"`) | the subviews, in order | | `grid` | `horizontalSpacing: number \| null`, `verticalSpacing: number \| null`, `alignment: Alignment` | `gridrow`s, or any other element (one full-span cell) | | `gridrow` | `alignment: "top" \| "center" \| "bottom" \| "firstTextBaseline" \| "lastTextBaseline" \| null` | the cells | | `lazyvgrid` | `columns: GridItem[]`, `spacing: number \| null`, `alignment: Alignment` | the cells (already flattened) | | `gauge` | `value: number` (0…1), `style: "automatic" \| "linearCapacity" \| "accessoryLinear" \| "accessoryCircular" \| "accessoryCircularCapacity"`, `hasLabel: bool`, `hasCurrentValueLabel: bool` | the label (when `hasLabel`), then the current value label (when `hasCurrentValueLabel`) | | `decorated` | `role: "overlay" \| "background"`, `alignment: Alignment` | exactly `[content, decoration]` | `Alignment` is the `frame.alignment` set (`center`, `leading`, `trailing`, `top`, `bottom`, `topLeading`, `topTrailing`, `bottomLeading`, `bottomTrailing`). `spacing: null` means the system default, 8. ```jsonc Proposal: { "width": 393 | "infinity" | null, "height": 759 | "infinity" | null } // null is a nil axis ("whatever you want"), "infinity" the maximum GridItem: { "size": { "kind": "fixed", "value": 80 } | { "kind": "flexible", "minimum": 10, "maximum": "infinity" } | { "kind": "adaptive", "minimum": 100, "maximum": "infinity" }, "spacing": 8 | null, // to the next column (default 8) "alignment": Alignment | null } // for the cells of this column (default: the grid's) ``` The renderer gives each a `div` with the kind class (`sb-layout`, `sb-grid`, `sb-gridrow`, `sb-lazyvgrid`, `sb-gauge`, `sb-decorated`). The gauge owns two chrome nodes, `.sb-gauge-track` and `.sb-gauge-fill`, positioned from its own box; they carry no `data-sb-id`. ### `styled` style (added fields) | field | meaning | |-------|---------| | `gridCellColumns: number` | `.gridCellColumns(_:)`: the cell spans this many `grid` columns (default 1). The `styled` is the direct child of the `gridrow`. | | `gridCellAnchor: { "x": 0…1, "y": 0…1 }` | `.gridCellAnchor(_:)`: unit point the cell is aligned by inside its cell box (default: the row's vertical and the grid's horizontal alignment) | | `tint: Color` | `.tint(_:)` (same JSON forms as `foreground`): sets the CSS variable `--sb-tint` on the box; gauge fills, switched-on toggles and `borderedProminent` / plain button colors below read `var(--sb-tint, …)` | | `position: { "x": number, "y": number }` | `.position(x:y:)`: the box takes the whole proposal (the child's ideal size on nil axes) and places the child's center at (x, y) inside it; the child is measured with a nil proposal | ### Layout rules #### `layout` (custom `Layout`) - **Measure**: `size` for the proposal it answers (`proposal`, compared within the 2-decimal rounding). For any other proposal the element answers from `sizes`, the layout's own `sizeThatFits` for a zero, unspecified and infinite proposal that Swift sends with every answer: per axis, a nil proposal gives `ideal`, anything else is clamped to `min` … `max` (what `LayoutSubview.sizeThatFits` does on the Swift side), until Swift answers that proposal in a later pass. Before Swift's first answer the element is flexible: it fills a finite proposal (Infinity stays Infinity) and takes the union of the children's ideal sizes on nil axes. Guessing flexibility from the answered size instead made a layout nested in another custom layout look rigid at a stale size, and the two then traded answers forever. - **Place**: when `frames` has one entry per child, child *i* is proposed `frames[i]`'s width and height and gets exactly that frame, relative to the element's origin (nothing is re-measured beyond that). Otherwise the children are proposed the element's size and centered in it, like a `zstack`. - After every layout pass the renderer builds, for each `layout` element that has a frame (hidden tabs are skipped, like `geometry`), the record ```jsonc { "type": "layout", "id": 12, "proposal": { "width": 393, "height": 759 }, // the proposal the element was *placed* with "children": [ // one per child, in order { "min": { "width": 10.2, "height": 66 }, // measured with { width: 0, height: 0 } "ideal": { "width": 30.6, "height": 22 }, // measured with { width: null, height: null } "max": { "width": "infinity", "height": "infinity" } } ] } // measured with Infinity × Infinity ``` Numbers are rounded to 2 decimals; a nil proposal axis is `null` and an unbounded one (a `Color`, a `Spacer`, `maxWidth: "infinity"`) is the string `"infinity"`. `min`, `ideal` and `max` are the engine's own answers to those three proposals (a `text` proposed width 0 breaks per character, as in a stack). The event goes through the same channel as `geometry` events, **only when the JSON of the record differs from the last one sent for that id**; the per-id cache is cleared when the element is removed. Swift answers in a later pass with an `update` carrying `proposal`, `size`, `frames` and `sizes`, which lays the tree out again; an unchanged record sends nothing, so the loop converges. A cycle guard covers the remaining case of nested layouts trading answers: a record already sent for that id within the last second is not sent again (a `[sb] layout N: measurements cycle` warning is logged once per id) and Swift's latest answer stands; after the window the same record counts as data that changed back and is sent. The renderer exposes the last sent records as `renderer.reportedLayouts` (a `Map`). #### `grid` / `gridrow` - Each `gridrow` child is a row whose children are the cells; any other child of the grid is a row with one cell spanning every column. A cell's span is its `gridCellColumns` (read through its `styled` wrappers), default 1. - Column count = the maximum over rows of Σ spans (at least 1). - A column is **flexible** when it has no non-spanning cells or all of its non-spanning cells are flexible, i.e. report a width ≥ the proposal width when proposed `{ width: proposal width (Infinity when nil), height: null }`. Other columns are fixed at the largest ideal width (nil proposal) of their non-spanning cells. - With a finite proposal width the flexible columns share, equally and at least 0, what is left after the fixed columns and `horizontalSpacing` × (columns − 1). Without one they take their cells' largest ideal width. - A cell's box width = the widths of its spanned columns + `horizontalSpacing` between them; a full-span cell gets the whole grid width. Row height = max over the row's cells of `measure(cell, { width: cell width, height: null }).height`. - Grid size = (Σ column widths + spacing, Σ row heights + spacing). The grid does not fill a proposal that has no flexible column. - Placement: rows top to bottom, cells left to right; each cell is aligned in its box by `gridCellAnchor`, else horizontally by the grid `alignment` and vertically by the row `alignment` (`top` / `firstTextBaseline` → 0, `center` → 0.5, `bottom` / `lastTextBaseline` → 1; `null` → the grid alignment's vertical anchor). Every `gridrow` gets a frame (the grid width × its row height). #### `lazyvgrid` Columns are resolved for the proposal width `W`: - `fixed` → `value`. - With `W` finite: `R = W − Σ fixed − Σ gaps`, where the gap after item *i* is that item's `spacing` (default 8) and there is one gap between consecutive items. Flexible and adaptive items share `R` equally; `flexible` → that share clamped to `[minimum, maximum]`; the width left after the flexible items, `W'`, is split equally between the adaptive items, each of which becomes `n = max(1, floor((W' + s) / (minimum + s)))` columns of `(W' − (n − 1)·s) / n` clamped to `[minimum, maximum]`, `s` being the item's spacing (also used between its columns). - With `W` nil: `flexible` and `adaptive` → one column of the cells' largest ideal width clamped to `[minimum, maximum]`. - An empty `columns` list is one flexible column (minimum 10). Cells fill rows left to right, one per resolved column; row height = max cell height measured with `(column width, null)`. Size = (`W`, or the columns plus gaps when `W` is nil; Σ row heights + `spacing` (default 8) between rows). When the columns are narrower than `W` they are offset inside it by the grid `alignment`'s horizontal anchor. Each cell is aligned in its box (column width × row height) by the column item's `alignment`, else the grid's. All cells render; nothing is lazy. #### `gauge` - Width = the proposal width (100 when nil); height = 4 + (label row + 4 when there is a label or a current value label). The label row is the taller of the two labels measured in the `caption` text style with the gauge width. - The label is placed leading and the current value label trailing on the same row (centered vertically in it); the bar (chrome `track`, 4px, rounded, `var(--sb-color-fill)`) is below them, and chrome `fill` is the leading `value` share of the track, colored `var(--sb-tint, var(--sb-color-accent))`. `value` is clamped to 0…1. - Circular styles (`accessoryCircular`, `accessoryCircularCapacity`) render as linear for now. - The element has `role="meter"` with `aria-valuenow`, and `data-sb-style`. #### `decorated` Size = the content measured with the proposal. The decoration is proposed the content's size, aligned inside it by `alignment` and placed with that proposal; it never affects the size. For `overlay` the decoration (the later child) draws above the content; for `background` the content is raised (`z-index: 1`, the box is an isolated stacking context) so the decoration draws below it. #### `position` In the `styled` nesting (frame > padding > fixedSize > aspectRatio > child) the positioned box replaces the child step: it reports the proposal it is handed (the child's ideal size on nil or infinite axes) and places the child, measured with a nil proposal, with its center at (x, y) inside that box. ### Events (added) ```jsonc { "type": "layout", "id": 12, "proposal": { "width": 393, "height": null }, "children": [ { "min": {...}, "ideal": {...}, "max": {...} } ] } { "type": "batch", "events": [ { "type": "geometry", ... }, { "type": "layout", ... } ] } ``` Sent through `sb_event_json` like `geometry`; see "`layout`" above for when. When a layout pass produces more than one `geometry` / `layout` event the renderer sends them as one `batch`: Swift applies every event in it and renders once, instead of once per element (a screen with eight custom layouts otherwise re-rendered eight times per pass). A batch is never nested. `sb_run_jobs` runs once after the batch as after any event. ## Phase 6: menus, toolbars, search, tap gestures and list row hints Everything in `docs/ops-protocol.md` still holds. This document is the renderer-side contract for the Phase 6 chrome features: the exact encodings the Swift side emits and the layout rules the web renderer (`Web/`) applies. ### New element kinds | kind | props | children | |---------------|-------|----------| | `menu` | `{}` | `[label, ...items]` | | `toolbar` | `placement: "trailing" \| "leading" \| "principal" \| "bottom"` | the items (buttons, menus, navlinks, texts, images) | | `searchfield` | `text: string`, `placeholder: string` (default `"Search"`) | none | | `tapgesture` | `{}` | exactly one | ### `picker` style (added) `style` gains `"inline"`: `Picker` with `.pickerStyle(.inline)`, or the default style of a `Picker` inside a `Menu`. ### `styled` style (added fields) | field | meaning | |-------|---------| | `badge: string \| null` | `.badge(_:)`; only meaningful on a list row (a direct child of a `list`, or of a `section` in a list) | | `listRowInsets: { "top", "leading", "bottom", "trailing" }` | `.listRowInsets(_:)`; replaces the row's default insets for that row | | `imageScale: "small" \| "medium" \| "large"` | `.imageScale(_:)`; symbol images in the subtree scale by 0.8 / 1 / 1.25 | ### `navscreen` children (changed) A `navscreen` had exactly one child, its content. It now has `[content, ...extras]`, where every extra is a `toolbar` or a `searchfield`. The renderer partitions the children by kind: the first child that is not an extra is the content (several such children form an implicit `VStack`, as before). ### Events Nothing new. `tapgesture` and the rows of a `menu` popover send the existing events: ```jsonc { "type": "tap", "id": 12 } // a tapgesture; a button or navlink item of a menu { "type": "select", "id": 9, "value": 1 } // an inline picker row, or a picker item in a menu popover { "type": "text", "id": 7, "value": "Mi" } // searchfield input, exactly like textfield ``` --- ### Layout and rendering rules #### `menu` - **Size**: the label's (children[0]) size for the proposal. The items are never laid out in place: they have no frames, are listed in `LayoutResult.hidden`, and the DOM gives them the `hidden` attribute (plus a CSS rule hiding every child of `.sb-menu` but the first), so they never show inline. - **Control**: the element is `role="button"`, `aria-haspopup="menu"`, focusable, drawn in the accent color like a plain button. Enter / Space activate it. - **Popover**: a tap opens the same popover a `menu` picker opens (250 wide, `--sb-color-menu-background`, 13px radius, scrim-less; a click anywhere outside closes it and is swallowed, Escape closes it), anchored with its trailing edge on the label's trailing edge, 4 below the label when it fits, else 4 above, clamped 8 from the screen edges. The rows are built from the **current** item children each time it opens, in order: - `button` / `navlink` → a 44pt row (`role="menuitem"`) showing the subtree's text (all `text` descendants joined with spaces) and, trailing, its first `image` with a `systemName`; a `role: "destructive"` button is red; a `styled { disabled: true }` wrapper disables the row. Clicking sends `{ "type": "tap", "id": }` and closes the popover. - `picker` (any style) → one 44pt `menuitemradio` row per `options[i]` with a checkmark on `selected`, as a group separated from its neighbours by an 8pt band. Clicking sends `{ "type": "select", "id": , "value": i }` and closes the popover. - `divider` → an 8pt separator band (never doubled). - `section` → its `header` as a 32pt dim caption, then its children as rows. - `text` → a 44pt static (disabled) caption row. - a nested `menu` → its label text as a caption, then its items. - any other container → its children, in order. - An `update` or `remove` of the menu or of anything inside it closes an open popover (the popover reflects what the items were when it opened). #### `picker` style `inline` - **Size**: height = (number of options + 1 if `label` is non-empty) × 44; width = the proposal, or the widest row (label width, or option width + 28 for the checkmark and its gap) when none is given. - **Rows**: the label, when non-empty, is a leading dim (secondary color) caption row; each option is a row with the title leading and an accent checkmark trailing on the selected one (`role="radio"`, `aria-checked`). Hairlines separate the rows. - **In a list / form**: the picker is one row of its section whose height is options × 44 (no extra vertical padding, like a `menu` picker row), and its rows span the full list row width with the usual 16pt content inset, so they read as rows of that section. Elsewhere the rows span the control's frame. - Tapping a row sends `select` with its index (the checkmark moves at once; Swift's `update` confirms or reverts it). #### `toolbar` in a `navscreen` - Items are measured at their **ideal size** (a nil proposal) and laid out as an hstack with 16pt spacing, vertically centered in the 44pt bar row (the row under the status bar, also for a large title). The `toolbar` element's own frame is the group's rect; the items are ordinary elements with ordinary frames, so buttons, menus and navlinks inside work as anywhere else (the bar never swallows their clicks). - `trailing`: the group ends 16pt from the trailing edge; several trailing toolbars stack leftwards, 16pt apart. `leading`: the group starts 8pt after the back button (16pt from the leading edge when there is none). `principal`: the group is centered in the row and the inline title is not drawn. `bottom`: the toolbar's frame is a bar of 49pt plus the bottom safe area (when the screen reaches the screen bottom) at the bottom, with the tab bar's translucent background and hairline; its items are spread evenly across it (16pt from the edges, equal gaps, a single item centered) and vertically centered in the 49pt row. The screen content is proposed the area **above** a bottom bar. - The inline title is centered and truncated to the room the back button and the leading / trailing items leave: `chrome.title` is a rect in the 44pt row, no wider than 56% of the bar and no wider than `bar width − 2 × (max(left occupied, right occupied) + 8)`. The back button label picks "previous title" / "Back" / chevron-only as before, with the leading items' width subtracted from the room it has. - Outside a `navscreen` a `toolbar` lays its children out as a plain vstack. #### `searchfield` in a `navscreen` - Adds **52pt** to the bar height under the title area (status bar + 44, + 52 for a large title): 8pt, the 36pt field, 8pt. The field is 16pt from each side (`chrome.searchField`), the element's frame is the field. - Rendering: `--sb-color-fill` background, 10pt radius, a `magnifyingglass` glyph leading in the secondary color, then an `` (17pt, primary color) whose placeholder is `placeholder` (`"Search"` when empty) in the secondary color. - Typing sends `{ "type": "text", "id", "value" }` on every input event, exactly like `textfield`; an `update` carrying the `text` the input already shows leaves the caret alone, a different `text` replaces the value. - Outside a `navscreen` it is a 36pt field taking the proposed width (200 when none). #### `tapgesture` - Size = the child's; the child is placed at the same origin. - `cursor: pointer`. A click anywhere on it sends `{ "type": "tap", "id" }` **unless** the click landed on an inner interactive element (a button, navlink, picker, menu, switch…), which handles it alone: the renderer resolves the innermost control under the pointer, so an inner button's tap never also fires the gesture. #### List row hints Read through the row element's `styled` chain (outermost wins). - **Default row geometry**: content proposed the card width minus 16pt on each side; row height = max(44, content + 2 × 11); content centered in the row. (Picker rows use 0 vertical padding, as before.) - `listRowInsets: { top, leading, bottom, trailing }` replaces all four values for that row: content x = card x + `leading`, content width = card width − `leading` − `trailing` (− the badge reserve), row height = max(44, content + `top` + `bottom`), content centered between `top` and `bottom`. The DOM row box is still the full card width; only the content moves. - `badge: "text"` (non-empty) reserves `width(text) + 8` at the trailing edge: the content is proposed that much less width, and the text is drawn in the row's font (17pt body by default), secondary color, right-aligned flush with the trailing inset, full row height (`chrome.badge`, `chromeText.badge`). The DOM adds a `.sb-badge` span inside the row's `styled` element. `null` or `""` reserves nothing. #### `imageScale` `image` elements with a `systemName`, and catalog images of `kind: "symbol"`, measure a square of `line height × scale` (scale 0.8 / 1 / 1.25 for `small` / `medium` / `large`) instead of the plain line height, and are drawn at `font size × scale` so the glyph (1em) scales with the frame. Text and bitmap images are unaffected. The scale flows down the subtree like a font; an inner `imageScale` replaces an outer one. ### `LayoutResult` additions | kind | chrome keys | |------|-------------| | `navscreen` | `title` (inline title rect; absent for a large title or with a `principal` toolbar), `searchField`, `bottomBar` | | `picker` (inline) | `label` (when non-empty), `option0` … `optionN-1` | | list rows | `badge` (with `chromeText.badge`) | `hidden` also lists the items of every `menu`. ## Phase 6: ShapeStyles, stroke borders, visual effects, asymmetric transitions Everything in `docs/ops-protocol.md` still holds. Phase 6 widens what a view can be *painted* with, adds a few CSS-only visual effects to `styled`, and one transition form. The Swift side emits exactly the JSON below; the web renderer resolves it in `Web/src/styles.ts` (pure, unit-tested) and applies it in `Web/src/renderer.ts`. ### ShapeStyle Wherever a `Color` used to be accepted for painting — `style.foreground`, `style.background`, a `shape`'s `fill` and `stroke.color` — the value is now a **ShapeStyle**. The three `Color` forms are ShapeStyles and render exactly as before: ```jsonc { "r": 0.0, "g": 0.478, "b": 1.0, "a": 1.0 } // explicit sRGB { "name": "primary" | "secondary" | "accent" | "systemBackground" | "secondarySystemBackground" | "clear", "a": 0.5 } { "named": "dough/plain-bg", "opacity": 1.0 } // asset catalog color ``` `{ "name": "systemBackground" }` is what `.background()` with no arguments and `BackgroundStyle` emit; it works as a background, fill and stroke (`var(--sb-color-system-background)`). The new forms: ```jsonc { "hierarchical": 1 | 2 | 3 | 4 } // .primary / .secondary / .tertiary / .quaternary { "linearGradient": { "stops": [{ "color": Color, "location": 0.0 }, ...], "start": { "x": 0.5, "y": 0 }, "end": { "x": 0.5, "y": 1 } } } { "radialGradient": { "stops": [...], "center": { "x": 0.5, "y": 0.5 }, "startRadius": 0, "endRadius": 100 } } { "colorGradient": Color } // Color.gradient { "material": "ultraThin" | "thin" | "regular" | "thick" | "ultraThick" } ``` Points (`start`, `end`, `center`) are **unit points** in the view's bounds; radii are points; stop `location` is 0…1. Every non-color form may carry an optional top-level `"opacity": number` (0…1) multiplier — `{ "hierarchical": 4, "opacity": 0.5 }` for `.quaternary.opacity(0.5)`, `{ "material": "thin", "opacity": 0.8 }`, `{ "linearGradient": {...}, "opacity": 0.5 }`, `{ "colorGradient": {...}, "opacity": 0.5 }`. It is applied like a semantic color's `a`: hierarchical colors and every gradient stop are wrapped in `color-mix(in srgb, C p%, transparent)`; a material's alpha is multiplied. `ShapeStyle.shadow(_:)` (`.drop(...)` / `.inner(...)`) is an approximation: the shadowed style serializes as its base style and the shadow is dropped. #### Resolution by role A ShapeStyle resolves differently depending on whether it colors glyphs (**text** role: `style.foreground`) or fills an area (**paint** role: `style.background`, shape `fill`, `stroke.color`). `styleToCSS(style, role)` returns `{ kind, value, solid, backdropFilter? }`: `value` is what to paint with (a ``, or a CSS `` for gradients), `solid` a plain `` stand-in used where an image cannot go (border colors, `currentColor` consumers such as symbols): the value itself for flat styles, the first stop for gradients, the base color for materials. | style | text role | paint role | |-------|-----------|------------| | `hierarchical: 1` | `var(--sb-color-primary)` | `var(--sb-color-fill)` | | `hierarchical: 2` | `var(--sb-color-secondary)` | `var(--sb-color-secondary-fill)` | | `hierarchical: 3` | `var(--sb-color-tertiary-label)` | `var(--sb-color-tertiary-fill)` | | `hierarchical: 4` | `var(--sb-color-quaternary-label)` | `var(--sb-color-quaternary-fill)` | | `linearGradient` | `linear-gradient(deg, color pct%, …)` | same | | `radialGradient` | `radial-gradient(circle px at % %, color pct%, …)` | same | | `colorGradient: C` | `linear-gradient(to bottom, color-mix(in srgb, C, white 12%), color-mix(in srgb, C, black 12%))` | same | | `material` | the material's base color (see below) | `rgba(var(--sb-color-material-base), α)` + `backdrop-filter: blur(20px)` | Levels outside 1…4 degrade to 1; an unrecognised object renders magenta. New theme variables in `Web/src/styles.css`: | variable | light | dark | |----------|-------|------| | `--sb-color-quaternary-label` | `rgba(60,60,67,0.18)` | `rgba(235,235,245,0.16)` | | `--sb-color-secondary-fill` | `rgba(120,120,128,0.16)` | `rgba(120,120,128,0.32)` | | `--sb-color-tertiary-fill` | `rgba(118,118,128,0.12)` | `rgba(118,118,128,0.24)` | | `--sb-color-quaternary-fill` | `rgba(116,116,128,0.08)` | `rgba(118,118,128,0.18)` | | `--sb-color-material-base` | `255, 255, 255` | `30, 30, 30` | (`--sb-color-fill` already existed: light `rgba(120,120,128,0.2)`, dark `rgba(120,120,128,0.36)`.) **Linear gradient angle.** CSS measures gradient angles clockwise from "to top", so the angle is `atan2(dx, −dy)` with `d = end − start`: top→bottom is 180deg, leading→trailing 90deg, topLeading→bottomTrailing 135deg. A CSS gradient line always spans the whole box, so stop locations are mapped onto the projections of `start` and `end` on that line — exact for axis-aligned gradients (a gradient from `y: 0.25` to `y: 0.75` puts its stops at 25% and 75%), approximated on a unit square otherwise. `start == end` falls back to 180deg. **Radial gradient.** Stops are placed at `(startRadius + location · (endRadius − startRadius)) / endRadius`. A missing or non-positive `endRadius` uses `circle farthest-corner` with the raw locations. **Materials.** α is 0.55 (`ultraThin`), 0.7 (`thin`), 0.82 (`regular`), 0.9 (`thick`), 0.96 (`ultraThick`), times the optional `opacity`. The renderer sets both `backdrop-filter` and `-webkit-backdrop-filter`. #### Where each role lands - `style.background` → `background-color` (colors, materials; materials also get the backdrop filter) or `background-image` with a transparent `background-color` (gradients) on the styled box. - `style.foreground` → `color` and `--sb-button-color` on the styled box (the `solid` value). For a gradient the box additionally sets the inherited custom properties `--sb-text-gradient` (the image), `--sb-text-clip: text`, `--sb-text-color: transparent` and `--sb-foreground-paint` (the image), and `data-sb-gradient-text`. The `.sb-text` rule reads them: `background-image: var(--sb-text-gradient, none); background-clip: var(--sb-text-clip, border-box); color: var(--sb-text-color)`, so every text in the subtree — including text under nested `styled` boxes — paints the gradient through its glyphs. A styled box with a *flat* foreground resets the four properties to `initial`, which ends the gradient for its subtree (with the properties unset the `.sb-text` declarations are invalid at computed-value time and fall back; `color` becomes `unset`, i.e. inherits as before). Symbols (`currentColor`) and other `currentColor` consumers get the first stop; a `shape` with `fill: null` paints `var(--sb-foreground-paint, currentColor)`, so it shows the gradient too. - shape `fill` → the shape's `background-color` / `background-image` (materials with backdrop filter). - shape `stroke.color` → the border color. CSS borders are flat, so a gradient stroke uses its first stop (`solid`); a material stroke its base color. ### Shape stroke border and `containerRelative` ```jsonc "stroke": { "color": ShapeStyle, "lineWidth": 2, "inset": true } ``` `inset: true` is `.strokeBorder(_:lineWidth:)`: the stroke lies fully inside the shape's frame. The renderer draws every stroke as a CSS `border` on a `box-sizing: border-box` box, which is already inside the frame, so the look of a non-inset stroke is unchanged from before. Which one it was is recorded as `data-sb-stroke="inset" | "center"` on the shape element. `shape` gains the name `"containerRelative"` (`ContainerRelativeShape`), rendered like `roundedRectangle` with the given `cornerRadius`. ### Visual effect style hints New `styled` style keys. They are applied as CSS on the styled box and have **no layout effect**: the layout engine sizes and places the subtree as if they were not there. ```jsonc { "offset": { "x": 10, "y": -4 }, // .offset: points "rotation": 45, // .rotationEffect: degrees, clockwise positive "rotationAnchor": { "x": 0.5, "y": 0.5 }, // unit point, default center "scale": { "x": 1.5, "y": 1.5 }, // .scaleEffect "scaleAnchor": { "x": 0.5, "y": 0.5 }, // unit point, default center "shadow": { "color": Color, "radius": 8, "x": 0, "y": 4 }, "clipped": true } ``` CSS: - one `transform`, composed in this order: `translate(xpx, ypx)` `rotate(deg)` `scale(x, y)` (identity parts are left out; no transform is set when nothing applies); - `transform-origin` from the anchor as percentages (`x·100% y·100%`). CSS has a single origin for the whole transform, so when both `rotationAnchor` and `scaleAnchor` are present and differ the **rotation anchor wins**; without any anchor the origin stays at the default center; - `shadow` → `filter: drop-shadow(xpx ypx radiuspx color)` (follows the alpha of images and text like SwiftUI's `.shadow`); - `clipped: true` → `overflow: hidden` and `data-sb-clipped` on the box. Animation: `transform` and `filter` are part of the Animator's animated style properties, so in an animated commit (`withAnimation`) or a subtree whose `animationToken` changed, a changed rotation/offset/scale/shadow tweens from the previous computed value. Frame animations tween `left`/`top`/`width`/ `height` and do not touch the transform. Enter and exit transitions compose their own transform on top of the box's (` ` → ``), so a rotated view slides in while staying rotated. ### Asymmetric transitions `transition` parts gain: ```jsonc { "kind": "asymmetric", "insertion": Transition, "removal": Transition } ``` where each side is a part or an array of parts (and may itself contain `asymmetric` parts; nesting is capped at 8 levels). When the subtree is inserted the renderer plays `insertion`; when it is removed, the exit clone plays `removal`. It combines with sibling parts of the enclosing array like any other part: ```jsonc [{ "kind": "opacity" }, { "kind": "asymmetric", "insertion": { "kind": "move", "edge": "leading" }, "removal": { "kind": "scale", "scale": 0.2 } }] ``` ### TypeScript `Web/src/protocol.ts`: `ShapeStyle = Color | HierarchicalStyle | LinearGradientStyle | RadialGradientStyle | ColorGradientStyle | MaterialStyle` (`Color` is unchanged and remains the type of `color.color`, gradient stops and `shadow.color`), `UnitPoint`, `GradientStop`, `Shadow`, `Stroke.inset`, `ShapeKind` + `containerRelative`, `TransitionPart.insertion/ removal`, and the `Style` effect keys. `Web/src/styles.ts`: `colorToCSS`, `styleToCSS(style, role)`, `linearGradientCSS`, `radialGradientCSS`, `effectsCSS(style)`. Tests: `Web/src/__tests__/styles.test.ts` (resolution), `Web/e2e/phase6-styles.spec.ts` (computed styles in Chromium). ## Phase 7: charts, split navigation, `ViewThatFits`, masks, size classes Everything above still holds. Phase 7 brings the Food Truck sample's Swift Charts screens, its `NavigationSplitView` root and the City panel to the browser. The Swift side (`Sources/Charts`, the shim) turns data into **unit coordinates**; the renderer owns pixels: it measures axis labels and annotation views, derives the plot rectangle and draws the marks as SVG. ### Environment event (extended) ```jsonc { "type": "environment", "colorScheme": "dark", "dynamicTypeSize": "large", "horizontalSizeClass": "compact" } // or "regular" ``` The renderer sends `regular` when the screen (`#screen`, the frame or the real viewport) is at least **700 CSS px** wide, else `compact`, and re-sends the event when that changes (a resize or rotation). Swift exposes it as `EnvironmentValues.horizontalSizeClass` (`UserInterfaceSizeClass?`; `.compact` until the first event). Headless runs are compact. ### Device mode (changed) A coarse pointer now means device mode at any width (a tablet fills the viewport instead of showing the phone bezel); the 500 px rule is gone. `?mode=frame` / `?mode=device` still force a mode. ### New element kinds | kind | props | children | |---------------|-------|----------| | `chart` | `marks: Mark[]`, `xAxis: Axis \| null`, `yAxis: Axis \| null`, `legend: Legend \| null` | `chartitem`s (annotations and custom axis labels), in any order | | `chartitem` | `role: "annotation" \| "xLabel" \| "yLabel"`, `x0, x1, y0, y1: number` (unit rect of the mark; for labels `x0 == x1` / `y0 == y1` is the tick), `position: "top" \| "bottom" \| "leading" \| "trailing" \| "overlay"`, `alignment: Alignment`, `spacing: number` | one view | | `navsplit` | none | `[sidebar, detail]`, each a `navstack` | | `viewthatfits`| `axes: "horizontal" \| "vertical" \| "both"` | the candidates, in order | #### Unit coordinates Every chart coordinate is a number in 0…1 relative to the **plot rectangle** (the chart's box minus axes and legend, computed by the renderer): `x` runs left → right, `y` runs **top → bottom** (so the largest value is `y = 0`, like every other box in this protocol). Swift applies the scales: a continuous axis maps `(value − min) / (max − min)` with the "nice" domain it chose; a categorical (band) axis gives category *i* of *n* the band `[i/n, (i+1)/n]`. Values outside 0…1 are allowed (a mark may leave the plot) and are clipped by the renderer to the plot rectangle, except annotations. #### `Mark` All marks carry `style: ShapeStyle` (Phase 6 JSON: a `Color`, `linearGradient`, `radialGradient`, `hierarchical`, …), `opacity: number` (0…1, default 1) and an optional `mask: Mark` (an `area` or `rect` mark whose geometry clips this mark; the renderer renders it as an SVG `clipPath`). `kind` selects the rest: | `kind` | fields | drawn as | |--------|--------|----------| | `rect` | `x0, x1, y0, y1` (unit rect, `x0 ≤ x1`, `y0 ≤ y1`), `cornerRadius: number` (px, default 0), `fixedWidth: number \| null` (px: the rect is `fixedWidth` wide, centered on `(x0 + x1) / 2`) | a filled `` (`BarMark`, `RectangleMark`) | | `line` | `points: [{ x, y }]`, `lineWidth: number` (px, default 2), `interpolation: Interpolation`, `symbol: Symbol \| null`, `symbolSize: number` (px, default 8) | a stroked `` plus one symbol glyph per point (`LineMark`) | | `area` | `points: [{ x, y0, y1 }]` (`y0 ≤ y1`), `interpolation: Interpolation` | a filled `` between the two curves (`AreaMark`) | | `point` | `x, y`, `symbol: Symbol`, `symbolSize: number` (px, default 8) | one symbol glyph (`PointMark`) | | `rule` | `x0, x1, y0, y1`, `lineWidth: number` (px, default 1) | a stroked line between the two points (`RuleMark`) | `Interpolation` is `"linear" | "cardinal" | "catmullRom" | "monotone" | "stepStart" | "stepCenter" | "stepEnd"`; the renderer draws `cardinal`, `catmullRom` and `monotone` as a Catmull-Rom spline through the points (cubic Béziers) and the steps as right-angle paths. `Symbol` is `"circle" | "square" | "triangle" | "diamond" | "cross" | "plus" | "asterisk"`; it is drawn in the mark's style, centered on the point. Marks draw in array order. #### `Axis` ```jsonc { "position": "bottom", // x: "bottom" | "top"; y: "trailing" | "leading" "gridLines": [0.0, 0.25, 0.5, 0.75, 1.0], // unit positions along the axis "ticks": [0.0, 0.25, 0.5, 0.75, 1.0], "labels": [{ "at": 0.125, "text": "Mon" }, …] } // default text labels ``` Positions are unit values along that axis (for the y axis `0` is the top). A label whose content is a view instead of text is not in `labels`: it is a `chartitem` child with role `xLabel` / `yLabel` and the tick position in `x0 == x1` (x) or `y0 == y1` (y). `null` for the whole axis means hidden. #### `Legend` ```jsonc { "position": "top", // "top" | "bottom" | "leading" | "trailing" "items": [{ "label": "Cupertino", "style": ShapeStyle, "symbol": Symbol | null }] } ``` `null`: no legend. Swift builds one entry per distinct `foregroundStyle(by:)` / `symbol(by:)` value, in first-seen order, with SwiftUI's chart palette (blue, green, orange, purple, red, teal, yellow, pink, indigo, mint, cyan, brown, in that order, cycling). #### Layout rules: `chart` - **Size**: the proposal on both axes; a nil axis is 300 (width) / 200 (height) and an infinite axis stays infinite (the chart is as flexible as a shape). The chart's box is split into the legend row (top or bottom; a leading / trailing legend is a column), the axis bands and the plot. The legend is separated from the rest by 8 px. - **Axis bands**: the y-axis band is as wide as its widest label (text labels measured in the axis font, `yLabel` children at their ideal width) plus 6 px, 0 when there are no labels; the x-axis band is as tall as its tallest label (text: one line of the axis font; `xLabel` children at their ideal height) plus 6 px. The 6 px lie between the plot edge and the label (the ticks sit inside them). A `top` x axis puts its band above the plot, a `leading` y axis its band to the left. The axis font is `caption` (12 px) in the secondary color. Grid lines are 1 px in `--sb-separator`; ticks are 1 px, 4 px long, outside the plot, same color. - **Plot**: what remains. Marks are drawn into one `` the size of the plot (chrome; `data-sb-id`-less) with `overflow: hidden`. The engine reports the plot, the legend, the axis bands, every text label and every legend item as chrome rects (`plot`, `legend`, `xband`, `yband`, `xlabel`, `ylabel`, `legend`). - **Labels**: an x text label is centered on `at`, clamped to the chart's width; a y text label is right-aligned (trailing axis: left-aligned) to the band, vertically centered on `at` and clamped to the chart's height. `xLabel` children: ideal size, centered horizontally on `x0`, against the plot edge of the x band (6 px away from the plot). `yLabel` children: ideal size, centered vertically on `y0`, against the plot edge of the y band. - **Annotations** (`role: "annotation"`): the child is measured at its ideal size and placed against the mark's pixel rect `R` (`x0…x1`, `y0…y1` mapped into the plot): `top` → centered above `R`, bottom edge at `R.top − spacing`; `bottom` → below, top edge at `R.bottom + spacing`; `leading` / `trailing` → beside, centered vertically; `overlay` → centered on `R`. `alignment` shifts it along the free axis the way `ZStack` alignment does (`top` + `.leading` puts its leading edge on `R.left`). Annotations are not clipped to the plot and draw above the marks. - **Legend items**: a 10 px symbol glyph (or an 18 × 3 px line when `symbol` is null) in the item's style, then the label in `caption` (12 px) secondary text, 4 px apart; items flow horizontally with 12 px between them and wrap at the chart's width into rows 4 px apart (a leading / trailing legend puts one item per row and is as wide as its widest item). Each item is one line of the caption font tall (16 px at the default size) and its width is rounded up to whole pixels. - **Styles in SVG**: a `Color` paints with the same CSS it does elsewhere (semantic and named colors through the `--sb-color-*` tokens, the chart palette names included); `hierarchical` levels use the fill tokens; a `linearGradient` becomes an SVG `` in object bounding box units (`start` → `x1 y1`, `end` → `x2 y2`), a `radialGradient` a `` in user space centered on the mark's bounds with `startRadius` / `endRadius` in px; `colorGradient` a vertical light → dark gradient of its color; a `material` a translucent gray (no backdrop blur inside the SVG). The style's `opacity` multiplies the stops, the mark's `opacity` the whole mark. #### Swift side How the shim (`Sources/SwiftUI/Charts`, re-exported by `Sources/Charts`) fills the JSON above; these refine the spec rather than change it: - **Domains.** A continuous y scale includes 0 by default for every mark kind (as in Swift Charts; `chartYScale(domain: .automatic(includesZero: false))` fits the data); a continuous x scale never adds 0. Numeric ticks use steps of 1, 2, 2.5, 5 × 10ᵏ (the one nearest to `span / (desiredCount − 1)`, about 5 ticks by default, at least `minimumStride`); with `roundLowerBound` / `roundUpperBound` (default true) the domain extends to the first and last tick, otherwise ticks outside the data are dropped. Explicit `AxisMarks(values: [...])` are used verbatim and never round the domain. A hidden axis still rounds the domain like a default one. Masks' marks do not contribute to the domain. - **Dates** are UTC epoch seconds; the tick unit follows the span: up to 3 days, every 1 / 3 / 6 / 12 hours ("9 AM"); up to 45 days, every 1 / 2 / 7 / 14 days ("Sep 30"); beyond, every 1 / 2 / 3 / 6 months ("Jan", or "Jan 2026" when the ticks cross a year) or 1 / 2 / 5 / 10 years ("2026"). The stride is the smallest giving at most `desiredCount + 1` (default 8) intervals. Explicit date values are labelled by their own granularity. - **Series.** `LineMark`s / `AreaMark`s with the same key (`series:`, else the `foregroundStyle(by:)` value, else the `symbol(by:)` value, else none) form one `line` / `area` mark wherever they appear in the content, with their points in x order. The mark's style, line width, symbol and interpolation come from the group's first mark. A mark with no style and no `by:` value is `Color.blue`. - **Bars** on a band axis are 0.8 of the band; on a continuous x axis the band is the smallest gap between bar centers. `MarkDimension.ratio` scales the band, `.inset` is treated as `.automatic`, `.fixed` sets `fixedWidth` with `x0 == x1` at the center. Bars are not stacked. - **Symbols** for `symbol(by:)` cycle circle, square, triangle, diamond, cross, plus, asterisk (`.pentagon` draws as `diamond`). `symbolSize(_:)` takes an area and emits its square root as the diameter. - **Annotations** anchor to the mark's rect (bars, rectangles), the rule's segment, or the point at `(x, y)` / `(x, yEnd)` for lines, areas and points; `.topLeading` and friends become `top` / `bottom` with the implied `alignment`. The default `spacing` is 4. ### `NavigationSplitView` `NavigationSplitView { sidebar } detail: { detail }` renders by size class: - **Regular**: a `navsplit` with two children. The sidebar child is a `navstack` whose root `navscreen` holds the sidebar content (its title from `.navigationTitle`, `displayMode` large). The detail child is the detail view's own `NavigationStack` (a `navstack`), or one the shim wraps around it. Layout: the sidebar column is **320 pt** wide (at most 40 % of the width left by the horizontal safe-area insets), the detail column takes the rest; a 1 px `--sb-color-separator` hairline sits between them. Each column's `navstack` fills its column (the renderer's chrome `sidebar`, `separator` and `detail` rects); the safe area's top and bottom insets apply to both columns (each column's screens reserve the status bar and the bottom inset exactly as a root `navstack` does), its leading inset to the sidebar and its trailing inset to the detail: the sidebar's `navstack` starts at the leading inset and the detail's ends at the trailing inset, so the column rects include the insets and the navstacks exclude them. A `navsplit` is only laid out as a root element (like a root `navstack` it ignores the safe areas itself). - **Compact**: a plain `navstack` rooted at the sidebar content. While the sidebar's selection (below) is non-nil (also at launch), Swift pushes one `navscreen` with the detail view's content; links inside the detail push after it; the back button pops it and clears the selection. A `NavigationStack` **nested inside a `navscreen`** of another stack is flattened: it renders its root content in place, its `navigationDestination`s register with the enclosing stack and its links push onto that stack (its `path` binding is ignored). **List selection.** `List(selection: Binding)` makes its rows selectable: a `NavigationLink(value:)` row inside it sets the selection to its value instead of pushing when the list is a `NavigationSplitView` sidebar (in compact mode the split view then pushes the detail, see above; outside a split view the link still pushes). The selected row's `navlink` carries `selected: true` and the renderer draws it with a `--sb-color-tertiary-fill` rounded (10 px) background (sidebar style, `data-sb-selected` on the row) and no chevron when inside a `navsplit` sidebar; elsewhere (the detail column, a plain stack) the chevron stays. The label keeps reserving the chevron's width either way, so selecting a row never reflows it. ### `ViewThatFits` Props `axes` (default `both`). The engine measures every child's **ideal** size and shows the first child whose ideal size fits the proposal on the restricted axes (`ideal.width ≤ proposal.width` when `horizontal` is restricted, same for height; a nil or infinite proposal axis always fits); when none fits, the last child. Only the chosen child is measured with the proposal and placed, and the element reports its size; the others get no frame and the renderer hides them (`hidden` attribute). Within one pass the choice is stable (it depends only on the proposal and the children). ### `styled` style (added fields) | field | meaning | |-------|---------| | `hidden: true` | `.hidden()`: the box keeps its layout but draws nothing (`visibility: hidden`, no input) | | `mask: ShapeStyle` | `.mask { Gradient / Color }`: CSS `mask-image` (and `-webkit-mask-image`, `mask-size: 100% 100%`) from the style (a `linearGradient` / `radialGradient` becomes the matching CSS gradient, a `Color` a solid mask whose alpha is the color's: `linear-gradient(rgba(0,0,0,a), rgba(0,0,0,a))`; a hierarchical style or material a solid mask at its `opacity`). Only style masks are supported; a view mask falls back to the unmasked view on the Swift side | ### Headless `chart` and `chartitem` elements appear in snapshots like any other; their props are stable for a pinned `SB_NOW`. ### TypeScript `src/protocol.ts`: `ChartProps`, `ChartMark` (union by `kind`), `ChartAxis`, `ChartLegend`, `ChartItemProps`, `ChartInterpolation`, `ChartSymbol`, `NavSplitProps`, `ViewThatFitsProps`, `Style.hidden`, `Style.mask`, `EnvironmentEvent.horizontalSizeClass`, `NavLinkProps.selected`. ## Phase 8: interaction Everything above still holds. Phase 8 adds what real apps do with their fingers: gestures, swipe actions and context menus on rows, edit mode with deletion and multi-selection, alerts and confirmation dialogs, full-screen covers, pull to refresh, the remaining controls (`Stepper`, `Slider`, `DatePicker`, `TextEditor`), plus 3D rotation, blur, safe-area bleed, repeating animations and the scene phase. The driver is `Examples/HackingWithSwift` (five of Paul Hudson's public-domain SwiftUI projects). ### Events (added) ```jsonc { "type": "drag", "id": 12, "phase": "began" | "changed" | "ended" | "cancelled", "translation": { "width": 40.5, "height": -3 }, // since the pointer went down, points "location": { "x": 120, "y": 30 }, // in the gesture element's box "startLocation": { "x": 80, "y": 33 }, "velocity": { "width": 310, "height": 0 } } // points per second (ended only; else 0) { "type": "longpress", "id": 12 } // 0.5 s hold without moving more than 10 px { "type": "step", "id": 7, "value": 1 } // Stepper: +1 / -1 { "type": "slide", "id": 7, "value": 0.35 } // Slider: the value in its range { "type": "date", "id": 7, "value": 1700000000 } // DatePicker: seconds since 1970, UTC { "type": "delete", "id": 31 } // a `listrow`: swipe-to-delete or the edit-mode minus { "type": "refresh", "id": 40 } // a `list` / `scrollview` with `refreshable` ``` `text` is reused by `texteditor`; `tap` by `alert` buttons, swipe actions, context-menu items and `listrow` selection. `drag` events are coalesced to one per animation frame while `changed`; `began` is sent once the pointer has moved `minimumDistance` (default 10 px, 0 for an immediate gesture), `ended` when the pointer lifts and `cancelled` when the browser takes the pointer (scroll, pointercancel). Headless: `drag:12:changed:40x-3`, `longpress:12`, `step:7:1`, `slide:7:0.35`, `date:7:1700000000`, `delete:31`, `refresh:40`. ### Environment event (extended) `"scenePhase": "active" | "inactive" | "background"` from the document's visibility and focus (`active` when visible and focused, `inactive` when visible but unfocused, `background` when hidden). Swift exposes it as `EnvironmentValues.scenePhase` (`ScenePhase`; `.active` by default). Every `environment` event carries it; the renderer sends a new one on `visibilitychange` and window `focus` / `blur` only when the phase actually changed (`window.__sb.scenePhase()` reads the current value). ### New element kinds | kind | props | children | |------|-------|----------| | `gesture` | `drag: bool`, `longPress: bool`, `minimumDistance: number`, `minimumDuration: number` | exactly one | | `swipeactions` | `trailing: number`, `leading: number`, `fullSwipeTrailing: bool`, `fullSwipeLeading: bool` | `[content, ...trailing actions, ...leading actions]`; actions are `button`s, each possibly inside `styled` wrappers (`.tint` is the `tint` style on such a wrapper and colors the action; `.disabled` likewise); role `destructive` is red | | `contextmenu` | none | `[content, ...items]`; items as in a `menu` | | `listrow` | `selectable: bool`, `selected: bool`, `deletable: bool` | one view (the row content) | | `alert` | `title: string`, `message: string \| null`, `style: "alert" \| "dialog"` | the `button`s, in order (a `role: "cancel"` button is drawn apart; none → the renderer adds "OK") | | `stepper` | `canIncrement: bool`, `canDecrement: bool` | the label (may be empty) | | `slider` | `value: number`, `min: number`, `max: number`, `step: number \| null` | `[label?, minimumValueLabel?, maximumValueLabel?]` as marked by `labels: ["label", "min", "max"]` in order present | | `datepicker` | `seconds: number`, `components: "date" \| "hourAndMinute" \| "dateAndTime"`, `min: number \| null`, `max: number \| null` | the label (may be empty; `labelsHidden` hides it) | | `texteditor` | `text: string` | none | `sheet` gains `detents: ["full"]` for `.fullScreenCover`: the card fills the screen (no corner radius, no grabber, no drag to dismiss) and slides up like a sheet. `list` and `scrollview` gain `refreshable: bool` and `refreshing: bool`; `list` gains `editing: bool`. Swift omits these when false (`refreshable` and `refreshing` are both present on a refreshable element, `editing` only while editing): absent means false. ### Gestures: `gesture` The element is transparent for layout (its child's size). The renderer tracks pointers on it: with `drag`, a pointer that moves `minimumDistance` starts a drag and the element receives `drag` events until the pointer lifts; the coordinates are relative to the element's own box. With `longPress`, a pointer held `minimumDuration` (default 0.5 s) without moving 10 px sends `longpress`. Taps still reach controls inside (a `drag` begins only after the distance is met; a long press suppresses the following click). A `gesture` inside a scrolling container claims the pointer once the drag begins on the axis the gesture wants; `drag` here is two-dimensional, so it claims immediately after `minimumDistance`. Swift applies `.gesture`, `.highPriorityGesture` and `.simultaneousGesture` identically (one wrapper each). `.onLongPressGesture` is a `gesture` with `longPress`. A `TapGesture` attached with `.gesture` is the Phase 6 `tapgesture` element, not a `gesture` (a gesture that both taps and drags nests the `tapgesture` inside the `gesture`); a gesture with neither drag, long press nor tap emits nothing. Combined gestures (`sequenced`, `simultaneously`, `exclusively`) merge into one `gesture` that tracks both, with the smaller distance / duration; the shim does not order or exclude them. On the Swift side a `cancelled` drag ends the gesture: `onEnded` runs with the event's value (so state an app resets in `onEnded` is reset), and `began` is handled like `changed`. A `longpress` runs `LongPressGesture`'s `onChanged(true)` and `onEnded(true)` together, and `.onLongPressGesture`'s `onPressingChanged` is told `true` then `false` around `perform`. Renderer details (src/interaction.ts): `began` carries the translation at the moment the distance is met (with `minimumDistance: 0` the pointer is owned from the pointer down and `began` goes out with the first move); `velocity` is the displacement over the samples of the last 100 ms; `cancelled` is sent when the browser cancels the pointer (a native scroll won). A pointer down in a text field (`input`, `textarea`) never starts a gesture, so text selection still works there; everywhere else a pointer session suppresses text selection. A right click on a `gesture` with `longPress` shows no browser menu. When several behaviours could own one pointer they are asked in a fixed order on each move: swipe back (a down within 24 pt of a pushed screen's leading edge) first, then the wrappers between the target and the screen innermost first (`gesture`, `contextmenu`, swipe row), then the sheet card, then pull to refresh; the first whose direction matches claims the pointer and the others are dropped. `allowsHitTesting(false)` is the style hint `hitTesting: false`: the subtree gets `pointer-events: none`. **Hit testing follows SwiftUI's**: a stack, spacer, `geometry`, `grid`, `layout`, `viewthatfits`, `decorated`, `gesture` / `tapgesture` wrapper or a plain `styled` box is not hit in its empty space (the pointer reaches whatever is under it, as a full-screen `VStack { Spacer() }` overlay never blocks the cards beneath it in Flashzilla); content is hit: text, images, shapes, colors, controls, rows, and a `styled` whose style paints a `background`. The renderer implements this with `pointer-events: none` on the containers and an opt-in on their children (styles.css), so an inert subtree (`hidden`, `disabled`, `hitTesting: false`, a popping screen, exit clones, a dismissing sheet or alert, a hidden tab) is blocked by an explicit descendant rule. ### Swipe actions: `swipeactions` Wraps a list row's content. A horizontal pan on the row (by pointer, or a horizontal wheel/trackpad scroll) slides the content aside and reveals the actions of that edge as full-height colored buttons, 74 pt wide each, text (and symbol, stacked) in white, in a row behind the content: trailing actions on the right with the **first declared action nearest the content**, leading on the left. Tapping an action sends `tap` with the button's id and closes the row. Pulling past the actions' total width plus 60 pt with full swipe allowed performs the first declared action of that edge (its `tap`) and closes the row. Tapping anywhere else, scrolling, or another row opening closes the open row with the slide animation. Outside a `list` the element is transparent. A row with `onDelete` (below) and no `swipeActions` behaves like one with a single trailing destructive "Delete" action that sends `delete` instead of `tap`. Several `.swipeActions` on one row accumulate into one element (trailing actions in declaration order, then leading ones); the per-edge `allowsFullSwipe` is the last one declared for that edge. The actions are flattened like a `menu`'s items, so `trailing` + `leading` equals the number of children after the content; the renderer finds each action's `button` by looking through its `styled` wrappers (that is where a `.tint` lives). When a row has both `swipeActions` and `onDelete` the `listrow` wraps the `swipeactions` element. Renderer details: the action `button` children are never laid out (they are in `hidden`, like a `menu`'s items); when a row starts to open the renderer builds the buttons from the current children (title from their `text`, symbol from their first `image`, `destructive` role → red, a `tint` on the button or its `styled` wrapper → that color, otherwise gray). The content follows the pointer 1:1 up to the actions' total width, then with resistance (no further than the row width), and the button nearest the content stretches past the total width. On release the row opens when the content is past half the actions' width or flung open faster than 300 pt/s, closes otherwise; `fullSwipeTrailing` / `fullSwipeLeading` default to true. A tap outside an open row closes it *and* is swallowed (nothing under it fires). A horizontal wheel sequence settles 150 ms after its last event with the same rules. ### Context menus: `contextmenu` The content is laid out alone; the items are hidden inline, like a `menu`'s. A long press (0.5 s) or a right click on the content opens the same popover a `menu` opens, anchored to the content, with the items as rows; choosing one sends `tap` with the item's id. The browser's own context menu is suppressed on the subtree, the long press swallows the click that follows it, and the long press is cancelled by moving 10 px (so the row still scrolls or swipes). ### Edit mode and list selection: `listrow` Swift wraps a `List` row in a `listrow` when it can be selected (the list has a `selection:` binding and the row carries a `.tag`, or is a `NavigationLink(value:)` in a `List(selection:)`) or deleted (its `ForEach` has `.onDelete`). The row's `list` carries `editing` (from `EditButton` / the `editMode` environment). Rows are the list's direct children and the children of its `section`s; the wrapper takes over the row's identity (its `ForEach` key), and a row that is neither selectable nor deletable is emitted bare, as before. A `listrow` whose content is a `swipeactions` is the swipe host itself: the whole row slides and the actions sit at the row's edge (the `swipeactions` wrapper also spans the row's width in the layout, so a pan anywhere on the row starts the swipe). A `ForEach` id is a row's implicit tag, as in SwiftUI, so `List(items, selection:)` needs no `.tag`; a tag (or link value) counts only when it is of the selection's value type (`Set` or `V?`). A tag below `styled`, `swipeactions`, `contextmenu`, `gesture` or `tapgesture` wrappers still marks the row. `.deleteDisabled(true)` makes a row not deletable; `onMove` / `.moveDisabled` are accepted but the renderer offers no reordering yet. The `editMode` binding comes from the nearest `navscreen` (each screen owns its own mode, as on iOS), or from the `List` itself when no screen encloses it (a list in a sheet or a plain root); an app-supplied `.environment(\.editMode, $mode)` below either wins. `EditButton` sets the binding to `.active` / `.inactive` and shows "Done" / "Edit". A `tap` on a `listrow`: for a `NavigationLink(value:)` row that is not editing, Swift does what a tap on the `navlink` does (push, or select in a split-view sidebar); otherwise a `Set` selection toggles the value, a `V?` selection sets it, or toggles it (deselects on a second tap) while editing. `delete` on a deletable row calls its `ForEach`'s `onDelete` with the row's offset in the `ForEach`'s data (one offset, computed from the row's position among the `ForEach`'s children, so it is right after earlier removals). - **Not editing**: a selectable row with a single-value selection sends `tap` on its id when tapped (Swift sets the selection; the row shows `selected` with the Phase 7 selected-row fill). A multi-selection list ignores taps. A deletable row swipes to delete (see `swipeactions`). - **Editing** (`list.editing`): every `listrow` gains a 44 pt leading control and its content shifts right by 44 with the list's animation: a selection circle for `selectable` rows (filled accent with a white checkmark when `selected`; a tap sends `tap` on the row id and Swift toggles the selection), a red minus circle for `deletable` rows that are not selectable (a tap slides the content to reveal a trailing "Delete" button; tapping it sends `delete`). `EditButton` itself is a `button` whose title Swift flips between "Edit" and "Done". - `delete` is sent with the `listrow` id; Swift maps it to the row's offset in its `ForEach` and calls `onDelete` with an index set of one. - Renderer details: the `listrow` is transparent for layout and keeps the list-row context (a `navlink` inside it still gets its chevron; `badge` / `listRowInsets` on a `styled` inside it still apply). In edit mode the engine proposes the row content 44 less width and reports the control as the row's `editControl` chrome (44 × row height at the card's leading edge); the shift animates with the commit like any frame change. Swift emits the `listrow` as the row's outermost element (the row modifiers inside it): the row clips to its box, so a `styled` wrapper *around* a `listrow` would clip the control. A tap on a selectable row sends `tap` with the row id whether or not the list is editing (inner controls still win the tap); a non-selectable row sends nothing on a plain tap. ### Alerts and dialogs: `alert` A root-level element (a child of id 0) like `sheet`, placed after the tree. `style: "alert"` draws an iOS alert: a dimming scrim, a 270 pt wide centered card (`--sb-color-secondary-system-background`, 14 pt radius, 60% translucent material look) with the bold title, the message below it, and the buttons: two buttons side by side, otherwise stacked, separated by hairlines; a `cancel` role is bold, `destructive` is red, others accent. `style: "dialog"` draws an action sheet: the buttons in a bottom card above a separate `cancel` button, the title (and message) as a dim caption on top (Swift sends an empty `title` unless `titleVisibility` is `.visible`, as iOS hides it by default; an empty title and null message mean no caption). A button tap sends `tap` with the button id (Swift runs the action and dismisses); a tap on the scrim of a dialog, or on an alert with no buttons, sends `tap` with the alert's own id (dismiss). Both appear with a 0.25 s fade (alert: scale from 1.1) and leave with a fade. An `alert` above a `sheet` covers the sheet. Geometry (engine `chrome`: `card`, `title`, `message`, `button`, `ok`, `cancelCard`; `chromeText`: the centered `title` / `message` lines). The scrim is 30% black; the layer never scales with the presenting tree behind a `large` sheet. `alert`: the card is 270 wide (narrower only on a screen under 286), vertically centered; the title is `headline` (17 semibold) and the message `footnote` (13), both centered, inset 20 top and bottom, 16 at the sides and 4 apart; every button row is 44 high, two buttons share one row (135 each), one or three and more stack in order, none gives a renderer-drawn "OK" row. The scrim of an alert that has buttons swallows the tap (modal). `dialog`: the button card and the cancel card are inset 8 from the screen sides; the cancel card is 57 high and ends 8 above the bottom safe area, the button card 8 above it (no `cancel` child: the button card sits there itself); rows are 57 high with 20 pt labels, in declared order without the cancel; the title (`footnote` semibold) and message (`footnote`) are a secondary-color caption inset 14 top and bottom and 16 at the sides (absent when both are empty, so the first row is flush with the card top). A button child gets its row as its frame with the label centered (a `cancel` label is measured semibold); a tap on it is an ordinary `tap`. ### Controls - `stepper`: the label leads; a 94 × 32 pt segmented minus/plus control trails (`--sb-color-tertiary-fill` track, 8 pt radius, hairline divider); a disabled half (`canDecrement` / `canIncrement` false) is dimmed (35%) and swallows taps. A tap sends `step` with −1 / +1; Swift applies `step` within `in` and emits the new label. Like a `toggle`: the label is proposed the width minus 94 + 8, the height is max(label, 32), the width is the proposal or label + 8 + 94 (engine `chrome`: `label`, `control`, `decrement`, `increment`). - `slider`: 4 pt track (`--sb-color-tertiary-fill`) with the accent fill from the minimum to the thumb, a 27 pt white shadowed thumb; width = proposal (200 when nil), height 44 (the label row above it when a label child exists, as iOS does in a `Form`: label leading, min/max value labels at the track's ends). The label row is the label's own height; the min / max labels sit 8 from the track's ends, vertically centered in the 44 row; the thumb's center travels from 13.5 after the track start to 13.5 before its end, so the thumb stays inside the track (engine `chrome`: `track`, `fill`, `thumb`). Dragging or tapping anywhere on the slider sends `slide` continuously (coalesced per frame, the thumb follows at once) with the value in `min…max`, snapped to `step` (to the nearest multiple from `min`, with the step's decimals; six decimals without a step); Swift confirms with `value`. Without `labels` all children are ignored. - `datepicker`: compact style. The label leads; one or two capsule buttons trail (`--sb-color-tertiary-fill`, 8 pt radius) showing the date ("Oct 3, 2026") and/or the time ("7:00 AM") formatted by the renderer in US English from `seconds` in UTC. Each capsule is its text (body font) plus 11 at each side, 34 high; two capsules are 8 apart and the label is proposed the width minus the capsules and 8; the width is the proposal or label + 8 + capsules (engine `chrome`: `label`, `date`, `time`). `labelsHidden: true` drops the label child from the layout. Tapping one opens the browser's native `` / `` picker (an invisible input over the capsule); a change sends `date` with the combined seconds: the changed component replaces its part of `seconds` (day, or hour and minute) and the other part is kept; seconds within the minute are dropped. `min` / `max` bound the native date input; the time input only when they fall on the shown day. - `texteditor`: a `