Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ Usage table in `README.md`; keep both in sync. Defaults:
and `animate` on, all other flags off (`pronouns` too - opt-in, since
it is a per-user pronouns.alejo.io lookup; `avatars` for the same
reason). Ranges: `max` 1-200, `delay` 0-300s, `fade`
0-600s, `refresh` 0 or 5-1440min, `emotescale` 1-4 in half steps. That
0-600s, `refresh` 0 or 1-1440min (1-4 round up to 5), `emotescale` 1-4
in half steps. That
last one is the only non-integer param: it snaps to the nearest half
step rather than rejecting, in BOTH `params.ts` and `parse-search.ts`,
or the parity test fails. It also feeds `assetScaleFor` alongside
Expand Down
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ its entire configuration from URL query parameters, so an OBS source
URL is the whole setup.

Reading chat anonymously is what removes the account, and it is also
the limit: HowlBox cannot send messages, moderate, or read anything
Twitch gates behind a token (subs, follows, bits). If you need those,
you need an overlay with a backend.
the limit: HowlBox cannot send messages, moderate, or show follower
alerts (follows only arrive over EventSub). Subs, gifts, raids, and
cheers do arrive over anonymous IRC, and the `events` parameter
renders them. If you need to send, moderate, or see follows, you need
an overlay with a backend.

Your chat. Your colors. Your howl.

Expand All @@ -28,6 +30,9 @@ Your chat. Your colors. Your howl.
- **Full emote support** - Native Twitch emotes plus 7TV (including
zero-width overlay emotes), BTTV, and FrankerFaceZ, resolved per
channel and cached in localStorage.
- **Adjustable emote size** - `emotescale` (1 to 4, half steps) grows
messages that are nothing but emotes, without inflating regular text
messages.
- **Badge art without secrets** - Every global and per-channel Twitch
badge (including subscriber and bits art) via public, CORS-safe
APIs, plus custom badge art overrides inline through the `badgeart`
Expand All @@ -36,6 +41,16 @@ Your chat. Your colors. Your howl.
names, from pronouns.alejo.io, the service 7TV and FrankerFaceZ read.
Each chatter's login is looked up there; enable it only if that
third-party call is acceptable for your channel.
- **Profile picture avatars** - Opt-in (`avatars=all` or
`avatars=subs`) chatter avatars before names, fetched in batches
from api.ivr.fi and cached locally. `subs` mode only looks up
subscribers and founders.
- **Sub, cheer, raid, and first-chat alerts** - Opt-in
(`events=sub,cheer,raid,first,announce` or `events=all`) event rows
for subs, gift bombs, raids, cheers, first-time and returning
chatters, and announcements, riding the same anonymous connection
as chat. A
100-gift bomb collapses to one row instead of one hundred.
- **Moderation aware** - Deleted messages, timeouts, and bans vanish
from the overlay instantly. An optional delay holds non-mod messages
so moderation lands before anything renders.
Expand Down Expand Up @@ -115,7 +130,7 @@ the short version.
| `fade` | seconds, `0` to `600` (default `0`) | Auto-hide each message N seconds after it appears |
| `badgeart` | comma-separated `set=url` or `set/version=url` pairs | Custom badge art overriding the Twitch defaults |
| `badgegist` | public gist id or URL | Custom badge art hosted in a gist (same pairs, one per line, or a JSON map) |
| `refresh` | minutes, `0` or `5` to `1440` (default `0`) | Re-fetch channel art every N minutes; globals retain their TTL |
| `refresh` | minutes, `0` or `1` to `1440` (default `0`) | Re-fetch channel art every N minutes; `1`-`4` round up to `5`; globals retain their TTL |

