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
25 changes: 22 additions & 3 deletions extension/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,20 @@ unit-testable, exactly as the Android domain layer is.
detection is CONFIRMED the moment it differs (or the inline data is authoritative, or a
6s backstop elapses) and SETTLING until then. While settling we withhold the interstitial
and stay gray, both fail-safe directions.
- **A NAVIGATION MUST NOT BE DEBOUNCED.** The first refresh after a full page load is what
NOTICES the url changed and starts the settle machinery, so routing `yt-navigate-finish`
through the 250ms debounce meant the mutation storm delayed even that, for ~2s the cold
first hop simply held the PREVIOUS video's verdict, colour and all (live QA). Navigation is
a discrete, known-important event: handle it immediately and let the debounced pass follow
for the DOM settling after it.
- **Colour and the verdict get DIFFERENT patience** (`SETTLE_COLOR_MS` 2.5s vs `SETTLE_MS`
6s). The tradeoff, weighed deliberately: on a hop between two videos by the SAME channel
the byline never changes, so nothing can confirm freshness and only the backstop ends the
wait. **Yes, this means an allowed -> allowed same-channel hop sits in grayscale for a
beat**, accepted, because the alternative is holding colour on a video that might be from
a channel the user is avoiding, and that is the exact hit the feature exists to remove.
Grayscale is cosmetic and self-correcting; a colour leak is the product failing. The
interstitial keeps the full 6s either way, so the reverse-direction protection is untouched.
- **A debounce can be STARVED.** The corrective re-check was losing to YouTube's
post-navigation mutation storm, which kept resetting the 250ms debounce, that starvation
is what stretched the window to 5s. Anything that MUST happen after an event needs its own
Expand All @@ -135,9 +149,14 @@ unit-testable, exactly as the Android domain layer is.
for ONE element (first match wins), but a few surfaces are genuinely several elements
shown together, the end-screen grid and the creator end-cards coexist, so those carry
`matchAll: true`. Getting this wrong hides one and leaves the other on screen.
- **The fail-open is observable.** When some cards resolve and others do not, the content
script logs a one-shot warning naming the count, so YouTube DOM churn shows up in
devtools rather than silently degrading channel filtering into a no-op.
- **The fail-open is observable, but only counts cards that SHOULD have had a channel.**
When some cards resolve and others do not, the content script logs a one-shot warning so
DOM churn shows up in devtools instead of silently degrading filtering into a no-op.
Ads and Shorts lockups have no channel BY NATURE, so counting them made it fire on every
normal home feed (~15 cards), and a warning that appears when nothing is wrong is a
warning nobody reads, which costs you the real signal. `isChannellessCard` excludes them
(a card that IS one, CONTAINS one, YouTube wraps in-feed ads in an ordinary
`ytd-rich-item-renderer`, or sits INSIDE a Shorts shelf).

#### Gray-screen: the mechanism and its flash behaviour

