navigate  ·  space next  ·  f fullscreen
01 / 00
EN中文
Hackathon · Slopfork Retrospective · 2026

Slopforking Voltra
React Native → Lynx

A native-surface UI library, ported to a new runtime, with 95.6% of the code untouched, and the entire SwiftUI/Glance renderer shipped byte-identical.

32,497
LoC Original
1,440
LoC New (Bridge + Shim)
64
Commits
~61
User Stories
5d
Calendar Time
The Result · End-to-End

Same pixels.
Different runtime.

These iOS Live Activities, Dynamic Island layouts, and home-screen Widgets now render from Lynx JSX, but because the SwiftUI rendering engine is byte-identical, the output is pixel-for-pixel the same as the original React Native version.

Basic Live Activity
Flight Tracker
Music Player
Workout
Compass
Liquid Glass
☀ Sunny
🌧 Rainy
⛈ Stormy
❄ Snowy
Z-Index Layering
Context · Why this is interesting

What is Voltra?

Voltra turns React JSX into SwiftUI and Jetpack Compose Glance, so you can ship custom Live Activities, Dynamic Island layouts and Android Widgets without touching native code.

The original

Voltra · React Native (Expo)

A Callstack-incubated open-source library. JSX → JSON → SwiftUI/Glance.
~32,500 LoC across 7 packages, an Expo config plugin, and native modules.

@use-voltra/core /ios /android /server
expo-plugin ios-client android-client

The port target

LynxJS

ByteDance's cross-platform runtime. JS executes on a background thread; UI is native. Not React Native. Different module ABI, different layout primitives, different DSL. But same Reactive mental model.

@lynx-js/react LynxModule GlobalEventEmitter
flex + linear layout Callback-based
The Hard Problem

You can't render Live Activities in a JS view tree.

Constraint

Live Activities, Dynamic Island, Widgets, and Glance run in out-of-process OS extensions.

They only accept SwiftUI / Compose Glance. No WKWebView. No React Native bridge. No Lynx runtime.

Implication

The actual UI rendering must happen natively. The JS framework only ever produces a JSON payload; native code reads that payload and constructs SwiftUI/Glance views.

The pipeline (unchanged)

// JS side
<Voltra.VStack>
  <Voltra.Text>Order #42</Voltra.Text>
</Voltra.VStack>
    
renderLiveActivityToString()
    
'{"type":"VStack","children":[…]}'
      // crosses bridge
// Native side (Swift/Kotlin)
VoltraParser.parse(json) → SwiftUI.View

This is the key insight. The bridge is small. Almost everything else is shared.

The Insight

The 5-Layer Model shows what travels free.

Layer 0
Pure JS Packages: @use-voltra/core · ios · android · server

Renderer, payload compression, JSX components. Framework-agnostic. Depend on react only.

100% via npm
Layer 1
Business Logic: live-activity/api · widgets · ongoing-notification · preload

Hooks, API functions. Only call VoltraModule methods. Vendored verbatim into the Lynx package.

~95% vendored
Layer 2
Bridge Adapter: lynx-bridge/

Promise⇄callback wrapper · GlobalEventEmitter shim · Platform detection. NEW.

+ 662 LoC
Layer 3
Native Module Registration: VoltraLynxModule.swift · .kt

Mechanical translation of Expo Module DSL → Lynx @LynxMethod. Bodies delegate to existing impl.

+ 788 LoC
Layer 4
Native Rendering: SwiftUI · Glance · Payload parsing · Image preload

The 19,800 LoC engine that actually turns JSON into pixels. Copied byte-for-byte from upstream Voltra.

100% shared

Architectural principle (P3) from LYNX_PORT.md: “The Adapter Pattern Absorbs All Differences.” All business logic sees the same interface regardless of host framework (Expo or Lynx).

The Headline Number
95.6%

of the Lynx port is reused from the React Native library.

Only 1,440 lines of new code (662 for the JS bridge adapter, 788 for the Swift + Kotlin native-module shims) to port 32,500 lines of native UI library to a different host runtime.

Code-Reuse Audit

Where every line went.

LayerWhat Original (RN) Lynx Port Reuse
L0 Pure-JS, core, ios, android, server, server-impls 5,7340 (npm-linked) 100%
L0/1 Voltra umbrella, renderer, JSX, payload, styles 4,4520 (npm-linked) 100%
L1 Client business logic, hooks, widget-api, live-activity 1,6161,241 (vendored) ~95%
L2 Bridge adapter (NEW) 0 662 NEW
L3 Native module registration, Swift + Kotlin 919 788 REWRITE
L4 SwiftUI + Glance rendering engine 19,776 19,783 (δ<0.1%) 100%
TOTAL 32,497 1,440 NEW + 21,024 reused 95.6%
Layer 2 · The Seam

