@wyndlass/app-sdk 0.0.0-stage → 1.0.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +196 -2
  3. package/cli/app-package.mjs +111 -0
  4. package/cli/commands.mjs +114 -0
  5. package/cli/main.mjs +66 -0
  6. package/cli/package.mjs +119 -0
  7. package/cli/preview.mjs +142 -0
  8. package/cli/zip.mjs +63 -0
  9. package/dist/contract/app-contract.d.ts +221 -0
  10. package/dist/contract/app-contract.js +6 -0
  11. package/dist/contract/app-definition.d.ts +155 -0
  12. package/dist/contract/app-definition.js +3 -0
  13. package/dist/contract/data-answer.d.ts +40 -0
  14. package/dist/contract/data-answer.js +3 -0
  15. package/dist/contract/data-calendar.d.ts +57 -0
  16. package/dist/contract/data-calendar.js +3 -0
  17. package/dist/contract/data-news.d.ts +29 -0
  18. package/dist/contract/data-news.js +3 -0
  19. package/dist/contract/data-quotes.d.ts +75 -0
  20. package/dist/contract/data-quotes.js +3 -0
  21. package/dist/contract/data-series.d.ts +51 -0
  22. package/dist/contract/data-series.js +3 -0
  23. package/dist/contract/data-snapshot.d.ts +30 -0
  24. package/dist/contract/data-snapshot.js +3 -0
  25. package/dist/contract/data-weather.d.ts +421 -0
  26. package/dist/contract/data-weather.js +3 -0
  27. package/dist/contract/player-api-event.d.ts +253 -0
  28. package/dist/contract/player-api-event.js +3 -0
  29. package/dist/contract/player-api-status.d.ts +178 -0
  30. package/dist/contract/player-api-status.js +3 -0
  31. package/dist/easing.d.ts +16 -0
  32. package/dist/easing.js +71 -0
  33. package/dist/index.d.ts +94 -0
  34. package/dist/index.js +218 -0
  35. package/dist/preview/frame/frame.js +2 -0
  36. package/dist/preview/frame/index.html +12 -0
  37. package/dist/preview/index.html +40 -0
  38. package/dist/preview/page.js +7 -0
  39. package/dist/qr.d.ts +5 -0
  40. package/dist/qr.js +14 -0
  41. package/dist/react.d.ts +52 -0
  42. package/dist/react.js +149 -0
  43. package/package.json +83 -3
  44. package/protocol/app-definition.schema.json +435 -0
  45. package/protocol/app-packages.json +172 -0
  46. package/protocol/common.schema.json +197 -0
  47. package/protocol/samples/calendar.json +63 -0
  48. package/protocol/samples/events/app.command.json +14 -0
  49. package/protocol/samples/events/item.end.json +13 -0
  50. package/protocol/samples/events/item.start.json +18 -0
  51. package/protocol/samples/events/state.changed.json +11 -0
  52. package/protocol/samples/news.json +37 -0
  53. package/protocol/samples/player-status.json +58 -0
  54. package/protocol/samples/weather.json +59 -0
  55. package/templates/react/AGENTS.md +13 -0
  56. package/templates/react/README.md +22 -0
  57. package/templates/react/app.json +14 -0
  58. package/templates/react/gitignore +2 -0
  59. package/templates/react/package.json +22 -0
  60. package/templates/react/src/app.css +7 -0
  61. package/templates/react/src/app.tsx +39 -0
  62. package/templates/react/src/env.d.ts +5 -0
  63. package/templates/react/tsconfig.json +13 -0
  64. package/templates/vanilla/AGENTS.md +13 -0
  65. package/templates/vanilla/README.md +22 -0
  66. package/templates/vanilla/app.json +14 -0
  67. package/templates/vanilla/gitignore +2 -0
  68. package/templates/vanilla/package.json +18 -0
  69. package/templates/vanilla/src/app.css +7 -0
  70. package/templates/vanilla/src/app.ts +59 -0
  71. package/templates/vanilla/src/env.d.ts +5 -0
  72. package/templates/vanilla/tsconfig.json +12 -0
@@ -0,0 +1,197 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.wyndlass.ai/protocol/v0/common.schema.json",
4
+ "title": "Common definitions",
5
+ "description": "Shared identifiers and value types used by every protocol message.",
6
+ "$defs": {
7
+ "tenantId": {
8
+ "type": "string",
9
+ "pattern": "^ten_[0-9a-hjkmnp-tv-z]{26}$",
10
+ "description": "Tenant id: type prefix plus a base32-encoded UUIDv7."
11
+ },
12
+ "nodeId": {
13
+ "type": "string",
14
+ "pattern": "^node_[0-9a-hjkmnp-tv-z]{26}$",
15
+ "description": "Org-tree node id."
16
+ },
17
+ "deviceId": {
18
+ "type": "string",
19
+ "pattern": "^dev_[0-9a-hjkmnp-tv-z]{26}$",
20
+ "description": "Device id."
21
+ },
22
+ "mediaId": {
23
+ "type": "string",
24
+ "pattern": "^med_[0-9a-hjkmnp-tv-z]{26}$",
25
+ "description": "Media item id (asset, app, composition, playlist or live source)."
26
+ },
27
+ "campaignId": {
28
+ "type": "string",
29
+ "pattern": "^cmp_[0-9a-hjkmnp-tv-z]{26}$",
30
+ "description": "Campaign id."
31
+ },
32
+ "adCampaignId": {
33
+ "type": "string",
34
+ "pattern": "^adc_[0-9a-hjkmnp-tv-z]{26}$",
35
+ "description": "Ad campaign id (WP-0075): a campaign sold to an advertiser."
36
+ },
37
+ "adDecisionId": {
38
+ "type": "string",
39
+ "pattern": "^add_[0-9a-hjkmnp-tv-z]{26}$",
40
+ "description": "Ad decision id (ADR 0075): the broker's answer for one ad slot."
41
+ },
42
+ "audienceId": {
43
+ "type": "string",
44
+ "pattern": "^aud_[0-9a-hjkmnp-tv-z]{26}$",
45
+ "description": "Audience id (saved targeting)."
46
+ },
47
+ "groupId": {
48
+ "type": "string",
49
+ "pattern": "^grp_[0-9a-hjkmnp-tv-z]{26}$",
50
+ "description": "Device group id."
51
+ },
52
+ "emergencyId": {
53
+ "type": "string",
54
+ "pattern": "^emg_[0-9a-hjkmnp-tv-z]{26}$",
55
+ "description": "Emergency message id."
56
+ },
57
+ "emergencySourceId": {
58
+ "type": "string",
59
+ "pattern": "^esr_[0-9a-hjkmnp-tv-z]{26}$",
60
+ "description": "Emergency source id: a CAP feed, the National Weather Service or IPAWS at a node (ADR 0127)."
61
+ },
62
+ "changeSetId": {
63
+ "type": "string",
64
+ "pattern": "^chg_[0-9a-hjkmnp-tv-z]{26}$",
65
+ "description": "Change set id."
66
+ },
67
+ "principalRef": {
68
+ "type": "string",
69
+ "pattern": "^(user|agent|device|client|system):[A-Za-z0-9_.:-]{1,128}$",
70
+ "description": "A principal, e.g. user:u_123, agent:fleet-guardian, device:dev_..."
71
+ },
72
+ "sha256": {
73
+ "type": "string",
74
+ "pattern": "^[0-9a-f]{64}$",
75
+ "description": "Lowercase hex SHA-256 digest."
76
+ },
77
+ "timestamp": {
78
+ "type": "string",
79
+ "format": "date-time",
80
+ "description": "RFC 3339 timestamp with offset."
81
+ },
82
+ "sourceKind": {
83
+ "type": "string",
84
+ "pattern": "^[a-z][a-z0-9-]{0,31}$",
85
+ "description": "A kind of sensor that measures impressions (WP-0105, ADR 0105): camera, proximity (WP-0043), and later ble, rfid, lidar and others. A pattern, not an enum, so a new kind is never a breaking change for players in the field."
86
+ },
87
+ "inputName": {
88
+ "type": "string",
89
+ "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$",
90
+ "description": "A named input on a screen (WP-0043, ADR 0107), which a serial leaf's input names: a sensor map's, or one the player names itself from a sensor's family and channel, such as pickup-3, or button-2.1 for one button of several."
91
+ },
92
+ "sensorFamily": {
93
+ "enum": ["pickup", "presence", "lidar", "rfid", "button", "gesture", "motion", "light", "climate", "camera", "io"],
94
+ "description": "What a sensor on a controller's channel does (WP-0043, ADR 0107): pickup (a product lifted and put back), presence (a person's distance), lidar (people in zones), rfid (tags placed and lifted), button, gesture, motion, light, climate (temperature and humidity), camera (a person's presence, gaze and, where allowed, estimated age and gender) and io (an input that is on or off)."
95
+ },
96
+ "bleAddress": {
97
+ "type": "string",
98
+ "pattern": "^[0-9A-F]{2}(:[0-9A-F]{2}){5}$",
99
+ "description": "A Bluetooth device address (WP-0044, ADR 0108): six octets in upper-case hex, most significant first, such as 0C:F3:EE:00:12:34."
100
+ },
101
+ "beaconFrame": {
102
+ "enum": ["ibeacon", "eddystone-uid", "eddystone-url", "eddystone-tlm", "altbeacon"],
103
+ "description": "A beacon's advertisement as the player reads it (WP-0044, ADR 0108): iBeacon; Eddystone's UID, URL or TLM (telemetry: battery, temperature and counts); or AltBeacon."
104
+ },
105
+ "gpioPin": {
106
+ "type": "integer",
107
+ "minimum": 0,
108
+ "maximum": 63,
109
+ "description": "A GPIO pin as the shell numbers it (WP-0044, ADR 0108): its line on the box's GPIO chip, as the gpio leaf's pin names it."
110
+ },
111
+ "demographics": {
112
+ "description": "Impressions by estimated gender and age group, counted only where a place allows it (WP-0043, ADR 0107): each presence counted once, by the estimate its sensor gave most often. Never anything about one person.",
113
+ "type": "object",
114
+ "required": ["female", "male", "genderUnknown", "age", "ageUnknown"],
115
+ "additionalProperties": false,
116
+ "properties": {
117
+ "female": { "type": "integer", "minimum": 0 },
118
+ "male": { "type": "integer", "minimum": 0 },
119
+ "genderUnknown": { "type": "integer", "minimum": 0, "description": "Presences whose gender the sensor did not estimate, or was unsure of." },
120
+ "age": {
121
+ "type": "array",
122
+ "minItems": 8,
123
+ "maxItems": 8,
124
+ "items": { "type": "integer", "minimum": 0 },
125
+ "description": "Presences by the sensor's age group: 0 to 11, 12 to 17, 18 to 24, 25 to 34, 35 to 44, 45 to 54, 55 to 64, and 65 or more."
126
+ },
127
+ "ageUnknown": { "type": "integer", "minimum": 0 }
128
+ }
129
+ },
130
+ "date": {
131
+ "type": "string",
132
+ "format": "date"
133
+ },
134
+ "localTime": {
135
+ "type": "string",
136
+ "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
137
+ "description": "Wall-clock time HH:MM in the device's time zone."
138
+ },
139
+ "timeZone": {
140
+ "type": "string",
141
+ "pattern": "^[A-Za-z_]+(/[A-Za-z0-9_+-]+){0,2}$|^UTC$",
142
+ "description": "IANA time zone name."
143
+ },
144
+ "locale": {
145
+ "type": "string",
146
+ "pattern": "^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2}|-[0-9]{3})?$",
147
+ "description": "BCP 47 language tag."
148
+ },
149
+ "semver": {
150
+ "type": "string",
151
+ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(-[0-9A-Za-z.-]+)?$"
152
+ },
153
+ "itemRef": {
154
+ "type": "object",
155
+ "description": "A pinned reference to a media item version.",
156
+ "required": [
157
+ "id",
158
+ "version"
159
+ ],
160
+ "additionalProperties": false,
161
+ "properties": {
162
+ "id": {
163
+ "$ref": "common.schema.json#/$defs/mediaId"
164
+ },
165
+ "version": {
166
+ "type": "integer",
167
+ "minimum": 1
168
+ }
169
+ }
170
+ },
171
+ "signature": {
172
+ "type": "object",
173
+ "description": "Detached Ed25519 signature over the UTF-8 bytes of the RFC 8785 canonical JSON of the message without this field. keyId names a public key the verifier already trusts (distributed at enrollment); never fetch a key named by the message.",
174
+ "required": [
175
+ "alg",
176
+ "keyId",
177
+ "value"
178
+ ],
179
+ "additionalProperties": false,
180
+ "properties": {
181
+ "alg": {
182
+ "const": "ed25519"
183
+ },
184
+ "keyId": {
185
+ "type": "string",
186
+ "minLength": 1,
187
+ "maxLength": 128
188
+ },
189
+ "value": {
190
+ "type": "string",
191
+ "contentEncoding": "base64",
192
+ "minLength": 16
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "calendar": {
3
+ "name": "Prairie Grocers"
4
+ },
5
+ "events": [
6
+ {
7
+ "id": "staff-meeting@prairie.test/20261012T133000Z",
8
+ "title": "Staff meeting, Store 12",
9
+ "location": "Back office; Store 12",
10
+ "start": "2026-10-12T13:30:00Z",
11
+ "end": "2026-10-12T14:00:00Z"
12
+ },
13
+ {
14
+ "id": "harvest@prairie.test",
15
+ "title": "Fall harvest sale",
16
+ "start": "2026-10-16",
17
+ "end": "2026-10-19",
18
+ "allDay": true
19
+ },
20
+ {
21
+ "id": "staff-meeting@prairie.test/20261019T133000Z",
22
+ "title": "Staff meeting, Store 12",
23
+ "location": "Back office; Store 12",
24
+ "start": "2026-10-19T13:30:00Z",
25
+ "end": "2026-10-19T14:00:00Z"
26
+ },
27
+ {
28
+ "id": "webinar@prairie.test",
29
+ "title": "Supplier webinar",
30
+ "start": "2026-10-20T15:00:00Z",
31
+ "end": "2026-10-20T15:45:00Z",
32
+ "tentative": true
33
+ },
34
+ {
35
+ "id": "staff-meeting@prairie.test/20261022T133000Z",
36
+ "title": "Staff meeting, Store 12",
37
+ "location": "Back office; Store 12",
38
+ "start": "2026-10-22T13:30:00Z",
39
+ "end": "2026-10-22T14:00:00Z"
40
+ },
41
+ {
42
+ "id": "staff-meeting@prairie.test/20261026T133000Z",
43
+ "title": "Staff meeting, Store 12",
44
+ "location": "Back office; Store 12",
45
+ "start": "2026-10-26T13:30:00Z",
46
+ "end": "2026-10-26T14:00:00Z"
47
+ },
48
+ {
49
+ "id": "staff-meeting@prairie.test/20261029T133000Z",
50
+ "title": "Staff meeting, Store 12",
51
+ "location": "Back office; Store 12",
52
+ "start": "2026-10-29T13:30:00Z",
53
+ "end": "2026-10-29T14:00:00Z"
54
+ },
55
+ {
56
+ "id": "staff-meeting@prairie.test/20261102T143000Z",
57
+ "title": "Staff meeting, Store 12",
58
+ "location": "Back office; Store 12",
59
+ "start": "2026-11-02T14:30:00Z",
60
+ "end": "2026-11-02T15:00:00Z"
61
+ }
62
+ ]
63
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "type": "app.command",
3
+ "seq": 42,
4
+ "at": "2026-10-01T14:01:00Z",
5
+ "name": "showSpecial",
6
+ "argument": {
7
+ "sku": "1234"
8
+ },
9
+ "by": {
10
+ "kind": "app",
11
+ "app": "com.wyndlass.menu",
12
+ "instance": "zone-main-1"
13
+ }
14
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "type": "item.end",
3
+ "seq": 40,
4
+ "at": "2026-10-01T13:59:50Z",
5
+ "zone": "main",
6
+ "item": {
7
+ "id": "med_01jc8x5s3h9k2m4n6p8q0r2t4v",
8
+ "version": 3
9
+ },
10
+ "blob": "3c8e1f5a9b2d6e0c4f7a1b5d8e2c6f9a3b7d0e4c8f1a5b9d2e6c0f3a7b1d4e8c",
11
+ "endedAt": "2026-10-01T13:59:50Z",
12
+ "reason": "finished"
13
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "type": "item.start",
3
+ "seq": 41,
4
+ "at": "2026-10-01T13:59:50Z",
5
+ "zone": "main",
6
+ "item": {
7
+ "id": "med_01jc8x5s3h9k2m4n6p8q0r2t4v",
8
+ "version": 3
9
+ },
10
+ "blob": "3c8e1f5a9b2d6e0c4f7a1b5d8e2c6f9a3b7d0e4c8f1a5b9d2e6c0f3a7b1d4e8c",
11
+ "contentType": "video/mp4",
12
+ "startedAt": "2026-10-01T13:59:50Z",
13
+ "durationMs": 8000,
14
+ "transition": {
15
+ "asked": "crossfade",
16
+ "played": "cut"
17
+ }
18
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "type": "state.changed",
3
+ "seq": 41,
4
+ "at": "2026-10-01T14:00:00Z",
5
+ "state": "paused",
6
+ "previous": "playing",
7
+ "by": {
8
+ "kind": "remote",
9
+ "principal": "agent:fleet-guardian"
10
+ }
11
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "feed": {
3
+ "title": "Prairie Grocers News",
4
+ "url": "https://news.example.org/feed.xml",
5
+ "link": "https://news.example.org/",
6
+ "image": "https://news.example.org/logo.png"
7
+ },
8
+ "items": [
9
+ {
10
+ "id": "harvest-2026",
11
+ "title": "Fall harvest arrives & prices drop",
12
+ "link": "https://news.example.org/harvest",
13
+ "summary": "Squash, apples and more .",
14
+ "published": "2026-10-03T14:00:00.000Z",
15
+ "author": "Olive Owner",
16
+ "image": "/v0/image?url=https%3A%2F%2Fcdn.example.org%2Fharvest.jpg"
17
+ },
18
+ {
19
+ "title": "New bakery hours",
20
+ "link": "https://news.example.org/bakery",
21
+ "summary": "Open at 6 — every day.",
22
+ "published": "2026-10-02T14:30:00.000Z",
23
+ "image": "/v0/image?url=https%3A%2F%2Fcdn.example.org%2Fbakery.webp"
24
+ },
25
+ {
26
+ "title": "Meet our farmers",
27
+ "link": "https://news.example.org/farmers",
28
+ "summary": "Three families, one field.",
29
+ "image": "/v0/image?url=https%3A%2F%2Fcdn.example.org%2Ffarmers.png"
30
+ },
31
+ {
32
+ "title": "Store 12 reopens",
33
+ "link": "https://news.example.org/store-12",
34
+ "image": "/v0/image?url=https%3A%2F%2Fnews.example.org%2Fimg%2Fstore-12.jpg"
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "apiVersion": 1,
3
+ "device": {
4
+ "id": "dev_01j9zq3k7m2p4r6t8v0w2x4y7a",
5
+ "type": "linux-player",
6
+ "shell": "universal-electron",
7
+ "shellVersion": "0.1.0",
8
+ "coreVersion": "0.1.0",
9
+ "model": "NUC 13 Pro",
10
+ "resolution": {
11
+ "width": 1920,
12
+ "height": 1080
13
+ },
14
+ "orientation": "landscape"
15
+ },
16
+ "mode": "player",
17
+ "timeZone": "America/Chicago",
18
+ "locale": "en-US",
19
+ "connection": "online",
20
+ "state": "paused",
21
+ "held": {
22
+ "by": {
23
+ "kind": "remote",
24
+ "principal": "agent:fleet-guardian"
25
+ },
26
+ "since": "2026-10-01T14:00:00Z"
27
+ },
28
+ "content": {
29
+ "manifest": "3c8e1f5a9b2d6e0c4f7a1b5d8e2c6f9a3b7d0e4c8f1a5b9d2e6c0f3a7b1d4e8c",
30
+ "issuedAt": "2026-10-01T13:00:00Z",
31
+ "switchedAt": "2026-10-01T13:00:04Z"
32
+ },
33
+ "now": [
34
+ {
35
+ "zone": "main",
36
+ "item": {
37
+ "id": "med_01jc8x5s3h9k2m4n6p8q0r2t4v",
38
+ "version": 3
39
+ },
40
+ "blob": "3c8e1f5a9b2d6e0c4f7a1b5d8e2c6f9a3b7d0e4c8f1a5b9d2e6c0f3a7b1d4e8c",
41
+ "contentType": "image/png",
42
+ "startedAt": "2026-10-01T13:59:50Z",
43
+ "durationMs": 10000,
44
+ "positionMs": 4200
45
+ }
46
+ ],
47
+ "audio": {
48
+ "volume": 40,
49
+ "muted": false
50
+ },
51
+ "capabilities": {
52
+ "audio": true,
53
+ "display": {
54
+ "power": false,
55
+ "brightness": false
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,59 @@
1
+ {
2
+ "place": {
3
+ "lat": 49.9,
4
+ "lon": -97.1
5
+ },
6
+ "units": "metric",
7
+ "timeZone": "America/Winnipeg",
8
+ "current": {
9
+ "at": "2026-10-03T22:30:00-05:00",
10
+ "temperature": 9.9,
11
+ "apparentTemperature": 7.8,
12
+ "condition": "clear",
13
+ "code": 0,
14
+ "isDay": false,
15
+ "humidity": 79,
16
+ "windSpeed": 8.2,
17
+ "windDirection": 308,
18
+ "precipitationProbability": 0
19
+ },
20
+ "daily": [
21
+ {
22
+ "date": "2026-10-03",
23
+ "high": 17.1,
24
+ "low": 9.5,
25
+ "condition": "drizzle",
26
+ "code": 51,
27
+ "precipitationProbability": 34,
28
+ "sunrise": "2026-10-03T07:31:00-05:00",
29
+ "sunset": "2026-10-03T19:01:00-05:00"
30
+ },
31
+ {
32
+ "date": "2026-10-04",
33
+ "high": 16.5,
34
+ "low": 5.1,
35
+ "condition": "mostly-clear",
36
+ "code": 1,
37
+ "precipitationProbability": 0,
38
+ "sunrise": "2026-10-04T07:33:00-05:00",
39
+ "sunset": "2026-10-04T18:59:00-05:00"
40
+ },
41
+ {
42
+ "date": "2026-10-05",
43
+ "high": 17.9,
44
+ "low": 3.9,
45
+ "condition": "cloudy",
46
+ "code": 3,
47
+ "precipitationProbability": 1,
48
+ "sunrise": "2026-10-05T07:34:00-05:00",
49
+ "sunset": "2026-10-05T18:57:00-05:00"
50
+ }
51
+ ],
52
+ "facts": {
53
+ "temperatureC": 9.9,
54
+ "condition": "clear",
55
+ "precipitationProbability": 0,
56
+ "windKph": 8.2,
57
+ "alertActive": false
58
+ }
59
+ }
@@ -0,0 +1,13 @@
1
+ # Agent instructions: __APP_NAME__
2
+
3
+ An app for Wyndlass's screens, on `@wyndlass/app-sdk` and React. Its README says what it is; these are the rules.
4
+
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
+ - **Time:** `useNow()` gives the layout's clock, drawn again at each of its frames; never `Date.now()`, so a seek or a pause shows the right moment.
7
+ - **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.
8
+ - **Data:** only through `useData(source, params)`, and only the sources `app.json`'s `data` lists. Pages: `usePaging(count, ms)`.
9
+ - **Styles:** `src/app.css`, passed as `css`, never a `<style>`.
10
+ - **No `localStorage`** for anything that matters, **no secrets** in params or the module, and **network hosts declared** in `capabilities.network`.
11
+ - **Look at it:** `npm run preview` (http://127.0.0.1:5199/), in both orientations, with the clock played and held, and once with `npm run preview -- --frame`, as the CMS runs it.
12
+ - **Before importing:** `npm run check`, then `npm run pack`. Raise `version` in `app.json` for each new version.
13
+ - **Importing and scheduling** go through the Wyndlass MCP, with the person's approval: `app.prepareImport`, `npx wyndlass-app upload <url> dist/<zip> --header "name: value"`, `app.import`, then `app.list` until the version is ready or held. A held version waits for a person who may approve media.
@@ -0,0 +1,22 @@
1
+ # __APP_NAME__
2
+
3
+ An app for Wyndlass's screens (`__APP_ID__`), built on [`@wyndlass/app-sdk`](https://www.npmjs.com/package/@wyndlass/app-sdk).
4
+
5
+ - `app.json` is its definition: its id, version, name, element, params and the data it reads.
6
+ - `src/app.tsx` is its module: a React component, made the element's class by `reactApp()`, the default export. `src/app.css` is its styles.
7
+
8
+ ## Commands
9
+
10
+ - `npm run preview`: the app as screens run it, at http://127.0.0.1:5199/, drawn again as you change it. Its controls set the screen's size, the params, the clock, enter and exit, the time zone, the screen's facts and each data source's answer, and give the app tables from CSV or JSON, the people at the screen and the player's events where it uses them. `npm run preview -- --frame` runs it in the frame the CMS gives apps, where it has no storage.
11
+ - `npm run check`: the checks Wyndlass makes when you import it.
12
+ - `npm run pack`: `dist/__APP_ID__-<version>.zip`, the package to import, after the checks.
13
+ - `npm run typecheck`: TypeScript's check.
14
+
15
+ Import the package through the Wyndlass MCP (`app.prepareImport`, `npx wyndlass-app upload`, then `app.import`) or in the CMS (Content, Library, Import an app). A person who may approve media there reviews the app's first version, and any that asks for more than the one before it, before screens play it. Raise `version` in `app.json` for each new one.
16
+
17
+ ## What screens enforce
18
+
19
+ - The time comes from `useNow()`, the layout's clock, never `Date.now()`.
20
+ - `ready()` within 5 s of the app's start.
21
+ - No `localStorage` for anything that matters, and no secrets in params or the module: both are readable.
22
+ - Data only from the sources `app.json` declares (`weather`, `news`, `image` or `calendar`), and the network hosts declared truthfully in `capabilities.network`: a person approves them.
@@ -0,0 +1,14 @@
1
+ {
2
+ "id": "__APP_ID__",
3
+ "version": "0.1.0",
4
+ "name": "__APP_NAME__",
5
+ "element": "__ELEMENT__",
6
+ "description": "A heading and the time on one page, the weather on the next.",
7
+ "params": {
8
+ "heading": { "type": "string", "default": "Good morning", "label": "Heading" },
9
+ "accent": { "type": "color", "default": "#4f8cff", "label": "Accent" },
10
+ "units": { "type": "enum", "values": ["metric", "imperial"], "default": "metric", "label": "Units" },
11
+ "seconds": { "type": "boolean", "default": false, "label": "Show seconds" }
12
+ },
13
+ "data": ["weather"]
14
+ }
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ dist/
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "__PACKAGE__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "preview": "wyndlass-app preview",
8
+ "check": "wyndlass-app check",
9
+ "pack": "wyndlass-app pack",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@wyndlass/app-sdk": "__SDK__",
14
+ "react": "^19.0.0",
15
+ "react-dom": "^19.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^19.0.0",
19
+ "@types/react-dom": "^19.0.0",
20
+ "typescript": "^5.9.3"
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ :host { display: block; width: 100%; height: 100%; color: #ffffff; font-family: system-ui, sans-serif; }
2
+ .page { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: calc(var(--h) * .04); }
3
+ .page[hidden] { display: none; }
4
+ h1 { margin: 0; font-size: min(calc(var(--h) * .14), calc(var(--w) * .08)); font-weight: 600; color: var(--accent); }
5
+ time { font-size: min(calc(var(--h) * .3), calc(var(--w) * .16)); font-variant-numeric: tabular-nums; }
6
+ .temperature { font-size: min(calc(var(--h) * .3), calc(var(--w) * .16)); }
7
+ .condition, .none { font-size: min(calc(var(--h) * .1), calc(var(--w) * .05)); opacity: .85; }
@@ -0,0 +1,39 @@
1
+ // __APP_NAME__: a heading and the time on one page, the weather on the next, a React component on Wyndlass's app SDK.
2
+ // Everything it draws comes from the layout's clock (useNow()), so the designer's seek and pause show the right moment.
3
+ import { useEffect, useMemo } from "react";
4
+ import { color, type weather } from "@wyndlass/app-sdk";
5
+ import { reactApp, useApp, useData, useNow, usePaging } from "@wyndlass/app-sdk/react";
6
+ import css from "./app.css";
7
+
8
+ /** How long each of the two pages shows. */
9
+ const PAGE_MS = 8_000;
10
+
11
+ function Board() {
12
+ const app = useApp();
13
+ const now = useNow();
14
+ const page = usePaging(2, PAGE_MS);
15
+ // The weather for the screen's place, or Winnipeg's where there is none (the designer's sample).
16
+ const place = app.place ?? { lat: 49.9, lon: -97.14 };
17
+ const { answer, error } = useData<weather.Weather>("weather", { lat: place.lat, lon: place.lon, units: app.prop("units", "metric") });
18
+ const time = useMemo(() => new Intl.DateTimeFormat(app.locale, { timeZone: app.timeZone, hour: "numeric", minute: "2-digit",
19
+ ...(app.prop("seconds", false) ? { second: "2-digit" } : {}) }), [app]);
20
+ // Ready once there is something to show: the kept answer at once, else the service's answer or its failure.
21
+ useEffect(() => { if (answer || error) app.ready(); }, [app, answer, error]);
22
+ const w = answer?.data;
23
+ return (
24
+ <div style={{ width: "100%", height: "100%", ["--accent" as string]: color(app.prop("accent", "#4f8cff"), "#4f8cff") }}>
25
+ <section className="page" hidden={page !== 0}>
26
+ <h1>{app.prop("heading", "Good morning")}</h1>
27
+ <time>{time.format(now)}</time>
28
+ </section>
29
+ <section className="page" hidden={page !== 1}>
30
+ {w ? <>
31
+ <div className="temperature">{Math.round(w.current.temperature)}°{w.units === "imperial" ? "F" : "C"}</div>
32
+ <div className="condition">{w.current.condition.replace(/-/g, " ")}</div>
33
+ </> : <div className="none">The weather is on its way.</div>}
34
+ </section>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export default reactApp(Board, { css });
@@ -0,0 +1,5 @@
1
+ // A CSS file the source imports is its text, for css(): pack bundles it so.
2
+ declare module "*.css" {
3
+ const css: string;
4
+ export default css;
5
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["src"]
13
+ }
@@ -0,0 +1,13 @@
1
+ # Agent instructions: __APP_NAME__
2
+
3
+ An app for Wyndlass's screens, on `@wyndlass/app-sdk`. Its README says what it is; these are the rules.
4
+
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
+ - **Time:** from `now()` or `draw`'s `epochMs`, never `Date.now()`, so a seek or a pause shows the right moment.
7
+ - **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()`.
8
+ - **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)`.
9
+ - **Styles:** `this.css(text)`, never a `<style>`. A CSS file the source imports is its text.
10
+ - **No `localStorage`** for anything that matters, **no secrets** in params or the module, and **network hosts declared** in `capabilities.network`.
11
+ - **Look at it:** `npm run preview` (http://127.0.0.1:5199/), in both orientations, with the clock played and held, and once with `npm run preview -- --frame`, as the CMS runs it.
12
+ - **Before importing:** `npm run check`, then `npm run pack`. Raise `version` in `app.json` for each new version.
13
+ - **Importing and scheduling** go through the Wyndlass MCP, with the person's approval: `app.prepareImport`, `npx wyndlass-app upload <url> dist/<zip> --header "name: value"`, `app.import`, then `app.list` until the version is ready or held. A held version waits for a person who may approve media.