Skip to content

fix(chat): make jumbo emotes fit the column and sit on the name's line - #31

Merged
nathanialhenniges merged 2 commits into
mainfrom
claude/emotescale-fit
Aug 12, 2026
Merged

fix(chat): make jumbo emotes fit the column and sit on the name's line#31
nathanialhenniges merged 2 commits into
mainfrom
claude/emotescale-fit

Conversation

@nathanialhenniges

@nathanialhenniges nathanialhenniges commented Aug 12, 2026

Copy link
Copy Markdown
Member

Follow-up to #30, from watching it run against live chat on kaicenat (~28k viewers) in a 370px source, which is a realistic OBS width.

Two problems, both measured rather than guessed.

1. A spam row ate the column

before
one-emote reaction 77px, looks right
eleven-emote message 77px each, wrapped to 4 lines, 275px tall, 35% of the overlay

The flat multiplier treats a deliberate one-emote reaction and an eleven-emote wall identically, and those are not the same message.

Fix: divide the extra size among the art. N emotes get 1 + (scale - 1) / N.

The useful property: total row width is about base x (N + scale - 1), so a row is only ever (scale - 1) emote widths wider than at 1x, however many were spammed. One emote keeps the full multiplier; spam degrades toward normal.

Measured after, same channel and settings: six emotes at 3x render 34px each, 4% of the overlay, down from roughly 20%.

Kept as a calc() over --hb-emote-boost rather than a number resolved in JS, so the boost stays overridable from OBS Custom CSS.

2. The name floated in the middle of the emote

Centring an image on the text line is correct at chat size and wrong once the art is several times the line height: the name ends up against the middle of a tall block with no shared edge. Browsers default inline images to baseline; Twitch, YouTube and Slack override to middle for chat emotes, which is the right call at 1.6em and stops being right at 4.8em.

A/B/C on a real row with a 77px emote:

name and emote share a floor row height
middle (before) emote bottom sits 31px below the name 77px
baseline 3px, but adds ~5px descender leading under every jumbo row 82px
bottom 2px, flush 77px

Fix: bottom, which wins on alignment and costs no extra height. HbRoot writes --hb-emote-align and only hb-message[data-emote-only] reads it, so the value is middle whenever emotescale is off and a default overlay renders byte-identically to before.

Also

Dropped the -0.25rem block tuck on jumbo rows. It exists so a 1.6em emote does not push a ~1.35em line apart; on a jumbo row the art is taller than the line box, so a fixed tuck let it bleed over the neighbouring rows. A one-emote row measured 69px tall around 77px of art.

Verification

  • bun test: 134 pass, including new emoteOnlyCount coverage and a guard that it never returns 0 for a row isEmoteOnly accepts (that count is a divisor)
  • check-types and biome check clean
  • Every number above was measured in the browser against the deployed overlay with real Twitch chat, by prototyping each change live and re-measuring

🤖 Generated with Claude Code

Verified against live chat (kaicenat, ~28k viewers) and the flat
multiplier does not fit. In a 370px source at emotescale=3, an
eleven-emote message rendered 77px per emote, wrapped to four lines and
stood 275px tall: 35% of the overlay for one message. A single-emote
reaction at the same setting is 77px and looks right.

So divide the extra size among the art rather than applying it to each
piece: N emotes get 1 + (scale - 1) / N. A row's added width then stays
near (scale - 1) emote widths whatever the count, so one emote still
lands at full size and spam degrades instead of taking the column. Six
emotes at 3x now measure 34px each, 4% of the overlay, down from ~20%.

Kept as a calc() over --hb-emote-boost rather than a number resolved in
JS, so the boost stays overridable from OBS Custom CSS.

Also drop the -0.25rem block tuck on jumbo rows. It exists so a 1.6em
emote does not push a ~1.35em line apart; on a jumbo row the art is
taller than the line box, so the fixed tuck let it bleed over the rows
above and below. Measured: a one-emote row was 69px tall around 77px of
art.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Jumbo scaling now distributes the extra multiplier across emotes in emote-only chat rows. The resolver counts emotes and cheermotes, rendering uses a CSS calc() expression, margins are reset, and documentation and tests describe the behavior.

Changes

Jumbo emote scaling

Layer / File(s) Summary
Emote count contract
apps/web/src/lib/emotes/resolve.ts, apps/web/src/lib/emotes/resolve.test.ts
emoteOnlyCount counts emotes and optional cheermotes. Non-whitespace text returns zero. Tests cover counts and isEmoteOnly compatibility.
Chat scaling and rendering
apps/web/src/components/chat/chat-message.tsx, apps/web/src/components/chat/overlay.css, apps/web/src/routes/docs.tsx, CLAUDE.md
Chat messages distribute the jumbo multiplier across emotes with CSS calc(). Emote-only rows reset emote margins. Documentation describes the scaling formula and zero-count guard.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Poem

A rabbit counts each emote bright,
And shares the jumbo boost just right.
One art piece grows; many divide,
CSS keeps the scales beside.
The margins hop away—hooray!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main changes to jumbo emote sizing and row layout.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/emotescale-fit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Centring an emote on the text line is right at chat size and wrong once
the art is several times the line height: the name ends up floating
against the middle of a tall block with no shared edge.

Measured on a 77px emote at emotescale=3, in a real row on live chat:
middle left the emote's lower edge 31px below the name, bottom closed
that to 2px. baseline lands in the same place visually but adds about
5px of descender leading under every jumbo row, which is height an
overlay cannot spare, so bottom it is.

HbRoot writes --hb-emote-align, and only hb-message[data-emote-only]
reads it, so the value is middle whenever emotescale is off and a
default overlay renders exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/web/src/lib/emotes/resolve.test.ts (1)

