diff --git a/.github/skills/maui-devflow-debug/SKILL.md b/.github/skills/maui-devflow-debug/SKILL.md
new file mode 100644
index 000000000..d79b9c1e2
--- /dev/null
+++ b/.github/skills/maui-devflow-debug/SKILL.md
@@ -0,0 +1,96 @@
+---
+name: maui-devflow-debug
+description: >-
+ Run build, deploy, inspect, and fix loops for .NET MAUI apps that already have
+ MAUI DevFlow integrated. USE FOR: launching MAUI apps, selecting devices or
+ emulators, waiting for or recovering agent connections, broker/port/adb
+ connectivity issues, visual tree inspection, screenshots, UI interaction,
+ Blazor WebView CDP debugging, reading DevFlow logs, and iterative app
+ debugging. DO NOT USE FOR: first-time DevFlow package setup (use
+ maui-devflow-onboard), or generic desktop automation unrelated to MAUI. INVOKES:
+ maui devflow CLI, dotnet CLI, Android adb/android tools, and Apple simctl
+ tools.
+---
+
+# DevFlow Debug
+
+Use this skill for the active debugging loop after a MAUI app has DevFlow
+packages and `builder.AddMauiDevFlowAgent()` registered.
+
+## When to Use
+
+- Build and run a MAUI app on Android, iOS, Mac Catalyst, macOS, Windows, or GTK.
+- Choose or create a simulator/emulator for a project.
+- Wait for a DevFlow agent, inspect the visual tree, tap/fill UI, or capture screenshots.
+- Recover from DevFlow connection failures after the app is integrated, including broker, port, and Android adb forwarding issues.
+- Debug Blazor Hybrid content through DevFlow WebView/CDP commands.
+- Read app logs, network captures, preferences, device info, or recordings through DevFlow.
+- Iterate on an app bug with a build -> deploy -> inspect -> fix loop.
+
+## Route Elsewhere
+
+- If DevFlow packages or `MauiProgram.cs` registration are missing, use `maui-devflow-onboard`.
+- If the failure is a generic build or SDK issue with no DevFlow angle, use normal .NET/MAUI diagnostics.
+
+## Core Loop
+
+1. Confirm the app is already integrated:
+
+ ```bash
+ grep -rl "Microsoft.Maui.DevFlow" --include="*.csproj" .
+ ```
+
+ If no project has DevFlow package references, stop and switch to `maui-devflow-onboard`.
+
+2. Pick the target framework and launch target. Do not assume `net10.0`; inspect the project first.
+
+ ```bash
+ grep -i "TargetFramework" *.csproj Directory.Build.props 2>/dev/null
+ ```
+
+3. Start or select the device/emulator. For Android and iOS, avoid reusing a simulator/emulator that is already running another app under investigation.
+
+4. Launch the app and keep the launch process alive when required.
+
+ - iOS, Android, and Mac Catalyst `dotnet build -t:Run` usually block for the app lifetime.
+ - GTK `dotnet run` blocks for the app lifetime.
+ - macOS AppKit builds can exit after compiling; launch the `.app` separately.
+
+5. Wait for the DevFlow agent before inspecting UI:
+
+ ```bash
+ maui devflow wait
+ maui devflow ui tree --depth 3 --fields "id,type,text,automationId"
+ ```
+
+ If `wait`, `list`, or `ui tree` cannot connect after the app is running, load `references/connectivity.md` and recover the broker/agent connection before continuing.
+
+6. Inspect, interact, capture evidence, then edit the app and repeat from launch.
+
+## Critical Anti-patterns
+
+- Do not treat an empty `maui devflow list` as proof the project is not integrated. `list` is runtime state; project files are source of truth.
+- Do not use arbitrary sleeps after launch. Use `maui devflow wait` to gate on the actual agent connection.
+- Do not kill an async `dotnet build -t:Run` or `dotnet run` shell while you still need the app; that often kills the app.
+- Do not reuse a busy simulator/emulator when multiple MAUI apps or agents may be running.
+- Do not debug Blazor WebView DOM issues through the native visual tree alone; use the WebView/CDP commands.
+
+## Stop Signals
+
+- Stop and switch to `maui-devflow-onboard` when package references or `AddMauiDevFlowAgent()` are absent.
+- Stop and ask which project, device, or agent to target when multiple candidates match.
+- Stop rebuilding after two identical failures until you inspect the first meaningful build/runtime error.
+- Stop using screenshots for exact property values; query the visual tree or properties instead.
+
+## Reference Map
+
+Load these only when needed:
+
+- `references/setup.md` - detailed integration, package, entitlement, and update notes.
+- `references/connectivity.md` - broker, agent, port, Android forwarding, and "no agents connected" recovery.
+- `references/android.md` - Android SDK, emulator, adb, build, deploy, and port forwarding details.
+- `references/ios-and-mac.md` - iOS simulator, Mac Catalyst, permissions, entitlements, and Apple tooling.
+- `references/macos.md` - macOS AppKit project shape, launch model, and troubleshooting.
+- `references/linux.md` - GTK/Linux launch, packages, and WebKitGTK notes.
+- `references/batch.md` - batching multiple DevFlow UI/WebView operations.
+- `references/troubleshooting.md` - build, connection, CDP, and platform-specific failure recovery.
diff --git a/.github/skills/maui-devflow-debug/references/android.md b/.github/skills/maui-devflow-debug/references/android.md
new file mode 100644
index 000000000..ea92d9d09
--- /dev/null
+++ b/.github/skills/maui-devflow-debug/references/android.md
@@ -0,0 +1,213 @@
+# Android Reference
+
+## Table of Contents
+- [Emulator Management](#emulator-management)
+- [Building and Deploying](#building-and-deploying)
+- [Android CLI Tool](#android-cli-tool)
+- [ADB Reference](#adb-reference)
+- [SDK Management](#sdk-management)
+- [Troubleshooting](#troubleshooting)
+
+## Emulator Management
+
+### Avoiding multi-project conflicts
+
+When multiple projects (or AI agents) may deploy to Android emulators simultaneously,
+each project should use its own dedicated AVD. Two apps deployed to the same emulator
+will coexist (unlike iOS), but `adb reverse`/`adb forward` port forwarding is per-device
+and can cause confusion when multiple emulators are running.
+
+**Before creating or starting an emulator, check what's already in use:**
+```bash
+maui devflow list # shows agents with platform + port
+adb devices # shows connected emulators
+```
+
+If an emulator is already running another project's agent, create a new AVD:
+```bash
+android avd create --name "ProjectName-Pixel8" \
+ --sdk "system-images;android-35;google_apis;arm64-v8a" --device pixel_8
+android avd start --name "ProjectName-Pixel8"
+```
+
+**When multiple emulators are running**, use `-s ` to target a specific one:
+```bash
+adb -s emulator-5554 reverse tcp:19223 tcp:19223 # first emulator
+adb -s emulator-5556 reverse tcp:19223 tcp:19223 # second emulator
+```
+
+**Naming convention:** Use `-` (e.g. `TodoApp-Pixel8`) so it's
+clear which AVD belongs to which project.
+
+### List and start AVDs
+```bash
+android avd list # list available AVDs
+android avd start --name # start emulator
+```
+
+### Create AVD
+```bash
+# List available targets and device profiles
+android avd targets # system images
+android avd devices # device profiles (pixel, etc.)
+
+android avd create --name "Pixel8API35" \
+ --sdk "system-images;android-35;google_apis;arm64-v8a" \
+ --device pixel_8
+```
+
+### Delete AVD
+```bash
+android avd delete --name
+```
+
+### Verify emulator is running
+```bash
+adb devices # should show "emulator-5554 device"
+android device list # formatted list
+```
+
+## Building and Deploying
+
+```bash
+# Build and deploy to running emulator
+dotnet build -f net10.0-android -t:Run
+
+# Build only (no deploy)
+dotnet build -f net10.0-android
+```
+
+**Critical: Port forwarding after deploy** — the Android emulator runs in its own network.
+Forward the broker port and the agent port:
+```bash
+adb reverse tcp:19223 tcp:19223 # Broker (lets agent register)
+adb forward tcp: tcp: # Agent (lets CLI reach agent)
+```
+
+The broker reverse is needed so the agent inside the emulator can connect to the host's
+broker daemon. The agent forward uses the port shown in `maui devflow list` after the agent
+registers (range 10223–10899).
+
+If the broker isn't available (fallback mode), forward the port from `.mauidevflow` instead:
+```bash
+adb forward tcp:9223 tcp:9223 # Fallback: direct agent port
+```
+
+Then verify: `maui devflow ui status` and `maui devflow webview status`.
+
+### Install APK manually
+```bash
+adb install -r path/to/app.apk # install/reinstall
+android device install --package path/to/app.apk
+```
+
+## Android CLI Tool
+
+The `android` command (from `androidsdk.tool` NuGet) wraps SDK tools.
+
+### SDK management
+```
+android sdk list # all packages
+android sdk list --installed # installed only
+android sdk list --available # available for install
+android sdk install --package "platforms;android-35"
+android sdk install --package "system-images;android-35;google_apis;arm64-v8a"
+android sdk install --package "emulator"
+android sdk uninstall --package
+android sdk info # SDK location, tools versions
+android sdk accept-licenses # accept all SDK licenses
+android sdk download # download cmdline-tools
+```
+
+### AVD management
+```
+android avd list # available AVDs
+android avd targets # available system images
+android avd devices # available device profiles
+android avd create --name --sdk --device
+android avd delete --name
+android avd start --name
+```
+
+### Device/emulator operations
+```
+android device list # connected devices/emulators
+android device info [--device ] # device properties
+android device install --package # install APK
+android device uninstall --package # uninstall by package name
+```
+
+### JDK management
+```
+android jdk list # available JDKs
+android jdk info # current JDK info
+```
+
+## ADB Reference
+
+### Device/emulator basics
+```bash
+adb devices # list connected devices
+adb -s shell # shell into specific device
+adb shell pm list packages | grep # find installed packages
+adb shell am start -n / # launch activity
+adb shell am force-stop # kill app
+```
+
+### Port forwarding (critical for MAUI DevFlow)
+```bash
+adb reverse tcp:19223 tcp:19223 # Broker (agent → host)
+adb forward tcp: tcp: # Agent (CLI → emulator, get port from `maui devflow list`)
+adb reverse --list # verify forwarding
+adb forward --list # verify forwarding
+adb reverse --remove-all # clean up reverse
+adb forward --remove-all # clean up forward
+```
+
+### File operations
+```bash
+adb push local/file /sdcard/path # push file to device
+adb pull /sdcard/path local/file # pull file from device
+```
+
+### Logs
+```bash
+adb logcat -s "DOTNET" --format brief # .NET runtime logs
+adb logcat -s "MauiDevFlow" # agent logs
+adb logcat --pid=$(adb shell pidof ) # app-specific logs
+adb logcat -c # clear log buffer
+```
+
+### Screenshots and screen recording
+```bash
+adb shell screencap /sdcard/screen.png && adb pull /sdcard/screen.png
+adb shell screenrecord /sdcard/video.mp4 # Ctrl+C to stop
+```
+
+## SDK Management
+
+### Typical setup for MAUI Android development
+```bash
+android sdk accept-licenses
+android sdk install --package "platforms;android-35"
+android sdk install --package "build-tools;35.0.0"
+android sdk install --package "system-images;android-35;google_apis;arm64-v8a"
+android sdk install --package "emulator"
+android sdk install --package "platform-tools"
+```
+
+### Environment variables
+```bash
+export ANDROID_HOME=$HOME/Library/Android/sdk
+export ANDROID_SDK_ROOT=$ANDROID_HOME
+export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator
+```
+
+## Troubleshooting
+
+- **`adb devices` shows "unauthorized"**: Accept the USB debugging prompt on the device/emulator.
+- **Agent not connecting on emulator**: Forgot `adb reverse tcp:19223 tcp:19223` for the broker. Run port forwarding, then check `maui devflow list`.
+- **Emulator won't start**: Check available system images with `android avd targets`. May need
+ to install with `android sdk install --package "system-images;..."`.
+- **Build error "No Android devices found"**: Ensure emulator is booted (`adb devices`).
+- **Slow emulator**: Use hardware acceleration. Prefer `arm64-v8a` images on Apple Silicon Macs.
diff --git a/.github/skills/maui-devflow-debug/references/batch.md b/.github/skills/maui-devflow-debug/references/batch.md
new file mode 100644
index 000000000..80d3725af
--- /dev/null
+++ b/.github/skills/maui-devflow-debug/references/batch.md
@@ -0,0 +1,50 @@
+# Batch Command Reference
+
+Execute multiple MAUI/cdp commands in a single CLI invocation via stdin. Outputs JSONL
+responses (one JSON object per line) to stdout — ideal for AI agents and scripting.
+
+## Usage
+
+```bash
+# Pipe multiple commands (semicolons or newlines as separators)
+echo "MAUI fill textUsername user; MAUI fill textPassword pwd123; MAUI tap buttonLogin" | maui devflow batch
+
+# Multi-line input
+printf "MAUI status\nMAUI tree\nMAUI screenshot --output screen.png" | maui devflow batch
+
+# With options
+echo "MAUI status; MAUI tree" | maui devflow batch --delay 500 --continue-on-error --agent-port 10224
+
+# Human-readable output instead of JSONL
+echo "MAUI status; MAUI tree" | maui devflow batch --human
+```
+
+## Options
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `--delay ` | 250 | Delay between commands (lets UI settle) |
+| `--continue-on-error` | false | Continue after a command fails (default: stop) |
+| `--human` | false | Human-readable output instead of JSONL |
+
+## JSONL Response Format
+
+One JSON object per command, streamed as each completes:
+```json
+{"command":"MAUI fill textUsername user","exit_code":0,"output":"Filled: textUsername"}
+{"command":"MAUI tap buttonLogin","exit_code":1,"output":"Error: Element not found: buttonLogin"}
+```
+
+## Interactive Streaming
+
+The batch command processes stdin line-by-line, so a caller can read each JSONL response
+before sending the next command. This enables reactive workflows where the AI agent inspects
+results and decides the next action.
+
+## Input Rules
+
+- Lines starting with `#` are comments (skipped)
+- Empty lines are skipped
+- Semicolons separate multiple commands on one line
+- Quoted strings are preserved: `MAUI fill myEntry "hello world"`
+- Only `MAUI` and `cdp` commands are allowed (broker/list/etc. are rejected)
diff --git a/.github/skills/maui-devflow-debug/references/connectivity.md b/.github/skills/maui-devflow-debug/references/connectivity.md
new file mode 100644
index 000000000..e6f398822
--- /dev/null
+++ b/.github/skills/maui-devflow-debug/references/connectivity.md
@@ -0,0 +1,114 @@
+# Connectivity Recovery
+
+Use this when a project already has DevFlow package references and `builder.AddMauiDevFlowAgent()` registered, but the `maui` CLI cannot reach a running app.
+
+## Quick workflow
+
+1. Run the built-in diagnostic:
+
+ ```bash
+ maui devflow diagnose
+ ```
+
+ Use the result to separate broker startup, missing project integration, no running app, and target-device networking issues.
+
+2. Verify integration before chasing ports:
+
+ ```bash
+ grep -R --include="*.csproj" "Microsoft.Maui.DevFlow.Agent" .
+ grep -R "AddMauiDevFlowAgent" .
+ ```
+
+ If package references or `AddMauiDevFlowAgent()` are missing, stop and switch to `maui-devflow-onboard`.
+
+3. Check broker health:
+
+ ```bash
+ maui devflow broker status
+ maui devflow broker start
+ ```
+
+4. List and wait for agents:
+
+ ```bash
+ maui devflow list
+ maui devflow wait
+ maui devflow ui tree --depth 1
+ ```
+
+ A successful `ui tree` means connectivity is fixed; continue the main debug loop.
+
+## Platform notes
+
+### Android emulator
+
+Android emulators run in a separate network namespace. Broker registration and CLI-to-agent traffic need opposite forwarding directions:
+
+```bash
+maui devflow list # note the assigned agent port
+adb reverse tcp:19223 tcp:19223 # app in emulator -> host broker
+adb forward tcp: tcp: # host CLI -> app agent
+adb reverse --list
+adb forward --list
+```
+
+If no broker is available and the app uses a direct `.mauidevflow` port, forward that port instead:
+
+```bash
+adb forward tcp:9223 tcp:9223
+```
+
+### iOS simulator
+
+No forwarding is normally needed because simulators share host networking:
+
+```bash
+xcrun simctl list devices booted
+```
+
+### Mac Catalyst and macOS
+
+Mac Catalyst needs Debug entitlements that allow the agent and CDP servers to bind ports:
+
+```xml
+com.apple.security.network.server
+
+```
+
+macOS AppKit and Mac Catalyst otherwise use direct localhost access. Check for explicit port conflicts only after confirming broker discovery:
+
+```bash
+lsof -i :9223
+```
+
+### Windows and Linux/GTK
+
+These use direct localhost access. On Linux, verify the app process started:
+
+```bash
+pgrep -f "YourApp"
+```
+
+## Common symptoms
+
+| Symptom | Fix |
+|---------|-----|
+| `maui devflow list` shows no agents | Verify app is running in Debug, package references exist, and `AddMauiDevFlowAgent()` executes |
+| Android agent never registers | Run `adb reverse tcp:19223 tcp:19223` for broker registration |
+| Android connection refused after registration | Run `adb forward tcp: tcp:` using the port from `maui devflow list` |
+| Broker unavailable | `maui devflow broker start`; retry the command |
+| Port already in use | Identify the owner with `lsof -i :` before killing a PID |
+| Multiple agents connected | Ask which app/device/agent port to target; use `--agent-port` |
+
+## Stop signals
+
+- Stop and switch to `maui-devflow-onboard` if package references or `AddMauiDevFlowAgent()` are missing.
+- Stop connectivity recovery once `maui devflow wait` succeeds and `maui devflow ui tree --depth 1` returns a tree.
+- Stop and ask which target to use if multiple connected agents or devices match the app.
+- Stop after confirming a generic build/deploy failure; the app must launch before broker connectivity can work.
+
+## Anti-patterns
+
+- Do not treat an empty `maui devflow list` as proof the project is not integrated. It only means no runtime agent is connected.
+- Do not use `adb reverse` for the agent HTTP port when the host CLI must connect into the emulator. Use `adb forward tcp: tcp:` for that direction.
+- Do not kill random processes by name. Identify the owning PID first.
diff --git a/.github/skills/maui-devflow-debug/references/ios-and-mac.md b/.github/skills/maui-devflow-debug/references/ios-and-mac.md
new file mode 100644
index 000000000..ca7231e29
--- /dev/null
+++ b/.github/skills/maui-devflow-debug/references/ios-and-mac.md
@@ -0,0 +1,324 @@
+# iOS & Mac Catalyst Reference
+
+## Table of Contents
+- [Simulator Management](#simulator-management)
+- [Building and Deploying](#building-and-deploying)
+- [Apple CLI Tool](#apple-cli-tool)
+- [xcrun simctl Reference](#xcrun-simctl-reference)
+- [Troubleshooting](#troubleshooting)
+
+## Simulator Management
+
+### Avoiding multi-project conflicts
+
+When multiple projects (or AI agents) may deploy to iOS simulators simultaneously, each
+project should use its own dedicated simulator. Two apps deployed to the same simulator
+will replace each other — only the last-deployed app survives.
+
+**Before creating or booting a simulator, check what's already in use:**
+```bash
+maui devflow list # shows agents with platform + port
+xcrun simctl list devices booted # shows all booted simulators
+```
+
+If a booted simulator is already running another project's agent, create a new one:
+```bash
+xcrun simctl create "ProjectName-iPhone17Pro" "iPhone 17 Pro" "iOS 26.2"
+# Use the returned UDID in your build command
+```
+
+**Naming convention:** Use `-` (e.g. `TodoApp-iPhone17Pro`) so
+it's clear which simulator belongs to which project.
+
+### List simulators
+```bash
+xcrun simctl list devices # all devices by runtime
+xcrun simctl list devices booted # only booted
+xcrun simctl list devices available # only available
+apple simulator list # formatted table
+apple simulator list --booted # booted only
+```
+
+### Create simulator
+```bash
+# List available device types and runtimes first
+xcrun simctl list devicetypes # e.g. "iPhone 16 Pro"
+xcrun simctl list runtimes # e.g. "iOS 18.2"
+
+xcrun simctl create "My iPhone" "iPhone 16 Pro" "iOS 18.2"
+apple simulator create "My iPhone" --device-type "iPhone 16 Pro" --runtime "iOS 18.2"
+```
+
+### Boot / shutdown
+```bash
+xcrun simctl boot
+xcrun simctl shutdown
+apple simulator boot
+apple simulator shutdown
+```
+
+### Install and launch app
+```bash
+xcrun simctl install booted /path/to/App.app
+xcrun simctl launch booted com.company.appid
+```
+
+### Screenshots (iOS Simulator)
+```bash
+xcrun simctl io booted screenshot output.png
+apple simulator screenshot --output output.png
+```
+
+### Screenshots (Mac Catalyst)
+
+**Use `maui devflow ui screenshot`** for Mac Catalyst apps — it captures the UI in-process
+and does NOT require the app to be in the foreground. Never use `osascript` to bring the window
+to the front or `screencapture` for Mac Catalyst screenshots; they are unnecessary and unreliable.
+
+```bash
+maui devflow ui screenshot --output screen.png
+```
+
+### Delete / erase
+```bash
+xcrun simctl erase # factory reset
+xcrun simctl delete # permanently remove
+xcrun simctl delete unavailable # clean up old sims
+```
+
+## Building and Deploying
+
+### Mac Catalyst
+
+**⚠️ Entitlements required:** Mac Catalyst apps are sandboxed by default and need the
+`com.apple.security.network.server` entitlement for MAUI DevFlow's in-app HTTP server.
+Without it, the agent fails to bind its port and the app may crash silently.
+
+**Quick fix (disable sandbox for Debug):** Create `Platforms/MacCatalyst/Entitlements.Debug.plist`:
+```xml
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+
+
+```
+
+Then reference it in `.csproj` for Debug only:
+```xml
+
+ Platforms/MacCatalyst/Entitlements.Debug.plist
+
+```
+
+If sandbox must stay enabled (e.g. testing App Store builds), add `network.server` explicitly.
+See [setup.md Step 5](setup.md#5-mac-catalyst-entitlements) for the full sandbox-enabled plist.
+
+```bash
+dotnet build -f net10.0-maccatalyst # build only
+dotnet build -f net10.0-maccatalyst -t:Run # build + run
+open path/to/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/AppName.app # run existing
+```
+
+### iOS Simulator
+```bash
+# Find UDID of booted simulator
+UDID=$(xcrun simctl list devices booted -j | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+for r in d['devices'].values():
+ for dev in r:
+ if dev['state']=='Booted': print(dev['udid']); break
+" 2>/dev/null | head -1)
+
+# Build and deploy
+dotnet build -f net10.0-ios -t:Run -p:_DeviceName=:v2:udid=$UDID
+```
+
+The `-t:Run` target keeps the process alive while the app runs — it **blocks until the app exits**.
+Always run in an async/background shell, then poll `maui devflow ui status` to detect when the
+app is ready. Do NOT wait for the process to finish.
+
+### Determining the correct TFM
+Check the project file for ``:
+```bash
+grep -i TargetFramework *.csproj
+```
+Common values: `net9.0-ios`, `net9.0-maccatalyst`, `net10.0-ios`, `net10.0-maccatalyst`.
+
+## Apple CLI Tool
+
+The `apple` command (from `appledev.tools` NuGet) provides higher-level wrappers.
+
+### Simulator commands
+```
+apple simulator list [--booted|--available|--unavailable|--name "..."]
+apple simulator create --device-type "..." [--runtime "..."]
+apple simulator boot
+apple simulator shutdown
+apple simulator erase
+apple simulator delete
+apple simulator screenshot [--output path.png]
+apple simulator app install
+apple simulator app launch
+apple simulator app uninstall
+apple simulator open []
+apple simulator open-url
+apple simulator logs [--filter "..."]
+apple simulator push [--payload "..."]
+apple simulator location set --lat --lon
+apple simulator privacy grant
+```
+
+### Device commands
+```
+apple device list
+apple xcode list # installed Xcode versions
+```
+
+## xcrun simctl Reference
+
+Key subcommands beyond the basics:
+
+| Command | Use |
+|---------|-----|
+| `simctl addmedia file.jpg` | Add photos/videos to sim |
+| `simctl openurl "url"` | Open URL / deep link |
+| `simctl push bundle payload.json` | Simulate push notification |
+| `simctl privacy grant location bundle` | Grant permissions |
+| `simctl location set 37.33,-122.03` | Set GPS location |
+| `simctl pbcopy ` | Copy stdin to clipboard |
+| `simctl pbpaste ` | Read clipboard |
+| `simctl get_app_container bundle` | App container path |
+| `simctl listapps ` | Installed apps |
+
+## Troubleshooting
+
+- **Mac Catalyst blank/white screen after crash**: macOS shows a "reopen windows" dialog after
+ a crash, blocking the app from rendering. All MAUI elements appear as `[hidden] [disabled]`
+ with `-1x-1` sizes. Fix: clear saved state before launch:
+ ```bash
+ rm -rf ~/Library/Saved\ Application\ State/.savedState
+ ```
+ If the "Reopen windows?" dialog is already on screen, ask the user to dismiss it manually,
+ then relaunch. Do not use AppleScript here by default — it steals focus from the user's
+ desktop session.
+- **"Unable to lookup in current state: Shutdown"**: Simulator not booted. Run `xcrun simctl boot `.
+- **Build error NETSDK1005 "Assets file doesn't have a target"**: Wrong TFM. Check
+ `` in .csproj and use matching version (e.g. `net10.0-ios` not `net9.0-ios`).
+- **Agent not connecting after deploy**: The app may still be launching. Poll
+ `maui devflow ui status` every few seconds. If it hasn't connected after ~60-90s, read the
+ async shell output from `dotnet build -t:Run` for build/launch errors.
+- **Mac Catalyst app name vs binary name**: The `.app` bundle name may differ from the project
+ name (e.g. `MauiTodo.app` vs `SampleMauiApp`). Check the `ApplicationTitle` in .csproj.
+ Find the bundle: `find bin/Debug/net10.0-maccatalyst -name "*.app" -maxdepth 3`
+
+## Permission & Dialog Handling
+
+### Pre-grant permissions (prevents dialogs from appearing)
+```bash
+# Grant specific permission before the app requests it
+xcrun simctl privacy grant location com.company.appid
+xcrun simctl privacy grant camera com.company.appid
+xcrun simctl privacy grant photos com.company.appid
+xcrun simctl privacy grant contacts com.company.appid
+xcrun simctl privacy grant microphone com.company.appid
+
+# Grant all permissions at once
+xcrun simctl privacy grant all com.company.appid
+
+# Revoke (deny) a permission
+xcrun simctl privacy revoke location com.company.appid
+
+# Reset (next request will show dialog again)
+xcrun simctl privacy reset all com.company.appid
+
+# Via apple CLI
+apple simulator privacy grant location com.company.appid
+```
+
+Available services: `all`, `calendar`, `contacts`, `contacts-limited`, `location`, `location-always`, `photos`, `photos-add`, `media-library`, `microphone`, `motion`, `reminders`, `siri`.
+
+### Using MAUI DevFlow Driver for permissions
+```csharp
+var driver = new iOSSimulatorAppDriver();
+driver.DeviceUdid = "";
+driver.BundleId = "com.company.appid";
+
+// Pre-grant before running the app
+await driver.GrantPermissionAsync(PermissionService.Location);
+await driver.GrantPermissionAsync(PermissionService.Camera);
+
+// Reset to test the dialog flow
+await driver.ResetPermissionAsync(PermissionService.Location);
+```
+
+### Detecting and dismissing alerts (accessibility tree + HID tap)
+When a dialog appears unexpectedly (permission prompt, app alert, action sheet), the driver can
+detect it via the iOS accessibility tree and tap a button to dismiss it:
+
+```csharp
+// Check if an alert is currently showing
+var alert = await driver.DetectAlertAsync();
+if (alert is not null)
+{
+ Console.WriteLine($"Alert: {alert.Title}");
+ foreach (var btn in alert.Buttons)
+ Console.WriteLine($" Button: {btn.Label} at ({btn.CenterX},{btn.CenterY})");
+}
+
+// Dismiss by tapping the first "accept" button (Allow, OK, etc.)
+await driver.DismissAlertAsync();
+
+// Dismiss by tapping a specific button
+await driver.DismissAlertAsync("Don't Allow");
+
+// Convenience: detect + dismiss if present, no-op if not
+await driver.HandleAlertIfPresentAsync();
+```
+
+### Example workflow: permission dialog handling
+```
+1. App requests location → system shows "Allow location?" dialog
+2. Agent detects dialog via DetectAlertAsync()
+3. Agent sees buttons: ["Allow While Using App", "Allow Once", "Don't Allow"]
+4. Agent taps "Allow While Using App" via DismissAlertAsync("Allow While Using App")
+5. App receives permission grant, continues normal flow
+```
+
+### Dialog test page in SampleMauiApp
+The SampleMauiApp includes a **Dialogs** tab with buttons that trigger:
+- **Permission dialogs**: Location, Camera, Photos, Contacts, Microphone, Notifications
+- **App alerts**: OK-only, OK/Cancel, custom buttons (Delete/Keep)
+- **Action sheets**: Multiple options with cancel/destructive
+- **Prompt dialogs**: Text input with OK/Cancel
+
+Use these to test and validate dialog detection and dismissal workflows.
+
+## Dark Mode Testing
+
+### Toggle dark mode
+```bash
+# Preferred when available: use an in-app theme toggle so the host desktop is unaffected.
+#
+# iOS Simulator (safe: affects the simulator only)
+xcrun simctl ui appearance dark
+xcrun simctl ui appearance light
+```
+
+For **Mac Catalyst**, changing system appearance affects the user's entire macOS desktop. Only do
+that with explicit user approval; otherwise prefer app-level theme controls and verify via
+`maui devflow ui property` / WebView inspection.
+
+### Verify dark mode via inspection
+Use `maui devflow` to verify colors without relying on screenshots:
+```bash
+maui devflow ui property BackgroundColor # check MAUI element colors
+maui devflow webview Runtime evaluate "window.matchMedia('(prefers-color-scheme: dark)').matches" # Blazor
+```
diff --git a/.github/skills/maui-devflow-debug/references/linux.md b/.github/skills/maui-devflow-debug/references/linux.md
new file mode 100644
index 000000000..397ceb6ca
--- /dev/null
+++ b/.github/skills/maui-devflow-debug/references/linux.md
@@ -0,0 +1,172 @@
+# Linux / GTK Platform Guide
+
+Platform-specific setup and usage for .NET MAUI apps running on Linux via Maui.Gtk (GTK4).
+
+## Overview
+
+Maui.Gtk apps target `net10.0` (not a platform-specific TFM like `net10.0-ios`) and use
+GTK4 via GirCore bindings. MAUI DevFlow provides dedicated Linux packages that work with
+this architecture.
+
+## NuGet Packages
+
+| Package | Purpose |
+|---------|---------|
+| `Microsoft.Maui.DevFlow.Agent.Gtk` | In-app agent (visual tree, screenshots, tapping, logging) |
+| `Microsoft.Maui.DevFlow.Blazor.Gtk` | CDP bridge for WebKitGTK BlazorWebView |
+
+These replace `Microsoft.Maui.DevFlow.Agent` and `Microsoft.Maui.DevFlow.Blazor` which target
+standard MAUI platforms (iOS, Android, macCatalyst, Windows).
+
+```xml
+
+
+
+
+
+```
+
+## Registration
+
+### MauiProgram.cs
+
+```csharp
+using Microsoft.Maui.DevFlow.Agent.Gtk;
+using Microsoft.Maui.DevFlow.Blazor.Gtk; // Blazor Hybrid only
+
+var builder = MauiApp.CreateBuilder();
+// ... your existing setup ...
+
+#if DEBUG
+builder.AddMauiDevFlowAgent();
+builder.AddMauiBlazorDevFlowTools(); // Blazor Hybrid only
+#endif
+```
+
+### Application Startup
+
+The agent must be started after the MAUI Application is available. In your GTK app
+startup (e.g., `GtkMauiApplication.OnActivate` or equivalent):
+
+```csharp
+#if DEBUG
+app.StartDevFlowAgent();
+
+// For Blazor Hybrid, wire CDP to the agent:
+var blazorService = app.Handler?.MauiContext?.Services
+ .GetService();
+blazorService?.WireBlazorCdpToAgent();
+#endif
+```
+
+## Building and Running
+
+Linux/GTK apps use `dotnet run` (not `dotnet build -t:Run` which is MAUI-specific):
+
+```bash
+# Build and run (in background/async shell)
+dotnet run --project
+
+# Build only
+dotnet build
+```
+
+Build times are typically fast (~5-10s) since there's no device deployment step.
+
+## Network Setup
+
+**No special setup needed.** Linux apps run directly on localhost — the CLI connects
+directly to `http://localhost:`. No port forwarding (unlike Android) or entitlements
+(unlike Mac Catalyst) required.
+
+## Key Simulation
+
+The `LinuxAppDriver` automatically detects the display server and uses the appropriate
+**driver-mediated** key simulation backend. This is an implementation detail for Linux
+automation support, **not** a recommendation for an AI agent to invoke these tools directly
+from the shell. Prefer `maui devflow ui fill`, `maui devflow ui tap`, and `maui devflow batch`
+for normal interaction.
+
+- **X11 sessions**: Uses `xdotool` (human-readable key names, no daemon needed)
+- **Wayland sessions**: Uses `ydotool` (Linux input event keycodes, requires `ydotoold` daemon)
+
+Detection uses `XDG_SESSION_TYPE` and `WAYLAND_DISPLAY` environment variables. If the
+preferred tool isn't installed, it falls back to whichever is available.
+
+### Installing xdotool (X11)
+
+```bash
+sudo apt install xdotool # Debian/Ubuntu
+sudo dnf install xdotool # Fedora
+```
+
+### Installing ydotool (Wayland)
+
+```bash
+sudo apt install ydotool # Debian/Ubuntu
+sudo dnf install ydotool # Fedora
+```
+
+**Required setup for ydotool** (to run without root):
+
+```bash
+# Add your user to the input group for /dev/uinput access
+sudo usermod -aG input $USER
+
+# Create udev rule for persistent permissions
+echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' | sudo tee /etc/udev/rules.d/99-uinput.rules
+sudo udevadm control --reload-rules && sudo udevadm trigger
+
+# Log out and back in, then start the daemon
+ydotoold &
+```
+
+Key simulation is used by the driver for alert dismissal and keyboard input.
+Only the driver backend should reach for `xdotool` or `ydotool`.
+
+## Platform Differences
+
+| Feature | Standard MAUI | Linux/GTK |
+|---------|--------------|-----------|
+| NuGet packages | `Agent`, `Blazor` | `Agent.Gtk`, `Blazor.Gtk` |
+| TFM | `net10.0-` | `net10.0` |
+| Build command | `dotnet build -f $TFM -t:Run` | `dotnet run --project ` |
+| Agent startup | Automatic (lifecycle hook) | Manual (`app.StartDevFlowAgent()`) |
+| Network | Varies by platform | Direct localhost |
+| Screenshots | `VisualDiagnostics` | GTK `WidgetPaintable` → `Texture.SaveToPng()` |
+| Native tap | Platform gesture system | `Gtk.Widget.Activate()` |
+| Key simulation | Driver-mediated backend | `xdotool` (X11) / `ydotool` (Wayland) |
+| Screen recording | Platform-specific | `ffmpeg` with `x11grab` (X11) / `pipewire` (Wayland) |
+| Blazor WebView | WKWebView / WebView2 / Chrome | WebKitGTK 6.0 |
+
+## Troubleshooting
+
+### Agent Not Starting
+
+1. Ensure `app.StartDevFlowAgent()` is called after the app is activated
+2. Check that `Application.Current` is available when `StartDevFlowAgent()` runs
+3. Verify the port isn't in use: `lsof -i :` or `ss -tlnp | grep `
+
+### xdotool / ydotool Not Working
+
+- On Wayland, `xdotool` does not work — install `ydotool` and start `ydotoold`
+- On X11, `xdotool` is preferred over `ydotool` (simpler, no daemon)
+- Prefer DevFlow commands over direct key injection wherever possible
+- If `ydotool` fails silently, check that `ydotoold` daemon is running: `pgrep -x ydotoold`
+- If `ydotool` gives permission errors, ensure your user is in the `input` group and `/dev/uinput` is group-writable
+- Ensure the app window has focus for key events
+- Check `XDG_SESSION_TYPE` to verify which display server is active: `echo $XDG_SESSION_TYPE`
+
+### Screen Recording on Wayland
+
+- Wayland screen recording uses `ffmpeg -f pipewire` instead of `x11grab`
+- Requires PipeWire to be installed and running (standard on modern Wayland desktops)
+- Check ffmpeg PipeWire support: `ffmpeg -devices | grep pipewire`
+- If PipeWire is not available, you can force X11 with `GDK_BACKEND=x11` and use `x11grab`
+- The first recording on Wayland may trigger a system permission dialog for screen sharing
+
+### WebKitGTK CDP Issues
+
+- WebKitGTK uses `EvaluateJavascriptAsync` for JS evaluation
+- The same two-eval CDP pattern (send + poll) applies as other platforms
+- Check that `chobitsu.js` is properly loaded in the WebView
diff --git a/.github/skills/maui-devflow-debug/references/macos.md b/.github/skills/maui-devflow-debug/references/macos.md
new file mode 100644
index 000000000..062b3aed8
--- /dev/null
+++ b/.github/skills/maui-devflow-debug/references/macos.md
@@ -0,0 +1,205 @@
+# macOS (AppKit) Platform Guide
+
+Platform-specific setup and usage for .NET MAUI apps running on macOS via Platform.Maui.MacOS (AppKit).
+
+## Table of Contents
+- [Overview](#overview)
+- [Project Structure](#project-structure)
+- [NuGet Packages](#nuget-packages)
+- [Registration](#registration)
+- [Building and Running](#building-and-running)
+- [Blazor Hybrid](#blazor-hybrid)
+- [Platform Differences](#platform-differences)
+- [Troubleshooting](#troubleshooting)
+
+## Overview
+
+macOS (AppKit) apps use the community `Platform.Maui.MacOS` packages to run MAUI on native
+AppKit (not Mac Catalyst). The TFM is `net10.0-macos`. Like Linux/GTK, macOS apps typically
+use a **separate app head project** rather than adding `-macos` to the standard MAUI project's
+TargetFrameworks.
+
+Detect macOS projects:
+```bash
+grep -i 'Platform\.Maui\.MacOS\|net.*-macos' *.csproj Directory.Build.props 2>/dev/null
+```
+
+## Project Structure
+
+macOS apps use a separate app head project (similar to Linux/GTK):
+
+```
+src/
+├── MyApp/ # Standard MAUI project (iOS, Android, Mac Catalyst, Windows)
+├── MyApp.MacOS/ # macOS AppKit app head
+│ ├── Program.cs # Entry point: MacOSMauiApplication
+│ ├── MauiProgram.cs # macOS-specific builder (UseMauiAppMacOS, AddMacOSEssentials)
+│ └── MyApp.MacOS.csproj # References Platform.Maui.MacOS packages
+```
+
+Shared source files (pages, view models, services) are typically linked from the main project.
+
+## NuGet Packages
+
+The app project needs the Platform.Maui.MacOS packages plus the standard MAUI DevFlow packages:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+```
+
+The standard `Microsoft.Maui.DevFlow.Agent` and `Microsoft.Maui.DevFlow.Blazor` packages include
+`net10.0-macos` targets — no separate macOS-specific MAUI DevFlow packages needed.
+
+## Registration
+
+### Program.cs (Entry Point)
+
+```csharp
+using AppKit;
+using Microsoft.Maui.Platform.MacOS;
+using ObjCRuntime;
+
+namespace MyApp.MacOS;
+
+[Register("Program")]
+public class Program : MacOSMauiApplication
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+
+ static void Main(string[] args)
+ {
+ NSApplication.Init();
+ NSApplication.Main(args);
+ }
+}
+```
+
+The `[Register]` attribute is required.
+
+### MauiProgram.cs
+
+```csharp
+using Microsoft.Maui.Platform.MacOS;
+using Microsoft.Maui.Platform.MacOS.Controls;
+
+#if DEBUG
+using Microsoft.Maui.DevFlow.Agent;
+using Microsoft.Maui.DevFlow.Blazor;
+#endif
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiAppMacOS() // NOT UseMauiApp — macOS-specific
+ .AddMacOSEssentials() // REQUIRED — without this, no window appears
+ .AddMacOSBlazorWebView() // Blazor only
+ .ConfigureFonts(fonts => { /* ... */ });
+
+#if DEBUG
+ builder.AddMauiDevFlowAgent();
+ builder.AddMauiBlazorDevFlowTools(); // Blazor only
+#endif
+
+ return builder.Build();
+ }
+}
+```
+
+**Critical:** `AddMacOSEssentials()` is required — without it the app runs but no window appears.
+
+## Building and Running
+
+macOS apps do NOT use `-t:Run`. Build first, then launch with `open`:
+
+```bash
+# Build
+dotnet build -f net10.0-macos path/to/MyApp.MacOS
+
+# Find and launch the .app bundle
+open path/to/MyApp.MacOS/bin/Debug/net10.0-macos/osx-arm64/AppName.app
+```
+
+**Code signing:** A clean `dotnet build` produces a valid ad-hoc signature. Do NOT manually
+re-sign the app — it breaks the signature (SIGKILL on launch). If the app fails to launch,
+clean rebuild: `rm -rf bin obj && dotnet build`.
+
+**Finding the .app bundle:**
+```bash
+find bin/Debug/net10.0-macos -name "*.app" -maxdepth 3
+```
+
+## Network Setup
+
+**No special setup needed.** macOS apps run directly on localhost — the CLI connects
+directly to `http://localhost:`. No port forwarding or entitlements required.
+
+## Blazor Hybrid
+
+Use `MacOSBlazorWebView` instead of the standard `BlazorWebView`:
+
+```csharp
+using Microsoft.Maui.Platform.MacOS.Controls;
+
+// In page code-behind, replace BlazorWebView with MacOSBlazorWebView
+var blazorWebView = new MacOSBlazorWebView();
+blazorWebView.HostPage = "wwwroot/index.html";
+blazorWebView.RootComponents.Add(new RootComponent { ... });
+```
+
+Chobitsu.js is auto-injected via the Blazor JS module initializer — no manual `
+```
+
+The library detects both approaches — manual script tags take priority over auto-injection.
+
+### What if it's not working?
+
+The library checks at runtime and logs a message:
+```
+[BlazorDevFlow] ⚠️ No chobitsu script tag found. Auto-injection via JS initializer may not have run.
+```
+
+### How the file gets there
+
+The `chobitsu.js` file is included in the NuGet package as a static web asset. It is
+automatically available at the root of your app's `wwwroot/` — no `.targets` file copying,
+no manual downloads. It works in both Debug and Release builds (though MAUI DevFlow itself
+should only be referenced in Debug configurations).
+
+## 5. Mac Catalyst: Entitlements
+
+Mac Catalyst apps need the `com.apple.security.network.server` entitlement to allow the
+agent and CDP servers to bind ports. Without this, the app will crash or fail silently.
+
+### Option A: Sandbox disabled (simpler for development)
+
+Create or update `Platforms/MacCatalyst/Entitlements.plist` for Debug builds:
+
+```xml
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+
+
+```
+
+### Option B: Sandbox enabled (required for App Store)
+
+```xml
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+ com.apple.security.network.server
+
+
+
+```
+
+Reference in your `.csproj` (Debug only, so Release uses the default entitlements):
+
+```xml
+
+ Platforms/MacCatalyst/Entitlements.Debug.plist
+
+```
+
+**Avoiding TCC permission dialogs:** Even with sandbox disabled, macOS prompts for access to
+`~/Documents`, `~/Downloads`, `~/Desktop`, and dotfiles in `~/` on every rebuild (ad-hoc signing
+changes the code signature each build). To avoid this, store app data in
+`Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)` (`~/Library/Application Support/`)
+instead of the home directory root. This path is not TCC-protected.
+
+## 6. Android: Port Forwarding
+
+After deploying to an Android emulator, set up port forwarding for the broker and agent:
+
+```bash
+adb reverse tcp:19223 tcp:19223 # Broker (lets agent register with host broker)
+adb forward tcp: tcp: # Agent (lets CLI reach agent — get port from `maui devflow list`)
+```
+
+The broker reverse (`tcp:19223`) is needed so the agent inside the emulator can connect to
+the host's broker daemon. Set this up once per emulator session.
+
+The agent forward uses the port shown in `maui devflow list` after the agent registers
+(range 10223–10899).
+
+**Fallback (no broker):** If using direct mode with a `.mauidevflow` config file:
+```bash
+adb forward tcp:9223 tcp:9223 # Direct agent port (single port for Agent + CDP)
+```
+
+## 7. Verify Setup
+
+After building and running the app:
+
+```bash
+maui devflow list # Should show registered agents (via broker)
+maui devflow ui status # Should show agent info, platform, app name
+maui devflow webview status # Should show "Connected" (Blazor Hybrid only)
+```
+
+If status commands fail:
+- **Broker not running?** `maui devflow broker status` — CLI auto-starts the broker, but check if it's healthy
+- **Agent not registered?** `maui devflow list` — wait a few seconds for the agent to register
+- **Mac Catalyst:** Check entitlements (Step 5)
+- **macOS (AppKit):** Ensure `AddMacOSEssentials()` is called — see [references/macos.md](macos.md)
+- **Android:** Check port forwarding (Step 6) — need both `adb reverse tcp:19223` and `adb forward tcp:`
+- **iOS Simulator:** Should work without extra config
+- **Linux/GTK:** Should work without extra config — runs directly on localhost
+- **All platforms:** Ensure the app is running and the `#if DEBUG` block is active
+- **Port conflict:** Check if another process holds the port: `lsof -i :9223` (or your configured port)
+- **Wrong port:** Use `maui devflow list` to find the assigned port, or ensure CLI is run from the project directory
+
+## Quick Checklist
+
+For an AI agent setting up MAUI DevFlow in a new project:
+
+1. [ ] `Microsoft.Maui.DevFlow.Agent` NuGet package added (or `Microsoft.Maui.DevFlow.Agent.Gtk` for Linux)
+2. [ ] `Microsoft.Maui.DevFlow.Blazor` NuGet package added (Blazor Hybrid only; or `Microsoft.Maui.DevFlow.Blazor.Gtk` for Linux)
+3. [ ] `builder.AddMauiDevFlowAgent(...)` in MauiProgram.cs inside `#if DEBUG`
+4. [ ] `builder.AddMauiBlazorDevFlowTools(...)` in MauiProgram.cs (Blazor Hybrid only)
+5. [ ] Chobitsu auto-injected via JS initializer (Blazor Hybrid — no manual step needed)
+6. [ ] Mac Catalyst entitlements include `network.server` (Mac Catalyst only)
+7. [ ] `adb reverse tcp:19223` for broker + `adb forward tcp:` for agent (Android only)
+8. [ ] Linux/GTK: `app.StartDevFlowAgent()` called after app activation
+9. [ ] macOS (AppKit): `UseMauiAppMacOS()`, `AddMacOSEssentials()`, `MacOSBlazorWebView` — see [macos.md](macos.md)
+10. [ ] Verify with `maui devflow list` and `maui devflow ui status`
+
+## Checking for Updates
+
+At the start of each session (or periodically), check whether the CLI, skill, and NuGet
+packages are up to date. Outdated components can cause confusing failures or missing features.
+
+### Check CLI version
+```bash
+maui devflow version
+dotnet tool search Microsoft.Maui.Cli | head -5
+```
+
+If a newer version is available:
+```bash
+dotnet tool update --global Microsoft.Maui.Cli
+```
+
+### Update the skill
+```bash
+maui devflow skills check
+maui devflow skills update
+```
+
+The skills manager compares installed files against the skills bundled in the running
+`Microsoft.Maui.Cli` and records local install state under `~/.maui/devflow`.
+
+**AI agents should check at session start:** run `maui devflow skills check`. If it reports
+`update-available-from-current-cli`, ask the user if they want to run
+`maui devflow skills update` before proceeding.
+
+### Check NuGet packages in the project
+```bash
+grep -i 'Microsoft.Maui.DevFlow' *.csproj Directory.Build.props Directory.Packages.props 2>/dev/null
+```
+
+If packages are outdated:
+```bash
+dotnet add package Microsoft.Maui.DevFlow.Agent
+dotnet add package Microsoft.Maui.DevFlow.Blazor # only if Blazor Hybrid
+# For Linux/GTK: use .Gtk variants instead
+```
+
+### Re-run setup verification
+After any updates, walk through the checklist above to ensure everything is still properly
+configured. A CLI update may introduce new setup requirements.
diff --git a/.github/skills/maui-devflow-debug/references/troubleshooting.md b/.github/skills/maui-devflow-debug/references/troubleshooting.md
new file mode 100644
index 000000000..458027685
--- /dev/null
+++ b/.github/skills/maui-devflow-debug/references/troubleshooting.md
@@ -0,0 +1,93 @@
+# Troubleshooting
+
+## Table of Contents
+- [Connection Refused](#connection-refused--cannot-connect)
+- [Build Failures](#build-failures)
+- [CDP Not Connecting](#cdp-not-connecting-blazor-hybrid)
+- [Mac Catalyst Permission Dialogs](#mac-catalyst-repeated-permission-dialogs-on-rebuild)
+
+## Connection Refused / Cannot Connect
+
+If `maui devflow ui status` fails with connection refused:
+
+1. **App not running?** Verify the app launched: check the build output for errors.
+2. **Check the broker:** Run `maui devflow list` to see if the agent registered. If the list
+ is empty, the app may not have connected to the broker yet (wait a few seconds and retry).
+3. **Wrong port?** If using `.mauidevflow`, ensure the port matches between build and CLI.
+ Run CLI from the project directory so it auto-detects the config file.
+4. **Port already in use?** Another process may hold the port. Check with:
+ ```bash
+ lsof -i : # macOS/Linux
+ ```
+ With the broker, this is less common since ports are auto-assigned.
+5. **Android?** Did you run `adb reverse tcp:19223 tcp:19223` (for broker) and
+ `adb forward tcp: tcp:` (for agent)? Re-run after each deploy.
+6. **Mac Catalyst?** Check entitlements include `network.server` (see setup.md step 5).
+7. **macOS (AppKit)?** Ensure `AddMacOSEssentials()` is called and the app window appeared.
+ See [references/macos.md](macos.md) for troubleshooting.
+8. **Linux/GTK?** No special network setup needed — runs directly on localhost. Check if the app started successfully.
+9. **Broker issues?** `maui devflow broker status` to check. `maui devflow broker stop` then
+ retry (CLI will auto-restart it).
+
+## Build Failures
+
+**Missing workloads:**
+```
+error NETSDK1147: To build this project, the following workloads must be installed: maui-ios
+```
+Fix: `dotnet workload install maui` (installs all MAUI workloads).
+
+**SDK version mismatch:**
+```
+error : The current .NET SDK does not support targeting .NET 10.0
+```
+Fix: Install the required .NET SDK version, or check `global.json` for version pins.
+
+**Android SDK not found:**
+```
+error XA0000: Could not find Android SDK
+```
+Fix: Install Android SDK via `android sdk install` or set `$ANDROID_HOME`.
+
+**iOS provisioning / signing errors:**
+Fix: For simulators, ensure no signing is configured (default). For devices, set up provisioning
+profiles via `apple appstoreconnect profiles list`.
+
+**General build failure recovery:**
+1. `dotnet clean` then retry the build
+2. Delete `bin/` and `obj/` directories: `rm -rf bin obj` then rebuild
+3. Check the full build output (not just the last error) — earlier warnings often reveal the root cause
+
+## CDP Not Connecting (Blazor Hybrid)
+
+If `maui devflow webview status` fails but `ui status` works:
+
+1. **Chobitsu not loading?** Check logs for `[BlazorDevFlow]` messages. If auto-injection failed, add `` manually to `wwwroot/index.html`
+2. **Blazor not initialized?** Navigate to a Blazor page first, then retry
+3. Check app logs: `maui devflow ui logs --limit 20` — look for `[BlazorDevFlow]` errors
+
+## Mac Catalyst: Repeated Permission Dialogs on Rebuild
+
+If macOS prompts "App would like to access your Documents folder" on every rebuild:
+
+**Cause:** TCC permissions are tied to the app's code signature. Ad-hoc Debug builds produce a
+different signature each rebuild → macOS forgets the grant and re-prompts. This happens even
+with App Sandbox disabled.
+
+**Fix:** Don't access TCC-protected directories (`~/Documents`, `~/Downloads`, `~/Desktop`,
+or dotfiles like `~/.myapp/` in the home root) programmatically. Instead use:
+- `Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)` → `~/Library/Application Support/` (not TCC-protected)
+- `NSOpenPanel`/`NSSavePanel` for user-initiated file access (grants automatic TCC exemption)
+
+If you can't avoid TCC paths, sign Debug builds with a stable Apple Development certificate
+so the code signature stays consistent across rebuilds.
+
+## macOS (AppKit) Issues
+
+For detailed macOS (AppKit) troubleshooting, see [references/macos.md](macos.md#troubleshooting).
+
+Common issues:
+- **No window appears** → Missing `AddMacOSEssentials()` in builder
+- **SIGKILL on launch** → Don't re-sign manually; clean rebuild instead
+- **Blazor stuck on "Loading..."** → Use `MacOSBlazorWebView`, not standard `BlazorWebView`
+- **No sidebar content** → Add `MacOSShell.SetUseNativeSidebar(shell, true)` + `FlyoutBehavior.Locked`
diff --git a/.github/skills/maui-devflow-onboard/SKILL.md b/.github/skills/maui-devflow-onboard/SKILL.md
new file mode 100644
index 000000000..d4ddc7c14
--- /dev/null
+++ b/.github/skills/maui-devflow-onboard/SKILL.md
@@ -0,0 +1,140 @@
+---
+name: maui-devflow-onboard
+description: >-
+ Add MAUI DevFlow to a .NET MAUI project with agent package references,
+ MauiProgram.cs registration, Blazor WebView support, GTK variants, Central
+ Package Management guidance, and verification commands. USE FOR: first-time
+ DevFlow setup, reviewing what files to edit, choosing DevFlow packages, or
+ continuing after `maui devflow init` installs skills. DO NOT USE FOR:
+ troubleshooting an already-integrated app that cannot connect, iterative app
+ debugging, UI inspection, or generic MAUI build failures (use
+ maui-devflow-debug). INVOKES: maui devflow CLI and dotnet CLI.
+---
+
+# DevFlow Onboard
+
+Use this skill to add MAUI DevFlow to a project after `maui devflow init` has installed the DevFlow skills.
+
+## When to Use
+
+- The project has no `Microsoft.Maui.DevFlow.*` package references yet.
+- The user asks how to onboard, initialize, install, integrate, or set up DevFlow.
+- The agent needs to choose between standard, Blazor, GTK, or GTK + Blazor DevFlow packages.
+- The repo uses Central Package Management and package edits need to be split correctly.
+- `maui devflow init` has installed skills and the user asks for the next project edits.
+
+## Route Elsewhere
+
+- If package references and `AddMauiDevFlowAgent()` are already present but the CLI cannot connect, use `maui-devflow-debug`.
+- If an agent is reachable and the user wants to inspect, tap, screenshot, or debug UI, use `maui-devflow-debug`.
+
+## Workflow
+
+1. Optionally run `maui devflow skills check` and update bundled skills before editing if it reports `update-available-from-current-cli`.
+2. Find MAUI app projects in the workspace. Prefer app projects with `UseMaui`, platform TFMs such as `net*-android`/`net*-ios`/`net*-maccatalyst`/`net*-windows`, or GTK MAUI package references.
+3. Determine whether each target project is standard MAUI, MAUI + Blazor WebView, GTK, or GTK + Blazor.
+4. Add the correct DevFlow package references. Respect Central Package Management if `Directory.Packages.props` is present.
+5. Register DevFlow in `MauiProgram.cs` inside `#if DEBUG`.
+6. Build and run the app.
+7. Verify with:
+
+ ```bash
+ maui devflow diagnose
+ maui devflow wait
+ maui devflow ui tree --depth 1
+ ```
+
+If verification fails after integration, switch to `maui-devflow-debug` for connectivity recovery.
+
+## Stop Signals
+
+- Stop before editing if multiple MAUI app projects exist and the user has not indicated which one to onboard.
+- Stop and ask before enabling DevFlow outside Debug builds.
+- Stop after package/registration edits and verify with a build before doing runtime debugging.
+
+## Critical Anti-patterns
+
+- Do not add versions to project `PackageReference` items when `Directory.Packages.props` is managing package versions.
+- Do not add Blazor DevFlow packages unless the app uses Blazor WebView.
+- Do not register DevFlow outside `#if DEBUG` unless the user explicitly asks.
+- Do not use the old `builder.Services.AddMauiDevFlowAgent()` pattern; use `builder.AddMauiDevFlowAgent()`.
+
+## Package Selection
+
+| Project flavor | Required packages |
+| --- | --- |
+| Standard MAUI | `Microsoft.Maui.DevFlow.Agent` |
+| MAUI + Blazor WebView | `Microsoft.Maui.DevFlow.Agent`, `Microsoft.Maui.DevFlow.Blazor` |
+| GTK MAUI | `Microsoft.Maui.DevFlow.Agent.Gtk` |
+| GTK MAUI + Blazor WebView | `Microsoft.Maui.DevFlow.Agent.Gtk`, `Microsoft.Maui.DevFlow.Blazor.Gtk` |
+
+Blazor WebView indicators include a `Microsoft.AspNetCore.Components.WebView.Maui` package reference or `AddMauiBlazorWebView()` in `MauiProgram.cs`.
+
+GTK indicators include package references such as `Maui.Gtk`, `Platform.Maui.Linux.Gtk4`, `GirCore.Gtk-4.0`, or `Platform.Maui.Linux.Gtk4.BlazorWebView`.
+
+## Central Package Management
+
+If the repo uses `Directory.Packages.props`, put versions there and leave project `PackageReference` entries versionless.
+
+```xml
+
+
+```
+
+```xml
+
+
+```
+
+If the repo does not use Central Package Management, put the version on the `PackageReference`.
+
+## MauiProgram.cs Registration
+
+For standard MAUI:
+
+```csharp
+using Microsoft.Maui.DevFlow.Agent;
+
+// inside CreateMauiApp(), before return builder.Build();
+#if DEBUG
+builder.AddMauiDevFlowAgent();
+#endif
+```
+
+For MAUI + Blazor WebView:
+
+```csharp
+using Microsoft.Maui.DevFlow.Agent;
+using Microsoft.Maui.DevFlow.Blazor;
+
+// inside CreateMauiApp(), before return builder.Build();
+#if DEBUG
+builder.AddMauiDevFlowAgent();
+builder.AddMauiBlazorDevFlowTools();
+#endif
+```
+
+For GTK, use the `.Gtk` namespaces and packages. GTK apps also need to start the agent after app activation, for example `app.StartDevFlowAgent()` in the platform app activation flow.
+
+For Mac Catalyst, ensure the Debug entitlements allow the in-app HTTP server:
+
+```xml
+com.apple.security.network.server
+
+```
+
+## Validation Checklist
+
+- `MauiProgram.cs` registers DevFlow only in Debug builds.
+- The app project references the package flavor that matches the target platform.
+- Blazor DevFlow tools are added only when the app uses Blazor WebView.
+- GTK apps start the DevFlow agent after app activation.
+- Mac Catalyst Debug entitlements include `com.apple.security.network.server`.
+- `dotnet build` succeeds.
+- A running app appears in `maui devflow list`.
+- `maui devflow ui tree --depth 1` returns a visual tree.
+
+## References
+
+- See `references/package-selection.md` for package/flavor details.
+- See `references/mauiprogram-registration.md` for registration patterns.
diff --git a/.github/skills/maui-devflow-onboard/references/mauiprogram-registration.md b/.github/skills/maui-devflow-onboard/references/mauiprogram-registration.md
new file mode 100644
index 000000000..d884f0632
--- /dev/null
+++ b/.github/skills/maui-devflow-onboard/references/mauiprogram-registration.md
@@ -0,0 +1,34 @@
+# MauiProgram.cs Registration
+
+Add DevFlow registration in `CreateMauiApp()` before `return builder.Build();`.
+
+Standard MAUI:
+
+```csharp
+using Microsoft.Maui.DevFlow.Agent;
+
+#if DEBUG
+builder.AddMauiDevFlowAgent();
+#endif
+```
+
+MAUI + Blazor WebView:
+
+```csharp
+using Microsoft.Maui.DevFlow.Agent;
+using Microsoft.Maui.DevFlow.Blazor;
+
+#if DEBUG
+builder.AddMauiDevFlowAgent();
+builder.AddMauiBlazorDevFlowTools();
+#endif
+```
+
+GTK uses the `.Gtk` namespaces:
+
+```csharp
+using Microsoft.Maui.DevFlow.Agent.Gtk;
+using Microsoft.Maui.DevFlow.Blazor.Gtk;
+```
+
+Keep the calls inside `#if DEBUG` unless the user explicitly wants DevFlow available in non-Debug builds.
diff --git a/.github/skills/maui-devflow-onboard/references/package-selection.md b/.github/skills/maui-devflow-onboard/references/package-selection.md
new file mode 100644
index 000000000..caef657fe
--- /dev/null
+++ b/.github/skills/maui-devflow-onboard/references/package-selection.md
@@ -0,0 +1,23 @@
+# Package Selection
+
+Choose packages based on the app flavor:
+
+| Project flavor | Packages |
+| --- | --- |
+| Standard MAUI | `Microsoft.Maui.DevFlow.Agent` |
+| MAUI + Blazor WebView | `Microsoft.Maui.DevFlow.Agent`, `Microsoft.Maui.DevFlow.Blazor` |
+| GTK MAUI | `Microsoft.Maui.DevFlow.Agent.Gtk` |
+| GTK MAUI + Blazor WebView | `Microsoft.Maui.DevFlow.Agent.Gtk`, `Microsoft.Maui.DevFlow.Blazor.Gtk` |
+
+MAUI indicators:
+
+- `true`;
+- platform TFMs such as `net10.0-android`, `net10.0-ios`, `net10.0-maccatalyst`, or `net10.0-windows10.0.19041.0`;
+- GTK package references such as `Maui.Gtk`, `Platform.Maui.Linux.Gtk4`, or `GirCore.Gtk-4.0`.
+
+Blazor WebView indicators:
+
+- package reference to `Microsoft.AspNetCore.Components.WebView.Maui`;
+- call to `AddMauiBlazorWebView()` in `MauiProgram.cs`.
+
+Use Central Package Management if `Directory.Packages.props` exists. Otherwise place versions directly on project package references.
diff --git a/.github/workflows/ci-comet.yml b/.github/workflows/ci-comet.yml
index b06000ca4..b30ce60ae 100644
--- a/.github/workflows/ci-comet.yml
+++ b/.github/workflows/ci-comet.yml
@@ -1,5 +1,8 @@
name: CI - Comet
+env:
+ COMET_WORKLOAD_VERSION: 11.0.100-preview.7.26410.2
+
on:
push:
branches: [main]
@@ -27,11 +30,10 @@ jobs:
- name: Setup .NET 11 Preview
uses: actions/setup-dotnet@v5
with:
- dotnet-version: '11.0.x'
- dotnet-quality: 'preview'
+ global-json-file: src/Comet/global.json
- name: Install MAUI workload
- run: dotnet workload install maui
+ run: dotnet workload install maui --version ${{ env.COMET_WORKLOAD_VERSION }}
- name: Build Comet
run: dotnet build src/Comet/Comet.csproj -c Release
@@ -63,11 +65,10 @@ jobs:
- name: Setup .NET 11 Preview
uses: actions/setup-dotnet@v5
with:
- dotnet-version: '11.0.x'
- dotnet-quality: 'preview'
+ global-json-file: src/Comet/global.json
- name: Install MAUI workload
- run: dotnet workload install maui
+ run: dotnet workload install maui --version ${{ env.COMET_WORKLOAD_VERSION }}
- name: Pack Comet
run: dotnet pack src/Comet/Comet.csproj -c Release -o ../../artifacts/packages
diff --git a/AGENTS.md b/AGENTS.md
index 05e9ea8ab..c3b0d784d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,6 +73,133 @@ dotnet test src/DevFlow/Microsoft.Maui.DevFlow.Tests/
- Test results: `artifacts/TestResults/**/*.xml`
- No quarantine or outerloop test attributes are used in this repo
+## Comet node-backend (Compose/SwiftUI) development
+
+Comet is being refactored off MAUI handlers onto a retained **node backend**
+(Jetpack Compose on Android, SwiftUI on iOS). This path uses **.NET 11 preview**,
+pinned by `src/Comet/global.json` — NOT the repo-root .NET 10.
+
+- **Run Comet builds from inside `src/Comet`.** Bare `dotnet` at the repo root
+ resolves the .NET 10 SDK (root `global.json`) and fails net11 targets with
+ NETSDK1045.
+- **Host tests link Comet via a HintPath**, so rebuild it first:
+ `dotnet build src/Comet/src/Comet/Comet.csproj -f net11.0-maccatalyst`
+ before `dotnet test tests/Comet.Tests` — otherwise tests run a stale DLL.
+- **Facade edits no longer need clean rebuilds** (fixed 2026-07-03). The old
+ `ComposableLambda2: no Java peer` crash after editing `src/Comet/src/vendor/`
+ was a .NET Android SDK incremental bug: `_BuildApkFastDev` didn't list
+ `libxamarin-app.so` (MVID-keyed debug typemaps) in its Inputs, so the APK kept
+ stale typemaps while fast deploy pushed the new assembly. Worked around in
+ `src/Comet/Directory.Build.targets` (`_CometFixFastDevApkInputs`); upstream
+ issue draft: `src/Comet/docs/research/upstream-issue-fastdev-typemap-staleness.md`.
+ If the crash ever reappears (SDK update changing target names), fall back to
+ `src/Comet/tools/clean-android.sh`. iOS: after a `Comet.SwiftUI.Shim` change,
+ re-run `build-xcframework.sh` AND rm the probe `obj`/`bin` (incremental build
+ won't relink the NativeReference xcframework).
+- **Probe apps**: `sample/CometComposeProbe` (Android),
+ `sample/CometSwiftUIProbe` (iOS). Android deploy:
+ `dotnet build -t:Run -p:AndroidPackageFormat=apk`.
+- **Standalone per-sample apps**: add `-p:CometSample=reply` (or `jetchat`) to
+ either probe build — unique app id `com.comet.sample.`, launcher name
+ "Comet Reply"/"Comet Jetchat", isolated `obj|bin/sample-/`, and the
+ screen defaults from the app id — so samples install SIDE BY SIDE instead of
+ replacing each other. The plain probe ids (`com.comet.composeprobe`,
+ `com.comet.swiftuiprobe`, Jetchat default + `--es screen` / `COMET_SCREEN`
+ switch) are what the smoke scripts drive — leave them installed.
+- **Two devices are usually attached** — always target explicitly with
+ `adb -s 13041FDD4007MT` (the physical Pixel 5). Don't use left-edge swipes to
+ open drawers (triggers the system back/home gesture).
+- **A black `adb screencap` usually means the display is off or the lock screen
+ is up, NOT a render bug** — check `dumpsys display | grep mScreenState` and
+ `keyguardShowing` before touching code. (Same lesson on the iOS simulator: its
+ GPU state degrades after a couple of launches — verify on a physical device.)
+- **Screenshots come scaled** ("multiply by N"); compute tap targets in the FULL
+ resolution (1080-wide), not the displayed size.
+- **Gold standard is local**: `~/work/compose-samples` (android/compose-samples —
+ Jetchat/JetNews/Reply). Read the Kotlin directly; don't WebFetch GitHub.
+
+### Matching the gold standard (fidelity rules) — TOP PRIORITY
+The point of the Comet sample work is proving Comet drives the **EXACT native
+control** the sample uses — the real Jetpack Compose widget on Android AND the
+real native SwiftUI control on iOS. The sample is the proof, not the goal.
+**A styled look-alike is a DEFECT even when it looks and behaves identically.**
+- **Before building/claiming any control, open the gold `.kt` and read which
+ composable it actually uses** (`grep -n "" .kt`), then drive
+ that exact widget — a `Button` is a `Button` (not a styled `Text`/`HStack`), a
+ FAB is a `FloatingActionButton`, a confirm is a `TextButton`. Don't assume which
+ control; VERIFY in source (the gold may hand-roll a row rather than use a Material
+ item — only the source tells you).
+- If the facade lacks the control or a needed param (e.g. FAB `containerColor`),
+ **EXTEND the facade** — that's the "fill out Comet" work, not a license to ship a
+ look-alike. Reproduce real modifiers too (`baselineHeight`, `clip(CircleShape)`).
+- **Same standard on iOS/SwiftUI** — drive the native SwiftUI control, never leave a
+ stub/placeholder where a native control exists.
+- Don't report "faithful / pixel-exact / done" without a side-by-side against the
+ gold image; state what you verified and on which device.
+- Never assert an environment fact (device locked, network up, the cause of a
+ black/blank shot) you haven't checked — verify first, then claim.
+
+### DevFlow integration in CometComposeProbe
+
+CometComposeProbe has a built-in DevFlow agent (`sample/CometComposeProbe/DevFlowHelper.cs`)
+that enables `maui devflow screenshot`, `maui devflow list`, and tap/inspect from the CLI.
+**Use DevFlow by default when doing Android Comet work** — it gives direct screenshot
+feedback without needing `adb screencap` + Read each time.
+
+#### Architecture (no UseMaui needed)
+
+- `DevFlowAgentService.StartServerOnly(IAgentDispatcher)` — designed for Comet apps where
+ `Application.Current` is unavailable; starts HTTP server + broker registration, no MAUI runtime.
+- `ComposeProbeAgentService` overrides `HandleScreenshot` and uses Android
+ `PixelCopy.Request` to capture GPU-rendered Compose content faithfully.
+- `PixelCopyListener` **must live outside `#if DEBUG`** — Xamarin.Android's JNI typemap
+ generator only registers `Java.Lang.Object` subclasses it sees in every build; a type
+ hidden behind `#if DEBUG` is skipped in the Release codegen pass → "no Java peer type found"
+ at runtime even on Debug builds after a clean deploy.
+- All `PixelCopy.Request` calls must be dispatched via `activity.RunOnUiThread(...)` — the
+ HTTP handler thread pool is not JNI-registered so `new PixelCopyListener(...)` would throw.
+
+#### ADB setup (required after every app restart — port changes each launch)
+
+The broker runs on the Mac; the agent runs on the device. Two tunnels are needed:
+
+```bash
+# 1. Device → Mac broker (reverse — device connects outward to Mac port 19223)
+adb -s 13041FDD4007MT reverse tcp:19223 tcp:19223
+
+# 2. Mac CLI → device agent (forward — Mac connects inward to device's assigned port)
+# Get the port first:
+adb -s 13041FDD4007MT logcat -d | grep "DevFlow agent started"
+# Then forward that port (e.g. 10227):
+adb -s 13041FDD4007MT forward tcp:10227 tcp:10227
+```
+
+Or read the port from the broker automatically:
+```bash
+PORT=$(maui devflow list | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['port']) if d else None")
+adb -s 13041FDD4007MT forward tcp:$PORT tcp:$PORT
+```
+
+#### Typical workflow
+
+```bash
+# After deploy, set up tunnels (see above), then:
+maui devflow list # confirm agent registered
+maui devflow ui screenshot --output /tmp/s.png # grab screenshot
+```
+
+#### Incremental deploy vs clean deploy
+
+After editing only `DevFlowHelper.cs` (not the facade), incremental deploy is fine.
+After adding or moving a `Java.Lang.Object` subclass (including `PixelCopyListener`),
+**do a clean rebuild** — incremental deploy replaces the managed DLL but not the APK's
+embedded Java proxy classes:
+
+```bash
+dotnet build sample/CometComposeProbe/CometComposeProbe.csproj -f net11.0-android -c Debug -t:Clean
+dotnet build sample/CometComposeProbe/CometComposeProbe.csproj -f net11.0-android -c Debug -t:Run "-p:AdbTarget=-s 13041FDD4007MT"
+```
+
## Code Conventions
- **ImplicitUsings**: enabled repo-wide
diff --git a/src/Comet/Directory.Build.props b/src/Comet/Directory.Build.props
index 987aeb0a2..241c6ec8f 100644
--- a/src/Comet/Directory.Build.props
+++ b/src/Comet/Directory.Build.props
@@ -12,7 +12,13 @@
- $(MicrosoftMauiControlsVersion)
+
+ 11.0.0-preview.7.26406.9
diff --git a/src/Comet/Directory.Build.targets b/src/Comet/Directory.Build.targets
index 0e0f5c0ed..7e137be17 100644
--- a/src/Comet/Directory.Build.targets
+++ b/src/Comet/Directory.Build.targets
@@ -62,6 +62,10 @@
-
+
+
+
diff --git a/src/Comet/NuGet.config b/src/Comet/NuGet.config
new file mode 100644
index 000000000..c52a66991
--- /dev/null
+++ b/src/Comet/NuGet.config
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Comet/RESULTS.md b/src/Comet/RESULTS.md
new file mode 100644
index 000000000..c418b38a1
--- /dev/null
+++ b/src/Comet/RESULTS.md
@@ -0,0 +1,535 @@
+# Comet-Next — Performance Baselines (Phase 0)
+
+These are the **pre-refactor** baselines captured on the existing MAUI-handler
+render path, on branch `davidortinau/comet-next` at the start of the Comet-Next
+refactoring (typed property storage + own backend abstraction + Jetpack Compose /
+SwiftUI backends). Every later phase A/B's against these numbers.
+
+See the plan at `~/.claude/plans/focus-on-comet-in-clever-ladybug.md` and the
+harness scripts in `tools/bench/`.
+
+## Environment
+
+| | |
+|---|---|
+| Date captured | 2026-06-12 |
+| Host | Apple M4 Max, 16 cores, macOS 26.5 (Darwin 25.5.0) |
+| .NET SDK | 11.0.100-preview.5.26302.115 (runtime 11.0.0, Arm64 RyuJIT) |
+| MAUI | .NET 11 preview 5 (`Microsoft.Maui.Controls $(MauiVersion)`) |
+| BenchmarkDotNet | 0.14.0 |
+| Android device | Pixel 5, Android 14 (physical, `13041FDD4007MT`) |
+| Android minSdk | 24 (bumped from 21 for .NET 11 preview 5) |
+
+## 1. Android app size — `CometStressTest`, Release default publish
+
+`dotnet publish -f net11.0-android -c Release` (no RID filter → fat APK, both
+ABIs; managed assemblies packed into `libassembly-store.so`). This is the
+"default Release" shape an app author gets today; the trimming/AOT gate in
+Phase 4 will compare against a single-RID, fully-trimmed build.
+
+| Metric | Value |
+|---|---|
+| APK raw size | **30.59 MiB** (32,077,084 bytes) |
+| APK download size (estimated) | 30.76 MB |
+
+Internal breakdown (largest entries, uncompressed):
+
+| Entry | Size |
+|---|---|
+| `lib/x86_64/libassembly-store.so` (managed code) | 8.66 MiB |
+| `lib/arm64-v8a/libassembly-store.so` (managed code) | 8.47 MiB |
+| `classes.dex` + `classes2.dex` | 15.58 MiB total |
+| `lib/*/libcoreclr.so` | 5.9 / 5.8 MiB |
+| `lib/*/libclrjit.so` | 3.4 / 3.2 MiB |
+
+> Note: dual-ABI (arm64-v8a + x86_64). A single-RID arm64 build roughly halves
+> the native `.so` + assembly-store contribution. The trimming win we are
+> targeting is on the managed `libassembly-store.so` (unused control handlers).
+
+## 2. Android cold start — `CometStressTest`, Release on Pixel 5
+
+`adb shell am start -W` TotalTime, app force-stopped between runs
+(`tools/bench/startup.sh`, 10 runs, median reported).
+
+| Metric | Value |
+|---|---|
+| Cold start (median of 10) | **644 ms** |
+| Cold start (range) | 629–711 ms |
+
+Raw runs (ms): 696, 629, 685, 635, 631, 655, 647, 639, 642, 711.
+
+> Methodology aligned with `jonathanpeppers/maui-profiling` (Pixel 5, adb,
+> launcher-activity resolution, `am start -W`). For Phase 4 we will additionally
+> capture the JIT / interpreter / profiled-AOT time split that repo tracks.
+
+## 3. Reactive / MVU microbenchmarks (BenchmarkDotNet)
+
+In-process, host-side (not on-device). These exercise the parts that **stay**
+through the refactor (Signal/Computed/scheduler, diff, body rebuilds) — they let
+us prove the typed-storage change doesn't regress the reactive core, and give the
+MVU-vs-XAML reference the framework has historically reported. Mean time and
+allocation per op.
+
+### Reactive primitives
+
+| Benchmark | Mean | Allocated |
+|---|---:|---:|
+| Create + dispose 1000 Signals | 19,878.9 ns | 192,000 B |
+| SignalList: 100 Adds + ConsumePendingChanges | 2,647.7 ns | 7,136 B |
+| 1 Signal write → 1 effect update | 3,166.0 ns | – |
+| 100 Signal writes → single flush | 631,694.3 ns | 747,136 B |
+| 50 Computeds, 1 Signal change → all re-evaluate | 60,180.8 ns | 50,680 B |
+| 1000-item SignalList + 1 Add → targeted insert | 4,007.2 ns | 17,192 B |
+| Computed cache hit (100 reads, no dep change) | 638.7 ns | – |
+
+### State updates (MVU vs XAML)
+
+| Benchmark | Mean | Allocated |
+|---|---:|---:|
+| XAML: Single property update | 1,360.8 ns | – |
+| **MVU: Single state update** | **174.6 ns** | – |
+| XAML: N independent property changes | 1,713.1 ns | – |
+| MVU: N independent state changes | 1,221.9 ns | – |
+| XAML: No-op property change (same value) | 1,467.7 ns | – |
+| MVU: No-op state change (same value) | 808.7 ns | – |
+| XAML: Change 1 of 100 properties | 1,764.7 ns | – |
+| MVU: Change 1 of 100 states | 1,042.2 ns | – |
+
+### View construction
+
+| Benchmark | Mean | Allocated |
+|---|---:|---:|
+| XAML: Flat StackLayout + Labels | 59,874.8 ns | 79,904 B |
+| MVU: Flat VStack + Text | 796.4 ns | 1,128 B |
+| XAML: Deep nested layouts | 311,710.9 ns | 269,775 B |
+| MVU: Deep nested views | 784.5 ns | 1,128 B |
+| XAML: Mixed form controls | 29,282.2 ns | 46,045 B |
+| MVU: Mixed form controls | 788.9 ns | 1,128 B |
+
+### Real-world scenarios
+
+| Benchmark | Mean | Allocated |
+|---|---:|---:|
+| XAML: Todo list build + mutations | 94,164.0 ns | 139,620 B |
+| MVU: Todo list build + mutations | 1,134.1 ns | 4,041 B |
+| XAML: Form build + validation | 57,653.5 ns | 93,218 B |
+| MVU: Form build + validation | 1,437.1 ns | 5,865 B |
+| XAML: Dashboard build | 175,332.3 ns | 244,125 B |
+| MVU: Dashboard build | 785.8 ns | 1,128 B |
+
+### Memory
+
+| Benchmark | Mean | Allocated |
+|---|---:|---:|
+| XAML: Alloc per property change | 2,152.2 ns | 3,664 B |
+| MVU: Alloc per state change (body rebuild) | 1,217.3 ns | 2,201 B |
+| MVU: Alloc for 100-node tree rebuild | 1,108.5 ns | 1,560 B |
+| XAML: Startup alloc (50-control page) | 1,243,158.0 ns | 2,181,102 B |
+| MVU: Startup alloc (50-control view) | 7,881.0 ns | 11,282 B |
+| XAML: Cascading computed properties | 6,868.5 ns | 12,352 B |
+| MVU: Cascading derived state | 1,626.9 ns | 3,201 B |
+
+### Diff algorithm
+
+| Benchmark | Mean | Allocated |
+|---|---:|---:|
+| Diff: Identical trees (no change) | 78,549.2 ns | 39,898 B |
+| Diff: Single node changed in N | 79,797.4 ns | 40,079 B |
+| Diff: All nodes changed | 84,354.7 ns | 40,217 B |
+| Diff: Append node to list | 916.7 ns | 1,344 B |
+| Diff: Remove node from middle | 1,000.8 ns | 1,368 B |
+| Diff: Toggle subtree (show/hide) | 983.0 ns | 1,392 B |
+
+### Rapid updates / animation
+
+| Benchmark | Mean | Allocated |
+|---|---:|---:|
+| XAML: Rapid counter | 30,796.2 ns | – |
+| MVU: Rapid counter | 9,208.1 ns | – |
+| XAML: Multi-prop animation (4 props) | 49,631.9 ns | 12,944 B |
+| MVU: Multi-prop animation (unbatched) | 30,781.0 ns | 9,928 B |
+| MVU: Multi-prop animation (batched) | 37,116.2 ns | 9,928 B |
+| XAML: String-heavy updates | 37,231.1 ns | 30,272 B |
+| MVU: String-heavy updates | 21,003.8 ns | 32,400 B |
+
+### Hot reload (must be preserved through the refactor)
+
+| Benchmark | Mean | Allocated |
+|---|---:|---:|
+| Hot reload: 10-Signal view state transfer | 32,236.0 ns | 10,928 B |
+| Hot reload: 50-Signal view state transfer | 124,847.5 ns | 57,272 B |
+
+## How to reproduce
+
+```bash
+# Microbenchmarks (host)
+dotnet run -c Release --project tests/Comet.Benchmarks -- --filter '*' --join
+
+# Android app size
+tools/bench/size.sh sample/CometStressTest/CometStressTest.csproj
+
+# Android cold start (set ANDROID_SERIAL if multiple devices attached)
+ANDROID_SERIAL= tools/bench/startup.sh \
+ sample/CometStressTest/bin/Release/net11.0-android/publish/com.comet.stresstest-Signed.apk \
+ com.comet.stresstest 10
+```
+
+---
+
+## Phase 4 — Trimming proof (managed code, new backend)
+
+The core thesis of the refactor: the legacy MAUI-handler path roots **every**
+control, so none can be trimmed, whereas the new node backend reaches a control's
+renderer only through the virtual `View.CreateBackendNode`, so unused controls
+trim away with their backend nodes.
+
+**Why the legacy path can't trim** — `AppHostBuilderExtensions.UseCometHandlers`
+(required by every legacy Comet app) registers a `Dictionary` literal
+with `typeof(Picker)`, `typeof(CollectionView)`, `typeof(GraphicsView)`, … for all
+~60 controls. A `typeof`/`ldtoken` in reachable code roots the type; the ILLinker
+cannot remove it. So a legacy "hello world" still ships every control + handler.
+
+**New backend, measured** — `CometComposeProbe` (pure Android, no `UseMaui`, no
+`UseCometHandlers`) uses only Text, Button, VStack, ListView, NavigationView.
+Published Release with `-p:TrimMode=full` (full ILLink; `RunAOTCompilation=false`
+to isolate managed trimming), then the linked `Comet.dll` was inspected with
+`ikdasm`/`monodis`:
+
+| Metric | Unlinked | Linked (TrimMode=full) | Δ |
+|---|---:|---:|---:|
+| `Comet.dll` size | 805,888 B (787 KiB) | 108,544 B (106 KiB) | **−87%** |
+| Type definitions (classes) | 773 | 113 | **−85%** |
+| `libassembly-store.so` (arm64, all managed) | 8.47 MiB¹ | 6.10 MiB | −2.37 MiB |
+
+¹ Phase 0 baseline (CometStressTest, legacy path).
+
+**Unused controls — typedefs present (unlinked → linked):**
+
+| Control | → | Control | → | Control | → |
+|---|---|---|---|---|---|
+| CollectionView | 3 → **0** | CarouselView | 2 → **0** | Picker | 2 → **0** |
+| GraphicsView | 1 → **0** | WebView | 1 → **0** | RefreshView | 2 → **0** |
+| FlyoutPage | 1 → **0** | Stepper | 3 → **0** | SwipeView | 1 → **0** |
+| TabView | 2 → **0** | SearchBar | 2 → **0** | DatePicker | 3 → **0** |
+| ProgressBar | 3 → **0** | ActivityIndicator | 2 → **0** | ScrollView/Frame/Border/Grid | → **0** |
+
+**Used controls survive** (linked): Text, Button, VStack, ListView (+`ListView`),
+NavigationView — all present.
+
+> Conclusion: the new backend makes Comet's managed surface pay-for-what-you-use —
+> a 5-control app ships 106 KiB of Comet instead of 787 KiB, and every unused
+> control (and its `CreateBackendNode`/`Compose*Node`) is gone. This is the
+> "handlers aren't trimmable → app-size bloat" problem, retired.
+>
+> Caveats: total APK is still dominated by the Compose AndroidX deps (dex) +
+> CoreCLR runtime, not Comet's managed code, so the win shows in `Comet.dll` /
+> assembly-store, not the package total. Cold-start A/B and a minimal **legacy**
+> trimmed build (to show controls *survive* empirically) are still pending — the
+> latter needs a few-control legacy app since CometStressTest instantiates most
+> controls.
+
+### Reproduce
+
+```bash
+dotnet publish sample/CometComposeProbe/CometComposeProbe.csproj \
+ -f net11.0-android -c Release -p:TrimMode=full -p:RunAOTCompilation=false
+LINKED=sample/CometComposeProbe/obj/Release/net11.0-android/android-arm64/linked/Comet.dll
+ikdasm "$LINKED" | grep -c '\.class ' # ~113
+ikdasm "$LINKED" | grep -E '\.class .*\bPicker\b' # (empty — trimmed)
+```
+
+### iOS / SwiftUI backend (same proof)
+
+`CometSwiftUIProbe` (Text/Button/TextField/Toggle/Nav) built
+`-c Release -r iossimulator-arm64 -p:TrimMode=full`, linked `Comet.dll` inspected:
+
+| Metric | Value |
+|---|---:|
+| `Comet.dll` (linked) | 243,200 B / 183 classes |
+| Compose backend (`ComposeNode`, `Compose*Node`) | **trimmed to 0** (Android-only code gone from the iOS build) |
+| SwiftUI nodes in use (`SwiftUINode`, `SwiftUINavigationNode`) | survive |
+| `SwiftUIListNode` (probe drops ListView) | **0** — trimmed |
+| Picker, GraphicsView, WebView, Stepper, TabView, DatePicker, ScrollView, Frame, Grid | **0** |
+
+So each platform's build trims the *other* backend plus unused controls. **Caveat
+(Phase 5 item):** on iOS a few legacy handlers still survive —
+`Comet.Handlers.CollectionViewHandler` (+ `CollectionView`/`CarouselView`) are
+rooted by the iOS static registrar's `[Register]` scan, even though the probe uses
+the new backend. On the pure new-backend Android probe these trim to 0. Fully
+decoupling the legacy iOS handlers from the new render path is Phase 5 work.
+
+## Phase 4 — Cold start A/B (Pixel 5, same session)
+
+`tools/bench/startup.sh` (`am start -W` TotalTime, force-stop + 2 s settle
+between runs, 10 runs, median). Both Release single-RID `android-arm64`, on the
+physical Pixel 5 (`13041FDD4007MT`), measured back-to-back on 2026-06-13.
+
+| App | Render path | Cold start (median) | Range |
+|---|---|---:|---:|
+| `CometStressTest` | **legacy** MAUI handlers (`UseCometHandlers`) | **721 ms** | 695–820 |
+| `CometComposeProbe` | **new** node backend, no `UseMaui` | **534 ms** | 515–570 |
+
+**~26% faster (−187 ms) and a much tighter distribution.** The new path is a pure
+`ComponentActivity` that never runs the MAUI app-host (`MauiApp.CreateBuilder` →
+handler registration → Shell), so it skips that init entirely; the legacy app pays
+for the full MAUI startup before the first frame.
+
+> Caveat: not a perfectly controlled A/B — the two are different apps
+> (CometStressTest exercises many pages; the probe is minimal), so part of the gap
+> is app complexity, not solely the backend. But the dominant cold-start cost is
+> runtime + framework init, and the new path provably avoids the MAUI host. (The
+> 2026-06-12 baseline measured CometStressTest at 644 ms; it read 721 ms today —
+> ~12% device/thermal drift — hence the same-session re-measure for a fair delta.)
+
+## Phase 5 — Legacy render path decoupled (iOS handler rooting fixed)
+
+The Phase 4 iOS caveat (legacy `CollectionViewHandler` + CollectionView/CarouselView
+survived trimming, rooted by the static registrar's scan of every NSObject type in
+the assembly) is resolved. The legacy MAUI ViewHandler render path is now gated
+behind `CometLegacyRenderPath` (MSBuild property, default `true`). Building with
+`-p:CometLegacyRenderPath=false` excludes the cluster (`Handlers/**`, `Maui/**`,
+`AppHostBuilderExtensions`, and the iOS `CometView*`/`CUI*` native views) so the
+registrar never sees them and they trim.
+
+`CometSwiftUIProbe` Release sim, `TrimMode=full`, linked `Comet.dll`:
+
+| | legacy ON | legacy OFF |
+|---|---:|---:|
+| `Comet.dll` | 243,200 B / 183 classes | **202,752 B / 136 classes** |
+| `CollectionViewHandler`, `CometViewHandler`, `CometView`, `CUITableView`, `ScrollViewHandler`, `NavigationViewHandler` | present | **all 0** |
+| SwiftUI backend (`SwiftUINode`, …) + used controls | present | present |
+
+Non-breaking: the default (`true`) build is unchanged — maccatalyst compiles and
+the host suite stays 875 pass / 1 known-fail. Windows/MacCatalyst keep the legacy
+path; iOS/Android node-backend apps opt out for the smaller, fully-trimmed binary.
+
+## Hot reload on the node backend (gate: "hot reload preserved") — 2026-07-01
+
+The plan's Phase 1/2 exit criterion, verified for the first time on the node
+backend — and it required real fixes: the reload path still assumed the legacy
+handler contract (handlers lazily re-render, so a rebuilt tree "eventually"
+appears). The node backend pushes patches at diff time, so the reload path had
+to transfer the retained node tree explicitly:
+
+- `UpdateFromOldView` now transfers `Node` (adopt + event-sink rebind to the new
+ view + re-emit set properties) — the node twin of the ViewHandler transfer.
+- Views with a live `Node` register as hot-reload targets (`AddActiveView`), and
+ a Component/[Body] view that collapses to its rendered subtree registers at
+ materialize time (`TriggerReload` only reloads `Parent == null` roots).
+- A fresh hot-reload replacement has never rendered; the diff now force-builds it
+ when retained nodes are in play, and `GetRenderView`'s replacement branch diffs
+ the outgoing built tree into the replacement so the nodes carry over.
+- `TryMergeComponents` reuses an already-installed replacement instead of
+ constructing a second instance (which stranded the transferred node).
+- `ComposeBackendRoot` re-resolves its layout root per pass (read-only
+ `BuiltView`; a captured tree goes stale after reload — and building views on a
+ background flush thread crashes).
+- New protocol hook `ICometBackendNode.OnOwnerViewChanged(View)`: own-content
+ nodes (Drawer, NavigationView, ListView) re-point at the reloaded view and
+ re-materialize the content they built from the old tree.
+
+Verified: 4 host tests (`Backend/BackendHotReloadTests.cs` — prop patch onto the
+SAME retained node, event rebind, root-type replacement via TriggerReload,
+component replacement preserving `Component` state) + on-device demo
+(emulator, `CometComposeProbe`): `adb shell am broadcast -n
+com.comet.composeprobe/.HotReloadDemoReceiver` swaps `JetchatRoot→JetchatRootV2`
+(the exact two calls `CometMetadataUpdateHandler` makes when the runtime applies
+an EnC delta); the running Jetchat re-renders with the new inset, the typed
+composer text survives, and the emoji selector still opens (events + reactive
+re-layout intact through the re-materialized drawer content).
+
+KNOWN LIMITATIONS: (1) structural child insert/remove in a reloaded tree does
+not re-materialize nodes yet (a brand-new view with no same-type pair renders
+nothing — legacy recreated handlers lazily, the node path needs explicit
+Insert/RemoveChild materialization; follow-up); (2) the SwiftUI nodes inherit
+the no-op `OnOwnerViewChanged` — iOS needs the same own-content pass.
+
+## Backend patch-stream benchmarks (host, BenchmarkDotNet) — 2026-07-01
+
+`tests/Comet.Benchmarks/BackendMutationBenchmarks.cs` (the plan's verification
+item): pure Comet-side overhead of the diff→backend contract against a no-op
+recording node (no Compose/SwiftUI interop in the loop). net11.0, ShortRunJob,
+MemoryDiagnoser (allocated = per single op):
+
+| Scenario | Mean | Allocated |
+|---|---:|---:|
+| `SignalChange_PatchesNode` (steady-state reactive patch: signal → flush → one property to the retained node) | **657 ns** | 1.52 KB |
+| `Materialize_SmallTree` (5-view tree → nodes, set-only property emission) | 358 µs | 151 KB |
+| `Reload_DiffsOntoRetainedNodes` ([Body] rebuild + diff + node transfer — the hot-reload path) | 279 µs | 119 KB |
+
+The steady-state loop (what runs per user interaction) is sub-microsecond with
+~1.5 KB/op (dominated by the bound string interpolation). Reload of a small
+Body sits ~350× under the plan's 100 ms hot-reload budget. Materialization is
+one-time-per-view; its 151 KB (env/context dictionaries) is the number to
+revisit when the typed-storage conversion of the remaining string-keyed
+environment happens.
+
+## FPS/GC on-device A/B — DEFERRED to hardware
+
+The dual-flavor scroll FPS + GC A/B (legacy handlers vs node backend, gfxinfo
+first-pass methodology) needs the physical Pixel 5; emulator frame numbers are
+not meaningful and are deliberately not recorded. Run it from tag
+`comet-pre-phase5-delete` (the last commit with the legacy render path present)
+when the device is available. The Jetchat scroll numbers already on record
+(100% jank / 350ms 90th → 3% / 12ms after the LazyColumn row cache, Pixel 5,
+Release) stand as the node-backend-only measurement.
+
+## NativeAOT (iOS) — gate: "NativeAOT where supported" — 2026-07-01
+
+`CometSwiftUIProbe` (full shared Jetchat sample) publishes under NativeAOT:
+`dotnet publish -f net11.0-ios -c Release -p:RuntimeIdentifier=ios-arm64
+-p:EnableProbeAot=true` (the probe csproj maps that to `PublishAot=true`
+app-side only — a global `-p:PublishAot` leaks into plain-classlib
+dependencies and fails with NETSDK1203, the PublishTrimmed trap again).
+
+ILC compiles end-to-end and produces the package: **7.2 MB .ipa** (17 MB .app,
+15.9 MB single native executable — no separate IL assemblies, no JIT).
+Remaining trim warnings come from MAUI's `HybridWebViewHandler` (legacy
+Microsoft.Maui.Controls dependency — goes away with the Phase 5 delete) and
+Comet's debug-only hot-reload reflection (IL2104, suppressed by design).
+Simulator RIDs don't support AOT (NETSDK1203) — device RID only.
+
+## Phase 5 COMPLETE: legacy MAUI render path DELETED — 2026-07-01
+
+The gate became a delete (approved): `Handlers/**`, `Maui/**`,
+`AppHostBuilderExtensions.cs`, and ALL native platform views
+(`Platform/{iOS,Android,MacOS,Windows,Standard}` handler bridges) are gone;
+the `CometLegacyRenderPath` MSBuild property and `COMET_LEGACY_RENDER_PATH`
+guards with them. The Compose/SwiftUI node backend is the only render path.
+Windows and the AppKit macOS heads are parked with it (TFMs removed);
+maccatalyst stays for the host suite. The 20 legacy samples
+(`UseCometApp`/`UseCometHandlers`) are parked as reference source —
+`sample/LEGACY-SAMPLES.md`; the last commit with the legacy path is tagged
+`comet-pre-phase5-delete`.
+
+Post-delete verification: all three TFM heads compile; host suite 886 pass /
+26 skip / 1 pre-existing known fail (4 handler-only NativeHost tests deleted
+with the interface they tested); CometComposeProbe renders on the Android
+emulator and CometSwiftUIProbe on the iPhone 16 Pro sim; Release
+TrimMode=full links Comet.dll at 265,728 B (larger than June's legacy-off
+202,752 B because P1–P7 + hot reload/IME/animations landed since — the point
+is the handlers can no longer be compiled in, let alone rooted).
+
+---
+
+# compose-samples build-out (Reply → JetNews → Jetsnack → Jetcaster → JetLagged)
+
+Per-sample sections use the template below (see
+`docs/sample-workflow-checklist.md`, step 8). Emulator numbers land with the
+sample; Pixel 5 numbers land in batches — **B1** after JetNews (Reply+JetNews),
+**B2** after Jetcaster (Jetsnack+Jetcaster), **B3** after JetLagged (final sweep
++ Jetchat re-run to catch framework-growth regressions).
+
+
+
+## Jetsnack — 2026-07-10
+
+Emulator = Pixel 9 Pro AVD (API 36, arm64, Apple M4 Max host). Gold = the Kotlin
+Jetsnack app from ~/work/compose-samples. NOTE: like JetNews, the gold's
+minified `app-release.apk` (10.6 MiB) fails to launch on this emulator
+(sample-side issue), so runtime comparisons use the gold DEBUG build.
+
+| Metric | Comet (emulator) | Comet (Pixel 5, B1) | Gold Kotlin app |
+|---|---|---|---|
+| Release APK, trimmed single-RID (`tools/bench/size.sh -p:CometSingleRid=true`) | 33.5 MiB (35,159,321 B) | — | 10.6 MiB (release artifact) |
+| Cold start median, 10 runs (`am start -W --es screen jetsnack`) | 678 ms | — | 876 ms (DEBUG build — release fails to launch) |
+| gfxinfo scroll jank, home feed (6 fling swipes) | 7.4% janky, p50 17ms / p90 20ms / p95 26ms | — | 5.3% janky, p50 17ms / p90 21ms / p95 25ms (DEBUG) |
+| iOS .ipa trimmed size / launch feel (manual note) | not measured (sim-only gate; Debug launch feels instant after splash) | n/a | n/a |
+| Comet.dll (linked, android-arm64) | 834 KiB (854,016 B) | — | n/a |
+
+Notes: the probe now bundles FOUR samples; the +7.3 MiB over the JetNews row
+is almost entirely the 36 snack photo jpgs (7.5 MiB of assets), not code.
+Cold start at 678 ms is the best Comet number yet (Reply 3705 → JetNews 2347 →
+Jetsnack 678 on the same runtime — the Jetsnack feed composes far less at
+first frame than JetNews' suite+list-detail chrome) and beats even the DEBUG
+gold. Scroll jank is at parity on the gradient-heavy feed. Pixel 5 column
+lands with batch B1.
+
+## JetNews — 2026-07-09
+
+Emulator = Pixel 9 Pro AVD (API 36, arm64, Apple M4 Max host). Gold = the Kotlin
+JetNews app from ~/work/compose-samples. NOTE: the gold's minified
+`app-release.apk` (3.8 MiB) CRASHES at launch on this emulator (sample-side
+proguard/deeplink issue), so runtime comparisons use the gold DEBUG build —
+gold cold start and jank read worse than a healthy release would.
+
+| Metric | Comet (emulator) | Comet (Pixel 5, B1) | Gold Kotlin app |
+|---|---|---|---|
+| Release APK, trimmed single-RID (`tools/bench/size.sh -p:CometSingleRid=true`) | 26.2 MiB (27,475,468 B) | — | 3.8 MiB (release artifact) |
+| Cold start median, 10 runs (`am start -W --es screen jetnews`) | 2347 ms | — | 2914 ms (DEBUG build — release crashes) |
+| gfxinfo scroll jank, home feed (6 fling swipes) | 13.8% janky, p50 17ms / p90 46ms / p95 81ms | — | 11.3% janky, p50 26ms / p90 42ms / p95 65ms (DEBUG) |
+| iOS .ipa trimmed size / launch feel (manual note) | not measured (sim-only gate; Debug launch feels instant after splash) | n/a | n/a |
+| Comet.dll (linked, android-arm64) | 829 KiB (849,408 B) | — | n/a |
+
+Notes: the probe APK now bundles THREE samples (Jetchat + Reply + JetNews,
+incl. all post images + fonts) — the total is still dominated by the AndroidX
+Compose dex + CoreCLR preview runtime, so the size delta vs the Reply row is
++0.1 MiB for a whole extra sample. Comet cold start improved vs the Reply row
+(3705 → 2347 ms median) with no tuning; against the DEBUG gold it now reads
+faster, but the honest release-gold comparison is blocked on the gold's own
+launch crash. Jank is near parity (worse p95, better p50) on the paragraph-
+heavy home feed. Pixel 5 column lands with batch B1.
+
+## Reply — 2026-07-08
+
+Emulator = Pixel 9 Pro AVD (API 36, arm64, Apple M4 Max host). Gold = the Kotlin
+Reply `app-release.apk` (minified) from ~/work/compose-samples, same device.
+
+| Metric | Comet (emulator) | Comet (Pixel 5, B1) | Gold Kotlin app |
+|---|---|---|---|
+| Release APK, trimmed single-RID (`tools/bench/size.sh -p:CometSingleRid=true`) | 26.1 MiB (27,409,987 B) | — | 3.4 MiB |
+| Cold start median, 10 runs (`am start -W --es screen reply`) | 3705 ms | — | 773 ms |
+| gfxinfo scroll jank, inbox list (6 fling swipes) | 13.0% janky, p50 26ms / p90 40ms / p95 57ms | — | 9.3% janky, p50 28ms / p90 40ms / p95 48ms |
+| iOS .ipa trimmed size / launch feel (manual note) | not measured (sim-only gate; Debug launch feels instant after splash) | n/a | n/a |
+| Comet.dll (linked, android-arm64) | 819 KiB (839,168 B) | — | n/a |
+
+Notes: the probe APK bundles BOTH samples (Jetchat + Reply, incl. avatar
+drawables + icon font) — a per-sample split would shave little since the total
+is dominated by the AndroidX Compose dex (~32 MB uncompressed across 3
+classes.dex) and the CoreCLR preview runtime (libcoreclr+libclrjit ~9 MB);
+managed code is 8.4 MiB (assembly store). No AOT/startup profile tuning yet —
+cold start carries full JIT + Compose init (the 4.8× gap vs gold is the
+headline framework-maturity number to attack, not the scroll path). Scroll
+jank is at parity with the gold app on the same device. Single-RID publish
+needs the `CometSingleRid` csproj switch — a raw `-p:RuntimeIdentifiers` flows
+into the netstandard2.0 source generator and breaks its copy step. Pixel 5
+column lands with batch B1 (after JetNews).
+
+
+## Pixel 5 batch B1 — 2026-07-15 (Reply + JetNews + Jetsnack)
+
+Physical Pixel 5 (`13041FDD4007MT`, Android 14). Comet = today's Release
+trimmed single-RID probe (bundles FOUR samples, 33.5 MiB — same APK as the
+Jetsnack row) launched with `am start -W --es screen `; 10 runs,
+0 FATALs across all 30 launches. Gold = Reply release APK; JetNews/Jetsnack
+DEBUG builds (their release APKs fail to launch — sample-side minify issue,
+same caveat as the emulator rows). Jank = 6 fling swipes on the same screens
+as the emulator rows, `dumpsys gfxinfo` after a post-launch reset.
+
+| Metric | Reply | JetNews | Jetsnack |
+|---|---|---|---|
+| Comet cold start median (range) | 1373 ms (1342–1439) | 1395 ms (1379–1423) | 1795 ms (1764–2127) |
+| Gold cold start median | 365 ms (release) | 1237 ms (DEBUG) | 1919 ms (DEBUG) |
+| Comet scroll jank | 26.7% janky, p50 13 / p90 150 ms | 21.9%, p50 11 / p90 105 ms | 20.0%, p50 13 / p90 250 ms |
+| Gold scroll jank | 1.6%, p50 10 / p90 20 ms (release) | 10.8%, p50 10 / p90 29 ms (DEBUG) | 25.0%, p50 14 / p90 97 ms (DEBUG) |
+
+Notes: on real hardware the full-JIT cost shows up in both cold start and the
+fling p90s (first-fling JIT warmup lands inside the 6-swipe window) — p50s are
+at parity everywhere, and Jetsnack Comet BEATS the debug gold on cold start
+(1795 vs 1919) and janky-frame rate (20.0 vs 25.0%). Reply remains the honest
+headline gap (1373 vs 365 release gold; 3.8× — emulator was 4.8×): no AOT /
+startup profile tuning yet. The per-sample emulator tables' "Pixel 5 (B1)"
+columns are superseded by this batch table.
diff --git a/src/Comet/docs/adaptive-primitives-design.md b/src/Comet/docs/adaptive-primitives-design.md
new file mode 100644
index 000000000..1c9b8e4ea
--- /dev/null
+++ b/src/Comet/docs/adaptive-primitives-design.md
@@ -0,0 +1,72 @@
+# Adaptive primitives design note (Reply → JetNews, Jetcaster)
+
+Written before coding, per plan. Two framework primitives + the chrome controls they
+compose. Everything keys off **`CometWindowMetrics`** (M0, `Backend/CometWindowMetrics.cs`):
+reading `WidthClass`/`HeightClass`/`SizeDp` in a body/binding re-renders exactly the
+views that read it — no new reactive machinery needed.
+
+## 1. `NavigationSuite` (the chrome switcher)
+
+Gold behavior (Reply): bottom bar <600dp, rail 600–1199dp, permanent drawer ≥1200dp
+(AND width-class Expanded); modal drawer overlays everything, openable only in rail
+mode. `NavigationSuiteScaffoldLayout` is NOT facade-bound and we deliberately don't
+bind it (plan fallback): **hand-compose the three variants in one Comet container view**.
+
+```csharp
+new NavigationSuite()
+ .Destinations(dests) // icon + label + route key, selected index Binding
+ .Header(...) // FAB/compose affordance per variant slot
+ .DrawerContent(...) // shared item list for modal+permanent sheets
+ .Content(() => currentScreen)
+```
+
+- Body: `switch (this.GetWindowMetrics().WidthClass, SizeDp.Width)` → HStack(rail, content)
+ | HStack(permanentSheet, content) | VStack(content, bottomBar); modal drawer wraps
+ the whole thing (existing Drawer control, gestures gated like the gold).
+- The suite type is **derived state, not stored** — resize re-runs the body; the
+ content view instance is shared across variants so screen state (scroll, text)
+ survives chrome swaps. Host test: resize across a breakpoint → chrome node type
+ changes, content node identity stable.
+- Per-variant chrome = real M3 widgets via new leaf controls (NavigationBar/Item,
+ NavigationRail/Item, NavigationDrawerItem, sheets) — thin nodes over the already-bound
+ facade composables, one commit each with FakeBackendNode tests.
+- Rail/drawer header-top/content-centered placement (gold `navigationMeasurePolicy`):
+ Yoga column with center-justified content block and `coerceAtLeast(header)` behavior —
+ express as spacer-weighted layout, NOT a custom measure policy.
+
+## 2. `ListDetail` (two-pane)
+
+Gold: accompanist TwoPane 50/50, 16dp gap, ≥840dp only; detail falls back to first item;
+compact pushes a full-screen detail with BackHandler.
+
+```csharp
+new ListDetail()
+ .List(() => inbox)
+ .Detail(() => opened ?? first)
+ .IsDetailOpen(binding) // compact: drives push/back; expanded: highlight only
+```
+
+- Body on WidthClass: Expanded → HStack(list.Frame(0.5f), gap 16, detail.Frame(0.5f));
+ else → single pane where `IsDetailOpen` swaps list↔detail (NavigationView push on
+ Android compact for real back handling).
+- Fold postures explicitly out of scope (plan); API leaves room (`SplitFraction`,
+ `Gap` modifiers) without modeling DisplayFeatures.
+- Host tests: breakpoint crossing swaps pane structure; open-detail state preserved
+ across the swap (the gold's `closeDetailScreen` LaunchedEffect on contentType is the
+ behavior to match: entering single-pane with detail-not-explicitly-open shows list).
+
+## 3. Plumbing prerequisite (M0 deferral now due)
+
+`ComposeNode.AvailableSize` is still a process-wide static fed by the root; the
+primitives read per-root `GetWindowMetrics()`. Re-plumb: ComposeBackendRoot owns a
+`CometWindowMetrics` instance, installs it via `view.WindowMetrics(m)`, updates it in
+LayoutChange (Shared stays as fallback + keeps updating for single-window). SwiftUI
+root same in RunLayout; REAL iOS resize observation (viewWillTransition/scene geometry)
+lands at the Reply iOS gate.
+
+## Test/verify hooks
+
+- Smoke: `android_resize 2100 2856` → assert rail element present, bottom bar absent;
+ `3780x2856` → drawer items with labels present; reset → bottom bar back. (Verified in
+ M0 that wm-size config change re-creates the activity and the app survives.)
+- The 3 gold chrome screenshots are the pixel contract (`sample/Shared/Reply/gold/`).
diff --git a/src/Comet/docs/baseline-alignment.md b/src/Comet/docs/baseline-alignment.md
new file mode 100644
index 000000000..92e24493f
--- /dev/null
+++ b/src/Comet/docs/baseline-alignment.md
@@ -0,0 +1,313 @@
+# Spec: Baseline alignment in the Comet layout engine
+
+Status: **Implemented on Android, pixel-exact; iOS pending.** Created 2026-06-14.
+
+> **Update — exact baseline landed.** The cross-engine seam (below) was closed by measuring
+> through **Compose's own `TextMeasurer`** (`ComposeTextMeasure.FirstBaselineDp` →
+> `TextLayoutResult.firstBaseline`) with a `TextStyle` mirroring the rendered `Text` — one engine for
+> measure + render, the RN model. **No JNI bridge was needed**: `createFontFamilyResolver`, `Density`,
+> `TextMeasurer`, `Measure`, `TextLayoutResult.FirstBaseline`, and `ConstraintsKt.Constraints` are all
+> directly callable from the Xamarin binding. Exact by construction (logged Jetchat header baselines:
+> `John Glenn` 16sp = 15.636dp, `8:12 PM` 12sp = 10.909dp → engine drops the timestamp 4.727dp so both
+> land at 15.636dp). Gotchas: `Constraints` can't pack a large *finite* maxHeight next to a width — use
+> Compose's `Int.MaxValue` Infinity sentinel; and a screenshot ruler has a ~1px font-size bias, so
+> trust the logged layout values, not pixel-counting.
+
+## Implementation notes (what shipped)
+- Public API: **`view.AlignBaseline()`** (env `Layout.BaselineAlign`), mirroring Compose's
+ `Modifier.alignByBaseline()`. Per-child opt-in on a row.
+- Engine: `YogaMeasureBridge.ApplyStyle` sets `AlignSelf = FlexAlign.Baseline` for opted-in
+ children of a row; `CometBackendLayoutEngine.Build` installs `node.BaselineFunction →
+ leaf.Node.MeasureBaseline(...)` (falling back to node height when null).
+- Node contract: `ICometBackendNode.MeasureBaseline(width, height)` (default `null`);
+ `ComposeTextNode` overrides it via `TextMeasure.FirstBaselineDp` — the analytical first-baseline
+ inside the pinned `LineHeightSp`, using the **proportional** leading split (Compose's default).
+- **Visibility gap (§4.1) was a non-issue:** `Comet.Layout.Yoga` already has
+ `[InternalsVisibleTo("Comet")]`, so `BaselineFunction`/`YogaBaselineFunc` are reachable — no port
+ change needed.
+- Jetchat `AuthorNameTimestamp` now uses `.AlignBaseline()`; the empirical `0.5dp` nudge is gone.
+- Verified: host unit test `BaselineAlignedRow_LinesUpChildBaselines` (engine math, generalizes to
+ any baseline values, 45/45 backend tests pass) + on-device Pixel 5 measurement (author/timestamp
+ baselines within 1px — the screenshot measurement floor).
+
+### Pixel-exact: the cross-engine seam (the ~1px residual)
+
+The engine baseline wiring is **the same design React Native uses** — Yoga `align-items: baseline`
++ a per-node baseline function the text node supplies (RN: ["base-line metric exposure for
+``"](https://github.com/facebook/react-native/commit/51b3529f6c2ca354800c0cf6ecb8eb3115eaa36e)).
+RN is pixel-exact because its `` **measures and renders with the same text engine** (Android
+`StaticLayout`/`Layout` → `ReactTextView`); the baseline handed to Yoga comes from the same layout
+object that paints the glyphs.
+
+Comet has **two text engines in series**: it measures with Android `StaticLayout`/`Paint`
+(`TextMeasure`) but **renders with Jetpack Compose** (`androidx.compose.ui.text`), which has its own
+leading distribution, `includeFontPadding` default, and integer-metric rounding. That seam is the
+entire ~1px error. Measured on a Pixel 5 for the 16sp/12sp pair, every *prediction* model floors at
+~1px regardless: proportional leading −1.0px, centered −0.93px, natural line-height (no leading)
+−2 to −3px. The leading model is not the lever; the cross-engine seam is.
+
+**The correct (RN-equivalent) fix: measure through Compose, so one engine does measure + render.**
+Two routes, both confirmed to be heavy facade work (investigated 2026-06-14):
+- **(1) Bind `TextMeasurer`** (measure-before-layout, RN-shadow-node style — cleanest, no frame
+ caveat). Needs: a shared `TextMeasurer` (built from `createFontFamilyResolver(context)` + `Density`
+ + `LayoutDirection`), its `measure()` (heavy — the `Constraints` inline value-class param packs to
+ a `long`; many params, a `$default` synthetic), and `TextLayoutResult.firstBaseline` + `.size`
+ (`IntSize` inline-class unpack). `ComposeTextNode.Measure`/`MeasureBaseline` then call it; this also
+ retires the `LineHeightSp`/letter-spacing fudges and makes *all* text sizing render-accurate.
+- **(2) Read `TextLayoutResult.firstBaseline` from the live render via `onTextLayout`**, cache it per
+ type-style (baseline depends only on family/weight/size, not content), and reflow once on a cache
+ miss (1-frame warm-up using the `StaticLayout` estimate, then exact; on a scrolling list, styles are
+ cached after their first row). Lighter conceptually BUT the facade's `[ComposeFacade]` generator
+ can't pass an object-typed callback (`[Callback]` supports only bool/string/float — error CN3005),
+ so the **core `Text` bridge must be hand-rewritten** (drop `[ComposeFacade]`), which is risky
+ because `Text` is used everywhere. Also needs a reflow trigger (`ComposeBackendRoot.RunLayout`
+ posted to the next frame).
+
+Recommendation: **(1) `TextMeasurer`** is the principled fix (single engine, measure-before-render,
+broad payoff) and should be its own task. Until then the proportional model (~1px, committed) is the
+floor for prediction. A purely *visual* exact result for a specific name+timestamp is also available
+by merging the two into one `AnnotatedText` run (Compose owns the line's baseline) — exact and
+fastest for long lists, but it's one `Text`, not the gold standard's `Row`+two `Text`s, and the gap
+becomes space-approximated.
+
+### Still open
+- **iOS**: the SwiftUI text node still returns `null` from `MeasureBaseline` (falls back to height
+ ≈ bottom). Implement via `UIFont` metrics — see §5. Not validated yet (Android-first per current
+ direction).
+- **`FormattedText`** (rich-text bubbles) doesn't override `MeasureBaseline` yet — only plain `Text`.
+- The proportional leading model matches Compose to ≤1px here; if a future font exposes a mismatch,
+ pin an explicit `LineHeightStyle` (§5) to make it closed-form.
+
+---
+
+## Original spec follows.
+
+## 1. Goal
+
+Let a row of text-bearing views align its children on a shared **text baseline**, the way
+Jetpack Compose's `Row(verticalAlignment = …)` + `Modifier.alignByBaseline()` and SwiftUI's
+`HStack(alignment: .firstTextBaseline)` do.
+
+The motivating case is the Jetchat conversation header — an author name and a timestamp at
+different font sizes that must sit on the same line:
+
+```
+John Glenn 8:12 PM
+‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ ← one shared baseline
+```
+
+The gold standard (`android/compose-samples` Jetchat `AuthorNameTimestamp`) uses:
+
+```kotlin
+Row {
+ Text(author, style = titleMedium, modifier = Modifier.alignBy(LastBaseline))
+ Spacer(Modifier.width(8.dp))
+ Text(timestamp, style = bodySmall, modifier = Modifier.alignBy(LastBaseline))
+}
+```
+
+### Why the current result is wrong
+
+The Comet layout engine has no baseline alignment, so today the sample bottom-aligns the two
+texts (`VerticalLayoutAlignment.End`) plus an **empirical `0.5dp` bottom-margin nudge** on the
+timestamp (`JetchatConversation.AuthorNameTimestamp`). Measured on a Pixel 5 this lands the two
+baselines within ~1px — but it is a hand-tuned constant calibrated to the specific 16sp/12sp pair.
+It does not generalize to other font-size pairs and is exactly the class of "≈2px off" error we
+want to eliminate structurally.
+
+**Definition of done:** a developer can opt two (or more) text views in a row into baseline
+alignment with no magic numbers, and the rendered baselines coincide on both the Compose (Android)
+and SwiftUI (iOS) backends, at any density and for any font-size pair.
+
+## 2. Background — how baseline alignment works
+
+- The **baseline** of a line of text is the line the glyphs "sit" on (descenders hang below).
+- Flexbox baseline alignment: in a row, each participating child reports its **first-baseline
+ offset** (distance from the child's top edge to its baseline). The engine finds the maximum such
+ offset across the row and shifts every participating child down so all baselines land on that
+ common line. Bigger text naturally has a larger ascent, so it pushes the shared baseline down and
+ the smaller text rides up to meet it.
+- This is **not** the same as bottom/top/center alignment of the boxes — those align box edges or
+ box centers, which only coincides with the baseline by accident.
+
+## 3. Prior art
+
+- **Yoga** (Meta's flexbox engine, the basis of React Native and of our C# port) fully supports
+ `align-items: baseline` and per-child `align-self: baseline`, plus a per-node **baseline
+ function** registered via `YGNodeSetBaselineFunc`. Docs:
+ https://www.yogalayout.dev/docs/styling/align-items-self
+- **React Native** exposes `alignItems: 'baseline'`; it works because RN's native **Text** shadow
+ node installs a baseline function that returns the text's first baseline from its measurement.
+ This is the canonical pattern we should mirror. See the Yoga 3.0 layout-conformance notes:
+ https://blog.logrocket.com/react-native-layout-management-yoga-3/
+- **Jetpack Compose**: `Modifier.alignByBaseline()` / `alignBy(FirstBaseline|LastBaseline)` on
+ `Row` children; the measured `Placeable` exposes `FirstBaseline`/`LastBaseline` alignment lines.
+- **SwiftUI**: `HStack(alignment: .firstTextBaseline)` and `.alignmentGuide(.firstTextBaseline)`.
+
+Takeaway: every comparable system implements this as **"the leaf reports its baseline; the
+container aligns to it."** Our engine is already built for exactly this.
+
+## 4. Current state of *this* codebase (what already exists)
+
+Good news: the vendored C# Yoga port (`src/Comet/src/Comet.Layout.Yoga/`) **already contains the
+full baseline machinery** — it was ported from Yoga's `Baseline.cpp`/`Align.cpp`, not stripped:
+
+- `FlexEnums.cs` → `FlexAlign.Baseline = 5`.
+- `YogaNode.cs` → `YogaBaselineFunc` delegate, `BaselineFunction` property, `HasBaselineFunc`,
+ `IsReferenceBaseline`.
+- `AlgorithmUtils.cs` → `BaselineHelper.CalculateBaseline(node)` (recursive: uses the node's
+ baseline func, else recurses into the first baseline child, else **falls back to the node's full
+ height**) and `BaselineHelper.IsBaselineLayout(node)`.
+- `YogaAlgorithm.cs` → the cross-axis positioning honors baseline layout (≈ lines 628, 700–780,
+ 1733–1795): it computes each child's ascent via `CalculateBaseline` and offsets children so
+ baselines line up.
+
+So the **engine needs no algorithm work.** The reason bottom-align is ~2px low today is precisely
+the fallback above: a text leaf with **no baseline function** reports its baseline as its full
+height (its bottom edge), so "baseline" degenerates to "bottom of the line box," which sits a
+descent-plus-leading below the real baseline.
+
+### The gaps (all at the Comet ↔ Yoga integration seam)
+
+1. **Visibility.** `YogaBaselineFunc` is `internal` and `IsReferenceBaseline` is `internal`, while
+ `BaselineFunction` is a `public` property *of an internal type* — so `Comet.csproj` (a different
+ assembly) cannot construct/set a baseline function today. Need to make the delegate public (or
+ expose a `public` setter taking `Func<…>`/a px value), or add `[InternalsVisibleTo]`.
+2. **No baseline value flows from the backend nodes.** `ICometBackendNode` exposes only
+ `Size Measure(double, double)` (`src/Comet/Backend/ICometBackendNode.cs:46`). There is no way for
+ a text node to report its first-baseline offset.
+3. **The layout engine never sets a baseline function.** `CometBackendLayoutEngine.Build`
+ (`src/Comet/Backend/CometBackendLayoutEngine.cs`, leaf branch ~line 103) installs a
+ `MeasureFunction` but no `BaselineFunction`.
+4. **No public API to request baseline alignment.** `LayoutAlignment` is
+ `Microsoft.Maui.Primitives.LayoutAlignment` (Start/Center/End/Fill) — a MAUI enum we cannot
+ extend with `Baseline`. We need a separate opt-in (see §6).
+5. **`YogaMeasureBridge.ApplyStyle`** (`src/Comet/Layout/YogaMeasureBridge.cs`) maps
+ `LayoutAlignment → AlignSelf`; it has no path to emit `FlexAlign.Baseline`.
+
+## 5. The core challenge: measured baseline must equal *rendered* baseline
+
+The baseline function returns a number used purely for layout. If that number differs from where the
+backend actually *draws* the baseline, the visual result is wrong by the difference — reintroducing
+the very ~2px error. So the hard part is **not** the flexbox math; it is computing a baseline value
+that exactly matches each backend's text rendering.
+
+### Compose (Android)
+`ComposeTextNode` pins `text.LineHeight = LineHeightSp(sp)` where `LineHeightSp = round(sp * 1.3)`
+(`ComposeLeafNodes.cs`). Within that line box, the baseline position depends on how Compose
+distributes the extra leading (`LineHeightStyle.Alignment`/`Trim`), which is **not** simply
+"`-ascent` from the top." Measurement already uses `android.text.StaticLayout` / `Paint.FontMetrics`
+(`TextMeasure`), so the metrics are in hand, but the leading distribution must be matched.
+
+Recommended approach: **make the distribution deterministic.** Set an explicit
+`LineHeightStyle(alignment = …, trim = LineHeightStyle.Trim.None)` on the rendered `Text`, then the
+first-baseline offset is a closed-form function of `FontMetrics` + `LineHeightSp` that the baseline
+function can reproduce exactly. Add `TextMeasure.MeasureBaseline(text, sp, width, typeface)`
+returning the first-baseline offset in **dp** (divide px by `ComposeNode.Density`).
+Cross-check with `StaticLayout.GetLineBaseline(0)` and, ideally, assert against the composed
+`Text`'s reported `FirstBaseline` alignment line in an instrumented test.
+
+### SwiftUI (iOS)
+Two options:
+- **(A) Compute from `UIFont` metrics** (mirrors the Compose path): first baseline ≈ `font.ascender`
+ plus the top-leading the shim applies; return it from `measureNode`. Keeps the absolute-positioning
+ model the engine already uses.
+- **(B) Defer to SwiftUI.** Because SwiftUI has true first-baseline support, a row that opts into
+ baseline alignment could be rendered as a real `HStack(alignment: .firstTextBaseline)` instead of
+ the engine's absolute placement. Cleaner typographically but it carves a hole in the
+ "engine owns all layout" model and only works when the whole row is SwiftUI-native.
+
+Option (A) is recommended for consistency with the Android path and the engine's design; (B) is a
+possible future optimization.
+
+## 6. Proposed design
+
+### 6.1 Public API (developer-facing)
+`LayoutAlignment` can't carry `Baseline`, so add a dedicated opt-in. Recommended: a per-child
+modifier mirroring Compose's `Modifier.alignByBaseline()`:
+
+```csharp
+new HStack {
+ new Text(author).TitleMedium().AlignBaseline(),
+ new Text(time).BodySmall().AlignBaseline(),
+}
+```
+
+`AlignBaseline()` sets an env flag (e.g. `EnvironmentKeys.Layout.BaselineAlign`) consumed by the
+engine, exactly like the existing `.AsSurface()`/`.Borderless()` opt-ins. (Alternative: an
+`HStack`-level `.BaselineAligned()` that sets `AlignItems=Baseline` for all children — less precise
+but matches the `Row(verticalAlignment=…)` shape. The per-child modifier is preferred; it is what the
+gold standard uses and avoids forcing baseline on non-text children like icons.)
+
+### 6.2 Backend-node contract
+Extend the node protocol so a leaf can report its baseline:
+
+```csharp
+// ICometBackendNode
+double? MeasureBaseline(double width, double height); // first-baseline offset from the top, in Dp;
+ // null = no text baseline (fall back to height)
+```
+
+Default implementation returns `null`. `ComposeTextNode` and the SwiftUI text node override it
+(§5). `FormattedText` returns its first line's baseline.
+
+### 6.3 Engine wiring
+- `CometBackendLayoutEngine.Build` leaf branch: if the view opted into baseline align, set
+ `node.BaselineFunction = (n, w, h) => (float)(leaf.Node?.MeasureBaseline(w, h) ?? h);`.
+- `YogaMeasureBridge.ApplyStyle` (and/or the engine's per-child setup): when the view has the
+ baseline-align flag, set `node.AlignSelf = FlexAlign.Baseline` (overriding the
+ cross-axis alignment), and ensure the parent isn't a column (baseline only applies to rows).
+- Make `YogaBaselineFunc` public (or expose a `Func<>`-typed setter) in `Comet.Layout.Yoga`.
+
+### 6.4 Retire the empirical nudge
+Once landed, delete the `0.5dp` margin + `VerticalLayoutAlignment.End` workaround in
+`JetchatConversation.AuthorNameTimestamp` and replace with `.AlignBaseline()` on both texts.
+
+## 7. Touch points (file checklist)
+- `src/Comet/src/Comet.Layout.Yoga/YogaNode.cs` — make `YogaBaselineFunc` public / expose setter.
+- `src/Comet/src/Comet/Backend/ICometBackendNode.cs` — add `MeasureBaseline` (default `null`).
+- `src/Comet/src/Comet/Backend/CometBackendLayoutEngine.cs` — set `BaselineFunction` for baseline leaves.
+- `src/Comet/src/Comet/Layout/YogaMeasureBridge.cs` — emit `FlexAlign.Baseline` for opted-in children.
+- `src/Comet/src/Comet/Helpers/…` — `AlignBaseline()` extension + `EnvironmentKeys.Layout.BaselineAlign`.
+- `src/Comet/src/Comet/Platform/Compose/ComposeLeafNodes.cs` — `TextMeasure.MeasureBaseline` + a
+ deterministic `LineHeightStyle`; `ComposeTextNode`/`ComposeFormattedTextNode` override `MeasureBaseline`.
+- `src/Comet/src/Comet/Platform/SwiftUI/SwiftUINode.cs` + `Comet.SwiftUI.Shim` — report baseline from
+ `measureNode` (UIFont metrics).
+- `JetchatConversation.AuthorNameTimestamp` — swap the nudge for `.AlignBaseline()`.
+
+## 8. Risks & open questions
+- **Leading distribution match (highest risk).** Getting `MeasureBaseline` to equal the rendered
+ baseline within ≤0.5px on Compose hinges on pinning `LineHeightStyle`. Must be verified by
+ instrumented measurement, not by eye.
+- **Multi-line text.** First vs last baseline semantics (`alignBy(FirstBaseline)` vs `LastBaseline`).
+ Jetchat uses `LastBaseline`; for single-line text first==last. Decide whether to expose both.
+- **Mixed children.** A row with an icon + text + text: only the texts opt in; the icon keeps its
+ own (top/center) alignment. The per-child API handles this; an `AlignItems=Baseline` container
+ would not.
+- **`LayoutContent` rows.** The LazyColumn/list row pass (`LayoutContent`) must preserve baseline
+ funcs — verify baseline survives the width-pinned/height-wrapped pass.
+- **Performance.** Baseline layout adds a second measurement pass for baseline rows in Yoga; rows
+ are tiny so this is negligible, but note it.
+
+## 9. Test plan
+- **Host/unit (`tests/Comet.Tests/Backend/…`):** a fake backend node returning a known
+ `MeasureBaseline`; assert that two children of different heights are positioned so
+ `top_i + baseline_i` is equal across the row (pure engine math, no device).
+- **Instrumented (Android):** render the 16sp/12sp pair, screenshot, measure the two baselines
+ (the harness already does this — see the pixel-baseline measurement used to find the original 2px
+ error); assert ≤0.5px. Repeat for a 22sp/14sp pair to prove it generalizes.
+- **iOS:** same screenshot-measure on the simulator.
+
+## 10. Rollout
+1. Engine plumbing + host unit tests (no rendering) — make `FlexAlign.Baseline` reachable end-to-end
+ with a fake baseline func.
+2. Compose `MeasureBaseline` + deterministic line height; instrumented test; convert Jetchat; delete
+ the nudge.
+3. SwiftUI `MeasureBaseline`; iOS verification.
+4. Doc the `AlignBaseline()` API in `controls.md`/`styling.md`.
+
+## Appendix: the original failing measurement (for regression context)
+Pixel 5, Jetchat header, bottom-aligned (pre-nudge): author baseline y=519, timestamp y=521 →
+timestamp **2px low** (it has less descender+leading below its baseline than the larger name). The
+`0.5dp` nudge brought it to within 1px but is font-size-pair-specific. This spec removes the need
+for any nudge.
diff --git a/src/Comet/docs/jetcaster-parity-backlog.md b/src/Comet/docs/jetcaster-parity-backlog.md
new file mode 100644
index 000000000..66f18b339
--- /dev/null
+++ b/src/Comet/docs/jetcaster-parity-backlog.md
@@ -0,0 +1,153 @@
+# Jetcaster (M4) — parity backlog
+
+Gold: ~/work/compose-samples/Jetcaster — **mobile/ + core/ only** (tv/wear/
+glancewidget out of scope per plan). Survey 2026-07-15 (file:line citations
+below are into that tree). Gold mobile-debug.apk built and ready for captures.
+
+## App shape (values-from-source pointers)
+
+- **Flat 2-destination NavHost** (JetcasterApp.kt:51-80): `home` and
+ `player/{episodeUri}` — NOT a bottom-nav app. Podcast details is a
+ **SupportingPaneScaffold supporting pane inside Home** (Home.kt:249), keyed
+ by podcast URI, with a custom `calculateScaffoldDirective` (Home.kt:132-172:
+ single pane when width OR height compact; 2 panes at expanded; tabletop →
+ vertical partitions). All wrapped in `SharedTransitionLayout`.
+- **Home** (Home.kt:362-441): radial-gradient background scrim → transparent
+ Scaffold; top bar = M3 **SearchBar** (decorative, never expands —
+ Home.kt:313) + LinearProgressIndicator while refreshing; content = ONE
+ **LazyVerticalGrid(Adaptive(362dp))** switching Library/Discover on a
+ bottom-center **HorizontalFloatingToolbar** pill (Home.kt:445-527).
+ - Library: **HorizontalMultiBrowseCarousel** of followed podcasts
+ (Home.kt:659) + "Latest Episodes" list.
+ - Discover: LazyRow of **FilterChip** category tabs (Discover.kt:96-118) +
+ per-category **HorizontalUncontainedCarousel** (PodcastCategory.kt:127) +
+ episode list.
+- **Episode rows** (EpisodeListItem.kt): Surface card + ripple, image shared
+ element, **SwipeToDismissBox** swipe-to-remove (:79), HTML summary via
+ HtmlTextContainer (:248).
+- **PodcastDetails** (supporting pane; PodcastDetailsScreen.kt): LazyVerticalGrid
+ header (280dp PodcastImage, expandable description, subscribe/notify
+ **ButtonGroup of ToggleButtons** :291) + episode items.
+- **Player** (PlayerScreen.kt): image-backed background with color scrim
+ (:193 — surface@0.9 over the Coil image), vertical gradient scrims
+ (:298/:358/:436), shared-element PlayerImage, **basicMarquee** title (:621),
+ **Slider** (:696), play/pause **ToggleButton with morphing shapes** (:722) +
+ ButtonGroup of prev/-10/+10/next IconButtons; fold-aware tabletop/book
+ layouts via Accompanist TwoPane (:254-334).
+- **Theme**: `MaterialExpressiveTheme`, and mobile ALWAYS uses the static
+ **darkScheme** (Theme.kt:478; dynamicColor param never true). Fonts:
+ Montserrat (already bundled for Jetsnack) + **RobotoFlex variable** (display
+ styles, e.g. displayLarge 64sp weight 738 — nearest static weight is the
+ documented approximation).
+
+## Data / player (app-layer plan)
+
+- Gold = **live RSS**: 18 hard-coded feeds (Feeds.kt:27-47), OkHttp + ROME
+ parse incl. iTunes DTD module (PodcastFetcher.kt:54-157), Room persistence
+ (5 entities), Coil artwork. **No artwork→theme extraction** (scrims only).
+- Gold player is a **MOCK TICKER** (MockEpisodePlayer.kt:88-112 — coroutine
+ delay loop advancing timeElapsed; media3 is in the version catalog but
+ unreferenced). Comet's player will be the same mock — that IS parity.
+- **Comet plan (per approved M4 shape)**: C# feed pipeline with TWO modes —
+ - **fixture mode** (default for smokes/pixel work): bundled snapshot of a
+ few real feed XMLs + bundled artwork, mirroring core/domain-testing
+ PreviewData (PreviewData.kt:27-72) so renders are deterministic offline;
+ - **live mode**: HttpClient fetch of the same 18 URLs, System.Xml parse
+ (RSS 2.0 + iTunes namespace: image, summary, duration, categories).
+ - Persistence: in-memory stores first (the gold's Room is an offline cache,
+ not observable behavior); revisit only if a screen needs it.
+- **Image v2 decision point** (plan): remote artwork = C#-side download cache
+ + placeholder/crossfade on the existing Image node — bias C# cache over
+ binding Coil, keeps iOS symmetric. Fixture mode sidesteps it for pixels;
+ live mode exercises it.
+
+## Capability classification
+
+**Bound in the facade already (unwired → needs Comet nodes/controls):**
+LazyVerticalGrid, HorizontalMultiBrowseCarousel, HorizontalUncontainedCarousel,
+SearchBar (+Docked/Top variants), FilterChip, Slider, Linear/Circular
+ProgressIndicator, TopAppBar, Snackbar/SnackbarHost, AlertDialog (Comet
+control exists), IconButton (backlogged promotion from M3).
+
+**Facade-missing (JNI binding work):** HorizontalFloatingToolbar (expressive),
+ButtonGroup, plain ToggleButton + ToggleButtonShapes (morphing),
+SwipeToDismissBox, basicMarquee modifier.
+
+**Comet-side capabilities:**
+- SupportingPaneScaffold → map onto the existing ListDetail/NavigationSuite
+ adaptive primitives (custom directive ≈ variantFor policy); assess whether
+ the real M3-adaptive widget is warranted or the gold's custom directive
+ makes hand-mapping faithful (it computes its own directive — same bar as
+ Jetsnack's hand-composed chrome).
+- **GradientSpec increment: Radial** — Home background radialGradientScrim
+ (GradientScrim.kt:44) and ImageBackgroundRadialGradientScrim
+ (ImageBackground.kt:43, + BlendMode.Multiply). Spec v2 (b4a0fb30) covers
+ vertical scrims (per-stop alpha precomputes the exponential decay of
+ GradientScrim.kt:72-138); radial is the next Direction/Kind value.
+- Image v2 (above). HTML→FormattedText mapping for episode summaries.
+- Shared-element transitions: same M3 decision — bounds-matched fallback,
+ documented deviation (plan's 3-day timebox already spent in M3 decision).
+- iOS twins: carousels → shim horizontal list w/ item sizing; floating
+ toolbar → hand-composed pill (SwiftUI has no twin); marquee → static
+ truncation deviation or manual animation.
+
+## Deviations declared up front
+
+- Mock player = gold behavior (not a deviation — the gold has no audio).
+- Marquee, morphing ToggleButtonShapes, shared elements: approximate/static
+ first pass, polish second pass (pre-agreed descope pattern).
+- Fold postures (tabletop/book TwoPane): WindowSizeClass adaptivity IS in
+ scope; hinge-specific postures are OUT per plan (foldable-hinge specifics
+ excluded).
+- displayLarge RobotoFlex weight 738 → nearest bundled static weight.
+
+## Milestone checklist state
+
+- [x] Source survey (this doc)
+- [x] Gold mobile-debug.apk built
+- [x] Gold captures, first set 2026-07-15 (emulator, live feeds):
+ compact-01-discover / 02-podcast-details / 03-player in
+ sample/Shared/Jetcaster/gold/. Capture notes: the floating
+ Library|Discover toolbar renders TOP-docked overlapping the status bar
+ on this build (survey said bottom-center — verify against source when
+ building it) and its Library half sits under the status clock, so the
+ library view + expanded/two-pane + offline-dialog captures are PENDING
+ (grab during build phase; details pane already proves the pane nav).
+ Player screen: episode CARD opens the player; the row's play button
+ only toggles mock playback in place.
+- [x] Fixture feed snapshot (`30230406`) + C# pipeline (`03f3ca5c`): models,
+ RSS+iTunes XDocument parser (host-verified on all 6 fixtures), in-memory
+ PodcastStore, MockEpisodePlayer ticker; fixtures wired into both probes
+ (Android assets + iOS bundle root). Live mode = later increment.
+- [x] Framework (`a92229d9`): radial GradientSpec; ListView grid
+ (LazyVerticalGrid Adaptive) + M3 carousel flavors; FilterChip control
+ (real widget + iOS twin); SearchBar control already existed (Reply).
+- [x] Home screen (`0bdaef8e`): Discover + Library on the floating pill,
+ verified interactive on Pixel 5. Known follow-ups: full-span grid
+ headers (span API unbound), HorizontalFloatingToolbar binding, Library
+ empty state, chip-label color nuance, reload granularity (whole-grid
+ ReloadData per chip tap ANRs the 3GB AVD; fine on hardware — the M3
+ reload-storm backlog item now has a concrete heavy consumer).
+- [ ] Screens remaining: PodcastDetails pane → Player (Home DONE)
+- [ ] Smokes both platforms; standalone app id; RESULTS row (+ Pixel 5 B2
+ with Jetsnack per plan); /code-review
+- [ ] Canvas feasibility spike (1 day, for M5 JetLagged) DURING this sample
+
+## /code-review round 1 (417bdcc7..HEAD) — outcome 2026-07-16
+
+11 verified findings, ALL FIXED (`022829a2` framework, `f9919ac8` sample):
+iOS FilterChip measured slots before Arrange materialized them (EnsureContent
+on the hosted base now guarantees it) and never Refresh()ed on state patches;
+ComposeListNode flavor precedence unified + carousel 0-width fallback + the
+scroll-state bridge no longer observes a state the grid can't attach
+(rememberLazyGridState binding BACKLOGGED); iOS border gradients honor
+spec.Direction; GradientSpec extent works on Vertical (Diagonal/Radial
+documented); RobotoFlex loads lazily; MockEpisodePlayer duration/threading/CTS
+hygiene; NEW IconButton control (real M3 widget both platforms) replacing the
+episode-card look-alikes. Verified: host 936, jetsnack 24+24 both platforms.
+
+Deferred cleanup themes (from the review's cleanup angles): one-row-ListView
+reload idiom → framework reactive-chrome primitive (3+ copies); per-sample
+ComposeScheme()/icon-map/typography boilerplate consolidation; gradient-over-
+solid on ONE node (the two-layer ZStack idiom will spread to player/details);
+grid full-span headers; sample registry for MainActivity's 3-site growth.
diff --git a/src/Comet/docs/jetchat-parity-backlog.md b/src/Comet/docs/jetchat-parity-backlog.md
new file mode 100644
index 000000000..cbd910ced
--- /dev/null
+++ b/src/Comet/docs/jetchat-parity-backlog.md
@@ -0,0 +1,240 @@
+# Jetchat → Comet parity backlog
+
+Purpose: use Google's **Jetchat** (android/compose-samples, the gold standard) to find and fill
+gaps in the **Comet node backend** (Jetpack Compose on Android). This is not about imitating the
+sample — each item below is a **Comet capability** the sample happens to exercise.
+
+**Method (important):** screenshots identify *what* differs (missing features, layout structure);
+every **color / size / spacing / lineHeight / weight** value is taken from **source**, never
+eyeballed or color-picked. Source root: `~/work/compose-samples/Jetchat/app/src/main/java/com/example/compose/jetchat`
+(checkout `d3ff757b`). Citations below are `file:line`.
+
+**Status (2026-06-14):** ✅ §0 value corrections (`d2743458`), ✅ **T1 Material You dynamic color**
+(`2a7e805b`), ✅ **T3 explicit line height** (`` — `View.LineHeight(sp)` → bubbles get
+bodyLarge 16/24; letterSpacing deferred, facade `Sp` integer-only + negligible). All verified Pixel 5.
+**Next: C1.** Facade is ready (`LazyColumn.State`/`.ReverseLayout`, `LazyListState.CanScrollForward`/
+`AnimateScrollToItemAsync`, `composer.RememberLazyListState`) — no facade change. C1 design:
+(1) `ListView` exposes a `Signal` "scrolled-away" + a `ScrollToBottom()`; (2) `ComposeListNode`
+remembers a `LazyListState`, reads `CanScrollForward` (boundary-triggered, so no per-frame recompose →
+no scroll-jank regression) and marshals it to the signal, and runs `AnimateScrollToItemAsync` on the
+remembered coroutine scope; (3) the JumpToBottom `ExtendedFloatingActionButton` (surface/primary,
+h36, `ic_arrow_downward` 18dp) overlays via ZStack. **Sub-dependency discovered: node-level REACTIVE
+VISIBILITY** (show/hide the button from the signal without re-running the whole tree) — currently the
+conversation tree is static; needs a `.Visible(signal)`/opacity node property or AnimatedVisibility.
+That's the real capability C1 unblocks (and it generalizes).
+✅ **D1 drawer structure** (``): "Chats" + leading logo pills + "(you)" + Settings, verified.
+✅ **C1 scroll-state + reactive visibility** (``): `ComposeNode` now honors `Opacity`/
+`IsVisible` → `Modifier.Alpha` + skip-clickable-when-faded (the **reactive-visibility capability**);
+`View.Backend` emits `Opacity` whenever explicitly set (even =1) so a toggle back to default reaches
+the node. `IListView` gained a scroll bridge (`ScrolledAway` signal + `RegisterScroller`/`ScrollToBottom`);
+`ComposeListNode` remembers a `LazyListState`, reads `CanScrollForward` in composition (boundary-
+triggered) → marshals to `ScrolledAway`, and registers an `AnimateScrollToItemAsync(last)` scroller.
+The Jetchat **JumpToBottom** FAB binds its opacity to `ScrolledAway` and taps → `ScrollToBottom()`.
+Verified Pixel 5: fades in at top / on scroll-up, animates to bottom on tap, fades out at the bottom.
+**Follow-up C1a:** the FAB is a styled Comet view (like the profile FAB); a real
+`ExtendedFloatingActionButton` *control* (Comet view → facade node) is deferred (same lift as the
+I-cluster "real widget" work). Box child `align`/`fillMaxSize` for the overlay handled by the existing
+ZStack engine path (no new unknowns). ✅ Follow-up (D1 color) DONE: the drawer chat-icon is now the
+jetchat logo TINTED monochrome (`onSurfaceVariant`, or `primary` when selected). `ComposeIconNode`
+routes a multicolor asset through the tinted Icon path WHEN an explicit `.Color()` is set (else the
+untinted Image path keeps its colors), so the channel-bar logo stays multicolor (dropped its no-op
+`.Color`) while the drawer rows tint. Verified Pixel 5.
+
+**Reframing finding:** the captured screenshots are rose/maroon because Jetchat runs
+`JetchatTheme(isDynamicColor = true)` → `dynamicLightColorScheme(context)` (`theme/Themes.kt:91`),
+i.e. **Material You from the wallpaper**. The blue scheme in our code is only the static fallback
+(`JetchatLightColorScheme`, `theme/Themes.kt`). So do not "match" the rose pixels — implement the
+capability and use the static tokens as the canonical values.
+
+---
+
+## 0. Value corrections to existing code (bugs from earlier screenshot-sampling)
+
+These are wrong *values* in `sample/Shared/Jetchat/JetchatTheme.cs`; the widgets are already correct.
+
+| Our current value | Correct (source) | Source |
+|---|---|---|
+| `TertiaryContainer = #F8D8F0` (FAB bg, pink) | `tertiaryContainer = Yellow90 = #FFDE9C` | `theme/Themes.kt` `JetchatLightColorScheme`; `theme/Color.kt` |
+| `OnTertiaryContainer = #3A2A33` | `onTertiaryContainer = Yellow10 = #261900` | same |
+| `SurfaceTinted #E7E9F8` used for the **header** | header is `CenterAlignedTopAppBar` → default container = **`surface` #FBFDFD** (no tint) | `components/JetchatAppBar.kt:46` |
+| same hex used for the **footer** | footer = `Surface(tonalElevation = 2.dp)` → **computed** tonal overlay; selector panel = `8.dp` | `conversation/UserInput.kt:151,224` |
+| footer action-icon tint ≈ primary | footer `Surface(contentColor = secondary)` → `DarkBlue40 #3648EA` | `conversation/UserInput.kt:151` |
+| lineHeight = `round(sp × 1.3)` heuristic | **explicit** per style (see T3) | `theme/Typography.kt` |
+| `LetterSpacing.Zero` everywhere | per style (see T3) | `theme/Typography.kt` |
+
+---
+
+## Theming capabilities
+
+### T1 — Material 3 dynamic color (Material You) + dark mode ⭐ foundational
+- **Source:** `theme/Themes.kt:91–110` — `JetchatTheme(isDarkTheme = isSystemInDarkTheme(), isDynamicColor = true)`; `dynamicLightColorScheme(context)` / `dynamicDarkColorScheme(context)` on API ≥ 31, else `JetchatLightColorScheme` / `JetchatDarkColorScheme`.
+- **Comet gap:** consume a real Material `ColorScheme` provider (incl. the platform dynamic scheme) and respond to system dark mode; feed it through the existing `ComposeBackendRoot.WrapContent` theme hook. Today the app hardcodes a light scheme.
+- **Canonical static tokens** (`theme/Themes.kt`, raw in `theme/Color.kt`): primary `Blue40 #1546F6`, onPrimary white, primaryContainer `Blue90 #DDE1FF`, secondary `DarkBlue40 #3648EA`, surface/background `Grey99 #FBFDFD`, onSurface `Grey10 #191C1D`, surfaceVariant `BlueGrey90 #E2E1EC`, onSurfaceVariant `BlueGrey30 #45464F`, tertiary `Yellow40 #7A5900`, tertiaryContainer `Yellow90 #FFDE9C`, onTertiaryContainer `Yellow10 #261900`, outline `BlueGrey50 #767680`.
+
+### T2 — Tonal elevation (`surfaceColorAtElevation`)
+- **Source:** footer `Surface(tonalElevation = 2.dp)` (`conversation/UserInput.kt:151`); selector panel `Surface(tonalElevation = 8.dp)` (`:224`).
+- **Comet gap:** compute the M3 tonal overlay (primary over surface at an elevation-derived alpha) instead of a flat sampled hex. Pairs with T1.
+
+### T3 — Type scale: explicit lineHeight + letterSpacing
+- **Source:** `theme/Typography.kt` — replace our `1.3×` heuristic with exact values:
+ - headlineSmall: Montserrat SemiBold **24 / 32**
+ - titleMedium: Montserrat SemiBold **16 / 24**, ls 0.1
+ - titleSmall: Karla Bold **14 / 20**
+ - bodyLarge: Karla Normal **16 / 24**, ls **0.15**
+ - bodyMedium: Montserrat Medium **14 / 20**, ls 0.25
+ - bodySmall: Karla Bold **12 / 16**, ls **0.4**
+ - labelLarge: Montserrat SemiBold **14 / 20**, ls 0.1
+ - labelMedium: Montserrat SemiBold **12 / 16**, ls 0.5
+ - labelSmall: Montserrat SemiBold **11 / 16**
+- **Comet gap:** carry explicit `lineHeight` + `letterSpacing` through the type system and the text measure/baseline path (bodyLarge is **24**, not 20.8 — this shifts bubble + profile line spacing and the baseline math). Families/weights/sizes already match.
+
+---
+
+## Conversation screen (`conversation/Conversation.kt`, `JumpToBottom.kt`)
+
+### C1 — Jump-to-bottom button ✅ (unlocked the scroll-state + reactive-visibility capabilities)
+- **Source:** `JumpToBottom.kt` — `ExtendedFloatingActionButton`, icon `ic_arrow_downward` (height 18dp), text `R.string.jumpBottom` ("Jump to bottom"), `containerColor = surface`, `contentColor = primary`, height **36dp**, visibility via `updateTransition` + `animateDp` offset between **-32dp** (gone) and **32dp** (visible). Trigger: `firstVisibleItemIndex != 0 || firstVisibleItemScrollOffset > 56.dp` (`Conversation.kt:329–336`, `JumpToBottomThreshold = 56.dp:563`), `align(BottomCenter)`.
+- **Comet gap:** expose `LazyListState` (first-visible index/offset) + `scrollToItem(0)` from `ListView`/`ComposeListNode`, plus animated show/hide. Today our list has no scroll awareness.
+
+### C2 — reverseLayout + auto-scroll on send ◑ (auto-scroll-on-send done; reverseLayout pending)
+- **Source:** `Messages(...)` uses a `LazyColumn(reverseLayout = true)` (newest at bottom); `UserInput(resetScroll = { scope.launch { scrollState.scrollToItem(0) } })`.
+- **Done (with I5, ``):** Send appends a "me" `MsgRow`, `ListView.ReloadData()` (now also drives the node backend via `UpdateBackendNode` → re-emit `List_Version` → recompose) rebuilds the LazyColumn, and `ScrollToBottom()` (C1 scroller) animates to the new message; the field clears via the two-way `InputText` signal.
+- **Pending:** `reverseLayout` (open at the newest message instead of the top) — needs the list to expose `ReverseLayout` + open scrolled-to-bottom.
+
+### C3 — Date day-headers by real date
+- **Source:** `Conversation.kt:300–306` emit `DayHeader("20 Aug")` and `DayHeader("Today")`; `DayHeader` (`:445`): row of `DayHeaderLine` + Text `labelSmall`, `onSurfaceVariant`, `padding(vertical=8, horizontal=16).height(16)`; divider color `onSurface.copy(alpha=0.12f)` (`:463`). We hardcode a single "Today".
+
+### C4 — Author avatar double-ring (verify ours matches)
+- **Source:** `Image(...).padding(horizontal=16).size(42).border(1.5.dp, borderColor, CircleShape).border(3.dp, surface, CircleShape)`; `borderColor = primary` (me) / `tertiary` (others) (`Conversation.kt` Message ~357–397). Non-first-in-group rows use a `Spacer(width = 74.dp)` gutter (16+42+16). Group spacing: `padding(top = 8.dp)` between authors; within a group `Spacer(height = 8.dp)` then `4.dp`.
+- **Already matches** (commit f3633806 FlexShrink + rings); re-confirm the **two** borders (1.5dp color + 3dp surface gap) and the `tertiary` ring for others (now `Yellow40`, was sampled).
+
+### Already faithful (for reference, do not redo)
+- ChatItemBubble: me = `primary`, others = `surfaceVariant`; shape `RoundedCornerShape(4,20,20,20)`; text `padding(16)`; image attachment `size(160)`, `Spacer(4)` (`:473–508`).
+- AuthorNameTimestamp: name `titleMedium` `paddingFrom(LastBaseline, after=8.dp)`, `Spacer(8)`, timestamp `bodySmall` `alignBy(LastBaseline)` `onSurfaceVariant` (`:422–443`) — our `.AlignBaseline()` covers this.
+- ChannelNameBar: title `titleMedium`, "N members" `bodySmall`/`onSurfaceVariant`; search/info `Icon` tint `onSurfaceVariant` `padding(horizontal=12, vertical=16)` (`:222–278`). Nav icon = `JetchatIcon` `size(64).padding(16)` (`JetchatAppBar.kt`).
+
+---
+
+## Input bar (`conversation/UserInput.kt`) — the largest cluster
+
+### I1 — InputSelector state machine + expandable panel ⭐
+- **Source:** `InputSelector` enum `{ NONE, MAP, DM, EMOJI, PHONE, PICTURE }` (`:113`); `UserInput` (`:135`) tracks `currentInputSelector`; `SelectorExpanded` (`:212`) shows a `Surface(tonalElevation = 8.dp)` (`:224`) panel below the input that swaps content by selector; selector row `UserInputSelector` `height(72).padding(start=16,end=16,bottom=16)` (`:265`).
+- **Comet gap:** stateful composite input with an expandable selector region.
+
+### I2 — Selected action-icon state
+- **Source:** `InputSelectorButton` (`:345`): when selected, `Modifier.background(color = LocalContentColor.current, shape = RoundedCornerShape(14.dp))`, icon tint `contentColorFor(LocalContentColor)`; `IconButton`, icon `padding(8).size(56)`. Icons: `ic_mood` (EMOJI), `ic_alternate_email` (DM), `ic_insert_photo` (PICTURE), `ic_place` (MAP), `ic_duo` (PHONE).
+- **Note:** the maroon "teardrop" above a selected icon in the screenshots is the **OS text-cursor drag handle** (the field was focused), **not** a Jetchat element — do not build it.
+
+### I3 — Emoji selector (EMOJI/STICKER tabs + emoji table)
+- **Source:** `EmojiSelector` (`:570`) with `ExtendedSelectorInnerButton` tabs (`:609`); `EmojiTable` (`:633`) = `Column(fillMaxWidth) { repeat(4) { Row(fillMaxWidth) { repeat(EMOJI_COLUMNS) { Text(emoji, clickable, textAlign=Center, Modifier.weight(1f)) } } } }`.
+- **Comet gap:** *minor* — this is **weighted Rows, not a Grid** (Yoga `FlexGrow` already supports it). Real needs: a tab row + insert-emoji-at-cursor (see I7). (No `LazyVerticalGrid` required — earlier assumption corrected by reading source.)
+
+### I4 — Dialog / AlertDialog on the node backend ✅ (``)
+- **Source:** `NotAvailablePopup` (`:381`) = Material `AlertDialog` "Functionality not available 🙊" + CLOSE (`UiExtras.kt` `FunctionalityNotAvailablePopup`); `FunctionalityNotAvailablePanel` (`:237`) = "Functionality currently not available / Grab a beverage and check back later!" (panel, not dialog).
+- **Done:** new Comet `AlertDialog` control (`Controls/AlertDialog.cs`: `Signal IsOpen` + Text/ConfirmButton/Title?/DismissButton? slot Views) → `ComposeAlertDialogNode` renders the real Material 3 `AlertDialog` (own window + scrim) only while open, materializing the slot Views and dropping them into the facade `ConfirmButton`/`Text`/`Title`/`DismissButton` slots (laid out by Material, no Yoga). `Dialog_IsOpen` prop (224) + `DialogDismissed` event (12), wired exactly like Drawer (signal→node, onDismissRequest→event→IsOpen=false). `IBackendManagesOwnContent` so it's a zero-size leaf in its parent layout — can sit anywhere. iOS = empty placeholder node (SwiftUI `.alert` deferred, Android-first). Triggered faithfully from the DM "@" selector (`InputSelector.DM -> NotAvailablePopup`). Verified Pixel 5: opens on "@", dismisses via CLOSE button AND scrim tap. **Follow-up I4a:** `FunctionalityNotAvailablePanel` (the inline panel, not the dialog) + a `TextButton` variant for CLOSE (currently a plain styled Button).
+
+### I5 — Send button reactive enabled/disabled ✅ (``)
+- **Source:** `Button` `height(36)`, `enabled = sendMessageEnabled`, `border = if(!enabled) BorderStroke(1.dp, onSurface.copy(0.3f)) else null`, `contentPadding = PaddingValues(0)`, `colors = ButtonDefaults.buttonColors(disabledContentColor = onSurface.copy(0.3f))`, Text `padding(horizontal=16)` (`:265` block). Empty → outlined/transparent; text present → filled `primary`.
+- **Done:** the composer text is a two-way `Signal InputText` (bound via `SignalExtensions.TextField`); the Send button restyles reactively from it (subscribe `PropertyChanged` → `send.Outlined(false).Color(OnPrimary)` filled when text present, `Outlined(true).Color(Disabled)` bordered-grey when empty). Added `Button.Outlined(bool)` + made `Button_Outlined` emit both ways (the set-only patch would otherwise leave it stuck outlined — same fix as Opacity). Verified Pixel 5: empty=outlined/grey, type→filled primary, send→clears→back to outlined.
+
+### I6 — Voice record (mic): long-press + drag + animated overlay
+- **Source:** `RecordButton.kt`; `RecordingIndicator` (`UserInput.kt:505`) — press-and-hold record, swipe-to-cancel, pulsing dot.
+- **Comet gap:** long-press + drag gestures + animated overlay. Larger; later.
+
+### I7 — TextField: cursor/selection + focus/IME coordination
+- **Source:** `UserInputText`/`UserInputTextField` (`:391–503`), `height(64)`, text `padding(start=32)`, `imeAction = Send`; emoji insert uses `TextFieldValue.addText` at the selection (`:197`); opening the emoji panel hides the keyboard and vice-versa.
+- **Comet gap:** `TextFieldValue` selection/caret + focus/IME control. Feeds I3 (insert at cursor) and I1 (keyboard ⇄ selector).
+
+---
+
+## Navigation drawer (`components/JetchatDrawer.kt`)
+
+### D1 — Drawer structure parity
+- **Source:** `JetchatDrawerContent` (`:65`): `DrawerItemHeader` "Chats" / "Recent Profiles" / "Settings" (`heightIn(min=52).padding(horizontal=28)`, `bodySmall`/`onSurfaceVariant`, `:117`).
+ - `ChatItem` (`:133`): `height(56).padding(horizontal=12).clip(CircleShape)`; selected → `background(primaryContainer)`; leading `Icon` (jetchat logo) tint `primary` (selected) / `onSurfaceVariant`, `padding(start=16,top=16,bottom=16)`; label `bodyMedium`, `primary`/`onSurface`, `padding(start=12)`. Two chats: "composers" (selected), "droidcon-nyc".
+ - `ProfileItem` (`:174`): same 56dp pill; avatar `Image size(24).clip(CircleShape)` (or `Spacer` if none), `padding(start=16,top=16,bottom=16)`; label `bodyMedium`/`onSurface`. "Ali Conors **(you)**", "Taylor Brooks".
+ - `WidgetDiscoverability` (`:246`) under "Settings": "Add Widget to Home Page".
+- **Comet gap:** mostly content/structure (section header "Chats" not "Channels", leading chat icons, `(you)` suffix, dynamic `primaryContainer` selected pill). Our drawer is close but differs in section name + per-chat icons.
+
+---
+
+## Profile (`profile/Profile.kt`) — mostly done; deltas
+
+### P1 — FAB float position + dynamic color
+- **Source:** `FloatingActionButton` `padding(16).align(BottomEnd).offset(y = -100.dp).height(48).widthIn(min=48)`, `containerColor = tertiaryContainer`, icon `ic_create` (me, "Edit Profile") / `ic_chat` (other, "Message") (`:113,233–245`).
+- **Delta:** ours uses `Margin(bottom: 24 + inset)`; source is `padding(16) + offset(y = -100.dp)` (≈116dp lift). FAB color falls out of T1 (`tertiaryContainer`).
+
+### P2 — Parallax photo header
+- **Source:** `ProfileHeader` (`:178`): `Image.heightIn(max = containerHeight/2).fillMaxWidth().padding(start=16, top=offsetDp, end=16).clip(CircleShape)`, `ContentScale.Crop`; `offset = scrollState.value / 2` (`:179`) → the photo scrolls up at half speed.
+- **Comet gap:** scroll-offset-driven layout (shares C1 scroll-state capability).
+
+### Already faithful (commit 5ae5833b)
+- `UserInfoFields`: `Spacer(8)`, `NameAndPosition`, 4×`ProfileProperty` (`:120`). `NameAndPosition` `Column(padding horizontal 16)`, Name `baselineHeight(32)`, Position `padding(bottom=20).baselineHeight(24)` (`:144`). `ProfileProperty` `Column(padding start=16,end=16,bottom=16)`, Label + value `baselineHeight(24)`, link = `primary` (`:203`).
+
+---
+
+## App / navigation
+- **Source:** single-Activity `NavHost`; drawer hosts conversation + profile; `AnimatedContent` transitions.
+- **Comet gap (minor):** animated nav transitions (we have push/pop; commit ca096220).
+
+---
+
+## Parked (need framework infrastructure that isn't built yet)
+- **I1/I2/I3 — InputSelector state machine + expandable selector panel + emoji table.** The panel
+ appears below the input and swaps content by selector (EMOJI table / "not available" panel), with
+ the active icon highlighted. The icon highlight is reactive-property (doable), but the **panel's
+ appear/collapse + content-swap is a structural reactive change** — clean only via root-Component
+ structural re-render (**task #28**, still open) or a new conditional-content "expandable panel"
+ node (the AlertDialog/Drawer pattern: always in the tree, renders the active panel from a
+ MutableState). Parked to avoid a half-built unverifiable state unattended. The DM selector already
+ shows the real `NotAvailablePopup` (I4).
+- **P2 — parallax profile photo.** Needs a **ScrollView scroll-offset bridge** (the C1 pattern, but
+ for `ComposeScrollNode`'s `ScrollState.Value` → drive the photo's translation at half speed).
+ Buildable; parked as a lower-value flourish.
+- **I7 (advanced) — TextField caret/selection + IME action + insert-at-cursor.** Basic input + send +
+ clear works (I5); the advanced `TextFieldValue` selection/IME coordination is facade-deep and feeds
+ I1/I3 (insert emoji at cursor). Parked with I1.
+- **I6 — voice record (mic).** Long-press + drag + animated overlay. Largest input item; later.
+
+## Out of scope (not a Comet in-app gap)
+- **"Add Widget to Home Page"** launches a **Glance home-screen App Widget** (`widget/`), an OS/launcher feature, not in-app UI. N/A for Comet UI parity.
+
+---
+
+## Suggested sequencing
+`T1 (dynamic color)` + `0 (value fixes)` → `T2 / T3 (tonal + type values)` → `C1 (scroll state)` →
+`I4 (dialog)` + `I5/I7 (textfield/send)` → `I1/I2 (selector)` → `I3 (emoji)` →
+animation → `I6 (voice)`. `C3 / D1 / P1` are quick wins anytime. The framework-capability builds are
+**T1, T2, C1, I4, I7**; most other items compose from those.
+
+---
+
+## Addendum 2026-07-01 (autonomous run)
+
+- **I7 / P4 caret+IME: DONE** (`210ede7c`) — `BasicTextField(TextFieldValue)` bound;
+ the composer tracks the caret, the emoji table inserts AT the caret (gold
+ `addText`), typing echo never resets the caret. Deferred: `reverseLayout` (C2
+ remainder), TextFieldValue composition/IME-region styling.
+- **P7 IME insets: DONE** (`7c592914`) — Android-15 edge-to-edge makes AdjustResize
+ a no-op; ComposeBackendRoot now observes the decor-level IME inset
+ (ViewCompat) + AdjustNothing and reflows to `AvailableSize`. Composer +
+ selector row sit flush above the keyboard; restore on dismiss.
+- **Record-dot pulse: DONE** (`24f6ddcf`) — Comet's own animation engine now runs on
+ the node backends (ChoreographerTicker / DisplayLinkTicker); the gold's
+ infiniteRepeatable alpha pulse is `recordDot.Animate(repeats, autoReverses)`.
+- **iOS F3 seed-scroll: DONE** (`01192c99`) — conversation opens at the newest
+ message on iOS.
+- Remaining engine-level deviations: `AnimatedContent`/`updateTransition`
+ facade bindings (nav transitions, panel crossfades), reverseLayout,
+ structural-insert re-materialization on reload, `_rowCache` LRU.
+
+
+## Known visual bug (found 2026-07-09, PRE-EXISTING — verified present at 56f5deae)
+- **Android: long message bubbles' TEXT overflows the bubble box** (right edge —
+ the FormattedText/Text ink wraps at a wider width than the Yoga box it gets,
+ clipping at the screen edge; the bubble BACKGROUND is correctly at
+ screen−16dp). iOS wraps correctly. Suspect: the Compose text node's
+ measure-vs-render width mismatch (MeasureRuns/MeasureWrapped constraint vs
+ the rendered composable's width). Repro: Jetchat conversation, John Glenn's
+ long messages. Surfaced during the Reply close-out visual inspection —
+ smokes never caught it (existence-based asserts).
diff --git a/src/Comet/docs/jetnews-parity-backlog.md b/src/Comet/docs/jetnews-parity-backlog.md
new file mode 100644
index 000000000..a970a92bb
--- /dev/null
+++ b/src/Comet/docs/jetnews-parity-backlog.md
@@ -0,0 +1,197 @@
+# JetNews parity backlog (M2)
+
+Gold standard: `~/work/compose-samples/JetNews` (Kotlin). Gold screenshots:
+`sample/Shared/JetNews/gold/` (self-captured 2026-07-09, Pixel 9 Pro API 36 —
+compact home/scrolled/drawer/interests×3/article×2; 700dp rail; 1260dp
+rail+two-pane "Select a post"). Method per `docs/sample-workflow-checklist.md`.
+Glance widget + deeplinks OUT of scope per plan.
+
+App shape: 2 top-level routes (Home, Interests) behind a **ModalNavigationDrawer**
+(`JetnewsApp.kt:62`, gesturesEnabled only when NOT expanded :74); expanded adds
+**AppNavRail** (`:77-81`) and Home becomes **list-detail** (`navigation/
+ListDetailScene.kt` — "Select a post" placeholder pane). Article opens
+full-screen compact / detail-pane expanded. All data static in-memory
+(`data/posts` fakes); post images are bundled drawables.
+
+## 1. Screens
+
+### Home (`home/HomeScreens.kt`, `home/PostCards.kt`, `home/PostCardTop.kt`)
+- App bar: drawer icon (terminal-prompt brand icon), centered lowercase
+ "jetnews" wordmark image, search icon. Compact only; expanded home shows a
+ **Search posts** OutlinedTextField at the top of the list pane instead.
+- Sections: "Top stories for you" (PostCardTop: full-width image 16:9ish,
+ headline, author, date+read-time) · simple rows (PostCardSimple: 40dp
+ thumbnail, title, author-readtime, bookmark toggle icon) · "Popular on
+ Jetnews" **horizontal card carousel** (PostCardPopular: elevated cards w/
+ image top) · history rows with "BASED ON YOUR HISTORY" overline + overflow
+ menu. Dividers between rows.
+- Interactions: row tap → article; bookmark toggle (state in viewmodel);
+ pull-to-refresh (fake refresh); snackbar on refresh error (JetnewsSnackbarHost).
+
+### Article (`post/PostScreen.kt`, `post/PostContent.kt` — THE typography lift)
+- Top bar: back arrow + "Published in: " w/ 36dp logo image.
+- Content LazyColumn (`PostContent.kt:99-110`): header image, title h4,
+ subtitle, author+avatar row, then **rich paragraphs** (`:159-197`):
+ ParagraphType Title/Subhead/Text/Header/CodeBlock/Quote/Bullet — styled via
+ `getTextAndParagraphStyle()`; **AnnotatedString runs** (bold/italic/code/link
+ markups from `model/Paragraph`); CodeBlock = surface-tinted background block;
+ Quote = styled italic; Bullet = custom ParagraphStyle w/ bullet glyph.
+ → Comet: FormattedText runs exist; needs per-paragraph style mapping,
+ code-block background, bullet rendering. Line-height/serif type (Domine
+ headers + Montserrat body — `theme/Type.kt`, verify).
+- Bottom action bar (compact): thumbs-up, bookmark, share, text-settings icons.
+- Expanded: article renders in the detail pane, top bar hidden (list-detail).
+
+### Interests (`interests/InterestsScreen.kt`)
+- Tabs: Topics / People / Publications (M3 TabRow, primary indicator).
+- Topics = sectioned list (section header + rows: 56dp image placeholder,
+ name, **SelectTopicButton** = round add/check toggle w/ border). People /
+ Publications = flat lists, same row shape.
+- Selection state per row (checked ⇒ filled check icon), persists in VM.
+
+### Drawer (`ui/AppDrawer.kt`)
+- Brand row (icon + wordmark), NavigationDrawerItems Home/Interests with
+ selected pill — same shape as Reply's modal drawer (reuse NavigationSuite
+ drawer variant OR standalone Drawer; JetNews has NO bottom bar — chrome =
+ drawer (compact) / rail (expanded), so NavigationSuite needs a
+ **drawer-primary compact variant** or this app drives Drawer directly).
+
+## 2. Adaptive (`JetnewsApp.kt:55-81`, `navigation/ListDetailScene.kt`)
+- `isExpandedScreen = widthSizeClass == Expanded` (≥840dp): rail + list-detail
+ home ("Select a post" empty pane), drawer gestures disabled.
+- Compact/medium: modal drawer, single pane. (No bottom bar anywhere.)
+- Comet: ListDetail primitive ✅ (Reply), rail ✅ (suite) — but chrome
+ composition differs from Reply (drawer-first, no bar): likely drive
+ Drawer + rail directly rather than NavigationSuite. DECIDE at build time.
+
+## 3. Capability classification (initial — verify each at build)
+| Need | Class |
+|---|---|
+| ModalNavigationDrawer + items | WORKING (Reply suite drawer / Drawer control) |
+| NavRail | WORKING (suite rail) — needs standalone-rail composition w/ labels |
+| ListDetail two-pane + placeholder pane | WORKING (Reply) + "Select a post" empty state |
+| LazyColumn lists + dividers | WORKING |
+| Horizontal card carousel (LazyRow) | **verify** — ListView is vertical; needs LazyRow node or HStack-in-Scroll |
+| Elevated Card w/ image | WORKING (Reply cards) + Image top slot |
+| TabRow (3 tabs, indicator) | **bound-unwired?** — facade TabRow? verify; Jetchat had none |
+| Rich text: styled paragraph runs | FormattedText runs (WORKING base) + paragraph-type styles |
+| CodeBlock background / Quote / Bullet | sample-level composition over FormattedText |
+| Bookmark/select toggle icon buttons | WORKING (icon + tap) |
+| Pull-to-refresh | **verify** — facade PullRefresh? else defer w/ note |
+| Snackbar | **bound-unwired?** — facade SnackbarHost? verify |
+| OutlinedTextField (expanded search) | WORKING (TextField borderless variant? verify outlined) |
+| Serif display font (Domine) + Montserrat | bundle fonts, register (Jetchat pattern) |
+| Post images (drawables) | copy to both probes (Reply avatar pattern) |
+
+## 4. Known carryovers relevant here
+- Android long-text bubble overflow (jetchat backlog) — the ARTICLE body is
+ paragraph-heavy; fix the Compose text measure/render width mismatch EARLY if
+ it reproduces on article paragraphs.
+- Registry/agent: existence-based asserts — smoke asserts should tap through
+ real visible flows (system back on Android for article close if no
+ NavigationView).
+- iOS gate: hand-composed chrome checklist (same-scale side-by-side pixel
+ measurement) applies to the drawer/rail/tabs this sample hand-composes.
+
+## 5. Next steps (in checklist order)
+1. Deep source survey → fill file:line cites + values (this doc is the skeleton).
+2. Framework gaps first, host-test-first: TabRow? LazyRow? Snackbar?
+ pull-to-refresh? (verify facade bindings before building controls).
+3. Screens: Home → Article (typography) → Interests → drawer/rail chrome.
+4. jetnews.android.sh as screens land; then iOS gate; then RESULTS row.
+
+## Status 2026-07-09 — compact-width core LANDED on Android (near-gold)
+
+Commits `bd797be8`…`31e64d6a`: data/theme/icons port, Home, Article,
+Interests, drawer-first chrome, jetnews.android.sh (16/16).
+
+Framework capabilities added en route:
+- `ListView.Horizontal` → real Compose `LazyRow` (popular carousel).
+- `Text.LineBreak(Heading|Paragraph)` → real Compose `LineBreak` presets via
+ the Text/AnnotatedText `style` slot + matching StaticLayout measurement
+ (balanced+phrase / high-quality). iOS: no SwiftUI wrap-strategy API —
+ documented greedy deviation.
+- `TabBar` → real M3 `PrimaryTabRow`+`Tab` (Interests switcher).
+- `DrawerOpened` event — gesture-open now reflects into the open signal
+ (desync previously swallowed the next programmatic close).
+- Surface-container tones derived from CorePalette.Neutral (mapper predates
+ the M3 container roles).
+
+Known deviations / follow-ups (compact):
+- Wordmark + jetnews-logo + publication-badge VECTORS are stand-ins (styled
+ Text / menu glyph / android glyph on #073042). Bundle the gold drawables
+ (tinted-Icon resId path exists — the Jetchat footer approach).
+- App bar does NOT collapse on scroll (gold: enterAlwaysScrollBehavior).
+- Drawer sheet pills are hand-composed to NavigationDrawerItem metrics
+ (Drawer takes a free-form sheet); selected highlight is Peek-baked, not
+ reactive.
+- Interests tab lists are ListViews (LazyColumn) vs gold Column+verticalScroll;
+ bullet dot is line-height-centered vs gold's baseline alignBy; history-row
+ 3-line phrase break differs on one title.
+- Article favorites/bookmark bottom-bar actions are inert (gold shows an
+ unimplemented-dialog for most; bookmark toggle should wire to favorites).
+- Scroll position resets on Article re-open (gold restores per-post state).
+
+Remaining for M2: expanded chrome (AppNavRail + list-detail + search field,
+gold medium-700dp/expanded-1260dp), per-sample app id + icons
+(`-p:CometSample=jetnews`), iOS gate (SwiftUI twins: horizontal list =
+ScrollView(.horizontal)+LazyHStack design noted; TabBar twin; article),
+RESULTS.md row, /code-review.
+
+## Status 2026-07-09 (later) — expanded chrome + standalone app + gold vectors LANDED
+
+Commits `ca09ce4b`…`bf775607`:
+- Expanded chrome (gold expanded-1260dp): root rebuilt on NavigationSuite
+ (JetNews policy: None <840dp + modal drawer w/ REAL NavigationDrawerItems;
+ REAL NavigationRail ≥840 with selected-only labels) + ListDetail at ~1/3
+ split (compact pushes article full-screen w/ system back; expanded shows
+ "Select a post" placeholder). Home list pane variant-aware via the suite's
+ new variant signal (app bar ↔ "Search posts" field). Smoke now 20/20 incl.
+ the 1260dp resize walk.
+- Framework: NavigationSuiteVariant.None + variantFor policy + variantSignal;
+ railShowsSelectedLabel (alwaysShowLabel slot exposed on the bridge);
+ ListDetail.ListFraction; suite per-flush metrics recheck (resize could
+ strand the variant); Icon.IconFillFrame (non-square vectors).
+- Standalone com.comet.sample.jetnews (-p:CometSample=jetnews, gold launcher).
+- Gold vectors landed: logo/wordmark/badge everywhere (stand-ins gone).
+- Drawer-item selected highlight now reactive (real NavigationDrawerItems).
+
+Still open for M2: app-bar collapse-on-scroll; expanded article chrome
+(floating toolbar, no top bar — currently shows compact chrome in the pane);
+rail items top-aligned vs gold centered; article bookmark action inert;
+scroll-position restore; iOS gate (TabBar twin + horizontal list +
+None-variant suite twin + article/interests screens); RESULTS.md row;
+/code-review.
+
+## /code-review (2026-07-09, 8-angle fan-out on bd797be8~1..HEAD) — outcome
+
+FIXED (commits `cd69df98`, `ee35663b`): stale-article-on-destination-change;
+FeedLists / article-shell subscription leaks; SwiftUIListDetailNode ignoring
+ListFraction (iOS rendered 50/50); hosted-twin OnOwnerViewChanged never
+dispatching (virtual on the base now); suite per-flush env-chain walk; JNI
+TextStyle/LineBreakConfig rebuilt per render/measure (cached); compact app-bar
+wordmark still the Text stand-in (replacement had silently missed); wrong
+"inert in the gold too" search comment; no-op TrimIndent; dead topInset param.
+
+REPORTED, BACKLOGGED (not fixed):
+- ~~REAL-widget promotions~~ **DONE 2026-07-10 (`4412942c`/`25700132`)**:
+ popular card → clickable M3 Card (View.AsCard()), bookmark →
+ IconToggleButton control, expanded search → OutlinedTextField
+ (TextField.Outlined().LeadingIcon()). All verified interactive on device.
+- LineBreakValues reflection hardening (R8/mangling → Release crash risk;
+ current Release publish survives). Consider deriving packed values from
+ public Strategy/Strictness/WordBreak constants.
+- MulticolorAssets hardcoded set → promote to a control-level .Multicolor()
+ property (both backends).
+- TabBar styling via ctor tokens → migrate to the env styling system.
+- iOS suite drawer only wraps the None variant (documented); DrawerOpen set
+ during Rail is swallowed/stale (also: reset the signal on variant switch).
+- Text_LineBreak has no reset-to-Default path on a reused node (hot reload).
+- Android horizontal ListView branch skips header/footer/scroll-state wiring.
+- ListFraction unvalidated (0/1/out-of-range).
+- TabBar backend hook + ContentSwitcher/NavigationSuite share the
+ subscribe-without-unsubscribe idiom on long-lived signals (framework-wide).
+- Dedup nits: Tx() ×4, ToArgbUint/C() color conversions ×3, 48dp icon-button
+ composition ×6, JetNewsRoot derived signals (ContentSwitcher bool overload).
+- Style base swap: LineBreak text uses TextStyle.Default (not LocalTextStyle)
+ as its base — benign while all slots are explicit; semantic trap noted.
diff --git a/src/Comet/docs/jetsnack-parity-backlog.md b/src/Comet/docs/jetsnack-parity-backlog.md
new file mode 100644
index 000000000..cd6231a52
--- /dev/null
+++ b/src/Comet/docs/jetsnack-parity-backlog.md
@@ -0,0 +1,159 @@
+# Jetsnack (M3) — parity backlog
+
+Gold: ~/work/compose-samples/Jetsnack (Kotlin). Golds self-captured 2026-07-10
+(app-debug on the Pixel 9 Pro AVD): sample/Shared/Jetsnack/gold/ — 8 compact
+(home / scrolled / filters sheet / detail / detail-scrolled / search / cart /
+profile) + medium-700dp + expanded-1260dp.
+
+## App shape (values-from-source pointers)
+
+- **Custom design system, NOT stock M3**: JetsnackTheme (ui/theme) with its own
+ color set (brand purple, GRADIENTS everywhere — card headers, section
+ titles use tinted text, bottom bar brand background), custom JetsnackSurface/
+ JetsnackButton/JetsnackBottomBar components (ui/components). The gold itself
+ hand-composes its chrome — so Comet hand-composing the SAME structure from
+ the same primitives IS the faithful reproduction here (unlike Reply/JetNews
+ where stock M3 widgets were the target).
+- Screens: Home (destination bar + filter chip row + snack collections as
+ horizontal card rows), Snack detail (gradient header, circular image, price,
+ Details/Ingredients, qty stepper + ADD TO CART bottom bar), Search
+ (categories/lifestyles image grid), Cart (order rows + qty steppers +
+ summary + checkout bar), Profile (work-in-progress placeholder).
+- Model: Snack.kt / SnackCollection.kt / Filter.kt / Search.kt (~575 lines
+ total — small port).
+- Images: LOCAL drawables in this gold revision (drawable-nodpi *.jpg —
+ copied to the Android probe; iOS bundle entries land with the screens).
+ (An older Jetsnack used Coil remote URLs — not this checkout.)
+
+## Capability classification (framework work expected)
+
+- **Gradient fills**: snack card headers + bottom-bar/status accents are
+ linear gradients. Comet Background() is solid-color today → needs a
+ gradient background capability (Compose Brush.linearGradient / SwiftUI
+ LinearGradient in the shim).
+- Custom bottom bar: hand-composed (gold's own idiom) — NavigationSuite not
+ required; selected item = pill with icon+label, others icon-only.
+- Qty stepper, filter chips, filter SHEET (custom dialog), search grid
+ (two-column image cards), cart swipe-to-remove (gold has it — classify).
+- Detail screen: collapsing/parallax header behavior on scroll (gold
+ ui/snackdetail — classify how deep to go).
+- Shared-element transitions (SnackSharedElementKey) — likely out of scope;
+ document as deviation.
+
+## Status
+
+- 2026-07-10: golds captured; backlog skeleton.
+- 2026-07-10 (`a3e92c3a`): model/data + custom palette ported (28 snacks,
+ collections, cart, filters as Signals; JetsnackColors roles + 8 gradient
+ stop lists); snack photos in the Android probe; both probes compile.
+ Next: gradient-fill capability (Compose Brush.linearGradient / shim
+ LinearGradient), then the Home screen (destination bar + filter row +
+ Highlight/Normal collection rows + custom bottom bar).
+
+## Status 2026-07-10 (later) — Home feed LANDED on Android
+
+Commits `8aef7b35`…`a4e62294`: BackgroundGradient capability (Compose
+Brush.horizontalGradient / shim LinearGradient; BrushBridges stale-jclass
+crash fixed — first real exercise of the vendored gradient path), Home feed
++ chrome near-gold (destination bar, chips, highlight gradient cards,
+circle rows, hand-composed bottom bar with routes).
+
+Known deviations / next:
+- Gradient parallax: the gold's offsetGradientBackground shifts the band
+ with scroll (gradientWidth 6×card); ours is a static horizontal gradient.
+- Chip border: gold uses a diagonal fade gradient border; ours solid
+ brand@40%. Filter icon metrics eyeballed.
+- Section-header arrow: gold mirrors ic_arrow_back; ours arrow_forward glyph.
+- Next increments: Snack detail (gradient header, qty stepper, ADD TO CART
+ bar), Filters sheet (FiltersOpen signal is wired, sheet unbuilt), Search,
+ Cart, iOS pass (bundle snack jpgs + verify gradients/rows), smokes,
+ standalone app id, RESULTS row, /code-review.
+
+## Status 2026-07-10 (night) — M3 COMPLETE on both platforms + reviewed
+
+Landed since the Home increment: Snack detail (gradient header, italic
+title/tagline via NEW Text italic support, SEE MORE/LESS, qty stepper, cart
+bar), Filters sheet (scrim overlay, sort/price/category — the walk exposed
+and fixed the framework-level invisible-overlay input interception), Search
+(uiFloated bar + gradient category grids), Cart (live totals, row remove),
+iOS pass (36 jpgs — BundleResource Remove+re-Include flattening trap),
+standalone app id, smokes (android 24, ios 24), RESULTS row (678ms cold
+start — best Comet number yet; jank at parity).
+
+## /code-review round 2 (4412942c~1..HEAD) — outcome
+
+FIXED (`06750c5f`/`8a8f1616` + earlier `3335eeda`): frozen bottom-bar pill
+(Peek-built, never re-styled); GradientBackground missing invalidation + the
+per-recomposition JNI brush rebuild (now cached per node); filters-overlay
+per-rebuild subscription leak; icon-toggle double materialization
+(IBackendManagesOwnContent); AsCard suppressing long-press; italic
+measure/render divergence; BrushBridges publication ordering; iOS card
+elevation lost in the AsCard promotion; _seeMore leaking across snacks; dead
+BodyVersion/DetailContent signals; sign-safe shared FormatPrice; captured
+gradient-index counter; Take2/Skip2 chip slicing.
+
+REPORTED, BACKLOGGED:
+- Stock-M3 promotions the gold demonstrably uses: DestinationBar → real
+ TopAppBar; IconButton control for the ~13 hand-rolled circular icon
+ buttons (section arrows, detail Up, cart X, filters close); filters
+ MaxCalories Slider (Comet has a real Slider node), Lifestyle chip section,
+ Reset button — currently silently omitted.
+- Alpha-0 compose-nothing guard: stacks only (list/scroll/switcher/suite
+ containers keep the input hole); tension with ComposeFabNode's
+ keep-composed contract (hidden subtrees now lose remember{} state).
+ Needs a node-level hit-test story; consider a first-class Overlay
+ primitive instead of the reactive-Opacity idiom (3rd copy).
+- Gradient payload is Color[]+horizontal only — the gold's parallax
+ offsetGradientBackground/diagonal gradients need a richer spec (define
+ the struct BEFORE more callers bake in the Color[] wire shape).
+- IconToggleButton takes bool+callback (vs Toggle's Binding) — the
+ node's Toggle_IsOn path is undriveable; bind it.
+- Outlined TextField branch drops color/fontSize/returnType; borderless
+ wins over outlined silently.
+- FontSlant.Oblique silently dropped; iOS gradient stops re-marshal per
+ rebuild; reload-storm granularity (whole sheet/cart per toggle vs the
+ gold's single-node recomposition); shim bundledImage subdir search
+ (would remove the csproj flattening incantation); QuantitySelector/CTA
+ pill/placeholder-panel dedup.
+
+## Status 2026-07-13 — David's fidelity feedback round (all 8 items fixed)
+
+Commits `760d193f` (framework) + `3fb988ed` (sample):
+1. Gradient (not solid) chip/circle borders — NEW BorderGradient capability;
+ the vendored border-brush bridge bound a nonexistent
+ `border-ziNgDLE$default` synthetic (Brush overload has no shape default →
+ no synthetic in the AAR) and crash-looped at first compose. The identical
+ "stale" screenshots during diagnosis were the RECENTS SNAPSHOT Android
+ paints while an activity crash-loops — pixel-compare across relaunches
+ before trusting a screenshot.
+2. Typography — gold Type.kt helpers in JetsnackTheme, applied everywhere.
+3. Horizontal-scroll spacing — wrapper Padding inside LazyRow items.
+4. Section arrows point LEFT (ic_arrow_back).
+5. Bottom-bar selected state — was the pre-8a8f1616 stale standalone install;
+ rebuilt both standalone apps (first launch on a loaded emulator can ANR
+ twice, then settles — CPU 0% after warm-up).
+6. Cart tagline sits above the price row.
+7. Stepper circles: 28dp gradient-border circles over uiBackground.
+8. Summary label+amount both default dark; Total label right-aligned beside
+ its titleMedium amount (gold wrapContentWidth(End) + pad end 16).
+
+Also fixed: home FilterBar chips froze on toggle (Peek-built; subscribe-once
++ ReloadData); custom-font italic never rendered on EITHER backend
+(Typeface-backed FontFamily bypasses Compose font matching; SwiftUI .italic()
+no-op on custom UIFont) — slant now baked into the resolved font on both.
+
+Verification: jetsnack smokes 24/24 android + 24/24 iOS; jetchat 13/13 +
+24/24; reply 14/14 + 22/22; jetnews iOS 22/22. KNOWN FLAKE: jetnews.android
+expanded-resize segment (2 fails: activity recreation after `wm size`
+restores Interests+open drawer instead of Home) — pre-existing, not in this
+round's paths. DIAGNOSED 2026-07-15: the resize-restore signature was the
+EMULATOR, not the app — the 3GB AVD throws system/launcher ANR dialogs under
+load that EAT smoke taps (the "home via drawer" tap never landed, so the
+resize happened on Interests with the drawer open). Two real harness fixes
+landed: curl 127.0.0.1 not localhost (a Mac-side MAUI DevFlow process squats
+the same port on IPv6 [::1] and answers instead of the adb forward), and
+agent_wait 20s -> 60s (first launch after emulator boot exceeds 20s of JIT).
+Recommend recreating the AVD with 6-8GB RAM. (A "Comet.Tests doesn't compile" scare was self-inflicted:
+clean-android.sh deletes src/Comet/bin, and the test project links Comet via
+a HintPath to the net11.0-maccatalyst DLL — rebuild that target first. Suite
+is green: 932 pass / 27 skip / 1 known scheduler failure.)
diff --git a/src/Comet/docs/reply-parity-backlog.md b/src/Comet/docs/reply-parity-backlog.md
new file mode 100644
index 000000000..9752d643c
--- /dev/null
+++ b/src/Comet/docs/reply-parity-backlog.md
@@ -0,0 +1,261 @@
+# Reply parity backlog (M1)
+
+Gold standard: `~/work/compose-samples/Reply` (Kotlin). Gold screenshots + capture
+matrix + breakpoint table: `sample/Shared/Reply/gold/` (self-captured 2026-07-05,
+Pixel 9 Pro API 36). Method per `docs/sample-workflow-checklist.md`: this document is
+the build contract — every widget/value cited `file:line` into the Kotlin source;
+capability classified **WORKING / bound-unwired / facade-missing / no-control**.
+
+App shape: 4 top-level routes (`ReplyApp.kt:128-153`) — Inbox (real), Articles /
+DirectMessages / Groups (all `EmptyComingSoon`). One activity, no per-route back
+stack beyond inbox→detail. All data static in-memory (`data/local/LocalEmailsDataProvider`);
+no network, no images beyond bundled avatar drawables.
+
+## 1. Adaptive chrome (the framework lift — see docs/adaptive-primitives-design.md)
+
+Source of truth `ReplyNavigationComponents.kt:77-107` + `ReplyApp.kt:76-88`:
+
+| Window | Nav chrome | Content |
+|---|---|---|
+| width <600dp OR height <480dp (`isCompact()` :77) or tabletop | NavigationBar (bottom) | single-pane |
+| 600–839dp | NavigationRail | single-pane (dual only in folding posture :79-83) |
+| 840–1199dp | NavigationRail | **two-pane** (`WindowWidthSizeClass.Expanded` :85) |
+| ≥840dp class AND window ≥1200dp (:98-99) | PermanentNavigationDrawer | two-pane |
+
+- Whole app wrapped in `ModalNavigationDrawer` (:122-137); gestures enabled only when
+ open or rail mode (:113-114); opened ONLY by the rail's menu item (:151-155);
+ `BackHandler` closes (:116-120). → Comet: **Drawer control exists** (ComposeDrawerNode)
+ — needs modal-sheet content variant + gesture-enable flag; verify against gold
+ `medium-700dp-02`.
+- `NavigationSuiteScaffoldLayout` (:138) is **facade-missing** — per plan, DON'T bind it;
+ hand-compose the 3 chrome variants switched on `CometWindowMetrics` (the pre-agreed
+ fallback). Height ≥480dp centers rail/drawer content, else top (:103-107,
+ `navigationMeasurePolicy` :435-471 — custom Layout; Yoga equivalent: header top,
+ content centered in remaining space, `coerceAtLeast(header)`).
+- Two-pane: accompanist `TwoPane` 50/50 split, 16dp gap (`ReplyListContent.kt:81-100`).
+ Fold `displayFeatures` OUT of scope (plan: foldable-hinge specifics excluded) →
+ plain HStack-based ListDetail primitive keyed off width class.
+- Detail pane content when nothing opened: `openedEmail ?: emails.first()` (:94).
+- Compact FAB only in BOTTOM_NAVIGATION mode (:112), aligned BottomEnd padding 16
+ (:119-121).
+
+Comet infra: `CometWindowMetrics` (WidthClass/HeightClass, Signal-reactive) landed in
+M0 (`bf879a75`) — the chrome switch programs against it. REMAINING M0 deferral now due:
+ComposeNode.AvailableSize re-plumb so per-root metrics (not the static) drive layout.
+
+## 2. Nav chrome widgets
+
+| Widget | Gold cite | Facade | Comet control | Class |
+|---|---|---|---|---|
+| NavigationBar + NavigationBarItem (icon-only, 4 items) | ReplyNavigationComponents.kt:237-252 | bound (NavigationBarItem.cs area — verify) | none | **bound-unwired** |
+| NavigationRail + NavigationRailItem (menu + FAB header, centered items) | :172-234 | bound (NavigationRailItem.cs) | none | **bound-unwired** |
+| PermanentDrawerSheet (min 200 / max 300dp :261, surfaceContainerHigh) | :255-337 | bound (ComposeBridges.cs) | none | **bound-unwired** |
+| ModalDrawerSheet + scrim | :340-433 | bound (DrawerStateHolder.cs) | Drawer (verify variant) | **bound-unwired** |
+| NavigationDrawerItem (selected pill, transparent unselected :326-328, label padding h16 :315) | :309-331 | bound | none | **bound-unwired** |
+| FloatingActionButton
+(rail: tertiaryContainer, 18dp icon, pad top8/bottom32 :197-208) | :197-208 | bound | Fab (Jetchat) | **WORKING** (variant check) |
+| ExtendedFloatingActionButton (drawers: fillMaxWidth, pad top8/bottom40 :282-300; compact list: text+icon, `expanded` collapse :113-126 ReplyListContent) | both files | bound (ExtendedFloatingActionButton.cs) | Fab is plain circle | **bound-unwired** — real ExtendedFAB control w/ `expanded` state (gold compact-05 = collapsed) |
+
+Drawer/rail headers: "REPLY" `titleMedium` primary uppercase (:278-281, :365-369);
+modal header adds `ic_menu_open` close IconButton (:370-375).
+
+## 3. Inbox list screen (`ReplyListContent.kt`)
+
+- Root Box `windowInsetsPadding(statusBars)` (:171) — **safe-area automation** is the
+ planned kill-the-manual-topInset item (no-control today; App.Build(topInset) hack).
+- `ReplyDockedSearchBar` pinned on top, h16/v16 padding (:172-180); LazyColumn under it
+ `padding(top = 80.dp)` (:182-186).
+- **DockedSearchBar + SearchBarDefaults.InputField** (`ReplyAppBars.kt:84-168`):
+ facade-bound (SearchBarInputField.cs), no Comet control → **bound-unwired**.
+ States (gold compact-04): collapsed = search icon + "Search emails" + 32dp profile
+ trailing (pad 12 :117-124); expanded = back arrow (pad start16, click collapses+clears
+ :97-107), content = "No search history" / "No item found" / results LazyColumn
+ (contentPadding 16, spacedBy 4 :131-156) of **ListItem** (headline=subject,
+ supporting=sender, leading 32dp avatar) → ListItem facade-bound, no control →
+ **bound-unwired**. Search filter: subject OR fullName startsWith ignoreCase (:65-82).
+ Item click → navigateToDetail + collapse (:149-153).
+- **ReplyEmailListItem** (`ReplyEmailListItem.kt:52-142`): Card, pad h16/v4 (:64),
+ inner Column pad 20 (:81); container = primaryContainer if selected /
+ secondaryContainer if opened / surfaceVariant (:72-76) — gold compact-01 shows the
+ opened first item darker. Comet Card exists? → verify; Jetchat used custom rows.
+ Row: 40dp circular avatar (`ReplyProfileImage.kt`: Image clip CircleShape size 40);
+ sender firstName + createdAt both `labelMedium` (:106-113); star IconButton in
+ CircleShape surfaceContainerHigh bg, outline tint (:115-126). Subject `bodyLarge`
+ pad top12/bottom8 (:129-133); body `bodyMedium` maxLines 2 ellipsis (:134-139).
+- **Interactions**: `combinedClickable` onClick=detail, onLongClick=toggleSelection
+ (:67-70) → needs long-press gesture (NOTE: comet-next HandleGesture still falls back
+ longpress→tap; the longpress CLI work lives only on devflow/longpress-gesture branch).
+ Avatar click (no ripple `indication = null` :84-87) also toggles selection;
+ `AnimatedContent` swaps avatar ↔ `SelectedProfileImage` (primary circle + 24dp check
+ :144-161) → AnimatedContent facade-bound (Transitions.cs) → **bound-unwired**;
+ fallback = plain swap, animation second pass.
+- ExtendedFAB collapse driver: `emailLazyListState.lastScrolledBackward ||
+ !canScrollBackward` (:124-125) → map to ListView scroll-bridge signals
+ (ScrollOffset/AtTop from Jetchat; may need lastScrolledBackward direction signal).
+- List end spacer: `windowInsetsBottomHeight(systemBars)` (:200-202).
+
+## 4. Email detail (`ReplyListContent.kt:207-225` + `ReplyAppBars.kt:173-228`)
+
+- LazyColumn on inverseOnSurface bg (:209-211).
+- **EmailDetailAppBar = M3 TopAppBar** (facade-bound, no Comet control →
+ **bound-unwired**): container inverseOnSurface (:176-178); title Column — subject
+ `titleMedium` onSurfaceVariant + "N Messages" `labelMedium` outline pad top4
+ (:179-196), centered when fullscreen else start-aligned (:182-183); nav icon only
+ fullscreen: **FilledIconButton** surface container, 14dp back icon, pad 8 (:199-214)
+ → facade-bound; actions: more-vert IconButton onSurfaceVariant (:216-226). No
+ scrollBehavior (that's JetNews).
+- **ReplyEmailThreadItem** (`ReplyEmailThreadItem.kt:44-136`): Card surfaceContainerHigh
+ (:47-49), pad h16/v4, inner pad 20; header row like list item but time hardcoded
+ "20 mins ago" outline (:71-75), star bg surfaceContainer (:79-82); subject
+ `bodyMedium` outline (:91-96); body `bodyLarge` onSurfaceVariant (:98-102);
+ buttons row pad top20/bottom8 spacedBy 12 (:103-133): two **Button**s weight 1f,
+ containerColor **surfaceBright**, text onSurface — plain M3 filled Button = WORKING
+ (Jetchat) but surfaceBright color token must exist in theme bridge.
+- Back: compact detail is `isDetailOnlyOpen` state w/ BackHandler (:141-147) — Comet
+ NavigationView push or state-swap; two-pane never navigates.
+
+## 5. EmptyComingSoon (`EmptyComingSoon.kt`)
+
+Column centered: title `titleLarge` primary + subtitle `bodySmall` outline, pad 8 —
+all WORKING (Text/VStack). Strings: "Screen under construction" / R.string subtitle.
+
+## 6. Theme (values-from-source — DETERMINISTIC, no dynamic color)
+
+- `ContrastAwareReplyTheme` defaults **dynamicColor = false** (`Theme.kt:294-317`);
+ default contrast → static `lightScheme`/`darkScheme` (:37-111). **Every color is a
+ literal in `ui/theme/Color.kt`** — port the light+dark tables verbatim (primary
+ `#805610`, tertiaryContainer `#D4EABB`, background/surface `#FFF8F4`, etc).
+ Contrast variants (UiModeManager) OUT of scope — note in RESULTS.
+- Typography `Type.kt`: default Roboto, explicit sizes/weights (headlineLarge
+ SemiBold 32/40 … ) — port the overridden styles; no custom fonts (unlike Jetchat).
+- Shapes: `Shapes.kt` (verify — likely default M3).
+- Jetchat theme generator NOT needed here; a static scheme ctor path suffices
+ (`JetchatTheme.cs` pattern minus the seed generation).
+
+## 7. Data layer
+
+`data/`: Email(id, sender/recipients Accounts, subject, body, attachments, mailbox,
+createdAt string, threads), MailboxType, LocalEmailsDataProvider static list, avatar
+drawables. → straight C# port under `sample/Shared/Reply/Data/`; ViewModel
+(`ReplyHomeViewModel.kt`) = replyHomeUIState { emails, openedEmail, selectedEmails,
+isDetailOnlyOpen } + navigateToDetail/toggleSelectedEmail/closeDetailScreen →
+Comet State/Signal.
+
+## 8. Capability rollup (build order)
+
+1. **Framework, host-test-first** (separate commits): ExtendedFAB control (+expanded
+ signal); NavigationBar/Rail/DrawerItem controls; modal/permanent drawer variants;
+ TopAppBar control; DockedSearchBar control; ListItem control; adaptive
+ NavSuite + ListDetail primitives on CometWindowMetrics (design note first);
+ safe-area statusBars inset automation; long-press gesture (port/redo — see branch
+ note); AnimatedContent wiring (or defer, plain swap first pass).
+2. **Sample screens**: theme port → data port → inbox list → detail → coming-soon →
+ search → adaptive chrome → selection mode.
+3. **Smoke**: `tools/smoke/reply.android.sh` as screens land (use `android_resize`
+ for the 3 chrome asserts; elements: search placeholder, first-email subject,
+ "Screen under construction", drawer items; drag for FAB collapse).
+4. iOS gate after Android fidelity (SwiftUI twins: rail→sidebar-ish hand-comp,
+ real resize observation lands here per M0 deferral).
+
+Known iOS debt Reply will hit: real resize observation (SwiftUIBackendRoot reads
+UIScreen bounds per pass only), drawer-row semantic-tap-doesn't-navigate (backlog),
+no drag injector (scroll_by fallback).
+
+## 9. Progress log (2026-07-06 — Android structural build-out COMPLETE)
+
+DONE (commits 2be19e2b..9f40b105): NavigationSuite (bar/rail/permanent-drawer on real
+M3 widgets, own-content swap) · ListDetail (two-pane ≥840 / compact push+BackHandler
+round-trip) · ContentSwitcher route host · safe-area contract (SafeAreaDp; suite insets
+content) · leaf-padding Yoga fix · full static ReplyTheme + per-screen Compose scheme ·
+ReplyData verbatim port + gold avatars · inbox/detail/coming-soon screens ·
+ExtendedFAB expand-at-top/collapse-on-scroll (ScrolledFromTop) · reply.android.sh
+14/14 + jetchat 13/13.
+
+REMAINING for M1 (updated 2026-07-09): items 1 and 2 SHIPPED (commit 4bc7557b —
+long-press end-to-end w/ agent `longpress` action + ListView.LastScrolledBackward
+on both backends; selection = check avatar + primaryContainer, gold precedence;
+FAB re-expands mid-list). M1 close-out = only the small polish items below.
+Agent gap (tracked): the `back` action = PopNavigation, which no-ops on apps
+without a NavigationView (Reply) — it should fall back to the system back
+(Android OnBackPressedDispatcher / the iOS detail's own affordance) so smokes
+drive the gold close path; reply.android.sh uses KEYCODE_BACK meanwhile.
+Registry asserts are EXISTENCE-based — elements of hidden panes still pass;
+a visibility-honest query (or per-pane pruning) would catch stale-screen runs.
+
+Small gold divergences (visual polish, non-blocking): detail status-bar
+ strip is Background, gold paints it inverseOnSurface (needs per-route strip
+ color or screen-owned top inset); Android search popup expands on focus
+ only (Expanded ↔ SearchBarState facade sync); rail container inverseOnSurface
+ (containerColor now exists control-side — pass it for the rail variant too).
+
+### Review skips (2026-07-07, tracked)
+- Leaf padding on NON-text Compose leaves (Image/Button/TextField-material) grows the
+ box without insetting content; SwiftUI engine path has no PadsOwnContent twin — align
+ both backends (generalize inset to all plain leaves) before any sample pads such leaves.
+- Suite BottomBar variant: bar offset ignores the widget's internal bottom-inset growth
+ (visible only with 3-button nav); use safeDp.Bottom in the bar offset + content math.
+- Refactor debt: shared ComposeOwnContentNode base (5 copies of lifecycle scaffolding);
+ EmitSignalProperty helper for the 7 backend partials; suite reusing bar/rail item
+ rendering + drawer sync from their standalone nodes.
+- Reply Release size snapshot ✅ (2026-07-08): single-RID (arm64) 26.1 MiB via the new
+ `-p:CometSingleRid=true` probe-csproj switch (a raw `-p:RuntimeIdentifiers` breaks the
+ netstandard2.0 source generator's copy step). Full RESULTS.md row filled: cold start
+ 3705ms vs gold 773ms (JIT + Compose init — the maturity gap to attack); inbox scroll
+ jank AT PARITY with gold (13.0% vs 9.3%, near-identical percentiles).
+
+## 10. iOS gate status (2026-07-07)
+
+DONE (01da1d53, 44ee7093): shim maxLines + scroll-top handler (xcframework rebuilt);
+SwiftUI hosted-composition twins (ContentSwitcher/ListDetail/NavigationSuite/SearchBar);
+Text_MaxLines + ScrolledFromTop on iOS nodes; agent semantic-tap BUBBLING (fixes the
+old drawer-row bug; Icons queryable by symbol); probe COMET_SCREEN=reply switch +
+avatars. VERIFIED on iPhone 16 Pro sim: inbox renders near-gold (cards/avatars/
+opened-highlight/2-line ellipsis/FAB/suite bar + pill, safe-area clean);
+jetchat.ios.sh 14/14 (no regressions); reply.ios.sh inbox asserts 4/4.
+
+✅ BLOCKER RESOLVED (26e3858a, 2026-07-08): the crash was NOT a context-graph
+cycle — the env walk was innocent (GetValue frames just topped a much deeper
+stack; the depth cap in 56f5deae was aimed at the wrong layer). The real loop,
+recovered from the managed dump (`--console-pty` stderr — CoreCLR prints the
+full managed stack on fatal overflow; DiagnosticReports .ips only shows
+interpreter frames): hosted Refresh → Materialize → SwiftUIListNode.Rebuild →
+ThreadItem's .FontSize → SetEnvironment → ReactiveEnv.SetValue →
+EnsureFlushScheduled → FlushEntry runs INLINE (main thread, _flushing already
+false during AfterFlush) → AfterFlush → ancestor twin Relayout → Layout →
+Arrange(mid-build LD node, _shown==null) → Refresh again. Fixed in
+ReactiveScheduler: FlushEntry re-entrancy guard + pass loop (see commit).
+Host repro: BackendHostedCompositionChurnTests (nests 67 before / 5 after).
+Also fixed: font-mapped Icons (Icon_Glyph path) lost their queryable symbol —
+CometDevRegistry now falls back to Icon.Symbol for element text.
+reply.ios.sh 13/13 → the iOS gate smoke is GREEN (inbox, detail round-trip via
+app-bar back, route switch); jetchat 14/14 iOS + 13/13 Android, reply.android
+14/14 — no regressions.
+✅ POST-GATE POLISH DONE (2026-07-08, commits 199d95ad..b12a7abb):
+- iOS search end-to-end (pill → expanded pane → typed query → gold-filtered
+ result renders → close restores pill) — smoke grew a search section (18/18).
+ Surfaced + fixed 4 framework gaps: HoldFlushes (atomic hosted swaps),
+ node-generation disposal + registry pruning (stale-gen ghosts), SearchBar
+ Expanded state on the CONTROL, ListView.ReloadData re-pulls items AND
+ invalidates the row-view cache (opened-highlight now moves on iOS).
+- iPad Pro 13 two-pane: rail + list + detail render side-by-side (gold
+ 840–1199 band); row tap swaps the detail in place; highlight follows.
+- Android search: SearchBar itself now accepts the agent fill (TextChanged →
+ expand + Query) since the M3 input field isn't a registered element —
+ query pipeline verified (results rebuild). FOLLOW-UP: sync
+ SearchBar.Expanded ↔ M3 SearchBarState so the popup opens/closes
+ programmatically on Android (today expansion is focus-driven only);
+ adb-IME text into the popup remains broken (upstream/IME quirk, agent
+ fill is the supported path).
+✅ iOS PARITY PASS 2 (2026-07-08, commit 77922ba0 — David flagged the FAB and
+missing icons): ExtendedFAB now CONTRACTS on iOS (the node re-frames to a
+Height-dp square with the right edge pinned — the shim only hides the label,
+the frame was staying extended-wide) and uses the real M3 CornerLarge 16dp
+shape (was a capsule). SearchBar.containerColor + NavigationSuite
+containerColor/indicatorColor let the hand-composed iOS chrome wear the gold
+M3 tokens (surfaceContainerHigh pill, surfaceContainer bar, secondaryContainer
+indicator — Android's real widgets take these from the scheme); nav item
+icons tinted onSurfaceVariant. Real app icons on BOTH platforms: per-variant
+asset catalogs (iOS property — icon-set NAME, so one Info.plist
+serves all variants) / @mipmap + per-variant app_name strings (Android manifest
+label was hardcoded); gold launcher art for reply/jetchat, the Comet mark
+(art/comet_icon.svg) for the probe.
diff --git a/src/Comet/docs/research/upstream-issue-fastdev-typemap-staleness.md b/src/Comet/docs/research/upstream-issue-fastdev-typemap-staleness.md
new file mode 100644
index 000000000..ebb3ebf31
--- /dev/null
+++ b/src/Comet/docs/research/upstream-issue-fastdev-typemap-staleness.md
@@ -0,0 +1,103 @@
+# DRAFT upstream issue for dotnet/android — fast-dev APK skips libxamarin-app.so changes → "no Java peer type found"
+
+Status: draft, not yet filed. Verified against Microsoft.Android.Sdk.Darwin
+36.99.0-preview.5.308 (.NET 11.0.100-preview.5). Repo workaround lives in
+`src/Comet/Directory.Build.targets` (`_CometFixFastDevApkInputs`) — remove it when
+the SDK fix ships.
+
+## Title
+
+Incremental Debug build: `_BuildApkFastDevStaticInputs` omits `@(_ApplicationSharedLibrary)`, deploying stale MVID-keyed typemaps → `NotSupportedException: no Java peer type found`
+
+## Description
+
+**Repro.** App project → library project containing `internal` `Java.Lang.Object`
+subclasses (e.g. a Compose binding facade full of internal bridge lambdas).
+Debug build with fast deployment (`EmbedAssembliesIntoApk=false`,
+`AndroidPackageFormat=apk`):
+
+1. Clean build, `-t:Run` — app runs.
+2. Make an **implementation-only** edit in the library (add a private field to an
+ internal JLO subclass; anything that does not change the ref assembly).
+3. Incremental `dotnet build -t:Run`.
+4. App crashes at startup:
+
+```
+W monodroid-assembly: typemap: unable to look up assembly name for type 'AndroidX.Compose.ComposableLambda2', trying without it.
+E AndroidRuntime: FATAL EXCEPTION: main
+E AndroidRuntime: android.runtime.JavaProxyThrowable: [System.NotSupportedException]: Cannot create instance of type 'AndroidX.Compose.ComposableLambda2': no Java peer type found.
+```
+
+**Cause (from the binlog).** On step 3 the build does the right work almost
+everywhere:
+
+- `_GenerateJavaStubs` re-runs (JCWs fine),
+- `_CompileNativeAssemblySources` re-runs and `_CreateApplicationSharedLibraries`
+ relinks `app_shared_libraries//libxamarin-app.so` with the new MVID-keyed
+ debug typemaps,
+
+but `_BuildApkFastDev` is **skipped** ("all output files are up-to-date"), because
+`_BuildApkFastDevStaticInputs` does not list the application shared library:
+
+```xml
+<_BuildApkFastDevStaticInputs>
+ @(_AndroidMSBuildAllProjects)
+ ;@(_ShrunkFrameworkAssemblies)
+ ;@(_AndroidNativeLibraryForFastDev)
+ ;@(_DexFileForFastDevInput)
+ ;$(_AndroidBuildPropertiesCache)
+ ;$(_AdbPropertiesCache)
+ ;$(_PackagedResources)
+
+```
+
+The embed-path property right above it **does** include it:
+
+```xml
+<_BuildApkEmbedInputs>
+ ...
+ ;@(_ApplicationSharedLibrary)
+
+```
+
+`_Upload` then fast-deploys only assemblies (`_FastDevFiles` contains .dlls only),
+so the device ends up with the **new-MVID** library assembly plus the **old-MVID**
+typemaps inside the installed APK's `libxamarin-app.so`. The managed→Java typemap
+lookup is MVID-keyed in Debug, so constructing any JLO subclass from that assembly
+throws.
+
+Why it bites hardest with internal types: any library rebuild changes the MVID, but
+teams using ref assemblies (`ProduceReferenceAssembly`, SDK default) typically only
+see downstream rebuild activity on impl-only edits — everything looks incremental
+and healthy, and the failure appears only at runtime. Users learn to "always clean
+rebuild after editing that library" (which is what we did for months).
+
+**Suggested fix.** Add `;@(_ApplicationSharedLibrary)` to
+`_BuildApkFastDevStaticInputs` (populated via `_PrepareApplicationSharedLibraryItems`,
+as the target already does for the task call at `ApplicationSharedLibraries=`).
+
+**Workaround** (project/repo level):
+
+```xml
+
+
+ <_BuildApkFastDevStaticInputs>$(_BuildApkFastDevStaticInputs);@(_ApplicationSharedLibrary)
+
+
+```
+
+Two traps hit while landing the workaround (both cost a re-verification):
+
+1. `_PrepareApplicationSharedLibraryItems` expands `%(_BuildTargetAbis.Identity)`,
+ populated by `_DefineBuildTargetAbis`. Without that dependency the item — and
+ thus the appended input — is silently EMPTY. Our copy warns when it detects this.
+2. MSBuild stops at the NEAREST `Directory.Build.targets`; a sample-level file
+ shadows the repo-level one, so the fix must live in a file imported by every
+ Android app project (`src/Comet/eng/FastDevApkFix.targets`, imported from both).
+
+Verified end-to-end: impl-only library edit → incremental `-t:Run` → `.so` relinks
+→ APK rebuilds + reinstalls → app survives (three consecutive edit cycles, both a
+facade edit and Comet.dll edits). Without the workaround the same edit crashes as
+above.
diff --git a/src/Comet/docs/sample-workflow-checklist.md b/src/Comet/docs/sample-workflow-checklist.md
new file mode 100644
index 000000000..3abe55466
--- /dev/null
+++ b/src/Comet/docs/sample-workflow-checklist.md
@@ -0,0 +1,95 @@
+# Per-sample workflow checklist (compose-samples build-out)
+
+The repeatable delivery process for reproducing an android/compose-samples app on
+Comet's node backends (Compose/Android + SwiftUI/iOS) at Jetchat-grade fidelity.
+Derived from the Jetchat effort's retro; the named verification techniques at the
+bottom are the ones that actually worked there. Gold-standard Kotlin source is
+local at `~/work/compose-samples/` — read it directly.
+
+Order of samples (reuse-ordered): **Reply → JetNews → Jetsnack → Jetcaster → JetLagged.**
+
+## The checklist
+
+1. **Gold capture.** Enumerate the sample's screens (including compact/medium/expanded
+ adaptive states and interaction states — open drawers, sheets, filters) and request
+ user-captured gold screenshots for each. These are the fidelity contract; store them
+ with the sample.
+
+2. **Source survey → parity backlog.** Write `docs/-parity-backlog.md` in the
+ `jetchat-parity-backlog.md` format: every widget, color, dp value, and type style
+ cited `file:line` from the Kotlin source. Classify each needed capability:
+ **WORKING / bound-unwired / facade-missing / no-control.** This document is the
+ sample's living plan; mark items with commit hashes as they land.
+
+3. **Framework work, host-test-first.** For each new node/control:
+ FakeBackendNode contract tests (`tests/Comet.Tests/Backend/`) → Compose node in
+ `src/Comet/Platform/Compose/` → device verify → SwiftUI shim kind **designed now**,
+ implemented by the iOS gate. Framework node + its host tests = one commit,
+ separate from sample commits. Keep the host-test baseline green at every commit.
+
+4. **Screen build.** `sample/Shared//` mirroring the Jetchat layout
+ (Theme / Icons / Screens files). Theme = the `JetchatTheme.cs` M3 generator seeded
+ with this sample's color/type tokens from source (swap font families per sample).
+
+5. **Fidelity passes.** Values-from-source sweep, then pixel-scan vs gold
+ (`tools/snapshot_compare.py`), forcing end-state signals for render-only states.
+ Loop until the backlog's fidelity items are closed.
+
+6. **DevFlow smoke script — written AS YOU GO, not after.**
+ `tools/smoke/..sh`: launch → walk every screen → exercise each
+ interaction → assert via tree/element queries → screenshot each screen.
+ Missing DevFlow verb? 30-minute timebox to add it upstream (agent or src/DevFlow);
+ otherwise adb fallback + a backlog entry.
+
+7. **iOS gate.** All screens on CometSwiftUIProbe; same smoke script
+ (`.ios.sh`); same values-from-source bar. Parity bar is **structure,
+ values, and interaction** — not cross-OS pixel identity. Budget for known iOS debt
+ the sample exercises (SelectorPanel, FormattedText runs, hot-reload own-content).
+ **Hand-composed chrome checklist** (anything an iOS twin builds from Comet
+ primitives where Android uses a real M3 widget): capture SAME-SCALE
+ side-by-side screenshots and pixel-measure each chrome piece — Yoga's
+ cross-axis default is flex-start while M3 widgets CENTER their slots, so
+ every slot needs explicit centering, every strip an explicit background,
+ every shape the real M3 token (16dp CornerLarge, 28dp capsule…). The Reply
+ gate shipped four such misses (search-pill slots, bar/rail icons, safe-area
+ strip, FAB shape) that only a measured side-by-side caught.
+ The sample is not done — and the next does not start — until this gate passes.
+
+8. **Perf/size snapshot (emulator).** `tools/bench/size.sh` trimmed Release APK,
+ `tools/bench/startup.sh` cold start, gfxinfo first-pass on the heaviest scroll
+ screen → the sample's RESULTS.md section, alongside the gold Kotlin app's own
+ Release APK size. (Pixel 5 numbers are batched: B1 after JetNews, B2 after
+ Jetcaster, B3 after JetLagged + Jetchat re-run.)
+
+9. **/code-review** on the full sample diff before calling it done; fix findings.
+
+10. **Docs/memory.** Backlog marked with commit hashes; new platform gotchas →
+ AGENTS.md; DevFlow gaps → upstream backlog; newly discovered framework debt →
+ the infra ledger in the plan.
+
+11. **Retro (15 min).** What was mis-estimated, which lazy-infra call was wrong,
+ what should change for the next sample.
+
+## Named verification techniques (use these; they worked)
+
+- **values-from-source** — never eyeball a dimension/color; cite the Kotlin
+ `file:line` and use its exact value.
+- **pixel-scan** — locate/verify small UI elements by scanning gold screenshot
+ pixels (`tools/snapshot_compare.py`), not by judgment.
+- **gfxinfo first-pass** — `adb shell dumpsys gfxinfo ` after ONE scroll pass,
+ reset stats first; later passes hide first-composition jank.
+- **forced end-state signals** — for states only reachable through timing
+ (animations, refreshes), drive the terminal signal directly to verify the render.
+- **device-tap method** — Comet OnTap needs `input swipe X Y X Y 120`, NOT
+ `input tap`; compute coordinates at full resolution (1080-wide), not screenshot scale.
+- **black screencap ≠ render bug** — check `mScreenState`/keyguard first.
+- **host-tests-first** — patch-stream/signal/reactive behavior proven in
+ FakeBackendNode tests before any device build.
+
+## Timeboxes
+
+- Hard problem with a pre-written fallback: use the timebox from the plan
+ (shared-element 3d, Canvas 4d, scrollBehavior 1d), then take the fallback and
+ document the decision in the sample's backlog doc.
+- Platform rabbit hole (build system, emulator, tooling): **90 minutes**, then write
+ the AGENTS.md gotcha and route around it.
diff --git a/src/Comet/eng/FastDevApkFix.targets b/src/Comet/eng/FastDevApkFix.targets
new file mode 100644
index 000000000..180a8ad18
--- /dev/null
+++ b/src/Comet/eng/FastDevApkFix.targets
@@ -0,0 +1,32 @@
+
+
+
+
+ <_BuildApkFastDevStaticInputs>$(_BuildApkFastDevStaticInputs);@(_ApplicationSharedLibrary)
+
+
+
+
diff --git a/src/Comet/global.json b/src/Comet/global.json
index f34a41ab2..4bdd8162c 100644
--- a/src/Comet/global.json
+++ b/src/Comet/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "11.0.100-preview.3.26207.106",
+ "version": "11.0.100-preview.7.26381.103",
"rollForward": "latestPatch"
}
}
diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_bold.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_bold.ttf
new file mode 100644
index 000000000..052231c16
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_bold.ttf differ
diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_regular.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_regular.ttf
new file mode 100644
index 000000000..4269aa069
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/karla_regular.ttf differ
diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/material_icons.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/material_icons.ttf
new file mode 100644
index 000000000..9d09b0feb
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/material_icons.ttf differ
diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_medium.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_medium.ttf
new file mode 100755
index 000000000..6e079f698
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_medium.ttf differ
diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_regular.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_regular.ttf
new file mode 100755
index 000000000..8d443d5d5
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_regular.ttf differ
diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_semibold.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_semibold.ttf
new file mode 100755
index 000000000..f8a43f2b2
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/montserrat_semibold.ttf differ
diff --git a/src/Comet/sample/CometComposeProbe/Assets/fonts/roboto_flex.ttf b/src/Comet/sample/CometComposeProbe/Assets/fonts/roboto_flex.ttf
new file mode 100644
index 000000000..2e5c2a26a
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Assets/fonts/roboto_flex.ttf differ
diff --git a/src/Comet/sample/CometComposeProbe/CometComposeProbe.csproj b/src/Comet/sample/CometComposeProbe/CometComposeProbe.csproj
new file mode 100644
index 000000000..0a9331456
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/CometComposeProbe.csproj
@@ -0,0 +1,75 @@
+
+
+
+
+ net11.0-android
+ 24
+ Exe
+ enable
+ disable
+ CometComposeProbe
+ com.comet.composeprobe
+ 1
+ 1.0
+
+ android-arm64
+
+
+
+
+ com.comet.sample.$(CometSample)
+ Comet $(CometSample.Substring(0,1).ToUpperInvariant())$(CometSample.Substring(1))
+ obj/sample-$(CometSample)/
+ bin/sample-$(CometSample)/
+
+
+
+
+ probe
+ $(CometSample)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/DevFlowHelper.cs b/src/Comet/sample/CometComposeProbe/DevFlowHelper.cs
new file mode 100644
index 000000000..338a33d2d
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/DevFlowHelper.cs
@@ -0,0 +1,187 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Android.App;
+using Android.Graphics;
+using Android.OS;
+using Android.Views;
+#if DEBUG
+using Microsoft.Maui.DevFlow.Agent.Core;
+#endif
+#pragma warning disable CA1416
+
+namespace CometComposeProbe
+{
+ // PixelCopyListener lives outside #if DEBUG so Xamarin.Android generates its JNI typemap
+ // entry unconditionally. The build tool only registers Java.Lang.Object subclasses it sees
+ // in every build; a type hidden behind #if DEBUG is skipped in the Release codegen pass.
+ sealed class PixelCopyListener : Java.Lang.Object, PixelCopy.IOnPixelCopyFinishedListener
+ {
+ readonly Action _callback;
+ public PixelCopyListener(Action callback) => _callback = callback;
+ public void OnPixelCopyFinished(int copyResult) => _callback(copyResult);
+ }
+
+#if DEBUG
+ ///
+ /// Bootstraps the DevFlow in-app agent for CometComposeProbe without requiring UseMaui.
+ /// Uses DevFlowAgentService.StartServerOnly (designed for Comet apps where
+ /// Application.Current is unavailable) with an Android-native PixelCopy screenshot.
+ ///
+ static class DevFlowHelper
+ {
+ static ComposeProbeAgentService? _agent;
+
+ public static void Start(Activity activity, View rootView)
+ {
+ var dispatcher = new ActivityDispatcher();
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var broker = new BrokerRegistration(
+ project: "CometComposeProbe",
+ tfm: "net11.0-android",
+ platform: "Android",
+ appName: "CometComposeProbe");
+
+ var assignedPort = await broker.TryRegisterAsync(TimeSpan.FromSeconds(5));
+ var options = new AgentOptions { Port = assignedPort ?? AgentOptions.DefaultPort };
+
+ var agent = new ComposeProbeAgentService(activity, options);
+ agent.SetBrokerRegistration(broker);
+ broker.CurrentPort = agent.Port;
+ _agent = agent;
+
+ dispatcher.Dispatch(() => agent.StartServerOnly(dispatcher));
+ Console.WriteLine($"[CometComposeProbe] DevFlow agent started on port {agent.Port}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[CometComposeProbe] DevFlow startup failed: {ex.Message}");
+ }
+ });
+ }
+ }
+
+ ///
+ /// DevFlowAgentService subclass for CometComposeProbe.
+ /// Provides Android PixelCopy screenshot (captures GPU-rendered Compose content).
+ /// Visual tree walking returns empty results (no MAUI app) — screenshot and
+ /// coordinate-based tap are fully functional.
+ ///
+ sealed class ComposeProbeAgentService : DevFlowAgentService
+ {
+ readonly Activity _activity;
+
+ public ComposeProbeAgentService(Activity activity, AgentOptions? options = null)
+ : base(options)
+ {
+ _activity = activity;
+ }
+
+ protected override async Task HandleScreenshot(HttpRequest request)
+ {
+ try
+ {
+ var pngData = await CaptureFullScreenAsync();
+ if (pngData != null)
+ return HttpResponse.Png(pngData);
+ return HttpResponse.Error("PixelCopy returned null");
+ }
+ catch (Exception ex)
+ {
+ return HttpResponse.Error($"Screenshot failed: {ex.Message}");
+ }
+ }
+
+ Task CaptureFullScreenAsync()
+ {
+ var tcs = new TaskCompletionSource();
+
+ // RunOnUiThread: PixelCopyListener (Java.Lang.Object) must be instantiated on a
+ // JNI-attached thread. The HTTP handler thread pool is not registered with the JVM.
+ _activity.RunOnUiThread(() =>
+ {
+ try
+ {
+ var win = _activity.Window;
+ var decorView = win?.DecorView;
+ if (win == null || decorView == null || decorView.Width <= 0 || decorView.Height <= 0)
+ {
+ tcs.SetResult(null);
+ return;
+ }
+
+ var bmp = Bitmap.CreateBitmap(decorView.Width, decorView.Height, Bitmap.Config.Argb8888!)!;
+
+ if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
+ {
+ PixelCopy.Request(
+ win,
+ bmp,
+ new PixelCopyListener(result =>
+ {
+ try
+ {
+ if (result == 0)
+ {
+ using var ms = new MemoryStream();
+ bmp.Compress(Bitmap.CompressFormat.Png!, 90, ms);
+ tcs.SetResult(ms.ToArray());
+ }
+ else
+ {
+ Android.Util.Log.Warn("CometProbe", $"PixelCopy failed: {result}");
+ tcs.SetResult(null);
+ }
+ }
+ finally { bmp.Recycle(); }
+ }),
+ new Handler(Looper.MainLooper!));
+ }
+ else
+ {
+ try
+ {
+ decorView.Draw(new Canvas(bmp));
+ using var ms = new MemoryStream();
+ bmp.Compress(Bitmap.CompressFormat.Png!, 90, ms);
+ tcs.SetResult(ms.ToArray());
+ }
+ catch (Exception ex)
+ {
+ Android.Util.Log.Warn("CometProbe", $"Canvas fallback failed: {ex.Message}");
+ tcs.SetResult(null);
+ }
+ finally { bmp.Recycle(); }
+ }
+ }
+ catch (Exception ex)
+ {
+ tcs.TrySetResult(null);
+ Android.Util.Log.Warn("CometProbe", $"CaptureFullScreen failed: {ex.Message}");
+ }
+ });
+
+ return tcs.Task;
+ }
+ }
+
+ sealed class ActivityDispatcher : IAgentDispatcher
+ {
+ readonly Handler _handler = new(Looper.MainLooper!);
+
+ public bool IsDispatchRequired => Looper.MyLooper() != Looper.MainLooper;
+ public bool Dispatch(Action action) { _handler.Post(action); return true; }
+ public bool DispatchDelayed(TimeSpan delay, Action action)
+ {
+ _handler.PostDelayed(action, (long)delay.TotalMilliseconds);
+ return true;
+ }
+ }
+#endif
+}
+
+#pragma warning restore CA1416
diff --git a/src/Comet/sample/CometComposeProbe/HotReloadDemo.cs b/src/Comet/sample/CometComposeProbe/HotReloadDemo.cs
new file mode 100644
index 000000000..523b8a447
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/HotReloadDemo.cs
@@ -0,0 +1,56 @@
+#if DEBUG
+using Android.Content;
+using Comet;
+
+namespace CometComposeProbe
+{
+ ///
+ /// The app root as a [Body] view — the shape a real Comet app has, and what
+ /// MauiHotReloadHelper.TriggerReload targets (it reloads root views, so a
+ /// bare static-built tree would never re-render on hot reload).
+ ///
+ public class JetchatRoot : View
+ {
+ [Body]
+ View body() => CometSamples.Jetchat.JetchatConversation.Build(topInset: 24);
+ }
+
+ ///
+ /// Stand-in for the EnC-updated JetchatRoot: property-level deltas (inset + root
+ /// background) make a successful reload unmistakable on screen while every static
+ /// signal (composer text, drawer state, selector) carries over untouched.
+ ///
+ public class JetchatRootV2 : View
+ {
+ [Body]
+ View body() => CometSamples.Jetchat.JetchatConversation.Build(topInset: 140)
+ .Background(Microsoft.Maui.Graphics.Color.FromArgb("#B3261E"));
+ }
+
+ ///
+ /// Demo trigger standing in for the .NET MetadataUpdateHandler: registers the
+ /// replacement type and triggers the reload — exactly the two calls
+ /// CometMetadataUpdateHandler.UpdateType/UpdateApplication make when the
+ /// runtime applies an EnC delta. Fire with:
+ /// adb shell am broadcast -n com.comet.composeprobe/.HotReloadDemoReceiver
+ ///
+ [BroadcastReceiver(Enabled = true, Exported = true, Name = "com.comet.composeprobe.HotReloadDemoReceiver")]
+ public class HotReloadDemoReceiver : BroadcastReceiver
+ {
+ public override void OnReceive(Context? context, Intent? intent)
+ {
+ Android.Util.Log.Info("CometProbe", "HotReload demo: registering JetchatRootV2 + TriggerReload");
+ Comet.HotReload.CometHotReloadHelper.RegisterReplacedView(
+ typeof(JetchatRoot).FullName!, typeof(JetchatRootV2));
+ ThreadHelper.RunOnMainThread(() =>
+ {
+ var before = MainActivity.RootView?.BuiltView;
+ Microsoft.Maui.HotReload.MauiHotReloadHelper.TriggerReload();
+ var after = MainActivity.RootView?.BuiltView;
+ Android.Util.Log.Info("CometProbe",
+ $"HotReload demo: built {before?.GetType().Name}#{before?.GetHashCode()} -> {after?.GetType().Name}#{after?.GetHashCode()} (changed={!ReferenceEquals(before, after)})");
+ });
+ }
+ }
+}
+#endif
diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetchat/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/jetchat/appicon.png
new file mode 100644
index 000000000..61f78a9e1
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/jetchat/appicon.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetchat/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/jetchat/strings.xml
new file mode 100644
index 000000000..217221956
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Icons/jetchat/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Comet Jetchat
+
diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetnews/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/jetnews/appicon.png
new file mode 100644
index 000000000..645a6e37b
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/jetnews/appicon.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetnews/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/jetnews/strings.xml
new file mode 100644
index 000000000..f57968f5d
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Icons/jetnews/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Comet Jetnews
+
diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetsnack/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/jetsnack/appicon.png
new file mode 100644
index 000000000..68ba15329
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/jetsnack/appicon.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Icons/jetsnack/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/jetsnack/strings.xml
new file mode 100644
index 000000000..d9c959842
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Icons/jetsnack/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Comet Jetsnack
+
diff --git a/src/Comet/sample/CometComposeProbe/Icons/probe/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/probe/appicon.png
new file mode 100644
index 000000000..be26d381f
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/probe/appicon.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Icons/probe/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/probe/strings.xml
new file mode 100644
index 000000000..dfd86163e
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Icons/probe/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Comet+Compose
+
diff --git a/src/Comet/sample/CometComposeProbe/Icons/reply/appicon.png b/src/Comet/sample/CometComposeProbe/Icons/reply/appicon.png
new file mode 100644
index 000000000..93c31df60
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Icons/reply/appicon.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Icons/reply/strings.xml b/src/Comet/sample/CometComposeProbe/Icons/reply/strings.xml
new file mode 100644
index 000000000..86b7bbf75
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Icons/reply/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Comet Reply
+
diff --git a/src/Comet/sample/CometComposeProbe/MainActivity.cs b/src/Comet/sample/CometComposeProbe/MainActivity.cs
new file mode 100644
index 000000000..c98a960be
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/MainActivity.cs
@@ -0,0 +1,279 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Android.App;
+using Android.OS;
+using Comet;
+using Comet.Platform.Compose;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+
+namespace CometComposeProbe
+{
+ ///
+ /// Reproduces a Jetchat-style conversation screen (a C# port of Google's Compose
+ /// "Jetchat" sample) on the Comet node backend: a fixed top bar over a virtualized,
+ /// Yoga-laid-out message list. Every row is laid out by the shared C# Yoga engine
+ /// (avatar + author + wrapping body), so it renders identically to the iOS/SwiftUI
+ /// backend — no MAUI handlers in the path.
+ ///
+ // No Label on the attribute: the launcher shows the application label, which comes from
+ // — so `-p:CometSample=…` names each standalone sample app. The icon
+ // resource is likewise per-variant (Icons//appicon.png linked into mipmap).
+ [Activity(MainLauncher = true, Icon = "@mipmap/appicon")]
+ public class MainActivity : AndroidX.Activity.ComponentActivity
+ {
+ /// Which sample to show: the `--es screen` intent extra (the smoke scripts /
+ /// dev loop), else derived from the package id (`com.comet.sample.<name>` — the
+ /// standalone per-sample installs), else Jetchat.
+ string Screen =>
+ Intent?.GetStringExtra("screen")
+ ?? (PackageName is { } pkg && pkg.StartsWith("com.comet.sample.")
+ ? pkg.Substring("com.comet.sample.".Length)
+ : "jetchat");
+#if DEBUG
+ /// The logical root, exposed so the hot-reload demo receiver can log reload state.
+ internal static View? RootView;
+#endif
+
+ protected override void OnCreate(Bundle? savedInstanceState)
+ {
+ base.OnCreate(savedInstanceState);
+ ActionBar?.Hide();
+
+ // P7 IME handling lives in ComposeBackendRoot (decor-level insets listener):
+ // no SoftInput mode needed — Android 15's forced edge-to-edge makes
+ // AdjustResize a no-op anyway, and the insets path works on every version.
+
+ ThreadHelper.SetFireOnMainThread(a => RunOnUiThread(a));
+
+#if DEBUG
+ // Must be on BEFORE any View is constructed: view registration and the
+ // active-view list (what TriggerReload targets) are gated on IsEnabled.
+ Microsoft.Maui.HotReload.MauiHotReloadHelper.IsEnabled = true;
+
+ // The Comet-tree dev agent (DevFlow wire-compatible tree/elements/tap/fill +
+ // real MotionEvent drags) must also start BEFORE the UI is built — it enables
+ // CometDevRegistry tracking, and views materialized earlier never enter the
+ // tree. Fixed port 9223: reach it with `adb forward tcp:9223 tcp:9223`.
+ Comet.Platform.Compose.ComposeDevAgentHost.Start(this);
+#endif
+
+ // Load Jetchat's real fonts (Montserrat titles/labels, Karla body) from bundled assets
+ // and register them per weight so the Compose backend renders the true outlines. Jetchat
+ // ships distinct weight files; synthesizing weights from a single base TTF makes the
+ // intermediate weights (e.g. Montserrat Medium) render thin/pale, so register each.
+ try
+ {
+ Android.Graphics.Typeface Asset(string name) =>
+ Android.Graphics.Typeface.CreateFromAsset(Assets, "fonts/" + name);
+
+ ComposeFontRegistry.Register("Montserrat", 400, Asset("montserrat_regular.ttf"));
+ ComposeFontRegistry.Register("Montserrat", 500, Asset("montserrat_medium.ttf"));
+ ComposeFontRegistry.Register("Montserrat", 600, Asset("montserrat_semibold.ttf"));
+ ComposeFontRegistry.Register("Karla", 400, Asset("karla_regular.ttf"));
+ ComposeFontRegistry.Register("Karla", 700, Asset("karla_bold.ttf"));
+
+ // Google's Material Icons font as the cross-platform icon set (same glyphs as iOS).
+ ComposeFontRegistry.Register(CometSamples.Jetchat.JetchatIcons.Font, 400, Asset("material_icons.ttf"));
+ CometSamples.Jetchat.JetchatIcons.Register();
+ }
+ catch (Exception ex)
+ {
+ Android.Util.Log.Warn("CometProbe", "Font load failed: " + ex);
+ }
+
+ // Material 3 color. By DEFAULT we seed the scheme from the Comet brand color via the
+ // shared material-color-utilities path (JetchatTheme.ApplyDynamicScheme) — the SAME algorithm
+ // and SAME seed as iOS — so the app is theme-driven and looks identical cross-platform.
+ // Flip UseWallpaperMaterialYou to instead adopt the device's Material You (gold-faithful to
+ // Themes.kt's isDynamicColor=true, but diverges from iOS, which can't read the wallpaper).
+ // Follow the system light/dark setting (the gold's isSystemInDarkTheme) so the generated
+ // scheme is the light OR dark M3 mapping. Read at startup (a live toggle would re-create
+ // the activity, which rebuilds anyway).
+ bool dark = (Resources!.Configuration!.UiMode & Android.Content.Res.UiMode.NightMask)
+ == Android.Content.Res.UiMode.NightYes;
+
+ const bool UseWallpaperMaterialYou = false;
+ AndroidX.Compose.Material3.ColorScheme scheme;
+ if (UseWallpaperMaterialYou && Build.VERSION.SdkInt >= BuildVersionCodes.S)
+ {
+ scheme = dark ? AndroidX.Compose.MaterialTheme.DynamicDarkColorScheme(this)
+ : AndroidX.Compose.MaterialTheme.DynamicLightColorScheme(this);
+ try { ApplySchemeToTheme(scheme); }
+ catch (Exception ex) { Android.Util.Log.Warn("CometProbe", "scheme read-back failed, using static theme: " + ex); }
+ }
+ else
+ {
+ // Generate the M3 scheme in shared C# (applies it to the C# theme tokens the Comet views
+ // read), then build the matching Compose ColorScheme so the real Material controls
+ // (Button, ripples) are seeded identically. Brand seed by default; flip SeedFromContent to
+ // derive the scheme from the profile photo (content-based Material You — same generator,
+ // image seed — identical to iOS's SeedFromContent path).
+ const bool SeedFromContent = false;
+ MaterialColorUtilities.Schemes.Scheme s =
+ SeedFromContent && PixelsFromDrawable(Resource.Drawable.ali) is { } px
+ ? CometSamples.Jetchat.JetchatTheme.ApplyDynamicSchemeFromPixels(px, dark)
+ : CometSamples.Jetchat.JetchatTheme.ApplyDynamicScheme(CometSamples.Jetchat.JetchatTheme.SeedColor, dark);
+ scheme = ComposeSchemeFromSeed(s, dark);
+ }
+
+ // Per-sample theming: the Reply screen uses Reply's OWN static scheme
+ // (gold: ContrastAwareReplyTheme, dynamicColor=false) — the real M3 widgets
+ // (NavigationBar/Rail, drawer sheets) derive their container colors from it.
+ bool replyScreen = Screen == "reply";
+ if (replyScreen)
+ scheme = CometSamples.Reply.ReplyTheme.ComposeScheme();
+ else if (Screen == "jetnews")
+ scheme = CometSamples.JetNews.JetNewsTheme.ComposeScheme();
+ else if (Screen == "jetcaster")
+ scheme = CometSamples.Jetcaster.JetcasterTheme.ComposeScheme();
+
+ // Status bar: Surface (matches the header bar).
+ // Nav bar: SurfaceTinted (matches the footer/UserInput bar so the background is seamless).
+ var surf = replyScreen ? CometSamples.Reply.ReplyTheme.Background : CometSamples.Jetchat.JetchatTheme.Surface;
+ var statusTint = Android.Graphics.Color.Argb(255, (int)(surf.Red * 255), (int)(surf.Green * 255), (int)(surf.Blue * 255));
+ var footerSurf = replyScreen ? CometSamples.Reply.ReplyTheme.SurfaceContainer : CometSamples.Jetchat.JetchatTheme.SurfaceTinted;
+ var navTint = Android.Graphics.Color.Argb(255, (int)(footerSurf.Red * 255), (int)(footerSurf.Green * 255), (int)(footerSurf.Blue * 255));
+ Window!.SetStatusBarColor(statusTint);
+ Window.SetNavigationBarColor(navTint);
+ // Light bars (dark icons) on a light surface; in dark mode clear the flags for light icons.
+ const int lightBars = (int)Android.Views.WindowInsetsControllerAppearance.LightStatusBars
+ | (int)Android.Views.WindowInsetsControllerAppearance.LightNavigationBars;
+ Window.InsetsController?.SetSystemBarsAppearance(dark ? 0 : lightBars, lightBars);
+
+ var root = BuildUi();
+#if DEBUG
+ RootView = root;
+#endif
+
+ // Link taps in chat bubbles open the system browser (the gold's uriHandler.openUri).
+ CometSamples.Jetchat.JetchatConversation.OpenUrl = url =>
+ {
+ try { StartActivity(new Android.Content.Intent(Android.Content.Intent.ActionView, Android.Net.Uri.Parse(url))); }
+ catch (Exception ex) { Android.Util.Log.Warn("CometProbe", "open url failed: " + ex.Message); }
+ };
+
+ var backend = new ComposeBackendRoot(new EmptyServiceProvider())
+ {
+ UseYogaLayout = true,
+ // Same scheme drives the real Material controls (Button, ripples) so they match.
+ WrapContent = content =>
+ {
+ var theme = new AndroidX.Compose.MaterialTheme { ColorScheme = scheme };
+ theme.Add(content);
+ return theme;
+ },
+ };
+ var composeView = backend.CreateView(this, root);
+ SetContentView(composeView);
+#if DEBUG
+ DevFlowHelper.Start(this, composeView);
+#endif
+ }
+
+ // Decode a drawable to a small AARRGGBB pixel buffer for content-based theming (the
+ // material-color-utilities Quantize+Score wants raw pixels). Downscaled for a fast seed extract.
+ uint[]? PixelsFromDrawable(int resId, int max = 64)
+ {
+ using var bmp = Android.Graphics.BitmapFactory.DecodeResource(Resources, resId);
+ if (bmp is null) return null;
+ int w = System.Math.Min(max, bmp.Width), h = System.Math.Min(max, bmp.Height);
+ using var scaled = Android.Graphics.Bitmap.CreateScaledBitmap(bmp, w, h, true);
+ var ints = new int[w * h];
+ scaled!.GetPixels(ints, 0, w, 0, 0, w, h); // ARGB ints
+ var px = new uint[ints.Length];
+ for (int i = 0; i < ints.Length; i++) px[i] = (uint)ints[i];
+ return px;
+ }
+
+ // Build the Compose MaterialTheme ColorScheme from the seed-generated M3 roles so the real
+ // Material controls (Button, ripples) match the Comet views (which read the same roles from the
+ // C# theme tokens). Mirrors the role subset the static scheme used.
+ static AndroidX.Compose.Material3.ColorScheme ComposeSchemeFromSeed(MaterialColorUtilities.Schemes.Scheme s, bool dark)
+ {
+ static AndroidX.Compose.Color C(uint argb) =>
+ AndroidX.Compose.Color.FromArgb(0xFF, (byte)((argb >> 16) & 0xFF), (byte)((argb >> 8) & 0xFF), (byte)(argb & 0xFF));
+ return dark
+ ? AndroidX.Compose.MaterialTheme.DarkColorScheme(
+ primary: C(s.Primary), onPrimary: C(s.OnPrimary),
+ surface: C(s.Surface), onSurface: C(s.OnSurface),
+ surfaceVariant: C(s.SurfaceVariant), onSurfaceVariant: C(s.OnSurfaceVariant),
+ background: C(s.Background), onBackground: C(s.OnBackground),
+ tertiary: C(s.Tertiary))
+ : AndroidX.Compose.MaterialTheme.LightColorScheme(
+ primary: C(s.Primary), onPrimary: C(s.OnPrimary),
+ surface: C(s.Surface), onSurface: C(s.OnSurface),
+ surfaceVariant: C(s.SurfaceVariant), onSurfaceVariant: C(s.OnSurfaceVariant),
+ background: C(s.Background), onBackground: C(s.OnBackground),
+ tertiary: C(s.Tertiary));
+ }
+
+ // Read the scheme's role colors back into the C# theme tokens (facade Color exposes A/R/G/B).
+ static void ApplySchemeToTheme(AndroidX.Compose.Material3.ColorScheme s)
+ {
+ static Microsoft.Maui.Graphics.Color M(AndroidX.Compose.Color c) =>
+ new Microsoft.Maui.Graphics.Color(c.R / 255f, c.G / 255f, c.B / 255f, c.A / 255f);
+
+ CometSamples.Jetchat.JetchatTheme.ApplyScheme(
+ primary: M(s.Primary), onPrimary: M(s.OnPrimary), primaryContainer: M(s.PrimaryContainer),
+ secondary: M(s.Secondary), surface: M(s.Surface), onSurface: M(s.OnSurface),
+ surfaceVariant: M(s.SurfaceVariant), onSurfaceVariant: M(s.OnSurfaceVariant),
+ background: M(s.Background), tertiary: M(s.Tertiary), tertiaryContainer: M(s.TertiaryContainer),
+ onTertiaryContainer: M(s.OnTertiaryContainer), outline: M(s.Outline));
+ }
+
+ // The faithful Jetchat conversation screen (shared tree, identical on iOS). A ~24dp top
+ // inset clears the status bar. The screen comes from the `--es screen` extra (smokes)
+ // or the standalone per-sample package id — see .
+#if DEBUG
+ // A [Body] root is what hot reload targets (see HotReloadDemo.cs).
+ View BuildUi() => Screen switch
+ {
+ "reply" => new CometSamples.Reply.ReplyProbeRoot(),
+ "jetnews" => new CometSamples.JetNews.JetNewsRoot(),
+ "jetsnack" => new CometSamples.Jetsnack.JetsnackRoot(topInset: 52),
+ "jetcaster" => BuildJetcaster(),
+ _ => new JetchatRoot(),
+ };
+#else
+ View BuildUi() => Screen switch
+ {
+ "reply" => new CometSamples.Reply.ReplyProbeRoot(),
+ "jetnews" => new CometSamples.JetNews.JetNewsRoot(),
+ "jetsnack" => new CometSamples.Jetsnack.JetsnackRoot(topInset: 52),
+ "jetcaster" => BuildJetcaster(),
+ _ => CometSamples.Jetchat.JetchatConversation.Build(topInset: 24),
+ };
+#endif
+
+ static bool _robotoFlexRegistered;
+
+ // Fixture mode: parse the six bundled feed snapshots — and load Jetcaster's
+ // 1.6MB RobotoFlex variable font — once, on first entry, so the other samples'
+ // cold-start numbers stay untouched.
+ View BuildJetcaster()
+ {
+ if (!_robotoFlexRegistered)
+ {
+ _robotoFlexRegistered = true;
+ // Instantiate the gold's display weights from the single variable file
+ // (Typeface.Create(tf, w, italic) resolves weight axes on API 28+).
+ var robotoFlex = Android.Graphics.Typeface.CreateFromAsset(Assets, "fonts/roboto_flex.ttf");
+ ComposeFontRegistry.Register("RobotoFlex", 400, robotoFlex);
+ if (OperatingSystem.IsAndroidVersionAtLeast(28))
+ ComposeFontRegistry.Register("RobotoFlex", 738,
+ Android.Graphics.Typeface.Create(robotoFlex, 738, false));
+ }
+ CometSamples.Jetcaster.JetcasterFixtures.Load(
+ name => Assets!.Open("jetcaster/" + name));
+ return new CometSamples.Jetcaster.JetcasterRoot(topInset: 52);
+ }
+
+ sealed class EmptyServiceProvider : IServiceProvider
+ {
+ public object? GetService(Type serviceType) => null;
+ }
+ }
+}
diff --git a/src/Comet/sample/CometComposeProbe/Properties/AndroidManifest.xml b/src/Comet/sample/CometComposeProbe/Properties/AndroidManifest.xml
new file mode 100644
index 000000000..22884cbd5
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Properties/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ali.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/ali.png
new file mode 100644
index 000000000..eab3c658a
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/ali.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/almonds.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/almonds.jpg
new file mode 100644
index 000000000..0f1eef57e
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/almonds.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_chips.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_chips.jpg
new file mode 100644
index 000000000..37c805d16
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_chips.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_juice.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_juice.jpg
new file mode 100644
index 000000000..d519dbcd4
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_juice.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_pie.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_pie.jpg
new file mode 100644
index 000000000..41096b221
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_pie.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_sauce.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_sauce.jpg
new file mode 100644
index 000000000..7f5331ee2
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apple_sauce.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/apples.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/apples.jpg
new file mode 100644
index 000000000..cc729f164
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/apples.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_0.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_0.jpg
new file mode 100644
index 000000000..dcf2608a8
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_0.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_1.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_1.jpg
new file mode 100644
index 000000000..23f171d48
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_1.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_10.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_10.jpg
new file mode 100644
index 000000000..27b8dc615
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_10.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_2.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_2.jpg
new file mode 100644
index 000000000..54c74a888
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_2.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_3.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_3.jpg
new file mode 100644
index 000000000..a63f8ce57
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_3.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_4.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_4.jpg
new file mode 100644
index 000000000..279b70def
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_4.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_5.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_5.jpg
new file mode 100644
index 000000000..e4266c738
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_5.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_6.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_6.jpg
new file mode 100644
index 000000000..0b3226775
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_6.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_7.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_7.jpg
new file mode 100644
index 000000000..01e9a775b
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_7.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_8.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_8.jpg
new file mode 100644
index 000000000..5b387afa2
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_8.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_9.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_9.jpg
new file mode 100644
index 000000000..087bf93af
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_9.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_express.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_express.png
new file mode 100644
index 000000000..f05790fb9
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/avatar_express.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/cheese.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/cheese.jpg
new file mode 100644
index 000000000..c5c6dce61
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/cheese.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/chips.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/chips.jpg
new file mode 100644
index 000000000..04d5fe2cd
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/chips.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/cupcake.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/cupcake.jpg
new file mode 100644
index 000000000..42e766d84
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/cupcake.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/desserts.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/desserts.jpg
new file mode 100644
index 000000000..6d4499076
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/desserts.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/donut.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/donut.jpg
new file mode 100644
index 000000000..076896a81
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/donut.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/eclair.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/eclair.jpg
new file mode 100644
index 000000000..560178034
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/eclair.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/froyo.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/froyo.jpg
new file mode 100644
index 000000000..e1bb068cc
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/froyo.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/fruit.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/fruit.jpg
new file mode 100644
index 000000000..412247318
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/fruit.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/gingerbread.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/gingerbread.jpg
new file mode 100644
index 000000000..ac069de10
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/gingerbread.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/gluten_free.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/gluten_free.jpg
new file mode 100644
index 000000000..0745457a3
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/gluten_free.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/grapes.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/grapes.jpg
new file mode 100644
index 000000000..3b573787c
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/grapes.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/honeycomb.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/honeycomb.jpg
new file mode 100644
index 000000000..ea632bd25
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/honeycomb.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_at.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_at.xml
new file mode 100644
index 000000000..37d1268e9
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_at.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_chat.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_chat.xml
new file mode 100644
index 000000000..e53bb3379
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_chat.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_create.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_create.xml
new file mode 100644
index 000000000..db9b31f52
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_create.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat.xml
new file mode 100644
index 000000000..e87573ebb
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_back.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_back.xml
new file mode 100644
index 000000000..2332fa8a1
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_back.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_front.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_front.xml
new file mode 100644
index 000000000..b2200fa4f
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetchat_front.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_badge.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_badge.xml
new file mode 100644
index 000000000..5f54f0f04
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_badge.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_logo.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_logo.xml
new file mode 100644
index 000000000..16a95957a
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_logo.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_wordmark.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_wordmark.xml
new file mode 100644
index 000000000..932f18a3a
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_jetnews_wordmark.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_mood.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_mood.xml
new file mode 100644
index 000000000..b5f400251
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_mood.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_photo.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_photo.xml
new file mode 100644
index 000000000..083151a12
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_photo.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_place.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_place.xml
new file mode 100644
index 000000000..ddb5a94e2
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_place.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_video.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_video.xml
new file mode 100644
index 000000000..7e0cdcdcd
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/ic_video.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/ice_cream_sandwich.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/ice_cream_sandwich.jpg
new file mode 100644
index 000000000..fd77631e9
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/ice_cream_sandwich.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/jelly_bean.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/jelly_bean.jpg
new file mode 100644
index 000000000..84a10208c
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/jelly_bean.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/jetchat_logo.xml b/src/Comet/sample/CometComposeProbe/Resources/drawable/jetchat_logo.xml
new file mode 100644
index 000000000..142212298
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/drawable/jetchat_logo.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/kitkat.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/kitkat.jpg
new file mode 100644
index 000000000..75b2e44ab
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/kitkat.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/kiwi.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/kiwi.jpg
new file mode 100644
index 000000000..2197fbd48
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/kiwi.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/lollipop.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/lollipop.jpg
new file mode 100644
index 000000000..98d1db7d2
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/lollipop.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/mango.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/mango.jpg
new file mode 100644
index 000000000..717773d7b
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/mango.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/marshmallow.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/marshmallow.jpg
new file mode 100644
index 000000000..cdc115922
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/marshmallow.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/nougat.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/nougat.jpg
new file mode 100644
index 000000000..1a844d9b9
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/nougat.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/nuts.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/nuts.jpg
new file mode 100644
index 000000000..03556767e
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/nuts.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/oreo.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/oreo.jpg
new file mode 100644
index 000000000..cf2c3e534
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/oreo.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/organic.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/organic.jpg
new file mode 100644
index 000000000..2847abf4f
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/organic.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/paleo.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/paleo.jpg
new file mode 100644
index 000000000..750fcd58e
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/paleo.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/pie.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/pie.jpg
new file mode 100644
index 000000000..439c18cfb
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/pie.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder.jpg
new file mode 100644
index 000000000..31e05faac
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder_1_1.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder_1_1.png
new file mode 100644
index 000000000..55fb3c2ac
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/placeholder_1_1.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/popcorn.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/popcorn.jpg
new file mode 100644
index 000000000..02713ffdb
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/popcorn.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1.png
new file mode 100644
index 000000000..f2efcdc27
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1_thumb.png
new file mode 100644
index 000000000..6af4fcc71
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_1_thumb.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2.png
new file mode 100644
index 000000000..acc31574c
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2_thumb.png
new file mode 100644
index 000000000..e5c598ec4
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_2_thumb.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3.png
new file mode 100644
index 000000000..f8922fbe9
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3_thumb.png
new file mode 100644
index 000000000..6e6a82d8b
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_3_thumb.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4.png
new file mode 100644
index 000000000..14b14d977
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4_thumb.png
new file mode 100644
index 000000000..d772e0019
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_4_thumb.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5.png
new file mode 100644
index 000000000..31eb8183b
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5_thumb.png
new file mode 100644
index 000000000..5c42ce2a8
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_5_thumb.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6.png
new file mode 100644
index 000000000..3ad0c2952
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6_thumb.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6_thumb.png
new file mode 100644
index 000000000..092cc3f13
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/post_6_thumb.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/pretzels.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/pretzels.jpg
new file mode 100644
index 000000000..d31d4aa4e
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/pretzels.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/smoothies.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/smoothies.jpg
new file mode 100644
index 000000000..f2eaa31bf
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/smoothies.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/someone_else.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/someone_else.jpg
new file mode 100644
index 000000000..95bc4a8bb
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/someone_else.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/sticker.png b/src/Comet/sample/CometComposeProbe/Resources/drawable/sticker.png
new file mode 100644
index 000000000..54bbcfb96
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/sticker.png differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/drawable/vegan.jpg b/src/Comet/sample/CometComposeProbe/Resources/drawable/vegan.jpg
new file mode 100644
index 000000000..29276a68b
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/Resources/drawable/vegan.jpg differ
diff --git a/src/Comet/sample/CometComposeProbe/Resources/values/colors.xml b/src/Comet/sample/CometComposeProbe/Resources/values/colors.xml
new file mode 100644
index 000000000..5c0c1ac20
--- /dev/null
+++ b/src/Comet/sample/CometComposeProbe/Resources/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+
+ #F3B711
+ #0540F2
+
diff --git a/src/Comet/sample/CometComposeProbe/phase1-gate-pixel5.png b/src/Comet/sample/CometComposeProbe/phase1-gate-pixel5.png
new file mode 100644
index 000000000..c285114be
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase1-gate-pixel5.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase1-interactive-count3.png b/src/Comet/sample/CometComposeProbe/phase1-interactive-count3.png
new file mode 100644
index 000000000..1702dab24
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase1-interactive-count3.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-app-detail.png b/src/Comet/sample/CometComposeProbe/phase2-app-detail.png
new file mode 100644
index 000000000..05ed19121
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-app-detail.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-app-list.png b/src/Comet/sample/CometComposeProbe/phase2-app-list.png
new file mode 100644
index 000000000..71723ae1c
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-app-list.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-form.png b/src/Comet/sample/CometComposeProbe/phase2-form.png
new file mode 100644
index 000000000..5e2d35350
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-form.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-input-toggle-on.png b/src/Comet/sample/CometComposeProbe/phase2-input-toggle-on.png
new file mode 100644
index 000000000..6bb71c7e6
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-input-toggle-on.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-lazycolumn.png b/src/Comet/sample/CometComposeProbe/phase2-lazycolumn.png
new file mode 100644
index 000000000..c2a7aad35
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-lazycolumn.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-navigation-detail.png b/src/Comet/sample/CometComposeProbe/phase2-navigation-detail.png
new file mode 100644
index 000000000..6cc840335
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-navigation-detail.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-navigation-home.png b/src/Comet/sample/CometComposeProbe/phase2-navigation-home.png
new file mode 100644
index 000000000..31e159142
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-navigation-home.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-nested-derived.png b/src/Comet/sample/CometComposeProbe/phase2-nested-derived.png
new file mode 100644
index 000000000..961e245b4
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-nested-derived.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-slider.png b/src/Comet/sample/CometComposeProbe/phase2-slider.png
new file mode 100644
index 000000000..b03198aea
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-slider.png differ
diff --git a/src/Comet/sample/CometComposeProbe/phase2-styling.png b/src/Comet/sample/CometComposeProbe/phase2-styling.png
new file mode 100644
index 000000000..edbd9ae6e
Binary files /dev/null and b/src/Comet/sample/CometComposeProbe/phase2-styling.png differ
diff --git a/src/Comet/sample/CometControlsGallery/App.cs b/src/Comet/sample/CometControlsGallery/App.cs
index b75cf4b1e..3bc221532 100644
--- a/src/Comet/sample/CometControlsGallery/App.cs
+++ b/src/Comet/sample/CometControlsGallery/App.cs
@@ -16,7 +16,7 @@
using Microsoft.Maui.LifecycleEvents;
using static Comet.CometControls;
#if DEBUG
-using MauiDevFlow.Agent;
+using Microsoft.Maui.DevFlow.Agent;
#endif
#if __MACOS__
using Microsoft.Maui.Platform.MacOS.Hosting;
diff --git a/src/Comet/sample/CometControlsGallery/CometControlsGallery.csproj b/src/Comet/sample/CometControlsGallery/CometControlsGallery.csproj
index c981e1f23..61da299c7 100644
--- a/src/Comet/sample/CometControlsGallery/CometControlsGallery.csproj
+++ b/src/Comet/sample/CometControlsGallery/CometControlsGallery.csproj
@@ -7,7 +7,6 @@
ExeCometControlsGallerytrue
- 10.0.31truedisableenable
@@ -37,18 +36,16 @@
-
-
-
-
-
-
+
diff --git a/src/Comet/sample/CometMacApp/CometMacApp.csproj b/src/Comet/sample/CometMacApp/CometMacApp.csproj
index 6d237c9fe..671b9c892 100644
--- a/src/Comet/sample/CometMacApp/CometMacApp.csproj
+++ b/src/Comet/sample/CometMacApp/CometMacApp.csproj
@@ -14,7 +14,6 @@
1.0114.0
- 10.0.31
diff --git a/src/Comet/sample/CometStressTest/CometStressTest.csproj b/src/Comet/sample/CometStressTest/CometStressTest.csproj
index 7620a85ec..d2ea128de 100644
--- a/src/Comet/sample/CometStressTest/CometStressTest.csproj
+++ b/src/Comet/sample/CometStressTest/CometStressTest.csproj
@@ -13,7 +13,7 @@
1XC010315.0
- 21.0
+ 24.016.0
diff --git a/src/Comet/sample/CometSwiftUIProbe/AppDelegate.cs b/src/Comet/sample/CometSwiftUIProbe/AppDelegate.cs
new file mode 100644
index 000000000..18f502085
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/AppDelegate.cs
@@ -0,0 +1,146 @@
+using Comet;
+using Comet.DevTools;
+using Comet.Platform.SwiftUI;
+using Comet.Reactive;
+using CoreFoundation;
+using Foundation;
+using Microsoft.Maui.Graphics;
+using UIKit;
+
+namespace CometSwiftUIProbe
+{
+ [Register("AppDelegate")]
+ public class AppDelegate : UIApplicationDelegate
+ {
+ public override UIWindow? Window { get; set; }
+
+ readonly Signal _count = new(0);
+ readonly Signal _name = new(string.Empty);
+ readonly Signal _fancy = new(false);
+ readonly Signal _taps = new(0);
+ readonly Signal _long = new(false);
+ readonly Signal _len = new(0);
+
+ static readonly string[] Lengths =
+ {
+ "one line",
+ "a slightly longer line that may wrap once on a phone width here",
+ "three lines worth of text here that should wrap onto roughly three lines on a typical phone width so we can watch the stack below it move",
+ "a much longer paragraph designed to wrap onto five or six lines so that the rows beneath it are pushed substantially further down the screen, proving the whole vertical stack expands and contracts as the text length changes rather than the text just growing inside a fixed slot",
+ };
+
+ NavigationView? _nav;
+ CometDevAgent? _agent;
+
+ public override bool FinishedLaunching(UIApplication application, NSDictionary launchOptions)
+ {
+ // Comet's fluent env writes post through ThreadHelper; we're on the main thread.
+ ThreadHelper.SetFireOnMainThread(a => a?.Invoke());
+
+ Window = new UIWindow(UIScreen.MainScreen.Bounds);
+
+ // Dev agent on the DevFlow CLI's default port: `maui devflow ui tree/tap` connects
+ // straight to localhost:9223 on the iOS sim, so the stock CLI drives this Comet app.
+ // Start BEFORE materializing so the registry tracks every node as the tree is built.
+ _agent = new CometDevAgent(CometDevAgent.DevFlowPort, a => DispatchQueue.MainQueue.DispatchAsync(a));
+ _agent.Start();
+
+ // Use Google's Material Icons font (bundled, registered via Info.plist UIAppFonts) as the
+ // cross-platform icon set, so Icon("mic") etc. draw the SAME Material glyph as Android.
+ CometSamples.Jetchat.JetchatIcons.Register();
+
+ // Generate the Material 3 scheme from the Comet theme using Google's material-color-utilities
+ // (the SAME algorithm as Android's Material You) so iOS renders a real tonal scheme — identical
+ // to Android when both seed from the same input. Must run BEFORE BuildUi() (the tree reads the
+ // theme tokens at build time). Brand seed by default; flip SeedFromContent to derive the scheme
+ // from the user's profile photo (content-based Material You — same generator, image seed).
+ // Follow the system light/dark setting (the gold's isSystemInDarkTheme) so the generated
+ // scheme is the light OR dark M3 mapping. Read at startup; a live toggle would need the
+ // structural re-render (re-apply the scheme + rebuild the tree).
+ bool dark = UIScreen.MainScreen.TraitCollection.UserInterfaceStyle == UIUserInterfaceStyle.Dark;
+ const bool SeedFromContent = false;
+ if (SeedFromContent && PixelsFromBundle("ali") is { } pixels)
+ CometSamples.Jetchat.JetchatTheme.ApplyDynamicSchemeFromPixels(pixels, dark);
+ else
+ CometSamples.Jetchat.JetchatTheme.ApplyDynamicScheme(CometSamples.Jetchat.JetchatTheme.SeedColor, dark);
+
+ // A real Comet view tree, rendered as SwiftUI through the node protocol —
+ // no MAUI handlers in the render path.
+ var backend = new SwiftUIBackendRoot(new EmptyServiceProvider()) { UseYogaLayout = true };
+ Window.RootViewController = backend.CreateController(BuildUi());
+
+ Window.MakeKeyAndVisible();
+ return true;
+ }
+
+ // The faithful Jetchat conversation screen (shared tree, identical on Android). iPhone
+ // safe-area insets: status bar / Dynamic Island ~50dp top, home indicator ~28dp bottom.
+ // The screen comes from SIMCTL_CHILD_COMET_SCREEN (the smoke scripts — the iOS twin
+ // of the Android probe's intent extra) or, for the standalone per-sample installs
+ // (-p:CometSample=…), from the bundle id (com.comet.sample.).
+ static string Screen =>
+ System.Environment.GetEnvironmentVariable("COMET_SCREEN")
+ ?? (NSBundle.MainBundle.BundleIdentifier is { } bid && bid.StartsWith("com.comet.sample.")
+ ? bid.Substring("com.comet.sample.".Length)
+ : "jetchat");
+
+ View BuildUi() => Screen switch
+ {
+ "reply" => new CometSamples.Reply.ReplyProbeRoot(),
+ "jetnews" => new CometSamples.JetNews.JetNewsRoot(),
+ "jetsnack" => new CometSamples.Jetsnack.JetsnackRoot(topInset: 59),
+ _ => CometSamples.Jetchat.JetchatConversation.Build(topInset: 50, bottomInset: 28),
+ };
+
+ // Decode a bundled image to a small AARRGGBB pixel buffer for content-based theming (the
+ // material-color-utilities Quantize+Score wants raw pixels). Downscaled for a fast seed extract.
+ static uint[]? PixelsFromBundle(string name, int max = 64)
+ {
+ using var img = UIImage.FromBundle(name);
+ if (img?.CGImage is not CoreGraphics.CGImage cg) return null;
+ int w = System.Math.Min(max, (int)cg.Width), h = System.Math.Min(max, (int)cg.Height);
+ if (w <= 0 || h <= 0) return null;
+ var bytes = new byte[w * h * 4];
+ using (var cs = CoreGraphics.CGColorSpace.CreateDeviceRGB())
+ using (var ctx = new CoreGraphics.CGBitmapContext(bytes, w, h, 8, w * 4, cs, CoreGraphics.CGImageAlphaInfo.PremultipliedLast))
+ ctx.DrawImage(new CoreGraphics.CGRect(0, 0, w, h), cg);
+ var px = new uint[w * h];
+ for (int i = 0; i < px.Length; i++)
+ px[i] = ((uint)bytes[i * 4 + 3] << 24) | ((uint)bytes[i * 4] << 16) | ((uint)bytes[i * 4 + 1] << 8) | bytes[i * 4 + 2];
+ return px;
+ }
+
+ // Interactive controls with AutomationIds (clean `--automationId` selector targets) plus
+ // a Navigate button, so the dev tree pruning across push/pop is observable.
+ View HomeScreen() => new VStack
+ {
+ new Text("Comet → SwiftUI").Color(Colors.White),
+
+ new Text(() => $"Count: {_count.Value}").Color(Colors.White).AutomationId("countLabel"),
+ new Button("Increment", () => _count.Value++).AutomationId("incrementButton"),
+
+ new TextField(_name, "Type a name").AutomationId("nameField"),
+ new Text(() => $"Hello, {(_name.Value.Length == 0 ? "stranger" : _name.Value)}").Color(Colors.White),
+
+ new Toggle(_fancy).AutomationId("fancyToggle"),
+ new Text(() => _fancy.Value ? "Fancy: ON" : "Fancy: off").Color(Colors.White),
+
+ new Text(() => $"Tapped: {_taps.Value}× (tap me)").Color(Colors.White)
+ .OnTap(_ => _taps.Value++).AutomationId("tapTarget"),
+
+ new Button("Go to Page 2 →", () => _nav!.Navigate(SecondScreen())).AutomationId("gotoPage2"),
+ }.Background(Color.FromArgb("#6750A4")).Padding(24);
+
+ View SecondScreen() => new VStack
+ {
+ new Text("📄 Page 2").Color(Colors.White),
+ new Text("Pushed via Navigate()").Color(Colors.White),
+ new Button("← Back", () => _nav!.Pop()).AutomationId("backButton"),
+ }.Background(Color.FromArgb("#7D5260")).Padding(28);
+
+ sealed class EmptyServiceProvider : System.IServiceProvider
+ {
+ public object? GetService(System.Type serviceType) => null;
+ }
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/CometSwiftUIProbe.csproj b/src/Comet/sample/CometSwiftUIProbe/CometSwiftUIProbe.csproj
new file mode 100644
index 000000000..4caa13e87
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/CometSwiftUIProbe.csproj
@@ -0,0 +1,122 @@
+
+
+
+
+ net11.0-ios
+ Exe
+ enable
+ disable
+ 17.0
+ CometSwiftUIProbe
+ com.comet.swiftuiprobe
+ CometSwiftUI
+ 1
+ 1.0
+
+ true
+
+
+
+
+ com.comet.sample.$(CometSample)
+ Comet $(CometSample.Substring(0,1).ToUpperInvariant())$(CometSample.Substring(1))
+ obj/sample-$(CometSample)/
+ bin/sample-$(CometSample)/
+
+
+
+
+ probe
+ $(CometSample)
+ AppIcon
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ali.png
+ jetnews_logo.png
+ jetnews_wordmark.png
+ jetnews_badge.png
+
+
+
+ someone_else.jpg
+ sticker.png
+ jetchat.png
+ avatar_0.jpg
+ avatar_1.jpg
+ avatar_10.jpg
+ avatar_2.jpg
+ avatar_3.jpg
+ avatar_4.jpg
+ avatar_5.jpg
+ avatar_6.jpg
+ avatar_7.jpg
+ avatar_8.jpg
+ avatar_9.jpg
+ avatar_express.png
+
+ post_1.png
+ post_1_thumb.png
+ post_2.png
+ post_2_thumb.png
+ post_3.png
+ post_3_thumb.png
+ post_4.png
+ post_4_thumb.png
+ post_5.png
+ post_5_thumb.png
+ post_6.png
+ post_6_thumb.png
+
+
+
+
+
+ Framework
+ False
+
+
+
+
diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Bold.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Bold.ttf
new file mode 100644
index 000000000..052231c16
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Bold.ttf differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Regular.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Regular.ttf
new file mode 100644
index 000000000..4269aa069
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla-Regular.ttf differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla.ttf
new file mode 100644
index 000000000..939d29ef1
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Karla.ttf differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/MaterialIcons-Regular.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/MaterialIcons-Regular.ttf
new file mode 100644
index 000000000..9d09b0feb
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/MaterialIcons-Regular.ttf differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Medium.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Medium.ttf
new file mode 100755
index 000000000..6e079f698
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Medium.ttf differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Regular.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Regular.ttf
new file mode 100755
index 000000000..8d443d5d5
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-Regular.ttf differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-SemiBold.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-SemiBold.ttf
new file mode 100755
index 000000000..f8a43f2b2
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat-SemiBold.ttf differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat.ttf
new file mode 100644
index 000000000..c97aca185
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/Montserrat.ttf differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Fonts/RobotoFlex-Regular.ttf b/src/Comet/sample/CometSwiftUIProbe/Fonts/RobotoFlex-Regular.ttf
new file mode 100644
index 000000000..2e5c2a26a
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Fonts/RobotoFlex-Regular.ttf differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..3193f63d6
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "appicon.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/appicon.png b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/appicon.png
new file mode 100644
index 000000000..fc5af0e8f
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/AppIcon.appiconset/appicon.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Icons/jetchat.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..3193f63d6
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "appicon.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/appicon.png b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/appicon.png
new file mode 100644
index 000000000..b98372683
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/AppIcon.appiconset/appicon.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Icons/jetsnack.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..3193f63d6
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "appicon.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/appicon.png b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/appicon.png
new file mode 100644
index 000000000..97f2f9ba2
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/AppIcon.appiconset/appicon.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Icons/probe.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000..3193f63d6
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "appicon.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/appicon.png b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/appicon.png
new file mode 100644
index 000000000..1afc3b250
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/AppIcon.appiconset/appicon.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/Contents.json b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Icons/reply.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/Info.plist b/src/Comet/sample/CometSwiftUIProbe/Info.plist
new file mode 100644
index 000000000..b41db398c
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Info.plist
@@ -0,0 +1,48 @@
+
+
+
+
+
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+
+ LSRequiresIPhoneOS
+
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+
+ UILaunchStoryboardName
+ LaunchScreen
+ MinimumOSVersion
+ 17.0
+ UIAppFonts
+
+ Fonts/Montserrat-Regular.ttf
+ Fonts/Montserrat-Medium.ttf
+ Fonts/Montserrat-SemiBold.ttf
+ Fonts/Karla-Regular.ttf
+ Fonts/Karla-Bold.ttf
+ Fonts/MaterialIcons-Regular.ttf
+ Fonts/RobotoFlex-Regular.ttf
+
+
+
diff --git a/src/Comet/sample/CometSwiftUIProbe/LaunchScreen.storyboard b/src/Comet/sample/CometSwiftUIProbe/LaunchScreen.storyboard
new file mode 100644
index 000000000..e27e4e8e8
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/LaunchScreen.storyboard
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/CometSwiftUIProbe/Main.cs b/src/Comet/sample/CometSwiftUIProbe/Main.cs
new file mode 100644
index 000000000..6f829f621
--- /dev/null
+++ b/src/Comet/sample/CometSwiftUIProbe/Main.cs
@@ -0,0 +1,9 @@
+using UIKit;
+
+namespace CometSwiftUIProbe
+{
+ public class Application
+ {
+ static void Main(string[] args) => UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/ali.png b/src/Comet/sample/CometSwiftUIProbe/Resources/ali.png
new file mode 100644
index 000000000..eab3c658a
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/ali.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_0.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_0.jpg
new file mode 100644
index 000000000..dcf2608a8
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_0.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_1.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_1.jpg
new file mode 100644
index 000000000..23f171d48
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_1.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_10.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_10.jpg
new file mode 100644
index 000000000..27b8dc615
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_10.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_2.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_2.jpg
new file mode 100644
index 000000000..54c74a888
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_2.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_3.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_3.jpg
new file mode 100644
index 000000000..a63f8ce57
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_3.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_4.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_4.jpg
new file mode 100644
index 000000000..279b70def
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_4.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_5.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_5.jpg
new file mode 100644
index 000000000..e4266c738
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_5.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_6.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_6.jpg
new file mode 100644
index 000000000..0b3226775
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_6.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_7.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_7.jpg
new file mode 100644
index 000000000..01e9a775b
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_7.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_8.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_8.jpg
new file mode 100644
index 000000000..5b387afa2
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_8.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_9.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_9.jpg
new file mode 100644
index 000000000..087bf93af
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_9.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_express.png b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_express.png
new file mode 100644
index 000000000..f05790fb9
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/avatar_express.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/jetchat.png b/src/Comet/sample/CometSwiftUIProbe/Resources/jetchat.png
new file mode 100644
index 000000000..f7b595c85
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/jetchat.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_badge.png b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_badge.png
new file mode 100644
index 000000000..4ef8ad291
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_badge.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_logo.png b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_logo.png
new file mode 100644
index 000000000..39927d235
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_logo.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_wordmark.png b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_wordmark.png
new file mode 100644
index 000000000..93e2ff4d9
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/jetnews_wordmark.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/placeholder_1_1.png b/src/Comet/sample/CometSwiftUIProbe/Resources/placeholder_1_1.png
new file mode 100644
index 000000000..55fb3c2ac
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/placeholder_1_1.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_1.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_1.png
new file mode 100644
index 000000000..f2efcdc27
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_1.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_1_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_1_thumb.png
new file mode 100644
index 000000000..6af4fcc71
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_1_thumb.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_2.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_2.png
new file mode 100644
index 000000000..acc31574c
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_2.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_2_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_2_thumb.png
new file mode 100644
index 000000000..e5c598ec4
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_2_thumb.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_3.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_3.png
new file mode 100644
index 000000000..f8922fbe9
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_3.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_3_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_3_thumb.png
new file mode 100644
index 000000000..6e6a82d8b
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_3_thumb.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_4.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_4.png
new file mode 100644
index 000000000..14b14d977
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_4.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_4_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_4_thumb.png
new file mode 100644
index 000000000..d772e0019
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_4_thumb.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_5.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_5.png
new file mode 100644
index 000000000..31eb8183b
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_5.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_5_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_5_thumb.png
new file mode 100644
index 000000000..5c42ce2a8
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_5_thumb.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_6.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_6.png
new file mode 100644
index 000000000..3ad0c2952
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_6.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/post_6_thumb.png b/src/Comet/sample/CometSwiftUIProbe/Resources/post_6_thumb.png
new file mode 100644
index 000000000..092cc3f13
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/post_6_thumb.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/almonds.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/almonds.jpg
new file mode 100644
index 000000000..0f1eef57e
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/almonds.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_chips.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_chips.jpg
new file mode 100644
index 000000000..37c805d16
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_chips.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_juice.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_juice.jpg
new file mode 100644
index 000000000..d519dbcd4
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_juice.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_pie.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_pie.jpg
new file mode 100644
index 000000000..41096b221
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_pie.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_sauce.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_sauce.jpg
new file mode 100644
index 000000000..7f5331ee2
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apple_sauce.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apples.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apples.jpg
new file mode 100644
index 000000000..cc729f164
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/apples.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cheese.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cheese.jpg
new file mode 100644
index 000000000..c5c6dce61
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cheese.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/chips.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/chips.jpg
new file mode 100644
index 000000000..04d5fe2cd
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/chips.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cupcake.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cupcake.jpg
new file mode 100644
index 000000000..42e766d84
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/cupcake.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/desserts.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/desserts.jpg
new file mode 100644
index 000000000..6d4499076
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/desserts.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/donut.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/donut.jpg
new file mode 100644
index 000000000..076896a81
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/donut.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/eclair.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/eclair.jpg
new file mode 100644
index 000000000..560178034
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/eclair.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/froyo.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/froyo.jpg
new file mode 100644
index 000000000..e1bb068cc
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/froyo.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/fruit.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/fruit.jpg
new file mode 100644
index 000000000..412247318
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/fruit.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gingerbread.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gingerbread.jpg
new file mode 100644
index 000000000..ac069de10
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gingerbread.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gluten_free.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gluten_free.jpg
new file mode 100644
index 000000000..0745457a3
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/gluten_free.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/grapes.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/grapes.jpg
new file mode 100644
index 000000000..3b573787c
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/grapes.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/honeycomb.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/honeycomb.jpg
new file mode 100644
index 000000000..ea632bd25
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/honeycomb.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/ice_cream_sandwich.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/ice_cream_sandwich.jpg
new file mode 100644
index 000000000..fd77631e9
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/ice_cream_sandwich.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/jelly_bean.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/jelly_bean.jpg
new file mode 100644
index 000000000..84a10208c
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/jelly_bean.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kitkat.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kitkat.jpg
new file mode 100644
index 000000000..75b2e44ab
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kitkat.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kiwi.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kiwi.jpg
new file mode 100644
index 000000000..2197fbd48
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/kiwi.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/lollipop.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/lollipop.jpg
new file mode 100644
index 000000000..98d1db7d2
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/lollipop.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/mango.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/mango.jpg
new file mode 100644
index 000000000..717773d7b
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/mango.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/marshmallow.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/marshmallow.jpg
new file mode 100644
index 000000000..cdc115922
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/marshmallow.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nougat.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nougat.jpg
new file mode 100644
index 000000000..1a844d9b9
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nougat.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nuts.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nuts.jpg
new file mode 100644
index 000000000..03556767e
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/nuts.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/oreo.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/oreo.jpg
new file mode 100644
index 000000000..cf2c3e534
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/oreo.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/organic.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/organic.jpg
new file mode 100644
index 000000000..2847abf4f
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/organic.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/paleo.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/paleo.jpg
new file mode 100644
index 000000000..750fcd58e
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/paleo.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pie.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pie.jpg
new file mode 100644
index 000000000..439c18cfb
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pie.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/placeholder.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/placeholder.jpg
new file mode 100644
index 000000000..31e05faac
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/placeholder.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/popcorn.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/popcorn.jpg
new file mode 100644
index 000000000..02713ffdb
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/popcorn.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pretzels.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pretzels.jpg
new file mode 100644
index 000000000..d31d4aa4e
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/pretzels.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/smoothies.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/smoothies.jpg
new file mode 100644
index 000000000..f2eaa31bf
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/smoothies.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/vegan.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/vegan.jpg
new file mode 100644
index 000000000..29276a68b
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/snacks/vegan.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/someone_else.jpg b/src/Comet/sample/CometSwiftUIProbe/Resources/someone_else.jpg
new file mode 100644
index 000000000..95bc4a8bb
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/someone_else.jpg differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/Resources/sticker.png b/src/Comet/sample/CometSwiftUIProbe/Resources/sticker.png
new file mode 100644
index 000000000..54bbcfb96
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/Resources/sticker.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-a.png b/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-a.png
new file mode 100644
index 000000000..8333ea0c4
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-a.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-b.png b/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-b.png
new file mode 100644
index 000000000..0db7b8b37
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-inputs-b.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-list.png b/src/Comet/sample/CometSwiftUIProbe/phase3-list.png
new file mode 100644
index 000000000..5dedf2716
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-list.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-nav-detail.png b/src/Comet/sample/CometSwiftUIProbe/phase3-nav-detail.png
new file mode 100644
index 000000000..7ceab6bd6
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-nav-detail.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-nav-home.png b/src/Comet/sample/CometSwiftUIProbe/phase3-nav-home.png
new file mode 100644
index 000000000..01a65139d
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-nav-home.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-a.png b/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-a.png
new file mode 100644
index 000000000..f0356f214
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-a.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-b.png b/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-b.png
new file mode 100644
index 000000000..afa1e9ec4
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-reactive-b.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-gate.png b/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-gate.png
new file mode 100644
index 000000000..5a9d5c4c5
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-gate.png differ
diff --git a/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-tree.png b/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-tree.png
new file mode 100644
index 000000000..85c6fcd03
Binary files /dev/null and b/src/Comet/sample/CometSwiftUIProbe/phase3-swiftui-tree.png differ
diff --git a/src/Comet/sample/Directory.Build.targets b/src/Comet/sample/Directory.Build.targets
index 97fe8f806..03e6e1a24 100644
--- a/src/Comet/sample/Directory.Build.targets
+++ b/src/Comet/sample/Directory.Build.targets
@@ -1,8 +1,8 @@
+ iOS/MacCatalyst SDK requires >= 17.0; current Android (.NET 11 preview 7)
+ requires minSdk >= 24. Override in Directory.Build.targets (after the csproj
+ evaluates) so all samples pick this up without per-project edits. -->
17.0
@@ -10,18 +10,31 @@
17.0
- 23.0
+ 24.0
-
+
+
-
-
+
+
+
+
+
+
$(MSBuildThisFileDirectory)Shared/RuntimeDebug/Entitlements.Debug.plist
+
+
+
diff --git a/src/Comet/sample/LEGACY-SAMPLES.md b/src/Comet/sample/LEGACY-SAMPLES.md
new file mode 100644
index 000000000..5e8fb7202
--- /dev/null
+++ b/src/Comet/sample/LEGACY-SAMPLES.md
@@ -0,0 +1,19 @@
+# Legacy samples (parked)
+
+Phase 5 (2026-07-01) **deleted the legacy MAUI ViewHandler render path** from
+Comet (`Handlers/**`, `Maui/**`, `AppHostBuilderExtensions`, the native
+`CUI*`/`Comet*` platform views, and the `Shared/RuntimeDebug` debug host).
+The Compose (Android) / SwiftUI (iOS) node backend is the only render path.
+
+Every sample in this folder that calls `UseCometApp`/`UseCometHandlers` is
+**parked**: it no longer compiles and is kept only as reference source. That is
+all of them except the node-backend probes:
+
+- `CometComposeProbe` — Android, Jetpack Compose backend (active)
+- `CometSwiftUIProbe` — iOS, SwiftUI backend (active)
+- `Shared/Jetchat` — the shared gold-standard sample both probes build (active)
+
+To build or run a parked sample, check out the last commit with the legacy
+path: tag **`comet-pre-phase5-delete`**. Reviving one for the node backend
+means porting its app host to a backend root (see the probes' `MainActivity`/
+`AppDelegate`) — the view/tree code itself is largely portable.
diff --git a/src/Comet/sample/MauiReference/MauiProgram.cs b/src/Comet/sample/MauiReference/MauiProgram.cs
index 10f597069..1496ce0eb 100644
--- a/src/Comet/sample/MauiReference/MauiProgram.cs
+++ b/src/Comet/sample/MauiReference/MauiProgram.cs
@@ -37,9 +37,8 @@ public static MauiApp CreateMauiApp()
fonts.AddFont("FluentSystemIcons-Regular.ttf", FluentUI.FontFamily);
});
-#if DEBUG
- builder.EnableSampleRuntimeDebugging();
-#endif
+ // NOTE: EnableSampleRuntimeDebugging() was removed with the legacy Comet render path
+ // (Phase 5) — the shared RuntimeDebug host is no longer compiled into samples.
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Comet/sample/Shared/JetNews/JetNewsArticle.cs b/src/Comet/sample/Shared/JetNews/JetNewsArticle.cs
new file mode 100644
index 000000000..724160148
--- /dev/null
+++ b/src/Comet/sample/Shared/JetNews/JetNewsArticle.cs
@@ -0,0 +1,255 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using Comet;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.JetNews.JetNewsTheme;
+
+namespace CometSamples.JetNews
+{
+ ///
+ /// The Article (post) screen, values-from-source: PostScreen.kt (chrome) +
+ /// PostContent.kt (body). Compact chrome = center top bar ("Published in: \n{name}"
+ /// next to the publication badge, back arrow tinted primary) + bottom action bar;
+ /// content = header image / headlineLarge title / subtitle / metadata / typed
+ /// paragraphs with markups (bold, italic, underline links, monospace code spans).
+ ///
+ public static class JetNewsArticle
+ {
+ const float SpacerSize = 16f; // PostContent.kt defaultSpacerSize
+
+ // ── Rows: PostContent.kt postContentItems — header item, metadata item, one item
+ // per paragraph (a real lazy list, like the gold LazyColumn). ──
+ abstract record ArticleRow;
+ sealed record HeaderRow(Post Post) : ArticleRow;
+ sealed record MetadataRow(Post Post) : ArticleRow;
+ sealed record ParagraphRow(Paragraph Paragraph) : ArticleRow;
+
+ public static View Screen(Comet.Reactive.Signal post, double topInset, System.Action onBack)
+ {
+ JetNewsIcons.Register();
+
+ static List Rows(Post p)
+ {
+ var rows = new List { new HeaderRow(p), new MetadataRow(p) };
+ rows.AddRange(p.Paragraphs.Select(pg => (ArticleRow)new ParagraphRow(pg)));
+ return rows;
+ }
+
+ var list = new ListView(() => Rows(post.Peek()))
+ {
+ ViewFor = r => r switch
+ {
+ HeaderRow h => Header(h.Post),
+ MetadataRow m => Metadata(m.Post.Metadata),
+ ParagraphRow p => ParagraphView(p.Paragraph),
+ _ => new HStack(),
+ },
+ };
+ // One retained shell serves every post: swapping CurrentPost re-pulls the rows.
+ // Subscribed ONCE to the (static) signal via the current-list slot — a per-Screen
+ // subscription would leak a handler + captured list per rebuild.
+ _currentList = list;
+ if (!_postHooked)
+ {
+ _postHooked = true;
+ post.PropertyChanged += (_, _) => _currentList?.ReloadData();
+ }
+
+ return new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: (float)topInset).FlexShrink(0),
+ TopBar(post, onBack).FlexShrink(0),
+ list.FlexGrow(1).FlexBasis(0),
+ BottomBar().FlexShrink(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.Background);
+ }
+
+ static ListView? _currentList;
+ static bool _postHooked;
+
+ static Text Tx(string s) => new Text(s).FontFamily("Montserrat");
+
+ // CenterAlignedTopAppBar (PostScreen.kt): back arrow tinted primary; centered title =
+ // publication badge (36dp circle; the gold vector is an Android head on #073042 —
+ // approximated with the Material Icons android glyph on the same navy) + two-line
+ // "Published in: \n{name}" labelLarge, 8dp start padding, text left-aligned.
+ static View TopBar(Comet.Reactive.Signal post, System.Action onBack) => new HStack(spacing: 0f)
+ {
+ new Icon("arrow_back").IconSize(24).Color(T.Primary)
+ .Frame(width: 48, height: 48).Padding(new Thickness(12))
+ .FlexShrink(0)
+ .OnTap(_ => onBack()),
+ new HStack().FlexGrow(1),
+ // The gold icon_post_background vector (multicolor — renders untinted).
+ new Icon("jetnews_badge").IconSize(36)
+ .Frame(width: 36, height: 36).CornerRadius(18)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .FlexShrink(0),
+ new VStack(spacing: 0f)
+ {
+ Tx("Published in:").FontSize(14).FontWeight(FontWeight.Medium).Color(T.OnSurface),
+ new Text(() => post.Value.Publication?.Name ?? string.Empty).FontFamily("Montserrat")
+ .FontSize(14).FontWeight(FontWeight.Medium).Color(T.OnSurface),
+ }.Padding(new Thickness(8, 0, 0, 0)).VerticalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ new HStack().Frame(width: 48).FlexShrink(0), // balances the nav slot
+ }.Frame(height: 64);
+
+ // PostContent.kt item 1: header image (minH 180, full width, shapes.large = r8),
+ // spacer 16, headlineLarge title, spacer 8, bodyMedium subtitle + spacer 16.
+ static View Header(Post post)
+ {
+ var stack = new VStack(spacing: 0f)
+ {
+ new Image(post.ImageId).Frame(height: 180).CornerRadius(8)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill),
+ new HStack().Frame(height: SpacerSize),
+ Tx(post.Title).FontSize(32).LineHeight(40).Color(T.OnSurface)
+ .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading),
+ new HStack().Frame(height: 8),
+ };
+ if (post.Subtitle is { } subtitle)
+ {
+ stack.Add(Tx(subtitle).FontSize(14).LineHeight(20).Color(T.OnSurface)
+ .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Paragraph));
+ stack.Add(new HStack().Frame(height: SpacerSize));
+ }
+ return stack.Padding(new Thickness(16, 0, 16, 0));
+ }
+
+ // PostContent.kt PostMetadata: account_circle 40 tinted content color, 8 gap,
+ // author labelLarge (padded 4 from top) over "{date} · {read} min read" bodySmall;
+ // 24dp bottom padding.
+ static View Metadata(Metadata metadata) => new HStack(spacing: 0f)
+ {
+ new Icon("account_circle").IconSize(40).Color(T.OnSurface)
+ .Frame(width: 40, height: 40).FlexShrink(0),
+ new VStack(spacing: 0f)
+ {
+ Tx(metadata.Author.Name).FontSize(14).FontWeight(FontWeight.Medium).Color(T.OnSurface)
+ .Padding(new Thickness(0, 4, 0, 0)),
+ Tx($"{metadata.Date} · {metadata.ReadTimeMinutes} min read")
+ .FontSize(12).Color(T.OnSurface),
+ }.Padding(new Thickness(8, 0, 0, 0)),
+ }.Padding(new Thickness(16, 0, 16, 24));
+
+ // ── Paragraph (PostContent.kt): style per ParagraphType; markups become styled runs.
+ // Trailing padding 24 (16 for Subhead/Header); non-block text carries the gold's 4dp pad. ──
+ static View ParagraphView(Paragraph paragraph)
+ {
+ var (fontSize, lineHeight, weight, trailing) = paragraph.Type switch
+ {
+ ParagraphType.Caption => (12f, 16f, FontWeight.Medium, 24f),
+ ParagraphType.Title => (32f, 40f, FontWeight.Regular, 24f),
+ ParagraphType.Subhead => (24f, 32f, FontWeight.Regular, 16f),
+ ParagraphType.Header => (28f, 36f, FontWeight.Regular, 16f),
+ _ => (16f, 28f, FontWeight.Regular, 24f), // Text/CodeBlock/Quote/Bullet = bodyLarge (Text pins lineHeight 28)
+ };
+
+ View content = paragraph.Type switch
+ {
+ ParagraphType.CodeBlock => CodeBlock(paragraph, fontSize),
+ ParagraphType.Bullet => Bullet(paragraph, fontSize, lineHeight),
+ _ => Runs(paragraph, fontSize, lineHeight, weight,
+ heading: paragraph.Type is ParagraphType.Title or ParagraphType.Subhead or ParagraphType.Header)
+ .Padding(new Thickness(4)),
+ };
+
+ return new VStack(spacing: 0f) { content }
+ .Padding(new Thickness(16, 0, 16, trailing));
+ }
+
+ /// The paragraph text with its markups as styled runs — bold / italic /
+ /// underlined link / monospace code span on onSurface@15% (PostContent.kt markup map).
+ static View Runs(Paragraph paragraph, float fontSize, float lineHeight, FontWeight weight, bool heading)
+ {
+ var view = paragraph.Markups is { Count: > 0 }
+ ? (View)new FormattedText(BuildRuns(paragraph))
+ : Tx(paragraph.Text);
+ return view
+ .FontSize(fontSize).LineHeight(lineHeight).FontWeight(weight)
+ .FontFamily("Montserrat").Color(T.OnSurface)
+ .LineBreakMode(LineBreakMode.WordWrap)
+ .LineBreak(heading ? TextLineBreak.Heading : TextLineBreak.Paragraph);
+ }
+
+ static IReadOnlyList BuildRuns(Paragraph paragraph)
+ {
+ var text = paragraph.Text;
+ var runs = new List();
+ int cursor = 0;
+ foreach (var m in paragraph.Markups!.OrderBy(m => m.Start))
+ {
+ int start = System.Math.Clamp(m.Start, 0, text.Length);
+ int end = System.Math.Clamp(m.End, start, text.Length);
+ if (start < cursor)
+ continue; // overlapping markups: first one wins (gold data has none)
+ if (start > cursor)
+ runs.Add(new TextRun(text[cursor..start]));
+ runs.Add(m.Type switch
+ {
+ MarkupType.Bold => new TextRun(text[start..end], Bold: true),
+ MarkupType.Italic => new TextRun(text[start..end], Italic: true),
+ MarkupType.Link => new TextRun(text[start..end], Underline: true),
+ _ => new TextRun(text[start..end], Monospace: true, Background: T.CodeBlockBackground),
+ });
+ cursor = end;
+ }
+ if (cursor < text.Length)
+ runs.Add(new TextRun(text[cursor..]));
+ return runs;
+ }
+
+ // CodeBlockParagraph: full-width Surface, shapes.small = r4, onSurface@15%, pad 16,
+ // monospace bodyLarge.
+ static View CodeBlock(Paragraph paragraph, float fontSize) => new VStack(spacing: 0f)
+ {
+ new FormattedText(new[] { new TextRun(paragraph.Text, Monospace: true) })
+ .FontSize(fontSize).LineHeight(24).Color(T.OnSurface)
+ .LineBreakMode(LineBreakMode.WordWrap)
+ .Padding(new Thickness(16)),
+ }
+ .Background(T.CodeBlockBackground).CornerRadius(4)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill);
+
+ // BulletParagraph: an 8sp dot aligned to the first line + indented text.
+ static View Bullet(Paragraph paragraph, float fontSize, float lineHeight) => new HStack(spacing: 0f)
+ {
+ new HStack().Frame(width: 8, height: 8)
+ .Background(T.OnSurface).CornerRadius(4)
+ .Margin(new Thickness(0, (lineHeight - 8) / 2, 8, 0))
+ .VerticalLayoutAlignment(LayoutAlignment.Start).FlexShrink(0),
+ Runs(paragraph, fontSize, lineHeight, FontWeight.Regular, heading: false)
+ .FlexGrow(1).FlexBasis(0),
+ };
+
+ // BottomAppBar (PostScreen.kt bottomBarContent): M3 tokens — 80dp, surfaceContainer,
+ // leading 16; actions thumb_up / bookmark toggle / share / text_format.
+ static View BottomBar()
+ {
+ View Action(string icon, System.Action? onTap = null) =>
+ new Icon(icon).IconSize(24).Color(T.OnSurfaceVariant)
+ .Frame(width: 48, height: 48).Padding(new Thickness(12))
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .OnTap(_ => onTap?.Invoke());
+
+ return new HStack(spacing: 0f)
+ {
+ Action("thumb_up_offalt"),
+ Action("bookmark_border"),
+ Action("share"),
+ Action("text_format"),
+ }
+ .Padding(new Thickness(16, 0, 16, 0))
+ .Frame(height: 80)
+ .Background(T.SurfaceContainer)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill);
+ }
+ }
+}
diff --git a/src/Comet/sample/Shared/JetNews/JetNewsData.cs b/src/Comet/sample/Shared/JetNews/JetNewsData.cs
new file mode 100644
index 000000000..d88a80868
--- /dev/null
+++ b/src/Comet/sample/Shared/JetNews/JetNewsData.cs
@@ -0,0 +1,1175 @@
+#nullable enable
+using System.Collections.Generic;
+
+namespace CometSamples.JetNews
+{
+ /// Verbatim port of the gold data (data/posts/impl/PostsData.kt) — five posts,
+ /// full paragraph + markup content, same feed composition. Image ids are bundled
+ /// resource names (post_N / post_N_thumb).
+ public static class JetNewsData
+ {
+ static IReadOnlyList L(params T[] items) => items;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /**
+ * Define hardcoded posts to avoid handling any non-ui operations.
+ */
+
+ public static readonly PostAuthor pietro = new PostAuthor("Pietro Maggi", "https://medium.com/@pmaggi");
+ public static readonly PostAuthor manuel = new PostAuthor("Manuel Vivo", "https://medium.com/@manuelvicnt");
+ public static readonly PostAuthor florina = new PostAuthor(
+ "Florina Muntenescu",
+ "https://medium.com/@florina.muntenescu"
+ );
+ public static readonly PostAuthor jose = new PostAuthor("Jose Alcérreca", "https://medium.com/@JoseAlcerreca");
+
+ public static readonly PostAuthor androidstudioteam = new PostAuthor("Android Studio Team", "https://twitter.com/androidstudio");
+
+ public static readonly Publication ThePublication = new Publication(
+ "Android Developers",
+ "https://cdn-images-1.medium.com/max/258/1*u7oZc2_5mrkcFaxkXEyfYA@2x.png"
+ );
+ public static readonly IReadOnlyList paragraphsPost1 = L(
+ new Paragraph(
+ ParagraphType.Text,
+ "Working to make our Android application more modular, I ended up with a sample that included a set of on-demand features grouped inside a folder:"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Pretty standard setup, all the on-demand modules, inside a “features” folder; clean."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "These modules are included in the settings.gradle file as:"
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ "include ':app'\ninclude ':features:module1'\ninclude ':features:module2'\ninclude ':features:module3'\ninclude ':features:module4'"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "These setup works nicely with a single “minor” issue: an empty module named features in the Android view in Android Studio:"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "I can live with that, but I would much prefer to remove that empty module from my project!"
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "If you cannot remove it, just rename it!"
+ ),
+
+ new Paragraph(
+ ParagraphType.Text,
+ "At I/O I was lucky enough to attend the “Android Studio: Tips and Tricks” talk where Ivan Gravilovic, from Google, shared some amazing tips. One of these was a possible solution for my problem: setting a custom path for my modules.",
+ L(
+ new Markup(
+ MarkupType.Italic,
+ 41,
+ 72
+ )
+ )
+ ),
+
+ new Paragraph(
+ ParagraphType.Text,
+ "In this particular case our settings.gradle becomes:",
+ L(new Markup(MarkupType.Code, 28, 43))
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ """
+ include ':app'
+ include ':module1'
+ include ':module1'
+ include ':module1'
+ include ':module1'
+ """
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ """
+ // Set a custom path for the four features modules.
+ // This avoid to have an empty "features" module in Android Studio.
+ project(":module1").projectDir=new File(rootDir, "features/module1")
+ project(":module2").projectDir=new File(rootDir, "features/module2")
+ project(":module3").projectDir=new File(rootDir, "features/module3")
+ project(":module4").projectDir=new File(rootDir, "features/module4")
+ """
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "And the layout in Android Studio is now:"
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Conclusion"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "As the title says, this is really a small thing, but it helps keep my project in order and it shows how a small Gradle configuration can help keep your project tidy."
+ ),
+ new Paragraph(
+ ParagraphType.Quote,
+ "You can find this update in the latest version of the on-demand modules codelab.",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 54,
+ 79,
+ "https://codelabs.developers.google.com/codelabs/on-demand-dynamic-delivery/index.html"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Resources"
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "Android Studio: Tips and Tricks (Google I/O’19)",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 0,
+ 47,
+ "https://www.youtube.com/watch?v=ihF-PwDfRZ4&list=PLWz5rJ2EKKc9FfSQIRXEWyWpHD6TtwxMM&index=32&t=0s"
+ )
+ )
+ ),
+
+ new Paragraph(
+ ParagraphType.Bullet,
+ "On Demand module codelab",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 0,
+ 24,
+ "https://codelabs.developers.google.com/codelabs/on-demand-dynamic-delivery/index.html"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "Patchwork Plaid — A modularization story",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 0,
+ 40,
+ "https://medium.com/androiddevelopers/a-patchwork-plaid-monolith-to-modularized-app-60235d9f212e"
+ )
+ )
+ )
+ );
+
+ public static readonly IReadOnlyList paragraphsPost2 = L(
+ new Paragraph(
+ ParagraphType.Text,
+ "Dagger is a popular Dependency Injection framework commonly used in Android. It provides fully static and compile-time dependencies addressing many of the development and performance issues that have reflection-based solutions.",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 0,
+ 6,
+ "https://dagger.dev/"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "This month, a new tutorial was released to help you better understand how it works. This article focuses on using Dagger with Kotlin, including best practices to optimize your build time and gotchas you might encounter.",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 14,
+ 26,
+ "https://dagger.dev/tutorial/"
+ ),
+ new Markup(MarkupType.Bold, 114, 132),
+ new Markup(MarkupType.Bold, 144, 159),
+ new Markup(MarkupType.Bold, 191, 198)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Dagger is implemented using Java’s annotations model and annotations in Kotlin are not always directly parallel with how equivalent Java code would be written. This post will highlight areas where they differ and how you can use Dagger with Kotlin without having a headache."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "This post was inspired by some of the suggestions in this Dagger issue that goes through best practices and pain points of Dagger in Kotlin. Thanks to all of the contributors that commented there!",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 58,
+ 70,
+ "https://github.com/google/dagger/issues/900"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "kapt build improvements"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "To improve your build time, Dagger added support for gradle’s incremental annotation processing in v2.18! This is enabled by default in Dagger v2.24. In case you’re using a lower version, you need to add a few lines of code (as shown below) if you want to benefit from it.",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 99,
+ 104,
+ "https://github.com/google/dagger/releases/tag/dagger-2.18"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 143,
+ 148,
+ "https://github.com/google/dagger/releases/tag/dagger-2.24"
+ ),
+ new Markup(MarkupType.Bold, 53, 95)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Also, you can tell Dagger not to format the generated code. This option was added in Dagger v2.18 and it’s the default behavior (doesn’t generate formatted code) in v2.23. If you’re using a lower version, disable code formatting to improve your build time (see code below).",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 92,
+ 97,
+ "https://github.com/google/dagger/releases/tag/dagger-2.18"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 165,
+ 170,
+ "https://github.com/google/dagger/releases/tag/dagger-2.23"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Include these compiler arguments in your build.gradle file to make Dagger more performant at build time:",
+ L(new Markup(MarkupType.Code, 41, 53))
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Alternatively, if you use Kotlin DSL script files, include them like this in the build.gradle.kts file of the modules that use Dagger:",
+ L(new Markup(MarkupType.Code, 81, 97))
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Qualifiers for field attributes"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "",
+ L(new Markup(MarkupType.Link, 0, 0))
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "When an annotation is placed on a property in Kotlin, it’s not clear whether Java will see that annotation on the field of the property or the method for that property. Setting the field: prefix on the annotation ensures that the qualifier ends up in the right place (See documentation for more details).",
+ L(
+ new Markup(MarkupType.Code, 181, 187),
+ new Markup(
+ MarkupType.Link,
+ 268,
+ 285,
+ "http://frogermcs.github.io/dependency-injection-with-dagger-2-custom-scopes/"
+ ),
+ new Markup(MarkupType.Italic, 114, 119),
+ new Markup(MarkupType.Italic, 143, 149)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "✅ The way to apply qualifiers on an injected field is:"
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ "@Inject @field:MinimumBalance lateinit var minimumBalance: BigDecimal",
+ L(new Markup(MarkupType.Bold, 8, 29))
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "❌ As opposed to:"
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ """
+ @Inject @MinimumBalance lateinit var minimumBalance: BigDecimal
+ // @MinimumBalance is ignored!
+ """,
+ L(new Markup(MarkupType.Bold, 65, 95))
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Forgetting to add field: could lead to injecting the wrong object if there’s an unqualified instance of that type available in the Dagger graph.",
+ L(new Markup(MarkupType.Code, 18, 24))
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Static @Provides functions optimization"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Dagger’s generated code will be more performant if @Provides methods are static. To achieve this in Kotlin, use a Kotlin object instead of a class and annotate your methods with @JvmStatic. This is a best practice that you should follow as much as possible.",
+ L(
+ new Markup(MarkupType.Code, 51, 60),
+ new Markup(MarkupType.Code, 73, 79),
+ new Markup(MarkupType.Code, 121, 127),
+ new Markup(MarkupType.Code, 141, 146),
+ new Markup(MarkupType.Code, 178, 188),
+ new Markup(MarkupType.Bold, 200, 213),
+ new Markup(MarkupType.Italic, 200, 213)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "In case you need an abstract method, you’ll need to add the @JvmStatic method to a companion object and annotate it with @Module too.",
+ L(
+ new Markup(MarkupType.Code, 60, 70),
+ new Markup(MarkupType.Code, 121, 128)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Alternatively, you can extract the object module out and include it in the abstract one:"
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Injecting Generics"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Kotlin compiles generics with wildcards to make Kotlin APIs work with Java. These are generated when a type appears as a parameter (more info here) or as fields. For example, a Kotlin List parameter shows up as List super Foo> in Java.",
+ L(
+ new Markup(MarkupType.Code, 184, 193),
+ new Markup(MarkupType.Code, 216, 233),
+ new Markup(
+ MarkupType.Link,
+ 132,
+ 146,
+ "https://kotlinlang.org/docs/reference/java-to-kotlin-interop.html#variant-generics"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "This causes problems with Dagger because it expects an exact (aka invariant) type match. Using @JvmSuppressWildcards will ensure that Dagger sees the type without wildcards.",
+ L(
+ new Markup(MarkupType.Code, 95, 116),
+ new Markup(
+ MarkupType.Link,
+ 66,
+ 75,
+ "https://en.wikipedia.org/wiki/Class_invariant"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 96,
+ 116,
+ "https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.jvm/-jvm-suppress-wildcards/index.html"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "This is a common issue when you inject collections using Dagger’s multibinding feature, for example:",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 57,
+ 86,
+ "https://dagger.dev/multibindings.html"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ """
+ class MyVMFactory @Inject constructor(
+ private val vmMap: Map>
+ ) {
+ ...
+ }
+ """,
+ L(new Markup(MarkupType.Bold, 72, 93))
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Inline method bodies"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Dagger determines the types that are configured by @Provides methods by inspecting the return type. Specifying the return type in Kotlin functions is optional and even the IDE sometimes encourages you to refactor your code to have inline method bodies that hide the return type declaration.",
+ L(new Markup(MarkupType.Code, 51, 60))
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "This can lead to bugs if the inferred type is different from the one you meant. Let’s see some examples:"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "If you want to add a specific type to the graph, inlining works as expected. See the different ways to do the same in Kotlin:"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "If you want to provide an implementation of an interface, then you must explicitly specify the return type. Not doing it can lead to problems and bugs:"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Dagger mostly works with Kotlin out of the box. However, you have to watch out for a few things just to make sure you’re doing what you really mean to do: @field: for qualifiers on field attributes, inline method bodies, and @JvmSuppressWildcards when injecting collections.",
+ L(
+ new Markup(MarkupType.Code, 155, 162),
+ new Markup(MarkupType.Code, 225, 246)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Dagger optimizations come with no cost, add them and follow best practices to improve your build time: enabling incremental annotation processing, disabling formatting and using static @Provides methods in your Dagger modules.",
+ L(
+ new Markup(
+ MarkupType.Code,
+ 185,
+ 194
+ )
+ )
+ )
+ );
+
+ public static readonly IReadOnlyList paragraphsPost3 = L(
+ new Paragraph(
+ ParagraphType.Text,
+ "Learn how to get started converting Java Programming Language code to Kotlin, making it more idiomatic and avoid common pitfalls, by following our new Refactoring to Kotlin codelab, available in English \uD83C\uDDEC\uD83C\uDDE7, Chinese \uD83C\uDDE8\uD83C\uDDF3 and Brazilian Portuguese \uD83C\uDDE7\uD83C\uDDF7.",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 151,
+ 172,
+ "https://codelabs.developers.google.com/codelabs/java-to-kotlin/#0"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 209,
+ 216,
+ "https://clmirror.storage.googleapis.com/codelabs/java-to-kotlin-zh/index.html#0"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 226,
+ 246,
+ "https://codelabs.developers.google.com/codelabs/java-to-kotlin-pt-br/#0"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "When you first get started writing Kotlin code, you tend to follow Java Programming Language idioms. The automatic converter, part of both Android Studio and Intellij IDEA, can do a pretty good job of automatically refactoring your code, but sometimes, it needs a little help. This is where our new Refactoring to Kotlin codelab comes in.",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 105,
+ 124,
+ "https://www.jetbrains.com/help/idea/converting-a-java-file-to-kotlin-file.html"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "We’ll take two classes (a User and a Repository) in Java Programming Language and convert them to Kotlin, check out what the automatic converter did and why. Then we go to the next level — make it idiomatic, teaching best practices and useful tips along the way.",
+ L(
+ new Markup(MarkupType.Code, 26, 30),
+ new Markup(MarkupType.Code, 37, 47)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "The Refactoring to Kotlin codelab starts with basic topics — understand how nullability is declared in Kotlin, what types of equality are defined or how to best handle classes whose role is just to hold data. We then continue with how to handle static fields and functions in Kotlin and how to apply the Singleton pattern, with the help of one handy keyword: object. We’ll see how Kotlin helps us model our classes better, how it differentiates between a property of a class and an action the class can do. Finally, we’ll learn how to execute code only in the context of a specific object with the scope functions.",
+ L(
+ new Markup(MarkupType.Code, 245, 251),
+ new Markup(MarkupType.Code, 359, 365),
+ new Markup(
+ MarkupType.Link,
+ 4,
+ 25,
+ "https://codelabs.developers.google.com/codelabs/java-to-kotlin/#0"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Thanks to Walmyr Carvalho and Nelson Glauber for translating the codelab in Brazilian Portuguese!",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 21,
+ 42,
+ "https://codelabs.developers.google.com/codelabs/java-to-kotlin/#0"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 76,
+ 96,
+ "https://codelabs.developers.google.com/codelabs/java-to-kotlin-pt-br/#0"
+ )
+ )
+ )
+ );
+
+ public static readonly IReadOnlyList paragraphsPost4 = L(
+ new Paragraph(
+ ParagraphType.Text,
+ "TL;DR: Expose resource IDs from ViewModels to avoid showing obsolete data."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "In a ViewModel, if you’re exposing data coming from resources (strings, drawables, colors…), you have to take into account that ViewModel objects ignore configuration changes such as locale changes. When the user changes their locale, activities are recreated but the ViewModel objects are not.",
+ L(
+ new Markup(
+ MarkupType.Bold,
+ 183,
+ 197
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "AndroidViewModel is a subclass of ViewModel that is aware of the Application context. However, having access to a context can be dangerous if you’re not observing or reacting to the lifecycle of that context. The recommended practice is to avoid dealing with objects that have a lifecycle in ViewModels.",
+ L(
+ new Markup(MarkupType.Code, 0, 16),
+ new Markup(MarkupType.Code, 34, 43),
+ new Markup(MarkupType.Bold, 209, 303)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Let’s look at an example based on this issue in the tracker: Updating ViewModel on system locale change.",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 61,
+ 103,
+ "https://issuetracker.google.com/issues/111961971"
+ ),
+ new Markup(MarkupType.Italic, 61, 104)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "The problem is that the string is resolved in the constructor only once. If there’s a locale change, the ViewModel won’t be recreated. This will result in our app showing obsolete data and therefore being only partially localized.",
+ L(new Markup(MarkupType.Bold, 73, 133))
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "As Sergey points out in the comments to the issue, the recommended approach is to expose the ID of the resource you want to load and do so in the view. As the view (activity, fragment, etc.) is lifecycle-aware it will be recreated after a configuration change so the resource will be reloaded correctly.",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 3,
+ 9,
+ "https://twitter.com/ZelenetS"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 28,
+ 36,
+ "https://issuetracker.google.com/issues/111961971#comment2"
+ ),
+ new Markup(MarkupType.Bold, 82, 150)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Even if you don’t plan to localize your app, it makes testing much easier and cleans up your ViewModel objects so there’s no reason not to future-proof."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "We fixed this issue in the android-architecture repository in the Java and Kotlin branches and we offloaded resource loading to the Data Binding layout.",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 66,
+ 70,
+ "https://github.com/googlesamples/android-architecture/pull/631"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 75,
+ 81,
+ "https://github.com/googlesamples/android-architecture/pull/635"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 128,
+ 151,
+ "https://github.com/googlesamples/android-architecture/pull/635/files#diff-7eb5d85ec3ea4e05ecddb7dc8ae20aa1R62"
+ )
+ )
+ )
+ );
+
+ public static readonly IReadOnlyList paragraphsPost5 = L(
+ new Paragraph(
+ ParagraphType.Text,
+ "Working with collections is a common task and the Kotlin Standard Library offers many great utility functions. It also offers two ways of working with collections based on how they’re evaluated: eagerly — with Collections, and lazily — with Sequences. Continue reading to find out what’s the difference between the two, which one you should use and when, and what the performance implications of each are.",
+ L(
+ new Markup(MarkupType.Code, 210, 220),
+ new Markup(MarkupType.Code, 241, 249),
+ new Markup(
+ MarkupType.Link,
+ 210,
+ 221,
+ "https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/index.html"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 241,
+ 250,
+ "https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.sequences/index.html"
+ ),
+ new Markup(MarkupType.Bold, 130, 134),
+ new Markup(MarkupType.Bold, 195, 202),
+ new Markup(MarkupType.Bold, 227, 233),
+ new Markup(MarkupType.Italic, 130, 134)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Collections vs sequences"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "The difference between eager and lazy evaluation lies in when each transformation on the collection is performed.",
+ L(
+ new Markup(
+ MarkupType.Italic,
+ 57,
+ 61
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Collections are eagerly evaluated — each operation is performed when it’s called and the result of the operation is stored in a new collection. The transformations on collections are inline functions. For example, looking at how map is implemented, we can see that it’s an inline function, that creates a new ArrayList:",
+ L(
+ new Markup(MarkupType.Code, 229, 232),
+ new Markup(MarkupType.Code, 273, 279),
+ new Markup(MarkupType.Code, 309, 318),
+ new Markup(
+ MarkupType.Link,
+ 183,
+ 199,
+ "https://kotlinlang.org/docs/reference/inline-functions.html"
+ ),
+ new Markup(
+ MarkupType.Link,
+ 229,
+ 232,
+ "https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/common/src/generated/_Collections.kt#L1312"
+ ),
+ new Markup(MarkupType.Bold, 0, 12),
+ new Markup(MarkupType.Italic, 16, 23)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ "public inline fun Iterable.map(transform: (T) -> R): List {\n return mapTo(ArrayList(collectionSizeOrDefault(10)), transform)\n}",
+ L(
+ new Markup(MarkupType.Bold, 7, 13),
+ new Markup(MarkupType.Bold, 88, 97)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Sequences are lazily evaluated. They have two types of operations: intermediate and terminal. Intermediate operations are not performed on the spot; they’re just stored. Only when a terminal operation is called, the intermediate operations are triggered on each element in a row and finally, the terminal operation is applied. Intermediate operations (like map, distinct, groupBy etc) return another sequence whereas terminal operations (like first, toList, count etc) don’t.",
+ L(
+ new Markup(MarkupType.Code, 357, 360),
+ new Markup(MarkupType.Code, 362, 370),
+ new Markup(MarkupType.Code, 372, 379),
+ new Markup(MarkupType.Code, 443, 448),
+ new Markup(MarkupType.Code, 450, 456),
+ new Markup(MarkupType.Code, 458, 463),
+ new Markup(MarkupType.Bold, 0, 9),
+ new Markup(MarkupType.Bold, 67, 79),
+ new Markup(MarkupType.Bold, 84, 92),
+ new Markup(MarkupType.Bold, 254, 269),
+ new Markup(MarkupType.Italic, 14, 20)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Sequences don’t hold a reference to the items of the collection. They’re created based on the iterator of the original collection and keep a reference to all the intermediate operations that need to be performed."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Unlike transformations on collections, intermediate transformations on sequences are not inline functions — inline functions cannot be stored and sequences need to store them. Looking at how an intermediate operation like map is implemented, we can see that the transform function is kept in a new instance of a Sequence:",
+ L(
+ new Markup(MarkupType.Code, 222, 225),
+ new Markup(MarkupType.Code, 312, 320),
+ new Markup(
+ MarkupType.Link,
+ 222,
+ 225,
+ "https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/common/src/generated/_Sequences.kt#L860"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ "public fun Sequence.map(transform: (T) -> R): Sequence{ \n return TransformingSequence(this, transform)\n}",
+ L(new Markup(MarkupType.Bold, 85, 105))
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "A terminal operation, like first, iterates through the elements of the sequence until the predicate condition is matched.",
+ L(
+ new Markup(MarkupType.Code, 27, 32),
+ new Markup(
+ MarkupType.Link,
+ 27,
+ 32,
+ "https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/common/src/generated/_Sequences.kt#L117"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ "public inline fun Sequence.first(predicate: (T) -> Boolean): T {\n for (element in this) if (predicate(element)) return element\n throw NoSuchElementException(“Sequence contains no element matching the predicate.”)\n}"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "If we look at how a sequence like TransformingSequence (used in the map above) is implemented, we’ll see that when next is called on the sequence iterator, the transformation stored is also applied.",
+ L(
+ new Markup(MarkupType.Code, 34, 54),
+ new Markup(MarkupType.Code, 68, 71)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ "internal class TransformingIndexedSequence \nconstructor(private val sequence: Sequence, private val transformer: (Int, T) -> R) : Sequence {",
+ L(
+ new Markup(
+ MarkupType.Bold,
+ 109,
+ 120
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.CodeBlock,
+ "override fun iterator(): Iterator = object : Iterator {\n …\n override fun next(): R {\n return transformer(checkIndexOverflow(index++), iterator.next())\n }\n …\n}",
+ L(
+ new Markup(MarkupType.Bold, 83, 89),
+ new Markup(MarkupType.Bold, 107, 118)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Independent on whether you’re using collections or sequences, the Kotlin Standard Library offers quite a wide range of operations for both, like find, filter, groupBy and others. Make sure you check them out, before implementing your own version of these.",
+ L(
+ new Markup(MarkupType.Code, 145, 149),
+ new Markup(MarkupType.Code, 151, 157),
+ new Markup(MarkupType.Code, 159, 166),
+ new Markup(
+ MarkupType.Link,
+ 193,
+ 207,
+ "https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/#functions"
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Collections and sequences"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Let’s say that we have a list of objects of different shapes. We want to make the shapes yellow and then take the first square shape."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Let’s see how and when each operation is applied for collections and when for sequences"
+ ),
+ new Paragraph(
+ ParagraphType.Subhead,
+ "Collections"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "map is called — a new ArrayList is created. We iterate through all items of the initial collection, transform it by copying the original object and changing the color, then add it to the new list.",
+ L(new Markup(MarkupType.Code, 0, 3))
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "first is called — we iterate through each item until the first square is found",
+ L(new Markup(MarkupType.Code, 0, 5))
+ ),
+ new Paragraph(
+ ParagraphType.Subhead,
+ "Sequences"
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "asSequence — a sequence is created based on the Iterator of the original collection",
+ L(new Markup(MarkupType.Code, 0, 10))
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "map is called — the transformation is added to the list of operations needed to be performed by the sequence but the operation is NOT performed",
+ L(
+ new Markup(MarkupType.Code, 0, 3),
+ new Markup(MarkupType.Bold, 130, 133)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "first is called — this is a terminal operation, so, all the intermediate operations are triggered, on each element of the collection. We iterate through the initial collection applying map and then first on each of them. Since the condition from first is satisfied by the 2nd element, then we no longer apply the map on the rest of the collection.",
+ L(new Markup(MarkupType.Code, 0, 5))
+ ),
+
+ new Paragraph(
+ ParagraphType.Text,
+ "When working with sequences no intermediate collection is created and since items are evaluated one by one, map is only performed on some of the inputs."
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Performance"
+ ),
+ new Paragraph(
+ ParagraphType.Subhead,
+ "Order of transformations"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Independent of whether you’re using collections or sequences, the order of transformations matters. In the example above, first doesn’t need to happen after map since it’s not a consequence of the map transformation. If we reverse the order of our business logic and call first on the collection and then transform the result, then we only create one new object — the yellow square. When using sequences — we avoid creating 2 new objects, when using collections, we avoid creating an entire new list.",
+ L(
+ new Markup(MarkupType.Code, 122, 127),
+ new Markup(MarkupType.Code, 157, 160),
+ new Markup(MarkupType.Code, 197, 200)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Because terminal operations can finish processing early, and intermediate operations are evaluated lazily, sequences can, in some cases, help you avoid doing unnecessary work compared to collections. Make sure you always check the order of the transformations and the dependencies between them!"
+ ),
+ new Paragraph(
+ ParagraphType.Subhead,
+ "Inlining and large data sets consequences"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Collection operations use inline functions, so the bytecode of the operation, together with the bytecode of the lambda passed to it will be inlined. Sequences don’t use inline functions, therefore, new Function objects are created for each operation.",
+ L(
+ new Markup(
+ MarkupType.Code,
+ 202,
+ 210
+ )
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "On the other hand, collections create a new list for every transformation while sequences just keep a reference to the transformation function."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "When working with small collections, with 1–2 operators, these differences don’t have big implications so working with collections should be ok. But, when working with large lists the intermediate collection creation can become expensive; in such cases, use sequences.",
+ L(
+ new Markup(MarkupType.Bold, 18, 35),
+ new Markup(MarkupType.Bold, 119, 130),
+ new Markup(MarkupType.Bold, 168, 179),
+ new Markup(MarkupType.Bold, 258, 267)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Unfortunately, I’m not aware of any benchmarking study done that would help us get a better understanding on how the performance of collections vs sequences is affected with different sizes of collections or operation chains."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Collections eagerly evaluate your data while sequences do so lazily. Depending on the size of your data, pick the one that fits best: collections — for small lists or sequences — for larger ones, and pay attention to the order of the transformations."
+ )
+ );
+
+ public static readonly IReadOnlyList paragraphsPost6 = L(
+ new Paragraph(
+ ParagraphType.Text,
+ "The Android Studio logo redesign caught the attention of the developer community since its sneak peek at the Android Developer Summit. We are thrilled to release the new Android Studio logo with the stable release of Flamingo. Now that the new logo is available to most Android Studio users, we can examine the design changes in greater detail and decode their meaning."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "This case study offers a comprehensive overview of the design journey, from identifying the initial problem to the final outcome. It explores the critical brand elements that the team needed to consider and the tools used throughout the redesign process. This case study also delves into the various stages of design exploration, highlighting the efforts to create a modern logo while honoring the Android Studio brand's legacy."
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Identifying the problem"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "You told us the Android Studio logo looked a little weird and complicated. It doesn't shrink down well and it's way too similar to the emulator. We heard you!"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "The Android Studio logo used between 2020 and 2022 was well-suited for print, but it posed challenges when used as an application icon. Its readability suffered when reduced to smaller sizes, and its similarity to the emulator caused confusion."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Additionally, the use of color alone to differentiate between Canary and Stable versions made it difficult for users with color vision deficiencies."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "The redesign aimed to resolve these concerns by creating a logo that was easy to read, visually distinctive, and followed the OS guidelines when necessary, ensuring accessibility. The new design also maintained a connection with the Android logo family while honoring its legacy."
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "In this case study, we will delve into the version history and evolution of the Android Studio logo and how it has changed over the years."
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "A brief history of the Android Studio logo"
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "2013: The original Android Studio logo was a 3D robot that highlighted the gears and interworking of the bugdroid. At this time, the Android Emulator was the bugdroid.",
+ L(
+ new Markup(MarkupType.Bold, 0, 5)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "2014: The Android Emulator merged to a flat mark but remained otherwise unchanged.",
+ L(
+ new Markup(MarkupType.Bold, 0, 5)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "2014-2019: An updated Android Studio logo was introduced featuring an \"A\" compass in front of a green circle.",
+ L(
+ new Markup(MarkupType.Bold, 0, 10)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "2019: In Canary 3.6, the color palette was updated to match Android 10.",
+ L(
+ new Markup(MarkupType.Bold, 0, 5)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "2020-2022: With the release of Android Studio 4.1 Canary, the \"A\" compass was reduced to an abstract form placed in front of a blueprint. The Android head was also added, peeking over the top.",
+ L(
+ new Markup(MarkupType.Bold, 0, 10)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Understanding the Android brand elements"
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "When redesigning a logo, it's important to consider brand elements that unify products within an ecosystem. For the Android Developer ecosystem, the \"robot head\" is a key brand element, alongside the primaryAndroid green color. The secondary colors blue and navy, and tertiary colors like orange, can also be utilized for support."
+ ),
+ new Paragraph(
+ ParagraphType.Header,
+ "Key objectives"
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "Iconography: use recognizable and appropriate symbols, such as compass \"A\" for Android Studio or a device for Android Emulator, to convey the purpose and functionality clearly and quickly.",
+ L(
+ new Markup(MarkupType.Bold, 0, 12)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "Enhance recognition and scalability: the Android Studio and Android Emulator should prioritize legibility and scalability, ensuring that they can be easily recognized and understood even at smaller sizes.",
+ L(
+ new Markup(MarkupType.Bold, 0, 36)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "Establish distinction: the Android Studio and Android Emulator need to be easily distinguishable, to avoid confusion.",
+ L(
+ new Markup(MarkupType.Bold, 0, 22)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "Maintain brand consistency: the Android Studio and Android Emulator designs should be consistent with the overall branding and visual identity of the Android family, while still being distinctive.",
+ L(
+ new Markup(MarkupType.Bold, 0, 27)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "Ensure accessibility: the logo should be accessible to all users, including those with visual impairments. This means using clear shapes, colors, and contrast.",
+ L(
+ new Markup(MarkupType.Bold, 0, 21)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "Follow OS guidelines: the updated application icon must align with the Android visual language and conform to the guidelines of macOS, Windows, and Linux operating systems, ensuring consistency and coherence across all platforms.",
+ L(
+ new Markup(MarkupType.Bold, 0, 21)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Bullet,
+ "Ensure versatility: the Android Studio logo should be versatile enough to work in a variety of sizes and contexts, such as on different devices and platforms.",
+ L(
+ new Markup(MarkupType.Bold, 0, 20)
+ )
+ ),
+ new Paragraph(
+ ParagraphType.Text,
+ "Read More",
+ L(
+ new Markup(
+ MarkupType.Link,
+ 0,
+ 9,
+ "https://android-developers.googleblog.com/2023/05/redesigning-android-studio-logo.html"
+ )
+ )
+ )
+ );
+
+ public static readonly Post post1 = new Post(
+ Id: "dc523f0ed25c",
+ Title: "A Little Thing about Android Module Paths",
+ Subtitle: "How to configure your module paths, instead of using Gradle’s default.",
+ Url: "https://medium.com/androiddevelopers/gradle-path-configuration-dc523f0ed25c",
+ Publication: ThePublication,
+ Metadata: new Metadata(
+ Author: pietro,
+ Date: "August 02",
+ ReadTimeMinutes: 1
+ ),
+ Paragraphs: paragraphsPost1,
+ ImageId: "post_1",
+ ImageThumbId: "post_1_thumb"
+ );
+
+ public static readonly Post post2 = new Post(
+ Id: "7446d8dfd7dc",
+ Title: "Dagger in Kotlin: Gotchas and Optimizations",
+ Subtitle: "Use Dagger in Kotlin! This article includes best practices to optimize your build time and gotchas you might encounter.",
+ Url: "https://medium.com/androiddevelopers/dagger-in-kotlin-gotchas-and-optimizations-7446d8dfd7dc",
+ Publication: ThePublication,
+ Metadata: new Metadata(
+ Author: manuel,
+ Date: "July 30",
+ ReadTimeMinutes: 3
+ ),
+ Paragraphs: paragraphsPost2,
+ ImageId: "post_2",
+ ImageThumbId: "post_2_thumb"
+ );
+
+ public static readonly Post post3 = new Post(
+ Id: "ac552dcc1741",
+ Title: "From Java Programming Language to Kotlin — the idiomatic way",
+ Subtitle: "Learn how to get started converting Java Programming Language code to Kotlin, making it more idiomatic and avoid common pitfalls, by…",
+ Url: "https://medium.com/androiddevelopers/from-java-programming-language-to-kotlin-the-idiomatic-way-ac552dcc1741",
+ Publication: ThePublication,
+ Metadata: new Metadata(
+ Author: florina,
+ Date: "July 09",
+ ReadTimeMinutes: 1
+ ),
+ Paragraphs: paragraphsPost3,
+ ImageId: "post_3",
+ ImageThumbId: "post_3_thumb"
+ );
+
+ public static readonly Post post4 = new Post(
+ Id: "84eb677660d9",
+ Title: "Locale changes and the AndroidViewModel antipattern",
+ Subtitle: "TL;DR: Expose resource IDs from ViewModels to avoid showing obsolete data.",
+ Url: "https://medium.com/androiddevelopers/locale-changes-and-the-androidviewmodel-antipattern-84eb677660d9",
+ Publication: ThePublication,
+ Metadata: new Metadata(
+ Author: jose,
+ Date: "April 02",
+ ReadTimeMinutes: 1
+ ),
+ Paragraphs: paragraphsPost4,
+ ImageId: "post_4",
+ ImageThumbId: "post_4_thumb"
+ );
+
+ public static readonly Post post5 = new Post(
+ Id: "55db18283aca",
+ Title: "Collections and sequences in Kotlin",
+ Subtitle: "Working with collections is a common task and the Kotlin Standard Library offers many great utility functions. It also offers two ways of…",
+ Url: "https://medium.com/androiddevelopers/collections-and-sequences-in-kotlin-55db18283aca",
+ Publication: ThePublication,
+ Metadata: new Metadata(
+ Author: florina,
+ Date: "July 24",
+ ReadTimeMinutes: 4
+ ),
+ Paragraphs: paragraphsPost5,
+ ImageId: "post_5",
+ ImageThumbId: "post_5_thumb"
+ );
+
+ public static readonly Post post6 = new Post(
+ Id: "55db18283ac0",
+ Title: "Redesigning the Android Studio Logo",
+ Subtitle: "A case study offering a comprehensive overview of the design journey of the Android Studio product logo.",
+ Url: "https://android-developers.googleblog.com/2023/05/redesigning-android-studio-logo.html",
+ Publication: ThePublication,
+ Metadata: new Metadata(
+ Author: androidstudioteam,
+ Date: "May 10",
+ ReadTimeMinutes: 5
+ ),
+ Paragraphs: paragraphsPost6,
+ ImageId: "post_6",
+ ImageThumbId: "post_6_thumb"
+ );
+
+ public static readonly PostsFeed Posts = new PostsFeed(
+ HighlightedPost: post6,
+ RecommendedPosts: L(post1, post2, post3),
+ PopularPosts: L(
+ post5,
+ post1 with { Id = "post6" },
+ post2 with { Id = "post7" }
+ ),
+ RecentPosts: L(
+ post6,
+ post3 with { Id = "post8" },
+ post4 with { Id = "post9" },
+ post5 with { Id = "post10" }
+ )
+ );
+ }
+
+}
diff --git a/src/Comet/sample/Shared/JetNews/JetNewsIcons.cs b/src/Comet/sample/Shared/JetNews/JetNewsIcons.cs
new file mode 100644
index 000000000..bfa8b3e07
--- /dev/null
+++ b/src/Comet/sample/Shared/JetNews/JetNewsIcons.cs
@@ -0,0 +1,46 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+
+namespace CometSamples.JetNews
+{
+ ///
+ /// JetNews' Material Icons glyph map (the one-font cross-platform approach shared by
+ /// Jetchat/Reply). Names follow the gold's icon usages (utils/JetnewsIcons.kt + the
+ /// material icons the screens reference directly).
+ ///
+ static class JetNewsIcons
+ {
+ public const string Font = "Material Icons";
+
+ static bool _registered;
+
+ public static void Register()
+ {
+ if (_registered)
+ return;
+ _registered = true;
+
+ var map = new Dictionary();
+ void Add(string name, int codepoint) => map[name] = char.ConvertFromUtf32(codepoint);
+
+ Add("menu", 0xE5D2); // drawer affordance (gold uses the brand icon image; menu as fallback)
+ Add("search", 0xE8B6); // app bar search
+ Add("arrow_back", 0xE5C4); // article top bar
+ Add("home", 0xE88A); // drawer Home
+ Add("list_alt", 0xE0EE); // drawer Interests (ListAlt)
+ Add("bookmark", 0xE866); // saved post (filled)
+ Add("bookmark_border", 0xE867); // unsaved post
+ Add("thumb_up_offalt", 0xE8DC); // article action bar like (ThumbUpOffAlt)
+ Add("share", 0xE80D); // article action bar share
+ Add("text_format", 0xE165); // article action bar text settings (FormatSize glyph)
+ Add("more_vert", 0xE5D4); // history row overflow
+ Add("add", 0xE145); // interests unselected toggle
+ Add("check", 0xE5CA); // interests selected toggle
+ Add("account_circle", 0xE853); // article author avatar placeholder
+ Add("android", 0xE859); // publication badge stand-in (gold: icon_post_background vector)
+
+ IconFont.Register(Font, map);
+ }
+ }
+}
diff --git a/src/Comet/sample/Shared/JetNews/JetNewsInterests.cs b/src/Comet/sample/Shared/JetNews/JetNewsInterests.cs
new file mode 100644
index 000000000..d50e07598
--- /dev/null
+++ b/src/Comet/sample/Shared/JetNews/JetNewsInterests.cs
@@ -0,0 +1,174 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.JetNews.JetNewsTheme;
+
+namespace CometSamples.JetNews
+{
+ ///
+ /// The Interests screen (InterestsScreen.kt): center top bar, REAL PrimaryTabRow
+ /// (Topics / People / Publications), and per-tab selectable topic lists.
+ /// Gold renders the lists as Column+verticalScroll; here they're ListViews (LazyColumn)
+ /// so toggles re-pull rows — documented deviation, visually identical on compact.
+ ///
+ public static class JetNewsInterests
+ {
+ // FakeInterestsRepository verbatim.
+ static readonly IReadOnlyList<(string Section, IReadOnlyList Topics)> Topics = new (string, IReadOnlyList)[]
+ {
+ ("Android", new[] { "Jetpack Compose", "Kotlin", "Jetpack" }),
+ ("Programming", new[] { "Kotlin", "Declarative UIs", "Java", "Unidirectional Data Flow", "C++" }),
+ ("Technology", new[] { "Pixel", "Google" }),
+ };
+ static readonly IReadOnlyList People = new[]
+ {
+ "Kobalt Toral", "K'Kola Uvarek", "Kris Vriloc", "Grala Valdyr", "Kruel Valaxar",
+ "L'Elij Venonn", "Kraag Solazarn", "Tava Targesh", "Kemarrin Muuda",
+ };
+ static readonly IReadOnlyList Publications = new[]
+ {
+ "Kotlin Vibe", "Compose Mix", "Compose Breakdown", "Android Pursue", "Kotlin Watchman",
+ "Jetpack Ark", "Composeshack", "Jetpack Point", "Compose Tribune",
+ };
+
+ static readonly Signal TabIndex = new(0);
+ static readonly HashSet Selected = new(); // keyed "{section}|{topic}" / name
+ static readonly List> Lists = new();
+
+ static void Toggle(string key)
+ {
+ if (!Selected.Add(key))
+ Selected.Remove(key);
+ foreach (var list in Lists)
+ list.ReloadData();
+ }
+
+ abstract record Row;
+ sealed record SectionRow(string Title) : Row;
+ sealed record TopicRow(string Key, string Title) : Row;
+
+ static Text Tx(string s) => new Text(s).FontFamily("Montserrat");
+
+ public static View Screen(double topInset, System.Action openDrawer)
+ {
+ JetNewsIcons.Register();
+ Lists.Clear();
+
+ var tabs = new TabBar(TabIndex, new[] { "Topics", "People", "Publications" },
+ selectedColor: T.Primary, unselectedColor: T.OnSurface.WithAlpha(0.8f),
+ fontFamily: "Montserrat");
+
+ var content = new ContentSwitcher(TabIndex, new View[]
+ {
+ TabList(TopicsRows()),
+ TabList(NamesRows(People)),
+ TabList(NamesRows(Publications)),
+ });
+
+ return new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: (float)topInset).FlexShrink(0),
+ TopBar(openDrawer).FlexShrink(0),
+ tabs.FlexShrink(0),
+ new HStack().Frame(height: 1).Background(T.OnSurface.WithAlpha(0.1f))
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill).FlexShrink(0),
+ content.FlexGrow(1).FlexBasis(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.Background);
+ }
+
+ // CenterAlignedTopAppBar: nav = brand logo (menu stand-in, like Home), centered
+ // "Interests" titleLarge tinted primary, search action.
+ static View TopBar(System.Action openDrawer) => new HStack(spacing: 0f)
+ {
+ new Icon("jetnews_logo").IconSize(24).Color(T.Primary)
+ .Frame(width: 48, height: 48).Padding(new Thickness(12))
+ .FlexShrink(0)
+ .OnTap(_ => openDrawer()),
+ new HStack().FlexGrow(1),
+ Tx("Interests").FontSize(22).Color(T.Primary)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ new Icon("search").IconSize(24).Color(T.OnSurfaceVariant)
+ .Frame(width: 48, height: 48).Padding(new Thickness(12))
+ .FlexShrink(0),
+ }.Frame(height: 64);
+
+ static IReadOnlyList TopicsRows()
+ {
+ var rows = new List();
+ foreach (var (section, topics) in Topics)
+ {
+ rows.Add(new SectionRow(section));
+ foreach (var topic in topics)
+ rows.Add(new TopicRow($"{section}|{topic}", topic));
+ }
+ return rows;
+ }
+
+ static IReadOnlyList NamesRows(IReadOnlyList names)
+ {
+ var rows = new List { new SectionRow("") }; // gold pads the list top 16
+ foreach (var name in names)
+ rows.Add(new TopicRow(name, name));
+ return rows;
+ }
+
+ static View TabList(IReadOnlyList rows)
+ {
+ var list = new ListView(() => rows)
+ {
+ ViewFor = r => r switch
+ {
+ SectionRow s when s.Title.Length > 0 =>
+ Tx(s.Title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface)
+ .Padding(new Thickness(16)),
+ SectionRow => new HStack().Frame(height: 16),
+ TopicRow t => TopicItem(t),
+ _ => new HStack(),
+ },
+ };
+ Lists.Add(list);
+ return list;
+ }
+
+ // TopicItem: placeholder image 56 r4 | title titleMedium pad 16 | select toggle 36;
+ // divider inset start 72, v-pad 8 (baked into the row column).
+ static View TopicItem(TopicRow t) => new VStack(spacing: 0f)
+ {
+ new HStack(spacing: 0f)
+ {
+ new Image("placeholder_1_1").Frame(width: 56, height: 56).CornerRadius(4)
+ .FlexShrink(0),
+ Tx(t.Title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface)
+ .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading)
+ .Padding(new Thickness(16)).FlexGrow(1).FlexBasis(0)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().Frame(width: 16),
+ SelectTopicButton(Selected.Contains(t.Key))
+ .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0),
+ },
+ new HStack().Frame(height: 1).Background(T.OnSurface.WithAlpha(0.1f))
+ .Margin(new Thickness(56, 8, 0, 8))
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill),
+ }
+ .Padding(new Thickness(16, 0, 16, 0))
+ .OnTap(_ => Toggle(t.Key));
+
+ // SelectTopicButton.kt: 36dp circle Surface — unselected: onPrimary bg, onSurface@10%
+ // border, primary "+"; selected: primary bg, primary border, onPrimary "✓".
+ static View SelectTopicButton(bool selected) =>
+ new Icon(selected ? "check" : "add").IconSize(20)
+ .Color(selected ? T.OnPrimary : T.Primary)
+ .Frame(width: 36, height: 36)
+ .Background(selected ? T.Primary : T.OnPrimary)
+ .CornerRadius(18)
+ .Border(1, selected ? T.Primary : T.OnSurface.WithAlpha(0.1f));
+ }
+}
diff --git a/src/Comet/sample/Shared/JetNews/JetNewsModel.cs b/src/Comet/sample/Shared/JetNews/JetNewsModel.cs
new file mode 100644
index 000000000..e294e2b97
--- /dev/null
+++ b/src/Comet/sample/Shared/JetNews/JetNewsModel.cs
@@ -0,0 +1,53 @@
+#nullable enable
+using System.Collections.Generic;
+
+namespace CometSamples.JetNews
+{
+ // Verbatim port of the gold model (model/Post.kt) — same names, same shape.
+ // Image ids become bundled resource NAMES (drawables on Android / bundle images on iOS).
+
+ public sealed record Post(
+ string Id,
+ string Title,
+ string? Subtitle,
+ string Url,
+ Publication? Publication,
+ Metadata Metadata,
+ IReadOnlyList Paragraphs,
+ string ImageId,
+ string ImageThumbId);
+
+ public sealed record Metadata(PostAuthor Author, string Date, int ReadTimeMinutes);
+
+ public sealed record PostAuthor(string Name, string? Url = null);
+
+ public sealed record Publication(string Name, string LogoUrl);
+
+ public sealed record Paragraph(ParagraphType Type, string Text, IReadOnlyList? Markups = null);
+
+ public sealed record Markup(MarkupType Type, int Start, int End, string? Href = null);
+
+ public enum MarkupType { Link, Code, Italic, Bold }
+
+ public enum ParagraphType { Title, Caption, Header, Subhead, Text, CodeBlock, Quote, Bullet }
+
+ // model/PostsFeed.kt
+ public sealed record PostsFeed(
+ Post HighlightedPost,
+ IReadOnlyList RecommendedPosts,
+ IReadOnlyList PopularPosts,
+ IReadOnlyList RecentPosts)
+ {
+ public IReadOnlyList AllPosts
+ {
+ get
+ {
+ var all = new List { HighlightedPost };
+ all.AddRange(RecommendedPosts);
+ all.AddRange(PopularPosts);
+ all.AddRange(RecentPosts);
+ return all;
+ }
+ }
+ }
+}
diff --git a/src/Comet/sample/Shared/JetNews/JetNewsRoot.cs b/src/Comet/sample/Shared/JetNews/JetNewsRoot.cs
new file mode 100644
index 000000000..16d3ec362
--- /dev/null
+++ b/src/Comet/sample/Shared/JetNews/JetNewsRoot.cs
@@ -0,0 +1,133 @@
+#nullable enable
+using System.Linq;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.JetNews.JetNewsTheme;
+
+namespace CometSamples.JetNews
+{
+ ///
+ /// JetNews chrome + routing over the adaptive primitives (the gold JetnewsApp):
+ /// NavigationSuite with the JetNews breakpoint policy (chromeless + modal drawer
+ /// below 840dp, REAL NavigationRail with selected-only labels above — JetnewsApp.kt's
+ /// isExpandedScreen), destinations = Home / Interests (real NavigationDrawerItems in
+ /// the sheet), and Home = ListDetail (compact: full-screen article with system-back;
+ /// expanded: feed | detail at the gold's ~⅓ split with a "Select a post" placeholder).
+ ///
+ public class JetNewsRoot : View
+ {
+ /// 0 = Home, 1 = Interests (the drawer/rail destinations).
+ public static readonly Signal SelectedDest = new(0);
+ public static readonly Signal ArticleOpen = new(false);
+ public static readonly Signal CurrentPost = new(JetNewsData.Posts.HighlightedPost);
+ public static readonly Signal DrawerOpen = new(false);
+
+ // Backend-written active chrome variant → derived indices for the variant-aware
+ // slots (compact app bar vs expanded search field; placeholder vs article).
+ static readonly Signal Variant = new((int)NavigationSuiteVariant.None);
+ static readonly Signal HomeChrome = new(0);
+ static readonly Signal DetailContent = new(0);
+
+ static JetNewsRoot()
+ {
+ Variant.PropertyChanged += (_, _) =>
+ HomeChrome.Value = Variant.Peek() == (int)NavigationSuiteVariant.Rail ? 1 : 0;
+ ArticleOpen.PropertyChanged += (_, _) =>
+ DetailContent.Value = ArticleOpen.Peek() ? 1 : 0;
+ // Selecting a destination dismisses an open article (the gold's PopUpTo reset) —
+ // without this, drawer → Home while an article is open lands on the stale article.
+ SelectedDest.PropertyChanged += (_, _) => ArticleOpen.Value = false;
+ }
+
+ public static void OpenPost(string id)
+ {
+ if (JetNewsData.Posts.AllPosts.FirstOrDefault(p => p.Id == id) is { } post)
+ {
+ CurrentPost.Value = post;
+ ArticleOpen.Value = true;
+ }
+ }
+
+ public JetNewsRoot() { }
+
+ static Text Tx(string s) => new Text(s).FontFamily("Montserrat");
+
+ [Body]
+ View body()
+ {
+ JetNewsIcons.Register();
+ JetNewsScreens.ResetFeedLists(); // a rebuild replaces the live lists — drop the old generation
+
+ // Home pane: compact = wordmark app bar; expanded = search field (gold
+ // HomeFeedWithArticleDetailsScreen swaps chrome the same way).
+ var homeList = new ContentSwitcher(HomeChrome, new View[]
+ {
+ JetNewsScreens.Home(topInset: 0, openDrawer: () => DrawerOpen.Value = true),
+ JetNewsScreens.ExpandedListPane(),
+ });
+
+ var detail = new ContentSwitcher(DetailContent, new View[]
+ {
+ SelectAPost(),
+ JetNewsArticle.Screen(CurrentPost, topInset: 0, onBack: () => ArticleOpen.Value = false),
+ });
+
+ var home = new ListDetail(ArticleOpen, homeList, detail, listFraction: 1 / 3.0);
+
+ var routes = new ContentSwitcher(SelectedDest, new View[]
+ {
+ home,
+ JetNewsInterests.Screen(topInset: 0, openDrawer: () => DrawerOpen.Value = true),
+ });
+
+ var items = new[]
+ {
+ // onSelect fires even when the index is unchanged — re-tapping Home
+ // still dismisses an open article (the equality-gated signal wouldn't).
+ new NavigationItem(new Icon("home").IconSize(24), Tx("Home").FontSize(14),
+ onSelect: () => ArticleOpen.Value = false),
+ new NavigationItem(new Icon("list_alt").IconSize(24), Tx("Interests").FontSize(14)),
+ };
+
+ return new NavigationSuite(SelectedDest, items, routes,
+ railHeader: RailHeader(), drawerHeader: DrawerHeader(),
+ drawerOpen: DrawerOpen,
+ // M3 tokens for the hand-composed iOS twin (Android's real widgets self-theme).
+ containerColor: T.Background, indicatorColor: T.SecondaryContainer,
+ // JetnewsApp.kt: expanded (≥840dp) = rail; below = drawer-only chrome.
+ variantFor: (w, _) => w >= 840 ? NavigationSuiteVariant.Rail : NavigationSuiteVariant.None,
+ railShowsSelectedLabel: true,
+ variantSignal: Variant)
+ .Background(T.Background);
+ }
+
+ // AppNavRail.kt header: the jetnews logo vector tinted primary, 12dp vertical padding.
+ static View RailHeader() =>
+ new Icon("jetnews_logo").IconSize(24).Color(T.Primary)
+ .Frame(width: 80, height: 48).Padding(new Thickness(28, 12, 28, 12));
+
+ // AppDrawer.kt JetNewsLogo row: logo (primary) + wordmark (onSurfaceVariant), 8dp gap.
+ static View DrawerHeader() => new HStack(spacing: 0f)
+ {
+ new Icon("jetnews_logo").IconSize(24).Color(T.Primary).FlexShrink(0),
+ new HStack().Frame(width: 8),
+ new Icon("jetnews_wordmark").Color(T.OnSurfaceVariant).IconFillFrame()
+ .Frame(width: 80, height: 24)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ }.Padding(new Thickness(28, 24, 28, 24));
+
+ // HomeScreens.kt: expanded detail placeholder — centered "Select a post".
+ static View SelectAPost() => new VStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ Tx("Select a post").FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.Background);
+ }
+}
diff --git a/src/Comet/sample/Shared/JetNews/JetNewsScreens.cs b/src/Comet/sample/Shared/JetNews/JetNewsScreens.cs
new file mode 100644
index 000000000..f31adeb8b
--- /dev/null
+++ b/src/Comet/sample/Shared/JetNews/JetNewsScreens.cs
@@ -0,0 +1,267 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.JetNews.JetNewsTheme;
+
+namespace CometSamples.JetNews
+{
+ ///
+ /// JetNews screens, values-from-source (file:line cites into the gold Kotlin app).
+ /// Home = HomeScreens.kt PostList (:423-443) — top section, simple section, popular
+ /// carousel, history section, dividers; cards = PostCards.kt / PostCardTop.kt /
+ /// PostCardYourNetwork.kt.
+ ///
+ public static class JetNewsScreens
+ {
+ // ── Bookmarks (gold favorites: Set in HomeViewModel) ──
+ static readonly Comet.Reactive.Signal SearchQuery = new(string.Empty);
+ static readonly HashSet Favorites = new();
+ static readonly List> FeedLists = new();
+
+ /// Root rebuild: drop the previous generation of feed lists so
+ /// ToggleFavorite stops reloading dead ones (and they can be collected).
+ internal static void ResetFeedLists() => FeedLists.Clear();
+ static void ToggleFavorite(string id)
+ {
+ if (!Favorites.Add(id))
+ Favorites.Remove(id);
+ foreach (var list in FeedLists)
+ list.ReloadData();
+ }
+
+ public static void OpenPost(string id) => JetNewsRoot.OpenPost(id);
+
+ // Gold typography (Type.kt defaultTextStyle): Montserrat for EVERY style — already
+ // registered in both probes (Jetchat ships the same family).
+ static Text Tx(string s) => new Text(s).FontFamily("Montserrat");
+
+ // ── Home feed rows: the gold's LazyColumn sections flattened into one list
+ // (PostList :423-443 — top / simple×3+divider / popular / history). ──
+ abstract record HomeRow;
+ sealed record TopSectionRow(Post Post) : HomeRow;
+ sealed record SimpleRow(Post Post) : HomeRow;
+ sealed record PopularSectionRow(IReadOnlyList Posts) : HomeRow;
+ sealed record HistoryRow(Post Post) : HomeRow;
+ sealed record DividerRow : HomeRow;
+
+ static IReadOnlyList HomeRows()
+ {
+ var feed = JetNewsData.Posts;
+ var rows = new List { new TopSectionRow(feed.HighlightedPost), new DividerRow() };
+ foreach (var p in feed.RecommendedPosts)
+ {
+ rows.Add(new SimpleRow(p));
+ rows.Add(new DividerRow());
+ }
+ rows.Add(new PopularSectionRow(feed.PopularPosts));
+ rows.Add(new DividerRow());
+ foreach (var p in feed.RecentPosts)
+ {
+ rows.Add(new HistoryRow(p));
+ rows.Add(new DividerRow());
+ }
+ return rows;
+ }
+
+ /// The compact Home screen: center-aligned top app bar + the feed.
+ /// clears the status bar (no NavigationSuite here —
+ /// JetNews chrome is drawer-first; the drawer lands in the next increment).
+ static ListView FeedList()
+ {
+ var list = new ListView(HomeRows)
+ {
+ ViewFor = r => r switch
+ {
+ TopSectionRow t => TopSection(t.Post),
+ SimpleRow s => PostCardSimple(s.Post),
+ PopularSectionRow p => PopularSection(p.Posts),
+ HistoryRow h => PostCardHistory(h.Post),
+ _ => Divider(),
+ },
+ };
+ FeedLists.Add(list);
+ return list;
+ }
+
+ public static View Home(double topInset, System.Action? openDrawer = null)
+ {
+ JetNewsIcons.Register();
+
+ return new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: (float)topInset).FlexShrink(0),
+ HomeTopAppBar(openDrawer).FlexShrink(0),
+ FeedList().FlexGrow(1).FlexBasis(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.Background);
+ }
+
+ /// The expanded home LIST pane (HomeFeedWithArticleDetailsScreen): the REAL
+ /// OutlinedTextField search (submit toast-stubbed in the gold) above the same feed.
+ public static View ExpandedListPane()
+ {
+ JetNewsIcons.Register();
+
+ // The REAL Material OutlinedTextField (gold HomeSearch:578): accepts input;
+ // submit is toast-stubbed in the gold. Android drives the exact widget; the
+ // iOS twin renders Comet's standard field (documented deviation).
+ var field = SignalExtensions.TextField(SearchQuery, "Search posts")
+ .Outlined().LeadingIcon("search")
+ .Frame(height: 56)
+ .Margin(new Thickness(16, 8, 16, 8))
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill);
+
+ return new VStack(spacing: 0f)
+ {
+ field.FlexShrink(0),
+ FeedList().FlexGrow(1).FlexBasis(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.Background);
+ }
+
+ // Gold HomeTopAppBar: CenterAlignedTopAppBar — brand logo nav slot, centered
+ // wordmark VECTOR tinted primary, search action (HomeScreens.kt:630-660).
+ static View HomeTopAppBar(System.Action? openDrawer) => new HStack(spacing: 0f)
+ {
+ new Icon("jetnews_logo").IconSize(24).Color(T.Primary)
+ .Frame(width: 48, height: 48).Padding(new Thickness(12))
+ .FlexShrink(0)
+ .OnTap(_ => openDrawer?.Invoke()),
+ new HStack().FlexGrow(1),
+ new Icon("jetnews_wordmark").Color(T.Primary).IconFillFrame()
+ .Frame(width: 80, height: 24)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ new Icon("search").IconSize(24).Color(T.OnSurfaceVariant)
+ .Frame(width: 48, height: 48).Padding(new Thickness(12))
+ .FlexShrink(0),
+ }.Frame(height: 64);
+
+ // ── Top section (HomeScreens.kt:468-479 + PostCardTop.kt): section title
+ // titleMedium pad s16/t16/e16; card column pad 16 — image minH 180 rounded,
+ // spacer 16, title titleLarge padB 8, author labelLarge padB 4,
+ // date-readtime bodySmall onSurfaceVariant. ──
+ static View TopSection(Post post) => new VStack(spacing: 0f)
+ {
+ Tx("Top stories for you").FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface)
+ .Padding(new Thickness(16, 16, 16, 0)),
+ new VStack(spacing: 0f)
+ {
+ new Image(post.ImageId).Frame(height: 180).CornerRadius(8)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill),
+ new HStack().Frame(height: 16),
+ Tx(post.Title).FontSize(22).Color(T.OnSurface)
+ .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading)
+ .Padding(new Thickness(0, 0, 0, 8)),
+ Tx(post.Metadata.Author.Name).FontSize(14).FontWeight(FontWeight.Medium).Color(T.OnSurface)
+ .Padding(new Thickness(0, 0, 0, 4)),
+ Tx($"{post.Metadata.Date} - {post.Metadata.ReadTimeMinutes} min read")
+ .FontSize(12).Color(T.OnSurfaceVariant),
+ }.Padding(new Thickness(16)),
+ }.OnTap(_ => OpenPost(post.Id));
+
+ // ── Simple row (PostCards.kt:~85-128): image 40 pad 16 | column v10 (title
+ // titleMedium, author-readtime bodyMedium) | bookmark toggle pad v2/h6. ──
+ static View PostCardSimple(Post post) => new HStack(spacing: 0f)
+ {
+ new Image(post.ImageThumbId).Frame(width: 40, height: 40).CornerRadius(4)
+ .Margin(new Thickness(16)).FlexShrink(0),
+ new VStack(spacing: 2f)
+ {
+ Tx(post.Title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface)
+ .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading),
+ Tx($"{post.Metadata.Author.Name} - {post.Metadata.ReadTimeMinutes} min read")
+ .FontSize(14).Color(T.OnSurfaceVariant),
+ }.Padding(new Thickness(0, 10, 0, 10)).FlexGrow(1).FlexBasis(0)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ BookmarkButton(post).FlexShrink(0),
+ }.OnTap(_ => OpenPost(post.Id));
+
+ // The REAL M3 IconToggleButton (gold utils/JetnewsIcons.kt BookmarkButton) —
+ // checked + onCheckedChange; rows rebuild with the new glyph via ReloadData.
+ static View BookmarkButton(Post post) =>
+ new IconToggleButton(Favorites.Contains(post.Id), _ => ToggleFavorite(post.Id),
+ new Icon(Favorites.Contains(post.Id) ? "bookmark" : "bookmark_border")
+ .IconSize(24).Color(T.OnSurfaceVariant))
+ .Margin(new Thickness(6, 2, 6, 2))
+ .VerticalLayoutAlignment(LayoutAlignment.Center);
+
+ // ── Popular section (HomeScreens.kt:516-541): title titleLarge pad 16; horizontal
+ // card row pad h16 spacing 8 (gold: Row+horizontalScroll; a horizontal ListView is
+ // the node-backend equivalent); spacer 16. Card (PostCardYourNetwork.kt:54-97):
+ // width 280 shape medium — image h100 fill, column pad 16 (title headlineSmall
+ // max2, author bodyMedium max1, date-readtime bodySmall). ──
+ static View PopularSection(IReadOnlyList posts)
+ {
+ var carousel = new ListView(() => posts)
+ {
+ Horizontal = true,
+ ViewFor = PostCardPopular,
+ };
+ return new VStack(spacing: 0f)
+ {
+ Tx("Popular on Jetnews").FontSize(22).Color(T.OnSurface)
+ .Padding(new Thickness(16)),
+ carousel.Frame(height: 250).Margin(left: 16)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill),
+ new HStack().Frame(height: 16),
+ };
+ }
+
+ static View PostCardPopular(Post post) => new VStack(spacing: 0f)
+ {
+ new Image(post.ImageId).Frame(width: 280, height: 100),
+ new VStack(spacing: 0f)
+ {
+ Tx(post.Title).FontSize(24).Color(T.OnSurface)
+ .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading).MaxLines(2),
+ Tx(post.Metadata.Author.Name).FontSize(14).Color(T.OnSurface)
+ .MaxLines(1).Padding(new Thickness(0, 4, 0, 0)),
+ Tx($"{post.Metadata.Date} - {post.Metadata.ReadTimeMinutes} min read")
+ .FontSize(12).Color(T.OnSurfaceVariant),
+ }.Padding(new Thickness(16)),
+ }
+ .Frame(width: 280)
+ // The REAL M3 Card (clickable — gold PostCardYourNetwork.kt:55): self-themed
+ // containerColor (surfaceContainerLow) + elevation on Android; the iOS twin has no
+ // Card widget, so the tokens stay for the hand-composed side.
+ .AsCard()
+ .Background(T.SurfaceContainerLow).CornerRadius(4)
+ .Margin(right: 8)
+ .OnTap(_ => OpenPost(post.Id));
+
+ // ── History row (PostCards.kt:131-160): image 40 pad 16 | column v12 (overline
+ // labelMedium "BASED ON YOUR HISTORY", title, author-readtime pad t4) | more_vert. ──
+ static View PostCardHistory(Post post) => new HStack(spacing: 0f)
+ {
+ new Image(post.ImageThumbId).Frame(width: 40, height: 40).CornerRadius(4)
+ .Margin(new Thickness(16)).FlexShrink(0),
+ new VStack(spacing: 2f)
+ {
+ Tx("BASED ON YOUR HISTORY").FontSize(12).FontWeight(FontWeight.Medium)
+ .Color(T.OnSurfaceVariant),
+ Tx(post.Title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.OnSurface)
+ .LineBreakMode(LineBreakMode.WordWrap).LineBreak(TextLineBreak.Heading),
+ Tx($"{post.Metadata.Author.Name} - {post.Metadata.ReadTimeMinutes} min read")
+ .FontSize(14).Color(T.OnSurfaceVariant).Padding(new Thickness(0, 4, 0, 0)),
+ }.Padding(new Thickness(0, 12, 0, 12)).FlexGrow(1).FlexBasis(0),
+ new Icon("more_vert").IconSize(24).Color(T.OnSurfaceVariant)
+ .Frame(width: 48, height: 48).Margin(new Thickness(0, 12, 4, 0)).FlexShrink(0),
+ }.OnTap(_ => OpenPost(post.Id));
+
+ // Gold PostListDivider: 1dp, pad h14, onSurface 12% (HomeScreens.kt tail).
+ static View Divider() => new HStack()
+ .Frame(height: 1)
+ .Margin(left: 14, right: 14)
+ .Background(T.Divider)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill);
+ }
+}
diff --git a/src/Comet/sample/Shared/JetNews/JetNewsTheme.cs b/src/Comet/sample/Shared/JetNews/JetNewsTheme.cs
new file mode 100644
index 000000000..ead920518
--- /dev/null
+++ b/src/Comet/sample/Shared/JetNews/JetNewsTheme.cs
@@ -0,0 +1,89 @@
+#nullable enable
+using Microsoft.Maui.Graphics;
+
+namespace CometSamples.JetNews
+{
+ ///
+ /// JetNews theme. The gold app uses DYNAMIC color unconditionally on S+
+ /// (Theme.kt:90-97), so the gold screenshots carry the capture emulator's
+ /// wallpaper-derived Material You scheme. We regenerate that scheme with the SAME
+ /// algorithm (material-color-utilities, the Jetchat generator path) from the seed
+ /// sampled off the gold captures (tab indicator / primary ≈ #475D92), so both
+ /// backends render the identical palette the golds show.
+ ///
+ public static class JetNewsTheme
+ {
+ /// Sampled from the gold captures (Interests tab indicator = primary).
+ public static readonly Color Seed = Color.FromArgb("#475D92");
+
+ // Order matters: static fields initialize in source order, and S's builder reads Core.
+ static readonly MaterialColorUtilities.Palettes.CorePalette Core =
+ MaterialColorUtilities.Palettes.CorePalette.Of(ToArgbUint(Seed));
+
+ static readonly MaterialColorUtilities.Schemes.Scheme S = BuildScheme();
+
+ static MaterialColorUtilities.Schemes.Scheme BuildScheme() =>
+ new MaterialColorUtilities.Schemes.LightSchemeMapper().Map(Core);
+
+ public static readonly Color Primary = C(S.Primary);
+ public static readonly Color OnPrimary = C(S.OnPrimary);
+ public static readonly Color PrimaryContainer = C(S.PrimaryContainer);
+ public static readonly Color OnPrimaryContainer = C(S.OnPrimaryContainer);
+ public static readonly Color Secondary = C(S.Secondary);
+ public static readonly Color SecondaryContainer = C(S.SecondaryContainer);
+ public static readonly Color OnSecondaryContainer = C(S.OnSecondaryContainer);
+ public static readonly Color Background = C(S.Background);
+ public static readonly Color OnBackground = C(S.OnBackground);
+ public static readonly Color Surface = C(S.Surface);
+ public static readonly Color OnSurface = C(S.OnSurface);
+ public static readonly Color SurfaceVariant = C(S.SurfaceVariant);
+ public static readonly Color OnSurfaceVariant = C(S.OnSurfaceVariant);
+ public static readonly Color Outline = C(S.Outline);
+ public static readonly Color InverseOnSurface = C(S.InverseOnSurface);
+
+ // M3 surface-container roles (neutral tones 100/96/94/92/90 in light schemes) —
+ // the 0.3.0 scheme mapper predates these tokens, so derive them from the palette.
+ // M3 Card's DEFAULT containerColor is SurfaceContainerLow (gold popular cards).
+ public static readonly Color SurfaceContainerLowest = C(Core.Neutral[100]);
+ public static readonly Color SurfaceContainerLow = C(Core.Neutral[96]);
+ public static readonly Color SurfaceContainer = C(Core.Neutral[94]);
+ public static readonly Color SurfaceContainerHigh = C(Core.Neutral[92]);
+ public static readonly Color SurfaceContainerHighest = C(Core.Neutral[90]);
+
+ /// onSurface @ 12% — the gold's list dividers (DividerDefaults).
+ public static readonly Color Divider = C(S.OnSurface).WithAlpha(0.12f);
+
+ /// onSurface @ 15% — code spans/blocks (PostContent.kt codeBlockBackground).
+ public static readonly Color CodeBlockBackground = C(S.OnSurface).WithAlpha(0.15f);
+
+#if ANDROID
+ /// The same scheme as a Compose ColorScheme, so the REAL M3 widgets
+ /// (TabRow, drawer sheets, snackbar) are seeded identically (the Reply pattern).
+ public static AndroidX.Compose.Material3.ColorScheme ComposeScheme()
+ {
+ static AndroidX.Compose.Color C(Color c) => AndroidX.Compose.Color.FromArgb(
+ (byte)(c.Alpha * 255), (byte)(c.Red * 255), (byte)(c.Green * 255), (byte)(c.Blue * 255));
+ return AndroidX.Compose.MaterialTheme.LightColorScheme(
+ primary: C(Primary), onPrimary: C(OnPrimary),
+ primaryContainer: C(PrimaryContainer), onPrimaryContainer: C(OnPrimaryContainer),
+ secondary: C(Secondary),
+ secondaryContainer: C(SecondaryContainer), onSecondaryContainer: C(OnSecondaryContainer),
+ background: C(Background), onBackground: C(OnBackground),
+ surface: C(Surface), onSurface: C(OnSurface),
+ surfaceVariant: C(SurfaceVariant), onSurfaceVariant: C(OnSurfaceVariant),
+ inverseOnSurface: C(InverseOnSurface),
+ outline: C(Outline),
+ surfaceContainerLowest: C(SurfaceContainerLowest),
+ surfaceContainerLow: C(SurfaceContainerLow),
+ surfaceContainer: C(SurfaceContainer),
+ surfaceContainerHigh: C(SurfaceContainerHigh),
+ surfaceContainerHighest: C(SurfaceContainerHighest));
+ }
+#endif
+
+ static Color C(uint argb) => Color.FromUint(argb | 0xFF000000);
+ static uint ToArgbUint(Color c) =>
+ ((uint)(c.Alpha * 255) << 24) | ((uint)(c.Red * 255) << 16) |
+ ((uint)(c.Green * 255) << 8) | (uint)(c.Blue * 255);
+ }
+}
diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-01-home.png b/src/Comet/sample/Shared/JetNews/gold/compact-01-home.png
new file mode 100644
index 000000000..7f2d71874
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-01-home.png differ
diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-02-home-scrolled.png b/src/Comet/sample/Shared/JetNews/gold/compact-02-home-scrolled.png
new file mode 100644
index 000000000..8e46ac9b2
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-02-home-scrolled.png differ
diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-03-drawer.png b/src/Comet/sample/Shared/JetNews/gold/compact-03-drawer.png
new file mode 100644
index 000000000..0edd92dab
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-03-drawer.png differ
diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-04-interests.png b/src/Comet/sample/Shared/JetNews/gold/compact-04-interests.png
new file mode 100644
index 000000000..72e84b529
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-04-interests.png differ
diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-05-interests-selected.png b/src/Comet/sample/Shared/JetNews/gold/compact-05-interests-selected.png
new file mode 100644
index 000000000..72e84b529
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-05-interests-selected.png differ
diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-06-interests-people.png b/src/Comet/sample/Shared/JetNews/gold/compact-06-interests-people.png
new file mode 100644
index 000000000..82b0b6e8b
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-06-interests-people.png differ
diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-07-article.png b/src/Comet/sample/Shared/JetNews/gold/compact-07-article.png
new file mode 100644
index 000000000..81637a842
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-07-article.png differ
diff --git a/src/Comet/sample/Shared/JetNews/gold/compact-08-article-scrolled.png b/src/Comet/sample/Shared/JetNews/gold/compact-08-article-scrolled.png
new file mode 100644
index 000000000..2baa36ebc
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/compact-08-article-scrolled.png differ
diff --git a/src/Comet/sample/Shared/JetNews/gold/expanded-1260dp-01.png b/src/Comet/sample/Shared/JetNews/gold/expanded-1260dp-01.png
new file mode 100644
index 000000000..ceedb815d
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/expanded-1260dp-01.png differ
diff --git a/src/Comet/sample/Shared/JetNews/gold/medium-700dp-01-rail.png b/src/Comet/sample/Shared/JetNews/gold/medium-700dp-01-rail.png
new file mode 100644
index 000000000..81ee2508f
Binary files /dev/null and b/src/Comet/sample/Shared/JetNews/gold/medium-700dp-01-rail.png differ
diff --git a/src/Comet/sample/Shared/Jetcaster/JetcasterFeedParser.cs b/src/Comet/sample/Shared/Jetcaster/JetcasterFeedParser.cs
new file mode 100644
index 000000000..7797417ed
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetcaster/JetcasterFeedParser.cs
@@ -0,0 +1,100 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Xml.Linq;
+
+namespace CometSamples.Jetcaster
+{
+ ///
+ /// RSS 2.0 + iTunes-namespace feed parser — the C# twin of the gold's ROME
+ /// SyndFeed mapping (PodcastFetcher.kt:120-157: title/description/author/image
+ /// from the iTunes module with RSS fallbacks; episodes keyed by guid). Pure
+ /// (stream in → records out) so fixture and live modes share it.
+ ///
+ public static class JetcasterFeedParser
+ {
+ static readonly XNamespace Itunes = "http://www.itunes.com/dtds/podcast-1.0.dtd";
+
+ public static PodcastWithEpisodes Parse(System.IO.Stream stream)
+ {
+ var doc = XDocument.Load(stream);
+ var channel = doc.Root?.Element("channel")
+ ?? throw new FormatException("not an RSS feed (no )");
+
+ string podcastUri = Value(channel.Element("link")) ?? Value(channel.Element("title"))
+ ?? throw new FormatException("feed has no link/title");
+
+ var podcast = new Podcast(
+ Uri: podcastUri,
+ Title: Value(channel.Element("title")) ?? "",
+ Description: Value(channel.Element(Itunes + "summary")) ?? Value(channel.Element("description")),
+ Author: Value(channel.Element(Itunes + "author")),
+ ImageUrl: channel.Element(Itunes + "image")?.Attribute("href")?.Value
+ ?? Value(channel.Element("image")?.Element("url")),
+ Copyright: Value(channel.Element("copyright")));
+
+ // The gold reads the iTunes category tree (nested ).
+ var categories = channel.Elements(Itunes + "category")
+ .SelectMany(c => new[] { c }.Concat(c.Elements(Itunes + "category")))
+ .Select(c => c.Attribute("text")?.Value)
+ .Where(t => !string.IsNullOrEmpty(t))
+ .Select(t => new Category(t!))
+ .DistinctBy(c => c.Name)
+ .ToArray();
+
+ var episodes = channel.Elements("item").Select(item => new Episode(
+ Uri: Value(item.Element("guid")) ?? Value(item.Element("link")) ?? Guid.NewGuid().ToString(),
+ PodcastUri: podcastUri,
+ Title: Value(item.Element("title")) ?? "",
+ Subtitle: Value(item.Element(Itunes + "subtitle")),
+ Summary: Value(item.Element(Itunes + "summary")) ?? Value(item.Element("description")),
+ Author: Value(item.Element(Itunes + "author")),
+ Published: ParseDate(Value(item.Element("pubDate"))),
+ Duration: ParseDuration(Value(item.Element(Itunes + "duration")))))
+ .OrderByDescending(e => e.Published)
+ .ToArray();
+
+ return new PodcastWithEpisodes(podcast, episodes, categories);
+ }
+
+ static string? Value(XElement? e)
+ {
+ var v = e?.Value.Trim();
+ return string.IsNullOrEmpty(v) ? null : v;
+ }
+
+ // RFC 1123-ish pubDate ("Sun, 12 Jul 2026 06:00:00 -0400" / "… GMT" / "… EST").
+ static DateTimeOffset ParseDate(string? s)
+ {
+ if (string.IsNullOrEmpty(s))
+ return default;
+ if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto))
+ return dto;
+ // Named zones DateTimeOffset can't parse (EST/PDT…) — strip and treat as UTC.
+ int lastSpace = s.LastIndexOf(' ');
+ if (lastSpace > 0 && DateTimeOffset.TryParse(s[..lastSpace], CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal, out dto))
+ return dto;
+ return default;
+ }
+
+ // itunes:duration: "HH:MM:SS", "MM:SS", or bare seconds.
+ static TimeSpan? ParseDuration(string? s)
+ {
+ if (string.IsNullOrEmpty(s))
+ return null;
+ var parts = s.Split(':');
+ return parts.Length switch
+ {
+ 1 when long.TryParse(parts[0], out var secs) => TimeSpan.FromSeconds(secs),
+ 2 when int.TryParse(parts[0], out var m) && int.TryParse(parts[1], out var sec) =>
+ new TimeSpan(0, m, sec),
+ 3 when int.TryParse(parts[0], out var h) && int.TryParse(parts[1], out var m2)
+ && int.TryParse(parts[2], out var s2) => new TimeSpan(h, m2, s2),
+ _ => null,
+ };
+ }
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetcaster/JetcasterHome.cs b/src/Comet/sample/Shared/Jetcaster/JetcasterHome.cs
new file mode 100644
index 000000000..43f4d556b
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetcaster/JetcasterHome.cs
@@ -0,0 +1,354 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Jetcaster.JetcasterTheme;
+
+namespace CometSamples.Jetcaster
+{
+ ///
+ /// Jetcaster Home (mobile ui/home/Home.kt), values-from-source: dark scheme over a
+ /// radial primary scrim, decorative M3 SearchBar top bar, ONE adaptive grid
+ /// (Adaptive(362dp)) switching Library/Discover on the floating Library|Discover
+ /// pill. Discover = category FilterChips + top-podcasts carousel + episode cards;
+ /// Library = followed carousel + Latest Episodes. The pill toolbar is hand-composed
+ /// until HorizontalFloatingToolbar binds (backlog); podcast-details pane and player
+ /// are the next increments.
+ ///
+ public class JetcasterRoot : View
+ {
+ /// false = Discover (the gold's default with no followed podcasts).
+ public static readonly Signal ShowLibrary = new(false);
+ public static readonly Signal SelectedCategory = new(string.Empty);
+
+ static ListView
This week, the team dives into R8, the whole-program optimizer that handles the "last mile" of your code before it runs on a device. Learn why implementing R8 is effectively like giving your users a "free hardware upgrade" by making their current devices perform like models that are years newer.
Chapters: 0:00 - Intro 0:23 - What is R8? 1:16 - R8 vs D8 1:40 - Tree Shaking & Dead Code 4:14 - Handling Reflection 10:10 - Performance Co-Optimization 13:03 - Kotlin-Specific Gains 16:22 - Real-World Impact 41:50 - Jetpack Compose & R8 50:51 - Keep annotations 1:03:49 - The "Free Hardware Upgrade"
]]>
+
+ Join hosts Tor and Romain as they sit down with Søren Gjesse, Chris Craik, and Shai Barack.
This week, the team dives into R8, the whole-program optimizer that handles the "last mile" of your code before it runs on a device. Learn why implementing R8 is effectively like giving your users a "free hardware upgrade" by making their current devices perform like models that are years newer.
Chapters: 0:00 - Intro 0:23 - What is R8? 1:16 - R8 vs D8 1:40 - Tree Shaking & Dead Code 4:14 - Handling Reflection 10:10 - Performance Co-Optimization 13:03 - Kotlin-Specific Gains 16:22 - Real-World Impact 41:50 - Jetpack Compose & R8 50:51 - Keep annotations 1:03:49 - The "Free Hardware Upgrade"
]]>
+
+
+
+ 01:06:30
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+ developers@android.com (Android Developers)Join hosts Tor and Romain as they sit down with Søren Gjesse, Chris Craik, and Shai Barack. This week, the team dives into R8, the whole-program optimizer that handles the "last mile" of your code before it runs on a device. Learn why implementing R8 is effectively like giving your users a "free hardware upgrade" by making their current devices perform like models that are years newer. Enable app optimization → https://goo.gle/R8-Optimizer Chapters: 0:00 - Intro 0:23 - What is R8? 1:16 - R8 vs D8 1:40 - Tree Shaking & Dead Code 4:14 - Handling Reflection 10:10 - Performance Co-Optimization 13:03 - Kotlin-Specific Gains 16:22 - Real-World Impact 41:50 - Jetpack Compose & R8 50:51 - Keep annotations 1:03:49 - The "Free Hardware Upgrade"Join hosts Tor and Romain as they sit down with Søren Gjesse, Chris Craik, and Shai Barack. This week, the team dives into R8, the whole-program optimizer that handles the "last mile" of your code before it runs on a device. Learn why implementing R8 is effectively like giving your users a "free hardware upgrade" by making their current devices perform like models that are years newer. Enable app optimization → https://goo.gle/R8-Optimizer Chapters: 0:00 - Intro 0:23 - What is R8? 1:16 - R8 vs D8 1:40 - Tree Shaking & Dead Code 4:14 - Handling Reflection 10:10 - Performance Co-Optimization 13:03 - Kotlin-Specific Gains 16:22 - Real-World Impact 41:50 - Jetpack Compose & R8 50:51 - Keep annotations 1:03:49 - The "Free Hardware Upgrade"
+
+ XR: Virtually here
+ XR: Virtually here
+ Wed, 14 Jan 2026 19:50:00 +0000
+
+
+ Join hosts Tor, Chet, and Romain as they sit down with Diego Perez (Android Studio) and Patrick Fuentes (Developer Relations) to explore the new frontiers of Android XR.
This episode of Android Developers Backstage breaks down the latest announcements and Developer Preview 3, offering a technical look at how developers can start building for spatial environments today.
Resources: Hello Android XR sample → https://goo.gle/3Nbnrre Build for AI Glasses with the Android XR SDK Developer Preview 3 → https://goo.gle/49lUjGr
Chapters: 0:00 - Intro 4:42 - Latest announcements 5:46 - AI Glasses 8:28 -Developer preview 3 16:13 - Spatialization 18:29 - Tools team perspective 24:07 - Using the XR Emulator 29:00 - What is an Orbiter? 37:33 - What hardware is currently available 38:39 - Final thoughts
]]>
+
+ Join hosts Tor, Chet, and Romain as they sit down with Diego Perez (Android Studio) and Patrick Fuentes (Developer Relations) to explore the new frontiers of Android XR.
This episode of Android Developers Backstage breaks down the latest announcements and Developer Preview 3, offering a technical look at how developers can start building for spatial environments today.
Resources: Hello Android XR sample → https://goo.gle/3Nbnrre Build for AI Glasses with the Android XR SDK Developer Preview 3 → https://goo.gle/49lUjGr
Chapters: 0:00 - Intro 4:42 - Latest announcements 5:46 - AI Glasses 8:28 -Developer preview 3 16:13 - Spatialization 18:29 - Tools team perspective 24:07 - Using the XR Emulator 29:00 - What is an Orbiter? 37:33 - What hardware is currently available 38:39 - Final thoughts
]]>
+
+
+
+ 43:30
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+ developers@android.com (Android Developers)Join hosts Tor, Chet, and Romain as they sit down with Diego Perez (Android Studio) and Patrick Fuentes (Developer Relations) to explore the new frontiers of Android XR. This episode of Android Developers Backstage breaks down the latest announcements and Developer Preview 3, offering a technical look at how developers can start building for spatial environments today. Resources: Hello Android XR sample → https://goo.gle/3Nbnrre Build for AI Glasses with the Android XR SDK Developer Preview 3 → https://goo.gle/49lUjGr Chapters: 0:00 - Intro 4:42 - Latest announcements 5:46 - AI Glasses 8:28 -Developer preview 3 16:13 - Spatialization 18:29 - Tools team perspective 24:07 - Using the XR Emulator 29:00 - What is an Orbiter? 37:33 - What hardware is currently available 38:39 - Final thoughtsJoin hosts Tor, Chet, and Romain as they sit down with Diego Perez (Android Studio) and Patrick Fuentes (Developer Relations) to explore the new frontiers of Android XR. This episode of Android Developers Backstage breaks down the latest announcements and Developer Preview 3, offering a technical look at how developers can start building for spatial environments today. Resources: Hello Android XR sample → https://goo.gle/3Nbnrre Build for AI Glasses with the Android XR SDK Developer Preview 3 → https://goo.gle/49lUjGr Chapters: 0:00 - Intro 4:42 - Latest announcements 5:46 - AI Glasses 8:28 -Developer preview 3 16:13 - Spatialization 18:29 - Tools team perspective 24:07 - Using the XR Emulator 29:00 - What is an Orbiter? 37:33 - What hardware is currently available 38:39 - Final thoughts
+
+ Bring your own model - Android Developers Backstage
+ Bring your own model - Android Developers Backstage
+ Wed, 10 Dec 2025 22:09:00 +0000
+
+
+ In this episode Tor and Chet chat with Raluca Saucic and Eugene Yakavets about the new "Bring Your Own Model" feature in Android Studio which allows using other models than Gemini for agent mode, including models from Anthropic, OpenAI, and more. We also discuss using local models via Ollama and LM Studio.
Chapters: 0:00 - Intro 0:49 - Bring your own model 4:13 - Bring your own key 7:24 - Anthropic model 13:17- How often do you need to remind the model of the context? 17:40 - Thinking vs planning 20:25 - What happens when the API needs to give multiple answers to a complex question? 24:14 - Supported models 31:18 - Are there any barriers when using a different model? 34:07 - Support for code completion 38:16 - GitHub's relationship with agents 44:35 - Using agents for development 56:36 - Studiobot 57:56 - Wrap up
]]>
+
+ In this episode Tor and Chet chat with Raluca Saucic and Eugene Yakavets about the new "Bring Your Own Model" feature in Android Studio which allows using other models than Gemini for agent mode, including models from Anthropic, OpenAI, and more. We also discuss using local models via Ollama and LM Studio.
Chapters: 0:00 - Intro 0:49 - Bring your own model 4:13 - Bring your own key 7:24 - Anthropic model 13:17- How often do you need to remind the model of the context? 17:40 - Thinking vs planning 20:25 - What happens when the API needs to give multiple answers to a complex question? 24:14 - Supported models 31:18 - Are there any barriers when using a different model? 34:07 - Support for code completion 38:16 - GitHub's relationship with agents 44:35 - Using agents for development 56:36 - Studiobot 57:56 - Wrap up
]]>
+
+
+
+ 58:26
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+ developers@android.com (Android Developers)In this episode Tor and Chet chat with Raluca Saucic and Eugene Yakavets about the new "Bring Your Own Model" feature in Android Studio which allows using other models than Gemini for agent mode, including models from Anthropic, OpenAI, and more. We also discuss using local models via Ollama and LM Studio. Resources: Use a remote model → https://goo.gle/43N2Z5E Use a local model → https://goo.gle/3Xij27V Chapters: 0:00 - Intro 0:49 - Bring your own model 4:13 - Bring your own key 7:24 - Anthropic model 13:17- How often do you need to remind the model of the context? 17:40 - Thinking vs planning 20:25 - What happens when the API needs to give multiple answers to a complex question? 24:14 - Supported models 31:18 - Are there any barriers when using a different model? 34:07 - Support for code completion 38:16 - GitHub's relationship with agents 44:35 - Using agents for development 56:36 - Studiobot 57:56 - Wrap up In this episode Tor and Chet chat with Raluca Saucic and Eugene Yakavets about the new "Bring Your Own Model" feature in Android Studio which allows using other models than Gemini for agent mode, including models from Anthropic, OpenAI, and more. We also discuss using local models via Ollama and LM Studio. Resources: Use a remote model → https://goo.gle/43N2Z5E Use a local model → https://goo.gle/3Xij27V Chapters: 0:00 - Intro 0:49 - Bring your own model 4:13 - Bring your own key 7:24 - Anthropic model 13:17- How often do you need to remind the model of the context? 17:40 - Thinking vs planning 20:25 - What happens when the API needs to give multiple answers to a complex question? 24:14 - Supported models 31:18 - Are there any barriers when using a different model? 34:07 - Support for code completion 38:16 - GitHub's relationship with agents 44:35 - Using agents for development 56:36 - Studiobot 57:56 - Wrap up
+
+ From natural language to UI tests: A deep dive into Journeys for Android Studio
+ From natural language to UI tests: A deep dive into Journeys for Android Studio
+ Tue, 14 Oct 2025 17:00:00 +0000
+
+
+ Hosts Tor and Chet are joined by Adarsh Fernando, a Product Manager, and Ray Buse, a Software Engineer, to discuss Journeys for Android Studio. Powered by Gemini's vision and reasoning, Journeys aims to simplify end-to-end test creation and maintenance by converting the natural language you provide to describe the steps and assertions for each test, resulting in actions and evaluations performed directly on your app.
Chapters: 0:00 - Intro 1:46 - Journeys: New AI-powered testing approach 3:40 - How Journeys Works with Gemini 4:27 - The natural language advantage 5:49 - Real-world use case: Google Maps 6:53 - Debugging with AI reasoning 8:08 - Why Journeys is important: Bridging the testing gap 9:56 - Journeys and End-to-End Testing 12:18 - Performance and Cached Journeys 24:14 - Android Studio and Firebase integration 25:27 - The development workflow 31:22 - AI for everyone: Beyond end-to-end testing 33:28 - Looking ahead: Feedback and the future
]]>
+
+ Hosts Tor and Chet are joined by Adarsh Fernando, a Product Manager, and Ray Buse, a Software Engineer, to discuss Journeys for Android Studio. Powered by Gemini's vision and reasoning, Journeys aims to simplify end-to-end test creation and maintenance by converting the natural language you provide to describe the steps and assertions for each test, resulting in actions and evaluations performed directly on your app.
Chapters: 0:00 - Intro 1:46 - Journeys: New AI-powered testing approach 3:40 - How Journeys Works with Gemini 4:27 - The natural language advantage 5:49 - Real-world use case: Google Maps 6:53 - Debugging with AI reasoning 8:08 - Why Journeys is important: Bridging the testing gap 9:56 - Journeys and End-to-End Testing 12:18 - Performance and Cached Journeys 24:14 - Android Studio and Firebase integration 25:27 - The development workflow 31:22 - AI for everyone: Beyond end-to-end testing 33:28 - Looking ahead: Feedback and the future
]]>
+
+
+
+ 44:57
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+ developers@android.com (Android Developers)Hosts Tor and Chet are joined by Adarsh Fernando, a Product Manager, and Ray Buse, a Software Engineer, to discuss Journeys for Android Studio. Powered by Gemini's vision and reasoning, Journeys aims to simplify end-to-end test creation and maintenance by converting the natural language you provide to describe the steps and assertions for each test, resulting in actions and evaluations performed directly on your app. Chapters: 0:00 - Intro 1:46 - Journeys: New AI-powered testing approach 3:40 - How Journeys Works with Gemini 4:27 - The natural language advantage 5:49 - Real-world use case: Google Maps 6:53 - Debugging with AI reasoning 8:08 - Why Journeys is important: Bridging the testing gap 9:56 - Journeys and End-to-End Testing 12:18 - Performance and Cached Journeys 24:14 - Android Studio and Firebase integration 25:27 - The development workflow 31:22 - AI for everyone: Beyond end-to-end testing 33:28 - Looking ahead: Feedback and the future Resources: Journeys for Android Studio → https://goo.gle/4m9YOr3 App Testing (Android) → https://goo.gle/3HVKTqB Tor on Bluesky → https://goo.gle/3ViCAYS Chet on Bluesky → https://goo.gle/4gzpccM Ardash on Bluesky → https://goo.gle/47JGNw9Hosts Tor and Chet are joined by Adarsh Fernando, a Product Manager, and Ray Buse, a Software Engineer, to discuss Journeys for Android Studio. Powered by Gemini's vision and reasoning, Journeys aims to simplify end-to-end test creation and maintenance by converting the natural language you provide to describe the steps and assertions for each test, resulting in actions and evaluations performed directly on your app. Chapters: 0:00 - Intro 1:46 - Journeys: New AI-powered testing approach 3:40 - How Journeys Works with Gemini 4:27 - The natural language advantage 5:49 - Real-world use case: Google Maps 6:53 - Debugging with AI reasoning 8:08 - Why Journeys is important: Bridging the testing gap 9:56 - Journeys and End-to-End Testing 12:18 - Performance and Cached Journeys 24:14 - Android Studio and Firebase integration 25:27 - The development workflow 31:22 - AI for everyone: Beyond end-to-end testing 33:28 - Looking ahead: Feedback and the future Resources: Journeys for Android Studio → https://goo.gle/4m9YOr3 App Testing (Android) → https://goo.gle/3HVKTqB Tor on Bluesky → https://goo.gle/3ViCAYS Chet on Bluesky → https://goo.gle/4gzpccM Ardash on Bluesky → https://goo.gle/47JGNw9
+
+ Android developer verification
+ Android developer verification
+ Tue, 30 Sep 2025 18:07:00 +0000
+
+
+ To protect users from malware and scams, Android has announced a new layer of security: developer verification. While this is nothing new for Google Play Store apps, Android is extending this to the entire Android ecosystem. Host Tor is joined by Matthew, Patrick, Naheed, and Raz to discuss this latest security update that deters bad actors and creates a safer environment.
]]>
+
+ To protect users from malware and scams, Android has announced a new layer of security: developer verification. While this is nothing new for Google Play Store apps, Android is extending this to the entire Android ecosystem. Host Tor is joined by Matthew, Patrick, Naheed, and Raz to discuss this latest security update that deters bad actors and creates a safer environment.
]]>
+
+
+
+ 45:22
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+ developers@android.com (Android Developers)To protect users from malware and scams, Android has announced a new layer of security: developer verification. While this is nothing new for Google Play Store apps, Android is extending this to the entire Android ecosystem. Host Tor is joined by Matthew, Patrick, Naheed, and Raz to discuss this latest security update that deters bad actors and creates a safer environment. To protect users from malware and scams, Android has announced a new layer of security: developer verification. While this is nothing new for Google Play Store apps, Android is extending this to the entire Android ecosystem. Host Tor is joined by Matthew, Patrick, Naheed, and Raz to discuss this latest security update that deters bad actors and creates a safer environment.
+
+ Autofill in Compose
+ Autofill in Compose
+ Wed, 13 Aug 2025 16:00:00 +0000
+
+
+ In this episode of Android Back Stage, Hosts Tor, and Chet are joined by Melba and Ralston, Software Engineers on the Compose Team, to talk about Autofill in Compose. Learn about Autofill services, best practices, and more!
Chapters: 0:00 - Intro 0:40 - What is Autofill? 1:21 - Autofill team journey and semantics 2:15 - Defining semantics in Compose 3:29 - Bridging views and Compose for Autofill 4:23 - Developer Experience: Early autofill in Compose 7:02 - Autofill services and communication flow 7:59 - User authentication and multiple autofill services 12:53 - The Autofill flow: From tap to fill 21:56 - Handling list types and saving credentials (commit) 30:11 - The "Asteriks" bug and semantics refractor 31:09 - Performance improvements in Autofill 37:48 - Compose's architectural advantages (composition over inheritance) 40:30 - Best practices and future improvements
]]>
+
+ In this episode of Android Back Stage, Hosts Tor, and Chet are joined by Melba and Ralston, Software Engineers on the Compose Team, to talk about Autofill in Compose. Learn about Autofill services, best practices, and more!
Chapters: 0:00 - Intro 0:40 - What is Autofill? 1:21 - Autofill team journey and semantics 2:15 - Defining semantics in Compose 3:29 - Bridging views and Compose for Autofill 4:23 - Developer Experience: Early autofill in Compose 7:02 - Autofill services and communication flow 7:59 - User authentication and multiple autofill services 12:53 - The Autofill flow: From tap to fill 21:56 - Handling list types and saving credentials (commit) 30:11 - The "Asteriks" bug and semantics refractor 31:09 - Performance improvements in Autofill 37:48 - Compose's architectural advantages (composition over inheritance) 40:30 - Best practices and future improvements
]]>
+
+
+
+ 44:22
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+ developers@android.com (Android Developers)In this episode of Android Back Stage, Hosts Tor, and Chet are joined by Melba and Ralston, Software Engineers on the Compose Team, to talk about Autofill in Compose. Learn about Autofill services, best practices, and more! Chapters: 0:00 - Intro 0:40 - What is Autofill? 1:21 - Autofill team journey and semantics 2:15 - Defining semantics in Compose 3:29 - Bridging views and Compose for Autofill 4:23 - Developer Experience: Early autofill in Compose 7:02 - Autofill services and communication flow 7:59 - User authentication and multiple autofill services 12:53 - The Autofill flow: From tap to fill 21:56 - Handling list types and saving credentials (commit) 30:11 - The "Asteriks" bug and semantics refractor 31:09 - Performance improvements in Autofill 37:48 - Compose's architectural advantages (composition over inheritance) 40:30 - Best practices and future improvements In this episode of Android Back Stage, Hosts Tor, and Chet are joined by Melba and Ralston, Software Engineers on the Compose Team, to talk about Autofill in Compose. Learn about Autofill services, best practices, and more! Chapters: 0:00 - Intro 0:40 - What is Autofill? 1:21 - Autofill team journey and semantics 2:15 - Defining semantics in Compose 3:29 - Bridging views and Compose for Autofill 4:23 - Developer Experience: Early autofill in Compose 7:02 - Autofill services and communication flow 7:59 - User authentication and multiple autofill services 12:53 - The Autofill flow: From tap to fill 21:56 - Handling list types and saving credentials (commit) 30:11 - The "Asteriks" bug and semantics refractor 31:09 - Performance improvements in Autofill 37:48 - Compose's architectural advantages (composition over inheritance) 40:30 - Best practices and future improvements
+
+ Back it up, bring it back: No data left behind
+ Back it up, bring it back: No data left behind
+ Thu, 10 Jul 2025 20:00:00 +0000
+
+
+ It can be a draw when setting up a new device. Some info transfers, while some don't. Learn how developers can ensure the best user experience when people backup and restore an Android device. Hosts Tor and Chet are joined by Graham and Piyush on the Android Consumer Experience team and Alon on the Android Studio team to discuss Android backup and restore.
Chapters:
0:00 - Introduction & the user pain point
1:38 - Why backup fails: Developer challenges
4:00 - Evolution of backup testing: From scripts to Android Studio
7:15 - Beyond testing: Backup for developer workflow
8:50 - Cross-device backup & GMS core integration
10:55 - Understanding backup types: Devices vs. Cloud
]]>
+
+ It can be a draw when setting up a new device. Some info transfers, while some don't. Learn how developers can ensure the best user experience when people backup and restore an Android device. Hosts Tor and Chet are joined by Graham and Piyush on the Android Consumer Experience team and Alon on the Android Studio team to discuss Android backup and restore.
Chapters:
0:00 - Introduction & the user pain point
1:38 - Why backup fails: Developer challenges
4:00 - Evolution of backup testing: From scripts to Android Studio
7:15 - Beyond testing: Backup for developer workflow
8:50 - Cross-device backup & GMS core integration
10:55 - Understanding backup types: Devices vs. Cloud
]]>
+
+
+
+ 48:50
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+ developers@android.com (Android Developers)It can be a draw when setting up a new device. Some info transfers, while some don't. Learn how developers can ensure the best user experience when people backup and restore an Android device. Hosts Tor and Chet are joined by Graham and Piyush on the Android Consumer Experience team and Alon on the Android Studio team to discuss Android backup and restore. Chapters: 0:00 - Introduction & the user pain point 1:38 - Why backup fails: Developer challenges 4:00 - Evolution of backup testing: From scripts to Android Studio 7:15 - Beyond testing: Backup for developer workflow 8:50 - Cross-device backup & GMS core integration 10:55 - Understanding backup types: Devices vs. Cloud 14:55 - Data categories & developer control 16:05 - System-level backup: Permissions & credentials 18:35 - Default backup pitfalls & key-value agents 28:15 - Database migrations & backup stress testing 32:04 - Automated testing framework 41:02 - Recap & feedback channelsIt can be a draw when setting up a new device. Some info transfers, while some don't. Learn how developers can ensure the best user experience when people backup and restore an Android device. Hosts Tor and Chet are joined by Graham and Piyush on the Android Consumer Experience team and Alon on the Android Studio team to discuss Android backup and restore. Chapters: 0:00 - Introduction & the user pain point 1:38 - Why backup fails: Developer challenges 4:00 - Evolution of backup testing: From scripts to Android Studio 7:15 - Beyond testing: Backup for developer workflow 8:50 - Cross-device backup & GMS core integration 10:55 - Understanding backup types: Devices vs. Cloud 14:55 - Data categories & developer control 16:05 - System-level backup: Permissions & credentials 18:35 - Default backup pitfalls & key-value agents 28:15 - Database migrations & backup stress testing 32:04 - Automated testing framework 41:02 - Recap & feedback channels
+
+ Bot to the Future: Two Years Later
+ Bot to the Future: Two Years Later
+ Fri, 13 Jun 2025 16:00:00 +0000
+
+
+ Join Tor and Siva Velusamy and Sandhya Mohan from the Android Studio team to learn about the latest updates to Studio Bot (Gemini in Android Studio) that dropped at Google I/O 2025 including Agent Mode. Building on their conversation from two years ago about the Studio Bot, they explore Gemini's role in every stage of development, from design to building, testing, and maintenance, the future of coding companions, and more.
]]>
+
+ Join Tor and Siva Velusamy and Sandhya Mohan from the Android Studio team to learn about the latest updates to Studio Bot (Gemini in Android Studio) that dropped at Google I/O 2025 including Agent Mode. Building on their conversation from two years ago about the Studio Bot, they explore Gemini's role in every stage of development, from design to building, testing, and maintenance, the future of coding companions, and more.
]]>
+
+
+
+ 46:27
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+ developers@android.com (Android Developers)Join Tor and Siva Velusamy and Sandhya Mohan from the Android Studio team to learn about the latest updates to Studio Bot (Gemini in Android Studio) that dropped at Google I/O 2025 including Agent Mode. Building on their conversation from two years ago about the Studio Bot, they explore Gemini's role in every stage of development, from design to building, testing, and maintenance, the future of coding companions, and more. Studio Bot podcast episode → https://goo.gle/3ZM0Wwx Join Tor and Siva Velusamy and Sandhya Mohan from the Android Studio team to learn about the latest updates to Studio Bot (Gemini in Android Studio) that dropped at Google I/O 2025 including Agent Mode. Building on their conversation from two years ago about the Studio Bot, they explore Gemini's role in every stage of development, from design to building, testing, and maintenance, the future of coding companions, and more. Studio Bot podcast episode → https://goo.gle/3ZM0Wwx
+
+ Kotlin Multiplatform: Have your code and eat it too
+ Kotlin Multiplatform: Have your code and eat it too
+ Wed, 07 May 2025 16:00:00 +0000
+
+
+ Software Engineers Dustin Lam and Yigit Boyar join host Tor Norbye to chat all about Kotlin Multiplatform.
]]>
+
+
+
+ 58:08
+ false
+
+
+
+
+
+ full
+
+
+
+ developers@android.com (Android Developers)Software Engineers Dustin Lam and Yigit Boyar join host Tor Norbye to chat all about Kotlin Multiplatform. Resources: Tor's Bluesky → https://goo.gle/4iLXEAg Yigit's Bluesky → https://goo.gle/4dmsYVjAndroid DevelopersSoftware Engineers Dustin Lam and Yigit Boyar join host Tor Norbye to chat all about Kotlin Multiplatform. Resources: Tor's Bluesky → https://goo.gle/4iLXEAg Yigit's Bluesky → https://goo.gle/4dmsYVj
+
+ Linting on the Side of Caution: Safety & Security
+ Linting on the Side of Caution: Safety & Security
+ Thu, 10 Apr 2025 21:48:00 +0000
+
+
+ What is app safety for Android? What is the Android team working on to ensure developers and users alike enjoy safe and reliable apps? Watch along as Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora take a deep dive backstage and discuss what's new in the Android safety space.
Speakers: Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora
]]>
+
+ What is app safety for Android? What is the Android team working on to ensure developers and users alike enjoy safe and reliable apps? Watch along as Tor Norbye, Chet Haase, Romain Guy, Theresa Khuu, Naheed Vora take a deep dive backstage and discuss what's new in the Android safety space.
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Tue, 14 Jul 2026 08:00:00 +0000
+ originals@stitcher.com (SiriusXM Podcasts and Roman Mars)
+ https://99percentinvisible.org/?p=48372
+
+ A Louisiana man born into slavery designs an airship with hopes to take flight. Those plans are interrupted, but maybe not forever.
+
Listen to Family Lore wherever you get your podcasts.
+
Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early.
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ If Mosquito Hawks Can Fly
+ SiriusXM Podcasts and Roman Mars
+
+ 00:41:56
+ A Louisiana man born into slavery designs an airship with hopes to take flight. Those plans are interrupted, but maybe not forever.
+ A Louisiana man born into slavery designs an airship with hopes to take flight. Those plans are interrupted, but maybe not forever.
+ aviation, airship, wright brothers, louisiana purchase exposition, black inventors, charles frederick page
+ false
+ full
+ 674
+
+
+ 1f30b8c1-5797-4088-b4d7-500efd13cc89
+ 100 Objects #8: Billy Possum
+ In 1902, an American classic was born: the teddy bear. Named after President Theodore Roosevelt, the toy became a huge hit, and sparked an idea…maybe every president from then on should have their own viral stuffed animal. Jon Mooallem tells the story of the ill-fated billy possum, and how our fickle feelings about animals have shaped the history—and perhaps the future—of our relationship with the natural world.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Fri, 10 Jul 2026 07:00:00 +0000
+ originals@stitcher.com (Jon Mooallem)
+ https://99percentinvisible.org/?p=48287
+
+ In 1902, an American classic was born: the teddy bear. Named after President Theodore Roosevelt, the toy became a huge hit, and sparked an idea…maybe every president from then on should have their own viral stuffed animal. Jon Mooallem tells the story of the ill-fated billy possum, and how our fickle feelings about animals have shaped the history—and perhaps the future—of our relationship with the natural world.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ 100 Objects #8: Billy Possum
+ Jon Mooallem
+
+ 00:36:10
+ The teddy bear was a huge hit, and sparked an idea…maybe every president from then on should have their own viral stuffed animal.
+ The teddy bear was a huge hit, and sparked an idea…maybe every president from then on should have their own viral stuffed animal.
+ theodore roosevelt, teddy bear, conservation, wildlife, william howard taft, billy possum
+ false
+ bonus
+
+
+ b4538be5-96af-4cce-8b63-b5b184b8a11b
+ The Score
+ We keep score of everything these days. But what happens when the numbers start changing what we actually care about?
+
Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early.
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Tue, 7 Jul 2026 08:00:00 +0000
+ originals@stitcher.com (C. Thi Nguyen)
+ https://99percentinvisible.org/?p=47305
+
+ We keep score of everything these days. But what happens when the numbers start changing what we actually care about?
+
Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early.
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ The Score
+ C. Thi Nguyen
+
+ 00:26:08
+ We keep score of everything these days. But what happens when the numbers start changing what we actually care about?
+ We keep score of everything these days. But what happens when the numbers start changing what we actually care about?
+ games, scoring, value capture, philosophy, c. thi nguyen, metrics
+ false
+ full
+ 673
+
+
+ 113ee1b3-dc46-406c-b462-64317962193c
+ 100 Objects #7: The Otis Pamphlet
+ Before the Declaration of Independence was even a twinkle in the founding fathers' eyes, there was James Otis. Journalist Jack Hitt returns to tell the wild, tragic story of America’s forgotten proto-founding father and his inflammatory 1764 pamphlet. A story that is, in many ways, the story of the founding document of the United States of America.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Fri, 3 Jul 2026 07:00:00 +0000
+ originals@stitcher.com (Jack Hitt)
+ https://99percentinvisible.org/?p=48226
+
+ Before the Declaration of Independence was even a twinkle in the founding fathers' eyes, there was James Otis. Journalist Jack Hitt returns to tell the wild, tragic story of America’s forgotten proto-founding father and his inflammatory 1764 pamphlet. A story that is, in many ways, the story of the founding document of the United States of America.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ 100 Objects #7: The Otis Pamphlet
+ Jack Hitt
+
+ 00:37:26
+ Journalist Jack Hitt returns to tell the wild, tragic story of America’s forgotten proto-founding father and his inflammatory 1764 pamphlet.
+ Journalist Jack Hitt returns to tell the wild, tragic story of America’s forgotten proto-founding father and his inflammatory 1764 pamphlet.
+ james otis, natural rights, american revolution, writs of assistance, taxation without representation, pamphlets
+ false
+ bonus
+
+
+ 41d640dc-738d-4c49-8959-26a97c00f8fc
+ Transatlantic Fiber-Optic Expialidocious
+ Before the internet went global, one undersea gamble made it possible.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Tue, 30 Jun 2026 08:00:00 +0000
+ originals@stitcher.com (Christopher Johnson)
+ https://99percentinvisible.org/?p=47210
+
+ Before the internet went global, one undersea gamble made it possible.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ Transatlantic Fiber-Optic Expialidocious
+ Christopher Johnson
+
+ 00:31:56
+ Before the internet went global, one undersea gamble made it possible.
+ Before the internet went global, one undersea gamble made it possible.
+ submarine cables, telecommunications, tat-8, bell labs, fiber optics, internet infrastructure
+ true
+ full
+ 672
+
+
+ c0bcde74-fbb7-402a-8f87-f0636fe8b934
+ 100 Objects #6: "Sharpened Screwdriver"
+ In this episode, Roman and historians Heather Ann Thompson and Elliot Williams tell the story of the sharpened screwdriver: the object at the heart of the 1984 Bernhard Goetz subway shooting. In a difficult moment in New York City, four Black teenagers were transformed in the public imagination into armed criminals. What follows is a gripping account of how misinformation takes hold, how fear shapes public opinion, and how one narrative can ripple outward – echoing through decades of similar cases that continue to unfold today.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Fri, 26 Jun 2026 07:00:00 +0000
+ originals@stitcher.com (Heather Ann Thompson, Elliot Williams)
+ https://99percentinvisible.org/?p=48221
+
+ In this episode, Roman and historians Heather Ann Thompson and Elliot Williams tell the story of the sharpened screwdriver: the object at the heart of the 1984 Bernhard Goetz subway shooting. In a difficult moment in New York City, four Black teenagers were transformed in the public imagination into armed criminals. What follows is a gripping account of how misinformation takes hold, how fear shapes public opinion, and how one narrative can ripple outward – echoing through decades of similar cases that continue to unfold today.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ 100 Objects #6: "Sharpened Screwdriver"
+ Heather Ann Thompson, Elliot Williams
+
+ 00:40:28
+ How was a household tool reimagined as a deadly weapon…that never actually existed?
+ How was a household tool reimagined as a deadly weapon…that never actually existed?
+ sharpened screwdriver, bernie getz, subway vigilante, racial bias, self-defense, misinformation
+ false
+ bonus
+
+
+ dbdeb18f-d3fa-4fdd-aa62-6cbc6d63e24c
+ The Most Exciting Change America Has Ever Seen
+ Billions of state quarters disappeared from circulation...and that was exactly the plan.
+
Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early.
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Tue, 23 Jun 2026 08:00:00 +0000
+ originals@stitcher.com (Katie Thornton)
+ https://99percentinvisible.org/?p=47211
+
+ Billions of state quarters disappeared from circulation...and that was exactly the plan.
+
Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early.
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ The Most Exciting Change America Has Ever Seen
+ Katie Thornton
+
+ 00:43:28
+ Billions of state quarters disappeared from circulation...and that was exactly the plan.
+ Billions of state quarters disappeared from circulation...and that was exactly the plan.
+ seigniorage, missouri quarter, u.s. mint, state quarters, coin collecting, numismatics
+ false
+ full
+ 671
+
+
+ 0478d505-7347-4a40-8c91-f2a93608b7df
+ 100 Objects #5: Blue Back Speller
+ In this episode, Roman and historian Imani Perry follow the Webster Blue Back Speller from the early days of the United States, to the heart of Black intellectual life. Through the lives of Booker T. Washington and W. E. B. Du Bois, Roman and Imani uncover how a single object became a gateway to literacy, self-determination, and an enduring debate about what education, citizenship, and freedom should mean in America.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Fri, 19 Jun 2026 07:00:00 +0000
+ originals@stitcher.com (Imani Perry)
+ https://99percentinvisible.org/?p=48146
+
+ In this episode, Roman and historian Imani Perry follow the Webster Blue Back Speller from the early days of the United States, to the heart of Black intellectual life. Through the lives of Booker T. Washington and W. E. B. Du Bois, Roman and Imani uncover how a single object became a gateway to literacy, self-determination, and an enduring debate about what education, citizenship, and freedom should mean in America.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ 100 Objects #5: Blue Back Speller
+ Imani Perry
+
+ 00:45:55
+ Why did an ordinary schoolbook become so treasured that enslaved people risked their lives to possess it?
+ Why did an ordinary schoolbook become so treasured that enslaved people risked their lives to possess it?
+ literacy, blue-back speller, w.e.b. du bois, booker t. washington, frederick douglass, noah webster
+ false
+ bonus
+
+
+ 1720f7cf-ef12-407c-be36-cfb87b7b07aa
+ The Horn That Divided the World Cup
+ Love it or hate it, one buzzing horn changed the sound of the World Cup forever.
+
Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early.
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Tue, 16 Jun 2026 08:00:00 +0000
+ originals@stitcher.com (James Parkinson)
+ https://99percentinvisible.org/?p=39575
+
+ Love it or hate it, one buzzing horn changed the sound of the World Cup forever.
+
Subscribe to SiriusXM Podcasts+ to listen to new episodes of 99% Invisible ad-free and a whole week early.
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ The Horn That Divided the World Cup
+ James Parkinson
+
+ 00:33:34
+ Love it or hate it, one buzzing horn changed the sound of the World Cup forever.
+ Love it or hate it, one buzzing horn changed the sound of the World Cup forever.
+ maake, world cup, vuvuzela, football, soccer, south africa
+ false
+ full
+ 511
+
+
+ b6845f27-add0-4f61-ba36-19bc454bcce8
+ 100 Objects #4: Lowe's Gas Bag
+ In 1861, one man and a “gas bag” filled with hydrogen sparked America's obsession with going higher, farther, into the unknown. In this episode, Roman and journalist Jack Hitt tell the story of Thaddeus Lowe — showman, scientist, and dreamer — whose balloon flight from Cincinnati went wildly off course and straight into the Civil War. Lowe pivoted to create the US military's first aerial reconnaissance unit, a precursor to the Air Force, and today his legacy spans generations— stretching all the way to the dark side of the moon.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+ Fri, 12 Jun 2026 07:00:00 +0000
+ originals@stitcher.com (Jack Hitt)
+ https://99percentinvisible.org/?p=48144
+
+ In 1861, one man and a “gas bag” filled with hydrogen sparked America's obsession with going higher, farther, into the unknown. In this episode, Roman and journalist Jack Hitt tell the story of Thaddeus Lowe — showman, scientist, and dreamer — whose balloon flight from Cincinnati went wildly off course and straight into the Civil War. Lowe pivoted to create the US military's first aerial reconnaissance unit, a precursor to the Air Force, and today his legacy spans generations— stretching all the way to the dark side of the moon.
+
Hosted by Simplecast, an AdsWizz company. See pcm.adswizz.com for information about our collection and use of personal data for advertising.
]]>
+
+ 100 Objects #4: Lowe's Gas Bag
+ Jack Hitt
+
+ 00:38:54
+ In 1861, one man and a “gas bag” filled with hydrogen sparked America's obsession with going higher, farther, into the unknown.
+ In 1861, one man and a “gas bag” filled with hydrogen sparked America's obsession with going higher, farther, into the unknown.
+ hot air balloon, innovation, civil war, aerial reconnaissance, thaddeus lowe, gas bag, abraham lincoln
+ false
+ bonus
+
+
+
\ No newline at end of file
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/ninety_nine_pi_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/ninety_nine_pi_art.jpg
new file mode 100644
index 000000000..f23deb3fa
Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/ninety_nine_pi_art.jpg differ
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish.rss
new file mode 100644
index 000000000..1d621c182
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish.rss
@@ -0,0 +1,215 @@
+
+
+
+
+
+No Such Thing As A Fish
+
+
The official channel for the show about everything: four dorks discuss the most amazing facts they’ve learned over the last seven days.
+
+
The multi award-winning, globe-travelling, 600-million-download phenomenon. Hosted by Dan Schreiber, James Harkin, Andrew Hunter Murray and Anna Ptaszynski.
Dan, James and Andy discuss YOUR facts including Curaçao, chips and chess. Dan and Andy compete to see who has the best cardboard box anecdote. And we name eight more Friend of the Podcast custodians.
Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia
]]>
+ Sun, 12 Jul 2026 22:59:00 +0000
+ audioboom-019f5884-247c-7ec6-bce3-890e8f7c5e95
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+ No Such Thing As No Such Thing As A Crab
+ https://audioboom.com/posts/8925825
+ 643
+
+
+ 3775
+ false
+ full
+
+
Bertie Terrilliams of the Margate Crab Museum joins Dan, Anna and James to discuss cures, curators, crabs and KAPOW!
Edited by James Harkin Produced by Leying Lee Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia
Shopify: Own your customer relationships. Own your revenue. Start with a free trial at shopify.co.uk
Get an exclusive 15% discount on Saily data plans! Use code [fish] at checkout. Download Saily app or go to https://saily.com/fish
]]>
+ Thu, 09 Jul 2026 22:55:00 +0000
+ audioboom-019f4789-9d22-71d6-88c0-05caebb31b63
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+ Little Fish: I believed it when Pythagoras said it
+ https://audioboom.com/posts/8923537
+ 36
+ 2
+
+
+ 1704
+ false
+ full
+
+
Dan, Andy and Anna discuss YOUR facts including scaring cormorants, cleaning horses and recognising frogmen. Anna reveals what her first word was. And we name eight more Friend of the Podcast custodians.
London Podcast Festival tickets go on sale tomorrow - visit nosuchthingasafish.com/live to join us in person, or via livestream!
Edited by James Harkin Produced by Leying Lee Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia
]]>
+ Sun, 05 Jul 2026 22:55:00 +0000
+ audioboom-019f23de-50b6-7bfa-af98-c0be67cc74fb
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+ No Such Thing As An Unlucky Fountain
+ https://audioboom.com/posts/8923496
+ 642
+
+
+ 3387
+ false
+ full
+
+
Jamie Morton joins Dan, Anna and Andy to discuss symmetry, lottery, and an erotic bee.
Edited by James Harkin Produced by Leying Lee Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia
]]>
+ Thu, 02 Jul 2026 22:55:00 +0000
+ audioboom-019f2393-603a-7627-b3c4-db94dfc60a1a
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+ Little Fish: Shall we put a podcast on? What a stupid idea!
+ https://audioboom.com/posts/8921807
+ 35
+ 2
+
+
+ 1826
+ false
+ full
+
+
Dan, Andy and Anna discuss YOUR facts including pasta, Proust and pencils. Anna shares her secret 'Wheel of Life' game. And we name eight more Friend of the Podcast custodians.
Edited by James Harkin Produced by Leying Lee Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia
]]>
+ Sun, 28 Jun 2026 22:59:00 +0000
+ audioboom-019f0ed4-4407-7928-979a-613bcb4c3468
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+ No Such Thing As A Glazed Lizard Donut
+ https://audioboom.com/posts/8920547
+ 641
+
+
+ 3422
+ false
+ full
+
+
Dan, James, Anna and Andy discuss Disney Princesses, brave tailors and tangled lizards.
Edited by James Harkin Produced by Leying Lee Team includes Tara Dorrell, Joe Mayo and Ethan Ruparelia
]]>
+ Thu, 25 Jun 2026 22:55:00 +0000
+ audioboom-019efb21-989f-770c-b8be-b095d9af022a
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+ Little Fish: Really Siding With The Baddies
+ https://audioboom.com/posts/8918215
+ 34
+ 2
+
+
+ 1843
+ false
+ full
+
+
James, Andy and Anna discuss YOUR facts including huge holes and dangerous materials. James and Andy argue over a TikTokker who doesn't exist. And we name eight more Friend of the Podcast custodians.
Edited by James Harkin Produced by Leying Lee Team includes Jack Chambers, Tara Dorrell, Joe Mayo and Ethan Ruparelia
]]>
+ Sun, 21 Jun 2026 22:55:00 +0000
+ audioboom-019edb75-6d53-74cf-be4a-d319f0dacde8
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+ No Such Thing As The Metal Bloke
+ https://audioboom.com/posts/8918202
+ 640
+
+
+ 3233
+ false
+ full
+
+
Nish Kumar joins James, Anna and Andy to discuss The Man Who Talks With String; The Man Who Sold The World; and the man you don't want directing your B-movie.
Edited by James Harkin Produced by Leying Lee Team includes Jack Chambers, Tara Dorrell, Joe Mayo and Ethan Ruparelia
]]>
+ Thu, 18 Jun 2026 22:55:00 +0000
+ audioboom-019edb53-f68e-736b-92bf-b0a462dc1ff2
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+ Little Fish: I've Booked Us In A Lovely Toilet
+ https://audioboom.com/posts/8916004
+ 33
+ 2
+
+
+ 1790
+ false
+ full
+
+
Andy, James and Anna discuss YOUR facts including a road that's not a road; and lots of other places that are not where you might think. Anna discovers the true nature of God. And we name eight more Friend of the Podcast custodians.
Edited by James Harkin Produced by Leying Lee Team includes Jack Chambers, Tara Dorrell, Joe Mayo and Ethan Ruparelia
]]>
+ Sun, 14 Jun 2026 22:55:00 +0000
+ audioboom-019ebc98-192b-7534-8c49-dfed8a977e61
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+ No Such Thing As Poddyversity Challenge
+ https://audioboom.com/posts/8915422
+ 639
+
+
+ 3480
+ false
+ full
+
+
Dan, James, Anna and Andy discuss shamans, humans, theme parks and theme tunes.
Edited by James Harkin Produced by Leying Lee Team includes Jack Chambers, Tara Dorrell, Joe Mayo and Ethan Ruparelia
]]>
+ Thu, 11 Jun 2026 22:55:00 +0000
+ audioboom-019eb720-4209-774b-bef6-bb86d0b1afbe
+ No Such Thing As A Fish
+ No Such Thing As A Fish
+
+
+
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish_art.jpg
new file mode 100644
index 000000000..5ed27a411
Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/no_such_thing_as_a_fish_art.jpg differ
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android.rss
new file mode 100644
index 000000000..340dbc982
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android.rss
@@ -0,0 +1,324 @@
+
+
+
+
+
+
+ Now in Android
+ Fri, 19 Dec 2025 23:45:17 +0000
+ Fri, 19 Dec 2025 23:45:17 +0000
+ Libsyn RSSgen 1.0
+ http://d.android.com
+ en
+
+ http://d.android.com
+
+
+ https://static.libsyn.com/p/assets/1/4/f/9/14f959f911553fcc27a2322813b393ee/NIA000_PodcastThumbnail-20241010-vj0kfz2md4.png
+ Now in Android
+
+
+ Now in Android
+
+
+
+ false
+
+ Google Developer Studio
+ gdevs.podcast@gmail.com
+
+
+
+ episodic
+
+
+
+
+
+
+
+ no
+
+
+
+
+
+
+ 123 – Android XR, Jetpack Navigation 3, and more!
+ 123 – Android XR, Jetpack Navigation 3, and more!
+ Fri, 19 Dec 2025 20:01:00 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers the latest releases from Android XR, the Android Studio Otter 2 feature drop with Gemini 3, the release of Android 16 QPR2, Jetpack Navigation 3, and more!
Chapters: 0:00 - Introduction 0:31 - Android XR 1:59 - Android Studio and AI 2:31 - Jetpack updates 3:05 - Performance 3:20 - Core OS
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers the latest releases from Android XR, the Android Studio Otter 2 feature drop with Gemini 3, the release of Android 16 QPR2, Jetpack Navigation 3, and more!
Chapters: 0:00 - Introduction 0:31 - Android XR 1:59 - Android Studio and AI 2:31 - Jetpack updates 3:05 - Performance 3:20 - Core OS
]]>
+
+
+
+ 04:38
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+ 122 – Android Studio updates, Android 16 beta feature, and more!
+ 122 – Android Studio updates, Android 16 beta feature, and more!
+ Wed, 12 Nov 2025 19:37:00 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Ash covers the latest releases from Android 16 QPR2 to Android Studio Narwhal. Discover The Android Show, Androidify, the Wear OS Spotlight Week, and more!
Chapters: 0:00 - Introduction 0:23 - Android 16 QPR 2 and Beta 1:55 - Android Studio Narwhal 2:33 - The Android Show & Androidify 3:09 - Wear OS Spotlight Week 4:07 - Latest articles 7:07 - Latest videos 9:01 - AndroidX updates
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Ash covers the latest releases from Android 16 QPR2 to Android Studio Narwhal. Discover The Android Show, Androidify, the Wear OS Spotlight Week, and more!
Chapters: 0:00 - Introduction 0:23 - Android 16 QPR 2 and Beta 1:55 - Android Studio Narwhal 2:33 - The Android Show & Androidify 3:09 - Wear OS Spotlight Week 4:07 - Latest articles 7:07 - Latest videos 9:01 - AndroidX updates
]]>
+
+
+
+ 10:32
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+ 121 – Android Studio Narwhal 3, Android 16 QPR2 beta, and more!
+ 121 – Android Studio Narwhal 3, Android 16 QPR2 beta, and more!
+ Wed, 03 Sep 2025 21:13:00 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers Android 16 QPR2 beta 1, the Android Studio Narwhal feature drop, Jetpack Compose 1.9, and more!
Chapters: 0:00 - Introduction 0:24 - Android 16 QPR2 beta 1:38 - Android Studio Updates 2:42 - Jetpack Compose August '25 released to stable 3:27 - Media3 1.8 Released 4:18 - Recently published Articles 4:55 - Recent published videos 5:26 - Android Developers Backstage 5:50 - AndroidX 6:45 - Recap
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers Android 16 QPR2 beta 1, the Android Studio Narwhal feature drop, Jetpack Compose 1.9, and more!
Chapters: 0:00 - Introduction 0:24 - Android 16 QPR2 beta 1:38 - Android Studio Updates 2:42 - Jetpack Compose August '25 released to stable 3:27 - Media3 1.8 Released 4:18 - Recently published Articles 4:55 - Recent published videos 5:26 - Android Developers Backstage 5:50 - AndroidX 6:45 - Recap
]]>
+
+
+
+ 07:14
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+ 120 - Android Canaries, Agentic AI, 16 KB page size requirements, and more!
+ Now in Android: 120 – Android Canaries, Agentic AI, 16 KB page size requirements, and more!
+ Wed, 06 Aug 2025 16:00:00 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers Android Canaries, Agentic AI, 16 KB Page Size requirements, and new subscription tools,
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Dan covers Android Canaries, Agentic AI, 16 KB Page Size requirements, and new subscription tools,
]]>
+
+
+
+ 07:47
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+ 119 - Android 16, testing, desktop experiences, AndroidX, and more!
+ 119 - Android 16, testing, desktop experiences, AndroidX, and more!
+ Wed, 09 Jul 2025 16:00:00 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Meghan shares exciting news like the release of Android 16! Discover developer previews for desktop windowing experiences, building adaptive apps, and more!
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. Meghan shares exciting news like the release of Android 16! Discover developer previews for desktop windowing experiences, building adaptive apps, and more!
]]>
+
+
+
+ 04:36
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+ 118 – What's new in Android development at Google I/O 2025 (part 2)
+ 118 – What's new in Android development at Google I/O 2025 (part 2)
+ Wed, 18 Jun 2025 16:00:00 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, Dan Galpin covers part two of the biggest announcements from Google I/O 2025. From Jetpack Compose, Camera and Media, accessibility, Kotlin Multiplatform, Android development tools, Google Play, and more.
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, Dan Galpin covers part two of the biggest announcements from Google I/O 2025. From Jetpack Compose, Camera and Media, accessibility, Kotlin Multiplatform, Android development tools, Google Play, and more. Check out part 1 → https://goo.gle/4n7nty5
]]>
+
+
+
+ 05:47
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+ Now in Android: 117 – What's new in Android development at Google I/O 2025 (part 1)
+ Now in Android: 117 – What's new in Android development at Google I/O 2025 (part 1)
+ Wed, 04 Jun 2025 16:00:00 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, Dan Galpin covers part one of the biggest announcements from Google I/O 2025. From Material Design's latest evolution, to building with on-device and cloud AI, to updates for wearables, automotive, XR, and more.
Stay tuned for part 2, where Dan covers Android Jetpack, Jetpack Compose, and Android Studio.
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, Dan Galpin covers part one of the biggest announcements from Google I/O 2025. From Material Design's latest evolution, to building with on-device and cloud AI, to updates for wearables, automotive, XR, and more.
Stay tuned for part 2, where Dan covers Android Jetpack, Jetpack Compose, and Android Studio.
]]>
+
+
+
+ 07:02
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+ 116 - Google I/O, Gemini, and Jetpack Compose 1.8
+ 116 - Google I/O, Gemini, and Jetpack Compose 1.8
+ Thu, 08 May 2025 16:00:00 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover the Google I/O 2025 program lineup, the release of Jetpack Compose 1.8, Android 16 Beta 4, new Play Console insights, and updated Android Vitals metrics.
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover the Google I/O 2025 program lineup, the release of Jetpack Compose 1.8, Android 16 Beta 4, new Play Console insights, and updated Android Vitals metrics.
]]>
+
+
+
+ 03:58
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+ 115 - Gemini in Android Studio, Enhancing Safety and Security, and more!
+ 115 - Gemini in Android Studio, Enhancing Safety and Security, and more!
+ Fri, 25 Apr 2025 13:28:00 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover Android 16 beta 3, Gemini in Android Studio for Business and Multimodal, Android XR, Media and Camera updates, and much more.
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover Android 16 beta 3, Gemini in Android Studio for Business and Multimodal, Android XR, Media and Camera updates, and much more.
]]>
+
+
+
+ 10:18
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+ 114 - Google I/O 2025, Android Studio at 10, Android 16 Betas, and more!
+ Now in Android: 114 - Google I/O 2025, Android Studio at 10, Android 16 Betas, and more!
+ Fri, 21 Mar 2025 02:07:55 +0000
+
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover the return of Google I/O, Android Studio Turning 10, the Android 16 Betas, Imagen in Firebase, the latest in AndroidX, and more!
For links to these items, check out Now in Android #114 on Medium → https://goo.gle/4hA69xv Catch the latest episode of #TheAndroidShow here → https://goo.gle/tas-mar25
Watch more Now in Android → https://goo.gle/now-in-android Subscribe to Android Developers → https://goo.gle/AndroidDevs
]]>
+
+ Welcome to Now in Android, your ongoing guide to what's new and notable in the world of Android development. In this episode, we'll cover the return of Google I/O, Android Studio Turning 10, the Android 16 Betas, Imagen in Firebase, the latest in AndroidX, and more!
For links to these items, check out Now in Android #114 on Medium → https://goo.gle/4hA69xv Catch the latest episode of #TheAndroidShow here → https://goo.gle/tas-mar25
Watch more Now in Android → https://goo.gle/now-in-android Subscribe to Android Developers → https://goo.gle/AndroidDevs
]]>
+
+
+
+ 06:27
+ false
+
+
+
+
+
+ full
+
+ Google
+
+
+
+
+
+
+
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android_art.jpg
new file mode 100644
index 000000000..ffea32410
Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/now_in_android_art.jpg differ
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all.rss
new file mode 100644
index 000000000..69560f288
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all.rss
@@ -0,0 +1,241 @@
+
+
+
+
+ Reply All
+ http://gimletmedia.com/shows/reply-all
+ en
+ All rights reserved
+ "'A podcast about the internet' that is actually an unfailingly original exploration of modern life and how to survive it." - The Guardian. Hosted by Alex Goldman and Emmanuel Dzotsi from Gimlet.
+
+ https://megaphone.imgix.net/podcasts/05f71746-a825-11e5-aeb5-a7a572df575e/image/uploads_2F1610700524297-f3e2p6fbm79-8b4536488226b56e93a99ce9f7b555b5_2FReplyAll_ShowArt.png?ixlib=rails-4.3.1&max-w=3000&max-h=3000&fit=crop&auto=format,compress
+ Reply All
+ http://gimletmedia.com/shows/reply-all
+
+ yes
+ episodic
+
+ Gimlet
+ "'A podcast about the internet' that is actually an unfailingly original exploration of modern life and how to survive it." - The Guardian. Hosted by Alex Goldman and Emmanuel Dzotsi from Gimlet.
+
+ "'A podcast about the internet' that is actually an unfailingly original exploration of modern life and how to survive it." - The Guardian. Hosted by Alex Goldman and Emmanuel Dzotsi from Gimlet.]]>
+
+
+ Gimlet
+ podcasts@gimletmedia.com
+
+
+
+
+
+
+
+ https://feeds.megaphone.fm/replyall
+
+ Big Lives: Emmanuel's New Show
+ Emmanuel has a new show out, Big Lives, and we're excited to share a preview of it! Alongside acclaimed journalist Kai Wright, Emmanuel digs into the BBC archive to examine the sensational stories, messy feelings, and enlightening questions surrounding the icons who have shaped our culture. From musical trailblazers like David Bowie, Amy Winehouse and Tina Turner to stars of the screen like Richard Pryor, Meg Ryan and Jane Fonda, Emmanuel and Kai see these icons in a new light and learn how each legend set the stage for our contemporary cultural landscape. In this preview, they unspool the emotional arc behind George Michael, from teen heartthrob to grieving partner to gay icon. If you like what you hear, find Big Lives wherever you get podcasts.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Mon, 30 Mar 2026 07:00:00 -0000
+ full
+ Gimlet
+
+
+ Emmanuel has a new show out, Big Lives, and we're excited to share a preview of it! Alongside acclaimed journalist Kai Wright, Emmanuel digs into the BBC archive to examine the sensational stories, messy feelings, and enlightening questions surrounding the icons who have shaped our culture. From musical trailblazers like David Bowie, Amy Winehouse and Tina Turner to stars of the screen like Richard Pryor, Meg Ryan and Jane Fonda, Emmanuel and Kai see these icons in a new light and learn how each legend set the stage for our contemporary cultural landscape. In this preview, they unspool the emotional arc behind George Michael, from teen heartthrob to grieving partner to gay icon. If you like what you hear, find Big Lives wherever you get podcasts.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ Emmanuel has a new show out, Big Lives, and we're excited to share a preview of it! Alongside acclaimed journalist Kai Wright, Emmanuel digs into the BBC archive to examine the sensational stories, messy feelings, and enlightening questions surrounding the icons who have shaped our culture. From musical trailblazers like David Bowie, Amy Winehouse and Tina Turner to stars of the screen like Richard Pryor, Meg Ryan and Jane Fonda, Emmanuel and Kai see these icons in a new light and learn how each legend set the stage for our contemporary cultural landscape. In this preview, they unspool the emotional arc behind George Michael, from teen heartthrob to grieving partner to gay icon. If you like what you hear, find Big Lives wherever you get podcasts.
]]>
+
+ 3246
+ no
+
+
+
+
+ Casey Wants to Believe
+ If you like this episode and want to hear more, please sign up for our premium feed here! https://hyperfixedpod.com/joinIf you would like to join at the $60 yearly level and receive your very own Big Chub Chet's button, you can do so here:https://hyperfixedpod.com/buttonThere are a limited number so get them before they're gone! Casey has been chasing the origin of a very specific piece of ephemera for almost 15 years. He came to us for help.LINKS:Busy Beaver Button MuseumThe Button in Question (Spoilers! don't look until the episode is done!)
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Thu, 05 Dec 2024 08:00:00 -0000
+ full
+ 190
+ Gimlet
+
+
+ If you like this episode and want to hear more, please sign up for our premium feed here! https://hyperfixedpod.com/joinIf you would like to join at the $60 yearly level and receive your very own Big Chub Chet's button, you can do so here:https://hyperfixedpod.com/buttonThere are a limited number so get them before they're gone! Casey has been chasing the origin of a very specific piece of ephemera for almost 15 years. He came to us for help.LINKS:Busy Beaver Button MuseumThe Button in Question (Spoilers! don't look until the episode is done!)
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ https://hyperfixedpod.com/join If you would like to join at the $60 yearly level and receive your very own Big Chub Chet's button, you can do so here:https://hyperfixedpod.com/buttonThere are a limited number so get them before they're gone! Casey has been chasing the origin of a very specific piece of ephemera for almost 15 years. He came to us for help. LINKS:Busy Beaver Button MuseumThe Button in Question (Spoilers! don't look until the episode is done!)
]]>
+
+ 2057
+ no
+
+
+
+
+ #189 Goodbye All
+ One last thing before we go.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Thu, 23 Jun 2022 10:00:00 -0000
+ full
+ Gimlet
+
+ One last thing before we go.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ One last thing before we go.
]]>
+
+ 3049
+ yes
+
+
+
+
+ #188 Into the Depths
+ Inspired by @depthsofwikipedia, this week we dive deeper into three of our favorite weird Wikipedia pages.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Thu, 09 Jun 2022 10:00:00 -0000
+ full
+ Gimlet
+
+ Inspired by @depthsofwikipedia, this week we dive deeper into three of our favorite weird Wikipedia pages.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ Inspired by @depthsofwikipedia, this week we dive deeper into three of our favorite weird Wikipedia pages.
]]>
+
+ 2554
+ yes
+
+
+
+
+ Introducing: Stolen - Surviving St. Michael's
+ We'll be back in a few weeks, in the meantime we're introducing a new show from Gimlet called Stolen: Surviving St. Michael's
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Thu, 26 May 2022 10:00:00 -0000
+ full
+ Gimlet
+
+ We'll be back in a few weeks, in the meantime we're introducing a new show from Gimlet called Stolen: Surviving St. Michael's
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ We'll be back in a few weeks, in the meantime we're introducing a new show from Gimlet called Stolen: Surviving St. Michael's
]]>
+
+ 2610
+
+
+
+
+ A Message About the Future of the Show
+ Some news: Emmanuel and Alex will be stepping away from making the show. And this iteration of Reply All will be ending in late June.
+This isn’t a decision that was made by any one person. Reply All has always been a super collaborative project which is what we’ve loved so much about making it. Ultimately, staff who have been working on this show for a long time – in some cases five, six, seven years at this point – are ready to pursue other things.
+Making this show has been an incredible pleasure. Thank you all so much for listening.
+We hope you join us for our remaining episodes, including our last one, on June 23rd.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Thu, 19 May 2022 21:10:58 -0000
+ full
+ Gimlet
+
+ Some news: Emmanuel and Alex will be stepping away from making the show. And this iteration of Reply All will be ending in late June.
+This isn’t a decision that was made by any one person. Reply All has always been a super collaborative project which is what we’ve loved so much about making it. Ultimately, staff who have been working on this show for a long time – in some cases five, six, seven years at this point – are ready to pursue other things.
+Making this show has been an incredible pleasure. Thank you all so much for listening.
+We hope you join us for our remaining episodes, including our last one, on June 23rd.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ Some news: Emmanuel and Alex will be stepping away from making the show. And this iteration of Reply All will be ending in late June.
This isn’t a decision that was made by any one person. Reply All has always been a super collaborative project which is what we’ve loved so much about making it. Ultimately, staff who have been working on this show for a long time – in some cases five, six, seven years at this point – are ready to pursue other things.
Making this show has been an incredible pleasure. Thank you all so much for listening.
We hope you join us for our remaining episodes, including our last one, on June 23rd.
]]>
+
+ 128
+
+
+
+
+ #187 Flying the Coop
+ A community of chicken lovers faces an unlikely foe.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Thu, 12 May 2022 10:00:00 -0000
+ full
+ Gimlet
+
+ A community of chicken lovers faces an unlikely foe.
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ A community of chicken lovers faces an unlikely foe.
]]>
+
+ 2095
+ yes
+
+
+
+
+ Introducing: Stuck with Damon Young
+ We'll be back with a new episode next week, but in the meantime, we're featuring an episode of a new show from Gimlet and Crooked Media: Stuck with Damon Young.
+
+You can listen to episodes here: https://open.spotify.com/show/42kxHmWquXQBJCxG0rXvu6
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Thu, 05 May 2022 14:28:41 -0000
+ full
+ Gimlet
+
+ We'll be back with a new episode next week, but in the meantime, we're featuring an episode of a new show from Gimlet and Crooked Media: Stuck with Damon Young.
+
+You can listen to episodes here: https://open.spotify.com/show/42kxHmWquXQBJCxG0rXvu6
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ We'll be back with a new episode next week, but in the meantime, we're featuring an episode of a new show from Gimlet and Crooked Media: Stuck with Damon Young.
You can listen to episodes here: https://open.spotify.com/show/42kxHmWquXQBJCxG0rXvu6
]]>
+
+ 2556
+
+
+
+
+ #186 The Contact List
+ Emmanuel tries a personal experiment.
+
+
+
+
+
+--------
+This episode was inspired by the work of Soraya Perry. Check out her work as a filmmaker, musician, and visual artist here: www.sorayaperry.com
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Thu, 21 Apr 2022 10:00:00 -0000
+ full
+ Gimlet
+
+ Emmanuel tries a personal experiment.
+
+
+
+
+
+--------
+This episode was inspired by the work of Soraya Perry. Check out her work as a filmmaker, musician, and visual artist here: www.sorayaperry.com
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ Emmanuel tries a personal experiment.
--------
This episode was inspired by the work of Soraya Perry. Check out her work as a filmmaker, musician, and visual artist here: www.sorayaperry.com
]]>
+
+ 2333
+
+
+
+
+ Introducing: Conviction - The Disappearance of Nuseiba Hasan
+ Alex chats with Habiba Nosheen about her new show, Conviction: The Disappearance of Nuseiba Hasan.
+
+Listen to all the episodes of the show here: https://open.spotify.com/show/2XquJlPU9ibLYZMH0ZzFwA
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+ Thu, 14 Apr 2022 10:00:00 -0000
+ full
+ Gimlet
+
+ Alex chats with Habiba Nosheen about her new show, Conviction: The Disappearance of Nuseiba Hasan.
+
+Listen to all the episodes of the show here: https://open.spotify.com/show/2XquJlPU9ibLYZMH0ZzFwA
+Learn more about your ad choices. Visit podcastchoices.com/adchoices
+
+ Alex chats with Habiba Nosheen about her new show, Conviction: The Disappearance of Nuseiba Hasan.
Listen to all the episodes of the show here: https://open.spotify.com/show/2XquJlPU9ibLYZMH0ZzFwA
]]>
+
+ 2355
+
+
+
+
+
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all_art.jpg
new file mode 100644
index 000000000..7cdcf7a0e
Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/reply_all_art.jpg differ
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life.rss b/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life.rss
new file mode 100644
index 000000000..4ce08c4fd
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life.rss
@@ -0,0 +1,311 @@
+
+
+
+ This American Life
+ https://www.thisamericanlife.org/podcast/rss.xml
+ Each week we choose a theme. Then anything can happen. This American Life is true stories that unfold like little movies for radio. Personal stories with funny moments, big feelings, and surprising plot twists. Newsy stories that try to capture what it’s like to be alive right now. It’s the most popular weekly podcast in the world, and winner of the first ever Pulitzer Prize for a radio show or podcast. Hosted by Ira Glass and produced in collaboration with WBEZ Chicago.
+ en
+ https://www.thisamericanlife.org/podcast/rss.xml
+
+ Copyright 1995-2026 This American Life
+ This American Life
+ Each week we choose a theme. Then anything can happen. This American Life is true stories that unfold like little movies for radio. Personal stories with funny moments, big feelings, and surprising plot twists. Newsy stories that try to capture what it’s like to be alive right now. It’s the most popular weekly podcast in the world, and winner of the first ever Pulitzer Prize for a radio show or podcast. Hosted by Ira Glass and produced in collaboration with WBEZ Chicago.
+ false
+ web@thislife.org
+
+
+
+
+
+
+ 891: The Test Case
+ https://www.thisamericanlife.org/891/the-test-case
+ <p>Some people in this country think Antifa is a dangerous domestic terror organization. Some think that’s a complete myth. This week, we go to the federal trial where, for the first time, the government sets out to prove that Antifa is real. Zoe Chace reports.
+
+</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Host Ira Glass tells the story of what happened at the Prairieland Detention Center in Alvarado, Texas. The government claims this was an attack by an Antifa cell. The defense says it was a protest gone awry. This argument goes to court in a first-of-its-kind trial. (7 minutes)</li><li>Act One: Reporter Zoe Chace walks us through one of the worst days for the defense: all these guns. (5 minutes)</li><li>Act Two: Zoe takes us through the testimony of the government’s Antifa expert. The defense team is optimistic that things are going well for them. (8 minutes)</li><li>Act Three: Zoe goes to jail to talk with one of the cooperating witnesses for the prosecution, a former church lady named Lynette Sharp. She has a lot to say about the events in her life that led her to accept a plea deal and testify against her friends. (24 minutes)</li><li>Act Four: The verdicts come in. (12 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/891/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p>
+ Sun, 12 Jul 2026 18:00:00 -0400
+ 46177 at https://www.thisamericanlife.org
+ full
+ 891
+ The Test Case
+ This American Life
+ false
+
+
+ 01:12:09
+ The government sets out to prove that Antifa is real.
+
+ Some people in this country think Antifa is a dangerous domestic terror organization. Some think that’s a complete myth. This week, we go to the federal trial where, for the first time, the government sets out to prove that Antifa is real. Zoe Chace reports.
+
+
+
+Prologue: Host Ira Glass tells the story of what happened at the Prairieland Detention Center in Alvarado, Texas. The government claims this was an attack by an Antifa cell. The defense says it was a protest gone awry. This argument goes to court in a first-of-its-kind trial. (7 minutes)
+
+Act One: Reporter Zoe Chace walks us through one of the worst days for the defense: all these guns. (5 minutes)
+
+Act Two: Zoe takes us through the testimony of the government’s Antifa expert. The defense team is optimistic that things are going well for them. (8 minutes)
+
+Act Three: Zoe goes to jail to talk with one of the cooperating witnesses for the prosecution, a former church lady named Lynette Sharp. She has a lot to say about the events in her life that led her to accept a plea deal and testify against her friends. (24 minutes)
+
+Act Four: The verdicts come in. (12 minutes)
+
+
+ 890: Maximal Americanness
+ https://www.thisamericanlife.org/890/maximal-americanness
+ <p>On this country's 250th birthday, we bring you stories about the most American people, places, objects, and social norms that make this country what it is.
+
+</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Ira talks to Pablo Torre about Major League Baseball’s new challenge system, and how it’s been optimized for maximum drama. (10 minutes)</li><li>Act One: Writer Jiayang Fan wrestles with a very common question she has never quite understood. (5 minutes)</li><li>Act Two: People come from all over the country to walk down one of Michigan’s tallest sand dunes, and then promptly turn around and trudge back up. Aviva DeKornfeld talks to Americans spending their limited vacation time on this punishing activity. (8 minutes)</li><li>Act Three: Emanuele Berry talks to Ira about Season 13 of the reality TV show, Survivor, known to fans as the “race war” season. (8 minutes)</li><li>Act Four: Years before his famous dictionary, Noah Webster wrote a book that took on a life of its own and served an unexpected purpose. (8 minutes)</li><li>Act Five: Emmanuel Dzotsi investigates a musical phenomenon very particular to the United States: singers embellishing the end of the national anthem. (9 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/890/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p>
+ Sun, 05 Jul 2026 18:00:00 -0400
+ 46176 at https://www.thisamericanlife.org
+ full
+ 890
+ Maximal Americanness
+ This American Life
+ false
+
+
+ 00:56:03
+ The people, places, objects, and social norms that make this country what it is.
+
+ On this country's 250th birthday, we bring you stories about the most American people, places, objects, and social norms that make this country what it is.
+
+
+
+Prologue: Ira talks to Pablo Torre about Major League Baseball’s new challenge system, and how it’s been optimized for maximum drama. (10 minutes)
+
+Act One: Writer Jiayang Fan wrestles with a very common question she has never quite understood. (5 minutes)
+
+Act Two: People come from all over the country to walk down one of Michigan’s tallest sand dunes, and then promptly turn around and trudge back up. Aviva DeKornfeld talks to Americans spending their limited vacation time on this punishing activity. (8 minutes)
+
+Act Three: Emanuele Berry talks to Ira about Season 13 of the reality TV show, Survivor, known to fans as the “race war” season. (8 minutes)
+
+Act Four: Years before his famous dictionary, Noah Webster wrote a book that took on a life of its own and served an unexpected purpose. (8 minutes)
+
+Act Five: Emmanuel Dzotsi investigates a musical phenomenon very particular to the United States: singers embellishing the end of the national anthem. (9 minutes)
+
+
+ 128: Four Corners
+ https://www.thisamericanlife.org/128/four-corners
+ <p>We try to tell the story of life in America through portraits of life on four different corners, in four different states across the nation.
+
+</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Host Ira Glass talks about the Four Corners tourist monument where Arizona, Colorado, Utah and New Mexico meet. (2 minutes)</li><li>Act One: Sarah Vowell has a theory that you can tell the entire history of the United States by standing on one street corner—specifically at Michigan Avenue and Wacker Drive in Chicago—and describing all the events that happened within eyeshot of the corner. She covers three centuries of history, from Louis Joliet to Keanu Reeves. (21 minutes)</li><li>Act Two: Scott Richer and Julie Riggs of Louisville, Kentucky, were supposed to have their first kiss at the corner where South Fourth Street meets the alley behind the West End Baptist Church. But it went wrong. (7 minutes)</li><li>Act Three: Writer Mike Paterniti tells a story of dogs and a community of dogwalkers that formed on the grounds of an old cemetery at the corner of Vaughn and Clifford in Portland, Maine. (14 minutes)</li><li>Act Four: Writer Achy Obejas reads a piece of short fiction from her book, We Came All the Way from Cuba So You Could Dress Like This? (11 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/128/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p>
+ Sun, 28 Jun 2026 18:00:00 -0400
+ 37545 at https://www.thisamericanlife.org
+ full
+ 128
+ Four Corners
+ This American Life
+ false
+
+
+ 01:00:44
+ We try to tell the story of life in America through portraits of life in four different states across the nation.
+
+ We try to tell the story of life in America through portraits of life on four different corners, in four different states across the nation.
+
+
+
+Prologue: Host Ira Glass talks about the Four Corners tourist monument where Arizona, Colorado, Utah and New Mexico meet. (2 minutes)
+
+Act One: Sarah Vowell has a theory that you can tell the entire history of the United States by standing on one street corner—specifically at Michigan Avenue and Wacker Drive in Chicago—and describing all the events that happened within eyeshot of the corner. She covers three centuries of history, from Louis Joliet to Keanu Reeves. (21 minutes)
+
+Act Two: Scott Richer and Julie Riggs of Louisville, Kentucky, were supposed to have their first kiss at the corner where South Fourth Street meets the alley behind the West End Baptist Church. But it went wrong. (7 minutes)
+
+Act Three: Writer Mike Paterniti tells a story of dogs and a community of dogwalkers that formed on the grounds of an old cemetery at the corner of Vaughn and Clifford in Portland, Maine. (14 minutes)
+
+Act Four: Writer Achy Obejas reads a piece of short fiction from her book, We Came All the Way from Cuba So You Could Dress Like This? (11 minutes)
+
+
+ 889: There’s Something About Hail Mary
+ https://www.thisamericanlife.org/889/theres-something-about-hail-mary
+ <p>We spend an hour in the last two minutes of the fourth quarter, behind and desperate, with people trying any damn thing they can think of.
+
+</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Five years after Ora first started experiencing mysterious and debilitating health problems, she decides to try a treatment that she knows very well might kill her. Host Ira Glass talks to her about the experience. (9 minutes)</li><li>Act One: Two lawyers have just three months to stop their client's execution. In Texas, where this story takes place, these kinds of appeals to get people off death row fail 94% of the time. (38 minutes)</li><li>Act Two: At the Otay Mesa Detention Center in San Diego, migrants figured out an ingenious way to communicate with the activists gathered outside of the detention center’s walls. (13 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/889/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p>
+ Sun, 21 Jun 2026 18:00:00 -0400
+ 46170 at https://www.thisamericanlife.org
+ full
+ 889
+ There’s Something About Hail Mary
+ This American Life
+ false
+
+
+ 01:06:13
+ We spend an hour in the last two minutes of the fourth quarter, behind and desperate, with people trying any damn thing they can think of.
+
+ We spend an hour in the last two minutes of the fourth quarter, behind and desperate, with people trying any damn thing they can think of.
+
+
+
+Prologue: Five years after Ora first started experiencing mysterious and debilitating health problems, she decides to try a treatment that she knows very well might kill her. Host Ira Glass talks to her about the experience. (9 minutes)
+
+Act One: Two lawyers have just three months to stop their client's execution. In Texas, where this story takes place, these kinds of appeals to get people off death row fail 94% of the time. (38 minutes)
+
+Act Two: At the Otay Mesa Detention Center in San Diego, migrants figured out an ingenious way to communicate with the activists gathered outside of the detention center’s walls. (13 minutes)
+
+
+ 888: Not Today, Hades!
+ https://www.thisamericanlife.org/888/not-today-hades
+ <p>Regular people trapped inside Greek myths.
+
+</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: When a mysterious, ripped-open package arrives on Pablo's doorstep, he takes it as a sign. (4 minutes)</li><li>Act One: Pablo flies closer to the sun. (14 minutes)</li><li>Act Two: In Greek mythology, there's Hades, where everyone goes when they die. You have to cross the river Styx to get there, and there’s a gate with this three-headed dog. He’s guarding the entrance and he’s supposed to make sure only actual dead people enter. This story is about a real person in America who stood at those very gates. Which is not the easiest job it turns out, at least not right now. (24 minutes)</li><li>Act Three: A mortal gets the assignment of a lifetime — to go interview an actual god who is living on earth, traveling under the name of Lionel Messi. (11 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/888/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p>
+ Sun, 07 Jun 2026 18:00:00 -0400
+ 46163 at https://www.thisamericanlife.org
+ full
+ 888
+ Not Today, Hades!
+ This American Life
+ false
+
+
+ 00:59:14
+ Regular people trapped inside Greek myths.
+
+ Regular people trapped inside Greek myths.
+
+
+
+Prologue: When a mysterious, ripped-open package arrives on Pablo's doorstep, he takes it as a sign. (4 minutes)
+
+Act One: Pablo flies closer to the sun. (14 minutes)
+
+Act Two: In Greek mythology, there's Hades, where everyone goes when they die. You have to cross the river Styx to get there, and there’s a gate with this three-headed dog. He’s guarding the entrance and he’s supposed to make sure only actual dead people enter. This story is about a real person in America who stood at those very gates. Which is not the easiest job it turns out, at least not right now. (24 minutes)
+
+Act Three: A mortal gets the assignment of a lifetime — to go interview an actual god who is living on earth, traveling under the name of Lionel Messi. (11 minutes)
+
+
+ 887: Two Is One, One Is None!
+ https://www.thisamericanlife.org/887/two-is-one-one-is-none
+ <p>One family faces the Trump administration’s ban on trans people serving in the military, and responds with a surprising secret weapon.
+
+</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Geirid and Chrissy are extreme planners. But about a year ago, they were confronted with a situation that even they had no idea how to plan for. (4 minutes)</li><li>Act One: Geirid and Chrissy make an “in case of emergency, break glass” spreadsheet and get some big news. (14 minutes)</li><li>Act Two: Geirid and Chrissy have less than a month to make a life-changing decision. The government gives them two options, and they try to find a third. (21 minutes)</li><li>Act Three: A short story from Rachel Khong: Two people have a very consequential choice to make, given to them by God. (15 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/887/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p>
+ Sun, 17 May 2026 18:00:00 -0400
+ 46156 at https://www.thisamericanlife.org
+ full
+ 887
+ Two Is One, One Is None!
+ This American Life
+ false
+
+
+ 01:00:56
+ One family faces the Trump administration’s ban on trans people serving in the military.
+
+ One family faces the Trump administration’s ban on trans people serving in the military, and responds with a surprising secret weapon.
+
+
+
+Prologue: Geirid and Chrissy are extreme planners. But about a year ago, they were confronted with a situation that even they had no idea how to plan for. (4 minutes)
+
+Act One: Geirid and Chrissy make an “in case of emergency, break glass” spreadsheet and get some big news. (14 minutes)
+
+Act Two: Geirid and Chrissy have less than a month to make a life-changing decision. The government gives them two options, and they try to find a third. (21 minutes)
+
+Act Three: A short story from Rachel Khong: Two people have a very consequential choice to make, given to them by God. (15 minutes)
+
+
+ 886: Blackout
+ https://www.thisamericanlife.org/886/blackout
+ <p>Since the war began in Iran, we've heard very little from people inside the country — and there's a reason for that. The entire country has been under an internet blackout. We worked with reporters Roxana Saberi and Fatemeh Jamalpour to get voice memos out of the country. Even though it was dangerous and difficult, people wanted to be heard.
+
+</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Shirin's parents suddenly disappear into the blackout. (5 minutes)</li><li>Act One: It’s a war and a blackout. People want to talk about both. (17 minutes)</li><li>Act Two: What happened before America and Israel went to war with Iran. (9 minutes)</li><li>Act Three: Iranians have many opinions about the war, and about each other. (12 minutes)</li><li>Act Four: What happened inside Iran the night President Trump threatened that "a whole civilization could die." And a clue about where the internet blackout is headed. (19 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/886/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p>
+ Sun, 03 May 2026 18:00:00 -0400
+ 46146 at https://www.thisamericanlife.org
+ full
+ 886
+ Blackout
+ This American Life
+ false
+
+
+ 00:54:19
+ Iran has been under a total internet blackout since the war began. We found a way to get voice memos out.
+
+ Since the war began in Iran, we've heard very little from people inside the country — and there's a reason for that. The entire country has been under an internet blackout. We worked with reporters Roxana Saberi and Fatemeh Jamalpour to get voice memos out of the country. Even though it was dangerous and difficult, people wanted to be heard.
+
+
+
+Prologue: Shirin's parents suddenly disappear into the blackout. (5 minutes)
+
+Act One: It’s a war and a blackout. People want to talk about both. (17 minutes)
+
+Act Two: What happened before America and Israel went to war with Iran. (9 minutes)
+
+Act Three: Iranians have many opinions about the war, and about each other. (12 minutes)
+
+Act Four: What happened inside Iran the night President Trump threatened that "a whole civilization could die." And a clue about where the internet blackout is headed. (19 minutes)
+
+
+ Ira (Reluctantly) Gives a Graduation Speech
+ <p>Ira always hated commencement speeches. Then he felt like he had to give this one.</p>
+
+<p><a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes-bonus">thisamericanlife.org/lifepartners</a></p>
+ Fri, 01 May 2026 00:00:00 -0400
+ 46148 at https://www.thisamericanlife.org
+
+
+
+ https://www.thisamericanlife.org/lifepartners
+ This American Life
+ 00:08:24
+ Ira always hated commencement speeches. Then he felt like he had to give this one.
+ Ira always hated commencement speeches. Then he felt like he had to give this one.
+
+
+ 885: Bless This Mess
+ https://www.thisamericanlife.org/bless-this-mess
+ <p>At a time when the U.S. government is trying to make American history tidier, we try to learn from the mess. Including the untold, messy story of Paul and Essie Robeson.
+
+</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Prologue: Guest Host Emanuele Berry talks to Nichole Hill about the Black movie characters Nichole was curious about as a child. (7 minutes)</li><li>Act One: A giant of the Harlem Renaissance, Paul Robeson was the most famous American of his day. Until he wasn’t. Nichole Hill tells the messy, complicated story of Paul and his wife, Essie Robeson. (38 minutes)</li><li>Act Two: In 1865, a formerly enslaved man named Jourdan Anderson received a letter from his former enslaver, asking Jourdan to return to the plantation and work. Actor Laurence Fishburne reads Jourdan’s response. </li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/885/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p>
+ Sun, 12 Apr 2026 18:00:00 -0400
+ 46137 at https://www.thisamericanlife.org
+ full
+ 885
+ Bless This Mess
+ This American Life
+ false
+
+
+ 01:01:41
+ The untold, messy story of Paul and Essie Robeson.
+
+ At a time when the U.S. government is trying to make American history tidier, we try to learn from the mess. Including the untold, messy story of Paul and Essie Robeson.
+
+
+
+Prologue: Guest Host Emanuele Berry talks to Nichole Hill about the Black movie characters Nichole was curious about as a child. (7 minutes)
+
+Act One: A giant of the Harlem Renaissance, Paul Robeson was the most famous American of his day. Until he wasn’t. Nichole Hill tells the messy, complicated story of Paul and his wife, Essie Robeson. (38 minutes)
+
+Act Two: In 1865, a formerly enslaved man named Jourdan Anderson received a letter from his former enslaver, asking Jourdan to return to the plantation and work. Actor Laurence Fishburne reads Jourdan’s response.
+
+
+ 884: The Idiot
+ https://www.thisamericanlife.org/the-idiot
+ <p>M. Gessen returns to our show with a true-crime story that takes place entirely within their own family. This story comes to us from the producers at Serial Productions—who invented the true-crime podcast more than a decade ago—and from The New York Times.
+
+</p><p>Visit <a href="https://thisamericanlife.supercast.com?utm_id=lifepartners&utm_source=rss&utm_medium=shownotes">thisamericanlife.org/lifepartners</a> to sign up for our premium subscription.</p><ul><li>Act One: M Gessen tells Ira Glass about the surprising events that prompted them to begin reporting on their own family for their new podcast, The Idiot. They play the first episode of the series. (14 minutes)</li><li>Act Two: Ira Glass and M Gessen continue to talk through the story of M’s cousin, Allen Gessen. They play more clips from the podcast, and we finally hear about the big, shocking thing that snapped their family apart. (20 minutes)</li><li>Act Three: M Gessen tells Ira Glass about Allen’s trial, and we hear a recording of his conversation with the undercover agent. (21 minutes)</li></ul><p>Transcripts are available at <a href="https://www.thisamericanlife.org/884/transcript">thisamericanlife.org</a></p><p><a href='https://www.thisamericanlife.org/page/privacy-policy'>This American Life privacy policy.</a><br /><a href='https://podcastchoices.com/adchoices'>Learn more about sponsor message choices.</a></p>
+ Sun, 29 Mar 2026 18:00:00 -0400
+ 46125 at https://www.thisamericanlife.org
+ full
+ 884
+ The Idiot
+ This American Life
+ false
+
+
+ 00:59:15
+ M. Gessen returns to our show with a true crime story that all takes place in their own family.
+
+ M. Gessen returns to our show with a true-crime story that takes place entirely within their own family. This story comes to us from the producers at Serial Productions—who invented the true-crime podcast more than a decade ago—and from The New York Times.
+
+
+
+Act One: M Gessen tells Ira Glass about the surprising events that prompted them to begin reporting on their own family for their new podcast, The Idiot. They play the first episode of the series. (14 minutes)
+
+Act Two: Ira Glass and M Gessen continue to talk through the story of M’s cousin, Allen Gessen. They play more clips from the podcast, and we finally hear about the big, shocking thing that snapped their family apart. (20 minutes)
+
+Act Three: M Gessen tells Ira Glass about Allen’s trial, and we hear a recording of his conversation with the undercover agent. (21 minutes)
+
+
+
diff --git a/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life_art.jpg b/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life_art.jpg
new file mode 100644
index 000000000..e37ab813b
Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/fixtures/this_american_life_art.jpg differ
diff --git a/src/Comet/sample/Shared/Jetcaster/gold/compact-01-discover.png b/src/Comet/sample/Shared/Jetcaster/gold/compact-01-discover.png
new file mode 100644
index 000000000..68bc1140d
Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/gold/compact-01-discover.png differ
diff --git a/src/Comet/sample/Shared/Jetcaster/gold/compact-02-podcast-details.png b/src/Comet/sample/Shared/Jetcaster/gold/compact-02-podcast-details.png
new file mode 100644
index 000000000..4ff39f674
Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/gold/compact-02-podcast-details.png differ
diff --git a/src/Comet/sample/Shared/Jetcaster/gold/compact-03-player.png b/src/Comet/sample/Shared/Jetcaster/gold/compact-03-player.png
new file mode 100644
index 000000000..dd8c6bb21
Binary files /dev/null and b/src/Comet/sample/Shared/Jetcaster/gold/compact-03-player.png differ
diff --git a/src/Comet/sample/Shared/Jetchat/JetchatConversation.cs b/src/Comet/sample/Shared/Jetchat/JetchatConversation.cs
new file mode 100644
index 000000000..ab9bee1a3
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetchat/JetchatConversation.cs
@@ -0,0 +1,782 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using Comet;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+
+namespace CometSamples.Jetchat
+{
+ ///
+ /// A faithful Comet port of the conversation screen from Google's official
+ /// android/compose-samplesJetchat (the gold standard for a Compose-vs-Comet
+ /// comparison). Rendered on the node backend (Yoga layout, no MAUI handlers), it mirrors the
+ /// real screen: the centered #composers channel bar, the author-grouped message log
+ /// (42dp circular avatars, name+timestamp headers, the iconic RoundedCornerShape(4,20,20,20)
+ /// chat bubbles in primary/surfaceVariant, the 160dp sticker), and the bottom input bar.
+ /// Colors, spacing, shapes, the nine messages and the real avatar/sticker assets are taken
+ /// straight from the sample.
+ ///
+ public static class JetchatConversation
+ {
+ // Semantic color roles come from the centralized JetchatTheme (mirrors theme/Themes.kt);
+ // these are local aliases so the screen reads tokens, not hardcoded hex.
+ // Read live from JetchatTheme (computed, not cached) so a runtime scheme swap (Material You,
+ // JetchatTheme.ApplyScheme) reaches these before the tree is built.
+ internal static Color Primary => JetchatTheme.Primary;
+ internal static Color OnPrimary => JetchatTheme.OnPrimary;
+ internal static Color InversePrimary => JetchatTheme.InversePrimary;
+ internal static Color Secondary => JetchatTheme.Secondary;
+ internal static Color Surface => JetchatTheme.Surface;
+ internal static Color OnSurface => JetchatTheme.OnSurface;
+ internal static Color SurfaceVariant => JetchatTheme.SurfaceVariant;
+ internal static Color OnSurfaceVariant => JetchatTheme.OnSurfaceVariant;
+ internal static Color Tertiary => JetchatTheme.Tertiary;
+ internal static Color Divider => JetchatTheme.Divider;
+ internal static Color BarSurface => JetchatTheme.SurfaceTinted;
+ internal static Color Disabled => JetchatTheme.Disabled;
+
+ // Bundled drawables (Jetchat ships these as local resources via painterResource, so they
+ // render offline and without a network round-trip). The backends resolve a bare name to a
+ // platform resource: Android drawable / iOS bundle image.
+ internal const string AvatarMe = "ali";
+ internal const string AvatarOther = "someone_else";
+ const string Sticker = "sticker";
+
+ sealed record Msg(string Author, string Content, string Timestamp, bool HasImage = false);
+
+ // FakeData.initialMessages is newest-first (the LazyColumn is reverseLayout); displayed
+ // top-to-bottom it is the reverse — oldest at top. Authored exactly as the sample.
+ static readonly List Display = BuildDisplay();
+
+ static List BuildDisplay()
+ {
+ var list = new List
+ {
+ new("John Glenn", "Yeah its seems to be pretty new!", "8:12 PM"),
+ new("Taylor Brooks", "Wow! I never knew about Glance Widgets when was this added to the android ecosystem", "8:10 PM"),
+ new("Shangeeth Sivan", "Does anyone know about Glance Widgets its the new way to build widgets in Android!", "8:08 PM"),
+ new("me", "Compose newbie: I’ve scourged the internet for tutorials about async data loading but haven’t found any good ones 🫠☁️. What’s the recommended way to load async data and emit composable widgets?", "8:03 PM"),
+ new("John Glenn", "Compose newbie as well 🦩, have you looked at the JetNews sample? Most blog posts end up out of date pretty fast but this sample is always up to date and deals with async data loading (it’s faked but the same idea applies) 👉 https://goo.gle/jetnews", "8:04 PM"),
+ new("Taylor Brooks", "@aliconors Take a look at the `Flow.collectAsStateWithLifecycle()` APIs", "8:05 PM"),
+ new("Taylor Brooks", "You can use all the same stuff", "8:05 PM"),
+ new("me", "Thank you!🥷", "8:06 PM", HasImage: true),
+ new("me", "Check it out!", "8:07 PM"),
+ };
+
+ // ~100 extra messages so the LazyColumn actually virtualizes/recycles while scrolling.
+ // Varied authors + lengths (some wrap, some carry @mentions / `code` / links) so each row
+ // re-measures realistically rather than all being identical.
+ string[] authors = { "John Glenn", "Taylor Brooks", "Shangeeth Sivan", "me", "Ali Conors" };
+ string[] bodies =
+ {
+ "👍",
+ "Sounds good to me",
+ "A medium-length reply that wraps onto a second line on most phones for layout variety",
+ "@aliconors did you try the `rememberLazyListState()` API for scroll position?",
+ "Here's the doc with the full async-loading walkthrough 👉 https://goo.gle/jetnews",
+ "Longer one: virtualization means only the rows on screen are composed and measured, so a list of hundreds should scroll just as smoothly as a list of nine — that's the whole point of LazyColumn 🚀",
+ "`collectAsStateWithLifecycle()` is the move",
+ "Nice 🔥",
+ };
+ for (int i = 0; i < 100; i++)
+ list.Add(new Msg(authors[i % authors.Length], bodies[i % bodies.Length], $"7:{59 - i % 60:D2} PM"));
+
+ return list;
+ }
+
+ // Flattened rows for the message LazyColumn: the "Today" header plus each message tagged with
+ // its grouping (top-of-group shows the avatar + author; bottom-of-group gets extra spacing).
+ abstract record Row;
+ sealed record HeaderRow(string Day) : Row;
+ sealed record MsgRow(Msg M, bool TopOfGroup, double BottomSpace) : Row;
+
+ static readonly List Rows = BuildRows();
+
+ // Display = [9 gold messages][~100 history]. Render history (older) first, then the gold's two
+ // hardcoded day dividers ("20 Aug" + "Today") around the 9-message conversation — so the newest
+ // real message ("Check it out!") is the LAST row and the list opens scrolled to it (Conversation.kt
+ // reverseLayout + the index==size-1 / index==2 DayHeaders).
+ const int GoldCount = 9;
+
+ static List BuildRows()
+ {
+ var real = Display.GetRange(0, GoldCount);
+ var history = Display.GetRange(GoldCount, Display.Count - GoldCount);
+
+ var rows = new List();
+ AddMessageRows(rows, history); // older history (top)
+ rows.Add(new HeaderRow("20 Aug"));
+ AddMessageRows(rows, real.GetRange(0, 3)); // John 8:12, Taylor 8:10, Shangeeth 8:08
+ rows.Add(new HeaderRow("Today"));
+ AddMessageRows(rows, real.GetRange(3, real.Count - 3)); // me 8:03 … Check it out 8:07
+ return rows;
+ }
+
+ // Append message rows for one contiguous segment, computing author-grouping within it (the
+ // first row starts a new group — a header/segment boundary precedes it).
+ static void AddMessageRows(List rows, IReadOnlyList msgs)
+ {
+ for (int i = 0; i < msgs.Count; i++)
+ {
+ var m = msgs[i];
+ bool topOfGroup = i == 0 || msgs[i - 1].Author != m.Author; // avatar + name here
+ bool bottomOfGroup = i == msgs.Count - 1 || msgs[i + 1].Author != m.Author;
+ rows.Add(new MsgRow(m, topOfGroup, bottomOfGroup ? 8 : 4));
+ }
+ }
+
+ /// Builds the conversation screen. /
+ /// are the platform safe-area insets (status bar / home indicator) in Dp.
+ internal static readonly Comet.Reactive.Signal DrawerOpen = new(false);
+
+ // Drives the "Functionality not available 🙊" AlertDialog (Jetchat's NotAvailablePopup),
+ // opened from the DM ("@") input selector — exactly `InputSelector.DM -> NotAvailablePopup`.
+ static readonly Comet.Reactive.Signal NotAvailableOpen = new(false);
+
+ // The composer's text, two-way bound to the input field. The Send button reads it to toggle
+ // enabled/style, and a send appends a "me" message + clears it (UserInput.kt sendMessageEnabled).
+ static readonly Comet.Reactive.Signal InputText = new(string.Empty);
+
+ // The active input selector (gold UserInput.kt InputSelector enum). Drives the expandable panel
+ // (emoji table / not-available pane — the F1 swap-panel capability) and the selected-icon
+ // highlight. The indices match the enum so the SelectorPanel slot list lines up with the icons.
+ const int SelNone = 0, SelMap = 1, SelDm = 2, SelEmoji = 3, SelPhone = 4, SelPicture = 5;
+ static readonly Comet.Reactive.Signal CurrentSelector = new(SelNone);
+
+ // Voice-record indicator state (gold UserInput.kt RecordButton/RecordingIndicator). The live
+ // mm:ss elapsed time shown while the mic is held, reactively bound to the indicator's Text. The
+ // gold records no audio — RecordButton is a pure UI mock (it just toggles a flag) — and so do we;
+ // this is the gesture + indicator reproduction. Android-only: the iOS mic stays static.
+ static readonly Comet.Reactive.Signal RecordDuration = new("00:00");
+
+ /// The Jetchat sample. The drawer wraps the whole stack
+ /// (like the gold's ModalNavigationDrawer around the NavHost), so the jetchat logo opens
+ /// it over ANY destination — the conversation or a profile. Tapping a drawer profile closes the
+ /// drawer and Navigates the profile detail onto the stack; the profile's Message FAB
+ /// Pops it.
+ // The drawer's current destination (a channel key like "composers" or a profile name), so the
+ // drawer highlights the active item; "composers" is the initial channel.
+ static readonly Comet.Reactive.Signal Selected = new("composers");
+ static string _lastChannel = "composers";
+
+ public static View Build(double topInset = 24, double bottomInset = 0)
+ {
+ var nav = new NavigationView();
+ int depth = 0; // profiles currently pushed above the conversation
+
+ // A drawer channel tap: return to the messages view (pop any profiles) and mark it selected.
+ void SelectChannel(string name)
+ {
+ Selected.Value = name;
+ _lastChannel = name;
+ while (depth > 0) { nav.Pop(); depth--; }
+ DrawerOpen.Value = false;
+ }
+ // A drawer profile tap: mark it selected and push the profile detail (the Message FAB /
+ // back restores the last channel as the selection).
+ void OpenProfile(string profileName)
+ {
+ Selected.Value = profileName;
+ DrawerOpen.Value = false;
+ depth++;
+ nav.Navigate(JetchatProfile.Screen(profileName, topInset, bottomInset,
+ () => { nav.Pop(); depth--; Selected.Value = _lastChannel; }));
+ }
+
+ // A @mention tap (from the message formatter) resolves its handle to a profile and pushes it.
+ NavigateToProfile = handle => { if (ProfileForHandle(handle) is { } n) OpenProfile(n); };
+
+ nav.Content = ConversationView(topInset, bottomInset);
+ // Settings → the gold adds a home-screen widget; iOS has no equivalent, so surface the
+ // "functionality not available" popup (same as the DM selector).
+ void OpenSettings() => NotAvailableOpen.Value = true;
+ return new Drawer(DrawerOpen, JetchatDrawer.Content(topInset, Selected, SelectChannel, OpenProfile, OpenSettings), nav);
+ }
+
+ /// The conversation screen (the drawer's content slot / nav root screen).
+ internal static View ConversationView(double topInset, double bottomInset)
+ {
+ var log = MessageLog();
+
+ return new VStack(spacing: 0f)
+ {
+ ChannelNameBar(topInset),
+
+ // The message log (a LazyColumn) scrolls itself between the fixed bars; the
+ // JumpToBottom FAB floats over its bottom-center (a ZStack overlay = Jetchat's
+ // Messages() Box), fading in only while the log is scrolled away from the newest
+ // message — the reactive-visibility + scroll-state capability (JumpToBottom.kt).
+ new ZStack
+ {
+ log.FillHorizontal().FillVertical(),
+ JumpToBottom(log)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center)
+ .VerticalLayoutAlignment(LayoutAlignment.End)
+ .Margin(bottom: 32),
+
+ // The NotAvailable popup lives here as a zero-size overlay; it's a real Material
+ // AlertDialog (its own window + scrim) that pops over everything when opened.
+ NotAvailablePopup(),
+ }.FillVertical(),
+
+ UserInput(bottomInset, log),
+ }.Background(Surface);
+ }
+
+ // ── Channel bar: ‹jetchat-logo⟩ ⟨#composers / 42 members⟩ ⌕ ⓘ ── (no bottom line; the
+ // background tint separates it from the body). Mirrors a CenterAlignedTopAppBar: equal-flex
+ // side zones screen-center the title; 16dp edge insets via the bar's own padding (leaf
+ // padding is ignored by the layout engine, so spacing comes from the zones/margins). ──
+ static View ChannelNameBar(double topInset) => new HStack(spacing: 0f)
+ {
+ // Left zone (flex): the jetchat logo at the start; the spacer balances the right zone.
+ new HStack(spacing: 0f)
+ {
+ new Icon("jetchat").IconSize(28) // multicolor brand logo (no tint → keeps its colors)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .OnTap(_ => DrawerOpen.Value = true), // tap the logo → open the nav drawer
+ Spacer(),
+ }.FlexGrow(1).FlexBasis(0),
+
+ new VStack(spacing: 0f)
+ {
+ new Text("#composers").Color(OnSurface).TitleMedium()
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ new Text("42 members").Color(OnSurfaceVariant).BodySmall()
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ }.VerticalLayoutAlignment(LayoutAlignment.Center),
+
+ // Right zone (flex, equal weight → title sits at true screen-center): the actions at the end.
+ new HStack(spacing: 0f)
+ {
+ Spacer(),
+ BarIcon("search", () => NotAvailableOpen.Value = true).Margin(right: 20),
+ BarIcon("info", () => NotAvailableOpen.Value = true),
+ }.FlexGrow(1).FlexBasis(0),
+ }.Padding(new Thickness(16, topInset + 12, 16, 12)).Background(Surface); // CenterAlignedTopAppBar = plain surface
+
+ // JetchatIcon (components/JetchatIcon.kt): the two-layer, theme-TINTED brand mark — ic_jetchat_back
+ // (primaryContainer) under ic_jetchat_front (primary), stacked. Unlike the static multicolor
+ // ic_jetchat, it tints with the Material scheme (so it tracks Material You / the brand seed). Both
+ // layers are tinted Icons (non-multicolor → the tinted Icon path = the gold's colorFilter.tint).
+ internal static View JetchatIcon(double size = 24) => new ZStack
+ {
+ new Icon("jetchat_back").Color(JetchatTheme.PrimaryContainer).IconSize(size),
+ new Icon("jetchat_front").Color(Primary).IconSize(size),
+ }.Frame(width: (float)size, height: (float)size).FlexShrink(0);
+
+ // A real Material Icon (ImageVector / SF Symbol), 24dp, tinted onSurfaceVariant, centered.
+ // Search/info tap the "functionality not available" popup (Conversation.kt clickable actions).
+ static View BarIcon(string symbol, System.Action? onTap = null)
+ {
+ var icon = new Icon(symbol).Color(OnSurfaceVariant).IconSize(24)
+ .VerticalLayoutAlignment(LayoutAlignment.Center);
+ return onTap is null ? icon : icon.OnTap(_ => onTap());
+ }
+
+ // ── Message log: a real virtualized LazyColumn (Comet ListView), exactly like the gold
+ // standard's Messages() — each row template is materialized only when it scrolls into view. ──
+ static ListView MessageLog() => new ListView(() => Rows)
+ {
+ AnchorBottom = true, // chat log: open at the newest message (gold reverseLayout)
+ ViewFor = r => r switch
+ {
+ HeaderRow h => DayHeader(h.Day),
+ MsgRow m => MessageRow(m.M, m.TopOfGroup, m.BottomSpace),
+ _ => new VStack(),
+ },
+ };
+
+ // ── JumpToBottom (JumpToBottom.kt): a real Material ExtendedFloatingActionButton — surface
+ // container, primary content, 36dp tall, a down-arrow + "Jump to bottom". Hidden until the
+ // log scrolls away from the newest message (reactive Opacity bound to the list's ScrolledAway
+ // signal); tapping animates the log to the bottom (LazyListState scroller). The icon/label
+ // carry no colour so they inherit the FAB's primary content colour. The gold uses
+ // ExtendedFloatingActionButton (always extended — never contracted); we match that here now
+ // that the ExtendedFAB slot bug is fixed via the RenderDirect bridge path. ──
+ static View JumpToBottom(ListView list)
+ {
+ var fab = new Comet.Fab(
+ // Colour the icon + label explicitly (not just via the FAB's contentColor): Compose's
+ // ExtendedFAB tints its slot content through LocalContentColor, but the SwiftUI FAB renders
+ // the children directly, so without an explicit colour the iOS label rendered invisible.
+ icon: new Icon("arrow_down").IconSize(18).Color(Primary),
+ label: new Text("Jump to bottom").LabelSmall().Color(Primary),
+ onClick: () => list.ScrollToBottom(),
+ height: 36,
+ containerColor: Surface,
+ contentColor: Primary,
+ extended: true) // always extended — the gold's ExtendedFloatingActionButton never contracts
+ .Opacity(0); // hidden until the log is scrolled away from the newest message
+
+ // Reactive show/hide: the backend node drives ScrolledAway from the LazyListState; mirror
+ // it onto the FAB's opacity (ComposeFabNode keeps it composed but pushes it off-screen).
+ list.ScrolledAway.PropertyChanged += (_, __) =>
+ fab.Opacity(list.ScrolledAway.Peek() ? 1.0 : 0.0);
+
+ return fab;
+ }
+
+ static View MessageRow(Msg m, bool topOfGroup, double bottomSpace)
+ {
+ bool isMe = m.Author == "me";
+
+ // Left gutter: a 42dp avatar (author ring + a surface "halo" gap around the photo) on
+ // the group's top message; a 74dp spacer otherwise — so a run of messages by one author
+ // lines up under the avatar. The 3px surface padding inside the ring is the halo.
+ View gutter = topOfGroup
+ ? new VStack(spacing: 0f)
+ {
+ new Image(isMe ? AvatarMe : AvatarOther).Frame(width: 36, height: 36).CornerRadius(18).FlexShrink(0),
+ }
+ .Frame(width: 42, height: 42)
+ .FlexShrink(0) // never compress the avatar's width when the row's text is wide
+ .Padding(new Thickness(3))
+ .Background(Surface)
+ .CornerRadius(21)
+ .Border(1.5, isMe ? Primary : Tertiary) // Conversation.kt: border(1.5.dp, borderColor)
+ .Margin(left: 16, right: 16)
+ .VerticalLayoutAlignment(LayoutAlignment.Start) // stay 42×42, don't stretch to row height
+ : new HStack().Frame(width: 74).FlexShrink(0);
+
+ var column = new VStack(spacing: 0f);
+ if (topOfGroup)
+ column.Add(AuthorNameTimestamp(m));
+ column.Add(Bubble(m.Content, isMe));
+ if (m.HasImage)
+ {
+ column.Add(new HStack().Frame(height: 4)); // 4dp gap to the image bubble
+ column.Add(StickerBubble(isMe));
+ }
+ column.Add(new HStack().Frame(height: (float)bottomSpace));
+
+ return new HStack(spacing: 0f)
+ {
+ gutter,
+ column.FlexGrow(1).Padding(new Thickness(0, 0, 16, 0)),
+ }.Padding(new Thickness(0, topOfGroup ? 8 : 0, 0, 0));
+ }
+
+ static View AuthorNameTimestamp(Msg m) => new HStack(spacing: 8f)
+ {
+ new Text(m.Author).Color(OnSurface).TitleMedium().AlignBaseline(),
+ new Text(m.Timestamp).Color(OnSurfaceVariant).BodySmall().AlignBaseline(),
+ }.Padding(new Thickness(0, 0, 0, 4));
+
+ // The iconic chat bubble: RoundedCornerShape(4,20,20,20); primary/white for me,
+ // surfaceVariant/onSurface for others. Left-aligned and hugging its content (it grows to
+ // the column width only when the text wraps).
+ static View Bubble(string content, bool isMe) => new VStack(spacing: 0f)
+ {
+ new FormattedText(FormatMessage(content, isMe)).BodyLarge(),
+ }
+ .AsSurface() // the gold standard draws the bubble with Surface(color, shape)
+ .Padding(new Thickness(16))
+ .Background(isMe ? Primary : SurfaceVariant)
+ .CornerRadius(JetchatTheme.BubbleTopStart, JetchatTheme.BubbleOther, JetchatTheme.BubbleOther, JetchatTheme.BubbleOther)
+ .HorizontalLayoutAlignment(LayoutAlignment.Start);
+
+ // The C# port of Jetchat's messageFormatter (MessageFormatter.kt). One pass over the gold
+ // symbolPattern: links + `code` + @mentions + *bold* + _italic_ + ~strike~. @mentions are
+ // bold and tap to the author's profile; links tap to the browser (no underline — gold colors
+ // only); `code` is monospace 12sp in a tonal box. The accent is inversePrimary on my (primary)
+ // bubbles and primary on others; the code box is secondary(me)/surface(other) — all from source.
+ static readonly Regex SymbolPattern = new(
+ @"(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)",
+ RegexOptions.Compiled);
+
+ static System.Collections.Generic.IReadOnlyList FormatMessage(string content, bool isMe)
+ {
+ var baseColor = isMe ? OnPrimary : OnSurface;
+ var accent = isMe ? InversePrimary : Primary; // gold: inversePrimary(me) / primary(other)
+ var codeBg = isMe ? Secondary : Surface; // gold: secondary(me) / surface(other)
+
+ var runs = new System.Collections.Generic.List();
+ int cursor = 0;
+ foreach (Match m in SymbolPattern.Matches(content))
+ {
+ if (m.Index > cursor)
+ runs.Add(new TextRun(content.Substring(cursor, m.Index - cursor), Color: baseColor));
+
+ string tok = m.Value;
+ switch (tok[0])
+ {
+ case '@':
+ string handle = tok.Substring(1);
+ runs.Add(new TextRun(tok, Color: accent, Bold: true,
+ OnTap: () => NavigateToProfile?.Invoke(handle)));
+ break;
+ case '`':
+ runs.Add(new TextRun(tok.Trim('`'), Color: baseColor, Monospace: true,
+ FontSize: 12, Background: codeBg));
+ break;
+ case '*':
+ runs.Add(new TextRun(tok.Trim('*'), Color: baseColor, Bold: true));
+ break;
+ case '_':
+ runs.Add(new TextRun(tok.Trim('_'), Color: baseColor, Italic: true));
+ break;
+ case '~':
+ runs.Add(new TextRun(tok.Trim('~'), Color: baseColor, Strikethrough: true));
+ break;
+ default: // http(s) link
+ runs.Add(new TextRun(tok, Color: accent, OnTap: () => OpenUrl?.Invoke(tok)));
+ break;
+ }
+ cursor = m.Index + m.Length;
+ }
+ if (cursor < content.Length)
+ runs.Add(new TextRun(content.Substring(cursor), Color: baseColor));
+ return runs;
+ }
+
+ // Tap hooks, wired in Build(). A @mention resolves its handle to a profile and navigates;
+ // a link opens the URL (the host wires a real browser-open). Both no-op until Build() runs.
+ internal static System.Action? NavigateToProfile;
+ internal static System.Action? OpenUrl;
+
+ // The seed data's only mention is @aliconors; map the known handles to their profile names.
+ static string? ProfileForHandle(string handle) => handle.ToLowerInvariant() switch
+ {
+ "aliconors" => "Ali Conors",
+ "taylor" or "taylorbrookscodes" => "Taylor Brooks",
+ _ => null,
+ };
+
+ static View StickerBubble(bool isMe) => new VStack(spacing: 0f)
+ {
+ new Image(Sticker).Frame(width: 160, height: 160),
+ }
+ .Background(isMe ? Primary : SurfaceVariant)
+ .CornerRadius(JetchatTheme.BubbleTopStart, JetchatTheme.BubbleOther, JetchatTheme.BubbleOther, JetchatTheme.BubbleOther)
+ .AsSurface()
+ .HorizontalLayoutAlignment(LayoutAlignment.Start);
+
+ // Row of: rule — "Today" — rule, all vertically centered (the rule aligns to the text's
+ // middle). Text given full height + center alignment so it isn't cropped.
+ static View DayHeader(string day) => new HStack(spacing: 0f)
+ {
+ HLine().FlexGrow(1).VerticalLayoutAlignment(LayoutAlignment.Center),
+ new Text(day).Color(OnSurfaceVariant).LabelSmall()
+ .VerticalLayoutAlignment(LayoutAlignment.Center).Padding(new Thickness(16, 0, 16, 0)),
+ HLine().FlexGrow(1).VerticalLayoutAlignment(LayoutAlignment.Center),
+ }.Padding(new Thickness(16, 8, 16, 8));
+
+ // ── Bottom input bar (same tint as the header; no top line). Row 1: a borderless,
+ // full-width text field bound two-way to InputText. Row 2: the blue action icons spread
+ // evenly to a Send button that reacts to whether there's text (UserInput.kt). ──
+ static View UserInput(double bottomInset, ListView log)
+ {
+ // The Send button: empty → a bordered, grey OutlinedButton (the gold's disabled Send);
+ // once there's text → a filled primary Button. The style is flipped reactively from the
+ // InputText signal (mirrors `enabled = sendMessageEnabled`).
+ var send = new Button("Send", () => Send(log))
+ .LabelLarge().CornerRadius(18).Frame(height: 36)
+ .VerticalLayoutAlignment(LayoutAlignment.Center);
+
+ void Restyle()
+ {
+ if (string.IsNullOrWhiteSpace(InputText.Peek()))
+ send.Outlined(true).Color(Disabled).Background(Colors.Transparent); // empty → bordered, grey
+ else
+ send.Outlined(false).Color(OnPrimary).Background(Primary); // text → filled primary
+ }
+ Restyle();
+ InputText.PropertyChanged += (_, __) => Restyle();
+
+ // The expandable selector panel (gold UserInput.kt SelectorExpanded): one Surface that swaps
+ // content by the active InputSelector. Slot indices match the enum; null slots collapse it.
+ // EMOJI → the emoji table; MAP/PHONE/PICTURE → the 320dp "not available" pane; NONE/DM →
+ // nothing (DM opens the separate not-available dialog). Opening a panel grows the footer and
+ // shrinks the message list (the panel reports its height to the Yoga engine), exactly like
+ // the gold's bottom-anchored Surface; a system back press collapses it.
+ var panel = new SelectorPanel(CurrentSelector, new View?[]
+ {
+ null, // NONE
+ NotAvailablePanel(), // MAP
+ null, // DM (separate dialog)
+ EmojiPanel(), // EMOJI
+ NotAvailablePanel(), // PHONE
+ NotAvailablePanel(), // PICTURE
+ }).Background(JetchatTheme.SurfaceTinted8).FillHorizontal();
+
+ // The borderless composer field — gold UserInputText. Send-on-keyboard: the IME action key is a
+ // paper-plane "Send" that submits (gold KeyboardOptions(imeAction = Send)); focusing it closes
+ // any open selector panel (gold onTextFieldFocused) so the keyboard never overlays it.
+ var field = SignalExtensions.TextField(InputText, "Message #composers", completed: () => Send(log))
+ .SendOnReturn()
+ .OnFocused(() => { CurrentSelector.Value = SelNone; log.ScrollToBottom(); })
+ .Borderless().Color(OnSurface).FillHorizontal()
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .Padding(new Thickness(20, 0, 8, 0));
+ ComposerField = field;
+
+ // The recording indicator that replaces the field while the mic is held (gold RecordingIndicator):
+ // a red dot, the live mm:ss elapsed time, and a "Swipe to cancel" hint that fades as the drag
+ // nears the cancel threshold. Hidden (Opacity 0) until a long-press starts recording. Both
+ // backends honour Opacity and make a hidden node non-interactive, so the SAME swap works on iOS —
+ // where the record gesture is dormant (press-and-hold is a Compose-only gesture) and the
+ // indicator simply stays hidden over the field.
+ var swipeHint = new Text("Swipe to cancel").BodyLarge().Color(OnSurfaceVariant)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center)
+ .VerticalLayoutAlignment(LayoutAlignment.Center);
+ var recordDot = new HStack().Frame(width: 10, height: 10).CornerRadius(5).Background(Colors.Red)
+ .VerticalLayoutAlignment(LayoutAlignment.Center);
+ var indicator = new HStack(spacing: 12f)
+ {
+ recordDot,
+ new Text(RecordDuration).BodyLarge().Color(OnSurface)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ swipeHint.FillHorizontal(),
+ }.FillHorizontal().Padding(new Thickness(20, 0, 8, 0)).Opacity(0);
+
+ // The mic — gold RecordButton. A 48dp touch target (so the long-press lands easily) carrying the
+ // press-and-hold voice-record gesture: long-press starts, drag tracks the swipe offset, release
+ // finishes, swipe-left past the threshold cancels (gold detectDragGesturesAfterLongPress). On iOS
+ // the SwiftUI backend has no record gesture wired, so this is dormant there.
+ var micIcon = new Icon("mic").Color(OnSurfaceVariant).IconSize(24)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center)
+ .VerticalLayoutAlignment(LayoutAlignment.Center);
+ var mic = new ZStack { micIcon }
+ .Frame(width: 48, height: 48).Margin(right: 8)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .OnRecord(g => HandleRecord(g, field, indicator, swipeHint, micIcon, recordDot));
+
+ return new VStack(spacing: 0f)
+ {
+ // Input row: a fixed 64dp row (the gold's UserInputText height). The field and the recording
+ // indicator share the leading cell (one fades out as the other fades in); the mic trails.
+ new HStack(spacing: 0f)
+ {
+ new ZStack { field, indicator }.FillHorizontal(),
+ mic,
+ }.Frame(height: 64),
+ // Selector row (gold UserInputSelector): five toggle icons packed at the start, a flexible
+ // gap, then Send. 72dp tall with 16dp bottom padding — height(72).padding(start=16, end=16,
+ // bottom=16). Icon→selector: mood=EMOJI, at=DM, photo=PICTURE, place=MAP, video=PHONE.
+ new HStack(spacing: 0f)
+ {
+ SelectorButton("mood", SelEmoji),
+ SelectorButton("at", SelDm),
+ SelectorButton("photo", SelPicture),
+ SelectorButton("place", SelMap),
+ SelectorButton("video", SelPhone),
+ Spacer(),
+ send,
+ }.Frame(height: 72).Padding(new Thickness(16, 0, 16, 16)),
+ panel,
+ // Safe-area fill: extends BarSurface behind the system navigation bar / home indicator.
+ new HStack().Frame(height: (float)bottomInset),
+ }.Background(BarSurface);
+ }
+
+ // Append the composed text as a "me" message, clear the field, and scroll to the newest
+ // (the C1 LazyListState scroller). Mirrors UserInput.kt onMessageSent + resetScroll.
+ static void Send(ListView log)
+ {
+ var text = InputText.Peek();
+ if (string.IsNullOrWhiteSpace(text))
+ return;
+
+ bool topOfGroup = Rows.Count == 0 || Rows[Rows.Count - 1] is not MsgRow last || last.M.Author != "me";
+ Rows.Add(new MsgRow(new Msg("me", text.Trim(), System.DateTime.Now.ToString("h:mm tt")), topOfGroup, 8));
+
+ InputText.Value = string.Empty; // two-way binding clears the field
+ log.ReloadData(); // recompose the LazyColumn against the new row
+ log.ScrollToBottom(); // animate to the newest message
+ }
+
+ // Drive the voice-record indicator from the press-and-hold gesture (gold RecordButton's
+ // onStartRecording/onFinishRecording/onCancelRecording + RecordingIndicator). The gold records
+ // nothing — RecordButton is a pure UI mock — so this only swaps the field for the indicator, runs
+ // the elapsed timer, and fades the "Swipe to cancel" hint as the drag nears the threshold.
+ static void HandleRecord(RecordGesture g, View field, View indicator, View swipeHint, View micIcon, View recordDot)
+ {
+ switch (g.Status)
+ {
+ case Comet.GestureStatus.Started:
+ swipeHint.Opacity(1);
+ field.Opacity(0); // fade the field out …
+ indicator.Opacity(1); // … and the recording indicator in
+ micIcon.Color(Primary); // tint the mic active (gold animates the icon colour)
+ // The gold's infiniteRepeatable alpha pulse on the red dot, driven by
+ // Comet's own animation engine (Choreographer-ticked on the node backend).
+ recordDot.Animate(v => v.Opacity(0.2), duration: 0.6,
+ repeats: true, autoReverses: true, id: "recordPulse");
+ StartRecordTimer();
+ break;
+ case Comet.GestureStatus.Running:
+ // Fade the hint as the leftward swipe approaches the 200dp cancel threshold (gold alpha).
+ double frac = System.Math.Min(1.0, System.Math.Abs(g.TotalX) / 200.0);
+ swipeHint.Opacity(1.0 - frac);
+ break;
+ case Comet.GestureStatus.Completed:
+ case Comet.GestureStatus.Canceled:
+ StopRecordTimer();
+ recordDot.AbortAnimation("recordPulse");
+ recordDot.Opacity(1);
+ field.Opacity(1); // restore the field
+ indicator.Opacity(0);
+ micIcon.Color(OnSurfaceVariant);
+ break;
+ }
+ }
+
+ // The recording elapsed-time ticker (gold RecordingIndicator's LaunchedEffect { while(true) delay(1000) }).
+ static System.Threading.Timer? _recordTimer;
+ static int _recordSeconds;
+ static void StartRecordTimer()
+ {
+ _recordSeconds = 0;
+ RecordDuration.Value = "00:00";
+ _recordTimer?.Dispose();
+ _recordTimer = new System.Threading.Timer(_ =>
+ {
+ _recordSeconds++;
+ RecordDuration.Value = $"{_recordSeconds / 60:00}:{_recordSeconds % 60:00}";
+ }, null, 1000, 1000);
+ }
+ static void StopRecordTimer()
+ {
+ _recordTimer?.Dispose();
+ _recordTimer = null;
+ }
+
+ // A selector icon (gold InputSelectorButton): a 48dp rounded touch target. When its selector is
+ // active it fills with the footer's content colour (secondary) clipped to RoundedCornerShape(14)
+ // and the glyph flips to the contrasting onSecondary tint; otherwise a plain onSurfaceVariant
+ // glyph. The fill + tint flip reactively from the CurrentSelector signal (the same restyle
+ // pattern the Send button uses). The icon is centred in the box by a ZStack.
+ static View SelectorButton(string symbol, int selector)
+ {
+ var icon = new Icon(symbol).IconSize(24).Color(OnSurfaceVariant)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center)
+ .VerticalLayoutAlignment(LayoutAlignment.Center);
+ var box = new ZStack { icon }
+ .Frame(width: 48, height: 48).CornerRadius(14)
+ .OnTap(_ => OnSelector(selector));
+
+ void Restyle()
+ {
+ bool sel = CurrentSelector.Peek() == selector;
+ box.Background(sel ? Secondary : Colors.Transparent);
+ icon.Color(sel ? JetchatTheme.OnSecondary : OnSurfaceVariant);
+ }
+ Restyle();
+ CurrentSelector.PropertyChanged += (_, __) => Restyle();
+ return box;
+ }
+
+ // Tap a selector icon. EMOJI/PICTURE/MAP/PHONE open that panel; DM closes any panel and shows the
+ // not-available dialog (gold InputSelector.DM -> NotAvailablePopup). Matches the gold's
+ // non-toggling onSelectorChange — the panel's BackHandler (or picking another selector) dismisses.
+ static void OnSelector(int selector)
+ {
+ if (selector == SelDm)
+ {
+ CurrentSelector.Value = SelNone;
+ NotAvailableOpen.Value = true;
+ return;
+ }
+ // Tapping the already-open selector collapses it — a discoverable dismiss (the gold doesn't
+ // toggle, but its back-press + tap-the-field dismisses aren't obvious on a gesture-nav device).
+ CurrentSelector.Value = CurrentSelector.Peek() == selector ? SelNone : selector;
+ }
+
+ // ── SelectorExpanded panes (gold UserInput.kt) ───────────────────────────────────────────────
+
+ // FunctionalityNotAvailablePanel: a 320dp pane with a vertically-centred title + subtitle, shown
+ // for the MAP / PHONE / PICTURE selectors.
+ static View NotAvailablePanel() => new VStack(spacing: 0f)
+ {
+ Spacer(),
+ new Text("Functionality currently not available").Color(OnSurface).TitleMedium()
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ new Text("Grab a beverage and check back later!").Color(OnSurfaceVariant).BodyMedium()
+ .HorizontalLayoutAlignment(LayoutAlignment.Center).Margin(top: 16),
+ Spacer(),
+ }.Frame(height: 320).FillHorizontal();
+
+ // EmojiSelector: Emojis/Stickers tabs over a 4×10 emoji table. The Emojis tab is always shown
+ // selected (the gold hardcodes selected=true); the Stickers tab pops the not-available dialog.
+ static View EmojiPanel() => new VStack(spacing: 0f)
+ {
+ new HStack(spacing: 0f)
+ {
+ EmojiTab("Emojis", selected: true, onTap: null),
+ EmojiTab("Stickers", selected: false, onTap: () => NotAvailableOpen.Value = true),
+ }.Padding(new Thickness(8, 0, 8, 0)),
+ EmojiTable(),
+ }.Padding(new Thickness(8)).FillHorizontal();
+
+ // ExtendedSelectorInnerButton: a real Material TextButton, titleSmall, that fills onSurface@8%
+ // when selected (else transparent). FlexGrow gives the two tabs equal width.
+ static View EmojiTab(string text, bool selected, System.Action? onTap)
+ {
+ var btn = new Button(text, () => onTap?.Invoke())
+ .TextButton().TitleSmall().Color(OnSurface)
+ .Frame(height: 36).FlexGrow(1).Margin(new Thickness(8)).CornerRadius(18);
+ if (selected)
+ btn.Background(OnSurface.WithAlpha(0.08f)); // gold: onSurface.copy(alpha = 0.08f)
+ return btn;
+ }
+
+ // 4 rows × EMOJI_COLUMNS cells; each emoji is a centred, tappable Text (a real Compose Text +
+ // clickable, like the gold's EmojiTable). FlexGrow gives the columns equal width.
+ static View EmojiTable()
+ {
+ var col = new VStack(spacing: 0f).FillHorizontal();
+ for (int x = 0; x < 4; x++)
+ {
+ var row = new HStack(spacing: 0f).FillHorizontal();
+ for (int y = 0; y < EmojiColumns; y++)
+ {
+ string e = Emojis[x * EmojiColumns + y];
+ row.Add(new ZStack { new Text(e).FontSize(18) }
+ .Frame(height: 44).FlexGrow(1).FlexBasis(0)
+ .OnTap(_ => AddEmoji(e)));
+ }
+ col.Add(row);
+ }
+ return col;
+ }
+
+ // The gold's insert-at-cursor (UserInput.kt addText): the emoji lands at the caret,
+ // replacing any selection, and the caret moves past it. Falls back to appending on a
+ // backend without caret tracking (the field's two-way signal updates either way).
+ static TextField? ComposerField;
+ static void AddEmoji(string emoji)
+ {
+ if (ComposerField is { } f)
+ f.InsertAtCursor(emoji);
+ else
+ InputText.Value = InputText.Peek() + emoji;
+ }
+
+ const int EmojiColumns = 10;
+
+ // The gold's first 40 emojis (emojis[0..39] from UserInput.kt), as full code points.
+ static readonly string[] Emojis =
+ {
+ "\U0001F600", "\U0001F601", "\U0001F602", "\U0001F603", "\U0001F604", "\U0001F605", "\U0001F606", "\U0001F609", "\U0001F60A", "\U0001F60B",
+ "\U0001F60E", "\U0001F60D", "\U0001F618", "\U0001F617", "\U0001F619", "\U0001F61A", "☺", "\U0001F642", "\U0001F917", "\U0001F607",
+ "\U0001F913", "\U0001F914", "\U0001F610", "\U0001F611", "\U0001F636", "\U0001F644", "\U0001F60F", "\U0001F623", "\U0001F625", "\U0001F62E",
+ "\U0001F910", "\U0001F62F", "\U0001F62A", "\U0001F62B", "\U0001F634", "\U0001F60C", "\U0001F61B", "\U0001F61C", "\U0001F61D", "\U0001F612",
+ };
+
+ // ── NotAvailablePopup (UiExtras.kt FunctionalityNotAvailablePopup): a real Material
+ // AlertDialog — bodyMedium text + a "CLOSE" confirm button — shown when the DM selector is
+ // picked. The CLOSE button and the scrim/back both clear the open signal. ──
+ static View NotAvailablePopup() => new AlertDialog(
+ NotAvailableOpen,
+ text: new Text("Functionality not available \U0001F648").Color(OnSurface).BodyMedium(),
+ confirmButton: new Button("CLOSE", () => NotAvailableOpen.Value = false).TextButton().Color(Primary).LabelLarge());
+
+ static View Spacer() => new HStack().FlexGrow(1);
+
+ // A 1dp hairline in the divider color (used for the day header rule).
+ static View HLine() => new HStack().Frame(height: 1).Background(Divider);
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetchat/JetchatDrawer.cs b/src/Comet/sample/Shared/Jetchat/JetchatDrawer.cs
new file mode 100644
index 000000000..48abb6f10
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetchat/JetchatDrawer.cs
@@ -0,0 +1,138 @@
+#nullable enable
+using Comet;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using C = CometSamples.Jetchat.JetchatConversation;
+
+namespace CometSamples.Jetchat
+{
+ ///
+ /// The Jetchat navigation-drawer panel (the C# port of JetchatDrawer.kt): the jetchat
+ /// logo header, a "Chats" section (each chat a 56dp pill with the leading logo + a
+ /// primaryContainer highlight when selected), a "Recent Profiles" section with avatars
+ /// ("Ali Conors (you)"), and a "Settings" section. Rendered inside the real
+ /// (Compose ModalNavigationDrawer / SwiftUI sliding panel).
+ ///
+ static class JetchatDrawer
+ {
+ public static View Content(double topInset, Comet.Reactive.Signal selected,
+ System.Action onChannel, System.Action onProfile, System.Action onSettings) => new VStack(spacing: 0f)
+ {
+ // DrawerHeader (JetchatDrawer.kt): Row(padding 16){ JetchatIcon(24dp) + jetchat_logo wordmark }.
+ // Android renders the gold two-layer tinted mark (ic_jetchat_back/front) + the jetchat_logo
+ // wordmark image (87x24). iOS's bundle has only jetchat.png (the square mark) — the back/front
+ // glyphs and the wordmark vector aren't there — so it shows the working jetchat mark + a text
+ // wordmark. (Drop in iOS jetchat_back/front/jetchat_logo PNGs to make both platforms identical.)
+ new HStack(spacing: 0f)
+ {
+ System.OperatingSystem.IsAndroid()
+ ? (View)C.JetchatIcon(24).VerticalLayoutAlignment(LayoutAlignment.Center)
+ : new Icon("jetchat").IconSize(24).VerticalLayoutAlignment(LayoutAlignment.Center),
+ System.OperatingSystem.IsAndroid()
+ ? (View)new Image("jetchat_logo").Frame(width: 87, height: 24).FlexShrink(0)
+ .Margin(left: 8).VerticalLayoutAlignment(LayoutAlignment.Center)
+ : new Text("jetchat").HeadlineSmall().Color(C.OnSurface)
+ .Margin(left: 8).VerticalLayoutAlignment(LayoutAlignment.Center),
+ }.Padding(new Thickness(16, topInset + 16, 16, 16)),
+
+ // Edge-to-edge divider under the header (the gold's first DividerItem() has no inset).
+ FullDivider(),
+
+ // DrawerItemHeader("Chats") + 56dp ChatItem pills (leading logo + label, no '#'). Tapping
+ // a channel returns to the messages view; the current destination is highlighted.
+ SectionTitle("Chats"),
+ ChatItem("composers", selected, onChannel),
+ ChatItem("droidcon-nyc", selected, onChannel),
+
+ InsetDivider(),
+ SectionTitle("Recent Profiles"),
+ ProfileItem("Ali Conors (you)", "Ali Conors", C.AvatarMe, selected, onProfile),
+ ProfileItem("Taylor Brooks", "Taylor Brooks", C.AvatarOther, selected, onProfile),
+
+ InsetDivider(),
+ SectionTitle("Settings"),
+ SettingItem("Add Widget to Home Page", onSettings),
+ }.Background(C.Surface);
+
+ // DrawerItemHeader: a 52dp row, text vertically centred, bodySmall / onSurfaceVariant, start
+ // 28dp — so the header text left-aligns with the menu rows' icon column (12 row + 16 icon = 28).
+ static View SectionTitle(string text) =>
+ new HStack(spacing: 0f)
+ {
+ new Text(text).Color(C.OnSurfaceVariant).BodySmall()
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ }.Frame(height: 52).Padding(new Thickness(28, 0, 16, 0));
+
+ // ChatItem: a 56dp stadium pill — leading jetchat logo + label (bodyMedium). The pill, logo
+ // tint, and label colour track the destination signal reactively
+ // (the selected one fills with the dynamic primaryContainer and tints its content primary);
+ // tapping it routes back to the messages view via .
+ static View ChatItem(string name, Comet.Reactive.Signal selected, System.Action onChannel)
+ {
+ var icon = new Icon("jetchat").IconSize(24).FlexShrink(0)
+ .Margin(left: 16, top: 16, bottom: 16).VerticalLayoutAlignment(LayoutAlignment.Center);
+ var label = new Text(name).BodyMedium()
+ .VerticalLayoutAlignment(LayoutAlignment.Center).Margin(left: 12);
+ var pill = new HStack(spacing: 0f) { icon, label }
+ .Frame(height: 56).CornerRadius(28).Margin(new Thickness(12, 0, 12, 0))
+ .OnTap(_ => onChannel(name));
+
+ void Apply()
+ {
+ bool sel = selected.Peek() == name;
+ pill.Background(sel ? JetchatTheme.PrimaryContainer : Colors.Transparent);
+ icon.Color(sel ? C.Primary : C.OnSurfaceVariant);
+ label.Color(sel ? C.Primary : C.OnSurface);
+ }
+ Apply();
+ selected.PropertyChanged += (_, __) => Apply();
+ return pill;
+ }
+
+ // ProfileItem: 56dp pill — 24dp avatar + display name (bodyMedium). The display text may carry
+ // a "(you)" suffix while the tap navigates by the bare profile name; the pill highlights when
+ // its profile is the current destination.
+ static View ProfileItem(string display, string profileName, string avatar,
+ Comet.Reactive.Signal selected, System.Action onProfile)
+ {
+ var label = new Text(display).BodyMedium()
+ .VerticalLayoutAlignment(LayoutAlignment.Center).Margin(left: 12);
+ var pill = new HStack(spacing: 0f)
+ {
+ new Image(avatar).Frame(width: 24, height: 24).CornerRadius(12).FlexShrink(0)
+ .Margin(left: 16, top: 16, bottom: 16).VerticalLayoutAlignment(LayoutAlignment.Center),
+ label,
+ }
+ .Frame(height: 56).CornerRadius(28).Margin(new Thickness(12, 0, 12, 0))
+ .OnTap(_ => onProfile(profileName));
+
+ void Apply()
+ {
+ bool sel = selected.Peek() == profileName;
+ pill.Background(sel ? JetchatTheme.PrimaryContainer : Colors.Transparent);
+ label.Color(sel ? C.Primary : C.OnSurface);
+ }
+ Apply();
+ selected.PropertyChanged += (_, __) => Apply();
+ return pill;
+ }
+
+ // WidgetDiscoverability: a 56dp clickable pill (no icon), bodyMedium label at start 12 — same
+ // row shape as the chat/profile items. Tapping invokes the supplied action (the gold adds a
+ // home-screen widget; on iOS that surfaces the "not available" popup).
+ static View SettingItem(string text, System.Action onTap) =>
+ new HStack(spacing: 0f)
+ {
+ new Text(text).Color(C.OnSurface).BodyMedium()
+ .VerticalLayoutAlignment(LayoutAlignment.Center).Margin(left: 12),
+ }
+ .Frame(height: 56).CornerRadius(28).Margin(new Thickness(12, 0, 12, 0))
+ .OnTap(_ => onTap());
+
+ // DividerItem (onSurface @ 12%): edge-to-edge under the header, inset 28dp between sections.
+ static View FullDivider() => new HStack().FillHorizontal().Frame(height: 1).Background(C.Divider);
+ static View InsetDivider() => new HStack().FillHorizontal().Frame(height: 1).Background(C.Divider)
+ .Margin(new Thickness(28, 0, 28, 0));
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetchat/JetchatIcons.cs b/src/Comet/sample/Shared/Jetchat/JetchatIcons.cs
new file mode 100644
index 000000000..98a95aec4
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetchat/JetchatIcons.cs
@@ -0,0 +1,41 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+
+namespace CometSamples.Jetchat
+{
+ ///
+ /// Registers Google's Material Icons font as Comet's icon font, so every Icon("name") in
+ /// the shared tree renders the SAME Material glyph on iOS and Android (the cross-platform Material
+ /// design choice — one font, one glyph set, fully tintable). The font file itself is loaded per
+ /// platform first (Android ComposeFontRegistry; iOS UIAppFonts) under this family.
+ /// The "jetchat" brand logo is intentionally NOT mapped — it falls back to its bundled asset.
+ ///
+ static class JetchatIcons
+ {
+ public const string Font = "Material Icons";
+
+ // Cross-platform name → Material Icons codepoint. Names match the gold's ic_* drawables:
+ // @ = alternate_email, photo = insert_photo, video = duo, arrow_down = arrow_downward.
+ public static void Register()
+ {
+ var map = new Dictionary();
+ void Add(string name, int codepoint) => map[name] = char.ConvertFromUtf32(codepoint);
+
+ Add("search", 0xE8B6);
+ Add("info", 0xE88E);
+ Add("mood", 0xE7F2);
+ Add("at", 0xE0E6); // alternate_email
+ Add("photo", 0xE251); // insert_photo
+ Add("place", 0xE55F);
+ Add("video", 0xE9A5); // duo
+ Add("mic", 0xE029);
+ Add("arrow_down", 0xE5DB); // arrow_downward
+ Add("create", 0xE150);
+ Add("chat", 0xE0B7);
+ Add("send", 0xE163);
+
+ IconFont.Register(Font, map);
+ }
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetchat/JetchatProfile.cs b/src/Comet/sample/Shared/Jetchat/JetchatProfile.cs
new file mode 100644
index 000000000..a428e40a2
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetchat/JetchatProfile.cs
@@ -0,0 +1,142 @@
+#nullable enable
+using System;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Jetchat.JetchatTheme;
+using C = CometSamples.Jetchat.JetchatConversation;
+
+namespace CometSamples.Jetchat
+{
+ ///
+ /// The Jetchat profile detail screen — the C# port of profile/Profile.kt: a circular
+ /// avatar, the name (headlineSmall) + position (bodyLarge / onSurfaceVariant), a column of
+ /// ProfileProperty rows (each preceded by a divider; the Twitter value is a primary-colored
+ /// link), and a bottom-right tertiaryContainer FAB ("Message" for others, "Edit Profile" for me).
+ /// Pushed onto the stack by a drawer profile tap; the FAB / back arrow pop.
+ ///
+ static class JetchatProfile
+ {
+ // ProfileScreenState (data/FakeData.kt): the "me" profile and the single colleague profile.
+ // PhotoAspect = the photo's width/height — the gold sizes the header image fillMaxWidth and lets
+ // CircleShape clip it, so a square photo (ali 432x431) renders a CIRCLE and a landscape photo
+ // (someone_else 640x427) a wide oval. Drives the box shape via AspectRatio (no intrinsic image size).
+ sealed record Data(string Photo, double PhotoAspect, string Name, string Status, string DisplayName,
+ string Position, string Twitter, string TimeZone, bool IsMe);
+
+ static Data For(string name) => name == "Ali Conors"
+ ? new Data(C.AvatarMe, 432.0 / 431.0, "Ali Conors", "Online", "aliconors",
+ "Senior Android Dev at Yearin\nGoogle Developer Expert", "twitter.com/aliconors", "In your timezone", true)
+ : new Data(C.AvatarOther, 640.0 / 427.0, "Taylor Brooks", "Away", "taylor",
+ "Senior Android Dev at Openlane", "twitter.com/taylorbrookscodes",
+ "12:25 AM local time (Eastern Daylight Time)", false);
+
+ public static View Screen(string name, double topInset, double bottomInset, Action onBack)
+ {
+ var d = For(name);
+
+ // The gold standard is a Box: a full-bleed scrolling Column with a FloatingActionButton
+ // aligned BottomEnd, floating over the content. A ZStack reproduces that — the scroll is
+ // the base layer (fills), the FAB the overlay (pinned bottom-right, overlapping the scroll).
+ // The scroll's AtTop signal drives the FAB: extended at top (scrollState.value == 0),
+ // contracted when scrolled away — matching the gold's derivedStateOf { scrollState.value == 0 }.
+ // Profile photo (ProfileHeader.kt): fillMaxWidth − 16dp each side, height from the photo's
+ // aspect (AspectRatio), ContentScale.Crop, clipped CircleShape. A square photo (ali) → a
+ // CIRCLE; a landscape photo (taylor) → a wide oval. Extracted so the parallax can translate it.
+ var photo = new Image(d.Photo).FillHorizontal().AspectRatio(d.PhotoAspect).FlexShrink(0)
+ .CornerRadius(1000).Margin(left: 16, top: 8, right: 16, bottom: 8);
+
+ var scroll = new ScrollView
+ {
+ new VStack(spacing: 0f)
+ {
+ photo,
+
+ // Name + position (NameAndPosition): each text baseline-anchored — the name's
+ // first baseline 32dp from its top, the position's 24dp, +20dp below.
+ new VStack(spacing: 0f)
+ {
+ new Text(d.Name).Color(T.OnSurface).HeadlineSmall().BaselineHeight(32),
+ new Text(d.Position).Color(T.OnSurfaceVariant).BodyLarge()
+ .BaselineHeight(24).Margin(bottom: 20),
+ }.Padding(new Thickness(16, 8, 16, 0)),
+
+ // Property rows, each preceded by a divider; Twitter is a primary-colored link.
+ Property("Display name", d.DisplayName, isLink: false),
+ Property("Status", d.Status, isLink: false),
+ Property("Twitter", d.Twitter, isLink: true),
+ Property("Timezone", d.TimeZone, isLink: false),
+
+ // Trailing scroll room — the gold ProfileHeader pads a containerHeight−320 spacer so
+ // the column always overflows (for the parallax + the FAB contract). Without it the
+ // profile content fits the screen, the ScrollView never scrolls, AtTop never flips,
+ // and the FAB can't contract. This guarantees overflow so the contract triggers.
+ new HStack().Frame(height: 240).FlexShrink(0),
+ },
+ }.FillVertical();
+
+ // NOTE: the gold ProfileHeader parallaxes the photo via padding(top = scrollState.value / 2),
+ // which GROWS the photo's slot so the content below stays clear. The infrastructure for it is
+ // in place — ScrollView.ScrollOffset (a continuous Dp signal the scroll node marshals) plus
+ // general TranslationY transform support on the nodes — but a faithful (no-overlap) parallax
+ // needs the layout-grow approach (a per-frame reflow), deferred to avoid scroll jank on the
+ // profile's baseline-measured text. A pure TranslationY transform makes the photo overlap the
+ // rows below, so it's not wired here.
+
+ // FAB is extended while at top, contracted when scrolled (gold: derivedStateOf { scrollState.value == 0 }).
+ var fab = ProfileFab(d.IsMe ? "Edit Profile" : "Message", d.IsMe ? "create" : "chat", onBack)
+ .ExtendedWhen(scroll.AtTop);
+
+ return new ZStack
+ {
+ new VStack(spacing: 0f)
+ {
+ // App-bar nav icon = the jetchat logo (JetchatAppBar.kt), which opens the drawer —
+ // NOT a back button. The drawer wraps the whole nav stack, so it slides over the
+ // profile just like in the original app.
+ new HStack(spacing: 0f)
+ {
+ new Icon("jetchat").IconSize(32)
+ .Margin(left: 16, top: (float)(topInset + 12))
+ .OnTap(_ => C.DrawerOpen.Value = true),
+ new HStack().FlexGrow(1),
+ }.Background(T.Surface).Padding(new Thickness(0, 0, 0, 8)),
+
+ scroll,
+ }.FillHorizontal().FillVertical().Background(T.Surface),
+
+ // The real Material ExtendedFloatingActionButton, floating bottom-right over the scroll.
+ fab
+ .HorizontalLayoutAlignment(LayoutAlignment.End)
+ .VerticalLayoutAlignment(LayoutAlignment.End)
+ .Margin(right: 16, bottom: (float)(24 + bottomInset)),
+ };
+ }
+
+ // ProfileProperty: a divider, then the label and value, each baseline-anchored 24dp from the
+ // top of its box (Jetchat's baselineHeight). Column padding: 16 start/end, 16 bottom.
+ static View Property(string label, string value, bool isLink) => new VStack(spacing: 0f)
+ {
+ Divider(),
+ new Text(label).Color(T.OnSurfaceVariant).BodySmall().BaselineHeight(24),
+ new Text(value).Color(isLink ? T.Primary : T.OnSurface).BodyLarge().BaselineHeight(24),
+ }.Padding(new Thickness(16, 0, 16, 16));
+
+ static View Divider() => new HStack().Frame(height: 1).Background(T.Divider);
+
+ // The real Material 3 FloatingActionButton (Profile.kt ProfileFab): tertiaryContainer
+ // container, an icon + label, height 48. The icon/label carry no colour so they inherit the
+ // FAB's onTertiaryContainer content colour. Starts extended (at-top); ExtendedWhen(scroll.AtTop)
+ // is wired by the caller to contract when scrolled — matching AnimatingFabContent in the gold.
+ static Comet.Fab ProfileFab(string text, string icon, Action onTap) => new Comet.Fab(
+ icon: new Icon(icon).IconSize(24).Color(T.OnTertiaryContainer),
+ label: new Text(text).LabelLarge().Color(T.OnTertiaryContainer),
+ onClick: onTap,
+ height: 48,
+ containerColor: T.TertiaryContainer,
+ contentColor: T.OnTertiaryContainer,
+ extended: true);
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetchat/JetchatTheme.cs b/src/Comet/sample/Shared/Jetchat/JetchatTheme.cs
new file mode 100644
index 000000000..f358ab778
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetchat/JetchatTheme.cs
@@ -0,0 +1,161 @@
+#nullable enable
+using Comet;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+
+namespace CometSamples.Jetchat
+{
+ ///
+ /// The Jetchat design tokens, mirroring the sample's theme/ package
+ /// (Color.kt / Themes.kt / Typography.kt) as a single source of truth: the raw palette,
+ /// the semantic color roles (the light ColorScheme), the type scale, and the shapes.
+ /// Components read these instead of hardcoding values inline — the C# analog of
+ /// MaterialTheme.colorScheme / typography / shapes.
+ ///
+ static class JetchatTheme
+ {
+ // ── Color.kt: raw palette ──
+ static readonly Color Blue40 = Color.FromArgb("#1546F6");
+ static readonly Color Blue80 = Color.FromArgb("#B8C3FF");
+ static readonly Color Blue90 = Color.FromArgb("#DDE1FF");
+ static readonly Color DarkBlue40 = Color.FromArgb("#3648EA");
+ static readonly Color Yellow10 = Color.FromArgb("#261900");
+ static readonly Color Yellow40 = Color.FromArgb("#7A5900");
+ static readonly Color Yellow90 = Color.FromArgb("#FFDE9C");
+ static readonly Color Grey10 = Color.FromArgb("#191C1D");
+ static readonly Color Grey99 = Color.FromArgb("#FBFDFD");
+ static readonly Color BlueGrey30 = Color.FromArgb("#45464F");
+ static readonly Color BlueGrey50 = Color.FromArgb("#767680");
+ static readonly Color BlueGrey90 = Color.FromArgb("#E2E1EC");
+
+ // ── Themes.kt: semantic roles. Settable so ApplyScheme can swap in the platform's
+ // Material You (dynamicLightColorScheme) at runtime; the values below are the static
+ // JetchatLightColorScheme fallback (used pre-Android-12 / when dynamic is off). Consumers
+ // must read these live (not cache in a static field) so the dynamic swap reaches them. ──
+ public static Color Primary { get; private set; } = Blue40;
+ public static Color OnPrimary { get; private set; } = Colors.White;
+ public static Color PrimaryContainer { get; private set; } = Blue90;
+ // inversePrimary (Themes.kt JetchatLightColorScheme): the accent for @mentions/links rendered
+ // ON a primary (me) bubble, where plain primary would lack contrast. Light = Blue80.
+ public static Color InversePrimary { get; private set; } = Blue80;
+ public static Color Secondary { get; private set; } = DarkBlue40; // footer Surface contentColor
+ // onSecondary: the contrasting tint for a selected input-selector icon (gold UserInput.kt
+ // contentColorFor(LocalContentColor=secondary)). Light scheme: white on the dark-blue secondary.
+ public static Color OnSecondary { get; private set; } = Colors.White;
+ public static Color Surface { get; private set; } = Grey99;
+ public static Color OnSurface { get; private set; } = Grey10;
+ public static Color SurfaceVariant { get; private set; } = BlueGrey90;
+ public static Color OnSurfaceVariant { get; private set; } = BlueGrey30;
+ public static Color Background { get; private set; } = Grey99;
+ public static Color Tertiary { get; private set; } = Yellow40;
+ public static Color Outline { get; private set; } = BlueGrey50;
+ // The profile FAB roles (tertiaryContainer / onTertiaryContainer) — Themes.kt JetchatLightColorScheme.
+ public static Color TertiaryContainer { get; private set; } = Yellow90; // #FFDE9C (was sampled pink #F8D8F0)
+ public static Color OnTertiaryContainer { get; private set; } = Yellow10; // #261900
+
+ // The footer bar = Surface(tonalElevation = 2.dp): primary composited over surface at the M3
+ // 2dp overlay alpha (≈0.0694). The HEADER (CenterAlignedTopAppBar) is plain Surface.
+ public static Color SurfaceTinted { get; private set; } = SurfaceAtElevation(Blue40, Grey99, 2);
+ // The expandable input-selector panel = Surface(tonalElevation = 8.dp): primary over surface at
+ // the M3 8dp overlay alpha (≈0.1189) — a touch more tinted than the 2dp footer bar.
+ public static Color SurfaceTinted8 { get; private set; } = SurfaceAtElevation(Blue40, Grey99, 8);
+ public static Color Divider { get; private set; } = Grey10.WithAlpha(0.12f); // onSurface @ 12%
+ public static readonly Color Disabled = Color.FromArgb("#C4C6D0");
+
+ /// The Jetchat brand seed (Blue40). Both platforms generate their Material 3 scheme
+ /// from THIS color via , so the look is theme-driven and identical
+ /// cross-platform (the gold seeds from the device wallpaper, which no iOS app can read).
+ public static readonly Color SeedColor = Color.FromArgb("#1546F6");
+
+ /// Generate a full Material 3 tonal scheme from a single using
+ /// Google's material-color-utilities algorithm (HCT → tonal palettes → roles — the SAME math
+ /// Android's Material You uses) and apply it. The cross-platform "dynamic color" path: seed from
+ /// the brand color (consistent) or from content (a photo). Call BEFORE building the view tree.
+ /// Returns the role colors so a platform can also feed them to its native theme (Compose MaterialTheme).
+ public static MaterialColorUtilities.Schemes.Scheme ApplyDynamicScheme(Color seed, bool dark = false)
+ {
+ var core = MaterialColorUtilities.Palettes.CorePalette.Of(ToArgbUint(seed));
+ MaterialColorUtilities.Schemes.Scheme s = dark
+ ? new MaterialColorUtilities.Schemes.DarkSchemeMapper().Map(core)
+ : new MaterialColorUtilities.Schemes.LightSchemeMapper().Map(core);
+ ApplyScheme(
+ primary: FromArgbUint(s.Primary), onPrimary: FromArgbUint(s.OnPrimary),
+ primaryContainer: FromArgbUint(s.PrimaryContainer), secondary: FromArgbUint(s.Secondary),
+ surface: FromArgbUint(s.Surface), onSurface: FromArgbUint(s.OnSurface),
+ surfaceVariant: FromArgbUint(s.SurfaceVariant), onSurfaceVariant: FromArgbUint(s.OnSurfaceVariant),
+ background: FromArgbUint(s.Background), tertiary: FromArgbUint(s.Tertiary),
+ tertiaryContainer: FromArgbUint(s.TertiaryContainer), onTertiaryContainer: FromArgbUint(s.OnTertiaryContainer),
+ outline: FromArgbUint(s.Outline));
+ InversePrimary = FromArgbUint(s.InversePrimary);
+ OnSecondary = FromArgbUint(s.OnSecondary);
+ return s;
+ }
+
+ /// Content-based theming: extract a Material You seed from image pixels (AARRGGBB) via
+ /// material-color-utilities' Quantize + Score, then apply the generated scheme — e.g. theme the
+ /// whole app from a profile photo, the same content-based mode Material You offers. Falls back to
+ /// the brand when no usable seed is found.
+ public static MaterialColorUtilities.Schemes.Scheme ApplyDynamicSchemeFromPixels(uint[] argbPixels, bool dark = false)
+ {
+ var seeds = MaterialColorUtilities.Utils.ImageUtils.ColorsFromImage(argbPixels);
+ return ApplyDynamicScheme(seeds.Count > 0 ? FromArgbUint(seeds[0]) : SeedColor, dark);
+ }
+
+ // material-color-utilities works in AARRGGBB uints; convert to/from Maui colors.
+ static uint ToArgbUint(Color c) =>
+ ((uint)System.Math.Round(c.Alpha * 255) << 24) | ((uint)System.Math.Round(c.Red * 255) << 16) |
+ ((uint)System.Math.Round(c.Green * 255) << 8) | (uint)System.Math.Round(c.Blue * 255);
+
+ public static Color FromArgbUint(uint argb) => new Color(
+ ((argb >> 16) & 0xFF) / 255f, ((argb >> 8) & 0xFF) / 255f, (argb & 0xFF) / 255f, ((argb >> 24) & 0xFF) / 255f);
+
+ /// Swap the semantic roles to a platform ColorScheme (Material You) at startup,
+ /// mirroring JetchatTheme(isDynamicColor = true) in Themes.kt. Call BEFORE building the
+ /// view tree. Derived roles (the tonal footer + the 12% divider) are recomputed from the new
+ /// surface/primary/onSurface.
+ public static void ApplyScheme(Color primary, Color onPrimary, Color primaryContainer, Color secondary,
+ Color surface, Color onSurface, Color surfaceVariant, Color onSurfaceVariant, Color background,
+ Color tertiary, Color tertiaryContainer, Color onTertiaryContainer, Color outline)
+ {
+ Primary = primary; OnPrimary = onPrimary; PrimaryContainer = primaryContainer; Secondary = secondary;
+ Surface = surface; OnSurface = onSurface; SurfaceVariant = surfaceVariant; OnSurfaceVariant = onSurfaceVariant;
+ Background = background; Tertiary = tertiary; TertiaryContainer = tertiaryContainer;
+ OnTertiaryContainer = onTertiaryContainer; Outline = outline;
+ SurfaceTinted = SurfaceAtElevation(primary, surface, 2);
+ SurfaceTinted8 = SurfaceAtElevation(primary, surface, 8);
+ Divider = onSurface.WithAlpha(0.12f);
+ }
+
+ // Material 3 surfaceColorAtElevation: primary composited over surface at alpha = (4.5·ln(dp+1)+2)/100.
+ static Color SurfaceAtElevation(Color primary, Color surface, double dp)
+ {
+ double a = (4.5 * System.Math.Log(dp + 1) + 2) / 100.0;
+ return new Color(
+ (float)(primary.Red * a + surface.Red * (1 - a)),
+ (float)(primary.Green * a + surface.Green * (1 - a)),
+ (float)(primary.Blue * a + surface.Blue * (1 - a)));
+ }
+
+ // ── Typography.kt: Jetchat uses Montserrat (titles/labels) + Karla (body), NOT Roboto.
+ // Each style carries the EXACT size + lineHeight from Typography.kt (not a 1.3× heuristic). ──
+ const string Montserrat = "Montserrat";
+ const string Karla = "Karla";
+
+ public static T HeadlineSmall(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 24, 32);
+ public static T TitleLarge(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 22, 28);
+ public static T TitleMedium(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 16, 24);
+ public static T TitleSmall(this T v) where T : View => v.Type(Montserrat, FontWeight.Medium, 14, 20);
+ public static T BodyLarge(this T v) where T : View => v.Type(Karla, FontWeight.Regular, 16, 24);
+ public static T BodyMedium(this T v) where T : View => v.Type(Montserrat, FontWeight.Medium, 14, 20);
+ public static T BodySmall(this T v) where T : View => v.Type(Karla, FontWeight.Bold, 12, 16);
+ public static T LabelLarge(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 14, 20);
+ public static T LabelSmall(this T v) where T : View => v.Type(Montserrat, FontWeight.Semibold, 11, 16);
+
+ static T Type(this T v, string family, FontWeight weight, double size, double lineHeight) where T : View =>
+ v.FontFamily(family).FontWeight(weight).FontSize(size).LineHeight(lineHeight);
+
+ // ── Shapes ──
+ // The chat bubble (RoundedCornerShape(4,20,20,20)); apply with .CornerRadius(BubbleTL,…).
+ public const double BubbleTopStart = 4, BubbleOther = 20;
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackCart.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackCart.cs
new file mode 100644
index 000000000..315ea0b15
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetsnack/JetsnackCart.cs
@@ -0,0 +1,211 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Jetsnack.JetsnackTheme;
+
+namespace CometSamples.Jetsnack
+{
+ ///
+ /// My Cart (ui/home/cart/Cart.kt): "Order (N items)" header, cart rows (100dp circle
+ /// image | name/tagline + price | X remove + Qty stepper), the Summary block
+ /// (Subtotal / Shipping & Handling / Total), "Inspired by your cart" collection, and
+ /// the pinned gradient Checkout bar. Swipe-to-dismiss is a documented deviation
+ /// (rows remove via the X, the gold's accessibility path).
+ ///
+ public static class JetsnackCart
+ {
+ sealed class Line
+ {
+ public Line(OrderLine source) { Snack = source.Snack; Count = source.Count; }
+ public Snack Snack { get; }
+ public int Count { get; set; }
+ }
+
+ static readonly List Lines = SnackRepo.GetCart().Select(l => new Line(l)).ToList();
+ static ListView? _list;
+
+ const long ShippingCosts = 369;
+
+ static IReadOnlyList Rows()
+ {
+ var rows = new List { "header" };
+ rows.AddRange(Lines);
+ rows.Add("summary");
+ rows.Add("inspired");
+ rows.Add("spacer");
+ return rows;
+ }
+
+ public static View Screen(double topInset)
+ {
+ var list = new ListView(Rows)
+ {
+ ViewFor = r => r switch
+ {
+ "header" => Header(),
+ Line line => CartRow(line),
+ "summary" => Summary(),
+ "inspired" => JetsnackHome.RelatedCollection(SnackRepo.GetInspiredByCart()),
+ _ => new HStack().Frame(height: 64),
+ },
+ };
+ _list = list;
+
+ return new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: (float)topInset).FlexShrink(0),
+ JetsnackHome.DestinationBar().FlexShrink(0),
+ new ZStack
+ {
+ list.HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill),
+ new VStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ CheckoutBar().FlexShrink(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill),
+ }.FlexGrow(1).FlexBasis(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.UiBackground);
+ }
+
+ static View Header() =>
+ new Text(() => $"Order ({Lines.Sum(l => l.Count)} items)")
+ .FontFamily("Montserrat").FontWeight(FontWeight.Semibold).FontSize(20).Color(T.Brand)
+ .Padding(new Thickness(24, 16, 24, 4));
+
+ // Cart.kt CartItem: image 100 (pad v16) | name titleMedium + tagline help + price |
+ // X remove top-right; Qty stepper at the row's end.
+ static View CartRow(Line line) => new VStack(spacing: 0f)
+ {
+ new HStack(spacing: 0f)
+ {
+ new Image(line.Snack.ImageRes).Frame(width: 100, height: 100).CornerRadius(50)
+ .Margin(new Thickness(0, 16, 0, 16))
+ .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0),
+ new VStack(spacing: 0f)
+ {
+ T.TitleMedium(line.Snack.Name).Color(T.TextSecondary),
+ // Gold CartItem: tagline (bodyLarge Karla, textHelp) sits between the
+ // name and the price row, spacer 8 above the price.
+ T.BodyLarge(line.Snack.Tagline.Length > 0 ? line.Snack.Tagline : "A tag line")
+ .Color(T.TextHelp).Padding(new Thickness(0, 4, 0, 0)),
+ new HStack().Frame(height: 8),
+ new HStack(spacing: 0f)
+ {
+ T.TitleMedium(Jetsnack.FormatPrice(line.Snack.Price)).Color(T.TextPrimary)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ QuantityStepper(line),
+ }.HorizontalLayoutAlignment(LayoutAlignment.Fill),
+ }.Padding(new Thickness(16, 16, 0, 16)).FlexGrow(1).FlexBasis(0),
+ new Icon("close").IconSize(18).Color(T.IconSecondary)
+ .Frame(width: 40, height: 40).Padding(new Thickness(11))
+ .FlexShrink(0)
+ .OnTap(_ => { Lines.Remove(line); _list?.ReloadData(); }),
+ }.Padding(new Thickness(24, 0, 12, 0)),
+ JetsnackHome.Divider(1).Margin(new Thickness(24, 0, 24, 0)),
+ };
+
+ static View QuantityStepper(Line line) => new HStack(spacing: 4f)
+ {
+ new Text("Qty").FontFamily("Montserrat").FontSize(16).Color(T.TextSecondary)
+ .Padding(new Thickness(0, 0, 14, 0))
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ StepperCircle("remove", () => { if (line.Count > 1) { line.Count--; _list?.ReloadData(); } }),
+ new Text(line.Count.ToString()).FontFamily("Karla").FontWeight(FontWeight.Bold).FontSize(18)
+ .Color(T.TextPrimary)
+ .Frame(width: 24)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ StepperCircle("add", () => { line.Count++; _list?.ReloadData(); }),
+ }.FlexShrink(0);
+
+ /// JetsnackGradientTintedIconButton: a circle with the interactiveSecondary
+ /// DIAGONAL-GRADIENT border over uiBackground (per-glyph gradient tint approximated
+ /// with the ramp's Shadow3 — backlogged).
+ internal static View StepperCircle(string icon, System.Action onTap) =>
+ new Icon(icon).IconSize(16).Color(T.Shadow3)
+ .Frame(width: 28, height: 28).Padding(new Thickness(6))
+ .BorderGradient(T.Gradient2_2).CornerRadius(14)
+ .Background(T.UiBackground)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .OnTap(_ => onTap());
+
+ // Cart.kt SummaryItem.
+ static View Summary()
+ {
+ long subtotal = Lines.Sum(l => l.Snack.Price * l.Count);
+ // Gold SummaryItem: label + amount both bodyLarge (Karla) in the DEFAULT dark
+ // content color; only the Total's AMOUNT steps up to titleMedium.
+ // The Total row's label hugs the amount (gold: weight(1f) +
+ // wrapContentWidth(End) + padding end 16); the others sit at the start.
+ View Row(string label, long amount, bool totalRow = false)
+ {
+ var amountText = (totalRow
+ ? T.TitleMedium(Jetsnack.FormatPrice(amount))
+ : T.BodyLarge(Jetsnack.FormatPrice(amount))).Color(T.TextSecondary)
+ .FlexShrink(0);
+ var row = totalRow
+ ? new HStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ T.BodyLarge(label).Color(T.TextSecondary)
+ .Padding(new Thickness(0, 0, 16, 0)).FlexShrink(0),
+ amountText,
+ }
+ : new HStack(spacing: 0f)
+ {
+ T.BodyLarge(label).Color(T.TextSecondary)
+ .FlexGrow(1).FlexBasis(0),
+ amountText,
+ };
+ return row.Padding(new Thickness(24, 6, 24, 6)).HorizontalLayoutAlignment(LayoutAlignment.Fill);
+ }
+
+ return new VStack(spacing: 0f)
+ {
+ T.TitleLarge("Summary").Color(T.Brand)
+ .Padding(new Thickness(24, 16, 24, 8)),
+ Row("Subtotal", subtotal),
+ Row("Shipping & Handling", ShippingCosts),
+ new HStack().Frame(height: 8),
+ JetsnackHome.Divider(1).Margin(new Thickness(24, 0, 24, 0)),
+ new HStack().Frame(height: 8),
+ Row("Total", subtotal + ShippingCosts, totalRow: true),
+ new HStack().Frame(height: 8),
+ JetsnackHome.Divider(1),
+ };
+ }
+
+ // Cart.kt CheckoutBar: divider + gradient full-width Checkout button.
+ static View CheckoutBar() => new VStack(spacing: 0f)
+ {
+ JetsnackHome.Divider(1),
+ new VStack(spacing: 0f)
+ {
+ new HStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ T.LabelLarge("Checkout")
+ .Color(T.TextInteractive).MaxLines(1)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ }
+ .Frame(height: 36)
+ .BackgroundGradient(T.Tornado1).CornerRadius(18)
+ .Margin(new Thickness(24, 10, 24, 10))
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill),
+ },
+ }.Background(T.UiBackground.WithAlpha(0.95f));
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackDetail.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackDetail.cs
new file mode 100644
index 000000000..d40dafa6f
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetsnack/JetsnackDetail.cs
@@ -0,0 +1,217 @@
+#nullable enable
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Jetsnack.JetsnackTheme;
+
+namespace CometSamples.Jetsnack
+{
+ ///
+ /// Snack detail (ui/snackdetail/SnackDetail.kt), values-from-source: gradient header
+ /// (280dp), 300dp circular image overlapping into the title block, italic
+ /// name/tagline + brand price, Details + Ingredients sections, related collections,
+ /// and the pinned cart bar (Qty stepper + gradient ADD TO CART). STATIC layout —
+ /// the gold's scroll parallax (Title/Image offset math) is a documented deviation.
+ ///
+ public static class JetsnackDetail
+ {
+ const float BottomBarHeight = 56f;
+
+ static readonly Signal Count = new(1);
+
+ static ListView? _list;
+
+ public static View Screen(Signal snackId, double topInset, System.Action onBack)
+ {
+ JetsnackIcons.Register();
+
+ var list = new ListView(() => new object[] { snackId.Peek() })
+ {
+ ViewFor = _ => Body(SnackRepo.GetSnack(snackId.Peek()), topInset),
+ };
+ _list = list;
+ if (!_hooked)
+ {
+ _hooked = true;
+ snackId.PropertyChanged += (_, _) => { Count.Value = 1; _seeMore = false; _list?.ReloadData(); };
+ Count.PropertyChanged += (_, _) => _list?.ReloadData();
+ }
+
+ return new ZStack
+ {
+ new VStack(spacing: 0f)
+ {
+ list.FlexGrow(1).FlexBasis(0),
+ CartBar().FlexShrink(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill),
+ // Up button floats over the header (SnackDetail.kt Up: 36dp circle,
+ // pad 16/10 below the status bar).
+ new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: (float)(topInset + 10)).FlexShrink(0),
+ new HStack(spacing: 0f)
+ {
+ new Icon("arrow_back").IconSize(20).Color(T.IconInteractive)
+ .Frame(width: 36, height: 36).Padding(new Thickness(8))
+ .Background(T.Neutral8.WithAlpha(0.32f)).CornerRadius(18)
+ .Margin(new Thickness(16, 0, 0, 0))
+ .OnTap(_ => onBack()),
+ new HStack().FlexGrow(1),
+ }.FlexShrink(0),
+ new HStack().FlexGrow(1),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.UiBackground);
+ }
+
+ static bool _hooked;
+
+ // The scrolling body — one row holding the whole column (Column+verticalScroll in
+ // the gold; a single-row LazyColumn here so ReloadData re-binds on snack change).
+ static View Body(Snack snack, double topInset) => new VStack(spacing: 0f)
+ {
+ // Header: 280dp gradient band with the 300dp circular image overlapping below.
+ new ZStack
+ {
+ new VStack(spacing: 0f) { new HStack().Frame(height: 280) }
+ .Frame(height: 280)
+ .BackgroundGradient(T.Tornado1)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Start),
+ new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: (float)(topInset + 56)),
+ new Image(snack.ImageRes).Frame(width: 300, height: 300).CornerRadius(150)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ }.HorizontalLayoutAlignment(LayoutAlignment.Center),
+ }
+ .Frame(height: (float)(topInset + 380))
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill),
+
+ // Title block (Title): name headlineMedium ITALIC textSecondary, tagline
+ // titleSmall 20 italic textHelp, price brand, divider.
+ new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: 16),
+ new Text(snack.Name).FontFamily("Montserrat").FontWeight(FontWeight.Semibold).FontSize(30)
+ .FontSlant(Microsoft.Maui.FontSlant.Italic).Color(T.TextSecondary)
+ .Padding(new Thickness(24, 0, 24, 0)),
+ new Text(snack.Tagline).FontFamily("Karla").FontWeight(FontWeight.Bold).FontSize(20)
+ .FontSlant(Microsoft.Maui.FontSlant.Italic).Color(T.TextHelp)
+ .Padding(new Thickness(24, 0, 24, 0)),
+ new HStack().Frame(height: 4),
+ T.TitleMedium(Jetsnack.FormatPrice(snack.Price)).Color(T.TextPrimary)
+ .Padding(new Thickness(24, 0, 24, 8)),
+ JetsnackHome.Divider(1),
+ },
+
+ // Body: Details overline + lorem placeholder + SEE MORE, Ingredients, divider,
+ // related collections (Customers also bought / Popular).
+ new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: 16),
+ T.LabelSmall("Details").Color(T.TextHelp)
+ .Padding(new Thickness(24, 0, 24, 0)),
+ new HStack().Frame(height: 16),
+ new Text(DetailPlaceholder).FontFamily("Karla").FontSize(16).LineHeight(28).Color(T.TextHelp)
+ .LineBreakMode(LineBreakMode.WordWrap).MaxLines(_seeMore ? 0 : 6)
+ .Padding(new Thickness(24, 0, 24, 0)),
+ T.LabelLarge(_seeMore ? "SEE LESS" : "SEE MORE")
+ .Color(T.TextLink)
+ .Padding(new Thickness(24, 15, 24, 0))
+ .HorizontalLayoutAlignment(LayoutAlignment.Center)
+ .OnTap(_ => { _seeMore = !_seeMore; _list?.ReloadData(); }),
+ new HStack().Frame(height: 40),
+ T.LabelSmall("Ingredients").Color(T.TextHelp)
+ .Padding(new Thickness(24, 0, 24, 0)),
+ new HStack().Frame(height: 4),
+ T.BodyLarge("Vanilla, Almond Flour, Eggs, Butter, Cream, Sugar")
+ .Color(T.TextHelp)
+ .LineBreakMode(LineBreakMode.WordWrap)
+ .Padding(new Thickness(24, 0, 24, 0)),
+ new HStack().Frame(height: 16),
+ JetsnackHome.Divider(1),
+ },
+
+ // Related collections (SnackRepo.getRelated) — the Home circle-row sections.
+ RelatedSection(snack.Id),
+ new HStack().Frame(height: BottomBarHeight + 8),
+ };
+
+ static bool _seeMore;
+
+ static View RelatedSection(long snackId)
+ {
+ var column = new VStack(spacing: 0f);
+ foreach (var collection in SnackRepo.GetRelated(snackId))
+ column.Add(JetsnackHome.RelatedCollection(collection));
+ return column;
+ }
+
+ /// CartBottomBar: divider + [QuantitySelector | gradient ADD TO CART].
+ static View CartBar()
+ {
+ var bar = new VStack(spacing: 0f)
+ {
+ JetsnackHome.Divider(1),
+ new HStack(spacing: 0f)
+ {
+ QuantitySelector(),
+ new HStack().Frame(width: 16),
+ new HStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ T.LabelLarge("ADD TO CART")
+ .Color(T.TextInteractive).MaxLines(1)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ }
+ .Frame(height: 36)
+ .BackgroundGradient(T.Tornado1).CornerRadius(18)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .FlexGrow(1).FlexBasis(0),
+ }
+ .Frame(height: BottomBarHeight)
+ .Padding(new Thickness(24, 0, 24, 0)),
+ new HStack().Frame(height: 24), // gesture-nav inset
+ }.Background(T.UiBackground);
+ return bar;
+ }
+
+ // QuantitySelector.kt: "Qty" (titleMedium, normal weight) + the gradient-bordered
+ // −/+ circles (shared with the cart) around the Karla-bold count.
+ static View QuantitySelector() => new HStack(spacing: 4f)
+ {
+ new Text("Qty").FontFamily("Montserrat").FontSize(16).Color(T.TextSecondary)
+ .Padding(new Thickness(0, 0, 14, 0))
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ JetsnackCart.StepperCircle("remove", () => { if (Count.Peek() > 0) Count.Value = Count.Peek() - 1; }),
+ new Text(() => Count.Value.ToString()).FontFamily("Karla").FontWeight(FontWeight.Bold).FontSize(18)
+ .Color(T.TextPrimary)
+ .Frame(width: 24)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ JetsnackCart.StepperCircle("add", () => Count.Value = Count.Peek() + 1),
+ }.FlexShrink(0);
+
+ const string DetailPlaceholder =
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut tempus, sem vitae " +
+ "convallis imperdiet, lectus nunc pharetra diam, ac rhoncus quam eros eu risus. " +
+ "Nulla pulvinar condimentum erat, pulvinar tempus turpis blandit ut. Etiam sed " +
+ "ipsum sed lacus eleifend hendrerit eu quis quam. Etiam ligula eros, finibus " +
+ "vestibulum tortor ac, ultrices accumsan dolor. Vivamus vel nisl a libero " +
+ "lobortis posuere. Aenean facilisis nibh vel ultrices bibendum. Pellentesque " +
+ "habitant morbi tristique senectus et netus et malesuada fames ac turpis " +
+ "egestas. Suspendisse ac est vitae lacus commodo efficitur at ut massa. Etiam " +
+ "vestibulum sit amet sapien sed varius. Aliquam non ipsum imperdiet, pulvinar " +
+ "enim nec, mollis risus. Fusce id tincidunt nisl.";
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackFilters.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackFilters.cs
new file mode 100644
index 000000000..5720f1066
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetsnack/JetsnackFilters.cs
@@ -0,0 +1,146 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Jetsnack.JetsnackTheme;
+
+namespace CometSamples.Jetsnack
+{
+ ///
+ /// The Filters sheet (ui/home/FilterScreen.kt): a rounded card floated over a scrim —
+ /// Sort rows (icon + name + check), Price chips, Category chips. Shown/hidden by
+ /// reactive Opacity on the overlay (the Jetchat FAB idiom — a faded-out node
+ /// doesn't eat taps); content is a one-row list reloaded on any filter toggle.
+ ///
+ public static class JetsnackFilters
+ {
+ static readonly Signal SortSelection = new(SnackRepo.SortDefault);
+ static ListView? _sheetList;
+ static View? _currentOverlay;
+ static bool _hooked;
+ static bool _openHooked;
+
+ public static View Overlay(Signal open)
+ {
+ var sheet = new ListView(() => new object[] { 0 })
+ {
+ ViewFor = _ => SheetContent(open),
+ };
+ _sheetList = sheet;
+ if (!_hooked)
+ {
+ _hooked = true;
+ SortSelection.PropertyChanged += (_, _) => _sheetList?.ReloadData();
+ foreach (var f in AllFilters())
+ f.Enabled.PropertyChanged += (_, _) => _sheetList?.ReloadData();
+ }
+
+ var overlay = new ZStack
+ {
+ // Scrim — tap closes (FilterScreen dismissOnClickOutside).
+ new VStack(spacing: 0f)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.Neutral8.WithAlpha(0.45f))
+ .OnTap(_ => open.Value = false),
+ new VStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ new VStack(spacing: 0f) { sheet.Frame(height: 470) }
+ .Frame(width: 340)
+ .Background(T.UiBackground).CornerRadius(16)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Opacity(open.Peek() ? 1.0 : 0.0);
+
+ open.PropertyChanged += (_, _) => overlay.Opacity(open.Peek() ? 1.0 : 0.0);
+ return overlay;
+ }
+
+ static IEnumerable AllFilters()
+ {
+ foreach (var f in SnackRepo.PriceFilters) yield return f;
+ foreach (var f in SnackRepo.CategoryFilters) yield return f;
+ }
+
+ static View SheetContent(Signal open) => new VStack(spacing: 0f)
+ {
+ // Header: X close | centered "Filters" titleLarge.
+ new HStack(spacing: 0f)
+ {
+ new Icon("close").IconSize(24).Color(T.IconSecondary)
+ .Frame(width: 48, height: 48).Padding(new Thickness(12))
+ .FlexShrink(0)
+ .OnTap(_ => open.Value = false),
+ new HStack().FlexGrow(1),
+ new Text("Filters").FontSize(18).FontWeight(FontWeight.Medium).Color(T.TextSecondary)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ new HStack().Frame(width: 48).FlexShrink(0),
+ }.Frame(height: 56),
+
+ SectionHeader("Sort"),
+ SortRow("android", "Android's favorite (default)"),
+ SortRow("star", "Rating"),
+ SortRow("sort_by_alpha", "Alphabetical"),
+
+ SectionHeader("Price"),
+ ChipRow(SnackRepo.PriceFilters),
+
+ SectionHeader("Category"),
+ ChipRows(SnackRepo.CategoryFilters, perRow: 2),
+ new HStack().Frame(height: 16),
+ }.Padding(new Thickness(16, 0, 16, 0));
+
+ static View SectionHeader(string title) =>
+ new Text(title).FontSize(16).FontWeight(FontWeight.Medium).Color(T.Brand)
+ .Padding(new Thickness(8, 16, 8, 8));
+
+ // FilterScreen SortFilters: icon + name + trailing check on the selection.
+ static View SortRow(string icon, string name) => new HStack(spacing: 0f)
+ {
+ new Icon(icon).IconSize(20).Color(T.TextSecondary)
+ .Frame(width: 36, height: 36).Padding(new Thickness(8))
+ .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0),
+ new Text(name).FontSize(14).Color(T.TextSecondary)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .FlexGrow(1).FlexBasis(0),
+ SortSelection.Peek() == name
+ ? new Icon("check").IconSize(20).Color(T.Brand)
+ .Frame(width: 36, height: 36).Padding(new Thickness(8))
+ .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0)
+ : new HStack().Frame(width: 36).FlexShrink(0),
+ }
+ .Frame(height: 44).Padding(new Thickness(8, 0, 8, 0))
+ .OnTap(_ => SortSelection.Value = name);
+
+ static View ChipRow(IReadOnlyList filters)
+ {
+ var row = new HStack(spacing: 8f);
+ foreach (var filter in filters)
+ row.Add(JetsnackHome.FilterChip(filter).FlexShrink(0));
+ return row.Frame(height: 44).Padding(new Thickness(8, 4, 8, 4));
+ }
+
+ static View ChipRows(IReadOnlyList filters, int perRow)
+ {
+ var column = new VStack(spacing: 0f);
+ for (int i = 0; i < filters.Count; i += perRow)
+ {
+ var slice = new List();
+ for (int j = i; j < filters.Count && j < i + perRow; j++)
+ slice.Add(filters[j]);
+ column.Add(ChipRow(slice));
+ }
+ return column;
+ }
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackHome.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackHome.cs
new file mode 100644
index 000000000..5ae452059
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetsnack/JetsnackHome.cs
@@ -0,0 +1,241 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using Comet;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Jetsnack.JetsnackTheme;
+
+namespace CometSamples.Jetsnack
+{
+ ///
+ /// Jetsnack Feed (ui/home/Feed.kt + ui/components/Snacks.kt), values-from-source.
+ /// Jetsnack is a CUSTOM design system — the gold hand-composes its chrome and cards
+ /// from Rows/Columns/Surfaces, so composing the SAME structure from Comet primitives
+ /// (with the new BackgroundGradient fills) IS the faithful reproduction.
+ ///
+ public static class JetsnackHome
+ {
+ // Snacks.kt: HighlightCardWidth = 170, height 250, gradient band 160/100, image 120.
+ const float HighlightCardWidth = 170f;
+
+ public static void OpenSnack(long id) => JetsnackRoot.OpenSnack(id);
+
+ // ── Feed rows: spacer(status+56 for the overlaid DestinationBar) is handled by the
+ // root; list = FilterBar + per-collection section + divider (Feed.kt SnackCollectionList). ──
+ abstract record FeedRow;
+ sealed record FilterBarRow : FeedRow;
+ sealed record CollectionRow(SnackCollection Collection) : FeedRow;
+
+ static IReadOnlyList FeedRows()
+ {
+ var rows = new List { new FilterBarRow() };
+ rows.AddRange(SnackRepo.GetSnacks().Select(c => (FeedRow)new CollectionRow(c)));
+ return rows;
+ }
+
+ public static View Feed(System.Action openFilters)
+ {
+ var list = new ListView(FeedRows)
+ {
+ ViewFor = r => r switch
+ {
+ FilterBarRow => FilterBar(openFilters),
+ CollectionRow c => CollectionSection(c.Collection),
+ _ => new HStack(),
+ },
+ };
+ return list;
+ }
+
+ /// DestinationBar.kt: 56dp TopAppBar — "Delivery to…" titleMedium
+ /// textSecondary centered + expand_more tinted brand; hairline divider below.
+ public static View DestinationBar() => new VStack(spacing: 0f)
+ {
+ new HStack(spacing: 0f)
+ {
+ new HStack().Frame(width: 48).FlexShrink(0), // balances the action slot
+ T.TitleMedium("Delivery to 1600 Amphitheater Way")
+ .Color(T.TextSecondary).MaxLines(1)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .FlexGrow(1).FlexBasis(0),
+ new Icon("expand_more").IconSize(24).Color(T.Brand)
+ .Frame(width: 48, height: 56).Padding(new Thickness(12, 16, 12, 16))
+ .FlexShrink(0),
+ }.Frame(height: 56),
+ Divider(1),
+ }.Background(T.UiBackground);
+
+ // FilterBar.kt: a REAL LazyRow (spacing 8, contentPadding start 12) — the gold's
+ // bar scrolls horizontally; the filter circle is the first item.
+ abstract record BarItem;
+ sealed record FilterCircle : BarItem;
+ sealed record ChipItem(Filter Filter) : BarItem;
+
+ static ListView? _filterBar;
+ static bool _filterBarHooked;
+
+ static View FilterBar(System.Action openFilters)
+ {
+ var items = new List { new FilterCircle() };
+ items.AddRange(SnackRepo.Filters.Select(f => (BarItem)new ChipItem(f)));
+ var row = new ListView(() => items)
+ {
+ Horizontal = true,
+ ViewFor = item => new VStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ item switch
+ {
+ FilterCircle => (View)new Icon("filter_list").IconSize(20).Color(T.Brand)
+ .Frame(width: 46, height: 46).Padding(new Thickness(13))
+ .BorderGradient(T.Gradient2_2).CornerRadius(23)
+ .OnTap(_ => openFilters()),
+ ChipItem c => FilterChip(c.Filter),
+ _ => new HStack(),
+ },
+ new HStack().FlexGrow(1),
+ }.Frame(height: 66).Padding(new Thickness(0, 0, 8, 0)),
+ };
+ _filterBar = row;
+ if (!_filterBarHooked)
+ {
+ // Subscribe ONCE (static filters outlive every rebuild); reload the
+ // current bar instance so the chip ON/OFF visuals follow the signal.
+ _filterBarHooked = true;
+ foreach (var f in SnackRepo.Filters)
+ f.Enabled.PropertyChanged += (_, _) => _filterBar?.ReloadData();
+ }
+ return row.Frame(height: 66).Margin(left: 12)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill);
+ }
+
+ /// FilterChip.kt: shapes.small pill — OFF = uiBackground with the
+ /// interactiveSecondary DIAGONAL GRADIENT border, bodySmall textSecondary;
+ /// ON = brandSecondary fill, black text, no border.
+ public static View FilterChip(Filter filter)
+ {
+ bool on = filter.Enabled.Peek();
+ var chip = new HStack(spacing: 0f)
+ {
+ T.BodySmall(filter.Name)
+ .Color(on ? Colors.Black : T.TextSecondary)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ }
+ .Frame(height: 28)
+ .Padding(new Thickness(20, 6, 20, 6))
+ .CornerRadius(14)
+ .OnTap(_ => filter.Enabled.Value = !filter.Enabled.Peek());
+ return on
+ ? chip.Background(T.BrandSecondary)
+ : chip.BorderGradient(T.Gradient2_2).Background(T.UiBackground);
+ }
+
+ // ── Collection section (Snacks.kt SnackCollection): header (name titleLarge BRAND,
+ // min 56, start 24, arrow at end) + Highlight card row or plain circle row + divider. ──
+ /// A collection section for reuse outside the feed (the detail's related rows).
+ public static View RelatedCollection(SnackCollection collection) => CollectionSection(collection);
+
+ static View CollectionSection(SnackCollection collection) => new VStack(spacing: 0f)
+ {
+ new HStack(spacing: 0f)
+ {
+ T.TitleLarge(collection.Name).Color(T.Brand)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .FlexGrow(1).FlexBasis(0),
+ new Icon("arrow_back").IconSize(24).Color(T.Brand)
+ .Frame(width: 48, height: 48).Padding(new Thickness(12))
+ .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0),
+ }.Frame(height: 56).Padding(new Thickness(24, 0, 4, 0)),
+ collection.Type == CollectionType.Highlight
+ ? HighlightRow(collection)
+ : SnackRow(collection),
+ Divider(2).Margin(new Thickness(0, 16, 0, 0)),
+ };
+
+ static View HighlightRow(SnackCollection collection)
+ {
+ // Index derived from the item, NOT a captured counter — ViewFor re-runs on
+ // every rebuild/reload and a counter would shift the gradient alternation.
+ var row = new ListView(() => collection.Snacks)
+ {
+ Horizontal = true,
+ // Wrapper padding, not item margin: the LazyRow item box is the row root's
+ // measured width, which excludes margins — cards rendered flush without it.
+ ViewFor = snack => new VStack(spacing: 0f)
+ {
+ HighlightSnackItem(snack, IndexOf(collection.Snacks, snack)),
+ }.Padding(new Thickness(0, 0, 16, 0)),
+ };
+ return row.Frame(height: 266).Margin(left: 24)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill);
+ }
+
+ /// HighlightSnackItem: 170×250 r20 card — gradient band 160 (alternating
+ /// gradient6_1/6_2 like the gold's index % 2), 120 circle image overlapping the band,
+ /// name titleLarge, tagline bodyLarge textHelp.
+ static View HighlightSnackItem(Snack snack, int index) => new VStack(spacing: 0f)
+ {
+ new ZStack
+ {
+ // Gradient band pinned to the card TOP (Snacks.kt Box: gradient 100 at top,
+ // image bottom-center overlapping it).
+ new VStack(spacing: 0f) { new HStack().Frame(height: 100) }
+ .Frame(width: HighlightCardWidth, height: 100)
+ .BackgroundGradient(index % 2 == 0 ? T.Gradient6_1 : T.Gradient6_2)
+ .VerticalLayoutAlignment(LayoutAlignment.Start),
+ new Image(snack.ImageRes).Frame(width: 120, height: 120).CornerRadius(60)
+ .Margin(new Thickness(25, 40, 25, 0)),
+ }.Frame(width: HighlightCardWidth, height: 160),
+ new HStack().Frame(height: 8),
+ T.TitleLarge(snack.Name).Color(T.TextSecondary).MaxLines(1)
+ .Padding(new Thickness(16, 0, 16, 0)),
+ new HStack().Frame(height: 4),
+ T.BodyLarge(snack.Tagline).Color(T.TextHelp).MaxLines(1)
+ .Padding(new Thickness(16, 0, 16, 0)),
+ }
+ .Frame(width: HighlightCardWidth, height: 250)
+ .Background(T.UiBackground).CornerRadius(20)
+ .Border(1, T.UiBorder)
+ .OnTap(_ => OpenSnack(snack.Id));
+
+ static int IndexOf(System.Collections.Generic.IReadOnlyList snacks, Snack snack)
+ {
+ for (int i = 0; i < snacks.Count; i++)
+ if (ReferenceEquals(snacks[i], snack))
+ return i;
+ return 0;
+ }
+
+ /// SnackItem (Normal rows): 120 circle image + name titleMedium under it.
+ static View SnackItem(Snack snack) => new VStack(spacing: 0f)
+ {
+ new Image(snack.ImageRes).Frame(width: 120, height: 120).CornerRadius(60),
+ T.TitleMedium(snack.Name).Color(T.TextSecondary)
+ .Padding(new Thickness(0, 8, 0, 0))
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ }
+ .Padding(new Thickness(8, 0, 8, 8))
+ .OnTap(_ => OpenSnack(snack.Id));
+
+ static View SnackRow(SnackCollection collection)
+ {
+ var row = new ListView(() => collection.Snacks)
+ {
+ Horizontal = true,
+ ViewFor = SnackItem,
+ };
+ return row.Frame(height: 160).Margin(left: 12)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill);
+ }
+
+ /// JetsnackDivider: hairline uiBorder.
+ public static View Divider(float thickness) => new HStack()
+ .Frame(height: thickness)
+ .Background(T.UiBorder)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill);
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackIcons.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackIcons.cs
new file mode 100644
index 000000000..741567c9d
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetsnack/JetsnackIcons.cs
@@ -0,0 +1,42 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+
+namespace CometSamples.Jetsnack
+{
+ /// Jetsnack's Material Icons glyph map (the shared one-font approach).
+ static class JetsnackIcons
+ {
+ public const string Font = "Material Icons";
+
+ static bool _registered;
+
+ public static void Register()
+ {
+ if (_registered)
+ return;
+ _registered = true;
+
+ var map = new Dictionary();
+ void Add(string name, int codepoint) => map[name] = char.ConvertFromUtf32(codepoint);
+
+ Add("expand_more", 0xE5CF); // destination bar
+ Add("filter_list", 0xE152); // filter bar
+ Add("arrow_forward", 0xE5C8); // section header (gold ic_arrow_back mirrored)
+ Add("arrow_back", 0xE5C4); // detail up
+ Add("home", 0xE88A); // bottom bar
+ Add("search", 0xE8B6);
+ Add("shopping_cart", 0xE8CC);
+ Add("account_circle", 0xE853);
+ Add("add", 0xE145); // qty stepper
+ Add("remove", 0xE15B);
+ Add("close", 0xE5CD); // filters sheet / cart remove
+ Add("star", 0xE838); // sort filter
+ Add("check", 0xE5CA); // sort selection
+ Add("sort_by_alpha", 0xE053);
+ Add("android", 0xE859);
+
+ IconFont.Register(Font, map);
+ }
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackModel.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackModel.cs
new file mode 100644
index 000000000..e2c751d83
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetsnack/JetsnackModel.cs
@@ -0,0 +1,188 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+
+namespace CometSamples.Jetsnack
+{
+ // Verbatim port of the gold model (model/Snack.kt, SnackCollection.kt, Filter.kt,
+ // Search.kt) — same names, same shape. Image ids become bundled resource NAMES.
+ // The gold assigns Random.nextLong() ids; here ids are stable sequential (the gold
+ // only needs uniqueness).
+
+ public sealed record Snack(
+ long Id,
+ string Name,
+ string ImageRes,
+ long Price,
+ string Tagline = "",
+ IReadOnlyCollection? Tags = null);
+
+ public sealed record SnackCollection(
+ long Id,
+ string Name,
+ IReadOnlyList Snacks,
+ CollectionType Type = CollectionType.Normal);
+
+ public enum CollectionType { Normal, Highlight }
+
+ public sealed record OrderLine(Snack Snack, int Count);
+
+ /// Filter.kt — enabled is mutable UI state (a signal here, matching the
+ /// gold's mutableStateOf).
+ public sealed class Filter
+ {
+ public Filter(string name, bool enabled = false, string? icon = null)
+ {
+ Name = name;
+ Icon = icon;
+ Enabled = new Comet.Reactive.Signal(enabled);
+ }
+
+ public string Name { get; }
+ public string? Icon { get; }
+ public Comet.Reactive.Signal Enabled { get; }
+ }
+
+ public sealed record SearchCategoryCollection(long Id, string Name, IReadOnlyList Categories);
+ public sealed record SearchCategory(string Name, string ImageRes);
+ public sealed record SearchSuggestionGroup(long Id, string Name, IReadOnlyList Suggestions);
+
+ /// The gold's single formatPrice (ui/utils/Currency.kt) — cents → "$x.yy",
+ /// sign-safe (integer / and % both carry the sign in C#).
+ public static class Jetsnack
+ {
+ public static string FormatPrice(long price)
+ {
+ long abs = System.Math.Abs(price);
+ return $"{(price < 0 ? "-" : "")}${abs / 100}.{abs % 100:00}";
+ }
+ }
+
+ public static class SnackRepo
+ {
+ static long _nextId = 1;
+ static long NextId() => _nextId++;
+
+ static Snack S(string name, string image, long price, string tagline = "") =>
+ new(NextId(), name, image, price, tagline);
+
+ /// model/Snack.kt `snacks` — order matters (collections slice by index).
+ public static readonly IReadOnlyList Snacks = new[]
+ {
+ S("Cupcake", "cupcake", 299, "A tag line"),
+ S("Donut", "donut", 299, "A tag line"),
+ S("Eclair", "eclair", 299, "A tag line"),
+ S("Froyo", "froyo", 299, "A tag line"),
+ S("Gingerbread", "gingerbread", 499, "A tag line"),
+ S("Honeycomb", "honeycomb", 299, "A tag line"),
+ S("Ice Cream Sandwich", "ice_cream_sandwich", 1299, "A tag line"),
+ S("Jellybean", "jelly_bean", 299, "A tag line"),
+ S("KitKat", "kitkat", 549, "A tag line"),
+ S("Lollipop", "lollipop", 299, "A tag line"),
+ S("Marshmallow", "marshmallow", 299, "A tag line"),
+ S("Nougat", "nougat", 299, "A tag line"),
+ S("Oreo", "oreo", 299, "A tag line"),
+ S("Pie", "pie", 299, "A tag line"),
+ S("Chips", "chips", 299),
+ S("Pretzels", "pretzels", 299),
+ S("Smoothies", "smoothies", 299),
+ S("Popcorn", "popcorn", 299),
+ S("Almonds", "almonds", 299),
+ S("Cheese", "cheese", 299),
+ S("Apples", "apples", 299, "A tag line"),
+ S("Apple sauce", "apple_sauce", 299, "A tag line"),
+ S("Apple chips", "apple_chips", 299, "A tag line"),
+ S("Apple juice", "apple_juice", 299, "A tag line"),
+ S("Apple pie", "apple_pie", 299, "A tag line"),
+ S("Grapes", "grapes", 299, "A tag line"),
+ S("Kiwi", "kiwi", 299, "A tag line"),
+ S("Mango", "mango", 299, "A tag line"),
+ };
+
+ // SnackCollection.kt — tastyTreats/popular + renamed copies.
+ static readonly SnackCollection TastyTreats = new(NextId(), "Android's picks",
+ Snacks.Take(13).ToArray(), CollectionType.Highlight);
+ static readonly SnackCollection Popular = new(NextId(), "Popular on Jetsnack",
+ Snacks.Skip(14).Take(5).ToArray());
+ static readonly SnackCollection WfhFavs = TastyTreats with { Id = NextId(), Name = "WFH favourites" };
+ static readonly SnackCollection NewlyAdded = Popular with { Id = NextId(), Name = "Newly Added" };
+ static readonly SnackCollection Exclusive = TastyTreats with { Id = NextId(), Name = "Only on Jetsnack" };
+ static readonly SnackCollection Also = TastyTreats with { Id = NextId(), Name = "Customers also bought" };
+ static readonly SnackCollection InspiredByCartCollection = TastyTreats with { Id = NextId(), Name = "Inspired by your cart" };
+
+ public static IReadOnlyList GetSnacks() => new[]
+ {
+ TastyTreats, Popular, WfhFavs, NewlyAdded, Exclusive,
+ };
+
+ public static Snack GetSnack(long snackId) => Snacks.First(s => s.Id == snackId);
+
+ public static IReadOnlyList GetRelated(long snackId) => new[]
+ {
+ Also with { Id = 900 }, Popular with { Id = 901 },
+ };
+
+ public static SnackCollection GetInspiredByCart() => InspiredByCartCollection;
+
+ public static IReadOnlyList GetCart() => new[]
+ {
+ new OrderLine(Snacks[4], 2),
+ new OrderLine(Snacks[6], 3),
+ new OrderLine(Snacks[8], 1),
+ };
+
+ // Filter.kt
+ public static readonly IReadOnlyList Filters = new[]
+ {
+ new Filter("Organic"), new Filter("Gluten-free"), new Filter("Dairy-free"),
+ new Filter("Sweet"), new Filter("Savory"),
+ };
+ public static readonly IReadOnlyList PriceFilters = new[]
+ {
+ new Filter("$"), new Filter("$$"), new Filter("$$$"), new Filter("$$$$"),
+ };
+ public static readonly IReadOnlyList SortFilters = new[]
+ {
+ new Filter("Android's favorite (default)", icon: "android"),
+ new Filter("Rating", icon: "star"),
+ new Filter("Alphabetical", icon: "sort_by_alpha"),
+ };
+ public static readonly IReadOnlyList CategoryFilters = new[]
+ {
+ new Filter("Chips & crackers"), new Filter("Fruit snacks"),
+ new Filter("Desserts"), new Filter("Nuts"),
+ };
+ public static readonly IReadOnlyList LifeStyleFilters = new[]
+ {
+ new Filter("Organic"), new Filter("Gluten-free"), new Filter("Dairy-free"),
+ new Filter("Sweet"), new Filter("Savory"),
+ };
+ public static string SortDefault => SortFilters[0].Name;
+ }
+
+ public static class SearchRepo
+ {
+ public static IReadOnlyList GetCategories() => new[]
+ {
+ new SearchCategoryCollection(0, "Categories", new[]
+ {
+ new SearchCategory("Chips & crackers", "chips"),
+ new SearchCategory("Fruit snacks", "fruit"),
+ new SearchCategory("Desserts", "desserts"),
+ new SearchCategory("Nuts", "nuts"),
+ }),
+ new SearchCategoryCollection(1, "Lifestyles", new[]
+ {
+ new SearchCategory("Organic", "organic"),
+ new SearchCategory("Gluten Free", "gluten_free"),
+ new SearchCategory("Paleo", "paleo"),
+ new SearchCategory("Vegan", "vegan"),
+ new SearchCategory("Vegetarian", "vegetarian"),
+ new SearchCategory("Whole30", "whole30"),
+ }),
+ };
+
+ public static IReadOnlyList Search(string query) =>
+ SnackRepo.Snacks.Where(s => s.Name.Contains(query, System.StringComparison.OrdinalIgnoreCase)).ToArray();
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackRoot.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackRoot.cs
new file mode 100644
index 000000000..11b91be93
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetsnack/JetsnackRoot.cs
@@ -0,0 +1,171 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Jetsnack.JetsnackTheme;
+
+namespace CometSamples.Jetsnack
+{
+ ///
+ /// Jetsnack chrome + routing (ui/home/Home.kt HomeSections + JetsnackBottomBar).
+ /// The gold's bottom bar is its OWN composable (brand background, selected item =
+ /// icon + UPPERCASE label, others icon-only inactive) — hand-composed here from the
+ /// same structure. Routes swap through ContentSwitcher (retained-node contract).
+ ///
+ public class JetsnackRoot : View
+ {
+ /// 0 Feed, 1 Search, 2 Cart, 3 Profile (HomeSections order).
+ public static readonly Signal SelectedTab = new(0);
+ public static readonly Signal CurrentSnack = new(1);
+ public static readonly Signal DetailOpen = new(false);
+ public static readonly Signal FiltersOpen = new(false);
+
+ static JetsnackRoot()
+ {
+ SelectedTab.PropertyChanged += (_, _) =>
+ {
+ DetailOpen.Value = false;
+ _bottomBar?.ReloadData(); // the selected pill follows the active tab
+ };
+ }
+
+ public static void OpenSnack(long id)
+ {
+ CurrentSnack.Value = id;
+ DetailOpen.Value = true;
+ }
+
+ readonly double _topInset;
+
+ public JetsnackRoot(double topInset = 0) => _topInset = topInset;
+
+ [Body]
+ View body()
+ {
+ JetsnackIcons.Register();
+
+ // Feed with the DestinationBar overlaid at the top (the gold's Box overlay —
+ // the bar floats over the scrolling feed).
+ var feed = new ZStack
+ {
+ new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: (float)(_topInset + 56)).FlexShrink(0),
+ JetsnackHome.Feed(openFilters: () => FiltersOpen.Value = true)
+ .FlexGrow(1).FlexBasis(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill),
+ new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: (float)_topInset).Background(T.UiBackground).FlexShrink(0),
+ JetsnackHome.DestinationBar().HorizontalLayoutAlignment(LayoutAlignment.Fill).FlexShrink(0),
+ new HStack().FlexGrow(1),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill);
+
+ // Compact push: DetailOpen swaps feed ↔ detail full-screen (system back closes) —
+ // the ListDetail primitive, same as JetNews.
+ var feedWithDetail = new ListDetail(DetailOpen, feed,
+ JetsnackDetail.Screen(CurrentSnack, _topInset, onBack: () => DetailOpen.Value = false));
+
+ var bottomBar = new ListView(() => new[] { 0 }) { ViewFor = _ => BottomBarRow() };
+ _bottomBar = bottomBar;
+
+ var routes = new ContentSwitcher(SelectedTab, new View[]
+ {
+ feedWithDetail,
+ JetsnackSearch.Screen(_topInset),
+ JetsnackCart.Screen(_topInset),
+ // The gold Profile IS a work-in-progress placeholder.
+ Placeholder("This is currently work in progress"),
+ });
+
+ return new ZStack
+ {
+ new VStack(spacing: 0f)
+ {
+ routes.FlexGrow(1).FlexBasis(0),
+ bottomBar.Frame(height: 80).FlexShrink(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill),
+ // Filters sheet floats over everything; reactive-opacity show/hide.
+ JetsnackFilters.Overlay(FiltersOpen),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.UiBackground);
+ }
+
+ static readonly (string Icon, string Label)[] Tabs =
+ {
+ ("home", "HOME"), ("search", "SEARCH"), ("shopping_cart", "MY CART"), ("account_circle", "PROFILE"),
+ };
+
+ static ListView? _bottomBar;
+
+ /// JetsnackBottomBar: brand background; SELECTED item = bordered pill with
+ /// icon + uppercase label (iconInteractive), others icon-only (iconInteractiveInactive).
+ /// Rendered as a one-row list reloaded on tab change so the pill FOLLOWS the selection
+ /// (a Peek-built bar froze on HOME — caught by review).
+ static View BottomBarRow()
+ {
+ var row = new HStack(spacing: 0f);
+ for (int i = 0; i < Tabs.Length; i++)
+ {
+ int index = i;
+ bool selected = SelectedTab.Peek() == index;
+ var cell = new HStack(spacing: 0f) { new HStack().FlexGrow(1) };
+ var content = new HStack(spacing: 8f)
+ {
+ new Icon(Tabs[i].Icon).IconSize(24)
+ .Color(selected ? T.IconInteractive : T.IconInteractiveInactive)
+ .VerticalLayoutAlignment(LayoutAlignment.Center),
+ };
+ if (selected)
+ content.Add(T.LabelLarge(Tabs[i].Label)
+ .Color(T.IconInteractive)
+ .VerticalLayoutAlignment(LayoutAlignment.Center));
+ var pill = new VStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ content.HorizontalLayoutAlignment(LayoutAlignment.Center),
+ new HStack().FlexGrow(1),
+ }
+ .Frame(height: 40)
+ .Padding(new Thickness(16, 0, 16, 0));
+ if (selected)
+ pill = (VStack)pill.Border(1, T.IconInteractive).CornerRadius(20);
+ cell.Add(pill.VerticalLayoutAlignment(LayoutAlignment.Center));
+ cell.Add(new HStack().FlexGrow(1));
+ row.Add(cell.FlexGrow(selected ? 1.6f : 1f).FlexBasis(0)
+ .OnTap(_ => SelectedTab.Value = index));
+ }
+ return new VStack(spacing: 0f)
+ {
+ row.Frame(height: 56),
+ new HStack().Frame(height: 24), // gesture-nav inset strip
+ }.Background(T.Brand);
+ }
+
+ static View Placeholder(string message) => new VStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ T.TitleMedium(message).Color(T.TextSecondary)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ T.BodyLarge("Grab a beverage and check back later!").Color(T.TextHelp)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center).Padding(new Thickness(0, 8, 0, 0)),
+ new HStack().FlexGrow(1),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.UiBackground);
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackSearch.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackSearch.cs
new file mode 100644
index 000000000..4c8230226
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetsnack/JetsnackSearch.cs
@@ -0,0 +1,116 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Jetsnack.JetsnackTheme;
+
+namespace CometSamples.Jetsnack
+{
+ ///
+ /// Search (ui/home/search): the uiFloated search bar over the Categories/Lifestyles
+ /// two-column grids — each category a 10dp-corner gradient card (name titleMedium on
+ /// the left, image on the right; gradients alternate 2_2/2_3 per collection index).
+ /// Query results are the next increment (the gold's suggestion/result states).
+ ///
+ public static class JetsnackSearch
+ {
+ static readonly Signal Query = new(string.Empty);
+
+ public static View Screen(double topInset)
+ {
+ var rows = new List { "bar" };
+ foreach (var collection in SearchRepo.GetCategories())
+ rows.Add(collection);
+
+ var list = new ListView(() => rows)
+ {
+ ViewFor = r => r switch
+ {
+ string => SearchBar(),
+ SearchCategoryCollection c => CollectionGrid(c),
+ _ => new HStack(),
+ },
+ };
+
+ return new VStack(spacing: 0f)
+ {
+ new HStack().Frame(height: (float)topInset).FlexShrink(0),
+ list.FlexGrow(1).FlexBasis(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill)
+ .Background(T.UiBackground);
+ }
+
+ // Search.kt SearchBar: 56dp uiFloated rounded surface, search icon + hint.
+ static View SearchBar() => new VStack(spacing: 0f)
+ {
+ // Wrapper padding, not margin: Fill + margin overflows the right edge
+ // (the fill width isn't reduced by the margin).
+ new HStack(spacing: 0f)
+ {
+ new Icon("search").IconSize(24).Color(T.TextHelp)
+ .Frame(width: 48, height: 40).Padding(new Thickness(12, 8, 12, 8))
+ .VerticalLayoutAlignment(LayoutAlignment.Center).FlexShrink(0),
+ SignalExtensions.TextField(Query, "Search Jetsnack")
+ .Borderless()
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .FlexGrow(1).FlexBasis(0),
+ }
+ .Frame(height: 40)
+ .Background(T.UiFloated).CornerRadius(20)
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill),
+ }
+ .Padding(new Thickness(24, 8, 24, 8))
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill);
+
+ static View CollectionGrid(SearchCategoryCollection collection)
+ {
+ var grid = new VStack(spacing: 0f)
+ {
+ T.TitleLarge(collection.Name).Color(T.Brand)
+ .Padding(new Thickness(24, 16, 24, 4)),
+ };
+ // VerticalGrid: two columns, pad 16, item pad 8.
+ for (int i = 0; i < collection.Categories.Count; i += 2)
+ {
+ var row = new HStack(spacing: 0f)
+ {
+ CategoryCard(collection.Categories[i], (int)collection.Id)
+ .FlexGrow(1).FlexBasis(0),
+ };
+ if (i + 1 < collection.Categories.Count)
+ row.Add(CategoryCard(collection.Categories[i + 1], (int)collection.Id)
+ .FlexGrow(1).FlexBasis(0));
+ else
+ row.Add(new HStack().FlexGrow(1).FlexBasis(0));
+ grid.Add(row.Padding(new Thickness(16, 0, 16, 0))
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill));
+ }
+ grid.Add(new HStack().Frame(height: 4));
+ return grid;
+ }
+
+ // Categories.kt SearchCategory: r10 gradient card (2_2 for Categories, 2_3 for
+ // Lifestyles), name titleMedium left, circular image right, min image 134.
+ static View CategoryCard(SearchCategory category, int collectionIndex) => new HStack(spacing: 0f)
+ {
+ T.TitleMedium(category.Name).Color(T.TextSecondary)
+ .LineBreakMode(LineBreakMode.WordWrap)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .Padding(new Thickness(12, 4, 4, 4))
+ .FlexGrow(1).FlexBasis(0),
+ new Image(category.ImageRes).Frame(width: 120, height: 120).CornerRadius(60)
+ .VerticalLayoutAlignment(LayoutAlignment.Center)
+ .FlexShrink(0),
+ }
+ .Frame(height: 134)
+ .BackgroundGradient(collectionIndex % 2 == 0 ? T.Gradient2_2 : T.Gradient2_3)
+ .CornerRadius(10)
+ .Margin(new Thickness(8))
+ .OnTap(_ => { /* the gold's category filter route is toast-stubbed too */ });
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/JetsnackTheme.cs b/src/Comet/sample/Shared/Jetsnack/JetsnackTheme.cs
new file mode 100644
index 000000000..47a346427
--- /dev/null
+++ b/src/Comet/sample/Shared/Jetsnack/JetsnackTheme.cs
@@ -0,0 +1,83 @@
+#nullable enable
+using Comet;
+using Microsoft.Maui.Graphics;
+
+namespace CometSamples.Jetsnack
+{
+ ///
+ /// Jetsnack's CUSTOM design system palette, values-from-source (ui/theme/Color.kt +
+ /// Theme.kt LightColorPalette). Unlike Reply/JetNews this is NOT a Material scheme —
+ /// the gold provides its own JetsnackColors (brand ramps + gradient stop lists) and
+ /// hand-composes its chrome from them, so the Comet sample carries the same tokens.
+ /// (Light palette only — the golds are light; dark is a later pass.)
+ ///
+ public static class JetsnackTheme
+ {
+ static Color C(uint argb) => Color.FromUint(argb);
+
+ // ── Color.kt ramps (the ones the light palette + gradients reference) ──
+ public static readonly Color Shadow11 = C(0xff001787);
+ public static readonly Color Shadow9 = C(0xff0009b3);
+ public static readonly Color Shadow5 = C(0xff4b30ed);
+ public static readonly Color Shadow4 = C(0xff7057f5);
+ public static readonly Color Shadow3 = C(0xff9b86fa);
+ public static readonly Color Shadow2 = C(0xffc8bbfd);
+ public static readonly Color Ocean11 = C(0xff005687);
+ public static readonly Color Ocean3 = C(0xff86f7fa);
+ public static readonly Color Lavender3 = C(0xffc186fa);
+ public static readonly Color Rose4 = C(0xfff4568b);
+ public static readonly Color Rose2 = C(0xfffdbbcf);
+ public static readonly Color Neutral8 = C(0xff121212);
+ public static readonly Color Neutral7 = C(0xde000000);
+ public static readonly Color Neutral6 = C(0x99000000);
+ public static readonly Color Neutral5 = C(0x61000000);
+ public static readonly Color Neutral4 = C(0x1f000000);
+ public static readonly Color Neutral1 = C(0xbdffffff);
+ public static readonly Color Neutral0 = C(0xffffffff);
+ public static readonly Color FunctionalRed = C(0xffd00036);
+ public static readonly Color FunctionalGreen = C(0xff52c41a);
+ public static readonly Color FunctionalGrey = C(0xfff6f6f6);
+
+ // ── Theme.kt LightColorPalette roles ──
+ public static readonly Color Brand = Shadow5;
+ public static readonly Color BrandSecondary = Ocean3;
+ public static readonly Color UiBackground = Neutral0;
+ public static readonly Color UiBorder = Neutral4;
+ public static readonly Color UiFloated = FunctionalGrey;
+ public static readonly Color TextPrimary = Shadow5; // defaults to brand in the gold
+ public static readonly Color TextSecondary = Neutral7;
+ public static readonly Color TextHelp = Neutral6;
+ public static readonly Color TextInteractive = Neutral0;
+ public static readonly Color IconPrimary = Shadow5;
+ public static readonly Color IconSecondary = Neutral7;
+ public static readonly Color IconInteractive = Neutral0;
+ public static readonly Color IconInteractiveInactive = Neutral1;
+ public static readonly Color TextLink = Ocean11;
+ public static readonly Color Error = FunctionalRed;
+
+ // ── Typography (Type.kt): Montserrat (SemiBold headings/labels) + Karla (body).
+ // Both families ship in the probes; helpers keep every screen on the gold styles. ──
+ public static Comet.Text TitleLarge(string s) => (Comet.Text)new Comet.Text(s)
+ .FontFamily("Montserrat").FontWeight(Microsoft.Maui.FontWeight.Semibold).FontSize(20);
+ public static Comet.Text TitleMedium(string s) => (Comet.Text)new Comet.Text(s)
+ .FontFamily("Montserrat").FontWeight(Microsoft.Maui.FontWeight.Semibold).FontSize(16);
+ public static Comet.Text BodyLarge(string s) => (Comet.Text)new Comet.Text(s)
+ .FontFamily("Karla").FontSize(16);
+ public static Comet.Text BodySmall(string s) => (Comet.Text)new Comet.Text(s)
+ .FontFamily("Karla").FontWeight(Microsoft.Maui.FontWeight.Bold).FontSize(12);
+ public static Comet.Text LabelLarge(string s) => (Comet.Text)new Comet.Text(s)
+ .FontFamily("Montserrat").FontWeight(Microsoft.Maui.FontWeight.Semibold).FontSize(14);
+ public static Comet.Text LabelSmall(string s) => (Comet.Text)new Comet.Text(s)
+ .FontFamily("Montserrat").FontWeight(Microsoft.Maui.FontWeight.Semibold).FontSize(12);
+
+ // ── Gradient stop lists (light) — Theme.kt:42-49 ──
+ public static readonly Color[] Gradient6_1 = { Shadow4, Ocean3, Shadow2, Ocean3, Shadow4 };
+ public static readonly Color[] Gradient6_2 = { Rose4, Lavender3, Rose2, Lavender3, Rose4 };
+ public static readonly Color[] Gradient3_1 = { Shadow2, Ocean3, Shadow4 };
+ public static readonly Color[] Gradient3_2 = { Rose2, Lavender3, Rose4 };
+ public static readonly Color[] Gradient2_1 = { Shadow4, Shadow11 };
+ public static readonly Color[] Gradient2_2 = { Ocean3, Shadow3 };
+ public static readonly Color[] Gradient2_3 = { Lavender3, Rose2 };
+ public static readonly Color[] Tornado1 = { Shadow4, Ocean3 };
+ }
+}
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-01-home.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-01-home.png
new file mode 100644
index 000000000..4f9f97142
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-01-home.png differ
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-02-home-scrolled.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-02-home-scrolled.png
new file mode 100644
index 000000000..585cb8f4e
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-02-home-scrolled.png differ
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-03-filters.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-03-filters.png
new file mode 100644
index 000000000..9b3a9c547
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-03-filters.png differ
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-04-detail.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-04-detail.png
new file mode 100644
index 000000000..4c2d09730
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-04-detail.png differ
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-05-detail-scrolled.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-05-detail-scrolled.png
new file mode 100644
index 000000000..7ac1e78fb
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-05-detail-scrolled.png differ
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-06-search.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-06-search.png
new file mode 100644
index 000000000..1fe7c4d74
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-06-search.png differ
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-07-cart.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-07-cart.png
new file mode 100644
index 000000000..ac5d5d1da
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-07-cart.png differ
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/compact-08-profile.png b/src/Comet/sample/Shared/Jetsnack/gold/compact-08-profile.png
new file mode 100644
index 000000000..3be28e0c7
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/compact-08-profile.png differ
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/expanded-1260dp-01.png b/src/Comet/sample/Shared/Jetsnack/gold/expanded-1260dp-01.png
new file mode 100644
index 000000000..fce6e9312
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/expanded-1260dp-01.png differ
diff --git a/src/Comet/sample/Shared/Jetsnack/gold/medium-700dp-01.png b/src/Comet/sample/Shared/Jetsnack/gold/medium-700dp-01.png
new file mode 100644
index 000000000..cb1a4fe83
Binary files /dev/null and b/src/Comet/sample/Shared/Jetsnack/gold/medium-700dp-01.png differ
diff --git a/src/Comet/sample/Shared/Reply/ReplyChromeProbe.cs b/src/Comet/sample/Shared/Reply/ReplyChromeProbe.cs
new file mode 100644
index 000000000..15c269cc7
--- /dev/null
+++ b/src/Comet/sample/Shared/Reply/ReplyChromeProbe.cs
@@ -0,0 +1,85 @@
+#nullable enable
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Reply.ReplyTheme;
+
+namespace CometSamples.Reply
+{
+ ///
+ /// Reply's root: the adaptive NavigationSuite hosting a ContentSwitcher over the four
+ /// gold routes (Inbox = real ListDetail screens; Articles / DirectMessages / Groups =
+ /// EmptyComingSoon, matching ReplyApp.kt:128-153).
+ ///
+ public class ReplyProbeRoot : View
+ {
+ static readonly Signal Selected = new(0);
+ static readonly Signal DrawerOpen = new(false);
+
+ [Body]
+ View body()
+ {
+ ReplyIcons.Register();
+
+ var items = new[]
+ {
+ NavItem("inbox", "Inbox"),
+ NavItem("article", "Articles"),
+ NavItem("chat_outline", "Direct Messages"),
+ NavItem("people_outline", "Groups"),
+ };
+
+ var routes = new ContentSwitcher(Selected, new[]
+ {
+ ReplyScreens.Inbox(),
+ ReplyScreens.ComingSoon(),
+ ReplyScreens.ComingSoon(),
+ ReplyScreens.ComingSoon(),
+ });
+
+ return new NavigationSuite(Selected, items, routes,
+ railHeader: RailHeader(), drawerHeader: DrawerHeader(),
+ drawerOpen: DrawerOpen,
+ // M3 chrome tokens for the hand-composed iOS twin (Android's real
+ // widgets take these from the Compose scheme).
+ containerColor: T.SurfaceContainer, indicatorColor: T.SecondaryContainer)
+ .Background(T.Background); // paints the safe-area strips too
+ }
+
+ // The gold drawer variant shows labeled items — label views feed NavigationDrawerItem
+ // slots; the bar/rail render icon-only, matching the gold.
+ static NavigationItem NavItem(string icon, string label) =>
+ new(new Icon(icon).IconSize(24).Color(T.OnSurfaceVariant), new Text(label).FontSize(14));
+
+ // The gold rail header: a menu affordance above the compose FAB (drawer wiring lands
+ // with the modal-drawer increment).
+ static View RailHeader() => new VStack(spacing: 4)
+ {
+ new Icon("menu").IconSize(24).Color(T.OnSurfaceVariant)
+ .Frame(width: 48, height: 48).Padding(new Thickness(12))
+ .OnTap(_ => DrawerOpen.Value = true),
+ new Icon("edit").IconSize(18).Color(T.OnTertiaryContainer)
+ .Frame(width: 56, height: 56)
+ .Background(T.TertiaryContainer)
+ .CornerRadius(16),
+ }.HorizontalLayoutAlignment(LayoutAlignment.Center);
+
+ // The gold permanent-drawer header: "REPLY" wordmark + the Compose extended FAB.
+ static View DrawerHeader() => new VStack(spacing: 8)
+ {
+ new Text("REPLY").FontSize(16).FontWeight(FontWeight.Semibold)
+ .Color(T.Primary),
+ new HStack(spacing: 8)
+ {
+ new Icon("edit").IconSize(18).Color(T.OnTertiaryContainer),
+ new Text("Compose").FontSize(14).Color(T.OnTertiaryContainer),
+ }
+ .Frame(height: 56)
+ .Background(T.TertiaryContainer)
+ .CornerRadius(16)
+ .Padding(new Thickness(16, 0)),
+ }.Padding(new Thickness(16, 16, 16, 40));
+ }
+}
diff --git a/src/Comet/sample/Shared/Reply/ReplyData.cs b/src/Comet/sample/Shared/Reply/ReplyData.cs
new file mode 100644
index 000000000..984505451
--- /dev/null
+++ b/src/Comet/sample/Shared/Reply/ReplyData.cs
@@ -0,0 +1,98 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+
+namespace CometSamples.Reply
+{
+ /// Straight port of the gold's data layer (data/Account.kt, Email.kt,
+ /// local/Local*DataProvider.kt): same ids, names, avatars, subjects, bodies and
+ /// timestamps, so list rows and detail threads match the gold pixel content.
+ /// Deterministic: the gold shuffles each email's threads; here they keep source
+ /// order (a stable fixture for the smoke scripts).
+ public sealed record ReplyAccount(long Id, long Uid, string FirstName, string LastName, string Email, string Avatar)
+ {
+ public string FullName => $"{FirstName} {LastName}";
+ }
+
+ public enum ReplyMailbox { Inbox, Sent, Drafts, Spam, Trash }
+
+ public sealed record ReplyEmail(
+ long Id, ReplyAccount Sender, string Subject, string Body, string CreatedAt,
+ ReplyMailbox Mailbox = ReplyMailbox.Inbox, bool IsStarred = false, bool IsImportant = false,
+ IReadOnlyList? Threads = null);
+
+ public static class ReplyData
+ {
+ // local/LocalAccountsDataProvider.kt — contact accounts (id, uid, names, avatar).
+ public static readonly ReplyAccount User = new(1, 0, "Jeff", "Hansen", "hikingfan@gmail.com", "avatar_10");
+ static readonly Dictionary Contacts = new[]
+ {
+ new ReplyAccount(4, 1, "Tracy", "Alvarez", "tracealvie@gmail.com", "avatar_1"),
+ new ReplyAccount(5, 2, "Allison", "Trabucco", "atrabucco222@gmail.com", "avatar_3"),
+ new ReplyAccount(6, 3, "Ali", "Connors", "aliconnors@gmail.com", "avatar_5"),
+ new ReplyAccount(7, 4, "Alberto", "Williams", "albertowilliams124@gmail.com", "avatar_0"),
+ new ReplyAccount(8, 5, "Kim", "Alen", "alen13@gmail.com", "avatar_7"),
+ new ReplyAccount(9, 6, "Google", "Express", "express@google.com", "avatar_express"),
+ new ReplyAccount(10, 7, "Sandra", "Adams", "sandraadams@gmail.com", "avatar_2"),
+ new ReplyAccount(11, 8, "Trevor", "Hansen", "trevorhandsen@gmail.com", "avatar_8"),
+ new ReplyAccount(12, 9, "Sean", "Holt", "sholt@gmail.com", "avatar_6"),
+ new ReplyAccount(13, 10, "Frank", "Hawkins", "fhawkank@gmail.com", "avatar_4"),
+ }.ToDictionary(a => a.Id);
+
+ static ReplyAccount C(long id) => Contacts[id];
+
+ // local/LocalEmailsDataProvider.kt `threads` — the conversation shown in every detail.
+ public static readonly IReadOnlyList Threads = new[]
+ {
+ new ReplyEmail(8, C(13), "Your update on Google Play Store is live!",
+ "Your update, 0.1.1, is now live on the Play Store and available for your alpha users to start testing.\n\nYour alpha testers will be automatically notified. If you'd rather send them a link directly, go to your Google Play Console and follow the instructions for obtaining an open alpha testing link.",
+ "3 hours ago", ReplyMailbox.Trash),
+ new ReplyEmail(5, C(13), "Update to Your Itinerary", "", "2 hours ago"),
+ new ReplyEmail(6, C(10), "Recipe to try",
+ "Raspberry Pie: We should make this pie recipe tonight! The filling is very quick to put together.",
+ "2 hours ago", ReplyMailbox.Sent),
+ new ReplyEmail(7, C(9), "Delivered", "Your shoes should be waiting for you at home!", "2 hours ago"),
+ new ReplyEmail(9, C(10), "(No subject)", "Hey, \n\nWanted to email and see what you thought of", "3 hours ago", ReplyMailbox.Drafts),
+ new ReplyEmail(1, C(6), "Brunch this weekend?",
+ "I'll be in your neighborhood doing errands and was hoping to catch you for a coffee this Saturday. If you don't have anything scheduled, it would be great to see you! It feels like its been forever.\n\nIf we do get a chance to get together, remind me to tell you about Kim. She stopped over at the house to say hey to the kids and told me all about her trip to Mexico.\n\nTalk to you soon,\n\nAli",
+ "40 mins ago"),
+ new ReplyEmail(2, C(5), "Bonjour from Paris", "Here are some great shots from my trip...", "1 hour ago", IsImportant: true),
+ };
+
+ // local/LocalEmailsDataProvider.kt `allEmails` — source order = gold list order.
+ public static readonly IReadOnlyList AllEmails = new[]
+ {
+ new ReplyEmail(0, C(9), "Package shipped!",
+ "Cucumber Mask Facial has shipped.\n\nKeep an eye out for a package to arrive between this Thursday and next Tuesday. If for any reason you don't receive your package before the end of next week, please reach out to us for details on your shipment.\n\nAs always, thank you for shopping with us and we hope you love our specially formulated Cucumber Mask!",
+ "20 mins ago", IsStarred: true, Threads: Threads),
+ new ReplyEmail(1, C(6), "Brunch this weekend?",
+ "I'll be in your neighborhood doing errands and was hoping to catch you for a coffee this Saturday. If you don't have anything scheduled, it would be great to see you! It feels like its been forever.\n\nIf we do get a chance to get together, remind me to tell you about Kim. She stopped over at the house to say hey to the kids and told me all about her trip to Mexico.\n\nTalk to you soon,\n\nAli",
+ "40 mins ago", Threads: Threads),
+ new ReplyEmail(2, C(5), "Bonjour from Paris", "Here are some great shots from my trip...",
+ "1 hour ago", IsImportant: true, Threads: Threads),
+ new ReplyEmail(3, C(8), "High school reunion?",
+ "Hi friends,\n\nI was at the grocery store on Sunday night.. when I ran into Genie Williams! I almost didn't recognize her afer 20 years!\n\nAnyway, it turns out she is on the organizing committee for the high school reunion this fall. I don't know if you were planning on going or not, but she could definitely use our help in trying to track down lots of missing alums. If you can make it, we're doing a little phone-tree party at her place next Saturday, hoping that if we can find one person, thee more will...",
+ "2 hours ago", ReplyMailbox.Sent, Threads: Threads),
+ new ReplyEmail(4, C(11), "Brazil trip",
+ "Thought we might be able to go over some details about our upcoming vacation.\n\nI've been doing a bit of research and have come across a few paces in Northern Brazil that I think we should check out. One, the north has some of the most predictable wind on the planet. I'd love to get out on the ocean and kitesurf for a couple of days if we're going to be anywhere near or around Taiba. I hear it's beautiful there and if you're up for it, I'd love to go. Other than that, I haven't spent too much time looking into places along our road trip route. I'm assuming we can find places to stay and things to do as we drive and find places we think look interesting. But... I know you're more of a planner, so if you have ideas or places in mind, lets jot some ideas down!\n\nMaybe we can jump on the phone later today if you have a second.",
+ "2 hours ago", IsStarred: true, Threads: Threads),
+ new ReplyEmail(5, C(13), "Update to Your Itinerary", "", "2 hours ago", Threads: Threads),
+ new ReplyEmail(6, C(10), "Recipe to try",
+ "Raspberry Pie: We should make this pie recipe tonight! The filling is very quick to put together.",
+ "2 hours ago", ReplyMailbox.Sent, Threads: Threads),
+ new ReplyEmail(7, C(9), "Delivered", "Your shoes should be waiting for you at home!",
+ "2 hours ago", Threads: Threads),
+ new ReplyEmail(8, C(13), "Your update on Google Play Store is live!",
+ "Your update, 0.1.1, is now live on the Play Store and available for your alpha users to start testing.\n\nYour alpha testers will be automatically notified. If you'd rather send them a link directly, go to your Google Play Console and follow the instructions for obtaining an open alpha testing link.",
+ "3 hours ago", ReplyMailbox.Trash, Threads: Threads),
+ new ReplyEmail(9, C(10), "(No subject)", "Hey, \n\nWanted to email and see what you thought of",
+ "3 hours ago", ReplyMailbox.Drafts, Threads: Threads),
+ new ReplyEmail(10, C(5), "Try a free TrailGo account",
+ "Looking for the best hiking trails in your area? TrailGo gets you on the path to the outdoors faster than you can pack a sandwich. \n\nWhether you're an experienced hiker or just looking to get outside for the afternoon, there's a segment that suits you.",
+ "3 hours ago", ReplyMailbox.Trash, Threads: Threads),
+ new ReplyEmail(11, C(5), "Free money",
+ "You've been selected as a winner in our latest raffle! To claim your prize, click on the link.",
+ "3 hours ago", ReplyMailbox.Spam, Threads: Threads),
+ };
+ }
+}
diff --git a/src/Comet/sample/Shared/Reply/ReplyIcons.cs b/src/Comet/sample/Shared/Reply/ReplyIcons.cs
new file mode 100644
index 000000000..6d6c9c52a
--- /dev/null
+++ b/src/Comet/sample/Shared/Reply/ReplyIcons.cs
@@ -0,0 +1,43 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+
+namespace CometSamples.Reply
+{
+ ///
+ /// Reply's Material Icons glyph map (same one-font cross-platform approach as
+ /// JetchatIcons). Names match the gold's ic_* drawables in
+ /// compose-samples/Reply/app/src/main/res/drawable/.
+ ///
+ static class ReplyIcons
+ {
+ public const string Font = "Material Icons";
+
+ static bool _registered;
+
+ public static void Register()
+ {
+ if (_registered)
+ return;
+ _registered = true;
+
+ var map = new Dictionary();
+ void Add(string name, int codepoint) => map[name] = char.ConvertFromUtf32(codepoint);
+
+ Add("inbox", 0xE156);
+ Add("article", 0xEF42);
+ Add("chat_outline", 0xE0CB); // ic_chat: chat_bubble_outline
+ Add("people_outline", 0xE7FC); // ic_group
+ Add("menu", 0xE5D2);
+ Add("menu_open", 0xE9BD);
+ Add("edit", 0xE3C9); // ic_edit (compose)
+ Add("search", 0xE8B6);
+ Add("arrow_back", 0xE5C4);
+ Add("more_vert", 0xE5D4);
+ Add("star_border", 0xE83A);
+ Add("check", 0xE5CA);
+
+ IconFont.Register(Font, map);
+ }
+ }
+}
diff --git a/src/Comet/sample/Shared/Reply/ReplyScreens.cs b/src/Comet/sample/Shared/Reply/ReplyScreens.cs
new file mode 100644
index 000000000..864748572
--- /dev/null
+++ b/src/Comet/sample/Shared/Reply/ReplyScreens.cs
@@ -0,0 +1,319 @@
+#nullable enable
+using System.Collections.Generic;
+using Comet;
+using Comet.Reactive;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Primitives;
+using T = CometSamples.Reply.ReplyTheme;
+
+namespace CometSamples.Reply
+{
+ ///
+ /// The Reply screens, values-from-source (file:line cites into the gold Kotlin app).
+ /// Inbox = ReplyListContent.kt; rows = ReplyEmailListItem.kt; detail = ReplyEmailDetail +
+ /// EmailDetailAppBar + ReplyEmailThreadItem; coming-soon = EmptyComingSoon.kt.
+ ///
+ public static class ReplyScreens
+ {
+ public static readonly Signal DetailOpen = new(false);
+ public static readonly Signal OpenedEmailId = new(0);
+
+ // Hook the static signals ONCE (a per-build += on a static signal stacks a handler —
+ // and roots the captured list — on every body rebuild); the fields track the current
+ // list instances.
+ static ListView? _inboxList, _searchResults;
+ static ReplyScreens()
+ {
+ SearchQuery.PropertyChanged += (_, __) => _searchResults?.ReloadData();
+ // Rows snapshot OpenedEmailId at build; reload so the opened highlight moves.
+ OpenedEmailId.PropertyChanged += (_, __) => _inboxList?.ReloadData();
+ }
+
+ // ── Selection mode (gold ReplyHomeViewModel.toggleSelectedEmail + ReplyEmailListItem
+ // combinedClickable onLongClick): long-press a row (or tap its avatar) to toggle it
+ // into the selection; selected rows show primaryContainer + a check avatar. ──
+ static readonly HashSet SelectedIds = new();
+ static void ToggleSelection(long id)
+ {
+ if (!SelectedIds.Add(id))
+ SelectedIds.Remove(id);
+ _inboxList?.ReloadData();
+ }
+
+ static ReplyEmail Opened()
+ {
+ foreach (var e in ReplyData.AllEmails)
+ if (e.Id == OpenedEmailId.Value)
+ return e;
+ return ReplyData.AllEmails[0];
+ }
+
+ // ── Inbox route: the ListDetail scaffold (gold ReplyInboxScreen) ──
+ public static View Inbox() => new ListDetail(DetailOpen, InboxList(), EmailDetail());
+
+ // ── The email list (gold ReplyEmailList :162-205): search bar pinned on top,
+ // list under it, ExtendedFAB bottom-end (compact only in gold; simplified: always). ──
+ static View InboxList()
+ {
+ var list = new ListView(() => ReplyData.AllEmails)
+ {
+ ViewFor = email => EmailListItem(email),
+ };
+ _inboxList = list;
+
+ // Gold ExtendedFAB (ReplyListContent.kt:113-126): tertiaryContainer, "Compose" +
+ // edit icon, expanded at the top (!canScrollBackward); collapses once scrolled.
+ // (The gold also re-expands on any upward scroll — lastScrolledBackward — that
+ // direction signal is a fidelity-pass follow-up.)
+ var extended = new Signal(true);
+ // Gold driver (ReplyListContent.kt:124-125): expanded = lastScrolledBackward ||
+ // !canScrollBackward — re-expands on ANY upward scroll, not only at the very top.
+ void UpdateFab() => extended.Value =
+ list.LastScrolledBackward.Peek() || !list.ScrolledFromTop.Peek();
+ list.ScrolledFromTop.PropertyChanged += (_, __) => UpdateFab();
+ list.LastScrolledBackward.PropertyChanged += (_, __) => UpdateFab();
+ var fab = new Fab(
+ icon: new Icon("edit").IconSize(18).Color(T.OnTertiaryContainer),
+ label: new Text("Compose").FontSize(14).Color(T.OnTertiaryContainer),
+ onClick: () => { },
+ height: 56,
+ containerColor: T.TertiaryContainer,
+ contentColor: T.OnTertiaryContainer)
+ .ExtendedWhen(extended);
+
+ return new ZStack
+ {
+ new VStack(spacing: 0f)
+ {
+ // The REAL M3 docked search bar (gold ReplyDockedSearchBar,
+ // ReplyAppBars.kt:84-168): "Search emails" placeholder + profile
+ // trailing; expanded popup shows history/results.
+ EmailSearchBar().Margin(left: 16, top: 16, right: 16, bottom: 16),
+ list.FlexGrow(1).FlexBasis(0),
+ }
+ .HorizontalLayoutAlignment(LayoutAlignment.Fill)
+ .VerticalLayoutAlignment(LayoutAlignment.Fill),
+ fab.HorizontalLayoutAlignment(LayoutAlignment.End)
+ .VerticalLayoutAlignment(LayoutAlignment.End)
+ .Margin(right: 16, bottom: 16),
+ }.Background(T.Background);
+ }
+
+ static readonly Signal SearchQuery = new("");
+
+ // Gold filter (ReplyAppBars.kt:65-82): subject OR sender fullName startsWith,
+ // ignore case; empty query → history message.
+ static IReadOnlyList SearchResults()
+ {
+ var q = SearchQuery.Peek();
+ if (string.IsNullOrEmpty(q))
+ return System.Array.Empty();
+ var hits = new List();
+ foreach (var e in ReplyData.AllEmails)
+ if (e.Subject.StartsWith(q, System.StringComparison.OrdinalIgnoreCase)
+ || e.Sender.FullName.StartsWith(q, System.StringComparison.OrdinalIgnoreCase))
+ hits.Add(e);
+ return hits;
+ }
+
+ static View EmailSearchBar()
+ {
+ var results = new ListView(() => SearchResults())
+ {
+ ViewFor = e => SearchResultRow(e),
+ };
+ _searchResults = results;
+
+ var content = new VStack(spacing: 0f)
+ {
+ // Gold: "No search history" (empty) / "No item found" (no hits) / results.
+ new Text(() => SearchQuery.Value.Length == 0 ? "No search history"
+ : SearchResults().Count == 0 ? "No item found" : "")
+ .FontSize(16).Color(T.OnSurface)
+ .Padding(new Thickness(16)),
+ results.FlexGrow(1).FlexBasis(0),
+ }.Frame(height: 420);
+
+ return new SearchBar(SearchQuery,
+ placeholder: new Text("Search emails").FontSize(16).Color(T.OnSurfaceVariant),
+ content: content,
+ leading: new Icon("search").IconSize(24).Color(T.OnSurfaceVariant),
+ trailing: new Image("avatar_6").Frame(width: 32, height: 32).CornerRadius(16),
+ containerColor: T.SurfaceContainerHigh);
+ }
+
+ // Gold search result rows: M3 ListItem shape — 32dp avatar, subject headline,
+ // sender supporting (ReplyAppBars.kt:137-155).
+ static View SearchResultRow(ReplyEmail email) => new HStack(spacing: 16f)
+ {
+ new Image(email.Sender.Avatar).Frame(width: 32, height: 32).CornerRadius(16).FlexShrink(0),
+ new VStack(spacing: 2f)
+ {
+ new Text(email.Subject).FontSize(16).Color(T.OnSurface),
+ new Text(email.Sender.FullName).FontSize(14).Color(T.OnSurfaceVariant),
+ }.FlexGrow(1).FlexBasis(0),
+ }
+ .Padding(new Thickness(16, 8, 16, 8))
+ .OnTap(_ =>
+ {
+ OpenedEmailId.Value = (int)email.Id;
+ DetailOpen.Value = true;
+ });
+
+ // ── One list row (gold ReplyEmailListItem.kt:52-142). Outer wrapper carries the
+ // h16/v4 gutter as PADDING (list rows are laid at full list width; root margins
+ // aren't part of the row's own layout box); the inner card paints/clips. ──
+ // Selected avatar (gold SelectedProfileImage): primary circle + centered onPrimary
+ // check; tapping either avatar state toggles selection (gold clickModifier).
+ static View AvatarSlot(ReplyEmail email) => (SelectedIds.Contains(email.Id)
+ ? (View)new ZStack { new Icon("check").IconSize(24).Color(T.OnPrimary) }
+ .Background(T.Primary)
+ : new Image(email.Sender.Avatar))
+ .Frame(width: 40, height: 40).CornerRadius(20).FlexShrink(0)
+ .OnTap(_ => ToggleSelection(email.Id));
+
+ static View EmailListItem(ReplyEmail email) => new VStack(spacing: 0f)
+ {
+ new VStack(spacing: 0f)
+ {
+ new HStack(spacing: 0f)
+ {
+ AvatarSlot(email),
+ new VStack(spacing: 0f)
+ {
+ new Text(email.Sender.FirstName).FontSize(12).FontWeight(FontWeight.Medium).Color(T.OnSurface),
+ new Text(email.CreatedAt).FontSize(12).FontWeight(FontWeight.Medium).Color(T.OnSurfaceVariant),
+ }.Padding(new Thickness(12, 4, 12, 4)).FlexGrow(1).FlexBasis(0),
+ new Icon("star_border").IconSize(24).Color(T.Outline)
+ .Frame(width: 40, height: 40)
+ .Background(T.SurfaceContainerHigh).CornerRadius(20)
+ .FlexShrink(0),
+ },
+ new Text(email.Subject).FontSize(16).Color(T.OnSurface)
+ .Padding(new Thickness(0, 12, 0, 8)),
+ // Gold: bodyMedium, maxLines 2, ellipsis, RAW line breaks kept
+ // (ReplyEmailListItem.kt:134-139 — short first lines ellipsize on line 2).
+ new Text(email.Body).FontSize(14).Color(T.OnSurfaceVariant)
+ .LineBreakMode(LineBreakMode.WordWrap)
+ .MaxLines(2),
+ }
+ .Padding(new Thickness(20))
+ // Gold precedence (:72-76): SELECTED wins (primaryContainer), then OPENED
+ // (secondaryContainer — the home state opens the first email at launch), else
+ // surfaceVariant.
+ .Background(SelectedIds.Contains(email.Id) ? T.PrimaryContainer
+ : email.Id == OpenedEmailId.Value ? T.SecondaryContainer : T.SurfaceVariant)
+ .CornerRadius(16)
+ .OnTap(_ =>
+ {
+ OpenedEmailId.Value = (int)email.Id;
+ DetailOpen.Value = true;
+ })
+ // Gold combinedClickable onLongClick: long-press toggles selection.
+ .OnLongPress(_ => ToggleSelection(email.Id)),
+ }.Padding(new Thickness(16, 4, 16, 4));
+
+ // ── Detail pane (gold ReplyEmailDetail :207-225 + EmailDetailAppBar) ──
+ static View EmailDetail()
+ {
+ var stack = new VStack(spacing: 0f) { DetailAppBar() };
+ var threads = new ListView(() => Opened().Threads ?? ReplyData.Threads)
+ {
+ ViewFor = t => ThreadItem(t),
+ };
+ stack.Add(threads.FlexGrow(1).FlexBasis(0));
+ return stack.Background(T.InverseOnSurface);
+ }
+
+ // Gold EmailDetailAppBar (ReplyAppBars.kt:173-228): inverseOnSurface bar; back
+ // FilledIconButton; centered subject titleMedium + "N Messages" labelMedium.
+ static View DetailAppBar() => new HStack(spacing: 0f)
+ {
+ new Icon("arrow_back").IconSize(14).Color(T.OnSurface)
+ .Padding(new Thickness(13)) // centers the 14dp glyph in the 40dp circle
+ .Frame(width: 40, height: 40)
+ .Background(T.Surface).CornerRadius(20)
+ .Margin(left: 8, top: 8, bottom: 8)
+ .FlexShrink(0)
+ .OnTap(_ => DetailOpen.Value = false),
+ new VStack(spacing: 2f)
+ {
+ new Text(() => Opened().Subject).FontSize(16).Color(T.OnSurfaceVariant)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ new Text(() => $"{(Opened().Threads ?? ReplyData.Threads).Count} Messages").FontSize(12).Color(T.Outline)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center),
+ }.FlexGrow(1).FlexBasis(0).VerticalLayoutAlignment(LayoutAlignment.Center),
+ new Icon("more_vert").IconSize(24).Color(T.OnSurfaceVariant)
+ .Frame(width: 48, height: 48)
+ .Margin(right: 4)
+ .FlexShrink(0),
+ }.Frame(height: 64);
+
+ // ── One thread item (gold ReplyEmailThreadItem.kt:44-136); same wrapper/card split
+ // as the list rows. ──
+ static View ThreadItem(ReplyEmail email) => new VStack(spacing: 0f)
+ {
+ new VStack(spacing: 0f)
+ {
+ new HStack(spacing: 0f)
+ {
+ new Image(email.Sender.Avatar).Frame(width: 40, height: 40).CornerRadius(20).FlexShrink(0),
+ new VStack(spacing: 0f)
+ {
+ new Text(email.Sender.FirstName).FontSize(12).Color(T.OnSurface),
+ new Text("20 mins ago").FontSize(12).Color(T.Outline), // gold hardcodes it (:72)
+ }.Padding(new Thickness(12, 4, 12, 4)).FlexGrow(1).FlexBasis(0),
+ new Icon("star_border").IconSize(24).Color(T.Outline)
+ .Frame(width: 40, height: 40)
+ .Background(T.SurfaceContainer).CornerRadius(20)
+ .FlexShrink(0),
+ },
+ new Text(email.Subject).FontSize(14).Color(T.Outline)
+ .Padding(new Thickness(0, 12, 0, 8)),
+ new Text(email.Body).FontSize(16).Color(T.OnSurfaceVariant)
+ .LineBreakMode(LineBreakMode.WordWrap),
+ new HStack(spacing: 12f)
+ {
+ ThreadButton("Reply"),
+ ThreadButton("Reply All"),
+ }.Padding(new Thickness(0, 20, 0, 8)),
+ }
+ .Padding(new Thickness(20))
+ .Background(T.SurfaceContainerHigh)
+ .CornerRadius(16),
+ }.Padding(new Thickness(16, 4, 16, 4));
+
+ // Gold: M3 Button, containerColor surfaceBright, onSurface text, weight(1f) each.
+ // Spacer rows/columns center the label (alignment positions a view in its parent).
+ static View ThreadButton(string label) => new HStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ new VStack(spacing: 0f)
+ {
+ new HStack().FlexGrow(1),
+ new Text(label).FontSize(14).Color(T.OnSurface),
+ new HStack().FlexGrow(1),
+ },
+ new HStack().FlexGrow(1),
+ }
+ .Frame(height: 40)
+ .Background(T.SurfaceBright)
+ .CornerRadius(20)
+ .FlexGrow(1).FlexBasis(0);
+
+ // ── EmptyComingSoon.kt ──
+ public static View ComingSoon() => new VStack(spacing: 8f)
+ {
+ new HStack().FlexGrow(1),
+ new Text("Screen under construction").FontSize(22).FontWeight(FontWeight.Bold)
+ .Color(T.Primary).HorizontalLayoutAlignment(LayoutAlignment.Center),
+ new Text("This screen is still under construction. This sample will help you learn\nabout adaptive layouts in Jetpack Compose")
+ .FontSize(12).Color(T.Outline)
+ .LineBreakMode(LineBreakMode.WordWrap)
+ .HorizontalLayoutAlignment(LayoutAlignment.Center)
+ .Padding(new Thickness(24, 0)),
+ new HStack().FlexGrow(1),
+ }.Background(T.Background);
+ }
+}
diff --git a/src/Comet/sample/Shared/Reply/ReplyTheme.cs b/src/Comet/sample/Shared/Reply/ReplyTheme.cs
new file mode 100644
index 000000000..11fe87525
--- /dev/null
+++ b/src/Comet/sample/Shared/Reply/ReplyTheme.cs
@@ -0,0 +1,82 @@
+#nullable enable
+using Microsoft.Maui.Graphics;
+
+namespace CometSamples.Reply
+{
+ /// Reply's static light scheme — every value a literal from the gold's
+ /// ui/theme/Color.kt (ContrastAwareReplyTheme defaults dynamicColor=false, default
+ /// contrast → lightScheme). Deterministic: fidelity compares exact hex, no seed
+ /// generation. Dark + contrast variants land at the fidelity pass if needed.
+ public static class ReplyTheme
+ {
+ public static readonly Color Primary = Color.FromArgb("#805610");
+ public static readonly Color OnPrimary = Color.FromArgb("#FFFFFF");
+ public static readonly Color PrimaryContainer = Color.FromArgb("#FFDDB3");
+ public static readonly Color OnPrimaryContainer = Color.FromArgb("#291800");
+ public static readonly Color Secondary = Color.FromArgb("#6F5B40");
+ public static readonly Color OnSecondary = Color.FromArgb("#FFFFFF");
+ public static readonly Color SecondaryContainer = Color.FromArgb("#FBDEBC");
+ public static readonly Color OnSecondaryContainer = Color.FromArgb("#271904");
+ public static readonly Color Tertiary = Color.FromArgb("#51643F");
+ public static readonly Color OnTertiary = Color.FromArgb("#FFFFFF");
+ public static readonly Color TertiaryContainer = Color.FromArgb("#D4EABB");
+ public static readonly Color OnTertiaryContainer = Color.FromArgb("#102004");
+ public static readonly Color Error = Color.FromArgb("#BA1A1A");
+ public static readonly Color OnError = Color.FromArgb("#FFFFFF");
+ public static readonly Color ErrorContainer = Color.FromArgb("#FFDAD6");
+ public static readonly Color OnErrorContainer = Color.FromArgb("#410002");
+ public static readonly Color Background = Color.FromArgb("#FFF8F4");
+ public static readonly Color OnBackground = Color.FromArgb("#201B13");
+ public static readonly Color Surface = Color.FromArgb("#FFF8F4");
+ public static readonly Color OnSurface = Color.FromArgb("#201B13");
+ public static readonly Color SurfaceVariant = Color.FromArgb("#F0E0CF");
+ public static readonly Color OnSurfaceVariant = Color.FromArgb("#4F4539");
+ public static readonly Color Outline = Color.FromArgb("#817567");
+ public static readonly Color OutlineVariant = Color.FromArgb("#D3C4B4");
+ public static readonly Color Scrim = Color.FromArgb("#000000");
+ public static readonly Color InverseSurface = Color.FromArgb("#362F27");
+ public static readonly Color InverseOnSurface = Color.FromArgb("#FCEFE2");
+ public static readonly Color InversePrimary = Color.FromArgb("#F4BD6F");
+ public static readonly Color SurfaceDim = Color.FromArgb("#E4D8CC");
+ public static readonly Color SurfaceBright = Color.FromArgb("#FFF8F4");
+ public static readonly Color SurfaceContainerLowest = Color.FromArgb("#FFFFFF");
+ public static readonly Color SurfaceContainerLow = Color.FromArgb("#FFF1E5");
+ public static readonly Color SurfaceContainer = Color.FromArgb("#F9ECDF");
+ public static readonly Color SurfaceContainerHigh = Color.FromArgb("#F3E6DA");
+ public static readonly Color SurfaceContainerHighest = Color.FromArgb("#EDE0D4");
+
+#if ANDROID
+ /// The matching Compose ColorScheme, so the REAL Material widgets
+ /// (NavigationBar/Rail, drawer sheets, ripples) render Reply's palette — the gold's
+ /// M3 defaults derive from these roles (e.g. the nav bar container is
+ /// surfaceContainer, the selection pill secondaryContainer).
+ public static AndroidX.Compose.Material3.ColorScheme ComposeScheme()
+ {
+ static AndroidX.Compose.Color C(Color c) => AndroidX.Compose.Color.FromArgb(
+ (byte)(c.Alpha * 255), (byte)(c.Red * 255), (byte)(c.Green * 255), (byte)(c.Blue * 255));
+ return AndroidX.Compose.MaterialTheme.LightColorScheme(
+ primary: C(Primary), onPrimary: C(OnPrimary),
+ primaryContainer: C(PrimaryContainer), onPrimaryContainer: C(OnPrimaryContainer),
+ inversePrimary: C(InversePrimary),
+ secondary: C(Secondary), onSecondary: C(OnSecondary),
+ secondaryContainer: C(SecondaryContainer), onSecondaryContainer: C(OnSecondaryContainer),
+ tertiary: C(Tertiary), onTertiary: C(OnTertiary),
+ tertiaryContainer: C(TertiaryContainer), onTertiaryContainer: C(OnTertiaryContainer),
+ background: C(Background), onBackground: C(OnBackground),
+ surface: C(Surface), onSurface: C(OnSurface),
+ surfaceVariant: C(SurfaceVariant), onSurfaceVariant: C(OnSurfaceVariant),
+ inverseSurface: C(InverseSurface), inverseOnSurface: C(InverseOnSurface),
+ error: C(Error), onError: C(OnError),
+ errorContainer: C(ErrorContainer), onErrorContainer: C(OnErrorContainer),
+ outline: C(Outline), outlineVariant: C(OutlineVariant),
+ scrim: C(Scrim),
+ surfaceBright: C(SurfaceBright), surfaceDim: C(SurfaceDim),
+ surfaceContainer: C(SurfaceContainer),
+ surfaceContainerHigh: C(SurfaceContainerHigh),
+ surfaceContainerHighest: C(SurfaceContainerHighest),
+ surfaceContainerLow: C(SurfaceContainerLow),
+ surfaceContainerLowest: C(SurfaceContainerLowest));
+ }
+#endif
+ }
+}
diff --git a/src/Comet/sample/Shared/Reply/gold/README.md b/src/Comet/sample/Shared/Reply/gold/README.md
new file mode 100644
index 000000000..9ccb4f262
--- /dev/null
+++ b/src/Comet/sample/Shared/Reply/gold/README.md
@@ -0,0 +1,44 @@
+# Reply gold screenshots (fidelity contract)
+
+Captured from the **Kotlin Reply app** (`~/work/compose-samples/Reply`, debug build)
+running on the **Pixel_9_Pro emulator** (API 36, physical 1280x2856 px, density 480 →
+3.0x scale, 427x952 dp). Adaptive states were driven with `adb shell wm size `
+(density unchanged), matching the smoke-script `android_resize` verb.
+
+## Capture matrix
+
+| File | wm size (px) | Window dp | Nav chrome | Content |
+|---|---|---|---|---|
+| compact-01-inbox.png | 1280x2856 (reset) | 427x952 | Bottom nav bar | Inbox list, docked search bar + profile, expanded Compose FAB |
+| compact-02-detail.png | 1280x2856 | 427x952 | Bottom nav bar | Email detail (full-screen), back button + subject app bar, Reply/Reply All per thread item |
+| compact-03-articles-comingsoon.png | 1280x2856 | 427x952 | Bottom nav bar | EmptyComingSoon ("Screen under construction") |
+| compact-04-search-active.png | 1280x2856 | 427x952 | Bottom nav bar | DockedSearchBar active: back arrow, "No search history", scrim over list |
+| compact-05-scrolled-fab-collapsed.png | 1280x2856 | 427x952 | Bottom nav bar | List scrolled; ExtendedFAB collapsed to icon-only (`expanded = lastScrolledBackward \|\| !canScrollBackward`) |
+| medium-700dp-01-inbox-rail.png | 2100x2856 | 700x952 | Navigation rail | Rail: menu, FAB, 4 destinations (centered); single-pane list |
+| medium-700dp-02-modal-drawer-open.png | 2100x2856 | 700x952 | Modal drawer over rail | REPLY header + close icon, Compose extended FAB, 4 drawer items, scrim |
+| expanded-960dp-01-twopane-rail.png | 2880x2856 | 960x952 | Navigation rail | TwoPane 50/50: list + detail (openedEmail ?: first email) |
+| expanded-1260dp-01-twopane-permanent-drawer.png | 3780x2856 | 1260x952 | Permanent drawer | Drawer (REPLY, Compose extended FAB, labeled items) + two-pane |
+
+## Breakpoints (from source — ReplyNavigationComponents.kt / ReplyApp.kt)
+
+- **Bottom nav:** width < 600dp OR height < 480dp (`isCompact()`), or tabletop posture.
+- **Permanent drawer:** width-class Expanded (≥ 840dp) **AND window width ≥ 1200dp**.
+- **Nav rail:** everything in between (600–1199dp).
+- **Two-pane content (`DUAL_PANE`):** width-class Expanded (≥ 840dp) — so 840–1199dp
+ is *rail + two-pane*; Medium (600–839dp) stays single-pane (unless folding posture).
+- Modal drawer opens **only from the rail's menu item** (gestures disabled on
+ compact/permanent; bottom nav has no entry point).
+
+## Caveats (do not build these)
+
+- **The warm beige/green scheme is Reply's OWN static palette, not Material You.**
+ `ContrastAwareReplyTheme` defaults `dynamicColor = false` (Theme.kt:298) and
+ MainActivity passes nothing; every color is a literal in `ui/theme/Color.kt`
+ (primary `#805610`, tertiaryContainer `#D4EABB` = the green FAB, background
+ `#FFF8F4`). Fidelity uses those exact hex values — deterministic on any device,
+ no seed generator needed. (Contrast variants exist behind UiModeManager contrast;
+ default contrast = `lightScheme`/`darkScheme`.)
+- Status-bar clock/battery and the bottom gesture handle are OS chrome.
+- compact-04: soft keyboard is not visible because the emulator reports a hardware
+ keyboard (`show_ime_with_hard_keyboard` was enabled during capture to suppress
+ Gboard's floating toolbar pill, which contaminated an earlier take).
diff --git a/src/Comet/sample/Shared/Reply/gold/compact-01-inbox.png b/src/Comet/sample/Shared/Reply/gold/compact-01-inbox.png
new file mode 100644
index 000000000..520685462
Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-01-inbox.png differ
diff --git a/src/Comet/sample/Shared/Reply/gold/compact-02-detail.png b/src/Comet/sample/Shared/Reply/gold/compact-02-detail.png
new file mode 100644
index 000000000..0cd162074
Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-02-detail.png differ
diff --git a/src/Comet/sample/Shared/Reply/gold/compact-03-articles-comingsoon.png b/src/Comet/sample/Shared/Reply/gold/compact-03-articles-comingsoon.png
new file mode 100644
index 000000000..56196e54d
Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-03-articles-comingsoon.png differ
diff --git a/src/Comet/sample/Shared/Reply/gold/compact-04-search-active.png b/src/Comet/sample/Shared/Reply/gold/compact-04-search-active.png
new file mode 100644
index 000000000..f3041957a
Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-04-search-active.png differ
diff --git a/src/Comet/sample/Shared/Reply/gold/compact-05-scrolled-fab-collapsed.png b/src/Comet/sample/Shared/Reply/gold/compact-05-scrolled-fab-collapsed.png
new file mode 100644
index 000000000..3c61f5f06
Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/compact-05-scrolled-fab-collapsed.png differ
diff --git a/src/Comet/sample/Shared/Reply/gold/expanded-1260dp-01-twopane-permanent-drawer.png b/src/Comet/sample/Shared/Reply/gold/expanded-1260dp-01-twopane-permanent-drawer.png
new file mode 100644
index 000000000..1748991a4
Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/expanded-1260dp-01-twopane-permanent-drawer.png differ
diff --git a/src/Comet/sample/Shared/Reply/gold/expanded-960dp-01-twopane-rail.png b/src/Comet/sample/Shared/Reply/gold/expanded-960dp-01-twopane-rail.png
new file mode 100644
index 000000000..7da20d6df
Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/expanded-960dp-01-twopane-rail.png differ
diff --git a/src/Comet/sample/Shared/Reply/gold/medium-700dp-01-inbox-rail.png b/src/Comet/sample/Shared/Reply/gold/medium-700dp-01-inbox-rail.png
new file mode 100644
index 000000000..580fbbbc3
Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/medium-700dp-01-inbox-rail.png differ
diff --git a/src/Comet/sample/Shared/Reply/gold/medium-700dp-02-modal-drawer-open.png b/src/Comet/sample/Shared/Reply/gold/medium-700dp-02-modal-drawer-open.png
new file mode 100644
index 000000000..36d7efff4
Binary files /dev/null and b/src/Comet/sample/Shared/Reply/gold/medium-700dp-02-modal-drawer-open.png differ
diff --git a/src/Comet/sample/Shared/RuntimeDebug/SampleRuntimeDebugExtensions.cs b/src/Comet/sample/Shared/RuntimeDebug/SampleRuntimeDebugExtensions.cs
index 21a3e2b9c..9c397fee8 100644
--- a/src/Comet/sample/Shared/RuntimeDebug/SampleRuntimeDebugExtensions.cs
+++ b/src/Comet/sample/Shared/RuntimeDebug/SampleRuntimeDebugExtensions.cs
@@ -1,7 +1,7 @@
#if DEBUG
using System;
using Comet;
-using MauiDevFlow.Agent;
+using Microsoft.Maui.DevFlow.Agent;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Maui;
diff --git a/src/Comet/src/Comet.SourceGenerator/AutoNotifyGenerator.cs b/src/Comet/src/Comet.SourceGenerator/AutoNotifyGenerator.cs
index 65bb31c02..2ee45c9b7 100644
--- a/src/Comet/src/Comet.SourceGenerator/AutoNotifyGenerator.cs
+++ b/src/Comet/src/Comet.SourceGenerator/AutoNotifyGenerator.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Text;
@@ -12,7 +13,7 @@
namespace Comet.SourceGenerator
{
[Generator]
- public class AutoNotifyGenerator : ISourceGenerator
+ public class AutoNotifyGenerator : IIncrementalGenerator
{
private const string attributeText = @"
using System;
@@ -29,43 +30,42 @@ public AutoNotifyAttribute()
}
";
- public void Initialize(GeneratorInitializationContext context)
+ public void Initialize(IncrementalGeneratorInitializationContext context)
{
- context.RegisterForPostInitialization((i) => i.AddSource("AutoNotifyAttribute", attributeText));
- // Register a syntax receiver that will be created for each generation pass
- context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
+ context.RegisterPostInitializationOutput((i) => i.AddSource("AutoNotifyAttribute", attributeText));
+
+ var fields = context.SyntaxProvider.ForAttributeWithMetadataName(
+ "Comet.AutoNotifyAttribute",
+ predicate: static (node, _) => node is VariableDeclaratorSyntax,
+ transform: static (ctx, _) => ctx.TargetSymbol as IFieldSymbol)
+ .Where(static f => f != null)
+ .Collect();
+
+ var compilationAndFields = context.CompilationProvider.Combine(fields);
+
+ context.RegisterSourceOutput(compilationAndFields, static (spc, source) => Execute(source.Left, source.Right, spc));
}
- public void Execute(GeneratorExecutionContext context)
+ static void Execute(Compilation compilation, ImmutableArray fields, SourceProductionContext context)
{
- // add the attribute text
-
- // retreive the populated receiver
- if (!(context.SyntaxContextReceiver is SyntaxReceiver receiver) || !receiver.Fields.Any())
+ if (fields.IsDefaultOrEmpty)
return;
- // we're going to create a new compilation that contains the attribute.
- // TODO: we should allow source generators to provide source during initialize, so that this step isn't required.
- CSharpParseOptions options = (context.Compilation as CSharpCompilation).SyntaxTrees[0].Options as CSharpParseOptions;
- Compilation compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(attributeText, Encoding.UTF8), options));
-
- var attributeSymbol = context.Compilation.GetTypeByMetadataName("Comet.AutoNotifyAttribute");
- var notifySymbol = context.Compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged");
+ var attributeSymbol = compilation.GetTypeByMetadataName("Comet.AutoNotifyAttribute");
+ var notifySymbol = compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged");
var notifyReadSymbol = compilation.GetTypeByMetadataName("Comet.INotifyPropertyRead");
var autoImplementedSymbol = compilation.GetTypeByMetadataName("Comet.IAutoImplemented");
// group the fields by class, and generate the source
- foreach (IGrouping group in receiver.Fields.GroupBy(f => f.ContainingType))
+ foreach (IGrouping group in fields.GroupBy(f => f.ContainingType, SymbolEqualityComparer.Default))
{
string classSource = ProcessClass(group.Key, group.ToList(), attributeSymbol, notifySymbol, notifyReadSymbol, autoImplementedSymbol, context);
if(!string.IsNullOrWhiteSpace(classSource))
context.AddSource($"{group.Key.Name}_autoNotify.cs", SourceText.From(classSource, Encoding.UTF8));
}
-
-
-
}
- private string ProcessClass(INamedTypeSymbol classSymbol, List fields, ISymbol attributeSymbol, ISymbol notifySymbol, ISymbol notifyReadSymbol, ISymbol autoImplementedSymbol, GeneratorExecutionContext context)
+
+ private static string ProcessClass(INamedTypeSymbol classSymbol, List fields, ISymbol attributeSymbol, ISymbol notifySymbol, ISymbol notifyReadSymbol, ISymbol autoImplementedSymbol, SourceProductionContext context)
{
if (!classSymbol.ContainingSymbol.Equals(classSymbol.ContainingNamespace, SymbolEqualityComparer.Default))
{
@@ -105,7 +105,7 @@ public partial class {classSymbol.Name} : {notifyReadSymbol.ToDisplayString()} ,
return source.ToString();
}
- private void ProcessField(StringBuilder source, IFieldSymbol fieldSymbol, ISymbol attributeSymbol)
+ private static void ProcessField(StringBuilder source, IFieldSymbol fieldSymbol, ISymbol attributeSymbol)
{
// get the name and type of the field
string fieldName = fieldSymbol.Name;
@@ -157,33 +157,5 @@ string chooseName(string fieldName, TypedConstant overridenNameOpt)
}
- ///
- /// Created on demand before each generation pass
- ///
- class SyntaxReceiver : ISyntaxContextReceiver
- {
- public List Fields { get; } = new List();
-
- ///
- /// Called for every syntax node in the compilation, we can inspect the nodes and save any information useful for generation
- ///
- public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
- {
- // any field with at least one attribute is a candidate for property generation
- if (context.Node is FieldDeclarationSyntax fieldDeclarationSyntax
- && fieldDeclarationSyntax.AttributeLists.Count > 0)
- {
- foreach (VariableDeclaratorSyntax variable in fieldDeclarationSyntax.Declaration.Variables)
- {
- // Get the symbol being declared by the field, and keep it if its annotated
- IFieldSymbol fieldSymbol = context.SemanticModel.GetDeclaredSymbol(variable) as IFieldSymbol;
- if (fieldSymbol.GetAttributes().Any(ad => ad.AttributeClass.ToDisplayString() == "Comet.AutoNotifyAttribute"))
- {
- Fields.Add(fieldSymbol);
- }
- }
- }
- }
- }
}
}
diff --git a/src/Comet/src/Comet.SourceGenerator/CometViewSourceGenerator.cs b/src/Comet/src/Comet.SourceGenerator/CometViewSourceGenerator.cs
index cdf73947e..19feda98e 100644
--- a/src/Comet/src/Comet.SourceGenerator/CometViewSourceGenerator.cs
+++ b/src/Comet/src/Comet.SourceGenerator/CometViewSourceGenerator.cs
@@ -14,7 +14,7 @@
namespace Comet.SourceGenerator
{
[Generator]
- public class CometViewSourceGenerator : ISourceGenerator
+ public class CometViewSourceGenerator : IIncrementalGenerator
{
private const string attributeSource = @"
[System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)]
@@ -244,25 +244,39 @@ static CometViewSourceGenerator()
};
}
- Stubble.Core.StubbleVisitorRenderer stubble = new StubbleBuilder().Build();
- public void Execute(GeneratorExecutionContext context)
+ static readonly Stubble.Core.StubbleVisitorRenderer stubble = new StubbleBuilder().Build();
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
{
+ context.RegisterPostInitializationOutput(pi => pi.AddSource("CometGenerationAttribute__", attributeSource));
- var rx = (SyntaxReceiver)context.SyntaxContextReceiver!;
+ var templateInfos = context.SyntaxProvider.CreateSyntaxProvider(
+ predicate: static (node, _) => node is AttributeSyntax attrib && IsCometGenerateAttributeName(attrib),
+ transform: static (ctx, _) => GetTemplateInfo(ctx))
+ .Where(static info => info != null);
- foreach (var item in rx.TemplateInfo)
+ context.RegisterSourceOutput(templateInfos, static (spc, item) =>
{
- var input = GetModelData(item.name, item.interfaceType, item.keyProperties, item.nameSpace, item.baseClass, item.propertyNameTransforms, item.propertyDefaultValues, item.skippedProperties);
+ var input = GetModelData(item.Name, item.InterfaceType, item.KeyProperties, item.NameSpace, item.BaseClass, item.PropertyNameTransforms, item.PropertyDefaultValues, item.SkippedProperties);
var classSource = stubble.Render(classMustacheTemplate, input);
- context.AddSource($"{item.name}.g.cs", classSource);
+ spc.AddSource($"{item.Name}.g.cs", classSource);
var extensionSource = stubble.Render(extensionMustacheTemplate, input);
- context.AddSource($"{item.name}Extension.g.cs", extensionSource);
+ spc.AddSource($"{item.Name}Extension.g.cs", extensionSource);
var factorySource = stubble.Render(factoryMustacheTemplate, input);
- context.AddSource($"{item.name}Factory.g.cs", factorySource);
- }
+ spc.AddSource($"{item.Name}Factory.g.cs", factorySource);
+ });
+ }
+
+ static bool IsCometGenerateAttributeName(AttributeSyntax attrib)
+ {
+ var name = attrib.Name;
+ while (name is QualifiedNameSyntax qn)
+ name = qn.Right;
+ var id = (name as SimpleNameSyntax)?.Identifier.ValueText;
+ return id == "CometGenerate" || id == "CometGenerateAttribute";
}
public static string GetFullName(ISymbol symbol, string ending = null)
{
@@ -288,7 +302,7 @@ static string Combine(string first, string second)
return $"{first}.{second}";
}
- dynamic GetModelData(string name, INamedTypeSymbol interfaceType, List keyProperties, string nameSpace, INamedTypeSymbol baseClass, Dictionary propertyNameTransforms, Dictionary propertyDefaultValues, List skippedProperties)
+ static dynamic GetModelData(string name, INamedTypeSymbol interfaceType, List keyProperties, string nameSpace, INamedTypeSymbol baseClass, Dictionary propertyNameTransforms, Dictionary propertyDefaultValues, List skippedProperties)
{
var interfaces = interfaceType.AllInterfaces.ToList();
interfaces.Insert(0, interfaceType);
@@ -570,181 +584,176 @@ string getNewName(string key)
return input;
}
- public void Initialize(GeneratorInitializationContext context)
+ sealed class TemplateInfo
{
- //if (!Debugger.IsAttached)
- //{
- // Debugger.Launch();
- //}
-
- context.RegisterForPostInitialization((pi) => pi.AddSource("CometGenerationAttribute__", attributeSource));
- context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
+ public string Name;
+ public INamedTypeSymbol InterfaceType;
+ public List KeyProperties;
+ public string NameSpace;
+ public INamedTypeSymbol BaseClass;
+ public Dictionary PropertyNameTransforms;
+ public Dictionary PropertyDefaultValues;
+ public List SkippedProperties;
}
- static INamedTypeSymbol cometViewSymbol;
- class SyntaxReceiver : ISyntaxContextReceiver
- {
- public List<(string name, INamedTypeSymbol interfaceType, List keyProperties, string nameSpace, INamedTypeSymbol baseClass, Dictionary propertyNameTransforms, Dictionary propertyDefaultValues, List skippedProperties)> TemplateInfo = new();
- public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
+ static TemplateInfo GetTemplateInfo(GeneratorSyntaxContext context)
+ {
+ var cometView = context.SemanticModel.Compilation.GetTypeByMetadataName("Comet.View");
+ var attrib = context.Node as AttributeSyntax;
+ if (attrib == null)
+ return null;
+ if (context.SemanticModel.GetTypeInfo(attrib).Type?.ToDisplayString() != "CometGenerateAttribute")
+ return null;
+
+ var f = attrib.ArgumentList.Arguments[0].Expression as TypeOfExpressionSyntax;
+ var realClass = GetType(context, f, cometView);
+
+ var keyProperties = new List();
+ string name = null;
+ string nameSpace = null;
+ INamedTypeSymbol baseClass = cometView;
+ List defaultValues = new();
+ List skippedProperties = new();
+
+ foreach (var arg in attrib.ArgumentList.Arguments.Skip(1))
{
- var cometView = context.SemanticModel.Compilation.GetTypeByMetadataName("Comet.View");
- cometViewSymbol ??= cometView;
- var attrib = context.Node as AttributeSyntax;
- if (attrib != null)
+ var constVal = context.SemanticModel.GetConstantValue(arg.Expression);
+ var symbol = context.SemanticModel.GetSymbolInfo(arg.Expression);
+ var argName = arg.NameEquals?.Name.Identifier.ValueText;
+ if (argName == "ClassName")
{
- var type = context.SemanticModel.GetTypeInfo(attrib);
- if (context.SemanticModel.GetTypeInfo(attrib).Type?.ToDisplayString() == "CometGenerateAttribute")
- {
- {
-
- var f = attrib.ArgumentList.Arguments[0].Expression as TypeOfExpressionSyntax;
- var realClass = GetType(context, f);
-
- var keyProperties = new List();
- string name = null;
- string nameSpace = null;
- INamedTypeSymbol baseClass = cometView;
- List defaultValues = new();
- List skippedProperties = new();
-
- foreach (var arg in attrib.ArgumentList.Arguments.Skip(1))
- {
- var constVal = context.SemanticModel.GetConstantValue(arg.Expression);
- var symbol = context.SemanticModel.GetSymbolInfo(arg.Expression);
- var argName = arg.NameEquals?.Name.Identifier.ValueText;
- if (argName == "ClassName")
- {
- name = constVal.ToString();
- continue;
- }
-
- if (argName == "Namespace")
- {
- nameSpace = constVal.ToString();
- continue;
- }
- if (argName == "DefaultValues")
- {
- var iac = (arg.Expression as ImplicitArrayCreationExpressionSyntax).Initializer.Expressions;
- foreach (var i in iac)
- {
- var fif = context.SemanticModel.GetConstantValue(i);
- defaultValues.Add(fif.ToString());
- }
- }
- if (argName == "Skip")
- {
- var iac = (arg.Expression as ImplicitArrayCreationExpressionSyntax).Initializer.Expressions;
- foreach (var i in iac)
- {
- var fif = context.SemanticModel.GetConstantValue(i);
- skippedProperties.Add(fif.ToString());
- }
- }
- //Strings which can only be key properties
- if (arg.Expression is LiteralExpressionSyntax || (arg.Expression is InvocationExpressionSyntax && constVal.HasValue) || arg.Expression is InterpolatedStringExpressionSyntax)
- {
- keyProperties.Add(constVal.ToString());
- continue;
- }
-
-
- if (arg.Expression is TypeOfExpressionSyntax toe)
- {
- //this is our base class!
- baseClass = GetType(context, toe);
- continue;
- }
-
- var exp = arg.Expression;
- }
- name ??= realClass.Name?.TrimStart('I');
- nameSpace ??= GetFullName(realClass.ContainingNamespace);
-
- Dictionary propertyTransform = new();
- Dictionary propertyDefaultValues = new();
- (bool hasParts, string key) getParts(string oldKey)
- {
- var hasName = oldKey.Contains(':');
- var hasValue = oldKey.Contains('=');
+ name = constVal.ToString();
+ continue;
+ }
- if (!hasName && !hasValue)
- {
- return (false, null);
- }
- var parts = oldKey.Split(':', '=');
- var key = parts[0].Trim();
- var newName = hasName ? parts[1].Trim() : null;
- var defaultValue = hasValue ? parts.Last().Trim() : null;
+ if (argName == "Namespace")
+ {
+ nameSpace = constVal.ToString();
+ continue;
+ }
+ if (argName == "DefaultValues")
+ {
+ var iac = (arg.Expression as ImplicitArrayCreationExpressionSyntax).Initializer.Expressions;
+ foreach (var i in iac)
+ {
+ var fif = context.SemanticModel.GetConstantValue(i);
+ defaultValues.Add(fif.ToString());
+ }
+ }
+ if (argName == "Skip")
+ {
+ var iac = (arg.Expression as ImplicitArrayCreationExpressionSyntax).Initializer.Expressions;
+ foreach (var i in iac)
+ {
+ var fif = context.SemanticModel.GetConstantValue(i);
+ skippedProperties.Add(fif.ToString());
+ }
+ }
+ //Strings which can only be key properties
+ if (arg.Expression is LiteralExpressionSyntax || (arg.Expression is InvocationExpressionSyntax && constVal.HasValue) || arg.Expression is InterpolatedStringExpressionSyntax)
+ {
+ keyProperties.Add(constVal.ToString());
+ continue;
+ }
- if (newName != null)
- propertyTransform[key] = newName;
+ if (arg.Expression is TypeOfExpressionSyntax toe)
+ {
+ //this is our base class!
+ baseClass = GetType(context, toe, cometView);
+ continue;
+ }
- if (defaultValue != null)
- propertyDefaultValues[key] = defaultValue;
- return (true, key);
- }
- foreach (var oldKey in keyProperties.ToList())
- {
- (bool hasParts, string key) = getParts(oldKey);
- if (!hasParts)
- {
- continue;
- }
- var oldIndex = keyProperties.IndexOf(oldKey);
- keyProperties.RemoveAt(oldIndex);
- keyProperties.Insert(oldIndex, key);
- }
+ var exp = arg.Expression;
+ }
+ name ??= realClass.Name?.TrimStart('I');
+ nameSpace ??= GetFullName(realClass.ContainingNamespace);
+ Dictionary propertyTransform = new();
+ Dictionary propertyDefaultValues = new();
+ (bool hasParts, string key) getParts(string oldKey)
+ {
+ var hasName = oldKey.Contains(':');
+ var hasValue = oldKey.Contains('=');
- foreach (var oldKey in defaultValues)
- {
- (bool hasParts, string key) = getParts(oldKey);
- }
- foreach (var oldKey in skippedProperties.ToList())
- {
- (bool hasParts, string key) = getParts(oldKey);
- if (!hasParts)
- {
- continue;
- }
- skippedProperties.Remove(oldKey);
- skippedProperties.Add(key);
- }
+ if (!hasName && !hasValue)
+ {
+ return (false, null);
+ }
+ var parts = oldKey.Split(':', '=');
+ var key = parts[0].Trim();
+ var newName = hasName ? parts[1].Trim() : null;
+ var defaultValue = hasValue ? parts.Last().Trim() : null;
- //string name = context.SemanticModel.GetTypeInfo(attrib.ArgumentList.Arguments[0].Expression).ToString();
- //string template = context.SemanticModel.GetConstantValue(attrib.ArgumentList.Arguments[1].Expression).ToString();
- //string hash = context.SemanticModel.GetConstantValue(attrib.ArgumentList.Arguments[2].Expression).ToString();
+ if (newName != null)
+ propertyTransform[key] = newName;
- TemplateInfo.Add((name, realClass, keyProperties, nameSpace, baseClass, propertyTransform, propertyDefaultValues, skippedProperties));
- }
- }
+ if (defaultValue != null)
+ propertyDefaultValues[key] = defaultValue;
+ return (true, key);
+ }
+ foreach (var oldKey in keyProperties.ToList())
+ {
+ (bool hasParts, string key) = getParts(oldKey);
+ if (!hasParts)
+ {
+ continue;
}
+ var oldIndex = keyProperties.IndexOf(oldKey);
+ keyProperties.RemoveAt(oldIndex);
+ keyProperties.Insert(oldIndex, key);
}
- static INamedTypeSymbol GetType(GeneratorSyntaxContext context, TypeOfExpressionSyntax expression)
+
+ foreach (var oldKey in defaultValues)
{
- var interfaceType = context.SemanticModel.GetSymbolInfo(expression.Type);
- var symbol = interfaceType.Symbol;
- if (symbol == null && interfaceType.CandidateSymbols.Length > 1)
+ (bool hasParts, string key) = getParts(oldKey);
+ }
+ foreach (var oldKey in skippedProperties.ToList())
+ {
+ (bool hasParts, string key) = getParts(oldKey);
+ if (!hasParts)
{
- symbol = interfaceType.CandidateSymbols.OfType().Where(x=> IsSublcassOfCometView(x)).FirstOrDefault();
+ continue;
}
- var s = GetFullName(symbol);
- return context.SemanticModel.Compilation.GetTypeByMetadataName(s);
+ skippedProperties.Remove(oldKey);
+ skippedProperties.Add(key);
}
- static bool IsSublcassOfCometView(INamedTypeSymbol symbol)
+
+ return new TemplateInfo
{
- if (symbol.BaseType == null)
- return false;
- if (symbol.BaseType == cometViewSymbol)
- return true;
+ Name = name,
+ InterfaceType = realClass,
+ KeyProperties = keyProperties,
+ NameSpace = nameSpace,
+ BaseClass = baseClass,
+ PropertyNameTransforms = propertyTransform,
+ PropertyDefaultValues = propertyDefaultValues,
+ SkippedProperties = skippedProperties,
+ };
+ }
- return IsSublcassOfCometView(symbol.BaseType);
+ static INamedTypeSymbol GetType(GeneratorSyntaxContext context, TypeOfExpressionSyntax expression, INamedTypeSymbol cometViewSymbol)
+ {
+ var interfaceType = context.SemanticModel.GetSymbolInfo(expression.Type);
+ var symbol = interfaceType.Symbol;
+ if (symbol == null && interfaceType.CandidateSymbols.Length > 1)
+ {
+ symbol = interfaceType.CandidateSymbols.OfType().Where(x => IsSublcassOfCometView(x, cometViewSymbol)).FirstOrDefault();
}
+ var s = GetFullName(symbol);
+ return context.SemanticModel.Compilation.GetTypeByMetadataName(s);
+ }
+
+ static bool IsSublcassOfCometView(INamedTypeSymbol symbol, INamedTypeSymbol cometViewSymbol)
+ {
+ if (symbol.BaseType == null)
+ return false;
+ if (SymbolEqualityComparer.Default.Equals(symbol.BaseType, cometViewSymbol))
+ return true;
+ return IsSublcassOfCometView(symbol.BaseType, cometViewSymbol);
}
}
}
diff --git a/src/Comet/src/Comet.SourceGenerator/StateWrapperGenerator.cs b/src/Comet/src/Comet.SourceGenerator/StateWrapperGenerator.cs
index 92a3e94d2..003b61c37 100644
--- a/src/Comet/src/Comet.SourceGenerator/StateWrapperGenerator.cs
+++ b/src/Comet/src/Comet.SourceGenerator/StateWrapperGenerator.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Text;
@@ -10,7 +11,7 @@
namespace Comet.SourceGenerator
{
[Generator]
- public class StateWrapperGenerator : ISourceGenerator
+ public class StateWrapperGenerator : IIncrementalGenerator
{
private const string attributeSource = @"
[System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class, AllowMultiple = true)]
@@ -170,15 +171,72 @@ static StateWrapperGenerator()
};
}
- Stubble.Core.StubbleVisitorRenderer stubble = new StubbleBuilder().Build();
+ static readonly Stubble.Core.StubbleVisitorRenderer stubble = new StubbleBuilder().Build();
static Dictionary<(bool HasGet, bool HasSet), string> interfacePropertyDictionary;
- public void Execute(GeneratorExecutionContext context)
+ public void Initialize(IncrementalGeneratorInitializationContext context)
{
- if (!(context.SyntaxContextReceiver is SyntaxReceiver rx) || !rx.TemplateInfo.Any())
+ context.RegisterPostInitializationOutput((pi) => pi.AddSource("GenerateStateClassAttribute__", attributeSource));
+
+ var candidates = context.SyntaxProvider.CreateSyntaxProvider(
+ predicate: static (node, _) => IsCandidateNode(node),
+ transform: static (ctx, _) => GetCandidateType(ctx))
+ .Where(static t => t != null)
+ .Collect();
+
+ var compilationAndCandidates = context.CompilationProvider.Combine(candidates);
+
+ context.RegisterSourceOutput(compilationAndCandidates, static (spc, source) => Execute(source.Left, source.Right, spc));
+ }
+
+ static bool IsCandidateNode(Microsoft.CodeAnalysis.SyntaxNode node)
+ {
+ if (node is AttributeSyntax attrib)
+ return IsGenerateStateClassName(attrib);
+ if (node is ClassDeclarationSyntax cds && cds.AttributeLists.Count > 0)
+ return cds.AttributeLists.SelectMany(al => al.Attributes).Any(IsGenerateStateClassName);
+ return false;
+ }
+
+ static bool IsGenerateStateClassName(AttributeSyntax attrib)
+ {
+ var name = attrib.Name;
+ while (name is QualifiedNameSyntax qn)
+ name = qn.Right;
+ var id = (name as SimpleNameSyntax)?.Identifier.ValueText;
+ return id == "GenerateStateClass" || id == "GenerateStateClassAttribute";
+ }
+
+ static INamedTypeSymbol GetCandidateType(GeneratorSyntaxContext context)
+ {
+ if (context.Node is ClassDeclarationSyntax cds && context.SemanticModel.GetDeclaredSymbol(cds).GetAttributes().Any(x => x.AttributeClass.Name == "GenerateStateClassAttribute"))
+ {
+ return context.SemanticModel.GetDeclaredSymbol(cds).OriginalDefinition as INamedTypeSymbol;
+ }
+
+ if (context.Node is AttributeSyntax attrib)
+ {
+ if (context.SemanticModel.GetTypeInfo(attrib).Type?.ToDisplayString() == "GenerateStateClassAttribute")
+ {
+ if (attrib.ArgumentList == null)
+ return null;
+ var f = attrib.ArgumentList.Arguments[0].Expression as TypeOfExpressionSyntax;
+ return GetType(context.SemanticModel, f);
+ }
+ }
+ return null;
+ }
+
+ static void Execute(Compilation compilation, ImmutableArray candidates, SourceProductionContext context)
+ {
+ if (candidates.IsDefaultOrEmpty)
return;
- var st = context.Compilation.SyntaxTrees;
+ var rx = new TemplateCollector();
+ foreach (var candidate in candidates)
+ rx.AddTemplate(candidate);
+
+ var st = compilation.SyntaxTrees;
void addChildStateClasses(IEnumerable match)
{
@@ -192,7 +250,7 @@ void addChildStateClasses(IEnumerable match)
var select = false;
if (sn is ClassDeclarationSyntax cds)
{
- var sm = context.Compilation.GetSemanticModel(cds.SyntaxTree);
+ var sm = compilation.GetSemanticModel(cds.SyntaxTree);
var ts = sm.GetDeclaredSymbol(cds).OriginalDefinition as INamedTypeSymbol;
select = matchWith.Any(c => c == ts || c.OriginalDefinition == ts);
}
@@ -204,7 +262,7 @@ void addChildStateClasses(IEnumerable match)
var select = false;
if (cn is PropertyDeclarationSyntax pds)
{
- var sm = context.Compilation.GetSemanticModel(pds.SyntaxTree);
+ var sm = compilation.GetSemanticModel(pds.SyntaxTree);
var realClass = StateWrapperGenerator.GetType(sm, pds.Type);
select = !(pds.Type is NullableTypeSyntax) && IsUserClass(realClass);
}
@@ -213,7 +271,7 @@ void addChildStateClasses(IEnumerable match)
var parentStateClassesProps = props.Select(pds => {
var p = (PropertyDeclarationSyntax)pds;
- var sm = context.Compilation.GetSemanticModel(p.SyntaxTree);
+ var sm = compilation.GetSemanticModel(p.SyntaxTree);
var realClass = StateWrapperGenerator.GetType(sm, p.Type);
return realClass;
}).ToList();
@@ -237,9 +295,9 @@ void addChildStateClasses(IEnumerable match)
}
}
- bool IsUserClass(ITypeSymbol realClass) => realClass?.TypeKind == TypeKind.Class && realClass.SpecialType == SpecialType.None && !realClass.ToString().StartsWith("System"); //&& realClass.isnu;
+ static bool IsUserClass(ITypeSymbol realClass) => realClass?.TypeKind == TypeKind.Class && realClass.SpecialType == SpecialType.None && !realClass.ToString().StartsWith("System"); //&& realClass.isnu;
- IEnumerable GetAllBaseTypes(INamedTypeSymbol classType)
+ static IEnumerable GetAllBaseTypes(INamedTypeSymbol classType)
{
yield return classType;
if (classType.BaseType != null)
@@ -249,7 +307,7 @@ IEnumerable GetAllBaseTypes(INamedTypeSymbol classType)
}
}
- dynamic GetModelData(string className, INamedTypeSymbol classType, string nameSpace, SyntaxReceiver sr)
+ static dynamic GetModelData(string className, INamedTypeSymbol classType, string nameSpace, TemplateCollector sr)
{
var baseClasses = GetAllBaseTypes(classType).ToList();
List<(string Type, string Name)> properties = new();
@@ -373,18 +431,7 @@ dynamic GetModelData(string className, INamedTypeSymbol classType, string nameSp
return input;
}
- public void Initialize(GeneratorInitializationContext context)
- {
- //if (!Debugger.IsAttached)
- //{
- // Debugger.Launch();
- //}
-
- context.RegisterForPostInitialization((pi) => pi.AddSource("GenerateStateClassAttribute__", attributeSource));
- context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
- }
-
- class SyntaxReceiver : ISyntaxContextReceiver
+ class TemplateCollector
{
public List<(string name, INamedTypeSymbol classType, string nameSpace)> TemplateInfo = new();
@@ -397,27 +444,6 @@ public void AddTemplate(INamedTypeSymbol classType)
TemplateInfo.Add((name, classType, nameSpace));
}
}
-
- public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
- {
- INamedTypeSymbol realClass;
- if (context.Node is ClassDeclarationSyntax cds && context.SemanticModel.GetDeclaredSymbol(cds).GetAttributes().Any(x => x.AttributeClass.Name == "GenerateStateClassAttribute"))
- {
- realClass = context.SemanticModel.GetDeclaredSymbol(cds).OriginalDefinition as INamedTypeSymbol;//GetType(context, f);
- AddTemplate(realClass);
- }
-
- if (context.Node is AttributeSyntax attrib)
- {
- if (context.SemanticModel.GetTypeInfo(attrib).Type?.ToDisplayString() == "GenerateStateClassAttribute")
- {
- if (attrib.ArgumentList == null) return;
- var f = attrib.ArgumentList.Arguments[0].Expression as TypeOfExpressionSyntax;
- realClass = StateWrapperGenerator.GetType(context.SemanticModel, f);
- AddTemplate(realClass);
- }
- }
- }
}
static INamedTypeSymbol GetType(SemanticModel sm, TypeOfExpressionSyntax expression)
diff --git a/src/Comet/src/Comet.SourceGenerator/StyleInfrastructureGenerator.cs b/src/Comet/src/Comet.SourceGenerator/StyleInfrastructureGenerator.cs
index 367993d54..d17d6ff55 100644
--- a/src/Comet/src/Comet.SourceGenerator/StyleInfrastructureGenerator.cs
+++ b/src/Comet/src/Comet.SourceGenerator/StyleInfrastructureGenerator.cs
@@ -14,30 +14,47 @@ namespace Comet.SourceGenerator
/// - Partial class with private state fields and ResolveCurrentStyle() method
///
[Generator]
- public class StyleInfrastructureGenerator : ISourceGenerator
+ public class StyleInfrastructureGenerator : IIncrementalGenerator
{
- public void Initialize(GeneratorInitializationContext context)
+ public void Initialize(IncrementalGeneratorInitializationContext context)
{
- context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
+ var controlStates = context.SyntaxProvider.CreateSyntaxProvider(
+ predicate: static (node, _) => node is AttributeSyntax attrib && IsCometControlStateName(attrib),
+ transform: static (ctx, _) => GetControlStateInfo(ctx))
+ .Where(static info => info != null)
+ .Collect();
+
+ var compilationAndControlStates = context.CompilationProvider.Combine(controlStates);
+
+ context.RegisterSourceOutput(compilationAndControlStates, static (spc, source) => Execute(source.Left, source.Right, spc));
+ }
+
+ static bool IsCometControlStateName(AttributeSyntax attrib)
+ {
+ var name = attrib.Name;
+ while (name is Microsoft.CodeAnalysis.CSharp.Syntax.QualifiedNameSyntax qn)
+ name = qn.Right;
+ var id = (name as Microsoft.CodeAnalysis.CSharp.Syntax.SimpleNameSyntax)?.Identifier.ValueText;
+ return id == "CometControlState" || id == "CometControlStateAttribute";
}
- public void Execute(GeneratorExecutionContext context)
+ static void Execute(Compilation compilation, System.Collections.Immutable.ImmutableArray controlStates, SourceProductionContext context)
{
- if (!(context.SyntaxContextReceiver is SyntaxReceiver rx) || !rx.ControlStates.Any())
+ if (controlStates.IsDefaultOrEmpty)
return;
- foreach (var info in rx.ControlStates)
+ foreach (var info in controlStates)
{
- EmitConfigurationStruct(context, info);
- EmitStyleExtensions(context, info);
- EmitResolveCurrentStyle(context, info);
+ EmitConfigurationStruct(context, compilation, info);
+ EmitStyleExtensions(context, compilation, info);
+ EmitResolveCurrentStyle(context, compilation, info);
}
}
- static void EmitConfigurationStruct(GeneratorExecutionContext context, ControlStateInfo info)
+ static void EmitConfigurationStruct(SourceProductionContext context, Compilation compilation, ControlStateInfo info)
{
// Skip if hand-written version already exists in the compilation
- var existing = context.Compilation.GetTypeByMetadataName(
+ var existing = compilation.GetTypeByMetadataName(
$"Comet.Styles.{info.ControlName}Configuration");
if (existing != null)
return;
@@ -74,10 +91,10 @@ static void EmitConfigurationStruct(GeneratorExecutionContext context, ControlSt
context.AddSource($"{info.ControlName}Configuration.g.cs", sb.ToString());
}
- static void EmitStyleExtensions(GeneratorExecutionContext context, ControlStateInfo info)
+ static void EmitStyleExtensions(SourceProductionContext context, Compilation compilation, ControlStateInfo info)
{
// Skip if a style extension method already exists (e.g., hand-written ControlStyleExtensions)
- if (HasExistingStyleExtension(context.Compilation, info.ControlName))
+ if (HasExistingStyleExtension(compilation, info.ControlName))
return;
var sb = new StringBuilder();
@@ -108,11 +125,11 @@ static void EmitStyleExtensions(GeneratorExecutionContext context, ControlStateI
context.AddSource($"{info.ControlName}StyleExtensions.g.cs", sb.ToString());
}
- static void EmitResolveCurrentStyle(GeneratorExecutionContext context, ControlStateInfo info)
+ static void EmitResolveCurrentStyle(SourceProductionContext context, Compilation compilation, ControlStateInfo info)
{
// Look up the actual config struct from the compilation to match its fields
var configTypeName = $"Comet.Styles.{info.ControlName}Configuration";
- var configType = context.Compilation.GetTypeByMetadataName(configTypeName);
+ var configType = compilation.GetTypeByMetadataName(configTypeName);
// Determine which state fields actually exist on the config struct
var validStates = new List();
@@ -131,7 +148,7 @@ static void EmitResolveCurrentStyle(GeneratorExecutionContext context, ControlSt
}
// Check if Theme has the 2-type-param GetControlStyle() method
- var themeType = context.Compilation.GetTypeByMetadataName("Comet.Styles.Theme");
+ var themeType = compilation.GetTypeByMetadataName("Comet.Styles.Theme");
var hasNewStyleApi = themeType != null && themeType.GetMembers("GetControlStyle")
.OfType()
.Any(m => m.TypeParameters.Length == 2);
@@ -249,90 +266,85 @@ class ControlStateInfo
= new List<(string, string, string)>();
}
- class SyntaxReceiver : ISyntaxContextReceiver
+ static ControlStateInfo GetControlStateInfo(GeneratorSyntaxContext context)
{
- public List ControlStates { get; } = new List();
+ if (!(context.Node is AttributeSyntax attrib))
+ return null;
- public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
- {
- if (!(context.Node is AttributeSyntax attrib))
- return;
+ var typeInfo = context.SemanticModel.GetTypeInfo(attrib);
+ var typeName = typeInfo.Type?.ToDisplayString();
- var typeInfo = context.SemanticModel.GetTypeInfo(attrib);
- var typeName = typeInfo.Type?.ToDisplayString();
+ if (typeName != "Comet.CometControlStateAttribute")
+ return null;
- if (typeName != "Comet.CometControlStateAttribute")
- return;
+ var args = attrib.ArgumentList?.Arguments;
+ if (args == null || args.Value.Count == 0)
+ return null;
- var args = attrib.ArgumentList?.Arguments;
- if (args == null || args.Value.Count == 0)
- return;
+ // First argument: typeof(IButton)
+ if (!(args.Value[0].Expression is TypeOfExpressionSyntax typeOfExpr))
+ return null;
- // First argument: typeof(IButton)
- if (!(args.Value[0].Expression is TypeOfExpressionSyntax typeOfExpr))
- return;
+ var interfaceSymbolInfo = context.SemanticModel.GetSymbolInfo(typeOfExpr.Type);
+ if (!(interfaceSymbolInfo.Symbol is INamedTypeSymbol interfaceSymbol))
+ return null;
- var interfaceSymbolInfo = context.SemanticModel.GetSymbolInfo(typeOfExpr.Type);
- if (!(interfaceSymbolInfo.Symbol is INamedTypeSymbol interfaceSymbol))
- return;
+ var info = new ControlStateInfo
+ {
+ InterfaceType = interfaceSymbol,
+ ControlName = interfaceSymbol.Name.TrimStart('I'),
+ };
- var info = new ControlStateInfo
- {
- InterfaceType = interfaceSymbol,
- ControlName = interfaceSymbol.Name.TrimStart('I'),
- };
+ // Parse named arguments
+ foreach (var arg in args.Value.Skip(1))
+ {
+ var argName = arg.NameEquals?.Name.Identifier.ValueText;
- // Parse named arguments
- foreach (var arg in args.Value.Skip(1))
+ if (argName == "ControlName")
{
- var argName = arg.NameEquals?.Name.Identifier.ValueText;
-
- if (argName == "ControlName")
- {
- var constVal = context.SemanticModel.GetConstantValue(arg.Expression);
- if (constVal.HasValue)
- info.ControlName = constVal.Value.ToString();
- }
- else if (argName == "States")
+ var constVal = context.SemanticModel.GetConstantValue(arg.Expression);
+ if (constVal.HasValue)
+ info.ControlName = constVal.Value.ToString();
+ }
+ else if (argName == "States")
+ {
+ foreach (var expr in GetArrayExpressions(arg.Expression))
{
- foreach (var expr in GetArrayExpressions(arg.Expression))
- {
- var val = context.SemanticModel.GetConstantValue(expr);
- if (val.HasValue)
- info.States.Add(val.Value.ToString());
- }
+ var val = context.SemanticModel.GetConstantValue(expr);
+ if (val.HasValue)
+ info.States.Add(val.Value.ToString());
}
- else if (argName == "ConfigProperties")
+ }
+ else if (argName == "ConfigProperties")
+ {
+ foreach (var expr in GetArrayExpressions(arg.Expression))
{
- foreach (var expr in GetArrayExpressions(arg.Expression))
+ var val = context.SemanticModel.GetConstantValue(expr);
+ if (val.HasValue)
{
- var val = context.SemanticModel.GetConstantValue(expr);
- if (val.HasValue)
+ var parts = val.Value.ToString().Split(':');
+ if (parts.Length >= 2)
{
- var parts = val.Value.ToString().Split(':');
- if (parts.Length >= 2)
- {
- var name = parts[0].Trim();
- var type = parts[1].Trim();
- var source = parts.Length >= 3 ? parts[2].Trim() : name;
- info.ConfigProperties.Add((name, type, source));
- }
+ var name = parts[0].Trim();
+ var type = parts[1].Trim();
+ var source = parts.Length >= 3 ? parts[2].Trim() : name;
+ info.ConfigProperties.Add((name, type, source));
}
}
}
}
-
- ControlStates.Add(info);
}
- static IEnumerable GetArrayExpressions(ExpressionSyntax expr)
- {
- if (expr is ImplicitArrayCreationExpressionSyntax iac)
- return iac.Initializer.Expressions;
- if (expr is ArrayCreationExpressionSyntax ac && ac.Initializer != null)
- return ac.Initializer.Expressions;
- return Enumerable.Empty();
- }
+ return info;
+ }
+
+ static IEnumerable GetArrayExpressions(ExpressionSyntax expr)
+ {
+ if (expr is ImplicitArrayCreationExpressionSyntax iac)
+ return iac.Initializer.Expressions;
+ if (expr is ArrayCreationExpressionSyntax ac && ac.Initializer != null)
+ return ac.Initializer.Expressions;
+ return Enumerable.Empty();
}
}
}
diff --git a/src/Comet/src/Comet.SwiftUI.Binding/ApiDefinition.cs b/src/Comet/src/Comet.SwiftUI.Binding/ApiDefinition.cs
new file mode 100644
index 000000000..33d6da5a5
--- /dev/null
+++ b/src/Comet/src/Comet.SwiftUI.Binding/ApiDefinition.cs
@@ -0,0 +1,111 @@
+using System;
+using Foundation;
+using ObjCRuntime;
+using UIKit;
+
+namespace Comet.SwiftUI.Interop
+{
+ // An opaque handle to a Swift CometNode (a node in the SwiftUI backend tree).
+ [BaseType(typeof(NSObject), Name = "CometNode")]
+ interface CometNode
+ {
+ [Export("kind")]
+ string Kind { get; }
+ }
+
+ // The @objc surface that drives the SwiftUI backend tree from C#.
+ [BaseType(typeof(NSObject), Name = "CometSwiftUIHost")]
+ interface CometSwiftUIHost
+ {
+ [Static, Export("makeNodeWithKind:")]
+ CometNode MakeNode(string kind);
+
+ [Static, Export("setString:property:value:")]
+ void SetString(CometNode node, string property, string value);
+
+ [Static, Export("setBool:property:value:")]
+ void SetBool(CometNode node, string property, bool value);
+
+ [Static, Export("setColor:property:argb:")]
+ void SetColor(CometNode node, string property, uint argb);
+
+ [Static, Export("setDouble:property:value:")]
+ void SetDouble(CometNode node, string property, double value);
+
+ [Static, Export("scrollNodeToBottom:")]
+ void ScrollToBottom(CometNode node);
+
+ [Static, Export("clearGradientStops:")]
+ void ClearGradientStops(CometNode node);
+
+ [Static, Export("clearBorderGradientStops:")]
+ void ClearBorderGradientStops(CometNode node);
+
+ [Static, Export("addBorderGradientStop:argb:")]
+ void AddBorderGradientStop(CometNode node, uint argb);
+
+ [Static, Export("addGradientStop:argb:")]
+ void AddGradientStop(CometNode node, uint argb);
+
+ [Static, Export("clearTextRuns:")]
+ void ClearTextRuns(CometNode node);
+
+ [Static, Export("addTextRun:text:colorArgb:hasColor:mono:bgArgb:hasBg:underline:")]
+ void AddTextRun(CometNode node, string text, uint colorArgb, bool hasColor, bool mono, uint bgArgb, bool hasBg, bool underline);
+
+ [Static, Export("setTapHandler:handler:")]
+ void SetTapHandler(CometNode node, Action handler);
+
+ [Static, Export("setTapGestureHandler:handler:")]
+ void SetTapGestureHandler(CometNode node, Action handler);
+
+ [Static, Export("setStringChangeHandler:handler:")]
+ void SetStringChangeHandler(CometNode node, Action handler);
+
+ [Static, Export("setBoolChangeHandler:handler:")]
+ void SetBoolChangeHandler(CometNode node, Action handler);
+
+ [Static, Export("setDoubleChangeHandler:handler:")]
+ void SetDoubleChangeHandler(CometNode node, Action handler);
+
+ [Static, Export("setDialogDismissHandler:handler:")]
+ void SetDialogDismissHandler(CometNode node, Action handler);
+
+ [Static, Export("setFocusHandler:handler:")]
+ void SetFocusHandler(CometNode node, Action handler);
+
+ [Static, Export("setScrollHandler:handler:")]
+ void SetScrollHandler(CometNode node, Action handler);
+
+ [Static, Export("setScrollTopHandler:handler:")]
+ void SetScrollTopHandler(CometNode node, Action handler);
+
+ [Static, Export("setLongPressGestureHandler:handler:")]
+ void SetLongPressGestureHandler(CometNode node, Action handler);
+
+ [Static, Export("setRowVisibilityHandler:handler:")]
+ void SetRowVisibilityHandler(CometNode node, Action handler);
+
+ [Static, Export("insertChild:atIndex:child:")]
+ void InsertChild(CometNode node, nint index, CometNode child);
+
+ [Static, Export("removeChild:atIndex:")]
+ void RemoveChild(CometNode node, nint index);
+
+ [Static, Export("clearChildren:")]
+ void ClearChildren(CometNode node);
+
+ [Static, Export("setFrame:x:y:width:height:")]
+ void SetFrame(CometNode node, double x, double y, double width, double height);
+
+ [Static, Export("measureNode:maxWidth:maxHeight:")]
+ CoreGraphics.CGSize MeasureNode(CometNode node, double maxWidth, double maxHeight);
+
+ [Static, Export("screenshotPNG")]
+ [return: NullAllowed]
+ Foundation.NSData ScreenshotPng();
+
+ [Static, Export("hostControllerForRoot:")]
+ UIViewController HostController(CometNode root);
+ }
+}
diff --git a/src/Comet/src/Comet.SwiftUI.Binding/Comet.SwiftUI.Binding.csproj b/src/Comet/src/Comet.SwiftUI.Binding/Comet.SwiftUI.Binding.csproj
new file mode 100644
index 000000000..a802a0631
--- /dev/null
+++ b/src/Comet/src/Comet.SwiftUI.Binding/Comet.SwiftUI.Binding.csproj
@@ -0,0 +1,25 @@
+
+
+
+
+ net11.0-ios
+ 17.0
+ true
+ disable
+ disable
+ false
+
+
+
+
+
+
+
+
+ Framework
+ True
+
+
+
+
diff --git a/src/Comet/src/Comet.SwiftUI.Shim/.gitignore b/src/Comet/src/Comet.SwiftUI.Shim/.gitignore
new file mode 100644
index 000000000..fa9897b64
--- /dev/null
+++ b/src/Comet/src/Comet.SwiftUI.Shim/.gitignore
@@ -0,0 +1,4 @@
+# Build outputs — regenerate the xcframework with ./build-xcframework.sh
+build/
+CometSwiftUIShim.xcframework/
+.build/
diff --git a/src/Comet/src/Comet.SwiftUI.Shim/Package.swift b/src/Comet/src/Comet.SwiftUI.Shim/Package.swift
new file mode 100644
index 000000000..71e7c4f4a
--- /dev/null
+++ b/src/Comet/src/Comet.SwiftUI.Shim/Package.swift
@@ -0,0 +1,13 @@
+// swift-tools-version:5.9
+import PackageDescription
+
+let package = Package(
+ name: "CometSwiftUIShim",
+ platforms: [.iOS(.v16)],
+ products: [
+ .library(name: "CometSwiftUIShim", type: .dynamic, targets: ["CometSwiftUIShim"]),
+ ],
+ targets: [
+ .target(name: "CometSwiftUIShim", path: "Sources/CometSwiftUIShim"),
+ ]
+)
diff --git a/src/Comet/src/Comet.SwiftUI.Shim/README.md b/src/Comet/src/Comet.SwiftUI.Shim/README.md
new file mode 100644
index 000000000..59705c88a
--- /dev/null
+++ b/src/Comet/src/Comet.SwiftUI.Shim/README.md
@@ -0,0 +1,31 @@
+# CometSwiftUIShim
+
+A small Swift package that exposes **SwiftUI** behind an `@objc`-representable surface so
+.NET for iOS can drive it **without Swift-ABI interop** — everything crosses the boundary
+as Objective-C-compatible types, bound with a standard `.NET`-for-iOS binding
+(`../Comet.SwiftUI.Binding`).
+
+This is the iOS counterpart to the vendored Jetpack Compose facade on Android: the
+platform-specific UI-kit bridge that Comet-Next's `ICometBackendNode` SwiftUI backend
+renders through.
+
+## Build
+
+```bash
+./build-xcframework.sh # produces CometSwiftUIShim.xcframework (device + simulator)
+```
+
+The `.xcframework` is a build output (git-ignored); regenerate it before building the
+binding or the `CometSwiftUIProbe` sample.
+
+## Why @objc instead of Swift bindings
+
+Stock .NET for iOS can't call Swift directly, but it has always bound Objective-C. A Swift
+`@objc` class (NSObject subclass, explicit `@objc(Name)` selectors) is ObjC-callable, so
+the binding is a plain `ApiDefinition` + `NativeReference` to the framework. iOS ships the
+Swift runtime, so the dynamic framework resolves it via `@rpath`. This is the plan's
+"fallback" path — in practice the lower-risk primary path (no `swift-dotnet-bindings`
+dependency, no `CallConvSwift`).
+
+Verified on the iOS 18 / iPhone 16 simulator: a SwiftUI view hosted via
+`UIHostingController`, with its text supplied from C#, renders correctly.
diff --git a/src/Comet/src/Comet.SwiftUI.Shim/Sources/CometSwiftUIShim/CometSwiftUIShim.swift b/src/Comet/src/Comet.SwiftUI.Shim/Sources/CometSwiftUIShim/CometSwiftUIShim.swift
new file mode 100644
index 000000000..4226f8267
--- /dev/null
+++ b/src/Comet/src/Comet.SwiftUI.Shim/Sources/CometSwiftUIShim/CometSwiftUIShim.swift
@@ -0,0 +1,1021 @@
+import UIKit
+import SwiftUI
+
+// A retained node in the SwiftUI backend tree, mirroring the Compose ComposeNode.
+// C# drives these through the @objc host functions; SwiftUI observes them so a property
+// or child change re-renders the narrowest scope. @Published vars are Swift-only (the C#
+// side mutates them via the host functions, never directly).
+// A styled slice of a FormattedText (the Comet TextRun): text + optional colour, monospace face,
+// background highlight, and underline — assembled into an AttributedString for rendering.
+struct CometTextRun {
+ let text: String
+ let color: Color?
+ let mono: Bool
+ let background: Color?
+ let underline: Bool
+}
+
+@objc(CometNode) public class CometNode: NSObject, ObservableObject, Identifiable {
+ @objc public let kind: String
+ @Published var runs: [CometTextRun] = [] // FormattedText styled runs (empty => plain text)
+ @Published var text: String = ""
+ @Published var placeholder: String = ""
+ @Published var imageUrl: String = ""
+ @Published var iconName: String = "" // cross-platform symbol name → SF Symbol
+ @Published var iconGlyph: String = "" // icon-font codepoint (when an icon font is used)
+ @Published var iconFontFamily: String = "" // icon-font family name
+ @Published var isOn: Bool = false
+ @Published var doubleValue: Double = 0
+ @Published var children: [CometNode] = []
+ @Published var backgroundARGB: UInt32 = 0 // 0 = none
+ @Published var gradientStops: [UInt32] = [] // linear-gradient fill (wins over background)
+ @Published var gradientDirection = 0 // 0 horizontal, 1 vertical, 2 diagonal (TL→BR)
+ @Published var opacity: CGFloat = 1 // Comet Opacity → SwiftUI .opacity(); 1 = opaque
+ @Published var isVisible: Bool = true // Comet IsVisible; false => hidden + non-interactive
+ @Published var textColorARGB: UInt32 = 0 // 0 = inherit (default foreground)
+ @Published var fontSize: CGFloat = 0 // 0 = default (body)
+ @Published var maxLines: Int = 0 // 0 = unlimited; else lineLimit + tail truncation
+ @Published var fontWeight: Int = 0 // 0 = default; otherwise Maui FontWeight (100–900)
+ @Published var fontFamily: String = "" // custom font family (e.g. "Montserrat"); "" = system
+ @Published var padding: CGFloat = 0
+ // Per-edge content padding (Yoga sizes the frame to include it; leaves inset their content by it).
+ @Published var padTop: CGFloat = 0
+ @Published var padLeading: CGFloat = 0
+ @Published var padBottom: CGFloat = 0
+ @Published var padTrailing: CGFloat = 0
+ // Per-corner radii (top-left, top-right, bottom-right, bottom-left); clips content.
+ @Published var cornerTL: CGFloat = 0
+ @Published var cornerTR: CGFloat = 0
+ @Published var cornerBR: CGFloat = 0
+ @Published var cornerBL: CGFloat = 0
+ @Published var elevation: CGFloat = 0 // soft drop shadow depth
+ @Published var borderWidth: CGFloat = 0 // stroke width (e.g. avatar ring)
+ @Published var borderColorARGB: UInt32 = 0 // stroke color
+ @Published var borderGradientStops: [UInt32] = [] // gradient stroke (wins over color)
+ @Published var borderGradientDirection = 2 // 0 horizontal, 1 vertical, 2 diagonal (TL→BR), 3 radial
+ @Published var hasTapGesture: Bool = false // view carries a Comet TapGesture
+ @Published var hasLongPressGesture: Bool = false
+ @Published var borderless: Bool = false // TextField: no rounded-border box (foundation field)
+ @Published var outlined: Bool = false // Button: OutlinedButton (bordered, no fill) vs filled
+ @Published var drawerOpen: Bool = false // Drawer: side panel shown
+ @Published var fabExtended: Bool = true // Fab: true = show label (pill); false = icon-only
+ @Published var scrollToken: Int = 0 // List: bumped to animate the log to the newest row
+ // AlertDialog (presented as a native SwiftUI .alert).
+ @Published var dialogOpen: Bool = false
+ @Published var dialogMessage: String = ""
+ @Published var dialogButton: String = "OK"
+ // Yoga-computed parent-relative layout frame. hasFrame flips true once C# arranges this
+ // node; until then the view uses native SwiftUI layout (so rendering is unchanged).
+ @Published var frame: CGRect = .zero
+ @Published var hasFrame: Bool = false
+ @Published var contentTopInset: CGFloat = 0 // baseline-height inset (gold baselineHeight): pad content down
+ // Callbacks into C# (set via host fns). ObjC blocks so .NET binds them as Actions.
+ var onTap: (() -> Void)? // Button action (-> Clicked)
+ var onTapGesture: (() -> Void)? // arbitrary-view tap gesture (-> OnGesture(Tap))
+ var onLongPressGesture: (() -> Void)? // long-press gesture (-> OnGesture(LongPress); Reply selection)
+ var onRowVisibility: ((Double, Double) -> Void)? // list rows: (index, 1=appeared/0=disappeared) -> scroll direction
+ var onChangeString: ((String) -> Void)?
+ var onChangeBool: ((Bool) -> Void)?
+ var onChangeDouble: ((Double) -> Void)?
+ var onDialogDismiss: (() -> Void)? // native .alert dismissed (-> DialogDismissed)
+ var onFocused: (() -> Void)? // TextField gained focus (-> Focused; gold onTextFieldFocused)
+ @Published var horizontal = false // "list": row axis (LazyHStack in a horizontal ScrollView)
+ @Published var iconFillFrame = false // "icon": non-square asset draws at the node frame
+ @Published var fontItalic = false
+ var onScroll: ((Double) -> Void)? // ScrollView scrolled (-> ScrollView.AtTop / ScrollOffset)
+ var onScrollTop: ((Double) -> Void)? // list first row visibility (-> ListView.ScrolledFromTop)
+
+ public var id: ObjectIdentifier { ObjectIdentifier(self) }
+
+ @objc public init(kind: String) {
+ self.kind = kind
+ super.init()
+ }
+}
+
+// The @objc surface C# binds. All parameters/returns are ObjC-representable.
+@objc(CometSwiftUIHost) public class CometSwiftUIHost: NSObject {
+
+ @objc(makeNodeWithKind:)
+ public static func makeNode(_ kind: String) -> CometNode {
+ return CometNode(kind: kind)
+ }
+
+ @objc(setString:property:value:)
+ public static func setString(_ node: CometNode, property: String, value: String) {
+ switch property {
+ case "text": node.text = value
+ case "placeholder": node.placeholder = value
+ case "imageurl": node.imageUrl = value
+ case "icon": node.iconName = value
+ case "iconglyph": node.iconGlyph = value
+ case "iconfontfamily": node.iconFontFamily = value
+ case "fontfamily": node.fontFamily = value
+ case "dialogmessage": node.dialogMessage = value
+ case "dialogbutton": node.dialogButton = value
+ default: break
+ }
+ }
+
+ @objc(setBool:property:value:)
+ public static func setBool(_ node: CometNode, property: String, value: Bool) {
+ switch property {
+ case "ison": node.isOn = value
+ case "hastapgesture": node.hasTapGesture = value
+ case "haslongpressgesture": node.hasLongPressGesture = value
+ case "borderless": node.borderless = value
+ case "outlined": node.outlined = value
+ case "isvisible": node.isVisible = value
+ case "draweropen": node.drawerOpen = value
+ case "horizontal": node.horizontal = value
+ case "iconfillframe": node.iconFillFrame = value
+ case "fontitalic": node.fontItalic = value
+ case "fabextended": node.fabExtended = value
+ case "dialogopen": node.dialogOpen = value
+ default: break
+ }
+ }
+
+ @objc(setColor:property:argb:)
+ public static func setColor(_ node: CometNode, property: String, argb: UInt32) {
+ switch property {
+ case "background": node.backgroundARGB = argb
+ case "textcolor": node.textColorARGB = argb
+ case "bordercolor": node.borderColorARGB = argb
+ default: break
+ }
+ }
+
+ @objc(setDouble:property:value:)
+ public static func setDouble(_ node: CometNode, property: String, value: Double) {
+ switch property {
+ case "padding": node.padding = CGFloat(value)
+ case "pad.t": node.padTop = CGFloat(value)
+ case "pad.l": node.padLeading = CGFloat(value)
+ case "pad.b": node.padBottom = CGFloat(value)
+ case "pad.r": node.padTrailing = CGFloat(value)
+ case "value": node.doubleValue = value
+ case "corner.tl": node.cornerTL = CGFloat(value)
+ case "corner.tr": node.cornerTR = CGFloat(value)
+ case "corner.br": node.cornerBR = CGFloat(value)
+ case "corner.bl": node.cornerBL = CGFloat(value)
+ case "elevation": node.elevation = CGFloat(value)
+ case "opacity": node.opacity = CGFloat(value)
+ case "contenttopinset": node.contentTopInset = CGFloat(value)
+ case "fontsize": node.fontSize = CGFloat(value)
+ case "maxlines": node.maxLines = Int(value)
+ case "fontweight": node.fontWeight = Int(value)
+ case "borderwidth": node.borderWidth = CGFloat(value)
+ case "gradientdirection": node.gradientDirection = Int(value)
+ case "bordergradientdirection": node.borderGradientDirection = Int(value)
+ default: break
+ }
+ }
+
+ // Animate a "list" node to its newest row (the ScrollViewReader observes scrollToken).
+ @objc(clearGradientStops:)
+ public static func clearGradientStops(_ node: CometNode) {
+ node.gradientStops = []
+ }
+
+ @objc(clearBorderGradientStops:)
+ public static func clearBorderGradientStops(_ node: CometNode) {
+ node.borderGradientStops = []
+ }
+
+ @objc(addBorderGradientStop:argb:)
+ public static func addBorderGradientStop(_ node: CometNode, argb: UInt32) {
+ node.borderGradientStops.append(argb)
+ }
+
+ @objc(addGradientStop:argb:)
+ public static func addGradientStop(_ node: CometNode, argb: UInt32) {
+ node.gradientStops.append(argb)
+ }
+
+ @objc(scrollNodeToBottom:)
+ public static func scrollToBottom(_ node: CometNode) {
+ node.scrollToken &+= 1
+ }
+
+ // FormattedText styled runs (rebuilt on each Text_Runs change).
+ @objc(clearTextRuns:)
+ public static func clearTextRuns(_ node: CometNode) {
+ node.runs.removeAll()
+ }
+
+ @objc(addTextRun:text:colorArgb:hasColor:mono:bgArgb:hasBg:underline:)
+ public static func addTextRun(_ node: CometNode, text: String, colorArgb: UInt32, hasColor: Bool,
+ mono: Bool, bgArgb: UInt32, hasBg: Bool, underline: Bool) {
+ node.runs.append(CometTextRun(
+ text: text,
+ color: hasColor ? colorFromARGB(colorArgb) : nil,
+ mono: mono,
+ background: hasBg ? colorFromARGB(bgArgb) : nil,
+ underline: underline))
+ }
+
+ @objc(setTapHandler:handler:)
+ public static func setTapHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) {
+ node.onTap = handler
+ }
+
+ @objc(setTapGestureHandler:handler:)
+ public static func setTapGestureHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) {
+ node.onTapGesture = handler
+ }
+
+ @objc(setStringChangeHandler:handler:)
+ public static func setStringChangeHandler(_ node: CometNode, handler: @escaping @convention(block) (String) -> Void) {
+ node.onChangeString = handler
+ }
+
+ @objc(setBoolChangeHandler:handler:)
+ public static func setBoolChangeHandler(_ node: CometNode, handler: @escaping @convention(block) (Bool) -> Void) {
+ node.onChangeBool = handler
+ }
+
+ @objc(setDoubleChangeHandler:handler:)
+ public static func setDoubleChangeHandler(_ node: CometNode, handler: @escaping @convention(block) (Double) -> Void) {
+ node.onChangeDouble = handler
+ }
+
+ @objc(setDialogDismissHandler:handler:)
+ public static func setDialogDismissHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) {
+ node.onDialogDismiss = handler
+ }
+
+ @objc(setFocusHandler:handler:)
+ public static func setFocusHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) {
+ node.onFocused = handler
+ }
+
+ @objc(setScrollHandler:handler:)
+ public static func setScrollHandler(_ node: CometNode, handler: @escaping @convention(block) (Double) -> Void) {
+ node.onScroll = handler
+ }
+
+ // Top-relative twin (drives ListView.ScrolledFromTop — Reply's ExtendedFAB collapse).
+ @objc(setScrollTopHandler:handler:)
+ public static func setScrollTopHandler(_ node: CometNode, handler: @escaping @convention(block) (Double) -> Void) {
+ node.onScrollTop = handler
+ }
+
+ @objc(setLongPressGestureHandler:handler:)
+ public static func setLongPressGestureHandler(_ node: CometNode, handler: @escaping @convention(block) () -> Void) {
+ node.onLongPressGesture = handler
+ }
+
+ @objc(setRowVisibilityHandler:handler:)
+ public static func setRowVisibilityHandler(_ node: CometNode, handler: @escaping @convention(block) (Double, Double) -> Void) {
+ node.onRowVisibility = handler
+ }
+
+ @objc(insertChild:atIndex:child:)
+ public static func insertChild(_ node: CometNode, atIndex index: Int, child: CometNode) {
+ let i = max(0, min(index, node.children.count))
+ node.children.insert(child, at: i)
+ }
+
+ @objc(removeChild:atIndex:)
+ public static func removeChild(_ node: CometNode, atIndex index: Int) {
+ guard index >= 0 && index < node.children.count else { return }
+ node.children.remove(at: index)
+ }
+
+ @objc(clearChildren:)
+ public static func clearChildren(_ node: CometNode) {
+ node.children.removeAll()
+ }
+
+ @objc(setFrame:x:y:width:height:)
+ public static func setFrame(_ node: CometNode, x: Double, y: Double, width: Double, height: Double) {
+ node.frame = CGRect(x: x, y: y, width: width, height: height)
+ node.hasFrame = true
+ }
+
+ // Measures a leaf node's intrinsic size — the one place layout crosses into native.
+ // The content is constrained to the available width so multi-line text wraps (and reports
+ // its wrapped height); UIHostingController.sizeThatFits alone returns the single-line ideal.
+ @objc(measureNode:maxWidth:maxHeight:)
+ public static func measureNode(_ node: CometNode, maxWidth: Double, maxHeight: Double) -> CGSize {
+ let w = (maxWidth.isFinite && maxWidth > 0) ? maxWidth : UIScreen.main.bounds.width
+
+ // Text: measure with TextKit, which reliably wraps to the width (UIHostingController's
+ // sizeThatFits/systemLayoutSizeFitting return the single-line ideal for SwiftUI Text).
+ if node.kind == "text" {
+ let size = node.fontSize > 0 ? node.fontSize : UIFont.preferredFont(forTextStyle: .body).pointSize
+ var font: UIFont = node.fontSize > 0
+ ? UIFont.systemFont(ofSize: node.fontSize, weight: uiFontWeight(node.fontWeight))
+ : UIFont.preferredFont(forTextStyle: .body)
+ if !node.fontFamily.isEmpty, let custom = customUIFont(node.fontFamily, size, node.fontWeight) {
+ font = custom
+ }
+ let rect = (node.text as NSString).boundingRect(
+ with: CGSize(width: w, height: .greatestFiniteMagnitude),
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
+ attributes: [.font: font],
+ context: nil)
+ // Report the ACTUAL used width (≤ constraint) so a short label hugs and the flex row
+ // packs it tight; the wrapped height drives multi-line bubbles. A maxLines clamp
+ // caps the box; the rendered Text ellipsizes via lineLimit.
+ var height = ceil(rect.height)
+ if node.maxLines > 0 {
+ height = min(height, ceil(font.lineHeight * CGFloat(node.maxLines)))
+ }
+ return CGSize(width: min(ceil(rect.width), w), height: height)
+ }
+
+ // Icon: a square box at the symbol's point size.
+ if node.kind == "icon" {
+ let s = node.fontSize > 0 ? node.fontSize : 24
+ return CGSize(width: s, height: s)
+ }
+
+ // Interactive controls (button/textfield/toggle/slider) don't wrap; SwiftUI sizes them.
+ let host = UIHostingController(rootView: CometLeafContent(node: node))
+ host.view.backgroundColor = .clear
+ return host.sizeThatFits(in: CGSize(width: w, height: .greatestFiniteMagnitude))
+ }
+
+ // Returns a UIViewController hosting the SwiftUI tree rooted at `root`.
+ @objc(hostControllerForRoot:)
+ public static func hostController(_ root: CometNode) -> UIViewController {
+ return UIHostingController(rootView: CometNodeView(node: root))
+ }
+
+ // In-app screenshot (DevFlow/ailoha style): renders the key window to a PNG, so external
+ // tooling can fetch the rendered UI over the agent connection (works on a physical device
+ // via USB port-forward, no Developer Disk Image needed). Must be called on the main thread.
+ @objc(screenshotPNG)
+ public static func screenshotPNG() -> Data? {
+ guard let window = activeKeyWindow() else { return nil }
+ let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
+ let image = renderer.image { _ in
+ window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
+ }
+ return image.pngData()
+ }
+
+ static func activeKeyWindow() -> UIWindow? {
+ for scene in UIApplication.shared.connectedScenes {
+ guard let ws = scene as? UIWindowScene else { continue }
+ if let kw = ws.windows.first(where: { $0.isKeyWindow }) ?? ws.windows.first {
+ return kw
+ }
+ }
+ return nil
+ }
+}
+
+// The leaf control for a node (no children, no layout). Used for rendering leaves and, via
+// UIHostingController.sizeThatFits, for the Yoga engine's intrinsic-size measurement.
+struct CometLeafContent: View {
+ @ObservedObject var node: CometNode
+ @FocusState private var fieldFocused: Bool // textfield focus → node.onFocused (gold onTextFieldFocused)
+ @ViewBuilder
+ var body: some View {
+ switch node.kind {
+ case "button":
+ // .plain so SwiftUI doesn't tint the label with the system accent (the Comet .Color() is
+ // authoritative). The pill FILL + corner clip come from the wrapper (background + surface
+ // modifiers, the filled Material Button); an OutlinedButton draws its own border instead.
+ Button(action: { node.onTap?() }) {
+ Text(node.text)
+ .modifier(ForegroundModifier(argb: node.textColorARGB))
+ .modifier(FontModifier(node: node))
+ .padding(.horizontal, 22)
+ .padding(.vertical, 8)
+ .overlay {
+ if node.outlined {
+ Capsule().stroke(colorFromARGB(node.textColorARGB), lineWidth: 1)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ case "textfield":
+ // Borderless = the gold's foundation BasicTextField (no rounded box) — the footer input.
+ if node.borderless {
+ TextField(node.placeholder, text: Binding(
+ get: { node.text }, set: { node.text = $0; node.onChangeString?($0) }))
+ .textFieldStyle(.plain)
+ .focused($fieldFocused)
+ .onChange(of: fieldFocused) { now in if now { node.onFocused?() } }
+ .modifier(ForegroundModifier(argb: node.textColorARGB))
+ .modifier(FontModifier(node: node))
+ } else {
+ TextField(node.placeholder, text: Binding(
+ get: { node.text }, set: { node.text = $0; node.onChangeString?($0) }))
+ .textFieldStyle(.roundedBorder)
+ .focused($fieldFocused)
+ .onChange(of: fieldFocused) { now in if now { node.onFocused?() } }
+ }
+ case "toggle":
+ Toggle("", isOn: Binding(
+ get: { node.isOn },
+ set: { node.isOn = $0; node.onChangeBool?($0) }))
+ .labelsHidden()
+ case "slider":
+ Slider(value: Binding(
+ get: { node.doubleValue },
+ set: { node.doubleValue = $0; node.onChangeDouble?($0) }))
+ case "icon":
+ if !node.iconGlyph.isEmpty {
+ // Icon-font glyph (e.g. Material Icons) — the SAME glyph the Android backend draws,
+ // rendered as a sized + tinted character in the registered icon font.
+ Text(node.iconGlyph)
+ .font(.custom(node.iconFontFamily, size: node.fontSize > 0 ? node.fontSize : 24))
+ .modifier(ForegroundModifier(argb: node.textColorARGB))
+ } else if let logo = bundledImage(node.iconName) {
+ // A bundled brand logo (e.g. the jetchat mark) — colourful when untinted (header),
+ // a tinted template when a colour is set (the mono chat-row logo). Mirrors the Android
+ // multicolor-asset path. Pinned to the icon size so it measures + can't overflow the
+ // text — unless iconFillFrame asks for the node's (non-square) frame (the JetNews
+ // 80×24 wordmark, the Android IconFillFrame twin).
+ let logoW = node.iconFillFrame && node.frame.width > 0 ? node.frame.width : (node.fontSize > 0 ? node.fontSize : 24)
+ let logoH = node.iconFillFrame && node.frame.height > 0 ? node.frame.height : (node.fontSize > 0 ? node.fontSize : 24)
+ if node.textColorARGB != 0 {
+ Image(uiImage: logo).renderingMode(.template).resizable().aspectRatio(contentMode: .fit)
+ .frame(width: logoW, height: logoH)
+ .modifier(ForegroundModifier(argb: node.textColorARGB))
+ } else {
+ Image(uiImage: logo).resizable().aspectRatio(contentMode: .fit)
+ .frame(width: logoW, height: logoH)
+ }
+ } else {
+ // Real SF Symbol, tinted + sized — the iOS native icon idiom.
+ Image(systemName: sfSymbol(node.iconName))
+ .font(.system(size: node.fontSize > 0 ? node.fontSize : 24))
+ .modifier(ForegroundModifier(argb: node.textColorARGB))
+ }
+ case "image":
+ // A bundled image (a bare name like "ali") renders from the app bundle — the iOS
+ // counterpart of Compose's painterResource; an http(s) source loads asynchronously.
+ if node.imageUrl.lowercased().hasPrefix("http") {
+ AsyncImage(url: URL(string: node.imageUrl)) { phase in
+ if let image = phase.image {
+ image.resizable().aspectRatio(contentMode: .fill)
+ } else {
+ Color.gray.opacity(0.25)
+ }
+ }
+ .clipped()
+ } else if let ui = bundledImage(node.imageUrl) {
+ Image(uiImage: ui).resizable().aspectRatio(contentMode: .fill).clipped()
+ } else {
+ Color.gray.opacity(0.25)
+ }
+ default: // "text" and unknown leaves
+ if !node.runs.isEmpty {
+ // FormattedText: styled runs (the gold's AnnotatedString) → concatenated Text, so
+ // per-run colour / code / link styling renders (e.g. white text on the primary bubble).
+ runsText(node)
+ .modifier(FontModifier(node: node))
+ } else {
+ Text(node.text)
+ .modifier(ForegroundModifier(argb: node.textColorARGB))
+ .modifier(FontModifier(node: node))
+ .lineLimit(node.maxLines > 0 ? node.maxLines : nil)
+ }
+ }
+ }
+}
+
+// A bundled image by bare name: the asset catalog / .png via UIImage(named:), else a loose
+// resource file (e.g. someone_else.jpg) found by trying common extensions in the main bundle.
+private func bundledImage(_ name: String) -> UIImage? {
+ if let img = UIImage(named: name) { return img }
+ for ext in ["png", "jpg", "jpeg"] {
+ if let path = Bundle.main.path(forResource: name, ofType: ext),
+ let img = UIImage(contentsOfFile: path) {
+ return img
+ }
+ }
+ return nil
+}
+
+// Assemble a FormattedText's styled runs by concatenating SwiftUI Text segments (this measures
+// reliably via sizeThatFits, unlike Text(AttributedString)). Per-run colour wins; monospace runs
+// use a system monospaced face at the base size; underline = links. (Per-run background — code-span
+// highlight — isn't expressible via Text concatenation, so it's dropped; the mono face still reads.)
+private func runsText(_ node: CometNode) -> Text {
+ let baseSize = node.fontSize > 0 ? node.fontSize : 16
+ var combined = Text(verbatim: "")
+ for run in node.runs {
+ var seg = Text(verbatim: run.text)
+ if let c = run.color { seg = seg.foregroundColor(c) }
+ if run.mono { seg = seg.font(.system(size: baseSize, design: .monospaced)) }
+ if run.underline { seg = seg.underline() }
+ combined = combined + seg
+ }
+ return combined
+}
+
+// Cross-platform symbol name → SF Symbol (the iOS counterpart of the Compose Icons mapping).
+private func sfSymbol(_ name: String) -> String {
+ // Cross-platform GENERIC names (the shared sample uses Android/Material-ish names) → the closest
+ // SF Symbol. Anything not listed falls through and, if it's itself a real SF Symbol name, is used
+ // verbatim — so the WHOLE SF Symbol library is available (e.g. Icon("mic.fill"), Icon("paperplane"))
+ // without adding a case here.
+ switch name {
+ case "search": return "magnifyingglass"
+ case "info": return "info.circle"
+ case "menu": return "line.3.horizontal"
+ case "send": return "paperplane.fill"
+ case "place", "location": return "mappin.circle.fill"
+ case "mic", "microphone": return "mic"
+ case "person": return "person.crop.circle"
+ case "people": return "person.2.fill"
+ case "jetchat": return "bubble.left.and.bubble.right.fill" // logo stand-in (no bundled vector on iOS yet)
+ case "account": return "person.crop.circle.fill"
+ case "call", "phone": return "phone.fill"
+ case "email", "mail": return "envelope"
+ case "close": return "xmark"
+ case "settings": return "gearshape"
+ case "share": return "square.and.arrow.up"
+ case "back": return "chevron.left"
+ case "arrow_down", "arrow_downward", "expand_more": return "chevron.down"
+ case "add": return "plus"
+ case "edit": return "pencil"
+ case "mood", "emoji": return "face.smiling"
+ case "at": return "at"
+ case "photo", "image": return "photo"
+ case "video", "duo": return "video"
+ default:
+ // Pass the name through if it's a valid SF Symbol; otherwise a clear "unknown" glyph.
+ return UIImage(systemName: name) != nil ? name : "questionmark.square.dashed"
+ }
+}
+
+// SwiftUI Font.Weight for a Maui FontWeight numeric (100–900).
+private func swiftFontWeight(_ w: Int) -> Font.Weight {
+ switch w {
+ case 1..<200: return .thin
+ case 200..<300: return .ultraLight
+ case 300..<400: return .light
+ case 400..<500: return .regular
+ case 500..<600: return .medium
+ case 600..<700: return .semibold
+ case 700..<800: return .bold
+ case 800..<900: return .heavy
+ case 900...: return .black
+ default: return .regular
+ }
+}
+
+private func uiFontWeight(_ w: Int) -> UIFont.Weight {
+ switch w {
+ case 1..<200: return .thin
+ case 200..<300: return .ultraLight
+ case 300..<400: return .light
+ case 400..<500: return .regular
+ case 500..<600: return .medium
+ case 600..<700: return .semibold
+ case 700..<800: return .bold
+ case 800..<900: return .heavy
+ case 900...: return .black
+ default: return .regular
+ }
+}
+
+// A registered custom font (e.g. Montserrat/Karla, bundled + listed in UIAppFonts) at the given
+// weight, or nil if not available. Derives the weight on a variable font via the descriptor.
+private func customUIFont(_ family: String, _ size: CGFloat, _ weight: Int, italic: Bool = false) -> UIFont? {
+ // Prefer the real per-weight face by PostScript name (e.g. "Montserrat-Medium", "Karla-Bold") so
+ // the actual weight renders rather than a synthesized one.
+ var font: UIFont?
+ if let f = UIFont(name: weightedFontName(family, weight), size: size) {
+ font = f
+ } else if let base = UIFont(name: family, size: size) {
+ let traits: [UIFontDescriptor.TraitKey: Any] = [.weight: uiFontWeight(weight)]
+ font = UIFont(descriptor: base.fontDescriptor.addingAttributes([.traits: traits]), size: size)
+ }
+ guard var resolved = font else { return nil }
+ if italic {
+ // SwiftUI's .italic() can't slant a custom UIFont-backed Font, so bake the
+ // slant here: a real italic face via the symbolic trait when the family has
+ // one, else a synthesized oblique (skew matrix) — same idea as Android.
+ if let d = resolved.fontDescriptor.withSymbolicTraits(
+ resolved.fontDescriptor.symbolicTraits.union(.traitItalic)),
+ d.symbolicTraits.contains(.traitItalic) {
+ resolved = UIFont(descriptor: d, size: size)
+ } else {
+ let skew = CGAffineTransform(a: 1, b: 0, c: 0.25, d: 1, tx: 0, ty: 0)
+ resolved = UIFont(descriptor: resolved.fontDescriptor.withMatrix(skew), size: size)
+ }
+ }
+ return resolved
+}
+
+// "-" PostScript-name convention for the bundled per-weight faces.
+private func weightedFontName(_ family: String, _ weight: Int) -> String {
+ let suffix: String
+ switch weight {
+ case 700...: suffix = "Bold"
+ case 600..<700: suffix = "SemiBold"
+ case 500..<600: suffix = "Medium"
+ default: suffix = "Regular"
+ }
+ return "\(family)-\(suffix)"
+}
+
+// Applies an explicit font (custom family if set, else system) at the Comet size/weight.
+private struct FontModifier: ViewModifier {
+ @ObservedObject var node: CometNode
+ func body(content: Content) -> some View {
+ let size = node.fontSize > 0 ? node.fontSize : UIFont.preferredFont(forTextStyle: .body).pointSize
+ let italicize: (AnyView) -> AnyView = node.fontItalic
+ ? { v in AnyView(v.italic()) }
+ : { v in v }
+ if !node.fontFamily.isEmpty,
+ let f = customUIFont(node.fontFamily, size, node.fontWeight, italic: node.fontItalic) {
+ // Slant is baked into the UIFont — .italic() is a no-op on custom fonts.
+ return AnyView(content.font(Font(f)))
+ }
+ if node.fontSize > 0 {
+ return italicize(AnyView(content.font(.system(size: node.fontSize, weight: swiftFontWeight(node.fontWeight)))))
+ }
+ if node.fontWeight > 0 {
+ return italicize(AnyView(content.fontWeight(swiftFontWeight(node.fontWeight))))
+ }
+ return italicize(AnyView(content))
+ }
+}
+
+// Applies an explicit text/foreground color when Comet set one (argb != 0); otherwise leaves
+// the default so it adapts to light/dark like native SwiftUI text.
+private struct ForegroundModifier: ViewModifier {
+ let argb: UInt32
+ func body(content: Content) -> some View {
+ if argb == 0 {
+ content
+ } else {
+ content.foregroundColor(Color(
+ red: Double((argb >> 16) & 0xFF) / 255.0,
+ green: Double((argb >> 8) & 0xFF) / 255.0,
+ blue: Double(argb & 0xFF) / 255.0,
+ opacity: Double((argb >> 24) & 0xFF) / 255.0))
+ }
+ }
+}
+
+private func isYogaContainer(_ kind: String) -> Bool {
+ return kind == "vstack" || kind == "hstack" || kind == "zstack"
+}
+
+// Recursively renders a CometNode tree as SwiftUI. Once C#'s Yoga engine has arranged a flow
+// container (hasFrame), its children are positioned absolutely from the computed frames;
+// otherwise native SwiftUI layout is used (rendering unchanged until the engine drives it).
+struct CometNodeView: View {
+ @ObservedObject var node: CometNode
+
+ var body: some View {
+ // Each node sizes + positions ITSELF from its own (observed) frame, so a re-arrange
+ // (reflow) re-renders just that node and the layout adapts live — the parent can't
+ // observe the children's frames in its own body. Order matters: size → background
+ // (so it fills the arranged frame) → offset (move the whole node into place).
+ content
+ .modifier(SizeModifier(node: node))
+ .modifier(BackgroundModifier(argb: node.backgroundARGB, stops: node.gradientStops,
+ direction: node.gradientDirection))
+ .modifier(SurfaceModifier(node: node)) // rounded corners + elevation (Material card)
+ .modifier(TapGestureModifier(node: node))
+ .modifier(OffsetModifier(node: node))
+ .modifier(OpacityModifier(node: node)) // Comet Opacity / IsVisible (applied last so a
+ // hidden node is also non-interactive)
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ if node.hasFrame && isYogaContainer(node.kind) {
+ // Children self-position via their own modifiers; just overlay them.
+ ZStack(alignment: .topLeading) {
+ ForEach(node.children) { CometNodeView(node: $0) }
+ }
+ } else {
+ nativeContent
+ }
+ }
+
+ @ViewBuilder
+ private var nativeContent: some View {
+ switch node.kind {
+ case "navigation":
+ // Pin the screen to the TOP of the nav area: when the keyboard shrinks the screen's
+ // laid-out height below the nav's full height, the shorter screen must stay top-aligned
+ // (so its footer sits just above the keyboard) instead of centering in the leftover space.
+ ZStack(alignment: .topLeading) {
+ if let top = node.children.last { CometNodeView(node: top) } else { EmptyView() }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ case "drawer":
+ // children[0] = content (full screen); children[1] = side panel. Scrim + slide-in.
+ ZStack(alignment: .topLeading) {
+ if node.children.count > 0 { CometNodeView(node: node.children[0]) }
+ if node.drawerOpen {
+ Color.black.opacity(0.32).ignoresSafeArea()
+ .onTapGesture { node.onTap?() }
+ if node.children.count > 1 {
+ CometNodeView(node: node.children[1])
+ .transition(.move(edge: .leading))
+ }
+ }
+ }
+ .animation(.easeInOut(duration: 0.25), value: node.drawerOpen)
+ case "list" where node.horizontal:
+ // Horizontal list (JetNews' popular carousel): the LazyHStack twin of the
+ // Compose LazyRow branch. Rows are Yoga-laid at their intrinsic width by C#;
+ // each renders as its own lazy item.
+ ScrollView(.horizontal, showsIndicators: false) {
+ LazyHStack(spacing: 0) {
+ ForEach(node.children) { child in
+ CometNodeView(node: child)
+ .frame(width: child.frame.width, height: child.frame.height)
+ .id(child.id)
+ }
+ }
+ }
+ case "list":
+ // Full-bleed, separator-free rows so a Yoga-laid-out row (which already carries its
+ // own padding) spans edge-to-edge exactly like the Compose LazyColumn. Wrapped in a
+ // ScrollViewReader so C# can animate the log to the newest message (JumpToBottom /
+ // after-send) by bumping node.scrollToken — the native counterpart of the Compose
+ // LazyListState scroller (Comet's IListView.ScrollToBottom -> scrollNodeToBottom).
+ ScrollViewReader { proxy in
+ List {
+ ForEach(node.children) { child in
+ CometNodeView(node: child)
+ .listRowInsets(EdgeInsets())
+ .listRowSeparator(.hidden)
+ .id(child.id)
+ // Drive ScrolledAway (the JumpToBottom FAB): the newest message is the last
+ // child — when it's on screen we're at the bottom (0), when it scrolls off
+ // we're scrolled away (1). The C# list node maps this onto IListView.ScrolledAway.
+ .onAppear {
+ if child.id == node.children.last?.id { node.onScroll?(0) }
+ if child.id == node.children.first?.id { node.onScrollTop?(0) }
+ if let idx = node.children.firstIndex(where: { $0.id == child.id }) {
+ node.onRowVisibility?(Double(idx), 1)
+ }
+ }
+ .onDisappear {
+ if child.id == node.children.last?.id { node.onScroll?(1) }
+ if child.id == node.children.first?.id { node.onScrollTop?(1) }
+ if let idx = node.children.firstIndex(where: { $0.id == child.id }) {
+ node.onRowVisibility?(Double(idx), 0)
+ }
+ }
+ }
+ }
+ .listStyle(.plain)
+ .onChange(of: node.scrollToken) { _ in
+ if let last = node.children.last {
+ withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
+ }
+ }
+ }
+ case "scroll":
+ // The single content view is laid out (by Yoga) taller than the viewport and
+ // self-positions, so a plain vertical ScrollView hosting it scrolls as one piece.
+ // The scroll offset is reported back to C# (-> the ScrollView's AtTop / ScrollOffset,
+ // which drive the profile FAB's collapse on scroll).
+ if #available(iOS 18.0, *) {
+ // onScrollGeometryChange observes the live scroll geometry, so it fires for BOTH a
+ // finger scroll and a programmatic contentOffset change. A GeometryReader/preference
+ // (the fallback below) only re-fires on a SwiftUI layout pass, which a UIScrollView
+ // contentOffset change does NOT trigger — so it silently misses programmatic scrolls.
+ ScrollView(.vertical, showsIndicators: true) {
+ ForEach(node.children) { CometNodeView(node: $0) }
+ }
+ .onScrollGeometryChange(for: Double.self) { geo in
+ Double(geo.contentOffset.y + geo.contentInsets.top)
+ } action: { _, newValue in
+ node.onScroll?(newValue)
+ }
+ } else {
+ // Pre-iOS-18 fallback: GeometryReader probe + preference (onScrollGeometryChange is iOS 18+).
+ ScrollView(.vertical, showsIndicators: true) {
+ ForEach(node.children) { CometNodeView(node: $0) }
+ .background(GeometryReader { geo in
+ Color.clear.preference(key: CometScrollOffsetKey.self,
+ value: -geo.frame(in: .named("cometScroll")).minY)
+ })
+ }
+ .coordinateSpace(name: "cometScroll")
+ .onPreferenceChange(CometScrollOffsetKey.self) { value in node.onScroll?(Double(value)) }
+ }
+ case "alert":
+ // Native SwiftUI .alert (the iOS counterpart of Compose's AlertDialog): a zero-size
+ // host that presents modally when C# opens the dialog; the button / scrim dismiss
+ // routes back to C# (DialogDismissed).
+ Color.clear.frame(width: 0, height: 0)
+ .alert(node.dialogMessage, isPresented: Binding(
+ get: { node.dialogOpen },
+ set: { node.dialogOpen = $0; if !$0 { node.onDialogDismiss?() } })) {
+ Button(node.dialogButton, role: .cancel) {}
+ }
+ // Cross-axis alignment matches Yoga's flex-start default (and Compose's Row=Top / Column=Start),
+ // NOT SwiftUI's center default — so any subtree not yet Yoga-arranged still aligns like Android
+ // instead of centering. Spacing is 0 because Yoga owns gaps (the arranged path bakes them into
+ // the child offsets); the native default 8pt would double them where the fallback is hit.
+ case "fab":
+ // iOS has no Material FAB, so the native idiom is a real Button (so it raises the
+ // tap, gets the system press feedback, and reads as a button to accessibility) whose
+ // content is an icon + optional label row. The node's frame positions + sizes it
+ // (from Yoga); the CometNodeView modifiers add the capsule background.
+ // fabExtended drives label show/hide with a 200ms easeInOut — matching the gold's
+ // AnimatingFabContent transition duration (transitionDuration = 200).
+ Button(action: { node.onTap?() }) {
+ HStack(spacing: 8) {
+ if let icon = node.children.first { CometNodeView(node: icon) }
+ if node.fabExtended, node.children.count > 1 {
+ CometNodeView(node: node.children[1])
+ }
+ }
+ .animation(.easeInOut(duration: 0.2), value: node.fabExtended)
+ .fixedSize(horizontal: true, vertical: false) // the label is single-line — never wrap it
+ .frame(maxWidth: .infinity, maxHeight: .infinity) // center the row within the capsule frame
+ .padding(.horizontal, node.padding)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ case "hstack":
+ HStack(alignment: .top, spacing: 0) { ForEach(node.children) { CometNodeView(node: $0) } }.padding(node.padding)
+ case "zstack":
+ ZStack(alignment: .topLeading) { ForEach(node.children) { CometNodeView(node: $0) } }.padding(node.padding)
+ case "vstack":
+ VStack(alignment: .leading, spacing: 0) { ForEach(node.children) { CometNodeView(node: $0) } }.padding(node.padding)
+ case "textfield":
+ // Inset the field's content by its (Yoga-sized) padding so the placeholder/text sit padded
+ // in, matching the gold (e.g. the footer input's 20/14/20/10). Render-only — the measure
+ // path hosts CometLeafContent directly, so the padding isn't double-counted into the frame.
+ CometLeafContent(node: node)
+ .padding(EdgeInsets(top: node.padTop + node.contentTopInset, leading: node.padLeading,
+ bottom: node.padBottom, trailing: node.padTrailing))
+ default:
+ // Leaves honour their own (Yoga-sized) content padding — e.g. a section-header Text
+ // padded 28 in / 18 tall. Render-only (the measure path hosts CometLeafContent directly,
+ // so the padding isn't double-counted into the frame).
+ CometLeafContent(node: node)
+ .padding(EdgeInsets(top: node.padTop + node.contentTopInset, leading: node.padLeading,
+ bottom: node.padBottom, trailing: node.padTrailing))
+ }
+ }
+}
+
+// Sizes a node to its Yoga-computed frame (observing the node, so reflow re-applies it).
+// Icons CENTER in their frame (a 24dp star glyph in a 40dp circle — nothing else would
+// center it, and topLeading pinned it to the corner); everything else keeps topLeading,
+// matching Yoga's coordinate origin.
+private struct SizeModifier: ViewModifier {
+ @ObservedObject var node: CometNode
+ func body(content: Content) -> some View {
+ if node.hasFrame {
+ content.frame(width: node.frame.width, height: node.frame.height,
+ alignment: node.kind == "icon" ? .center : .topLeading)
+ } else {
+ content
+ }
+ }
+}
+
+// Positions a node at its Yoga-computed parent-relative offset (applied after background so
+// the background fills the frame and moves with the node).
+private struct OffsetModifier: ViewModifier {
+ @ObservedObject var node: CometNode
+ func body(content: Content) -> some View {
+ if node.hasFrame {
+ content.offset(x: node.frame.minX, y: node.frame.minY)
+ } else {
+ content
+ }
+ }
+}
+
+// Applies Comet's Opacity / IsVisible. A node that is hidden (IsVisible == false) or fully
+// transparent (Opacity ~ 0) is also made non-interactive via allowsHitTesting(false), so an
+// invisible overlay in a ZStack lets touches fall through to the views beneath it.
+private struct OpacityModifier: ViewModifier {
+ @ObservedObject var node: CometNode
+ func body(content: Content) -> some View {
+ let alpha = node.isVisible ? node.opacity : 0
+ return content
+ .opacity(alpha)
+ .allowsHitTesting(alpha > 0.01)
+ }
+}
+
+// Carries the scroll content's offset (negative minY in the scroll coordinate space) up to the
+// ScrollView via a SwiftUI preference, so C# can mirror it onto AtTop / ScrollOffset.
+private struct CometScrollOffsetKey: PreferenceKey {
+ static var defaultValue: CGFloat = 0
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
+}
+
+// Routes a SwiftUI tap on an arbitrary view to the node's gesture callback (the iOS
+// counterpart of Compose's Modifier.Clickable). contentShape makes the whole padded
+// frame — including transparent areas of a stack — hittable.
+private struct TapGestureModifier: ViewModifier {
+ @ObservedObject var node: CometNode
+ func body(content: Content) -> some View {
+ if node.hasTapGesture && node.hasLongPressGesture {
+ content
+ .contentShape(Rectangle())
+ .onTapGesture { node.onTapGesture?() }
+ .onLongPressGesture { node.onLongPressGesture?() }
+ } else if node.hasTapGesture {
+ content
+ .contentShape(Rectangle())
+ .onTapGesture { node.onTapGesture?() }
+ } else if node.hasLongPressGesture {
+ content
+ .contentShape(Rectangle())
+ .onLongPressGesture { node.onLongPressGesture?() }
+ } else {
+ content
+ }
+ }
+}
+
+// Rounds a node's corners (clipping its background + content) and casts a soft drop shadow —
+// the Compose `.Clip(RoundedCornerShape)` + `.Shadow(elevation)` analog, so a card looks the
+// same on both backends. Applied after the background so the fill is clipped to the rounded rect.
+private struct SurfaceModifier: ViewModifier {
+ @ObservedObject var node: CometNode
+ func body(content: Content) -> some View {
+ let hasCorners = node.cornerTL > 0 || node.cornerTR > 0 || node.cornerBR > 0 || node.cornerBL > 0
+ let elevation = node.elevation
+ let shape = UnevenRoundedRectangle(
+ topLeadingRadius: node.cornerTL,
+ bottomLeadingRadius: node.cornerBL,
+ bottomTrailingRadius: node.cornerBR,
+ topTrailingRadius: node.cornerTR,
+ style: .continuous)
+ if hasCorners || elevation > 0 || node.borderWidth > 0 || node.borderGradientStops.count > 1 {
+ content
+ .clipShape(shape)
+ .overlay(node.borderGradientStops.count > 1
+ // Gradient stroke along the spec's axis (Android twin honors it too;
+ // radial borders fall back to diagonal until a consumer needs them).
+ ? AnyView(shape.strokeBorder(LinearGradient(
+ colors: node.borderGradientStops.map(argbColor),
+ startPoint: node.borderGradientDirection == 0 ? .leading
+ : node.borderGradientDirection == 1 ? .top : .topLeading,
+ endPoint: node.borderGradientDirection == 0 ? .trailing
+ : node.borderGradientDirection == 1 ? .bottom : .bottomTrailing), lineWidth: 2))
+ : node.borderWidth > 0
+ ? AnyView(shape.strokeBorder(colorFromARGB(node.borderColorARGB), lineWidth: node.borderWidth))
+ : AnyView(EmptyView()))
+ .shadow(color: Color.black.opacity(elevation > 0 ? 0.18 : 0),
+ radius: elevation, x: 0, y: elevation > 0 ? elevation / 2 : 0)
+ } else {
+ content
+ }
+ }
+}
+
+private func colorFromARGB(_ argb: UInt32) -> Color {
+ Color(red: Double((argb >> 16) & 0xFF) / 255.0,
+ green: Double((argb >> 8) & 0xFF) / 255.0,
+ blue: Double(argb & 0xFF) / 255.0,
+ opacity: Double((argb >> 24) & 0xFF) / 255.0)
+}
+
+private func argbColor(_ argb: UInt32) -> Color {
+ Color(
+ red: Double((argb >> 16) & 0xFF) / 255.0,
+ green: Double((argb >> 8) & 0xFF) / 255.0,
+ blue: Double(argb & 0xFF) / 255.0,
+ opacity: Double((argb >> 24) & 0xFF) / 255.0)
+}
+
+private struct BackgroundModifier: ViewModifier {
+ let argb: UInt32
+ var stops: [UInt32] = []
+ var direction: Int = 0
+ func body(content: Content) -> some View {
+ if stops.count > 1, direction == 3 {
+ // Radial (Jetcaster's home scrim). SwiftUI needs explicit radii — size
+ // isn't known here, so a screen-scale end radius approximates Compose's
+ // bounds-fitted radial (documented deviation until a consumer needs more).
+ content.background(RadialGradient(
+ colors: stops.map(argbColor),
+ center: .center, startRadius: 0, endRadius: 500))
+ } else if stops.count > 1 {
+ // Linear gradient (Jetsnack fills, Jetcaster scrims) — the LinearGradient
+ // twin of Compose's horizontal/vertical/linearGradient; stops spaced evenly.
+ let (start, end): (UnitPoint, UnitPoint) = switch direction {
+ case 1: (.top, .bottom)
+ case 2: (.topLeading, .bottomTrailing)
+ default: (.leading, .trailing)
+ }
+ content.background(LinearGradient(
+ colors: stops.map(argbColor),
+ startPoint: start, endPoint: end))
+ } else if argb == 0 {
+ content
+ } else {
+ content.background(argbColor(argb))
+ }
+ }
+}
diff --git a/src/Comet/src/Comet.SwiftUI.Shim/build-xcframework.sh b/src/Comet/src/Comet.SwiftUI.Shim/build-xcframework.sh
new file mode 100755
index 000000000..45d0a53f7
--- /dev/null
+++ b/src/Comet/src/Comet.SwiftUI.Shim/build-xcframework.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Builds CometSwiftUIShim.xcframework (device + simulator) from the Swift package.
+# The shim exposes SwiftUI behind an @objc-representable surface so .NET for iOS can
+# bind it without Swift-ABI interop.
+set -euo pipefail
+cd "$(dirname "$0")"
+
+rm -rf build CometSwiftUIShim.xcframework
+
+xcodebuild archive -scheme CometSwiftUIShim -destination "generic/platform=iOS Simulator" \
+ -archivePath build/sim.xcarchive SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES >/dev/null
+xcodebuild archive -scheme CometSwiftUIShim -destination "generic/platform=iOS" \
+ -archivePath build/dev.xcarchive SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES >/dev/null
+
+xcodebuild -create-xcframework \
+ -framework build/sim.xcarchive/Products/usr/local/lib/CometSwiftUIShim.framework \
+ -framework build/dev.xcarchive/Products/usr/local/lib/CometSwiftUIShim.framework \
+ -output CometSwiftUIShim.xcframework >/dev/null
+
+echo "built CometSwiftUIShim.xcframework:"
+ls CometSwiftUIShim.xcframework/
diff --git a/src/Comet/src/Comet/AppHostBuilderExtensions.cs b/src/Comet/src/Comet/AppHostBuilderExtensions.cs
deleted file mode 100644
index fcbd0a3b3..000000000
--- a/src/Comet/src/Comet/AppHostBuilderExtensions.cs
+++ /dev/null
@@ -1,1321 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Comet.Handlers;
-using Comet.Styles;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.DependencyInjection.Extensions;
-using Microsoft.Maui;
-using Microsoft.Maui.Animations;
-using Microsoft.Maui.ApplicationModel;
-using Microsoft.Maui.Devices;
-using Microsoft.Maui.Graphics;
-using Microsoft.Maui.Handlers;
-using Microsoft.Maui.Hosting;
-using System.Runtime.CompilerServices;
-using Microsoft.Maui.Platform;
-
-#if WINDOWS
-using Microsoft.Maui.Graphics.Win2D;
-#endif
-
-#if __MACOS__
-using Microsoft.Maui.Platform.MacOS.Hosting;
-// Redirect MAUI built-in handler types to Platform.Maui.MacOS implementations.
-// The MAUI built-in handlers (Microsoft.Maui.Handlers.*) throw NotImplementedException
-// on macOS. Platform.Maui.MacOS provides AppKit-based implementations.
-using ActivityIndicatorHandler = Microsoft.Maui.Platform.MacOS.Handlers.ActivityIndicatorHandler;
-using ButtonHandler = Microsoft.Maui.Platform.MacOS.Handlers.ButtonHandler;
-using CheckBoxHandler = Microsoft.Maui.Platform.MacOS.Handlers.CheckBoxHandler;
-using ContentViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.ContentViewHandler;
-using DatePickerHandler = Microsoft.Maui.Platform.MacOS.Handlers.DatePickerHandler;
-using EditorHandler = Microsoft.Maui.Platform.MacOS.Handlers.EditorHandler;
-using EntryHandler = Microsoft.Maui.Platform.MacOS.Handlers.EntryHandler;
-using GraphicsViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.GraphicsViewHandler;
-using ImageButtonHandler = Microsoft.Maui.Platform.MacOS.Handlers.ImageButtonHandler;
-using ImageHandler = Microsoft.Maui.Platform.MacOS.Handlers.ImageHandler;
-using IndicatorViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.IndicatorViewHandler;
-using LabelHandler = Microsoft.Maui.Platform.MacOS.Handlers.LabelHandler;
-using LayoutHandler = Microsoft.Maui.Platform.MacOS.Handlers.LayoutHandler;
-using PickerHandler = Microsoft.Maui.Platform.MacOS.Handlers.PickerHandler;
-using ProgressBarHandler = Microsoft.Maui.Platform.MacOS.Handlers.ProgressBarHandler;
-using RefreshViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.RefreshViewHandler;
-using SearchBarHandler = Microsoft.Maui.Platform.MacOS.Handlers.SearchBarHandler;
-using SliderHandler = Microsoft.Maui.Platform.MacOS.Handlers.SliderHandler;
-using StepperHandler = Microsoft.Maui.Platform.MacOS.Handlers.StepperHandler;
-using SwipeViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.SwipeViewHandler;
-using SwitchHandler = Microsoft.Maui.Platform.MacOS.Handlers.SwitchHandler;
-using TimePickerHandler = Microsoft.Maui.Platform.MacOS.Handlers.TimePickerHandler;
-using WebViewHandler = Microsoft.Maui.Platform.MacOS.Handlers.WebViewHandler;
-#endif
-
-namespace Comet
-{
- public static class AppHostBuilderExtensions
- {
- // Weak tables to track event subscriptions per platform view, preventing
- // duplicate handlers when mappers re-fire during SetVirtualView/Reload.
-#if __IOS__ || MACCATALYST
- static readonly ConditionalWeakTable _pickerEditingDidEndHandlers = new();
- static readonly ConditionalWeakTable _entryEditingChangedHandlers = new();
- static readonly ConditionalWeakTable _editorChangedHandlers = new();
- static readonly ConditionalWeakTable> _searchBarTextChangedHandlers = new();
- static readonly ConditionalWeakTable _sliderValueChangedHandlers = new();
- static readonly ConditionalWeakTable _switchValueChangedHandlers = new();
- static readonly ConditionalWeakTable _checkBoxCheckedHandlers = new();
-#elif ANDROID
- static readonly ConditionalWeakTable _pickerTextChangedHandlers = new();
- static readonly ConditionalWeakTable _entryTextChangedHandlers = new();
- static readonly ConditionalWeakTable _editorTextChangedHandlers = new();
- static readonly ConditionalWeakTable _searchBarTextChangedHandlers = new();
- static readonly ConditionalWeakTable _sliderValueChangedHandlers = new();
- static readonly ConditionalWeakTable _switchValueChangedHandlers = new();
- static readonly ConditionalWeakTable _checkBoxCheckedHandlers = new();
-#endif
- static void AddHandlers(this IMauiHandlersCollection collection, Dictionary handlers) => handlers.ForEach(x => collection.AddHandler(x.Key, x.Value));
- public static MauiAppBuilder UseCometApp(this MauiAppBuilder builder)
- where TApp : class, IApplication
- {
- builder.Services.TryAddSingleton();
- builder.UseCometHandlers();
- return builder;
- }
-#if __MACOS__
- ///
- /// Registers a Comet app for macOS AppKit. Combines Platform.Maui.MacOS setup
- /// (UseMauiAppMacOS) with Comet handler overrides (UseCometHandlers).
- ///
- public static MauiAppBuilder UseCometAppMacOS(this MauiAppBuilder builder)
- where TApp : class, IApplication
- {
- builder.UseMauiAppMacOS();
- builder.UseCometHandlers();
- return builder;
- }
-#endif
- public static MauiAppBuilder UseCometHandlers(this MauiAppBuilder builder)
- {
-
- //AnimationManger.SetTicker(new iOSTicker());
-
- // Initialize the token-based theme system with Material 3 defaults.
- // Fork the shared Defaults.Light via `with { }` so the framework's
- // ButtonStyles.Filled registration doesn't mutate the shared singleton
- // (records copy the _controlStyles ImmutableDictionary on `with`, so
- // subsequent SetControlStyle calls affect only this derived theme).
- var defaultTheme = Defaults.Light with { };
- defaultTheme.SetControlStyle