# Building a custom design for streamoverlay.app

A **design** is an installable overlay you author with HTML, CSS, and JavaScript. Other streamers can install your design into their own OBS with one click. This guide is also intended to be readable by an LLM — paste the whole file into Claude / ChatGPT / Gemini and ask it to build the design you want.

The live editor is at `https://streamoverlay.app/templates` → **+ New ▾**.

---

## Quick start

1. Go to **`https://streamoverlay.app/templates`**.
2. Click **+ New ▾** → pick **Empty**, **Default**, or **Example**.
3. Enter a name and pick a **kind** (`custom`, `alerts`, `chat`, `goal`, `eventlist`, `labels`, `leaderboard`, `counter`, `countdown`, `tipjar`, `emoterain`).
4. Edit four panes: **HTML body**, **Style** (CSS), **Script** (JS), **Config schema** (optional JSON).
5. Click **Publish** to mint a new immutable version.
6. Install on your own channel via the **Install** button on the design row.
7. Copy the resulting overlay URL on the **My Overlays** page and paste it into OBS as a Browser Source.

---

## The four editor panes

### HTML body
You author what goes **inside `<body>`**. The platform wraps it in a complete document with a UTF-8 charset, the viewport meta, `style.css` linked, and your `main.js` loaded as a module. So:

```html
<!-- Your HTML body — what you write -->
<div id="root">Hello!</div>
```

…becomes:

```html
<!doctype html>
<html><head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>streamoverlay.app template</title>
  <link rel="stylesheet" href="./style.css" />
</head><body>
  <div id="root">Hello!</div>
  <script type="module" src="./main.js"></script>
</body></html>
```

**Rules**
- No `<script>` tags allowed inside the body. The CSP forbids inline scripts. Put all JS in the **Script** pane.
- `<style>` tags are allowed but prefer the **Style** pane.

### Style (CSS)
Standard CSS. A few conventions for OBS overlays:

- **Always keep `html, body` backgrounds transparent.** OBS composites your overlay on top of the scene. A non-transparent body looks like a block.
  ```css
  html, body { margin: 0; padding: 0; background: transparent; overflow: hidden; }
  ```
- Use **`box-sizing: border-box`** universally to keep widths predictable in OBS browser sources.
- Web fonts via Google Fonts work (the CSP allows `font-src https:`), but cache them locally for absolute reliability.
- Animations should stay under 60 fps. Test with OBS's browser source built-in FPS limit.

### Script (JS)
Standard JavaScript, loaded as an ES module. **Import the SDK** to access events, Twitch APIs, chat, config, and signals:

```js
import { events, helix, irc, config, signals, ready } from '/t/sdk/v1/index.js';
```

Always `await ready` before calling `helix`, `irc`, or `config` — they need the overlay's session info from a server exchange that resolves on page load.

### Config schema (optional)
A JSON object describing user-facing settings the installer can configure. Example:

```json
{
  "title": "Top supporters",
  "limit": 5,
  "refreshSec": 60
}
```

When a streamer installs your design, these become the default values. (Today there's no per-install UI to edit them — that's roadmap. For now your design should read these via `config.get()` and treat them as immutable.)

---

## The SDK (`/t/sdk/v1/index.js`)

The SDK is a **frozen** ES module — it never changes for v1. (When the SDK gets new features that aren't backward-compatible, we'll bump to `/t/sdk/v2/index.js`.)

### `ready` — promise

Resolves once your design's token has been exchanged with the server. Always await this before calling `helix`, `irc`, or `config`.

```js
import { ready } from '/t/sdk/v1/index.js';
await ready;
// Safe to call helix / irc / config now.
```

### `events.on(type, fn)` — subscribe to channel events

Subscribes to **Twitch EventSub** events (real, live channel events) and **channel signals** (synthetic events dispatched from the dashboard Test panel, the Deck, or Stripe tip webhooks).

```js
events.on('follow',  (e) => console.log(e.user + ' followed'));
events.on('sub',     (e) => console.log(e.user + ' subscribed (' + e.tier + ')'));
events.on('resub',   (e) => console.log(e.user + ' resubbed for ' + e.months + ' months'));
events.on('giftSub', (e) => console.log(e.user + ' gifted ' + e.count + ' subs'));
events.on('raid',    (e) => console.log(e.user + ' raided with ' + e.viewers + ' viewers'));
events.on('cheer',   (e) => console.log(e.user + ' cheered ' + e.bits + ' bits: ' + e.message));
events.on('tip',     (e) => console.log(e.user + ' tipped $' + (e.amountCents/100).toFixed(2)));
events.on('*',       (e) => console.log('any event:', e));
```

**Event shapes**

| `type` | Fields |
|---|---|
| `follow` | `user`, `userId`, `at` |
| `sub` | `user`, `userId`, `tier` (`1000` / `2000` / `3000`), `isGift`, `at` |
| `resub` | `user`, `userId`, `tier`, `months` (cumulative), `message`, `at` |
| `giftSub` | `user`, `userId`, `count`, `tier`, `at` |
| `raid` | `user`, `userId`, `viewers`, `at` |
| `cheer` | `user`, `userId`, `bits`, `message`, `at` |
| `tip` | `user`, `amountCents`, `currency`, `message`, `at` |
| `signal` | `name`, `payload`, `at` |

