Build an App for Studio

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.

Download app-skeleton.zip

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

All display copy lives in locales/<code>.json. de.json is the universal fallback and must contain every key.

Bundle layout

folder 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

The ZIP must contain the files at its root, not inside a directory. The skeleton's 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.

app.json
{
  "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

text
string
Single-line text.
textarea
string
Multi-line text with newlines preserved.
number
number
Numeric input. Honours min and max.
url
string
URL field. Validated by the browser at edit time.
password
string
Masked text. For API keys and secrets stored per instance.
color
string
Hex string, e.g. #0EA5E9.
select
string
One of options[].value. Localised labels live in the locale file.
checkbox
boolean
Boolean toggle.

locale is a reserved key

A field with 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.

locales/de.json
{
  "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

  1. If the instance config has locale set, Studio uses that value.
  2. Otherwise the Studio user's App-Store locale is used (the language they picked when browsing apps).
  3. Otherwise de wins.

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.

runtime contract
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

Studio injects a <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.

Use when

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.

Use when

Text should reflow, layout adapts to different ratios, or the app already uses a fluid CSS system.

Switching from Canvas to Fluid

In 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.

postMessage handshake
// 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_READY
child → parent
Sent once when config.html has finished booting.
LOAD_CONFIG
parent → child
Studio pushes the saved config so the form can populate its inputs.
CONFIG_CHANGED
child → parent
Sent on every form change. Studio updates the preview and persists on Save.

Follow the config UI standard

Use the shared row-card pattern from the app skeleton's 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.

API-fallback loader
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

Serialize nested arrays and objects as JSON strings in your 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

async/await, spread, destructuring are fine. Avoid optional chaining and nullish coalescing inside the scaler / boot code. Both are fine inside async helpers loaded after boot.

No bundlers required

Apps are plain HTML/JS/CSS. Skip module bundlers unless your app genuinely needs them. The catalogue prefers self-contained files.

Test on a TV stick

Mobile Chrome is forgiving. Hardware sticks (Android TV, Tizen) are where performance and font issues actually surface.

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.

local server
cd my-app
python3 -m http.server 8000
# index.html  → http://localhost:8000/
# config.html → http://localhost:8000/config.html

Drive the config locally with URL parameters. The app skeleton already parses them back into the same shape as APP_CONFIG.config:

URL-driven config
http://localhost:8000/?title=Test&fontSize=200&transparentBackground=true

Debugging

console.log with app context

Prefix logs with [${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

Log every CONFIG_* message in config.html during development. Studio drops malformed payloads silently.

Accent + theme drift

Read the accent colour from one source (config or default), then derive everything else (focus rings, callouts). No hard-coded brand colour anywhere else in the form.

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.pngrequired
1200×400, PNG
Catalogue card and listing thumbnail.
featured.pngrequired
1920×1080, PNG
Detail-page hero when the app is featured.
banner.<lang>.png
1200×400, PNG
Localised variant per locale (e.g. banner.en.png).
featured.<lang>.png
1920×1080, PNG
Localised variant per locale.

Localised variants fall back to the default

A locale without its own 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

Pull colours from 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.setViewport with deviceScaleFactor: 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

Validate app.json, make sure locales/de.json covers every key, and place the banner / featured images at the bundle root.

2. Bump version

Run ./build.sh --bump-patch (or --bump-minor / --bump-major). The skeleton ships build.sh for this.

3. Submit

Send the flat ZIP to apps@screenway.com. We validate, upload, and reply with the live catalogue link.
build with the skeleton script
# 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 (manual zip)
# 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

MAJOR
1.0.0 → 2.0.0
Breaking config changes: renamed or removed fields, restructured layout. Existing instances may need reconfiguration.
MINOR
1.0.0 → 1.1.0
New features, new optional config fields with sensible defaults.
PATCH
1.0.0 → 1.0.1
Bug fixes, polish, performance, no config changes.

Upload 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

App slug, target locales, a one-line summary of the change, and the bundle ZIP as an attachment. For updates, mention the previous version so we line up the rollback path.

Next steps

Start from the skeleton

The current 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

App instances are first-class API resources. Build flows that create instances, update their config, and attach them to programs without ever opening Studio.

Mind the store assets

Banner and featured images are rendered from HTML templates per app. Keep their visual language aligned with the in-app accent so the store feels coherent.