@wyndlass/app-sdk 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.js CHANGED
@@ -17,8 +17,10 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
17
17
  * React's tree (which hangs off the shadow root's nodes) holds nothing another app could use: data, images and the
18
18
  * rest go through the SDK's functions, as for any app.
19
19
  */
20
- import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
20
+ import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
21
21
  import { createRoot } from "react-dom/client";
22
+ import { follow } from "./following.js";
23
+ import { onCommand } from "./commands.js";
22
24
  import { contextOf, data, kept, snapshot, WyndlassApp } from "./index.js";
23
25
  const bridges = new WeakMap();
24
26
  const App = createContext(null);
@@ -149,9 +151,47 @@ export function useData(source, params, everyMs = 10 * 60000) {
149
151
  }, [app, key, everyMs]);
150
152
  return state.key === key ? { answer: state.answer, error: state.error } : { answer: kept(app, source, params) };
151
153
  }
152
- /** A data table's snapshot, as snapshot() gives it (null when the screen has none), read again each frame the layout draws: a bound table's rows change with time. */
154
+ /**
155
+ * A data table's snapshot, as snapshot() gives it (null when the screen has none). On a player that says when a table
156
+ * changes (onSnapshot, WP-0149), it renders when the rows do: when the screen says so, or when a binding's rows, chosen
157
+ * on the layout's clock, change. On an older player, which makes the app again when a table changes, it reads them at
158
+ * every frame, as before.
159
+ */
153
160
  export function useSnapshot(table) {
154
- const [app] = useBridge();
155
- useNow();
161
+ const [app, bridge] = useBridge();
162
+ const live = typeof contextOf(app)?.onSnapshot === "function";
163
+ // Each frame, and each change the screen says (it may come while the layout is held).
164
+ const subscribe = useCallback((listener) => {
165
+ const off = bridge.subscribe(listener);
166
+ const unheard = contextOf(app)?.onSnapshot?.(() => listener());
167
+ return () => { off(); unheard?.(); };
168
+ }, [app, bridge]);
169
+ // Live, the rows are the same object until they change; an older player's are a new object at each read, so its frames decide.
170
+ const read = useCallback(() => live ? snapshot(app, table) : bridge.time(), [app, bridge, live, table]);
171
+ useSyncExternalStore(subscribe, read, read);
156
172
  return snapshot(app, table);
157
173
  }
