Skip to content

Architecture

Kempt is a Manifest V3 extension with no framework and no background worker. esbuild bundles the TypeScript, and tests run on node:test.

  1. CSS before JS. A visual change is a stylesheet rule. JS runs only to add elements, move elements, read page data or change navigation.
  2. The core knows nothing about features. A feature is one folder plus one line in a list.
  3. Every feature can be turned off and fully reversed. When it’s off, none of its CSS applies and none of its JS runs.
  4. No global observers and no polling. Route changes come from YouTube’s yt-navigate-finish event. DOM waits disconnect as soon as they resolve or time out (10 s by default); pass the narrowest root you can.
  5. YouTube selectors have one home each. JS selectors live in src/core/selectors.ts; CSS selectors live in the feature’s style.css.

The manifest injects two scripts into every YouTube page, both at document_start:

Script World Does
content.js + content.css Isolated Reads settings, sets <html> attributes, routes, runs features
main-world.js YouTube’s page Answers bridge calls that need YouTube’s own objects
src/
content.ts entry: settings -> html attributes -> router -> runner
main-world.ts entry in the page world: a 20-line dispatcher
core/
feature.ts Feature and Option types, popup groups, the runner
router.ts routeOf(), onRoute(), shortsId()
settings.ts chrome.storage settings and their localStorage mirror
bridge.ts call(): the isolated-world side of the page bridge
dom.ts waitFor, keep, el, observe
selectors.ts every YouTube selector used from JS
icon.ts Figma icons from src/icons/ as CSS masks
i18n.ts YouTube's UI language and per-feature string tables
page/
youtube.ts page-world helpers: ytcfg, Innertube, walking YouTube's data
core.ts page-world handlers shared by features (stamping, navigate)
theme/tokens.css --kyt-* design tokens and overrides of YouTube's --yt-spec-* variables
features/
index.ts the only list of features (also the popup order)
page.ts the only list of page-world handlers
<id>/index.ts the feature
<id>/style.css its CSS
<id>/page.ts optional: its page-world handlers
sidebar/nav.ts YouTube's guide sections -> Kempt's sidebar layout (pure)
timeline/dates.ts relative upload times and History day headers -> dated groups (pure)
timeline/filter.ts Subscriptions toolbar: item kind and search match (pure)
font/faces.ts generated by scripts/fetch-fonts.mjs
popup/ renders itself from features/index.ts
color.ts accent color picker: swatches, hue/saturation wheel, brightness, hex
test/*.test.ts
export interface Feature {
id: string; // also the CSS gate: html[kyt-<id>]
label: string; // shown in the popup
hint?: string; // popup tooltip
group: Group; // popup section: look, navigation, watch, feeds
icon?: string; // file name in src/icons/ without .svg
defaultOn: boolean;
routes?: Route[]; // omit = every page
options?: Record<string, Option>; // popup controls
run?(ctx: Ctx): void | Promise<void>; // omit for CSS-only features
}
export interface Ctx {
signal: AbortSignal; // aborted when the feature turns off or leaves its routes
option<T>(key: string): T; // live option value
call: typeof call; // typed call into the page world
}

Rules for run():

  • Pass { signal: ctx.signal } to every listener, observer and wait.
  • On abort, remove what you added and put back what you moved.
  • To react to a new video on the watch page, listen for kyt:navigate with the same signal.

On startup, on every settings change and on every route change:

for each feature:
enabled = settings[id] ?? defaultOn
html.toggleAttribute(`kyt-${id}`, enabled) // turns its CSS on or off
for each option:
boolean -> html[kyt-<id>-<key>] while true
choice -> html[kyt-<id>-<key>="<value>"]
cssVar -> html.style.setProperty(cssVar, value) while enabled
shouldRun = enabled && run && (no routes || routes includes route)
if running and not shouldRun: abort its controller
if not running and shouldRun: new AbortController, run(ctx)

One AbortController per running feature is the whole lifecycle. Every listener and observer the feature made hangs off its signal, so turning a feature off can’t leak.

Every rule in a feature’s style.css starts with html[kyt-<id>]:

html[kyt-subscribe-red] ytd-subscribe-button-renderer button:not([subscribed]) { ... }

All feature CSS ships as one content.css, which the manifest injects before YouTube paints. Toggling a feature flips one attribute, so nothing gets injected at runtime and nothing flashes. test/structure.test.ts fails the build if a rule is missing its gate.

chrome.storage answers asynchronously, too late for the first paint. So settings.ts mirrors the whole settings object to localStorage['kempt-yt'], and content.ts reads it synchronously at document_start. Storage stays the source of truth.

routeOf(url) is a pure, unit-tested function: /watch is watch, /feed/subscriptions is subscriptions, and so on. The router listens to yt-navigate-finish, which YouTube fires on every in-page navigation, and to popstate. It then dispatches kyt:navigate with { route }. Shorts, search and playlists are other; shorts code uses shortsId().

waitFor(sel, { root, signal, timeout = 10_000 }): Promise<Element | null>

One MutationObserver on root only. It disconnects on a match, an abort or a timeout, and resolves null on abort or timeout (timeout: Infinity waits until abort). On timeout it logs kyt: "<selector>" not found after <ms>ms, the first warning when YouTube changes its markup.

keep(el, root, mount, signal, init?) // re-runs mount() when YouTube re-renders under root; removes el on abort
el(tag, cls?, ...children) // createElement + class + children
observe(target, init, fn, signal) // MutationObserver, disconnected on abort

Observers that repair YouTube’s re-renders write inside the observer callback itself. That runs before the next paint; a requestAnimationFrame delay would paint the unrepaired page for one frame, which shows as flicker.

src/theme/tokens.css defines every color Kempt uses as a --kyt-* variable, with a light and a dark value. The accent feature points YouTube’s own --yt-spec-* variables at them. A custom accent is one variable on <html>, and every tint is derived from it with color-mix().