From 8df2a637b77304cd10a737d3bf69ed3bdba45472 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:08:25 +0200 Subject: [PATCH 01/26] docs(probes): capture Save-to-playlist popup DOM structure Trimmed to the relevant yt-sheet-view-model fragment (dropped the 19MB full-page asset save) and redacted real playlist names before committing to this public repo. Confirms the newer sheet-based popup renders all playlists directly in the DOM with no continuation tokens, and the create-new control lives in the sheet footer rather than the list. --- docs/probes/save-to-playlist-dom-findings.md | 119 +++++++++++++++++++ docs/probes/save-to-playlist-popup.html | 1 + 2 files changed, 120 insertions(+) create mode 100644 docs/probes/save-to-playlist-dom-findings.md create mode 100644 docs/probes/save-to-playlist-popup.html diff --git a/docs/probes/save-to-playlist-dom-findings.md b/docs/probes/save-to-playlist-dom-findings.md new file mode 100644 index 0000000..96441a6 --- /dev/null +++ b/docs/probes/save-to-playlist-dom-findings.md @@ -0,0 +1,119 @@ +# "Save to playlist" popup — DOM findings + +Captured 2026-08-07 by K from a real logged-in YouTube account (watch page, +video's ⋮ menu → "Save to..."). Source: a full "Save as complete" page save; +only the relevant fragment was kept as `save-to-playlist-popup.html` (the +`` subtree) — the rest of the saved page and its 19MB +asset folder were discarded as unnecessary bulk, and every real playlist name +in the kept fragment was replaced with a generic `Playlist A`..`Playlist P` +placeholder (`Watch later` and `Shorts`, YouTube's own system playlists, were +left as-is). Locale of the source account is Polish; only the alphabet/text +differs from other locales, not the structure. + +## Component + +Newer view-model "sheet" system, not the legacy `ytd-add-to-playlist-renderer` +Polymer popup: + +``` +yt-sheet-view-model[slot="dropdown-content"] + yt-contextual-sheet-layout + div.ytContextualSheetLayoutHeaderContainer + yt-panel-header-view-model[aria-label="Zapisz film na playliście..."] (~"Save video to playlist...") + h2 > span.ytAttributedStringHost → "Zapisz na…" (~"Save to…") + div.ytContextualSheetLayoutContentContainer + yt-list-view-model.ytListViewModelHost[role="menu"][style*="max-height: 220px"] + toggleable-list-item-view-model.toggleableListItemViewModelHost (repeated, one per playlist) + yt-list-item-view-model.ytListItemViewModelHost[role="menuitem"][aria-label="{name}, {visibility}, {selected-state}"] + div.ytContextualSheetLayoutFooterContainer + yt-panel-footer-view-model + ...button[aria-label="Utwórz nową playlistę"] (~"Create new playlist") +``` + +## Playlist rows + +- One `toggleable-list-item-view-model` > `yt-list-item-view-model` per + playlist, `role="menuitem"`. +- **No `aria-checked` or other ARIA state attribute.** Selected/unselected + state is only exposed as the last comma-segment of `aria-label`, e.g. + `"Playlist A, Publiczna, Niewybrany"` (Public, Unselected) — this capture + has nothing checked, so the "selected" text value wasn't observed directly; + need a second capture with at least one playlist already containing the + video to confirm the exact selected-state string. **This is locale text** + (Polish `Niewybrany`/`Wybrany`, `Publiczna`/`Prywatna`/`Niepubliczna`), so a + real scraper can't match on it directly the same way the queue-overlay menu + item text isn't matched — needs either a translation table or (preferably) + a structural/icon-based signal once found. Not yet located in this capture; + flagged as a follow-up probe target (does the row grow a checkmark icon + element when selected? no icon-related class was found in this empty state, + which is what we'd expect for the *unchecked* case, so it doesn't rule one + in for the checked case). +- Every row carries a small thumbnail (`yt-collection-thumbnail-view-model`), + not needed for matching. +- 18 playlists were present (16 user-created + `Watch later` + `Shorts`, + YouTube's two built-in system playlists) and **all 18 rendered directly in + the DOM** inside a single `yt-list-view-model` with a fixed + `max-height: 220px` (scrollable, presumably via plain CSS overflow — no + `overflow` value captured explicitly, but no other scroll mechanism is + present). + +## Pagination — resolved, contradicts the original open question + +**No continuation tokens, no lazy-load markers found anywhere in or near this +component.** The whole-page search for `continuationCommand` / +`continuationItemRenderer` only matched unrelated recommendation-sidebar +continuations elsewhere on the page, not this popup. All playlists for this +account (18, including 16 user-created) rendered in one shot inside the fixed- +height scrollable container. + +This supports K's suspicion that pagination here was legacy behavior from the +older `ytd-add-to-playlist-renderer` popup (which may have paginated) and does +not apply to the current `yt-sheet-view-model` system. **Conclusion for the +scraper: don't build continuation/load-more handling.** A scroll-and-collect +pass over the fixed-height list container is what's needed if an account ever +has enough playlists to overflow it — untested here since 18 items apparently +fit without needing to scroll (would need an account with many more playlists, +or a forced small viewport, to confirm the overflow-scroll behavior itself +actually reveals more DOM nodes vs. just clipping). Keep this a ponytail-style +documented assumption (self-healing scraper, don't hard-fail if this changes) +per K's direction, not a hard guarantee. + +## Create-new-playlist control + +**Not a row inside the scrollable list** — it's a separate button in the +sheet's *footer* (`ytContextualSheetLayoutFooterContainer` → +`yt-panel-footer-view-model`), `aria-label="Utwórz nową playlistę"` +(~"Create new playlist"). Our own overlay UI can still choose to present +create-new as an in-list "+" row per K's UX decision (issue #16) — that's a +presentational choice in our overlay, independent of how the native popup +lays it out. The scraper driving the native popup just needs to look for this +footer button separately from the list items, not expect a checkbox-shaped +row for it. + +Clicking it was not captured in this session (this fragment shows the +pre-click state only) — a follow-up capture of what appears after clicking +"Utwórz nową playlistę" (a name-entry field? inline or a second sheet?) is +still needed before implementing the create-new flow. + +## No batch "Done"/confirm button + +No `aria-label="Gotowe"` (~"Done") button or equivalent was found anywhere in +the fragment or the full source page. This is consistent with K's statement +that the native popup only supports acting on one playlist per opening: each +row's checkbox toggle appears to apply immediately (add/remove) rather than +staging changes for a batch confirm. Not independently verified by clicking +(this is a static capture), but no confirm-button markup exists to stage +changes against, so a live "click toggles immediately" model is the working +assumption until confirmed by interaction. + +## Follow-ups needed before implementation + +1. A capture with **at least one playlist already containing the video**, to + read the actual "selected" aria-label text/structural signal. +2. A capture of the **post-click state of "Utwórz nową playlistę"** (create-new + name entry UI). +3. Confirm live (click, not just static capture) that a checkbox toggle + applies immediately with no separate confirm step. +4. If possible, an account with enough playlists to actually overflow + `max-height: 220px`, to confirm scrolling reveals more DOM nodes rather + than the list being capped/virtualized. diff --git a/docs/probes/save-to-playlist-popup.html b/docs/probes/save-to-playlist-popup.html new file mode 100644 index 0000000..4ca2dae --- /dev/null +++ b/docs/probes/save-to-playlist-popup.html @@ -0,0 +1 @@ +

Zapisz na…

