Performance, battery and size
Zvessa has to feel instant on a mid-range phone, stay cool, and stay small. These rules apply to every screen, block, kit and engine. A change that breaks one says why in its description.
Budgets #
- 60 fps while scrolling and animating (120 on ProMotion screens). No dropped frames on a three-year-old Android phone.
- Idle means idle. With nothing changing on screen, the app does no work: no timers, no animation frames, no polling, no sensor reads.
- Background means asleep. In the background the app holds only its Convex connection; mini-apps, animations, sensors and media are paused.
- Size. The JS bundle grows only with code people use; new native packages are added only when the package plan says so. Icons cost 18 KB on Android and web, nothing on iOS.
Drawing #
- Few views. Draw decoration (grids, dots, gradients, glows) with style properties (
backgroundImage,boxShadow), never with one view per line or dot. - Lists that can grow are virtualized. Chat, records, history, members, feeds: use a list component (FlashList once installed, FlatList until then), never
.map()inside a ScrollView. Give rows a stablekeyand wrap the row component inmemo. - Lists inside a page load in pages. A block inside a scrolling page can't virtualize, so it asks the server for a page (about 50) and shows "Show more" (
blueprint.words.showMore). - Don't redraw what didn't change. Derived values (
resolveStyle, lookups, filtered lists) go inuseMemo; look things up through aMap, not.find()inside a loop. - Images come in at the size they're shown (ask the server for a thumbnail), with a placeholder (thumbhash) and
expo-imagecaching once installed.
Motion #
- Animations run on the UI thread.
useNativeDriver: truetoday, Reanimated worklets once installed. Animatetransformandopacityonly, neverwidth,height,topor colours. - Decoration stops when it isn't seen. Anything that moves on its own uses
useDecorativeMotion()fromsrc/lib/activity.ts: it is false when the screen is covered, the app is in the background, or the person turned on Reduce Motion. - A style's
motion.speed: "off"turns off all decorative motion. Respect it everywhere.
Work that keeps running #
- Gate it on
useLive(). Timers, sensors, location, camera, audio, live streams and game loops start only whileuseLive()(screen showing and app in the foreground) is true, and stop in the effect's cleanup. - Sensors (
expo-sensors): subscribe in an effect gated onuseLive(), set the slowestsetUpdateIntervalthat works (100 ms for tilt and shake, 16 ms only for a game that needs it), and always remove the listener. Location: lowest accuracy that works, never in the background unless a kit is about background location and the person turned it on. - Mini-apps pause themselves.
MiniAppcallswindow.myaibio.setPaused()when hidden: animation frames, timers and media stop, and the mini-app getsmyaibio:pauseandmyaibio:resumeevents to save state. Timers can't fire faster than every 16 ms. - No polling. Live data comes from Convex subscriptions. Something outside Convex is fetched by a Convex action or a scheduled job, never by a timer on the phone.
Data #
- Subscribe to what the screen shows. Every
useQueryreturns a bounded result (.take(n)or pagination, never.collect()on a table that grows). Counts and totals come from@convex-dev/aggregate, not from reading every row (records.countmoves there with the Events engine). - Ask once. Two blocks that need the same data call the same query with the same arguments; Convex shares one subscription.
- Small results. Queries return only the fields a screen draws; big text and files are fetched when opened.
- Writes are cheap. Mutations take what changed (a patch), not the whole document.
Loading and size #
- Native first, package second. Use what React Native and Expo already ship (style gradients,
boxShadow,expo-symbols) before adding a package. Every new package goes through the package plan andnpx expo install. - Heavy and rare code loads on demand on the web. Kits a workspace hasn't added (and their editors) are imported with
React.lazyso first load stays small. On phones the bundle is Hermes bytecode read from disk, so this matters less there. - Release builds shrink. Android uses R8 and resource shrinking (
expo-build-properties), iOS and Android ship Hermes bytecode, and the web build is minified with tree shaking. - Measure before and after. Use the React Native DevTools profiler and Expo Atlas (
EXPO_ATLAS=1 npx expo export) to check render counts and bundle size.
Icons #
Use Icon from src/icons with a name from src/icons/icons.json ("send", "delete", "ai"…):
<Icon name="send" color="accent" size={20} label="Send" />
- iOS draws Apple's SF Symbols natively. Android and the web draw Material Symbols from
myaibio-icons.ttf, a font holding only the listed icons (18 KB instead of 1 MB). - Names say what an icon means, not what it shows, so blueprints and the AI pick from the same list (
ICON_NAMES). Unknown names draw a circle instead of failing. - Colour is a style role and size scales with the style's type, like text.
- To add one: add
"name": ["sf.symbol.name", "material_name"]toicons.json, then runpython3 src/icons/build-font.py(needspip install fonttools) andnpm run typecheck, which rejects SF Symbol names that don't exist.