From 7b20cd0344d3ca1e121792de843e53961b2eeec1 Mon Sep 17 00:00:00 2001 From: D V Date: Sun, 27 Sep 2026 05:01:01 +0530 Subject: [PATCH 01/53] web-ext: Bun build, feature folders, Audio preset, bug fixes Typecheck runs again: tsconfig listed uninstalled mocha and chai types, hiding 82 real errors. Content-script JSX is typed as DOM through a local dom-chef jsx-runtime selected per file with a jsxImportSource pragma. Build: webpack and Babel replaced by Bun.build in scripts/build.ts, Tailwind through its PostCSS API from tailwind.config.ts, CSS via the manifest and instead of JS imports. Fifteen dev dependencies go. Audio preset finished, Audio Mode removed: profiles replaced the feature. watch.layout no-video collapses the player and keeps playback; the overlay, visualizer, custom images, pins, B key, popup toggle, options page and store subtree are gone, with watch.visuals and the unimplemented theatre layout. Bugs: Shorts button ids started with '#', the auto-scroll toggle never reached storage, the playback-menu marker class did not match its query, cssText was set to "null". Structure: app/ holds the four surfaces; features/ (playback, playlist, profiles, segments, shorts); lib/ plumbing; ui/ shared React. core/, data/, utils/, components/ and the primitive barrels are gone; cross- folder imports use @/. SegmentPanel.tsx is features/segments/panel/, one file per section. Website copy follows the Audio preset. Storage keys, message protocol, primitive and preset ids, default keybindings and manifest permissions are unchanged. --- .github/workflows/build-web-ext.yml | 2 + AGENTS.md | 2 +- AGENTS/web-ext.md | 101 +++ web-ext/{src => }/_locales/en/messages.json | 0 web-ext/app/background/context.ts | 149 +++++ web-ext/app/background/index.ts | 91 +++ web-ext/app/content/index.css | 12 + web-ext/app/content/index.ts | 61 ++ web-ext/app/content/jsx/jsx-dev-runtime.ts | 3 + web-ext/app/content/jsx/jsx-runtime.ts | 43 ++ web-ext/{src => app}/manifest.json | 32 +- web-ext/app/options/App.tsx | 30 + web-ext/app/options/data.ts | 49 ++ web-ext/{src/popup => app/options}/index.css | 26 +- web-ext/{src => app}/options/index.html | 1 + web-ext/app/options/index.tsx | 10 + web-ext/app/options/layout/AppLayout.tsx | 25 + web-ext/app/options/layout/Sidebar.tsx | 122 ++++ web-ext/app/options/layout/SidebarNavLink.tsx | 50 ++ .../app/options/layout/SidebarThemeToggle.tsx | 45 ++ web-ext/app/options/router.tsx | 34 + web-ext/app/options/routes/General.tsx | 202 ++++++ web-ext/app/options/routes/Keybindings.tsx | 292 +++++++++ web-ext/app/options/routes/Playlist.tsx | 36 ++ web-ext/app/options/routes/Profiles.tsx | 593 ++++++++++++++++++ web-ext/app/options/routes/Shorts.tsx | 94 +++ web-ext/app/options/routes/Watch.tsx | 266 ++++++++ web-ext/app/options/routes/error-page.tsx | 47 ++ web-ext/app/options/routing.tsx | 41 ++ web-ext/app/options/search/OptionsSearch.tsx | 251 ++++++++ web-ext/app/options/search/searchIndex.ts | 157 +++++ web-ext/app/options/validators.ts | 19 + web-ext/app/popup/App.tsx | 306 +++++++++ web-ext/app/popup/components/NavBtn.tsx | 46 ++ web-ext/app/popup/components/PopupRow.tsx | 47 ++ web-ext/app/popup/components/TinySwitch.tsx | 34 + web-ext/app/popup/data.ts | 14 + web-ext/app/popup/formatTabUrl.ts | 11 + web-ext/{src/options => app/popup}/index.css | 2 + web-ext/{src => app}/popup/index.html | 1 + web-ext/{src => app}/popup/index.tsx | 1 - web-ext/app/popup/openOptions.ts | 5 + web-ext/app/popup/useScope.ts | 42 ++ .../assets/icons/disabled_icon128.png | Bin .../assets/icons/disabled_icon16.png | Bin .../assets/icons/disabled_icon32.png | Bin .../assets/icons/disabled_icon48.png | Bin web-ext/{src => }/assets/icons/icon128.png | Bin web-ext/{src => }/assets/icons/icon16.png | Bin web-ext/{src => }/assets/icons/icon32.png | Bin web-ext/{src => }/assets/icons/icon48.png | Bin web-ext/bun.lock | 257 ++++++++ web-ext/bun.lockb | Bin 202569 -> 0 bytes web-ext/features/playback/watch.tsx | 570 +++++++++++++++++ web-ext/features/playlist/api.ts | 10 + web-ext/features/playlist/cache.ts | 58 ++ .../playlist/page.tsx} | 89 ++- .../features/profiles/CapabilityStatusRow.tsx | 106 ++++ web-ext/features/profiles/applyProfile.ts | 76 +++ web-ext/features/profiles/capabilityCache.ts | 92 +++ web-ext/features/profiles/featureReports.ts | 107 ++++ web-ext/features/profiles/gearMenu.ts | 285 +++++++++ web-ext/features/profiles/importExport.ts | 167 +++++ web-ext/features/profiles/nativeSettings.ts | 479 ++++++++++++++ .../features/profiles/primitives/home/feed.ts | 22 + .../profiles/primitives/home/shorts.ts | 23 + .../profiles/primitives/home/thumbnails.ts | 45 ++ .../features/profiles/primitives/resolve.ts | 68 ++ .../profiles/primitives/search/metadata.ts | 32 + .../profiles/primitives/search/shorts.ts | 21 + .../profiles/primitives/search/thumbnails.ts | 35 ++ .../profiles/primitives/shorts/shelf.ts | 32 + .../profiles/primitives/watch/comments.ts | 38 ++ .../profiles/primitives/watch/endCards.ts | 41 ++ .../profiles/primitives/watch/layout.ts | 39 ++ .../profiles/primitives/watch/sidebar.ts | 38 ++ web-ext/features/profiles/profileStore.ts | 116 ++++ web-ext/features/profiles/profiles.ts | 86 +++ .../features/profiles/useCapabilityCache.ts | 45 ++ web-ext/features/profiles/youtubePage.ts | 25 + web-ext/features/segments/SegmentButton.tsx | 69 ++ web-ext/features/segments/Segments.tsx | 392 ++++++++++++ web-ext/features/segments/engine.ts | 246 ++++++++ web-ext/features/segments/factories.ts | 140 +++++ web-ext/features/segments/markers.tsx | 393 ++++++++++++ .../features/segments/panel/configManager.tsx | 188 ++++++ .../segments/panel/configSwitcher.tsx | 85 +++ web-ext/features/segments/panel/format.ts | 15 + web-ext/features/segments/panel/header.tsx | 50 ++ web-ext/features/segments/panel/menu.ts | 24 + web-ext/features/segments/panel/mutations.ts | 101 +++ web-ext/features/segments/panel/panel.tsx | 88 +++ web-ext/features/segments/panel/pinButton.tsx | 119 ++++ web-ext/features/segments/panel/saveMenu.tsx | 123 ++++ .../features/segments/panel/segmentList.tsx | 102 +++ .../segments/panel/sequenceEditor.tsx | 247 ++++++++ web-ext/features/segments/panel/state.ts | 70 +++ web-ext/features/segments/panel/styles.ts | 72 +++ web-ext/features/segments/segmentStore.ts | 219 +++++++ web-ext/features/segments/types.ts | 50 ++ .../shorts.tsx => features/shorts/page.tsx} | 64 +- web-ext/index.d.ts | 1 - web-ext/lib/brand.ts | 5 + web-ext/lib/browser.ts | 12 + web-ext/lib/duration.ts | 32 + web-ext/lib/indexedDb.ts | 91 +++ web-ext/lib/keybinding.ts | 70 +++ web-ext/lib/object.test.ts | 26 + web-ext/lib/object.ts | 18 + web-ext/lib/pageToast.ts | 54 ++ web-ext/lib/protocol.ts | 19 + web-ext/lib/storageKeys.ts | 14 + web-ext/lib/store.ts | 110 ++++ .../store.tsx => lib/storeContext.tsx} | 2 +- web-ext/lib/urls.ts | 14 + web-ext/lib/useChromeStorageLocal.ts | 35 ++ web-ext/lib/useChromeStorageSync.ts | 21 + web-ext/lib/version.ts | 10 + web-ext/lib/youtube.ts | 17 + web-ext/package.json | 77 +-- web-ext/scripts/build.js | 138 ---- web-ext/scripts/build.ts | 113 ++++ web-ext/scripts/release.js | 72 --- web-ext/scripts/release.ts | 11 + web-ext/scripts/tsconfig.json | 13 + web-ext/src/assets/svg/apps.svg | 1 - web-ext/src/assets/svg/arrowhead-left.svg | 1 - web-ext/src/assets/svg/bell.svg | 1 - web-ext/src/assets/svg/brackets.svg | 1 - web-ext/src/assets/svg/bug_report.svg | 1 - web-ext/src/assets/svg/burger-menu.svg | 6 - web-ext/src/assets/svg/gear.svg | 1 - web-ext/src/assets/svg/heart.svg | 1 - web-ext/src/assets/svg/help.svg | 1 - web-ext/src/assets/svg/home.svg | 1 - web-ext/src/background/context.ts | 138 ---- web-ext/src/background/index.ts | 69 -- web-ext/src/background/store.ts | 85 --- .../src/background/utils/dispatchContext.ts | 37 -- web-ext/src/constants/global.constants.ts | 59 -- .../components/LoopSegment.tsx | 210 ------- web-ext/src/content_scripts/index.css | 6 - web-ext/src/content_scripts/index.ts | 49 -- web-ext/src/content_scripts/pages/watch.tsx | 343 ---------- web-ext/src/options/App.tsx | 23 - web-ext/src/options/components/Card.tsx | 27 - web-ext/src/options/components/Input.tsx | 149 ----- web-ext/src/options/components/Keybinding.tsx | 79 --- web-ext/src/options/components/Navbar.tsx | 16 - web-ext/src/options/components/Switch.tsx | 71 --- web-ext/src/options/components/Tooltip.tsx | 29 - web-ext/src/options/index.tsx | 29 - web-ext/src/options/routes/General.tsx | 428 ------------- web-ext/src/options/routes/error-page.tsx | 41 -- web-ext/src/popup/App.tsx | 24 - web-ext/src/popup/components/Navbar.tsx | 55 -- web-ext/tailwind.config.ts | 37 ++ web-ext/theme.css | 102 +++ web-ext/tsconfig.json | 13 +- web-ext/ui/ThemeApplier.tsx | 11 + web-ext/ui/cn.ts | 6 + web-ext/ui/feedback/ActionPopup.tsx | 77 +++ web-ext/ui/feedback/ConfirmPopup.tsx | 42 ++ web-ext/ui/feedback/ConfirmProvider.tsx | 67 ++ web-ext/ui/feedback/Popup.tsx | 98 +++ web-ext/ui/feedback/Toast.tsx | 72 +++ web-ext/ui/feedback/ToastProvider.tsx | 102 +++ web-ext/ui/form/Field.tsx | 75 +++ web-ext/ui/form/FilePicker.tsx | 73 +++ web-ext/ui/form/Input.tsx | 127 ++++ web-ext/ui/form/Keybinding.tsx | 66 ++ web-ext/ui/form/Select.tsx | 149 +++++ web-ext/ui/form/Slider.tsx | 66 ++ web-ext/ui/form/Switch.tsx | 51 ++ web-ext/ui/layout/Card.tsx | 18 + web-ext/ui/layout/PageHeader.tsx | 31 + web-ext/ui/layout/Section.tsx | 32 + web-ext/ui/layout/SectionNav.tsx | 74 +++ web-ext/ui/primitives/Badge.tsx | 40 ++ web-ext/ui/primitives/Button.tsx | 67 ++ web-ext/ui/primitives/Icon.tsx | 111 ++++ web-ext/ui/primitives/IconButton.tsx | 59 ++ web-ext/ui/primitives/Tooltip.tsx | 134 ++++ web-ext/ui/useTheme.ts | 38 ++ website/app/docs/changelog/releases.ts | 1 + website/app/docs/faq/faq.ts | 4 +- website/app/docs/page.tsx | 2 +- website/app/greetings/page.tsx | 2 +- website/lib/site.ts | 7 +- 189 files changed, 11711 insertions(+), 2354 deletions(-) create mode 100644 AGENTS/web-ext.md rename web-ext/{src => }/_locales/en/messages.json (100%) create mode 100644 web-ext/app/background/context.ts create mode 100644 web-ext/app/background/index.ts create mode 100644 web-ext/app/content/index.css create mode 100644 web-ext/app/content/index.ts create mode 100644 web-ext/app/content/jsx/jsx-dev-runtime.ts create mode 100644 web-ext/app/content/jsx/jsx-runtime.ts rename web-ext/{src => app}/manifest.json (70%) create mode 100644 web-ext/app/options/App.tsx create mode 100644 web-ext/app/options/data.ts rename web-ext/{src/popup => app/options}/index.css (93%) rename web-ext/{src => app}/options/index.html (89%) create mode 100644 web-ext/app/options/index.tsx create mode 100644 web-ext/app/options/layout/AppLayout.tsx create mode 100644 web-ext/app/options/layout/Sidebar.tsx create mode 100644 web-ext/app/options/layout/SidebarNavLink.tsx create mode 100644 web-ext/app/options/layout/SidebarThemeToggle.tsx create mode 100644 web-ext/app/options/router.tsx create mode 100644 web-ext/app/options/routes/General.tsx create mode 100644 web-ext/app/options/routes/Keybindings.tsx create mode 100644 web-ext/app/options/routes/Playlist.tsx create mode 100644 web-ext/app/options/routes/Profiles.tsx create mode 100644 web-ext/app/options/routes/Shorts.tsx create mode 100644 web-ext/app/options/routes/Watch.tsx create mode 100644 web-ext/app/options/routes/error-page.tsx create mode 100644 web-ext/app/options/routing.tsx create mode 100644 web-ext/app/options/search/OptionsSearch.tsx create mode 100644 web-ext/app/options/search/searchIndex.ts create mode 100644 web-ext/app/options/validators.ts create mode 100644 web-ext/app/popup/App.tsx create mode 100644 web-ext/app/popup/components/NavBtn.tsx create mode 100644 web-ext/app/popup/components/PopupRow.tsx create mode 100644 web-ext/app/popup/components/TinySwitch.tsx create mode 100644 web-ext/app/popup/data.ts create mode 100644 web-ext/app/popup/formatTabUrl.ts rename web-ext/{src/options => app/popup}/index.css (99%) rename web-ext/{src => app}/popup/index.html (88%) rename web-ext/{src => app}/popup/index.tsx (91%) create mode 100644 web-ext/app/popup/openOptions.ts create mode 100644 web-ext/app/popup/useScope.ts rename web-ext/{src => }/assets/icons/disabled_icon128.png (100%) rename web-ext/{src => }/assets/icons/disabled_icon16.png (100%) rename web-ext/{src => }/assets/icons/disabled_icon32.png (100%) rename web-ext/{src => }/assets/icons/disabled_icon48.png (100%) rename web-ext/{src => }/assets/icons/icon128.png (100%) rename web-ext/{src => }/assets/icons/icon16.png (100%) rename web-ext/{src => }/assets/icons/icon32.png (100%) rename web-ext/{src => }/assets/icons/icon48.png (100%) create mode 100644 web-ext/bun.lock delete mode 100755 web-ext/bun.lockb create mode 100644 web-ext/features/playback/watch.tsx create mode 100644 web-ext/features/playlist/api.ts create mode 100644 web-ext/features/playlist/cache.ts rename web-ext/{src/content_scripts/pages/playlist.tsx => features/playlist/page.tsx} (63%) create mode 100644 web-ext/features/profiles/CapabilityStatusRow.tsx create mode 100644 web-ext/features/profiles/applyProfile.ts create mode 100644 web-ext/features/profiles/capabilityCache.ts create mode 100644 web-ext/features/profiles/featureReports.ts create mode 100644 web-ext/features/profiles/gearMenu.ts create mode 100644 web-ext/features/profiles/importExport.ts create mode 100644 web-ext/features/profiles/nativeSettings.ts create mode 100644 web-ext/features/profiles/primitives/home/feed.ts create mode 100644 web-ext/features/profiles/primitives/home/shorts.ts create mode 100644 web-ext/features/profiles/primitives/home/thumbnails.ts create mode 100644 web-ext/features/profiles/primitives/resolve.ts create mode 100644 web-ext/features/profiles/primitives/search/metadata.ts create mode 100644 web-ext/features/profiles/primitives/search/shorts.ts create mode 100644 web-ext/features/profiles/primitives/search/thumbnails.ts create mode 100644 web-ext/features/profiles/primitives/shorts/shelf.ts create mode 100644 web-ext/features/profiles/primitives/watch/comments.ts create mode 100644 web-ext/features/profiles/primitives/watch/endCards.ts create mode 100644 web-ext/features/profiles/primitives/watch/layout.ts create mode 100644 web-ext/features/profiles/primitives/watch/sidebar.ts create mode 100644 web-ext/features/profiles/profileStore.ts create mode 100644 web-ext/features/profiles/profiles.ts create mode 100644 web-ext/features/profiles/useCapabilityCache.ts create mode 100644 web-ext/features/profiles/youtubePage.ts create mode 100644 web-ext/features/segments/SegmentButton.tsx create mode 100644 web-ext/features/segments/Segments.tsx create mode 100644 web-ext/features/segments/engine.ts create mode 100644 web-ext/features/segments/factories.ts create mode 100644 web-ext/features/segments/markers.tsx create mode 100644 web-ext/features/segments/panel/configManager.tsx create mode 100644 web-ext/features/segments/panel/configSwitcher.tsx create mode 100644 web-ext/features/segments/panel/format.ts create mode 100644 web-ext/features/segments/panel/header.tsx create mode 100644 web-ext/features/segments/panel/menu.ts create mode 100644 web-ext/features/segments/panel/mutations.ts create mode 100644 web-ext/features/segments/panel/panel.tsx create mode 100644 web-ext/features/segments/panel/pinButton.tsx create mode 100644 web-ext/features/segments/panel/saveMenu.tsx create mode 100644 web-ext/features/segments/panel/segmentList.tsx create mode 100644 web-ext/features/segments/panel/sequenceEditor.tsx create mode 100644 web-ext/features/segments/panel/state.ts create mode 100644 web-ext/features/segments/panel/styles.ts create mode 100644 web-ext/features/segments/segmentStore.ts create mode 100644 web-ext/features/segments/types.ts rename web-ext/{src/content_scripts/pages/shorts.tsx => features/shorts/page.tsx} (72%) create mode 100644 web-ext/lib/brand.ts create mode 100644 web-ext/lib/browser.ts create mode 100644 web-ext/lib/duration.ts create mode 100644 web-ext/lib/indexedDb.ts create mode 100644 web-ext/lib/keybinding.ts create mode 100644 web-ext/lib/object.test.ts create mode 100644 web-ext/lib/object.ts create mode 100644 web-ext/lib/pageToast.ts create mode 100644 web-ext/lib/protocol.ts create mode 100644 web-ext/lib/storageKeys.ts create mode 100644 web-ext/lib/store.ts rename web-ext/{src/options/store.tsx => lib/storeContext.tsx} (83%) create mode 100644 web-ext/lib/urls.ts create mode 100644 web-ext/lib/useChromeStorageLocal.ts create mode 100644 web-ext/lib/useChromeStorageSync.ts create mode 100644 web-ext/lib/version.ts create mode 100644 web-ext/lib/youtube.ts delete mode 100644 web-ext/scripts/build.js create mode 100644 web-ext/scripts/build.ts delete mode 100644 web-ext/scripts/release.js create mode 100644 web-ext/scripts/release.ts create mode 100644 web-ext/scripts/tsconfig.json delete mode 100644 web-ext/src/assets/svg/apps.svg delete mode 100644 web-ext/src/assets/svg/arrowhead-left.svg delete mode 100644 web-ext/src/assets/svg/bell.svg delete mode 100644 web-ext/src/assets/svg/brackets.svg delete mode 100644 web-ext/src/assets/svg/bug_report.svg delete mode 100644 web-ext/src/assets/svg/burger-menu.svg delete mode 100644 web-ext/src/assets/svg/gear.svg delete mode 100644 web-ext/src/assets/svg/heart.svg delete mode 100644 web-ext/src/assets/svg/help.svg delete mode 100644 web-ext/src/assets/svg/home.svg delete mode 100644 web-ext/src/background/context.ts delete mode 100644 web-ext/src/background/index.ts delete mode 100644 web-ext/src/background/store.ts delete mode 100644 web-ext/src/background/utils/dispatchContext.ts delete mode 100644 web-ext/src/constants/global.constants.ts delete mode 100644 web-ext/src/content_scripts/components/LoopSegment.tsx delete mode 100644 web-ext/src/content_scripts/index.css delete mode 100644 web-ext/src/content_scripts/index.ts delete mode 100644 web-ext/src/content_scripts/pages/watch.tsx delete mode 100644 web-ext/src/options/App.tsx delete mode 100644 web-ext/src/options/components/Card.tsx delete mode 100644 web-ext/src/options/components/Input.tsx delete mode 100644 web-ext/src/options/components/Keybinding.tsx delete mode 100644 web-ext/src/options/components/Navbar.tsx delete mode 100644 web-ext/src/options/components/Switch.tsx delete mode 100644 web-ext/src/options/components/Tooltip.tsx delete mode 100644 web-ext/src/options/index.tsx delete mode 100644 web-ext/src/options/routes/General.tsx delete mode 100644 web-ext/src/options/routes/error-page.tsx delete mode 100644 web-ext/src/popup/App.tsx delete mode 100644 web-ext/src/popup/components/Navbar.tsx create mode 100644 web-ext/tailwind.config.ts create mode 100644 web-ext/theme.css create mode 100644 web-ext/ui/ThemeApplier.tsx create mode 100644 web-ext/ui/cn.ts create mode 100644 web-ext/ui/feedback/ActionPopup.tsx create mode 100644 web-ext/ui/feedback/ConfirmPopup.tsx create mode 100644 web-ext/ui/feedback/ConfirmProvider.tsx create mode 100644 web-ext/ui/feedback/Popup.tsx create mode 100644 web-ext/ui/feedback/Toast.tsx create mode 100644 web-ext/ui/feedback/ToastProvider.tsx create mode 100644 web-ext/ui/form/Field.tsx create mode 100644 web-ext/ui/form/FilePicker.tsx create mode 100644 web-ext/ui/form/Input.tsx create mode 100644 web-ext/ui/form/Keybinding.tsx create mode 100644 web-ext/ui/form/Select.tsx create mode 100644 web-ext/ui/form/Slider.tsx create mode 100644 web-ext/ui/form/Switch.tsx create mode 100644 web-ext/ui/layout/Card.tsx create mode 100644 web-ext/ui/layout/PageHeader.tsx create mode 100644 web-ext/ui/layout/Section.tsx create mode 100644 web-ext/ui/layout/SectionNav.tsx create mode 100644 web-ext/ui/primitives/Badge.tsx create mode 100644 web-ext/ui/primitives/Button.tsx create mode 100644 web-ext/ui/primitives/Icon.tsx create mode 100644 web-ext/ui/primitives/IconButton.tsx create mode 100644 web-ext/ui/primitives/Tooltip.tsx create mode 100644 web-ext/ui/useTheme.ts diff --git a/.github/workflows/build-web-ext.yml b/.github/workflows/build-web-ext.yml index 3e7f9443..9b4f9ba8 100644 --- a/.github/workflows/build-web-ext.yml +++ b/.github/workflows/build-web-ext.yml @@ -40,5 +40,7 @@ jobs: - name: Install Dependencies run: cd web-ext && bun install + - name: Check + run: cd web-ext && bun run check - name: Build for Chrome run: cd web-ext && bun run build diff --git a/AGENTS.md b/AGENTS.md index 3d0f29e2..c85006a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/AGENTS/web-ext.md b/AGENTS/web-ext.md new file mode 100644 index 00000000..810f9950 --- /dev/null +++ b/AGENTS/web-ext.md @@ -0,0 +1,101 @@ +# 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. CSS is not imported from JS: the content script's stylesheet is listed in the manifest, and the popup and options pages link theirs. + +--- + +## 2. ENTRY POINTS + +Four 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 | + +--- + +## 3. THE TWO RENDERING WORLDS + +This is the single most important thing to get right in this project. + +- **`app/popup/`, `app/options/`, `ui/`** — real React with `react-dom`, hooks, state, context. +- **`app/content/`, and the content-script code under `features/`** — **dom-chef**, not React. JSX there compiles to real DOM nodes at call time. There is no reconciler, no re-render, no hooks. A component is a function that returns an `HTMLElement` you insert yourself and update by hand or replace outright. + +A content-script file declares its world with `/** @jsxImportSource dom-chef-jsx */` as its first line; that routes its JSX to `app/content/jsx/jsx-runtime.ts`, which builds DOM nodes with dom-chef and types elements as `HTMLElement` with native event handlers. Without the pragma a file gets React's runtime and React's types. Reaching for `useState` in a content script fails at runtime, not at build. + +--- + +## 4. STORAGE LAYERS + +Three, with different rules. Keys live in `lib/storageKeys.ts`. + +| Layer | Holds | Accessed via | +| --- | --- | --- | +| `chrome.storage.sync` | User settings, shape of `DEFAULT_STORE` (`lib/store.ts`) | `lib/store.ts`, `lib/useChromeStorageSync.ts` | +| `chrome.storage.local` | Profiles, feature reports — keys in `CHROME_STORAGE_LOCAL_KEY` | `features/profiles/profileStore.ts`, `features/profiles/featureReports.ts`, `lib/useChromeStorageLocal.ts` | +| IndexedDB | Capability cache, segment data — stores in `BROWSER_STORAGE_IDB_STORE` | `lib/indexedDb.ts`, `features/profiles/capabilityCache.ts`, `features/segments/segmentStore.ts` | + +**`DEFAULT_STORE` is the schema.** `mergeDefaults` (`lib/object.ts`) overlays stored values on it and **drops keys absent from the defaults**, and `syncStorageWithDefaults` writes the result back. Adding a setting means adding it to `DEFAULT_STORE`; removing one from `DEFAULT_STORE` removes it from every user's storage on next sync. Every `storage.sync.set` in the codebase writes this one shape — keep it that way, or `mergeDefaults` will quietly delete whatever does not fit. + +--- + +## 5. 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 `data/version.ts` as `EXTENSION_VERSION` and is injected into the manifest at build time. It is also mirrored in `website/lib/site.ts` (`site.version`) on release — update both. + +--- + +## 6. LAYOUT + +```text +web-ext/ +├── app/ # The four surfaces, and only their shells +│ ├── background/ # Service worker: context dispatch, install hooks +│ ├── content/ # Content-script entry, its CSS, the dom-chef JSX runtime +│ ├── options/ # React options page: router, layout, search, routes/ +│ └── popup/ # React toolbar popup +├── features/ # One folder per thing the extension does +│ ├── playback/ # Rates, seek, double-tap, the player menu (watch page) +│ ├── playlist/ # Playlist page, cache, API client +│ ├── profiles/ # Presets, profile store, primitives/ (YouTube DOM strategies), +│ │ # applyProfile, gear menu, native settings, import/export, +│ │ # capability cache, feature reports +│ ├── segments/ # Engine, store, markers, button, panel +│ └── shorts/ # Shorts page +├── lib/ # Plumbing any feature may use: store schema and helpers, +│ # storage keys, protocol, YouTube constants, indexedDb, +│ # keybinding, duration, object, browser, version +├── ui/ # Shared React components: form, feedback, layout, primitives, theme +└── scripts/ # build.ts (Bun.build + manifest transform), release.ts +``` + +Imports across folders use the `@/` root alias (`@/lib/store`); 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 (Keybindings spans all of them). + +A helper that only one feature uses lives in that feature's folder. It moves to `lib/` when a second feature needs it, not before. + +--- + +## 7. YOUTUBE-SPECIFIC GOTCHAS + +- **YouTube is a SPA — the content script never re-runs on navigation.** The background listens to `chrome.webNavigation.onHistoryStateUpdated` (`app/background/index.ts`) and dispatches a `CONTEXT` message; `app/content/index.ts` receives it and routes to a per-scope handler. So a page handler runs repeatedly against a live DOM it did not start with: it must be idempotent, must not double-inject, and must tear down what the previous route left behind. Nothing is cleaned up for you. +- Selectors in `features/profiles/primitives/` target YouTube's markup and break when YouTube ships changes. They are the correct place for a "why" comment naming the layout variant a selector targets — that is exactly the external constraint the code cannot state on its own. diff --git a/web-ext/src/_locales/en/messages.json b/web-ext/_locales/en/messages.json similarity index 100% rename from web-ext/src/_locales/en/messages.json rename to web-ext/_locales/en/messages.json diff --git a/web-ext/app/background/context.ts b/web-ext/app/background/context.ts new file mode 100644 index 00000000..a1401340 --- /dev/null +++ b/web-ext/app/background/context.ts @@ -0,0 +1,149 @@ +import { EXTENSION_CONTEXT_SCOPE } from "@/lib/protocol"; +import { YOUTUBE_QUERY_PARAM, YOUTUBE_SYSTEM_PLAYLIST_ID, YOUTUBE_URL_PATH } from "@/lib/youtube"; +import { fetchPlaylist } from "@/features/playlist/api"; +import { getStorage, Storage } from "@/lib/store"; +import { + getCachedPlaylist, + setCachedPlaylist, +} from "@/features/playlist/cache"; + +export type YoutubeContext = BaseContext & { + scope: typeof EXTENSION_CONTEXT_SCOPE.YOUTUBE; + payload: Record; +}; + +export type Context = WatchContext | PlaylistContext | ShortsContext | YoutubeContext | null; + +export interface BaseContext { + scope: string; + payload: Record; + store: Storage; +} + +export type WatchContext = BaseContext & { + scope: typeof EXTENSION_CONTEXT_SCOPE.WATCH; + payload: WatchPayload; +}; + +export type WatchPayload = { + videoId: string | null; +}; + +export type PlaylistContext = BaseContext & { + scope: typeof EXTENSION_CONTEXT_SCOPE.PLAYLIST; + payload: ValidPlaylistPayload | InvalidPlaylistPayload; +}; + +export type ValidPlaylistPayload = { + playlistId: string; + averageRuntime: number; + totalRuntime: number; + totalVideos: number; +}; + +export type InvalidPlaylistPayload = { + playlistId: + | typeof YOUTUBE_SYSTEM_PLAYLIST_ID.WATCH_LATER + | typeof YOUTUBE_SYSTEM_PLAYLIST_ID.LIKED + | string + | null; +}; + +export type PlaylistResponse = PlaylistContext; + +export type ShortsContext = BaseContext & { + scope: typeof EXTENSION_CONTEXT_SCOPE.SHORTS; + payload: ShortsPayload; +}; + +export type ShortsPayload = { + shortId: string | null; +}; + +export const getContext = async (rawURL: string): Promise => { + const url = new URL(rawURL); + const store = await getStorage(); + + if (!store) { + throw new Error("Store not found"); + } + + if (url.pathname.startsWith(YOUTUBE_URL_PATH.WATCH)) { + const videoId = url.searchParams.get(YOUTUBE_QUERY_PARAM.VIDEO_ID); + if (!videoId) return null; + + return { + scope: EXTENSION_CONTEXT_SCOPE.WATCH, + payload: { videoId }, + store, + } as const; + } else if (url.pathname.startsWith(YOUTUBE_URL_PATH.PLAYLIST)) { + const playlistId = url.searchParams.get(YOUTUBE_QUERY_PARAM.PLAYLIST_ID); + + if ( + !playlistId || + playlistId === YOUTUBE_SYSTEM_PLAYLIST_ID.WATCH_LATER || + playlistId === YOUTUBE_SYSTEM_PLAYLIST_ID.LIKED + ) { + return { + scope: EXTENSION_CONTEXT_SCOPE.PLAYLIST, + payload: { playlistId }, + store, + } as const; + } + + try { + // Check cache before hitting the API. + const cached = await getCachedPlaylist(playlistId); + if (cached) { + return { + scope: EXTENSION_CONTEXT_SCOPE.PLAYLIST, + payload: cached, + store, + } as const; + } + + const response = await fetchPlaylist(playlistId); + + if (!response.ok) { + throw new Error("Failed fetching playlist data"); + } + + const body = (await response.json()) as PlaylistResponse; + + // Persist to cache for subsequent visits. + if (body.scope === EXTENSION_CONTEXT_SCOPE.PLAYLIST && "totalRuntime" in body.payload) { + void setCachedPlaylist(playlistId, body.payload as ValidPlaylistPayload); + } + + return { + ...body, + store, + } as const; + } catch (error) { + console.error("Failed fetching playlist data", error); + return { + scope: EXTENSION_CONTEXT_SCOPE.PLAYLIST, + payload: { playlistId }, + store, + } as const; + } + } else if (url.pathname.startsWith(YOUTUBE_URL_PATH.SHORTS)) { + const shortId = + url.pathname.split("/")[2] || null; + return { + scope: EXTENSION_CONTEXT_SCOPE.SHORTS, + payload: { shortId }, + store, + } as const; + } + + // Generic YouTube page (home, search, channel, etc.) — return a YouTube + // scope context so the content script can apply profile primitives that + // target these pages (home feed, search results, Shorts shelf). + return { + scope: EXTENSION_CONTEXT_SCOPE.YOUTUBE, + payload: {}, + store, + } as const; +}; diff --git a/web-ext/app/background/index.ts b/web-ext/app/background/index.ts new file mode 100644 index 00000000..5753ca49 --- /dev/null +++ b/web-ext/app/background/index.ts @@ -0,0 +1,91 @@ +import { syncStorageWithDefaults } from "@/lib/store"; +import { getActiveProfile } from "@/features/profiles/profileStore"; +import { + getFeatureReports, + markRecovered, + removeFeatureReport, +} from "@/features/profiles/featureReports"; +import { getCapabilityStatus } from "@/features/profiles/capabilityCache"; +import { getContext } from "./context"; +import { URLS } from "@/lib/urls"; +import { EXTENSION_MESSAGE_EVENT, EXTENSION_MESSAGE_TYPE } from "@/lib/protocol"; + +chrome.runtime.onInstalled.addListener(onInitialize); +chrome.runtime.onMessage.addListener(onConnected); +chrome.webNavigation.onHistoryStateUpdated.addListener(onWebNavigation); + +type InitializeDetails = Parameters< + Parameters[0] +>[0]; +function onInitialize({ reason }: InitializeDetails): void { + if (reason === "install" || reason === "update") { + if (process.env.NODE_ENV === "production") { + void chrome.tabs.create({ url: URLS.GREETINGS }); + void chrome.runtime.setUninstallURL(URLS.FAREWELL); + } + void syncStorageWithDefaults(); + // Read purely for its side effect: this is what writes the default profile + // store on a fresh install. + void getActiveProfile(); + if (reason === "update") { + void checkRecoveredFeatures(); + } + } +} + +// An update can add selector strategies that fix a primitive the user reported, +// so anything now resolving is marked recovered for the options page to surface. +async function checkRecoveredFeatures(): Promise { + const reports = await getFeatureReports(); + for (const report of reports) { + const status = await getCapabilityStatus(report.primitiveId); + if (status === "supported") { + await markRecovered(report.primitiveId); + await removeFeatureReport(report.primitiveId); + } + } +} + +type WebNavigationDetails = Parameters< + Parameters[0] +>[0]; +async function onWebNavigation(details: WebNavigationDetails) { + const tabId = details.tabId; + + const ctx = await getContext(details.url); + if (!ctx?.store.isExtensionEnabled) return; + await chrome.tabs.sendMessage( + tabId, + JSON.stringify({ type: EXTENSION_MESSAGE_TYPE.CONTEXT, payload: ctx }), + ); +} + +function onConnected( + message: any, + sender: chrome.runtime.MessageSender, + sendResponse: (response: any) => void, +) { + (async () => { + const parsed = JSON.parse(message) as Record; + const { type, payload } = parsed; + if (type !== EXTENSION_MESSAGE_TYPE.EVENT) return; + + const event = payload; + if (event !== EXTENSION_MESSAGE_EVENT.CONNECTED) return; + + const tabId = sender.tab?.id; + if (!tabId) return; + + const url = sender.url; + if (!url) return; + + const ctx = await getContext(url); + if (!ctx?.store.isExtensionEnabled) return; + + sendResponse( + JSON.stringify({ type: EXTENSION_MESSAGE_TYPE.CONTEXT, payload: ctx }), + ); + })(); + + return true; +} diff --git a/web-ext/app/content/index.css b/web-ext/app/content/index.css new file mode 100644 index 00000000..50543ae7 --- /dev/null +++ b/web-ext/app/content/index.css @@ -0,0 +1,12 @@ +@tailwind base; +@tailwind utilities; + +#contentContainer.tp-yt-app-drawer[swipe-open].tp-yt-app-drawer::after { + width: 10px !important; +} + +/* Segment marker merge-ready pulse animation */ +@keyframes tppng-merge-pulse { + from { transform: translateX(-50%) scale(1); filter: brightness(1); } + to { transform: translateX(-50%) scale(1.25); filter: brightness(1.6) drop-shadow(0 0 6px #fff); } +} diff --git a/web-ext/app/content/index.ts b/web-ext/app/content/index.ts new file mode 100644 index 00000000..37986a98 --- /dev/null +++ b/web-ext/app/content/index.ts @@ -0,0 +1,61 @@ +import type { Context } from "@/app/background/context"; +import type { Storage } from "@/lib/store"; + +import onPlaylistPage from "@/features/playlist/page"; +import onShortsPage from "@/features/shorts/page"; +import onWatchPage from "@/features/playback/watch"; +import onYoutubePage from "@/features/profiles/youtubePage"; +import { setupNativeSettings } from "@/features/profiles/nativeSettings"; +import { EXTENSION_CONTEXT_SCOPE } from "@/lib/protocol"; +import { EXTENSION_MESSAGE_EVENT, EXTENSION_MESSAGE_TYPE } from "@/lib/protocol"; + +const scopeHandlers: Record = { + [EXTENSION_CONTEXT_SCOPE.PLAYLIST]: onPlaylistPage, + [EXTENSION_CONTEXT_SCOPE.SHORTS]: onShortsPage, + [EXTENSION_CONTEXT_SCOPE.WATCH]: onWatchPage, + [EXTENSION_CONTEXT_SCOPE.YOUTUBE]: onYoutubePage, +}; + +function runApp(message: any): undefined { + (async () => { + const parsed = JSON.parse(message) as Record; + const { type, payload } = parsed; + + if (type !== EXTENSION_MESSAGE_TYPE.CONTEXT) return; + const ctx = payload as Exclude; + + const { scope, store } = ctx; + + // Native settings sidebar — runs on every YouTube navigation, independent + // of scope. Feature-gated by store.ui.nativeSettingsEnabled. + setupNativeSettings( + !!(store.isExtensionEnabled && store.ui?.nativeSettingsEnabled), + ); + + const handler = scopeHandlers[scope]; + + if (!handler) { + console.warn("[Toppings] No content script handler for scope:", scope); + return; + } + + // The YOUTUBE scope has no preferences entry — it's always enabled when the + // extension is enabled (checked above via store.isExtensionEnabled). + const prefs = store.preferences[scope as keyof typeof store.preferences]; + const isEnabled = prefs ? prefs.isEnabled : true; + if (!isEnabled) return; + + await handler(ctx); + })(); +} + +chrome.runtime.sendMessage( + chrome.runtime.id, + JSON.stringify({ + type: EXTENSION_MESSAGE_TYPE.EVENT, + payload: EXTENSION_MESSAGE_EVENT.CONNECTED, + }), + {}, + runApp, +); +chrome.runtime.onMessage.addListener(runApp); diff --git a/web-ext/app/content/jsx/jsx-dev-runtime.ts b/web-ext/app/content/jsx/jsx-dev-runtime.ts new file mode 100644 index 00000000..f17229b2 --- /dev/null +++ b/web-ext/app/content/jsx/jsx-dev-runtime.ts @@ -0,0 +1,3 @@ +// Development builds import jsxDEV instead of jsx; the DOM output is the same. +export { jsx as jsxDEV, Fragment } from "./jsx-runtime"; +export type { JSX } from "./jsx-runtime"; diff --git a/web-ext/app/content/jsx/jsx-runtime.ts b/web-ext/app/content/jsx/jsx-runtime.ts new file mode 100644 index 00000000..875aee60 --- /dev/null +++ b/web-ext/app/content/jsx/jsx-runtime.ts @@ -0,0 +1,43 @@ +// Content scripts build real DOM nodes with dom-chef, not React. Files opt in +// with `/** @jsxImportSource dom-chef-jsx */`, which routes their JSX here and +// types elements as HTMLElement with native event handlers, instead of +// React's ReactNode and SyntheticEvent that the options and popup pages use. +import { h, Fragment as DomFragment } from "dom-chef"; + +type Child = Node | string | number | boolean | null | undefined | Child[]; + +type Props = { + [attribute: string]: unknown; + children?: Child; +}; + +type Component = (props: Props) => HTMLElement | SVGElement | DocumentFragment; + +function flatten(child: Child, into: Node[]): Node[] { + if (Array.isArray(child)) { + for (const c of child) flatten(c, into); + } else if (child instanceof Node) { + into.push(child); + } else if (typeof child === "string" || typeof child === "number") { + into.push(document.createTextNode(String(child))); + } + return into; +} + +export function jsx(type: string | Component, props: Props): HTMLElement { + const { children, ...attributes } = props; + return h(type as string, attributes, ...flatten(children, [])) as HTMLElement; +} + +export const jsxs = jsx; +export const Fragment = DomFragment; + +export namespace JSX { + export type Element = HTMLElement; + export interface IntrinsicElements { + [tag: string]: Props; + } + export interface ElementChildrenAttribute { + children: unknown; + } +} diff --git a/web-ext/src/manifest.json b/web-ext/app/manifest.json similarity index 70% rename from web-ext/src/manifest.json rename to web-ext/app/manifest.json index e27a0b6f..e888a7c2 100644 --- a/web-ext/src/manifest.json +++ b/web-ext/app/manifest.json @@ -18,15 +18,23 @@ "content_scripts": [ { "all_frames": true, - "js": ["/content.js"], - "matches": ["https://www.youtube.com/*"], - "run_at": "document_end" + "js": [ + "/content.js" + ], + "matches": [ + "https://www.youtube.com/*" + ], + "run_at": "document_end", + "css": [ + "/content.css" + ] } ], "default_locale": "en", "description": "__MSG_description__", - "homepage_url": "https://enrych.github.io/toppings", - "host_permissions": ["*://*.youtube.com/*"], + "host_permissions": [ + "*://*.youtube.com/*" + ], "icons": { "16": "assets/icons/icon16.png", "32": "assets/icons/icon32.png", @@ -39,13 +47,19 @@ "open_in_tab": true, "page": "/options/index.html" }, - "permissions": ["webNavigation", "storage"], + "permissions": [ + "webNavigation", + "storage" + ], "short_name": "Toppings", - "version": "0.0.0", "web_accessible_resources": [ { - "matches": ["https://*/*"], - "resources": ["assets/*"] + "matches": [ + "https://*/*" + ], + "resources": [ + "assets/*" + ] } ] } diff --git a/web-ext/app/options/App.tsx b/web-ext/app/options/App.tsx new file mode 100644 index 00000000..1619f1c5 --- /dev/null +++ b/web-ext/app/options/App.tsx @@ -0,0 +1,30 @@ +import React, { useState } from "react"; +import { Outlet, useLoaderData, useLocation } from "react-router-dom"; +import StoreContext from "@/lib/storeContext"; +import { Storage } from "@/lib/store"; +import ThemeApplier from "@/ui/ThemeApplier"; +import AppLayout from "./layout/AppLayout"; +import { ToastProvider } from "@/ui/feedback/ToastProvider"; +import { ConfirmProvider } from "@/ui/feedback/ConfirmProvider"; +import { SECTION_RAILS } from "./routing"; + +export default function App() { + const loadedStore = useLoaderData() as Storage; + const [store, setStore] = useState(loadedStore); + const location = useLocation(); + const Rail = SECTION_RAILS[location.pathname]; + + return ( + + + + + : undefined}> + + + + + + + ); +} diff --git a/web-ext/app/options/data.ts b/web-ext/app/options/data.ts new file mode 100644 index 00000000..85937444 --- /dev/null +++ b/web-ext/app/options/data.ts @@ -0,0 +1,49 @@ +import { EXTENSION_CONTEXT_SCOPE } from "@/lib/protocol"; + +export const OPTIONS_HTML = "options/index.html"; + +export const OPTIONS_ICON_SRC = "/assets/icons/icon48.png"; + +export type ExtensionOptionsPage = { + readonly segment: string; + readonly path: string; + readonly label: string; + readonly icon: string; + readonly sectionNav?: true; +}; + +export const OPTIONS_PAGES = [ + { segment: "", path: "/", label: "General", icon: "general" }, + { + segment: EXTENSION_CONTEXT_SCOPE.WATCH, + path: "/watch", + label: "Watch", + icon: "watch", + sectionNav: true, + }, + { + segment: EXTENSION_CONTEXT_SCOPE.SHORTS, + path: "/shorts", + label: "Shorts", + icon: "shorts", + }, + { + segment: EXTENSION_CONTEXT_SCOPE.PLAYLIST, + path: "/playlist", + label: "Playlist", + icon: "playlist", + }, + { + segment: "keybindings", + path: "/keybindings", + label: "Shortcuts", + icon: "keyboard", + sectionNav: true, + }, + { + segment: "profiles", + path: "/profiles", + label: "Profiles", + icon: "profiles", + }, +] as const satisfies readonly ExtensionOptionsPage[]; diff --git a/web-ext/src/popup/index.css b/web-ext/app/options/index.css similarity index 93% rename from web-ext/src/popup/index.css rename to web-ext/app/options/index.css index 135c24b0..6c5e089d 100644 --- a/web-ext/src/popup/index.css +++ b/web-ext/app/options/index.css @@ -2,6 +2,8 @@ @tailwind components; @tailwind utilities; +@import "../../theme.css"; + /* 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) @@ -420,6 +422,25 @@ video { display: none; } +/* Search highlight in dropdown results */ +.tppng-search-highlight { + background: #fbbf24; + color: #000; + border-radius: 2px; + padding: 0 1px; +} + +/* Flash animation for section headers after search navigation */ +@keyframes tppng-section-flash { + 0% { background: transparent; } + 20% { background: color-mix(in srgb, #fbbf24 30%, transparent); border-radius: 8px; } + 100% { background: transparent; } +} + +.tppng-section-flash { + animation: tppng-section-flash 900ms ease-out forwards; +} + :root { font-synthesis: none; text-rendering: optimizeLegibility; @@ -434,10 +455,11 @@ video { } ::-webkit-scrollbar-thumb { - background: #18181b; + background: var(--color-border-default); } ::-webkit-scrollbar-thumb:hover { - background: #555; + background: var(--color-border-strong); } } + diff --git a/web-ext/src/options/index.html b/web-ext/app/options/index.html similarity index 89% rename from web-ext/src/options/index.html rename to web-ext/app/options/index.html index 2f7c577e..859e3b35 100644 --- a/web-ext/src/options/index.html +++ b/web-ext/app/options/index.html @@ -5,6 +5,7 @@ Toppings: Preferences + diff --git a/web-ext/app/options/index.tsx b/web-ext/app/options/index.tsx new file mode 100644 index 00000000..76ed7053 --- /dev/null +++ b/web-ext/app/options/index.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider } from "react-router-dom"; +import { optionsRouter } from "./router"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/web-ext/app/options/layout/AppLayout.tsx b/web-ext/app/options/layout/AppLayout.tsx new file mode 100644 index 00000000..839bd8e9 --- /dev/null +++ b/web-ext/app/options/layout/AppLayout.tsx @@ -0,0 +1,25 @@ +import React, { ReactNode } from "react"; +import Sidebar from "./Sidebar"; + +interface AppLayoutProps { + children: ReactNode; + rightRail?: ReactNode; +} + +export default function AppLayout({ children, rightRail }: AppLayoutProps) { + return ( +
+ +
+
+
{children}
+ {rightRail && ( +
+ {rightRail} +
+ )} +
+
+
+ ); +} diff --git a/web-ext/app/options/layout/Sidebar.tsx b/web-ext/app/options/layout/Sidebar.tsx new file mode 100644 index 00000000..515708cc --- /dev/null +++ b/web-ext/app/options/layout/Sidebar.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import Icon, { IconName } from "@/ui/primitives/Icon"; +import IconButton from "@/ui/primitives/IconButton"; +import Tooltip from "@/ui/primitives/Tooltip"; +import { useChromeStorageLocal } from "@/lib/useChromeStorageLocal"; +import { useChromeStorageSync } from "@/lib/useChromeStorageSync"; +import { BRAND_METADATA } from "@/lib/brand"; +import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys"; +import { OPTIONS_ICON_SRC, OPTIONS_PAGES } from "@/app/options/data"; +import { ThemePreference } from "@/ui/useTheme"; +import SidebarNavLink, { SidebarNavItem } from "./SidebarNavLink"; +import SidebarThemeToggle from "./SidebarThemeToggle"; +import OptionsSearch from "@/app/options/search/OptionsSearch"; + +const NAV_ITEMS: SidebarNavItem[] = OPTIONS_PAGES.map((page) => ({ + to: page.path, + label: page.label, + icon: page.icon as IconName, +})); + +const COLLAPSE_LABEL_EXPAND = "Expand sidebar"; +const COLLAPSE_LABEL_COLLAPSE = "Collapse sidebar"; + +export default function Sidebar() { + const version = chrome.runtime.getManifest().version; + const [collapsed, setCollapsed] = useChromeStorageLocal( + CHROME_STORAGE_LOCAL_KEY.OPTIONS_SIDEBAR_COLLAPSED, + false, + ); + const { store, update } = useChromeStorageSync(); + const themePref: ThemePreference = store.ui?.theme ?? "system"; + + const setTheme = (next: ThemePreference) => { + update((d) => { + d.ui.theme = next; + }); + }; + + return ( + + ); +} diff --git a/web-ext/app/options/layout/SidebarNavLink.tsx b/web-ext/app/options/layout/SidebarNavLink.tsx new file mode 100644 index 00000000..d6654b20 --- /dev/null +++ b/web-ext/app/options/layout/SidebarNavLink.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { NavLink } from "react-router-dom"; +import Icon, { IconName } from "@/ui/primitives/Icon"; +import { OPTIONS_PAGES } from "@/app/options/data"; + +export interface SidebarNavItem { + to: string; + label: string; + icon: IconName; +} + +export default function SidebarNavLink({ + item, + collapsed, +}: { + item: SidebarNavItem; + collapsed: boolean; +}) { + return ( + + `tw-flex tw-items-center tw-rounded-md tw-transition-colors tw-text-sm ${ + collapsed + ? "tw-justify-center tw-w-10 tw-h-10 tw-mx-auto" + : "tw-gap-2.5 tw-px-2.5 tw-py-2" + } ${ + isActive + ? "tw-bg-surface-hover tw-text-fg" + : "tw-text-fg-muted hover:tw-bg-surface-hover hover:tw-text-fg" + }` + } + > + {({ isActive }) => ( + <> + + + + {!collapsed && {item.label}} + + )} + + ); +} diff --git a/web-ext/app/options/layout/SidebarThemeToggle.tsx b/web-ext/app/options/layout/SidebarThemeToggle.tsx new file mode 100644 index 00000000..96a78bfa --- /dev/null +++ b/web-ext/app/options/layout/SidebarThemeToggle.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { ThemePreference } from "@/ui/useTheme"; + +const THEME_OPTIONS: { id: ThemePreference; label: string }[] = [ + { id: "system", label: "System" }, + { id: "dark", label: "Dark" }, + { id: "light", label: "Light" }, +]; + +export default function SidebarThemeToggle({ + value, + onChange, +}: { + value: ThemePreference; + onChange: (next: ThemePreference) => void; +}) { + return ( +
+ {THEME_OPTIONS.map((opt) => { + const active = value === opt.id; + return ( + + ); + })} +
+ ); +} diff --git a/web-ext/app/options/router.tsx b/web-ext/app/options/router.tsx new file mode 100644 index 00000000..a7f2608c --- /dev/null +++ b/web-ext/app/options/router.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { createMemoryRouter } from "react-router-dom"; +import { OPTIONS_PAGES } from "./data"; +import { getStorage } from "@/lib/store"; +import App from "./App"; +import General from "./routes/General"; +import Watch from "./routes/Watch"; +import Shorts from "./routes/Shorts"; +import Playlist from "./routes/Playlist"; +import Keybindings from "./routes/Keybindings"; +import Profiles from "./routes/Profiles"; +import ErrorPage from "./routes/error-page"; + +const ROUTE_BY_SEGMENT: Record = { + "": , + watch: , + shorts: , + playlist: , + keybindings: , + profiles: , +}; + +export const optionsRouter = createMemoryRouter([ + { + path: OPTIONS_PAGES[0].path, + element: , + loader: getStorage, + errorElement: , + children: OPTIONS_PAGES.map((page) => ({ + path: page.segment, + element: ROUTE_BY_SEGMENT[page.segment], + })), + }, +]); diff --git a/web-ext/app/options/routes/General.tsx b/web-ext/app/options/routes/General.tsx new file mode 100644 index 00000000..b8f7112d --- /dev/null +++ b/web-ext/app/options/routes/General.tsx @@ -0,0 +1,202 @@ +import React, { useState } from "react"; +import PageHeader from "@/ui/layout/PageHeader"; +import Section from "@/ui/layout/Section"; +import Card from "@/ui/layout/Card"; +import Switch from "@/ui/form/Switch"; +import Select from "@/ui/form/Select"; +import { useChromeStorageSync } from "@/lib/useChromeStorageSync"; +import { useToast } from "@/ui/feedback/ToastProvider"; +import { useCapabilityCache } from "@/features/profiles/useCapabilityCache"; +import { setExtensionIcon } from "@/lib/browser"; +import { ThemePreference } from "@/ui/useTheme"; + +export default function General() { + const { store, update } = useChromeStorageSync(); + const toast = useToast(); + const { rescan } = useCapabilityCache(); + const [isRescanning, setIsRescanning] = useState(false); + + return ( + <> + + +
+
+ + { + update((draft) => { + draft.isExtensionEnabled = isEnabled; + }); + setExtensionIcon(!isEnabled); + toast.success( + isEnabled ? "Extension enabled" : "Extension disabled", + ); + }} + /> + +
+ +
+ + + label="Theme" + description="System follows your OS appearance. Affects the popup and options UI — YouTube itself is unaffected." + value={store.ui?.theme ?? "system"} + options={[ + { + value: "system", + label: "System", + description: "Match your operating system", + }, + { + value: "dark", + label: "Dark", + description: "Always use dark theme", + }, + { + value: "light", + label: "Light", + description: "Always use light theme", + }, + ]} + onChange={(theme) => { + update((draft) => { + draft.ui.theme = theme; + }); + }} + /> + +
+ +
+ + { + toast.info("The popup switcher is always available."); + }} + /> + { + update((draft) => { + draft.ui.gearMenuEnabled = isEnabled; + }); + toast.success( + isEnabled + ? "Gear menu injection enabled — open the ⚙ menu on a video to try it" + : "Gear menu injection disabled", + ); + }} + /> + { + update((draft) => { + draft.ui.nativeSettingsEnabled = isEnabled; + }); + toast.success( + isEnabled + ? "Sidebar entry enabled — visit YouTube to see it" + : "Sidebar entry disabled", + ); + }} + /> + +
+ +
+ +
+
+ + Re-scan Capabilities + + + Clears the cached feature compatibility check. Features will + be re-tested next time you visit a YouTube page. + +
+ +
+
+
+ +
+ + { + update((draft) => { + draft.preferences.watch.isEnabled = isEnabled; + }); + }} + /> + { + update((draft) => { + draft.preferences.shorts.isEnabled = isEnabled; + }); + }} + /> + { + update((draft) => { + draft.preferences.playlist.isEnabled = isEnabled; + }); + }} + /> + +
+
+ + ); +} diff --git a/web-ext/app/options/routes/Keybindings.tsx b/web-ext/app/options/routes/Keybindings.tsx new file mode 100644 index 00000000..b302b903 --- /dev/null +++ b/web-ext/app/options/routes/Keybindings.tsx @@ -0,0 +1,292 @@ +import React from "react"; +import PageHeader from "@/ui/layout/PageHeader"; +import Section from "@/ui/layout/Section"; +import Card from "@/ui/layout/Card"; +import Keybinding from "@/ui/form/Keybinding"; +import Input from "@/ui/form/Input"; +import { useChromeStorageSync } from "@/lib/useChromeStorageSync"; + +export default function Keybindings() { + const { store, update } = useChromeStorageSync(); + const w = store.preferences.watch; + const s = store.preferences.shorts; + const nudge = w.nudgeLoopSegment; + const seg = w.segments; + + return ( + <> + + +
+
+ + { + update((draft) => { + draft.preferences.watch.togglePlaybackRate.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.increasePlaybackRate.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.decreasePlaybackRate.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.seekBackward.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.seekForward.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.toggleLoopSegment.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.segments.freshSlateKey = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.setLoopSegmentBegin.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.watch.setLoopSegmentEnd.key = key; + }); + }} + /> + { + update((draft) => { + if (!draft.preferences.watch.saveLoopSegment) { + draft.preferences.watch.saveLoopSegment = { key }; + } else { + draft.preferences.watch.saveLoopSegment.key = key; + } + }); + }} + /> + +
+ +
+ + { + update((draft) => { + draft.preferences.shorts.togglePlaybackRate.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.shorts.seekBackward.key = key; + }); + }} + /> + { + update((draft) => { + draft.preferences.shorts.seekForward.key = key; + }); + }} + /> + +
+ +
+ + { + update((draft) => { + if (!draft.preferences.watch.cycleProfiles) { + draft.preferences.watch.cycleProfiles = { key }; + } else { + draft.preferences.watch.cycleProfiles.key = key; + } + }); + }} + /> + +
+ +
+ + { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.startBackwardKey = key; + }); + }} + /> + { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.startForwardKey = key; + }); + }} + /> + { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.endForwardKey = key; + }); + }} + /> + { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.endBackwardKey = key; + }); + }} + /> + + + !isNaN(parseFloat(v)) && parseFloat(v) > 0} + errorMessage="Must be a positive number." + onChange={(val) => { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.baseStep = val; + }); + }} + /> + !isNaN(parseFloat(v)) && parseFloat(v) >= 1} + errorMessage="Must be ≥ 1." + onChange={(val) => { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.multiplier = val; + }); + }} + /> + !isNaN(parseFloat(v)) && parseFloat(v) > 0} + errorMessage="Must be a positive number." + onChange={(val) => { + update((draft) => { + if (!draft.preferences.watch.nudgeLoopSegment) return; + draft.preferences.watch.nudgeLoopSegment.maxStep = val; + }); + }} + /> + +
+
+ + ); +} diff --git a/web-ext/app/options/routes/Playlist.tsx b/web-ext/app/options/routes/Playlist.tsx new file mode 100644 index 00000000..599784e8 --- /dev/null +++ b/web-ext/app/options/routes/Playlist.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import PageHeader from "@/ui/layout/PageHeader"; +import Section from "@/ui/layout/Section"; +import Card from "@/ui/layout/Card"; + +export default function Playlist() { + return ( + <> + + +
+
+ +

+ The Playlist feature toggle lives on the{" "} + + General + {" "} + page. There are no other playlist-specific settings yet. +

+
+
+
+ + ); +} diff --git a/web-ext/app/options/routes/Profiles.tsx b/web-ext/app/options/routes/Profiles.tsx new file mode 100644 index 00000000..8b9ffa5c --- /dev/null +++ b/web-ext/app/options/routes/Profiles.tsx @@ -0,0 +1,593 @@ +import React, { useEffect, useState } from "react"; +import PageHeader from "@/ui/layout/PageHeader"; +import Section from "@/ui/layout/Section"; +import Card from "@/ui/layout/Card"; +import Switch from "@/ui/form/Switch"; +import Select from "@/ui/form/Select"; +import { useToast } from "@/ui/feedback/ToastProvider"; +import { useCapabilityCache } from "@/features/profiles/useCapabilityCache"; +import { + getAllProfiles, + getActiveProfile, + setActiveProfileId, + createProfile, + updateProfile, + deleteProfile, +} from "@/features/profiles/profileStore"; +import { + exportProfile, + importProfileFromFile, +} from "@/features/profiles/importExport"; +import { + BUILT_IN_PRESETS, + type Profile, + type ProfilePrimitiveConfig, + type ThumbnailMode, +} from "@/features/profiles/profiles"; +import type { PlayerLayout } from "@/features/profiles/profiles"; + +function blankPrimitives(): ProfilePrimitiveConfig { + return { + "watch.sidebar": { visible: true }, + "watch.comments": { visible: true }, + "watch.endCards": { visible: true }, + "watch.layout": { value: "default" }, + }; +} + +interface ProfileEditorProps { + initial: Partial; + onSave: (name: string, primitives: ProfilePrimitiveConfig) => Promise; + onCancel: () => void; + getStatus: (id: string) => "supported" | "unsupported" | "untested"; +} + +function ProfileEditor({ + initial, + onSave, + onCancel, + getStatus, +}: ProfileEditorProps) { + const [name, setName] = useState(initial.name ?? ""); + const [primitives, setPrimitives] = useState( + initial.primitives ?? blankPrimitives(), + ); + const [isSaving, setIsSaving] = useState(false); + + const layout = primitives["watch.layout"]?.value ?? "default"; + + const set = ( + key: K, + value: ProfilePrimitiveConfig[K], + ) => setPrimitives((prev) => ({ ...prev, [key]: value })); + + const handleSave = async () => { + if (!name.trim()) return; + setIsSaving(true); + await onSave(name.trim(), primitives); + setIsSaving(false); + }; + + const isUnsupported = (id: string) => getStatus(id) === "unsupported"; + + return ( +
+ {/* Name */} +
+ + setName(e.currentTarget.value)} + placeholder="e.g. Study session" + maxLength={40} + className="tw-w-full tw-rounded-lg tw-border tw-border-border-default tw-bg-surface tw-px-3 tw-py-2 tw-text-sm tw-text-fg placeholder:tw-text-fg-subtle focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-accent/50" + /> +
+ + {/* Watch scope */} +
+ + Watch page + + +
+ {/* Layout */} +
+ + label="Player Layout" + description={ + isUnsupported("watch.layout") + ? "Not available on your YouTube" + : "How the video player is arranged on the page." + } + value={layout} + options={[ + { value: "default", label: "Default", description: "YouTube's standard layout" }, + { value: "no-video", label: "No Video", description: "Hide the player area entirely" }, + ]} + onChange={(v) => set("watch.layout", { value: v })} + /> +
+ + {/* Sidebar */} +
+ set("watch.sidebar", { visible: v })} + /> +
+ + {/* Comments */} +
+ set("watch.comments", { visible: v })} + /> +
+ + {/* End cards */} +
+ set("watch.endCards", { visible: v })} + /> +
+
+
+ + {/* Home scope */} +
+ + Home page + +
+
+ + label="Feed Thumbnails" + description="Show, hide, or blur thumbnail images in the home feed." + value={primitives["home.thumbnails"]?.mode ?? "show"} + options={[ + { value: "show", label: "Show", description: "Normal thumbnails" }, + { value: "blur", label: "Blur", description: "Blurred — layout preserved" }, + { value: "hide", label: "Hide", description: "Invisible thumbnails" }, + ]} + onChange={(v) => set("home.thumbnails", { mode: v })} + /> +
+
+ set("home.feed", { visible: v })} + /> +
+
+ set("home.shorts", { visible: v })} + /> +
+
+
+ + {/* Search scope */} +
+ + Search + +
+
+ + label="Result Thumbnails" + description="Show, hide, or blur thumbnails in search results." + value={primitives["search.thumbnails"]?.mode ?? "show"} + options={[ + { value: "show", label: "Show", description: "Normal thumbnails" }, + { value: "blur", label: "Blur", description: "Blurred — layout preserved" }, + { value: "hide", label: "Hide", description: "Plain text list" }, + ]} + onChange={(v) => set("search.thumbnails", { mode: v })} + /> +
+
+ set("search.metadata", { visible: v })} + /> +
+
+ set("search.shorts", { visible: v })} + /> +
+
+
+ + {/* Shorts shelf (cross-page) */} +
+ + Shorts + +
+
+ set("shorts.shelf", { visible: v })} + /> +
+
+
+ + {/* Actions */} +
+ + +
+
+ ); +} + +interface PresetCardProps { + profile: Profile; + isActive: boolean; + onActivate: () => void; +} + +function PresetCard({ profile, isActive, onActivate }: PresetCardProps) { + const primitiveLabels: Record = { + "watch.layout": "Layout", + "watch.sidebar": "Sidebar", + "watch.comments": "Comments", + "watch.endCards": "End Cards", + "home.thumbnails": "Home Thumbnails", + "home.feed": "Home Feed", + "home.shorts": "Home Shorts", + "search.thumbnails": "Search Thumbnails", + "search.metadata": "Search Metadata", + "search.shorts": "Search Shorts", + "shorts.shelf": "Shorts Shelf", + }; + + const summaryParts = Object.entries(profile.primitives) + .map(([key, val]) => { + const label = primitiveLabels[key] ?? key; + if ("visible" in val) return `${label}: ${val.visible ? "on" : "off"}`; + if ("mode" in val) return `${label}: ${val.mode}`; + if ("value" in val) return `${label}: ${val.value}`; + return label; + }) + .join(" · "); + + return ( +
+
+
+ + {profile.name} + + + Built-in + + {isActive && ( + + Active + + )} +
+ {summaryParts} +
+ +
+ ); +} + +interface CustomProfileCardProps { + profile: Profile; + isActive: boolean; + onActivate: () => void; + onEdit: () => void; + onDelete: () => void; +} + +function CustomProfileCard({ + profile, + isActive, + onActivate, + onEdit, + onDelete, +}: CustomProfileCardProps) { + return ( +
+
+
+ + {profile.name} + + {isActive && ( + + Active + + )} +
+ + {Object.keys(profile.primitives).length} primitive + {Object.keys(profile.primitives).length !== 1 ? "s" : ""} configured + +
+
+ + + + +
+
+ ); +} + +type EditorMode = + | { type: "closed" } + | { type: "create" } + | { type: "edit"; profile: Profile }; + +export default function Profiles() { + const toast = useToast(); + const { getStatus } = useCapabilityCache(); + + const [profiles, setProfiles] = useState([]); + const [activeId, setActiveId] = useState(null); + const [editor, setEditor] = useState({ type: "closed" }); + + const load = async () => { + const [all, active] = await Promise.all([ + getAllProfiles(), + getActiveProfile(), + ]); + setProfiles(all); + setActiveId(active?.id ?? null); + }; + + useEffect(() => { + void load(); + }, []); + + const handleActivate = async (id: string) => { + const next = activeId === id ? null : id; + await setActiveProfileId(next); + setActiveId(next); + const name = profiles.find((p) => p.id === id)?.name ?? "Profile"; + toast.success(next ? `${name} activated` : "Profile deactivated"); + }; + + const handleCreate = async ( + name: string, + primitives: ProfilePrimitiveConfig, + ) => { + await createProfile({ name, primitives }); + await load(); + setEditor({ type: "closed" }); + toast.success(`"${name}" created`); + }; + + const handleUpdate = async ( + id: string, + name: string, + primitives: ProfilePrimitiveConfig, + ) => { + await updateProfile(id, { name, primitives }); + await load(); + setEditor({ type: "closed" }); + toast.success(`"${name}" saved`); + }; + + const handleDelete = async (profile: Profile) => { + await deleteProfile(profile.id); + await load(); + toast.success(`"${profile.name}" deleted`); + }; + + const presets = profiles.filter((p) => p.isPreset); + const custom = profiles.filter((p) => !p.isPreset); + + return ( + <> + + +
+ {/* Built-in presets */} +
+ + {presets.map((p) => ( + handleActivate(p.id)} + /> + ))} + +
+ + {/* Custom profiles */} +
+ + +
+ ) : null + } + > + + {/* Inline create editor */} + {editor.type === "create" && ( + setEditor({ type: "closed" })} + getStatus={getStatus} + /> + )} + + {custom.length === 0 && editor.type !== "create" ? ( +
+ No custom profiles yet. Create one with the button above. +
+ ) : ( + custom.map((p) => + editor.type === "edit" && editor.profile.id === p.id ? ( + + handleUpdate(p.id, name, primitives) + } + onCancel={() => setEditor({ type: "closed" })} + getStatus={getStatus} + /> + ) : ( + handleActivate(p.id)} + onEdit={() => setEditor({ type: "edit", profile: p })} + onDelete={() => handleDelete(p)} + /> + ), + ) + )} +
+ + + + ); +} diff --git a/web-ext/app/options/routes/Shorts.tsx b/web-ext/app/options/routes/Shorts.tsx new file mode 100644 index 00000000..d554ec77 --- /dev/null +++ b/web-ext/app/options/routes/Shorts.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import PageHeader from "@/ui/layout/PageHeader"; +import Section from "@/ui/layout/Section"; +import Card from "@/ui/layout/Card"; +import Switch from "@/ui/form/Switch"; +import Input from "@/ui/form/Input"; +import { useChromeStorageSync } from "@/lib/useChromeStorageSync"; + +export default function Shorts() { + const { store, update } = useChromeStorageSync(); + const s = store.preferences.shorts; + + return ( + <> + + +
+
+ + { + update((draft) => { + draft.preferences.shorts.reelAutoScroll.value = isEnabled; + }); + }} + /> + +
+ +
+ + { + const n = parseFloat(v); + return Number.isFinite(n) && n >= 0.0625 && n <= 16; + }} + errorMessage="Must be between 0.0625 and 16" + onChange={(value) => { + update((draft) => { + draft.preferences.shorts.togglePlaybackRate.value = value; + }); + }} + /> + +
+ +
+ + { + const n = parseFloat(v); + return Number.isFinite(n) && n >= 0; + }} + onChange={(value) => { + update((draft) => { + draft.preferences.shorts.seekBackward.value = value; + }); + }} + /> + { + const n = parseFloat(v); + return Number.isFinite(n) && n >= 0; + }} + onChange={(value) => { + update((draft) => { + draft.preferences.shorts.seekForward.value = value; + }); + }} + /> + +
+
+ + ); +} diff --git a/web-ext/app/options/routes/Watch.tsx b/web-ext/app/options/routes/Watch.tsx new file mode 100644 index 00000000..b34785d0 --- /dev/null +++ b/web-ext/app/options/routes/Watch.tsx @@ -0,0 +1,266 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import PageHeader from "@/ui/layout/PageHeader"; +import Section from "@/ui/layout/Section"; +import Card from "@/ui/layout/Card"; +import Input from "@/ui/form/Input"; +import Select from "@/ui/form/Select"; +import CapabilityStatusRow from "@/features/profiles/CapabilityStatusRow"; +import { useChromeStorageSync } from "@/lib/useChromeStorageSync"; +import { useCapabilityCache } from "@/features/profiles/useCapabilityCache"; +import { isCustomPlaybackRatesList } from "@/app/options/validators"; +import { + getUndismissedRecovered, + dismissRecovered, + type RecoveredFeature, +} from "@/features/profiles/featureReports"; + +// Adding a watch-page primitive means appending an entry here; nothing +// discovers them automatically. +const WATCH_PRIMITIVES: { id: string; label: string }[] = [ + { id: "watch.player", label: "Video Player" }, + { id: "watch.rightControls", label: "Player Controls Bar" }, + { id: "watch.progressBar", label: "Progress Bar" }, + { id: "watch.moviePlayer", label: "Movie Player Container" }, + { id: "watch.settingsButton",label: "Settings Button" }, + { id: "watch.sidebar", label: "Recommendations Sidebar" }, + { id: "watch.comments", label: "Comments Section" }, + { id: "watch.endCards", label: "End Screen Cards" }, +]; + +function useRecoveredFeatures() { + const [recovered, setRecovered] = useState([]); + + useEffect(() => { + getUndismissedRecovered().then(setRecovered); + }, []); + + const dismiss = async (primitiveId: string) => { + await dismissRecovered(primitiveId); + setRecovered((prev) => prev.filter((r) => r.primitiveId !== primitiveId)); + }; + + return { recovered, dismiss }; +} + +export default function Watch() { + const { store, update } = useChromeStorageSync(); + const { getStatus, isLoading } = useCapabilityCache(); + const { recovered, dismiss } = useRecoveredFeatures(); + const w = store.preferences.watch; + + return ( + <> + + + {/* Recovery banners — shown when a previously-reported feature is now working */} + {recovered.map((r) => ( +
+
+ + A feature you reported is now working 🎉 + + + {r.primitiveId} is now + supported on your YouTube. You can re-scan to confirm. + +
+ +
+ ))} + +
+
+ + { + const n = parseFloat(v); + return Number.isFinite(n) && n >= 0.0625 && n <= 16; + }} + errorMessage="Must be between 0.0625 and 16" + onChange={(value) => { + update((draft) => { + draft.preferences.watch.defaultPlaybackRate.value = value; + }); + }} + /> + { + update((draft) => { + const trimmed = value.trim(); + draft.preferences.watch.customPlaybackRates = + trimmed === "" + ? [] + : trimmed + .split(",") + .map((r) => Number(r.trim()).toFixed(2)); + }); + }} + /> + { + const n = parseFloat(v); + return Number.isFinite(n) && n >= 0.0625 && n <= 16; + }} + errorMessage="Must be between 0.0625 and 16" + onChange={(value) => { + update((draft) => { + draft.preferences.watch.togglePlaybackRate.value = value; + }); + }} + /> + { + const n = parseFloat(v); + return Number.isFinite(n) && n >= 0 && n <= 16; + }} + onChange={(value) => { + update((draft) => { + draft.preferences.watch.increasePlaybackRate.value = value; + }); + }} + /> + { + const n = parseFloat(v); + return Number.isFinite(n) && n >= 0 && n <= 16; + }} + onChange={(value) => { + update((draft) => { + draft.preferences.watch.decreasePlaybackRate.value = value; + }); + }} + /> + +
+ +
+ + { + const n = parseFloat(v); + return Number.isFinite(n) && n >= 0; + }} + onChange={(value) => { + update((draft) => { + draft.preferences.watch.seekBackward.value = value; + }); + }} + /> + { + const n = parseFloat(v); + return Number.isFinite(n) && n >= 0; + }} + onChange={(value) => { + update((draft) => { + draft.preferences.watch.seekForward.value = value; + }); + }} + /> + +
+ +
+ + { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className="tw-w-full tw-bg-surface-hover tw-border tw-border-border-default tw-rounded-lg tw-pl-8 tw-pr-7 tw-py-1.5 tw-text-xs tw-text-fg tw-placeholder-fg-subtle focus:tw-outline-none focus:tw-border-accent tw-transition-colors" + /> + {query && ( + + )} +
+ + {open && results.length > 0 && ( +
+ {results.map((result, i) => ( + + ))} +
+ )} + + {open && query.length >= 1 && results.length === 0 && ( +
+

+ No results for "{query}" +

+
+ )} + + ); +} diff --git a/web-ext/app/options/search/searchIndex.ts b/web-ext/app/options/search/searchIndex.ts new file mode 100644 index 00000000..455d44a4 --- /dev/null +++ b/web-ext/app/options/search/searchIndex.ts @@ -0,0 +1,157 @@ +export interface SearchEntry { + // Must match the rendered text character for character: navigating to a result + // locates the row by this label in the DOM, so a copy edit in the UI silently + // breaks the jump unless the entry is updated too. + label: string; + description?: string; + path: string; + sectionId?: string; // fallback scroll target when the row itself is not found + page: string; + section?: string; +} + +export interface SearchResult { + entry: SearchEntry; + score: number; + matchedIndices: number[]; // into entry.label, for highlighting +} + +export const SEARCH_INDEX: SearchEntry[] = [ + { label: "Enable Extension", description: "When off, no Toppings features run on YouTube.", path: "/", page: "General", section: "Extension" }, + { label: "Theme", description: "System, Dark, or Light. Affects the popup and options UI.", path: "/", page: "General", section: "Appearance" }, + { label: "Popup profile switcher", description: "Show the profile list in the extension popup.", path: "/", page: "General", section: "Profile Surfaces" }, + { label: "Player gear menu", description: "Adds a Toppings section to the video player's settings menu.", path: "/", page: "General", section: "Profile Surfaces" }, + { label: "YouTube sidebar entry", description: "Adds a Toppings entry to YouTube's left navigation sidebar.", path: "/", page: "General", section: "Profile Surfaces" }, + { label: "Re-scan Capabilities", description: "Clears the cached feature compatibility check.", path: "/", page: "General", section: "Feature Diagnostics" }, + { label: "Watch Page", description: "Enable or disable all Toppings features on the watch page.", path: "/", page: "General", section: "YouTube Pages" }, + { label: "Shorts", description: "Enable or disable Toppings on Shorts.", path: "/", page: "General", section: "YouTube Pages" }, + { label: "Playlist", description: "Enable or disable runtime statistics on playlist pages.", path: "/", page: "General", section: "YouTube Pages" }, + + { label: "Default Playback Rate", description: "Rate applied to every video on load. 1 = Normal.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Custom Playback Rates", description: "Comma-separated rates for the player speed menu.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Toggle Playback Rate", description: "Rate to switch to when pressing the toggle shortcut.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Increase Playback Rate Step", description: "Amount the rate goes up when pressing the increase shortcut.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Decrease Playback Rate Step", description: "Amount the rate goes down when pressing the decrease shortcut.", path: "/watch", sectionId: "playback-rate", page: "Watch", section: "Playback Rate" }, + { label: "Seek Backward", description: "Seconds to seek backward.", path: "/watch", sectionId: "seek", page: "Watch", section: "Seek" }, + { label: "Seek Forward", description: "Seconds to seek forward.", path: "/watch", sectionId: "seek", page: "Watch", section: "Seek" }, + { label: "Auto-load on page open", description: "Automatically restore segments when you open a video.", path: "/watch", sectionId: "loop", page: "Watch", section: "Segments" }, + { label: "Feature Availability", description: "Which features are active on your YouTube.", path: "/watch", sectionId: "feature-availability", page: "Watch", section: "Feature Availability" }, + + { label: "Auto-Scroll", description: "Automatically scroll to the next reel when one ends.", path: "/shorts", page: "Shorts", section: "Behavior" }, + { label: "Toggle Playback Rate", description: "Rate to switch to when pressing the toggle shortcut.", path: "/shorts", page: "Shorts", section: "Playback Rate" }, + { label: "Seek Backward", description: "Seconds to seek backward.", path: "/shorts", page: "Shorts", section: "Seek" }, + { label: "Seek Forward", description: "Seconds to seek forward.", path: "/shorts", page: "Shorts", section: "Seek" }, + + { label: "Runtime Statistics", description: "Total and average runtime shown at the top of playlist pages.", path: "/playlist", page: "Playlist", section: "Runtime Statistics" }, + + { label: "Toggle Playback Rate", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Increase Playback Rate", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Decrease Playback Rate", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Seek Backward", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Seek Forward", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Load Last Used / Toggle", description: "Load the last-used segment config and enable it. If segments are already active, disables them.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Fresh Slate", description: "Always start a brand-new segment config.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Set Start of Active", description: "Pin the start marker of the active segment to the current time.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Set End of Active", description: "Pin the end marker of the active segment to the current time.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + { label: "Segments: Save to Default Slot", description: "While segments active: saves to default slot. While segments off: clears the last-used record.", path: "/keybindings", sectionId: "watch", page: "Shortcuts", section: "Watch Page" }, + + { label: "Toggle Playback Rate", path: "/keybindings", sectionId: "shorts", page: "Shortcuts", section: "Shorts" }, + { label: "Seek Backward", path: "/keybindings", sectionId: "shorts", page: "Shortcuts", section: "Shorts" }, + { label: "Seek Forward", path: "/keybindings", sectionId: "shorts", page: "Shortcuts", section: "Shorts" }, + + { label: "Cycle Profiles", description: "Cycle through all profiles without leaving the video.", path: "/keybindings", sectionId: "profiles", page: "Shortcuts", section: "Profiles" }, + + { label: "Nudge Active Segment Start Backward", description: "Move the start marker of the active segment back.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Nudge Active Segment Start Forward", description: "Move the start marker of the active segment forward.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Nudge Active Segment End Forward", description: "Move the end marker of the active segment forward.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Nudge Active Segment End Backward", description: "Move the end marker of the active segment back.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Base Step (seconds)", description: "How many seconds the first press nudges the marker.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Multiplier", description: "Step multiplier applied on rapid consecutive presses.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + { label: "Max Step (seconds)", description: "The nudge step will not exceed this value.", path: "/keybindings", sectionId: "nudge", page: "Shortcuts", section: "Segments Nudge" }, + + { label: "Built-in Presets", description: "Curated by Toppings — activate in one tap, no configuration needed.", path: "/profiles", page: "Profiles", section: "Built-in Presets" }, + { label: "Audio", description: "Built-in preset: hides video player, shows only audio.", path: "/profiles", page: "Profiles", section: "Built-in Presets" }, + { label: "Focus", description: "Built-in preset: hides sidebar, comments, and end cards.", path: "/profiles", page: "Profiles", section: "Built-in Presets" }, + { label: "My Profiles", description: "Create your own mix of YouTube experience settings.", path: "/profiles", page: "Profiles", section: "My Profiles" }, + { label: "Player Layout", description: "Default, Theatre, or No Video layout for the watch page.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Player Visuals", description: "What fills the video slot: real video, black screen, visualizer, or custom image.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Recommendations Sidebar", description: "Show or hide the Up Next recommendations panel.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Comments Section", description: "Show or hide the comments below the video.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "End Screen Cards", description: "Show or hide overlay cards at the end of videos.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Feed Thumbnails", description: "Show, hide, or blur thumbnail images in the home feed.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Home Feed", description: "Show or hide the entire home page feed.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Shorts Shelf", description: "Show or hide the Shorts row in the home feed.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Result Thumbnails", description: "Show, hide, or blur thumbnails in search results.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Video Metadata", description: "Show or hide view count and date below search results.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Shorts in Search", description: "Show or hide the Shorts shelf in search results.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, + { label: "Shorts Shelf (everywhere)", description: "Hide the Shorts shelf across home, search, and other pages.", path: "/profiles", page: "Profiles", section: "Profile Editor" }, +]; + +// Subsequence match: query characters must appear in order but need not be +// adjacent. The bonuses below exist to pull contiguous and word-start matches +// above the scattered ones a bare subsequence test would rank equally. +function fuzzyMatch( + text: string, + query: string, +): { score: number; indices: number[] } { + const tl = text.toLowerCase(); + const ql = query.toLowerCase(); + + let ti = 0; + let qi = 0; + let score = 0; + let consecutive = 0; + let lastMatchIdx = -1; + const indices: number[] = []; + + while (ti < tl.length && qi < ql.length) { + if (tl[ti] === ql[qi]) { + indices.push(ti); + const isConsecutive = lastMatchIdx === ti - 1; + consecutive = isConsecutive ? consecutive + 1 : 0; + score += 1 + consecutive * 2; + if (ti === 0 || tl[ti - 1] === " " || tl[ti - 1] === ":") score += 3; + lastMatchIdx = ti; + qi++; + } + ti++; + } + + if (qi < ql.length) return { score: 0, indices: [] }; + + if (tl.includes(ql)) score += 15; + if (tl.startsWith(ql)) score += 25; + if (tl === ql) score += 50; + + return { score, indices }; +} + +export function fuzzySearch(query: string): SearchResult[] { + const q = query.trim(); + if (q.length < 1) return []; + + const results: SearchResult[] = []; + + for (const entry of SEARCH_INDEX) { + // Four fields contribute to the score but only the label's indices are kept: + // highlighting is drawn on the label, so another field's indices would be wrong. + const labelMatch = fuzzyMatch(entry.label, q); + const descMatch = entry.description ? fuzzyMatch(entry.description, q) : { score: 0, indices: [] }; + const sectionMatch = entry.section ? fuzzyMatch(entry.section, q) : { score: 0, indices: [] }; + const pageMatch = fuzzyMatch(entry.page, q); + + const totalScore = + labelMatch.score * 3 + + sectionMatch.score * 1.5 + + pageMatch.score + + descMatch.score * 0.5; + + if (totalScore > 0) { + results.push({ entry, score: totalScore, matchedIndices: labelMatch.indices }); + } + } + + return results + .sort((a, b) => b.score - a.score) + .slice(0, 10); +} diff --git a/web-ext/app/options/validators.ts b/web-ext/app/options/validators.ts new file mode 100644 index 00000000..4cb48234 --- /dev/null +++ b/web-ext/app/options/validators.ts @@ -0,0 +1,19 @@ +const CUSTOM_PLAYBACK_RATES_PATTERN = + /^(\d+(\.\d+)?)(\s*,\s*\d+(\.\d+)?)*$/; + +const PLAYBACK_RATE_MIN = 0.0625; +const PLAYBACK_RATE_MAX = 16; + +export function isCustomPlaybackRatesList(value: string): boolean { + if (value.trim() === "") return true; + const playbackRates = value.split(",").map((r) => r.trim()); + if (!CUSTOM_PLAYBACK_RATES_PATTERN.test(value) || playbackRates.length <= 1) { + return false; + } + const rates = playbackRates.map(parseFloat); + return ( + !rates.includes(NaN) && + rates.includes(1) && + !rates.some((r) => r < PLAYBACK_RATE_MIN || r > PLAYBACK_RATE_MAX) + ); +} diff --git a/web-ext/app/popup/App.tsx b/web-ext/app/popup/App.tsx new file mode 100644 index 00000000..35cefd7e --- /dev/null +++ b/web-ext/app/popup/App.tsx @@ -0,0 +1,306 @@ +import React, { useEffect, useState } from "react"; +import { Storage, DEFAULT_STORE, getStorage } from "@/lib/store"; +import StoreContext from "@/lib/storeContext"; +import ThemeApplier from "@/ui/ThemeApplier"; +import { ToastProvider } from "@/ui/feedback/ToastProvider"; +import { useChromeStorageSync } from "@/lib/useChromeStorageSync"; +import { useScope } from "./useScope"; +import { EXTENSION_CONTEXT_SCOPE } from "@/lib/protocol"; +import { URLS } from "@/lib/urls"; +import { setExtensionIcon } from "@/lib/browser"; +import { POPUP_TAB_SCOPE_LABEL, POPUP_SIZE } from "./data"; +import { formatTabUrlShort } from "./formatTabUrl"; +import TinySwitch from "./components/TinySwitch"; +import PopupRow from "./components/PopupRow"; +import NavBtn from "./components/NavBtn"; +import { openOptionsPage } from "./openOptions"; +import type { Profile } from "@/features/profiles/profiles"; +import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys"; +import { + getAllProfiles, + getActiveProfile, + setActiveProfileId, +} from "@/features/profiles/profileStore"; + +function usePopupProfiles() { + const [profiles, setProfiles] = useState([]); + const [activeProfileId, setActiveId] = useState(null); + + const load = async () => { + const [all, active] = await Promise.all([getAllProfiles(), getActiveProfile()]); + setProfiles(all); + setActiveId(active?.id ?? null); + }; + + useEffect(() => { + void load(); + const listener = (changes: Record) => { + if (CHROME_STORAGE_LOCAL_KEY.PROFILE_STORE in changes) void load(); + }; + chrome.storage.onChanged.addListener(listener); + return () => chrome.storage.onChanged.removeListener(listener); + }, []); + + const activate = async (id: string | null) => { + await setActiveProfileId(id); + setActiveId(id); + }; + + return { profiles, activeProfileId, activate }; +} + +function PopupShell() { + const { store, update } = useChromeStorageSync(); + const version = chrome.runtime.getManifest().version; + const { url, scope } = useScope(); + const { profiles, activeProfileId, activate } = usePopupProfiles(); + + const masterOn = store.isExtensionEnabled; + const loopOn = store.preferences.watch.isEnabled; + const shortsAuto = store.preferences.shorts.reelAutoScroll.value; + const defaultRate = store.preferences.watch.defaultPlaybackRate.value; + + const toggleMaster = () => { + const next = !masterOn; + update((d) => { + d.isExtensionEnabled = next; + }); + setExtensionIcon(!next); + }; + + return ( +
+
+ +
+ Toppings +
+ + v{version} + +
+ +
+ +
+
+ {POPUP_TAB_SCOPE_LABEL[scope]} +
+
+ {formatTabUrlShort(url)} +
+
+
+ +
+ } + onActivate={toggleMaster} + /> + + update((d) => { + d.preferences.watch.isEnabled = !d.preferences.watch.isEnabled; + }) + } + /> + } + onActivate={() => + update((d) => { + d.preferences.watch.isEnabled = !d.preferences.watch.isEnabled; + }) + } + disabled={!masterOn} + /> + + update((d) => { + d.preferences.shorts.reelAutoScroll.value = + !d.preferences.shorts.reelAutoScroll.value; + }) + } + /> + } + onActivate={() => + update((d) => { + d.preferences.shorts.reelAutoScroll.value = + !d.preferences.shorts.reelAutoScroll.value; + }) + } + disabled={!masterOn} + /> + + {defaultRate}x + + } + /> + + {/* Profile switcher */} +
+
+ + Profile + + {activeProfileId !== null && ( + + )} +
+
+ {profiles.map((p) => { + const isActive = p.id === activeProfileId; + return ( + + ); + })} +
+
+
+ + +
+ ); +} + +export default function App() { + const [store, setStore] = useState(DEFAULT_STORE); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + getStorage().then((s) => { + setStore(s); + setLoaded(true); + }); + }, []); + + if (!loaded) { + return ( +
+ ); + } + + return ( + + + + + + + + ); +} diff --git a/web-ext/app/popup/components/NavBtn.tsx b/web-ext/app/popup/components/NavBtn.tsx new file mode 100644 index 00000000..47a5fa8e --- /dev/null +++ b/web-ext/app/popup/components/NavBtn.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +export default function NavBtn({ + label, + active, + onClick, + path, +}: { + label: string; + active?: boolean; + onClick: () => void; + path: string; +}) { + return ( + + ); +} diff --git a/web-ext/app/popup/components/PopupRow.tsx b/web-ext/app/popup/components/PopupRow.tsx new file mode 100644 index 00000000..8dadf1f7 --- /dev/null +++ b/web-ext/app/popup/components/PopupRow.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +export default function PopupRow({ + title, + subtitle, + control, + onActivate, + disabled, +}: { + title: string; + subtitle: string; + control: React.ReactNode; + onActivate?: () => void; + disabled?: boolean; +}) { + const clickable = !!onActivate && !disabled; + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onActivate?.(); + } + } + : undefined + } + className={`tw-grid tw-grid-cols-[1fr_auto] tw-items-center tw-gap-3 tw-px-4 tw-py-3 tw-border-b tw-border-border-default tw-transition-colors tw-duration-150 ${ + clickable ? "tw-cursor-pointer hover:tw-bg-surface-hover" : "" + } ${disabled ? "tw-opacity-50 tw-pointer-events-none" : ""}`} + > +
+
+ {title} +
+
+ {subtitle} +
+
+
e.stopPropagation()}>{control}
+
+ ); +} diff --git a/web-ext/app/popup/components/TinySwitch.tsx b/web-ext/app/popup/components/TinySwitch.tsx new file mode 100644 index 00000000..a05c9f14 --- /dev/null +++ b/web-ext/app/popup/components/TinySwitch.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +export default function TinySwitch({ + on, + onClick, +}: { + on: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/web-ext/app/popup/data.ts b/web-ext/app/popup/data.ts new file mode 100644 index 00000000..6e5ee0c3 --- /dev/null +++ b/web-ext/app/popup/data.ts @@ -0,0 +1,14 @@ +import { EXTENSION_CONTEXT_SCOPE, type ExtensionContextScope } from "@/lib/protocol"; + +export const POPUP_SIZE = { + WIDTH_PX: 340, + HEIGHT_PX: 520, +} as const; + +export const POPUP_TAB_SCOPE_LABEL: Record = { + [EXTENSION_CONTEXT_SCOPE.WATCH]: "Watch page · active", + [EXTENSION_CONTEXT_SCOPE.SHORTS]: "Shorts · active", + [EXTENSION_CONTEXT_SCOPE.PLAYLIST]: "Playlist · active", + [EXTENSION_CONTEXT_SCOPE.YOUTUBE]: "YouTube · active", + [EXTENSION_CONTEXT_SCOPE.UNSUPPORTED]: "Open a YouTube video", +}; diff --git a/web-ext/app/popup/formatTabUrl.ts b/web-ext/app/popup/formatTabUrl.ts new file mode 100644 index 00000000..4e644ec4 --- /dev/null +++ b/web-ext/app/popup/formatTabUrl.ts @@ -0,0 +1,11 @@ +export function formatTabUrlShort(url: string | null): string { + if (!url) return "—"; + try { + const u = new URL(url); + const path = + u.pathname.length > 28 ? `${u.pathname.slice(0, 28)}…` : u.pathname; + return `${u.hostname}${path}`; + } catch { + return url; + } +} diff --git a/web-ext/src/options/index.css b/web-ext/app/popup/index.css similarity index 99% rename from web-ext/src/options/index.css rename to web-ext/app/popup/index.css index 135c24b0..dcbbc3b7 100644 --- a/web-ext/src/options/index.css +++ b/web-ext/app/popup/index.css @@ -2,6 +2,8 @@ @tailwind components; @tailwind utilities; +@import "../../theme.css"; + /* 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) diff --git a/web-ext/src/popup/index.html b/web-ext/app/popup/index.html similarity index 88% rename from web-ext/src/popup/index.html rename to web-ext/app/popup/index.html index 27246fc0..b8d6bb43 100644 --- a/web-ext/src/popup/index.html +++ b/web-ext/app/popup/index.html @@ -4,6 +4,7 @@ Toppings: Quick Access + diff --git a/web-ext/src/popup/index.tsx b/web-ext/app/popup/index.tsx similarity index 91% rename from web-ext/src/popup/index.tsx rename to web-ext/app/popup/index.tsx index 8b1ddb97..2be325ed 100644 --- a/web-ext/src/popup/index.tsx +++ b/web-ext/app/popup/index.tsx @@ -1,7 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; -import "./index.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/web-ext/app/popup/openOptions.ts b/web-ext/app/popup/openOptions.ts new file mode 100644 index 00000000..b1bab99a --- /dev/null +++ b/web-ext/app/popup/openOptions.ts @@ -0,0 +1,5 @@ +import { OPTIONS_HTML } from "@/app/options/data"; + +export function openOptionsPage() { + window.open(chrome.runtime.getURL(OPTIONS_HTML), "_blank"); +} diff --git a/web-ext/app/popup/useScope.ts b/web-ext/app/popup/useScope.ts new file mode 100644 index 00000000..6e4f2b77 --- /dev/null +++ b/web-ext/app/popup/useScope.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import { EXTENSION_CONTEXT_SCOPE, type ExtensionContextScope } from "@/lib/protocol"; +import { YOUTUBE_HOSTNAME_SUFFIX, YOUTUBE_URL_PATH } from "@/lib/youtube"; + +export function useScope() { + const [url, setUrl] = useState(null); + const [scope, setScope] = useState( + EXTENSION_CONTEXT_SCOPE.UNSUPPORTED, + ); + + const resolveScope = (url: string | null): ExtensionContextScope => { + if (!url) return EXTENSION_CONTEXT_SCOPE.UNSUPPORTED; + try { + const u = new URL(url); + if (!u.hostname.endsWith(YOUTUBE_HOSTNAME_SUFFIX)) { + return EXTENSION_CONTEXT_SCOPE.UNSUPPORTED; + } + if (u.pathname.startsWith(YOUTUBE_URL_PATH.WATCH)) { + return EXTENSION_CONTEXT_SCOPE.WATCH; + } + if (u.pathname.startsWith(YOUTUBE_URL_PATH.SHORTS)) { + return EXTENSION_CONTEXT_SCOPE.SHORTS; + } + if (u.pathname.startsWith(YOUTUBE_URL_PATH.PLAYLIST)) { + return EXTENSION_CONTEXT_SCOPE.PLAYLIST; + } + return EXTENSION_CONTEXT_SCOPE.YOUTUBE; + } catch { + return EXTENSION_CONTEXT_SCOPE.UNSUPPORTED; + } + }; + + useEffect(() => { + chrome.tabs?.query?.({ active: true, currentWindow: true }, (tabs) => { + const tabUrl = tabs?.[0]?.url ?? null; + setUrl(tabUrl); + setScope(resolveScope(tabUrl)); + }); + }, []); + + return { url, scope }; +} diff --git a/web-ext/src/assets/icons/disabled_icon128.png b/web-ext/assets/icons/disabled_icon128.png similarity index 100% rename from web-ext/src/assets/icons/disabled_icon128.png rename to web-ext/assets/icons/disabled_icon128.png diff --git a/web-ext/src/assets/icons/disabled_icon16.png b/web-ext/assets/icons/disabled_icon16.png similarity index 100% rename from web-ext/src/assets/icons/disabled_icon16.png rename to web-ext/assets/icons/disabled_icon16.png diff --git a/web-ext/src/assets/icons/disabled_icon32.png b/web-ext/assets/icons/disabled_icon32.png similarity index 100% rename from web-ext/src/assets/icons/disabled_icon32.png rename to web-ext/assets/icons/disabled_icon32.png diff --git a/web-ext/src/assets/icons/disabled_icon48.png b/web-ext/assets/icons/disabled_icon48.png similarity index 100% rename from web-ext/src/assets/icons/disabled_icon48.png rename to web-ext/assets/icons/disabled_icon48.png diff --git a/web-ext/src/assets/icons/icon128.png b/web-ext/assets/icons/icon128.png similarity index 100% rename from web-ext/src/assets/icons/icon128.png rename to web-ext/assets/icons/icon128.png diff --git a/web-ext/src/assets/icons/icon16.png b/web-ext/assets/icons/icon16.png similarity index 100% rename from web-ext/src/assets/icons/icon16.png rename to web-ext/assets/icons/icon16.png diff --git a/web-ext/src/assets/icons/icon32.png b/web-ext/assets/icons/icon32.png similarity index 100% rename from web-ext/src/assets/icons/icon32.png rename to web-ext/assets/icons/icon32.png diff --git a/web-ext/src/assets/icons/icon48.png b/web-ext/assets/icons/icon48.png similarity index 100% rename from web-ext/src/assets/icons/icon48.png rename to web-ext/assets/icons/icon48.png diff --git a/web-ext/bun.lock b/web-ext/bun.lock new file mode 100644 index 00000000..e6303bed --- /dev/null +++ b/web-ext/bun.lock @@ -0,0 +1,257 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "web-ext", + "dependencies": { + "clsx": "^2.1.1", + "dom-chef": "^5.1.1", + "element-ready": "^7.0.0", + "immer": "^10.1.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "tailwind-merge": "^2.5.4", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "@types/node": "^20.8.9", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.2.24", + "autoprefixer": "^10.4.19", + "chrome-types": "^0.1.231", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@remix-run/router": ["@remix-run/router@1.23.4", "", {}, "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@types/scheduler": ["@types/scheduler@0.16.8", "", {}, "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "autoprefixer": ["autoprefixer@10.6.1", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.26", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.29.1", "", { "dependencies": { "baseline-browser-mapping": "^2.11.25", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.438", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001812", "", {}, "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "chrome-types": ["chrome-types@0.1.450", "", {}, "sha512-ixMJSqLzFkLNd08+Pr59zIBO34XssCn7DhPH5jAOY4ph7lAvDxm38YLpJd7QJXfZwcIIw9zjFlIYzG4MXdZEpw=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "deferred-async-iterator": ["deferred-async-iterator@3.0.0", "", { "dependencies": { "p-defer": "^4.0.0", "yocto-queue": "^1.0.0" } }, "sha512-VEWY9Cwv4LkWHDl7cAwMDisj0Y3n/D9tAJynuuzxA3s14Wg8z69yACbKWt/dBBfvmUbu6wa6ea3W7E6adRHx1A=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "dom-chef": ["dom-chef@5.2.0", "", { "dependencies": { "@types/react": "^17.0.42", "svg-tag-names": "^3.0.1" } }, "sha512-alkh7LDdOQcvsZPTmn8uPA1z7uF7QV4XKnl0KQEne4ashZAX/TprA7Y1XIE2SGq60nfT7qr6DHptWHFeRrCxng=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.439", "", {}, "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q=="], + + "element-ready": ["element-ready@7.0.0", "", { "dependencies": { "deferred-async-iterator": "^3.0.0", "many-keys-map": "^2.0.1", "p-defer": "^4.0.0", "typed-query-selector": "^2.11.0" } }, "sha512-PuyQkIQPxgEmHZA3WmiKBhvnbCUybsIUhW3+7WSglmN746IljgVfPVJOq4PYdj5lh2LSnPDe8aSfb/cXK50mbQ=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "many-keys-map": ["many-keys-map@2.0.1", "", {}, "sha512-DHnZAD4phTbZ+qnJdjoNEVU1NecYoSdbOOoVmTDH46AuxDkEVh3MxTVpXq10GtcTC6mndN9dkv1rNfpjRcLnOw=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "node-releases": ["node-releases@2.0.57", "", {}, "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "p-defer": ["p-defer@4.0.1", "", {}, "sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-router": ["react-router@6.30.6", "", { "dependencies": { "@remix-run/router": "1.23.4" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg=="], + + "react-router-dom": ["react-router-dom@6.30.6", "", { "dependencies": { "@remix-run/router": "1.23.4", "react-router": "6.30.6" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ=="], + + "read-cache": ["read-cache@1.0.2", "", {}, "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "svg-tag-names": ["svg-tag-names@3.0.1", "", {}, "sha512-R5EDS44RQSOwwKIOOUZLKZggMeFL8fDSSZr1hVK5Z0KkyuGVOZfyepBZDkPu/aoL+hSOysxxvAk8uN4yN2LOMQ=="], + + "tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "dom-chef/@types/react": ["@types/react@17.0.93", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", "csstype": "^3.2.2" } }, "sha512-KM4Ty/ZTLZupiYxZVAlP+InNJS3De6uBMdq0ePa6/04+eG9Y7ftnWfst1xTLQ5rwAhgHwQ4momt/O4KepdGBTw=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "tinyglobby/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + } +} diff --git a/web-ext/bun.lockb b/web-ext/bun.lockb deleted file mode 100755 index b52920bb129af04eea00b4ef25b593f901c43464..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 202569 zcmeEvc|2Cz_y3cSDM~UV$q*q!B2$KhiV%emGS4%GC@PUA4KkEyGBi(=G-#k`QjwGf zO){i;Q0TXo=j_it-}}0^9{m2i`_*~g&suxG*IIk+;p}rB_jP5YRHGv!R6YDdRJ}tM zkN1cU;ekszDAY61$3Mth*(Wq2*gZ;lu^JB>gTYwn?5Vg|>FF;)o2+wHXFrD3uU|Fn z`LRnXJMS#!m}$%(>MFgZvI~G+>l>L?k-= z1&stj_UBoGc+Ou;%bFH=51m}n^z(3~W49JM?0gL-`8Xe)U91zK1ghhwMdWL#=yFw?BZvr?5P!kaQ zRRlbYcP-dQd+v}tP$l}gM=JXSx%=9IjCSL}D%N{JW0a%Z!`wZBycrL{KTs2E0kOZq z{=weLULK6gAY*ZG0CD{10%Ck76z>`& z4fCcB$w9t%q^En3yO(!4DA8XbAog=RAo@`T9_DcrAojZel06D=6CmbQACzckDadH| zGRSCW0mw3dI)IXZtuTHV2isU?esArv?!9f&L+0%Ar1ta*#1E{*j>`0p6ZbOZJJ&9KUWYoiM=j#n^h~EcOnDP6#M@EH1 zoDts9kr;Be1~U&%n#}Pn0K|E9jcO0GDA3=_J;EoFxng18^@8yaiE|942;9H#?-!vS=kLGuJ0McIZ%IIWVeW8?GX?t?AK4$o z%$a#A0>p8d2K9(Okq}f=guAN+vwc8hXb2u4yh4MOJ^j3W#>`>zQXnR@cM@cbdoLiy z4Qn(iHq4vRN%eQE6?6R0TQd7S3}jsQEg+8vyatH--6Lp^>-^vDi|uon_2hmS4l<56 z&OZ-W(JBy^^ao4b&<^XbgC6HiJk@@dBQwr&u#NdYLLs#2YtQY> zU`T;JA8;Jt20%eTvL0jIgM$1cqZnjt~`UTBB=&*DYQ`>HdbILH~4J`NDq@d-ej7Xi%eyf5GGL6HnEZ;xnSh&ebCKA`x3 zUF6q8dz>Ga0nu*?Ag(80?~usY5KjiqM@?uiPU-u5pR0m<2HM>NHn5*BVGdwF8UZoi z?h&4T{;+a8Avnx?H6Yff!6d>sGyt(bvakyv-!sBfd5SuNaRl07eNd=Z--P=D_5Z{t z=fBGV%=zda=^hc`9?LidGTI~ak<7oBR2-2}u%idZFvNqIaS8xpdk+nFOdo>-Wb{`V z#GL;RfQR+xD0J`39)qz3+G6}EfLI>^h`fWLOkO<5n1}b23~BB=Ck=<25!~t%@4%!U z1K17-N0Y>SFrWx{7{(FOkhlVH65v8WoKK!mkMqz7WSloiAme&31HLHWOt3!+&|wLK zp#rD~s0;{mpl`l0Kt}s;8|&L=79}$nmLN+58UVgr&R{42wk9zBy;=qLV2}?1Y6Hds zY5?Z1VK9^cXQVOv{Q*!O}Ane8e;k9i>TwiINX2ibrUfExjM08J_T zb5=4KlR*{)c?#gQ6%2+N;4{!;UIIW?1w2g22~_)Io0)N&Zeh;VeV&`zAH&!%{b zGMN3D1~TS}pTfyW%y^#xqW%OR=FcsWnYR>>`^JN6Hw;h_>K{|}Ma!6Z5M0U}&l8kB ziPEp3KQfvUi(8XF!}EniOh)e;JSkC^?t1 zyM#hpK+KCWgx))>lxNOZks4X6`>P0dYO6 z?Pt!Bz@uai9zT9?$T+?m^O zI>kbkheI`HQK9$SO(u;$9J^BsH$S))K3dznrc-_Um>9$FVM=F)y-nr6+x_umSH{r~ zE1Z56$_$ylZA9YqDK3XiUv``sQ!&2h&Z=J}JAaCf4!O^-6Cya;Vs_)Vjb3N+Uw(*H zD|@dh6=ELyq2$3M!?Vk}MtTSKYTV3TG~1I`dX3$~?W0e%jY&Qm*xY;b@I;N_SqmTg z2d$nmrKB$3ux)yC_d=;hs;=^`QY}gMs>X5-Rk#h7oP3F_(hbLAo zR7zW|VBoPvi=V@#F;%YDz&VwBm0_K@dF{2CV%IitKTbY+;99H3!RVt;IySfj`?F7b zVPJaM)AOeMr-&_0erJX+cvN$pt5we2s^-QfRj2iGCHgwtq0_3`Y;wx4b!qT!Rlaw{ zrQu=d4wE6VszbJ9x?Xqxe7SP(hn}i^AuEKf&g(p_c%zrLyGdQD>*CX41;(aq@AI~2 z%bvH~QusMge`{*^y{4<~m!v9ZW{G_vvQd6ewb=KdFMjV%zO&VcQYh#?arIvKhJ?_p z+XBl{=VZ=(x2w?UTKvMuHM4q8?vsu04!Yc9=^W#BO0=@6wzl_WyPmsywYnAel+B)T z7jyFy?(6w!3=98i9eHvyucN348>1p&`xr5;5i*k&Sk`+xtc&8C6rX;i%=fcmd6^IQ zX(?efvG5siK6o?BBN*|`c_ z*!gLPv4}zZj`+1Z*V}eWmTT>8nOJzZx7byGYRreRc1Jb_?r@X)arxf%Q@jluCx3im z;L3Kk?j6_D9G$5=z55^bcEzmx@M6MFvCT=A#ox}2UhC*H&)#WfblrsYPv&enk-J?k zMub;#%#(`ojx~`&LC$&y+SN?_M+xn>G>nYYoxuC~o!yI|9KCFB*9lVu#I|m_GI8?0 z+v0Dczjaq=bzS$i@mhJ*`?jKvb=jVP#nG|7&Jwc>*K)5he^$FayJk#y@M&J@9uKMI zr55iMT=rhmTexW4t^lo)t6g!wI=*{6b{oZ|akJ;9^A)r2FZf^k^N%U9iJTcfYL|y= zbD_P>bu0G~C$BchOdFOQo3i?g%iMK6ku{1*4oV^gB`!XCX1;aTjtO2G?v^&nOXkql zxyypiHXS>0@J8^4Z+ominiWlQcsNh6t-)yY(=6Q!TnRj_4ibj>>wesgywF>3JB^#H z7w%u1@)kuk51AGh+LkYyB|5w5qxs$sYVV7;-_zBrvNsi*JaWin$;HCv->ZW--tC!x zEJkL=4dv#q->mz-ty$XV!qtEOWPSCUfwo`^ev#}~R; zOUg=Ly1zDNtbc8Ds_RFMAAIK=GLE^9og%0`p;k86@qkR*@Z`m3)k1G)ZxTt+oX%Uq zIC$gIx+xZ~zm~347`AUszKo*PZi9GEm+kuN_JyQH1e|j*Z>`$7x_m}a4}ZauRX(p( ztfMt~a+@En8uns}c=7V5hg(V*R+mo9zkk+adwcMe?F(9LuG=l@`1K>*{cDBL1I{y- zTO{>ARUE4sq3OrIKz;sA&i$2&sK?@ zn|;!W-wTtrKh(1mHDCPP{GOWCdKUp%)A@!OlNR3Ov#fI}d8d#vOkt2Im4vVz4L9!X=eytgYev0>B-?XWo6gQK#|xbtJD$9q=wMT4>GH9tAIH_KFTOP4tf+40EiTV7aC(2%2H z(k2fNhE6^i)akji!^_F;@w5sPE7eQ8#cD1Kd4$XA8}SNg-&wG~cJ}AISAVkOST)tQ z&AhAQ?{>rl1%u|x2-Kao^}o#4doP{L>oq&>JFwgHRa{u%b>cvdg#%xubc0Q%jkIGx zi^iqL6(@F!Iu<3Qm)4A){mOp$X4w}PmS=T0Pj_<5n~*q3DcRe@Pt&bHb<*36$E~kZ zn^ZEgzJ_dDA;eQ4V_x#>*^`CO>@I}MKk8U+F7$eC+)zuq>{GX2B&p32Uijee`}>An z$L)F&ZrlA^zc)0l73kV?kc~~Cv)=yFld31B2LwEA_vMrQ;egbF-9M6l$+q*27vo(q zK|N>gosUkpw(+}uI4G$JAmk9Y)7kQ=f(e;dXeutaNJES5fjcK#3s(#>I5b!paTS&os z{jC=xwf2^C9buO`xHG(?jKN;~Juc|W1`9Vbe=D!Il`M0c6VB0+F*PLXFyG(jYxVkg zei63%n2|YcBBD?0d`It_bgBJ$ZQYd}jko0E7yrC=)HpjU#+`@kFH^K8?%!XkwzuPu zrTZp>KwqJ?51-rJsNDDY^F0nz^=Y5P*J<=hoVFAv@$Z+M=li2!rK(#rr)o2Yq4uqp z=fbi!e0i$7eTZX}*@G!gs`Ub`Lmkt$lyhYKDsgXJ5H{a9$6UKETxQR!6bA|ZlU?H; zrYG(^tr5RI>tp=!C+!Ek_wBr!S6Vf8RBvvpfGAmyA2W~M9}_S2k<3rWxV`7TjDB)h z;6U%e=p0#o;Zd*WOiZ11aYALmJbCc|_US?g{Kp)zU;1r_K=bO;HS1j)->X>Vk6AP3 zd+^(03+Xi7vbFPnj`liUBm84DkHCj|V{1EW%k^8%3(PQkB%uFRyFFm(YHr=BdF{65 z2JJ6x+f~k=Rxwtecz44}hDP(4nyS4guhf?`Y@8LkY>R1Qq2f)3&-p8F)5DwE-{j=X zQ@uQA=JOy^#fzuP7LBpi+F{RoQ#ZoHRbTYL616c#C2kM4*!A~5H^QiDNrKf$p0CPB z6X(8uyd*(gMe@?9q0%!eTr-!BR*HBUaOeHY1f^$AlRu4h3+G&P{Vm6W>2epQmfwBt zQ51PHWkaJvwQ^{=-NAF5C5~e*Es~nMYv)V8Q$6L1x&{&k7I_uV7whNhSk*1wp_8L< zC3?u;`Q-d}UC&G}bLl?oQBM*s-1F2CEKS*U+B7t^fXqiS|12_AH{BdkUs=9zvt!~@ zt|8u0JC{Ey`lU6}&Y_?{O62L7mL9V}*XvtiZ(i*XUTTxL^1i*?i)fRP(hJ7~ZO@mN zJX>Ao-R!GoD16%Xko18@xzzH4yP`|(Od{*IhEw63g<8_*r5(OEQw7_l$$4?l^{m*u zfV=*qeMdZ*t7v}8rqR7Z>xO1w!r6I=dKXh3w_JO&Ey7M}0zdb&dE+|EUG{ZKwQf(_ z_wK%x>jkl!Aq5MktXxx)^{e)*&XB8jy&Jn`aUXq~RIyJs@LQ>d!AIkiZ*w-joBUG7 zuVitfTJ7SbW1$K+IKPzSCTAW`rL*Bf zb&02m%9U%+%a)$`;!z-E-KEpHr%^iQspjYRT_a`R1?r91c{W%}zVqlNn>p+wE4{`p z3o1J9FeCMxepr)#wi*B14i)Z^b9rYPR9CW9AD=FUB9&hRDHXh@n@09LY>ape2 z1o3BDN@?b&Jk#RVPIL5G!lf;KFUwvffd2_II8-&qm9 z`Q5o2FM|C%CYU}CQD66%@4!?)nVhbwyK@dbDPw22F<#Yrf6m|B_Nlx1m-o)q=i_#l zJ5S$yF@4WZ9!1B6BX25~`Di!~(-~q?cs|zWAcd+BgCn-fl`A zI^uC9^hOEWkM!qT{Ya~3;>dd4 zi!a9IAGTOpzma>UW#-q_Rfq_t;m)96=<=1M)*;;`iddro!RzvX{=D=QcjIN zJArN1u;3WQXX5b{S3-s`23Oc9x)m6j4bSbIAjA7D2CH@n|0EFffG-H#p#$KvgCTtX zG*G@4@Qr|vakIn4Y9aBb!xvrP_lJu<2)_otVE<7kLer!CyF&OJkhJN*A87u~fNunR z>_7aj(eLqF4SdW$yc_J}q8(NWiN6f^#=sxO3W?T+@TWlHZGew5yubWkUTIDL@hjo4 z0zSNJO(f&j-}uh}5#uNJ`pfSIKKxXY2x;h7A^u!AnD2$rFS%GTMELtCK0qIz? z2!At7JS~VH`3or z;{OrwO@NP_f%uo?W-#UfAM=NO$92R?A^uMSAATR|$)8UHlkn}L54JF8A=U_je!f1l|uN(fRE=#l=13s{#t-f&M&z3h}}QMK-x)-V*dV5 ze9QcaNYxX*EAa9BhH=9>X1Nf42Jnr6kK@nk+(##b-vE3MDt}}Ru@VH~>%hR{{)60s z_zwoY5%9^{>F@Ye03Xjk1MPn|@X7PzK;oYY2R8JN@ehO_0esxQu-|Bd)pbnre;WAs z{Eu8%$}AVcA0o_P%%S`vxxfA`fj=Gi$iwI7{*K=h;LoT0v&zLdN&KeZ6!TBG1hLKU z3gIUKANNljJLD4m@3>e;+LZx+2JnfDdmt-?wC@JKEfqiJP>sb&MEF+l(q|^E|8J_R zSxN!nX8_-x;^W?p^N5u~`0c>AqWE}zVaYkPBm6Qra2rv4eD>?_`KJr`CWGM5gqzM( z;A8GFCK4BX{cR!b5`a(E??B?O1wNU-#O7}uQwxr$Mf$%`Eui!&#wdJhYXTm3VgEv45a^D z@Y2I|5dHT7z8=NLsomfGXBY5s|HEw)&+z0NkG6hS$olyLe0|{K`CA(5tZW|fN$2!A>7asA`|gQdUwXTx~r`H$Q-`pXxSV(x!r-z9eXg89t{iQgLdxc?EE z^y4?wzADl_1Ne~lzVX9~{>Fck;uCxQ<#R|g|8ADdJ7R~`_%(r#_YZ83bBNVK{455( z7Vt4{VYpbeLHI9$??drf^^NTa-(2Re`w!~-yM9&!-xU0#Jyy>?=!f{f4tx!YPuJu3 z-xb2=lV#3-%pYDv|2r<$k#<(V$MZkVdt&2HTsr>%@QrAER=9NjSKu2^d{()1zR?7x ze=>hrwL$uy2z>har@#DC8Xxn=Y9apFHVp%SPe+2kAf02)c z)f^DM@Wj9Rk9?xX@4qXg-K@Xyf5)NM5q=!-&8Ym->;Lqf@NWX&82GIAo7g3M0foQr ze*@WnW&$72Pvq=D<^l2ZZ%F*Fp!h_F*C8ww(*7Lq4QcsfbsZ9ZH}LWNMP!113WBuL zQDnaVKzShfTLFAi@Q?m5b~2Aq{#_yd?*O0d-?)dcx{nclnG*B)n-jRKjvdBD_!5); zdj6&B@%!%z;RgaA@4o|`zj?sN=YP^~V)u8xv5vHR3w+#vxIl+CU^`){5dLK4zwZA; z$BF^cHVOEo|CodRj(-X8>E{Px_fIj9cJF|%2K`4F``_R7BdPM&`*ULRPrk99v|R*z z6Y!7o4-2bvknr~cAJ-55Zo(=T{Sf{z)&G0`#5z_A;m-g*$zOlwFyTi6AD`bb_e9qp z|CfP}`Rk8wv`>5ugW$>j!HQqfhVV@(J{dovC++?XNxPN6C+9zG%W6Le|1$9L`4#1X z?7zP#KIR__t8<9>pEa4e{)yaQej4!U&)@xB|F?mU>mU7N?)vMW8xDVD|H1zEH~%w% zkMU#N1Ihm;;N$!y`%Zu3e*%2$KdX7eK4Kx`B|7D=^^ZED?`!m%57KTv@JaqzjUCMr z{sxMVdB=u(Eb=LCE_e-SR;M_DO^ ze-QZ4g+4z1e$?Oip8_BE55gns_)jqq|D!ekdVhd6$UJ0~?*n`zTL1sVBlX083Gng! zPx9B__4gC_gE{}|Lh{J_FS3V|7=MQ({zQt8GMU4_YTKRrnQ?}3l|2ihZZfcW?w z691-f_%o;YtmYo=68=Wull-B7R&5Y|C-Cw7i98uJ%wi#Yb8v10e59fO;rNAxLipQ( z4=GF}>xR`iK=^ln4@c;}{P$-MCj9a6#~Zl+VC<~=#_kb*0Prz>%pYE?&H=*Dr};-d ztA+5N0N)t=WB$;0fBlb#n+MK6$IzJfF(npZZL%Cvl3v;RWyC+*Du>%}0^fudKe0oP^`E|zcHgP^F?X!a9kfUI*6{Mf82qCS$Dh?g_?v)_`6KszqW_Z} z(#{SRKl1ytcc5*;&jCK9V8G9h&nP|__x_IGSXewp;Ge8LR_8E@e;M%YfzK+B(86B5241dn`-zdtb$z6nk zKa$h`@AC`Ru~LYCXNphmU984H_?f^rg8mQm{QU~}Ccwv-iB0_er$X8)7&FHo_dnDT zJ*#|QijVzAZh!l~5BRenew=#);eV(6e4Upjxu5bS*bBL8Vi2q*Th)44dMHmGS@%m53l~_e-DjMY@+=?71H)S z@bUaa#-8X|^^|ytzs|P;zpN#)+NwiAB7lX|U$B)%{Lrf694e;jzpNt#E!Ac?gUBJir zkN#0d?EJ|F;XehwG36iE5Uc$le5u+0_x{~qejxD4^CzpZW8aAXUBJiu6PZ;TqD)oABEyKFWBpYJ>16 zSTO(l3w(AUI{5m>LfQrZ-w5I-a)15r2R@EJ-aA<3VsOOYbBd4a4*9IwAbj~b%<&_0 zr@wrE;N$ZzzWc-idjU&@_^$*$+(P<(|H3_#k0thB{pYb{#*efSaB=mMKL_~a{R{S; z_g`t8)+IBL1HPAO0oc`t464{K+t3ji~sEjuktkZ9K&vX#URv zUlsi0yv6y;Y9ao6fe%BNNMwS4vP0U`>!OtJcT3R|K@Lh z$KMzDIDc8?kq(jg_W_@Nej$3&?%$BKdkXxSlz)tY)qRZc)$Ex2Kk>tAKL|ev`1JE9 ztMiEP_fmY~7tb-Q6vA%;z6LG-ME@rnq}>R3`H1re{j(Z7+9rG>ich$tAFTQh13s=_ zw8tu!?teG%O$NdLJV?Ho1Jgf=o#chp{-*(-?EkEC>HWV3d>sFQo_|L#_$z;Cm-K_x z_#J_d{z>d)90nmkoSl;A30Ny)=shK=_YyOtFT8({{A*l{~o}{`wtmE7SsQ~@!tXbslX>^m;Una0-vm3VxvF#=X7B(77ZeQ zFM%I22)->09_DYL`7Z-LEa3t3N4hcp`*AWKS;-x?A?;m&KbZM@5%{o#1{}XQ44%;- z_*;MvQ)s~c-vNIx`Jd@NxcGMff7&48e*k>rLGWcf{yKl)9zf;=eEn@9?d*WB4gT@m z$?CfoG)(yGflr=aaQ$NptQ5k(0Q^bdA7$Koi2hGDNV|664`%#lc>eYG4;VkIh4^0s z{5e$ota7m(;nxEn&kqCLe@4OLhd*IR#N5$~hj)~_3JlDeT&WLlzRWhF*Wq{Ke=hLJ z`4x4n=78{50N(`sv;W<}A7sLR0DQdvVE_A52%q1Z$;a~}tFfaW!Z!gv=AYGhOJXGa z6%-%iCpyyRcSzb+03X*MtItiuCgFbvKIU(r`B(8_&fosz4r3tx(|~Ub{&_%$F^s}W z77F2i0=^mWkus3=uj9)cKlF`#V09i5|4V>R&p**)JijZX-FM*Q{!iu)(f^K%b)=mM zygbGJW9*zXJ+}E>A^a@hn*bkk*Pk&Y{3hVz`x6pBt2PK<&7Z+AqVfN}5B*yo2tNV% zqdf5;_v{}cvk=Lmd!|48Ki`Y#4P=ATs_iHrDq3Vb6P zzrXWWGKl&92=m^b7>Ivs;M1SKa1FCk2!9RmasR-$(SLvUuUo*!zrRL%f~+{E+7SP8 z!OZbT8P^ce|H%|-=LLKtD*pq?-(KLWP<+f?fAe=2_?SQ9n;@(4^TWd&PWje(DIht=58HsR}s zGV@3HtlA*_CBWCD<*&cvUjlsm{Tas(FWkdfDa3yl6~7RW_~7Dag$!*7e@Ynh{)PJo z<{r;MtQ5lc1->oBkLz9(uKuon&2adCqYi>!2z>iN@OyyoGYEbS1iyF?{AZC2#_B=v zeWDnQxIyrr0UwsofXB}&dT{R#4+4KM?{7FE`HKb-zYp*SGk;G5AJ3lyo&U*87>s3u z;17*qFyaTnUk-e)LGWJye=zgkF_wA$8)*IxB{LXj2NAzNEWVwC;15e-FklG{c>I?F zAC3TheB6Wkd;fU@{2hbvzbX~}pV5Qh3$I}?(gwl54t$tG1J0i{I5z@5-n%e&C;lSt(L^3B#U_uql~KM8!?KL+Z5+Ir^s8RO=Li`5(u ze{sN92mi=t_3Vai3I8DQ4Jkg(e^v|Ow*%i3_;_w1XDC)|5Wdxhzs}zSd45O({$S?6 z&_)Jh6~vGGANB!bV5N}wvw#mvc);std^&@XMDYi*|Lg+(V9qb{o4`5cAJ49=&LPtO zLf{X^zcOroB>$q|AIE`}Li{%Xe=z5#MOy~<{{J-a;T1yP_~ZEXcm5b|WiWP8eEPd1 z8PJaf{kZLoi=PRY6#P9Wr2o<_VLFNdB@i26z8a+%~xLQ_&##a+wVLU&jA0|NXsx z=K_B)=RfhR!JYpyfe%OU0msjmJ-FwO)xeJ##Qf<6{$S3ZQ8|O-w*em>!3Uf_$L)iA z{=5ME!Sr7f4v+8%-xvQt_TPQLAB=zT+`*lnHx80N6b}D`IlnIfKH2{Va{qn>{D?u! ze}}xm@vDJP#(yCDw@&`x_{D?di^0u{#4iXpDNeXpDdhgO2KZ$B2Gaj;gXDYfW-xpQ zv47kE{$S$Q-7`3T(IEN4dl`(NLCpUZgXDJ(lJB%{aPe0SlCKUYvtTNJd@%8cvoiUi z4VnLWzz-Ni{<#YW$B!H&|1t3K`EMBXX&}!pMn!{*e?N^sko%`lG4sDa!uSWW|7-&O zJm^2r`~Kf^WL#1HT_Np00^eW|<3D5nU;qC+?EjG8Ii=MR|C@pDHwgbf2Fdq0Fu3#A zHQ?j=!LH%Ghx7~O-xbm>{2+7vV%vf6-vZwZ{IgTea1F3hi2o^vnE(H$fzH1S8lMLo zlQ{kq1M&Y2_=_O^f%4>>;g$w<7z=iF+;KK4d#CAS#A;MnQSLlfShIcr91rhrRXQDm~?$h?|5qFtdFPae}~xLWmG#vJWaB;u|x6!_Wh{7FH1LeTED9 z-{Hb|yWqm|JH&QB;6nTf7Zyac*MkBSM3jG_00j}-;|Jmp3fcOU(Dok?=h-kykBDEn z-~;XQP%5B6CkRbDLo?ka{LVP% zsvZ%)R!}k``nyEQbi_PXfnETx9uVz4q}tIDRgWk=9kKt-pvS7`@PXs{ifTtkRK11| zjN=U;j_*f6)PJJ%UjUK+9hDSfeBG2xM^yDtdPKDQi^3r&KtaT>Ln)b#sN#SRG{Fgo zG8cYOhodIzkcmraUFNOYqXg7euKtSY&QuW~!Mgn4f;sCLq36y?0 zAo@+Fr`dLclJOO(Q~RQ)(W^dkuf{}|GgUY3&O zDO9BNlPFZ7^ph!h3Wb`KUYkNaK#a?XsyC+41Q6qyMakxrY(>d)DYONIe+)ZHb^t_s z3n{%DrFW-fFG}{IWPeHyq~s7v4x=!F!YB$C1HwN>EF~`m#5k8zauS6rDNLp?1rYu* z(kNU{={HjHW=h^l$(ewd_bdvt0kI(B*Bun*0pj@XqHqr&&Z}ZTEQnZtfWkw7+#r_% z!aqhCg=ZB2$$l)Y{f{5$$zvpY_y87?=x^G(aJ$L{2 ze9e5`#-;M#^EGo`{P%p#%)@`r*UbCrf6v#<=cWIiubK1Vzvt_Jd%o@!=%vnF%pK(a z!zb7q!eH8^n`WQqW?m9kwELibdr8=xVe+poA33zlWWz=)GtWb^Hy$|EZh7%#L%i)Z zFZYA<&n{e2FU$T`ZuZn**CI#G%6;R`_u_96#4bL2kiz~tMBZrsvO`;)@7=vAnP)a7 zG2BXWaf{o>jN_>xndMfSj>YGl+uWO^VtjF7>Wkyso`pmVnOZ#S(NddS%`JDU>ttwl z@tK4a_KT{!*w}27C3sd@+?M5edislHL-VKmk44*-d8!;s4>9|G=Cr`5o{{I?m84fh zU-%)M7$ev0G1lQtxk$h~d&5$Ew@>25XH`Dt`BED+-dlv5D)Kz0+2tTc0ohIO z&R^!h{4j&7>tw6JjXKzy2=f2!^w0la&!yC3mHf@ikoF==R`E(-W(a+Mvs}0|0 z9r|oJUd?yM=-k>#+;al&6~v6*7R1SYucs$_d5P2I;_F#AX?F3QCn@aunw1MZN((2n zN^1H&Ihi1Ny7>A0joz6WLE&Xw>^>V0yIC6S7cU>RlY4ucgJ}5z$)~E_N~1RIQH{vU z^BThs63-%kbAOIjnq7Q%MGE@~Pl3aJZ_Z{;oV@Gp)!ila z!sYq;I+}({FWa)U=+4<>kXWkwQ}WUaKdt!t(vxk{<|xS8Xk0RRXL+M;L12%TvLwwe zo?%I0H|R)DUnNjAL{1`I%gHZ|Q@kZ;f_lg$q2%D+w_f?%JeR)MJz)cfo4&2pft3#y z8+fO9+}-j1$%nmpq2EN5a^-?)c6o_WK=!s$>#mEZD!xy>Y#(sJ+gSI?{%zA&Z99Hf ztXa0^xX@Sks_&V#Ue-UhTE3~~QMtYTprxwshtDDx*oXMrooH=pO`zH3quW(KZ_b;U zcb2Wp&OCUL@x;A7&H+a8uPsv@7jNPufhFp!))5q*0@3PH0+d0p~9=hLQl@mSkLmka7 z{?e20YuKv@~V(tQhX&Q-;5Y$pQZpC(P8zU_EqsM0A>i}f5=1CH2U?pS^IHn(6_$`Pwj0g;Yz8zYaE8&(UA z7JKv7??rrv!5q0dl~GwVyZATiq_B5STQ{QZa^mr{@xhWgKO=vp%y;XlTD|W5ijds~ zc|2y~EnP1qzrEBG3$oLw*81^mqvJP+MJnu97A;Iqek?TeY6HzK{H@mi3i~IXi(J?3 zRu_sGdL1rwP&S`6rmbb_FbOW{kH@XgZV{G0@RH-|?p3K>eCbBK^FlAIs!e9&I=DsH zyKQli%69MjV`z4T{$~~U$>XuTI>}-8jKefz_cUzTAz0nLbc&;kz`Mmw-yOc^se<%O$qMb6Re(*IjC_HCwL#%8>~c2(hsK$pAO z!iTpmA5NDZD^b$pw(9<|x_nx&AXg?ujBij)ja3sw>*k+k>_nmSn6pyGUnmcK!N2VT-GBR16of# zJJG4G-@UQLxsBJp_W7CUkzek=+M4o>W>{YI?hPdwYR=MP22yY_=7IZt|;B^ z>5+9!=bCO^Qg@HY{B-s4!2s3Dx)r-7uQc7c*?p<)Ou@!^lb^l(_2bRbxHHKU#=SG~ zNm-T_-K@C2&6rQb#_%J3K8Vrn+G>v}Xx)}^LMmN!igl@MY2{Z|yB-pc z9NM+Su(`I&v$eH)|FkdOXG$|Oa*Efq+np8G@l+QHkLjevJBDs|)T>(Vv4X(`yLrSz z+pJbz8fFkGnRMbn=fq7{GqWdNeea}j+QUsZU61W_*xS-;v&{-USL93^oveR#yQum! z|7!X^DNeULCc`W1wyH>At?jI@DqnpBmd&nwZZ&BV_m@i9Gkkj=F5MN<)0?%uRe81Y zPwTC-ZkYT??Om$+N_KX$FMrq7PwT>I@s6e2EvYfPwlOkf(!I~y>f2Vczc^pt=Jm?; z=kq)IoR4K@Ewaoh2(5Z8o)qYo_Nm@1ZtbI$Z$5@8K3(lnAuv)&_r)1}w?_5{3A)|- z&GWopKiltU_3qKm#9!hW_<*Kdw`P@vOA|Kr`=?{_S^wN8Q8as1ay2VRIS z8n4aACKj~WeaVA8;!(9Lyym!_RX)E|VCeQH&Xap;6Zf@w;NOCfcqNHZK=v4y16O22 zwi;(jXn61+{n*-e!#!aAX#+xWPBzN(;d z*XgKQW9WPTIGWw@bi2WVf{D^HhQWpg& z#8F~Z`eog9O%uB}b>ru0=5?)3KWE&P=J-%SiDp-dZdXRV+b3!0cp>{yI>zCmR(?vw zo-(mXcX_(_^MV|#`T0|dm+dJqR?a(GDV=dnnXf@S|LNBXz3=bS zcjh%-)2XgCxmq7?u6u5hHiIuLCKZ(5th28Ne#)~E3WT(X*z0|r9?I(q`-AS z!xGLNt@~bi9OWtI*HZYk_~&}{ErV!O_qA)MZdqv((RsGvn2IZ?=$JG zP90`z>t$EVpIKIuS*&V0%ExKVEoF_lU$wkO_(!fAU7n`2N{%n=q-n|M0|XJlV72|RjO{gubZ zdqESo=jP7pkgy0CKjpK~^sUxr>O&0U_AROHeK>(bvO~*byXu`|rTpCi_4NA?`JEEY zv%lWCZO-GN(UUWbtuoeMlqq+cm9(ofx_Dti!R2=oXNW!14Zb3ubzw!px3zLdzODZD zeu;h2^4;G)?K!++PMm4?Iw@Mb6Y248TvD&Xdv8x}c|d&DwJobHS2&Bex8!oFj+$MT z<3F}u{e0dCg#x3sYAZhb@019!GoG5Xd9~R2Et9pq=j`QNIUfHOhRg>Ay4}v>TdymB zo3U`mx0<`L4pPIGecSUZ2`IQg9^)33&VTif_aMCst?6IXXA z-gY~vv{tU`F(=I~`CYaz59ih{ovT0pi=S@8@=2@8Ep)#bmA@N$RQ35*XOsC$hW+Hr z4Og@r70gJG$t~+$w`ru)s`I9vC0}+~wJ6w|mi#y?OtY&*kGHb)l4Pc_yVbn+r`T7E z@9Kz%e;?iH9CuJ=vBr(prV3w8Bx>waqYdT9zHASjezT{S#_z@F3@kOTS~@*G4xt(q0ABL z0g@wr=3I#vuvn2(>~%>kx z<@Wnud_uM7hsqfXjJfzQTTJM~iDHhex3=y)^UC6U=;+BcN}8*WaPH@b6g(Tu_uaUv z<#{su$kx*p>Q5t;ILFfRph}Oo^XINl8L{EDZ{oX;<>kpA{C;useZRZ!!tVibo5ugH5UO0HC-*;S+4RnK78@+?|hKc(tp zdgi$ub6xMu*d#sf;t}&ov%7NVBDXp(@c7ELfvv;b+M;q4dz$yog^j6?GFlEA2)_uw zzBHndW_L2(?kELr>(82s8uud}`X?riPmnoza$kt|tmed;FP-LF&Uj9EGwfZHv0%n4 zz2b~1yTU5x47tarGF&(5g70W8!LB*_G`s3_yZ+_Bidt0kyR>XZJr&T&o@ZN=(=lz6 z;mI9I?-Y;c3!jx%PaaI) zTxNYXo?9pQuJTj<8k$`Vx?Q=s>-1W7MH%z#w+Y{}ThcyJ$lhAK!g!E^2F@=-X(qvZ{RL!{<4b1&ch+hR=U9bYq7| zRN!%u2#2K%(aL(;fH{jg45H3hmDB8M(d~LoH3;e|yQxvxm1ca9Lx0C1p~S>2ef<$7 z`j+1Aq0euhpVK%t$$raS{`1Wehhw$m{9wQeEewV$s(k%V>GfrQ5wK6;?CmP4}jW%XjTs z>UZKngUD|EgWFaHIbCQtYBEFVhx9c0hf~F3wHsQ0Xh;m%I(LRK_kyOY-_*rK$~?X~ zO5xvBlKX`o-EKCYL1Wj5Ahu64N}?vO=PGV&&}i9mvRCQuyoB3F;Xf70Tv3?w_UQAq z!3>Y%Lk>DDmz3ap&@!b}Il4|RdDBesJ2bn~=yo48g$21a`x+O=Z5w{)vh$v1%houa z4xf|^u0>bB&+>822sEpFv30I?ht#BXXEPX&PEX8Ww zTBqX z@K3pV_xswg{cpEE|8|PMG}rrR?A$N2@A3{;4-7f}qoh7VD^GaOKCKv64!6|e*98up zZDTa<4Y#4p;pOiS(d^Ek+qIneCf>Gw=P$K(pxHH{+Z|bFI=q#-t7Z$;ozd)AbI+!@@zgrAbffVPZaf^{Fe+lj>7uZmAFdfm`?P9XH0Gty z-=~_=?FK}C@vS+!+U#}t2@b=oEvGt0D8#7U?EPddwJ*HsVW@1**Kbj-k9!&|mF$Ch z3RdQD>Z^?1e7t7k@-x{Q~Gr z-B*RuJkLNqPg=aQ=ypf!>T=6b2|mBk5 zd6s@W!uR7rld3y^?S-LBQgUf_E$DWu6u8!&nkp3i@x}UX539~Pb$rnOg>|} zF;%Yo_$K9*Cm2m{FPdsdl`EdDIdS@K=?`uju|=zjb2Jx7e;h)yJBM!feEri$?6ptZ zi@$6i|FNpH+AqCO>_3Bxnv&6h^dx_&cdJ?=- zFHUw2N%>_$vujDWyS?Wc@9q))v|W6qvAB((K0XS=QAt1-KouO9P6!zz3;BHI@tSQdg3!_ zM(r2X<)}232t_Y?9<1qh zC*4wLye=^H>+_BKG?%-DwtB_3a>h@%`0Ozox7GZkHyq@y?%S**y2HaDwS7rXwWZfc zi@?31dpFEVWt(|qTzbP{S{~-o?G9fqugJ4QoOkS<=eEUbV>5?-=UZmFHTHFxchXFi zhT4WPViIDd9Xm%(xMISK{klWiom9G7qi^of z(=*0BY|T@jooT5;M|R%*QMA@6rDeB&_SO?4&M#S4eDkKfME1|w*FwUbXm;n* z?VdB1^1alUpPa&(>M`Q=2m6p+6+K$dr^!V&svOvqE3xVGCF%M}E1T5h{pBa0=5<*( zZq?LRlV=$ldil>CYPam>6PjH+y4^T6nXe*uyn|&7-`WEPX zHk|NTH@j|pkN4Y44(TSx?>=jP$}ir1Xia@ZLiwW2H>%f8PNCVgr`xU5I~wdB6@AyL=;yQ!Q-_0Tt}%Rt$I{k~6_R;;IyQ!*B>;#RjT;OGviL&r)sZbS<}P1cd(1^UK?4y z{h|@~(HjeNW%lwMS+}O?U?}&U@S5sj_4IkRfNob#AYo%qzPMk`?HG@Z0y-TW3*yXN z8^=%DRI_ivYKINlWsXX|x*2`}kDpEd{A z>iFDIchoocy0O94*+F@;5C5a<{UW+SgGy6!yr*03|@FV$@|wh1h`IzPO|?fR?UuQEgSMw~S|-h4HPOMmU= zms6Ez*5~xBaU61d;sup9yV_{B!)ve8?7GnHPMN}yA$_sv&f=tt_w}Zioc;A+hWxuD z!cjqPhdYDnqH~vDIm~!o)A=Lrp|63*6Dh7Oj?v{4clc?}8@5V3Xg0%xX4jQ&*If9> zYHPhjr3H=O9fn_Dn4tXRjYGJT(Xsmz#J)5Og^X*rDt5HCzILOPO~*MZeqxjG>ja^% zl4TwD*w^HZ9e+`bX4j2w_vg9>g{Gypk>~GEUH{x=pTykWEa#SttpdM_J^eDAo8>$z zR_wHmyZ72<{3gRGKbA=;rk~%i<;a_NHb26`rrXI!f^ zm`$_mPPhAO_kOqL44a1=Pk(y+;Oq>6u?=s=i?7Sv#Tc5l^R)LF>N6^O?_RfSPhz854zpxDmG-Q(q4z;t*XL2y8j)~AL8W8*mW>~3r;qLOH(|GF8{w6(-LX2@#QavF!kXqY zqN5J-ewo_-C|1ejbM|+Y$fCLf76L0G%f)M_hV984jk^-Gv-xzVCjTqLwHm4Vt?{qY*D7= z*N1L5^nl6;!?Is-_x2gNh%ZptyCz=kgE6B*V|u*YHudJS6L`Wc9*;^D-l7^=(7Yl% z{MHEr?r%e@Gy~3P@Ar|J_AQxa*OzX$x7(k4iOZVh!^J*q==FNIoU5&Qm(#Cv{^yE! z7h0c};?}wm`v0i7%do7PXkoy_Ln_@VAV@bzcc*lN5+WT6N{6&`cXuNorId7ccXx+0 z-^+DA&NXNK!CyDa*_+w3*P7YHRy5}x)B}QbYk2DY`ZA0EwEE6>Srg}TGUSZh0oM$4 zGjh}T87y)a{bGK1YO1S^kgwkM_wu zSnf+sVdqW|=U#sxI6s(!Zq@>Bszaj(f{9lEslZ_*@eHRHnqbn}>`r^OGs(np>F?rU zk;&@z1rqap*YhEb&(K2*JGw{5Y;4*Of5uxF$AEk-K$n5-i-0l&{oJE3oc*622gU-M zelsl;vA<6E`i4J=5wh0Yy>G=HWku5Np=vq!kbpV@%!9x=Y# z*V(WnxG~Jh8-^EA#AJUO2vDx3J-iiucO9_#b#w7E^%~24n`@@CVssSnw|>?9La-rg z1Rg_Ojl@$4{JXFPU1&0g!_|WnKF;h+gpeZ5B`?_z97QRb#qR7u%!Ws7nXbe2CwV;V zFJB?8{lL0Ru7he@7elxUGbkB+G4Rb&nF6?OcA(2!qx0UF=%!LM$MXg`rl@vaLL=Ja zmW0#b%h#Zz58Dr(OL(~qNO;;xu(|UWWOk1l_3vBFkmgY8}kA*@l;rSI)=JMh7m*wn9klEaWI&gj@b;aS;54By53|;NhTmRm6Mz z8s#!8#7gTE;C=>O67)0ctn3%dsM_i$(C<#Ryut`pnJ+dUwrOEykWmyD@p>!}-`s}( zbgJq+>L|Bv585I6mGUM)g_z8C2X_T8Zn;(64{0p{)XLA8U>(K*bS>T~^~Lao z`d{iIkoHBIH=+EfTJrj&{W!99i_*V%iy zwT{v1_tRPB=X#$b=+@TRT}BD&+*WYJ9!IB4kfvl(lq}EYY(%LjddkGMn~m0uQU%l^HLn^box)I)%cEfUnxa9&u|R7uVgW34-;x-k@taozk%-KBY%Q= z6!j~rc7|gIC}{bpOLi*nDcdyiu>O~gQ3ds0k*hJ6CCZJKQ9az7^XhMMFkaSa&PTw| zVdP3G@$2pZt`q1=1r0%~wWtcaqj`7|DjZTh-s<}gf1x9n&bNid#E_mQ$2A~5(o)Yb zZ{>V>Z*Lmkeh@e%8vWv6jdkuQqlEYAn*z_{`*i;9IUvpie}8ct5JZ{_GmyWbLj8?3 ztc^24>$Nq~81D3Kin)9yICYfaWedrLp}J8G%%AV~hUc3i>75wTg6AD0RKtUS`*eQq zIUt|^5wbP)7CIWPx>XTyS}5)`c_9YRmDz7{;tG596+fPFn@-+9;_jHkwCW9vZ}u;c zsa!J%7&?Y*M&U#n%B}(T>D=FQK!VVRD09)&#KaCphWL8dt#*d;$xgjnI)0h$I2()l zFyjhYOnt%WJK>|0Sf|?FZJoAxP)eHpK438wY2!H936Ag6IlRX#vy+5i`2yNd{58=B1e`pN#!3_a&!tOCCC1+B8jAQ=MOkz5 z^%e*8ua94)lcR{&k;A_>m#v~C1^1CYonw3sNQ9f^+*@;_Dbtb#3sl~{_m_^PBql^H zCpSp6^vZS4&gBOEZ|oEd6+UPf%5RkBu}s}1=I1e?gxwm=&2dSYz6A31d`AEK2{}4S z#U;|~a=%_&)+ugjlk%}?$16o(`TY?#Gm!%^#hzrRx>$eOw~wWqNO5M(58t6WQ(Hd1 zHd?5c_|8R&2zL08kcP9E#vDdR>sUQ{&KO!;&?k28N`^^WjmCC`N5{2OuQdVEdPNZ1Cny2aUGWiG%wmy+OCnhirCY-#<~@n!Kv3Ca?bvnP%C) z!8cOqI2&W0C0az0P@Sl_P_`k&=Oepzl$Df2viVk^zI%9&O0C?%w}l2^9KM4t`3Ye$Dz;U@3gc|Uom`aqD@m5cUN!fwgn050P^((-Tt4$4&lR9>TK^~p&gAaDj+%)UV_k> zZncL@GdVRWvLd1+d0KwDrx}!SrKCz?pI#(}QOtHDd|#EDm#;~1JO*4p&~0OU^IgLn zg(gZ)qN`NR1A-v^&9<`rynxE~Rpr-O(>Tnyt+i%?p%wpLc{1&kQDXc>`km-0%{0wb zQ%L((UoznOgRTRPjXnwgED~2(tN8(*CuJCA?>s!o7B#Z(fu6?gOVaNgE>2~rCKGiZ zMjcZbjJ9|TF5*6%5x@ttGgI)_aO4AS0O*>A#Pf_AlasmL;k{5Gjl%r9OgB9GDM8#MOVETjb z-ge--3Fw=ZKQgkSpWz4E{5<#qxIaMm{_P`Gckh(`Cr)bk%qID_bJW#ioMx}pQ0YYO zd00%eKBG3YuT_V2-Mh8uC);onFQojH&i(Z+@nBuDBje>GxKAM%bgiijyCZ|gv| zjQ7I{r~ahTrHky8KH3Xbix2%RT{u)CW?ft$(<=MA%!hqO$wN#tHHit)qXI3rFl$5o z1Gr8N0o~EN_8TP%qB`yP_xG>+t7!gwGk>LN6Y$Rsqqo7e-)%VoW{kY7XWlOEZ4f7h z?FhlQ^g^cXP|c6LWR=5c0ka}m2lQn^hnuH5}ywxtUNUf0jx`vjV1 z7I4Es*X4&t3k!2juv+f`oQ_C0?Q&yJ4ZXbS_?kKoeRFcnq_#_-ZE9Hm#iVlt$uIng zEF6KN@m(~<>v?_}Odi$xY`~2GT{GgZHZDE`s+Ao1`@P*Qvz9!j+H2BqHBQ!#%#qDV z^w}P4J@UzrHjU1<9vDwW!T$j&zvNq`#(x+=9$kXLoqvK>&wL~-lg z5ZS4}m;0(0t_rc~iFHWs^(=uzRYA-jI>vld>9d@>76{OI!jR;33l8Nxt2X$M|K}T4 z|I?R_0^I~zr}i_7jq13-&i(H`OLh-eaUH>=;zS<@_KmkE4HxT? z#6#o@rM;yjxXx(hth8&g?!om=H0b&#Jy0u(Osi`t!F4&6YITP;?58ih>aQRHX zd5WDd`4%Y!=cGTQ7qy@LjinsvpW-lrwy>hY$8hUor59j7D+YA=5Nz*sT`AZzlCP>e z&%{2d?1{rFyYF+`T3F(JiDQL1VZ5!iW`@&1FDWGpp$}5t$`j}*_fjOp%3+Car!55g z7qOsQKg|Fgp04P6bSTPaUZPtJ7iuev5J*A?IT8?v3Xk&L#NQ^d`PoJzRE_ZQs-COo zPVGYKo#iEDKU8|)O|VTCaNXiScYI8=s<^E58pnF0bA=fRN630=rf%IUKj@KNtJXQ= zJaJ2$x}~f4%6+Xll>b}c@neN`&4W${lXRfV3v*Q|XTXgI-JMOV_1|1N!&w!Mg%^ze zs>~!svXa4G>T&{|76raL`-irQj?>~*zZ_YVNWX7?PZpJYEBN&>K@is{@UIjC%hTEH z=XGiV=t|Juvyj8mG9(0d|P&NRBV!{*ebd{nycXn*ZSopVMUc-qGOmThKYCliEnj&ImB{hM@1HJ zlR;OeG8t+HWlv!uII!LHL4a{XhRuVG=mpV03QdVD8zy~e#ljXePZEW#30))e*~nt66Dl>U9Aqzzp0=ryk72dWfe!WYz(pAA-e6) zm&fbTm9+AxhyFRY*Y;&K@P7GI6|>2YMV)Z#7B9=%+%m>3#z$wKBVp70toAm235 zWjK4BFQV^ffbRGgQ=gb5mDf2dWkB*~arnN}*j`RTk=dwjdMH791>R0mW=)l3&1E%h zuZAfSMNaxsgoZZ^><^`bE|tQ8lfyum;afFwzrjs@47a~1dXNH=q?233X85RTCtsjY4c}H6Y&%(CvYr@;Vxni4~g;reM<9zWuqh zgW`ss)GIAVlF{GmbQ=G8Vfl{`4P#q`Lr7;s(lxG-FUk=a-?jZ0r7v#1_J0636LgJQ z3tb%p7|IEZm>9LV-%>L!S`%NEy0D-TY<%-y7bfAbAG-L2Cahyu9Xx;VGNJUxtx{u_ z0BzPOU9F%mPBplnD+_e@qHlu*t8D) zw7Pod=2A;h%^~?P{h=~-Iphs_Q3=#v=^6f?K)%_ayQY-oA2V{O8*^Aclw`rjtP1WEvI`u>B(*QRIbbW|E zdt{UC8&&!tC6A^3*dI8l;%`nSziV4|k`Ri}!)EvcoBGA^G%2Hv-^~#ocKPQp)CR?W zo9JfGCK<&cZ9{;Y3%U&xVYMd#gBz?Vl|K>kP0rNJcy>{ooQO)D$_N$Nx(zI1i5~*} zG=8V~&fNGD>r4%aqo`oEKKAq$olEUy$%1v5JkZT_FRp`DYJD6m6MA#xF};y*j{;lG z0OfkmB%h9+DnGoin&w?afm8V?Bie#x%~5omIg;}Sk>uA$rODEcrC)qNzWJbgS~h_R zi=_MA?N`@?5Hs1>n4TECzAdzP5ni))BF$^=tw)DWv=@p=r=!sqXK{;!sC{uXo}A>Pnjc%Lv_B2H5QFn%5$OKJ5nEJ` zC3no&CT=*1c5NaQu+&kqTbhJuc)_%^;ql0X-N1K=-3|S6tZmFFQF;G{sP@C9NW($B zXJt~G*#LN6q!@HnXS&WhYu$$yBgh@&-;E2Y&{Wa~hEuK_qTA}LA{fExxmg~ZElz#k zzEoOma|#n7GXMRs&t~>Zb?5Tn1>_XG&q_eIsk9Eh^fWoLUDP_1hVEaF-ErvPkzy;B zANp2Qst^6F-HIyVB5bLBK+eZkVCj-yjMQXT9`jO-chQ%&Nqt= z$zUA@_j8qj?%T~!nX>eloNKC_1Ti+VXhoy}whKhmBtoL=($mTLD>~Sm??V1Fe}e87A*{P+I&Am2*R^ zO2vES(T2Sg?u@&>P)OT!P$x4tYD=~U{ZfS5!b42($a;ZSK%GFoA8@Nc*Q6E0aE~{K zKecxy>uc9<-qw>}m3-QoF_ zP0fxp&l{FxK5rgtC+;2ngl5I#V3)Yn%*0MUfy$mqpaWhb?}ZeQZw=@kOHQEDHwG?k zVKA^dGl-tIF2OPQWKRPNRA)L0*kDID>?hY(VyMsiC(CTs0^hrb2~RTpP2 zGl1Iwy5jRnuWka*Zok#w^q?Uy{1U4h*e%!#kLi0X;Zzgp^4pr;c;HDl6WR_+8L`My zwzM==Q(yfqQ(B;IR{JhEHUV&dgRX>z3b}zakv>B7xBRTxm1Y5~i=Boqy&vsW|>U)ECr@@)d$Bihfgdjw)<6LZrm=se$)ueNt$Z@uV($|GI*i*~;( z939+^_F6i^r$AJDT9jS+i}{a=aMygxluS`WYERa|{V>g-O9V@$WDp*4!$qj@OO*g7 z3#)wibe`PNnx0}KNA&Hp4zY4e@4eaAL~MP6Pkwj!9Gq4 z=<1iDv>9~B4buv%xHlNBQZdn#m~9@U9IN~xOl*y)bErb2H@s*-Mkz@#9G*AfEBXML zYV+Nrfy zI?8?eWyWcUMk+}>5W$f<3w1E#;`&o=Fa6W|@fu5DeE)(j5|ZP2`tsG+HJb${AMlBNw#hC^2f8g%T`9$3HQI4 zggI>ztJJccjy0mgDNK};vCYSBZ<1k#;dJmt!1Y-N=o;7|HlmTT@4)QjzUV4p5@o=t zblVS}`ld@fF|VT`|AF(|pk`8@#HNK9J9n8@WJagIDKRXISr_3QjxRPtDcEP|1l>j7 zG^GP9R)gQZ3&M%N_US{C3Kb8+$Tw`U#Y}dkhI1RQsN+|vh0Wce(uo{uNkYnE%yl7z zLNHY1vOT#mH#Dn!Z;Vw^Le7iySR^23Y_*@usD08HkN~*}> z=bai1v+#1aEuyA5olXt0tfZGGQ zeBJY*c~cgeVMN2jW6K!&rXmX0Lo3F@#e7+mWgGEfP{|lSnoqy7`woTUPcQMbBHgy? z=^cKywr-vG>-lMc4!FIbYp5I;Zcdm`9o>Z*`ZHl00dMqg&v{RmLFFgz{PnjJgI@$U z9rNCYdgAD}-|CZIhpMgf>c4?shGXnq>Ikm50Q)U{plj@jBS1c1Bw~pi`AKZjn@4{j?iUT!|5x;pQdBBGAu%i0qQ=$3xoXMV#ERB?VKi+(Ky1$<5QG=j6M4Pv@*Hl4|sY`ZWFqsyr z--df7O4}p*m0{W}mWddROT2FG(4ma*n?9m_`6z)PZCEP{l9l#qo$@v>hC#e2;0}Q< zE6wykM<_Q6X$$nukQi&Ox9wll%5X*nA~$Q7A60u{ILP56f=GK)C;qkDDT@@U189)n z4h!rCn3hNrDu%vK-x7b$cNlaBD%?vbgx~4Rg;9ywnpw~&4}AFZq2?WmGO_l&za5&r z%iDQf{TP@uhph2>)+?`Vzp)M6)S5XsVTM2hlNd~J|HBCAN?^bUcHjOuI72i+fKY3eGxs}AT2xOCIS;y$0g z)+_d5YN=AcKv)UsCP^mm)FMb4Iwl{)$>6|>vF&bDp@(EvZ_~}BrP4Lmnb-sF1n8zm z^KZrX+M1NO{iVRx!6|`~qGU#|cEjn~3DCM9wv+WH2~0~9)F+{NZA&CNVo0DL?9|$F zGgu9YVPC=B{NM(-lb}obc@T-@D72H`dKZOh>h-7etxoqO4`ax#mL+O!a+Yj}MS8sT z#-H@@shs1%!jT`F`G||Y=lD63dr-g8yJHIgcM5c04`CU`rd&8BkYIB^e&Z?QUivP* z!c0{Yw-V^L;ww?5w7tpK!=G&7P>AwX=ZtAW=}{|z3*U09vN(Y7kYZO0aHm0+`8}*z zGM=APiHgr0Y17Punfs{+4Tb~3DGOFo&&9T{6GE8cCQDH|YjRY@v%Ydg(w z2jqXX=6#$1>s&LSYqfZP3jJQhvpg7Of>K6uk_~PoV@7!-7bk% z(*ewH?QDx{Z~tP3^f8-N8KpoEpoEl1QwhpM*1&Njzl$~|+MB7=?34%GIncG`PngFTsh?|p=rPs>htGG(*QATm!-Q~BbyJ7W5 z>ny@7M+?dUcOG;(R`bsAP5Kef@LoaBJ}5#tJp4{l78!^^Dvd3ko@Tzk$hM-mv=V`> z?8Y6~*Njzp5&mU2lH9!to9R50PM`t2ZVRA0N4e~5GTGmeLwXpLKPKzF)@JTKmo{C| z7+gkKGsiWzvCqQ_P-DRK3{m6UrUV!!+Ii1xy0}+0r@V1?v?ZBTAB_1 z!fv9DLvdT&rVsDVi1>vXKVMV)kjvB5@g`&13hx}=R+0`lFeh=kC>Dzn$x%$o)Jgw% zswOHu)dILnpeqn~ioMz^HebJ=JiG(5eKJh;wwNWLjZ^%umLr1AyAoY(8sw$y$4|Ep z`9u6UgN-3=ujD!wjtv4Q#f4svj1vOxGU!ei;1=O)n<7p?t9-7lUU+$F#*Z~XQk(NW zU^0b5ZiKMP^bvYGQjRqSA2WCe7G1B$v|!0%2{ zpr9wv!pOmxp_hlXz0Y>7P>XAPX}yiWlKm2ELUbd4sl6HMPva>e# zMR_RLCtC$w>|sQ>4?i#SG5o#fz5hCVzu=Qz_-Lq*Xt16huSg`#q?mWyg-0P8IwqB6 zJk`lTg^0s@;McHxH+^&=jqcF_?tfSV-5+y(o3Ai1SYQ&?JHGh6dQJ9K;shbMQMfBp zNRWP_vs~flYBG<7LAO0BdA(Pg&i*kHhsN%{@I1?*=B?2L3%K8C9dsWE7rW)M%}7{| zN_w-)E)MnkM#Fb800<3EzX{jdqT2P0B%Ez)qv8D}D_kn0Lc3bz(g3&Xb-E@c9D zka63>A73_S*$Kb@BEeS>kyTkVR*|#T7BGOvSg1QzBs+Y!3b*XNMU3#-@K zR9{=O$Ms!)VxGpQ$0Xs8FJZ?jwJ4J%H=?alb=fYwzHSziA{wiCSU=Hv^d|9}{=Byd z;P`HXE?g(VIF$_K$hw(=V3yOs020V{k~pn2@`p>2T#c%h{BRze;z2+}}gbHVHPDkWt_?MF8O9r^F zcn@^hrkgxx%qgc>@PaiohXzYI4)C-Hg17E)3qM28GL;tU;TS4u={LQF3XsKLM`{kl z#@xO5o!Fva|5+S+H9o2y7>9k(73DN;o#?I&DxkW`6fN;q@GXF@sxsb*vLO`l zUrXK;*OJ0rq3+=1y{c=|PAnzu3%HJh*^3|{d@ueIe1AIt-HNRDQ(L6Mgjdo%%%XN; z9DMIrICnH~BrT@OMwc8<>c0(Z&taL@{TZFQR!_U1HI8i#Xn;-_usIa5zd!!cZVcpm z2)d2cW+kK&%5deer7o)No<;C}uiw-ZSRJEQnIa`8(A6x+h{11%Y9pFpIA3FE5QRl} z$EF^(ig}31+;A41T7cizI09XD0fN_s1QO#bCm}&?4k6Y-&A-%VHNV1xZ{vW#FLfsC=KwS{Yjg#z{8LHvU?4#OO8QT)>cGwnWmEUm{2^Lee%Fz5F9^Miz_{`cfvR9;wTL$gDtOfNsYsF=_N=k*iN zJ^D22;w0Z@IK#>*N2NeRFCoFvCEGrIwI^d%~*r z@>F(%3VQ)j{Cort4H(~3(EXdDT{ULb5f_9(AxR!{`cg6>Palq!Oc48(&KribgaY}$ z@=89XD=PxBZ{XlbV|Mx3l|P#&Fm2OIvVDY~C93HRc`MQU8S=iiU z2}K~Oc>g@%Fe2xyJh7XXkN&H%xF{SYT{~HS&$FpE<=4>ZMRS+!@F;CfyS)oQzUQFZ zxQXuv--MwY&`a007xYWL+ zHB;9N@Bn^W-Jy5}^2M6DnyAZC}0QH!6PrF?xv; zC(%f9)#07VX2Zt^^xCG8F1VAt>44SU+}P`0SVGxx+6Y#x&)c<$#6JlPZ-9LNfv!-C zF-*tNN9I^EPQ)oKDI{p-BY&*D8tmXG8SII`g2vj-7Sm7k5BQ+I+pWxrmOu|PpjdN8 z(Vx0aEfvWu=vVVXvZI-Fu)i-WEWF159R=1WuR&Mv!|LKb@}|9w0qGP9k9sPjYkSP^ zM(dj&15Isidd=;zon%g2WLTa7jFB2VHsaauCAm|7&mfn2V)BXiHk;G}`QCu;_hczf zL~^p3`G@mP3I$AVWj{_gJ}R9J2|3ZM&&qL^72ACAW3cs9J2aOa#Vq)SSGV}kY1?{; zVX2Sfbb)NafO`wN8`IQ|#>GLtc^f^`X3>TgDg!UGi|}6hAl&SYztgVxL_v5OUe*4! z!~45=U;2{ZkBRR-L+{_ZFQprQqh5@r(+Au;(2b$gkx`MUEHA)1u-CsWXyH)C4ZMo1 z!Jtx-`-SF6o0lA#@~=-$H+I^l-{@CV*qcUb85HcfKc(t_1q)Yq_g?|-J?KhB!penV zh`g?7$wK9kl%q``V_lIuIUbcsy6{t}|0J5zyAsP2f9evUjVg9{U-aSzJFK_g{tps= z-69*8(I6|}K7cM$9RkHcF6JoB@!-O^CgMDvoxTNz7%Yw6rt9_Cn68YLxb$X>^EV+0 zPBj6F$2s$yz4EhL!sOqm;Urh@u>Gt6_Yrir_*V3Tl{pD)GTyDhq`_;nr`D^pa~kFF zB>jDflTfWV7nD-bI$Q(Y%I>p;$?{X#U5gY-r_gOOIq>QPXG(nnaQ`br{BJjp(sP3f2Mkxi>RIFy>P&e<@F}?ruC*QA!4z9cW{PPq>&Q1jkHH7iH zvPu{B%embZ;69z5ehvt`ZbU?nTPaL)70Wrpv~@8i8dE|Zp)CV9G4`R#7IIATyEj}t z8=UHc8_PM@1A+{XuYVGIC?1Y7)m42@selFhLolGbV>xZ)v2kw?&((1_d*N3*Gcj0^ zzckm|!wTnl!iq&lS&^_N6YDv4f30^dZVB;`~w( zh`#t?cixNzp7eh!4_ABIZ4ZA`Y+>Q1PlsfZpgh*6p9?xTx6`SFiF}$goISa+qQZEP z6D_Q8Pwwjp1>C3J>~laWy$wB-Vzp{VIM&7&>90Rcs5uI9x2x$6vh8DW6t6?xe9!Ke z|E4y7hBZ!oQQ7b2RweTi#kl@fD}HVK^L;<~+=K(&tn!S>KKh^0C0%0zJA-zo8j(pS zOy`WV4jkF9d$H_m*GpOX174kZ(h;fn1$JbG^K~BVFx5m8+O1nu=E!r30Qo-k9G(NR z-JNFq8_}Ju+Am<%6Gf3lq-my(UrdLXn9fYAA&gF-$3UfQ*=DXrCghHE19x*e(l1Zq zb)PAdo3MP7t3eg3pynL<0)P zA7KUw1^X@$MqEYB{yi|&F9@J^cW`(d|FADV0rEuzU3d2oow&S6Yw}N65TAE>Mt0o8 z)!P;8NgKS0=cb4V{6Ut(wIY%s0_1Po5u0t+EKiRFWRCA!93s?+_aw?kc>wpRhV&c| zX6cd2s-4g&N+B1t29!rlEZJI1OR4sG^M?LyRWBIbzmH=ppUs;-FOwexPio91&KfyZ-YcGg@gfS;@NtWAePFXEtQgd8e47NVhU8 zn?z4bh^8K&kv=pEYdu9~@v2yzidg+sl^&ZIgsWWV}O)%-YKd6Y35-oO^J@XHb5B!GiBBcC(!Juv6!pKRt`1pcM>1t|2ZFk3c9)^$X%(K?TUY=x=mQ5$&8dK zJaQ9%`A2>a4$5kdV;$QPBS)TcUBrryWhvpFC+N$6mcjNQ`-A>rrwg6pXM-hR;e zVe$rbw-T>NPb`8c(4N6BW5;;zCNrma#?B{bB##~83)z3xb6pt&biLS;Px9nZmLGq` zmYhE{6^pvFBay_H$ZNK*r5OJ!h?N=3TVO*Xf>#+o4tUS|mjO?}TUTd5U0|wigzAjF zr5SLaYRu08S=TEaI;A)3-pxl?SPCMYYa3JRo(YMpVY$Apt@2&jfKi&dgTVi`jg668 z8g6RJYgxzK@8NBndzlty=(>If-e*tu#&bXx#!8?Zr&PqTayE%GUdmDY=vPc5BS*d_ zw_a6mYB^Mv&Xj95}`0OJw z7cat5-(PCRlABebr~eR04PJ)|sK*p}4L_`ieM)AVaJah1&6MG|@ix(2T>H;@z8~;F zH$}~%zd>!rD@lr&1~IvBNAB+{BZ+Qx>1Ie@MZo3wi-4FuN?0vJg&9F64W#NFt=hSp zuea=IA0#C+ep-vVk*0=~aJ zy(2ycgu20H5!(ocpOb@?rA_^Ju*U-e1wD^(y*>nLAa#Q0?>YZ`^56JP%`=BBR^cIt zfHH%xy+`SSSQC9*5gcqFxSk{e-9Y+qV%85Oj<>HY+h0G}=L_%+khhMB?=&E1CI+h~ zv^8wKe}lX(pRoxSI|3aq&3X1aJ)2916z+$;x9*;fe;_apPd)YLfZWp^t<16Xz>0VT zO6Cih?fCyA5*RHN-i3HrDsD?k)nwLaOw&2qx0(LMZvPmX!G~c#tDnwN@y*OZcPfh@uvbjn*r9H!5D)67nC&J+SBE}Dfm(B z^PRHa;hcIk>2d2E0}{!R(^>(S^#8aEf%lF4#UaeNU3iGUcRgDFmhpBR(Zv6J5vXw? zzQ6QV*E6XovD{J3vv z@@>>_Qe3Cr`z9Z8HS^HxZXiuAFFA2u_)bnw?Pu!Y(J4A#2++kD)Jdgqh4_)5hP0_Fj<; zT?TefH+&jn=Zm);_oUt$>O^n&b_?W74!U)(I3h4~AEp-eIwxXR8sA0wR~WCosccb+ zaTbW8_a*2y#~zmCO{Jnoq!SC@UPi>JyT#v~ub<+{nVz{ph@Ayo3efHQdPf)U#trMV z7HIE=+uobWt9yawHpSzN70zB;W8~~vLJh4CRD9j)U|Gj0Uf|UX9{knE zy*-t&wTeU2rMTJ_!RGY0OQ$b8ivLIgaG$Q>b3pzwWWM8S)9E{gw)w`5RvOvVvAK}%ZcLI;0=tgk{6suoy}q5@qMiJ)@N z7^XCK0}HDAHzqjuJ%sIz_Y}<6p(C{mk1FgtsEJev@fR*lsPkum)^ah__L|>|4Q5bB zOBJhNN+{2OeBXd>+u~bmE{cGz1Z#Fu$n_!ULSJe*; zr*Y(j=4(P;o^&EJ$mPi#+>cgv(#(!M%e{&%>XW>N>do?z3pZ*)DEDpPei$0i9X4Ox z7R0uzo@<`s+2=ZW`N?ukeaTyTS39}x-=}js@8YSFHdRG$t;Z24xxt38zhc6nAzy=I z2HihINhE*KhzIh0>iIqggw2r&iyF$sQpJ2lCG>OLebKgNO%7a*pzLF0G@V}<6zP$@ z2P+8{oRPOU&l`7(W>Ruf$Ks_=%D)4A!#{rg4(|V@16?%28{vdUi3uU!H{KyzC%U^0 z@$|7+SrS@5T2zw5cnOi-_zGNfP%qM8m^q59KWdagt8 zX`N-9i0!yzUoGRYugF`e=?9(vV0j@Y42yBxF#e|(U-i2`Uy3L&<{I&_n zHPL$FcQ<6-&;0HlaG!cQ&jI-;uiIbfKz=IC$k9++SGaMG&VKq$K90$Xr#7W?xxPxl zkg3M`TTI^f@fSn4ZqY_K-eV=k!|8QaIh^cdj{m^*r`>W!GzIr9r{;e6^=J z?D;-p0^O1ReU9r({gAA)bh%385c*Yvh}XxI$MZ+b?9>Gei3NY?uwzLDB)HSW16B)p z%QtoXDD#!9m(1#P1;o}uZK45}8FbsNN8foZ#|21w_D21^f9bdp>|e3pc7G|{rOFp` zVe-S>`$h4vGfcNGAM6Xta(yIPd(FQQ%G_TC;ZWL zeab&?)Sa_6ehiRI!-O1t(Juzi7e4*_IUs$Eo^)3$nwO%?>mi5emQ7z}7LudS_Y%Fn z3lm1SD|(?c4y>{l`1~Ol2^*o(dDEp@cz0Jdawk1?E+>uQQ=1Qr13Tyzq_mLtozLGT z>69pY9lnuK*!V#(k0Q{xdwp7`5O%uE-R6j(6^6NpPx^5|?!;QeW09k6)SlY(YRb$< zNm)4na5+GiE?QUuitF`@hfo%dh6xR`s?& z8M-Ec+rzn^c+1}HR5)@iyHAb>b!F~Sq7IWEAl|YK9QA)PCHfEjU-zjN@f;965lvY= zBcf#eX;Ed@zQIJ?S$(Znb_7=a#o3M8ab)G=`3$&m959|N%nR5sgxiy1YGNz?xai0} z`nN&B%&#^8mlt&RJ11&OEL+rw8tgA$jx#_#Sjk(xFMJ_hb}aqJhhkft+6E4_UV||0 zT>P`4CHIw?9^qIGx1rFE#!Z--TY2fzyY}-q@PY2#<_~&#t+Ii(WNfX1=2Mg*zjk#T zBlHcuZsnx=vwsG5)Ao)vUmUYOeA^1VQx^$)z#SPPyD@f!I(+Dq`$w7uxcs1t8W?&O zUPt)NA+@uWdq~(sOns$HJFxMd#D~o!GVu;xyjNt}A%&yWdfl;){NHK*6}BdEJ!O=J z^TZY!oe?LvFGv7%k6#ANGyh)chEZ4FZo955r`?0Hd`RW|uz6ZG`_(*P-c_RAZqUp@ zO(QoZ*-s(ew5zzznOgJXKhy!bXccROryA7rI0%AnPg6a{qRKrKu`BGm0GXLD{EbwT zcu~Sc6sL(}QZU(C)xQ zQ^l;(g0IWK_s|+}g+UjO4oR{5kzWG$_xmwJ{CIiq`BRPKYnJKAm*vx<}*bIC*U0y-g5+tzSm3v+^3r2b3j6WqznEMpkj3pG(MdiohGq) zng2c42UF})KVN97ZIAMijHZ6eYqRu&FvbsQ7rix-d(#T`e?pkjl1p3IQ>{M$_vsz+ zIUtuC_>wkZg~)#k5hN$cyj!qlyq1UqSuC^7ySX*8qy#)63_7nXLL1@RNN=G^pnktY zlX5I?U3k~fz-+VZ82>b;&*Liwx>q~;Tct0kA(FR_pr=^t z1-MUZ?B{@JEt}toz^hO&P|>-z_C5Axqu!{ys|3yKl-S^0|8XK_*P3Q?!*d}L3Uta5 z2%)X?_rAYGab|aT5XqIo<*rf#+^4+|&jHC>AmWh8cVc69Xw>|h@CZu>IW?7hw`YtvL%j`5|Nvn6t(IX6Sr9ijB9Ib1^m~F;u zyW{fEiyIq5oIxq^lW(0tzCVv$`Cgyu&D(!X??!sE;)KM;=&4E*szXMalN`m{8-H{1 zO9p^-n5VIR4oE*bVq--}K+OJiGjI8Ew(21s!FPsh661fVAKF8EldMG8&@)zjUsgUO z>Z8`_?KFSBTT)dv&0r|q+hzF@^U?^&R|a&Kq!X)Qmxorz$bXRjxJk$!{N|~U4d zxsp$q2&XB=)xPI$w)oGbp6mlsqH5^ListU;&nLR0g+30>3{3c79sFr6{2UOv8>g+2 zJxxP#Y9+5qM%mwM_=}PyuimTT(P9l*X(V(N?bN^JRG2dO*K>{fD_P#;mlfl!@~Gi` zk7@g6)z`?Uca`V+R}OUFY6rcUn{R8Z6pztMq4jU1XzARF{RNNEF(P6Ceq~mxM1QQ z1bSDsNJ~&na_xwJ7rjOOE%7{oKWTxEkfH|r&N>B7uOmqxjLA_xA>_{>;3|NwfpMH` zZ6%5K#me6WwHr2{m9SSX)9;UdXVpi6%A<=zP83$a|jO)JfE0KrrpId7wi zj1~?5M7H)ybTS>j#Q409Ny~#IyXCgLkqN<)$o$^NYgDGz*J(0xicdYE=j-c#Sxmc2rKPEw1#juNyo}ztQaA>?vj$dZm=hiWInR+dorsK|r@62h7dPu&jCfx^U z3k)s|6dCt8g>;mH>po@BmE^Yegi-9`f=d|7+SS#w?yI9X@?&;u4sBY)XY^uAtDWFAdPkWx!U&R51Lue=(ZcT_>Q zjSaMa9XC60UKQ|E$VSw}AU2D*p6rk7 z^V4x^WfG$s|Yz7Z0LUPdZbBA ztcJZvyVBRn9q+t~lG*+o278&JRYrTn9f~r|bBCPEKD|>skAoKInzk&pA3C;tz!R6` z*(lqv938c{uxlQ-r>KE~ueaq^`uOTRQ_$t$=eIJYfGL{n*q}Chv0Z+uw>?c9b?if* zT>)1cbd}LAQ*8#=^d)cAsZ0J8ilL}G3Tc|6wIlrvFRu5H;UBH3&ibQ*N0EAk*2Xii zCaD{3#=EQkf&6GkFADKY0jz60&27&CVLVGMl^a6nG&7hRL=g0e8P4^%YQAsM-ztpK zr7$}9+I9DBh5OsM@9s}Op*@IGr+Z5pJyzeo%o1YJG#)Prf&3O5 zPsspWJ<#E@U3|v>WA9DivHHG-?~7DQnIcgUMan$SW601bnL_5sm?2W6P*h4%h@^<9 zL`5<*7&0XlA+u64lr)jVyVm8p-ru=@zj~hMeee6Z-{=2-uk-nT?|sf*XRp2X+G|he z>i*euYA7dja(*B|FaG90y%>IJFB5b7M7Q8<6nrI>`Ea07!Ehpa*vlqv)HG&!sHblK~AFLO@zt5=4KdoXe zkg!JhxZ#dxJ9=K8k6C4q6BO!hyoBXV;;cE}w}1MvIX+%yVT4@WEMA>u@;jT42A;J( z!7)hCyOXF_XW%wtc4P9ewENgR`WVuHt?4nfyUWcUo4m-*E^sXt+QfUgfQjzK{nI(M z-A%IXZ30ruM?>^Eb_YuHMeaAAi{B2Z_SzElt_xTDzIw+4rPO&+B>E+HTe+EQZwi>4 z)Opl+N8NUvzP-#-`Gx+Ec70&(>lkjA5ML#>uH`_I-oTdvJ%ta}MN8)p^x6^iK5(7I z_HqB&{zXNmVFGWqX^1_qi)31olGyZNWRZo)p}iY(D%3?y9xu1r9$ndR+omp}U^{oe zrqDFkt{q;dPJ7{=fod=AQK%P#SJ#}#=g$m*cv5z$8+D! ztSnh=(;GWSPG3knv)|SA%~e`hVui3 zZ#h2r5$ts&>b>~&Qp%^-@3*#ln2j7OQn!qJ&80GRyPQSR+p6B|RIuT?RVBh=YW#OC z0v$-&o)T+TF?F%7-xYJpTBIr*ND^)=p7$3d!i>@TH@)h6=WHF zE2C=BT7f7Ii*LILdUp}^niTT$J8XEb{p{(2F1!aT|sZKGJNMV}seFfC&HL;8wagC>4Eq{f2_QLhup>-d(A^prWw8JQ>h zxmyFr9HkG!H0kVn;<+1rLcJK`u5(VC4yAi$%+^|;f4|5EoW4fB%N28qK<=AvEP$lzSisZ66Xi}CQiK=t|m-8 z6Z|k7A;&qH-0@<=N#5|-o`@iI<$A-Yo|J*CsBK4A2fKgZnyr+q7-?J|yWQd^Q*6C@ zirIrx7bb0Im*Uxq>IZkCUeSE|mvR@P&33iSjpwe&tC0=ZeCoV6?+p{D#8dH0gScMVER{ThUOaBYLQ-AvVLEWJy&iH_d>a-n&Tr1IQMuG??7#ZHh)?Zpa- z->W@|dNrG~M}^vm6snXP9( zi{CnrPu8?N*?4P5agpghMbs<82R7@2_t6kmHJV%Ld9c`wF}IUagH-+q^Jg)t5t~YZ~hFx(_cHo9y^> zDNj41r`bH3Y5ew;MH-S^9&f^&tD-F|v($;dw+wrN!1NlVG6Ukbz1MVt-0 z)kL>!Nol*g*zuUwt?N5AO3XMHiXDqtKF#>%i3f0rSm#G0kz$m^O%5UK)KdVQ?c|ndkZF=k|^; z%eNw?pCdNe?s;OX%=mMzPWfqqUYv`m7lTKdMQz4;@ny$NvK}z+=-jc%UrLyH-~sRU z&%94}>hw*Vt1y=l_xhy0UuVaOr?oG46y;Xj)OI4}t?}#iE-|dTMbPU{)XTn%l+pK2 zk~_X|+vQjerWKs42YYS5nBx1=%#xHIhtl~S9dveU`SbBTrq1^DqSIZ@hgZ@W zA>FM0j|xuWRZMlyw$Q^&P zpxTRjD(c0s>(t2%GxmV*+}8@+Z+!atOgbu1R9x;9{BSmF^{V~d{flN_(l@$u_?}b)8)SErxILcXQ`i?9k9nzggD-Uh?@qlKj0B(W_r0)#UUIk9y0EiiLOxqm-m&aa_!N0Wey;9Y|JLBN z!U6N*kFhPRA=*dP&Oq`ESCQ&>`s3Fke6-UCFvk;bY^R6T-5YkplQSe?A( zie-fOwuQG^m~8lh-z>SvwO|iVjoYiUEn+TWvtM`I>6z%%T1S7^OCh~yl+F9cC8217 z-e96$y_|_MmHl-ZnsoJsDJF)pj1T-O&RUY5S4CIy9@g=A%rUg+oXf@wCX>tjRnmnD zM~!my_*E}@jxOQ-A#fulhn=7|gs3+%cWle66;-a&yoHZ~DtFk0q&2S1oGNoH`2Ioo z>N1yGti5*{*VQhawbtsTrO&cY-|ND9k9JfkrHYM?)U}+T&m`zQNYr~;iHF&yyWD@LIcwURUc=VAf)d49?_9oZV&gqhtH-~2&81^qbxm~d zCRub;Fb1gc8%or>VkAsVtxiizP^!rH?6s|Q{L^P4wL8Zo`UA`mIRsbr)}c%wh*v4NX=!y6^VtdZ@lBfS;#(sdiw3=#AmCh9$L z^-P{eUZT6yX7fD$mX@vOLo|}()-S(aEdA={>DNQYn)G5?{VMNdOvfFxsj%O8Iv~14 zf>%tu=FO7iR$&>9_XNFRM7^_9yOg6^?{gWJzjl$bART8Vt(Gc?-d6o+_k4yi^LwXu zyt$ClcKhSXCKmhUwHZfWb)7BPy)NTX?9<4hxzcM-(-ZWD6ZK|EN>waUeYsUSb&l+& z7eiH+B-VJ@Z?Q!mSe&96-d|nw@MnsR0_mjgbte&#``24qzGSN^`I+dyT%FTSa`IIp z&c6{vy}r{Ix4+h!t7WtHkyp8hd6edq=+a(Zso0hJif_`Mb2pvkj(sjW|B-Q!ysAz+ zz17`Ey<^1Z;i<+FvV0_kBmZc^PW?g?&Y}gAflNG!SMDtIbK5v3+^_iU zk(;Z3WzClUVd6R;Nz|)aNN2jSyZ&DI3HF1{(Dc%|s66LlAJ5IA(R*Hr z%8#3syMh+~tR6d{pK^_Rf%wk60jZ{W`YMMbSD#U5G4wM`KiZNKp5g3clXLeOAM;s) z-eW|)Q{p&yeGPA1Oc%UvW<$lMB$xjhynOZ!SbDy`A59F!M zH_uw~+&3d9D6R7Pio?mPw$l^O7coS=YY&&Q=Y5mp*4E(kGnwUR?eQRhS>kM7*mZN6 z(R*>ZYzZ+YRvyb19L;1Dde^CbPij5Wk-g8-Urc{elTOkmImCi4s(*10NWB<}o+GVqx43is!Z9Ojl;PskZq3q!dIJaRhC~dU3&JNadF2wEeHNEj#sP3tt<9g z*6zRCc9QgCRGOd{>!4l?n^t|h)gZT{o>#j(WW?j7Yu^Hg(ZU+}@ue@^vfhW0R1_3y zcBnqO;_p&gRp)b6@SbdmztHl~`&!C3(*=8JwwzxrTp{g=;FX6$CsNwHn_aa z6~4-==hzf$vAs(564ShwF9lsI zjU(!9^eam|P_X!A4O@J>mF$b`8#{GkM9$nZ`zjN`G^JwNYVNx%QiU(YdftV!)Ti1L z?dg$u}IhuVQ(d@Zj($-!v?mp`((SAj8>_xn` zpH0pEuSNq$rnOU#=$73rb~(>87P@4x>V&Y<#EZ%jWJejsX&*Kw0C8@>?q zCKC15oZOtQw9_O)>8HdaeNjz8R(&H=t5?q+cj^yLRQPRs?XY#Ixi!JXyEo)k_$FJ~ z^K`>cpS;eg6V3})&zNKI0(DXSfO}!;#o#|%>|*EQ-G0&WAKz(aa?#J(Q@Xx*mrk~g zxy*wNZ9OFd#>;Lqh##n#b@v>Hu+bw zlI4?>`Z|ceqvJjGVyK!JTvFwGs=wL!(@9%Pkxh)go^7WVrr2uCxhiYd8LCkEilkwE zu53=qg3Bk}eeRJY$F)Y(imVzQy6le>Y~?8=_~8Ok@6L@X>xGPR1-6?O_ONbu71McD zzB2JlkbuN}3!ir@<038$-Zr@NzHctWvOXrZoO89N8#WHs2$uiQ`0kM=7{%Jd?c^Uv zc|%m9NN{rjoOC zW$>5H`th2)NxK7k0$*?0DYdLujNy5X=EUMl72m@-kAApK(3?us8}Nk3klU3*F>~D+ zu@Wb>3Q6WCm&QkWbuS0pdQ}i15GFEq;gay{fhNEGrcd5Gf4OcprLMbFM($*JOF`R< zmOHq%Q{(IsQSYXwqgobniiXRsDV1FJ<>IyS%KPr;t+lr{DlR)VT-N^t*NNIQHa{GW zmGC95793JmpTiSyLHr)~jZSUP@MWr32zoCQ^}4R^ej`)8az}xt`seYT!pu*;eH&I> zVK*FkbogJsEL2P|x&NKXPJHP*isG*|6T-lJW`ZNsU?B6;k2_mw(1g5ETu-ZAxw zq-CogT5YIXY|1{@mUY#NIO~`XkBpKG796bAE9*X@pS0w8dGuP1roxX)6@w+GdU)dl z(v6O__3@LcFWU=tdI(QM`n<1g=03lCi1W$Dl%Mms z_KBU5E_a-Kkg#z7GB+MAwdsXSi|vAUzN9-BvzF7CU@!WFdNFJs-^#Qj`=;Cz;XBG( zL>Fv+YTRKo=)zgf{cE;M)%cr}F;(rgW#;^){dXpY620Z5*iE;UxtD=|7|f zxjAoXTe2~UG)rttulb@A-##=&(2Hj|>c#Lq_V^wpi!qmtjum`M_^T5Q*C}sV5o@@6 z_xA98KTZ3#__*D7{8U(Q%UqoWd&$eb%JT5Q0-HwG16RzF>yNJCdY`&tS>hQ5g5GSRUj4rG12LWcX)1|X%HE@E zZZ9~axBv9HSAp%V8*ZdC9NvCk*-#=`bokhl#`Cpr9i|6G&C9NkD)q)YliW$~&b%8V z=)FeNtC#tpbhVmNA4!z7Gt~pNsr;q;ip{<`%L5B4>2o$l6qgX zC-rQ^nD>PJe3qjt{2YAx;)L@FdU4ICUJRA-hqs$~dxeQE;9gaeQkc(m&i>YO-fas< z+!}_toIBn!7xP;zTH?N3clABjckSPv#?kw(*X~x2**DZ0y6?nS8ny+PDl7<{tl z?LL|37IyPL`QElRb&%3LbC^lW#=hUlNwkUNryDqwrq$mzW~b6CJIl0Y;WE*uHj%09 zgVImDdtLO7^%L~w{0F^xtNpT1^>ICNhkpvM%2H7n9Gli`xuE{nwB*1BlEB)B>{laA z&Yp7!-n+9j{9dVASZu`DyImviw|#s#dY?y4_)8k^j3h)^qkYf&U)FeD}z|&7ft(fT0cLLe|5W}phd;mPJ+F; zM7`A?pU+Yc&oi0j!uIV`XpQ$nf8L;`i*^Q+NrJxLZm51sKFMPu=&z9@|LOdW+nL4F zyX2DDIi6T)_oqCW+$$IMh@dx*sP}<(X<@UdYUz9Hi$A#sGZh4SwA zy+PW;{plg&G0%!+tu(y8^g8`8W$AdgRqdX$X5)BVs=3hyg5Fz1y;1=`&R%&xP_OT< z*A=RCd&%S+IhDE2cG3fMw^KeEA9bR){FzcW=li~q!;G6`w1wR7a;h=ZvZ}Ne)oU3$ zXs10V=*2aOdNHhhI2`nS19Nq1eXi`-P@9j>K*H#_t4oKoTxYfMk*DXK9xv{idtyn! z;ODM!L2Z3y;f7Jx&o@Q_4xjka-89a{K+t=KsJA_y{o{0TV_wdC75PgiZ_L+~%)My7 zLcZrvcZko3LxW^bWLt3r-{js6qZXgfT~JJJzc##bkLWyK>(~5r10%fXQ)>L;7@=Ma ztzJCzqk-jO-jj5Rrw6ibJU^kw>@p-%ACdP`z|psBhiHFXDrO3vXwhyB|>#uo; zW?9ek5=>KaXI(%0U>QMg0a0)7{3`lV9&1wg;=qDyp@YJ?W6i-~5`BAo=hdH(a@6a{s`;+RyTSI6Btr{S3H zQ!TUSL|HA~73-WT>s@v6E|1-sxQ??jZt~15jG`t&pYu<=sTEl&Zk~QpE51^$uH*u7 z9OIsfdNEWxoqNkOl{f2>$hsS!my`<)n{@g-*WC5CR9*0hNy0NJFaOfUhZza&sZme% zo1fb*TdLvEUv1TWuao2F+RY}{o)G+iXHV+Iu&lDS)A^D5xh?m;KUo?Y_@2d2-6$u# z+lxWy$L5B!q@bJh*E+-{3cRz$PuDtcjS_uQXuN0R+h&mqNA8JsD6Y^Y=q)1Z^?E0* zzI)sd!nm~T!Bjh~nP@s?5etdTW&p~2%z7S%6kXoSBad2HZw$z|@Rt+}Eh z^nw@IDwWiNT-M;3m>LhoM7;)cWShq3g*dABF!2vRSI;?D$9XS%mfb0V`L`=aw@DoL z=SbiEM)uw9sop!;F7ve2lpdLfDPK%jy}p0H@|4mK{N_v5TSC-J_o-3a_Dby5GQ|=P zp0J<1>ra%Yq_=!;)K(P@VV}r2?=tyxks?R-gyN$u?C;E`gageLxgB^)q5^y44;Q#5 zZYJn0CF*6ZE|k2(zI<$NN^Z8MZXRc=WgAI5YZJHknx0-;yk9eq$bEt({){RMx781bZJ4^?nUW@c*$) zY$#{&dg^ac9j~M+gNn+gd28LoUJbN zwp(g?+kXsWfa+hYgL*M=hcmo*S!{nKx+N&$sBgZkkfP)dj!3$(5dQsJD-W*Ro0cFQ z?&tCL$GT+qd4=^_3WxVMZR=fqJb%a}TP<+J5@Ud>7w2N?#ZZ#b?Ka!oO(5Bz@ATUv zG7W+wxl*RvT*F2;ZR33V!F^>Nr(w#l`{Bc1imar*M2{%9_6(^kC^BF9Zq3`H?)(OC z2zqg@pk55y_GUHDyZ7_x1z$%Ejgjrw7uoTw?>{)goqgVXWQ)sG@~gx^JF&J@SA!s* z9>3uaA$0T}jCb3V-6SKMU#x4riQgot_LdX%GWL}zYZV*`nI~*FpMlgjt1{XvWE+nG zoBK)o%}Pd~XUw1+qGRAmk4R^OCs|$&Z+$~qSwTz2#Y*H+8 z-a&I2x{GHG*_RXaRuc7&c(%r9vOE%C-85yNR57<(=>pomE(y}F`6(Z`qC$S+yx)^#T?ubT!RK$R#J%U%EKAItr7MJI zAgbOfqTamvmiDw>CsKPy!X`cE^gYFWfl(t>8lj($T8_PP7*4sPwe*zp7tJ#rtY;;S zUnXY;Z5d*AYQu1-Hj2MHCMTE9>{2lKUvP!qX`ASLG7!?+Q8&CbBTb15Xp|ts&|aZ06g!;`A|A z-Zxj91k7_JyoMW>ODYvgmhtD6e_I+FB%9cl$kNA|Ne9lhv)=w!s)8XV$?-P+D7+@#CLR<%U9TE&YWJ<4=>+33_o&qFxL~ zOw`^#w9ud3bnTu;tcqucvG%-`Qd8;$-}ftoFC4J(HjB=x*U+vy{@PWoN3})%+#%z9 z6T4q;_uu;XfvtvRl{7(bJyEY|(}8f+V%yyP<6}3z9=T?tsPU9^LVss|Lem2_IsvvT zEUDYeOdmZS3XiOOe14^S&7mbXe?+Ca(y?#-7V2O4h4{VtDN*mX=T^xq@-L4uiU+bM zMlF7C{#on8nVOg%>H7oOjQJBQ`mQHybYv&r;K(Z+RJvnoaQse1OKVtlwCB&cXI2*U zw-fBeKBQg@EiF+h)2sYesXYlld9W{h%NY&#*^IZoE7&TvIH_c9S*0pIzd7a6gU~I( zd%q3(?|W|baKY%$4xnZyeN%L9R>t{;Z`I4Oe$ay5CD%=EYcJ zK6lN#>qDN*r3Ufq^`{<6MUy=2c5{4KykSddv#On>sIdPs3Eh6xGddg|i7Lc(9`}~i ziy_2QU@!d3Aw8amb+#NY%H|s0GH4f87uwvlilOVC^b1>)JCFBvuc-MZU>%lewsA#& z(YCYM`~kf4e_hBQv6khwCD{9ds5i#<#fo^f2c9zQuKNzXPW@Uv`c``&Bw%GDgR6bV zj*{zV?sz9K>*{n~de*qdE!|KtbbZ17TLwm^YK(Dhn^=|F2zncddT0H7DKRT3j_1i@ zhgk_>G2dZeQnGm(fSLC!1{eRdUo)OT72(ubA8yAbxG7zqbDT zT6OPx9)f!$lpcg$Ga~41A?iKKcfTlhK5T=9eozAr_WW7%dG-5l2L zCw`xNMbsPdWlby3>=$Y2UHwXC9wbrQ7q40zv@Q?rwHdwcG(tKi`7!gHPW{>TZT`6W@^`i~1eUX||FisUn55_8-^(2H{g^g8R&S}2S!MbG`cYeu-mYLcUygwoUE#E!3JQ>@8)_e+BKif!%<4VtFrsFl}F|LS5c zdZW#C$=WK|XWyIvg5D0I-p=bcv?rpb_gSpUqhyaQZz#FP_1v7sJ?~R`d1kT|YHG1voc+ z%8A=@pxn)~_!8aNVT;Apo!2;o&(NL!X3&(WMdXvy)01ZmFF`R|j8ek6S@TTsb{ zzZp~g+eOr?^CCBxoh$6{@N~_I&GPqBNB8NiJ2&e7z;YHTb7y6X(1C=|53VcljU@)&ywxHhy3V>mTvPte(4id9XQ6WldV7d^#cU?R zdhRtkzuuA)T63XJ#9D$m<};K0u87lSF~LS7$yMs=B@>ydP7{0DT0_EANv%&li+r%1 z-op}qGlmQ04HA=CR=`6-ySgTW7QyU-058wY#@hjZmmb^gSAH!PX)G5^h)ei&(T?B; z++$NO2K&r+KfFg9-wYk~HxAUHSKBOCEfHmLA#1^?vC$FhE8p!h!&Y%T4rAKBHg8aZ z^*Xz8VztDBn<70|G7Gwo>W6(K=w~N_MebryGgq&9_9oRb5wAv}M1w zE?{A|B(<nO2vQsXHb00T_H*Q!e{U&=(WT&;!GZm>thc-SI4VIWyP$nd;)%W#m zO^0rb%$4s38x*VR#h3i>K4mET+;G|89tIo>)Nz5|=%^P%du-6ka=qXj^OzOyL;dtw z0$$F`)L-8)z+-*zR#Mp+`H8a@69&uQpG*!;9{3fhX=Pm9+8!$8mvt|ei*HTG6(53L z9MjZ`;hT)TfQw=2{8;04H_!Yyp_}}a)byHdOR+`j#_)joG9@9?r{&_$c-}q_a!G=S$)ojIU4X%2x1rI+u^Bi$`=6nAByg1o!1igbqy}WlYQ*0MDc*}YI;yACz?0N4uSM(2dH%hn+M*b| zm+{q^j1{=|r}}q@sQ2`aWm)?a71suh3i<8TXINuiG+mw6y>b3*$C_)wr+zi_TFB;# zM4Q&ATE=zS=-DdmddV@F|BEHh)X*tde~uK z|IaRu?Zxvd6SOyJHi5jn*D)o0I2W zS5IdzlE>;l`x)Px1Uc?A<_MEWtp7$nV4wsQHKjcMFD2*~2VY+&UkBKqi2mpIKB&*r z&eO})kwo$p`|}>v7xl<|MwoiF-qlM#ThtOce#7nqrJP8 z|GnnUf0aJ?ceE4ZYPYMOE4Iyh-JjR)|4iGl4qqpay?C}S)gh4<{$bt6G06yEO1u8@ z0J)ER#mGywwZWg)^S?^a?r0wPKi2~o&pxgmK6cI|lDGu!JZT%x|8slm-_NrD)%ED# zrRDgu%TYTLiSrNZF14LA>kRoAO5(Bq*Z4j@W3!j195kY8?SKC4O!dY8&J=A<1MqjI zAeaOH6~2R@%?>^eGSZNG!*20UdpHo&E}92u9-w*Pzs&%za* zTK3=OFj_@4570b7^8n2Q|64tP=Pues^8n2QG!M``K=T0212hlNJV5gR%>y(K&^$o% z0L=q5570b7^8n2QG!M``K=T0212hlNJV5gR%>y(K&^$o%0L=q5570b7^8n2QG!M`` zK=T0212hlNJV5gR%>y(K&^$o%0L=q5570b7^8n2QG!M``K=T0212hlNJV5gR%>y(K z&^$o%0L=q5570b7^8n2QG!M``K=T0212hlNJV5gR%>y(K&^$o%0L=q5570b7^8n2Q zG!M``K=T0212hlNJV5gR%>y(K&^+*;^g#d2fBpUX;7mGS)z`;C+||?9&(7Ul+}+E; z&Dqu6N!-N8$w_^MwB!n3*B~b^XVDe1E9~4|cX@g_!yn1UYifGt!utwPh1%fnMUgM+ zGybg;HcDP4<(*mZ41a5!d_AV*;osHdqU2Rk@@B*HdQ<>cH6?Ej++TuwylN9Q(e@@tQH*}df_uE0Des}5mcc#R-a^S^ zhx=~;Uage8MR0$BoC9s7n$aZ7w)kgX!|=#9v|G70caR@IR6OkOMbp z(-0+ZB_$7S8m8o}g8Om+>-s>+6NGyixX0@wq^Md5kfY>%qPz!xHl8F;$s48Q!Jm*P zDNyo0Ly9rK2B-wEu5Xn0;LpO5RN)@$`cBCcfqO-`M;&97JW;q;qU4QJ^28udnUeQ| zk|z#%8gP#~CMbEWlyP2xhQKvn)n7(h9ftEC1~$HhZAF`L4B$A#*u`;%v5Dgd#|_3B z#umm6_CNMF_AkaU#x2Gv#wGS8#vS$_#ufG%#trrf_5sEL&I35^ah&70#&L|}_6dMv z6~`uy#b*GHKOAp3zHmH+g0Y8y!$24i2}A+MfD=G8a1uBL!~my(Sl|qB7B~mQ0|`JP za2`kkl7S0A3UCof1ug-Xfixf;xB_GVnZQ*b3&;kp0oQ>WKn`#d$OZC%TflAL4v-HN z0C#~x;2ux}6aytdDR3Wn06YX90cAirPyti|kAW(n8mIwkfjZy`P!Bu>8h~fObKnKg z2s8mNfo7lu*b4*zfxu<}=iVUb8(m2C0Cr#zzyWXqivccR3BUv_02Ttw01LnhltKTL z12|{n9DNKp4x9i^0{%b%5D4r8f`C1M8{iIj03%?-C}0Hl8-PZ*ZvvWuR^S!T26O>xE`1Q z+W|>H3c$ZXCa)3Ob04M@VfHI&0r~)|lah&4V#<7fZfB=AVAkIUR(AL)g&Y?JG z;yj1*&7vMPm30MI*&*OUX7zXbnpa{qX;(!>y9@qe^0L%?Wr~+yL4{#Xl3IoD{S@6sN#6W$gfmq-SkOo`N`U2%#|!WQD*$GI1z-a>08U^E-unrB1BQSB;0ACS zhy~(-MBqG-1Y7`)0VjZH;3RMW2nO~87JwB{4b%X&z(XJpxCPt>Bmg161=tNZ1GrAM z0U6+Dckq!15D)hwka9pO2dO-u1n>dMfCiumXaOq#ejoyL&4%j6$h37et&ISBIM=m@+0PX{Y zzye@Cup6)h?!!93}*z0SPCoySb<@Xc>-R5CGY?cg?I0P ztO&>kgn@C$4*`4u>if2EUj{q^9N--(C?^f%z&#=g;QQr31>gwJ6Clrn6sd$fRY=tU z6+jD61e5?};4#QCANK{+cQoPt(`WcUaIw;ZGPwRn`vROt(f7xI6TnemKM(-OfNU?M zIG0-hrU3S@IDq34;}XaIB)sJ50B(REz%nZUTq^_se8&4#fFK|a2m`ArDXuZ1fC#V#SPNj^NdwgN3)d}0 zKpjv6GyrT1+HC;n1M2}DKo8Ib&<|)M`p6i-ak3fM2y6g00b2kQ0AU zI*Jv z4uE-hk8#}rJOJ(k_kcp+E>HrL0!2VE<+%;gDxd;*1e600fieJ}D}l#A4Nwg<1NA@? zPzN*uwZJm~^D*xUC2fH8DexS40lWgRJeGY)Nn0Up0rms!0QU780Q>eefd0ok^yfJ6 z75EIi1^R&Zz&k)0!1ne7y1)Q11k9#94?;Qud<2Gp55Om26!-#s2fhJg00;0Bn4o~q zQ@}3(*F2n9ry;>L4DFf==^Oy}WwQW`4WjMT_we0F08`Avdwdt45sX`mQQVVZf8t(b zC8XGXaexV)anFY9x)|jdQ(U8QyfB?V` zECz57vjV_<;&Olo;0BfexL#u{;F^u^;64&r1qcFI7RSL_NHNZEy~e!~?vum-8OYlN zDYgsOLbOE>QrsKho?-)lYpyncXB8~~_Y&&>>}NwraSwxg58T6G8Qhbs0~zj5aG!$v z6MX>JX-*GL+`cOBn zxB->`%B%s*$G)=yqM)oGuoLcW0E{b?<1?nN0LHgHq;>#R?f~~L0P5KVI0H_ABY-;b zeg(h_;5)kkZlDrmUXbnq+$pIyq@I8WfHHh{FF*(2I0}R`2-pY6!ZXIn5x5TquunsP z0|53R=3)P03{aoL;64I4NXf&v&;-H(lwsc;2C)AQ0ighv#s0wlA*OgweIMVY*75fg z?K%b=2cm#Tfcg&BiT5bS6m?SDqXzd>IkpAmXzzOfV|Wn2`~jdJz16@ENPz`hf9Y8D40yG0nKqK%1$O4`N&wvKtDNqkQ0qTHS-~kW=)Bsh$W1tAQ z3seFXKsitbJOZ$99s>7)Q$Q(D0^9c-f`_EYozo}!)DcbSxTu0o3CsP5GD)q9PGa_N;U^a^lkB(le)ECXn3u!P^!Sno`I}XQLo4 zEh{cdJ}dHrSDS!;NgDp4SxchI)lIcrLdBoBQBiIljcxZKR?25mbnVHquyucibN zX-RQKaV7FOegQ~$I0e}bt=#qm%P5M=h|7>qDy$%hEY5hbB;dK|j0Bo0Yv<$O;=0$# zr+fGFPkq56Js^>XdZcDf8mR5NSZ?T+cbx%PMp9f>TuKSg7AV=XGVSeAv*pYnQ4*Je zYRD%7l$d*)XrDJ!Yyt`B5|@^d!t(*_5klXvDWn%O-edrYEYu@8vtvi^=;hY9G1hU+ z!+Nl(lH`3h`r!V(k7*fqIzEF$0ky#n!plRr1k-ds;iZf$9KkVt2o6S?9ysAfZ^z4ifAI!|@vT*&}HtAdvxWu=NOVviG)ga3dWxS~Y*R zM4&B5Z59$QNGj;n`aXwCV4t^ks>2K}gj;V-43HG)WdH*^OwC!J_GU0mE#Q84?&e|9U zDg_Z@Z$5dmfkYm92g<~Q1fzF?eRwH*0lOwW9n=Bdp80Z&KCn7t8alYsx|N;|ia;2^ zzVUAsu+pikNy&>V&g`Ix7W^d{fqGD4{3gRK&i!E{o%U^`(P+DGki#IK*9+UZYef8DZagnK>{-f zdUPBlTp-CxYE)KvY^pm`58B2uXC@8|IzAhwk4k;t?r;`Q@5cnX&Pbd1_@dX zej5M@dh~nBeccl}L*#J^tpVeHf&|xvlrPEJTmfCO#Jn7m!pxaTA_s=%Z6Ai;6uGID3cSk`Y1lz~|cI*;T75;%K?YNoo^ zaF4rxBiDn?AswRBla#pX{>C$zU+KYb^My0W+zf49`cD_33`_>t3(-&p zXJw6XZ;_(8A0k0QUVdPI3=)hTMZ?;U(n&mlAOWkzrKCt1AXyF)m1CDXr1^h50*N$v zN+gwo1pD;x;!XFJx*xu$l!1Dlfdp3>3A!`!Hg$Gj8mNu_s9lCvF z2~2))f&vNp?NH<6WpieUV?7kB{p`H$?A@J6)jwpJ;=~#;s!$vFUmd`T5R| zv$-IVL)#=t42(1NOh#tLwm#j%NiIX4VHbh~J(}tC;k3@H2@6U+Fc$tFdWXDf`aom2 zLEFC3{ML4jk`T}aBTigel5`v-=+Tqb9)1hRxYe*RRC>>ssBlA1vq zwzMf#V^FJi(h9U;B!UHeFsx9*-!5UZ+J?U$Bsd&Faup=#x9S1e>;Ac2!yv&~8CG<= z-Fsnff$y7}Ch zn!Eb+yv~T+YveNIsM3LK9*~4FY$bB+2uN@~01LeRJ@+|yIXbOTw~ta_o^4ImMs6woMu0w`?xN}G){@%? zzl^ZWw5N`7K*(@Y?UTPOumcIsNV???4)v?Q_<#h~3$)F}&R5LY-EP;J4ws^Z=Iym) z3&;{ja3^|ao|o$5!L8?uAri@BL6+p<>ft2jXiqAyOdtLA_zFa#l(-yu=DPvSga8h; zE90+RpTLdtDC|?Qo&u0yjJm1CMeb?rs3J?qe*29C>cKL9m@Q@D8{qHes+lq{6wzv# zScjlT7gAyNKM6TFe`Z38y)dL=XICS=33~x{u;L0z^2L-l?k=h9c=l|n4tN?@=jb%)UjA$2j=-HD!BGM|Ck=xKTk0F7KXvEI zssq$A|6H?J=~UrevcLYe0z$k&v9$88-A@V^zb-vMQ9$V+Zx(yO3ae4T1 z3X+u|NwYlrGxf@w)W0PAL4xi1y0-Y9^Sna-za*zYf+KjPsQJd1PG%?ml4O8n5lBc6 zgOhl;6x>;rQD&@Y===L*f0 zUdEM>e*UHH&sO_F8DY>i|EYZYb{m!lf0g;O5B_Z1pFO%67GS6<^psojPN9kNMshu5 zU;o)QHz*?jWs>ePwVaar-1S$PXprDMYNI9}IJ#{6&A%k+6p2E5zi?*GRMuaTf9{c} zyDJSEzAk`G2BRr2Td{MY*6&+YlM)ql3_&mR4+jnO|_@c*W_|LlYJFcdN3 ze*}h&z7b5}{%cnL=PsH$U;kP1&((}t2Igu%oEJv6zi~gWlIR2@3(iG2?*zEIJ383; zIBwMN^;H&Rn%NDKXLA1)Gvn04d7G)_djBRUgQqrF&5A*StJw;RT3-(L9V#HfnGa@I zH&;(bF)wG5P#_l*A5Ygqkl-E(>Zye?*iymht_^2yUq}K8<=pt^GOoT}_Pd=N{H_g2 z>bNfA$flG53w)e>z1;UYk%XnLG%S7ZKL!$*MbYdVFsyLRO545Hf8W%OJ-v zNO0Z>%XMm4tF-wZxs0T^0(_qb3C@X=4;6mYIc;^MNM_bKuK-W@^+eU z_DsW6iS0>BtmWC`QOHCtL!QaK-FAsN!a1w{SN)C(z4137!3hqu4Xv74gD>q?$glbl zGz}7p4`c;r)}P^V`_7$<7-d0%{R3s3(WCy}B<6Rn%sly9eaYI$l3^%=E4t~{x=nVh zcW`Z_lo_K))*o1O$=;?n^RF`AV(>5R?fjftR;td8*mrbh=Rn>cfW+0q)sOVeddrDl zzO`$i4D2Y-0&g)tpM4NJhs;E{GV-nQ)P}u{5<5Q^F*m1uFMJ>jF zGUDt`P=3IOrzuLA02fyW{C4`p}0fw&2g_)8+7`1ieH3_Vm}u^}nl(ozE_44fJcJK+D$ntpZ>*Wz0Fc z?RDRwt}BXDzd1lu1T zvtFtpQAC!&xefYLWM;f}E*JF%eD>};M5Nbp+#%*wy%ABwiW#|{U4(Znsj63Ne3%o7e0q;PY2 zXSV7t8t(hG$ zd3O1;5B_c&?rtG=V1GRzKC>sk5|z4g?WW6kdPk2!g!}>t#+w#XKI!_CGT4ujy#tcp zt{75i^>6E$ncrNUapEJ%7`^*m5VmG6#iO9@&!h1kNYR4%7kb;m=f064mx0j@r*{Bn zu}x*SO`1#WaRtOV5psWP0gPZNlD&_egOe}(Qq0jhr>MN256WQA!DxI8>%|O#XTZ-L-+AQJ!;OX$_+%+j1({AB< ziu1J+{L&5*+zF}h>DvUw8X8f204=oh+~;BE=ioB#|08_+P%L~c3Zb0z{@JG>A$#75YD?DEg~ja(1-8ous3!Vbk-fG?hVxqvZ@ zV33f#0eX=m-FjvIK$Sww~2fmp7A>P0Sm~UVVv4L%lCkjkmJ5oM^572Q?&p$1& zq0BqKjb8E&@(g9P5i2>f$B*OX_*DK|U2yU9ux+^tjgV#OfAvHg%o za8B_~+a8JpJX%4K@X^1v+4}LLH`W6^2V=p*)zj76&ezv>PE4>vf--(_ru-6F4`uLM zK=y*>sp)kuai@yw5BTj3NU%K-tXA&s;dIGl34B_RB8jQYSn#PL*gLu937n9ze{lN$ zXFc-pE)QtgsyZ{WEFOHEo!s?77|Kv)%M6gTxi@_YlazI6DO?dUTUS*=6q9atv9?KgK8vej<#52MBr{DCz>JQp8 ze!Fk%T24RJi0d3>4*BQl19|~|dypiZ(3vUo;9$aJua@ObC^NI+`opOJyTRAV1O6=o zM3})_(}l6C@T{o--=e{zmp~iFwQ%;rotv|_D|67rLk@cX?-p>(!~t0S&wa=ZD8mY6 zRCQ;f_xzUUpDmxB!&wYZhu~3NkYGJs1=m;{jIX4E1WppzbGv=T$Xn8rua2|)NV|@| z+)-k5E0jS?T;z6UNQizo3lhp+8E0gaQ2&Jr?mA`R6S^F!8_KYO#4vxlr^~8mJCwnm zgL<5Ne7t<%A3<~uyo>A*KRfd)0?d7|I>Rqe%c0EQejS4{e_j`a^=5kEyXU#X9P=|) zLOs~qpzRO4ThNvdW&W?`z64CF;@W?}7&S2>1~q~vU|b@}9riU!P=or!6-46_v8ShJ z=%F`yVP+5!BvIq8=u=b-iA#8*#<&|5(TIk)Jf8-mF%ndwi6ACXqrMn@zjLbYt-4E9 z_4N49_aDBGzV}w0rA}3yI(6z))sCR^@SJ^L-hAfa)K58jW*lZysoZRo>AzoJH2jEN zPI(b2BWZ4iJt%#JMAdq|-?zSbDFblhA)?eGXhd0vOUI7yaap z8UH%tze#TBS7g>Cc=u_24&_Pk?wk8I#y^6I_igIhFb112mn@~QK-Rb4mUYKJx}IiW zQXlMgxX=_UhkWm+|NFyF_maE;TY^7CYQ7bKAw9Zj`@y??@vOZ^0!H#?@cRQ>N2OSx z(*>sfYQ?(o8|JPDjMN9GP8F^9fFbYKnEmdUdGL8%G^>`D!<$GMjFeSpubp?ry;G+kg=zt9XPcwMsP@Ft z7x%uS&nINNP$7V>D$Y;8?ayI!e@{oWliU!lrxCqo%ZWU%Ouv# zw;q|gbkgj0l~VHZsSE?J=5X#)(Y>I|w%-qehseNXMv1ILq^LN;_?>fu@L4<^3DQRapRVqP; zJa+9%&+I#Y?mw~KD9d9`dEcl=onxPB5aUjUtn*Yr8DddQ390hDwOJ5kink_WcF;?uiRe^KN1 zd`O9Si$`Y+I!SOaeBk7}XTG?E>^Irx%(@rU8Ji!?4K0Q?N<(eDH%>YL!#Vz1GcC1t z{9d%UY4ocDe?wLp^=(t1CJhe z?eSlHx^JHp%0aW-0)Ds0?=D|{Z_*@`_&PEk*#VSQg%j4Q`@S?-o9kcA2@|X-Siex z$gVnZ-VG1Ezw`Rr6rJr(=KXC8N6)|Ts=v>_gX~esfmxynJ`@_wAnW9qMw+@_1Ukg^ z?JAiS3$uqq$XGaH^2Ofdf`%2mbtcT4RwlcO6m&fzIA*ZkoRD zpbi?doV=Oom{ZIxF|p5y{)15_m8�uez`KppoBr4!n^k4b93-$J~D!Y4InYCZzM5 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 diff --git a/web-ext/features/playback/watch.tsx b/web-ext/features/playback/watch.tsx new file mode 100644 index 00000000..ea80d2dc --- /dev/null +++ b/web-ext/features/playback/watch.tsx @@ -0,0 +1,570 @@ +/** @jsxImportSource dom-chef-jsx */ +import { + setupSegments, + SegmentButton, + SegmentPanel, + initMarkers, + toggleSegmentsLastUsed, + activateFreshSegments, + setActiveSegmentStart, + setActiveSegmentEnd, + nudgeActiveSegmentStart, + nudgeActiveSegmentEnd, + saveSegmentsShortcut, + activateNamedConfig, + getCachedNamedConfigs, +} from "@/features/segments/Segments"; +import { isTypingTarget, matchesBinding } from "@/lib/keybinding"; +import { WatchContext } from "@/app/background/context"; +import { Storage } from "@/lib/store"; +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; +import { applyWatchProfile } from "@/features/profiles/applyProfile"; +import { + getCustomProfiles, + getActiveProfile, + setActiveProfileId, +} from "@/features/profiles/profileStore"; +import { BUILT_IN_PRESETS } from "@/features/profiles/profiles"; +import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys"; +import { showPageToast } from "@/lib/pageToast"; +import { injectGearMenuEntry } from "@/features/profiles/gearMenu"; +import { getCachedPlaylist } from "@/features/playlist/cache"; +import { formatDuration } from "@/lib/duration"; + +// Each array is ordered most-likely-variant first, and resolveTarget walks it in +// order. Supporting a new YouTube layout means appending a selector here, nothing else. +const STRATEGIES = { + player: ["video"] as const, + + rightControls: [ + "div.ytp-right-controls", + ] as const, + + progressBar: [ + "div.ytp-progress-bar-container", + ] as const, + + panelHost: [ + "#above-the-fold", + "ytd-watch-flexy #below", + "#secondary-inner", + "#columns", + ] as const, + + settingsButton: [ + "button.ytp-settings-button", + ] as const, + + playbackRatePanel: [ + ".ytp-panel-animate-forward", + ".ytp-panel.ytp-panel-animate-forward", + ] as const, + + doubleTapSeek: [ + ".ytp-doubletap-ui-legacy", + ".ytp-doubletap-ui", + ] as const, +} as const; + +let player: HTMLVideoElement | undefined; +let playbackMenuButton: HTMLElement | undefined; +let preferences: Storage["preferences"]["watch"] | undefined; +let gearMenuEnabled = false; + +const onWatchPage = async (ctx: WatchContext) => { + const { store } = ctx; + preferences = store.preferences.watch; + gearMenuEnabled = !!(store.ui?.gearMenuEnabled); + if (!preferences) return; + + const playerResolution = await resolveTarget(STRATEGIES.player, { + stopOnDomReady: false, + }); + void setCapabilityStatus("watch.player", "watch", playerResolution); + if (!playerResolution.resolved) return; + player = playerResolution.element as HTMLVideoElement; + + player.playbackRate = parseFloat(preferences.defaultPlaybackRate.value); + const labels = document.querySelectorAll(".ytp-menuitem-label"); + if (labels.length !== 0) { + for (const label of labels) { + if (label.textContent === "Playback speed") { + const playbackMenuButton = label.parentNode as HTMLElement; + playbackMenuButton.children[2].textContent = + player.playbackRate === 1 + ? "Normal" + : `${Number(player.playbackRate.toFixed(2))}`; + break; + } + } + } + + document.removeEventListener("keydown", useShortcuts); + document.addEventListener("keydown", useShortcuts); + + const rightControlsResolution = await resolveTarget(STRATEGIES.rightControls); + void setCapabilityStatus("watch.rightControls", "watch", rightControlsResolution); + if (rightControlsResolution.resolved) { + rightControlsResolution.element.prepend(SegmentButton); + } + + // Must precede setupSegments: auto-loading a saved config renders into + // #tppng-sp-inner, which does not exist until the panel is in the DOM. + const panelHostResolution = await resolveTarget(STRATEGIES.panelHost, { + stopOnDomReady: false, + }); + if (panelHostResolution.resolved) { + document.getElementById("tppng-segment-panel")?.remove(); + (panelHostResolution.element as HTMLElement).prepend(SegmentPanel); + } + + const progressBarResolution = await resolveTarget(STRATEGIES.progressBar); + void setCapabilityStatus("watch.progressBar", "watch", progressBarResolution); + if (progressBarResolution.resolved) { + await setupSegments( + ctx.payload.videoId ?? undefined, + preferences.segments?.autoLoad ?? "off", + ); + if (player) { + initMarkers( + progressBarResolution.element as HTMLElement, + player, + ); + } + } + + const settingsResolution = await resolveTarget(STRATEGIES.settingsButton); + void setCapabilityStatus("watch.settingsButton", "watch", settingsResolution); + if (!settingsResolution.resolved) return; + const playerSettingsButton = settingsResolution.element as HTMLElement; + playerSettingsButton.removeEventListener("click", onSettingsMenu); + playerSettingsButton.addEventListener("click", onSettingsMenu); + + // Last, so profile overrides land on top of a fully initialised segment + // panel rather than being overwritten by its setup. + void applyWatchProfile(); + + // Remove-then-add keeps this single-registered across SPA navigations, which + // re-run this whole function against the same page. + chrome.storage.onChanged.removeListener(onProfileStoreChanged); + chrome.storage.onChanged.addListener(onProfileStoreChanged); + + const listId = new URL(window.location.href).searchParams.get("list"); + if (listId) { + void injectPlaylistRuntimeInWatchPanel(listId); + } +}; + +const WATCH_PANEL_RUNTIME_ID = "tppng-watch-playlist-runtime"; + +const WATCH_PLAYLIST_PANEL_STRATEGIES = [ + "#playlist-container .ytd-playlist-panel-renderer #header", + "ytd-playlist-panel-renderer #header", + "#secondary ytd-playlist-panel-renderer #header-title", + "#secondary ytd-playlist-panel-renderer", +] as const; + +async function injectPlaylistRuntimeInWatchPanel(playlistId: string): Promise { + const data = await getCachedPlaylist(playlistId); + if (!data) return; // Nothing cached until the user has opened the playlist page itself. + + const panelResolution = await resolveTarget(WATCH_PLAYLIST_PANEL_STRATEGIES); + if (!panelResolution.resolved) return; + + const header = panelResolution.element as HTMLElement; + + // SPA navigation re-runs this against a header that may already carry a badge. + header.querySelector(`#${WATCH_PANEL_RUNTIME_ID}`)?.remove(); + + const badge = document.createElement("div"); + badge.id = WATCH_PANEL_RUNTIME_ID; + badge.style.cssText = ` + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--yt-spec-text-secondary, rgba(255,255,255,0.7)); + margin-top: 4px; + font-family: "YouTube Sans","Roboto",sans-serif; + `; + + badge.innerHTML = ` + ⏱ ${formatDuration(data.totalRuntime)} + · + avg ${formatDuration(data.averageRuntime)} + `; + + header.appendChild(badge); +} + +const onProfileStoreChanged = ( + changes: Record, + area: string, +): void => { + if (area !== "local") return; + if (!(CHROME_STORAGE_LOCAL_KEY.PROFILE_STORE in changes)) return; + void applyWatchProfile(); +}; + +const onSettingsMenu = async (): Promise => { + if (!player) return; + + const menuItemLabelResolution = await resolveTarget([".ytp-menuitem-label"], { + stopOnDomReady: false, + }); + if (!menuItemLabelResolution.resolved) return; + const labels = document.querySelectorAll(".ytp-menuitem-label"); + if (labels.length === 0) return; + + for (const label of labels) { + if (label.textContent === "Playback speed") { + playbackMenuButton = label.parentNode as HTMLElement; + playbackMenuButton.children[2].textContent = + player.playbackRate === 1 + ? "Normal" + : `${Number(player.playbackRate.toFixed(2))}`; + + playbackMenuButton.removeEventListener("click", onPlaybackRateMenu); + playbackMenuButton.addEventListener("click", onPlaybackRateMenu); + break; + } + } + + if (gearMenuEnabled) { + const settingsMenu = document.querySelector( + ".ytp-settings-menu", + ) as HTMLElement | null; + if (settingsMenu) { + void injectGearMenuEntry(settingsMenu); + } + } +}; + +const onPlaybackRateMenu = async (): Promise => { + if (!player || !preferences) return; + if (preferences.customPlaybackRates.length === 0) return; + + const panelResolution = await resolveTarget(STRATEGIES.playbackRatePanel, { + stopOnDomReady: false, + }); + if (!panelResolution.resolved) return; + const playbackRatePanel = panelResolution.element as HTMLElement; + + const menuPanelOptions = playbackRatePanel.querySelector( + ".ytp-panel-options", + ) as HTMLElement | null; + if (menuPanelOptions) { + menuPanelOptions.style.display = "none"; + } + + replacePlaybackItems(playbackRatePanel); +}; + +const replacePlaybackItems = (playbackRatePanel: HTMLElement) => { + if (!player) return; + if (!preferences) return; + + const panelMenu = playbackRatePanel.querySelector(".ytp-panel-menu"); + if (!panelMenu) return; + + const currentRate = player.playbackRate; + const isPresetRate = preferences.customPlaybackRates.some( + (rate) => parseFloat(rate) === currentRate, + ); + + const playbackRateItems = preferences.customPlaybackRates.map( + (playbackRate) => { + const label = parseFloat(playbackRate) === 1 ? "Normal" : Number(playbackRate); + const isAriaChecked = + parseFloat(playbackRate) === currentRate ? "true" : "false"; + + return ( +
{ + const panelBackButton = document.querySelector( + ".ytp-panel-back-button", + ) as HTMLElement | null; + if (panelBackButton) { + panelBackButton.click(); + } + setPlaybackRate(Number(playbackRate)); + }} + > +
{label}
+
+ ); + }, + ); + + const customPlaybackRateItem = ( +
{ + const panelBackButton = document.querySelector( + ".ytp-panel-back-button", + ) as HTMLElement | null; + if (panelBackButton) { + panelBackButton.click(); + } + setPlaybackRate( + Number( + player!.getAttribute("data-tppng-playback-rate") ?? "1", + ), + ); + }} + > +
+ Custom ( + {Number(player!.getAttribute("data-tppng-playback-rate") ?? String(player!.playbackRate))} + ) +
+
+ ); + + panelMenu.replaceChildren(customPlaybackRateItem, ...playbackRateItems); +}; + +const useShortcuts = (event: KeyboardEvent): void => { + if (!player || !preferences) return; + + if (isTypingTarget(event.target)) return; + + if (preferences.nudgeLoopSegment) { + const cfg = preferences.nudgeLoopSegment; + const baseStep = Math.max(0.1, parseFloat(cfg.baseStep) || 1); + const multiplier = Math.max(1, parseFloat(cfg.multiplier) || 2); + const maxStep = Math.max(baseStep, parseFloat(cfg.maxStep) || 16); + + if (matchesBinding(event, cfg.startBackwardKey)) { + event.preventDefault(); + nudgeActiveSegmentStart("backward", baseStep, multiplier, maxStep); + return; + } + if (matchesBinding(event, cfg.startForwardKey)) { + event.preventDefault(); + nudgeActiveSegmentStart("forward", baseStep, multiplier, maxStep); + return; + } + if (matchesBinding(event, cfg.endForwardKey)) { + event.preventDefault(); + nudgeActiveSegmentEnd("forward", baseStep, multiplier, maxStep); + return; + } + if (matchesBinding(event, cfg.endBackwardKey)) { + event.preventDefault(); + nudgeActiveSegmentEnd("backward", baseStep, multiplier, maxStep); + return; + } + } + + if (matchesBinding(event, preferences.togglePlaybackRate.key)) { + setPlaybackRate( + player.playbackRate !== 1 + ? 1 + : Number(preferences.togglePlaybackRate.value), + ); + return; + } + + if (matchesBinding(event, preferences.seekBackward.key)) { + const value = Number(preferences.seekBackward.value); + player.currentTime -= value; + onDoubleTapSeek("back", value); + return; + } + + if (matchesBinding(event, preferences.seekForward.key)) { + const value = Number(preferences.seekForward.value); + player.currentTime += value; + onDoubleTapSeek("forward", value); + return; + } + + if (matchesBinding(event, preferences.increasePlaybackRate.key)) { + const value = Number(preferences.increasePlaybackRate.value); + const increasedPlaybackRate = Number((player.playbackRate + value).toFixed(2)); + if (increasedPlaybackRate <= 16) { + setPlaybackRate(increasedPlaybackRate); + } + return; + } + + if (matchesBinding(event, preferences.decreasePlaybackRate.key)) { + const value = Number(preferences.decreasePlaybackRate.value); + const decreasedPlaybackRate = Number((player.playbackRate - value).toFixed(2)); + if (decreasedPlaybackRate >= 0.0625) { + setPlaybackRate(decreasedPlaybackRate); + } + return; + } + + if (matchesBinding(event, preferences.toggleLoopSegment.key)) { + void toggleSegmentsLastUsed(); + return; + } + + if (matchesBinding(event, preferences.segments?.freshSlateKey ?? "Shift+Z")) { + activateFreshSegments(); + return; + } + + if (matchesBinding(event, preferences.setLoopSegmentBegin.key)) { + setActiveSegmentStart(); + return; + } + + if (matchesBinding(event, preferences.setLoopSegmentEnd.key)) { + setActiveSegmentEnd(); + return; + } + + if (preferences.saveLoopSegment?.key && matchesBinding(event, preferences.saveLoopSegment.key)) { + void saveSegmentsShortcut(); + return; + } + + // Reads a cache rather than storage: this runs on every keydown. + const namedConfigs = getCachedNamedConfigs(); + for (const config of namedConfigs) { + if (config.shortcutKey && matchesBinding(event, config.shortcutKey)) { + void activateNamedConfig(config); + return; + } + } + + if (preferences.cycleProfiles?.key && matchesBinding(event, preferences.cycleProfiles.key)) { + void cycleProfilesShortcut(); + return; + } +}; + +async function cycleProfilesShortcut(): Promise { + // getCustomProfiles, not getAllProfiles: the presets are prepended below, and + // getAllProfiles already includes them. + const customProfiles = await getCustomProfiles(); + const cycle: Array<{ id: string | null; name: string }> = [ + { id: null, name: "Default" }, + ...BUILT_IN_PRESETS.map((p) => ({ id: p.id, name: p.name })), + ...customProfiles.map((p) => ({ id: p.id, name: p.name })), + ]; + + const activeProfile = await getActiveProfile(); + const currentId = activeProfile?.id ?? null; + + const currentIdx = cycle.findIndex((c) => c.id === currentId); + const nextIdx = currentIdx === -1 ? 0 : (currentIdx + 1) % cycle.length; + const next = cycle[nextIdx]; + + await setActiveProfileId(next.id); + void applyWatchProfile(); // reads back the id set on the line above + showPageToast(`Profile: ${next.name}`); +} + + +let doubleTapSeekTimeout: ReturnType; +const onDoubleTapSeek = (dataSide: "back" | "forward", time: number): void => { + // Synchronous rather than resolveTarget: this fires on every seek keypress, + // and the overlay is guaranteed present once the player has loaded. + const selector = STRATEGIES.doubleTapSeek.find((s) => + document.querySelector(s), + ); + const doubleTapSeekElement = selector + ? (document.querySelector(selector) as HTMLElement | null) + : null; + if (doubleTapSeekElement) { + doubleTapSeekElement.setAttribute("data-side", dataSide); + doubleTapSeekElement.style.display = ""; + const doubleTapSeekLabel = doubleTapSeekElement.querySelector( + ".ytp-doubletap-tooltip-label", + ) as HTMLElement; + if (doubleTapSeekLabel) { + doubleTapSeekLabel.textContent = `${time} seconds`; + } + const staticCircle = document.querySelector( + ".ytp-doubletap-static-circle", + ) as HTMLElement; + if (staticCircle && dataSide === "back") { + staticCircle.style.top = "50%"; + staticCircle.style.left = "10%"; + staticCircle.style.width = "110px"; + staticCircle.style.height = "110px"; + staticCircle.style.transform = "translate(-14px, -40px)"; + } else if (staticCircle && dataSide === "forward") { + staticCircle.style.top = "50%"; + staticCircle.style.left = "80%"; + staticCircle.style.width = "110px"; + staticCircle.style.height = "110px"; + staticCircle.style.transform = "translate(-28px, -40px)"; + } + clearTimeout(doubleTapSeekTimeout); + doubleTapSeekTimeout = setTimeout(() => { + (doubleTapSeekElement as HTMLElement).setAttribute("data-side", "null"); + (doubleTapSeekElement as HTMLElement).style.display = "none"; + const doubleTapLabel = ( + doubleTapSeekElement as HTMLElement + ).querySelector(".ytp-doubletap-tooltip-label"); + if (doubleTapLabel) { + doubleTapLabel.textContent = "5 seconds"; + } + staticCircle.style.cssText = ""; + }, 500); + } +}; + +const setPlaybackRate = (rate: number): void => { + if (!player) return; + + const prevPlaybackRate = player.playbackRate.toFixed(2); + const prevPlaybackMenuItem = + document.querySelector( + `.tppng-playback-item[data-tppng-playback-rate="${prevPlaybackRate}"]`, + ) || document.querySelector("#tppng-playback-custom-item"); + if (prevPlaybackMenuItem) { + prevPlaybackMenuItem.ariaChecked = "false"; + } + + player.playbackRate = rate; + + const nextPlaybackRate = player.playbackRate.toFixed(2); + const nextPlaybackMenuItem: HTMLElement | null = + document.querySelector( + `.tppng-playback-item[data-tppng-playback-rate="${nextPlaybackRate}"]`, + ) || document.querySelector("#tppng-playback-custom-item"); + if (nextPlaybackMenuItem) { + nextPlaybackMenuItem.ariaChecked = "true"; + if (nextPlaybackMenuItem.id === "tppng-playback-custom-item") { + player.setAttribute( + "data-tppng-playback-rate", + player.playbackRate.toFixed(2), + ); + nextPlaybackMenuItem.style.display = ""; + const customPlaybackItemLabel = document.querySelector( + "#tppng-playback-custom-item > .ytp-menuitem-label", + ); + if (customPlaybackItemLabel) { + customPlaybackItemLabel.textContent = `Custom (${player.playbackRate})`; + } + } + } + + if (playbackMenuButton) { + playbackMenuButton.children[2].textContent = + player.playbackRate === 1 + ? "Normal" + : `${Number(player.playbackRate.toFixed(2))}`; + } +}; + +export default onWatchPage; diff --git a/web-ext/features/playlist/api.ts b/web-ext/features/playlist/api.ts new file mode 100644 index 00000000..8b99e0d3 --- /dev/null +++ b/web-ext/features/playlist/api.ts @@ -0,0 +1,10 @@ +const API_BASE = + process.env.NODE_ENV === "development" + ? "http://127.0.0.1:8787/api" + : "https://toppings.enry.ch/api"; + +export function fetchPlaylist(playlistId: string): Promise { + return fetch(`${API_BASE}/v1/playlist/${playlistId}`, { + headers: { Accept: "application/json" }, + }); +} diff --git a/web-ext/features/playlist/cache.ts b/web-ext/features/playlist/cache.ts new file mode 100644 index 00000000..22306af6 --- /dev/null +++ b/web-ext/features/playlist/cache.ts @@ -0,0 +1,58 @@ +// Exists to keep every playlist page visit from hitting the Toppings API. Users +// can force a fresh call by refreshing from the playlist UI, which evicts the entry. + +import type { ValidPlaylistPayload } from "@/app/background/context"; +import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys"; + +const CACHE_KEY_PREFIX = CHROME_STORAGE_LOCAL_KEY.PLAYLIST_CACHE_PREFIX; +const CACHE_TTL_MS = 30 * 60 * 1000; + +interface PlaylistCacheEntry { + playlistId: string; + data: ValidPlaylistPayload; + cachedAt: number; // Unix timestamp (ms) +} + +function storageKey(playlistId: string): string { + return `${CACHE_KEY_PREFIX}${playlistId}`; +} + +export async function getCachedPlaylist( + playlistId: string, +): Promise { + const key = storageKey(playlistId); + return new Promise((resolve) => { + chrome.storage.local.get(key, (result) => { + const entry = result[key] as PlaylistCacheEntry | undefined; + if (!entry) { resolve(null); return; } + if (Date.now() - entry.cachedAt > CACHE_TTL_MS) { + chrome.storage.local.remove(key); + resolve(null); + return; + } + resolve(entry.data); + }); + }); +} + +export async function setCachedPlaylist( + playlistId: string, + data: ValidPlaylistPayload, +): Promise { + const entry: PlaylistCacheEntry = { + playlistId, + data, + cachedAt: Date.now(), + }; + return new Promise((resolve) => { + chrome.storage.local.set({ [storageKey(playlistId)]: entry }, resolve); + }); +} + +export async function invalidateCachedPlaylist( + playlistId: string, +): Promise { + return new Promise((resolve) => { + chrome.storage.local.remove(storageKey(playlistId), resolve); + }); +} diff --git a/web-ext/src/content_scripts/pages/playlist.tsx b/web-ext/features/playlist/page.tsx similarity index 63% rename from web-ext/src/content_scripts/pages/playlist.tsx rename to web-ext/features/playlist/page.tsx index 6d2b140c..eba8f12b 100644 --- a/web-ext/src/content_scripts/pages/playlist.tsx +++ b/web-ext/features/playlist/page.tsx @@ -1,10 +1,12 @@ -import React from "dom-chef"; +/** @jsxImportSource dom-chef-jsx */ +import { formatDuration } from "@/lib/duration"; import elementReady from "element-ready"; import { InvalidPlaylistPayload, PlaylistContext, ValidPlaylistPayload, -} from "../../background/context"; +} from "@/app/background/context"; +import { invalidateCachedPlaylist } from "./cache"; const onPlaylistPage = async (ctx: PlaylistContext): Promise => { const { payload } = ctx; @@ -25,6 +27,40 @@ const onPlaylistPage = async (ctx: PlaylistContext): Promise => { const { averageRuntime, totalRuntime } = payload; + const playlistIdForRefresh = (payload as ValidPlaylistPayload & { playlistId?: string }).playlistId + ?? new URL(window.location.href).searchParams.get("list") + ?? ""; + + const handleRefresh = async (btn: HTMLElement) => { + if (!playlistIdForRefresh) return; + btn.textContent = "↻"; + btn.setAttribute("disabled", "true"); + await invalidateCachedPlaylist(playlistIdForRefresh); + // Reload the page so the background re-fetches fresh data. + window.location.reload(); + }; + + const refreshBtn = ( + + ) as HTMLButtonElement; + runtimeSection = (
=> {

Toppings

+ {refreshBtn}
Average Runtime: - {formatRuntime(averageRuntime)} + {formatDuration(averageRuntime)}
Total Runtime: - {formatRuntime(totalRuntime)} + {formatDuration(totalRuntime)}
@@ -73,49 +110,11 @@ const onPlaylistPage = async (ctx: PlaylistContext): Promise => { ); if (averageRuntimeElement && totalRuntimeElement) { - averageRuntimeElement.textContent = formatRuntime(averageRuntime); - totalRuntimeElement.textContent = formatRuntime(totalRuntime); + averageRuntimeElement.textContent = + formatDuration(averageRuntime); + totalRuntimeElement.textContent = formatDuration(totalRuntime); } } }; -/** - * Formats the runtime seconds into a human-readable string. - * - * @param {number} seconds - The seconds in the runtime. - * @returns {string} - A formatted string representing the runtime. - */ -function formatRuntime(seconds: number): string { - const days = Math.floor(seconds / 86400); // 1 day = 24 hours * 60 minutes * 60 seconds - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const remainingSeconds = seconds % 60; - - const parts = []; - - if (days > 0) { - parts.push(`${days} day${days !== 1 ? "s" : ""}`); - } - - if (hours > 0) { - parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`); - } - - if (minutes > 0) { - parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`); - } - - if (remainingSeconds > 0) { - parts.push( - `${remainingSeconds} second${remainingSeconds !== 1 ? "s" : ""}`, - ); - } - - if (parts.length === 0) { - parts.push("0 seconds"); - } - - return parts.join(", "); -} - export default onPlaylistPage; diff --git a/web-ext/features/profiles/CapabilityStatusRow.tsx b/web-ext/features/profiles/CapabilityStatusRow.tsx new file mode 100644 index 00000000..bc749600 --- /dev/null +++ b/web-ext/features/profiles/CapabilityStatusRow.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import { EXTENSION_VERSION } from "@/lib/version"; +import type { CapabilityStatus } from "./capabilityCache"; +import { addFeatureReport } from "./featureReports"; + +interface CapabilityStatusRowProps { + label: string; + primitiveId: string; // e.g. "watch.sidebar" + status: CapabilityStatus; +} + +const STATUS_CONFIG: Record< + CapabilityStatus, + { dot: string; text: string; hint: string } +> = { + supported: { + dot: "tw-bg-green-500", + text: "Working", + hint: "This feature is active on your YouTube.", + }, + unsupported: { + dot: "tw-bg-amber-500", + text: "Unavailable on your YouTube", + hint: "YouTube's UI on your account doesn't match any known layout for this feature.", + }, + untested: { + dot: "tw-bg-fg-subtle", + text: "Not yet checked", + hint: "Visit a YouTube watch page to check this feature.", + }, +}; + +export default function CapabilityStatusRow({ + label, + primitiveId, + status, +}: CapabilityStatusRowProps) { + const config = STATUS_CONFIG[status]; + const [reported, setReported] = useState(false); + + const handleReport = async () => { + // Stored before the issue is opened so the next update can tell the user + // their report started working, whether or not they submit the issue. + await addFeatureReport(primitiveId); + setReported(true); + + const title = encodeURIComponent( + `[Report] Feature unavailable: ${label} (${primitiveId})`, + ); + const body = encodeURIComponent( + [ + `**Feature:** ${label}`, + `**Primitive ID:** \`${primitiveId}\``, + `**Extension version:** ${EXTENSION_VERSION}`, + `**Browser:** ${navigator.userAgent}`, + "", + "### What happened", + "This feature shows as unavailable in my Toppings options page.", + "", + "### Steps to reproduce", + "1. Install Toppings", + "2. Open YouTube watch page", + "3. Open Options → Watch → Feature Availability", + `4. "${label}" shows as unavailable`, + ].join("\n"), + ); + window.open( + `https://github.com/enrych/toppings/issues/new?title=${title}&body=${body}&labels=Type%3A+Bug+%F0%9F%90%9B%2CStatus%3A+Needs+Investigation`, + "_blank", + ); + }; + + return ( +
+ {/* Label + status badge */} +
+ + {label} + +
+ + {config.text} +
+
+ + {/* Report button — only shown when unsupported */} + {status === "unsupported" && ( + + )} +
+ ); +} diff --git a/web-ext/features/profiles/applyProfile.ts b/web-ext/features/profiles/applyProfile.ts new file mode 100644 index 00000000..cdaada64 --- /dev/null +++ b/web-ext/features/profiles/applyProfile.ts @@ -0,0 +1,76 @@ +import { getActiveProfile } from "./profileStore"; +import type { ProfilePrimitiveConfig } from "./profiles"; +import { setSidebarVisible, resetSidebar } from "./primitives/watch/sidebar"; +import { setCommentsVisible, resetComments } from "./primitives/watch/comments"; +import { setEndCardsVisible, resetEndCards } from "./primitives/watch/endCards"; +import { setPlayerLayout, resetPlayerLayout } from "./primitives/watch/layout"; +import { setHomeThumbnailMode, resetHomeThumbnails } from "./primitives/home/thumbnails"; +import { setHomeFeedVisible, resetHomeFeed } from "./primitives/home/feed"; +import { setHomeShortsVisible, resetHomeShorts } from "./primitives/home/shorts"; +import { setSearchThumbnailMode, resetSearchThumbnails } from "./primitives/search/thumbnails"; +import { setSearchMetadataVisible, resetSearchMetadata } from "./primitives/search/metadata"; +import { setSearchShortsVisible, resetSearchShorts } from "./primitives/search/shorts"; +import { setShortsShelfVisible, resetShortsShelf } from "./primitives/shorts/shelf"; + +// Must run after the watch page handler's own setup, so profile overrides land +// on initialised components rather than being overwritten by them. +export async function applyWatchProfile( + overrideConfig?: ProfilePrimitiveConfig, +): Promise { + const config = + overrideConfig ?? (await getActiveProfile())?.primitives ?? null; + + // Reset first, unconditionally: entering, switching and leaving a profile all + // have to start from the same clean state, and no profile means reset only. + resetPlayerLayout(); + resetSidebar(); + resetComments(); + resetEndCards(); + resetHomeThumbnails(); + resetHomeFeed(); + resetHomeShorts(); + resetSearchThumbnails(); + resetSearchMetadata(); + resetSearchShorts(); + resetShortsShelf(); + + if (!config) return; + + if (config["watch.layout"] !== undefined) { + void setPlayerLayout(config["watch.layout"].value); + } + if (config["watch.sidebar"] !== undefined) { + void setSidebarVisible(config["watch.sidebar"].visible); + } + if (config["watch.comments"] !== undefined) { + void setCommentsVisible(config["watch.comments"].visible); + } + if (config["watch.endCards"] !== undefined) { + void setEndCardsVisible(config["watch.endCards"].visible); + } + + if (config["home.thumbnails"] !== undefined) { + void setHomeThumbnailMode(config["home.thumbnails"].mode); + } + if (config["home.feed"] !== undefined) { + void setHomeFeedVisible(config["home.feed"].visible); + } + if (config["home.shorts"] !== undefined) { + void setHomeShortsVisible(config["home.shorts"].visible); + } + + if (config["search.thumbnails"] !== undefined) { + void setSearchThumbnailMode(config["search.thumbnails"].mode); + } + if (config["search.metadata"] !== undefined) { + void setSearchMetadataVisible(config["search.metadata"].visible); + } + if (config["search.shorts"] !== undefined) { + void setSearchShortsVisible(config["search.shorts"].visible); + } + + if (config["shorts.shelf"] !== undefined) { + void setShortsShelfVisible(config["shorts.shelf"].visible); + } +} + diff --git a/web-ext/features/profiles/capabilityCache.ts b/web-ext/features/profiles/capabilityCache.ts new file mode 100644 index 00000000..aa8942b2 --- /dev/null +++ b/web-ext/features/profiles/capabilityCache.ts @@ -0,0 +1,92 @@ +import { EXTENSION_VERSION } from "@/lib/version"; +import { withStore } from "@/lib/indexedDb"; +import { BROWSER_STORAGE_IDB_STORE } from "@/lib/storageKeys"; +import type { PrimitiveResolution } from "./primitives/resolve"; + +export type CapabilityStatus = "supported" | "unsupported" | "untested"; + +export type PrimitiveScope = + | "watch" + | "home" + | "search" + | "shorts" + | "playlist"; + +export interface CapabilityCacheEntry { + primitiveId: string; // e.g. "watch.sidebar" + scope: PrimitiveScope; + status: CapabilityStatus; + resolvedStrategyIndex: number | null; + lastCheckedAt: number; // unix ms + // Stamped so an upgrade that adds selector strategies invalidates the entry: + // a primitive recorded as unsupported may resolve under the new version. + extensionVersion: string; +} + +export async function getCapabilityStatus( + primitiveId: string, +): Promise { + try { + const entry = await withStore( + BROWSER_STORAGE_IDB_STORE.CAPABILITY_CACHE, + "readonly", + (store) => store.get(primitiveId), + ); + if (!entry) return "untested"; + if (entry.extensionVersion !== EXTENSION_VERSION) return "untested"; + return entry.status; + } catch { + return "untested"; + } +} + +export async function setCapabilityStatus( + primitiveId: string, + scope: PrimitiveScope, + resolution: PrimitiveResolution, +): Promise { + const entry: CapabilityCacheEntry = { + primitiveId, + scope, + status: resolution.resolved ? "supported" : "unsupported", + resolvedStrategyIndex: resolution.resolved + ? resolution.strategyIndex + : null, + lastCheckedAt: Date.now(), + extensionVersion: EXTENSION_VERSION, + }; + try { + await withStore( + BROWSER_STORAGE_IDB_STORE.CAPABILITY_CACHE, + "readwrite", + (store) => store.put(entry), + ); + } catch { + // Swallowed: the cache is an optimisation, and a failed write only costs a + // re-resolution on the next page load. + } +} + +export async function getAllCapabilityEntries(): Promise< + CapabilityCacheEntry[] +> { + try { + return await withStore( + BROWSER_STORAGE_IDB_STORE.CAPABILITY_CACHE, + "readonly", + (store) => store.getAll(), + ); + } catch { + return []; + } +} + +export async function clearCapabilityCache(): Promise { + try { + await withStore(BROWSER_STORAGE_IDB_STORE.CAPABILITY_CACHE, "readwrite", (store) => + store.clear(), + ); + } catch { + // Swallowed for the same reason as setCapabilityStatus. + } +} diff --git a/web-ext/features/profiles/featureReports.ts b/web-ext/features/profiles/featureReports.ts new file mode 100644 index 00000000..6b18c04e --- /dev/null +++ b/web-ext/features/profiles/featureReports.ts @@ -0,0 +1,107 @@ +// Reports are kept locally and never sent anywhere. Their only consumer is the +// post-update re-scan, which flags a reported primitive that now resolves so the +// options page can tell the user it started working. + +import { getCurrentVersion } from "@/lib/version"; +import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys"; + +const REPORT_STORE_KEY = CHROME_STORAGE_LOCAL_KEY.FEATURE_REPORTS; +const RECOVERED_KEY = CHROME_STORAGE_LOCAL_KEY.FEATURE_RECOVERED; + +export interface FeatureReport { + primitiveId: string; + reportedAt: number; // Unix timestamp (ms) + reportedVersion: string; // Extension version at time of report +} + +export interface RecoveredFeature { + primitiveId: string; + recoveredAt: number; + dismissedAt: number | null; +} + +export async function getFeatureReports(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(REPORT_STORE_KEY, (result) => { + const stored = result[REPORT_STORE_KEY]; + resolve(Array.isArray(stored) ? (stored as FeatureReport[]) : []); + }); + }); +} + +export async function addFeatureReport(primitiveId: string): Promise { + const reports = await getFeatureReports(); + // One report per primitive: the button stays tappable and must not stack. + if (reports.some((r) => r.primitiveId === primitiveId)) return; + + const version = await getCurrentVersion(); + const newReport: FeatureReport = { + primitiveId, + reportedAt: Date.now(), + reportedVersion: version, + }; + + await new Promise((resolve) => { + chrome.storage.local.set( + { [REPORT_STORE_KEY]: [...reports, newReport] }, + resolve, + ); + }); +} + +export async function removeFeatureReport(primitiveId: string): Promise { + const reports = await getFeatureReports(); + const filtered = reports.filter((r) => r.primitiveId !== primitiveId); + await new Promise((resolve) => { + chrome.storage.local.set({ [REPORT_STORE_KEY]: filtered }, resolve); + }); +} + +// --------------------------------------------------------------------------- +// Recovered features (set by background on update) +// --------------------------------------------------------------------------- + +export async function getRecoveredFeatures(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(RECOVERED_KEY, (result) => { + const stored = result[RECOVERED_KEY]; + resolve(Array.isArray(stored) ? (stored as RecoveredFeature[]) : []); + }); + }); +} + +export async function markRecovered(primitiveId: string): Promise { + const existing = await getRecoveredFeatures(); + if (existing.some((r) => r.primitiveId === primitiveId && r.dismissedAt === null)) { + return; // Already pending dismissal — don't duplicate + } + const entry: RecoveredFeature = { + primitiveId, + recoveredAt: Date.now(), + dismissedAt: null, + }; + const filtered = existing.filter((r) => r.primitiveId !== primitiveId); + await new Promise((resolve) => { + chrome.storage.local.set( + { [RECOVERED_KEY]: [...filtered, entry] }, + resolve, + ); + }); +} + +export async function dismissRecovered(primitiveId: string): Promise { + const existing = await getRecoveredFeatures(); + const updated = existing.map((r) => + r.primitiveId === primitiveId + ? { ...r, dismissedAt: Date.now() } + : r, + ); + await new Promise((resolve) => { + chrome.storage.local.set({ [RECOVERED_KEY]: updated }, resolve); + }); +} + +export async function getUndismissedRecovered(): Promise { + const all = await getRecoveredFeatures(); + return all.filter((r) => r.dismissedAt === null); +} diff --git a/web-ext/features/profiles/gearMenu.ts b/web-ext/features/profiles/gearMenu.ts new file mode 100644 index 00000000..231644e9 --- /dev/null +++ b/web-ext/features/profiles/gearMenu.ts @@ -0,0 +1,285 @@ +// Rides on YouTube's own settings-panel structure, which every class name here +// depends on: .ytp-settings-menu > .ytp-panel > .ytp-panel-menu > .ytp-menuitem, +// where a sub-panel is an extra .ytp-panel sibling toggled by display. +// +// injectGearMenuEntry runs on every gear-menu open, so every mutation below has +// to reuse existing nodes rather than append a second copy. + +import { + getAllProfiles, + getActiveProfile, + setActiveProfileId, +} from "./profileStore"; +import { setSidebarVisible, resetSidebar } from "./primitives/watch/sidebar"; +import { setCommentsVisible, resetComments } from "./primitives/watch/comments"; +import { setEndCardsVisible, resetEndCards } from "./primitives/watch/endCards"; +import { applyWatchProfile } from "./applyProfile"; +import type { Profile } from "./profiles"; + +const TPPNG_MENU_ITEM_ID = "tppng-gear-menu-item"; +const TPPNG_PANEL_ID = "tppng-gear-panel"; + +interface PanelState { + sidebarVisible: boolean; + commentsVisible: boolean; + endCardsVisible: boolean; + profiles: Profile[]; + activeProfileId: string | null; +} + +function makeToggleItem( + label: string, + checked: boolean, + onChange: (next: boolean) => void, +): HTMLElement { + const item = document.createElement("div"); + item.className = "ytp-menuitem"; + item.setAttribute("role", "menuitemcheckbox"); + item.setAttribute("aria-checked", String(checked)); + item.setAttribute("tabindex", "0"); + + const labelEl = document.createElement("div"); + labelEl.className = "ytp-menuitem-label"; + labelEl.textContent = label; + + const content = document.createElement("div"); + content.className = "ytp-menuitem-content"; + + const toggle = document.createElement("div"); + toggle.className = "ytp-menuitem-toggle-checkbox"; + + content.appendChild(toggle); + item.appendChild(labelEl); + item.appendChild(content); + + const updateChecked = (val: boolean) => { + item.setAttribute("aria-checked", String(val)); + }; + + const handle = () => { + const next = item.getAttribute("aria-checked") !== "true"; + updateChecked(next); + onChange(next); + }; + item.addEventListener("click", handle); + item.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handle(); + } + }); + + return item; +} + +function makeSectionHeader(text: string): HTMLElement { + const el = document.createElement("div"); + el.className = "ytp-menuitem"; + el.style.cssText = + "opacity:0.5;pointer-events:none;font-size:11px;padding-top:8px;"; + const label = document.createElement("div"); + label.className = "ytp-menuitem-label"; + label.textContent = text; + el.appendChild(label); + return el; +} + +function makeProfileItem( + profile: Profile | null, + isActive: boolean, + onSelect: () => void, +): HTMLElement { + const item = document.createElement("div"); + item.className = "ytp-menuitem"; + item.setAttribute("role", "menuitemradio"); + item.setAttribute("aria-checked", String(isActive)); + item.setAttribute("tabindex", "0"); + + const label = document.createElement("div"); + label.className = "ytp-menuitem-label"; + label.textContent = profile ? profile.name : "Default (no profile)"; + item.appendChild(label); + + const content = document.createElement("div"); + content.className = "ytp-menuitem-content"; + item.appendChild(content); + + item.addEventListener("click", onSelect); + item.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }); + + return item; +} + +function buildToppingsPanel( + panelMenu: HTMLElement, + state: PanelState, + onClose: () => void, +): void { + panelMenu.innerHTML = ""; + + panelMenu.appendChild(makeSectionHeader("Watch page")); + + panelMenu.appendChild( + makeToggleItem("Recommendations sidebar", state.sidebarVisible, (next) => { + if (next) void setSidebarVisible(true); + else void setSidebarVisible(false); + }), + ); + + panelMenu.appendChild( + makeToggleItem("Comments", state.commentsVisible, (next) => { + if (next) void setCommentsVisible(true); + else void setCommentsVisible(false); + }), + ); + + panelMenu.appendChild( + makeToggleItem("End cards", state.endCardsVisible, (next) => { + if (next) void setEndCardsVisible(true); + else void setEndCardsVisible(false); + }), + ); + + panelMenu.appendChild(makeSectionHeader("Profile")); + + panelMenu.appendChild( + makeProfileItem(null, state.activeProfileId === null, async () => { + await setActiveProfileId(null); + void applyWatchProfile(); + onClose(); + }), + ); + + for (const profile of state.profiles) { + const isActive = profile.id === state.activeProfileId; + panelMenu.appendChild( + makeProfileItem(profile, isActive, async () => { + const next = isActive ? null : profile.id; + await setActiveProfileId(next); + void applyWatchProfile(); + onClose(); + }), + ); + } +} + +export async function injectGearMenuEntry( + settingsMenu: HTMLElement, +): Promise { + const mainPanel = settingsMenu.querySelector(".ytp-panel"); + if (!mainPanel) return; + const mainPanelMenu = mainPanel.querySelector(".ytp-panel-menu"); + if (!mainPanelMenu) return; + + let tppngMenuItem = settingsMenu.querySelector( + `#${TPPNG_MENU_ITEM_ID}`, + ) as HTMLElement | null; + + if (!tppngMenuItem) { + tppngMenuItem = document.createElement("div"); + tppngMenuItem.id = TPPNG_MENU_ITEM_ID; + tppngMenuItem.className = "ytp-menuitem"; + tppngMenuItem.setAttribute("role", "menuitem"); + tppngMenuItem.setAttribute("tabindex", "0"); + tppngMenuItem.setAttribute("aria-haspopup", "true"); + + const labelEl = document.createElement("div"); + labelEl.className = "ytp-menuitem-label"; + labelEl.textContent = "Toppings"; + tppngMenuItem.appendChild(labelEl); + + const content = document.createElement("div"); + content.className = "ytp-menuitem-content"; + // Inlined rather than imported: matches the right-arrow YouTube draws on its + // own sub-menu items, and must live in the page's own DOM to inherit its styles. + content.innerHTML = + ''; + tppngMenuItem.appendChild(content); + + mainPanelMenu.prepend(tppngMenuItem); + } + + let tppngPanel = settingsMenu.querySelector( + `#${TPPNG_PANEL_ID}`, + ) as HTMLElement | null; + + if (!tppngPanel) { + tppngPanel = document.createElement("div"); + tppngPanel.id = TPPNG_PANEL_ID; + tppngPanel.className = "ytp-panel"; + tppngPanel.style.display = "none"; + + const header = document.createElement("div"); + header.className = "ytp-panel-header-back"; + + const backBtn = document.createElement("button"); + backBtn.className = "ytp-panel-back-button"; + backBtn.setAttribute("aria-label", "Back"); + backBtn.innerHTML = + ''; + + const title = document.createElement("span"); + title.className = "ytp-panel-title"; + title.textContent = "Toppings"; + + header.appendChild(backBtn); + header.appendChild(title); + tppngPanel.appendChild(header); + + const panelMenu = document.createElement("div"); + panelMenu.className = "ytp-panel-menu"; + tppngPanel.appendChild(panelMenu); + + settingsMenu.appendChild(tppngPanel); + + backBtn.addEventListener("click", () => { + tppngPanel!.style.display = "none"; + (mainPanel as HTMLElement).style.display = ""; + }); + } + + const panelMenu = tppngPanel.querySelector( + ".ytp-panel-menu", + ) as HTMLElement | null; + if (!panelMenu) return; + + tppngMenuItem.onclick = async () => { + const [profiles, activeProfile] = await Promise.all([ + getAllProfiles(), + getActiveProfile(), + ]); + + // Read from the DOM rather than the profile store: another profile, a + // shortcut, or YouTube itself may have changed visibility since it was written. + const sidebarEl = document.querySelector( + "#secondary, ytd-watch-next-secondary-results-renderer", + ) as HTMLElement | null; + const commentsEl = document.querySelector( + "ytd-comments#comments, #comments", + ) as HTMLElement | null; + const endCardEl = document.querySelector( + ".ytp-ce-element, .ytp-endscreen-element", + ) as HTMLElement | null; + + const state: PanelState = { + sidebarVisible: !sidebarEl || sidebarEl.style.display !== "none", + commentsVisible: !commentsEl || commentsEl.style.display !== "none", + endCardsVisible: !endCardEl || endCardEl.style.display !== "none", + profiles, + activeProfileId: activeProfile?.id ?? null, + }; + + buildToppingsPanel(panelMenu, state, () => { + tppngPanel!.style.display = "none"; + (mainPanel as HTMLElement).style.display = ""; + }); + + (mainPanel as HTMLElement).style.display = "none"; + tppngPanel!.style.display = ""; + }; +} diff --git a/web-ext/features/profiles/importExport.ts b/web-ext/features/profiles/importExport.ts new file mode 100644 index 00000000..22e5a133 --- /dev/null +++ b/web-ext/features/profiles/importExport.ts @@ -0,0 +1,167 @@ +import type { Profile, ProfilePrimitiveConfig } from "./profiles"; + +export function exportProfile(profile: Profile): void { + const exportData = { + $schema: + "https://toppings.greenstitch.studio/profile-schema/v1.json", + name: profile.name, + primitives: profile.primitives, + exportedAt: new Date().toISOString(), + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `toppings-profile-${slugify(profile.name)}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +export interface ImportResult { + ok: true; + name: string; + primitives: ProfilePrimitiveConfig; +} + +export interface ImportError { + ok: false; + message: string; +} + +export async function importProfileFromFile( + file: File, +): Promise { + if (!file.name.endsWith(".json") && file.type !== "application/json") { + return { ok: false, message: "File must be a .json file." }; + } + + let raw: string; + try { + raw = await file.text(); + } catch { + return { ok: false, message: "Could not read the file." }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, message: "File is not valid JSON." }; + } + + return validateProfileJson(parsed); +} + +// Accepts both a full export and a bare { name, primitives } object, so a +// hand-written config is importable without the $schema wrapper. +function validateProfileJson(data: unknown): ImportResult | ImportError { + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return { ok: false, message: "Expected a JSON object at the top level." }; + } + + const obj = data as Record; + + const name = + typeof obj.name === "string" && obj.name.trim() + ? obj.name.trim() + : null; + if (!name) { + return { + ok: false, + message: 'Missing or empty "name" field.', + }; + } + if (name.length > 40) { + return { ok: false, message: "Profile name must be 40 characters or fewer." }; + } + + if ( + typeof obj.primitives !== "object" || + obj.primitives === null || + Array.isArray(obj.primitives) + ) { + return { + ok: false, + message: '"primitives" must be an object.', + }; + } + + const primitives = obj.primitives as Record; + const validated: ProfilePrimitiveConfig = {}; + const errors: string[] = []; + + for (const [key, value] of Object.entries(primitives)) { + const error = validatePrimitiveEntry(key, value, validated); + if (error) errors.push(error); + } + + if (errors.length > 0) { + return { + ok: false, + message: `Invalid primitive values:\n${errors.slice(0, 5).join("\n")}`, + }; + } + + return { ok: true, name, primitives: validated }; +} + +const VALID_PLAYER_LAYOUTS = new Set(["default", "no-video"]); +const VALID_THUMBNAIL_MODES = new Set(["show", "hide", "blur"]); + +function validatePrimitiveEntry( + key: string, + value: unknown, + out: ProfilePrimitiveConfig, +): string | null { + if (typeof value !== "object" || value === null) { + return `"${key}": value must be an object`; + } + const v = value as Record; + + switch (key) { + case "watch.layout": + if (!VALID_PLAYER_LAYOUTS.has(v.value as string)) { + return `"${key}.value" must be one of: ${[...VALID_PLAYER_LAYOUTS].join(", ")}`; + } + out["watch.layout"] = { value: v.value as "default" | "no-video" }; + return null; + + case "watch.sidebar": + case "watch.comments": + case "watch.endCards": + case "home.feed": + case "home.shorts": + case "search.metadata": + case "search.shorts": + case "shorts.shelf": + if (typeof v.visible !== "boolean") { + return `"${key}.visible" must be a boolean`; + } + (out as Record)[key] = { visible: v.visible }; + return null; + + case "home.thumbnails": + case "search.thumbnails": + if (!VALID_THUMBNAIL_MODES.has(v.mode as string)) { + return `"${key}.mode" must be one of: ${[...VALID_THUMBNAIL_MODES].join(", ")}`; + } + (out as Record)[key] = { mode: v.mode as "show" | "hide" | "blur" }; + return null; + + default: + // Skipped rather than rejected: a file written by a newer version must + // still import on an older one, minus the keys it does not know. + return null; + } +} + +function slugify(str: string): string { + return str + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} diff --git a/web-ext/features/profiles/nativeSettings.ts b/web-ext/features/profiles/nativeSettings.ts new file mode 100644 index 00000000..cb0d2db1 --- /dev/null +++ b/web-ext/features/profiles/nativeSettings.ts @@ -0,0 +1,479 @@ +import { + getAllProfiles, + getActiveProfile, + setActiveProfileId, +} from "./profileStore"; +import { applyWatchProfile } from "./applyProfile"; +import type { Profile } from "./profiles"; +import { setCommentsVisible } from "./primitives/watch/comments"; +import { setEndCardsVisible } from "./primitives/watch/endCards"; +import { setSidebarVisible } from "./primitives/watch/sidebar"; + +const SIDEBAR_LINK_ID = "tppng-native-sidebar-link"; +const OVERLAY_ID = "tppng-native-settings-overlay"; + +const SIDEBAR_STRATEGIES = [ + "ytd-guide-renderer #sections", + "ytd-guide-renderer", + "#guide-inner-content", + "tp-yt-app-drawer #guide-inner-content", +] as const; + +// Every colour reads a --yt-spec-* token with a hard-coded fallback, so the +// overlay follows YouTube's own theme instead of shipping a second one. +const YT_STYLES = ` +#${OVERLAY_ID} { + position: fixed; + inset: 0; + z-index: 100000; + display: none; + overflow-y: auto; + background: var(--yt-spec-base-background, #0f0f0f); + color: var(--yt-spec-text-primary, #fff); + font-family: "YouTube Sans", "Roboto", sans-serif; + font-size: 14px; + padding: 0; +} +#${OVERLAY_ID}.tppng-visible { + display: block; +} +#${OVERLAY_ID} .tppng-ns-header { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + border-bottom: 1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1)); + position: sticky; + top: 0; + background: var(--yt-spec-base-background, #0f0f0f); + z-index: 1; +} +#${OVERLAY_ID} .tppng-ns-back { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + background: none; + border: none; + color: inherit; + padding: 0; +} +#${OVERLAY_ID} .tppng-ns-back:hover { + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1)); +} +#${OVERLAY_ID} .tppng-ns-title { + font-size: 20px; + font-weight: 600; +} +#${OVERLAY_ID} .tppng-ns-body { + max-width: 900px; + margin: 0 auto; + padding: 24px 24px 64px; +} +#${OVERLAY_ID} .tppng-ns-section { + margin-bottom: 32px; +} +#${OVERLAY_ID} .tppng-ns-section-title { + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--yt-spec-text-secondary, rgba(255,255,255,0.7)); + margin-bottom: 12px; +} +#${OVERLAY_ID} .tppng-ns-card { + background: var(--yt-spec-raised-background, #212121); + border-radius: 12px; + overflow: hidden; +} +#${OVERLAY_ID} .tppng-ns-row { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,0.08)); + cursor: pointer; + transition: background 0.1s; +} +#${OVERLAY_ID} .tppng-ns-row:last-child { + border-bottom: none; +} +#${OVERLAY_ID} .tppng-ns-row:hover { + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.08)); +} +#${OVERLAY_ID} .tppng-ns-row-label { + flex: 1; + font-size: 14px; + font-weight: 500; +} +#${OVERLAY_ID} .tppng-ns-row-desc { + font-size: 12px; + color: var(--yt-spec-text-secondary, rgba(255,255,255,0.6)); + margin-top: 2px; +} +#${OVERLAY_ID} .tppng-ns-toggle { + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.2)); + position: relative; + flex-shrink: 0; + transition: background 0.15s; +} +#${OVERLAY_ID} .tppng-ns-toggle.on { + background: var(--yt-spec-call-to-action, #3ea6ff); +} +#${OVERLAY_ID} .tppng-ns-toggle::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s; +} +#${OVERLAY_ID} .tppng-ns-toggle.on::after { + transform: translateX(16px); +} +#${OVERLAY_ID} .tppng-ns-profile-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.1s; + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1)); + color: var(--yt-spec-text-primary, #fff); +} +#${OVERLAY_ID} .tppng-ns-profile-chip.active { + background: var(--yt-spec-call-to-action, #3ea6ff); + color: #fff; +} +#${OVERLAY_ID} .tppng-ns-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 16px; +} +#${OVERLAY_ID} .tppng-ns-open-options { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: 20px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--yt-spec-10-percent-layer, rgba(255,255,255,0.2)); + background: none; + color: inherit; + transition: background 0.1s; +} +#${OVERLAY_ID} .tppng-ns-open-options:hover { + background: var(--yt-spec-10-percent-layer, rgba(255,255,255,0.08)); +} +`; + +let overlayEl: HTMLElement | null = null; +let stylesInjected = false; + +function injectStyles() { + if (stylesInjected) return; + const style = document.createElement("style"); + style.id = "tppng-native-settings-styles"; + style.textContent = YT_STYLES; + document.head.appendChild(style); + stylesInjected = true; +} + +function makeToggle(on: boolean): HTMLElement { + const el = document.createElement("div"); + el.className = `tppng-ns-toggle${on ? " on" : ""}`; + return el; +} + +async function renderOverlay(body: HTMLElement): Promise { + body.innerHTML = ""; + + const [profiles, activeProfile] = await Promise.all([ + getAllProfiles(), + getActiveProfile(), + ]); + const activeId = activeProfile?.id ?? null; + + const profileSection = document.createElement("div"); + profileSection.className = "tppng-ns-section"; + + const profileTitle = document.createElement("div"); + profileTitle.className = "tppng-ns-section-title"; + profileTitle.textContent = "Active Profile"; + profileSection.appendChild(profileTitle); + + const chips = document.createElement("div"); + chips.className = "tppng-ns-card tppng-ns-chips"; + + const defaultChip = makeProfileChip(null, activeId === null, async () => { + await setActiveProfileId(null); + void applyWatchProfile(); + void renderOverlay(body); + }); + chips.appendChild(defaultChip); + + for (const p of profiles) { + const isActive = p.id === activeId; + chips.appendChild( + makeProfileChip(p, isActive, async () => { + const next = isActive ? null : p.id; + await setActiveProfileId(next); + void applyWatchProfile(); + void renderOverlay(body); + }), + ); + } + profileSection.appendChild(chips); + body.appendChild(profileSection); + + const primSection = document.createElement("div"); + primSection.className = "tppng-ns-section"; + + const primTitle = document.createElement("div"); + primTitle.className = "tppng-ns-section-title"; + primTitle.textContent = "Watch Page"; + primSection.appendChild(primTitle); + + const primCard = document.createElement("div"); + primCard.className = "tppng-ns-card"; + + const sidebarEl = document.querySelector( + "#secondary, ytd-watch-next-secondary-results-renderer", + ) as HTMLElement | null; + const commentsEl = document.querySelector( + "ytd-comments#comments, #comments", + ) as HTMLElement | null; + const endCardEl = document.querySelector( + ".ytp-ce-element, .ytp-endscreen-element", + ) as HTMLElement | null; + + primCard.appendChild( + makePrimRow( + "Recommendations sidebar", + "Show or hide the right-side recommendations", + !sidebarEl || sidebarEl.style.display !== "none", + async (next) => { + if (next) void setSidebarVisible(true); + else void setSidebarVisible(false); + }, + ), + ); + + primCard.appendChild( + makePrimRow( + "Comments", + "Show or hide the comments section", + !commentsEl || commentsEl.style.display !== "none", + async (next) => { + void setCommentsVisible(next); + }, + ), + ); + + primCard.appendChild( + makePrimRow( + "End cards", + "Show or hide video end card overlays", + !endCardEl || endCardEl.style.display !== "none", + async (next) => { + void setEndCardsVisible(next); + }, + ), + ); + + primSection.appendChild(primCard); + body.appendChild(primSection); + + const linkSection = document.createElement("div"); + linkSection.className = "tppng-ns-section"; + + const openBtn = document.createElement("button"); + openBtn.className = "tppng-ns-open-options"; + openBtn.textContent = "Open full settings ↗"; + openBtn.addEventListener("click", () => { + chrome.runtime.sendMessage({ type: "OPEN_OPTIONS" }); + }); + linkSection.appendChild(openBtn); + body.appendChild(linkSection); +} + +function makeProfileChip( + profile: Profile | null, + isActive: boolean, + onClick: () => void, +): HTMLElement { + const chip = document.createElement("button"); + chip.className = `tppng-ns-profile-chip${isActive ? " active" : ""}`; + chip.textContent = profile ? profile.name : "Default"; + chip.addEventListener("click", onClick); + return chip; +} + +function makePrimRow( + label: string, + desc: string, + on: boolean, + onChange: (next: boolean) => void, +): HTMLElement { + const row = document.createElement("div"); + row.className = "tppng-ns-row"; + + const text = document.createElement("div"); + text.style.flex = "1"; + + const labelEl = document.createElement("div"); + labelEl.className = "tppng-ns-row-label"; + labelEl.textContent = label; + + const descEl = document.createElement("div"); + descEl.className = "tppng-ns-row-desc"; + descEl.textContent = desc; + + text.appendChild(labelEl); + text.appendChild(descEl); + + const toggle = makeToggle(on); + + row.appendChild(text); + row.appendChild(toggle); + + let current = on; + row.addEventListener("click", () => { + current = !current; + toggle.classList.toggle("on", current); + onChange(current); + }); + + return row; +} + +function getOrCreateOverlay(): HTMLElement { + let el = document.getElementById(OVERLAY_ID); + if (el) return el as HTMLElement; + + el = document.createElement("div"); + el.id = OVERLAY_ID; + + const header = document.createElement("div"); + header.className = "tppng-ns-header"; + + const backBtn = document.createElement("button"); + backBtn.className = "tppng-ns-back"; + backBtn.setAttribute("aria-label", "Back to YouTube"); + backBtn.innerHTML = + ''; + backBtn.addEventListener("click", hideOverlay); + + const titleEl = document.createElement("div"); + titleEl.className = "tppng-ns-title"; + titleEl.textContent = "Toppings Settings"; + + header.appendChild(backBtn); + header.appendChild(titleEl); + el.appendChild(header); + + const body = document.createElement("div"); + body.className = "tppng-ns-body"; + el.appendChild(body); + + document.body.appendChild(el); + overlayEl = el as HTMLElement; + return overlayEl; +} + +function showOverlay(): void { + const el = getOrCreateOverlay(); + el.classList.add("tppng-visible"); + const body = el.querySelector(".tppng-ns-body") as HTMLElement; + void renderOverlay(body); +} + +function hideOverlay(): void { + if (overlayEl) { + overlayEl.classList.remove("tppng-visible"); + } +} + +function injectSidebarLink(): void { + if (document.getElementById(SIDEBAR_LINK_ID)) return; + + let container: HTMLElement | null = null; + for (const sel of SIDEBAR_STRATEGIES) { + container = document.querySelector(sel) as HTMLElement | null; + if (container) break; + } + if (!container) return; + + const link = document.createElement("div"); + link.id = SIDEBAR_LINK_ID; + link.style.cssText = ` + display: flex; + align-items: center; + gap: 16px; + padding: 0 24px; + height: 40px; + cursor: pointer; + border-radius: 10px; + font-family: "YouTube Sans","Roboto",sans-serif; + font-size: 14px; + font-weight: 400; + color: var(--yt-spec-text-primary, #fff); + margin: 4px 12px; + transition: background 0.1s; + `; + + link.innerHTML = ` + + + + Toppings + `; + + link.addEventListener("mouseenter", () => { + link.style.background = + "var(--yt-spec-10-percent-layer, rgba(255,255,255,0.1))"; + }); + link.addEventListener("mouseleave", () => { + link.style.background = ""; + }); + link.addEventListener("click", showOverlay); + + container.appendChild(link); +} + +function removeSidebarLink(): void { + document.getElementById(SIDEBAR_LINK_ID)?.remove(); +} + +export function setupNativeSettings(enabled: boolean): void { + if (!enabled) { + removeSidebarLink(); + hideOverlay(); + return; + } + + injectStyles(); + + // One delayed retry: on a cold SPA navigation the guide renderer often does + // not exist yet, and there is no event that reliably fires once it does. + injectSidebarLink(); + if (!document.getElementById(SIDEBAR_LINK_ID)) { + setTimeout(injectSidebarLink, 1500); + } +} diff --git a/web-ext/features/profiles/primitives/home/feed.ts b/web-ext/features/profiles/primitives/home/feed.ts new file mode 100644 index 00000000..e96e4620 --- /dev/null +++ b/web-ext/features/profiles/primitives/home/feed.ts @@ -0,0 +1,22 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; + +const STRATEGIES = [ + "ytd-rich-grid-renderer", + "ytd-browse[page-subtype='home'] ytd-rich-grid-renderer", + "#contents.ytd-rich-grid-renderer", +] as const; + +export async function setHomeFeedVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("home.feed", "home", resolution); + if (!resolution.resolved) return; + + (resolution.element as HTMLElement).style.display = visible ? "" : "none"; +} + +export function resetHomeFeed(): void { + const selectors = STRATEGIES.join(", "); + const el = document.querySelector(selectors) as HTMLElement | null; + if (el) el.style.display = ""; +} diff --git a/web-ext/features/profiles/primitives/home/shorts.ts b/web-ext/features/profiles/primitives/home/shorts.ts new file mode 100644 index 00000000..adac5ddc --- /dev/null +++ b/web-ext/features/profiles/primitives/home/shorts.ts @@ -0,0 +1,23 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; + +const STRATEGIES = [ + "ytd-rich-section-renderer:has(ytd-rich-shelf-renderer[is-shorts])", + "ytd-rich-shelf-renderer[is-shorts]", + "#shorts-container", + "ytd-reel-shelf-renderer", +] as const; + +export async function setHomeShortsVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("home.shorts", "home", resolution); + if (!resolution.resolved) return; + + (resolution.element as HTMLElement).style.display = visible ? "" : "none"; +} + +export function resetHomeShorts(): void { + const selectors = STRATEGIES.join(", "); + const el = document.querySelector(selectors) as HTMLElement | null; + if (el) el.style.display = ""; +} diff --git a/web-ext/features/profiles/primitives/home/thumbnails.ts b/web-ext/features/profiles/primitives/home/thumbnails.ts new file mode 100644 index 00000000..7c0fc127 --- /dev/null +++ b/web-ext/features/profiles/primitives/home/thumbnails.ts @@ -0,0 +1,45 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; +import type { ThumbnailMode } from "@/features/profiles/profiles"; + +const STRATEGIES = [ + "ytd-rich-grid-renderer ytd-rich-item-renderer img#img", + "ytd-rich-grid-renderer ytd-thumbnail img", + "#contents ytd-rich-item-renderer yt-image img", +] as const; + +const BLUR_FILTER = "blur(12px)"; + +let lastMode: ThumbnailMode = "show"; + +export async function setHomeThumbnailMode(mode: ThumbnailMode): Promise { + lastMode = mode; + + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("home.thumbnails", "home", resolution); + + applyHomeThumbnailMode(mode); +} + +// Synchronous by design: the feed re-renders constantly, so this is called +// repeatedly and cannot afford to await a resolveTarget on each pass. +export function applyHomeThumbnailMode(mode: ThumbnailMode): void { + const selector = [ + "ytd-rich-grid-renderer ytd-rich-item-renderer img#img", + "ytd-rich-grid-renderer ytd-thumbnail img", + "#contents ytd-rich-item-renderer yt-image img", + ].join(", "); + + const images = document.querySelectorAll(selector) as NodeListOf; + for (const img of images) { + img.style.filter = mode === "blur" ? BLUR_FILTER : ""; + img.style.visibility = mode === "hide" ? "hidden" : ""; + } +} + +export function resetHomeThumbnails(): void { + lastMode = "show"; + applyHomeThumbnailMode("show"); +} + +export { lastMode as currentHomeThumbnailMode }; diff --git a/web-ext/features/profiles/primitives/resolve.ts b/web-ext/features/profiles/primitives/resolve.ts new file mode 100644 index 00000000..deddf498 --- /dev/null +++ b/web-ext/features/profiles/primitives/resolve.ts @@ -0,0 +1,68 @@ +import elementReady from "element-ready"; + +export type PrimitiveResolution = + | { resolved: true; element: Element; strategyIndex: number } + | { resolved: false; element: null; strategyIndex: null }; + +export type PrimitiveStrategy = string; + +export interface ResolveOptions { + // Defaults to false: YouTube is a SPA, so the target often appears long after + // DOMContentLoaded has fired. + stopOnDomReady?: boolean; + timeout?: number; +} + +// Callers should record the result in the capability cache — that is what lets the +// options UI report a primitive as unsupported instead of failing silently. +export async function resolveTarget( + strategies: readonly PrimitiveStrategy[], + options: ResolveOptions = {}, +): Promise { + const { stopOnDomReady = false, timeout = 10_000 } = options; + + if (strategies.length === 0) { + return { resolved: false, element: null, strategyIndex: null }; + } + + for (let i = 0; i < strategies.length; i++) { + const el = document.querySelector(strategies[i]); + if (el) return { resolved: true, element: el, strategyIndex: i }; + } + + return new Promise((resolve) => { + let settled = false; + + const settle = (resolution: PrimitiveResolution): void => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(resolution); + } + }; + + const timer = setTimeout( + () => settle({ resolved: false, element: null, strategyIndex: null }), + timeout, + ); + + // element-ready v7 takes no AbortSignal, so losing watchers cannot be + // cancelled — the `settled` flag is what makes them inert instead. + const watchers = strategies.map((selector, i) => + elementReady(selector, { stopOnDomReady }) + .then((el) => { + if (el) { + settle({ resolved: true, element: el, strategyIndex: i }); + } + }) + .catch(() => { + // Swallowed deliberately: one strategy failing is expected, and only + // every strategy failing counts as unresolved. + }), + ); + + Promise.allSettled(watchers).then(() => { + settle({ resolved: false, element: null, strategyIndex: null }); + }); + }); +} diff --git a/web-ext/features/profiles/primitives/search/metadata.ts b/web-ext/features/profiles/primitives/search/metadata.ts new file mode 100644 index 00000000..77637e1a --- /dev/null +++ b/web-ext/features/profiles/primitives/search/metadata.ts @@ -0,0 +1,32 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; + +const STRATEGIES = [ + "ytd-search ytd-video-renderer #metadata-line", + "ytd-search ytd-video-renderer .ytd-video-meta-block", + "#contents.ytd-section-list-renderer ytd-video-renderer #metadata-line", +] as const; + +export async function setSearchMetadataVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("search.metadata", "search", resolution); + + applySearchMetadataVisible(visible); +} + +export function applySearchMetadataVisible(visible: boolean): void { + const selector = [ + "ytd-search ytd-video-renderer #metadata-line", + "ytd-search ytd-video-renderer .ytd-video-meta-block", + "#contents.ytd-section-list-renderer ytd-video-renderer #metadata-line", + ].join(", "); + + const els = document.querySelectorAll(selector) as NodeListOf; + for (const el of els) { + el.style.display = visible ? "" : "none"; + } +} + +export function resetSearchMetadata(): void { + applySearchMetadataVisible(true); +} diff --git a/web-ext/features/profiles/primitives/search/shorts.ts b/web-ext/features/profiles/primitives/search/shorts.ts new file mode 100644 index 00000000..f8a3bbce --- /dev/null +++ b/web-ext/features/profiles/primitives/search/shorts.ts @@ -0,0 +1,21 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; + +const STRATEGIES = [ + "ytd-search ytd-reel-shelf-renderer", + "ytd-search ytd-shelf-renderer:has(ytd-reel-item-renderer)", + "#contents.ytd-section-list-renderer ytd-reel-shelf-renderer", +] as const; + +export async function setSearchShortsVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("search.shorts", "search", resolution); + if (!resolution.resolved) return; + (resolution.element as HTMLElement).style.display = visible ? "" : "none"; +} + +export function resetSearchShorts(): void { + const selector = STRATEGIES.join(", "); + const el = document.querySelector(selector) as HTMLElement | null; + if (el) el.style.display = ""; +} diff --git a/web-ext/features/profiles/primitives/search/thumbnails.ts b/web-ext/features/profiles/primitives/search/thumbnails.ts new file mode 100644 index 00000000..a2177f4b --- /dev/null +++ b/web-ext/features/profiles/primitives/search/thumbnails.ts @@ -0,0 +1,35 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; +import type { ThumbnailMode } from "@/features/profiles/profiles"; + +const STRATEGIES = [ + "ytd-search ytd-video-renderer ytd-thumbnail img#img", + "ytd-search ytd-video-renderer a.ytd-thumbnail img", + "#contents.ytd-section-list-renderer ytd-video-renderer ytd-thumbnail img", +] as const; + +const BLUR_FILTER = "blur(12px)"; + +export async function setSearchThumbnailMode(mode: ThumbnailMode): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("search.thumbnails", "search", resolution); + applySearchThumbnailMode(mode); +} + +export function applySearchThumbnailMode(mode: ThumbnailMode): void { + const selector = [ + "ytd-search ytd-video-renderer ytd-thumbnail img#img", + "ytd-search ytd-video-renderer a.ytd-thumbnail img", + "#contents.ytd-section-list-renderer ytd-video-renderer ytd-thumbnail img", + ].join(", "); + + const images = document.querySelectorAll(selector) as NodeListOf; + for (const img of images) { + img.style.filter = mode === "blur" ? BLUR_FILTER : ""; + img.style.visibility = mode === "hide" ? "hidden" : ""; + } +} + +export function resetSearchThumbnails(): void { + applySearchThumbnailMode("show"); +} diff --git a/web-ext/features/profiles/primitives/shorts/shelf.ts b/web-ext/features/profiles/primitives/shorts/shelf.ts new file mode 100644 index 00000000..e4db55b5 --- /dev/null +++ b/web-ext/features/profiles/primitives/shorts/shelf.ts @@ -0,0 +1,32 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; + +// Deliberately broad: the same shelf renders under different tags on home and +// on search results. +const STRATEGIES = [ + "ytd-rich-section-renderer:has(ytd-rich-shelf-renderer[is-shorts])", + "ytd-reel-shelf-renderer", + "ytd-rich-shelf-renderer[is-shorts]", + "#shorts-container", + "ytd-shelf-renderer:has(ytd-reel-item-renderer)", +] as const; + +export async function setShortsShelfVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("shorts.shelf", "shorts", resolution); + + // Every match, not just the first: a feed can render several shelves. + applyShortsShelfVisible(visible); +} + +export function applyShortsShelfVisible(visible: boolean): void { + const selector = STRATEGIES.join(", "); + const els = document.querySelectorAll(selector) as NodeListOf; + for (const el of els) { + el.style.display = visible ? "" : "none"; + } +} + +export function resetShortsShelf(): void { + applyShortsShelfVisible(true); +} diff --git a/web-ext/features/profiles/primitives/watch/comments.ts b/web-ext/features/profiles/primitives/watch/comments.ts new file mode 100644 index 00000000..850d9992 --- /dev/null +++ b/web-ext/features/profiles/primitives/watch/comments.ts @@ -0,0 +1,38 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; + +const STRATEGIES = [ + "ytd-comments#comments", + "#comments", + "ytd-item-section-renderer #contents ytd-comments", + "ytd-watch-flexy #comments", +] as const; + +let hiddenByToppings = false; + +export async function setCommentsVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("watch.comments", "watch", resolution); + + if (!resolution.resolved) return; + + const comments = resolution.element as HTMLElement; + if (visible) { + if (hiddenByToppings) { + comments.style.display = ""; + hiddenByToppings = false; + } + } else { + comments.style.display = "none"; + hiddenByToppings = true; + } +} + +export function resetComments(): void { + if (!hiddenByToppings) return; + const el = STRATEGIES + .map((s) => document.querySelector(s)) + .find(Boolean) ?? null; + if (el) el.style.display = ""; + hiddenByToppings = false; +} diff --git a/web-ext/features/profiles/primitives/watch/endCards.ts b/web-ext/features/profiles/primitives/watch/endCards.ts new file mode 100644 index 00000000..1b0fa75d --- /dev/null +++ b/web-ext/features/profiles/primitives/watch/endCards.ts @@ -0,0 +1,41 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; + +const STRATEGIES = [ + ".ytp-ce-element", + ".ytp-endscreen-element", + ".html5-endscreen", + ".ytp-player-content .ytp-ce-element", +] as const; + +// Tracked so reset only touches what this file hid, never YouTube's own state. +let suppressedByToppings = false; + +// Targets the container, not the cards: YouTube injects cards mid-video, and +// anything hidden per-element would miss the ones that appear later. +export async function setEndCardsVisible(visible: boolean): Promise { + const resolution = await resolveTarget(STRATEGIES); + void setCapabilityStatus("watch.endCards", "watch", resolution); + + if (!resolution.resolved) return; + + const container = resolution.element as HTMLElement; + if (visible) { + if (suppressedByToppings) { + container.style.display = ""; + suppressedByToppings = false; + } + } else { + container.style.display = "none"; + suppressedByToppings = true; + } +} + +export function resetEndCards(): void { + if (!suppressedByToppings) return; + const el = STRATEGIES + .map((s) => document.querySelector(s)) + .find(Boolean) ?? null; + if (el) el.style.display = ""; + suppressedByToppings = false; +} diff --git a/web-ext/features/profiles/primitives/watch/layout.ts b/web-ext/features/profiles/primitives/watch/layout.ts new file mode 100644 index 00000000..96936449 --- /dev/null +++ b/web-ext/features/profiles/primitives/watch/layout.ts @@ -0,0 +1,39 @@ +import { resolveTarget } from "@/features/profiles/primitives/resolve"; +import { setCapabilityStatus } from "@/features/profiles/capabilityCache"; +import type { PlayerLayout } from "@/features/profiles/profiles"; + +// The container is collapsed rather than the