\ No newline at end of file From 254a379f65a6d1c6d2984ee24b70572e48c171bf Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:18:38 +0200 Subject: [PATCH 02/26] docs(probes): add selected-state capture, find aria-pressed signal Diffing an unselected vs. selected playlist row across the two captures found aria-pressed on the row's inner button as the real locale- independent selection signal, replacing the earlier assumption that only locale-text aria-label was available. Also documents the create-new- playlist footer button's structural (non-text) selector path. --- docs/probes/save-to-playlist-dom-findings.md | 104 +++++++++++------- .../save-to-playlist-popup-selected.html | 1 + 2 files changed, 68 insertions(+), 37 deletions(-) create mode 100644 docs/probes/save-to-playlist-popup-selected.html diff --git a/docs/probes/save-to-playlist-dom-findings.md b/docs/probes/save-to-playlist-dom-findings.md index 96441a6..0dc1fdc 100644 --- a/docs/probes/save-to-playlist-dom-findings.md +++ b/docs/probes/save-to-playlist-dom-findings.md @@ -1,14 +1,17 @@ # "Save to playlist" popup — DOM findings Captured 2026-08-07 by K from a real logged-in YouTube account (watch page, -video's ⋮ menu → "Save to..."). Source: a full "Save as complete" page save; -only the relevant fragment was kept as `save-to-playlist-popup.html` (the -`` subtree) — the rest of the saved page and its 19MB -asset folder were discarded as unnecessary bulk, and every real playlist name -in the kept fragment was replaced with a generic `Playlist A`..`Playlist P` -placeholder (`Watch later` and `Shorts`, YouTube's own system playlists, were -left as-is). Locale of the source account is Polish; only the alphabet/text -differs from other locales, not the structure. +video's ⋮ menu → "Save to..."), across two sessions: one with nothing checked +(`save-to-playlist-popup.html`), one with the video already saved to "Watch +later" (`save-to-playlist-popup-selected.html`), diffed to find the selected- +state signal below. Source for both: a full "Save as complete" page save; +only the relevant fragment was kept (the `` subtree) — +the rest of each saved page and its ~19MB asset folder were discarded as +unnecessary bulk, and every real playlist name in the kept fragments was +replaced with a generic `Playlist A`..`Playlist P` placeholder (`Watch later` +and `Shorts`, YouTube's own system playlists, were left as-is). Locale of the +source account is Polish; only the alphabet/text differs from other locales, +not the structure. ## Component @@ -34,20 +37,18 @@ yt-sheet-view-model[slot="dropdown-content"] - One `toggleable-list-item-view-model` > `yt-list-item-view-model` per playlist, `role="menuitem"`. -- **No `aria-checked` or other ARIA state attribute.** Selected/unselected - state is only exposed as the last comma-segment of `aria-label`, e.g. - `"Playlist A, Publiczna, Niewybrany"` (Public, Unselected) — this capture - has nothing checked, so the "selected" text value wasn't observed directly; - need a second capture with at least one playlist already containing the - video to confirm the exact selected-state string. **This is locale text** - (Polish `Niewybrany`/`Wybrany`, `Publiczna`/`Prywatna`/`Niepubliczna`), so a - real scraper can't match on it directly the same way the queue-overlay menu - item text isn't matched — needs either a translation table or (preferably) - a structural/icon-based signal once found. Not yet located in this capture; - flagged as a follow-up probe target (does the row grow a checkmark icon - element when selected? no icon-related class was found in this empty state, - which is what we'd expect for the *unchecked* case, so it doesn't rule one - in for the checked case). +- **Selected/unselected state resolved** — K captured a second probe + (`save-to-playlist-popup-selected.html`, watch page for a video already + saved to "Watch later") and diffing the two captures' identical row found + the real signal: the row's inner + `
\ No newline at end of file From 649be2c98a1bb8124c3779ef078cec38934d0fdc Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:31:14 +0200 Subject: [PATCH 03/26] docs(playlist): link overflow-scroll follow-up to issue #17 --- docs/probes/save-to-playlist-dom-findings.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/probes/save-to-playlist-dom-findings.md b/docs/probes/save-to-playlist-dom-findings.md index 0dc1fdc..68106aa 100644 --- a/docs/probes/save-to-playlist-dom-findings.md +++ b/docs/probes/save-to-playlist-dom-findings.md @@ -144,6 +144,8 @@ assumption until confirmed by interaction. blocking a first implementation pass, since "toggle applies immediately" is YouTube's standard pattern elsewhere (e.g. like/dislike, subscribe) and can be verified during implementation instead of via another manual probe. -4. If possible, an account with enough playlists to actually overflow - `max-height: 220px`, to confirm scrolling reveals more DOM nodes rather - than the list being capped/virtualized — nice-to-have, not blocking. +4. An account with enough playlists to actually overflow `max-height: 220px`, + to confirm scrolling reveals more DOM nodes rather than the list being + capped/virtualized — spun out to + [#17](https://github.com/Kotmin/VideoDefaults/issues/17), not blocking + #16. From 328539918ba31f5b668031d4e86bfd7afa338094 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:46:44 +0200 Subject: [PATCH 04/26] feat(shortcuts): add shift-modifier chord namespace for Ctrl+A,Shift+P Chords previously matched key case-insensitively with no shift distinction. Introduces a separate shiftChords map matched via evt.shiftKey so Shift+P (playlist picker, issue #16) stays unambiguous from plain p (queue overlay) regardless of layout-dependent key casing. --- src/core/keyboard-shortcuts.js | 17 ++++++++++++---- src/core/shortcuts.config.json | 3 +++ tests/unit/keyboard-shortcuts.test.js | 29 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/core/keyboard-shortcuts.js b/src/core/keyboard-shortcuts.js index ce5f7af..0ca97a9 100644 --- a/src/core/keyboard-shortcuts.js +++ b/src/core/keyboard-shortcuts.js @@ -4,6 +4,7 @@ import { validateSpeed } from './speed.js'; export const COMMANDS = Object.freeze({ SHOW_JUMP_LABELS: 'show-jump-labels', SHOW_QUEUE_LABELS: 'show-queue-labels', + SHOW_PLAYLIST_LABELS: 'show-playlist-labels', GO_HOME: 'go-home', SET_SPEED_1: 'set-speed-1', SET_SPEED_2: 'set-speed-2', @@ -31,6 +32,12 @@ const FALLBACK_KEYMAP = Object.freeze({ n: COMMANDS.SET_SPEED_3, h: COMMANDS.TOGGLE_AUTO_APPLY, }), + // Separate namespace from `chords` (rather than shift-encoded key casing) so + // Ctrl+A,p (queue) and Ctrl+A,Shift+P (playlist) stay unambiguous regardless + // of the raw evt.key case a layout produces; matched via evt.shiftKey. + shiftChords: Object.freeze({ + p: COMMANDS.SHOW_PLAYLIST_LABELS, + }), homeUrl: 'https://www.youtube.com/', }); @@ -54,8 +61,8 @@ function normalizePrefix(raw, isMac = false) { return { key, ctrl, meta }; } -function normalizeChords(raw) { - const chords = { ...FALLBACK_KEYMAP.chords }; +function normalizeChordMap(raw, fallback) { + const chords = { ...fallback }; if (raw == null || typeof raw !== 'object') return chords; for (const [key, command] of Object.entries(raw)) { if (!isSingleChar(key)) continue; @@ -82,7 +89,8 @@ export function normalizeKeymap(raw, isMac = false) { const src = raw != null && typeof raw === 'object' ? raw : {}; return { prefix: normalizePrefix(src.prefix, isMac), - chords: normalizeChords(src.chords), + chords: normalizeChordMap(src.chords, FALLBACK_KEYMAP.chords), + shiftChords: normalizeChordMap(src.shiftChords, FALLBACK_KEYMAP.shiftChords), homeUrl: normalizeHomeUrl(src.homeUrl), }; } @@ -142,7 +150,8 @@ export function createShortcutController() { pending = false; if (evt.key === 'Escape') return { consume: true, command: null, pending }; if (evt.altKey === true) return { consume: false, command: null, pending }; - const command = keymap.chords[evt.key.toLowerCase()] ?? null; + const map = evt.shiftKey === true ? keymap.shiftChords : keymap.chords; + const command = map[evt.key.toLowerCase()] ?? null; return { consume: command !== null, command, pending }; }, }; diff --git a/src/core/shortcuts.config.json b/src/core/shortcuts.config.json index 87fd1ee..69e2224 100644 --- a/src/core/shortcuts.config.json +++ b/src/core/shortcuts.config.json @@ -9,6 +9,9 @@ "n": "set-speed-3", "h": "toggle-auto-apply" }, + "shiftChords": { + "p": "show-playlist-labels" + }, "homeUrl": "https://www.youtube.com/", "speeds": { "set-speed-1": 1, diff --git a/tests/unit/keyboard-shortcuts.test.js b/tests/unit/keyboard-shortcuts.test.js index f676854..1e7ecb2 100644 --- a/tests/unit/keyboard-shortcuts.test.js +++ b/tests/unit/keyboard-shortcuts.test.js @@ -70,6 +70,16 @@ describe('normalizeKeymap', () => { assert.equal(km.chords.h, DEFAULT_KEYMAP.chords.h); }); + it('drops shiftChord entries with unknown commands and invalid keys, keeping defaults for the rest', () => { + const km = normalizeKeymap({ shiftChords: { p: 'rm-rf', long: COMMANDS.GO_HOME } }); + assert.deepEqual(km.shiftChords, { ...DEFAULT_KEYMAP.shiftChords }); + }); + + it('merges a stored shiftChords partial override onto the defaults', () => { + const km = normalizeKeymap({ shiftChords: { p: COMMANDS.GO_HOME } }); + assert.equal(km.shiftChords.p, COMMANDS.GO_HOME); + }); + it('rejects non-youtube and non-https home urls', () => { assert.equal(normalizeKeymap({ homeUrl: 'https://evil.example/' }).homeUrl, DEFAULT_KEYMAP.homeUrl); assert.equal(normalizeKeymap({ homeUrl: 'http://www.youtube.com/' }).homeUrl, DEFAULT_KEYMAP.homeUrl); @@ -104,6 +114,11 @@ describe('DEFAULT_KEYMAP', () => { it('chord p maps to show-queue-labels', () => { assert.equal(DEFAULT_KEYMAP.chords.p, COMMANDS.SHOW_QUEUE_LABELS); }); + + it('shift-chord p maps to show-playlist-labels, distinct from plain p', () => { + assert.equal(DEFAULT_KEYMAP.shiftChords.p, COMMANDS.SHOW_PLAYLIST_LABELS); + assert.notEqual(DEFAULT_KEYMAP.shiftChords.p, DEFAULT_KEYMAP.chords.p); + }); }); describe('SPEED_SHORTCUTS', () => { @@ -169,6 +184,20 @@ describe('createShortcutController', () => { assert.equal(r.command, COMMANDS.SHOW_QUEUE_LABELS); }); + it('prefix then Shift+P returns show-playlist-labels, not show-queue-labels', () => { + const c = createShortcutController(); + c.handleKey(prefix, DEFAULT_KEYMAP); + const r = c.handleKey(key('P', { shiftKey: true }), DEFAULT_KEYMAP); + assert.deepEqual(r, { consume: true, command: COMMANDS.SHOW_PLAYLIST_LABELS, pending: false }); + }); + + it('shift held on a key with no shiftChord entry does not fall back to the unshifted chord', () => { + const c = createShortcutController(); + c.handleKey(prefix, DEFAULT_KEYMAP); + const r = c.handleKey(key('Y', { shiftKey: true }), DEFAULT_KEYMAP); + assert.deepEqual(r, { consume: false, command: null, pending: false }); + }); + it('unknown key cancels pending without consuming', () => { const c = createShortcutController(); c.handleKey(prefix, DEFAULT_KEYMAP); From c995a7ae1863dca2f774e4f8a7aec2cbcbc78b1b Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:47:18 +0200 Subject: [PATCH 05/26] feat(core): add fuzzy-match utility for the playlist picker Case/space-insensitive substring match plus edit-distance-2 typo tolerance against the whole playlist name, per issue #16's resolved match algorithm (deliberately not a full fzf-style scorer). --- src/core/fuzzy-match.js | 39 ++++++++++++++++++ tests/unit/fuzzy-match.test.js | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/core/fuzzy-match.js create mode 100644 tests/unit/fuzzy-match.test.js diff --git a/src/core/fuzzy-match.js b/src/core/fuzzy-match.js new file mode 100644 index 0000000..fd444b9 --- /dev/null +++ b/src/core/fuzzy-match.js @@ -0,0 +1,39 @@ +const MAX_TYPO_DISTANCE = 2; + +export function normalizeForMatch(value) { + return value.toLowerCase().replace(/\s+/g, ''); +} + +export function levenshteinDistance(a, b) { + const rows = a.length + 1; + const cols = b.length + 1; + let prev = Array.from({ length: cols }, (_, j) => j); + for (let i = 1; i < rows; i += 1) { + const curr = [i]; + for (let j = 1; j < cols; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min( + prev[j] + 1, + curr[j - 1] + 1, + prev[j - 1] + cost, + ); + } + prev = curr; + } + return prev[cols - 1]; +} + +// Case/space-insensitive substring match, or edit-distance-2 typo tolerance +// against the whole playlist name (issue #16) — deterministic, not a +// fzf-style relevance scorer. +export function matchesPlaylistQuery(name, query) { + const q = normalizeForMatch(query); + if (q === '') return true; + const n = normalizeForMatch(name); + if (n.includes(q)) return true; + return levenshteinDistance(n, q) <= MAX_TYPO_DISTANCE; +} + +export function filterPlaylistsByQuery(playlists, query) { + return playlists.filter((p) => matchesPlaylistQuery(p.name, query)); +} diff --git a/tests/unit/fuzzy-match.test.js b/tests/unit/fuzzy-match.test.js new file mode 100644 index 0000000..5193adb --- /dev/null +++ b/tests/unit/fuzzy-match.test.js @@ -0,0 +1,73 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + normalizeForMatch, + levenshteinDistance, + matchesPlaylistQuery, + filterPlaylistsByQuery, +} from '../../src/core/fuzzy-match.js'; + +describe('normalizeForMatch', () => { + it('lowercases and strips whitespace', () => { + assert.equal(normalizeForMatch('Watch Later'), 'watchlater'); + assert.equal(normalizeForMatch(' Sci-Fi '), 'sci-fi'); + }); +}); + +describe('levenshteinDistance', () => { + it('is 0 for identical strings', () => { + assert.equal(levenshteinDistance('abc', 'abc'), 0); + }); + + it('counts a single substitution as 1', () => { + assert.equal(levenshteinDistance('cat', 'cot'), 1); + }); + + it('counts insertions and deletions', () => { + assert.equal(levenshteinDistance('cat', 'cats'), 1); + assert.equal(levenshteinDistance('cats', 'cat'), 1); + }); + + it('handles empty strings', () => { + assert.equal(levenshteinDistance('', 'abc'), 3); + assert.equal(levenshteinDistance('abc', ''), 3); + }); +}); + +describe('matchesPlaylistQuery', () => { + it('matches an empty query against anything', () => { + assert.equal(matchesPlaylistQuery('Watch later', ''), true); + }); + + it('matches case-insensitively', () => { + assert.equal(matchesPlaylistQuery('Watch later', 'WATCH'), true); + }); + + it('matches space-insensitively', () => { + assert.equal(matchesPlaylistQuery('Sci Fi Favorites', 'scifi'), true); + }); + + it('matches a substring anywhere in the name', () => { + assert.equal(matchesPlaylistQuery('My Favorite Playlist', 'favorite'), true); + }); + + it('tolerates a 2-edit typo against the whole name', () => { + assert.equal(matchesPlaylistQuery('Playlist A', 'Playlst B'), true); + }); + + it('rejects a query with distance > 2 and no substring match', () => { + assert.equal(matchesPlaylistQuery('Playlist A', 'Something entirely different'), false); + }); + + it('rejects a short query that is neither substring nor close by edit distance', () => { + assert.equal(matchesPlaylistQuery('Documentaries', 'xyz'), false); + }); +}); + +describe('filterPlaylistsByQuery', () => { + it('filters a list of {name} objects by query', () => { + const playlists = [{ name: 'Watch later' }, { name: 'Shorts' }, { name: 'Comedy' }]; + const out = filterPlaylistsByQuery(playlists, 'wat'); + assert.deepEqual(out.map((p) => p.name), ['Watch later']); + }); +}); From bf866a76e2de7a54a92a70e48f62494f0dde5b13 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:49:19 +0200 Subject: [PATCH 06/26] feat(ui): add native Save-to-playlist popup driver Scrapes/toggles the native yt-sheet-view-model "Save to playlist" sheet off-screen (issue #16), same DOM-driving trick queue-overlay.js uses for "Add to queue". Row/state/footer selectors are verified against the captured DOM in docs/probes/save-to-playlist-dom-findings.md. How the watch page's own action row opens this sheet was never captured in the probes, so the caller supplies the trigger element rather than the driver guessing a selector for it (flagged in docs/ai/questions-for-K.md). --- src/ui/playlist-popup-driver.js | 75 ++++++++++++++ tests/unit/playlist-popup-driver.test.js | 118 +++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/ui/playlist-popup-driver.js create mode 100644 tests/unit/playlist-popup-driver.test.js diff --git a/src/ui/playlist-popup-driver.js b/src/ui/playlist-popup-driver.js new file mode 100644 index 0000000..882752f --- /dev/null +++ b/src/ui/playlist-popup-driver.js @@ -0,0 +1,75 @@ +// Drives YouTube's native "Save to playlist" sheet off-screen/silently +// (issue #16) instead of showing it, mirroring queue-overlay.js's approach +// for "Add to queue". Selectors below are verified against a real captured +// DOM (docs/probes/save-to-playlist-dom-findings.md) — the newer +// yt-sheet-view-model system, matched structurally (aria-pressed state, +// footer button position) per K's direction to avoid locale text, never the +// legacy ytd-add-to-playlist-renderer popup. +const ROW_TOGGLE_SELECTOR = 'yt-list-view-model[role="menu"] toggleable-list-item-view-model button[aria-pressed]'; +const CREATE_NEW_BUTTON_SELECTOR = '.ytContextualSheetLayoutFooterContainer .ytPanelFooterViewModelPrimaryButton button'; +const POPUP_WAIT_TIMEOUT_MS = 1500; +const POPUP_WAIT_POLL_MS = 50; + +function parsePlaylistName(ariaLabel) { + // ponytail: name is the first comma-separated segment of the row's own + // aria-label ("{name}, {visibility}, {selected-state}", confirmed in probe + // captures). This is the user's own playlist title, not locale UI text, so + // it doesn't conflict with K's no-locale-text-matching rule for STATE — + // but a playlist named with a literal comma would still misparse; no such + // case was in the captured account (18 playlists, none with a comma). + return (ariaLabel ?? '').split(',')[0].trim(); +} + +export function scrapePlaylistRows(doc) { + return [...doc.querySelectorAll(ROW_TOGGLE_SELECTOR)].map((button) => ({ + name: parsePlaylistName(button.getAttribute('aria-label')), + selected: button.getAttribute('aria-pressed') === 'true', + element: button, + })); +} + +export function findCreateNewButton(doc) { + return doc.querySelector(CREATE_NEW_BUTTON_SELECTOR); +} + +function waitForRows(doc, win) { + return new Promise((resolve) => { + const deadline = Date.now() + POPUP_WAIT_TIMEOUT_MS; + (function poll() { + const rows = scrapePlaylistRows(doc); + if (rows.length > 0) return resolve(rows); + if (Date.now() >= deadline) return resolve([]); + win.setTimeout(poll, POPUP_WAIT_POLL_MS); + }()); + }); +} + +// UNVERIFIED entry point: the DOM probes only captured this sheet already +// open, never how the watch page's own action row triggers it — see +// docs/ai/questions-for-K.md. The caller supplies triggerButton (same +// dependency-injection shape as queue-overlay's activateQueueTarget); once +// clicked, everything below is driving probe-verified DOM. +export async function openSaveToPlaylistPopup(triggerButton, doc, win) { + triggerButton.click(); + return waitForRows(doc, win); +} + +export function togglePlaylistRow(row) { + row.element.click(); +} + +export function activateCreateNew(doc) { + const button = findCreateNewButton(doc); + if (!button) return false; + button.click(); + return true; +} + +// ponytail: no explicit close/cancel control was found in either probe +// capture (consistent with "no batch Done button" — see findings doc). +// Escape is YouTube's universal sheet-dismiss key elsewhere on the site; +// self-healing best-effort here, not independently confirmed for this +// specific sheet by a live click. +export function closeSaveToPlaylistPopup(doc, win) { + doc.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); +} diff --git a/tests/unit/playlist-popup-driver.test.js b/tests/unit/playlist-popup-driver.test.js new file mode 100644 index 0000000..4041cbd --- /dev/null +++ b/tests/unit/playlist-popup-driver.test.js @@ -0,0 +1,118 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + scrapePlaylistRows, + findCreateNewButton, + openSaveToPlaylistPopup, + togglePlaylistRow, + activateCreateNew, + closeSaveToPlaylistPopup, +} from '../../src/ui/playlist-popup-driver.js'; + +function makeRowButton({ name, selected = false }) { + return { + clicked: 0, + getAttribute(attr) { + if (attr === 'aria-label') return `${name}, Private, Unselected`; + if (attr === 'aria-pressed') return selected ? 'true' : 'false'; + return null; + }, + click() { this.clicked += 1; }, + }; +} + +function makeDoc(rowButtons, createNewButton = null) { + return { + querySelectorAll: () => rowButtons, + querySelector: () => createNewButton, + }; +} + +const win = { setTimeout: (fn) => fn() }; + +describe('scrapePlaylistRows', () => { + it('parses name and selected state from each row button', () => { + const rows = [ + makeRowButton({ name: 'Watch later', selected: true }), + makeRowButton({ name: 'Comedy', selected: false }), + ]; + const out = scrapePlaylistRows(makeDoc(rows)); + assert.deepEqual(out.map((r) => [r.name, r.selected]), [['Watch later', true], ['Comedy', false]]); + assert.equal(out[0].element, rows[0]); + }); + + it('returns an empty list when no rows are present', () => { + assert.deepEqual(scrapePlaylistRows(makeDoc([])), []); + }); +}); + +describe('findCreateNewButton', () => { + it('returns the footer create-new button', () => { + const btn = { id: 'create' }; + assert.equal(findCreateNewButton(makeDoc([], btn)), btn); + }); +}); + +describe('openSaveToPlaylistPopup', () => { + it('clicks the trigger and resolves once rows appear', async () => { + const trigger = { clicked: 0, click() { this.clicked += 1; } }; + const row = makeRowButton({ name: 'Comedy' }); + let calls = 0; + const doc = { + querySelectorAll: () => { + calls += 1; + return calls < 3 ? [] : [row]; + }, + }; + const rows = await openSaveToPlaylistPopup(trigger, doc, win); + assert.equal(trigger.clicked, 1); + assert.equal(rows.length, 1); + assert.equal(rows[0].name, 'Comedy'); + }); + + it('resolves with an empty list when rows never appear before the timeout', async () => { + const trigger = { clicked: 0, click() { this.clicked += 1; } }; + let now = 0; + const doc = { querySelectorAll: () => [] }; + const fastWin = { setTimeout: (fn) => { now += 100; fn(); } }; + const realNow = Date.now; + Date.now = () => now; + try { + const rows = await openSaveToPlaylistPopup(trigger, doc, fastWin); + assert.deepEqual(rows, []); + } finally { + Date.now = realNow; + } + }); +}); + +describe('togglePlaylistRow', () => { + it('clicks the row element', () => { + const row = { element: makeRowButton({ name: 'Comedy' }) }; + togglePlaylistRow(row); + assert.equal(row.element.clicked, 1); + }); +}); + +describe('activateCreateNew', () => { + it('clicks the create-new button and returns true when found', () => { + const btn = { clicked: 0, click() { this.clicked += 1; } }; + assert.equal(activateCreateNew(makeDoc([], btn)), true); + assert.equal(btn.clicked, 1); + }); + + it('returns false when no create-new button is found', () => { + assert.equal(activateCreateNew(makeDoc([], null)), false); + }); +}); + +describe('closeSaveToPlaylistPopup', () => { + it('dispatches an Escape keydown on the document', () => { + let dispatched = null; + const doc = { dispatchEvent: (evt) => { dispatched = evt; } }; + const fakeWin = { KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } } }; + closeSaveToPlaylistPopup(doc, fakeWin); + assert.equal(dispatched.type, 'keydown'); + assert.equal(dispatched.key, 'Escape'); + }); +}); From c68959ef922c7cd8e296790217bc41aa95e99188 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:50:11 +0200 Subject: [PATCH 07/26] feat(core): add cross-tab playlist catalog cache browser.storage.local-backed, 5 min TTL per issue #16's resolved default. Caches the playlist name catalog only, not per-video membership, since aria-pressed state reflects the currently-open video and would be wrong to serve to a different one from cache. --- src/core/playlist-cache.js | 27 +++++++++++ tests/unit/playlist-cache.test.js | 75 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/core/playlist-cache.js create mode 100644 tests/unit/playlist-cache.test.js diff --git a/src/core/playlist-cache.js b/src/core/playlist-cache.js new file mode 100644 index 0000000..69ac5a9 --- /dev/null +++ b/src/core/playlist-cache.js @@ -0,0 +1,27 @@ +const STORAGE_KEY = 'videodefaults_playlist_cache'; +const TTL_MS = 5 * 60 * 1000; + +// Shared across tabs via browser.storage.local (issue #16) rather than a +// tab-local variable, so a scrape in one tab benefits others. Caches only +// the playlist catalog (name), not per-video membership — membership +// reflects the *current* video's aria-pressed state and would be wrong to +// serve to a different video, so it's always freshly scraped when the +// native popup opens. See docs/ai/questions-for-K.md. +export function createPlaylistCache(browser, now = Date.now) { + return { + async read() { + const result = await browser.storage.local.get(STORAGE_KEY); + const entry = result[STORAGE_KEY]; + if (entry == null || typeof entry !== 'object') return null; + if (typeof entry.fetchedAt !== 'number' || !Array.isArray(entry.playlists)) return null; + if (now() - entry.fetchedAt > TTL_MS) return null; + return entry.playlists; + }, + async write(playlists) { + await browser.storage.local.set({ [STORAGE_KEY]: { fetchedAt: now(), playlists } }); + }, + async invalidate() { + await browser.storage.local.remove(STORAGE_KEY); + }, + }; +} diff --git a/tests/unit/playlist-cache.test.js b/tests/unit/playlist-cache.test.js new file mode 100644 index 0000000..025cd09 --- /dev/null +++ b/tests/unit/playlist-cache.test.js @@ -0,0 +1,75 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createPlaylistCache } from '../../src/core/playlist-cache.js'; + +const STORAGE_KEY = 'videodefaults_playlist_cache'; + +function makeBrowser(store = {}) { + return { + storage: { + local: { + async get(key) { + return key in store ? { [key]: store[key] } : {}; + }, + async set(obj) { + Object.assign(store, obj); + }, + async remove(key) { + delete store[key]; + }, + }, + }, + }; +} + +describe('createPlaylistCache.read', () => { + it('returns null when nothing is cached', async () => { + const cache = createPlaylistCache(makeBrowser()); + assert.equal(await cache.read(), null); + }); + + it('returns cached playlists within TTL', async () => { + const store = { [STORAGE_KEY]: { fetchedAt: 1000, playlists: [{ name: 'Comedy' }] } }; + const cache = createPlaylistCache(makeBrowser(store), () => 1000 + 60_000); + assert.deepEqual(await cache.read(), [{ name: 'Comedy' }]); + }); + + it('returns null once the entry is older than 5 minutes', async () => { + const store = { [STORAGE_KEY]: { fetchedAt: 1000, playlists: [{ name: 'Comedy' }] } }; + const cache = createPlaylistCache(makeBrowser(store), () => 1000 + 5 * 60_000 + 1); + assert.equal(await cache.read(), null); + }); + + it('returns null for a malformed cache entry', async () => { + const store = { [STORAGE_KEY]: { garbage: true } }; + const cache = createPlaylistCache(makeBrowser(store)); + assert.equal(await cache.read(), null); + }); +}); + +describe('createPlaylistCache.write', () => { + it('stores playlists with the current timestamp', async () => { + const store = {}; + const cache = createPlaylistCache(makeBrowser(store), () => 42); + await cache.write([{ name: 'Comedy' }]); + assert.deepEqual(store[STORAGE_KEY], { fetchedAt: 42, playlists: [{ name: 'Comedy' }] }); + }); + + it('read after write round-trips within TTL', async () => { + const store = {}; + let time = 0; + const cache = createPlaylistCache(makeBrowser(store), () => time); + await cache.write([{ name: 'Docs' }]); + time += 60_000; + assert.deepEqual(await cache.read(), [{ name: 'Docs' }]); + }); +}); + +describe('createPlaylistCache.invalidate', () => { + it('clears the cached entry', async () => { + const store = { [STORAGE_KEY]: { fetchedAt: 0, playlists: [{ name: 'Comedy' }] } }; + const cache = createPlaylistCache(makeBrowser(store), () => 0); + await cache.invalidate(); + assert.equal(await cache.read(), null); + }); +}); From 0394b9bee4fcf10f003853640154c9cad864bdad Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:52:18 +0200 Subject: [PATCH 08/26] feat(ui): add playlist overlay state machine and DOM renderer Pure reducer module (playlist-overlay-state.js, fully unit tested) drives query typing, up/down highlight, space-checkbox toggling capped at 5, enter-confirm resolution (checked set, implicit single-select, or create-new), and the nested create-new sub-dialog per issue #16's resolved UX. playlist-overlay.js is the DOM painter consuming that state, left untested at the unit level same as jump-overlay.js's createJumpOverlay. --- src/ui/playlist-overlay-state.js | 100 ++++++++++++++ src/ui/playlist-overlay.js | 85 ++++++++++++ tests/unit/playlist-overlay-state.test.js | 158 ++++++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 src/ui/playlist-overlay-state.js create mode 100644 src/ui/playlist-overlay.js create mode 100644 tests/unit/playlist-overlay-state.test.js diff --git a/src/ui/playlist-overlay-state.js b/src/ui/playlist-overlay-state.js new file mode 100644 index 0000000..5920b1a --- /dev/null +++ b/src/ui/playlist-overlay-state.js @@ -0,0 +1,100 @@ +import { filterPlaylistsByQuery } from '../core/fuzzy-match.js'; + +// Virtual last row, always shown regardless of query/match state (issue #16). +export const CREATE_NEW_ROW = Symbol('create-new'); +const MAX_CONFIRM_SELECTION = 5; + +export function createOverlayState(playlists) { + return { + playlists, + query: '', + highlightIndex: 0, + checked: new Set(), + subDialog: null, + }; +} + +export function visibleRows(state) { + return filterPlaylistsByQuery(state.playlists, state.query); +} + +function rowCount(state) { + return visibleRows(state).length + 1; +} + +function highlightedRow(state) { + const rows = visibleRows(state); + return state.highlightIndex >= rows.length ? CREATE_NEW_ROW : rows[state.highlightIndex]; +} + +export function moveHighlight(state, delta) { + const count = rowCount(state); + const next = ((state.highlightIndex + delta) % count + count) % count; + return { ...state, highlightIndex: next }; +} + +export function typeChar(state, char) { + return { ...state, query: state.query + char, highlightIndex: 0 }; +} + +export function backspace(state) { + return { ...state, query: state.query.slice(0, -1), highlightIndex: 0 }; +} + +// Space: local checkbox state only, capped at 5 (issue #16) — a 6th toggle +// attempt is ignored rather than silently dropping an earlier pick at +// confirm time, so the checked set the user sees is always what gets added. +export function toggleHighlighted(state) { + const row = highlightedRow(state); + if (row === CREATE_NEW_ROW) return state; + const checked = new Set(state.checked); + if (checked.has(row.name)) { + checked.delete(row.name); + } else { + if (checked.size >= MAX_CONFIRM_SELECTION) return state; + checked.add(row.name); + } + return { ...state, checked }; +} + +// Enter: checked set if non-empty, else implicit single-select on the +// highlighted row, else opens the create-new sub-dialog (resolved 2026-08-07). +export function resolveEnter(state) { + const row = highlightedRow(state); + if (row === CREATE_NEW_ROW) return { type: 'create-new' }; + if (state.checked.size > 0) return { type: 'confirm', names: [...state.checked] }; + return { type: 'confirm', names: [row.name] }; +} + +export function openCreateDialog(state) { + return { ...state, subDialog: { query: '' } }; +} + +export function typeInCreateDialog(state, char) { + if (!state.subDialog) return state; + return { ...state, subDialog: { query: state.subDialog.query + char } }; +} + +export function backspaceInCreateDialog(state) { + if (!state.subDialog) return state; + return { ...state, subDialog: { query: state.subDialog.query.slice(0, -1) } }; +} + +export function closeCreateDialog(state) { + return { ...state, subDialog: null }; +} + +// New playlist checked by default, prior checks intact, back to main +// checklist (issue #16 resolved create-new UX). +export function commitCreatedPlaylist(state, name) { + const checked = new Set(state.checked); + checked.add(name); + return { + ...state, + playlists: [...state.playlists, { name, selected: false }], + subDialog: null, + query: '', + highlightIndex: 0, + checked, + }; +} diff --git a/src/ui/playlist-overlay.js b/src/ui/playlist-overlay.js new file mode 100644 index 0000000..d343be1 --- /dev/null +++ b/src/ui/playlist-overlay.js @@ -0,0 +1,85 @@ +import { visibleRows } from './playlist-overlay-state.js'; + +const PANEL_STYLE = [ + 'position: fixed', + 'z-index: 2147483647', + 'top: 72px', + 'left: 50%', + 'transform: translateX(-50%)', + 'background: #111', + 'color: #fff', + 'font: 13px/1.4 monospace', + 'border: 1px solid #ffd54a', + 'border-radius: 4px', + 'padding: 8px', + 'min-width: 260px', + 'max-height: 320px', + 'overflow-y: auto', +].join('; '); + +const LABEL_STYLE = 'color:#ffd54a; margin-bottom:6px; white-space:pre'; +const ROW_STYLE = 'padding:2px 4px; white-space:nowrap'; +const ROW_HIGHLIGHT_STYLE = `${ROW_STYLE}; background:#333; border-radius:2px`; +const CREATE_ROW_STYLE = 'padding:2px 4px; white-space:nowrap; color:#9be89b'; +const CREATE_ROW_HIGHLIGHT_STYLE = `${CREATE_ROW_STYLE}; background:#333; border-radius:2px`; + +function renderMain(doc, container, state) { + const query = doc.createElement('div'); + query.setAttribute('style', LABEL_STYLE); + query.textContent = `/ ${state.query}`; + container.appendChild(query); + + const rows = visibleRows(state); + rows.forEach((row, i) => { + const el = doc.createElement('div'); + el.setAttribute('style', i === state.highlightIndex ? ROW_HIGHLIGHT_STYLE : ROW_STYLE); + el.setAttribute('data-videodefaults-playlist-row', row.name); + el.textContent = `${state.checked.has(row.name) ? '[x]' : '[ ]'} ${row.name}`; + container.appendChild(el); + }); + + const createRow = doc.createElement('div'); + const createHighlighted = state.highlightIndex === rows.length; + createRow.setAttribute('style', createHighlighted ? CREATE_ROW_HIGHLIGHT_STYLE : CREATE_ROW_STYLE); + createRow.setAttribute('data-videodefaults-playlist-create-new', ''); + createRow.textContent = '+ Create new'; + container.appendChild(createRow); +} + +function renderSubDialog(doc, container, state) { + const label = doc.createElement('div'); + label.setAttribute('style', LABEL_STYLE); + label.textContent = 'New playlist name:'; + container.appendChild(label); + + const query = doc.createElement('div'); + query.setAttribute('style', LABEL_STYLE); + query.setAttribute('data-videodefaults-playlist-create-input', ''); + query.textContent = `/ ${state.subDialog.query}`; + container.appendChild(query); +} + +export function createPlaylistOverlay(doc) { + let container = null; + + return { + isOpen() { + return container !== null; + }, + render(state) { + if (!container) { + container = doc.createElement('div'); + container.setAttribute('data-videodefaults-playlist-overlay', ''); + container.setAttribute('style', PANEL_STYLE); + doc.body.appendChild(container); + } + container.textContent = ''; + if (state.subDialog) renderSubDialog(doc, container, state); + else renderMain(doc, container, state); + }, + close() { + if (container) container.remove(); + container = null; + }, + }; +} diff --git a/tests/unit/playlist-overlay-state.test.js b/tests/unit/playlist-overlay-state.test.js new file mode 100644 index 0000000..911ef58 --- /dev/null +++ b/tests/unit/playlist-overlay-state.test.js @@ -0,0 +1,158 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + CREATE_NEW_ROW, + createOverlayState, + visibleRows, + moveHighlight, + typeChar, + backspace, + toggleHighlighted, + resolveEnter, + openCreateDialog, + typeInCreateDialog, + backspaceInCreateDialog, + closeCreateDialog, + commitCreatedPlaylist, +} from '../../src/ui/playlist-overlay-state.js'; + +const PLAYLISTS = [{ name: 'Comedy' }, { name: 'Documentaries' }, { name: 'Watch later' }]; + +describe('visibleRows', () => { + it('returns all playlists for an empty query', () => { + const s = createOverlayState(PLAYLISTS); + assert.equal(visibleRows(s).length, 3); + }); + + it('filters by fuzzy query', () => { + const s = typeChar(createOverlayState(PLAYLISTS), 'com'); + assert.deepEqual(visibleRows(s).map((p) => p.name), ['Comedy']); + }); +}); + +describe('moveHighlight', () => { + it('wraps forward past the create-new row back to index 0', () => { + let s = createOverlayState(PLAYLISTS); + for (let i = 0; i < 4; i += 1) s = moveHighlight(s, 1); + assert.equal(s.highlightIndex, 0); + }); + + it('wraps backward from index 0 to the create-new row', () => { + const s = moveHighlight(createOverlayState(PLAYLISTS), -1); + assert.equal(s.highlightIndex, 3); + }); + + it('wraps within a single-row (create-new only) list', () => { + const s0 = typeChar(createOverlayState(PLAYLISTS), 'zzz-no-match'); + assert.equal(visibleRows(s0).length, 0); + const s1 = moveHighlight(s0, 1); + assert.equal(s1.highlightIndex, 0); + }); +}); + +describe('typeChar / backspace', () => { + it('typing resets highlight to 0', () => { + const s0 = moveHighlight(createOverlayState(PLAYLISTS), 1); + const s1 = typeChar(s0, 'c'); + assert.equal(s1.highlightIndex, 0); + assert.equal(s1.query, 'c'); + }); + + it('backspace removes the last typed character', () => { + const s = backspace(typeChar(createOverlayState(PLAYLISTS), 'co')); + assert.equal(s.query, 'c'); + }); +}); + +describe('toggleHighlighted', () => { + it('checks then unchecks the highlighted playlist', () => { + const s0 = createOverlayState(PLAYLISTS); + const s1 = toggleHighlighted(s0); + assert.ok(s1.checked.has('Comedy')); + const s2 = toggleHighlighted(s1); + assert.ok(!s2.checked.has('Comedy')); + }); + + it('does nothing when the create-new row is highlighted', () => { + const s0 = moveHighlight(createOverlayState(PLAYLISTS), -1); + const s1 = toggleHighlighted(s0); + assert.equal(s1.checked.size, 0); + }); + + it('caps checked selection at 5 and ignores a 6th toggle', () => { + const many = Array.from({ length: 6 }, (_, i) => ({ name: `P${i}` })); + let s = createOverlayState(many); + for (let i = 0; i < 6; i += 1) { + s = toggleHighlighted(s); + s = moveHighlight(s, 1); + } + assert.equal(s.checked.size, 5); + assert.ok(!s.checked.has('P5')); + }); +}); + +describe('resolveEnter', () => { + it('confirms the checked set when non-empty', () => { + const s = toggleHighlighted(moveHighlight(createOverlayState(PLAYLISTS), 1)); + const result = resolveEnter(s); + assert.deepEqual(result, { type: 'confirm', names: ['Documentaries'] }); + }); + + it('implicitly single-selects the highlighted row when nothing is checked', () => { + const s = createOverlayState(PLAYLISTS); + assert.deepEqual(resolveEnter(s), { type: 'confirm', names: ['Comedy'] }); + }); + + it('opens create-new when the create-new row is highlighted', () => { + const s = moveHighlight(createOverlayState(PLAYLISTS), -1); + assert.deepEqual(resolveEnter(s), { type: 'create-new' }); + }); +}); + +describe('create-new sub-dialog', () => { + it('opens with an empty query and preserves prior checked state', () => { + const s0 = toggleHighlighted(createOverlayState(PLAYLISTS)); + const s1 = openCreateDialog(s0); + assert.deepEqual(s1.subDialog, { query: '' }); + assert.ok(s1.checked.has('Comedy')); + }); + + it('types and backspaces within the sub-dialog only', () => { + let s = openCreateDialog(createOverlayState(PLAYLISTS)); + s = typeInCreateDialog(s, 'N'); + s = typeInCreateDialog(s, 'e'); + assert.equal(s.subDialog.query, 'Ne'); + s = backspaceInCreateDialog(s); + assert.equal(s.subDialog.query, 'N'); + }); + + it('typing when no sub-dialog is open is a no-op', () => { + const s0 = createOverlayState(PLAYLISTS); + const s1 = typeInCreateDialog(s0, 'x'); + assert.equal(s1, s0); + }); + + it('closeCreateDialog clears the sub-dialog and keeps checked state', () => { + const s0 = toggleHighlighted(createOverlayState(PLAYLISTS)); + const s1 = closeCreateDialog(openCreateDialog(s0)); + assert.equal(s1.subDialog, null); + assert.ok(s1.checked.has('Comedy')); + }); + + it('commitCreatedPlaylist appends the new playlist, checks it, and preserves prior checks', () => { + const s0 = toggleHighlighted(createOverlayState(PLAYLISTS)); + let s1 = openCreateDialog(s0); + s1 = typeInCreateDialog(s1, 'New Stuff'); + const s2 = commitCreatedPlaylist(s1, 'New Stuff'); + assert.equal(s2.subDialog, null); + assert.ok(s2.checked.has('Comedy')); + assert.ok(s2.checked.has('New Stuff')); + assert.ok(s2.playlists.some((p) => p.name === 'New Stuff')); + }); +}); + +describe('CREATE_NEW_ROW', () => { + it('is a unique symbol not equal to any playlist row', () => { + assert.equal(typeof CREATE_NEW_ROW, 'symbol'); + }); +}); From beb7d224884d9192b8bcc56e743a987a7d572fb1 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:59:29 +0200 Subject: [PATCH 09/26] feat(content): wire playlist picker into content.js keyboard handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+A,Shift+P now opens the playlist overlay: gated behind a best-effort isLoggedIn signal, loads the catalog from the shared cache or a live native-popup scrape, drives query/highlight/checkbox/ enter through the existing overlay-state reducer, and adds to playlists sequentially (idempotent per row, since the native button is a toggle) with a progress badge reused from jump-overlay's BADGE_STYLE. Create-new is wired through driveCreateNewPlaylist. findSaveToPlaylistTrigger stays an explicit null stub — the watch page's native Save button was never captured in DOM probes, so the whole feature self-heals to a silent no-op rather than driving a fabricated selector. Tracked as the blocking open question for M7. --- apps/shared/src/content/content.js | 164 +++++++++++++++++- .../youtube/youtube-site-adapter.js | 19 ++ src/ui/playlist-overlay.js | 30 ++++ src/ui/playlist-popup-driver.js | 30 ++++ tests/unit/playlist-popup-driver.test.js | 62 +++++++ tests/unit/youtube-site-adapter.test.js | 21 +++ 6 files changed, 324 insertions(+), 2 deletions(-) diff --git a/apps/shared/src/content/content.js b/apps/shared/src/content/content.js index e16a132..ae99cd7 100644 --- a/apps/shared/src/content/content.js +++ b/apps/shared/src/content/content.js @@ -10,8 +10,10 @@ } = await import(browser.runtime.getURL('lib/core/playback-state.js')); const { MESSAGE_TYPES, validateMessage } = await import(browser.runtime.getURL('lib/core/validation.js')); const { debounce } = await import(browser.runtime.getURL('lib/core/debounce.js')); - const { isYouTubeWatchPage, findVideoElement, createYouTubeSiteAdapter } = - await import(browser.runtime.getURL('lib/site-adapters/youtube/youtube-site-adapter.js')); + const { + isYouTubeWatchPage, findVideoElement, createYouTubeSiteAdapter, + isLoggedIn, findSaveToPlaylistTrigger, + } = await import(browser.runtime.getURL('lib/site-adapters/youtube/youtube-site-adapter.js')); const { createPlayerAdapter } = await import(browser.runtime.getURL('lib/player-adapters/html5-video-player-adapter.js')); const { COMMANDS, SPEED_SHORTCUTS, createShortcutController, generateLabels, filterLabelPairs, @@ -20,6 +22,17 @@ await import(browser.runtime.getURL('lib/ui/jump-overlay.js')); const { collectQueueTargets, activateQueueTarget, showQueueConfirmation } = await import(browser.runtime.getURL('lib/ui/queue-overlay.js')); + const { + openSaveToPlaylistPopup, togglePlaylistRow, driveCreateNewPlaylist, closeSaveToPlaylistPopup, + } = await import(browser.runtime.getURL('lib/ui/playlist-popup-driver.js')); + const { createPlaylistCache } = await import(browser.runtime.getURL('lib/core/playlist-cache.js')); + const { + createOverlayState, moveHighlight, typeChar, backspace, toggleHighlighted, resolveEnter, + openCreateDialog, typeInCreateDialog, backspaceInCreateDialog, closeCreateDialog, commitCreatedPlaylist, + } = await import(browser.runtime.getURL('lib/ui/playlist-overlay-state.js')); + const { + createPlaylistOverlay, showPlaylistProgress, finishPlaylistProgress, + } = await import(browser.runtime.getURL('lib/ui/playlist-overlay.js')); const isMac = isMacPlatform(navigator); let settings = null; @@ -143,6 +156,148 @@ labelState = { pairs, typed: '', mode: 'queue' }; } + const playlistOverlay = createPlaylistOverlay(document); + const playlistCache = createPlaylistCache(browser); + let playlistState = null; + + function closePlaylistOverlay() { + playlistOverlay.close(); + playlistState = null; + } + + async function loadPlaylistCatalog(trigger) { + const cached = await playlistCache.read(); + if (cached) return cached; + const rows = await openSaveToPlaylistPopup(trigger, document, window); + closeSaveToPlaylistPopup(document, window); + if (rows.length === 0) return null; + const playlists = rows.map((r) => ({ name: r.name })); + await playlistCache.write(playlists); + return playlists; + } + + // Feature gated to logged-in users; both isLoggedIn and the trigger + // finder are unverified best-effort (see youtube-site-adapter.js), + // so a wrong or missing signal self-heals to a silent no-op here. + async function openPlaylistOverlay() { + if (!isLoggedIn(document)) return; + const trigger = findSaveToPlaylistTrigger(document); + if (!trigger) return; + const playlists = await loadPlaylistCatalog(trigger); + if (!playlists) return; + playlistState = createOverlayState(playlists); + playlistOverlay.render(playlistState); + } + + // Adds sequentially, reopening the native popup once per playlist + // (issue #16 resolved design — no batch confirm exists natively). + // Idempotent per row: only clicks when not already selected, since + // the native button is a toggle and would remove an existing add. + async function addVideoToPlaylists(names) { + const trigger = findSaveToPlaylistTrigger(document); + if (!trigger) return; + const videoEl = findVideoElement(document); + const rect = videoEl ? videoEl.getBoundingClientRect() : { top: 0, left: 0 }; + let added = 0; + showPlaylistProgress(document, rect, 0, names.length); + for (const name of names) { + const rows = await openSaveToPlaylistPopup(trigger, document, window); + const row = rows.find((r) => r.name === name); + if (row) { + if (!row.selected) togglePlaylistRow(row); + added += 1; + } + closeSaveToPlaylistPopup(document, window); + showPlaylistProgress(document, rect, added, names.length); + } + await playlistCache.invalidate(); + finishPlaylistProgress(document, rect, added, names.length); + } + + async function createNewPlaylistOnSite(name) { + const trigger = findSaveToPlaylistTrigger(document); + if (!trigger) return false; + await openSaveToPlaylistPopup(trigger, document, window); + const ok = await driveCreateNewPlaylist(document, window, name); + closeSaveToPlaylistPopup(document, window); + if (ok) await playlistCache.invalidate(); + return ok; + } + + function handlePlaylistKey(e) { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + if (playlistState.subDialog) { + playlistState = closeCreateDialog(playlistState); + playlistOverlay.render(playlistState); + } else { + closePlaylistOverlay(); + } + return; + } + + if (playlistState.subDialog) { + if (e.key === 'Backspace') { + playlistState = backspaceInCreateDialog(playlistState); + playlistOverlay.render(playlistState); + return; + } + if (e.key === 'Enter') { + const name = playlistState.subDialog.query.trim(); + if (name === '') return; + const finalState = commitCreatedPlaylist(playlistState, name); + closePlaylistOverlay(); + createNewPlaylistOnSite(name).then((ok) => { + if (ok) addVideoToPlaylists([...finalState.checked]); + }); + return; + } + if (e.key.length === 1) { + playlistState = typeInCreateDialog(playlistState, e.key); + playlistOverlay.render(playlistState); + } + return; + } + + if (e.key === 'ArrowDown') { + playlistState = moveHighlight(playlistState, 1); + playlistOverlay.render(playlistState); + return; + } + if (e.key === 'ArrowUp') { + playlistState = moveHighlight(playlistState, -1); + playlistOverlay.render(playlistState); + return; + } + if (e.key === 'Backspace') { + playlistState = backspace(playlistState); + playlistOverlay.render(playlistState); + return; + } + if (e.key === ' ') { + playlistState = toggleHighlighted(playlistState); + playlistOverlay.render(playlistState); + return; + } + if (e.key === 'Enter') { + const result = resolveEnter(playlistState); + if (result.type === 'create-new') { + playlistState = openCreateDialog(playlistState); + playlistOverlay.render(playlistState); + return; + } + closePlaylistOverlay(); + addVideoToPlaylists(result.names); + return; + } + if (e.key.length === 1) { + playlistState = typeChar(playlistState, e.key); + playlistOverlay.render(playlistState); + } + } + function handleLabelKey(e) { e.preventDefault(); e.stopPropagation(); @@ -178,6 +333,10 @@ if (!['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) handleLabelKey(e); return; } + if (playlistState) { + if (!['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) handlePlaylistKey(e); + return; + } const t = e.target; const isEditable = t != null && (t.isContentEditable === true @@ -197,6 +356,7 @@ if (result.command === COMMANDS.GO_HOME) goHome(); if (result.command === COMMANDS.SHOW_JUMP_LABELS) openOverlay(); if (result.command === COMMANDS.SHOW_QUEUE_LABELS) openQueueOverlay(); + if (result.command === COMMANDS.SHOW_PLAYLIST_LABELS) openPlaylistOverlay(); if (result.command in SPEED_SHORTCUTS) setDefaultSpeedFromShortcut(SPEED_SHORTCUTS[result.command]); if (result.command === COMMANDS.TOGGLE_AUTO_APPLY) toggleAutoApply(); }, true); diff --git a/src/site-adapters/youtube/youtube-site-adapter.js b/src/site-adapters/youtube/youtube-site-adapter.js index f527d37..bfba803 100644 --- a/src/site-adapters/youtube/youtube-site-adapter.js +++ b/src/site-adapters/youtube/youtube-site-adapter.js @@ -22,6 +22,25 @@ export function findVideoElement(document) { return document.querySelector('video'); } +// ponytail: #avatar-btn is the masthead's signed-in account button, present +// only when logged in (signed-out masthead shows a "Sign in" link instead, +// no such id). Not independently DOM-captured for issue #16 — best-effort +// per K's direction, self-healing to "not logged in" (feature no-ops) if +// wrong. See docs/ai/questions-for-K.md. +export function isLoggedIn(document) { + return document.querySelector('#avatar-btn') !== null; +} + +// UNVERIFIED STUB: the watch page's native "Save to playlist" trigger button +// was never captured in DOM probes (only the already-open sheet was — see +// docs/probes/save-to-playlist-dom-findings.md). Returns null until a real +// capture resolves this, so the playlist overlay self-heals to a silent +// no-op rather than driving a fabricated selector. See +// docs/ai/questions-for-K.md (blocking item). +export function findSaveToPlaylistTrigger(document) { + return null; +} + export function createYouTubeSiteAdapter(document, window) { const handlers = new Map(); diff --git a/src/ui/playlist-overlay.js b/src/ui/playlist-overlay.js index d343be1..d9a8a1b 100644 --- a/src/ui/playlist-overlay.js +++ b/src/ui/playlist-overlay.js @@ -1,4 +1,7 @@ import { visibleRows } from './playlist-overlay-state.js'; +import { BADGE_STYLE } from './jump-overlay.js'; + +const PROGRESS_DONE_MS = 1400; const PANEL_STYLE = [ 'position: fixed', @@ -83,3 +86,30 @@ export function createPlaylistOverlay(doc) { }, }; } + +// ponytail: module-level singleton badge, same shape as queue-overlay.js's +// showQueueConfirmation — only one add-sequence runs at a time per tab. +let progressBadge = null; + +export function showPlaylistProgress(doc, rect, current, total) { + if (!progressBadge) { + progressBadge = doc.createElement('span'); + progressBadge.setAttribute('data-videodefaults-playlist-progress', ''); + doc.body.appendChild(progressBadge); + } + progressBadge.textContent = `Adding ${current}/${total}`; + progressBadge.setAttribute('style', BADGE_STYLE + + `; top: ${Math.max(0, rect.top)}px; left: ${Math.max(0, rect.left)}px`); +} + +export function finishPlaylistProgress(doc, rect, added, total) { + if (!progressBadge) return; + const ok = added === total; + progressBadge.textContent = `${added}/${total} added`; + progressBadge.setAttribute('style', BADGE_STYLE + + `; top: ${Math.max(0, rect.top)}px; left: ${Math.max(0, rect.left)}px` + + (ok ? '; background: #1a7f37; border-color: #1a7f37; color: #fff' : '')); + const badge = progressBadge; + progressBadge = null; + setTimeout(() => badge.remove(), PROGRESS_DONE_MS); +} diff --git a/src/ui/playlist-popup-driver.js b/src/ui/playlist-popup-driver.js index 882752f..ee7ba28 100644 --- a/src/ui/playlist-popup-driver.js +++ b/src/ui/playlist-popup-driver.js @@ -7,6 +7,7 @@ // legacy ytd-add-to-playlist-renderer popup. const ROW_TOGGLE_SELECTOR = 'yt-list-view-model[role="menu"] toggleable-list-item-view-model button[aria-pressed]'; const CREATE_NEW_BUTTON_SELECTOR = '.ytContextualSheetLayoutFooterContainer .ytPanelFooterViewModelPrimaryButton button'; +const CREATE_NAME_INPUT_SELECTOR = 'yt-sheet-view-model input, yt-sheet-view-model [contenteditable="true"]'; const POPUP_WAIT_TIMEOUT_MS = 1500; const POPUP_WAIT_POLL_MS = 50; @@ -65,6 +66,35 @@ export function activateCreateNew(doc) { return true; } +function waitForCreateInput(doc, win) { + return new Promise((resolve) => { + const deadline = Date.now() + POPUP_WAIT_TIMEOUT_MS; + (function poll() { + const input = doc.querySelector(CREATE_NAME_INPUT_SELECTOR); + if (input) return resolve(input); + if (Date.now() >= deadline) return resolve(null); + win.setTimeout(poll, POPUP_WAIT_POLL_MS); + }()); + }); +} + +// UNVERIFIED: the DOM after clicking "Create new playlist" was never +// captured (see docs/ai/questions-for-K.md). Best-effort per K's direction — +// assumes an inline text field appears inside the same sheet, and submits it +// with Enter. Self-healing: resolves false without throwing if no field +// appears within the timeout, so the caller can leave the overlay state +// untouched on failure. +export async function driveCreateNewPlaylist(doc, win, name) { + if (!activateCreateNew(doc)) return false; + const input = await waitForCreateInput(doc, win); + if (!input) return false; + input.focus(); + input.value = name; + input.dispatchEvent(new win.Event('input', { bubbles: true })); + input.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + return true; +} + // ponytail: no explicit close/cancel control was found in either probe // capture (consistent with "no batch Done button" — see findings doc). // Escape is YouTube's universal sheet-dismiss key elsewhere on the site; diff --git a/tests/unit/playlist-popup-driver.test.js b/tests/unit/playlist-popup-driver.test.js index 4041cbd..325db6e 100644 --- a/tests/unit/playlist-popup-driver.test.js +++ b/tests/unit/playlist-popup-driver.test.js @@ -6,6 +6,7 @@ import { openSaveToPlaylistPopup, togglePlaylistRow, activateCreateNew, + driveCreateNewPlaylist, closeSaveToPlaylistPopup, } from '../../src/ui/playlist-popup-driver.js'; @@ -106,6 +107,67 @@ describe('activateCreateNew', () => { }); }); +function makeCreateNewWin(extra = {}) { + return { + setTimeout: (fn) => fn(), + Event: class { constructor(type, init) { this.type = type; Object.assign(this, init); } }, + KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } }, + ...extra, + }; +} + +function makeInputElement() { + return { + value: null, + focused: false, + dispatched: [], + focus() { this.focused = true; }, + dispatchEvent(evt) { this.dispatched.push(evt); }, + }; +} + +describe('driveCreateNewPlaylist', () => { + it('clicks create-new, fills the field, and submits with Enter', async () => { + const btn = { clicked: 0, click() { this.clicked += 1; } }; + const input = makeInputElement(); + const doc = { querySelector: (sel) => (sel === '.ytContextualSheetLayoutFooterContainer .ytPanelFooterViewModelPrimaryButton button' ? btn : input) }; + const ok = await driveCreateNewPlaylist(doc, makeCreateNewWin(), 'New Stuff'); + assert.equal(ok, true); + assert.equal(btn.clicked, 1); + assert.equal(input.value, 'New Stuff'); + assert.equal(input.focused, true); + assert.deepEqual(input.dispatched.map((e) => e.type), ['input', 'keydown']); + assert.equal(input.dispatched[1].key, 'Enter'); + }); + + it('returns false when the create-new button is not found', async () => { + const doc = { querySelector: () => null }; + assert.equal(await driveCreateNewPlaylist(doc, makeCreateNewWin(), 'New Stuff'), false); + }); + + it('returns false when no input field appears before the timeout', async () => { + const btn = { clicked: 0, click() { this.clicked += 1; } }; + let calls = 0; + const doc = { + querySelector: (sel) => { + if (sel === '.ytContextualSheetLayoutFooterContainer .ytPanelFooterViewModelPrimaryButton button') return btn; + calls += 1; + return null; + }, + }; + let now = 0; + const realNow = Date.now; + Date.now = () => now; + try { + const fastWin = makeCreateNewWin({ setTimeout: (fn) => { now += 100; fn(); } }); + assert.equal(await driveCreateNewPlaylist(doc, fastWin, 'New Stuff'), false); + assert.ok(calls > 0); + } finally { + Date.now = realNow; + } + }); +}); + describe('closeSaveToPlaylistPopup', () => { it('dispatches an Escape keydown on the document', () => { let dispatched = null; diff --git a/tests/unit/youtube-site-adapter.test.js b/tests/unit/youtube-site-adapter.test.js index 6a86f6f..7aee460 100644 --- a/tests/unit/youtube-site-adapter.test.js +++ b/tests/unit/youtube-site-adapter.test.js @@ -5,6 +5,8 @@ import { getVideoContextId, findVideoElement, createYouTubeSiteAdapter, + isLoggedIn, + findSaveToPlaylistTrigger, } from '../../src/site-adapters/youtube/youtube-site-adapter.js'; function makeEventTarget() { @@ -89,6 +91,25 @@ describe('findVideoElement', () => { }); }); +describe('isLoggedIn', () => { + it('returns true when the avatar button is present', () => { + const doc = { querySelector: (sel) => (sel === '#avatar-btn' ? {} : null) }; + assert.equal(isLoggedIn(doc), true); + }); + + it('returns false when the avatar button is absent', () => { + const doc = { querySelector: () => null }; + assert.equal(isLoggedIn(doc), false); + }); +}); + +describe('findSaveToPlaylistTrigger', () => { + it('returns null (unverified stub pending a real DOM capture)', () => { + const doc = { querySelector: () => ({}) }; + assert.equal(findSaveToPlaylistTrigger(doc), null); + }); +}); + function makeWindow(href) { return { location: { href } }; } From d09e5bf597ed363ed8e2c28976de016ef13a14ab Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:01:06 +0200 Subject: [PATCH 10/26] docs(playlist): document picker, changelog entry, open questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends keyboard-quickstart.md and keyboard-shortcuts.md with the Ctrl+A,Shift+P playlist picker (behavior, config shape, shiftChords merge semantics, verification coverage). Adds an Unreleased changelog entry. Files four open questions in questions-for-K.md surfaced while building issue #16: the still-uncaptured native Save-trigger button (blocking — feature currently no-ops on the live site), the best-effort login-detection selector, the best-effort create-new post-click UI assumption, and the name-collision limitation of keying playlists by name with no stable id available. --- CHANGELOG.md | 3 ++ docs/ai/questions-for-K.md | 57 ++++++++++++++++++++++ docs/keyboard-quickstart.md | 6 +++ docs/specs/keyboard-shortcuts.md | 82 ++++++++++++++++++++++++++++++-- 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cbeecb..6a5ffc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Added +- Playlist picker: `Ctrl+A, Shift+P` opens a fuzzy-search overlay to add the current watch-page video to one or more playlists, or create a new one, without leaving the keyboard. Gated to logged-in users; catalog is cached across tabs. Blocked from working against the live site until the native "Save" trigger button's selector is confirmed (see `docs/ai/questions-for-K.md`). + ## [0.2.0] - 2026-08-06 ### Added diff --git a/docs/ai/questions-for-K.md b/docs/ai/questions-for-K.md index 1c9e55b..f178656 100644 --- a/docs/ai/questions-for-K.md +++ b/docs/ai/questions-for-K.md @@ -109,3 +109,60 @@ Chrome/Edge. Needs your go/no-go per browser before any research or implementation starts (Opera is cheap to explore, Safari is a materially different pipeline — macOS runner, paid Apple account, Xcode project). **Default:** no work started on either until you answer in that issue. + +--- + +Dated 2026-08-07. Surfaced while implementing issue #16 (playlist picker, +built with your sign-off from this batch). Same convention: default is what +I proceeded with, code is flagged `UNVERIFIED`/`ponytail:` at each spot. + +## Q13 — native "Save to playlist" trigger button on the watch page (BLOCKING) + +`docs/probes/save-to-playlist-dom-findings.md` only captured the sheet +*already open* — never the watch page's own button/menu item that opens it. +Every other piece of the feature (fuzzy search, popup driving, cache, +multi-add, create-new) is built and unit-tested, but +`findSaveToPlaylistTrigger` (`src/site-adapters/youtube/youtube-site-adapter.js`) +is a stub that always returns `null`, so `Ctrl+A, Shift+P` currently no-ops +on the live site — nothing breaks, it just does nothing. Needs a real DOM +capture of the watch page's action row (like/dislike/share/save) the same +way the existing probes captured the sheet, ideally including how it differs +(if at all) for Shorts vs. regular watch pages. +**Default:** stub in place, feature inert until this lands; no guessed +selector shipped in its place since a wrong one would look confident and +fail silently in a worse way than an honest no-op. + +## Q14 — login detection (`isLoggedIn`) + +`isLoggedIn` checks for `#avatar-btn` in the masthead (present when signed +in; signed-out shows a "Sign in" link instead) — a reasonable, structurally- +grounded guess, but never independently confirmed against a captured +signed-out DOM. +**Default:** ship as best-effort; if wrong, the picker either never opens +for a logged-in user (safe, just annoying) or attempts to open for a +signed-out one and then fails harmlessly at the trigger-button stub (Q13) +either way, so the failure mode is safe regardless. + +## Q15 — create-new post-click UI shape + +Per your "best-effort, ponytail-flagged" answer: `driveCreateNewPlaylist` +(`src/ui/playlist-popup-driver.js`) assumes clicking the footer "create new" +button reveals an inline text input/contenteditable inside the same sheet, +and submits by setting its value and dispatching an `Enter` keydown. This +was never captured — could be a separate dialog, a different submit +mechanism (dedicated button vs. Enter), or something else entirely. +**Default:** shipped as described; self-heals to a no-op (returns `false`, +overlay state left untouched) if no field appears within 1.5 s. Needs +verification during real-browser testing, same as Q13. + +## Q16 — playlist identity when names collide + +The picker keys playlists by name (no stable DOM id exists on the row — +only the `aria-label` text). Two playlists with the same name (YouTube +allows this) would be indistinguishable to fuzzy search, checkbox state, and +the add sequence (`rows.find((r) => r.name === name)` would always resolve +to whichever matches first). +**Default:** accepted as a known limitation, not fixed — no id-bearing DOM +signal was found in probes to key on instead. Flag if this turns out to +matter in practice (e.g. your account actually has duplicate-named +playlists). diff --git a/docs/keyboard-quickstart.md b/docs/keyboard-quickstart.md index fa115e4..9327496 100644 --- a/docs/keyboard-quickstart.md +++ b/docs/keyboard-quickstart.md @@ -13,6 +13,7 @@ want via the extension's stored settings, same as any other override. | `Ctrl+A` | Prefix — arms the next key as a command (default binding, configurable) | Global | | `Ctrl+A` then `o` | Open jump overlay (labels clickable elements) | Global | | `Ctrl+A` then `p` | Open queue overlay (labels videos with an "Add to queue" option; typing a label adds that video next) | Global | +| `Ctrl+A` then `Shift+P` | Open playlist picker for the current video (fuzzy-search playlists, checkbox multi-add, create new) — watch page only, requires being logged in | Watch page | | `Ctrl+A` then `y` | Go home (click YouTube logo, or navigate to configured home URL) | Global | | `Ctrl+A` then `v` | Set playback speed to preset 1 (default `1×`) | Global | | `Ctrl+A` then `b` | Set playback speed to preset 2 (default `1.5×`) | Global | @@ -22,5 +23,10 @@ want via the extension's stored settings, same as any other override. | Two-char label (e.g. `AA`) | Filter/select the labelled target; typing the full label activates it | Jump/queue overlay open | | `Backspace` | Remove last typed label character | Jump/queue overlay open | | `Esc` | Close overlay | Jump/queue overlay open | +| Type letters | Fuzzy-filter playlists by name | Playlist picker open | +| `↑`/`↓` | Move highlight (wraps, includes "+ Create new" as the last row) | Playlist picker open | +| `Space` | Toggle a checkbox on the highlighted playlist (local only, up to 5) | Playlist picker open | +| `Enter` | Add to checked playlists, or the highlighted one if none checked, or open create-new | Playlist picker open | +| `Esc` | Close create-new sub-dialog, or the picker if none open | Playlist picker open | Pending prefix auto-cancels after 2 seconds if no chord key follows. diff --git a/docs/specs/keyboard-shortcuts.md b/docs/specs/keyboard-shortcuts.md index e95cdc8..dc6861b 100644 --- a/docs/specs/keyboard-shortcuts.md +++ b/docs/specs/keyboard-shortcuts.md @@ -1,6 +1,8 @@ # Keyboard Shortcuts — tmux-style prefix navigation -Status: implemented 2026-07-17 (all editions; shared source). +Status: implemented 2026-07-17 (all editions; shared source). Playlist picker +(`Ctrl+A, Shift+P`) added 2026-08-07 (issue #16) — see below; blocked on two +unverified DOM assumptions, see `docs/ai/questions-for-K.md`. ## Model @@ -14,6 +16,7 @@ select, or contenteditable — `Ctrl+A` still selects text there. |---|---|---| | `Ctrl+A` then `o` | `show-jump-labels` | Overlay deterministic two-char indexes on clickable elements; type an index to focus+click it | | `Ctrl+A` then `p` | `show-queue-labels` | Overlay two-char indexes on every video that has an "Add to queue" option; type an index to open that video's ⋮ menu, click "Add to queue", and confirm with a badge | +| `Ctrl+A` then `Shift+P` | `show-playlist-labels` | Open a fuzzy-search playlist picker for the current watch-page video; add to one or more playlists, or create a new one | | `Ctrl+A` then `y` | `go-home` | Go to the main YouTube page (clicks the logo; falls back to `homeUrl`) | | `Ctrl+A` then `v` | `set-speed-1` | Set playback speed to preset 1's configured value (default `1`); persists as `settings.defaultSpeed` and applies to the active video | | `Ctrl+A` then `b` | `set-speed-2` | Same as above for preset 2 (default `1.5`) | @@ -118,6 +121,67 @@ still held (`Ctrl+A`, keep Ctrl, `o` works). - On success, a green confirmation badge ("Added to queue") appears near the target's rect and fades out after ~1.4 s, reusing the jump overlay's `BADGE_STYLE`. +## Playlist picker + +Resolved 2026-08-07 (issue #16). Wired in `apps/shared/src/content/content.js`'s +`setupKeyboard()`, backed by four new modules: + +- `src/core/fuzzy-match.js` — substring match, else Levenshtein distance ≤ 2 on + whitespace-stripped, lowercased names (`matchesPlaylistQuery`). +- `src/ui/playlist-popup-driver.js` — drives YouTube's native "Save to + playlist" sheet silently, same off-screen-driving approach as the queue + overlay: `openSaveToPlaylistPopup` scrapes rows (`aria-pressed` for + selected state, name parsed from `aria-label`'s first comma-segment), + `togglePlaylistRow` clicks a row, `driveCreateNewPlaylist` clicks the + footer "create new" button and best-effort drives whatever inline field + appears (**UNVERIFIED**, see `docs/ai/questions-for-K.md`). +- `src/core/playlist-cache.js` — `browser.storage.local`-backed catalog cache + (name only, 5 min TTL), shared across tabs so repeated opens skip + re-scraping the native popup. Deliberately does **not** cache per-video + membership (`selected`), since that's video-specific and would be wrong to + serve to a different video — the overlay never displays native membership + at all, only the local checkbox state the user sets in this session. +- `src/ui/playlist-overlay-state.js` / `src/ui/playlist-overlay.js` — pure + reducer (fully unit tested) + DOM painter (untested at the unit level, same + convention as `createJumpOverlay`) for the fuzzy-search list, checkbox + multi-select (capped at 5), and the nested create-new sub-dialog. + +Behavior: + +- Gated to logged-in users (`isLoggedIn` in the YouTube site adapter — **best-effort, + unverified**, see below) and to the watch page. +- Opening: read the playlist cache; on a miss, open the native popup once to + scrape it, then close it again — the overlay never leaves the native sheet + visibly open. +- Typing filters by fuzzy match; `↑`/`↓` move the highlight through the + filtered list plus an always-present "+ Create new" row; `Space` toggles a + local checkbox (max 5); `Enter` adds to the checked set if non-empty, else + implicitly single-selects the highlighted row, else (on "+ Create new") + opens a nested sub-dialog for typing the new playlist's name. +- Adding: reopens the native popup once per playlist, sequentially (no native + batch-confirm exists), clicking a row only if it isn't already + `aria-pressed="true"` — the native button is a toggle, so an unconditional + click on an already-saved playlist would remove it instead of leaving it + added. A progress badge (`Adding n/total`, reusing jump-overlay's + `BADGE_STYLE`) tracks the sequence and settles to a green `n/total added` + badge for ~1.4 s. +- Create-new: on sub-dialog confirm, opens the native popup, drives the + best-effort create-new flow, and on success adds the video to the checked + playlists plus the new one. +- Cache is invalidated after any successful add or create. + +**Two open, unverified DOM assumptions block this from working against the +real site today** (both flagged `UNVERIFIED` in source, see +`docs/ai/questions-for-K.md`): + +1. `findSaveToPlaylistTrigger` (YouTube site adapter) is a stub returning + `null` — the watch page's native "Save" button was never captured in DOM + probes, only the already-open sheet was. Until resolved, the whole feature + self-heals to a silent no-op on `Ctrl+A, Shift+P`. +2. `isLoggedIn` (`#avatar-btn` presence) and the post-create-new inline-field + shape in `driveCreateNewPlaylist` are best-effort assumptions, not + independently DOM-captured. + ## Configuration `DEFAULT_KEYMAP` and the default speed-shortcut values are sourced from the @@ -138,6 +202,9 @@ shortcut key or a speed value is a one-file edit, no code change needed: "n": "set-speed-3", "h": "toggle-auto-apply" }, + "shiftChords": { + "p": "show-playlist-labels" + }, "homeUrl": "https://www.youtube.com/", "speeds": { "set-speed-1": 1, @@ -159,6 +226,11 @@ the same `normalizeKeymap()` on every read: A stored override is merged onto the defaults key-by-key, not swapped in wholesale — a partial override (e.g. only remapping `o`) keeps every other default chord (`p`, `y`, `v`, `b`, `n`, `h`) working. +- `shiftChords` is a separate map, same merge/validation rules as `chords`, + looked up only when the chord key is pressed with `Shift` held — pressing + `p` with Shift does **not** fall back to the unshifted `chords.p` + (`show-queue-labels`) if no `shiftChords.p` entry exists; it's simply + unbound. - `homeUrl` must be an `https://*.youtube.com` URL (blocks `javascript:` and third-party redirect targets). - `speeds` maps each `set-speed-*` command to a numeric value validated by @@ -181,8 +253,12 @@ No options UI yet — edit via storage or wait for the options page ## Verification - Unit: `tests/unit/keyboard-shortcuts.test.js` (state machine, keymap - sanitizing, labels), `tests/unit/jump-overlay.test.js` (target collection), - `tests/unit/queue-overlay.test.js` (queue target collection, menu-item activation). + sanitizing, labels, `shiftChords`), `tests/unit/jump-overlay.test.js` + (target collection), `tests/unit/queue-overlay.test.js` (queue target + collection, menu-item activation), `tests/unit/fuzzy-match.test.js`, + `tests/unit/playlist-popup-driver.test.js`, `tests/unit/playlist-cache.test.js`, + `tests/unit/playlist-overlay-state.test.js`, `tests/unit/youtube-site-adapter.test.js` + (`isLoggedIn`, `findSaveToPlaylistTrigger` stub). - E2E: `scripts/test-chrome-smoke.mjs` presses the real chords in Chromium and asserts overlay render, Escape close, and home navigation. `scripts/test-extension-e2e.mjs` (TC-15, TC-16, TC-17) dispatches the same chords over the Firefox RDP console actor From 3a20bb15c6b95e48d93d7e90f4e0c86d29a6d7f6 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:19:59 +0200 Subject: [PATCH 11/26] feat(playlist): resolve native Save-to-playlist trigger selector Live DOM probe of the watch page's action row found the real trigger: Save is the sole yt-button-view-model-wrapped child of #flexible-item-buttons, distinct from Download's wrapper. Replaces the null stub that kept Ctrl+A, Shift+P inert, resolving Q13. --- docs/ai/questions-for-K.md | 26 +++---- docs/probes/save-trigger-dom-findings.md | 69 +++++++++++++++++++ .../youtube/youtube-site-adapter.js | 18 +++-- tests/unit/youtube-site-adapter.test.js | 12 +++- 4 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 docs/probes/save-trigger-dom-findings.md diff --git a/docs/ai/questions-for-K.md b/docs/ai/questions-for-K.md index f178656..1bcce8e 100644 --- a/docs/ai/questions-for-K.md +++ b/docs/ai/questions-for-K.md @@ -116,21 +116,17 @@ Dated 2026-08-07. Surfaced while implementing issue #16 (playlist picker, built with your sign-off from this batch). Same convention: default is what I proceeded with, code is flagged `UNVERIFIED`/`ponytail:` at each spot. -## Q13 — native "Save to playlist" trigger button on the watch page (BLOCKING) - -`docs/probes/save-to-playlist-dom-findings.md` only captured the sheet -*already open* — never the watch page's own button/menu item that opens it. -Every other piece of the feature (fuzzy search, popup driving, cache, -multi-add, create-new) is built and unit-tested, but -`findSaveToPlaylistTrigger` (`src/site-adapters/youtube/youtube-site-adapter.js`) -is a stub that always returns `null`, so `Ctrl+A, Shift+P` currently no-ops -on the live site — nothing breaks, it just does nothing. Needs a real DOM -capture of the watch page's action row (like/dislike/share/save) the same -way the existing probes captured the sheet, ideally including how it differs -(if at all) for Shorts vs. regular watch pages. -**Default:** stub in place, feature inert until this lands; no guessed -selector shipped in its place since a wrong one would look confident and -fail silently in a worse way than an honest no-op. +## Q13 — native "Save to playlist" trigger button on the watch page (RESOLVED 2026-08-07) + +Live DOM probe (headless Firefox, logged out) found the trigger: +`#flexible-item-buttons > yt-button-view-model button[aria-label]` — Save is +the sole `yt-button-view-model`-wrapped child of `#flexible-item-buttons`; +Download uses a different wrapper. `findSaveToPlaylistTrigger` now returns +this instead of the `null` stub, so `Ctrl+A, Shift+P` is live. Details and +remaining gaps (untested logged-in session, untested Shorts layout — a +"Clip" button there might share the same wrapper and break the "sole child" +assumption) in `docs/probes/save-trigger-dom-findings.md`. Flag if this +turns out wrong in real use. ## Q14 — login detection (`isLoggedIn`) diff --git a/docs/probes/save-trigger-dom-findings.md b/docs/probes/save-trigger-dom-findings.md new file mode 100644 index 0000000..f876d30 --- /dev/null +++ b/docs/probes/save-trigger-dom-findings.md @@ -0,0 +1,69 @@ +# Watch-page "Save to playlist" trigger button — DOM findings + +Captured 2026-08-07 via a live, headless-Firefox Playwright probe against +`https://www.youtube.com/watch?v=dQw4w9WgXcQ`, logged out (fresh browser +profile, no stored session). Resolves Q13 in `docs/ai/questions-for-K.md`: +the earlier probe (`save-to-playlist-dom-findings.md`) only ever captured +the sheet *after* it was already open — this one captures the watch page's +own action-row button that opens it. + +## Action row structure + +``` +#actions + #actions-inner + #menu + ytd-menu-renderer + #top-level-buttons-computed (like/dislike segmented button, Share) + #flexible-item-buttons (Save, Download) + yt-button-view-model → Save + button-view-model + button[aria-label="Save to playlist"] + ytd-download-button-renderer → Download + ytd-button-renderer + yt-button-shape + button[aria-label="Download"] +``` + +Save and Download are the only two children of `#flexible-item-buttons`, +and they use different wrapper components — Save is the sole +`` child, Download is wrapped in +``. That wrapper-tag distinction is the +selector used, not the `aria-label` text (locale-dependent elsewhere, though +this particular capture happened to be English since it was an unauthenticated +session with no locale customization): + +``` +#flexible-item-buttons > yt-button-view-model button[aria-label] +``` + +## Login-gate behavior confirmed live + +Clicked the resolved trigger while logged out: it opens a real sheet +(`ytd-modal-with-title-and-button-renderer` inside `ytd-popup-container`), +but it's YouTube's own **"Want to watch this again later? Sign in to add +this video to a playlist."** interstitial, not the playlist-picking sheet. +This confirms the existing `isLoggedIn()` gate in `content.js` is load- +bearing, not just defensive: without it, pressing `Ctrl+A, Shift+P` while +signed out would open our overlay against YouTube's sign-in prompt instead +of a playlist list, and `openSaveToPlaylistPopup`'s row-scraping would find +nothing sensible. + +## Still unverified + +- **Logged-in session**: not captured (fresh automated profile has no + stored YouTube login). Assumed structurally identical — the wrapper + distinction (`yt-button-view-model` vs `ytd-download-button-renderer`) is + unrelated to auth state — but not confirmed. +- **Shorts / other layouts**: not captured. A "Clip" button, if present, + might also use the `yt-button-view-model` wrapper and share + `#flexible-item-buttons`, which would break the "sole child" assumption + the selector above relies on (see the `ponytail:` note on + `findSaveToPlaylistTrigger` in `youtube-site-adapter.js`). + +## Source + +Live capture only (headless Firefox via the `playwright` devDependency, +run as a throwaway script, not committed) — no HTML fragment saved +alongside this doc since the page contains no personal/account data in the +logged-out state captured. diff --git a/src/site-adapters/youtube/youtube-site-adapter.js b/src/site-adapters/youtube/youtube-site-adapter.js index bfba803..6ad994e 100644 --- a/src/site-adapters/youtube/youtube-site-adapter.js +++ b/src/site-adapters/youtube/youtube-site-adapter.js @@ -31,14 +31,18 @@ export function isLoggedIn(document) { return document.querySelector('#avatar-btn') !== null; } -// UNVERIFIED STUB: the watch page's native "Save to playlist" trigger button -// was never captured in DOM probes (only the already-open sheet was — see -// docs/probes/save-to-playlist-dom-findings.md). Returns null until a real -// capture resolves this, so the playlist overlay self-heals to a silent -// no-op rather than driving a fabricated selector. See -// docs/ai/questions-for-K.md (blocking item). +// Captured 2026-08-07 via a live watch-page DOM probe (logged-out session): +// the Save button is the only `#flexible-item-buttons` child wrapped in +// `` — Download uses a different wrapper +// (`ytd-download-button-renderer`), Share lives under +// `#top-level-buttons-computed` instead. See +// docs/probes/save-trigger-dom-findings.md. +// ponytail: assumes Save is the sole yt-button-view-model-wrapped button in +// #flexible-item-buttons — untested against a logged-in session or Shorts +// layout, where another button (e.g. Clip) might share that wrapper. +// Self-heals to a missed trigger (openPlaylistOverlay no-ops) if wrong. export function findSaveToPlaylistTrigger(document) { - return null; + return document.querySelector('#flexible-item-buttons > yt-button-view-model button[aria-label]'); } export function createYouTubeSiteAdapter(document, window) { diff --git a/tests/unit/youtube-site-adapter.test.js b/tests/unit/youtube-site-adapter.test.js index 7aee460..79fdaf7 100644 --- a/tests/unit/youtube-site-adapter.test.js +++ b/tests/unit/youtube-site-adapter.test.js @@ -104,8 +104,16 @@ describe('isLoggedIn', () => { }); describe('findSaveToPlaylistTrigger', () => { - it('returns null (unverified stub pending a real DOM capture)', () => { - const doc = { querySelector: () => ({}) }; + const SAVE_SELECTOR = '#flexible-item-buttons > yt-button-view-model button[aria-label]'; + + it('returns the button matching the Save wrapper selector', () => { + const btn = {}; + const doc = { querySelector: (sel) => (sel === SAVE_SELECTOR ? btn : null) }; + assert.equal(findSaveToPlaylistTrigger(doc), btn); + }); + + it('returns null when the Save wrapper is absent', () => { + const doc = { querySelector: () => null }; assert.equal(findSaveToPlaylistTrigger(doc), null); }); }); From c4e6ac60ccb9c36da221b39624edef3cf3c9761e Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:20:03 +0200 Subject: [PATCH 12/26] feat(playlist): show sign-in badge for logged-out playlist chord The logged-out path silently no-op'd, giving no feedback that the chord was even recognized. Reuses the existing badge pattern to tell the user to sign in instead. --- apps/shared/src/content/content.js | 9 +++++++-- src/ui/playlist-overlay.js | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/shared/src/content/content.js b/apps/shared/src/content/content.js index ae99cd7..fd69349 100644 --- a/apps/shared/src/content/content.js +++ b/apps/shared/src/content/content.js @@ -31,7 +31,7 @@ openCreateDialog, typeInCreateDialog, backspaceInCreateDialog, closeCreateDialog, commitCreatedPlaylist, } = await import(browser.runtime.getURL('lib/ui/playlist-overlay-state.js')); const { - createPlaylistOverlay, showPlaylistProgress, finishPlaylistProgress, + createPlaylistOverlay, showPlaylistProgress, finishPlaylistProgress, showNotLoggedInBadge, } = await import(browser.runtime.getURL('lib/ui/playlist-overlay.js')); const isMac = isMacPlatform(navigator); @@ -180,7 +180,12 @@ // finder are unverified best-effort (see youtube-site-adapter.js), // so a wrong or missing signal self-heals to a silent no-op here. async function openPlaylistOverlay() { - if (!isLoggedIn(document)) return; + if (!isLoggedIn(document)) { + const videoEl = findVideoElement(document); + const rect = videoEl ? videoEl.getBoundingClientRect() : { top: 0, left: 0 }; + showNotLoggedInBadge(document, rect); + return; + } const trigger = findSaveToPlaylistTrigger(document); if (!trigger) return; const playlists = await loadPlaylistCatalog(trigger); diff --git a/src/ui/playlist-overlay.js b/src/ui/playlist-overlay.js index d9a8a1b..0deb888 100644 --- a/src/ui/playlist-overlay.js +++ b/src/ui/playlist-overlay.js @@ -2,6 +2,7 @@ import { visibleRows } from './playlist-overlay-state.js'; import { BADGE_STYLE } from './jump-overlay.js'; const PROGRESS_DONE_MS = 1400; +const NOT_LOGGED_IN_DURATION_MS = 1400; const PANEL_STYLE = [ 'position: fixed', @@ -102,6 +103,16 @@ export function showPlaylistProgress(doc, rect, current, total) { + `; top: ${Math.max(0, rect.top)}px; left: ${Math.max(0, rect.left)}px`); } +export function showNotLoggedInBadge(doc, rect) { + const badge = doc.createElement('span'); + badge.setAttribute('data-videodefaults-playlist-not-logged-in', ''); + badge.textContent = 'Sign in to save to a playlist'; + badge.setAttribute('style', BADGE_STYLE + + `; top: ${Math.max(0, rect.top)}px; left: ${Math.max(0, rect.left)}px`); + doc.body.appendChild(badge); + setTimeout(() => badge.remove(), NOT_LOGGED_IN_DURATION_MS); +} + export function finishPlaylistProgress(doc, rect, added, total) { if (!progressBadge) return; const ok = added === total; From 7273a3c90263059d34176d22b7de71ae2afdc431 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:14 +0200 Subject: [PATCH 13/26] fix(playlist): hide native Save-to-playlist sheet while it's driven The sheet was genuinely visible on screen for the whole add/remove sequence, not just briefly. Hides it via inline style as soon as rows are found, instead of relying solely on the unverified Escape-close to make it disappear afterward. --- src/ui/playlist-popup-driver.js | 20 ++++++++++++- tests/unit/playlist-popup-driver.test.js | 37 +++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/ui/playlist-popup-driver.js b/src/ui/playlist-popup-driver.js index ee7ba28..30abd68 100644 --- a/src/ui/playlist-popup-driver.js +++ b/src/ui/playlist-popup-driver.js @@ -8,9 +8,25 @@ const ROW_TOGGLE_SELECTOR = 'yt-list-view-model[role="menu"] toggleable-list-item-view-model button[aria-pressed]'; const CREATE_NEW_BUTTON_SELECTOR = '.ytContextualSheetLayoutFooterContainer .ytPanelFooterViewModelPrimaryButton button'; const CREATE_NAME_INPUT_SELECTOR = 'yt-sheet-view-model input, yt-sheet-view-model [contenteditable="true"]'; +const SHEET_SELECTOR = 'yt-sheet-view-model[slot="dropdown-content"]'; const POPUP_WAIT_TIMEOUT_MS = 1500; const POPUP_WAIT_POLL_MS = 50; +// Reported live: without this, the sheet is genuinely visible on screen for +// the whole drive sequence (open/toggle/close per playlist), not the +// "off-screen/silently" behavior this module already claimed. Hides the +// sheet itself rather than depending on the unverified Escape-close below — +// stays correct even if that close never actually unmounts it. +function hideOpenSheet(doc) { + const sheet = doc.querySelector(SHEET_SELECTOR); + if (!sheet) return; + sheet.style.setProperty('position', 'fixed', 'important'); + sheet.style.setProperty('left', '-9999px', 'important'); + sheet.style.setProperty('top', '-9999px', 'important'); + sheet.style.setProperty('opacity', '0', 'important'); + sheet.style.setProperty('pointer-events', 'none', 'important'); +} + function parsePlaylistName(ariaLabel) { // ponytail: name is the first comma-separated segment of the row's own // aria-label ("{name}, {visibility}, {selected-state}", confirmed in probe @@ -52,7 +68,9 @@ function waitForRows(doc, win) { // clicked, everything below is driving probe-verified DOM. export async function openSaveToPlaylistPopup(triggerButton, doc, win) { triggerButton.click(); - return waitForRows(doc, win); + const rows = await waitForRows(doc, win); + if (rows.length > 0) hideOpenSheet(doc); + return rows; } export function togglePlaylistRow(row) { diff --git a/tests/unit/playlist-popup-driver.test.js b/tests/unit/playlist-popup-driver.test.js index 325db6e..5b9bd24 100644 --- a/tests/unit/playlist-popup-driver.test.js +++ b/tests/unit/playlist-popup-driver.test.js @@ -54,6 +54,14 @@ describe('findCreateNewButton', () => { }); }); +function makeStyleTarget() { + const style = new Map(); + return { + style: { setProperty: (prop, value) => style.set(prop, value) }, + getStyle: (prop) => style.get(prop), + }; +} + describe('openSaveToPlaylistPopup', () => { it('clicks the trigger and resolves once rows appear', async () => { const trigger = { clicked: 0, click() { this.clicked += 1; } }; @@ -64,6 +72,7 @@ describe('openSaveToPlaylistPopup', () => { calls += 1; return calls < 3 ? [] : [row]; }, + querySelector: () => null, }; const rows = await openSaveToPlaylistPopup(trigger, doc, win); assert.equal(trigger.clicked, 1); @@ -74,7 +83,7 @@ describe('openSaveToPlaylistPopup', () => { it('resolves with an empty list when rows never appear before the timeout', async () => { const trigger = { clicked: 0, click() { this.clicked += 1; } }; let now = 0; - const doc = { querySelectorAll: () => [] }; + const doc = { querySelectorAll: () => [], querySelector: () => null }; const fastWin = { setTimeout: (fn) => { now += 100; fn(); } }; const realNow = Date.now; Date.now = () => now; @@ -85,6 +94,32 @@ describe('openSaveToPlaylistPopup', () => { Date.now = realNow; } }); + + it('hides the sheet once rows appear so it is not visible while driven', async () => { + const trigger = { clicked: 0, click() { this.clicked += 1; } }; + const row = makeRowButton({ name: 'Comedy' }); + const sheet = makeStyleTarget(); + const doc = { querySelectorAll: () => [row], querySelector: () => sheet }; + await openSaveToPlaylistPopup(trigger, doc, win); + assert.equal(sheet.getStyle('opacity'), '0'); + assert.equal(sheet.getStyle('pointer-events'), 'none'); + }); + + it('does not attempt to hide anything when no rows appear', async () => { + const trigger = { clicked: 0, click() { this.clicked += 1; } }; + let queried = false; + const doc = { querySelectorAll: () => [], querySelector: () => { queried = true; return null; } }; + let now = 0; + const fastWin = { setTimeout: (fn) => { now += 100; fn(); } }; + const realNow = Date.now; + Date.now = () => now; + try { + await openSaveToPlaylistPopup(trigger, doc, fastWin); + assert.equal(queried, false); + } finally { + Date.now = realNow; + } + }); }); describe('togglePlaylistRow', () => { From 08af8ccc3047459045ed36d2fb5ff8f9dc3b7a09 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:18 +0200 Subject: [PATCH 14/26] fix(playlist): pre-check existing playlists and support removal The picker never showed which playlists the video was already in (loadPlaylistCatalog dropped the scraped selected flag, and the cache-hit path skipped scraping entirely). Now always scrapes fresh, pre-checks membership, and unchecking a pre-checked row on confirm removes the video from that playlist instead of only ever adding. --- apps/shared/src/content/content.js | 45 ++++++++++++++--------- src/ui/playlist-overlay-state.js | 33 ++++++++++++++--- tests/unit/playlist-overlay-state.test.js | 37 +++++++++++++++++-- 3 files changed, 89 insertions(+), 26 deletions(-) diff --git a/apps/shared/src/content/content.js b/apps/shared/src/content/content.js index fd69349..fdecd34 100644 --- a/apps/shared/src/content/content.js +++ b/apps/shared/src/content/content.js @@ -29,6 +29,7 @@ const { createOverlayState, moveHighlight, typeChar, backspace, toggleHighlighted, resolveEnter, openCreateDialog, typeInCreateDialog, backspaceInCreateDialog, closeCreateDialog, commitCreatedPlaylist, + resolveCreatedPlaylistChanges, } = await import(browser.runtime.getURL('lib/ui/playlist-overlay-state.js')); const { createPlaylistOverlay, showPlaylistProgress, finishPlaylistProgress, showNotLoggedInBadge, @@ -165,14 +166,17 @@ playlistState = null; } + // Always scrapes fresh: membership (which playlists already contain + // this video) is per-video, not cacheable, and the popup is now hidden + // while driven (see playlist-popup-driver.js) so there's no visible + // cost to opening it on every overlay open. The cache is still written + // (name catalog only) for other consumers that don't need membership. async function loadPlaylistCatalog(trigger) { - const cached = await playlistCache.read(); - if (cached) return cached; const rows = await openSaveToPlaylistPopup(trigger, document, window); closeSaveToPlaylistPopup(document, window); if (rows.length === 0) return null; - const playlists = rows.map((r) => ({ name: r.name })); - await playlistCache.write(playlists); + const playlists = rows.map((r) => ({ name: r.name, selected: r.selected })); + await playlistCache.write(playlists.map((p) => ({ name: p.name }))); return playlists; } @@ -194,29 +198,34 @@ playlistOverlay.render(playlistState); } - // Adds sequentially, reopening the native popup once per playlist + // Applies sequentially, reopening the native popup once per playlist // (issue #16 resolved design — no batch confirm exists natively). - // Idempotent per row: only clicks when not already selected, since - // the native button is a toggle and would remove an existing add. - async function addVideoToPlaylists(names) { + // Idempotent per row: only clicks when the row's live state disagrees + // with the desired one, since the native button is a plain toggle. + async function addVideoToPlaylists(toAdd, toRemove = []) { + const changes = [ + ...toAdd.map((name) => ({ name, shouldSelect: true })), + ...toRemove.map((name) => ({ name, shouldSelect: false })), + ]; + if (changes.length === 0) return; const trigger = findSaveToPlaylistTrigger(document); if (!trigger) return; const videoEl = findVideoElement(document); const rect = videoEl ? videoEl.getBoundingClientRect() : { top: 0, left: 0 }; - let added = 0; - showPlaylistProgress(document, rect, 0, names.length); - for (const name of names) { + let applied = 0; + showPlaylistProgress(document, rect, 0, changes.length); + for (const { name, shouldSelect } of changes) { const rows = await openSaveToPlaylistPopup(trigger, document, window); const row = rows.find((r) => r.name === name); if (row) { - if (!row.selected) togglePlaylistRow(row); - added += 1; + if (row.selected !== shouldSelect) togglePlaylistRow(row); + applied += 1; } closeSaveToPlaylistPopup(document, window); - showPlaylistProgress(document, rect, added, names.length); + showPlaylistProgress(document, rect, applied, changes.length); } await playlistCache.invalidate(); - finishPlaylistProgress(document, rect, added, names.length); + finishPlaylistProgress(document, rect, applied, changes.length); } async function createNewPlaylistOnSite(name) { @@ -255,7 +264,9 @@ const finalState = commitCreatedPlaylist(playlistState, name); closePlaylistOverlay(); createNewPlaylistOnSite(name).then((ok) => { - if (ok) addVideoToPlaylists([...finalState.checked]); + if (!ok) return; + const { toAdd, toRemove } = resolveCreatedPlaylistChanges(finalState); + addVideoToPlaylists(toAdd, toRemove); }); return; } @@ -294,7 +305,7 @@ return; } closePlaylistOverlay(); - addVideoToPlaylists(result.names); + addVideoToPlaylists(result.toAdd, result.toRemove); return; } if (e.key.length === 1) { diff --git a/src/ui/playlist-overlay-state.js b/src/ui/playlist-overlay-state.js index 5920b1a..78f8182 100644 --- a/src/ui/playlist-overlay-state.js +++ b/src/ui/playlist-overlay-state.js @@ -4,12 +4,16 @@ import { filterPlaylistsByQuery } from '../core/fuzzy-match.js'; export const CREATE_NEW_ROW = Symbol('create-new'); const MAX_CONFIRM_SELECTION = 5; +// Playlists the current video is already in are pre-checked (issue #16 +// follow-up) so unchecking one and confirming reads as "remove from this +// playlist" — see resolveEnter's diff against the original selected flags. export function createOverlayState(playlists) { return { playlists, query: '', highlightIndex: 0, - checked: new Set(), + checked: new Set(playlists.filter((p) => p.selected).map((p) => p.name)), + touched: false, subDialog: null, }; } @@ -54,16 +58,33 @@ export function toggleHighlighted(state) { if (checked.size >= MAX_CONFIRM_SELECTION) return state; checked.add(row.name); } - return { ...state, checked }; + return { ...state, checked, touched: true }; } -// Enter: checked set if non-empty, else implicit single-select on the -// highlighted row, else opens the create-new sub-dialog (resolved 2026-08-07). +// Diffs the desired checked set against each playlist's original (native) +// selected flag, so a confirm can both add newly-checked playlists and +// remove ones the user unchecked that the video was already in. +function diffSelection(playlists, desired) { + const originallySelected = new Set(playlists.filter((p) => p.selected).map((p) => p.name)); + const toAdd = [...desired].filter((name) => !originallySelected.has(name)); + const toRemove = [...originallySelected].filter((name) => !desired.has(name)); + return { toAdd, toRemove }; +} + +// Enter: if the user has toggled anything (Space, at least once) or the +// create-new dialog isn't involved, confirm the checked set as-is; else +// (nothing ever toggled) implicit single-select adds the highlighted row on +// top of whatever's already checked, so it isn't lost when the video is +// already saved elsewhere (resolved 2026-08-07). export function resolveEnter(state) { const row = highlightedRow(state); if (row === CREATE_NEW_ROW) return { type: 'create-new' }; - if (state.checked.size > 0) return { type: 'confirm', names: [...state.checked] }; - return { type: 'confirm', names: [row.name] }; + const desired = state.touched ? state.checked : new Set([...state.checked, row.name]); + return { type: 'confirm', ...diffSelection(state.playlists, desired) }; +} + +export function resolveCreatedPlaylistChanges(state) { + return diffSelection(state.playlists, state.checked); } export function openCreateDialog(state) { diff --git a/tests/unit/playlist-overlay-state.test.js b/tests/unit/playlist-overlay-state.test.js index 911ef58..45af4d4 100644 --- a/tests/unit/playlist-overlay-state.test.js +++ b/tests/unit/playlist-overlay-state.test.js @@ -14,6 +14,7 @@ import { backspaceInCreateDialog, closeCreateDialog, commitCreatedPlaylist, + resolveCreatedPlaylistChanges, } from '../../src/ui/playlist-overlay-state.js'; const PLAYLISTS = [{ name: 'Comedy' }, { name: 'Documentaries' }, { name: 'Watch later' }]; @@ -92,21 +93,51 @@ describe('toggleHighlighted', () => { }); describe('resolveEnter', () => { - it('confirms the checked set when non-empty', () => { + it('confirms the checked set as an add when non-empty', () => { const s = toggleHighlighted(moveHighlight(createOverlayState(PLAYLISTS), 1)); const result = resolveEnter(s); - assert.deepEqual(result, { type: 'confirm', names: ['Documentaries'] }); + assert.deepEqual(result, { type: 'confirm', toAdd: ['Documentaries'], toRemove: [] }); }); it('implicitly single-selects the highlighted row when nothing is checked', () => { const s = createOverlayState(PLAYLISTS); - assert.deepEqual(resolveEnter(s), { type: 'confirm', names: ['Comedy'] }); + assert.deepEqual(resolveEnter(s), { type: 'confirm', toAdd: ['Comedy'], toRemove: [] }); }); it('opens create-new when the create-new row is highlighted', () => { const s = moveHighlight(createOverlayState(PLAYLISTS), -1); assert.deepEqual(resolveEnter(s), { type: 'create-new' }); }); + + it('pre-checks playlists the video is already in', () => { + const seeded = [{ name: 'Comedy', selected: true }, { name: 'Documentaries', selected: false }]; + const s = createOverlayState(seeded); + assert.ok(s.checked.has('Comedy')); + assert.ok(!s.checked.has('Documentaries')); + }); + + it('unchecking a pre-checked playlist and confirming resolves it as a removal', () => { + const seeded = [{ name: 'Comedy', selected: true }, { name: 'Documentaries', selected: false }]; + const s = toggleHighlighted(createOverlayState(seeded)); + const result = resolveEnter(s); + assert.deepEqual(result, { type: 'confirm', toAdd: [], toRemove: ['Comedy'] }); + }); + + it('implicit single-select on a highlighted row adds it without dropping pre-checked playlists', () => { + const seeded = [{ name: 'Comedy', selected: true }, { name: 'Documentaries', selected: false }]; + const s = moveHighlight(createOverlayState(seeded), 1); + const result = resolveEnter(s); + assert.deepEqual(result, { type: 'confirm', toAdd: ['Documentaries'], toRemove: [] }); + }); +}); + +describe('resolveCreatedPlaylistChanges', () => { + it('diffs the checked set against original selected flags', () => { + const seeded = [{ name: 'Comedy', selected: true }, { name: 'Documentaries', selected: false }]; + let s = toggleHighlighted(createOverlayState(seeded)); + s = { ...s, playlists: [...s.playlists, { name: 'New Stuff', selected: false }], checked: new Set([...s.checked, 'New Stuff']) }; + assert.deepEqual(resolveCreatedPlaylistChanges(s), { toAdd: ['New Stuff'], toRemove: ['Comedy'] }); + }); }); describe('create-new sub-dialog', () => { From e299c313e3e5f1c7de505e2cf10d155d23b09ccf Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:47:32 +0200 Subject: [PATCH 15/26] fix(playlist): restore native sheet visibility on close hideOpenSheet's !important overrides were never undone, so YouTube's reused sheet DOM node stayed permanently hidden after the first drive, breaking the native Save button on subsequent opens. --- src/ui/playlist-popup-driver.js | 17 +++++++++++++++++ tests/unit/playlist-popup-driver.test.js | 18 ++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/ui/playlist-popup-driver.js b/src/ui/playlist-popup-driver.js index 30abd68..617d997 100644 --- a/src/ui/playlist-popup-driver.js +++ b/src/ui/playlist-popup-driver.js @@ -27,6 +27,22 @@ function hideOpenSheet(doc) { sheet.style.setProperty('pointer-events', 'none', 'important'); } +// Undoes hideOpenSheet's overrides. Reported live: without this, the sheet +// stays permanently hidden after the first drive — YouTube reuses the same +// sheet DOM node across opens rather than remounting it, so the leftover +// !important styles also broke the native Save button, not just our own +// overlay flow. Called synchronously right before the Escape dispatch in +// closeSaveToPlaylistPopup so there's no intermediate paint to flash. +function restoreSheetVisibility(doc) { + const sheet = doc.querySelector(SHEET_SELECTOR); + if (!sheet) return; + sheet.style.removeProperty('position'); + sheet.style.removeProperty('left'); + sheet.style.removeProperty('top'); + sheet.style.removeProperty('opacity'); + sheet.style.removeProperty('pointer-events'); +} + function parsePlaylistName(ariaLabel) { // ponytail: name is the first comma-separated segment of the row's own // aria-label ("{name}, {visibility}, {selected-state}", confirmed in probe @@ -119,5 +135,6 @@ export async function driveCreateNewPlaylist(doc, win, name) { // self-healing best-effort here, not independently confirmed for this // specific sheet by a live click. export function closeSaveToPlaylistPopup(doc, win) { + restoreSheetVisibility(doc); doc.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); } diff --git a/tests/unit/playlist-popup-driver.test.js b/tests/unit/playlist-popup-driver.test.js index 5b9bd24..2d8f08d 100644 --- a/tests/unit/playlist-popup-driver.test.js +++ b/tests/unit/playlist-popup-driver.test.js @@ -57,7 +57,10 @@ describe('findCreateNewButton', () => { function makeStyleTarget() { const style = new Map(); return { - style: { setProperty: (prop, value) => style.set(prop, value) }, + style: { + setProperty: (prop, value) => style.set(prop, value), + removeProperty: (prop) => style.delete(prop), + }, getStyle: (prop) => style.get(prop), }; } @@ -206,10 +209,21 @@ describe('driveCreateNewPlaylist', () => { describe('closeSaveToPlaylistPopup', () => { it('dispatches an Escape keydown on the document', () => { let dispatched = null; - const doc = { dispatchEvent: (evt) => { dispatched = evt; } }; + const doc = { querySelector: () => null, dispatchEvent: (evt) => { dispatched = evt; } }; const fakeWin = { KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } } }; closeSaveToPlaylistPopup(doc, fakeWin); assert.equal(dispatched.type, 'keydown'); assert.equal(dispatched.key, 'Escape'); }); + + it('restores a hidden sheet so the native popup works again next time', () => { + const sheet = makeStyleTarget(); + sheet.style.setProperty('opacity', '0'); + sheet.style.setProperty('pointer-events', 'none'); + const doc = { querySelector: () => sheet, dispatchEvent: () => {} }; + const fakeWin = { KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } } }; + closeSaveToPlaylistPopup(doc, fakeWin); + assert.equal(sheet.getStyle('opacity'), undefined); + assert.equal(sheet.getStyle('pointer-events'), undefined); + }); }); From e386ab9b4c7b23e79db42e6bb7b685b31891a22f Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:56:25 +0200 Subject: [PATCH 16/26] fix(playlist): verify native sheet close, fall back to trigger re-click Escape wasn't reliably closing YouTube's Save-to-playlist sheet, leaving it open on screen at the end of a drive sequence. Close now polls for the sheet actually disappearing and re-clicks the trigger button as a fallback instead of trusting Escape blindly. --- apps/shared/src/content/content.js | 6 +- src/ui/playlist-popup-driver.js | 33 ++++++++-- tests/unit/playlist-popup-driver.test.js | 82 ++++++++++++++++++++---- 3 files changed, 100 insertions(+), 21 deletions(-) diff --git a/apps/shared/src/content/content.js b/apps/shared/src/content/content.js index fdecd34..668910d 100644 --- a/apps/shared/src/content/content.js +++ b/apps/shared/src/content/content.js @@ -173,7 +173,7 @@ // (name catalog only) for other consumers that don't need membership. async function loadPlaylistCatalog(trigger) { const rows = await openSaveToPlaylistPopup(trigger, document, window); - closeSaveToPlaylistPopup(document, window); + await closeSaveToPlaylistPopup(document, window, trigger); if (rows.length === 0) return null; const playlists = rows.map((r) => ({ name: r.name, selected: r.selected })); await playlistCache.write(playlists.map((p) => ({ name: p.name }))); @@ -221,7 +221,7 @@ if (row.selected !== shouldSelect) togglePlaylistRow(row); applied += 1; } - closeSaveToPlaylistPopup(document, window); + await closeSaveToPlaylistPopup(document, window, trigger); showPlaylistProgress(document, rect, applied, changes.length); } await playlistCache.invalidate(); @@ -233,7 +233,7 @@ if (!trigger) return false; await openSaveToPlaylistPopup(trigger, document, window); const ok = await driveCreateNewPlaylist(document, window, name); - closeSaveToPlaylistPopup(document, window); + await closeSaveToPlaylistPopup(document, window, trigger); if (ok) await playlistCache.invalidate(); return ok; } diff --git a/src/ui/playlist-popup-driver.js b/src/ui/playlist-popup-driver.js index 617d997..a37e709 100644 --- a/src/ui/playlist-popup-driver.js +++ b/src/ui/playlist-popup-driver.js @@ -11,6 +11,8 @@ const CREATE_NAME_INPUT_SELECTOR = 'yt-sheet-view-model input, yt-sheet-view-mod const SHEET_SELECTOR = 'yt-sheet-view-model[slot="dropdown-content"]'; const POPUP_WAIT_TIMEOUT_MS = 1500; const POPUP_WAIT_POLL_MS = 50; +const CLOSE_VERIFY_TIMEOUT_MS = 800; +const CLOSE_VERIFY_POLL_MS = 50; // Reported live: without this, the sheet is genuinely visible on screen for // the whole drive sequence (open/toggle/close per playlist), not the @@ -129,12 +131,35 @@ export async function driveCreateNewPlaylist(doc, win, name) { return true; } +function isSheetOpen(doc) { + return !!doc.querySelector(SHEET_SELECTOR); +} + +function waitForSheetClosed(doc, win) { + return new Promise((resolve) => { + const deadline = Date.now() + CLOSE_VERIFY_TIMEOUT_MS; + (function poll() { + if (!isSheetOpen(doc)) return resolve(true); + if (Date.now() >= deadline) return resolve(false); + win.setTimeout(poll, CLOSE_VERIFY_POLL_MS); + }()); + }); +} + // ponytail: no explicit close/cancel control was found in either probe // capture (consistent with "no batch Done button" — see findings doc). -// Escape is YouTube's universal sheet-dismiss key elsewhere on the site; -// self-healing best-effort here, not independently confirmed for this -// specific sheet by a live click. -export function closeSaveToPlaylistPopup(doc, win) { +// Escape is YouTube's universal sheet-dismiss key elsewhere on the site but +// reported live as unreliable for this sheet (K found it left open at the +// end of a drive sequence) — verifies the close actually happened instead +// of trusting it blindly, and falls back to re-clicking the trigger (a +// standard toggle-button pattern) when Escape didn't work. Ceiling: if +// neither closes it, the sheet is left as-is rather than looping forever. +export async function closeSaveToPlaylistPopup(doc, win, triggerButton) { restoreSheetVisibility(doc); + if (!isSheetOpen(doc)) return; doc.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + if (await waitForSheetClosed(doc, win)) return; + if (!triggerButton) return; + triggerButton.click(); + await waitForSheetClosed(doc, win); } diff --git a/tests/unit/playlist-popup-driver.test.js b/tests/unit/playlist-popup-driver.test.js index 2d8f08d..ec65315 100644 --- a/tests/unit/playlist-popup-driver.test.js +++ b/tests/unit/playlist-popup-driver.test.js @@ -206,24 +206,78 @@ describe('driveCreateNewPlaylist', () => { }); }); +function makeCloseableDoc(escapeCloses) { + let open = true; + const sheet = makeStyleTarget(); + const dispatched = []; + const close = () => { open = false; }; + return { + sheet, + dispatched, + isOpen: () => open, + close, + doc: { + querySelector: () => (open ? sheet : null), + dispatchEvent: (evt) => { + dispatched.push(evt); + if (evt.type === 'keydown' && evt.key === 'Escape' && escapeCloses) close(); + }, + }, + }; +} + +function fastWin(extra = {}) { + return { + KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } }, + setTimeout: (fn) => fn(), + ...extra, + }; +} + describe('closeSaveToPlaylistPopup', () => { - it('dispatches an Escape keydown on the document', () => { - let dispatched = null; - const doc = { querySelector: () => null, dispatchEvent: (evt) => { dispatched = evt; } }; - const fakeWin = { KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } } }; - closeSaveToPlaylistPopup(doc, fakeWin); - assert.equal(dispatched.type, 'keydown'); - assert.equal(dispatched.key, 'Escape'); + it('does nothing when no sheet is open', async () => { + const doc = { querySelector: () => null, dispatchEvent: () => { throw new Error('should not dispatch'); } }; + await closeSaveToPlaylistPopup(doc, fastWin()); }); - it('restores a hidden sheet so the native popup works again next time', () => { - const sheet = makeStyleTarget(); + it('restores hidden styles and dispatches Escape, which closes the sheet', async () => { + const { doc, dispatched, isOpen, sheet } = makeCloseableDoc(true); sheet.style.setProperty('opacity', '0'); - sheet.style.setProperty('pointer-events', 'none'); - const doc = { querySelector: () => sheet, dispatchEvent: () => {} }; - const fakeWin = { KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } } }; - closeSaveToPlaylistPopup(doc, fakeWin); + await closeSaveToPlaylistPopup(doc, fastWin()); assert.equal(sheet.getStyle('opacity'), undefined); - assert.equal(sheet.getStyle('pointer-events'), undefined); + assert.deepEqual(dispatched.map((e) => e.key), ['Escape']); + assert.equal(isOpen(), false); + }); + + it('falls back to re-clicking the trigger when Escape does not close the sheet', async () => { + const { doc, isOpen, close } = makeCloseableDoc(false); + const trigger = { clicked: 0, click() { this.clicked += 1; close(); } }; + let now = 0; + const realNow = Date.now; + Date.now = () => now; + try { + const win = fastWin({ setTimeout: (fn) => { now += 100; fn(); } }); + await closeSaveToPlaylistPopup(doc, win, trigger); + } finally { + Date.now = realNow; + } + assert.equal(trigger.clicked, 1); + assert.equal(isOpen(), false); + }); + + it('gives up quietly when neither Escape nor the trigger closes the sheet', async () => { + const { doc, isOpen } = makeCloseableDoc(false); + const trigger = { clicked: 0, click() { this.clicked += 1; } }; + let now = 0; + const realNow = Date.now; + Date.now = () => now; + try { + const win = fastWin({ setTimeout: (fn) => { now += 100; fn(); } }); + await closeSaveToPlaylistPopup(doc, win, trigger); + } finally { + Date.now = realNow; + } + assert.equal(trigger.clicked, 1); + assert.equal(isOpen(), true); }); }); From 8b1d1222367772a47d5320809c188b802c0e3145 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:08:12 +0200 Subject: [PATCH 17/26] perf(playlist): stop reopening the native popup per playlist row Toggle every row in a single open/close session instead of N open+verify-close round trips, and skip re-clicking an already-open trigger (which was toggling it shut and returning stale rows on a same-cycle reopen). Also stop unhiding the sheet before confirming close, so the verification wait is no longer visible on screen. --- apps/shared/src/content/content.js | 12 ++++++---- src/ui/playlist-popup-driver.js | 30 ++++++++++++++++-------- tests/unit/playlist-popup-driver.test.js | 26 +++++++++++++------- 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/apps/shared/src/content/content.js b/apps/shared/src/content/content.js index 668910d..5dcd051 100644 --- a/apps/shared/src/content/content.js +++ b/apps/shared/src/content/content.js @@ -198,8 +198,12 @@ playlistOverlay.render(playlistState); } - // Applies sequentially, reopening the native popup once per playlist - // (issue #16 resolved design — no batch confirm exists natively). + // Opens the native popup once and toggles every row within that same + // session (issue #16 resolved design — no batch confirm exists + // natively, but nothing requires closing/reopening between rows + // either). Reopening per playlist was the original design; K reported + // it as slow and visibly flickering the native popup — each + // open/close round trip pays the close-verification wait, N times. // Idempotent per row: only clicks when the row's live state disagrees // with the desired one, since the native button is a plain toggle. async function addVideoToPlaylists(toAdd, toRemove = []) { @@ -214,16 +218,16 @@ const rect = videoEl ? videoEl.getBoundingClientRect() : { top: 0, left: 0 }; let applied = 0; showPlaylistProgress(document, rect, 0, changes.length); + const rows = await openSaveToPlaylistPopup(trigger, document, window); for (const { name, shouldSelect } of changes) { - const rows = await openSaveToPlaylistPopup(trigger, document, window); const row = rows.find((r) => r.name === name); if (row) { if (row.selected !== shouldSelect) togglePlaylistRow(row); applied += 1; } - await closeSaveToPlaylistPopup(document, window, trigger); showPlaylistProgress(document, rect, applied, changes.length); } + await closeSaveToPlaylistPopup(document, window, trigger); await playlistCache.invalidate(); finishPlaylistProgress(document, rect, applied, changes.length); } diff --git a/src/ui/playlist-popup-driver.js b/src/ui/playlist-popup-driver.js index a37e709..777d3f7 100644 --- a/src/ui/playlist-popup-driver.js +++ b/src/ui/playlist-popup-driver.js @@ -11,8 +11,8 @@ const CREATE_NAME_INPUT_SELECTOR = 'yt-sheet-view-model input, yt-sheet-view-mod const SHEET_SELECTOR = 'yt-sheet-view-model[slot="dropdown-content"]'; const POPUP_WAIT_TIMEOUT_MS = 1500; const POPUP_WAIT_POLL_MS = 50; -const CLOSE_VERIFY_TIMEOUT_MS = 800; -const CLOSE_VERIFY_POLL_MS = 50; +const CLOSE_VERIFY_TIMEOUT_MS = 250; +const CLOSE_VERIFY_POLL_MS = 25; // Reported live: without this, the sheet is genuinely visible on screen for // the whole drive sequence (open/toggle/close per playlist), not the @@ -83,9 +83,14 @@ function waitForRows(doc, win) { // open, never how the watch page's own action row triggers it — see // docs/ai/questions-for-K.md. The caller supplies triggerButton (same // dependency-injection shape as queue-overlay's activateQueueTarget); once -// clicked, everything below is driving probe-verified DOM. +// clicked, everything below is driving probe-verified DOM. Idempotent: if +// the sheet is already open (e.g. a prior close attempt didn't finish), +// re-clicking the trigger would toggle it shut instead of opening it — skip +// the click and just read whatever's already there. K reported stale data +// on a reopen right after an add; a same-cycle double-toggle-closed is the +// most likely cause given close was flaky (see closeSaveToPlaylistPopup). export async function openSaveToPlaylistPopup(triggerButton, doc, win) { - triggerButton.click(); + if (!isSheetOpen(doc)) triggerButton.click(); const rows = await waitForRows(doc, win); if (rows.length > 0) hideOpenSheet(doc); return rows; @@ -154,12 +159,17 @@ function waitForSheetClosed(doc, win) { // of trusting it blindly, and falls back to re-clicking the trigger (a // standard toggle-button pattern) when Escape didn't work. Ceiling: if // neither closes it, the sheet is left as-is rather than looping forever. +// Stays hidden throughout the attempt (K reported the verify wait itself +// being visibly on screen) — restoreSheetVisibility only runs at the very +// end, a no-op once the sheet is actually gone, a fallback so it's at least +// visible/usable if both close attempts failed. export async function closeSaveToPlaylistPopup(doc, win, triggerButton) { + if (isSheetOpen(doc)) { + doc.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + if (!(await waitForSheetClosed(doc, win)) && triggerButton) { + triggerButton.click(); + await waitForSheetClosed(doc, win); + } + } restoreSheetVisibility(doc); - if (!isSheetOpen(doc)) return; - doc.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - if (await waitForSheetClosed(doc, win)) return; - if (!triggerButton) return; - triggerButton.click(); - await waitForSheetClosed(doc, win); } diff --git a/tests/unit/playlist-popup-driver.test.js b/tests/unit/playlist-popup-driver.test.js index ec65315..b9958d1 100644 --- a/tests/unit/playlist-popup-driver.test.js +++ b/tests/unit/playlist-popup-driver.test.js @@ -110,19 +110,27 @@ describe('openSaveToPlaylistPopup', () => { it('does not attempt to hide anything when no rows appear', async () => { const trigger = { clicked: 0, click() { this.clicked += 1; } }; - let queried = false; - const doc = { querySelectorAll: () => [], querySelector: () => { queried = true; return null; } }; + const sheet = makeStyleTarget(); + const doc = { querySelectorAll: () => [], querySelector: () => sheet }; let now = 0; const fastWin = { setTimeout: (fn) => { now += 100; fn(); } }; const realNow = Date.now; Date.now = () => now; try { await openSaveToPlaylistPopup(trigger, doc, fastWin); - assert.equal(queried, false); + assert.equal(sheet.getStyle('opacity'), undefined); } finally { Date.now = realNow; } }); + + it('does not re-click the trigger when the sheet is already open', async () => { + const trigger = { clicked: 0, click() { this.clicked += 1; } }; + const row = makeRowButton({ name: 'Comedy' }); + const doc = { querySelectorAll: () => [row], querySelector: () => makeStyleTarget() }; + await openSaveToPlaylistPopup(trigger, doc, win); + assert.equal(trigger.clicked, 0); + }); }); describe('togglePlaylistRow', () => { @@ -240,11 +248,9 @@ describe('closeSaveToPlaylistPopup', () => { await closeSaveToPlaylistPopup(doc, fastWin()); }); - it('restores hidden styles and dispatches Escape, which closes the sheet', async () => { - const { doc, dispatched, isOpen, sheet } = makeCloseableDoc(true); - sheet.style.setProperty('opacity', '0'); + it('dispatches Escape and closes the sheet without needing the trigger fallback', async () => { + const { doc, dispatched, isOpen } = makeCloseableDoc(true); await closeSaveToPlaylistPopup(doc, fastWin()); - assert.equal(sheet.getStyle('opacity'), undefined); assert.deepEqual(dispatched.map((e) => e.key), ['Escape']); assert.equal(isOpen(), false); }); @@ -265,8 +271,9 @@ describe('closeSaveToPlaylistPopup', () => { assert.equal(isOpen(), false); }); - it('gives up quietly when neither Escape nor the trigger closes the sheet', async () => { - const { doc, isOpen } = makeCloseableDoc(false); + it('still restores hidden styles as a fallback when neither Escape nor the trigger closes the sheet', async () => { + const { doc, isOpen, sheet } = makeCloseableDoc(false); + sheet.style.setProperty('opacity', '0'); const trigger = { clicked: 0, click() { this.clicked += 1; } }; let now = 0; const realNow = Date.now; @@ -279,5 +286,6 @@ describe('closeSaveToPlaylistPopup', () => { } assert.equal(trigger.clicked, 1); assert.equal(isOpen(), true); + assert.equal(sheet.getStyle('opacity'), undefined); }); }); From 80904c1bf2fc42cbd184cf09426da3037f784667 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:23:33 +0200 Subject: [PATCH 18/26] feat(playlist): configurable toggle key, arrow check/uncheck, follow scroll Space sometimes leaks through to YouTube's own play/pause, so the playlist overlay's toggle key is now sourced from shortcuts.config.json (playlistKeys.toggle) instead of hardcoded. Adds ArrowRight/ArrowLeft as a non-conflicting check/uncheck alternative, also configurable. The highlighted row now scrolls into view as the selector moves past the panel's visible area. Issue #16. --- apps/shared/src/content/content.js | 18 ++++++++--- docs/keyboard-quickstart.md | 4 ++- src/core/keyboard-shortcuts.js | 17 ++++++++++ src/core/shortcuts.config.json | 5 +++ src/ui/playlist-overlay-state.js | 36 ++++++++++++++++----- src/ui/playlist-overlay.js | 11 ++++++- tests/unit/keyboard-shortcuts.test.js | 27 ++++++++++++++++ tests/unit/playlist-overlay-state.test.js | 38 +++++++++++++++++++++++ 8 files changed, 143 insertions(+), 13 deletions(-) diff --git a/apps/shared/src/content/content.js b/apps/shared/src/content/content.js index 5dcd051..5868181 100644 --- a/apps/shared/src/content/content.js +++ b/apps/shared/src/content/content.js @@ -16,7 +16,7 @@ } = await import(browser.runtime.getURL('lib/site-adapters/youtube/youtube-site-adapter.js')); const { createPlayerAdapter } = await import(browser.runtime.getURL('lib/player-adapters/html5-video-player-adapter.js')); const { - COMMANDS, SPEED_SHORTCUTS, createShortcutController, generateLabels, filterLabelPairs, + COMMANDS, SPEED_SHORTCUTS, PLAYLIST_KEYS, createShortcutController, generateLabels, filterLabelPairs, } = await import(browser.runtime.getURL('lib/core/keyboard-shortcuts.js')); const { collectJumpTargets, createJumpOverlay } = await import(browser.runtime.getURL('lib/ui/jump-overlay.js')); @@ -27,8 +27,8 @@ } = await import(browser.runtime.getURL('lib/ui/playlist-popup-driver.js')); const { createPlaylistCache } = await import(browser.runtime.getURL('lib/core/playlist-cache.js')); const { - createOverlayState, moveHighlight, typeChar, backspace, toggleHighlighted, resolveEnter, - openCreateDialog, typeInCreateDialog, backspaceInCreateDialog, closeCreateDialog, commitCreatedPlaylist, + createOverlayState, moveHighlight, typeChar, backspace, toggleHighlighted, checkHighlighted, uncheckHighlighted, + resolveEnter, openCreateDialog, typeInCreateDialog, backspaceInCreateDialog, closeCreateDialog, commitCreatedPlaylist, resolveCreatedPlaylistChanges, } = await import(browser.runtime.getURL('lib/ui/playlist-overlay-state.js')); const { @@ -296,11 +296,21 @@ playlistOverlay.render(playlistState); return; } - if (e.key === ' ') { + if (e.key === PLAYLIST_KEYS.toggle) { playlistState = toggleHighlighted(playlistState); playlistOverlay.render(playlistState); return; } + if (e.key === PLAYLIST_KEYS.check) { + playlistState = checkHighlighted(playlistState); + playlistOverlay.render(playlistState); + return; + } + if (e.key === PLAYLIST_KEYS.uncheck) { + playlistState = uncheckHighlighted(playlistState); + playlistOverlay.render(playlistState); + return; + } if (e.key === 'Enter') { const result = resolveEnter(playlistState); if (result.type === 'create-new') { diff --git a/docs/keyboard-quickstart.md b/docs/keyboard-quickstart.md index 9327496..5e68569 100644 --- a/docs/keyboard-quickstart.md +++ b/docs/keyboard-quickstart.md @@ -25,7 +25,9 @@ want via the extension's stored settings, same as any other override. | `Esc` | Close overlay | Jump/queue overlay open | | Type letters | Fuzzy-filter playlists by name | Playlist picker open | | `↑`/`↓` | Move highlight (wraps, includes "+ Create new" as the last row) | Playlist picker open | -| `Space` | Toggle a checkbox on the highlighted playlist (local only, up to 5) | Playlist picker open | +| `Space` | Toggle a checkbox on the highlighted playlist (local only, up to 5, configurable via `src/core/shortcuts.config.json`'s `playlistKeys.toggle`) | Playlist picker open | +| `→` | Check the highlighted playlist (configurable via `playlistKeys.check`) | Playlist picker open | +| `←` | Uncheck the highlighted playlist (configurable via `playlistKeys.uncheck`) | Playlist picker open | | `Enter` | Add to checked playlists, or the highlighted one if none checked, or open create-new | Playlist picker open | | `Esc` | Close create-new sub-dialog, or the picker if none open | Playlist picker open | diff --git a/src/core/keyboard-shortcuts.js b/src/core/keyboard-shortcuts.js index 0ca97a9..a2a503a 100644 --- a/src/core/keyboard-shortcuts.js +++ b/src/core/keyboard-shortcuts.js @@ -115,6 +115,23 @@ export function normalizeSpeedShortcuts(raw) { export const SPEED_SHORTCUTS = normalizeSpeedShortcuts(shortcutsConfig.speeds); +const FALLBACK_PLAYLIST_KEYS = Object.freeze({ + toggle: ' ', + check: 'ArrowRight', + uncheck: 'ArrowLeft', +}); + +export function normalizePlaylistKeys(raw) { + const src = raw != null && typeof raw === 'object' ? raw : {}; + const out = {}; + for (const [action, fallback] of Object.entries(FALLBACK_PLAYLIST_KEYS)) { + out[action] = typeof src[action] === 'string' && src[action].length > 0 ? src[action] : fallback; + } + return Object.freeze(out); +} + +export const PLAYLIST_KEYS = normalizePlaylistKeys(shortcutsConfig.playlistKeys); + export function eventMatchesPrefix(evt, prefix) { return typeof evt.key === 'string' && evt.key.toLowerCase() === prefix.key diff --git a/src/core/shortcuts.config.json b/src/core/shortcuts.config.json index 69e2224..cd07d59 100644 --- a/src/core/shortcuts.config.json +++ b/src/core/shortcuts.config.json @@ -13,6 +13,11 @@ "p": "show-playlist-labels" }, "homeUrl": "https://www.youtube.com/", + "playlistKeys": { + "toggle": " ", + "check": "ArrowRight", + "uncheck": "ArrowLeft" + }, "speeds": { "set-speed-1": 1, "set-speed-2": 1.5, diff --git a/src/ui/playlist-overlay-state.js b/src/ui/playlist-overlay-state.js index 78f8182..38ca978 100644 --- a/src/ui/playlist-overlay-state.js +++ b/src/ui/playlist-overlay-state.js @@ -45,22 +45,44 @@ export function backspace(state) { return { ...state, query: state.query.slice(0, -1), highlightIndex: 0 }; } -// Space: local checkbox state only, capped at 5 (issue #16) — a 6th toggle -// attempt is ignored rather than silently dropping an earlier pick at -// confirm time, so the checked set the user sees is always what gets added. -export function toggleHighlighted(state) { +// Shared by toggle/check/uncheck: local checkbox state only, capped at 5 +// (issue #16) — a 6th check attempt is ignored rather than silently +// dropping an earlier pick at confirm time, so the checked set the user +// sees is always what gets added. +function setHighlightedChecked(state, shouldCheck) { const row = highlightedRow(state); if (row === CREATE_NEW_ROW) return state; + const already = state.checked.has(row.name); + if (shouldCheck === already) return state; const checked = new Set(state.checked); - if (checked.has(row.name)) { - checked.delete(row.name); - } else { + if (shouldCheck) { if (checked.size >= MAX_CONFIRM_SELECTION) return state; checked.add(row.name); + } else { + checked.delete(row.name); } return { ...state, checked, touched: true }; } +export function toggleHighlighted(state) { + const row = highlightedRow(state); + if (row === CREATE_NEW_ROW) return state; + return setHighlightedChecked(state, !state.checked.has(row.name)); +} + +// Right/left-arrow alternative to space (issue #16 follow-up — space can +// occasionally leak through to YouTube's own play/pause; see +// docs/keyboard-quickstart.md). Directional rather than a toggle: pressing +// check on an already-checked row (or uncheck on an already-unchecked one) +// is a no-op instead of flipping it. +export function checkHighlighted(state) { + return setHighlightedChecked(state, true); +} + +export function uncheckHighlighted(state) { + return setHighlightedChecked(state, false); +} + // Diffs the desired checked set against each playlist's original (native) // selected flag, so a confirm can both add newly-checked playlists and // remove ones the user unchecked that the video was already in. diff --git a/src/ui/playlist-overlay.js b/src/ui/playlist-overlay.js index 0deb888..d04dd85 100644 --- a/src/ui/playlist-overlay.js +++ b/src/ui/playlist-overlay.js @@ -34,12 +34,15 @@ function renderMain(doc, container, state) { container.appendChild(query); const rows = visibleRows(state); + let highlighted = null; rows.forEach((row, i) => { const el = doc.createElement('div'); - el.setAttribute('style', i === state.highlightIndex ? ROW_HIGHLIGHT_STYLE : ROW_STYLE); + const isHighlighted = i === state.highlightIndex; + el.setAttribute('style', isHighlighted ? ROW_HIGHLIGHT_STYLE : ROW_STYLE); el.setAttribute('data-videodefaults-playlist-row', row.name); el.textContent = `${state.checked.has(row.name) ? '[x]' : '[ ]'} ${row.name}`; container.appendChild(el); + if (isHighlighted) highlighted = el; }); const createRow = doc.createElement('div'); @@ -48,6 +51,12 @@ function renderMain(doc, container, state) { createRow.setAttribute('data-videodefaults-playlist-create-new', ''); createRow.textContent = '+ Create new'; container.appendChild(createRow); + if (createHighlighted) highlighted = createRow; + + // Container scrolls (PANEL_STYLE's max-height/overflow-y) once the list + // outgrows the panel — 'nearest' follows the highlight with the smallest + // possible scroll instead of re-centering the list on every move. + highlighted?.scrollIntoView?.({ block: 'nearest' }); } function renderSubDialog(doc, container, state) { diff --git a/tests/unit/keyboard-shortcuts.test.js b/tests/unit/keyboard-shortcuts.test.js index 1e7ecb2..c04eb37 100644 --- a/tests/unit/keyboard-shortcuts.test.js +++ b/tests/unit/keyboard-shortcuts.test.js @@ -5,12 +5,14 @@ import { DEFAULT_KEYMAP, MAX_JUMP_TARGETS, SPEED_SHORTCUTS, + PLAYLIST_KEYS, createShortcutController, eventMatchesPrefix, filterLabelPairs, generateLabels, normalizeKeymap, normalizeSpeedShortcuts, + normalizePlaylistKeys, } from '../../src/core/keyboard-shortcuts.js'; function key(k, extra = {}) { @@ -149,6 +151,31 @@ describe('normalizeSpeedShortcuts', () => { }); }); +describe('PLAYLIST_KEYS', () => { + it('has the expected defaults from shortcuts.config.json', () => { + assert.deepEqual(PLAYLIST_KEYS, { toggle: ' ', check: 'ArrowRight', uncheck: 'ArrowLeft' }); + }); +}); + +describe('normalizePlaylistKeys', () => { + it('falls back to defaults for garbage input', () => { + assert.deepEqual(normalizePlaylistKeys(null), PLAYLIST_KEYS); + assert.deepEqual(normalizePlaylistKeys('nope'), PLAYLIST_KEYS); + }); + + it('falls back per-action for empty or non-string entries', () => { + const result = normalizePlaylistKeys({ toggle: '', check: 42, uncheck: 'ArrowLeft' }); + assert.equal(result.toggle, PLAYLIST_KEYS.toggle); + assert.equal(result.check, PLAYLIST_KEYS.check); + assert.equal(result.uncheck, 'ArrowLeft'); + }); + + it('accepts a full override', () => { + const result = normalizePlaylistKeys({ toggle: 'x', check: 'Enter', uncheck: 'Backspace' }); + assert.deepEqual(result, { toggle: 'x', check: 'Enter', uncheck: 'Backspace' }); + }); +}); + describe('eventMatchesPrefix', () => { it('matches ctrl+a against the default prefix', () => { assert.equal(eventMatchesPrefix(prefix, DEFAULT_KEYMAP.prefix), true); diff --git a/tests/unit/playlist-overlay-state.test.js b/tests/unit/playlist-overlay-state.test.js index 45af4d4..0c0d06d 100644 --- a/tests/unit/playlist-overlay-state.test.js +++ b/tests/unit/playlist-overlay-state.test.js @@ -8,6 +8,8 @@ import { typeChar, backspace, toggleHighlighted, + checkHighlighted, + uncheckHighlighted, resolveEnter, openCreateDialog, typeInCreateDialog, @@ -92,6 +94,42 @@ describe('toggleHighlighted', () => { }); }); +describe('checkHighlighted / uncheckHighlighted', () => { + it('checks the highlighted playlist and is a no-op if already checked', () => { + const s0 = createOverlayState(PLAYLISTS); + const s1 = checkHighlighted(s0); + assert.ok(s1.checked.has('Comedy')); + const s2 = checkHighlighted(s1); + assert.ok(s2.checked.has('Comedy')); + assert.equal(s2.checked.size, 1); + }); + + it('unchecks the highlighted playlist and is a no-op if already unchecked', () => { + const s0 = checkHighlighted(createOverlayState(PLAYLISTS)); + const s1 = uncheckHighlighted(s0); + assert.ok(!s1.checked.has('Comedy')); + const s2 = uncheckHighlighted(s1); + assert.ok(!s2.checked.has('Comedy')); + }); + + it('does nothing when the create-new row is highlighted', () => { + const s0 = moveHighlight(createOverlayState(PLAYLISTS), -1); + assert.equal(checkHighlighted(s0).checked.size, 0); + assert.equal(uncheckHighlighted(s0).checked.size, 0); + }); + + it('caps checked selection at 5 and ignores a 6th check', () => { + const many = Array.from({ length: 6 }, (_, i) => ({ name: `P${i}` })); + let s = createOverlayState(many); + for (let i = 0; i < 6; i += 1) { + s = checkHighlighted(s); + s = moveHighlight(s, 1); + } + assert.equal(s.checked.size, 5); + assert.ok(!s.checked.has('P5')); + }); +}); + describe('resolveEnter', () => { it('confirms the checked set as an add when non-empty', () => { const s = toggleHighlighted(moveHighlight(createOverlayState(PLAYLISTS), 1)); From 97721b38c4130c8bc4cf3345e7de7125775740d0 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:58:34 +0200 Subject: [PATCH 19/26] fix(playlist): drive the real create-playlist dialog, not a phantom field Create-new never worked live: the native "Create new playlist" click opens a separate yt-dialog-view-model with a