174
+ /** A field of the player's status (WP-0150 decision 2): the component renders when its value changes, not at frames. */
175
+ function useFollowed(field) {
176
+ const [app] = useBridge();
177
+ const f = useMemo(() => follow(app, field), [app, field]);
178
+ return useSyncExternalStore(f.subscribe, f.get, f.get);
179
+ }
180
+ /** The player's state: starting, playing, paused, stopped, idle or error. Null where the app has no controller (the designer). */
181
+ export function usePlayback() { return useFollowed("playback"); }
182
+ /** Whether the screen is online or offline; null without a controller. */
183
+ export function useConnection() { return useFollowed("connection"); }
184
+ /** The screen's volume and whether it is muted; null without a controller, or where the host sets no audio. */
185
+ export function useAudio() { return useFollowed("audio"); }
186
+ /** The items playing (the status's `now`); null without a controller. */
187
+ export function usePlaying() { return useFollowed("now"); }
188
+ /**
189
+ * Calls `handler` with the argument of each command of this name sent to the screen's apps (`apps.sendCommand`,
190
+ * WP-0150 decision 3), from mount until the component goes. The handler may change between renders: the latest is called.
191
+ */
192
+ export function useCommand(name, handler) {
193
+ const [app] = useBridge();
194
+ const latest = useRef(handler);
195
+ latest.current = handler;
196
+ useEffect(() => onCommand(app, name, argument => latest.current(argument)), [app, name]);
197
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Table rows and the player's state as Preact signals (WP-0149 decision 5, WP-0150 decision 1;
3
+ * `@wyndlass/app-sdk/signals`): for an app that updates its DOM when they change, in an effect, rather than at every
4
+ * frame. An entry of its own, so an app that does not import it does not bundle it. Its signals are this copy of
5
+ * `@preact/signals-core`'s: take effect, computed and the rest from here too, as an effect from another copy does not
6
+ * see them.
7
+ */
8
+ import { type ReadonlySignal } from "@preact/signals-core";
9
+ import type { PlayerAPIStatus } from "./contract/player-api-status.js";
10
+ import { type WyndlassApp } from "./index.js";
11
+ export { batch, computed, effect, signal, untracked, type ReadonlySignal, type Signal } from "@preact/signals-core";
12
+ /**
13
+ * A data table's snapshot, as snapshot() gives it, as a read-only signal: its value changes when the rows do, and is the
14
+ * same object until then. Read again when the screen says the table changed, and at each frame, since a binding's rows
15
+ * are chosen on the layout's clock (WP-0137). Make it in mount(), where the app has its context.
16
+ *
17
+ * On a player before WP-0149, which makes the app again when a table changes, it keeps the rows the app was made with.
18
+ */
19
+ export declare function tableSignal(app: WyndlassApp, table: string): ReadonlySignal<unknown | null>;
20
+ /**
21
+ * The player's state as a read-only signal: starting, playing, paused, stopped, idle or error, set on each
22
+ * `state.changed`. Null where the app has no controller (the designer). Make it in mount(), where the app has its context.
23
+ */
24
+ export declare function playbackSignal(app: WyndlassApp): ReadonlySignal<PlayerAPIStatus["state"] | null>;
25
+ /** Whether the screen is online or offline, as a read-only signal set on each `connection.changed`; null without a controller. */
26
+ export declare function connectionSignal(app: WyndlassApp): ReadonlySignal<PlayerAPIStatus["connection"] | null>;
27
+ /**
28
+ * The screen's volume and whether it is muted, as a read-only signal set on each `audio.changed`. Null without a
29
+ * controller, or on a screen whose host sets no audio.
30
+ */
31
+ export declare function audioSignal(app: WyndlassApp): ReadonlySignal<NonNullable<PlayerAPIStatus["audio"]> | null>;
32
+ /** The items playing (the status's `now`), as a read-only signal read again on each `item.start` and `item.end`; null without a controller. */
33
+ export declare function nowSignal(app: WyndlassApp): ReadonlySignal<PlayerAPIStatus["now"] | null>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Table rows and the player's state as Preact signals (WP-0149 decision 5, WP-0150 decision 1;
3
+ * `@wyndlass/app-sdk/signals`): for an app that updates its DOM when they change, in an effect, rather than at every
4
+ * frame. An entry of its own, so an app that does not import it does not bundle it. Its signals are this copy of
5
+ * `@preact/signals-core`'s: take effect, computed and the rest from here too, as an effect from another copy does not
6
+ * see them.
7
+ */
8
+ import { computed, signal } from "@preact/signals-core";
9
+ import { follow } from "./following.js";
10
+ import { eachFrame } from "./frames.js";
11
+ import { contextOf, snapshot } from "./index.js";
12
+ export { batch, computed, effect, signal, untracked } from "@preact/signals-core";
13
+ /**
14
+ * A data table's snapshot, as snapshot() gives it, as a read-only signal: its value changes when the rows do, and is the
15
+ * same object until then. Read again when the screen says the table changed, and at each frame, since a binding's rows
16
+ * are chosen on the layout's clock (WP-0137). Make it in mount(), where the app has its context.
17
+ *
18
+ * On a player before WP-0149, which makes the app again when a table changes, it keeps the rows the app was made with.
19
+ */
20
+ export function tableSignal(app, table) {
21
+ const rows = signal(snapshot(app, table));
22
+ const context = contextOf(app);
23
+ if (context?.onSnapshot) {
24
+ // The same object while the rows are the same: set only when another comes.
25
+ const read = () => { const next = snapshot(app, table); if (next !== rows.peek())
26
+ rows.value = next; };
27
+ context.onSnapshot(changed => { if (changed === table)
28
+ read(); });
29
+ eachFrame(app, read);
30
+ }
31
+ return computed(() => rows.value);
32
+ }
33
+ /** A field of the player's status as a read-only signal (WP-0150 decision 1): set only when its value changes. */
34
+ function followed(app, field) {
35
+ const f = follow(app, field);
36
+ const value = signal(f.get());
37
+ f.subscribe(() => { value.value = f.get(); });
38
+ return computed(() => value.value);
39
+ }
40
+ /**
41
+ * The player's state as a read-only signal: starting, playing, paused, stopped, idle or error, set on each
42
+ * `state.changed`. Null where the app has no controller (the designer). Make it in mount(), where the app has its context.
43
+ */
44
+ export function playbackSignal(app) {
45
+ return followed(app, "playback");
46
+ }
47
+ /** Whether the screen is online or offline, as a read-only signal set on each `connection.changed`; null without a controller. */
48
+ export function connectionSignal(app) {
49
+ return followed(app, "connection");
50
+ }
51
+ /**
52
+ * The screen's volume and whether it is muted, as a read-only signal set on each `audio.changed`. Null without a
53
+ * controller, or on a screen whose host sets no audio.
54
+ */
55
+ export function audioSignal(app) {
56
+ return followed(app, "audio");
57
+ }
58
+ /** The items playing (the status's `now`), as a read-only signal read again on each `item.start` and `item.end`; null without a controller. */
59
+ export function nowSignal(app) {
60
+ return followed(app, "now");
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyndlass/app-sdk",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Build apps for Wyndlass's screens: web components on the app contract (Player API 2), with the runtime Wyndlass's own apps use.",
5
5
  "keywords": [
6
6
  "wyndlass",
@@ -33,6 +33,10 @@
33
33
  "types": "./dist/react.d.ts",
34
34
  "import": "./dist/react.js"
35
35
  },
36
+ "./signals": {
37
+ "types": "./dist/signals.d.ts",
38
+ "import": "./dist/signals.js"
39
+ },
36
40
  "./app-definition.schema.json": "./protocol/app-definition.schema.json",
37
41
  "./common.schema.json": "./protocol/common.schema.json"
38
42
  },
