Package web apps that run inside Studio and Player.
ScreenWay apps are versioned bundles delivered from Studio storage. They run inside an iframe, receive their configuration through window.APP_CONFIG, and display alongside Designer projects, programs, and notifications.
Start from the official skeleton
Universal app scaler, postMessage-wired config.html, German and English locales, and the safe-default boot pattern, ready to copy.
What an app is
A modern app ships at least an index.html (the runtime), config.html (the in-Studio settings form), an app.json schema, an icon.svg, and a mandatory locales/de.json file. Banner and featured images are generated separately and required before the first upload.
Runtime
index.html renders inside Studio Designer, the player, and in standalone preview. It is sized 1920×1080 and scaled to fit any viewport.Config UI
config.html renders inside a Studio iframe and talks back to Studio with postMessage. No outer card, no app header. Studio provides the chrome.Localisation
locales/<code>.json. de.json is the universal fallback and must contain every key.Bundle layout
my-app/
├── app.json # required: schema, no display copy
├── index.html # required: the app itself (1920×1080)
├── config.html # required: config UI rendered inside Studio
├── icon.svg # required: 512×512 brand mark
├── locales/
│ ├── de.json # required: primary fallback locale
│ └── en.json # optional: additional locale
├── build.sh # ships with the skeleton: packages the folder into a ZIP
├── README.md # ships with the skeleton: excluded from the upload ZIP
├── banner.png # 1200×400, required for App Store visibility
├── banner.en.png # optional: localised banner per locale
├── featured.png # 1920×1080, required for App Store visibility
├── featured.en.png # optional: localised featured image
├── mobile.html # optional: QR-code controller view
└── assets/ # optional: images, videos, fonts, audio
├── images/
├── videos/
└── fonts/- index.html: the runtime entry point
- config.html: the in-Studio settings form, no outer card
- app.json: technical metadata and config_schema
- icon.svg: 512×512 brand mark, no wordmark
- locales/de.json: primary fallback, contains every visible string
- banner.png (1200×400) and featured.png (1920×1080) for App Store visibility
- build.sh + README.md ship in the skeleton and are excluded from the upload ZIP
- mobile.html and assets/ are optional and slot in around the required files
Flat bundle, no parent folder
build.sh handles this automatically and excludes build.sh and README.md from the upload.app.json
app.json is the stable technical schema. Display copy stays in the locale files. Never put a label, description, or option title here.
{
"name": "Lobby Weather",
"slug": "lobby-weather",
"version": "1.0.0",
"category": "utility",
"is_free": true,
"price_monthly": 0,
"config_schema": {
"fields": [
{ "key": "title", "type": "text", "default": "Berlin", "required": true },
{ "key": "fontSize", "type": "number", "default": 120, "min": 24, "max": 320 },
{ "key": "accent", "type": "color", "default": "#0EA5E9" },
{
"key": "locale",
"type": "select",
"default": "de",
"options": [{ "value": "de" }, { "value": "en" }]
}
]
}
}Supported field types
config_schema.fields[].type
textstringtextareastringnumbernumberurlstringpasswordstringcolorstringselectstringcheckboxbooleanlocale is a reserved key
key: "locale" drives the display locale. Its value becomes window.APP_CONFIG.locale.code and selects which runtime.* strings Studio injects.Localisation
Studio merges locales/<chosen>.json over locales/de.json. Missing keys fall back to German automatically. Apps must keep de.json complete.
{
"description": "Live weather and forecast for any city.",
"featured": {
"title": "Always-current weather on every screen",
"description": "Pick a city, choose your accent colour, mount on any display."
},
"config_schema": {
"fields": {
"title": { "label": "City", "placeholder": "Berlin" },
"fontSize": { "label": "Font size" },
"accent": { "label": "Accent colour" }
}
},
"runtime": {
"today": "Today",
"fallback": { "title": "Weather", "subtitle": "Set a city in app settings." }
}
}Locale resolution chain
- If the instance config has
localeset, Studio uses that value. - Otherwise the Studio user's App-Store locale is used (the language they picked when browsing apps).
- Otherwise
dewins.
Runtime: window.APP_CONFIG
Studio injects window.APP_CONFIG at the <script id="app-config"></script> placeholder before your app script runs. Treat it as read-only.
interface AppConfig {
instanceId: string // UUID for this configured instance
instanceName: string // user-defined display name
appId: string // app UUID
appSlug: string // e.g. "lobby-weather"
appName: string // e.g. "Lobby Weather"
version: string // e.g. "v1.0.0"
config: Record<string, unknown> // values matching config_schema
locale: {
code: string // resolved BCP-47 (e.g. "de", "en", "de-CH")
strings: Record<string, unknown> // runtime.* merged with de.json runtime.*
}
mobileUrl?: string // only set when the bundle ships mobile.html
}- Always guard the read: var cfg = (window.APP_CONFIG && window.APP_CONFIG.config) || {}
- Fall back to inline defaults so local dev works without Studio
- Read runtime strings from window.APP_CONFIG.locale.strings
- Use Intl.DateTimeFormat with locale.code for dates and numbers
- Set document.body.style.opacity = "1" only after applying config to avoid flicker
- transparentBackground in config means the app should render over a transparent body
- mobileUrl is only set when the bundle ships mobile.html. Check before using
Use relative asset paths only
<base> tag so relative paths resolve to the storage location. <img src="assets/logo.png"> works; <img src="/assets/logo.png"> breaks in production. The same rule applies to stylesheets, scripts, and fetch URLs for bundled data.Canvas-mode vs Fluid-mode
The skeleton ships in Canvas-mode. Pick the alternative if your content needs to reflow.
Canvas-mode (default)
Fixed 1920×1080 design, all sizes in pixels. The Universal App Scaler CSS-transforms the design to the actual viewport while preserving aspect ratio.
The designer's mockups should map 1:1 to the screen, layout is composed of fixed regions, and reflow on narrow viewports is not a concern.
Fluid-mode (alternative)
100vw / 100vh root, sizes via clamp() and viewport units. No scaler. The browser handles sizing natively.
Text should reflow, layout adapts to different ratios, or the app already uses a fluid CSS system.
Switching from Canvas to Fluid
index.html, search for BEGIN UNIVERSAL APP SCALER and delete everything up to and including END UNIVERSAL APP SCALER. Change html, body width/height from 1920px / 1080px to 100vw / 100vh and convert pixel values to clamp(). The app-config placeholder, getConfig(), and the postMessage handshake stay identical.config.html (the settings form)
Studio renders config.html inside an iframe and handles the surrounding card, title, and Save button. The form communicates with Studio over postMessage.
// config.html boots inside Studio's iframe.
// Use postMessage to read and write config values.
// 1) Tell Studio we are ready to receive values
window.parent.postMessage({ type: 'CONFIG_READY' }, '*')
// 2) Studio responds with the saved config
window.addEventListener('message', (event) => {
if (event.data?.type === 'LOAD_CONFIG') {
applyToForm(event.data.config)
}
})
// 3) On every input change, push the new config back
function emitChange(config) {
window.parent.postMessage({ type: 'CONFIG_CHANGED', config }, '*')
}Message types
CONFIG_READYchild → parentLOAD_CONFIGparent → childCONFIG_CHANGEDchild → parentFollow the config UI standard
config.html: no outer card, no app heading, no logo, no custom fonts. Only the --accent CSS custom property changes per app.Large configs: load from API
URL-encoded configs are capped by the browser. For apps with long arrays (quiz questions, ticker items, big media lists) fetch the saved config directly from /api/app-instances/{instanceId}/config instead of relying on URL parameters.
async function loadConfig() {
const urlParams = new URLSearchParams(window.location.search)
const instanceId = urlParams.get('instanceId') || window.APP_CONFIG?.instanceId
if (instanceId) {
const res = await fetch(`/api/app-instances/${instanceId}/config`)
if (res.ok) {
const { config } = await res.json()
return config
}
}
return window.APP_CONFIG?.config ?? {}
}Stringify complex fields in config.html
CONFIG_CHANGED payload and parse them again on load. That keeps every transport path safe (URL, postMessage, API).Browser compatibility
Player devices include Android sticks, smart TVs, and embedded screens. The baseline target is Chrome 66 / ES2017. The Universal App Scaler is intentionally written in plain ES5/ES2017 so it runs anywhere.
Use ES2017 features
No bundlers required
Test on a TV stick
Local development
Run any static file server from the bundle root and open index.html. With no APP_CONFIG present, your inline defaults take over. Design for that path from day one.
cd my-app
python3 -m http.server 8000
# index.html → http://localhost:8000/
# config.html → http://localhost:8000/config.htmlDrive the config locally with URL parameters. The app skeleton already parses them back into the same shape as APP_CONFIG.config:
http://localhost:8000/?title=Test&fontSize=200&transparentBackground=trueDebugging
console.log with app context
[${APP_CONFIG?.appName ?? 'dev'}] so screen-captured logs stay searchable.Detect dev vs production
!!window.APP_CONFIG is the safest production check. Use it to skip mock data when running on real screens.postMessage trace
CONFIG_* message in config.html during development. Studio drops malformed payloads silently.Accent + theme drift
Store assets
Studio shows two images for every app: in the catalogue grid, on the app detail page, and on instance previews. Both files live at the bundle root next to app.json.
Image files
banner.pngrequired1200×400, PNGfeatured.pngrequired1920×1080, PNGbanner.<lang>.png1200×400, PNGbanner.en.png).featured.<lang>.png1920×1080, PNGLocalised variants fall back to the default
banner.<lang>.png picks up banner.png automatically. Only ship localised variants when the image itself has language-specific content (text, screenshots).Design rules
Banner (1200×400, 3:1)
Horizontal layout, stylised marketing mockup (not a literal screenshot). Safe-area inset of 72px / 40px. Left half: brand badge, tagline, hero element. Right half: feature tiles, variant grid, or a centred hero. Dark gradient base tinted toward your accent colour.
Featured (1920×1080, 16:9)
Pure visual asset. Studio overlays brand, headline, description and CTAs on top, so leave them out. Centre-stage hero scaled 3–5× larger than on the banner. Same palette as the banner. Safe-area inset of 140px / 100px. Generous empty space.
Derive the palette from the app
icon.svg first (gradient stops, fill colours), then from config_schema defaults (backgroundColor, accentColor), and only fall back to a category-based palette as a last resort. Avoid generic dark-slate banners. Push the base deeply toward the accent.Producing the PNGs
- HTML mockup + Chrome screenshot: render at exact dimensions in a normal browser tab, open DevTools, toggle the device toolbar, set custom size (1200×400 or 1920×1080) at DPR 1, then
Capture full size screenshot. - Headless via Puppeteer:
page.setViewportwithdeviceScaleFactor: 1, navigate to a local HTML file,page.screenshot. - Design tool: Figma / Sketch / Affinity export at exact dimensions, DPR 1× (not retina-doubled).
Publish flow
Studio admins onboard apps into the catalogue. Self-service uploads are on the roadmap. Until they ship, every catalogue entry goes through the ScreenWay team after a short review.
1. Package
app.json, make sure locales/de.json covers every key, and place the banner / featured images at the bundle root.2. Bump version
./build.sh --bump-patch (or --bump-minor / --bump-major). The skeleton ships build.sh for this.3. Submit
# Inside your app folder. The skeleton ships build.sh
./build.sh # build at the current version
./build.sh --bump-patch # 1.0.0 → 1.0.1
./build.sh --bump-minor # 1.0.0 → 1.1.0
./build.sh --bump-major # 1.0.0 → 2.0.0
# Result: my-app-v1.0.1.zip in the current directory.
# Requires: bash, zip, jq (brew install jq / apt install jq).# Fallback if bash + jq are not available
cd my-app
zip -r ../my-app-v1.0.0.zip . \
-x "*.DS_Store" -x "__MACOSX/*" \
-x "build.sh" -x "README.md" -x ".git/*"Versioning
Semantic versioning
MAJOR1.0.0 → 2.0.0MINOR1.0.0 → 1.1.0PATCH1.0.0 → 1.0.1Upload validation
- Rejected: missing app.json, index.html, icon.svg, or locales/de.json at the ZIP root
- Rejected: invalid JSON in app.json or any locale file
- Rejected: app.json without name or slug, or with an invalid config_schema
- Rejected: duplicate slug. Upload as a new version of the existing app instead
- Rejected: not a ZIP, corrupted ZIP, or nested-folder layout
- Accepted but hidden: missing banner.png or featured.png. App uploads but stays out of the App Store grid
- Accepted with fallback: missing config.html. Studio auto-generates a basic form (visually inconsistent with the catalogue)
What to include in the submission email
Next steps
Start from the skeleton
app-skeleton ships a full scaler, postMessage-wired config.html, German + English locales, and the safe-default boot pattern. Copy it, rename, ship.Use the v1 API for content
config, and attach them to programs without ever opening Studio.