The `signal` event is dispatched only via `signals.on(name, fn)` (see below); use that for cleaner code.

### `signals.on(name, fn)` — receive custom server-dispatched signals

A signal is a named event you trigger from the dashboard's **Deck** (or via a worker route). Use it to fire animations / sound effects / arbitrary actions in your design at will.

```js
import { signals } from '/t/sdk/v1/index.js';

signals.on('sparkles', (payload) => {
  for (let i = 0; i < (payload.count ?? 10); i++) spawnSparkle();
});

signals.on('confetti', () => playConfettiSound());
```

To dispatch, add a button to the Deck (`/deck`) with action **Send custom signal** and set the signal name to `sparkles` plus a JSON payload like `{"count": 30}`.

### `config.get()` and `config.channel()` — read installation context

```js
const values = await config.get();          // { title, limit, ... } from the install's `config.values`
const channel = await config.channel();     // { id, login, displayName }
```

`config.get()` returns the per-install JSON the installer provided (or your schema defaults). `config.channel()` returns the broadcaster's Twitch info.

### `helix.fetch(path)` — call Twitch Helix

A pre-authenticated passthrough to the Twitch Helix REST API. The platform injects `Client-Id` and `Authorization` headers from the streamer's token.

```js
const data = await helix.fetch('/channels/followers?broadcaster_id=' + channel.id + '&first=1');
console.log(data.total + ' total followers');
```

**Scope availability**: each overlay only has the Twitch scopes its kind needs. `custom` designs default to no scopes (read-only public data). If you need follower / sub data, your design's installer must have authorised the corresponding scope at login. Twitch returns 401 otherwise — handle that gracefully.

**Endpoints worth knowing**
- `/channels/followers?broadcaster_id=X&first=1` — total followers
- `/streams?user_id=X` — is the channel live, viewer count, game
- `/users?login=X` — look up a user by login
- `/games?id=X` — game info
- Full list: <https://dev.twitch.tv/docs/api/reference/>

### `irc.connect(channelLogin).onMessage(fn)` — read Twitch chat

An anonymous, read-only chat connection. No scope required.

```js
import { irc, config, ready } from '/t/sdk/v1/index.js';
await ready;
const channel = (await config.channel()).login;

irc.connect(channel).onMessage((m) => {
  // m: { user, displayName, color, message, isMod, isSub, isVip, isBroadcaster, badges, at, id }
  if (m.message.startsWith('!gg')) showGG(m.displayName);
});
```

**`ChatMessage` shape**
- `user` — login (lowercase)
- `displayName` — Twitch display name (case-preserving)
- `color` — hex string like `#9146ff` (may be empty if the user never set one)
- `message` — the text
- `isMod` / `isSub` / `isVip` / `isBroadcaster` — booleans
- `badges` — array of badge strings (e.g. `"subscriber/12"`)
- `at` — `tmi-sent-ts` timestamp in ms
- `id` — Twitch message UUID

---

## Per-kind quirks

A design's **kind** is metadata that:
1. Determines which category it shows up under in the public gallery (`kind=alerts` lives next to other alerts designs).
2. Suggests which event types your script should handle.

When installed, the resulting overlay is always `kind=custom` internally — the original `kind` just guides discovery.

Recommended kinds and typical event subscriptions:

| Kind | Typical use |
|---|---|
| `custom` | Anything that doesn't fit a category. The generic gallery bucket. |
| `alerts` | `events.on('follow' / 'sub' / 'resub' / 'giftSub' / 'raid' / 'cheer' / 'tip', …)` → render a popup. |
| `chat` | `irc.connect(channel).onMessage(…)` → render a chat list. |
| `goal` | `events.on('follow' / 'sub' / 'cheer', …)` → increment a progress bar toward `config.target`. |
| `eventlist` | `events.on('*', …)` → push to a rolling visual log. |
| `labels` | Live text labels (last follower, sub count) — update on relevant events. |
| `leaderboard` | Poll the loyalty API (`/api/loyalty/top?t=<token>&limit=5`) every minute. |
| `counter` | A single integer. Increment via chat command, signal, or event. |
| `countdown` | A timer that ticks down to `config.targetEpochMs`. |
| `tipjar` | `events.on('tip', …)` → progress bar to `config.goalCents`. |
| `emoterain` | Canvas particle effect triggered by cheer/sub/raid. |

---

## Sandboxing & restrictions

Designs are served at `https://streamoverlay.app/t/<id>/v<n>/` with a strict Content-Security-Policy. Things to know:

- **No inline `<script>` tags.** All JS must live in the **Script** pane (which becomes `main.js`). Inline-style attributes (`style="…"`) and `<style>` tags are fine.
- **`connect-src` is locked down** to `'self'` (your own published files) plus `https://api.twitch.tv`, `wss://eventsub.wss.twitch.tv`, and `wss://irc-ws.chat.twitch.tv`. You **cannot** fetch arbitrary third-party APIs. If you need a dynamic data source, route it through Twitch Helix or coordinate with the platform.
- **Images, audio, fonts** can come from any HTTPS source (`img-src https:`, `media-src https:`, `font-src https:`). Use any CDN.
- **`frame-ancestors 'none'`** — your design cannot be embedded by other sites.
- Templates served at `streamoverlay.app/t/...` run **same-origin** as the dashboard. Be aware that a malicious template could attack viewers via this — install only designs from authors you trust.

---

## Publishing & versioning

When you click **Publish**:
- Your current draft is frozen into version `v{N+1}` and pushed to immutable storage.
- The published URLs are `https://streamoverlay.app/t/<your-design-id>/v<N+1>/index.html` (and `style.css`, `main.js`, `meta.json`).
- A separate `https://streamoverlay.app/t/<id>/latest/index.html` redirect always points at the newest version.

**Installed overlays pin to the version at install time.** If you publish v3 after a friend installed v2, their overlay keeps using v2 until they reinstall. This protects them from surprise breakage but means improvements don't auto-propagate.

**Visibility**
- **Private** — only you can see it (in your dashboard). Use while iterating.
- **Unlisted** — anyone with the install link can install it. Shareable via Discord/Twitter without admin review. Share link: `https://streamoverlay.app/install?t=<your-design-id>` (a **Copy install link** button appears next to Install once unlisted).
- **Public** — appears in the public gallery (`/gallery`). **Requires admin approval** — only admins can flip visibility to public.

---

## Recipes

### A follow alert that pops in for 5 seconds

```js
import { events, ready } from '/t/sdk/v1/index.js';
await ready;

const stage = document.getElementById('stage');
events.on('follow', (e) => {
  const el = document.createElement('div');
  el.className = 'alert';
  el.textContent = e.user + ' just followed!';
  stage.appendChild(el);
  requestAnimationFrame(() => el.classList.add('show'));
  setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 500); }, 5000);
});
```

```css
.alert {
  position: fixed; top: 30%; left: 50%; transform: translate(-50%, 20px);
  background: #9146ff; color: #fff; padding: 16px 24px; border-radius: 12px;
  opacity: 0; transition: opacity .3s, transform .3s;
}
.alert.show { opacity: 1; transform: translate(-50%, 0); }
```

### A chat overlay (anonymous IRC)

```js
import { irc, config, ready } from '/t/sdk/v1/index.js';
await ready;
const channel = (await config.channel()).login;
const list = document.getElementById('chat');

irc.connect(channel).onMessage((m) => {
  const li = document.createElement('li');
  const u = document.createElement('span'); u.style.color = m.color || '#9146ff'; u.textContent = m.displayName + ': ';
  const t = document.createElement('span'); t.textContent = m.message;
  li.append(u, t);
  list.prepend(li);
  while (list.children.length > 50) list.removeChild(list.lastChild);
});
```

```html
<ul id="chat"></ul>
```

```css
#chat { list-style: none; margin: 0; padding: 12px; display: flex; flex-direction: column-reverse; height: 100vh; overflow: hidden; }
#chat li { padding: 6px 10px; background: rgba(0,0,0,.6); border-radius: 8px; margin-bottom: 4px; color: #fff; }
```

### A goal that ticks up on follows

```js
import { events, config, ready } from '/t/sdk/v1/index.js';
await ready;
const v = await config.get();
const target = v.target ?? 100;
let count = v.baseline ?? 0;

const fill = document.getElementById('fill');
const text = document.getElementById('text');
function render() {
  text.textContent = count + ' / ' + target;
  fill.style.width = Math.min(100, (count / target) * 100) + '%';
}
render();
events.on('follow', () => { count++; render(); });
```

### A signal-driven sparkle effect

```js
import { signals, ready } from '/t/sdk/v1/index.js';
await ready;
signals.on('sparkles', (payload) => {
  for (let i = 0; i < (payload.count ?? 20); i++) spawnSparkle();
});

function spawnSparkle() {
  const s = document.createElement('div');
  s.className = 'spark';
  s.textContent = '✨';
  s.style.left = Math.random() * 100 + 'vw';
  s.style.top = Math.random() * 100 + 'vh';
  document.body.appendChild(s);
  setTimeout(() => s.remove(), 2000);
}
```

Fire from the Deck: add a button with action **Send custom signal**, name `sparkles`, payload `{"count": 50}`.

---

## Asking an AI to write a design for you

This document lives at a public URL. Just give the AI the URL and a description of what you want:

> "Follow the design docs at https://streamoverlay.app/designs-guide.md and build me a spooky alert design that fires a green skull on every follow and rattles when a viewer cheers more than 200 bits. Give me the HTML body, CSS, and JS in separate code blocks."

Claude / ChatGPT / Gemini will fetch the page, read the SDK reference, and produce a working design.

---

## Need help?

- The seeded **Example** starters (in `+ New ▾` → **Example**) are deeply commented and per-kind.
- The **Gallery → Details** button on any community design shows that design's full source — read existing designs as references.
- Join the [streamoverlay.app Discord](https://discord.gg/bmJgQHXc) and ping the `#designs` channel.