The entire framework-difference cost: 662 LoC.

Six files, all in voltra-lynx/packages/lynx/src/bridge/. Every Expo→Lynx ABI difference is absorbed here.

module-adapter.ts
270 LoC
Wraps Lynx callback-style NativeModules.VoltraModule into Promise-based VoltraIOSModuleSpec / VoltraAndroidModuleSpec
types.ts
250 LoC
Re-declares the module-spec interfaces shared by adapter and consumers
platform.ts
53 LoC
Runtime platform detection replacing RN's Platform.OS
event-adapter.ts
46 LoC
Translates Lynx GlobalEventEmitter → RN-compatible EventSubscription
index.ts
33 LoC
Barrel export
env.d.ts
10 LoC
Ambient declarations for Lynx globals (NativeModules, GlobalEventEmitter)
Before / After · TypeScript

Module loading.

Before · Expo

packages/ios-client/src/VoltraModule.ts (57 LoC)

import { requireNativeModule } from 'expo'

const VoltraModule =
  requireNativeModule<VoltraIOSModuleSpec>('VoltraModule')

// Promise-returning methods are auto-generated by Expo.
// module.startLiveActivity(json, opts) → Promise<string>
After · Lynx

voltra-lynx/.../lynx/src/ios-client/VoltraModule.ts (17 LoC)

import { createIOSModuleAdapter } from '../bridge/index.js'

declare const NativeModules: {
  VoltraModule: Record<string, (...a: any[]) => any>
}

const VoltraModule: VoltraIOSModuleSpec =
  createIOSModuleAdapter(NativeModules.VoltraModule)

The adapter is a 270-line file that wraps every method in new Promise((resolve, reject) => raw.method(args, (r) => …)). Above it, every line of business logic (useLiveActivity, startLiveActivity, updateWidget, all 28+ functions) is unchanged.

Before / After · Swift

Method declaration.

Before · Expo Module DSL

packages/voltra/ios/app/VoltraModule.swift

AsyncFunction("startLiveActivity") {
  (jsonString: String,
   options: StartVoltraOptions?)
    async throws -> String in

  return try await self.impl
    .startLiveActivity(
      jsonString: jsonString,
      options: options
    )
}
After · LynxModule Protocol

voltra-lynx/host/ios/.../VoltraLynxModule.swift

@objc func startLiveActivity(
  _ jsonString: NSString,
  options: NSDictionary?,
  callback: LynxCallbackBlock?
) {
  let opts = StartVoltraOptions(from: options)
  Task {
    do {
      let id = try await impl.startLiveActivity(
        jsonString: jsonString as String, options: opts
      )
      callback?(id as NSString)
    } catch {
      callback?("ERROR:\(error.localizedDescription)"
                as NSString)
    }
  }
}

Pure mechanical translation. AsyncFunction("name") { … } becomes @objc func name(_:callback:); async throws → T becomes a Task { … callback?(…) } wrapper. The 330-line VoltraModuleImpl.swift that does all the actual work (ActivityKit calls, image preloading, widget timelines) is byte-for-byte identical.

Before / After · Kotlin

Method declaration.

Before · Expo Module DSL

packages/voltra/android/.../VoltraModule.kt

AsyncFunction("updateAndroidWidget") {
  widgetId: String,
  jsonString: String,
  options: Map<String, Any?> ->

  widgetManager.writeWidgetData(
    widgetId, jsonString,
    options["deepLinkUrl"] as? String
  )
  runBlocking {
    widgetManager.updateWidget(widgetId)
  }
}
After · @LynxMethod

voltra-lynx/host/android/.../VoltraLynxModule.kt

@LynxMethod
fun updateAndroidWidget(
  widgetId: String,
  jsonString: String,
  options: Map<String, Any?>?,
  callback: Callback
) {
  widgetManager.writeWidgetData(
    widgetId, jsonString,
    options?.get("deepLinkUrl") as? String
  )
  scope.launch {
    widgetManager.updateWidget(widgetId)
    callback.invoke(null as Any?)
  }
}

Same shape on Android: AsyncFunction@LynxMethod fun … callback: Callback, and runBlockingscope.launch { … ; callback.invoke(…) }. Every VoltraWidgetManager, VoltraNotificationManager, Glance renderer, parser: all 9,509 lines identical.

💡
Breakthrough · 01 of 03

The React Alias.

pluginReactLynx aliases react → @lynx-js/react at build time. Which means Voltra's JSX components (VStack, Text, Symbol, Image) work as-is in Lynx.

