@nebularstreams/overlay-sdk 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 (133) hide show
  1. package/package.json +44 -0
  2. package/preview/assets/images/favicon.ico +0 -0
  3. package/preview/assets/images/favicon.png +0 -0
  4. package/preview/assets/images/login-right.webp +0 -0
  5. package/preview/assets/images/nebular.color.100h.png +0 -0
  6. package/preview/assets/images/nebular.color.white.100h.png +0 -0
  7. package/preview/assets/images/nebular.medallion.transparent..png +0 -0
  8. package/preview/assets/images/testcard.png +0 -0
  9. package/preview/assets/webfonts/fa-brands-400.woff2 +0 -0
  10. package/preview/assets/webfonts/fa-solid-900.woff2 +0 -0
  11. package/preview/bundle.min.js +6 -0
  12. package/preview/index.css +28 -0
  13. package/preview/index.html +23 -0
  14. package/readme.md +132 -0
  15. package/skill/nebular-overlay/SKILL.md +56 -0
  16. package/skill/nebular-overlay/agents/openai.yaml +4 -0
  17. package/skill/nebular-overlay/references/examples.md +327 -0
  18. package/skill/nebular-overlay/references/manifest.md +153 -0
  19. package/skill/nebular-overlay/references/runtime-and-design.md +88 -0
  20. package/skill/nebular-overlay/scripts/validate-templates.mjs +244 -0
  21. package/templates/all/broadcast/breaking-news/index.html +100 -0
  22. package/templates/all/broadcast/breaking-news/manifest.json +9 -0
  23. package/templates/all/broadcast/empty/index.html +46 -0
  24. package/templates/all/broadcast/empty/manifest.json +7 -0
  25. package/templates/all/broadcast/empty/nebular.medallion.transparent.png +0 -0
  26. package/templates/all/broadcast/event-countdown/index.html +160 -0
  27. package/templates/all/broadcast/event-countdown/logo.png +0 -0
  28. package/templates/all/broadcast/event-countdown/manifest.json +33 -0
  29. package/templates/all/broadcast/lower-third/index.html +77 -0
  30. package/templates/all/broadcast/lower-third/manifest.json +8 -0
  31. package/templates/all/broadcast/newsticker/index.html +153 -0
  32. package/templates/all/broadcast/newsticker/manifest.json +21 -0
  33. package/templates/all/broadcast/newsticker-frames/background-label-green.png +0 -0
  34. package/templates/all/broadcast/newsticker-frames/background-label-orange.png +0 -0
  35. package/templates/all/broadcast/newsticker-frames/banner.png +0 -0
  36. package/templates/all/broadcast/newsticker-frames/index.html +229 -0
  37. package/templates/all/broadcast/newsticker-frames/manifest.json +20 -0
  38. package/templates/all/broadcast/weather/index.html +382 -0
  39. package/templates/all/broadcast/weather/manifest.json +104 -0
  40. package/templates/all/engagement/call-to-action/index.html +116 -0
  41. package/templates/all/engagement/call-to-action/manifest.json +12 -0
  42. package/templates/all/engagement/chat/index.html +121 -0
  43. package/templates/all/engagement/chat/manifest.json +32 -0
  44. package/templates/all/engagement/post/index.html +132 -0
  45. package/templates/all/engagement/post/manifest.json +31 -0
  46. package/templates/all/gambling/bingo/index.html +227 -0
  47. package/templates/all/gambling/bingo/manifest.json +144 -0
  48. package/templates/all/gambling/keno/index.html +202 -0
  49. package/templates/all/gambling/keno/manifest.json +129 -0
  50. package/templates/all/gambling/lottery/index.html +159 -0
  51. package/templates/all/gambling/lottery/manifest.json +95 -0
  52. package/templates/all/gambling/prize-wall/index.html +256 -0
  53. package/templates/all/gambling/prize-wall/manifest.json +108 -0
  54. package/templates/all/gambling/roulette/index.html +200 -0
  55. package/templates/all/gambling/roulette/manifest.json +78 -0
  56. package/templates/all/gaming/battleship/index.html +578 -0
  57. package/templates/all/gaming/battleship/manifest.json +279 -0
  58. package/templates/all/gaming/gta/mission/index.html +121 -0
  59. package/templates/all/gaming/gta/mission/manifest.json +38 -0
  60. package/templates/all/sports/football/scoreboard/index.html +115 -0
  61. package/templates/all/sports/football/scoreboard/manifest.json +54 -0
  62. package/templates/all/sports/tennis/scoreboard/index.html +120 -0
  63. package/templates/all/sports/tennis/scoreboard/manifest.json +92 -0
  64. package/templates/all/trivia/audience-poll/index.html +60 -0
  65. package/templates/all/trivia/audience-poll/manifest.json +26 -0
  66. package/templates/all/trivia/classic-quiz/index.html +49 -0
  67. package/templates/all/trivia/classic-quiz/manifest.json +15 -0
  68. package/templates/all/trivia/lightning-round/index.html +126 -0
  69. package/templates/all/trivia/lightning-round/manifest.json +27 -0
  70. package/templates/all/trivia/pasapalabra/index.html +249 -0
  71. package/templates/all/trivia/pasapalabra/manifest.json +68 -0
  72. package/templates/all/trivia/quiz-deck/index.html +58 -0
  73. package/templates/all/trivia/quiz-deck/manifest.json +25 -0
  74. package/templates/all/trivia/true-or-false/index.html +81 -0
  75. package/templates/all/trivia/true-or-false/manifest.json +27 -0
  76. package/templates/basic/broadcast/empty/index.html +46 -0
  77. package/templates/basic/broadcast/empty/manifest.json +7 -0
  78. package/templates/basic/broadcast/empty/nebular.medallion.transparent.png +0 -0
  79. package/templates/basic/broadcast/event-countdown/index.html +160 -0
  80. package/templates/basic/broadcast/event-countdown/logo.png +0 -0
  81. package/templates/basic/broadcast/event-countdown/manifest.json +33 -0
  82. package/templates/basic/broadcast/lower-third/index.html +77 -0
  83. package/templates/basic/broadcast/lower-third/manifest.json +8 -0
  84. package/templates/basic/broadcast/newsticker/index.html +153 -0
  85. package/templates/basic/broadcast/newsticker/manifest.json +21 -0
  86. package/templates/basic/broadcast/weather/index.html +382 -0
  87. package/templates/basic/broadcast/weather/manifest.json +104 -0
  88. package/templates/basic/engagement/call-to-action/index.html +116 -0
  89. package/templates/basic/engagement/call-to-action/manifest.json +12 -0
  90. package/templates/basic/engagement/chat/index.html +121 -0
  91. package/templates/basic/engagement/chat/manifest.json +32 -0
  92. package/templates/basic/engagement/post/index.html +132 -0
  93. package/templates/basic/engagement/post/manifest.json +31 -0
  94. package/templates/basic/sports/football/scoreboard/index.html +115 -0
  95. package/templates/basic/sports/football/scoreboard/manifest.json +54 -0
  96. package/templates/basic/sports/tennis/scoreboard/index.html +120 -0
  97. package/templates/basic/sports/tennis/scoreboard/manifest.json +92 -0
  98. package/templates/debug/testing/security-probe/forbidden.js +1 -0
  99. package/templates/debug/testing/security-probe/forbidden.json +1 -0
  100. package/templates/debug/testing/security-probe/forbidden.mp4 +1 -0
  101. package/templates/debug/testing/security-probe/forbidden.svg +1 -0
  102. package/templates/debug/testing/security-probe/index.html +367 -0
  103. package/templates/debug/testing/security-probe/manifest.json +13 -0
  104. package/templates/empty/broadcast/empty/index.html +46 -0
  105. package/templates/empty/broadcast/empty/manifest.json +7 -0
  106. package/templates/empty/broadcast/empty/nebular.medallion.transparent.png +0 -0
  107. package/templates/games/gambling/bingo/index.html +227 -0
  108. package/templates/games/gambling/bingo/manifest.json +56 -0
  109. package/templates/games/gambling/keno/index.html +202 -0
  110. package/templates/games/gambling/keno/manifest.json +129 -0
  111. package/templates/games/gambling/lottery/index.html +159 -0
  112. package/templates/games/gambling/lottery/manifest.json +95 -0
  113. package/templates/games/gambling/prize-wall/index.html +256 -0
  114. package/templates/games/gambling/prize-wall/manifest.json +108 -0
  115. package/templates/games/gambling/roulette/index.html +200 -0
  116. package/templates/games/gambling/roulette/manifest.json +78 -0
  117. package/templates/games/gaming/battleship/index.html +578 -0
  118. package/templates/games/gaming/battleship/manifest.json +279 -0
  119. package/templates/games/gaming/gta/mission/index.html +121 -0
  120. package/templates/games/gaming/gta/mission/manifest.json +38 -0
  121. package/templates/games/trivia/audience-poll/index.html +60 -0
  122. package/templates/games/trivia/audience-poll/manifest.json +26 -0
  123. package/templates/games/trivia/classic-quiz/index.html +49 -0
  124. package/templates/games/trivia/classic-quiz/manifest.json +15 -0
  125. package/templates/games/trivia/lightning-round/index.html +126 -0
  126. package/templates/games/trivia/lightning-round/manifest.json +27 -0
  127. package/templates/games/trivia/pasapalabra/index.html +249 -0
  128. package/templates/games/trivia/pasapalabra/manifest.json +68 -0
  129. package/templates/games/trivia/quiz-deck/index.html +58 -0
  130. package/templates/games/trivia/quiz-deck/manifest.json +25 -0
  131. package/templates/games/trivia/true-or-false/index.html +81 -0
  132. package/templates/games/trivia/true-or-false/manifest.json +27 -0
  133. package/tool/cli.cjs +7 -0
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
7
+ const SUPPORTED_TYPES = new Set(["string", "number", "range", "date", "color", "choose", "select", "multi", "set", "selector", "action", "reset"]);
8
+ const SUPPORTED_ASSETS = new Set([".css", ".jpg", ".jpeg", ".png", ".webp", ".otf", ".ttf", ".woff", ".woff2"]);
9
+ const PATH_SEGMENT = /^[A-Za-z0-9_-]+$/;
10
+ const MAX_ASSET_SIZE = 5 * 1024 * 1024;
11
+ const errors = [];
12
+ const warnings = [];
13
+
14
+ function report(collection, file, message) {
15
+ collection.push(`${file}: ${message}`);
16
+ }
17
+
18
+ function isObject(value) {
19
+ return value !== null && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+
22
+ function walk(directory) {
23
+ return fs.readdirSync(directory, {withFileTypes: true}).flatMap(entry => {
24
+ if (entry.name.startsWith(".") || entry.name === "node_modules") return [];
25
+ const item = path.join(directory, entry.name);
26
+ return entry.isDirectory() ? walk(item) : [item];
27
+ });
28
+ }
29
+
30
+ function choiceValue(choice) {
31
+ return isObject(choice) && Object.hasOwn(choice, "value") ? choice.value : choice;
32
+ }
33
+
34
+ function matchesChoice(value, choice) {
35
+ const expected = choiceValue(choice);
36
+ return value === expected || ((typeof value === "string" || typeof value === "number")
37
+ && (typeof expected === "string" || typeof expected === "number")
38
+ && String(value) === String(expected));
39
+ }
40
+
41
+ function validateValue(value, definition, location) {
42
+ if (value === null) {
43
+ if (definition.remove === true || definition.default === null) return;
44
+ report(errors, location, "null is not supported by this definition");
45
+ return;
46
+ }
47
+
48
+ switch (definition.type) {
49
+ case "string":
50
+ if (typeof value !== "string") report(errors, location, "default must be a string");
51
+ if (typeof value === "string" && Number.isInteger(definition.maxLength) && value.length > definition.maxLength) report(errors, location, `default exceeds maxLength ${definition.maxLength}`);
52
+ break;
53
+ case "number":
54
+ case "range": {
55
+ const number = Number(value);
56
+ if ((typeof value !== "number" && typeof value !== "string") || value === "" || !Number.isFinite(number)) {
57
+ report(errors, location, "default must be a finite number");
58
+ } else {
59
+ if (definition.min != null && number < Number(definition.min)) report(errors, location, "default is below min");
60
+ if (definition.max != null && number > Number(definition.max)) report(errors, location, "default is above max");
61
+ }
62
+ break;
63
+ }
64
+ case "date":
65
+ if (typeof value !== "number" || !Number.isFinite(value)) report(errors, location, "default must be an epoch-millisecond number");
66
+ break;
67
+ case "color":
68
+ if (typeof value !== "string") report(errors, location, "default must be a CSS color string");
69
+ break;
70
+ case "choose":
71
+ case "select":
72
+ if (Array.isArray(definition.choose) && !definition.choose.some(choice => matchesChoice(value, choice))) report(errors, location, "default is not a declared choice");
73
+ break;
74
+ case "multi":
75
+ if (!Array.isArray(value)) report(errors, location, "default must be an array");
76
+ else if (Array.isArray(definition.choose)) for (const item of value) {
77
+ if (!definition.choose.some(choice => matchesChoice(item, choice))) report(errors, location, `default contains undeclared choice ${JSON.stringify(item)}`);
78
+ }
79
+ break;
80
+ case "set":
81
+ if (!Array.isArray(value)) report(errors, location, "default must be an array of records");
82
+ else if (isObject(definition.meta)) {
83
+ if (definition.max != null && value.length > Number(definition.max)) report(errors, location, "default contains more items than max");
84
+ value.forEach((record, index) => validateRecord(record, definition.meta, `${location}[${index}]`));
85
+ }
86
+ break;
87
+ case "selector": {
88
+ const index = Number(value);
89
+ if (!Number.isInteger(index) || index < 1) report(errors, location, "default must be a one-based positive integer");
90
+ break;
91
+ }
92
+ case "action":
93
+ if (typeof value !== "number" || !Number.isFinite(value)) report(errors, location, "default must be a finite timestamp");
94
+ break;
95
+ case "reset":
96
+ break;
97
+ }
98
+ }
99
+
100
+ function validateRecord(record, meta, location) {
101
+ if (!isObject(record)) {
102
+ report(errors, location, "must be an object");
103
+ return;
104
+ }
105
+ for (const key of Object.keys(record)) {
106
+ if (!Object.hasOwn(meta, key)) report(errors, location, `unknown record field ${key}`);
107
+ else validateValue(record[key], meta[key], `${location}.${key}`);
108
+ }
109
+ }
110
+
111
+ function validateDefinition(key, definition, manifestFile, nested = false) {
112
+ const location = `${manifestFile}#meta.${key}`;
113
+ if (!key.split("/").every(segment => PATH_SEGMENT.test(segment))) report(errors, location, "invalid parameter path");
114
+ if (!isObject(definition)) {
115
+ report(errors, location, "definition must be an object");
116
+ return;
117
+ }
118
+ if (!SUPPORTED_TYPES.has(definition.type)) {
119
+ report(errors, location, `unsupported type ${JSON.stringify(definition.type)}`);
120
+ return;
121
+ }
122
+ if (definition.phase != null && !["edit", "play"].includes(definition.phase)) report(errors, location, "phase must be edit or play");
123
+ if (definition.when != null) {
124
+ if (!isObject(definition.when)) report(errors, location, "when must be an object");
125
+ else {
126
+ if (typeof definition.when.key !== "string" || !definition.when.key.split("/").every(segment => PATH_SEGMENT.test(segment))) report(errors, location, "when.key must be a valid non-empty parameter path");
127
+ if (!Object.hasOwn(definition.when, "is") || definition.when.is === null || !["string", "number", "boolean"].includes(typeof definition.when.is)) report(errors, location, "when.is must be a string, number, or boolean");
128
+ }
129
+ }
130
+ if (definition.source != null && (typeof definition.source !== "string" || !definition.source.split("/").every(segment => PATH_SEGMENT.test(segment)))) report(errors, location, "source must be a valid non-empty parameter path");
131
+ if (["choose", "select", "multi"].includes(definition.type) && !Array.isArray(definition.choose)) report(errors, location, `${definition.type} requires a choose array`);
132
+ if (definition.type === "set" && !isObject(definition.meta)) report(errors, location, "set requires a meta object");
133
+ if (definition.type === "set" && isObject(definition.meta)) {
134
+ for (const [field, nestedDefinition] of Object.entries(definition.meta)) validateDefinition(field, nestedDefinition, location, true);
135
+ }
136
+ if (definition.type === "selector") {
137
+ if (typeof definition.set !== "string" || !definition.set.trim()) report(errors, location, "selector requires a non-empty set path");
138
+ if (definition.include != null && (!Array.isArray(definition.include) || definition.include.some(item => typeof item !== "string"))) report(errors, location, "selector include must be an array of strings");
139
+ }
140
+ if (definition.type === "reset" && !isObject(definition.values)) report(errors, location, "reset requires a values object");
141
+ if (Object.hasOwn(definition, "default")) validateValue(definition.default, definition, `${location}.default`);
142
+ else if (!nested && !definition.source && !["reset", "action", "selector"].includes(definition.type)) report(warnings, location, "has no default");
143
+ }
144
+
145
+ function validateConditionalTargets(meta, manifestFile, prefix = "meta") {
146
+ for (const [key, definition] of Object.entries(meta)) {
147
+ const location = `${manifestFile}#${prefix}.${key}`;
148
+ if (isObject(definition?.when) && typeof definition.when.key === "string" && !Object.hasOwn(meta, definition.when.key)) {
149
+ report(errors, location, `when.key ${definition.when.key} is not declared in the same meta object`);
150
+ }
151
+ const controllingDefinition = isObject(definition?.when) ? meta[definition.when.key] : null;
152
+ if (["choose", "select"].includes(controllingDefinition?.type) && Array.isArray(controllingDefinition.choose)
153
+ && !controllingDefinition.choose.some(choice => matchesChoice(definition.when.is, choice))) {
154
+ report(errors, location, `when.is is not a declared choice of ${definition.when.key}`);
155
+ }
156
+ if (definition?.type === "set" && isObject(definition.meta)) validateConditionalTargets(definition.meta, manifestFile, `${prefix}.${key}.meta`);
157
+ }
158
+ }
159
+
160
+ function validateManifest(templateDirectory) {
161
+ const manifestFile = path.join(templateDirectory, "manifest.json");
162
+ if (!fs.existsSync(manifestFile)) {
163
+ report(errors, templateDirectory, "missing manifest.json");
164
+ return;
165
+ }
166
+ let manifest;
167
+ try {
168
+ manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
169
+ } catch (error) {
170
+ report(errors, manifestFile, `invalid JSON: ${error.message}`);
171
+ return;
172
+ }
173
+ if (!isObject(manifest)) report(errors, manifestFile, "manifest must be an object");
174
+ if (!isObject(manifest?.meta)) {
175
+ report(errors, manifestFile, "meta must be an object");
176
+ return;
177
+ }
178
+ if (manifest.instructions != null && typeof manifest.instructions !== "string") report(errors, manifestFile, "instructions must be a string");
179
+ for (const [key, definition] of Object.entries(manifest.meta)) validateDefinition(key, definition, manifestFile);
180
+ validateConditionalTargets(manifest.meta, manifestFile);
181
+
182
+ for (const [key, definition] of Object.entries(manifest.meta)) {
183
+ if (definition.type === "selector" && !Object.hasOwn(manifest.meta, definition.set)) report(errors, `${manifestFile}#meta.${key}`, `selector set ${definition.set} is not declared`);
184
+ if (definition.type === "reset" && isObject(definition.values)) for (const resetPath of Object.keys(definition.values)) {
185
+ const canonical = manifest.meta[resetPath]?.source || resetPath;
186
+ if (!Object.hasOwn(manifest.meta, canonical)) report(errors, `${manifestFile}#meta.${key}`, `reset target ${resetPath} is not declared`);
187
+ }
188
+ }
189
+ }
190
+
191
+ function validateHtml(templateDirectory) {
192
+ const htmlFile = path.join(templateDirectory, "index.html");
193
+ const html = fs.readFileSync(htmlFile, "utf8");
194
+ if (!/nebular-overlay:update/.test(html)) report(warnings, htmlFile, "does not listen for nebular-overlay:update");
195
+ if (!/background\s*:\s*transparent/i.test(html)) report(warnings, htmlFile, "transparent page background was not detected");
196
+ if (/(?:src|href)\s*=\s*["']https?:\/\//i.test(html)) report(errors, htmlFile, "contains an external asset URL");
197
+ if (/\b(fetch|XMLHttpRequest|WebSocket|EventSource|Worker|SharedWorker|AudioContext)\s*\(/.test(html)) report(errors, htmlFile, "uses a blocked runtime API");
198
+ if (/<(?:iframe|object|embed|video|audio|canvas)\b/i.test(html)) report(errors, htmlFile, "contains a blocked element");
199
+ const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi)];
200
+ for (const [index, match] of scripts.entries()) {
201
+ try {
202
+ new Function(match[1]);
203
+ } catch (error) {
204
+ report(errors, htmlFile, `inline script ${index + 1} has invalid syntax: ${error.message}`);
205
+ }
206
+ }
207
+ }
208
+
209
+ function validateAssets(templateDirectory) {
210
+ for (const file of walk(templateDirectory)) {
211
+ const relative = path.relative(templateDirectory, file);
212
+ if (["index.html", "manifest.json"].includes(relative)) continue;
213
+ const extension = path.extname(file).toLowerCase();
214
+ if (!SUPPORTED_ASSETS.has(extension)) report(errors, file, `unsupported template asset ${extension || "without extension"}`);
215
+ if (fs.statSync(file).size > MAX_ASSET_SIZE) report(errors, file, "asset exceeds 5 MiB");
216
+ }
217
+ }
218
+
219
+ const target = path.resolve(process.argv[2] || ".");
220
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
221
+ console.error(`Not a directory: ${target}`);
222
+ process.exit(2);
223
+ }
224
+ const files = walk(target);
225
+ const templateDirectories = files.filter(file => path.basename(file) === "index.html").map(file => path.dirname(file));
226
+ if (templateDirectories.length === 0) {
227
+ console.error(`No templates found beneath ${target}`);
228
+ process.exit(2);
229
+ }
230
+
231
+ for (const templateDirectory of templateDirectories) {
232
+ const relative = path.relative(target, templateDirectory);
233
+ if (relative && !relative.split(path.sep).every(segment => PATH_SEGMENT.test(segment))) report(errors, templateDirectory, "template path contains an invalid segment");
234
+ const nested = templateDirectories.find(candidate => candidate !== templateDirectory && candidate.startsWith(`${templateDirectory}${path.sep}`));
235
+ if (nested) report(errors, templateDirectory, `contains nested template ${path.relative(templateDirectory, nested)}`);
236
+ validateManifest(templateDirectory);
237
+ validateHtml(templateDirectory);
238
+ validateAssets(templateDirectory);
239
+ }
240
+
241
+ for (const warning of warnings) console.warn(`WARN ${warning}`);
242
+ for (const error of errors) console.error(`ERROR ${error}`);
243
+ console.log(`Checked ${templateDirectories.length} template${templateDirectories.length === 1 ? "" : "s"}: ${errors.length} error(s), ${warnings.length} warning(s)`);
244
+ process.exitCode = errors.length ? 1 : 0;
@@ -0,0 +1,100 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Breaking News</title>
6
+ <style>
7
+ * {
8
+ box-sizing: border-box
9
+ }
10
+
11
+ html, body {
12
+ width: 100%;
13
+ height: 100%;
14
+ margin: 0;
15
+ overflow: hidden;
16
+ background: transparent
17
+ }
18
+
19
+ body {
20
+ color: #fff;
21
+ font-family: Inter, system-ui, sans-serif
22
+ }
23
+
24
+ .strap {
25
+ position: absolute;
26
+ left: 3%;
27
+ right: 3%;
28
+ bottom: 5%;
29
+ display: grid;
30
+ grid-template-columns:auto 1fr;
31
+ border-radius: .65rem;
32
+ overflow: hidden;
33
+ box-shadow: 0 1rem 3rem #0005
34
+ }
35
+
36
+ .label {
37
+ display: grid;
38
+ place-items: center;
39
+ padding: 0 1.5rem;
40
+ background: #e12c36;
41
+ font-weight: 900;
42
+ letter-spacing: .08em;
43
+ text-transform: uppercase;
44
+ font-size: 3rem;
45
+ }
46
+
47
+ .copy {
48
+ min-width: 0;
49
+ background: #0c1018f2
50
+ }
51
+
52
+ .headline {
53
+ padding: .75rem 1.25rem .3rem;
54
+ font-size: 3rem;
55
+ font-weight: 850
56
+ }
57
+
58
+ .ticker {
59
+ padding: 0 1.25rem .75rem;
60
+ color: #cbd2dc;
61
+ font-size: 3rem;
62
+ white-space: nowrap;
63
+ overflow: hidden
64
+ }
65
+
66
+ .ticker span {
67
+ display: inline-block;
68
+ min-width: 100%;
69
+ animation: crawl 60s linear infinite
70
+ }
71
+
72
+ @keyframes crawl {
73
+ from {
74
+ transform: translateX(100%)
75
+ }
76
+ to {
77
+ transform: translateX(-100%)
78
+ }
79
+ }
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <section class="strap">
84
+ <div class="label" id="label">Breaking</div>
85
+ <div class="copy">
86
+ <div class="headline" id="headline">Nebular launches a new generation of live graphics</div>
87
+ <div class="ticker"><span id="ticker">More information will follow throughout the broadcast · Stay tuned</span></div>
88
+ </div>
89
+ </section>
90
+ <script>
91
+ const $ = id => document.getElementById(id);
92
+ window.addEventListener("nebular-overlay:update", ({detail}) => {
93
+ if (detail.news?.label != null) $("label").textContent = detail.news.label;
94
+ if (detail.news?.headline != null) $("headline").textContent = detail.news.headline;
95
+ if (detail.news?.ticker != null) $("ticker").textContent = detail.news.ticker;
96
+ if (detail.style?.color != null) $("label").style.backgroundColor = detail.style.color
97
+ });
98
+ </script>
99
+ </body>
100
+ </html>
@@ -0,0 +1,9 @@
1
+ {
2
+ "instructions": "Enter the breaking-news label, headline and ticker text, choose an accent colour, then press Update. Use short headlines and concise ticker copy so both remain comfortably readable on screen.",
3
+ "meta": {
4
+ "news/label": {"type": "string", "phase": "play", "label": "Label", "hint": "Enter short label", "default": "BREAKING"},
5
+ "news/headline": {"type": "string", "phase": "play", "label": "Headline", "hint": "Enter headline", "default": "Nebular launches a new generation of live graphics"},
6
+ "news/ticker": {"type": "string", "phase": "play", "label": "Ticker", "hint": "Enter ticker text", "default": "More information will follow throughout the broadcast · Stay tuned"},
7
+ "style/color": {"type": "color", "phase": "edit", "label": "label", "hint": "Accent colour used by the breaking-news graphic", "default": "#e12c36"}
8
+ }
9
+ }
@@ -0,0 +1,46 @@
1
+ <html>
2
+ <head>
3
+ <style>
4
+ body {
5
+ font-family: sans-serif;
6
+ margin: 1.5rem;
7
+ color: white;
8
+ }
9
+
10
+ h1 {
11
+ font-weight: bold;
12
+ }
13
+
14
+ #logo {
15
+ position: absolute;
16
+ right: 1rem;
17
+ top: 1rem;
18
+ display: none;
19
+ width: 10rem;
20
+ }
21
+
22
+ #logo.on {
23
+ display: block;
24
+ }
25
+
26
+ </style>
27
+ <script>
28
+
29
+
30
+ window.addEventListener("nebular-overlay:update", ({detail}) => {
31
+
32
+ const
33
+ programElement = document.getElementById("program"),
34
+ logoElement = document.getElementById("logo");
35
+
36
+ if (typeof detail.program === "string") programElement.textContent = detail.program;
37
+ if (detail.logo) logoElement.classList.toggle("on", detail.logo === "on");
38
+ });
39
+
40
+ </script>
41
+ </head>
42
+ <body>
43
+ <h1 id="program"></h1>
44
+ <img id="logo" src="nebular.medallion.transparent.png">
45
+ </body>
46
+ </html>
@@ -0,0 +1,7 @@
1
+ {
2
+ "instructions": "Empty overlay with the program name",
3
+ "meta": {
4
+ "program": {"type": "string", "default": "", "hint": "Type the program name", "label": "Program Name"},
5
+ "logo": {"type": "choose", "default": "off", "hint": "Logo ON/OFF", "label": "Logo Visibility", "choose": ["on", "off"]}
6
+ }
7
+ }
@@ -0,0 +1,160 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Event Countdown</title>
6
+ <style>
7
+ * {
8
+ box-sizing: border-box
9
+ }
10
+
11
+ html, body {
12
+ width: 100%;
13
+ height: 100%;
14
+ margin: 0;
15
+ overflow: hidden;
16
+ background: transparent
17
+ }
18
+
19
+ body {
20
+ font-family: Inter, Arial, Helvetica, sans-serif;
21
+ }
22
+
23
+ .countdown-card {
24
+ --panel: #fff;
25
+ --ink: #090909;
26
+ position: absolute;
27
+ top: 4.5%;
28
+ right: 4.5%;
29
+ filter: drop-shadow(0 1rem 2.25rem #0007);
30
+ display: flex;
31
+ flex-direction: column;
32
+ align-items: center;
33
+ }
34
+
35
+ .clock {
36
+ display: flex;
37
+ color: var(--ink);
38
+ overflow: hidden;
39
+ font-size: 3rem;
40
+ font-weight: bold;
41
+ line-height: 3rem;
42
+ }
43
+
44
+ .clock span {
45
+ background: var(--panel);
46
+ margin: 0 0.1rem;
47
+ padding: 0 0.1rem;
48
+ }
49
+
50
+ .time {
51
+ min-width: 0;
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ white-space: nowrap
56
+ }
57
+
58
+ .days-label {
59
+ background: var(--panel);
60
+ font-variant: all-petite-caps;
61
+ }
62
+
63
+ .logo-frame {
64
+ width: 100%;
65
+ min-height: 9rem;
66
+ max-height: 22rem;
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: center;
70
+ overflow: hidden;
71
+ background: transparent
72
+ }
73
+
74
+ .logo {
75
+ display: block;
76
+ width: 100%;
77
+ height: auto;
78
+ max-height: 22rem;
79
+ object-fit: contain;
80
+ object-position: center top;
81
+ margin-top: -2rem;
82
+ }
83
+
84
+ .logo-missing {
85
+ display: none;
86
+ width: 100%;
87
+ padding: 2.5rem 1rem;
88
+ color: #fff;
89
+ background: #111d;
90
+ text-align: center;
91
+ font-size: 2rem;
92
+ font-weight: 850;
93
+ letter-spacing: .08em;
94
+ text-transform: uppercase
95
+ }
96
+ </style>
97
+ </head>
98
+ <body>
99
+ <main class="countdown-card" id="card">
100
+ <section class="clock" aria-label="Event countdown">
101
+ <div class="days-number" id="days">00</div>
102
+ <div class="days-label" id="daysLabel">D</div>
103
+ <div class="time" id="time">00:00:00</div>
104
+ </section>
105
+ <div class="logo-frame">
106
+ <img class="logo" id="logo" src="logo.png" alt="Event logo">
107
+ <div class="logo-missing" id="logoMissing">logo.png</div>
108
+ </div>
109
+ </main>
110
+
111
+ <script>
112
+ const card = document.getElementById("card");
113
+ const days = document.getElementById("days");
114
+ const daysLabel = document.getElementById("daysLabel");
115
+ const time = document.getElementById("time");
116
+ const logo = document.getElementById("logo");
117
+ const logoMissing = document.getElementById("logoMissing");
118
+ let startsAt = 1795046400000;
119
+ let timer = null;
120
+
121
+ const
122
+ pad = value => String(value).padStart(2, "0"),
123
+ spanner = (str) => "<span>" + (str.split("").join("</span><span>")) + "</span>";
124
+
125
+ function renderCountdown() {
126
+ const remaining = Math.max(0, startsAt - Date.now());
127
+ const totalSeconds = Math.floor(remaining / 1000);
128
+ const dayCount = Math.floor(totalSeconds / 86400);
129
+ const hours = Math.floor(totalSeconds % 86400 / 3600);
130
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
131
+ const seconds = totalSeconds % 60;
132
+
133
+ days.innerHTML = spanner(pad(dayCount));
134
+ time.innerHTML = spanner(`${pad(hours)}:${pad(minutes)}:${pad(seconds)}`);
135
+
136
+ clearTimeout(timer);
137
+ window.invalidate?.();
138
+ if (remaining > 0) timer = setTimeout(renderCountdown, 1000 - Date.now() % 1000 + 15);
139
+ }
140
+
141
+ logo.addEventListener("error", () => {
142
+ logo.style.display = "none";
143
+ logoMissing.style.display = "block";
144
+ });
145
+
146
+ window.addEventListener("nebular-overlay:update", ({detail}) => {
147
+ if (detail.event?.startsAt != null) {
148
+ const nextStartsAt = Number(detail.event.startsAt);
149
+ if (Number.isFinite(nextStartsAt)) startsAt = nextStartsAt;
150
+ renderCountdown();
151
+ }
152
+ if (detail.content?.days != null) daysLabel.textContent = detail.content.days;
153
+ if (detail.style?.color) card.style.setProperty("--panel", detail.style.color);
154
+ if (detail.style?.textColor) card.style.setProperty("--ink", detail.style.textColor);
155
+ });
156
+
157
+ renderCountdown();
158
+ </script>
159
+ </body>
160
+ </html>
@@ -0,0 +1,33 @@
1
+ {
2
+ "instructions": "Choose the event date and time, then add logo.png to the template directory. The countdown is derived from that fixed moment and stops at zero when the event begins.",
3
+ "meta": {
4
+ "event/startsAt": {
5
+ "type": "date",
6
+ "phase": "edit",
7
+ "label": "Event date and time",
8
+ "hint": "Choose the event date and time",
9
+ "default": 1795046400000
10
+ },
11
+ "content/days": {
12
+ "type": "string",
13
+ "phase": "edit",
14
+ "label": "Days label",
15
+ "hint": "Days",
16
+ "default": "Days"
17
+ },
18
+ "style/color": {
19
+ "type": "color",
20
+ "phase": "edit",
21
+ "label": "Countdown colour",
22
+ "hint": "Background colour behind the countdown",
23
+ "default": "#ffffff"
24
+ },
25
+ "style/textColor": {
26
+ "type": "color",
27
+ "phase": "edit",
28
+ "label": "Text colour",
29
+ "hint": "Countdown numbers and label colour",
30
+ "default": "#090909"
31
+ }
32
+ }
33
+ }