Expand Down
16 changes: 13 additions & 3 deletions extension/src/content/channelFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import { decideChannel, shouldShowInColor, type ChannelProbe } from '../core/channels';
import { channelFreshness, channelKey } from '../core/channelFreshness';
import { channelFreshness, channelKey, SETTLE_COLOR_MS } from '../core/channelFreshness';
import type { YoutubeConfig } from '../core/protocol';
import {
detectCardChannel,
Expand All @@ -21,7 +21,13 @@ import {
inlineVideoId,
videoIdFromUrl,
} from './channelDetection';
import { CHANNEL_HIDDEN_CLASS, COLOR_CLASS, NUDGE_OVERLAY_ID, pageTypeFor } from './selectors';
import {
CHANNEL_HIDDEN_CLASS,
COLOR_CLASS,
isChannellessCard,
NUDGE_OVERLAY_ID,
pageTypeFor,
} from './selectors';

/** The slice of config this module needs. */
export type ChannelConfig = Pick<
Expand Down Expand Up @@ -70,7 +76,9 @@ export function applyChannelFilter(
if (card.id === NUDGE_OVERLAY_ID || card.closest(`#${NUDGE_OVERLAY_ID}`)) continue;
const detected = detectCardChannel(card);
if (detected === null) {
result.unidentified += 1;
// Only count cards that SHOULD have had a channel. Ads and Shorts lockups never do,
// so counting them made the degraded-detection canary fire on every normal feed.
if (!isChannellessCard(card)) result.unidentified += 1;
continue;
}
const verdict = decideChannel({
Expand Down Expand Up @@ -219,6 +227,8 @@ export function applyGrayColor(
detectedKey: channelKey(detected),
previousKey: options.previousKey ?? null,
msSinceNav: options.msSinceNav ?? Number.POSITIVE_INFINITY,
// Colour gets the shorter backstop, see SETTLE_COLOR_MS.
settleMs: SETTLE_COLOR_MS,
}) === 'SETTLING';

const inColor =
Expand Down
50 changes: 50 additions & 0 deletions extension/src/content/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,56 @@ export const VIDEO_RENDERERS: string[] = [
/** One selector matching every feed card on the page. */
export const VIDEO_RENDERER_SELECTOR = VIDEO_RENDERERS.join(', ');

/**
* Feed cards that HAVE no channel by nature, so failing to identify one proves nothing.
*
* The degraded-detection canary counted these as evidence of DOM rot and therefore fired on
* every ordinary home feed (~15 cards, live QA, 2026-07-26). A warning that appears when
* nothing is wrong is a warning nobody reads, which would have cost us the real signal.
*
* - Ads have an advertiser, not a channel.
* - Shorts lockups in a feed carry no byline at all; Shorts are handled by their own
* hiding/gating path anyway.
*/
export const CHANNELLESS_CARD_SELECTORS: string[] = [
'ytd-ad-slot-renderer',
'ytd-in-feed-ad-layout-renderer',
'ytd-promoted-video-renderer',
'ytd-promoted-sparkles-web-renderer',
'ytd-display-ad-renderer',
'ad-slot-renderer',
'ytd-reel-item-renderer',
'ytm-shorts-lockup-view-model',
];

export const CHANNELLESS_CARD_SELECTOR = CHANNELLESS_CARD_SELECTORS.join(', ');

/** Shelves whose descendants are all Shorts, and therefore all channel-less. */
export const SHORTS_CONTAINER_SELECTOR =
'ytd-rich-shelf-renderer[is-shorts], ytd-reel-shelf-renderer, grid-shelf-view-model';

/**
* True when this card could never have yielded a channel, so its absence is not evidence
* that YouTube's DOM moved.
*/
export function isChannellessCard(card: Element): boolean {
try {
return (
// The card IS one (e.g. a Shorts lockup).
card.matches(CHANNELLESS_CARD_SELECTOR) ||
// The card CONTAINS one: YouTube wraps in-feed ads in an ordinary
// `ytd-rich-item-renderer`, so the ad marker is a descendant, not an ancestor.
card.querySelector(CHANNELLESS_CARD_SELECTOR) !== null ||
// The card is INSIDE one (a lockup within an ad or Shorts shelf).
card.closest(CHANNELLESS_CARD_SELECTOR) !== null ||
card.closest(SHORTS_CONTAINER_SELECTOR) !== null
);
} catch {
// An unparsable selector must never break feed filtering.
return false;
}
}

/**
* Decorations YouTube wraps around an accessible channel name, stripped before display.
* `aria-label` is preferred over `textContent` (ext-03 §3: more reliable) but it is phrased
Expand Down
16 changes: 15 additions & 1 deletion extension/src/content/youtube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,21 @@ export function initYoutubeContentScript(
refresh();
}

const onNavigate = (): void => scheduleRefresh();
/**
* Navigation is handled IMMEDIATELY, not through the debounce.
*
* `scheduleRefresh()` was the cold-hop bug (live QA, 2026-07-26): the very first refresh
* after a full page load is what NOTICES the url changed and starts the settle machinery,
* and routing it through the 250ms debounce meant YouTube's post-nav mutation storm kept
* resetting it, so for ~2s nothing ran at all and the page held the PREVIOUS video's
* verdict, colour and all. A navigation is a discrete, known-important event; it should
* never queue behind page churn. The debounced pass still follows for the DOM settling
* after it.
*/
const onNavigate = (): void => {
refresh();
scheduleRefresh();
};
doc.addEventListener('yt-navigate-finish', onNavigate);
view?.addEventListener('popstate', onNavigate);

Expand Down
19 changes: 19 additions & 0 deletions extension/src/core/channelFreshness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ export function channelKey(
*/
export const SETTLE_MS = 6_000;

/**
* A SHORTER backstop for the colour decision than for the block verdict.
*
* The two decisions carry different risk, so they get different patience:
*
* - The VERDICT (interstitial) keeps the full `SETTLE_MS`. Gating the wrong video is the
* most damaging thing we can do, so we wait as long as it takes to be sure.
* - COLOUR is cosmetic in one direction and not the other. Staying gray a moment too long
* on an allowed video is a mild annoyance; flashing colour onto a channel someone is
* avoiding is the hit the feature exists to remove.
*
* Why it needs its own value at all: on a hop between two videos BY THE SAME CHANNEL the
* byline never changes, so nothing can ever confirm freshness and only the backstop ends the
* wait. With one shared 6s value, watching several videos from a channel you deliberately
* whitelisted would sit in grayscale for six seconds each time, the feature would read as
* broken. This bounds that to a beat while leaving the verdict's caution untouched.
*/
export const SETTLE_COLOR_MS = 2_500;

export type Freshness = 'CONFIRMED' | 'SETTLING';

export interface FreshnessInput {
Expand Down
54 changes: 53 additions & 1 deletion extension/tests/content/channelFilter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
} from '../../src/content/channelFilter';
import { CHANNEL_HIDDEN_CLASS, COLOR_CLASS } from '../../src/content/selectors';
import type { ChannelEntry } from '../../src/core/settingsSchema';
import { FEED_ALL_IDENTIFIABLE_HTML, FEED_MULTI_CHANNEL_HTML } from './fixtures/feedPage';
import {
FEED_ALL_IDENTIFIABLE_HTML,
FEED_MULTI_CHANNEL_HTML,
FEED_WITH_ADS_AND_SHORTS_HTML,
} from './fixtures/feedPage';
import {
WATCH_FIXTURE_VIDEO_ID,
WATCH_NOTHING_HTML,
Expand Down Expand Up @@ -341,3 +345,51 @@ describe('when YouTube changes its DOM and channels stop being identifiable', ()
expect(warnings).toEqual([]);
});
});

describe('the degraded-detection canary on an ordinary feed', () => {
/**
* REGRESSION (live QA, 2026-07-26): the canary counted ads and Shorts lockups, which have
* no channel by nature, as unidentifiable, so it cried "YouTube changed its DOM" on every
* normal home feed (~15 cards). A warning that fires when nothing is wrong is a warning
* nobody reads, which would have cost us the real signal.
*/
function runOnMixedFeed(): { warnings: string[]; unidentified: number } {
document.body.innerHTML = FEED_WITH_ADS_AND_SHORTS_HTML;
const warnings: string[] = [];
const result = applyChannelFilter(
document,
config({ channelMode: 'WHITELIST', channels: [channel({ channelId: ALPHA })] }),
{ warn: (message) => warnings.push(message) },
);
return { warnings, unidentified: result.unidentified };
}

it('counts only the organic video it genuinely could not identify', () => {
// One organic mystery card; the ad and both Shorts lockups must not count.
expect(runOnMixedFeed().unidentified).toBe(1);
});

it('still leaves every unidentifiable card visible', () => {
runOnMixedFeed();

const mystery = document.querySelector('[data-testid="card-organic-unreadable"]');
const ad = document.querySelector('[data-testid="card-ad"]');
expect(mystery?.classList.contains(CHANNEL_HIDDEN_CLASS)).toBe(false);
expect(ad?.classList.contains(CHANNEL_HIDDEN_CLASS)).toBe(false);
});

it('stays quiet on a feed whose only unidentifiable cards are ads and Shorts', () => {
document.body.innerHTML = FEED_WITH_ADS_AND_SHORTS_HTML;
const organic = document.querySelector('[data-testid="card-organic-unreadable"]');
organic?.remove();

const warnings: string[] = [];
applyChannelFilter(
document,
config({ channelMode: 'WHITELIST', channels: [channel({ channelId: ALPHA })] }),
{ warn: (message) => warnings.push(message) },
);

expect(warnings).toEqual([]);
});
});
43 changes: 43 additions & 0 deletions extension/tests/content/fixtures/feedPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,46 @@ export const FEED_ALL_IDENTIFIABLE_HTML = `
</ytd-rich-grid-renderer>
</div>
`;

/**
* A realistic home feed: ordinary videos, an in-feed AD, a SHORTS shelf, and exactly ONE
* organic video whose channel genuinely cannot be read.
*
* The degraded-detection canary must count only that last one. Counting the ad and the
* Shorts lockups is what made it fire on every normal feed (live QA, 2026-07-26), those
* card types have no channel by nature, so their absence proves nothing about DOM rot.
*/
export const FEED_WITH_ADS_AND_SHORTS_HTML = `
<div id="page-manager">
<ytd-rich-grid-renderer>
<div id="contents">
<ytd-rich-item-renderer data-testid="card-alpha">
<ytd-channel-name id="channel-name">
<a class="yt-formatted-string" href="/channel/UCALPHACHANNEL000000001">Alpha Channel</a>
</ytd-channel-name>
</ytd-rich-item-renderer>

<ytd-rich-item-renderer data-testid="card-ad">
<ytd-ad-slot-renderer>
<ytd-in-feed-ad-layout-renderer>Sponsored, buy a mattress</ytd-in-feed-ad-layout-renderer>
</ytd-ad-slot-renderer>
</ytd-rich-item-renderer>

<ytd-rich-section-renderer data-testid="shorts-shelf">
<ytd-rich-shelf-renderer is-shorts>
<ytd-reel-item-renderer data-testid="shorts-card-1">
<a href="/shorts/aaa">A short</a>
</ytd-reel-item-renderer>
<ytd-reel-item-renderer data-testid="shorts-card-2">
<a href="/shorts/bbb">Another short</a>
</ytd-reel-item-renderer>
</ytd-rich-shelf-renderer>
</ytd-rich-section-renderer>

<ytd-video-renderer data-testid="card-organic-unreadable">
<a id="video-title-link" href="/watch?v=mystery001">An organic video with no readable byline</a>
</ytd-video-renderer>
</div>
</ytd-rich-grid-renderer>
</div>
`;
31 changes: 31 additions & 0 deletions extension/tests/core/channelFreshness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
channelFreshness,
channelKey,
SETTLE_COLOR_MS,
SETTLE_MS,
SETTLE_RECHECK_MS,
type FreshnessInput,
Expand Down Expand Up @@ -132,3 +133,33 @@ describe('the post-navigation re-check schedule', () => {
expect(new Set(SETTLE_RECHECK_MS).size).toBe(SETTLE_RECHECK_MS.length);
});
});

describe('colour is less patient than the block verdict', () => {
/**
* Two videos by the SAME channel is the case that needs this: the byline never changes, so
* nothing can confirm freshness and only the backstop ends the wait. Sharing one 6s value
* meant watching several videos from a channel you deliberately whitelisted sat in
* grayscale for six seconds each time, which reads as the feature being broken.
*/
const sameChannelHop = () => input({ detectedKey: ALLOWED, previousKey: ALLOWED });

it('restores colour sooner than it would deliver a verdict', () => {
expect(SETTLE_COLOR_MS).toBeLessThan(SETTLE_MS);
});

it('is still withholding both a moment after the hop', () => {
const justAfter = { ...sameChannelHop(), msSinceNav: 200 };

expect(channelFreshness({ ...justAfter, settleMs: SETTLE_COLOR_MS })).toBe('SETTLING');
expect(channelFreshness(justAfter)).toBe('SETTLING');
});

it('gives colour back while the verdict is still deliberately waiting', () => {
const midWindow = { ...sameChannelHop(), msSinceNav: SETTLE_COLOR_MS };

// Cosmetic decision: settled.
expect(channelFreshness({ ...midWindow, settleMs: SETTLE_COLOR_MS })).toBe('CONFIRMED');
// Punitive decision: still waiting, because gating the wrong video is far worse.
expect(channelFreshness(midWindow)).toBe('SETTLING');
});
});
Loading