feat(ext): YouTube channel lists, gray-screen mode, Unhook-parity toggles - #10
Merged
Conversation
…egistration Foundation for Phase 4 (YouTube advanced). Settings schema goes to v2, purely additive, every new default off, so a v1 user's protection is neither weakened nor silently strengthened. migrateSettings was already total, so v1 objects pick up the new defaults with no per-version branch; channel entries store BOTH the UCxxxx id AND the handle because neither is always available (feed cards expose /@handle, watch pages expose the canonical id) and entries sharing either identifier are merged. selectors.ts gains the ext-03 §3/§5 taxonomy: CHANNEL_ELEMENTS chain, VIDEO_RENDERERS (scoped per card, that scoping IS the feed-composition mechanism), and per-page-type HIDE_SURFACES for the Unhook-parity toggles. The href^= channel rungs are deliberately NOT marked fallback: a bare /@handle link is normal YouTube, so warning on it would make the churn warning meaningless. Gray-screen: VERIFIED against the chrome.scripting docs that registerContentScripts accepts css and injects it 'before any DOM is constructed or displayed'. That is the only mechanism that is BOTH gateable and flash-free, static manifest CSS can't be turned off, and JS-injected CSS must await a storage read and therefore paints in colour first. grayscale.css is registered while the feature is on and unregistered when off, re-derived from settings on every worker wake because persistAcrossSessions defaults to true and a stale registration would outlive the setting. Adds the 'scripting' permission (no install warning).
…gles Phase 4, the differentiator features. All YouTube; the other-platform reel surfaces are still unbuilt and stay in the known-gaps list. CHANNEL LISTS. Three-tier identification per ext-03 §3 (ytInitialPlayerResponse -> a brace-counting ytInitialData scan -> a DOM chain) because YouTube ships different shapes on different surfaces. Entries carry BOTH the UCxxxx id and the handle and match on either, since feed cards usually expose only /@handle while watch pages expose the canonical id. Feed cards are matched with the channel chain SCOPED per card; an unscoped query returns the first channel on the page for every card, and the fixture plants a page-level decoy specifically to catch that. Watch pages get the interstitial in the configured mode, reusing the Shorts overlay rather than growing a second one that could drift. THE UNKNOWN-CHANNEL CASE is the load-bearing decision. Detection can fail, and a hard whitelist that failed SHUT would block the whole of YouTube the moment a selector rots. So an unidentified channel is ALLOWED, but carries a distinct reason ('unknown-channel'), so "checked and fine" stays distinguishable from "couldn't tell". A silent fail-open is a bug; a documented observable one is a design decision. Gray-screen takes the opposite bias: an unidentified channel never earns colour, because staying gray is harmless. GRAY-SCREEN. The requirement looks contradictory, gateable AND before first paint, and neither obvious option gives both: manifest CSS can't be turned off, JS-injected CSS must await a storage read and paints in colour first. VERIFIED against the chrome.scripting reference that registerContentScripts accepts css and injects "before any DOM is constructed or displayed", so the worker registers grayscale.css while the feature is on and unregisters it when off, re-deriving from settings on every wake (persistAcrossSessions defaults true, so a stale registration would outlive its setting). The one remaining flash is gray->colour on an ALLOWED channel, which is unavoidable and the right direction; documented with its tradeoff table in extension/CLAUDE.md. UNHOOK TOGGLES. Five independent, all default off. Each hiding feature owns its OWN css class, one shared class would mean turning Shorts hiding off also un-hid your comments. Autoplay is honestly documented as best-effort: no API exists, so we click the player's own switch only when it reads aria-checked=true (idempotent by construction), and the dashboard says so rather than implying certainty. SCHEMA v2, purely additive, every new default off, so upgrading changes nothing a v1 user is protected from. migrateSettings was already total so there is no per-version branch; explicit v1->v2 tests assert the old settings survive intact, the new features are all off, and re-migrating is a no-op. E2E now drives the REAL YouTube content script with zero network: *.youtube.com is mapped onto the fixture server. That server had to move to HTTPS, youtube.com is HSTS-preloaded, so http:// is force-upgraded before the resolver rule applies and a plain-HTTP server answers ERR_SSL_PROTOCOL_ERROR. It generates a throwaway self-signed cert per run (never committed). The grayscale assertions check COMPUTED style, which is what proves the registered CSS actually loaded. Gate: eslint clean, tsc clean, 570 unit tests (20 files, +130), 35/35 e2e (+8).
…SPA navigation CRITICAL, found by live QA. YouTube does not rewrite its inline ytInitialPlayerResponse / ytInitialData scripts on a watch -> watch client-side navigation, so they keep describing whatever video was last FULL-loaded. Tier 1 therefore returned a well-formed channel belonging to the WRONG video, short-circuited the chain, and the freshly re-rendered DOM byline was never consulted. Observed live: full-load a whitelisted channel, SPA-hop to a non-whitelisted video, and it played in full colour with no gate, defeating the whitelist, the blacklist AND gray-screen during the browsing pattern people actually use. My "primary" tier was the stalest source precisely when it mattered. Both inline tiers must now PROVE they describe the current video: when the URL names one, a tier is used only if it declares the same videoId. A tier declaring a different id is stale; a tier declaring none cannot be verified. Both are skipped in favour of the DOM, which YouTube genuinely re-renders. Tier 2 has the same staleness (verified) and is handled identically. Feed filtering was never affected, it is per-card DOM. Regression PROVEN both ways: with the guard neutered the unit test fails and with it restored it passes. The e2e reproduces the exact live sequence (full load -> history.pushState + yt-navigate-finish to a video whose inline data is still pinned to the previous one) and asserts both that the gate appears and that gray-screen withholds colour. Also from the QA round: - channelFromDom now iterates every rung and every match until one yields an identifier, skipping hrefless placeholders. A hidden empty ytd-channel-name anchor on search results was winning the first rung and masking the real /@handle link, leaking the whitelist. Added the observed "Big Think" shape. - hide-comments targets #comments itself; hiding only #comments #contents left the "N Comments / Sort by" header behind as dead chrome. - end-screen: .ytp-endscreen-content and .ytp-ce-element COEXIST rather than substituting, so HideSurface gained matchAll and that surface uses it. First-match-wins had been hiding the grid and leaving the cards. - refreshed the Shorts nav chain for current YouTube; our own degraded-mode canary is what reported it, working exactly as designed. - unknown-channel fail-open now has a runtime signal: a one-shot console warning when some cards resolve and others do not, so selector rot is visible in devtools instead of quietly turning the filter into a no-op. Deliberately silent when nothing resolved (a page with no feed cards), a canary that cries wolf is ignored. Two of the fixes required correcting tests that asserted implementation rather than outcome: one pinned the exact winning selector string (broken by a legitimate chain refresh) and the e2e fixture page lacked a videoId, which the new guard correctly rejected. Both are now outcome-phrased. Gate: eslint clean, tsc clean, 580 unit tests (20 files, +10), 37/37 e2e (+2).
…ling after SPA nav Residual from the staleness fix, found by live QA. Killing the PERMANENT bypass left a TRANSIENT one: yt-navigate-finish fires before YouTube re-renders the owner byline, so for ~1.5-5s even the DOM tier reports the previous video's channel. Forward (allowed -> blocked) was a couple of ungated seconds; the REVERSE (blocked -> allowed) was much worse, a channel the user explicitly allowed was accused of being "off your list", and greyed, for 3-5s. Punishing someone for watching exactly what they said they wanted is the most damaging thing this extension can do, and it teaches them to distrust the gate. core/channelFreshness.ts is the fix, and it turns on a cheap discriminator: staleness only MATTERS while the byline still names the channel from BEFORE the hop. If what we detect now differs from what we detected then, the byline has demonstrably re-rendered and is trustworthy immediately; if it is the same we cannot distinguish "stale" from "two videos by the same channel", so we wait. CONFIRMED also short-circuits when the inline data is authoritative for the URL's video, when there is no previous channel, or when a deliberately generous 6s backstop elapses, the backstop only bites on same-channel hops, where the verdict is identical anyway, so it costs no latency in the common case. While SETTLING we withhold the interstitial and stay gray. Both are the fail-safe direction: not-yet-gating is the same fail-open we already chose for an unidentifiable channel, and an extra second of grayscale is unnoticeable next to flashing colour onto a channel someone is avoiding. Second half of the bug: the corrective re-check was being STARVED. YouTube's post-navigation mutation storm kept resetting the 250ms debounce, which is what stretched the window to 5s. Navigation now schedules its own fixed re-checks (400ms/900ms/1.8s/3.2s/6.1s) that page activity cannot reset. The state machine is pure and unit-tested outcome-first (12 cases incl. both directions of the live repro). The full transient is live-only, fixtures have no mutation storm, so QA re-verifies on device; the existing SPA e2e still passes because it re-renders the byline, which is exactly the CONFIRMED path. Gate: eslint clean, tsc clean, 592 unit tests (21 files, +12), 37/37 e2e.
astraedus
marked this pull request as ready for review
July 26, 2026 08:28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 4, the differentiator features, all YouTube. Builds on the merged MVP (#9).
What's in it
Channel whitelist / blacklist. Three modes (off / "block these channels" / "only allow these channels"), applied to both feed composition and the watch page. Identification is three-tier per ext-03 §3,
ytInitialPlayerResponse→ a brace-countingytInitialDatascan → a DOM chain, because YouTube ships different shapes on different surfaces. Entries store both theUCxxxxid and the handle and match on either, since feed cards usually expose only/@handlewhile watch pages expose the canonical id.Gray-screen mode. YouTube goes grayscale; channels you allowed come back in colour.
Unhook-parity toggles. Hide home feed / sidebar recs / end-screen suggestions / comments, plus disable autoplay. Five independent switches, all default off.
The two decisions worth reviewing
1. The unknown-channel case fails OPEN, deliberately and observably.
Detection can fail, YouTube moves its DOM, or the page hasn't hydrated. A hard whitelist that failed shut would block the entire site the moment a selector rots. So an unidentified channel is allowed, but carries a distinct
reason: 'unknown-channel'so "checked and it's fine" stays distinguishable from "couldn't tell at all". A silent fail-open is a bug; a documented, observable one is a design decision. Gray-screen takes the opposite bias, an unidentified channel never earns colour, because staying gray is harmless while over-blocking is not.2. Gray-screen's mechanism, the requirement looks contradictory.
The CSS must be gateable (off when the feature is off) and applied before first paint (or the page flashes full colour, which is the exact hit the feature exists to remove). Neither obvious option gives both:
content_scripts.cssregisterContentScriptswithcssI verified against the
chrome.scriptingreference thatregisterContentScriptsacceptscssand injects it "before any DOM is constructed or displayed". The worker registersgrayscale.csswhile the feature is on and unregisters it when off, re-deriving from settings on every wake (persistAcrossSessionsdefaults true, so a stale registration would otherwise outlive its setting). Adds thescriptingpermission, no new install warning.The one flash that remains is gray → colour on an allowed channel, which is unavoidable and the right direction. Documented with the tradeoff table in
extension/CLAUDE.md, as requested.Verification
npm run lintnpm run typechecknpm testnpm run buildgrayscale.cssshipsnpm run e2eE2E now drives the real YouTube content script with zero network:
*.youtube.comis mapped onto the local fixture server, so the script that only matches*://*.youtube.com/*runs for real against a page carrying a genuineytInitialPlayerResponse. That required moving the fixture server to HTTPS, youtube.com is HSTS-preloaded, sohttp://is force-upgraded before the resolver rule applies and a plain-HTTP server answersERR_SSL_PROTOCOL_ERROR. It generates a throwaway self-signed cert per run (never committed). The grayscale specs assert computed style, which is what actually proves the dynamically-registered CSS loaded.Schema migration
v1 → v2 is purely additive and every new default is off, so upgrading changes nothing an existing user is protected from.
migrateSettingswas already total, so there's no per-version branch. Explicit tests assert a realistic v1 object keeps every setting intact, comes out with all new features off, and re-migrating is a no-op.Reviewer notes
aria-checked="true"(idempotent by construction). YouTube can restore its own state; the dashboard says so rather than implying certainty.Draft on purpose, the orchestrator QA-gates the merge.