Skip to content

feat(ext): YouTube channel lists, gray-screen mode, Unhook-parity toggles - #10

Merged
astraedus merged 4 commits into
mainfrom
extension-yt-advanced
Jul 26, 2026
Merged

feat(ext): YouTube channel lists, gray-screen mode, Unhook-parity toggles#10
astraedus merged 4 commits into
mainfrom
extension-yt-advanced

Conversation

@astraedus

Copy link
Copy Markdown
Owner

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-counting ytInitialData scan → a DOM chain, because YouTube ships different shapes on different surfaces. Entries store 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.

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:

Approach Gateable? Flash-free?
Static content_scripts.css No, always on Yes
CSS injected from JS after a storage read Yes No, paints colour first
Dynamic registerContentScripts with css Yes Yes

I verified against the chrome.scripting reference that registerContentScripts accepts css and injects it "before any DOM is constructed or displayed". 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 otherwise outlive its setting). Adds the scripting permission, 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

Gate Result
npm run lint clean
npm run typecheck clean
npm test 570 passing, 20 files (+130)
npm run build loadable, grayscale.css ships
npm run e2e 35/35 passing (+8)

E2E now drives the real YouTube content script with zero network: *.youtube.com is mapped onto the local fixture server, so the script that only matches *://*.youtube.com/* runs for real against a page carrying a genuine ytInitialPlayerResponse. That required moving the fixture server 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 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. migrateSettings was 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

  • Every test is phrased as a user-visible outcome, never an internal branch, carrying forward the lesson from the redirect-loop incident.
  • Each hiding feature owns its own CSS class: with one shared class, turning Shorts hiding off would un-hide your comments.
  • Autoplay is honestly best-effort, no API exists, so we click the player's own switch only when it reads aria-checked="true" (idempotent by construction). YouTube can restore its own state; the dashboard says so rather than implying certainty.
  • Instagram / TikTok / X reel surfaces are in the PRD's v1.1 but not in this phase; they stay in the known-gaps list.

Draft on purpose, the orchestrator QA-gates the merge.

…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
astraedus marked this pull request as ready for review July 26, 2026 08:28
@astraedus
astraedus merged commit 0ca3ad2 into main Jul 26, 2026
2 checks passed
@astraedus
astraedus deleted the extension-yt-advanced branch July 26, 2026 08:29
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