Why it works

Voltra's components only use createElement and hooks: never ReactDOM, never platform-specific APIs.

renderLiveActivityToString() walks the React element tree and serializes nodes to JSON. The element tree is the same shape no matter which createElement produced it.

What it unlocks

100% Layer 0 reuse via plain npm. No fork, no wrapper, no shim.

// In a Lynx .tsx file:
import { Voltra } from '@use-voltra/ios'

<Voltra.VStack style={{ padding: '16px' }}>
  <Voltra.Text>Order #42</Voltra.Text>
</Voltra.VStack>
// → traverses, serializes, native parses → SwiftUI ✨

Any React library that only touches createElement and hooks (not DOM) works in Lynx through this alias. Same trick that makes @tanstack/react-query work in Lynx.

📐
Breakthrough · 02 of 03

Lynx layout: flex works, but the default is linear.

Lynx supports full CSS Flexbox and Android-LinearLayout-style display: linear. <view> defaults to linear, so flex: 1 on a child <scroll-view> collapses to zero height, until you set display: 'flex' on the parent. One opt-in, and the rest of your RN styles port 1:1.

PatternRN / WebLynx (preferred)Linear fallback
Fill remaining space flex: 1 flex: 1 (parent display:'flex') linearWeight: 1
Horizontal row display: 'flex', flexDirection: 'row' same display: 'linear', linearDirection: 'row'
Scroll axis scroll-y scroll-orientation="vertical" same
Border radius borderRadius: 12 borderRadius: '12px' silently ignored
Line height lineHeight: 18 lineHeight: '18px' or remove 18 × font-size gaps
Padding shorthand paddingHorizontal: 16 paddingLeft: 16, paddingRight: 16 not applied
Tap event onPress / onClick bindtap no handler fires

Lesson encoded into LYNX_PORT.md §Lynx CSS Gotchas. Future agents read it before writing any layout code.

🧩
Breakthrough · 03 of 03

Custom Element > Native View.

Initially flagged as blocked stretch goals (US-050 / US-051), live in-app previews of widgets seemed to require Lynx's requireNativeView equivalent. Turns out the simpler Custom Element system was sufficient.

The component

// Lynx-side
<voltra-preview
  width="170"
  height="170"
  payload={renderWidgetToString(jsx)}
/>

The native registration

LynxUIRegister.register(
  "voltra-preview",
  VoltraPreviewHostView.self
)

What it does

Renders Voltra SwiftUI inline inside the Lynx app, not on the home screen, not in the lock screen. Makes the Testing Grounds screens show actual SwiftUI output instead of JSON dumps.

Wraps a UIHostingController on iOS / ComposeView on Android, subscribes to width prop changes from Lynx, re-renders SwiftUI on payload mutations.

9 testing-ground screens converted live SwiftUI in-app

The Journey · 64 commits across 10 phases

From US-001 to US-061.

Phase 01
Scaffolding

pnpm monorepo · rspeedy shell · Layer 0 proof-of-import

Phase 02
Bridge Adapter

module-adapter · event-adapter · platform · types, 662 LoC

Phase 03
Client Vendoring

iOS + Android client packages, business logic copied verbatim

Phase 04
Native Modules

Mechanical Swift + Kotlin translation, delegating to existing impl

Phase 05
iOS Host App

xcodegen · CocoaPods · Widget Extension · first end-to-end Live Activity

Phase 06
Demo Porting

10 Live Activities · 5 widgets · 14 testing screens

Phase 07
CSS Reality Check

flex+linear coexist · scroll-orientation · borderRadius px · lineHeight

Phase 08
High-Fidelity Rewrite

Second pass · pixel-parity with RN Expo example

Phase 09
Custom Element Preview

<voltra-preview> · <voltra-widget-preview> · 9 screens converted

Phase 10
API Surface Cleanup

Collapsed into single @use-voltra/lynx · Pods removed from git

243 files changed +27,395 / −200 ~61 user stories 1 PRD per phase cluster ≈ 1 commit per story
Part Two

Harness Engineering
how we actually shipped it

27,395 lines of code across 64 commits and ~61 stories. A single developer with Claude Code, structured agents, and a stack of guardrail markdown files.

The harness is the product.

Technique · 01

PRD → User Stories → 1-commit-per-story.

The work was broken into four written PRDs. Each PRD enumerates user stories US-001 … US-NN with explicit acceptance criteria. Each story maps to exactly one commit, prefixed with its ID.

The 4 PRDs

prd-voltra-lynx-port.md
51 stories
Original port: bridge + demos + Testing Grounds
prd-high-fidelity-rewrite.md
16 stories
Pixel-parity pass after CSS gotchas surfaced
prd-lynx-voltra-preview-screens.md
12 stories
<voltra-preview> Custom Element conversion
prd-voltra-lynx-android.md
28+ stories
Android host + Kotlin module + Glance widgets

Why it works

A user story is a cache-friendly unit of agent work, small enough to fit in one context window, with explicit acceptance criteria the agent can self-verify against.

The (US-XXX) commit suffix becomes a permanent audit trail: run git log --oneline | grep US-053 and you find the Positioning Screen conversion in one line.

Cost of context drift drops to zero, each story is a fresh start with the PRD as ground truth.

$ git log --oneline | grep 'US-0' | wc -l
       61
Technique · 02

CLAUDE.md as architectural guardrails.

Architectural principles are encoded as P1..P5 in LYNX_PORT.md. Every agent reads it before touching code. Mechanical translation tables let agents do the right thing without re-deriving it.

The principles

P1  Never modify Layer 0 (pure-JS packages)
P2  Vendor business logic, replace only bridge files
P3  The Adapter Pattern absorbs all differences
P4  Native code follows mechanical translation
P5  Verify end-to-end before moving on

+ Translation tables

| Expo Pattern | Lynx Equivalent |
| AsyncFunction(...)
        → @LynxMethod fun(... callback: Callback)
| requireNativeModule<T>
        → createModuleAdapter<T>(NativeModules…)
| sendEvent(name)
        → lynxContext.sendGlobalEvent("voltra:" + name)
| Platform.OS → __PLATFORM__ build constant

The compounding effect

When a new agent dispatches on a fresh story, it doesn't have to discover how the bridge works. It reads three things, CLAUDE.md, LYNX_PORT.md, the relevant PRD section, and produces code that matches the architecture.

Personal CLAUDE.md additionally captures lessons from past mistakes:

PRINCIPLE 1 · FROM PAST MISTAKE

说不清解决什么问题的改动,就不该做. “If you can't name the concrete bug your change fixes, don't make it. ‘More robust’ is not a reason.”

Technique · 03

Spec → Plan → Implement.

For non-trivial changes, the harness gates the work behind a written spec and a written implementation plan, each in its own commit. Then, and only then, the implementation commits land.

CommitSubjectType
14e732ddocs: spec for voltra-lynx API surface & project cleanupSPEC
4141225docs: implementation plan for API surface cleanupPLAN
9798e71refactor: create @use-voltra/lynx package with bridge subpathEXEC
791b239refactor: move ios-client into @use-voltra/lynx packageEXEC
c9b04d4refactor: move android-client into @use-voltra/lynx packageEXEC
cce1ecfrefactor: remove old packages, dead files, and Ralph artifactsEXEC

The brainstorming → design-doc → plan pipeline (from the superpowers:brainstorming and writing-plans skills) forces the agent to think before typing. By the time implementation starts, the decisions are already made and the agent's job is mechanical.

Technique · 04

Ralph Loop · autonomous story-by-story execution.

For the Android port (the second half of the project), execution was handed off to Ralph Loop, an autonomous agent that reads the PRD, picks the next unfinished story, implements it, runs verification, commits, and repeats.

The hand-off file

.claude/ralph-loop.local.md

---
active: true
iteration: 6
max_iterations: 0     # unbounded
started_at: "2026-04-26T13:19:56Z"
---

Implement the Voltra Lynx Android port following the
PRD at tasks/prd-voltra-lynx-android.md.
The JS bridge layer is already complete.
Scaffold Android host app with Sparkling,
implement VoltraLynxModule.kt, verify all 5 Android
demos work end-to-end. Follow LYNX_PORT.md
architectural principles strictly.

What Ralph contributed

+ Android host app scaffolded
+ VoltraLynxModule.kt, 558 LoC, 28 methods
+ 5 Android demos wired end-to-end
+ Sparkling-generated gradle setup

Why it works here

The architectural seam was already proven on iOS. Android is the same problem with a different vocabulary, exactly the kind of predictable, parallel work loop-style agents excel at.

Pattern: stand up a strong harness on platform A by hand, let the loop port to platform B.

Technique · 05

Parallel sub-agent dispatch.

Independent research is dispatched to background sub-agents. The main thread stays cache-warm; the sub-agents burn fresh context against narrow questions.

This very deck, agents used

Agent #1
research
Compute LoC + reuse % across Layer 0–4 (parallel)
Agent #2
narrative
Extract phase arc from git log + JSONL session (parallel)
Agent #3
repro
Try to boot iOS sim, screenshot LynxVoltra (background)