Invalid or missing values fall back to safe defaults; a typo in OBS
never produces a blank overlay.
Expand All @@ -131,8 +146,9 @@ Custom badge art precedence, weakest to strongest: fetched Twitch art,
then `badgegist`, then inline `badgeart`. A bare `set` key (no
`/version`) covers every version of that set. The gist is fetched from
the public GitHub API (no token), which allows 60 unauthenticated
requests per hour per IP. Refresh is off by default; when enabled, its
5-minute floor keeps a gist well under that limit.
requests per hour per IP. Refresh is off by default; when enabled, a
value of 1 to 4 rounds up to the 5-minute floor rather than falling
back to off, which keeps a gist well under that limit.
The art URLs in `badgeart` and `badgegist` point at whatever image host
you name, and your browser fetches them directly. Only HTTPS URLs are
accepted (URLs carrying credentials are rejected), and badge, emote, and
Expand Down Expand Up @@ -210,8 +226,9 @@ settings, set Pages > Source to "GitHub Actions". The site serves at
`BASE_PATH=/howlbox/`; use a custom domain and drop the variable for
root hosting).

A copy of `index.html` is deployed as `404.html` so the `/overlay`
route resolves on a static host.
The build generates a `404.html` marked `noindex` plus a real `index.html`
per route (including `/overlay`), so every route resolves with an
HTTP 200 instead of falling back to the SPA's 404 status.

#### Coolify or any static host

Expand Down Expand Up @@ -247,7 +264,7 @@ howlbox/
│ │ ├── emotes/ # 7TV/BTTV/FFZ fetch, cache, resolution
│ │ ├── overlay/ # URL param schema + builder
│ │ └── twitch/ # Anonymous chat client, badges, colors
│ └── routes/ # / landing, /config builder, /overlay
│ └── routes/ # / landing, /config builder, /docs reference, /overlay
├── packages/
│ ├── config/ # Shared tsconfig base
│ └── ui/ # Shared shadcn/ui components and styles
Expand Down
35 changes: 22 additions & 13 deletions apps/web/src/routes/docs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const GROUPS: { id: string; title: string; blurb: string; params: Param[] }[] =
{
name: "channel",
values: "a Twitch login name",
body: "The channel to join. Login name only, not a URL and not a display name: mrdemonwolf, not MrDemonWolf or twitch.tv/mrdemonwolf. Anything that fails the Twitch login pattern is dropped, and the overlay shows a status pill instead of joining.",
body: "The channel to join. Login name only, not a URL: mrdemonwolf, not twitch.tv/mrdemonwolf. Case does not matter (MrDemonWolf and mrdemonwolf both work, the value is lowercased first), but anything with characters outside a login (spaces, dots, slashes) fails the Twitch login pattern and is dropped, and the overlay shows a status pill instead of joining.",
},
],
},
Expand All @@ -56,12 +56,12 @@ const GROUPS: { id: string; title: string; blurb: string; params: Param[] }[] =
{
name: "bg",
values: "off, panel, bubble",
body: "Display mode. off draws bare text over gameplay with an outline stack for legibility, panel puts one themed backdrop behind the whole column, bubble gives each message its own surface. The panel backdrop only draws while messages exist, so a quiet channel shows nothing rather than an empty rectangle. Default off.",
body: "Display mode. off draws bare text over gameplay with an outline stack for legibility, panel puts one themed backdrop behind the whole column, bubble gives each message its own surface. The panel backdrop only draws while messages exist, so a quiet channel shows nothing rather than an empty rectangle. A system reduced-transparency preference swaps every theme to its solid, opaque surface automatically. Default off.",
},
{
name: "size",
values: "50 to 300, percent",
body: "Scales the theme's own font size, with a 12px rendered floor for broadcast readability. Change this rather than resizing the browser source with OBS transform handles, which resamples the render and blurs the text. Default 100.",
body: "Scales the theme's own font size, with a 12px rendered floor for broadcast readability. Change this rather than resizing the browser source with OBS transform handles, which resamples the render and blurs the text. size and emotescale together decide how large a resolution of emote art gets fetched, so size=300 at emotescale=1 requests the same source art as size=100 at emotescale=3. Default 100.",
},
{
name: "emotescale",
Expand All @@ -84,7 +84,7 @@ const GROUPS: { id: string; title: string; blurb: string; params: Param[] }[] =
{
name: "events",
values: "comma-separated: sub, cheer, raid, first, announce, or all",
body: "Which events to show. Anonymous IRC carries all of these, so none of it needs an account: sub covers new subs, resubs, single and mass gifts, and Prime upgrades; cheer shows bits with the matching tier art; raid shows the raider and their viewer count; first marks a chatter's first message in the channel, and returning chatters; announce marks the /announce highlight from mods. Unknown values are dropped, and all is shorthand for every kind. A sub or raid row is a whole sentence with no separate name header, while a cheer, first message or announcement decorates the message it came with. A mass gift collapses to a single row: Twitch also sends one notice per recipient, and showing those would mean a hundred rows for one gift bomb. Default empty, meaning no events.",
body: "Which events to show. Anonymous IRC carries all of these, so none of it needs an account: sub covers new subs, resubs, single and mass gifts, and Prime upgrades; cheer shows bits with the matching tier art; raid shows the raider and their viewer count; first marks a chatter's first message in the channel, and returning chatters; announce marks the /announce highlight from mods. Unknown values are dropped, and all is shorthand for every kind. A sub or raid row is a whole sentence with no separate name header, while a cheer, first message or announcement decorates the message it came with. A mass gift collapses to a single row: Twitch also sends one notice per recipient, and showing those would mean a hundred rows for one gift bomb. The collapse has a 60-second window per gifter; a gift outside that window from the same person renders as its own row. A word inside a cheer message that happens to look like a cheer token (letters followed by digits, such as GG100) is stripped along with the real ones, since the two are indistinguishable by shape alone. Default empty, meaning no events.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
],
},
Expand All @@ -111,7 +111,7 @@ const GROUPS: { id: string; title: string; blurb: string; params: Param[] }[] =
{
name: "pronouns",
values: "true, false",
body: "A text pronoun badge before the name, from pronouns.alejo.io, the same service 7TV and FrankerFaceZ read. Off by default because it is a per-user lookup: the first message from a given chatter usually misses the badge and later ones hit it. Turn it on only if that third-party call is acceptable for your channel.",
body: "A text pronoun badge before the name, from pronouns.alejo.io, the same service 7TV and FrankerFaceZ read. Off by default because it is a per-user lookup: the first message from a given chatter usually misses the badge and later ones hit it. Turn it on only if that third-party call is acceptable for your channel. Default false.",
},
{
name: "timestamps",
Expand All @@ -138,7 +138,7 @@ const GROUPS: { id: string; title: string; blurb: string; params: Param[] }[] =
{
name: "delay",
values: "0 to 300, seconds",
body: "Hold non-mod messages this long before rendering them, so a deletion or timeout lands first. Messages from mods and the broadcaster skip the buffer. The buffer is bounded, and deletes, timeouts, and bans evict anything still pending. Default 0.",
body: "Hold non-mod messages this long before rendering them, so a deletion or timeout lands first. Messages from mods and the broadcaster skip the buffer. Sub, raid, cheer, first-chat and announcement rows skip the buffer too, so a raid alert never lands behind a moderation delay. The buffer is bounded, and deletes, timeouts, and bans evict anything still pending. Default 0.",
},
{
name: "hidebots",
Expand All @@ -153,12 +153,12 @@ const GROUPS: { id: string; title: string; blurb: string; params: Param[] }[] =
{
name: "hide",
values: "comma-separated logins",
body: "Always hide these users. Anything that fails the login pattern is dropped from the list rather than erroring.",
body: "Always hide these users. Anything that fails the login pattern is dropped from the list rather than erroring. Default empty, meaning nobody is hidden.",
},
{
name: "allow",
values: "comma-separated logins",
body: "Featured mode: show only these users and nobody else. Leave it empty to show everyone.",
body: "Featured mode: show only these users and nobody else. Leave it empty to show everyone. Default empty, meaning everyone is shown.",
},
],
},
Expand All @@ -170,7 +170,7 @@ const GROUPS: { id: string; title: string; blurb: string; params: Param[] }[] =
{
name: "badgeart",
values: "set=url or set/version=url pairs",
body: "Replace badge art inline, comma separated. See custom badge art below.",
body: "Replace badge art inline, comma separated. Badge URLs must be HTTPS with no username or password embedded in the URL; anything else is dropped silently. The combined list from badgeart and badgegist is capped at 200 entries. See custom badge art below.",
},
{
name: "badgegist",
Expand All @@ -180,7 +180,7 @@ const GROUPS: { id: string; title: string; blurb: string; params: Param[] }[] =
{
name: "refresh",
values: "0, or 5 to 1440, minutes",
body: "Refetch channel-scoped emote and badge maps this often, so art added mid-stream shows up without reloading the source. Global maps keep their normal cache TTL. 0 turns refresh off and is the default. The 5-minute floor protects the free, unauthenticated upstream APIs.",
body: "Refetch channel-scoped emote and badge maps this often, so art added mid-stream shows up without reloading the source. Global maps keep their normal cache TTL. 0 turns refresh off and is the default. The 5-minute floor protects the free, unauthenticated upstream APIs: a value from 1 to 4 is not rejected, it is rounded up to 5 rather than falling back to 0, since a nonzero value means refresh was wanted.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
],
},
Expand Down Expand Up @@ -286,7 +286,7 @@ const CSS_HOOKS = [
{ cls: "hb-sep", body: "The colon between the name and the message." },
{
cls: "hb-emote",
body: "One emote image inside the message body. Height is 1.6em times --hb-emote-scale times --hb-emote-jumbo. The first is yours to set on hb-root and scales every emote; the second is the emotescale multiplier and is 1 on any row that is not emote-only.",
body: "One emote image inside the message body. Height is 1.6em times --hb-emote-scale times --hb-emote-jumbo. --hb-emote-scale is the Custom CSS hook, 1 by default and never written by the app, so a rule on it applies everywhere. emotescale itself lives in a separate variable, --hb-emote-boost, written inline on hb-root; --hb-emote-jumbo is computed per row from that boost and is 1 on any row that is not emote-only, so a Custom CSS override of --hb-emote-scale still multiplies jumbo rows instead of being silently skipped by them. Past 1x, a jumbo row also switches --hb-emote-align from middle to bottom so the name sits on the emote's lower edge instead of floating at its middle. A zero-width modifier stacked on top of a base emote is not itself an hb-emote: only the emote underneath carries the class, so a Custom CSS rule on .hb-emote will not reach the stacked overlay art.",
},
{
cls: "hb-status",
Expand Down Expand Up @@ -317,7 +317,7 @@ const TROUBLE = [
},
{
q: "Custom badge art is not showing",
a: "Check precedence: fetched Twitch art loses to badgegist, which loses to inline badgeart. A bare set key covers every version of that set, so moderator=... beats nothing but is beaten by moderator/1=.... The image URL has to be reachable from the browser and served with permissive CORS.",
a: "Check precedence: fetched Twitch art loses to badgegist, which loses to inline badgeart. A bare set key covers every version of that set, so moderator=... beats nothing but is beaten by moderator/1=.... The image URL has to be reachable from the browser and served with permissive CORS. It also has to be HTTPS with no embedded username or password, and a gist over 16 files or 64KB of content is skipped entirely.",
},
];

Expand Down Expand Up @@ -550,7 +550,9 @@ function DocsPage() {
The gist is read through the public GitHub API with no token,
which allows 60 unauthenticated requests per hour per IP.
Refresh is off by default. If enabled, its 5-minute floor keeps
a gist far under that limit.
a gist far under that limit. A gist is capped at 16 files and
64KB of total content; a gist over either limit is skipped
entirely rather than partially applied.
</p>
</section>

Expand Down Expand Up @@ -578,6 +580,13 @@ function DocsPage() {
</div>
))}
</dl>
<p className="hb-text-2 mt-6 leading-relaxed">
Avatar shape is three more variables on hb-root:
--hb-avatar-size (default 1.5em), --hb-avatar-radius (default
999px, a circle; several themes square it), and --hb-avatar-ring
(a 1px border-colored ring by default). --hb-event-accent sets
the event line's color, themed individually per preset.
</p>
<p className="hb-text-2 mt-6 leading-relaxed">
The theme variables are overridable the same way. To keep a
theme but change one color:
Expand Down