@@ -49,6 +53,7 @@
49
53
  "node": ">=20"
50
54
  },
51
55
  "dependencies": {
56
+ "@preact/signals-core": "1.14.5",
52
57
  "acorn": "8.19.0",
53
58
  "ajv": "8.17.1",
54
59
  "ajv-formats": "3.0.1",
@@ -91,6 +91,8 @@ Screens or the CMS enforce each of these:
91
91
 
92
92
  One more, which nothing enforces: **make nothing in a draw.** `draw(epochMs)` and a React component run again at every frame, many times a second. Make formatters (`Intl.DateTimeFormat`, `Intl.NumberFormat`) and other objects once: in `mount()`, or with `useMemo` on what they depend on. 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.
93
93
 
94
+ And **read a table's rows where you draw,** never once in `mount()`: a screen gives the app a table's new rows while it plays, within about a second of the save. Use `snapshot(this, table)` in `draw()`, `useSnapshot(table)` in a component, or `tableSignal(this, table)` with an `effect` (`@wyndlass/app-sdk/signals`). The rows are frozen: copy them before you sort or change them.
95
+
94
96
  ## When something goes wrong
95
97
 
96
98
  - **A tool you need is missing:** call `whoami`. The import tools are listed only while the organization has imported apps turned on at its top place. An Owner or Admin turns them on in the CMS, under **Settings**, **Imported apps**.
@@ -27,8 +27,9 @@ A paged app also defines `paging()`, returning `{count, ms}`, so the layout can
27
27
  - `data(this, source, params)`: the data service's answer, a promise. Only for sources `app.json`'s `data` lists;
28
28
  - `kept(this, source, params)`: the answer the screen last kept for the same request, at once, or `undefined`. Draw it while `data` asks again, so the app is ready without the network;
29
29
  - `image(this, path)`: a picture a data answer names, as a URL the app can draw;
30
- - `snapshot(this, table)`: the rows of a table the layout names, in a `datatable` param or in the element's `binding`. Where a binding chooses rows (a filter, a sort, a limit), only those. `null` when the screen has no snapshot of that table. A `datatable` param needs no binding;
31
- - `controllerOf(this)`: the player's controller, as `capabilities.player` allows; absent in the designer and previews;
30
+ - `snapshot(this, table)`: the rows of a table the layout names, in a `datatable` param or in the element's `binding`. Where a binding chooses rows (a filter, a sort, a limit), only those. `null` when the screen has no snapshot of that table. A `datatable` param needs no binding. A screen gives the app a table's new rows while it plays: read them where you draw (in `draw()`, `useSnapshot` or `tableSignal`), never once in `mount()`. They are the same object until they change, and frozen: copy rows before you sort or change them;
31
+ - `controllerOf(this)`: the player's controller, as `capabilities.player` allows; absent in the designer and the CMS's previews (`wyndlass-app preview` gives a stand-in). Follow its state with the signals or hooks below, never by reading `status()` at each frame;
32
+ - `onCommand(this, name, handler)`: each command of that name sent to the screen's apps (`apps.sendCommand`), with its argument; returns how to stop. A command is an event: the same one sent twice runs twice. Apps cannot answer one;
32
33
  - `impressionsOf(this)`: the people at the screen, for an app whose definition declares the `impressions` sensor;
33
34
  - `contextOf(this)`: the rest of the context (the screen's `place` and facts, `preview`).
34
35
 
@@ -43,7 +44,18 @@ A paged app also defines `paging()`, returning `{count, ms}`, so the layout can
43
44
  - `useNow(everyMs?)`: the layout's clock. Without `everyMs`, drawn again at each of its frames, so the component renders many times a second. With it, the time is a multiple of `everyMs` (`useNow(1000)`: whole seconds), and the component renders only when that changes: take the coarsest the app shows. Either way, make formatters and other objects with `useMemo`, never in the render;
44
45
  - `usePaging(count, ms)`: the page showing now, rendering when it turns. It also tells the host the pages;
45
46
  - `useData(source, params)`: `{answer, error}`. The kept answer at once, then the service's, asked again every 10 minutes;
46
- - `useSnapshot(table)`: a data table's rows, as `snapshot` gives them, read again each frame.
47
+ - `useSnapshot(table)`: a data table's rows, as `snapshot` gives them. The component renders when they change;
48
+ - `usePlayback()`, `useConnection()`, `useAudio()`, `usePlaying()`: the player's state (starting, playing, paused, stopped, idle, error), online or offline, `{volume, muted}`, and the items playing. Each renders only when its value changes; `null` without a controller;
49
+ - `useCommand(name, handler)`: each command of that name, with its argument, while the component is mounted.
50
+
51
+ ## Signals (`@wyndlass/app-sdk/signals`)
52
+
53
+ For a vanilla app that updates its DOM when its rows or the player's state change, rather than at every frame:
54
+ - `tableSignal(this, table)`: a read-only Preact signal of the table's snapshot, made in `mount()`. Its value changes when the rows do;
55
+ - `playbackSignal(this)`, `connectionSignal(this)`, `audioSignal(this)`, `nowSignal(this)`: the player's state, online or offline, `{volume, muted}`, and the items playing, as read-only signals made in `mount()`. Each is set only when its value changes; `null` without a controller;
56
+ - `effect`, `computed`, `batch`, `signal`, `untracked`: Preact's, from the same copy. Import them from here, never from `@preact/signals-core`, whose effects would not see these signals.
57
+
58
+ Draw in an `effect(() => { … rows.value … })` made in `mount()`, and leave `draw()` empty.
47
59
 
48
60
  ## The definition, `app.json`
49
61
 
@@ -29,10 +29,11 @@ Two pages, eight seconds each: a heading and the time, then the weather for the
29
29
  1. **Change `app.json` first:** the `params` people set in the designer (each with a `label` and a `default`), the `data` sources it reads, and `capabilities` if it asks for more.
30
30
  2. **Read each param** with `prop(name, default)` (vanilla) or `useApp().prop(name, default)` (react), with the same default as `app.json`.
31
31
  3. **Draw from the layout's clock:** vanilla's `draw(epochMs)` gets the moment; react's `useNow()` gives it, and `useNow(1000)` gives whole seconds, rendering once a second. Work out what changes with time (a page, a countdown, a clock) from that number, never from a timer of your own. Both run at every frame: make a formatter once, as the templates do (`#format` in `mount()`, `useMemo` in react), never in the draw.
32
- 4. **Say `ready()`** as soon as the app has something to show, within 5 s: the kept answer, a default, or the failure.
33
- 5. **Styles** go in `src/app.css`. Size text to the box with `fit()` or the `--w` and `--h` properties ([contract.md](contract.md)), since screens run from 1080p to 4K.
34
- 6. **Images:** import them from `src/` (`import logo from "./logo.png"`: PNG, JPEG, GIF, WebP or SVG), and `pack` inlines each as a data URL, for an `<img>`'s `src` or a style set from code. A `url()` inside `src/app.css` is not bundled.
35
- 7. **Fonts:** give the app a param named `font`, of the kind `font`, so the person picks a font in the designer. Apply it as CSS with `font()` (vanilla), or `typographyCss(app.prop("font", null))` (react) ([contract.md](contract.md)).
36
- 8. **Delete what the template drew** that the person did not ask for, its weather included, and its `data` entry with it.
32
+ 4. **Read a table's rows where you draw,** for an app with a `datatable` param: `snapshot(this, table)` in `draw()`, `tableSignal(this, table)` with an `effect` (vanilla, `@wyndlass/app-sdk/signals`), or `useSnapshot(table)` (react). A screen gives the app a table's new rows while it plays, so a copy kept from `mount()` goes stale. The rows are frozen: copy before you sort them. In the preview, edit a table's rows and check the app follows without being made again.
33
+ 5. **Say `ready()`** as soon as the app has something to show, within 5 s: the kept answer, a default, or the failure.
34
+ 6. **Styles** go in `src/app.css`. Size text to the box with `fit()` or the `--w` and `--h` properties ([contract.md](contract.md)), since screens run from 1080p to 4K.
35
+ 7. **Images:** import them from `src/` (`import logo from "./logo.png"`: PNG, JPEG, GIF, WebP or SVG), and `pack` inlines each as a data URL, for an `<img>`'s `src` or a style set from code. A `url()` inside `src/app.css` is not bundled.
36
+ 8. **Fonts:** give the app a param named `font`, of the kind `font`, so the person picks a font in the designer. Apply it as CSS with `font()` (vanilla), or `typographyCss(app.prop("font", null))` (react) ([contract.md](contract.md)).
37
+ 9. **Delete what the template drew** that the person did not ask for, its weather included, and its `data` entry with it.
37
38
 
38
39
  Each source change redraws the preview. `npm run typecheck` and `npm run check` find mistakes before the person sees them.
@@ -5,6 +5,8 @@ An app for Wyndlass's screens, on `@wyndlass/app-sdk` and React. Its README says
5
5
  - **The module is `src/app.tsx`;** its default export is `reactApp(Component, { css })`, the element's class. The component renders in the app's shadow root.
6
6
  - **Time:** `useNow()` gives the layout's clock, drawn again at each of its frames, and `useNow(1000)` whole seconds, rendering once a second: take the coarsest the app shows; never `Date.now()`, so a seek or a pause shows the right moment.
7
7
  - **Nothing made in a render:** the component renders at every frame, many times a second. Make formatters (`Intl.DateTimeFormat`, `Intl.NumberFormat`) and other objects with `useMemo` on what they depend on, 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.
8
+ - **A table's rows from `useSnapshot(table)`:** a screen gives the app a table's new rows while it plays, and the component renders when they change. Never keep a copy made once. They are frozen: copy before you sort or change them.
9
+ - **The player's state** (playing or paused, online or offline, its sound): `usePlayback()`, `useConnection()` and `useAudio()`, which render only when it changes. **Commands** sent to the screen's apps: `useCommand(name, handler)`. A command is an event, and the app cannot answer it.
8
10
  - **Ready:** call `useApp().ready()` once drawn, within 5 s, or the screen reports the app late. `useData()` gives the answer the screen kept at once, then the service's.
9
11
  - **Data:** only through `useData(source, params)`, and only the sources `app.json`'s `data` lists. Pages: `usePaging(count, ms)`.
10
12
  - **Styles:** `src/app.css`, passed as `css`, never a `<style>`.
@@ -5,6 +5,8 @@ An app for Wyndlass's screens, on `@wyndlass/app-sdk`. Its README says what it i
5
5
  - **The module is `src/app.ts`;** its default export is the element's class, extending `WyndlassApp`. `mount()` builds the shadow DOM once; `draw(epochMs)` draws that moment of the layout's clock.
6
6
  - **Time:** from `now()` or `draw`'s `epochMs`, never `Date.now()`, so a seek or a pause shows the right moment.
7
7
  - **Nothing made in `draw`:** it runs at every frame, many times a second. Make formatters (`Intl.DateTimeFormat`, `Intl.NumberFormat`) and other objects in `mount()`, never in `draw`. 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.
8
+ - **A table's rows where you draw:** a screen gives the app a table's new rows while it plays. Read them with `snapshot(this, table)` in `draw`, or with `tableSignal(this, table)` and an `effect` from `@wyndlass/app-sdk/signals`, made in `mount()`. Never read them once in `mount()` and keep them. They are frozen: copy before you sort or change them.
9
+ - **The player's state** (playing or paused, online or offline, its sound): `playbackSignal(this)`, `connectionSignal(this)` and `audioSignal(this)` from `@wyndlass/app-sdk/signals`, made in `mount()`, never `status()` read at each frame. **Commands** sent to the screen's apps: `onCommand(this, name, handler)`. A command is an event, and the app cannot answer it.
8
10
  - **Ready:** call `ready()` once drawn, within 5 s, or the screen reports the app late. Draw the answer `kept()` gives at once, then ask `data()`.
9
11
  - **Data:** only through `data(this, source, params)`, and only the sources `app.json`'s `data` lists. Images a data answer names through `image(this, path)`.
10
12
  - **Styles:** `this.css(text)`, never a `<style>`. A CSS file the source imports is its text.