The economics

Anthropic's prompt cache has a 5-minute TTL. Sleeping past 300s on the main thread means reading the full context uncached, slow and expensive.

Sub-agents don't share that cache, so dispatching them costs nothing extra, and the main thread keeps moving while they work.

Rule of thumb: if 2+ tasks are independent (no shared state, no sequential dependency), they should run in parallel.

// Single message · 3 tool calls
<Agent description="loc audit" />
<Agent description="narrative" />
<Bash command="find ..." />
Technique · 06

Project-local skills & cross-session memory.

Domain knowledge is captured as agent-readable references that survive across sessions, no need to re-explain the project on each prompt.

skills/voltra/ · 17 reference docs

SKILL.md component-mapping ios-widgets ios-live-activities android-widgets widget-families charts variant-shapes plugin-schema runtime-api-checklist push-flow ios-server-updates server-driven-widgets images setup app-config source-of-truth

The agent loads SKILL.md on relevant triggers and pulls deeper references on demand. Tribal knowledge → committed file → reusable.

~/.claude/projects/.../memory/

Per-project memory directory with typed entries: user, feedback, project, reference.

When a session learns something durable, “the iOS host uses CocoaPods Lynx 3.7.0, not the internal template”, it writes a memory. The next session reads it on boot.

The result: a session in week 4 inherits the breakthroughs of week 1 without paying for them again.

memory/
├── MEMORY.md                # index
├── lynx-host-setup.md       # project
├── css-gotchas.md           # reference
└── react-alias-pattern.md   # reference
Technique · 07

Mistakes, codified.

Every wrong turn becomes a written principle. The personal CLAUDE.md grows a section per bug class, so the next agent doesn't repeat it.

Principle 1

说不清解决什么问题的改动,就不该做

If you can't name a specific, reproducible bug, don't change it. “More robust” and “more standard” are not reasons.

Born from: vue-lynx migration, a “cleaner” path-resolve refactor that introduced a new pnpm-symlink bug.

Principle 2

Don't mechanically port upstream patterns.

Ask: what does this pattern depend on? Does my environment satisfy that premise?

Born from: lynx-stack vs vue-lynx, same pattern, different architecture; the pattern didn't transfer.

Principle 3

Don't intuit “pre-existing” errors.

git stash first. If the error reproduces on the parent commit, it's pre-existing. Otherwise: you caused it.

Born from: a build error blamed on the upstream, that was actually introduced by the very change being verified.

Principle 4

Compile ≠ works. Test the user path.

pnpm build green + pnpm test green ≠ shippable. Run the example end-to-end. Pack the npm package. Open the app.

Born from: a migration where unit tests passed but the example app build broke at runtime.

For the Next Slopfork

The Slopfork Recipe.

A reusable template for the next “take this library to a new runtime” project.

01
Find the architectural seam.

What is framework-specific vs framework-agnostic in the source? Draw the layer model. The smaller you can keep the framework-bound region, the cheaper the port.

02
Treat the host framework as just another adapter.

Don't fork upstream code. Vendor it, write one bridge module, and absorb every ABI mismatch in one place. Promise⇄callback, EventEmitter, platform detection, all in one file.

03
Encode the architecture into CLAUDE.md.

Write P1..PN principles. Add translation tables (Expo→Lynx, RN→Lynx CSS, etc.). Every agent reads this before code. Compounding returns.

04
Decompose into PRD → user stories.

Each story has explicit acceptance criteria, fits in one context window, and produces exactly one commit. The (US-NNN) suffix becomes a permanent audit trail.

05
Spec first. Plan second. Code third.

Non-trivial work gets a docs: spec for X commit and a docs: implementation plan for X commit before any refactor lands. Forces thinking ahead of typing.

06
Verify pixel-parity, not compile-parity.

Snapshot tests catch what the type-checker doesn't. Pack the package. Open the example. Tap the buttons. Compile ≠ ships.

07
Hand off the loop once the seam is proven.

iOS by hand; Android by Ralph Loop. Once the agent knows the pattern, parallel platforms become a queue.

08
Promote every breakthrough to a written principle.

The React alias, the flex-vs-linear scroll-view rule, the LynxModule Swift quirks, all live in LYNX_PORT.md now. Next agent: free wins.

Fin · Thank you

Voltra runs on Lynx
because almost nothing had to change.

The library is the library. The runtime is just an adapter. The harness is the multiplier.

95.6%
Reuse
662
Bridge LoC
788
Native Shim LoC
7
Harness Techniques

voltra · voltra-lynx · LYNX_PORT.md · CLAUDE.md · ralph-loop · skills/voltra · superpowers