104-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the invariant assertion unconditional.

The if skips all assertions when isEmoteOnly returns false. The test can pass if the predicate rejects every row. Assert that isEmoteOnly(parts) is true before checking emoteOnlyCount(parts).

Proposed test fix
-			if (isEmoteOnly(parts)) {
-				expect(emoteOnlyCount(parts)).toBeGreaterThan(0);
-			}
+			expect(isEmoteOnly(parts)).toBe(true);
+			expect(emoteOnlyCount(parts)).toBeGreaterThan(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/emotes/resolve.test.ts` around lines 104 - 111, Make the
test covering isEmoteOnly and emoteOnlyCount assert isEmoteOnly(parts)
unconditionally for each defined emote-only row, then assert
emoteOnlyCount(parts) is greater than zero; remove the conditional guard so the
test cannot pass without validating the predicate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/routes/docs.tsx`:
- Line 69: Update the documentation body for the multiplier setting to define N
as the total art count, explicitly stating that it includes one cheermote when
hasCheermote is true, even if message.parts is empty. Keep the existing
multiplier formula and behavior description unchanged.

---

Nitpick comments:
In `@apps/web/src/lib/emotes/resolve.test.ts`:
- Around line 104-111: Make the test covering isEmoteOnly and emoteOnlyCount
assert isEmoteOnly(parts) unconditionally for each defined emote-only row, then
assert emoteOnlyCount(parts) is greater than zero; remove the conditional guard
so the test cannot pass without validating the predicate.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b64e861-8560-459a-8046-18d8f0eebc50

📥 Commits

Reviewing files that changed from the base of the PR and between 25e965a and 5e8a23b.

📒 Files selected for processing (6)
  • CLAUDE.md
  • apps/web/src/components/chat/chat-message.tsx
  • apps/web/src/components/chat/overlay.css
  • apps/web/src/lib/emotes/resolve.test.ts
  • apps/web/src/lib/emotes/resolve.ts
  • apps/web/src/routes/docs.tsx

name: "emotescale",
values: "1 to 4, half steps",
body: "Grows emotes on messages that are nothing but emotes. One word alongside the emote and that message renders at normal size, so a wall of PogChamp stands out and a sentence does not. Any value between the half steps snaps to the nearest one. Cheermote art follows the same multiplier; badges, pronoun pills, avatars and text do not. This counts toward the source art the overlay requests the same way size does, so raising it past 2x pulls larger files from the emote services and costs more bandwidth. Default 1.",
body: "Grows emotes on messages that are nothing but emotes. One word alongside the emote and that message renders at normal size, so a wall of PogChamp stands out and a sentence does not. Any value between the half steps snaps to the nearest one. The multiplier is shared out across the emotes in the message: a single emote gets the full value, and a message of N gets 1 + (value - 1) / N. Spam therefore grows a little and one reaction grows a lot, which keeps an eleven-emote message from filling a third of the source. Cheermote art follows the same multiplier; badges, pronoun pills, avatars and text do not. This counts toward the source art the overlay requests the same way size does, so raising it past 2x pulls larger files from the emote services and costs more bandwidth. Default 1.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Define N as the total art count.

emoteOnlyCount includes one cheermote when hasCheermote is true, even when message.parts is empty. The documentation says the multiplier is shared across emotes, then states that cheermotes use the same multiplier. This leaves the denominator ambiguous for cheer messages. State that N includes one cheermote when present.

Proposed documentation fix
- The multiplier is shared out across the emotes in the message: a single emote gets the full value, and a message of N gets 1 + (value - 1) / N.
+ The multiplier is shared out across the emote art in the message, including one cheermote when present: a single art element gets the full value, and a message with N art elements gets 1 + (value - 1) / N.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
body: "Grows emotes on messages that are nothing but emotes. One word alongside the emote and that message renders at normal size, so a wall of PogChamp stands out and a sentence does not. Any value between the half steps snaps to the nearest one. The multiplier is shared out across the emotes in the message: a single emote gets the full value, and a message of N gets 1 + (value - 1) / N. Spam therefore grows a little and one reaction grows a lot, which keeps an eleven-emote message from filling a third of the source. Cheermote art follows the same multiplier; badges, pronoun pills, avatars and text do not. This counts toward the source art the overlay requests the same way size does, so raising it past 2x pulls larger files from the emote services and costs more bandwidth. Default 1.",
body: "Grows emotes on messages that are nothing but emotes. One word alongside the emote and that message renders at normal size, so a wall of PogChamp stands out and a sentence does not. Any value between the half steps snaps to the nearest one. The multiplier is shared out across the emote art in the message, including one cheermote when present: a single art element gets the full value, and a message with N art elements gets 1 + (value - 1) / N. Spam therefore grows a little and one reaction grows a lot, which keeps an eleven-emote message from filling a third of the source. Cheermote art follows the same multiplier; badges, pronoun pills, avatars and text do not. This counts toward the source art the overlay requests the same way size does, so raising it past 2x pulls larger files from the emote services and costs more bandwidth. Default 1.",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/docs.tsx` at line 69, Update the documentation body for
the multiplier setting to define N as the total art count, explicitly stating
that it includes one cheermote when hasCheermote is true, even if message.parts
is empty. Keep the existing multiplier formula and behavior description
unchanged.

@nathanialhenniges nathanialhenniges changed the title fix(chat): share the emote jumbo out across the emotes in a row fix(chat): make jumbo emotes fit the column and sit on the name's line Aug 12, 2026
@nathanialhenniges
nathanialhenniges merged commit 120dee4 into main Aug 12, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant