Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
7b20cd0
web-ext: Bun build, feature folders, Audio preset, bug fixes
DarhkVoyd Sep 26, 2026
65c88a9
fix(web-ext): theme CSS import, no-video in theater mode, playlist he…
DarhkVoyd Sep 26, 2026
2ddd7b1
web-ext: kernel, and playlist runtime as the first feature on it
DarhkVoyd Sep 27, 2026
174fbe4
web-ext: keybinding registry, and shorts on the kernel
DarhkVoyd Sep 27, 2026
7c60dba
web-ext: playback on the kernel
DarhkVoyd Sep 27, 2026
62e0861
web-ext: profiles on the kernel
DarhkVoyd Sep 27, 2026
3e477fc
web-ext: segments on the kernel
DarhkVoyd Sep 27, 2026
8c92dcb
web-ext: popup and options on Preact with YouTube's theme
DarhkVoyd Sep 27, 2026
d6f1d77
web-ext: finish the kernel cutover
DarhkVoyd Sep 27, 2026
e5178f6
Refresh the README and docs after the web-ext rebuild
DarhkVoyd Sep 27, 2026
cc9b3b3
web-ext: fix the Firefox build, whose chrome.* calls returned undefined
DarhkVoyd Sep 27, 2026
a7f7212
web-ext: segment markers drawn like an editor's in and out points
DarhkVoyd Sep 27, 2026
3d74d65
web-ext: segment markers ride above the progress bar
DarhkVoyd Sep 27, 2026
ee25149
web-ext: crisp pin markers in YouTube red
DarhkVoyd Sep 27, 2026
5eeba5d
web-ext: playlist runtime that works in dev builds, and blends in
DarhkVoyd Sep 27, 2026
b8ec435
web-ext: resolve only elements that are actually on screen
DarhkVoyd Sep 27, 2026
7577a0e
web-ext: skeleton while the playlist runtime loads
DarhkVoyd Sep 27, 2026
b5562c9
backend: fetch each page's durations while the next page loads
DarhkVoyd Sep 27, 2026
457a032
web-ext: playlist runtime typeset exactly like YouTube's metadata
DarhkVoyd Sep 27, 2026
b1dc7ee
website: YouTube's palette
DarhkVoyd Sep 27, 2026
e0c118f
web-ext: drop _locales for its two strings
DarhkVoyd Sep 27, 2026
ed88b5a
New logo: a heavy t with a topping on top
DarhkVoyd Sep 27, 2026
2f20e64
website: back to its own palette
DarhkVoyd Sep 27, 2026
b0b5949
Logo: the play slice, in ember
DarhkVoyd Sep 27, 2026
e809a4e
web-ext: popup and options in the Toppings brand
DarhkVoyd Sep 27, 2026
4dcfa7e
Logo: a true wedge with a crust band and a cheese drip
DarhkVoyd Sep 27, 2026
ec0f5b6
Logo: back to the original until a designer draws the new one
DarhkVoyd Sep 27, 2026
32f1e00
web-ext: rebuild on asset changes in watch mode
DarhkVoyd Sep 27, 2026
11223f6
web-ext: find Shorts controls among rendered elements only
DarhkVoyd Sep 27, 2026
1e35bf7
web-ext: find the speed row in any language
DarhkVoyd Sep 27, 2026
e39b269
web-ext: look up player and Shorts controls inside their live root
DarhkVoyd Sep 27, 2026
7c40d20
web-ext: release, CI and type-check fixes from review
DarhkVoyd Sep 27, 2026
4548650
web-ext: fixes from the full-branch review: kernel, playlist runtime
DarhkVoyd Sep 27, 2026
e55ef19
web-ext: type AppLayout with Preact, fixing CI
DarhkVoyd Sep 27, 2026
fd72e05
web-ext: version 4.0.0-alpha
DarhkVoyd Sep 27, 2026
d87a8e9
web-ext: segments fixes from the full-branch review
DarhkVoyd Sep 27, 2026
5319e27
web-ext: options page fixes from the full-branch review
DarhkVoyd Sep 27, 2026
392df7e
web-ext: kernel fixes from the full-branch review
DarhkVoyd Sep 27, 2026
2c1751a
web-ext: profiles fixes from the full-branch review
DarhkVoyd Sep 27, 2026
00b4b14
backend: validate playlist IDs, stop paging after a failure, count days
DarhkVoyd Sep 27, 2026
676a2cf
web-ext: segments hold still during mid-roll ads
DarhkVoyd Sep 27, 2026
c428da6
web-ext: declare data collection for Firefox
DarhkVoyd Sep 27, 2026
529a3ea
web-ext: fix regressions found verifying the review fixes
DarhkVoyd Sep 27, 2026
c1bc68c
web-ext: segments button shows state by its icon alone
DarhkVoyd Sep 27, 2026
d44415f
Constants and module state at the top of each file
DarhkVoyd Sep 27, 2026
b1c43a7
web-ext: audio mode returns, over profiles, with per-profile shortcuts
DarhkVoyd Sep 27, 2026
89f0750
web-ext: the Audio preset only covers the video
DarhkVoyd Sep 28, 2026
92654d7
web-ext: schedules switch profiles by time or place
DarhkVoyd Sep 28, 2026
993fec3
web-ext: schedules UI; remove the YouTube sidebar entry
DarhkVoyd Sep 28, 2026
fa4356a
web-ext: open tabs pick up updates; schedules switch on time
DarhkVoyd Sep 28, 2026
1934e54
web-ext: the speed row and panel follow every rate change live
DarhkVoyd Sep 28, 2026
c8e6883
web-ext: the gear menu gets a native Profile row
DarhkVoyd Sep 28, 2026
d2cf43e
web-ext: the speed slider's fill follows the rate
DarhkVoyd Sep 28, 2026
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
14 changes: 10 additions & 4 deletions .github/workflows/build-web-ext.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ jobs:
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14

- name: Install Dependencies
run: cd web-ext && bun install
run: cd web-ext && bun install --frozen-lockfile

- name: Build for Firefox
run: cd web-ext && bun run build:firefox
Expand All @@ -35,10 +37,14 @@ jobs:
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14

- name: Install Dependencies
run: cd web-ext && bun install
run: cd web-ext && bun install --frozen-lockfile

- name: Check
run: cd web-ext && bun run check
- name: Build for Chrome
run: cd web-ext && bun run build
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This file holds the rules that apply **everywhere in the repo**. Anything that a

| You are touching | Read this too | What it is |
| --- | --- | --- |
| `web-ext/` | _(not written yet — root rules only)_ | Chrome/Firefox extension for YouTube |
| `web-ext/` | [`AGENTS/web-ext.md`](AGENTS/web-ext.md) | Chrome/Firefox extension for YouTube |
| `website/` | [`website/AGENTS.md`](website/AGENTS.md) | Next.js marketing and docs site (static export) |
| `backend/` | [`backend/AGENTS.md`](backend/AGENTS.md) | Cloudflare Worker behind `/api` |
| `assets/` | _(none)_ | Brand assets |
Expand Down Expand Up @@ -78,6 +78,7 @@ The repo predates this rule and still carries comments that break it. Do not lau
- **Smallest diff that works.** Do not restructure files you were not asked to change. Do not add abstractions for a second caller that does not exist yet.
- **Delete over add.** Removing dead code is a complete contribution.
- **Match the surrounding file** in naming, import ordering, and idiom, even where it differs from your own preference.
- **File order:** imports, then types, then module-level constants and state (selectors, ids, limits, caches, flags), then functions. A value built from the file's own helpers (a settings slice, a feature object, a catalogue) comes after the helpers it uses.
- **No new dependencies** for something a few lines of platform or standard-library code already do. If a dependency is genuinely needed, say why before adding it.
- **Never commit unless asked.** When asked, stage only the files your task touched — the working tree often carries unrelated in-progress work. No `Co-Authored-By` or agent-attribution trailers.
- **Verify before claiming done.** Run the project's type-check or test command and report what it actually printed, including pre-existing failures you did not cause.
Expand Down
128 changes: 128 additions & 0 deletions AGENTS/web-ext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# AGENTS/web-ext.md

Scoped rules for `web-ext/`, the Chrome and Firefox extension. Read [`../AGENTS.md`](../AGENTS.md) first — its rules, especially the comment policy, apply here in full.

---

## 1. COMMANDS

Run from `web-ext/`:

| Command | What it does |
| --- | --- |
| `bun run dev` | Build into `dist/` (Chrome, MV3) and rebuild on change |
| `bun run dev:firefox` | Same, transformed to MV2 |
| `bun run build` / `build:firefox` | Production build |
| `bun run check` | `tsc --noEmit` (app and `scripts/`), then `bun test` |
| `bun run release` / `release:firefox` | Build, then zip `dist/` via `scripts/release.ts` |

The build is `scripts/build.ts`: `Bun.build` per entry (IIFE, no code splitting), Tailwind through its PostCSS API from `tailwind.config.ts`, static copies, and the manifest transform. Set `DIST=<dir>` to build somewhere other than `dist/`, so a Chrome and a Firefox build can coexist. CSS is not imported from JS: the popup and options pages link their stylesheet; the content script ships none, because everything it injects is styled inside its own shadow roots.

---

## 2. ENTRY POINTS

Five entries, all under `app/` (`scripts/build.ts`):

| Entry | Source | Runs in |
| --- | --- | --- |
| `background` | `app/background/index.ts` | Service worker (MV3) / background page (MV2) |
| `content` | `app/content/index.ts` | Injected into YouTube pages |
| `popup` | `app/popup/index.tsx` | Toolbar popup |
| `options` | `app/options/index.tsx` | Options page |
| `offscreen` | `app/offscreen/index.ts` | Chrome only: a hidden page the service worker opens to read location (`lib/location.ts`) |

The content script does one thing: `bootFeatures([...features], onNavigate, { enabled })`. The background serves feature messages, migrates the pre-kernel store, keeps the toolbar icon in step with the master switch, and runs profile schedules (`features/schedules/background.ts`) on a one-minute alarm, with their status on the toolbar badge.

---

## 3. THE KERNEL

Every feature is one folder under `features/` exporting a `Feature` (`kernel/features.ts`). The kernel mounts a feature on each navigation that lands on one of its `routes` and calls the returned unmount on the next, so `mount` always starts from a clean page and never has to be idempotent. YouTube is a single-page app; `youtube/route.ts` turns its `yt-navigate-finish` event into a `Route`.

A feature folder holds, as needed:

- `settings.ts` — its slice: `defineSettings(id, defaults, { legacy?, area? })` (`kernel/settings.ts`), stored as `settings:<id>` in `chrome.storage.sync` (or `local` for data too big or personal to roam). `legacy` maps the pre-kernel store into the slice; `migrateSettings` runs each once. Pages read a slice with `useSettings`.
- `keys.ts` — its keyboard actions: `defineKeys({ id, title, keys })` (`kernel/keys.ts`). A feature binds handlers at mount with `bindKeys`; the user's overrides live in the `keybindings` slice by action id (`playback.seekForward`); the Shortcuts page renders every group from the registries. Data-driven shortcuts (a saved segment config's own key) are the feature's business.
- `messages.ts` — anything the background must do for it: `defineMessage(kind)` (`kernel/messaging.ts`), handled in a `background.ts` next to the feature.
- `index.tsx` — the `Feature`. Where it needs a page element it asks `youtube/` (`resolveVideo`, `resolveRatePanel`, …), which wraps `resolveTarget` (`kernel/dom/resolve.ts`) around ordered selector strategies, and records the outcome with `setCapabilityStatus` so the options page can say a selector broke. `resolveTarget` skips elements that are not rendered, because YouTube keeps pages you left alive but hidden, and a bare `document.querySelector` can answer from one of those. Lookups inside something already resolved live (the player, the Shorts page) use `findWithin(root, strategies)` instead, which does not require visibility: there YouTube hides things on purpose, like the settings menu while closed. **Selectors live in `youtube/`, not in features.**
- UI is Preact. `mount()` (`kernel/dom/mount.ts`) renders into a shadow root so YouTube's CSS and ours never meet; `mountInline()` renders into a host the caller shapes when the element must take YouTube's own classes (a `.ytp-button`, a settings-menu row). In-page styles read YouTube's colour tokens through `themeTokens` (`kernel/dom/theme.ts`) and so follow the page's theme. `showToast` (`kernel/dom/toast.tsx`) is the shared notice.
- Profiles are built on primitives: `youtube/primitives.ts` catalogues each page knob (id, routes, strategies, `parse`, idempotent `apply`, `reset`), and `runPrimitives` (`kernel/primitives.ts`) keeps a set of values applied while the page re-renders and restores everything on stop.
- Tests run under Bun with happy-dom; `test/setup.ts` registers the DOM and an in-memory `chrome.storage` that emits change events. A feature takes anything it cannot get from the page as a `deps` object (a fetcher, a storage) so tests mount it against HTML fixtures of each YouTube layout. `features/playlist-runtime/index.test.ts` and `features/segments/index.test.ts` are the patterns.

Live verification covers both browsers. Chromium: Playwright loading the built `dist/`. Firefox: Puppeteer over WebDriver BiDi with `browser.installExtension`, launched with `-remote-allow-system-access`; BiDi will not navigate to `moz-extension://` pages directly, so a throwaway copy of the build lists `options/*` and `popup/*` as web-accessible and is reached from a normal page. Headless, logged-out YouTube serves older layouts than a signed-in browser, so a strategy list should carry both.

---

## 4. PAGES

`app/popup/` and `app/options/` are Preact pages (`preact/hooks`; `preact/compat` only for portals). Options routes by hash (`app/options/router.ts`); the search box (`app/options/search/`) locates a result by its rendered label, so a copy edit in a page must land in `searchIndex.ts` too.

The pages wear the Toppings brand, shared with the website: ink, bone and ember on `--color-*` tokens in `ui/theme.css`, Gloock for display (`tw-font-display`) and Instrument Sans for text, both bundled in `assets/fonts` so the popup never calls a third party. Only what Toppings draws inside YouTube takes YouTube's palette (`kernel/dom/theme.ts`). Tailwind runs prefixed and without preflight; `ui/base.css` is the reset, and sets the font on `body` because Chrome gives extension pages' body its own system font. Tailwind's opacity modifiers (`tw-bg-accent/50`) do nothing on these variable colours — use `color-mix` in a style attribute instead.

---

## 5. STORAGE

| Layer | Holds | Accessed via |
| --- | --- | --- |
| `chrome.storage.sync` | Every settings slice, `settings:<id>` | `kernel/settings.ts` |
| `chrome.storage.local` | The profile store (`settings:profiles`), schedules and places (`settings:schedules`), schedule state (`settings:schedule-state`), the capability cache (`toppings:capability:<id>`), feature reports, per-device UI flags | `features/profiles/store.ts`, `features/schedules/`, `kernel/dom/capabilities.ts`, `kernel/dom/featureReports.ts`, `lib/useChromeStorageLocal.ts` |
| IndexedDB | Per-video segment data | `lib/indexedDb.ts`, `features/segments/store.ts` |

Anything the options page or background must read goes in extension storage, never IndexedDB: a content script's IndexedDB belongs to youtube.com.

A slice is read with its defaults merged in, which is also how a new setting reaches existing users. The pre-kernel store (`isExtensionEnabled`, `ui`, `preferences`) is migrated once by `app/background/migrations.ts` and then removed; `migrations.test.ts` pins the mapping.

---

## 6. MANIFEST AND VERSIONING

- `app/manifest.json` is the **MV3 source of truth**. The MV2 Firefox variant is generated at build time by the transform in `scripts/build.ts` (flips `manifest_version`, folds `host_permissions` into `permissions`, renames `action` → `browser_action`, flattens `web_accessible_resources`). Never hand-maintain a second manifest; extend the transform.
- Version lives in `lib/version.ts` as `EXTENSION_VERSION` and is injected into the manifest at build time; a pre-release suffix (`4.0.0-alpha`) goes to `version_name`, since `version` takes only numbers. It is mirrored in `website/lib/site.ts` (`site.version`) only on a store release — update both then.

---

## 7. LAYOUT

```text
web-ext/
├── app/ # The four surfaces, and only their shells
│ ├── background/ # Install hooks, migrations, message handlers, icon
│ ├── content/ # bootFeatures
│ ├── options/ # Preact options page: router, layout, search, routes/
│ ├── popup/ # Preact toolbar popup
│ └── settings.ts # The app slice: master switch and theme
├── kernel/ # Feature contract, settings, keys, messaging, primitives,
│ # dom/ (resolve, mount, capabilities, reports, theme, toast)
├── youtube/ # Routes, navigation, selectors per page area, the primitive catalogue
├── features/ # One folder per thing the extension does
│ ├── playback/ # Default rate, rate and seek keys, custom rates in the speed panel
│ ├── playlist-runtime/# Runtime statistics on playlist and watch pages
│ ├── profiles/ # Presets, profile store, gear-menu panel, native settings, import/export
│ ├── segments/ # Engine, session, storage, markers, button, panel/
│ └── shorts/ # Auto-scroll, rate and seek on Shorts
├── lib/ # Plumbing with no YouTube or kernel knowledge: storage keys,
│ # indexedDb, duration, urls, version, brand
├── ui/ # Shared page components: form, feedback, layout, primitives, theme
├── test/ # Bun test preload
└── scripts/ # build.ts (Bun.build + manifest transform), release.ts
```

Imports across folders use the `@/` root alias; same-folder imports stay relative. There are no barrel `index.ts` files: import the file that defines what you need. Options routes stay under `app/options/routes` because they do not map one-to-one onto features.

A helper that only one feature uses lives in that feature's folder. It moves to `kernel/` when it is about running features, to `youtube/` when it is about YouTube's page, and to `lib/` only when it is neither.

---

## 8. YOUTUBE-SPECIFIC GOTCHAS

- Selectors in `youtube/` target YouTube's markup and break when YouTube ships changes. They are the correct place for a "why" comment naming the layout variant a strategy targets.
- YouTube's Polymer lists (the guide, the settings menu) drop foreign children when they re-render; anything injected into one must be put back on a `MutationObserver` (`features/profiles/index.tsx`).
- The player's speed panel is a slider with preset chips, not a menu list; the playback feature replaces the chips and syncs the slider and display itself, because YouTube only redraws them from its own state.
- The desktop player has no double-tap seek overlay to reuse; the playback feature draws its own.
- An install or update cuts off the content script in open tabs (Firefox unloads it; Chrome leaves it running without the extension). The background starts the new one in those tabs, and a new copy dispatches `tppng:takeover` so an older one stops (`app/content/index.ts`); removing that handshake doubles every shortcut after an update.
- `process.env.BROWSER` is `"chrome"` or `"firefox"` at build time and branches on it are folded away, so a Firefox bundle carries no Chrome-only API calls for AMO's linter to flag.
- Location is asked for the way a website asks, from the options page; the manifest holds no location permission, because Chrome cannot make it optional and a required one pauses the extension for every user on update.
- Firefox's `chrome.*` namespace is callback-only under MV2: called for a promise it returns `undefined`. The code calls `chrome.*` promise-style, and the Firefox build rewrites the global to `browser` (`scripts/build.ts`). Never pass callbacks to extension APIs, or the Firefox build breaks the other way.
- YouTube's theme reaches shadow roots as `--yt-sys-color-baseline--*` custom properties on `<html>`; a shadow host reset with `all: initial` would discard them.
Loading
Loading