- Manual Connect: split the server picker into its own inset panel,
visually separated from the Room ID / Password fields (they were
welded together in one flat list).
- Custom server URL: stop auto-prepending ws:// — default to wss://
for real hosts, ws:// only for localhost/127.0.0.1 (matches what
the background already enforces; the displayed URL no longer lies).
- Host Control card: replace the raw native checkbox with the proper
.toggle-switch slider used in Options; move the toggle row from a
<label> to a <div> to avoid an illegally nested label.
- Co-host discoverability: owner-only hint in the Host Control card
pointing to the participant list (new NOTICE_COHOST_HINT across all
15 locales). Shown only when actionable: owner + co-host-capable
relay + host-only active.
- Peer row: group right-side badges/actions into one container so they
no longer scatter via space-between or collide with long names;
restyle the give/revoke button (padding, weight, hover fill, tooltip).
- Gate promote/demote (and role badges) on host-only mode being ON,
in addition to the existing owner-privilege check — roles are moot
in 'everyone' mode, so no badge/button noise there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- server: SET_PEER_ROLE now uses the same per-room 500ms debounce as
SET_CONTROL_MODE (M-4), preventing a buggy/malicious host from
thrashing the room's UI with rapid promote/demote bursts.
- server: PEER_STATUS relay treats explicit null as 'clear' for
tabTitle/mediaTitle/currentTime, so the tab-close heartbeat actually
zeroes out stale state on other peers instead of being silently
preserved by the clamp fallback.
- background: _persistLastSeq is now trailing-debounced (500ms),
cutting storage.session IPC writes from one-per-relayed-event to
one-per-quiet-window in active rooms.
- locales: add missing TOAST_ID_REGENERATED to all 14 non-English
locales (was blocking locale coverage test on main).
- test: H-1 force-sync demote test now waits out the debounce window
to reflect real-world UI timing (a host cannot promote, run a
force-sync, and demote inside 500ms).
Two pre-existing issues surfaced during audit follow-up:
1. popup.js bound elements.regenId but never attached a click handler —
the 'Regenerate Peer ID' button has been dead UI since it was added
(verified across full git history). Wired it up to send REGENERATE_ID;
the background handler already did the right thing (rotate peerId,
persist, force reconnect). The button is functional (regen ID on
duplicate-identity errors), not privacy-theater — username is already
user-changeable in settings, so it stays out of scope.
2. MAX_CONTROLLERS=10 was arbitrary. Payload math (25 controllers ×
16-char IDs ≈ 500 bytes) is well under the 4KB maxHttpBufferSize,
controllers.has() is O(1) on a Set, and 'just use everyone mode' is
not a real answer when a host wants 12 controllers + 3 guests in a
15-person room. Removed the cap entirely; the only real upper bound
is MAX_PEERS_PER_ROOM (25).
Added TOAST_ID_REGENERATED to en.json — the i18n fallback merge
(Object.assign({}, enDict, targetDict)) covers the other 14 locales
automatically with English until they're translated.
Defense-in-depth: the field was always undefined in practice (no code
path ever sets currentRoom.password — verified across the full git
history), and no GET_STATUS caller in the codebase reads it. But if a
future change ever populated currentRoom.password, any caller of
GET_STATUS would receive it in cleartext.
Threat surface today is narrow (externally_connectable is not set, no
content script sends GET_STATUS, only the popup uses it), but the field
has no reason to exist — removing it eliminates the future-leak vector
for free.
Backward compatible: no caller reads this field, so removing it is a no-op
for every existing code path.
8 hex chars (32-bit) gives ~7% collision probability at the relay's
25k-peer capacity and ~1% at 10k peers. A same-room collision silently
triggers the dedup path and kicks the older session with a confusing
'Another session with this ID joined' error.
16 hex chars (64-bit) drops collision probability to ~1e-10 even at one
million peers. The server's JOIN_ROOM sanitizer already clamps peerId to
16 chars, so no server change is needed. Fully backward compatible:
- Existing clients keep their persisted 8-char peerId (only newly
generated IDs change).
- Mixed 8-char + 16-char ID rooms work — both fit the server's 16-char
clamp.
- No protocol change on the wire.
Birthday-paradox reference (computed, not estimated):
32-bit @ 10k peers: 1.16%
32-bit @ 25k peers: 7.02%
64-bit @ 25k peers: ~0.017e-9%
Audit fixes (each verified against the actual code path):
H-1 (server): track force-sync initiator on PREPARE; let the demoted
initiator's EXECUTE through the host-only gate so mid-sync demotion no
longer strands the whole room paused. Clear on EXECUTE/peer-leave.
M-1 (background): episode-lobby gate now uses !amController() for parity
with CONTENT_EVENT and server gates — co-hosts can drive the room and
initiate lobbies, not just the owner.
M-2/M-3 (popup/content/background): forceSyncReset respects hcmGuestLocked;
desynced guest skips EPISODE_LOBBY so they don't get frozen in pause after
lobby completion, and checkEpisodeLobbyCompletion excludes desynced peers
from the required count so they don't block the lobby.
M-4 (background): getHostSyncTarget clamps extrapolation to 2x heartbeat
interval so a stale host heartbeat can't snap the guest tens of seconds
past the host's real position.
L-1..L-4 (server/content/background/popup): clarify dedup comment re:
network-blip window, enforce desync invariant on SW-restore, add
forceSyncBtn guest-locked backstop, refresh badge text in place.
Backward compatibility (verified by BC-1..BC-4 regression tests):
- Old client ↔ new server: server adds fields only, never requires; old
heartbeats stripped of desynced; host-only enforced server-side even
when the client has no awareness.
- New client ↔ old server: empty capabilities → host-control UI hidden,
all gates default to everyone, behavior byte-identical to pre-HCM.
- Mixed rooms: every pre-HCM event type relays cleanly in both directions.
- updateHostControlUI takes a state object now; role badge shows Host / Controller
/ Guest; remote-control lock uses amController (a co-host can drive, so their
buttons aren't locked).
- Peer list: Host/Controller badges and, for the owner, a per-peer Give control /
Revoke button (SET_PEER_ROLE). Shown only when the feature is active and the room
is host-only or the viewer is the owner — no role noise for guests in normal rooms.
Hidden entirely against a relay without the co-host capability.
- i18n: BADGE_CONTROLLER, BTN_GIVE_CONTROL, BTN_REVOKE_CONTROL across all 15 locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- background: track controllers[] from ROOM_DATA/CONTROL_MODE (restore + reset on
teardown); add amController() (owner or co-host). Both gates now key on controller
membership (sender: !amController(); receiver: senderId not in controllers); desync
invariant uses amController. Thread controllers/amController/coHostSupported through
GET_STATUS/GET_CONTROL_MODE/CONTROL_MODE. New SET_PEER_ROLE message (owner→server).
- content: gate on amController instead of amHost (a controller is not a gated guest);
reconcile/HOST_BLOCKED updated accordingly. Drop now-unused hcmAmHost.
Single-host behavior unchanged (owner is always the sole controller until they promote).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Findings recovered from the multi-agent audit (verification phase was cut off by a
usage limit; verified inline against the code):
HIGH:
- Host was demoted on peerId dedup (fast reconnect / second tab): the dedup path
removes the old socket and the kicked socket's 'disconnect' both ran the
host-leave fallback before the same peerId re-joined → room silently unlocked on
every host network blip. Now skip the fallback while a join for that peerId is in
flight (peerJoinLocks). Regression test added. (A long real disconnect still
falls back — that's the deferred host-grace EC-10.)
- Episode auto-advance froze a gated guest: in host-only the guest's EPISODE_LOBBY
is dropped server-side, but the guest still self-paused (PAUSE_FOR_LOBBY) waiting
for readies that never came → 60s freeze. A host-only guest now skips creating a
lobby (the host drives episode sync).
LOW / cleanup:
- GET_HCM_STRINGS could return a raw key name ("HCM_DIALOG_TITLE") if the locale
dictionary failed to load (getMessage returns the key on miss) → omit it so
content keeps its English fallback.
- hcmReset now also clears the snap-back cooldown + buffering grace, so a stale
cooldown can't swallow the first snap-back after a room/host change.
- Popup play/pause labels reset on lock too (not just unlock), so a button can't
freeze on "Playing…" when host-only activates mid-click.
- Fix a stale comment (heartbeat now carries 'desynced').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit follow-up on the snap-back paths. The immediate involuntary snap used the
captured target directly; if it was null/invalid (host position not known yet,
e.g. before the first host heartbeat) hcmSnapBackToHost no-ops and the guest is
left silently paused with no dialog and no retry. The deferred and "Stay in sync"
paths already re-query with retry — make the immediate path fall back to the same
when the captured target isn't usable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the snap-back robustly instead of deferring it to device testing —
since the set of video sites is unbounded, "measure which players misbehave, then
fix" doesn't scale; the snap-back must be safe on any player.
On an involuntary pause/seek, if the player isn't ready to play (readyState<3 or
seeking) we no longer snap immediately (which fights the buffer: seek → re-buffer
→ pause → … = stutter). Instead hcmDeferredSnapBack polls until the player can
play (8s cap) and then snaps ONCE to the host's re-queried current position. A
player can't play while buffering anyway, so waiting is also more correct, not
just safer. Ready players still snap immediately (no regression).
Guards: aborts if the user goes solo or is no longer a gated guest; single
pending poll (no stacking); cleared in hcmReset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the implicit "hostPeerId present ⇒ feature supported" heuristic with an
explicit capabilities list the relay advertises in ROOM_DATA. Cleaner, self-
documenting, and the extensible hook for the planned co-host feature (owner
promotes guests to extra controllers) — a future 'co-host' capability + events
slot in without a protocol bump.
- shared: CAPABILITIES { HOST_CONTROL }.
- server: SERVER_CAPABILITIES advertised in ROOM_DATA.
- background: track serverCapabilities (empty against older relay), serverSupports(),
thread hostControlSupported through GET_STATUS / GET_CONTROL_MODE / CONTROL_MODE.
- popup: gate the host-control card on the explicit capability instead of hostPeerId.
Backwards-compatible both ways: old relay omits the field → feature stays hidden
(no errors); old client ignores the field. WS test asserts ROOM_DATA advertises
the capability. Adds docs/host-control-mode-TESTING.md (beta-server setup, wss
caveat, two-new-clients note, verification checklist).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The card was rendered in every room with a "Guest" badge by default — confusing
in normal rooms, and outright misleading against a relay that doesn't yet support
host control (older server omits hostPeerId, so amHost is always false and nobody
is ever recognized as host).
Now the card shows only when it's meaningful: the host always sees it (to toggle
host-only), a guest sees it only while host-only is active, and it's hidden in a
normal "everyone" room and whenever the server doesn't advertise hostPeerId
(feature unavailable). Thread hostPeerId through updateHostControlUI to detect
server support.
Note: the feature still requires the relay to run the host-control server code;
this just makes the client degrade cleanly instead of showing a stray "Guest".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host-control desync dialog and the "watching on your own" badge were
hardcoded English — content.js has no i18n loader. Background does, so it now
resolves the strings (GET_HCM_STRINGS: loadLocale + getMessage) and content
fetches them on init with English fallbacks, re-rendering the badge if it was
already shown. The dialog body is now generic (dropped the paused/jumped verb)
to keep translation clean across languages.
Adds 6 keys (HCM_DIALOG_TITLE/BODY/STAY/SOLO, HCM_BADGE_SOLO/RESYNC) translated
across all 15 popup locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two issues surfaced by a holistic audit of the combined desync-persist +
reconcile-on-init changes:
1. Stale desync outside gated-guest state. hcmDesynced is persisted, but neither
ROOM_DATA nor CONTROL_MODE cleared it when the local peer became host or the
room switched to 'everyone'. Combined with reconcile-on-init, a desynced guest
promoted to host (host-leave fallback) while content was reloading could
re-adopt desynced=true — self-labelling "Solo" to all peers and ignoring host
commands in 'everyone' mode. Enforce the invariant centrally
(hcmEnforceDesyncInvariant: desynced ⟹ host-only && !host) on every role/mode
change, and guard the content reconcile to only adopt when actually a gated guest.
2. Solo-badge flicker. The host-side PEER_STATUS handler set peer.desynced
unconditionally, but the background-driven keepAlive heartbeat omits the field —
so every ~30s it clobbered desynced to false, flickering the badge. Only update
when present (matching the sibling fields), and include desynced in the
background heartbeat so both heartbeat sources are consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Resync badge already retried REQUEST_HOST_SYNC when the host's state wasn't
known yet, but the dialog's "Stay in sync" path did a single no-retry request —
so a stay tap right after the host paused (before a heartbeat propagated) could
silently leave the guest paused/out of sync. Extracted the retry loop into a
shared hcmRequestHostSyncWithRetry() and used it in both paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes a split-brain where a guest who chose "watch on my own" and then reloaded
the video page would appear stuck as "Solo" to the host while actually being
back in sync.
Root cause: background persists hcmDesynced (survives SW restart) and attaches
it to content-driven heartbeats, but a freshly injected content script started
with hcmDesynced=false and GET_CONTROL_MODE never returned the desync state — so
content re-applied host commands (synced) while background kept relaying
desynced=true to the host.
Fix: GET_CONTROL_MODE now also returns desynced; content adopts it on init
(re-shows the badge) and also adopts hostPeerId so later host-change resets work.
Desync now survives a page reload consistently on both sides, as intended.
Verified the full path: background.js heartbeat (1555 omits / 2018 attaches),
reset paths, CONTENT_BOOT and keepAlive-disconnect (neither reset it), content
sendHeartbeat on inject.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Original code at L1957 already validates sender.tab tabId against
currentTabId before calling processEvent() — added guard was
duplicate. HCM_DESYNC_STATE had no such guard; added with same
sender.tab && currentTabId pattern (popup-safe via undefined
short-circuit).
hcmDesynced now persisted in session storage:
- Restored in ensureState()
- Set on HCM_DESYNC_STATE change
- Reset to false in all 3 teardown storage.sets
Survives SW restarts; cleared on leave/switch/idle-room
- CONTENT_EVENT now rejected from non-selected tabs (popup exempted via sender.tab check)
- HCM_DESYNC_STATE same guard
- hcmDesynced persisted in session storage: restored on SW restart, cleared on teardown
Bis auf peerId-Auth (akzeptiert) alle Audit-Findings behoben:
- Snap-back spielt nicht mehr blind ab, wenn Host-Status unbekannt
- Teardown-Pfade broadcasten jetzt CONTROL_MODE → kein verwaistes Badge/Dialog
- Dialog-Timer bei Ersetzung sauber gecleart
- Server re-synchronisiert Sender bei nicht-Host-Reject
- Popup-Toggle revertiert bei Server-Ablehnung
- Desync-Zweig sendet keine Phantom-ACKs mehr an Host
- Toggle-Debouncing serverseitig (500ms pro Raum)
- Button-Text auf Unlock zurückgesetzt
- Live-Erkennung defensiver, Resync-Retry bei fehlendem Target, Badge-Retry
Neu: Host sieht 'Solo'-Badge für desyncte Gäste via PEER_STATUS-Heartbeat
(16 Sprachen, neuer i18n-Key BADGE_DESYNCED/TOOLTIP_PEER_DESYNCED)
Tests: H-5-Reject-Unicast, M-4-Debounce, Desync-Heartbeat-Relay
hcmHandleBlocked no longer re-checks local control mode before acting. Background
only sends HOST_BLOCKED to a gated guest, so the message itself is authoritative —
adopt host-only/guest role from it. Fixes the join race where a HOST_BLOCKED
arriving before the CONTROL_MODE broadcast was silently ignored.
Also corrects the EC-4 note: "let catch-up re-sync" is invalid (sync is
event-driven, no continuous catch-up); the right fix is a buffer-aware deferred
snap-back.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- popup: lock remote-control buttons (Play/Pause/SYNC) for guests in host-only
mode so clicks don't silently get gated and leave the button stuck; backstop
guards in the handlers and the 2.5s safety reset respects the lock.
- content: rebuild the desync dialog + resync badge with the DOM API inside a
Shadow DOM. innerHTML inline style="" attributes are stripped by strict
style-src CSP (Netflix/YouTube/Disney+); CSSOM .style is CSP-safe and the
shadow root isolates from page CSS.
- content: detect live-DVR in hcmIsLive() via a sliding seekable window
(seekable.start(0) > 1), not just duration === Infinity (EC-15).
Records remaining audit items (snap-back thrash, join race, dialog i18n) in the
edge-case log for the device-testing pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final wiring of host control mode — the user-facing UI:
- Active-room section gains a Host Control card: a Host/Guest role badge, a
host-only toggle ("Only I can control playback") shown to the host, and a
guest notice when the room is host-only.
- Toggle sends SET_CONTROL_MODE; UI refreshes from the server's CONTROL_MODE
broadcast (with optimistic revert on failure). Card updates on GET_STATUS,
CONTROL_MODE, and reconnect.
- i18n: 6 new keys (LABEL_HOST_CONTROL[_TOOLTIP], LABEL_HOST_ONLY_TOGGLE,
NOTICE_HOST_CONTROLS, BADGE_HOST, BADGE_GUEST) translated across all 15
popup locales.
Completes the feature end to end (server + background + content + popup).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guest-side handling when a room is in host-only mode:
- Intent classifier (EC-9): tells a deliberate guest pause/seek from an
involuntary one (buffering/seeking/tab-refocus/no recent gesture) via
readyState, video.seeking, a 'waiting' buffering-grace window, the existing
visibility grace, and own-tracked keydown/pointerdown gestures.
- HOST_BLOCKED handler: live streams are left alone (EC-15); involuntary events
snap back silently; deliberate events show a Teleparty-style choice dialog
(Stay in sync / Watch on my own), defaulting to stay after 8s (EC-18).
- Snap-back reuses tryMediaAction + suppress, with a cooldown to avoid
pause/play fight loops (EC-4).
- Desync ("watch on my own") mode: persistent resync badge, ignores host
commands while solo (still ACKs so force-sync doesn't stall), and auto-clears
on episode change (EC-12). Resync pulls the host's extrapolated position.
- background: GET_CONTROL_MODE (init fetch) + REQUEST_HOST_SYNC (resync target).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Background service worker now understands room control mode (no UI yet):
- Track controlMode + hostPeerId + amHost(); adopt from ROOM_DATA, update on
CONTROL_MODE, restore from session storage, reset on leave/room-switch.
- Receiver-side gate: ignore room-moving events from non-host peers in host-only
mode (backstop for old/buggy/modified clients past the server gate).
- Sender-side gate: a guest's play/pause/seek/force-sync is not broadcast;
instead a HOST_BLOCKED message is sent to the content tab with an extrapolated
host sync target (hook for step 4's snap-back + desync dialog).
- broadcastControlMode() notifies popup + content tab; GET_STATUS exposes
controlMode/hostPeerId/amHost; new SET_CONTROL_MODE message lets the host toggle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When no other peer is present, heartbeats, force-sync, and episode auto-sync
are now fully suppressed (previously the keepAlive heartbeat, force-sync, and
episode lobby were still broadcast to an empty room):
- keepAlive heartbeat gated on a live peer count
- force-sync is a no-op when solo (no pause-then-timeout freeze, no traffic)
- episode auto-sync skips the lobby entirely and just plays the next episode
The solo state is recomputed live from the current peer list on every event
(never cached), so the instant another peer joins syncing resumes. On that
solo->not-solo transition the background also requests an immediate heartbeat
from the content script so the newcomer sees the current position without
waiting for the next interval.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Room ID, password, and username were resurrected from synced storage on a
fresh install (sync survives an uninstall in the user's Google account), which
made the extension silently auto-connect to a dead room and appear permanently
connected. getSettings() and all settings reads (popup, content, audio-options,
applyAudioSettingsToTab) are now local-only, and legacy keys are actively
purged from sync on install/update/startup. Only onboardingComplete and
dismissedHints remain in sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address client-side rate-limit edge cases that could trip the server's
per-socket event budget or per-IP connection limit:
- Pace the offline event-queue flush after (re)connect in batches
(10 per 3s) instead of one synchronous burst, so a reconnect after a
long outage no longer dumps the whole backlog and gets kicked.
- Wrap socket.send() in try/catch and re-queue on failure (race with a
server-side disconnect) instead of losing the event.
- Ping liveness: tolerate one missed PONG; only force a reconnect after
2 consecutive misses (~20s) instead of a single hard 5s timeout.
- Reconnect backoff: tune to ~8 attempts/60s (under the connection limit)
and add +/-20% jitter to de-synchronize reconnect herds.
All magic numbers extracted into named constants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3-way merge of Kaia-Alenia's tab-manager refactor (PR #11) onto current
main (v2.4.3). Only the tab-related service-worker listeners move out of
background.js into extension/modules/tab-manager.js via initTabManager({...})
dependency injection; all other recent main changes are preserved untouched.
Behavior-preserving; listeners still register synchronously at top level.
The unrelated website/assets/NewLogoIcon_64.webp change from the PR was
excluded to keep main clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Switch the Ko-Fi support links to https://support.koalastuff.net across the
README badge, the extension footer (popup.html/template.html, label
"Support KoalaSync") and remove the now-unused KOFI_URL constant. Stop
generating/sending the anonymous uninstall token: initUninstallURL() now
registers the feedback URL without the `t` parameter or storage write.
CHANGELOG updated to match.
Also guard the client CMD_ACK path: only ACK a command sender that is still
a known peer in the room, so we don't emit ACKs to peers that already left
(which the server now drops as absent-peer ACKs anyway).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Media players fire bursts of native play/pause events (source swaps, ABR,
ads, teardown). Each was relayed as a distinct command, spamming peers and
tripping the relay's event rate limit. Add leading+trailing coalescing in
the content script: emit the first event instantly (zero added latency) and,
on a 150ms window, collapse a burst to its final state. Echo-suppression and
seek-flush stay synchronous on event arrival; the trailing send is never
deduped against the leading one (a remote command may change shared state
mid-window — re-sending is the safe, idempotent choice).
Server: split the EVENT_ACK handler's logging so only a genuine different-room
ACK is logged as [SECURITY]; an ACK to a peer that already left is logged
quietly as [ACKDROP] (verbose-only), so real signals are no longer drowned out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two new languages (uk, zh / zh_CN) across the extension and website:
register them in SUPPORTED_LANGUAGES, the Chrome _locales map, the website
build/lang-init/app routing, hreflang/og/schema tags and language selectors.
Fix systematic machine-translation word-sense errors in the new zh/uk
locales (e.g. zh "Status"=地位→状态, "Leave Room"=留出空间→离开房间,
"Play"=玩→播放, "Seek"=寻找→跳转, audio Attack/Release/Knee; uk "Play"=Грати
→Відтворити, "Clear"=ЯСНО→Очистити, "Open"=ВІДЧИНЕНО→Відкрити, peers
"Однолітки"→"Учасники"), translate remaining English leftovers (Room ID,
Custom) and normalise terminology. All 15 locales pass test-locales.cjs.
Note: popup.html and website/template.html also carry the language-selector
additions here; their Ko-Fi→Support label text is part of the branding change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- "No peers yet" state now shows a localized "Copy invite link" button (reuses BTN_COPY_INVITE, present in all 13 locales) so users can share without hunting for the field
- Add v2.4.1 changelog entry covering the onboarding tour improvements, empty-state action, and the mobile landing-page fixes from this session
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add the previously-orphaned 5th onboarding step (ONBOARDING_5) so the tour ends on a closing card instead of stopping at the username field
- Make step 1 a centered welcome card instead of spotlighting the logo; guard querySelector for target-less steps
- Fix stale static placeholders in popup.html (Step 1 of 3 -> 5, progress 33% -> 20%)
Verified ONBOARDING_5/DONE exist across all 13 locales; rendered with the longest (German) copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move extractEpisodeId() and sameEpisode() to shared episode-utils.js.
background.js imports via ES module. content.js (IIFE) receives injected
copy via build-extension.js marker replacement, eliminating duplication.
- background.js: remove isConnecting=false at line 608 (was set before
WebSocket handshake completed, defeating the guard for the entire
CONNECTING phase). isConnecting is already properly reset in onclose,
onopen/40 handler, catch block, and socket guard path.
- background.js: restore isConnecting=false in forceDisconnect() so
subsequent connect() calls can proceed after intentional disconnect.
- popup.js: remove hardcoded applyConnectionStatus('disconnected') that
caused a guaranteed red flicker on every popup open. Status is now
set only by the async GET_STATUS response.