@wyndlass/app-sdk
v1.5.0
Published
Build apps for Wyndlass's screens: web components on the app contract (Player API 2), with the runtime Wyndlass's own apps use.
Maintainers
Readme
@wyndlass/app-sdk
Build your own apps for Wyndlass's screens. An app is a web component, written in TypeScript or React. You import it into your organization, a person reviews it, and layouts place it like any of Wyndlass's own apps.
This package has:
- the runtime Wyndlass's own apps are built on;
- a React adapter;
- two templates;
- the command
wyndlass-app, which previews, checks and packs an app; - a skill that builds, imports and schedules an app with your coding agent.
It needs Node 20 or later. MIT licensed, © Northwynd Labs LLC.
Before you start
- Imported apps must be on where you import: an Owner or Admin turns them on in the CMS, under Settings, Imported apps.
- To import an app, you need a role that may add media at that place. A later version goes to the app where it already is, so it needs that role there.
- Versions are reviewed: a person who may approve media approves an app's first version, and any later version that asks for more than the one before it.
- Screens play an approved version only where their player can run imported apps. A screen whose player is too old leaves the app out, and plays the rest of the layout.
Start an app
npx @wyndlass/app-sdk create queue-board --template react --id com.example.queue-board
cd queue-board
npm install
npm run previewcreate makes a working app from a template:
react: a React component, rendered in the app's shadow root;vanilla: TypeScript onWyndlassApp, with no framework.
Each template is a small app with two pages: a heading and the time, then the weather. It has params of four kinds, says ready(), and shows the weather the screen kept while it asks again. The project has a README, an AGENTS.md for coding agents, and the scripts preview, check, pack and typecheck.
The id is a reverse-domain name in your organization's own domain. Ids under com.wyndlass. are Wyndlass's, and are refused.
With a coding agent
npx @wyndlass/app-sdk skill installIt adds the skill wyndlass-app to the project's .claude/skills/, where Claude Code finds it. With --user, it goes in ~/.claude/skills/ instead, for every project. Running it again replaces the copy with this version's.
Then ask your agent for the app you want. The skill:
- asks what the app shows and to whom, the screens' orientation, the data it needs, and its name and id;
- makes it from a template, and previews it with you in both orientations and in the CMS's frame;
- checks and packs it;
- imports, places and schedules it through the Wyndlass MCP, asking you before each.
The agent never approves a version: a person does, in the CMS.
Preview it
npm run preview (wyndlass-app preview) runs the app as screens run it, at http://127.0.0.1:5199/, on your computer only. The page is the same host Wyndlass's players run, with the same contract, deadline and error handling. It draws the app again whenever a source or app.json changes, and says why when a source doesn't build.
Its controls:
- The screen: its size (1920 × 1080, 1080 × 1920, 3840 × 2160, or any), its name, place and fields, the time zone and the locale.
- The app's params, each shown while its
whenholds, as in the designer. - The clock: play, pause, seek, and speeds from 0.25× to 4×.
- Enter and exit, with each preset and how long it lasts, and the app's pages.
- Data: each data source's answer, from samples you can edit in the page.
--data <dir>reads<source>.jsonthere instead (the answer'sdata, or a whole answer). The preview reaches no data service. A source the definition doesn't declare gets no answer, as on a screen. - Tables: each file in
<dir>/tables/, or one you add in the page. A file may be CSV, a JSON array of rows, or a snapshot. A CSV's header gives its columns' labels, and their keys are made as Wyndlass makes a table's (Now servingisnow_serving), so acolumnparam holds the key it will on screens. Adatatableparam chooses among the tables, acolumnparam among the chosen table's columns, and an app withslotsis bound to the first table. Edit a table's rows in the page, or change its file, and the app gets them as it plays, as a screen gives a table's new version: frozen, and without making the app again. The report says how many times the app was made. - The people at the screen, for an app that declares the
impressionssensor: someone arrives, looks, or leaves. - The player's controller, for what
capabilities.playerallows. The page sends the app the player's events, each one editable, and the controller's status follows them, as a screen's does. It sends a command by name, with an argument, asapps.sendCommanddoes. A command the app may give is answered "not applied", and one it may not is refused, as on a screen.
What a screen would report is shown beside the app: how long it took to say it was ready, or that it was late, and each failure with its reason.
What it makes while it plays is shown below that: anything it makes at every frame that holds memory outside the script's heap, such as date and number formatters (Intl.DateTimeFormat, or toLocaleTimeString with options), canvases, object URLs, workers and listeners on window, with what to do; and how many DOM nodes it has. One made at every frame holds memory a screen frees late, and can run it out. Fix what it says before you pack.
npm run preview -- --frame runs the app in the frame the CMS gives every imported app: sandboxed, on an origin of its own, with no storage, cookies or parent page. An app that reaches for one of them works on screens but fails in the CMS's designer. Run it with --frame before you import.
Check and pack it
npm run check(wyndlass-app check) makes the checks Wyndlass makes when you import an app, and gives the same answer:- the package's shape and limits;
- the definition against its schema and Wyndlass's rules;
- the module: ES2020, no imports, a default export.
It also warns, without refusing, of a module over 1 MiB and of a module that never calls
ready().npm run pack(wyndlass-app pack) writesdist/<id>-<version>.zip, the package you import, after the checks. It writes nothing for an app Wyndlass would refuse.- The module is one minified ES2020 file. CSS a source imports becomes text for
css(), and images and fonts are inlined. - The same sources give the same bytes, so importing a version again changes nothing.
- The source map is written beside the zip, never in it.
- The module is one minified ES2020 file. CSS a source imports becomes text for
Import it
In the CMS: under Content, Library, choose Import an app, choose the place it goes, and choose the zip. Wyndlass checks it, then says whether it is approved or held for review.
With the Wyndlass MCP, from a coding agent, as the person who connected it. The MCP lists its import tools only while imported apps are on at the top of your organization.
app.prepareImportat the place, with the zip's name and size, answers an address and headers for an hour.npx wyndlass-app upload <address> dist/<id>-<version>.zip --header "<name>: <value>" …PUTs the zip there. The command holds no token and signs in to nothing.app.importwith the same place and the upload's id imports it.app.listshows when the version is checked, and whether it is approved, held, refused or rejected, with the reason.
Reviews:
- An app's first version is held until a person approves it in the CMS. So is a later version that asks for more than the newest approved one: control of the player, a new sensor, audio, or a new network host. Any other version is approved with its import.
- Any version is also held where the place's Media review holds everything new, or holds media made with AI and the Assistant or one of your organization's agents imported it.
- Agents never approve.
- A held version can be placed and previewed, and reaches no screen. Screens keep playing the newest approved version meanwhile.
- A version never changes. The same bytes again are answered with the version you have. Other bytes need a new
versioninapp.json.
Place it: in the designer, your organization's apps are under Apps, Your apps. Place one as you place Wyndlass's.
What an app is
- One ES2020 module, whose default export is its element's class. The class extends
HTMLElement, usually throughWyndlassApp.- The host defines it under a tag of its own: your definition's
element, a hyphen and a suffix. - It hands the element its context once, before the element joins the page. Nothing is left on the element for another app to read.
- The host defines it under a tag of its own: your definition's
- A definition,
app.json, valid againstapp-definition.schema.json(in this package, as@wyndlass/app-sdk/app-definition.schema.json):id,version,nameandelement, which are required, anddescription;params: each of a kind the schema lists, such asstring,number,boolean,enum,color,font,mediaordatatable, with its label, default, values, limits and help, andwhenit applies;data: the data sources it reads, fromweather,news,imageandcalendar;capabilities:player(read, the default, orcontrol),sensors(impressions),audio, andnetwork, the hosts it reaches;slotsandanimation, for apps bound to a table's rows and for the enter and exit presets it takes.
- A package is a zip of exactly those two,
app.jsonandapp.js.packmakes it.
The runtime
import { WyndlassApp, fit } from "@wyndlass/app-sdk";
export default class QueueBoard extends WyndlassApp {
#number!: HTMLElement;
protected mount() {
this.css(`:host { display: grid; place-items: center; color: white; } b { font-size: ${fit(0.5, 0.3)}; }`);
this.#number = document.createElement("b");
this.root.append(this.#number);
this.ready();
}
protected draw() {
this.#number.textContent = String(this.prop("number", 7));
}
}WyndlassApp: extend it, and writemount()(build the shadow DOM, once) anddraw(epochMs)(draw that moment of the layout's clock, at every frame: make formatters and other objects inmount(), never indraw). It gives:prop(name, default),font()(the font param as CSS),now(),startedAt(),timeZoneandlocale;css(text): styles for the shadow root, as a constructable stylesheet;ready(): says the app may be shown, once, within 5 s of its start;--wand--h: the box's size, for text sized to it withfit;- the enter and exit presets (fade, scale, slide-up, slide-left), staggered over
staggered()'s items and drawn on the host's clock; paging(), for an app that turns pages.
- What acts or reads for the app is a function taking it, not a method of the element, so another app on the page can't use it:
data(this, source, params), the data service's answer;kept(this, source, params), the answer the screen last kept, at once;image(this, path),snapshot(this, table),controllerOf(this)andimpressionsOf(this);onCommand(this, name, handler), each command of that name sent to the screen's apps (apps.sendCommand), with its argument. A command is an event: the same one sent twice runs twice;contextOf(this), for the rest.
- Follow the player with the signals or hooks (
playbackSignal,usePlaybackand the rest, below), never by readingcontrollerOf(this).status()at each frame. - A table's rows change while the app plays. A screen gives the app a table's new rows within about a second of the save, without making it again. Read them where you draw:
snapshot()indraw(),useSnapshot, ortableSignal. Never read them once inmount()and keep them. On a screen they are the same object until they change, and frozen: copy rows before you sort or change them. - Helpers:
fit,typographyCss,colorandescape, and the presets' curveseasingOf,cubicBezierandstaggerTimes. - Types:
- the contract's (
AppContext,DataAnswer,HookOptionsand the rest); - the definition's (
AppDefinition); - each data source's answer, as a namespace (
weather,news,calendar,tables).
- the contract's (
@wyndlass/app-sdk/qr:qrModules(text), a QR code's modules, made without the network.
Outside a host, such as a unit test or a page of your own, an app still runs: the time is now, each prop is its default, data and image are refused as unavailable, and ready() does nothing.
React (@wyndlass/app-sdk/react)
import { useEffect } from "react";
import { reactApp, useApp, useData, useNow, usePaging } from "@wyndlass/app-sdk/react";
import css from "./app.css";
function Board() {
const app = useApp();
const now = useNow(1000);
const page = usePaging(2, 8000);
const { answer, error } = useData("weather", { lat: 49.9, lon: -97.14 });
useEffect(() => { if (answer || error) app.ready(); }, [app, answer, error]);
// …
}
export default reactApp(Board, { css });reactApp(Component, { css })makes the element's class, the module's default export. The component renders in the app's shadow root, withcssas its styles. React is a peer dependency, andpackbundles it.useApp():prop(name, default),ready(),timeZone,locale, the screen'splaceandscreen, andpreview.useNow(everyMs?): the layout's clock. WithouteveryMs, the component renders again at each of the layout's frames, many times a second. With it, the time is a multiple ofeveryMs(useNow(1000): whole seconds), and the component renders only when that changes. Take the coarsest the app shows. Either way, make formatters (Intl.DateTimeFormat,Intl.NumberFormat) and other objects withuseMemo, never in the render. One made in each draw holds memory a screen does not free in time: an app that did so ran a 4 GB screen out of memory within an hour.usePaging(count, ms): the page showing now, rendering when it turns. It also tells the host the pages.useData(source, params): at once the answer the screen kept, then the service's, asked again every 10 minutes.erroris set once an ask fails with nothing to show.useSnapshot(table): a data table's snapshot, assnapshotgives it. The component renders when the rows change: when the screen gets a table's new version, or when a binding's rows, chosen on the layout's clock, change. On players before Wyndlass's live rows (WP-0149), which make the app again when a table changes, it renders at every frame, as before.usePlayback(),useConnection(),useAudio(),usePlaying(): the player's state (starting, playing, paused, stopped, idle or error), online or offline, the volume and whether it is muted, and the items playing. Each renders only when its value changes. Each isnullwhere the app has no controller: in the designer and the CMS's previews.useCommand(name, handler): each command of that name sent to the screen's apps, with its argument, while the component is mounted.useEmergency(): emergency messages at the screen (see below). The component renders when they change.
The hooks reach the app's context through the SDK's own module, never through React's tree, so nothing another app could use hangs off the shadow root.
Signals (@wyndlass/app-sdk/signals)
For an app that updates its DOM when its rows or the player's state change, rather than at every frame:
import { WyndlassApp } from "@wyndlass/app-sdk";
import { effect, tableSignal } from "@wyndlass/app-sdk/signals";
export default class Queue extends WyndlassApp {
protected mount() {
const rows = tableSignal(this, String(this.prop("table", "")));
const number = document.createElement("p");
this.root.append(number);
effect(() => { number.textContent = String((rows.value as { rows?: { number?: string }[] } | null)?.rows?.[0]?.number ?? ""); });
this.ready();
}
protected draw() { /* the effect draws */ }
}tableSignal(this, table): a read-only Preact signal of the table's snapshot. Its value changes when the rows do, and is the same object until then. Make it inmount().playbackSignal(this),connectionSignal(this),audioSignal(this),nowSignal(this): the player's state, online or offline, the volume and whether it is muted, and the items playing, as read-only signals. Each is set only when its value changes, and isnullwhere the app has no controller. Make them inmount().emergencySignal(this): emergency messages at the screen (below), as a read-only signal.effect,computed,batch,signal,untracked: Preact's, from the same copy. Import them from here, not from@preact/signals-core: an effect from another copy does not see these signals.- Its own entry, so an app that doesn't import it doesn't bundle it (about 2 KB gzipped when it does).
Emergency messages
A screen shows emergency messages itself, over every app: a takeover covers the screen, and a banner covers its bottom 14% while the content above it plays on. emergencySignal(this) and useEmergency() say what shows, so an app can behave:
{ showing, messages }:showingisnone,bannerortakeover. Each message in force gives itssource,severity,presentation,eventandcategorywhere it has them, andtestfor a test or a drill.nullwhere the app has no controller, or before the screen has said what it holds. That is not the same asnone.- Under a banner, the app plays on below it. It may lower its sound, stop calling numbers or pause what calls for attention, or show the way out.
- Never an alert's words. The screen shows them in one design that apps can't change, and an app that drew them again could show one out of date or mistranslated.
- An app can't move, cover or hide the emergency layer.
- In the preview, the controller's panel shows a Test banner or takeover over the app, drawn as a screen draws it, and the app gets its
emergency.changed. - On players before WP-0149, the signal keeps the rows the app was made with: such a player makes the app again when a table changes.
Limits
- The zip: at most 6 MiB, holding only
app.json(at most 256 KiB) andapp.js(at most 5 MiB). - The module: ES2020, one file, importing nothing.
- Data: only the sources your definition declares, from
weather,news,imageandcalendar.
What screens do
- They draw on the layout's clock. The time comes from
now()(oruseNow()), neverDate.now(), so a seek or a pause draws the right moment. ready()within 5 s. An app that isn't ready by then is shown as it is, and reported late.- They catch every hook. An app that throws three times in a minute is stopped and its box emptied. The screen reports why (
load,shape,unsupported,error,lateoroverran), with the name of what was thrown, never its message. - Each app paints only in its box. What an app adds outside it is removed, and a dialog opens inside it.
alert,confirm,prompt,printandwindow.opendo nothing. - A version that hangs the page twice in an hour is held back on that screen until the screen's content next changes.
- Offline, an app shows the answers the screen kept:
kept()gives them at once. - In the CMS, an app runs in a sandboxed frame with no storage, cookies or parent page.
So:
- don't use
localStoragefor anything that matters; - keep secrets out of params and the module, since both can be read;
- declare the network hosts truthfully: a person approves them.
Versions
This package follows semver from 1.0.0. Its 1.x versions build apps on Player API 2, and a change a 1.x app couldn't survive would be a 2.0. A version on npm never changes: a broken one is deprecated, never replaced.
Licence
MIT, © Northwynd Labs LLC. See LICENSE.
