Getting started
On a phone
A PWA in one command, an APK in one more, the stores after that.
Three ways to get this app onto a phone. Start at the top.
| Want | Command | Needs |
|---|---|---|
| Install from the browser | bunx omaris pwa | nothing |
| One file you can send | bunx omaris apk | a JDK and the Android SDK |
| Play Store, App Store | bunx omaris native | Android Studio, Xcode |
The components need no changes for any of it — they render in a WebView.
Installable
bunx omaris pwa Writes static/manifest.webmanifest, the icons each browser asks for, the head tags that link them, and the src/service-worker.ts SvelteKit registers on its own: build assets from cache, pages network-first so they open offline.
bunx omaris pwa --name "Ledger" --theme '#0b0b0c' # name and colourbunx omaris pwa --no-icons # keep the artwork you havebunx omaris pwa --dry-run # show it, write nothing The icons are a monogram in your theme colour, so there is nothing to design first. Overwrite static/icons/ when you have real artwork.
Nothing is ever overwritten, so a second run only adds what is missing — which means --theme changes nothing once the icons exist. Delete static/icons/ and static/manifest.webmanifest to redraw them.
Android and iOS
bunx omaris native Installs Capacitor, switches the build to a static SPA, writes capacitor.config.json, generates android/ and ios/, draws the launcher icons, and adds the scripts you use from then on:
| Script | Does |
|---|---|
bun run android:run | run on a device, reloading as you edit |
bun run android:apk | build one installable .apk — see below |
bun run android | build, sync, open Android Studio |
bun run ios:run | run on a device, reloading as you edit |
bun run ios | build, sync, open Xcode |
bunx omaris native --app-id com.acme.ledger # the id the stores key your listing onbunx omaris native --theme '#0b6bcb' # the icon colourbunx omaris native --no-ios # Android onlybunx omaris native --dry-run # show it, write nothing Run omaris pwa first and the launcher icons pick up the colour already in the manifest, so the home-screen icon and the one in the app drawer match.
Android needs Android Studio; iOS needs a Mac with Xcode. Neither is something a CLI can install for you.
What it changed
| File | Why |
|---|---|
vite.config.ts | adapter-static, falling back to one index.html |
vite.config.ts | Vite's watcher kept out of android/ and ios/ |
src/routes/+layout.ts | ssr = false — there is no server inside an app |
capacitor.config.json | the app's id, its name, and where the build lands |
src/app.html | viewport-fit=cover, so the safe-area insets report |
The adapter is the one worth knowing about: SvelteKit moved it into vite.config.ts, and every Capacitor guide written before that tells you to edit a svelte.config.js your project does not have. The watcher is the one nobody mentions: after one Gradle build android/ is thousands of files, which is enough to hit Linux's inotify limit and stop the dev server noticing edits.
Reloading as you edit
bun run android:run # or bun run ios:run You don't rebuild for every change. That script starts the dev server, works out the address your phone can reach it on, builds and installs the app once, and launches it pointed at the server — so from then on a save reloads the app the same way it reloads the browser. Ctrl-C stops the server and puts the native project back the way it was.
Nothing is written to your capacitor.config.json. The address goes to cap run as a flag, which sets it in the native project's copy for the run and reverts it after — so there is no server.url block to remember to delete before you build something real, the mistake that ships an app pointing at a laptop.
Phone and laptop have to be on the same network. A blank screen on the phone means it cannot reach the address the command printed — a firewall on the laptop, nearly always; allow the port, or use --forward-ports over USB.
The flags belong to omaris run, and bun run android:run --packaged passes them through (npm run android:run -- --packaged).
| Flag | For |
|---|---|
--packaged | the built app instead: no server, no reloading |
--port 3000 | a dev server that isn't on 5173 |
--host 192.168.1.20 | choosing the address yourself |
--target emulator-5554 | one device out of several |
--forward-ports 5173:5173 | a device on USB with no route to your machine |
Only web code reloads. Adding a Capacitor plugin, editing anything inside android/ or ios/, or changing the app id or icons still means a rebuild.
An APK you can send
bun run android:apk # or, from anywhere: bunx omaris apk Builds the web app, syncs it into android/, runs Gradle, and copies the result next to package.json as <your-app>-debug.apk. No IDE is opened at any point.
adb install -r ledger-debug.apk # over USB Or send the file. Android installs it from the Files app, once that app is allowed to install unknown apps.
A debug APK installs anywhere and is what you want for testers. Play does not accept one — a store upload is a signed .aab, which is Android Studio's Build → Generate Signed App Bundle. bunx omaris apk --release runs assembleRelease instead, and that APK is unsigned, so no phone will install it until you add a signingConfig.
The two ways it fails are both the machine, not the project:
| Gradle says | Do |
|---|---|
SDK location not found | set ANDROID_HOME, or open the project once in Android Studio |
JAVA_HOME is not set | install a JDK 21 and point JAVA_HOME at it |
Three things to get right
Fonts work offline. omaris/fonts.css ships its woff2 files inside the package, so a packaged app draws Geist with no network. Replacing them with your own is a matter of @font-face rules; the token names don't change. See Typography.
Give controls a thumb. The default control height is 40px, drawn for a mouse; Apple and Google both ask for 44. Set size="lg" on anything a finger has to hit. A number that has bounds can skip the keypad entirely — <NumberInput picker min={30} max={200} /> is a field on a desktop and a wheel in a bottom sheet under a thumb.
Respect the safe areas. native sets viewport-fit=cover, which is what makes the insets report at all — the padding is still yours to add, or anything pinned to an edge sits under the status bar. Wrap the app once:
<SafeArea class="min-h-dvh"> {@render children()}</SafeArea> …and one edge at a time where a bar is supposed to reach the glass:
<footer class="bg-surface-container"> <SafeArea edges={['bottom']} min={12}>…</SafeArea></footer> min is the part people forget: half the phones in the world report 0 for the bottom inset, so padding that is only the inset is no padding at all on them. The same insets are spacing utilities (pt-safe-top, pb-safe-bottom, ps-safe-start, pe-safe-end) when a wrapper is one element too many, and numbers on the safeArea rune when a canvas has to be drawn around them. <SafeArea keyboard> grows the bottom edge by however much the on-screen keyboard is covering, which is the other thing that eats a bottom action row.
See Safe Area.
Not this
Reach for React Native, Flutter or Kotlin and none of omaris comes with you. The trade is worth it only when the app is mostly native — heavy gestures, background work, real-time graphics. A dashboard is not that.