A simple, cross-platform reader for ScreenJSON screenplay files.
Built as a single Tauri 2 app targeting iOS, Android, Windows, macOS, and Linux — one codebase, one webview, five platforms. Reuses the screenjson-ui library for all rendering, pagination, validation, and decryption.
Released as a reference implementation under the MIT license.
- Opens local
.jsonScreenJSON files by picker, drag-and-drop, or by tapping/double-clicking a file the OS routes to the app. - Rejects files that aren't ScreenJSON with a friendly, non-technical message (distinguishing not valid JSON from valid JSON but not a screenplay).
- Prompts for a password on encrypted scripts and decrypts in-memory.
- Traditional screenplay title page (title, author, logline, based-on credits) rendered as a proper cover before the script.
- Reader view dedicates maximum screen space to the script itself: tap to toggle a minimal top bar and an e-book style bottom page slider. Pinch-to-zoom and double-tap-to-zoom on touch devices.
- Remembers recent files.
- Responsive by design: mobile reflows to viewport-width with proportional indents and readable body font; desktop uses the traditional centered-on-workspace paper metaphor.
- Dark/light mode, zoom, responsive layout, and system print dialog.
- No editing, no export, url internet content, no upload. Read-only by design.
The viewer is a thin shell around the screenjson-ui rendering library, which does all the heavy lifting: pagination, element styling, validation, and AES decryption.
This repo does not vendor or submodule screenjson-ui. The dependency in package.json points to the public GitHub source tarball for the screenjson-ui default branch (develop). .npmrc disables lockfile generation and sets install-strategy=nested so npm can resolve that tarball without hitting arborist dedupe bugs; the postinstall script refreshes node_modules/screenjson-ui, so installs pull the latest upstream UI source instead of freezing one internal checkout or one commit.
The viewer consumes the library from source, not from the built bundle. This is enforced by a Vite alias in vite.config.ts pointing screenjson-ui at a viewer-local facade, src/lib/shims/screenjson-ui.ts, which re-exports only the named source modules the native viewer uses. Three reasons:
- Single Svelte runtime. The pre-built
dist/screenjson-ui.jsbundles its own Svelte copy. Mounting a bundled Svelte component inside another Svelte app throwseffect_orphanthe moment the inner component touches a rune. Importing source avoids this. - WebKit-compatible module shape. Tauri's WebKit runtime can reject the upstream public barrel's default re-export during native ESM resolution. The facade avoids loading that barrel in the app.
- Transparent upstream boundary. The viewer can compile the UI source directly while keeping the library's source and history in the public
screenjson-uirepo.
Design tokens and @utility rules from the library are inlined into src/app.css rather than imported. Tailwind v4 doesn't currently process @utility declarations through nested @imports, and it only emits utilities it sees used — so the viewer's app.css includes @source "../node_modules/screenjson-ui/src/**/*.{svelte,ts,js}" to scan the library's components for class references.
| Addition | Location | Why it lives here |
|---|---|---|
| Dialogue normalizer | src/lib/flow/normalize.ts | Repairs a common data-quality issue — consecutive dialogue elements from one speaker-turn get merged. Fixes PDF-converted scripts where each visual line was stored as its own element. |
| Now in the library — see "Library edits" below | — | |
| Responsive reflow CSS | src/app.css | Mobile (≤640px) drops the paper metaphor in favor of edge-to-edge reflow with proportional indents. Library handles tablet/desktop; the viewer adds the phone case. |
| Cross-platform shell | src-tauri/ + src/lib/platform/ | File associations and picker abstraction for iOS/Android/Win/Mac/Linux. |
If a bug or limitation belongs in screenjson-ui, make that change in a separate clone of github.com/screenjson/screenjson-ui, commit it there, and push it to the branch this wrapper consumes. Installing this wrapper will then fetch that source:
npm installIn an existing checkout with node_modules already present, you can refresh only the UI dependency after upstream changes land:
npm run ui:updateFor temporary local iteration, npm link still works with a separate local checkout. Keep that checkout outside this repository so the wrapper remains only a container:
# inside a separate screenjson-ui checkout
npm install
npm link
# inside screenjson-viewer (after npm link was run in the library repo)
npm link screenjson-uinpm link screenjson-ui without running npm link first in a local screenjson-ui clone will try the public npm registry and fail with 404 — that package is not published to npm; the default install path is the GitHub tarball in package.json.
The normalizer (normalize.ts) merges runs of consecutive dialogue elements that share the same character turn. Without it, PDF-converted scripts render every wrapped line as a separate paragraph, which looks like dumped text on a phone.
- Safe for well-formed documents. One dialogue element per speech-beat? Nothing to merge, document passes through unchanged.
- Safe for parentheticals. A parenthetical between two dialogue elements ends one run and starts another — preserves the intended pause.
- Joins mid-word hyphenation (
"seven-"+"letter"→"seven-letter") and otherwise space-joins. - One known tradeoff: a screenwriter who intentionally splits a cue into two dialogue elements for dramatic effect would see those merged. If your files rely on intentional multi-element dialogue, disable the normalizer by removing the
normalizeDocument()call in openAndRoute.ts.
screenjson-viewer/
├── src/ # Vite + Svelte 5 webview UI
│ ├── App.svelte # top-level state router
│ ├── main.ts
│ └── lib/
│ ├── state.svelte.ts # app state (Svelte 5 runes)
│ ├── platform/ # Tauri file/dialog/deep-link abstraction
│ ├── flow/ # validate → decrypt → route
│ └── components/ # Home, Reader, PageSlider, TopBar, …
├── src-tauri/ # Rust shell (one project, five targets)
│ ├── src/lib.rs # Tauri entry; plugin wiring
│ ├── tauri.conf.json # bundle + file associations
│ ├── capabilities/ # plugin permissions
│ └── icons/
├── index.html
├── vite.config.ts
└── package.json
Do system + Rust setup first; npm install alone is not enough to run the desktop app.
- Tauri system dependencies — Follow the Tauri prerequisites for your OS before
npm run tauri:devornpm run tauri:build. That page covers the native libraries and toolchains Tauri expects (for example WebKitGTK and build essentials on Linux, Xcode Command Line Tools on macOS, WebView2 and MSVC build tools on Windows). - Rust 1.77+ via rustup so
cargoandrustcare on yourPATH. The first Tauri command will download Rust crate dependencies intosrc-tauri/target/; no separate manualcargo fetchis required if the toolchain is installed correctly. - Node.js 20+ and npm 10+. Use a single toolchain for both commands (for example the
nodeandnpmfrom the same nvm or fnm install). Some environments put anothernodeearlier onPATHthan the one that ownsnpm, which can produce confusing install errors. - For iOS: Xcode 15+, an Apple Developer account, CocoaPods (
brew install cocoapods). - For Android: Android Studio, Android SDK, NDK, JDK 17, and
ANDROID_HOME/NDK_HOMEset.
npm install adds the JavaScript side (including @tauri-apps/cli); it does not replace the OS-specific libraries from step 1 or the Rust toolchain from step 2.
From this app’s directory (named screenjson-viewer whether you cloned the viewer repo by itself or it lives inside a larger checkout):
cd screenjson-viewer # skip if your shell is already here
npm installRequirements:
- Network:
npm installmust reachgithub.comto download thescreenjson-uisource tarball declared in package.json. - Layout: .npmrc sets
install-strategy=nestedso npm does not hit a known arborist bug when deduplicating dependencies installed from that tarball (Cannot read properties of null (reading 'matches')and similar).
No screenjson-ui source folder or package-lock.json should be committed to this repository (package-lock=false is intentional).
If install still fails after a partial or interrupted run, or the dev webview shows stale module errors, reset front-end and Rust artifacts, then reinstall:
rm -rf node_modules dist .vite
(cd src-tauri && cargo clean)
npm installThen npm run tauri:dev again.
Requires the Tauri prerequisites (system libraries + Rust) to be in place first. Then:
npm run tauri:devThe Vite dev server starts on http://localhost:1420 and the Tauri window opens against it. Hot-reload works for both the frontend and (with a rebuild) the Rust shell. src-tauri/tauri.conf.json runs npm run dev as the frontend beforeDevCommand (same package manager as this repo).
One-time setup:
npm run tauri:ios:initThis generates src-tauri/gen/apple/. After generation, edit the Info.plist inside that folder to make sure CFBundleDocumentTypes matches the file associations declared in tauri.conf.json (Tauri writes most of these automatically, but UTI imports/exports sometimes need a pass). Then:
npm run tauri:ios:dev # runs on an attached device or simulator
npm run tauri:ios:build # archive for App Store / TestFlightnpm run tauri:android:init
npm run tauri:android:dev
npm run tauri:android:buildAfter init, confirm src-tauri/gen/android/app/src/main/AndroidManifest.xml has an <intent-filter> for android.intent.action.VIEW on application/vnd.screenjson+json with the .json extension — Tauri generates it from fileAssociations, but double-check.
# Desktop — bundles .app/.dmg (mac), .msi/.exe (win), .deb/.AppImage (linux)
npm run tauri:build
# Mobile
npm run tauri:ios:build
npm run tauri:android:buildArtifacts land in src-tauri/target/release/bundle/ for desktop and in the native project folders under src-tauri/gen/ for mobile.
Edit src-tauri/icons/app-icon.svg or replace it with a 1024x1024 source image, then regenerate with:
npm run tauri -- icon src-tauri/icons/app-icon.svgThis produces the full icon set for every platform in one go.
| Platform | Configured in |
|---|---|
| Desktop | src-tauri/tauri.conf.json → bundle.fileAssociations |
| iOS | Auto-generated into gen/apple/Info.plist |
| Android | Auto-generated into gen/android/.../AndroidManifest.xml |
| macOS UTI exports | bundle.fileAssociations (role: Viewer) |
The registered file extension is .json with MIME type application/vnd.screenjson+json. The app gracefully rejects non-ScreenJSON JSON files with a clear message.
- No editing. Full stop.
- No export. Printing is available via the system print dialog.
- No cloud sync, no accounts, no remote URL opens. Local files only.
MIT. See LICENSE.

