I have a working native Android port of thinking-orbs — Kotlin + Jetpack
Compose, no Skia, no React Native — built against 0.3.1 / spec 1.0.0 and
running in production in a shipped app. Before I package it up as a PR I'd
rather ask whether you want it in the repo at all, and where.
PORT_PLAN.md covers Android through ports/react-native on Skia, which is
the right call for RN apps. This is for the other case: a native Compose app,
where pulling in RN + Reanimated + Skia to draw a 64pt loading indicator is
not an option. It sits alongside the RN port rather than replacing it.
Parity
Geometry-exact against spec/orbs-golden.json, unmodified and byte-identical
to the copy on main:
golden: 72 cases, 70115 values within 1.0E-4 (spec 1.0.0)
All 9 states x 2 sizes x 4 timestamps. The engine is hand-transcribed from
src/engine/*.ts, which is only defensible because the fixture catches a
mistyped constant or a sign error by exact case and field. 39 tests total,
including per-mode golden tests (lattice, orbits, strands, morph,
web), JS rounding semantics including negative zero, preset resolution, and
a coarse frame-budget floor.
The tunings are not transcribed. OrbSpec.kt is generated from
spec/orbs-spec.json by a codegen-kotlin.ts written to sit next to your
scripts/codegen-swift.ts, so a retune upstream flows into Kotlin by
re-running a script rather than retyping forty literals.
Behaviours
Filling in the column your table in PORT_PLAN.md leaves open:
| Web |
Jetpack Compose |
matchMedia + ancestor data-theme observer |
isSystemInDarkTheme() (theme param overrides) |
prefers-reduced-motion -> static frame |
Settings.Global.ANIMATOR_DURATION_SCALE == 0 -> static frame |
IntersectionObserver / visibilitychange pause |
Compose stops recomposing a stopped host (plus a paused param) |
aria-label |
semantics { contentDescription; role = Role.Image } |
shared performance.now clock |
one shared withFrameNanos clock, so co-mounted orbs stay in phase |
Android has no direct prefers-reduced-motion; ANIMATOR_DURATION_SCALE == 0
is the closest system signal and is what the platform's own animation APIs
honour.
API
Deliberately shaped like the SwiftUI one:
ThinkingOrb(
state = OrbState.Searching,
size = OrbSize.Px64,
theme = OrbTheme.Auto, // Auto | Dark | Light
speed = 1.0,
paused = false,
displaySize = 96.dp, // arbitrary size, tuned preset geometry
tint = Color(0xFFD64040), // depth as opacity over one hue
)
displaySize scales inside the Canvas rather than through a
graphicsLayer, so it stays vector-crisp instead of rasterising and
upscaling.
Size and shape
~2,600 lines including tests. Zero third-party dependencies — Compose
foundation/ui/runtime only; the JSON parse is test-scope. Ships a demo
gallery module mirroring ThinkingOrbsDemo.
Layout I'd propose, mirroring ports/ios:
packages/thinking-orbs/
scripts/codegen-kotlin.ts # beside codegen-swift.ts
ports/android/
thinking-orbs-compose/ # the library
ThinkingOrbsDemo/ # the gallery
Tests would read spec/orbs-golden.json in place rather than vendoring a
second copy.
Two things the port turned up in your spec pipeline
Neither blocks this; both change what a port should contain, so I would
rather ask than guess.
spec/orbs-golden.json cannot cover the 32px size. src/types.ts has
OrbSize = 64 | 32 | 20 and src/presets.ts carries 32px tunings for every
mode, but scripts/extract-golden.ts:24 hardcodes const SIZES: OrbSize[] = [64, 20]. Re-running npm run spec today reproduces the golden file
byte-for-byte, so there are no reference vectors for 32px and no port can be
verified at that size — ThinkingOrbsKit's OrbSize declares only px64 and
px20 for the same reason. This port matches that coverage. Adding 32 to
SIZES in both extractors would fix it for every port at once.
spec/orbs-spec.json is stale. extract-spec.ts does emit the 32px
presets, so regenerating changes the checked-in file — it predates the 32px
addition. That is the one file where re-running the pipeline is not a no-op.
On tint
This port exposes a tint parameter; ThinkingOrbsKit has none, so there was
no SwiftUI baseline to follow. It now follows inkColor() in
src/engine/core.ts instead — the ramp runs on the tint's own channels, toward
white on a light substrate and toward black on a dark one, with alpha passed
through untouched. An earlier revision held one hue and folded depth into
alpha; that predated inkColor and has been dropped.
There is no golden vector for the tinted path (the fixture carries greys only),
so FrozenTimeTest pins the formula directly, per theme and per channel.
What is not done
- Not published to Maven Central. Consumable as a Gradle module today; happy
to wire publishing if you want it on a coordinate.
- No PNG cross-platform diff against the web canvas — the numeric golden pass
covers geometry, but your Phase 3 pixel layer has no Android arm yet.
- The performance floor runs on a desktop JVM, so it catches an
order-of-magnitude regression and nothing finer. No Pixel-6a-class numbers
against your Phase 3 target.
- It currently builds inside another repo's Gradle setup; making it standalone
(own settings/wrapper/version catalog) is part of preparing the PR.
Worth noting one thing the port turned up: the SwiftUI static path scales the
reduced-motion instant by preset speed where the web does not. Filed
separately.
If you want it, tell me where it should live and I'll open the PR against
that layout. If native Android is out of scope for the repo, no problem — I'd
just rather ask than drop 2,600 lines on you unannounced.
The branch is ready either way, rebased on current main:
https://github.com/andresain123/Libraries/tree/feat/android-compose-port
I have a working native Android port of thinking-orbs — Kotlin + Jetpack
Compose, no Skia, no React Native — built against
0.3.1/ spec1.0.0andrunning in production in a shipped app. Before I package it up as a PR I'd
rather ask whether you want it in the repo at all, and where.
PORT_PLAN.mdcovers Android throughports/react-nativeon Skia, which isthe right call for RN apps. This is for the other case: a native Compose app,
where pulling in RN + Reanimated + Skia to draw a 64pt loading indicator is
not an option. It sits alongside the RN port rather than replacing it.
Parity
Geometry-exact against
spec/orbs-golden.json, unmodified and byte-identicalto the copy on
main:All 9 states x 2 sizes x 4 timestamps. The engine is hand-transcribed from
src/engine/*.ts, which is only defensible because the fixture catches amistyped constant or a sign error by exact case and field. 39 tests total,
including per-mode golden tests (
lattice,orbits,strands,morph,web), JS rounding semantics including negative zero, preset resolution, anda coarse frame-budget floor.
The tunings are not transcribed.
OrbSpec.ktis generated fromspec/orbs-spec.jsonby acodegen-kotlin.tswritten to sit next to yourscripts/codegen-swift.ts, so a retune upstream flows into Kotlin byre-running a script rather than retyping forty literals.
Behaviours
Filling in the column your table in
PORT_PLAN.mdleaves open:matchMedia+ ancestordata-themeobserverisSystemInDarkTheme()(themeparam overrides)prefers-reduced-motion-> static frameSettings.Global.ANIMATOR_DURATION_SCALE == 0-> static framevisibilitychangepausepausedparam)aria-labelsemantics { contentDescription; role = Role.Image }performance.nowclockwithFrameNanosclock, so co-mounted orbs stay in phaseAndroid has no direct
prefers-reduced-motion;ANIMATOR_DURATION_SCALE == 0is the closest system signal and is what the platform's own animation APIs
honour.
API
Deliberately shaped like the SwiftUI one:
displaySizescales inside theCanvasrather than through agraphicsLayer, so it stays vector-crisp instead of rasterising andupscaling.
Size and shape
~2,600 lines including tests. Zero third-party dependencies — Compose
foundation/ui/runtime only; the JSON parse is test-scope. Ships a demo
gallery module mirroring
ThinkingOrbsDemo.Layout I'd propose, mirroring
ports/ios:Tests would read
spec/orbs-golden.jsonin place rather than vendoring asecond copy.
Two things the port turned up in your spec pipeline
Neither blocks this; both change what a port should contain, so I would
rather ask than guess.
spec/orbs-golden.jsoncannot cover the 32px size.src/types.tshasOrbSize = 64 | 32 | 20andsrc/presets.tscarries 32px tunings for everymode, but
scripts/extract-golden.ts:24hardcodesconst SIZES: OrbSize[] = [64, 20]. Re-runningnpm run spectoday reproduces the golden filebyte-for-byte, so there are no reference vectors for 32px and no port can be
verified at that size —
ThinkingOrbsKit'sOrbSizedeclares onlypx64andpx20for the same reason. This port matches that coverage. Adding 32 toSIZESin both extractors would fix it for every port at once.spec/orbs-spec.jsonis stale.extract-spec.tsdoes emit the 32pxpresets, so regenerating changes the checked-in file — it predates the 32px
addition. That is the one file where re-running the pipeline is not a no-op.
On tint
This port exposes a
tintparameter;ThinkingOrbsKithas none, so there wasno SwiftUI baseline to follow. It now follows
inkColor()insrc/engine/core.tsinstead — the ramp runs on the tint's own channels, towardwhite on a light substrate and toward black on a dark one, with alpha passed
through untouched. An earlier revision held one hue and folded depth into
alpha; that predated
inkColorand has been dropped.There is no golden vector for the tinted path (the fixture carries greys only),
so
FrozenTimeTestpins the formula directly, per theme and per channel.What is not done
to wire publishing if you want it on a coordinate.
covers geometry, but your Phase 3 pixel layer has no Android arm yet.
order-of-magnitude regression and nothing finer. No Pixel-6a-class numbers
against your Phase 3 target.
(own settings/wrapper/version catalog) is part of preparing the PR.
Worth noting one thing the port turned up: the SwiftUI static path scales the
reduced-motion instant by preset speed where the web does not. Filed
separately.
If you want it, tell me where it should live and I'll open the PR against
that layout. If native Android is out of scope for the repo, no problem — I'd
just rather ask than drop 2,600 lines on you unannounced.
The branch is ready either way, rebased on current
main:https://github.com/andresain123/Libraries/tree/feat/android-compose-port