overlaify 0.0.0-stage → 0.1.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/lib/ws.js ADDED
@@ -0,0 +1,79 @@
1
+ // Just enough WebSocket server for `overlaify dev`: the SDK in OBS connects to /api/live/:room and receives
2
+ // action keys as text frames, the same protocol as the hosted LiveRoom. No dependencies, text frames only.
3
+ import { createHash } from 'node:crypto';
4
+
5
+ const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
6
+
7
+ /** Completes the handshake for an HTTP 'upgrade' request. Returns a socket wrapper, or null if it isn't a WebSocket. */
8
+ export function acceptWebSocket(req, socket, { onMessage, onClose }) {
9
+ const key = req.headers['sec-websocket-key'];
10
+ if (!key || (req.headers.upgrade || '').toLowerCase() !== 'websocket') {
11
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
12
+ return null;
13
+ }
14
+ const accept = createHash('sha1').update(key + GUID).digest('base64');
15
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
16
+ socket.setNoDelay(true);
17
+
18
+ let buf = Buffer.alloc(0);
19
+ let closed = false;
20
+ const ws = {
21
+ send(text) { if (!closed) socket.write(frame(0x1, Buffer.from(text))); },
22
+ close() { if (!closed) { socket.write(frame(0x8, Buffer.alloc(0))); socket.end(); } }
23
+ };
24
+ const done = () => { if (!closed) { closed = true; onClose(ws); } };
25
+
26
+ socket.on('data', (chunk) => {
27
+ buf = Buffer.concat([buf, chunk]);
28
+ for (;;) {
29
+ const f = parse(buf);
30
+ if (!f) break;
31
+ buf = buf.subarray(f.length);
32
+ if (f.opcode === 0x1) onMessage(ws, f.payload.toString('utf8'));
33
+ else if (f.opcode === 0x9) socket.write(frame(0xA, f.payload));
34
+ else if (f.opcode === 0x8) { ws.close(); done(); return; }
35
+ }
36
+ });
37
+ socket.on('close', done);
38
+ socket.on('error', done);
39
+ return ws;
40
+ }
41
+
42
+ // One complete frame at the start of buf: { opcode, payload, length }, or null if more bytes are needed.
43
+ // Client frames are always masked. Fragmented messages aren't supported; the SDK never sends them.
44
+ function parse(buf) {
45
+ if (buf.length < 2) return null;
46
+ const opcode = buf[0] & 0x0f;
47
+ const masked = (buf[1] & 0x80) !== 0;
48
+ let len = buf[1] & 0x7f;
49
+ let at = 2;
50
+ if (len === 126) {
51
+ if (buf.length < 4) return null;
52
+ len = buf.readUInt16BE(2);
53
+ at = 4;
54
+ } else if (len === 127) {
55
+ if (buf.length < 10) return null;
56
+ len = Number(buf.readBigUInt64BE(2));
57
+ at = 10;
58
+ }
59
+ const mask = masked ? buf.subarray(at, at + 4) : null;
60
+ if (masked) at += 4;
61
+ if (buf.length < at + len) return null;
62
+ const payload = Buffer.from(buf.subarray(at, at + len));
63
+ if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
64
+ return { opcode, payload, length: at + len };
65
+ }
66
+
67
+ function frame(opcode, payload) {
68
+ const len = payload.length;
69
+ const head = len < 126 ? Buffer.from([0x80 | opcode, len])
70
+ : len < 65536 ? Buffer.from([0x80 | opcode, 126, len >> 8, len & 0xff])
71
+ : Buffer.concat([Buffer.from([0x80 | opcode, 127]), bigLen(len)]);
72
+ return Buffer.concat([head, payload]);
73
+ }
74
+
75
+ function bigLen(n) {
76
+ const b = Buffer.alloc(8);
77
+ b.writeBigUInt64BE(BigInt(n));
78
+ return b;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "name": "overlaify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Build Twitch/OBS overlays that follow the Overlaify Overlay Spec: start from a template, validate, preview with simulated events and control them live.",
5
+ "type": "module",
6
+ "bin": { "overlaify": "bin/overlaify.js" },
7
+ "files": ["bin", "lib", "assets", "skill"],
8
+ "engines": { "node": ">=18" },
9
+ "scripts": {
10
+ "sync": "node scripts/sync.mjs"
11
+ },
12
+ "keywords": ["twitch", "obs", "overlay", "stream", "alerts", "browser-source", "claude", "skill"],
13
+ "homepage": "https://www.overlaify.com",
14
+ "repository": { "type": "git", "url": "git+https://github.com/overlaify/overlaify.git" },
15
+ "license": "MIT"
16
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: overlaify
3
+ description: Build live-stream overlays for Twitch/OBS (alert boxes, chat boxes, goal bars, timers, counters, tickers, webcam frames, Starting soon / BRB scenes, chat-command effects) as single HTML files that follow the Overlaify Overlay Spec, then validate and preview them with the overlaify CLI. Use whenever someone wants a stream overlay, an OBS browser source, or to change one that already exists.
4
+ argument-hint: "[describe the overlay you want]"
5
+ ---
6
+
7
+ # Overlaify overlays
8
+
9
+ An Overlaify overlay is one HTML file that runs as an OBS Browser Source (1920×1080, transparent). It gets
10
+ Twitch events from the Overlaify SDK and declares its settings and live control keys in a JSON manifest.
11
+ Following the spec is what makes it work in OBS, in `overlaify dev`, and on overlaify.com without changes.
12
+
13
+ The full rules are in `references/overlay-spec.md`. Read it before writing your first overlay in a session.
14
+ `examples/` has three overlays that pass the spec: an alert queue (`neon-alert.html`), a chat box
15
+ (`chat-stack.html`) and a goal with settings and actions (`goal-bar.html`).
16
+
17
+ ## Workflow
18
+
19
+ 1. **Start the file.** Use `npx overlaify init <file>.html` for a blank starter (an alert with a manifest),
20
+ or `npx overlaify init <file>.html --template <slug>` to start from a library template
21
+ (`npx overlaify init --list` shows them). Pick the template closest to the request, then edit it.
22
+ 2. **Write the overlay** (rules below). Keep everything in the one file.
23
+ 3. **Validate** with `npx overlaify validate <file>.html`. Fix every error; fix warnings unless there is a
24
+ reason not to, and say what it is. Repeat until it passes.
25
+ 4. **Preview.** Suggest that the user run `npx overlaify dev <file>.html`. It is a long-running server, so
26
+ start it in the background if you run it yourself. It prints:
27
+ - a dev page with the preview, event buttons (follow, sub, gift, bits, raid, chat), the settings panel
28
+ and the action keys;
29
+ - an OBS URL (`http://localhost:4545/overlay`) to add as a Browser source at 1920×1080. It reloads
30
+ when the file is saved, and the action keys reach it live.
31
+ Tell the user to set their Twitch channel in the dev page's settings so real chat and events arrive.
32
+ 5. **Publish (only when the user asks).** `npx overlaify publish <file>.html --private` or `--public` puts it
33
+ on overlaify.com at `/<their username>/<name>` with a hosted OBS URL (`--name` sets the name). Ask the user which one they want; never pick for
34
+ them. Private: only they can open it on the site, but the OBS URL still works. Public: anyone with a link
35
+ can open and remix it, and it can be picked for the Overlaify Library. If it says they're not logged in,
36
+ ask them to run `npx overlaify login` (it opens a browser to approve). Publishing the same file again
37
+ adds a new version.
38
+ 6. **Start from an overlay on overlaify.com.** When the user names one (`<user>/<name>` or its page URL),
39
+ `npx overlaify download <user>/<name> [file]` saves it as a local file to change, then carry on from step 3.
40
+ A private one only downloads for its owner; if it says to log in, ask them to run `npx overlaify login`.
41
+
42
+ If `npx` can't be used, the file still works: it loads the SDK from overlaify.com, so OBS can open it as a
43
+ local file with `?channel=<twitch name>` added.
44
+
45
+ ## Rules that matter most
46
+
47
+ - One self-contained HTML document, at most 80 KB. Inline all CSS and JS. Keep
48
+ `<script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>` in `<head>`; it is the only
49
+ external script. External files are limited to Google Fonts and Twitch emote images; draw everything else
50
+ with CSS, inline SVG, canvas or emoji.
51
+ - `html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }`.
52
+ Paint a full-screen background only for full scenes (Starting soon, BRB).
53
+ - Events come only from the SDK: `Overlaify.on('sub', fn)` and friends. There are no network calls, no
54
+ clicks and no dialogs; the overlay just runs.
55
+ - Events look the same on every platform. For bits and other money, handle `tip` (show `e.display`, size
56
+ with `e.usd`), not `cheer`, which is Twitch-only; never both, or bits count twice.
57
+ - For alerts, use `Overlaify.queue(handler)` so alerts never overlap.
58
+ - Never insert viewer text as HTML. Use `textContent`, `Overlaify.escape()`, or
59
+ `Overlaify.renderMessage(text, emotes)` for chat with emotes.
60
+ - Manifest in `<head>`: `<script type="application/json" id="overlaify-manifest">{ "spec": 1, ... }</script>`.
61
+ - Every customizable value is a **setting**, read with `Overlaify.param('key', <same default>)`.
62
+ - Every **action** has a handler, `Overlaify.action('name', fn)`. Add actions only for live state
63
+ (timers, counters, goals, queues, scenes): pause/resume, add/remove, reset with `"style": "danger"` and
64
+ `"confirm": true`.
65
+ - Never declare `channel`, `volume`, `mute` or `test`; the platform provides them.
66
+ - Wrap `localStorage` in `try/catch` (the page runs sandboxed), and use it when an action's state should
67
+ survive an OBS refresh.
68
+
69
+ ## Making it look good
70
+
71
+ Aim for a premium, hand-made overlay pack, not a generic template:
72
+ - one clear visual idea;
73
+ - a palette and type pairing that match the streamer's description;
74
+ - motion with personality: an entrance, a subtle idle and an exit.
75
+
76
+ Animate `transform` and `opacity`. Text must stay readable over busy gameplay and on phone screens: large
77
+ sizes, strong contrast, an outline or shadow. Place things where streamers expect them unless asked
78
+ otherwise:
79
+ - alerts: top-center
80
+ - chat: left edge
81
+ - goal bars: top edge
82
+ - tickers: bottom edge
83
+ - webcam frames: a corner
84
+
85
+ ## Changing an existing overlay
86
+
87
+ Edit the file in place and keep everything that wasn't asked to change. When behavior changes, update the
88
+ manifest too (new settings, renamed actions), then validate again.
@@ -0,0 +1,68 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>
5
+ <meta charset="utf-8">
6
+ <title>Glass Chat Stack</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
9
+ <script type="application/json" id="overlaify-manifest">
10
+ {
11
+ "spec": 1,
12
+ "settings": [
13
+ {"key": "max", "label": "Messages on screen", "type": "range", "default": 7, "min": 1, "max": 20, "step": 1, "desc": "Maximum number of messages on screen."},
14
+ {"key": "fade", "label": "Fade after", "type": "range", "default": 30, "min": 0, "max": 300, "step": 5, "unit": "s", "desc": "Seconds before a message fades out. 0 keeps messages until they are pushed off."},
15
+ {"key": "hidebots", "label": "Hide users", "type": "text", "default": "nightbot,streamelements,streamlabs", "desc": "Comma-separated usernames (lowercase, no spaces) whose messages are hidden."}
16
+ ],
17
+ "actions": [
18
+ {"name": "clear", "label": "Clear chat", "style": "danger", "wide": true}
19
+ ]
20
+ }
21
+ </script>
22
+ <style>
23
+ html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }
24
+ #chat { position: absolute; left: 40px; bottom: 60px; width: 520px; display: flex; flex-direction: column; gap: 12px; }
25
+ .msg {
26
+ font-family: 'Outfit', system-ui, sans-serif; font-size: 26px; line-height: 1.35; color: #f4f1ff;
27
+ background: rgba(16, 13, 26, .72); backdrop-filter: blur(8px);
28
+ border-left: 5px solid var(--uc, #9b5cff); border-radius: 6px 16px 16px 6px;
29
+ padding: 12px 18px 14px; box-shadow: 0 8px 24px rgba(0,0,0,.35);
30
+ text-shadow: 0 1px 2px rgba(0,0,0,.6);
31
+ transform-origin: left bottom; animation: slide .35s cubic-bezier(.2,1.2,.4,1);
32
+ transition: opacity .6s, transform .6s;
33
+ }
34
+ .msg.gone { opacity: 0; transform: translateX(-40px); }
35
+ .who { font-weight: 800; color: var(--uc, #9b5cff); margin-right: 8px; }
36
+ .badge { display: inline-block; font-size: 15px; font-weight: 600; padding: 1px 8px; border-radius: 99px; margin-right: 6px; vertical-align: 3px; color: #0b0a10; }
37
+ .b-mod { background: #4dffa6; } .b-sub { background: #ffb547; } .b-vip { background: #ff4f8b; } .b-broadcaster { background: #ff6b4a; }
38
+ .bits { font-weight: 800; color: #c6ff3d; margin-left: 6px; }
39
+ @keyframes slide { from { opacity: 0; transform: translateY(20px) scale(.96); } }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div id="chat"></div>
44
+ <script>
45
+ const MAX = Overlaify.param('max', 7);
46
+ const FADE = Overlaify.param('fade', 30); // seconds, 0 = never
47
+ const HIDE_BOTS = Overlaify.param('hidebots', 'nightbot,streamelements,streamlabs').split(',');
48
+ const box = document.getElementById('chat');
49
+
50
+ function remove(el) { el.classList.add('gone'); setTimeout(() => el.remove(), 600); }
51
+
52
+ Overlaify.on('chat', e => {
53
+ if (HIDE_BOTS.includes(e.user) || e.message.startsWith('!')) return;
54
+ const el = document.createElement('div');
55
+ el.className = 'msg';
56
+ el.style.setProperty('--uc', e.color || '#9b5cff');
57
+ const badges = [e.isBroadcaster && 'broadcaster', e.isMod && 'mod', e.isVip && 'vip', e.isSub && 'sub'].filter(Boolean)
58
+ .map(b => `<span class="badge b-${b}">${b === 'broadcaster' ? 'LIVE' : b.toUpperCase()}</span>`).join('');
59
+ el.innerHTML = `${badges}<span class="who">${Overlaify.escape(e.name)}</span>${Overlaify.renderMessage(e.message, e.emotes)}${e.bits ? `<span class="bits">◆ ${e.bits}</span>` : ''}`;
60
+ box.appendChild(el);
61
+ while (box.children.length > MAX) box.firstElementChild.remove();
62
+ if (FADE > 0) setTimeout(() => el.isConnected && remove(el), FADE * 1000);
63
+ });
64
+
65
+ Overlaify.action('clear', () => { box.replaceChildren(); });
66
+ </script>
67
+ </body>
68
+ </html>
@@ -0,0 +1,89 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>
5
+ <meta charset="utf-8">
6
+ <title>Liquid Goal Bar</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
9
+ <script type="application/json" id="overlaify-manifest">
10
+ {
11
+ "spec": 1,
12
+ "settings": [
13
+ {"key": "type", "label": "What to count", "type": "segmented", "default": "subs", "options": [{"value": "subs", "label": "Subs"}, {"value": "bits", "label": "Bits"}], "group": "Goal"},
14
+ {"key": "goal", "label": "Goal", "type": "number", "default": 50, "desc": "Defaults to 50 subs or 10,000 bits.", "group": "Goal"},
15
+ {"key": "start", "label": "Starting value", "type": "number", "default": 0, "desc": "E.g. subs you already have this month.", "group": "Goal"},
16
+ {"key": "label", "label": "Title", "type": "text", "default": "Sub goal", "group": "Look"},
17
+ {"key": "color", "label": "Fill color", "type": "color", "default": "#ff4f8b", "swatches": ["#ff4f8b", "#ffb547", "#c6ff3d", "#5ce1ff", "#a879ff"], "group": "Look"}
18
+ ],
19
+ "actions": [
20
+ {"name": "add", "label": "Add", "input": {"type": "number", "label": "Amount", "default": 1, "step": 1, "min": 1}},
21
+ {"name": "set", "label": "Set progress", "style": "secondary", "input": {"type": "number", "label": "Value", "default": 0, "step": 1, "min": 0}},
22
+ {"name": "reset", "label": "Reset to 0", "style": "danger", "confirm": true, "wide": true}
23
+ ]
24
+ }
25
+ </script>
26
+ <style>
27
+ :root { --c: #ff4f8b; --c2: #ffb547; }
28
+ html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }
29
+ .wrap { position: absolute; left: 50%; top: 36px; transform: translateX(-50%); width: 900px; font-family: 'Space Grotesk', system-ui, sans-serif; color: #fff; }
30
+ .row { display: flex; justify-content: space-between; align-items: baseline; margin: 0 6px 10px; text-shadow: 0 2px 6px rgba(0,0,0,.7); }
31
+ .label { font-size: 28px; font-weight: 700; letter-spacing: .02em; }
32
+ .count { font-size: 26px; font-weight: 500; font-variant-numeric: tabular-nums; }
33
+ .track { position: relative; height: 34px; border-radius: 99px; background: rgba(12,10,20,.75); border: 2px solid rgba(255,255,255,.18); overflow: hidden; box-shadow: 0 6px 24px rgba(0,0,0,.4); }
34
+ .fill { position: absolute; inset: 0 auto 0 0; width: 0; border-radius: 99px;
35
+ background: linear-gradient(90deg, var(--c), var(--c2)); transition: width 1.2s cubic-bezier(.2,1,.3,1);
36
+ box-shadow: 0 0 20px var(--c); }
37
+ .fill::after { content: ''; position: absolute; inset: 0; border-radius: inherit;
38
+ background: repeating-linear-gradient(115deg, rgba(255,255,255,.22) 0 14px, transparent 14px 28px); animation: flow 1s linear infinite; }
39
+ .bump { animation: bump .5s; }
40
+ .done .fill { background: linear-gradient(90deg, #c6ff3d, #4dffa6); box-shadow: 0 0 28px #c6ff3d; }
41
+ @keyframes flow { to { background-position: 28px 0; } }
42
+ @keyframes bump { 40% { transform: scaleY(1.25); } }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div class="wrap" id="w">
47
+ <div class="row"><span class="label" id="label"></span><span class="count" id="count"></span></div>
48
+ <div class="track" id="track"><div class="fill" id="fill"></div></div>
49
+ </div>
50
+ <script>
51
+ // ?type=subs|bits|follows &goal=50 &start=0 &label=Sub%20goal &color=%23ff4f8b
52
+ const TYPE = Overlaify.param('type', 'subs');
53
+ const GOAL = Overlaify.param('goal', TYPE === 'bits' ? 10000 : 50);
54
+ const KEY = `overlaify-goal-${Overlaify.channel}-${TYPE}-${GOAL}`;
55
+ const defaultLabel = { subs: 'Sub goal', bits: 'Bits goal', follows: 'Follower goal' }[TYPE] || 'Goal';
56
+ document.getElementById('label').textContent = Overlaify.param('label', defaultLabel);
57
+ document.documentElement.style.setProperty('--c', Overlaify.param('color', '#ff4f8b'));
58
+
59
+ let value = Overlaify.param('start', 0);
60
+ try { const saved = Number(localStorage.getItem(KEY)); if (saved > value) value = saved; } catch (e) {}
61
+
62
+ const fill = document.getElementById('fill'), count = document.getElementById('count'), track = document.getElementById('track');
63
+ function render(bump) {
64
+ const pct = Math.min(100, value / GOAL * 100);
65
+ fill.style.width = pct + '%';
66
+ count.textContent = `${value.toLocaleString()} / ${GOAL.toLocaleString()}`;
67
+ document.getElementById('w').classList.toggle('done', value >= GOAL);
68
+ if (bump) { track.classList.remove('bump'); void track.offsetWidth; track.classList.add('bump'); }
69
+ try { localStorage.setItem(KEY, String(value)); } catch (e) {}
70
+ }
71
+ function add(n) { if (n > 0) { const was = value < GOAL; value += n; render(true); if (was && value >= GOAL) Overlaify.chime('fanfare'); } }
72
+ Overlaify.action('add', n => add(Number(n) || 0));
73
+ Overlaify.action('set', n => { value = Math.max(0, Number(n) || 0); render(true); });
74
+ Overlaify.action('reset', () => { value = 0; render(true); });
75
+
76
+ if (TYPE === 'subs') {
77
+ Overlaify.on('sub', () => add(1));
78
+ Overlaify.on('gift', () => add(1));
79
+ // giftbomb is followed by individual 'gift' events from Twitch; count it only for simulations
80
+ Overlaify.on('giftbomb', e => e.simulated && add(e.count));
81
+ } else if (TYPE === 'bits') {
82
+ Overlaify.on('cheer', e => add(e.bits));
83
+ } else if (TYPE === 'follows') {
84
+ Overlaify.on('follow', () => add(1));
85
+ }
86
+ render(false);
87
+ </script>
88
+ </body>
89
+ </html>
@@ -0,0 +1,84 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <script src="https://www.overlaify.com/sdk/v1/overlaify.js"></script>
5
+ <meta charset="utf-8">
6
+ <title>Neon Pulse Alerts</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;800&display=swap" rel="stylesheet">
9
+ <script type="application/json" id="overlaify-manifest">
10
+ {
11
+ "spec": 1,
12
+ "settings": [
13
+ {"key":"color","label":"Accent color","type":"color","default":"#c6ff3d","swatches":["#c6ff3d","#ff4f8b","#5ce1ff","#a879ff","#ffb547"],"desc":"Border, glow and kicker text."},
14
+ {"key":"duration","label":"Duration","type":"range","default":5,"min":2,"max":20,"step":0.5,"unit":"s","desc":"Seconds each alert stays on screen."}
15
+ ]
16
+ }
17
+ </script>
18
+ <style>
19
+ :root { --c: #c6ff3d; --c2: #9b5cff; }
20
+ html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent; }
21
+ #stage { position: absolute; left: 50%; top: 120px; transform: translateX(-50%); }
22
+ .alert {
23
+ position: relative; min-width: 640px; padding: 34px 56px 38px; text-align: center;
24
+ font-family: 'Unbounded', system-ui, sans-serif; color: #fff;
25
+ background: rgba(10, 8, 18, .82); border-radius: 22px;
26
+ border: 3px solid var(--c);
27
+ box-shadow: 0 0 24px var(--c), inset 0 0 24px rgba(198,255,61,.25), 0 0 80px var(--c2);
28
+ opacity: 0; transform: scale(.6) translateY(-30px);
29
+ }
30
+ .alert.in { animation: pop .55s cubic-bezier(.2,1.6,.4,1) forwards; }
31
+ .alert.out { animation: leave .45s ease-in forwards; }
32
+ .kicker { font-size: 22px; font-weight: 500; letter-spacing: .32em; text-transform: uppercase; color: var(--c); text-shadow: 0 0 12px var(--c); }
33
+ .name { font-size: 64px; font-weight: 800; margin: 10px 0 6px; line-height: 1.05;
34
+ background: linear-gradient(90deg, #fff, var(--c), #fff); background-size: 200% 100%;
35
+ -webkit-background-clip: text; background-clip: text; color: transparent; animation: shine 1.6s linear infinite; }
36
+ .detail { font-size: 26px; font-weight: 500; color: #e9e4ff; }
37
+ .msg { margin-top: 14px; font: 500 22px system-ui, sans-serif; color: #cfc8e6; max-width: 760px; }
38
+ .ring { position: absolute; inset: -3px; border-radius: 22px; border: 3px solid var(--c); animation: ring 1.2s ease-out infinite; pointer-events: none; }
39
+ @keyframes pop { to { opacity: 1; transform: scale(1) translateY(0); } }
40
+ @keyframes leave { to { opacity: 0; transform: scale(.9) translateY(-40px); } }
41
+ @keyframes shine { to { background-position: -200% 0; } }
42
+ @keyframes ring { from { opacity: .9; transform: scale(1); } to { opacity: 0; transform: scale(1.12); } }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div id="stage"></div>
47
+ <script>
48
+ const accent = Overlaify.param('color', '#c6ff3d');
49
+ document.documentElement.style.setProperty('--c', accent);
50
+ const DURATION = Overlaify.param('duration', 5) * 1000;
51
+ const stage = document.getElementById('stage');
52
+ const esc = Overlaify.escape;
53
+ const tierLabel = t => t === 'prime' ? 'with Prime' : `Tier ${t}`;
54
+
55
+ function content(e) {
56
+ switch (e.type) {
57
+ case 'follow': return ['New follower', e.name, 'welcome to the squad', '', 'pop'];
58
+ case 'sub': return [e.months > 1 ? 'Resub' : 'New sub', e.name, `${tierLabel(e.tier)}${e.months > 1 ? ` · ${e.months} months` : ''}`, e.message, 'fanfare'];
59
+ case 'gift': return ['Gifted sub', e.name, `→ ${e.recipientName || e.recipient}`, '', 'alert'];
60
+ case 'giftbomb': return ['Gift bomb', e.name, `${e.count} subs to the community!`, '', 'fanfare'];
61
+ case 'cheer': return ['Cheer', e.name, `${e.bits.toLocaleString()} bits`, e.message, 'coin'];
62
+ case 'raid': return ['Raid incoming', e.name, `with ${e.viewers.toLocaleString()} raiders`, '', 'fanfare'];
63
+ }
64
+ }
65
+
66
+ const enqueue = Overlaify.queue(async (e) => {
67
+ const c = content(e); if (!c) return;
68
+ const [kicker, name, detail, msg, sound] = c;
69
+ const el = document.createElement('div');
70
+ el.className = 'alert';
71
+ el.innerHTML = `<div class="ring"></div><div class="kicker">${esc(kicker)}</div><div class="name">${esc(name)}</div><div class="detail">${esc(detail)}</div>${msg ? `<div class="msg">${esc(msg)}</div>` : ''}`;
72
+ stage.appendChild(el);
73
+ requestAnimationFrame(() => el.classList.add('in'));
74
+ Overlaify.chime(sound);
75
+ await Overlaify.sleep(DURATION);
76
+ el.classList.add('out');
77
+ await Overlaify.sleep(450);
78
+ el.remove();
79
+ }, { gap: 400 });
80
+
81
+ ['follow', 'sub', 'gift', 'giftbomb', 'cheer', 'raid'].forEach(t => Overlaify.on(t, enqueue));
82
+ </script>
83
+ </body>
84
+ </html>
@@ -0,0 +1,196 @@
1
+ # Overlaify Overlay Spec — v1
2
+
3
+ An Overlaify overlay is one HTML document that runs as an OBS Browser Source. Anything that follows this spec
4
+ — written in Overlaify Studio, by another AI, or by hand — can be hosted on Overlaify, previewed with simulated
5
+ events, configured from a settings panel and controlled live from a control page.
6
+
7
+ The spec has three parts:
8
+
9
+ 1. **The document**: what the HTML file may contain.
10
+ 2. **The runtime**: the `Overlaify` SDK the overlay talks to, and the events it receives.
11
+ 3. **The manifest**: a JSON block in which the overlay declares its settings and live actions.
12
+
13
+ `src/lib/validate.js` checks an overlay against this spec. Each rule below names the code it reports.
14
+ **Errors** break the overlay on Overlaify and must be fixed. **Warnings** are quality problems.
15
+
16
+ ---
17
+
18
+ ## 1. The document
19
+
20
+ | Rule | Code |
21
+ |---|---|
22
+ | One complete HTML document (`<!doctype html><html>…</html>`). | `not_html` (warning) |
23
+ | A short overlay name in `<title>`. | `title_missing` (warning) |
24
+ | At most 80 KB (80,000 bytes), so that Studio can still edit it. | `too_large` |
25
+ | All CSS and JS inline. The one `<script src>` allowed is the SDK, `https://www.overlaify.com/sdk/v1/overlaify.js` (or `/sdk/v1/overlaify.js`), so the file also runs on its own; Overlaify replaces it with its own copy when hosting. | `external_script` |
26
+ | No external files: stylesheets, images, audio, video, iframes, CSS `url()`/`@import`. The only hosts allowed are Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and Twitch emotes (`static-cdn.jtvnw.net`). Use `data:` URLs, inline SVG, canvas or emoji instead. | `external_resource` |
27
+ | No `<iframe>`, `<object>` or `<embed>`. | `embedded_frame` |
28
+ | Other URLs in the code (outside tags) point only at those hosts. | `external_url` (warning) |
29
+ | No network calls (`fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`): events come from the SDK. | `network_access` (warning) |
30
+ | No user interaction or dialogs (`alert`, `confirm`, `prompt`): the overlay just runs. | `blocking_dialog` (warning) |
31
+
32
+ Guidelines (not checked):
33
+
34
+ - The canvas is 1920×1080. `html` and `body`: `margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: transparent`.
35
+ Paint a full-screen background only for full scenes (Starting soon, BRB).
36
+ - Never insert user text as raw HTML. Use `Overlaify.escape(str)` or `textContent`, and `Overlaify.renderMessage(text, emotes)` for chat with emotes.
37
+ - Wrap `localStorage`/`sessionStorage` in `try/catch`: the page runs in a sandboxed, opaque origin.
38
+ - Animate with `transform` and `opacity`. Keep text readable over gameplay (contrast, outline or shadow, large sizes).
39
+
40
+ ## 2. The runtime
41
+
42
+ Overlaify injects the SDK before the overlay's own scripts, so a global `Overlaify` object is always there.
43
+ Each spec version has its own SDK URL, `/sdk/v1/overlaify.js` for this one, so overlays written for it keep
44
+ working when a later spec changes the runtime. `Overlaify.spec` is the version the loaded SDK implements.
45
+
46
+ ```
47
+ Overlaify.on(type, handler) subscribe; returns an unsubscribe function. '*' receives every event once.
48
+ Overlaify.off(type, handler)
49
+ Overlaify.queue(handler, { gap }) returns enqueue(event); runs handler one event at a time. handler may be
50
+ async or return ms to wait. Use it for alerts so they never overlap.
51
+ Overlaify.param(key, fallback) a setting's value; number and boolean fallbacks coerce the type.
52
+ Overlaify.action(name, fn) handle a manifest action; fn(value, event) gets the action's input value.
53
+ Overlaify.dispatch(name, value?) run an action from the overlay's own code (e.g. a chat command).
54
+ Overlaify.escape(str) HTML-escape.
55
+ Overlaify.renderMessage(text, emotes) safe HTML with Twitch emote <img class="ov-emote">.
56
+ Overlaify.chime(kind, volume?) built-in sound: 'alert' | 'pop' | 'coin' | 'fanfare' | 'low'. Respects mute.
57
+ Overlaify.sleep(ms) Promise delay.
58
+ Overlaify.simulate(type, overrides?) emit a fake event (used by previews).
59
+ Overlaify.channel Twitch channel login ('' in previews).
60
+ Overlaify.sources the connected sources: [{ platform, channel, connected }].
61
+ Overlaify.isTest true when random test events are running.
62
+ ```
63
+
64
+ ### Events
65
+
66
+ Events have the same shape whatever platform they come from, so an overlay works on every platform
67
+ Overlaify connects to without knowing which one it is on. Today that is Twitch; more will follow without a new
68
+ spec version.
69
+
70
+ Every event has `type`, `id`, `timestamp`, `platform` (`'twitch'`), `user` (login), `userId` (`''` when
71
+ unknown), `name` (display name), `color` (hex or `null`) and `simulated` (boolean). Simulated events look like
72
+ Twitch events.
73
+
74
+ | Type | Extra fields |
75
+ |---|---|
76
+ | `chat` | `message`, `emotes`, `badges: string[]`, `isMod`, `isSub`, `isVip`, `isBroadcaster`, `bits`, `action` |
77
+ | `follow` | — |
78
+ | `sub` | `months`, `tier: 'prime' \| '1' \| '2' \| '3'`, `message` |
79
+ | `gift` | `recipient`, `recipientName`, `tier`, `anonymous` |
80
+ | `giftbomb` | `count`, `tier` (Twitch also sends one `gift` per recipient afterwards) |
81
+ | `tip` | `amount`, `currency`, `kind`, `usd`, `display`, `message`, `emotes` |
82
+ | `cheer` | `bits`, `message`, `emotes` (Twitch only) |
83
+ | `raid` | `viewers` |
84
+
85
+ `tip` is money from a viewer on any platform. `amount` is in `currency`, which is `'bits'` for Twitch bits or an
86
+ ISO code such as `'USD'`; `kind` names the platform's feature (`'bits'` on Twitch); `usd` is the approximate
87
+ value in US dollars, for thresholds and sizing; `display` is ready to show, such as `"500 bits"`. Twitch bits
88
+ fire both `cheer` and `tip`. Handle `tip` in new overlays so they also count tips from other platforms, and
89
+ never both, or bits count twice (`cheer_and_tip`, warning). `'*'` gets Twitch bits once, as the `cheer`.
90
+
91
+ Lifecycle events, not included in `'*'`: `connected`, `disconnected`, with `platform` and `channel`; they fire
92
+ for each source. Subscribing to any other name is reported as `unknown_event` (warning).
93
+
94
+ ### Where values come from
95
+
96
+ - **Settings** saved by the streamer arrive as `window.OVERLAIFY_CONFIG`. URL query parameters override them.
97
+ Both are read with `Overlaify.param`.
98
+ - Reserved keys, always provided by the platform: `channel`, `sources`, `volume`, `mute`, `test`.
99
+ - **Sources** say where events come from: `OVERLAIFY_CONFIG.sources`, a list of `{ platform, channel }`,
100
+ plus `?channel=` as a Twitch source. One overlay can listen to several at once.
101
+ - **Actions** come from the control page (`/c/:id`), the preview's keys, or `Overlaify.dispatch`.
102
+
103
+ ## 3. The manifest
104
+
105
+ The overlay declares itself in a JSON block in `<head>`, before its own scripts:
106
+
107
+ ```html
108
+ <script type="application/json" id="overlaify-manifest">
109
+ {
110
+ "spec": 1,
111
+ "settings": [ ... ],
112
+ "actions": [ ... ]
113
+ }
114
+ </script>
115
+ ```
116
+
117
+ | Rule | Code |
118
+ |---|---|
119
+ | Overlays that read settings or handle actions have a manifest. Without one, the streamer gets no settings panel or keys. | `manifest_missing` (warning) |
120
+ | At most one manifest block. | `manifest_duplicate` |
121
+ | `type="application/json"`, so the browser does not run it. | `manifest_type` |
122
+ | Valid JSON object: double quotes, no comments, no trailing commas. | `manifest_json` |
123
+ | `"spec": 1`. | `spec_missing` (warning), `spec_version` |
124
+ | `settings` and `actions` are arrays when present. | `manifest_shape` |
125
+ | No other top-level keys. | `manifest_unknown_key` (warning) |
126
+
127
+ ### Settings
128
+
129
+ Settings are the overlay's starting state. The streamer edits them in a panel; the overlay reads each one with
130
+ `Overlaify.param(key, fallback)`, using the manifest's default as the fallback.
131
+
132
+ ```json
133
+ { "key": "color", "label": "Fill color", "type": "color", "default": "#ff4f8b",
134
+ "swatches": ["#ff4f8b", "#ffb547", "#c6ff3d"], "group": "Look", "desc": "Bar and glow." }
135
+ ```
136
+
137
+ | Field | Rule | Code |
138
+ |---|---|---|
139
+ | `key` | Required. Letters, digits, `_` or `-`, starting with a letter, at most 40 characters. Unique. | `setting_key`, `setting_duplicate` |
140
+ | | Not a reserved key (`channel`, `volume`, `mute`, `test`). | `setting_reserved` |
141
+ | `label` | Short name shown in the panel. | `setting_label` (warning) |
142
+ | `type` | `text`, `number`, `toggle`, `color`, `select`, `segmented` or `range`. | `setting_type` |
143
+ | `default` | Required, and matching the type (see below). | `setting_default_missing` (warning), `setting_default` |
144
+ | `desc`, `group` | Optional strings. `group` puts settings under a heading; use groups once there are more than about 4 settings. | `setting_field` (warning) |
145
+
146
+ By type:
147
+
148
+ | Type | Default | Other fields |
149
+ |---|---|---|
150
+ | `text` | string | — |
151
+ | `number` | number | — |
152
+ | `toggle` | boolean; read with a boolean fallback | — |
153
+ | `color` | `#rrggbb` | `swatches`: optional array of `#rrggbb` presets (`setting_swatches`) |
154
+ | `select` | one of the option values | `options`: non-empty array (`setting_options`) |
155
+ | `segmented` | one of the option values | `options`: 2–4 short choices (`setting_options`; more than 4 is a warning) |
156
+ | `range` | number within `min`–`max` | `min` and `max` required, `min < max`, `step > 0`, optional `unit` (`setting_range`) |
157
+
158
+ Options are plain values (`"subs"`) or `{ "value": "subs", "label": "Subs" }`.
159
+
160
+ Consistency with the code:
161
+
162
+ - Every setting is read with `Overlaify.param('key', …)`. → `setting_unused` (warning)
163
+ - Every `Overlaify.param('key', …)`, other than reserved keys, is declared as a setting. → `param_undeclared` (warning)
164
+
165
+ ### Actions
166
+
167
+ Actions change the overlay while it runs (pause, add time, reset, show a message). They appear as keys on the
168
+ control page. Add them only when the overlay has state worth controlling live: timers, counters, goals, queues
169
+ or toggled scenes.
170
+
171
+ ```json
172
+ { "name": "add", "label": "Add", "input": { "type": "number", "label": "Amount", "default": 1, "step": 1, "min": 1 } }
173
+ ```
174
+
175
+ | Field | Rule | Code |
176
+ |---|---|---|
177
+ | `name` | Required, same format as setting keys, unique. | `action_name`, `action_duplicate` |
178
+ | `label` | The key's text. | `action_label` (warning) |
179
+ | `group` | Optional heading. | `action_field` (warning) |
180
+ | `style` | `primary` (default), `secondary` (less used half of a pair) or `danger` (resets and clears). | `action_style` |
181
+ | `confirm` | `true` or a short question. The key asks for a second press. Use it on anything that throws away progress. | `action_confirm` |
182
+ | `wide` | `true` makes the key take a full row. | `action_field` (warning) |
183
+ | `input` | `{ type: number \| text \| select, label, default, min?, max?, step?, options? }`: a value sent with the action. `select` needs `options`, and a `number` default must be a number. | `action_input` |
184
+
185
+ Consistency with the code:
186
+
187
+ - Every action has a handler, `Overlaify.action('name', fn)`. Otherwise its key does nothing. → `action_unhandled`
188
+ - Every handler is declared in the manifest, unless it is only dispatched from the overlay's own code. → `action_undeclared` (warning)
189
+
190
+ ---
191
+
192
+ ## Versioning
193
+
194
+ This is spec version 1. Additions that old overlays can ignore (new event types, new setting types, new
195
+ optional fields) keep the version number. Changes that would break an existing overlay raise it and
196
+ get a new SDK URL (`/sdk/v2/…`); `/sdk/v1/overlaify.js` stays as it is for v1 overlays.