From a9956831c45e8b903394ae001b2efbe96400bb02 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 4 Apr 2026 13:33:11 -0400 Subject: [PATCH 1/9] refactor(shortcuts): switch shortcut bindings to event.code --- inject.js | 270 ++++++++++++++++++------------------- options.js | 385 ++++++++++++++++++++++++----------------------------- 2 files changed, 298 insertions(+), 357 deletions(-) diff --git a/inject.js b/inject.js index 5139c1f..1c30691 100644 --- a/inject.js +++ b/inject.js @@ -15,7 +15,6 @@ var tc = { lastSpeed: 1.0, enabled: true, speeds: {}, - displayKeyCode: 86, rememberSpeed: false, forceLastSavedSpeed: false, audioBoolean: false, @@ -130,60 +129,10 @@ var controllerButtonDefs = { jump: { label: "", className: "" } }; -var keyCodeToEventKey = { - 32: " ", - 37: "ArrowLeft", - 38: "ArrowUp", - 39: "ArrowRight", - 40: "ArrowDown", - 96: "0", - 97: "1", - 98: "2", - 99: "3", - 100: "4", - 101: "5", - 102: "6", - 103: "7", - 104: "8", - 105: "9", - 106: "*", - 107: "+", - 109: "-", - 110: ".", - 111: "/", - 112: "F1", - 113: "F2", - 114: "F3", - 115: "F4", - 116: "F5", - 117: "F6", - 118: "F7", - 119: "F8", - 120: "F9", - 121: "F10", - 122: "F11", - 123: "F12", - 186: ";", - 188: "<", - 189: "-", - 187: "+", - 190: ">", - 191: "/", - 192: "~", - 219: "[", - 220: "\\", - 221: "]", - 222: "'", - 59: ";", - 61: "+", - 173: "-" -}; - -function createDefaultBinding(action, key, keyCode, value) { +function createDefaultBinding(action, code, value) { return { action: action, - key: key, - keyCode: keyCode, + code: code, value: value, force: false, predefined: true @@ -194,62 +143,54 @@ function defaultKeyBindings(storage) { return [ createDefaultBinding( "slower", - "S", - Number(storage.slowerKeyCode) || 83, + "KeyS", Number(storage.speedStep) || 0.1 ), createDefaultBinding( "faster", - "D", - Number(storage.fasterKeyCode) || 68, + "KeyD", Number(storage.speedStep) || 0.1 ), createDefaultBinding( "rewind", - "Z", - Number(storage.rewindKeyCode) || 90, + "KeyZ", Number(storage.rewindTime) || 10 ), createDefaultBinding( "advance", - "X", - Number(storage.advanceKeyCode) || 88, + "KeyX", Number(storage.advanceTime) || 10 ), createDefaultBinding( "reset", - "R", - Number(storage.resetKeyCode) || 82, + "KeyR", 1.0 ), createDefaultBinding( "fast", - "G", - Number(storage.fastKeyCode) || 71, + "KeyG", Number(storage.fastSpeed) || 1.8 ), createDefaultBinding( "move", - "P", - 80, + "KeyP", 0 ), createDefaultBinding( "toggleSubtitleNudge", - "N", - 78, + "KeyN", 0 ) ]; } -function ensureDefaultKeyBinding(action, key, keyCode, value) { +function ensureDefaultKeyBinding(action, code, value) { if (tc.settings.keyBindings.some((binding) => binding.action === action)) { return false; } tc.settings.keyBindings.push( - createDefaultBinding(action, key, keyCode, value) + createDefaultBinding(action, code, value) ); return true; } @@ -544,37 +485,113 @@ function normalizeBindingKey(key) { return key; } -function legacyKeyCodeToBinding(keyCode) { - if (!Number.isInteger(keyCode)) return null; - var key = keyCodeToEventKey[keyCode]; - if (!key && keyCode >= 48 && keyCode <= 57) { - key = String.fromCharCode(keyCode); - } - if (!key && keyCode >= 65 && keyCode <= 90) { - key = String.fromCharCode(keyCode); - } - return { - key: normalizeBindingKey(key), - keyCode: keyCode, - code: null, - disabled: false +function legacyBindingKeyToCode(key) { + var normalizedKey = normalizeBindingKey(key); + if (!normalizedKey) return null; + if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey; + if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey; + if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey; + + var keyMap = { + " ": "Space", + ArrowLeft: "ArrowLeft", + ArrowUp: "ArrowUp", + ArrowRight: "ArrowRight", + ArrowDown: "ArrowDown", + ";": "Semicolon", + "<": "Comma", + "-": "Minus", + "+": "Equal", + ">": "Period", + "/": "Slash", + "~": "Backquote", + "[": "BracketLeft", + "\\": "Backslash", + "]": "BracketRight", + "'": "Quote" }; + + return keyMap[normalizedKey] || null; } -function normalizeStoredBinding(binding, fallbackKeyCode) { - var fallbackBinding = legacyKeyCodeToBinding(fallbackKeyCode); - if (!binding) return fallbackBinding; +function legacyKeyCodeToCode(keyCode) { + if (!Number.isInteger(keyCode)) return null; + if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode); + if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode); + if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96); + if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111); + + var keyCodeMap = { + 32: "Space", + 37: "ArrowLeft", + 38: "ArrowUp", + 39: "ArrowRight", + 40: "ArrowDown", + 106: "NumpadMultiply", + 107: "NumpadAdd", + 109: "NumpadSubtract", + 110: "NumpadDecimal", + 111: "NumpadDivide", + 186: "Semicolon", + 188: "Comma", + 189: "Minus", + 187: "Equal", + 190: "Period", + 191: "Slash", + 192: "Backquote", + 219: "BracketLeft", + 220: "Backslash", + 221: "BracketRight", + 222: "Quote", + 59: "Semicolon", + 61: "Equal", + 173: "Minus" + }; + + return keyCodeMap[keyCode] || null; +} + +function inferBindingCode(binding, fallbackCode) { + if (binding && typeof binding.code === "string" && binding.code.length > 0) { + return binding.code; + } + + if (binding && typeof binding.key === "string") { + var codeFromKey = legacyBindingKeyToCode(binding.key); + if (codeFromKey) return codeFromKey; + } + + var legacyKeyCode = getLegacyKeyCode(binding); + if (Number.isInteger(legacyKeyCode)) { + var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode); + if (codeFromKeyCode) return codeFromKeyCode; + } + + return typeof fallbackCode === "string" && fallbackCode.length > 0 + ? fallbackCode + : null; +} + +function normalizeStoredBinding(binding, fallbackCode) { + if (!binding) { + if (!fallbackCode) return null; + return { + code: fallbackCode, + disabled: false, + value: 0, + force: "false", + predefined: false + }; + } if ( binding.disabled === true || - (binding.key === null && - binding.keyCode === null && - binding.code === null) + (binding.code === null && + binding.key === null && + binding.keyCode === null) ) { return { action: binding.action, - key: null, - keyCode: null, code: null, disabled: true, value: Number(binding.value), @@ -583,46 +600,20 @@ function normalizeStoredBinding(binding, fallbackKeyCode) { }; } + var normalizedCode = inferBindingCode(binding, fallbackCode); + if (!normalizedCode) { + return null; + } + var normalized = { action: binding.action, - key: null, - keyCode: null, - code: - typeof binding.code === "string" && binding.code.length > 0 - ? binding.code - : null, + code: normalizedCode, disabled: false, value: Number(binding.value), force: String(binding.force) === "true" ? "true" : "false", predefined: Boolean(binding.predefined) }; - if (typeof binding.key === "string") { - normalized.key = normalizeBindingKey(binding.key); - } - - var legacyKeyCode = getLegacyKeyCode(binding); - if (Number.isInteger(legacyKeyCode)) { - var legacyBinding = legacyKeyCodeToBinding(legacyKeyCode); - if (legacyBinding) { - normalized.key = normalized.key || legacyBinding.key; - normalized.keyCode = legacyKeyCode; - } - } - - if (Number.isInteger(binding.keyCode)) { - normalized.keyCode = binding.keyCode; - } - - if (!normalized.key && fallbackBinding) { - normalized.key = fallbackBinding.key; - if (normalized.keyCode === null) normalized.keyCode = fallbackBinding.keyCode; - } - - if (!normalized.key && !normalized.code && normalized.keyCode === null) { - return null; - } - return normalized; } @@ -979,19 +970,13 @@ function takePendingRateChange(video, currentSpeed) { } function matchesKeyBinding(binding, event) { - if (!binding || binding.disabled) return false; - - var normalizedEventKey = normalizeBindingKey(event.key); - if (binding.key && normalizedEventKey) { - return binding.key === normalizedEventKey; - } - - if (binding.code && event.code) { - return binding.code === event.code; - } - - var legacyKeyCode = getLegacyKeyCode(binding); - return Number.isInteger(legacyKeyCode) && legacyKeyCode === event.keyCode; + return Boolean( + binding && + binding.disabled !== true && + typeof binding.code === "string" && + binding.code.length > 0 && + binding.code === event.code + ); } function mediaSelector() { @@ -1206,7 +1191,6 @@ chrome.storage.sync.get(tc.settings, function (storage) { chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings, version: tc.settings.version, - displayKeyCode: tc.settings.displayKeyCode, rememberSpeed: tc.settings.rememberSpeed, forceLastSavedSpeed: tc.settings.forceLastSavedSpeed, audioBoolean: tc.settings.audioBoolean, @@ -1226,7 +1210,6 @@ chrome.storage.sync.get(tc.settings, function (storage) { tc.settings.lastSpeed = 1.0; } tc.persistedLastSpeed = tc.settings.lastSpeed; - tc.settings.displayKeyCode = Number(storage.displayKeyCode); tc.settings.rememberSpeed = Boolean(storage.rememberSpeed); tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed); tc.settings.audioBoolean = Boolean(storage.audioBoolean); @@ -1281,14 +1264,13 @@ chrome.storage.sync.get(tc.settings, function (storage) { addedDefaultBinding = ensureDefaultKeyBinding( "display", - "V", - Number(storage.displayKeyCode) || 86, + "KeyV", 0 ) || addedDefaultBinding; addedDefaultBinding = - ensureDefaultKeyBinding("move", "P", 80, 0) || addedDefaultBinding; + ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding; addedDefaultBinding = - ensureDefaultKeyBinding("toggleSubtitleNudge", "N", 78, 0) || + ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) || addedDefaultBinding; if (addedDefaultBinding) { diff --git a/options.js b/options.js index 37b0eba..7851653 100644 --- a/options.js +++ b/options.js @@ -2,105 +2,38 @@ var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; var keyBindings = []; -var keyCodeAliases = { - 0: "null", - null: "null", - undefined: "null", - 32: "Space", - 37: "Left", - 38: "Up", - 39: "Right", - 40: "Down", - 96: "Num 0", - 97: "Num 1", - 98: "Num 2", - 99: "Num 3", - 100: "Num 4", - 101: "Num 5", - 102: "Num 6", - 103: "Num 7", - 104: "Num 8", - 105: "Num 9", - 106: "Num *", - 107: "Num +", - 109: "Num -", - 110: "Num .", - 111: "Num /", - 112: "F1", - 113: "F2", - 114: "F3", - 115: "F4", - 116: "F5", - 117: "F6", - 118: "F7", - 119: "F8", - 120: "F9", - 121: "F10", - 122: "F11", - 123: "F12", - 186: ";", - 188: "<", - 189: "-", - 187: "+", - 190: ">", - 191: "/", - 192: "~", - 219: "[", - 220: "\\", - 221: "]", - 222: "'", - 59: ";", - 61: "+", - 173: "-" -}; - -var keyCodeToKey = { - 32: " ", - 37: "ArrowLeft", - 38: "ArrowUp", - 39: "ArrowRight", - 40: "ArrowDown", - 96: "0", - 97: "1", - 98: "2", - 99: "3", - 100: "4", - 101: "5", - 102: "6", - 103: "7", - 104: "8", - 105: "9", - 106: "*", - 107: "+", - 109: "-", - 110: ".", - 111: "/", - 112: "F1", - 113: "F2", - 114: "F3", - 115: "F4", - 116: "F5", - 117: "F6", - 118: "F7", - 119: "F8", - 120: "F9", - 121: "F10", - 122: "F11", - 123: "F12", - 186: ";", - 188: "<", - 189: "-", - 187: "+", - 190: ">", - 191: "/", - 192: "~", - 219: "[", - 220: "\\", - 221: "]", - 222: "'", - 59: ";", - 61: "+", - 173: "-" +var bindingCodeAliases = { + Space: "Space", + ArrowLeft: "Left", + ArrowUp: "Up", + ArrowRight: "Right", + ArrowDown: "Down", + Numpad0: "Num 0", + Numpad1: "Num 1", + Numpad2: "Num 2", + Numpad3: "Num 3", + Numpad4: "Num 4", + Numpad5: "Num 5", + Numpad6: "Num 6", + Numpad7: "Num 7", + Numpad8: "Num 8", + Numpad9: "Num 9", + NumpadMultiply: "Num *", + NumpadAdd: "Num +", + NumpadSubtract: "Num -", + NumpadDecimal: "Num .", + NumpadDivide: "Num /", + Backquote: "`", + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Slash: "/" }; var modifierKeys = new Set([ @@ -114,13 +47,6 @@ var modifierKeys = new Set([ "Shift" ]); -var displayKeyAliases = { - " ": "Space", - ArrowLeft: "Left", - ArrowUp: "Up", - ArrowRight: "Right", - ArrowDown: "Down" -}; var controllerLocations = [ "top-left", "top-center", @@ -221,11 +147,10 @@ function fillControlBarIconElement(icon, buttonId) { icon.textContent = (def && def.icon) || "?"; } -function createDefaultBinding(action, key, keyCode, value) { +function createDefaultBinding(action, code, value) { return { action: action, - key: key, - keyCode: keyCode, + code: code, value: value, force: false, predefined: true @@ -235,7 +160,6 @@ function createDefaultBinding(action, key, keyCode, value) { var tcDefaults = { speed: 1.0, lastSpeed: 1.0, - displayKeyCode: 86, rememberSpeed: false, audioBoolean: false, startHidden: false, @@ -251,15 +175,15 @@ var tcDefaults = { controllerMarginBottom: 65, controllerMarginLeft: 0, keyBindings: [ - createDefaultBinding("display", "V", 86, 0), - createDefaultBinding("move", "P", 80, 0), - createDefaultBinding("slower", "S", 83, 0.1), - createDefaultBinding("faster", "D", 68, 0.1), - createDefaultBinding("rewind", "Z", 90, 10), - createDefaultBinding("advance", "X", 88, 10), - createDefaultBinding("reset", "R", 82, 1), - createDefaultBinding("fast", "G", 71, 1.8), - createDefaultBinding("toggleSubtitleNudge", "N", 78, 0) + createDefaultBinding("display", "KeyV", 0), + createDefaultBinding("move", "KeyP", 0), + createDefaultBinding("slower", "KeyS", 0.1), + createDefaultBinding("faster", "KeyD", 0.1), + createDefaultBinding("rewind", "KeyZ", 10), + createDefaultBinding("advance", "KeyX", 10), + createDefaultBinding("reset", "KeyR", 1), + createDefaultBinding("fast", "KeyG", 1.8), + createDefaultBinding("toggleSubtitleNudge", "KeyN", 0) ], siteRules: [ { @@ -363,10 +287,10 @@ function refreshAddShortcutSelector() { } } -function ensureDefaultBinding(storage, action, key, keyCode, value) { +function ensureDefaultBinding(storage, action, code, value) { if (storage.keyBindings.some((item) => item.action === action)) return; - storage.keyBindings.push(createDefaultBinding(action, key, keyCode, value)); + storage.keyBindings.push(createDefaultBinding(action, code, value)); } function normalizeControllerLocation(location) { @@ -413,112 +337,156 @@ function getLegacyKeyCode(binding) { return null; } -function legacyKeyCodeToBinding(keyCode) { - if (!Number.isInteger(keyCode)) return null; - var normalizedKey = keyCodeToKey[keyCode]; - if (!normalizedKey && keyCode >= 48 && keyCode <= 57) { - normalizedKey = String.fromCharCode(keyCode); - } - if (!normalizedKey && keyCode >= 65 && keyCode <= 90) { - normalizedKey = String.fromCharCode(keyCode); - } - return { - key: normalizeBindingKey(normalizedKey), - keyCode: keyCode, - code: null, - disabled: false +function legacyBindingKeyToCode(key) { + var normalizedKey = normalizeBindingKey(key); + if (!normalizedKey) return null; + if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey; + if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey; + if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey; + + var keyMap = { + " ": "Space", + ArrowLeft: "ArrowLeft", + ArrowUp: "ArrowUp", + ArrowRight: "ArrowRight", + ArrowDown: "ArrowDown", + ";": "Semicolon", + "<": "Comma", + "-": "Minus", + "+": "Equal", + ">": "Period", + "/": "Slash", + "~": "Backquote", + "[": "BracketLeft", + "\\": "Backslash", + "]": "BracketRight", + "'": "Quote" }; + + return keyMap[normalizedKey] || null; +} + +function legacyKeyCodeToCode(keyCode) { + if (!Number.isInteger(keyCode)) return null; + if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode); + if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode); + if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96); + if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111); + + var keyCodeMap = { + 32: "Space", + 37: "ArrowLeft", + 38: "ArrowUp", + 39: "ArrowRight", + 40: "ArrowDown", + 106: "NumpadMultiply", + 107: "NumpadAdd", + 109: "NumpadSubtract", + 110: "NumpadDecimal", + 111: "NumpadDivide", + 186: "Semicolon", + 188: "Comma", + 189: "Minus", + 187: "Equal", + 190: "Period", + 191: "Slash", + 192: "Backquote", + 219: "BracketLeft", + 220: "Backslash", + 221: "BracketRight", + 222: "Quote", + 59: "Semicolon", + 61: "Equal", + 173: "Minus" + }; + + return keyCodeMap[keyCode] || null; +} + +function inferBindingCode(binding, fallbackCode) { + if (binding && typeof binding.code === "string" && binding.code.length > 0) { + return binding.code; + } + + if (binding && typeof binding.key === "string") { + var codeFromKey = legacyBindingKeyToCode(binding.key); + if (codeFromKey) return codeFromKey; + } + + var legacyKeyCode = getLegacyKeyCode(binding); + if (Number.isInteger(legacyKeyCode)) { + var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode); + if (codeFromKeyCode) return codeFromKeyCode; + } + + return typeof fallbackCode === "string" && fallbackCode.length > 0 + ? fallbackCode + : null; } function createDisabledBinding() { return { - key: null, - keyCode: null, code: null, disabled: true }; } -function normalizeStoredBinding(binding, fallbackKeyCode) { - var fallbackBinding = legacyKeyCodeToBinding(fallbackKeyCode); +function normalizeStoredBinding(binding, fallbackCode) { if (!binding) { - return fallbackBinding; + if (!fallbackCode) return null; + return { + code: fallbackCode, + disabled: false + }; } if ( binding.disabled === true || - (binding.key === null && - binding.keyCode === null && - binding.code === null) + (binding.code === null && + binding.key === null && + binding.keyCode === null) ) { return createDisabledBinding(); } - var normalized = { - key: null, - keyCode: null, - code: - typeof binding.code === "string" && binding.code.length > 0 - ? binding.code - : null, - disabled: false - }; - - if (typeof binding.key === "string") { - normalized.key = normalizeBindingKey(binding.key); - } - - var legacyKeyCode = getLegacyKeyCode(binding); - if (Number.isInteger(legacyKeyCode)) { - var legacyBinding = legacyKeyCodeToBinding(legacyKeyCode); - if (legacyBinding) { - normalized.key = normalized.key || legacyBinding.key; - normalized.keyCode = legacyKeyCode; - } - } - - if (Number.isInteger(binding.keyCode)) { - normalized.keyCode = binding.keyCode; - } - - if (!normalized.key && fallbackBinding) { - normalized.key = fallbackBinding.key; - if (normalized.keyCode === null) normalized.keyCode = fallbackBinding.keyCode; - } - - if (!normalized.key && !normalized.code && normalized.keyCode === null) { + var normalizedCode = inferBindingCode(binding, fallbackCode); + if (!normalizedCode) { return null; } + var normalized = { + code: normalizedCode, + disabled: false + }; + return normalized; } +function formatBindingCode(code) { + if (typeof code !== "string" || code.length === 0) return ""; + if (bindingCodeAliases[code]) return bindingCodeAliases[code]; + if (/^Key[A-Z]$/.test(code)) return code.substring(3); + if (/^Digit[0-9]$/.test(code)) return code.substring(5); + if (/^F([1-9]|1[0-2])$/.test(code)) return code; + return code; +} + function getBindingLabel(binding) { if (!binding) return ""; if (binding.disabled) return ""; - if (binding.key) { - return displayKeyAliases[binding.key] || binding.key; - } - var legacyKeyCode = getLegacyKeyCode(binding); - if (keyCodeAliases[legacyKeyCode]) return keyCodeAliases[legacyKeyCode]; - if (Number.isInteger(legacyKeyCode)) return String.fromCharCode(legacyKeyCode); - return ""; + return formatBindingCode(binding.code); } function setShortcutInputBinding(input, binding) { input.vscBinding = binding ? Object.assign({}, binding) : null; - input.keyCode = - binding && Number.isInteger(binding.keyCode) ? binding.keyCode : null; input.value = getBindingLabel(binding); } function captureBindingFromEvent(event) { - var normalizedKey = normalizeBindingKey(event.key); - if (!normalizedKey || modifierKeys.has(normalizedKey)) return null; + if (modifierKeys.has(event.key)) return null; + if (typeof event.code !== "string" || event.code.length === 0) return null; return { - key: normalizedKey, - keyCode: Number.isInteger(event.keyCode) ? event.keyCode : null, - code: event.code || null, + code: event.code, disabled: false }; } @@ -549,8 +517,10 @@ function recordKeyPress(event) { } function inputFilterNumbersOnly(event) { - var char = String.fromCharCode(event.keyCode); + var char = event.key; if ( + typeof char !== "string" || + char.length !== 1 || !/[\d\.]$/.test(char) || !/^\d+(\.\d*)?$/.test(event.target.value + char) ) { @@ -577,7 +547,15 @@ function updateCustomShortcutInputText(inputItem, bindingOrKeyCode) { return; } - setShortcutInputBinding(inputItem, legacyKeyCodeToBinding(bindingOrKeyCode)); + if (typeof bindingOrKeyCode === "string") { + setShortcutInputBinding(inputItem, { code: bindingOrKeyCode, disabled: false }); + return; + } + + setShortcutInputBinding( + inputItem, + normalizeStoredBinding({ keyCode: bindingOrKeyCode }) + ); } function appendSelectOptions(select, options) { @@ -637,11 +615,7 @@ function createKeyBindings(item) { var input = item.querySelector(".customKey"); var valueInput = item.querySelector(".customValue"); var predefined = !!item.id; - var fallbackKeyCode = - predefined && action === "display" - ? tcDefaults.displayKeyCode - : undefined; - var binding = normalizeStoredBinding(input.vscBinding, fallbackKeyCode); + var binding = normalizeStoredBinding(input.vscBinding); if (!binding) { return { @@ -652,8 +626,6 @@ function createKeyBindings(item) { keyBindings.push({ action: action, - key: binding.key, - keyCode: binding.keyCode, code: binding.code, disabled: binding.disabled === true, value: customActionsNoValues.includes(action) @@ -872,8 +844,6 @@ function save_options() { if (binding) { shortcuts.push({ action: action, - key: binding.key, - keyCode: binding.keyCode, code: binding.code, disabled: binding.disabled === true, value: customActionsNoValues.includes(action) @@ -908,18 +878,7 @@ function save_options() { function ensureAllDefaultBindings(storage) { tcDefaults.keyBindings.forEach((binding) => { - // Special case for "display" to support legacy displayKeyCode - if (binding.action === "display" && storage.displayKeyCode) { - ensureDefaultBinding(storage, "display", "V", storage.displayKeyCode, 0); - } else { - ensureDefaultBinding( - storage, - binding.action, - binding.key, - binding.keyCode, - binding.value - ); - } + ensureDefaultBinding(storage, binding.action, binding.code, binding.value); }); } From 3cf1a4acd16915adb5aa398a941f16b7e99f954e Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 4 Apr 2026 16:14:56 -0400 Subject: [PATCH 2/9] style(inject): normalize formatting --- inject.js | 154 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 85 insertions(+), 69 deletions(-) diff --git a/inject.js b/inject.js index 1c30691..8d90682 100644 --- a/inject.js +++ b/inject.js @@ -56,7 +56,7 @@ var vscObservedRoots = new WeakSet(); var requestIdle = typeof window.requestIdleCallback === "function" ? window.requestIdleCallback.bind(window) - : function (callback, options) { + : function(callback, options) { return setTimeout(callback, (options && options.timeout) || 1); }; var controllerLocations = [ @@ -115,18 +115,18 @@ var controllerLocationStyles = { /* `label` fallback only when ui-icons has no path for the action. */ var controllerButtonDefs = { - rewind: { label: "", className: "rw" }, - slower: { label: "", className: "" }, - faster: { label: "", className: "" }, - advance: { label: "", className: "rw" }, - display: { label: "", className: "hideButton" }, - reset: { label: "\u21BB", className: "" }, - fast: { label: "", className: "" }, + rewind: { label: "", className: "rw" }, + slower: { label: "", className: "" }, + faster: { label: "", className: "" }, + advance: { label: "", className: "rw" }, + display: { label: "", className: "hideButton" }, + reset: { label: "\u21BB", className: "" }, + fast: { label: "", className: "" }, settings: { label: "", className: "" }, - pause: { label: "", className: "" }, - muted: { label: "", className: "" }, - mark: { label: "", className: "" }, - jump: { label: "", className: "" } + pause: { label: "", className: "" }, + muted: { label: "", className: "" }, + mark: { label: "", className: "" }, + jump: { label: "", className: "" } }; function createDefaultBinding(action, code, value) { @@ -388,7 +388,7 @@ function captureSiteRuleBase() { ? tc.settings.controllerButtons.slice() : tc.settings.controllerButtons, keyBindings: Array.isArray(tc.settings.keyBindings) - ? tc.settings.keyBindings.map(function (binding) { + ? tc.settings.keyBindings.map(function(binding) { return Object.assign({}, binding); }) : tc.settings.keyBindings @@ -414,7 +414,7 @@ function resetSettingsFromSiteRuleBase() { ? base.controllerButtons.slice() : base.controllerButtons; tc.settings.keyBindings = Array.isArray(base.keyBindings) - ? base.keyBindings.map(function (binding) { + ? base.keyBindings.map(function(binding) { return Object.assign({}, binding); }) : base.keyBindings; @@ -759,7 +759,7 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) { if (flashEl) { flashEl.classList.add("visible"); clearTimeout(flashEl._flashTimer); - flashEl._flashTimer = setTimeout(function () { + flashEl._flashTimer = setTimeout(function() { flashEl.classList.remove("visible"); }, 1500); } @@ -833,7 +833,7 @@ function schedulePersistLastSpeed(speed) { tc.pendingLastSpeedValue = speed; if (tc.pendingLastSpeedSave !== null) return; - tc.pendingLastSpeedSave = setTimeout(function () { + tc.pendingLastSpeedSave = setTimeout(function() { var speedToPersist = tc.pendingLastSpeedValue; tc.pendingLastSpeedSave = null; @@ -841,7 +841,8 @@ function schedulePersistLastSpeed(speed) { return; } - chrome.storage.sync.set({ lastSpeed: speedToPersist }, function () { }); + chrome.storage.sync.set({ lastSpeed: speedToPersist }, function() { + }); tc.persistedLastSpeed = speedToPersist; }, 250); } @@ -923,7 +924,7 @@ function scheduleSpeedRestore(video, desiredSpeed, reason) { clearTimeout(video.vsc.restoreSpeedTimer); } - video.vsc.restoreSpeedTimer = setTimeout(function () { + video.vsc.restoreSpeedTimer = setTimeout(function() { if (!video.vsc) return; if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) { @@ -1006,7 +1007,7 @@ function hasUsableMediaSource(node) { } if (node.querySelectorAll) { - return Array.from(node.querySelectorAll("source[src]")).some(function ( + return Array.from(node.querySelectorAll("source[src]")).some(function( source ) { var src = source.getAttribute("src"); @@ -1145,7 +1146,7 @@ function patchAttachShadow() { } var originalAttachShadow = Element.prototype.attachShadow; - Element.prototype.attachShadow = function () { + Element.prototype.attachShadow = function() { var shadowRoot = originalAttachShadow.apply(this, arguments); try { if (shadowRoot) { @@ -1176,7 +1177,7 @@ function log(message, level) { } } -chrome.storage.sync.get(tc.settings, function (storage) { +chrome.storage.sync.get(tc.settings, function(storage) { var storedBindings = Array.isArray(storage.keyBindings) ? storage.keyBindings : []; @@ -1282,7 +1283,7 @@ chrome.storage.sync.get(tc.settings, function (storage) { // We use a global flag to ensure the listener is only attached once. if (!window.vscMessageListener) { chrome.runtime.onMessage.addListener( - function (request, sender, sendResponse) { + function(request, sender, sendResponse) { if (request.action === "rescan_page") { log("Re-scan command received from popup.", 4); initializeWhenReady(document, true); @@ -1323,7 +1324,7 @@ chrome.storage.sync.get(tc.settings, function (storage) { // Set the flag to prevent adding the listener again. window.vscMessageListener = true; } - chrome.storage.local.get(["customButtonIcons"], function (loc) { + chrome.storage.local.get(["customButtonIcons"], function(loc) { tc.settings.customButtonIcons = loc && loc.customButtonIcons && @@ -1333,18 +1334,18 @@ chrome.storage.sync.get(tc.settings, function (storage) { if (!window.vscCustomIconListener) { window.vscCustomIconListener = true; - chrome.storage.onChanged.addListener(function (changes, area) { + chrome.storage.onChanged.addListener(function(changes, area) { if (area !== "local" || !changes.customButtonIcons) return; var nv = changes.customButtonIcons.newValue; tc.settings.customButtonIcons = nv && typeof nv === "object" ? nv : {}; if (tc.mediaElements && tc.mediaElements.length) { - tc.mediaElements.forEach(function (video) { + tc.mediaElements.forEach(function(video) { if (!video.vsc || !video.vsc.div) return; var doc = video.ownerDocument; var shadow = video.vsc.div.shadowRoot; if (!shadow) return; - shadow.querySelectorAll("button[data-action]").forEach(function (btn) { + shadow.querySelectorAll("button[data-action]").forEach(function(btn) { var act = btn.dataset.action; if (!act) return; var svg = @@ -1389,6 +1390,7 @@ function getKeyBindings(action, what = "value") { return false; } } + function setKeyBindings(action, value) { tc.settings.keyBindings.find((item) => item.action === action)["value"] = value; @@ -1425,7 +1427,7 @@ function createControllerButton(doc, action, label, className) { } function defineVideoController() { - tc.videoController = function (target, parent) { + tc.videoController = function(target, parent) { if (target.vsc) return target.vsc; tc.mediaElements.push(target); target.vsc = this; @@ -1445,7 +1447,7 @@ function defineVideoController() { tc.settings.controllerLocation ); - log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || 'none'}`, 4); + log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4); let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0); this.targetSpeed = storedSpeed; @@ -1465,9 +1467,9 @@ function defineVideoController() { return; } - log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains('vsc-hidden')}`, 4); + log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains("vsc-hidden")}`, 4); - var mediaEventAction = function (event) { + var mediaEventAction = function(event) { if ( event.type === "loadedmetadata" || event.type === "loadeddata" || @@ -1585,7 +1587,7 @@ function defineVideoController() { this.startSubtitleNudge(); }; - tc.videoController.prototype.remove = function () { + tc.videoController.prototype.remove = function() { this.stopSubtitleNudge(); if (this.youTubeAutoHideObserver) { this.youTubeAutoHideObserver.disconnect(); @@ -1617,7 +1619,7 @@ function defineVideoController() { if (idx != -1) tc.mediaElements.splice(idx, 1); }; - tc.videoController.prototype.startSubtitleNudge = function () { + tc.videoController.prototype.startSubtitleNudge = function() { if ( !isSubtitleNudgeSupported(this.video) || !isSubtitleNudgeEnabledForVideo(this.video) || @@ -1690,7 +1692,7 @@ function defineVideoController() { log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5); }; - tc.videoController.prototype.stopSubtitleNudge = function () { + tc.videoController.prototype.stopSubtitleNudge = function() { if (this.nudgeAnimationId !== null) { clearTimeout(this.nudgeAnimationId); this.nudgeAnimationId = null; @@ -1708,7 +1710,7 @@ function defineVideoController() { // doesn't lose the user's intended speed if the site hijacks it. }; - tc.videoController.prototype.performImmediateNudge = function () { + tc.videoController.prototype.performImmediateNudge = function() { if ( !isSubtitleNudgeSupported(this.video) || !isSubtitleNudgeEnabledForVideo(this.video) || @@ -1738,7 +1740,7 @@ function defineVideoController() { log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5); }; - tc.videoController.prototype.setupYouTubeAutoHide = function (wrapper) { + tc.videoController.prototype.setupYouTubeAutoHide = function(wrapper) { if (!wrapper || !isOnYouTube()) return; const video = this.video; @@ -1754,7 +1756,7 @@ function defineVideoController() { // The vsc-hidden class (from V key) takes precedence via CSS specificity if (ytPlayer.classList.contains("ytp-autohide")) { wrapper.classList.add("ytp-autohide"); - + // Immediately end any temporary "vsc-show" state to hide with YouTube // UNLESS it was forced by a shortcut (vsc-forced-show) if (!wrapper.classList.contains("vsc-forced-show")) { @@ -1764,7 +1766,7 @@ function defineVideoController() { wrapper.showTimeOut = undefined; } } - + log("YouTube controls hidden, hiding controller", 5); } else { wrapper.classList.remove("ytp-autohide"); @@ -1814,7 +1816,7 @@ function defineVideoController() { }; }; - tc.videoController.prototype.setupGenericAutoHide = function (wrapper) { + tc.videoController.prototype.setupGenericAutoHide = function(wrapper) { if (!wrapper) return; const video = this.video; @@ -1873,7 +1875,7 @@ function defineVideoController() { log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4); }; - tc.videoController.prototype.initializeControls = function () { + tc.videoController.prototype.initializeControls = function() { const doc = this.video.ownerDocument; const speed = this.video.playbackRate.toFixed(2); var wrapper = doc.createElement("div"); @@ -1911,7 +1913,7 @@ function defineVideoController() { var subtitleNudgeIndicator = null; - buttonConfig.forEach(function (btnId) { + buttonConfig.forEach(function(btnId) { if (btnId === "nudge") { subtitleNudgeIndicator = doc.createElement("span"); subtitleNudgeIndicator.id = "nudge-indicator"; @@ -1943,20 +1945,22 @@ function defineVideoController() { this.subtitleNudgeIndicator = subtitleNudgeIndicator; this.nudgeFlashIndicator = nudgeFlashIndicator; this.resetButtonEl = - shadow.querySelector('button[data-action="reset"]') || null; + shadow.querySelector("button[data-action=\"reset\"]") || null; this.resetToggleArmed = false; if (subtitleNudgeIndicator) { updateSubtitleNudgeIndicator(this.video); } + function blurAfterPointerTap(target, e) { if (!target || typeof target.blur !== "function") return; var pt = e.pointerType; if (pt === "mouse" || pt === "touch" || (!pt && e.detail > 0)) { - requestAnimationFrame(function () { + requestAnimationFrame(function() { target.blur(); }); } } + dragHandle.addEventListener( "mousedown", (e) => { @@ -2004,7 +2008,7 @@ function defineVideoController() { this.setupGenericAutoHide(wrapper); } } - + var fragment = doc.createDocumentFragment(); fragment.appendChild(wrapper); const parentEl = this.parent || this.video.parentElement; @@ -2074,6 +2078,7 @@ function escapeStringRegExp(str) { const m = /[|\\{}()[\]^$+*?.]/g; return str.replace(m, "\\$&"); } + function applySiteRuleOverrides() { resetSettingsFromSiteRuleBase(); @@ -2152,7 +2157,7 @@ function applySiteRuleOverrides() { [ "controllerMarginTop", "controllerMarginBottom" - ].forEach(function (key) { + ].forEach(function(key) { tc.settings[key] = normalizeControllerMarginPx(tc.settings[key], 0); }); @@ -2183,7 +2188,7 @@ function applySiteRuleOverrides() { /** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */ function refreshAllControllerGeometry() { - tc.mediaElements.forEach(function (video) { + tc.mediaElements.forEach(function(video) { if (!video || !video.vsc) return; applyControllerLocation(video.vsc, tc.settings.controllerLocation); var controllerEl = getControllerElement(video.vsc); @@ -2217,6 +2222,7 @@ function shouldPreserveDesiredSpeed(video, speed) { function setupListener(root) { root = root || document; if (root.vscRateListenerAttached) return; + function updateSpeedFromEvent(video, skipResetDisarm) { if (!video.vsc || !video.vsc.speedIndicator) return; if (!skipResetDisarm) { @@ -2237,9 +2243,10 @@ function setupListener(root) { else video.vsc.startSubtitleNudge(); } } + root.addEventListener( "ratechange", - function (event) { + function(event) { if (tc.isNudging) return; var video = event.target; if (!video || typeof video.playbackRate === "undefined" || !video.vsc) @@ -2283,6 +2290,7 @@ function setupListener(root) { } var vscInitializedDocuments = new Set(); + function clearPendingInitialization(doc) { if (!doc || !doc.vscPendingInitializeHandler) return; @@ -2319,7 +2327,7 @@ function initializeWhenReady(doc, forceReinit = false) { if (doc.vscPendingInitializeHandler) return; - var pendingInitializeHandler = function () { + var pendingInitializeHandler = function() { tryInitializeDocument(doc, doc.vscPendingForceReinit === true); }; @@ -2334,6 +2342,7 @@ function initializeWhenReady(doc, forceReinit = false) { setTimeout(pendingInitializeHandler, 0); } } + function inIframe() { try { return window.self !== window.top; @@ -2346,13 +2355,14 @@ function attachKeydownListeners(doc) { var docs = [doc]; try { if (inIframe() && window.top.document !== doc) docs.push(window.top.document); - } catch (e) { } + } catch (e) { + } - docs.forEach(function (keyDoc) { + docs.forEach(function(keyDoc) { if (keyDoc.vscKeydownListenerAttached) return; keyDoc.addEventListener( "keydown", - function (event) { + function(event) { if ( !event.getModifierState || event.getModifierState("Alt") || @@ -2375,7 +2385,7 @@ function attachKeydownListeners(doc) { if (!tc.mediaElements.length) return; - var item = tc.settings.keyBindings.find(function (binding) { + var item = tc.settings.keyBindings.find(function(binding) { return matchesKeyBinding(binding, event); }); @@ -2399,24 +2409,24 @@ function attachMutationObserver(root) { var pendingMutations = []; var mutationProcessingScheduled = false; - var observer = new MutationObserver(function (mutations) { + var observer = new MutationObserver(function(mutations) { pendingMutations.push(...mutations); if (mutationProcessingScheduled) return; mutationProcessingScheduled = true; requestIdle( - function () { + function() { var mutationsToProcess = pendingMutations.splice(0); mutationProcessingScheduled = false; - mutationsToProcess.forEach(function (mutation) { + mutationsToProcess.forEach(function(mutation) { if (mutation.type === "childList") { - mutation.addedNodes.forEach(function (node) { + mutation.addedNodes.forEach(function(node) { // Skip text nodes, comments, etc. — only elements can contain media if (node.nodeType !== Node.ELEMENT_NODE) return; scanNodeForMedia(node, node.parentNode || mutation.target, true); }); - mutation.removedNodes.forEach(function (node) { + mutation.removedNodes.forEach(function(node) { if (node.nodeType !== Node.ELEMENT_NODE) return; scanNodeForMedia(node, node.parentNode || mutation.target, false); }); @@ -2465,7 +2475,7 @@ function attachMutationObserver(root) { function attachMediaDetectionListeners(root) { if (root.vscMediaEventListenersAttached) return; - var handleDetectedMedia = function (event) { + var handleDetectedMedia = function(event) { var target = event.target; if (!isMediaElement(target)) return; ensureController(target, target.parentElement || target.parentNode); @@ -2478,21 +2488,22 @@ function attachMediaDetectionListeners(root) { "canplay", "playing", "play" - ].forEach(function (eventName) { + ].forEach(function(eventName) { root.addEventListener(eventName, handleDetectedMedia, true); }); root.vscMediaEventListenersAttached = true; } function attachIframeListeners(doc) { - Array.from(doc.getElementsByTagName("iframe")).forEach(function (frame) { + Array.from(doc.getElementsByTagName("iframe")).forEach(function(frame) { if (!frame.vscLoadListenerAttached) { - frame.addEventListener("load", function () { + frame.addEventListener("load", function() { try { if (frame.contentDocument) { initializeWhenReady(frame.contentDocument, true); } - } catch (e) { } + } catch (e) { + } }); frame.vscLoadListenerAttached = true; } @@ -2501,24 +2512,25 @@ function attachIframeListeners(doc) { if (frame.contentDocument) { initializeWhenReady(frame.contentDocument); } - } catch (e) { } + } catch (e) { + } }); } function attachNavigationListeners() { if (window.vscNavigationListenersAttached) return; - var scheduleRescan = function () { + var scheduleRescan = function() { clearTimeout(window.vscNavigationRescanTimer); - window.vscNavigationRescanTimer = setTimeout(function () { + window.vscNavigationRescanTimer = setTimeout(function() { initializeWhenReady(document, true); }, 300); }; - ["pushState", "replaceState"].forEach(function (method) { + ["pushState", "replaceState"].forEach(function(method) { if (typeof history[method] !== "function") return; var original = history[method]; - history[method] = function () { + history[method] = function() { var result = original.apply(this, arguments); scheduleRescan(); return result; @@ -2665,7 +2677,7 @@ function runAction(action, value, e) { ); } - mediaTagsToProcess.forEach(function (v) { + mediaTagsToProcess.forEach(function(v) { if (!v.vsc) return; // Don't process videos without a controller var controller = v.vsc.div; const userDrivenActionsThatShowController = [ @@ -2854,15 +2866,18 @@ function resetSpeed(v, target, isFastKey = false) { function muted(v) { v.muted = !v.muted; } + function setMark(v) { v.vsc.mark = v.currentTime; } + function jumpToMark(v) { if (v.vsc && typeof v.vsc.mark === "number") { extendSpeedRestoreWindow(v); v.currentTime = v.vsc.mark; } } + function handleDrag(video, e) { const c = video.vsc.div; const sC = convertControllerToManualPosition(video.vsc); @@ -2872,7 +2887,7 @@ function handleDrag(video, e) { pE.parentNode && pE.parentNode.offsetHeight === pE.offsetHeight && pE.parentNode.offsetWidth === pE.offsetWidth - ) + ) pE = pE.parentNode; video.classList.add("vcs-dragging"); sC.classList.add("dragging"); @@ -2901,6 +2916,7 @@ function handleDrag(video, e) { pE.addEventListener("mouseleave", eD); pE.addEventListener("mousemove", sD); } + function showController(controller, duration = 2000, forced = false) { if (!controller || typeof controller.classList === "undefined") return; var restoreHidden = @@ -2918,7 +2934,7 @@ function showController(controller, duration = 2000, forced = false) { clearTimeout(controller.showTimeOut); } - controller.showTimeOut = setTimeout(function () { + controller.showTimeOut = setTimeout(function() { controller.classList.remove("vsc-show"); controller.classList.remove("vsc-forced-show"); if (controller.restoreHiddenAfterShow === true) { From a7a0aafd6855b98a053d21a87172277497913682 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 7 Apr 2026 14:31:27 -0400 Subject: [PATCH 3/9] Add Vitest suite and fix wrapped local import restore --- importExport.js | 92 +- inject.js | 181 +- manifest.json | 3 + options.html | 4 + options.js | 145 +- package-lock.json | 2128 ++++++++++++++++++++++++ package.json | 12 + popup.html | 2 + popup.js | 90 +- shared/controller-utils.js | 55 + shared/import-export.js | 85 + shared/key-bindings.js | 122 ++ shared/popup-controls.js | 85 + shared/site-rules.js | 69 + tests/helpers/browser.js | 164 ++ tests/helpers/extension-test-utils.js | 240 +++ tests/importExport.integration.test.js | 224 +++ tests/importExport.spec.js | 189 +++ tests/inject.spec.js | 141 ++ tests/lucide-client.spec.js | 61 + tests/options.integration.test.js | 140 ++ tests/options.spec.js | 230 +++ tests/popup.integration.test.js | 121 ++ tests/popup.spec.js | 173 ++ tests/setup.js | 25 + tests/shared.test.js | 145 ++ vitest.config.js | 12 + 27 files changed, 4555 insertions(+), 383 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 shared/controller-utils.js create mode 100644 shared/import-export.js create mode 100644 shared/key-bindings.js create mode 100644 shared/popup-controls.js create mode 100644 shared/site-rules.js create mode 100644 tests/helpers/browser.js create mode 100644 tests/helpers/extension-test-utils.js create mode 100644 tests/importExport.integration.test.js create mode 100644 tests/importExport.spec.js create mode 100644 tests/inject.spec.js create mode 100644 tests/lucide-client.spec.js create mode 100644 tests/options.integration.test.js create mode 100644 tests/options.spec.js create mode 100644 tests/popup.integration.test.js create mode 100644 tests/popup.spec.js create mode 100644 tests/setup.js create mode 100644 tests/shared.test.js create mode 100644 vitest.config.js diff --git a/importExport.js b/importExport.js index 3e55fca..19b9f4f 100644 --- a/importExport.js +++ b/importExport.js @@ -1,25 +1,20 @@ // Import/Export functionality for Video Speed Controller settings +var speederShared = + typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; +var importExportUtils = speederShared.importExport || {}; function generateBackupFilename() { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const hours = String(now.getHours()).padStart(2, "0"); - const minutes = String(now.getMinutes()).padStart(2, "0"); - const seconds = String(now.getSeconds()).padStart(2, "0"); - return `speeder-backup_${year}-${month}-${day}_${hours}.${minutes}.${seconds}.json`; + return importExportUtils.generateBackupFilename(new Date()); } function exportSettings() { chrome.storage.sync.get(null, function (storage) { chrome.storage.local.get(null, function (localStorage) { - const backup = { - version: "1.1", - exportDate: new Date().toISOString(), - settings: storage, - localSettings: localStorage || {} - }; + const backup = importExportUtils.buildBackupPayload( + storage, + localStorage, + new Date() + ); const dataStr = JSON.stringify(backup, null, 2); const blob = new Blob([dataStr], { type: "application/json" }); @@ -50,25 +45,50 @@ function importSettings() { const reader = new FileReader(); reader.onload = function (e) { try { - const backup = JSON.parse(e.target.result); - let settingsToImport = null; + const parsedBackup = importExportUtils.parseImportText(e.target.result); - // Detect backup format: check for 'settings' wrapper or raw storage keys - if (backup.settings && typeof backup.settings === "object") { - settingsToImport = backup.settings; - } else if (typeof backup === "object" && (backup.keyBindings || backup.rememberSpeed !== undefined)) { - settingsToImport = backup; // Raw storage object - } - - if (!settingsToImport) { + if (!parsedBackup) { showStatus("Error: Invalid backup file format", true); return; } - var localToImport = - backup.localSettings && typeof backup.localSettings === "object" - ? backup.localSettings - : null; + var settingsToImport = parsedBackup.settings; + var localToImport = parsedBackup.localSettings; + + function importLocalSettings(callback) { + if (parsedBackup.isWrappedBackup !== true) { + callback(); + return; + } + + chrome.storage.local.clear(function () { + if (chrome.runtime.lastError) { + showStatus( + "Error: Failed to clear local extension data - " + + chrome.runtime.lastError.message, + true + ); + return; + } + + if (localToImport && Object.keys(localToImport).length > 0) { + chrome.storage.local.set(localToImport, function () { + if (chrome.runtime.lastError) { + showStatus( + "Error: Failed to save local extension data - " + + chrome.runtime.lastError.message, + true + ); + return; + } + callback(); + }); + return; + } + + callback(); + }); + } function afterLocalImport() { chrome.storage.sync.clear(function () { @@ -93,21 +113,7 @@ function importSettings() { }); } - if (localToImport && Object.keys(localToImport).length > 0) { - chrome.storage.local.set(localToImport, function () { - if (chrome.runtime.lastError) { - showStatus( - "Error: Failed to save local extension data - " + - chrome.runtime.lastError.message, - true - ); - return; - } - afterLocalImport(); - }); - } else { - afterLocalImport(); - } + importLocalSettings(afterLocalImport); } catch (err) { showStatus("Error: Failed to parse backup file - " + err.message, true); } diff --git a/inject.js b/inject.js index 8d90682..7f7318c 100644 --- a/inject.js +++ b/inject.js @@ -1,5 +1,10 @@ var isUserSeek = false; // Track if seek was user-initiated var lastToggleSpeed = {}; // Store last toggle speeds per video +var speederShared = + typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; +var controllerUtils = speederShared.controllerUtils || {}; +var keyBindingUtils = speederShared.keyBindings || {}; +var siteRuleUtils = speederShared.siteRules || {}; function getPrimaryVideoElement() { if (!tc.mediaElements || tc.mediaElements.length === 0) return null; @@ -59,17 +64,20 @@ var requestIdle = : function(callback, options) { return setTimeout(callback, (options && options.timeout) || 1); }; -var controllerLocations = [ - "top-left", - "top-center", - "top-right", - "middle-right", - "bottom-right", - "bottom-center", - "bottom-left", - "middle-left" -]; -var defaultControllerLocation = controllerLocations[0]; +var controllerLocations = Array.isArray(controllerUtils.controllerLocations) + ? controllerUtils.controllerLocations.slice() + : [ + "top-left", + "top-center", + "top-right", + "middle-right", + "bottom-right", + "bottom-center", + "bottom-left", + "middle-left" + ]; +var defaultControllerLocation = + controllerUtils.defaultControllerLocation || controllerLocations[0]; var controllerLocationStyles = { "top-left": { top: "10px", @@ -196,28 +204,20 @@ function ensureDefaultKeyBinding(action, code, value) { } function getLegacyKeyCode(binding) { - if (!binding) return null; - if (Number.isInteger(binding.keyCode)) return binding.keyCode; - if (typeof binding.key === "number" && Number.isInteger(binding.key)) { - return binding.key; - } - return null; + return keyBindingUtils.getLegacyKeyCode(binding); } function normalizeControllerLocation(location) { - if (controllerLocations.includes(location)) return location; - return defaultControllerLocation; + return controllerUtils.normalizeControllerLocation( + location, + defaultControllerLocation + ); } var CONTROLLER_MARGIN_MAX_PX = 200; function normalizeControllerMarginPx(value, fallback) { - var n = Number(value); - if (!Number.isFinite(n)) return fallback; - return Math.min( - CONTROLLER_MARGIN_MAX_PX, - Math.max(0, Math.round(n)) - ); + return controllerUtils.clampControllerMarginPx(value, fallback); } function applyControllerMargins(controller) { @@ -256,9 +256,7 @@ function applyControllerMargins(controller) { } function getNextControllerLocation(location) { - var normalizedLocation = normalizeControllerLocation(location); - var currentIndex = controllerLocations.indexOf(normalizedLocation); - return controllerLocations[(currentIndex + 1) % controllerLocations.length]; + return controllerUtils.getNextControllerLocation(location); } function getControllerElement(videoOrController) { @@ -478,98 +476,19 @@ function cycleControllerLocation(video) { } function normalizeBindingKey(key) { - if (typeof key !== "string" || key.length === 0) return null; - if (key === "Spacebar") return " "; - if (key === "Esc") return "Escape"; - if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase(); - return key; + return keyBindingUtils.normalizeBindingKey(key); } function legacyBindingKeyToCode(key) { - var normalizedKey = normalizeBindingKey(key); - if (!normalizedKey) return null; - if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey; - if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey; - if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey; - - var keyMap = { - " ": "Space", - ArrowLeft: "ArrowLeft", - ArrowUp: "ArrowUp", - ArrowRight: "ArrowRight", - ArrowDown: "ArrowDown", - ";": "Semicolon", - "<": "Comma", - "-": "Minus", - "+": "Equal", - ">": "Period", - "/": "Slash", - "~": "Backquote", - "[": "BracketLeft", - "\\": "Backslash", - "]": "BracketRight", - "'": "Quote" - }; - - return keyMap[normalizedKey] || null; + return keyBindingUtils.legacyBindingKeyToCode(key); } function legacyKeyCodeToCode(keyCode) { - if (!Number.isInteger(keyCode)) return null; - if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode); - if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode); - if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96); - if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111); - - var keyCodeMap = { - 32: "Space", - 37: "ArrowLeft", - 38: "ArrowUp", - 39: "ArrowRight", - 40: "ArrowDown", - 106: "NumpadMultiply", - 107: "NumpadAdd", - 109: "NumpadSubtract", - 110: "NumpadDecimal", - 111: "NumpadDivide", - 186: "Semicolon", - 188: "Comma", - 189: "Minus", - 187: "Equal", - 190: "Period", - 191: "Slash", - 192: "Backquote", - 219: "BracketLeft", - 220: "Backslash", - 221: "BracketRight", - 222: "Quote", - 59: "Semicolon", - 61: "Equal", - 173: "Minus" - }; - - return keyCodeMap[keyCode] || null; + return keyBindingUtils.legacyKeyCodeToCode(keyCode); } function inferBindingCode(binding, fallbackCode) { - if (binding && typeof binding.code === "string" && binding.code.length > 0) { - return binding.code; - } - - if (binding && typeof binding.key === "string") { - var codeFromKey = legacyBindingKeyToCode(binding.key); - if (codeFromKey) return codeFromKey; - } - - var legacyKeyCode = getLegacyKeyCode(binding); - if (Number.isInteger(legacyKeyCode)) { - var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode); - if (codeFromKeyCode) return codeFromKeyCode; - } - - return typeof fallbackCode === "string" && fallbackCode.length > 0 - ? fallbackCode - : null; + return keyBindingUtils.inferBindingCode(binding, fallbackCode); } function normalizeStoredBinding(binding, fallbackCode) { @@ -2074,11 +1993,6 @@ function defineVideoController() { }; } -function escapeStringRegExp(str) { - const m = /[|\\{}()[\]^$+*?.]/g; - return str.replace(m, "\\$&"); -} - function applySiteRuleOverrides() { resetSettingsFromSiteRuleBase(); @@ -2087,34 +2001,7 @@ function applySiteRuleOverrides() { } var currentUrl = location.href; - var matchedRule = null; - - for (var i = 0; i < tc.settings.siteRules.length; i++) { - var rule = tc.settings.siteRules[i]; - var pattern = rule.pattern; - if (!pattern || pattern.length === 0) continue; - - var regex; - if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) { - try { - var lastSlash = pattern.lastIndexOf("/"); - regex = new RegExp( - pattern.substring(1, lastSlash), - pattern.substring(lastSlash + 1) - ); - } catch (e) { - log(`Invalid site rule regex: ${pattern}. ${e.message}`, 2); - continue; - } - } else { - regex = new RegExp(escapeStringRegExp(pattern)); - } - - if (regex && regex.test(currentUrl)) { - matchedRule = rule; - break; - } - } + var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules); if (!matchedRule) return false; @@ -2122,13 +2009,9 @@ function applySiteRuleOverrides() { log(`Matched site rule: ${matchedRule.pattern}`, 4); // Check if extension should be enabled/disabled on this site - if (matchedRule.enabled === false) { + if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) { log(`Extension disabled for site: ${currentUrl}`, 4); return true; - } else if (matchedRule.disableExtension === true) { - // Handle old format - log(`Extension disabled (legacy) for site: ${currentUrl}`, 4); - return true; } // Override general settings with site-specific overrides diff --git a/manifest.json b/manifest.json index 3d2b692..1c859c7 100644 --- a/manifest.json +++ b/manifest.json @@ -59,6 +59,9 @@ "inject.css" ], "js": [ + "shared/controller-utils.js", + "shared/key-bindings.js", + "shared/site-rules.js", "ui-icons.js", "inject.js" ] diff --git a/options.html b/options.html index 3d5be8d..0bd6c9e 100644 --- a/options.html +++ b/options.html @@ -5,9 +5,13 @@ Speeder Settings + + + + diff --git a/options.js b/options.js index 7851653..f26c5b9 100644 --- a/options.js +++ b/options.js @@ -1,4 +1,9 @@ var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; +var speederShared = + typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; +var controllerUtils = speederShared.controllerUtils || {}; +var keyBindingUtils = speederShared.keyBindings || {}; +var popupControlUtils = speederShared.popupControls || {}; var keyBindings = []; @@ -47,16 +52,18 @@ var modifierKeys = new Set([ "Shift" ]); -var controllerLocations = [ - "top-left", - "top-center", - "top-right", - "middle-right", - "bottom-right", - "bottom-center", - "bottom-left", - "middle-left" -]; +var controllerLocations = Array.isArray(controllerUtils.controllerLocations) + ? controllerUtils.controllerLocations.slice() + : [ + "top-left", + "top-center", + "top-right", + "middle-right", + "bottom-right", + "bottom-center", + "bottom-left", + "middle-left" + ]; var controllerButtonDefs = { rewind: { icon: "\u00AB", name: "Rewind" }, @@ -82,15 +89,11 @@ var lucideSubtitleNudgeActionLabels = { }; function sanitizePopupButtonOrder(buttonIds) { - if (!Array.isArray(buttonIds)) return []; - var seen = new Set(); - return buttonIds.filter(function (id) { - if (!controllerButtonDefs[id] || popupExcludedButtonIds.has(id) || seen.has(id)) { - return false; - } - seen.add(id); - return true; - }); + return popupControlUtils.sanitizeButtonOrder( + buttonIds, + controllerButtonDefs, + popupExcludedButtonIds + ); } /** Cached custom Lucide SVGs (mirrors chrome.storage.local customButtonIcons). */ @@ -294,14 +297,14 @@ function ensureDefaultBinding(storage, action, code, value) { } function normalizeControllerLocation(location) { - if (controllerLocations.includes(location)) return location; - return tcDefaults.controllerLocation; + return controllerUtils.normalizeControllerLocation( + location, + tcDefaults.controllerLocation + ); } function clampMarginPxInput(el, fallback) { - var n = parseInt(el && el.value, 10); - if (!Number.isFinite(n)) return fallback; - return Math.min(200, Math.max(0, n)); + return controllerUtils.clampControllerMarginPx(el && el.value, fallback); } function syncSiteRuleField(ruleEl, rule, key, isCheckbox) { @@ -321,107 +324,23 @@ function syncSiteRuleField(ruleEl, rule, key, isCheckbox) { } function normalizeBindingKey(key) { - if (typeof key !== "string" || key.length === 0) return null; - if (key === "Spacebar") return " "; - if (key === "Esc") return "Escape"; - if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase(); - return key; + return keyBindingUtils.normalizeBindingKey(key); } function getLegacyKeyCode(binding) { - if (!binding) return null; - if (Number.isInteger(binding.keyCode)) return binding.keyCode; - if (typeof binding.key === "number" && Number.isInteger(binding.key)) { - return binding.key; - } - return null; + return keyBindingUtils.getLegacyKeyCode(binding); } function legacyBindingKeyToCode(key) { - var normalizedKey = normalizeBindingKey(key); - if (!normalizedKey) return null; - if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey; - if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey; - if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey; - - var keyMap = { - " ": "Space", - ArrowLeft: "ArrowLeft", - ArrowUp: "ArrowUp", - ArrowRight: "ArrowRight", - ArrowDown: "ArrowDown", - ";": "Semicolon", - "<": "Comma", - "-": "Minus", - "+": "Equal", - ">": "Period", - "/": "Slash", - "~": "Backquote", - "[": "BracketLeft", - "\\": "Backslash", - "]": "BracketRight", - "'": "Quote" - }; - - return keyMap[normalizedKey] || null; + return keyBindingUtils.legacyBindingKeyToCode(key); } function legacyKeyCodeToCode(keyCode) { - if (!Number.isInteger(keyCode)) return null; - if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode); - if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode); - if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96); - if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111); - - var keyCodeMap = { - 32: "Space", - 37: "ArrowLeft", - 38: "ArrowUp", - 39: "ArrowRight", - 40: "ArrowDown", - 106: "NumpadMultiply", - 107: "NumpadAdd", - 109: "NumpadSubtract", - 110: "NumpadDecimal", - 111: "NumpadDivide", - 186: "Semicolon", - 188: "Comma", - 189: "Minus", - 187: "Equal", - 190: "Period", - 191: "Slash", - 192: "Backquote", - 219: "BracketLeft", - 220: "Backslash", - 221: "BracketRight", - 222: "Quote", - 59: "Semicolon", - 61: "Equal", - 173: "Minus" - }; - - return keyCodeMap[keyCode] || null; + return keyBindingUtils.legacyKeyCodeToCode(keyCode); } function inferBindingCode(binding, fallbackCode) { - if (binding && typeof binding.code === "string" && binding.code.length > 0) { - return binding.code; - } - - if (binding && typeof binding.key === "string") { - var codeFromKey = legacyBindingKeyToCode(binding.key); - if (codeFromKey) return codeFromKey; - } - - var legacyKeyCode = getLegacyKeyCode(binding); - if (Number.isInteger(legacyKeyCode)) { - var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode); - if (codeFromKeyCode) return codeFromKeyCode; - } - - return typeof fallbackCode === "string" && fallbackCode.length > 0 - ? fallbackCode - : null; + return keyBindingUtils.inferBindingCode(binding, fallbackCode); } function createDisabledBinding() { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..320da4a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2128 @@ +{ + "name": "speeder", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "speeder", + "devDependencies": { + "jsdom": "^26.1.0", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d7d1ddf --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "speeder", + "private": true, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "jsdom": "^26.1.0", + "vitest": "^3.2.4" + } +} diff --git a/popup.html b/popup.html index 35371f3..cae9e41 100644 --- a/popup.html +++ b/popup.html @@ -4,6 +4,8 @@ Speeder + + diff --git a/popup.js b/popup.js index 3154478..1eabdbd 100644 --- a/popup.js +++ b/popup.js @@ -1,5 +1,8 @@ document.addEventListener("DOMContentLoaded", function () { - var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; + var speederShared = + typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; + var siteRuleUtils = speederShared.siteRules || {}; + var popupControlUtils = speederShared.popupControls || {}; /* `label` is only used if ui-icons.js has no path for this action (fallback). */ var controllerButtonDefs = { @@ -30,73 +33,20 @@ document.addEventListener("DOMContentLoaded", function () { }; var renderToken = 0; - function escapeStringRegExp(str) { - const m = /[|\\{}()[\]^$+*?.]/g; - return str.replace(m, "\\$&"); - } - function matchSiteRule(url, siteRules) { - if (!url || !Array.isArray(siteRules)) return null; - for (var i = 0; i < siteRules.length; i++) { - var rule = siteRules[i]; - if (!rule || !rule.pattern) continue; - var pattern = rule.pattern.replace(regStrip, ""); - if (pattern.length === 0) continue; - var re; - if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) { - try { - var ls = pattern.lastIndexOf("/"); - re = new RegExp(pattern.substring(1, ls), pattern.substring(ls + 1)); - } catch (e) { - continue; - } - } else { - re = new RegExp(escapeStringRegExp(pattern)); - } - if (re && re.test(url)) return rule; - } - return null; + return siteRuleUtils.matchSiteRule(url, siteRules); } function isSiteRuleDisabled(rule) { - return Boolean( - rule && - (rule.enabled === false || rule.disableExtension === true) - ); + return siteRuleUtils.isSiteRuleDisabled(rule); } function resolvePopupButtons(storage, siteRule) { - function sanitize(buttons) { - if (!Array.isArray(buttons)) return []; - var seen = new Set(); - return buttons.filter(function (id) { - if (!controllerButtonDefs[id] || popupExcludedButtonIds.has(id) || seen.has(id)) { - return false; - } - seen.add(id); - return true; - }); - } - - if (siteRule && Array.isArray(siteRule.popupControllerButtons)) { - return sanitize(siteRule.popupControllerButtons); - } - - if (storage.popupMatchHoverControls) { - if (siteRule && Array.isArray(siteRule.controllerButtons)) { - return sanitize(siteRule.controllerButtons); - } - - if (Array.isArray(storage.controllerButtons)) { - return sanitize(storage.controllerButtons); - } - } - - if (Array.isArray(storage.popupControllerButtons)) { - return sanitize(storage.popupControllerButtons); - } - - return sanitize(defaultButtons); + return popupControlUtils.resolvePopupButtons(storage, siteRule, { + controllerButtonDefs: controllerButtonDefs, + defaultButtons: defaultButtons, + excludedIds: popupExcludedButtonIds + }); } function setControlBarVisible(visible) { @@ -165,23 +115,7 @@ document.addEventListener("DOMContentLoaded", function () { } function pickBestFrameSpeedResult(results) { - if (!results || !results.length) return null; - var i; - var r; - var fallback = null; - for (i = 0; i < results.length; i++) { - r = results[i]; - if (!r || typeof r.speed !== "number") { - continue; - } - if (r.preferred) { - return { speed: r.speed }; - } - if (!fallback) { - fallback = { speed: r.speed }; - } - } - return fallback; + return popupControlUtils.pickBestFrameSpeedResult(results); } function querySpeed() { diff --git a/shared/controller-utils.js b/shared/controller-utils.js new file mode 100644 index 0000000..6988b70 --- /dev/null +++ b/shared/controller-utils.js @@ -0,0 +1,55 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.controllerUtils = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + var CONTROLLER_MARGIN_MAX_PX = 200; + var controllerLocations = [ + "top-left", + "top-center", + "top-right", + "middle-right", + "bottom-right", + "bottom-center", + "bottom-left", + "middle-left" + ]; + var defaultControllerLocation = controllerLocations[0]; + + function normalizeControllerLocation(location, fallback) { + if (controllerLocations.includes(location)) return location; + return typeof fallback === "string" + ? fallback + : defaultControllerLocation; + } + + function clampControllerMarginPx(value, fallback) { + var numericValue = Number(value); + if (!Number.isFinite(numericValue)) return fallback; + + return Math.min( + CONTROLLER_MARGIN_MAX_PX, + Math.max(0, Math.round(numericValue)) + ); + } + + function getNextControllerLocation(location) { + var normalizedLocation = normalizeControllerLocation(location); + var currentIndex = controllerLocations.indexOf(normalizedLocation); + return controllerLocations[(currentIndex + 1) % controllerLocations.length]; + } + + return { + CONTROLLER_MARGIN_MAX_PX: CONTROLLER_MARGIN_MAX_PX, + clampControllerMarginPx: clampControllerMarginPx, + controllerLocations: controllerLocations.slice(), + defaultControllerLocation: defaultControllerLocation, + getNextControllerLocation: getNextControllerLocation, + normalizeControllerLocation: normalizeControllerLocation + }; +}); diff --git a/shared/import-export.js b/shared/import-export.js new file mode 100644 index 0000000..507d5f0 --- /dev/null +++ b/shared/import-export.js @@ -0,0 +1,85 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.importExport = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + function generateBackupFilename(now) { + var date = now instanceof Date ? now : new Date(now || Date.now()); + var year = date.getFullYear(); + var month = String(date.getMonth() + 1).padStart(2, "0"); + var day = String(date.getDate()).padStart(2, "0"); + var hours = String(date.getHours()).padStart(2, "0"); + var minutes = String(date.getMinutes()).padStart(2, "0"); + var seconds = String(date.getSeconds()).padStart(2, "0"); + + return ( + "speeder-backup_" + + year + + "-" + + month + + "-" + + day + + "_" + + hours + + "." + + minutes + + "." + + seconds + + ".json" + ); + } + + function buildBackupPayload(settings, localSettings, now) { + return { + version: "1.1", + exportDate: new Date(now || Date.now()).toISOString(), + settings: settings, + localSettings: localSettings || {} + }; + } + + function extractImportSettings(backup) { + var settingsToImport = null; + var isWrappedBackup = false; + + if (backup && backup.settings && typeof backup.settings === "object") { + settingsToImport = backup.settings; + isWrappedBackup = true; + } else if ( + backup && + typeof backup === "object" && + (backup.keyBindings || backup.rememberSpeed !== undefined) + ) { + settingsToImport = backup; + } + + if (!settingsToImport) return null; + + return { + isWrappedBackup: isWrappedBackup, + settings: settingsToImport, + localSettings: + backup && + backup.localSettings && + typeof backup.localSettings === "object" + ? backup.localSettings + : null + }; + } + + function parseImportText(text) { + return extractImportSettings(JSON.parse(text)); + } + + return { + buildBackupPayload: buildBackupPayload, + extractImportSettings: extractImportSettings, + generateBackupFilename: generateBackupFilename, + parseImportText: parseImportText + }; +}); diff --git a/shared/key-bindings.js b/shared/key-bindings.js new file mode 100644 index 0000000..ec4faf3 --- /dev/null +++ b/shared/key-bindings.js @@ -0,0 +1,122 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.keyBindings = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + function normalizeBindingKey(key) { + if (typeof key !== "string" || key.length === 0) return null; + if (key === "Spacebar") return " "; + if (key === "Esc") return "Escape"; + if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase(); + return key; + } + + function getLegacyKeyCode(binding) { + if (!binding) return null; + if (Number.isInteger(binding.keyCode)) return binding.keyCode; + if (typeof binding.key === "number" && Number.isInteger(binding.key)) { + return binding.key; + } + return null; + } + + function legacyBindingKeyToCode(key) { + var normalizedKey = normalizeBindingKey(key); + if (!normalizedKey) return null; + if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey; + if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey; + if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey; + + var keyMap = { + " ": "Space", + ArrowLeft: "ArrowLeft", + ArrowUp: "ArrowUp", + ArrowRight: "ArrowRight", + ArrowDown: "ArrowDown", + ";": "Semicolon", + "<": "Comma", + "-": "Minus", + "+": "Equal", + ">": "Period", + "/": "Slash", + "~": "Backquote", + "[": "BracketLeft", + "\\": "Backslash", + "]": "BracketRight", + "'": "Quote" + }; + + return keyMap[normalizedKey] || null; + } + + function legacyKeyCodeToCode(keyCode) { + if (!Number.isInteger(keyCode)) return null; + if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode); + if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode); + if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96); + if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111); + + var keyCodeMap = { + 32: "Space", + 37: "ArrowLeft", + 38: "ArrowUp", + 39: "ArrowRight", + 40: "ArrowDown", + 106: "NumpadMultiply", + 107: "NumpadAdd", + 109: "NumpadSubtract", + 110: "NumpadDecimal", + 111: "NumpadDivide", + 186: "Semicolon", + 188: "Comma", + 189: "Minus", + 187: "Equal", + 190: "Period", + 191: "Slash", + 192: "Backquote", + 219: "BracketLeft", + 220: "Backslash", + 221: "BracketRight", + 222: "Quote", + 59: "Semicolon", + 61: "Equal", + 173: "Minus" + }; + + return keyCodeMap[keyCode] || null; + } + + function inferBindingCode(binding, fallbackCode) { + if (binding && typeof binding.code === "string" && binding.code.length > 0) { + return binding.code; + } + + if (binding && typeof binding.key === "string") { + var codeFromKey = legacyBindingKeyToCode(binding.key); + if (codeFromKey) return codeFromKey; + } + + var legacyKeyCode = getLegacyKeyCode(binding); + if (Number.isInteger(legacyKeyCode)) { + var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode); + if (codeFromKeyCode) return codeFromKeyCode; + } + + return typeof fallbackCode === "string" && fallbackCode.length > 0 + ? fallbackCode + : null; + } + + return { + getLegacyKeyCode: getLegacyKeyCode, + inferBindingCode: inferBindingCode, + legacyBindingKeyToCode: legacyBindingKeyToCode, + legacyKeyCodeToCode: legacyKeyCodeToCode, + normalizeBindingKey: normalizeBindingKey + }; +}); diff --git a/shared/popup-controls.js b/shared/popup-controls.js new file mode 100644 index 0000000..c85e4e5 --- /dev/null +++ b/shared/popup-controls.js @@ -0,0 +1,85 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.popupControls = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + function normalizeExcludedIds(excludedIds) { + if (excludedIds instanceof Set) return excludedIds; + return new Set(Array.isArray(excludedIds) ? excludedIds : []); + } + + function sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds) { + if (!Array.isArray(buttonIds)) return []; + + var seen = new Set(); + var excluded = normalizeExcludedIds(excludedIds); + + return buttonIds.filter(function(id) { + if (!controllerButtonDefs[id] || excluded.has(id) || seen.has(id)) { + return false; + } + + seen.add(id); + return true; + }); + } + + function resolvePopupButtons(storage, siteRule, options) { + var settings = storage || {}; + var config = options || {}; + var controllerButtonDefs = config.controllerButtonDefs || {}; + var defaultButtons = Array.isArray(config.defaultButtons) + ? config.defaultButtons + : []; + var excludedIds = config.excludedIds; + + function sanitize(buttonIds) { + return sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds); + } + + if (siteRule && Array.isArray(siteRule.popupControllerButtons)) { + return sanitize(siteRule.popupControllerButtons); + } + + if (settings.popupMatchHoverControls) { + if (siteRule && Array.isArray(siteRule.controllerButtons)) { + return sanitize(siteRule.controllerButtons); + } + + if (Array.isArray(settings.controllerButtons)) { + return sanitize(settings.controllerButtons); + } + } + + if (Array.isArray(settings.popupControllerButtons)) { + return sanitize(settings.popupControllerButtons); + } + + return sanitize(defaultButtons); + } + + function pickBestFrameSpeedResult(results) { + if (!results || !results.length) return null; + + var fallback = null; + for (var i = 0; i < results.length; i++) { + var result = results[i]; + if (!result || typeof result.speed !== "number") continue; + if (result.preferred) return { speed: result.speed }; + if (!fallback) fallback = { speed: result.speed }; + } + + return fallback; + } + + return { + pickBestFrameSpeedResult: pickBestFrameSpeedResult, + resolvePopupButtons: resolvePopupButtons, + sanitizeButtonOrder: sanitizeButtonOrder + }; +}); diff --git a/shared/site-rules.js b/shared/site-rules.js new file mode 100644 index 0000000..1d327f1 --- /dev/null +++ b/shared/site-rules.js @@ -0,0 +1,69 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.siteRules = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; + + function escapeStringRegExp(str) { + return String(str).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"); + } + + function compileSiteRulePattern(pattern) { + if (typeof pattern !== "string") return null; + + var normalizedPattern = pattern.replace(regStrip, ""); + if (normalizedPattern.length === 0) return null; + + if ( + normalizedPattern.startsWith("/") && + normalizedPattern.lastIndexOf("/") > 0 + ) { + var lastSlash = normalizedPattern.lastIndexOf("/"); + return new RegExp( + normalizedPattern.substring(1, lastSlash), + normalizedPattern.substring(lastSlash + 1) + ); + } + + return new RegExp(escapeStringRegExp(normalizedPattern)); + } + + function matchSiteRule(url, siteRules) { + if (!url || !Array.isArray(siteRules)) return null; + + for (var i = 0; i < siteRules.length; i++) { + var rule = siteRules[i]; + if (!rule || !rule.pattern) continue; + + try { + var re = compileSiteRulePattern(rule.pattern); + if (re && re.test(url)) { + return rule; + } + } catch (e) { + } + } + + return null; + } + + function isSiteRuleDisabled(rule) { + return Boolean( + rule && + (rule.enabled === false || rule.disableExtension === true) + ); + } + + return { + compileSiteRulePattern: compileSiteRulePattern, + escapeStringRegExp: escapeStringRegExp, + isSiteRuleDisabled: isSiteRuleDisabled, + matchSiteRule: matchSiteRule + }; +}); diff --git a/tests/helpers/browser.js b/tests/helpers/browser.js new file mode 100644 index 0000000..bf864bf --- /dev/null +++ b/tests/helpers/browser.js @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { vi } from "vitest"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, "..", ".."); + +function readRepoFile(relPath) { + return fs.readFileSync(path.join(repoRoot, relPath), "utf8"); +} + +export function loadHtml(relPath) { + document.open(); + document.write(readRepoFile(relPath)); + document.close(); +} + +export function loadScript(relPath) { + window.eval( + "var chrome = window.chrome || globalThis.chrome;\n" + + readRepoFile(relPath) + + "\n//# sourceURL=" + + relPath + ); +} + +export async function flushAsyncWork() { + await Promise.resolve(); + await Promise.resolve(); +} + +export function triggerDomContentLoaded() { + document.dispatchEvent( + new window.Event("DOMContentLoaded", { + bubbles: true, + cancelable: true + }) + ); +} + +function createEvent() { + const listeners = []; + return { + addListener(listener) { + listeners.push(listener); + }, + trigger(...args) { + listeners.forEach((listener) => listener(...args)); + }, + listeners + }; +} + +function createStorageArea(initialState = {}) { + const state = { ...initialState }; + + function resolveGet(keys) { + if (keys == null) return { ...state }; + if (Array.isArray(keys)) { + return keys.reduce((acc, key) => { + if (Object.prototype.hasOwnProperty.call(state, key)) { + acc[key] = state[key]; + } + return acc; + }, {}); + } + if (typeof keys === "string") { + return Object.prototype.hasOwnProperty.call(state, keys) + ? { [keys]: state[keys] } + : {}; + } + if (typeof keys === "object") { + const result = { ...keys }; + Object.keys(state).forEach((key) => { + result[key] = state[key]; + }); + return result; + } + return {}; + } + + return { + __state: state, + get: vi.fn((keys, callback) => { + callback(resolveGet(keys)); + }), + set: vi.fn((items, callback) => { + Object.assign(state, items); + if (callback) callback(); + }), + remove: vi.fn((keys, callback) => { + const list = Array.isArray(keys) ? keys : [keys]; + list.forEach((key) => { + delete state[key]; + }); + if (callback) callback(); + }), + clear: vi.fn((callback) => { + Object.keys(state).forEach((key) => delete state[key]); + if (callback) callback(); + }) + }; +} + +export function createChromeMock(options = {}) { + const syncArea = createStorageArea(options.sync ?? {}); + const localArea = createStorageArea(options.local ?? {}); + const tabsOnActivated = createEvent(); + const tabsOnUpdated = createEvent(); + const storageOnChanged = createEvent(); + + const chrome = { + runtime: { + lastError: null, + getManifest: vi.fn(() => ({ + version: options.manifestVersion || "9.9.9" + })), + getURL: vi.fn((url) => "moz-extension://speeder/" + url) + }, + storage: { + sync: syncArea, + local: localArea, + onChanged: storageOnChanged + }, + tabs: { + query: vi.fn((queryInfo, callback) => { + callback( + options.tabs ?? + [ + { + id: 1, + active: true, + url: "https://example.com/watch" + } + ] + ); + }), + sendMessage: vi.fn((tabId, message, callback) => { + if (callback) { + callback(options.sendMessageResponse ?? { speed: 1.25 }); + } + }), + executeScript: vi.fn((tabId, details, callback) => { + if (callback) { + callback( + options.executeScriptResponse ?? [ + { speed: 1.25, preferred: true } + ] + ); + } + }), + create: vi.fn(), + onActivated: tabsOnActivated, + onUpdated: tabsOnUpdated + }, + browserAction: { + setIcon: vi.fn() + } + }; + + return chrome; +} diff --git a/tests/helpers/extension-test-utils.js b/tests/helpers/extension-test-utils.js new file mode 100644 index 0000000..dba6668 --- /dev/null +++ b/tests/helpers/extension-test-utils.js @@ -0,0 +1,240 @@ +const fs = require("fs"); +const path = require("path"); +const { vi } = require("vitest"); + +const ROOT = path.resolve(__dirname, "..", ".."); + +function clone(value) { + if (value === undefined) return undefined; + return JSON.parse(JSON.stringify(value)); +} + +function workspacePath(relPath) { + return path.join(ROOT, relPath); +} + +function readWorkspaceFile(relPath) { + return fs.readFileSync(workspacePath(relPath), "utf8"); +} + +function loadHtmlFile(relPath) { + document.open(); + document.write(readWorkspaceFile(relPath)); + document.close(); +} + +function loadHtmlString(html) { + document.open(); + document.write(html); + document.close(); +} + +function evaluateScript(relPath) { + const source = readWorkspaceFile(relPath); + window.eval( + `${source}\n//# sourceURL=${workspacePath(relPath).replace(/\\/g, "/")}` + ); +} + +function fireDOMContentLoaded() { + document.dispatchEvent( + new window.Event("DOMContentLoaded", { + bubbles: true, + cancelable: true + }) + ); +} + +async function flushAsyncWork(turns) { + const count = turns || 2; + for (let i = 0; i < count; i += 1) { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function pickStorageValues(data, keys) { + if (keys == null) return clone(data || {}); + + if (typeof keys === "string") { + return { [keys]: clone(data ? data[keys] : undefined) }; + } + + if (Array.isArray(keys)) { + const result = {}; + keys.forEach((key) => { + result[key] = clone(data ? data[key] : undefined); + }); + return result; + } + + if (typeof keys === "object") { + const result = clone(keys) || {}; + Object.keys(keys).forEach((key) => { + if (data && Object.prototype.hasOwnProperty.call(data, key)) { + result[key] = clone(data[key]); + } + }); + return result; + } + + return {}; +} + +function createChromeEvent() { + const listeners = []; + return { + addListener(listener) { + listeners.push(listener); + }, + removeListener(listener) { + const index = listeners.indexOf(listener); + if (index >= 0) { + listeners.splice(index, 1); + } + }, + hasListener(listener) { + return listeners.includes(listener); + }, + emit(...args) { + listeners.slice().forEach((listener) => listener(...args)); + }, + listeners + }; +} + +function createStorageArea(areaName, initialData, onChangedEvent) { + let data = clone(initialData) || {}; + + function emitChanges(changes) { + if (changes && Object.keys(changes).length > 0) { + onChangedEvent.emit(changes, areaName); + } + } + + return { + get: vi.fn((keys, callback) => { + if (callback) callback(pickStorageValues(data, keys)); + }), + set: vi.fn((items, callback) => { + const nextItems = items || {}; + const changes = {}; + + Object.keys(nextItems).forEach((key) => { + const oldValue = clone(data[key]); + const newValue = clone(nextItems[key]); + data[key] = newValue; + changes[key] = { oldValue, newValue }; + }); + + emitChanges(changes); + if (callback) callback(); + }), + remove: vi.fn((keys, callback) => { + const list = Array.isArray(keys) ? keys : [keys]; + const changes = {}; + + list.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(data, key)) { + changes[key] = { + oldValue: clone(data[key]), + newValue: undefined + }; + delete data[key]; + } + }); + + emitChanges(changes); + if (callback) callback(); + }), + clear: vi.fn((callback) => { + const changes = {}; + Object.keys(data).forEach((key) => { + changes[key] = { + oldValue: clone(data[key]), + newValue: undefined + }; + }); + data = {}; + emitChanges(changes); + if (callback) callback(); + }), + _dump() { + return clone(data); + } + }; +} + +function createChromeMock(options) { + const config = options || {}; + const storageOnChanged = createChromeEvent(); + const tabsOnActivated = createChromeEvent(); + const tabsOnUpdated = createChromeEvent(); + const runtimeOnMessage = createChromeEvent(); + + const chrome = { + runtime: { + lastError: null, + getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }), + getURL: vi.fn((relPath) => `moz-extension://${relPath}`), + onMessage: runtimeOnMessage + }, + browserAction: { + setIcon: vi.fn() + }, + tabs: { + query: vi.fn((queryInfo, callback) => { + const tabs = clone(config.tabsQueryResult) || [ + { id: 1, active: true, url: "https://example.com/" } + ]; + if (callback) callback(tabs); + }), + sendMessage: vi.fn((tabId, message, callback) => { + if (callback) callback(null); + }), + executeScript: vi.fn((tabId, details, callback) => { + if (callback) callback([]); + }), + create: vi.fn(), + onActivated: tabsOnActivated, + onUpdated: tabsOnUpdated + }, + storage: { + onChanged: storageOnChanged, + sync: null, + local: null + } + }; + + chrome.storage.sync = createStorageArea( + "sync", + config.syncData, + storageOnChanged + ); + chrome.storage.local = createStorageArea( + "local", + config.localData, + storageOnChanged + ); + + return chrome; +} + +function installCommonWindowMocks() { + window.open = vi.fn(); + window.close = vi.fn(); + window.requestAnimationFrame = vi.fn((callback) => setTimeout(callback, 0)); + window.cancelAnimationFrame = vi.fn((id) => clearTimeout(id)); +} + +module.exports = { + createChromeMock, + evaluateScript, + fireDOMContentLoaded, + flushAsyncWork, + installCommonWindowMocks, + loadHtmlFile, + loadHtmlString, + readWorkspaceFile, + workspacePath +}; diff --git a/tests/importExport.integration.test.js b/tests/importExport.integration.test.js new file mode 100644 index 0000000..f934be5 --- /dev/null +++ b/tests/importExport.integration.test.js @@ -0,0 +1,224 @@ +import { + createChromeMock, + flushAsyncWork, + loadHtml, + loadScript +} from "./helpers/browser.js"; + +async function setupImportExport(overrides = {}) { + loadHtml("options.html"); + globalThis.chrome = createChromeMock(overrides); + window.chrome = globalThis.chrome; + globalThis.restore_options = vi.fn(); + loadScript("shared/import-export.js"); + loadScript("importExport.js"); + await flushAsyncWork(); + return globalThis.chrome; +} + +describe("import/export flows", () => { + it("exports sync and local settings as a JSON download", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10)); + const chrome = await setupImportExport({ + sync: { rememberSpeed: true }, + local: { customButtonIcons: { faster: { slug: "rocket" } } } + }); + const OriginalBlob = globalThis.Blob; + globalThis.Blob = class TestBlob { + constructor(parts, options) { + this.parts = parts; + this.options = options; + } + + async text() { + return this.parts.join(""); + } + }; + + let capturedBlob = null; + let clickedDownload = null; + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: vi.fn((blob) => { + capturedBlob = blob; + return "blob:test"; + }) + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: vi.fn(() => {}) + }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function () { + clickedDownload = this.download; + }); + + document.getElementById("exportSettings").click(); + + expect(clickedDownload).toBe("speeder-backup_2026-04-04_08.09.10.json"); + expect(capturedBlob).not.toBeNull(); + const blobText = await capturedBlob.text(); + expect(JSON.parse(blobText)).toEqual({ + version: "1.1", + exportDate: "2026-04-04T12:09:10.000Z", + settings: { rememberSpeed: true }, + localSettings: { customButtonIcons: { faster: { slug: "rocket" } } } + }); + expect(document.getElementById("status").textContent).toBe( + "Settings exported successfully" + ); + expect(chrome.storage.sync.get).toHaveBeenCalled(); + expect(chrome.storage.local.get).toHaveBeenCalled(); + globalThis.Blob = OriginalBlob; + }); + + it("imports wrapped backup payloads and refreshes options", async () => { + vi.useFakeTimers(); + const chrome = await setupImportExport(); + + const originalCreateElement = document.createElement.bind(document); + let createdInput = null; + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + const el = originalCreateElement(tagName); + if (tagName === "input") { + createdInput = el; + el.click = vi.fn(); + } + return el; + }); + + globalThis.FileReader = class MockFileReader { + readAsText(file) { + this.onload({ + target: { + result: file.__text + } + }); + } + }; + + globalThis.importSettings(); + createdInput.onchange({ + target: { + files: [ + { + __text: JSON.stringify({ + settings: { rememberSpeed: true }, + localSettings: { customButtonIcons: { faster: { slug: "rocket" } } } + }) + } + ] + } + }); + + expect(chrome.storage.local.set).toHaveBeenCalledWith( + { customButtonIcons: { faster: { slug: "rocket" } } }, + expect.any(Function) + ); + expect(chrome.storage.sync.clear).toHaveBeenCalled(); + expect(chrome.storage.sync.set).toHaveBeenCalledWith( + { rememberSpeed: true }, + expect.any(Function) + ); + expect(document.getElementById("status").textContent).toBe( + "Settings imported successfully. Reloading..." + ); + + vi.advanceTimersByTime(500); + expect(globalThis.restore_options).toHaveBeenCalled(); + }); + + it("clears stale local data when a wrapped backup has empty local settings", async () => { + vi.useFakeTimers(); + const chrome = await setupImportExport({ + local: { + customButtonIcons: { faster: { slug: "rocket" } }, + lucideTagsCacheV1: { stale: true } + } + }); + + const originalCreateElement = document.createElement.bind(document); + let createdInput = null; + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + const el = originalCreateElement(tagName); + if (tagName === "input") { + createdInput = el; + el.click = vi.fn(); + } + return el; + }); + + globalThis.FileReader = class MockFileReader { + readAsText(file) { + this.onload({ + target: { + result: file.__text + } + }); + } + }; + + globalThis.importSettings(); + createdInput.onchange({ + target: { + files: [ + { + __text: JSON.stringify({ + settings: { rememberSpeed: true }, + localSettings: {} + }) + } + ] + } + }); + + expect(chrome.storage.local.clear).toHaveBeenCalled(); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + expect(chrome.storage.sync.set).toHaveBeenCalledWith( + { rememberSpeed: true }, + expect.any(Function) + ); + }); + + it("shows an error for invalid backup files", async () => { + vi.useFakeTimers(); + const chrome = await setupImportExport(); + + const originalCreateElement = document.createElement.bind(document); + let createdInput = null; + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + const el = originalCreateElement(tagName); + if (tagName === "input") { + createdInput = el; + el.click = vi.fn(); + } + return el; + }); + + globalThis.FileReader = class MockFileReader { + readAsText(file) { + this.onload({ + target: { + result: file.__text + } + }); + } + }; + + globalThis.importSettings(); + createdInput.onchange({ + target: { + files: [ + { + __text: JSON.stringify({ enabled: true }) + } + ] + } + }); + + expect(document.getElementById("status").textContent).toBe( + "Error: Invalid backup file format" + ); + expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/importExport.spec.js b/tests/importExport.spec.js new file mode 100644 index 0000000..8694b31 --- /dev/null +++ b/tests/importExport.spec.js @@ -0,0 +1,189 @@ +const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest"); +const { + createChromeMock, + evaluateScript, + flushAsyncWork, + installCommonWindowMocks, + loadHtmlString +} = require("./helpers/extension-test-utils"); + +function bootImportExport(options) { + const config = options || {}; + + loadHtmlString(` + + +
+ `); + + installCommonWindowMocks(); + + const chrome = createChromeMock({ + syncData: config.syncData, + localData: config.localData + }); + + global.chrome = chrome; + window.chrome = chrome; + + const createObjectURL = vi.fn(() => "blob:test"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal("URL", { + createObjectURL, + revokeObjectURL + }); + + evaluateScript("importExport.js"); + return { chrome, createObjectURL, revokeObjectURL }; +} + +describe("importExport.js", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + delete global.chrome; + }); + + it("generates timestamped backup filenames", () => { + vi.setSystemTime(new Date("2026-04-04T13:14:15Z")); + bootImportExport(); + + expect(window.generateBackupFilename()).toBe( + "speeder-backup_2026-04-04_13.14.15.json" + ); + }); + + it("exports sync and local settings into a downloadable backup", async () => { + const clickSpy = vi + .spyOn(window.HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + const { createObjectURL, revokeObjectURL } = bootImportExport({ + syncData: { + rememberSpeed: true, + keyBindings: [{ action: "faster", code: "KeyD", value: 0.1 }] + }, + localData: { + customButtonIcons: { + faster: { slug: "rocket", svg: "" } + } + } + }); + + document.querySelector("#exportSettings").click(); + await flushAsyncWork(); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + const blob = createObjectURL.mock.calls[0][0]; + const backup = JSON.parse(await blob.text()); + + expect(backup.settings.rememberSpeed).toBe(true); + expect(backup.localSettings.customButtonIcons.faster.slug).toBe("rocket"); + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:test"); + expect(document.querySelector("#status").textContent).toContain("exported"); + }); + + it("imports wrapped backups, restores local data, and refreshes the options page", async () => { + const { chrome } = bootImportExport(); + window.restore_options = vi.fn(); + + const realCreateElement = document.createElement.bind(document); + const fakeInput = realCreateElement("input"); + Object.defineProperty(fakeInput, "files", { + configurable: true, + value: [ + { + __contents: JSON.stringify({ + settings: { + rememberSpeed: true, + enabled: false + }, + localSettings: { + customButtonIcons: { + faster: { slug: "rocket", svg: "" } + } + } + }) + } + ] + }); + fakeInput.click = vi.fn(() => { + fakeInput.onchange({ target: fakeInput }); + }); + + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + if (String(tagName).toLowerCase() === "input") { + return fakeInput; + } + return realCreateElement(tagName); + }); + + class FakeFileReader { + readAsText(file) { + this.onload({ target: { result: file.__contents } }); + } + } + + vi.stubGlobal("FileReader", FakeFileReader); + + document.querySelector("#importSettings").click(); + await flushAsyncWork(); + + expect(chrome.storage.local.set).toHaveBeenCalledWith( + { + customButtonIcons: { + faster: { slug: "rocket", svg: "" } + } + }, + expect.any(Function) + ); + expect(chrome.storage.sync.clear).toHaveBeenCalled(); + expect(chrome.storage.sync.set).toHaveBeenCalledWith( + { rememberSpeed: true, enabled: false }, + expect.any(Function) + ); + + vi.advanceTimersByTime(500); + expect(window.restore_options).toHaveBeenCalled(); + }); + + it("shows an error for malformed backups", async () => { + bootImportExport(); + + const realCreateElement = document.createElement.bind(document); + const fakeInput = realCreateElement("input"); + Object.defineProperty(fakeInput, "files", { + configurable: true, + value: [{ __contents: "{bad json" }] + }); + fakeInput.click = vi.fn(() => { + fakeInput.onchange({ target: fakeInput }); + }); + + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + if (String(tagName).toLowerCase() === "input") { + return fakeInput; + } + return realCreateElement(tagName); + }); + + class FakeFileReader { + readAsText(file) { + this.onload({ target: { result: file.__contents } }); + } + } + + vi.stubGlobal("FileReader", FakeFileReader); + + document.querySelector("#importSettings").click(); + await flushAsyncWork(); + + expect(document.querySelector("#status").textContent).toContain( + "Failed to parse backup file" + ); + }); +}); diff --git a/tests/inject.spec.js b/tests/inject.spec.js new file mode 100644 index 0000000..95006f2 --- /dev/null +++ b/tests/inject.spec.js @@ -0,0 +1,141 @@ +const { afterEach, describe, expect, it, vi } = require("vitest"); +const { + createChromeMock, + evaluateScript, + flushAsyncWork, + loadHtmlString +} = require("./helpers/extension-test-utils"); + +function bootInject(options) { + const config = options || {}; + + loadHtmlString(""); + + const chrome = createChromeMock({ + syncData: config.syncData, + localData: config.localData + }); + + global.chrome = chrome; + window.chrome = chrome; + window.requestIdleCallback = (callback, opts) => + setTimeout( + () => + callback({ + didTimeout: false, + timeRemaining() { + return 1; + } + }), + (opts && opts.timeout) || 0 + ); + window.cancelIdleCallback = (id) => clearTimeout(id); + + evaluateScript("ui-icons.js"); + evaluateScript("inject.js"); + + return chrome; +} + +describe("inject.js helper logic", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + delete global.chrome; + }); + + it("normalizes bindings from legacy formats", async () => { + bootInject(); + await flushAsyncWork(3); + + expect( + window.normalizeStoredBinding({ + action: "faster", + key: "g", + value: 1.8, + force: false + }).code + ).toBe("KeyG"); + + expect( + window.normalizeStoredBinding({ + action: "pause", + code: null, + key: null, + keyCode: null, + value: 0 + }) + ).toEqual({ + action: "pause", + code: null, + disabled: true, + value: 0, + force: "false", + predefined: false + }); + + expect(window.defaultKeyBindings({ speedStep: 0.25, rewindTime: 5 })[0]).toEqual( + { + action: "slower", + code: "KeyS", + value: 0.25, + force: false, + predefined: true + } + ); + }); + + it("clamps controller margins and ignores stale source-specific target speeds", async () => { + bootInject(); + await flushAsyncWork(3); + + expect(window.normalizeControllerMarginPx(250, 0)).toBe(200); + expect(window.normalizeControllerMarginPx(-5, 65)).toBe(0); + expect(window.normalizeControllerMarginPx("bad", 65)).toBe(65); + + const staleVideo = { + currentSrc: "fresh.mp4", + vsc: { + targetSpeed: 1.75, + targetSpeedSourceKey: "old.mp4" + } + }; + + expect(window.getControllerTargetSpeed(staleVideo)).toBeNull(); + + window.tc.settings.rememberSpeed = true; + window.tc.settings.forceLastSavedSpeed = false; + window.tc.settings.lastSpeed = 1.3; + window.tc.settings.speeds = { "fresh.mp4": 1.6 }; + + expect(window.getRememberedSpeed({ currentSrc: "fresh.mp4" })).toBe(1.6); + expect(window.getDesiredSpeed(staleVideo)).toBe(1.6); + }); + + it("applies site rule overrides and detects disabled sites", async () => { + bootInject(); + await flushAsyncWork(3); + + window.tc.settings.siteRules = [{ pattern: "localhost", enabled: false }]; + window.captureSiteRuleBase(); + expect(window.applySiteRuleOverrides()).toBe(true); + + window.resetSettingsFromSiteRuleBase(); + window.tc.settings.siteRules = [ + { + pattern: "localhost", + controllerLocation: "bottom-left", + controllerMarginTop: 300, + controllerMarginBottom: -10, + rememberSpeed: true + } + ]; + window.captureSiteRuleBase(); + + expect(window.applySiteRuleOverrides()).toBe(false); + expect(window.tc.settings.controllerLocation).toBe("bottom-left"); + expect(window.tc.settings.controllerMarginTop).toBe(200); + expect(window.tc.settings.controllerMarginBottom).toBe(0); + expect(window.tc.settings.rememberSpeed).toBe(true); + }); +}); diff --git a/tests/lucide-client.spec.js b/tests/lucide-client.spec.js new file mode 100644 index 0000000..b5b439b --- /dev/null +++ b/tests/lucide-client.spec.js @@ -0,0 +1,61 @@ +const { afterEach, describe, expect, it } = require("vitest"); +const { + evaluateScript, + loadHtmlString +} = require("./helpers/extension-test-utils"); + +describe("lucide-client.js", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("builds icon URLs and rejects invalid slugs", () => { + loadHtmlString(""); + evaluateScript("ui-icons.js"); + evaluateScript("lucide-client.js"); + + expect(window.lucideIconSvgUrl("alarm-clock")).toContain( + "/icons/alarm-clock.svg" + ); + expect(window.lucideIconSvgUrl("bad slug!!")).toBe(""); + expect(window.lucideTagsJsonUrl()).toContain("/tags.json"); + }); + + it("sanitizes SVG before persisting a Lucide icon", () => { + loadHtmlString(""); + evaluateScript("ui-icons.js"); + evaluateScript("lucide-client.js"); + + const sanitized = window.sanitizeLucideSvg(` + + + bad + + + `); + + expect(sanitized).toContain(" { + loadHtmlString(""); + evaluateScript("ui-icons.js"); + evaluateScript("lucide-client.js"); + + const results = window.searchLucideSlugs( + { + alarm: ["clock", "time"], + badge-alert: ["alert", "warning"], + calendar: ["date", "time"] + }, + "al", + 10 + ); + + expect(results).toEqual(["alarm", "badge-alert", "calendar"]); + }); +}); diff --git a/tests/options.integration.test.js b/tests/options.integration.test.js new file mode 100644 index 0000000..4e14f13 --- /dev/null +++ b/tests/options.integration.test.js @@ -0,0 +1,140 @@ +import { + createChromeMock, + flushAsyncWork, + loadHtml, + loadScript, + triggerDomContentLoaded +} from "./helpers/browser.js"; + +async function setupOptions(overrides = {}) { + loadHtml("options.html"); + globalThis.chrome = createChromeMock(overrides); + window.chrome = globalThis.chrome; + globalThis.fetch = vi.fn(); + loadScript("shared/controller-utils.js"); + loadScript("shared/key-bindings.js"); + loadScript("shared/popup-controls.js"); + loadScript("ui-icons.js"); + loadScript("lucide-client.js"); + loadScript("options.js"); + triggerDomContentLoaded(); + await flushAsyncWork(); + return globalThis.chrome; +} + +describe("options page", () => { + it("restores stored settings, custom shortcuts, and site rules", async () => { + await setupOptions({ + manifestVersion: "5.1.7.0", + sync: { + rememberSpeed: true, + enabled: false, + popupMatchHoverControls: false, + popupControllerButtons: ["rewind", "settings", "advance", "advance"], + keyBindings: [ + { action: "display", code: "KeyV", value: 0, predefined: true }, + { action: "pause", code: "KeyQ", value: 0, predefined: false } + ], + siteRules: [ + { + pattern: "youtube.com", + enabled: true, + showPopupControlBar: false, + popupControllerButtons: ["advance", "settings", "advance"] + } + ] + } + }); + + expect(document.getElementById("app-version").textContent).toBe("5.1.7.0"); + expect(document.getElementById("rememberSpeed").checked).toBe(true); + expect(document.getElementById("enabled").checked).toBe(false); + expect(document.querySelector('.shortcut-row[data-action="pause"]')).not.toBe( + null + ); + expect(document.getElementById("siteRulesContainer").children.length).toBe( + 1 + ); + expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]); + }); + + it("validates site rule regexes before saving", async () => { + const chrome = await setupOptions(); + chrome.storage.sync.set.mockClear(); + globalThis.createSiteRule(null); + const rule = document.querySelector(".site-rule"); + rule.querySelector(".site-pattern").value = "/(/"; + + globalThis.save_options(); + + expect(document.getElementById("status").textContent).toContain( + "Invalid site rule regex" + ); + expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + }); + + it("saves normalized settings and sanitized popup/site-rule controls", async () => { + const chrome = await setupOptions(); + + document.getElementById("rememberSpeed").checked = true; + document.getElementById("hideWithControlsTimer").value = "20"; + document.getElementById("controllerOpacity").value = "0.55"; + document.getElementById("controllerMarginTop").value = "250"; + document.getElementById("controllerMarginBottom").value = "-4"; + document.getElementById("enableSubtitleNudge").checked = true; + document.getElementById("subtitleNudgeInterval").value = "5"; + document.getElementById("popupMatchHoverControls").checked = false; + document.getElementById("showPopupControlBar").checked = false; + + globalThis.populatePopupControlBarEditor([ + "rewind", + "settings", + "faster", + "faster" + ]); + + globalThis.createSiteRule(null); + const rule = document.querySelector(".site-rule"); + rule.querySelector(".site-pattern").value = "youtube.com"; + rule.querySelector(".override-playback").checked = true; + rule.querySelector(".site-rememberSpeed").checked = true; + rule.querySelector(".override-popup-controlbar").checked = true; + rule.querySelector(".site-showPopupControlBar").checked = false; + globalThis.populateControlBarZones( + rule.querySelector(".site-popup-cb-active"), + rule.querySelector(".site-popup-cb-available"), + ["advance", "settings", "advance"], + function (id) { + return id !== "settings"; + } + ); + + globalThis.save_options(); + + expect(chrome.storage.sync.remove).toHaveBeenCalled(); + const savedSettings = + chrome.storage.sync.set.mock.calls[ + chrome.storage.sync.set.mock.calls.length - 1 + ][0]; + + expect(savedSettings.rememberSpeed).toBe(true); + expect(savedSettings.hideWithControlsTimer).toBe(15); + expect(savedSettings.controllerOpacity).toBe(0.55); + expect(savedSettings.controllerMarginTop).toBe(200); + expect(savedSettings.controllerMarginBottom).toBe(0); + expect(savedSettings.subtitleNudgeInterval).toBe(10); + expect(savedSettings.showPopupControlBar).toBe(false); + expect(savedSettings.popupMatchHoverControls).toBe(false); + expect(savedSettings.popupControllerButtons).toEqual(["rewind", "faster"]); + expect(savedSettings.siteRules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pattern: "youtube.com", + rememberSpeed: true, + showPopupControlBar: false, + popupControllerButtons: ["advance"] + }) + ]) + ); + }); +}); diff --git a/tests/options.spec.js b/tests/options.spec.js new file mode 100644 index 0000000..766d2a3 --- /dev/null +++ b/tests/options.spec.js @@ -0,0 +1,230 @@ +const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest"); +const { + createChromeMock, + evaluateScript, + fireDOMContentLoaded, + flushAsyncWork, + installCommonWindowMocks, + loadHtmlFile +} = require("./helpers/extension-test-utils"); + +function bootOptions(options) { + const config = options || {}; + + loadHtmlFile("options.html"); + installCommonWindowMocks(); + + const chrome = createChromeMock({ + manifest: { version: "5.1.7.0" }, + syncData: config.syncData, + localData: config.localData + }); + + global.chrome = chrome; + window.chrome = chrome; + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + text: () => Promise.resolve("") + }) + ); + window.fetch = global.fetch; + + evaluateScript("ui-icons.js"); + evaluateScript("lucide-client.js"); + evaluateScript("options.js"); + fireDOMContentLoaded(); + + return chrome; +} + +describe("options.js", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + delete global.chrome; + delete global.fetch; + }); + + it("restores saved settings, bindings, site rules, and popup bar order", async () => { + bootOptions({ + syncData: { + rememberSpeed: true, + forceLastSavedSpeed: true, + controllerLocation: "middle-right", + controllerOpacity: 0.75, + controllerMarginTop: 22, + controllerMarginBottom: 14, + popupMatchHoverControls: false, + controllerButtons: ["rewind", "fast", "display"], + popupControllerButtons: ["advance", "settings", "rewind", "advance"], + keyBindings: [ + { action: "display", code: "KeyV", value: 0, predefined: true }, + { action: "pause", code: "KeyQ", value: 0, predefined: false } + ], + siteRules: [ + { + pattern: "youtube.com", + enabled: false, + controllerMarginTop: 12, + popupControllerButtons: ["advance", "settings", "rewind"] + } + ] + } + }); + + await flushAsyncWork(3); + + expect(document.getElementById("app-version").textContent).toBe("5.1.7.0"); + expect(document.getElementById("rememberSpeed").checked).toBe(true); + expect(document.getElementById("forceLastSavedSpeed").checked).toBe(true); + expect(document.getElementById("controllerLocation").value).toBe( + "middle-right" + ); + expect(document.getElementById("controllerOpacity").value).toBe("0.75"); + expect(document.getElementById("controllerMarginTop").value).toBe("22"); + expect(document.getElementById("popupMatchHoverControls").checked).toBe(false); + expect( + document.getElementById("popupCbEditorWrap").classList.contains( + "cb-editor-disabled" + ) + ).toBe(false); + + const popupButtons = Array.from( + document.querySelectorAll("#popupControlBarActive .cb-block") + ).map((block) => block.dataset.buttonId); + expect(popupButtons).toEqual(["advance", "rewind"]); + + expect( + document.querySelector('.shortcut-row.customs[data-action="pause"]') + ).not.toBeNull(); + expect(document.querySelectorAll(".site-rule")).toHaveLength(1); + expect(document.querySelector(".site-rule .site-enabled").checked).toBe(false); + }); + + it("saves normalized settings and site rule overrides", async () => { + const chrome = bootOptions(); + await flushAsyncWork(3); + + document.getElementById("rememberSpeed").checked = true; + document.getElementById("hideWithControlsTimer").value = "99"; + document.getElementById("controllerLocation").value = "bottom-left"; + document.getElementById("controllerOpacity").value = "0.65"; + document.getElementById("controllerMarginTop").value = "250"; + document.getElementById("controllerMarginBottom").value = "-5"; + document.getElementById("popupMatchHoverControls").checked = false; + document.getElementById("showPopupControlBar").checked = true; + + window.populatePopupControlBarEditor(["advance", "settings", "rewind"]); + + window.createSiteRule({ pattern: "youtube.com" }); + const ruleEl = document.querySelector(".site-rule"); + ruleEl.querySelector(".override-placement").checked = true; + ruleEl.querySelector(".site-controllerLocation").value = "top-right"; + ruleEl.querySelector(".site-controllerMarginTop").value = "300"; + ruleEl.querySelector(".site-controllerMarginBottom").value = "-10"; + + ruleEl.querySelector(".override-autohide").checked = true; + ruleEl.querySelector(".site-hideWithControls").checked = true; + ruleEl.querySelector(".site-hideWithControlsTimer").value = "0"; + + ruleEl.querySelector(".override-popup-controlbar").checked = true; + ruleEl.querySelector(".site-showPopupControlBar").checked = false; + window.populateControlBarZones( + ruleEl.querySelector(".site-popup-cb-active"), + ruleEl.querySelector(".site-popup-cb-available"), + ["advance", "settings", "rewind"], + function (id) { + return id !== "settings"; + } + ); + + window.save_options(); + + expect(chrome.storage.sync.remove).toHaveBeenCalledWith( + [ + "resetSpeed", + "speedStep", + "fastSpeed", + "rewindTime", + "advanceTime", + "resetKeyCode", + "slowerKeyCode", + "fasterKeyCode", + "rewindKeyCode", + "advanceKeyCode", + "fastKeyCode", + "blacklist" + ], + expect.any(Function) + ); + + const savedSettings = chrome.storage.sync.set.mock.calls.at(-1)[0]; + expect(savedSettings.rememberSpeed).toBe(true); + expect(savedSettings.hideWithControlsTimer).toBe(15); + expect(savedSettings.controllerLocation).toBe("bottom-left"); + expect(savedSettings.controllerMarginTop).toBe(200); + expect(savedSettings.controllerMarginBottom).toBe(0); + expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]); + expect(savedSettings.siteRules).toEqual([ + { + pattern: "youtube.com", + enabled: true, + controllerLocation: "top-right", + controllerMarginTop: 200, + controllerMarginBottom: 0, + hideWithControls: true, + hideWithControlsTimer: 0.1, + showPopupControlBar: false, + popupControllerButtons: ["advance", "rewind"] + } + ]); + }); + + it("blocks save when a site rule regex is invalid", async () => { + const chrome = bootOptions(); + await flushAsyncWork(3); + + window.createSiteRule({ pattern: "/[abc/" }); + window.save_options(); + + expect(document.getElementById("status").textContent).toContain( + "Invalid site rule regex" + ); + expect(chrome.storage.sync.set).not.toHaveBeenCalled(); + }); + + it("adds shortcuts from the selector and records key input states", async () => { + bootOptions(); + await flushAsyncWork(3); + + const selector = document.getElementById("addShortcutSelector"); + selector.value = "pause"; + selector.dispatchEvent(new window.Event("change", { bubbles: true })); + + const row = document.querySelector('.shortcut-row.customs[data-action="pause"]'); + expect(row).not.toBeNull(); + + const keyInput = row.querySelector(".customKey"); + keyInput.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "q", + code: "KeyQ", + bubbles: true + }) + ); + expect(keyInput.vscBinding.code).toBe("KeyQ"); + expect(keyInput.value).toBe("Q"); + + keyInput.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "Escape", + code: "Escape", + bubbles: true + }) + ); + expect(keyInput.vscBinding.disabled).toBe(true); + expect(selector.disabled).toBe(false); + }); +}); diff --git a/tests/popup.integration.test.js b/tests/popup.integration.test.js new file mode 100644 index 0000000..9c79aa9 --- /dev/null +++ b/tests/popup.integration.test.js @@ -0,0 +1,121 @@ +import { + createChromeMock, + flushAsyncWork, + loadHtml, + loadScript, + triggerDomContentLoaded +} from "./helpers/browser.js"; + +async function setupPopup(overrides = {}) { + loadHtml("popup.html"); + globalThis.chrome = createChromeMock(overrides); + window.chrome = globalThis.chrome; + loadScript("shared/site-rules.js"); + loadScript("shared/popup-controls.js"); + loadScript("ui-icons.js"); + loadScript("popup.js"); + triggerDomContentLoaded(); + await flushAsyncWork(); + return globalThis.chrome; +} + +describe("popup UI", () => { + it("renders version, builds controls, and prefers the active frame speed", async () => { + await setupPopup({ + manifestVersion: "5.1.7.0", + executeScriptResponse: [ + { speed: 1.1, preferred: false }, + { speed: 1.75, preferred: true } + ] + }); + + expect(document.getElementById("app-version").innerText).toBe("5.1.7.0"); + expect(document.getElementById("popupSpeed").textContent).toBe("1.75"); + expect( + document.querySelectorAll("#popupControlBar button").length + ).toBeGreaterThan(0); + }); + + it("shows disabled state for a matching site rule", async () => { + await setupPopup({ + sync: { + enabled: true, + siteRules: [{ pattern: "example.com", enabled: false }] + } + }); + + expect(document.getElementById("status").innerText).toBe( + "Speeder is disabled for this site." + ); + expect(document.getElementById("popupSpeed").textContent).toBe("1.00"); + expect(document.getElementById("popupControlBar").style.display).toBe( + "none" + ); + }); + + it("toggles enabled state and updates the browser action icons", async () => { + const chrome = await setupPopup(); + chrome.storage.sync.set.mockClear(); + chrome.browserAction.setIcon.mockClear(); + + document.getElementById("disable").click(); + + expect(chrome.storage.sync.set).toHaveBeenCalledWith( + { enabled: false }, + expect.any(Function) + ); + expect(document.getElementById("enable").classList.contains("hide")).toBe( + false + ); + expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({ + path: { + 19: "icons/icon19_disabled.png", + 38: "icons/icon38_disabled.png", + 48: "icons/icon48_disabled.png" + } + }); + }); + + it("handles refresh responses for unsupported and successful pages", async () => { + vi.useFakeTimers(); + const chrome = await setupPopup(); + let response = null; + chrome.tabs.sendMessage.mockImplementation((tabId, message, callback) => { + if (message.action === "rescan_page") { + callback(response); + return; + } + callback({ speed: 1.25 }); + }); + + document.getElementById("refresh").click(); + expect(document.getElementById("status").innerText).toBe( + "Cannot run on this page." + ); + + response = { status: "complete" }; + document.getElementById("refresh").click(); + expect(document.getElementById("status").innerText).toBe( + "Scan complete. Closing..." + ); + vi.advanceTimersByTime(500); + expect(window.close).toHaveBeenCalled(); + }); + + it("dispatches popup control bar actions back to the active tab", async () => { + const chrome = await setupPopup(); + chrome.tabs.sendMessage.mockClear(); + chrome.tabs.executeScript.mockClear(); + + document.querySelector("#popupControlBar button").click(); + + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + action: "run_action" + }), + expect.any(Function) + ); + expect(chrome.tabs.executeScript).toHaveBeenCalled(); + }); +}); diff --git a/tests/popup.spec.js b/tests/popup.spec.js new file mode 100644 index 0000000..1d7b0d1 --- /dev/null +++ b/tests/popup.spec.js @@ -0,0 +1,173 @@ +const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest"); +const { + createChromeMock, + evaluateScript, + fireDOMContentLoaded, + flushAsyncWork, + installCommonWindowMocks, + loadHtmlFile +} = require("./helpers/extension-test-utils"); + +function bootPopup(options) { + const config = options || {}; + + loadHtmlFile("popup.html"); + installCommonWindowMocks(); + + const chrome = createChromeMock({ + manifest: { version: "9.9.9-test" }, + syncData: config.syncData, + localData: config.localData, + tabsQueryResult: [ + config.activeTab || { id: 99, active: true, url: "https://example.com/" } + ] + }); + + chrome.tabs.executeScript.mockImplementation( + config.executeScriptImpl || + ((tabId, details, callback) => { + if (callback) callback([{ speed: 1.0, preferred: true }]); + }) + ); + chrome.tabs.sendMessage.mockImplementation( + config.sendMessageImpl || + ((tabId, message, callback) => { + if (message.action === "get_speed") { + callback({ speed: 1.0 }); + return; + } + if (message.action === "rescan_page") { + callback({ status: "complete" }); + return; + } + callback({ speed: 1.0 }); + }) + ); + + global.chrome = chrome; + window.chrome = chrome; + + evaluateScript("ui-icons.js"); + evaluateScript("popup.js"); + fireDOMContentLoaded(); + + return chrome; +} + +describe("popup.js", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + delete global.chrome; + }); + + it("renders the popup disabled state when a site rule disables Speeder", async () => { + bootPopup({ + syncData: { + enabled: true, + siteRules: [ + { + pattern: "youtube.com", + enabled: false + } + ] + }, + activeTab: { + id: 10, + active: true, + url: "https://www.youtube.com/watch?v=abc123" + } + }); + + await flushAsyncWork(); + + expect(document.querySelector("#app-version").textContent).toBe("9.9.9-test"); + expect(document.querySelector("#status").textContent).toContain( + "disabled for this site" + ); + expect(document.querySelector("#popupSpeed").textContent).toBe("1.00"); + expect(document.querySelector("#popupControlBar").style.display).toBe("none"); + }); + + it("builds sanitized popup buttons and refreshes speed after an action", async () => { + const chrome = bootPopup({ + syncData: { + enabled: true, + controllerButtons: ["faster", "settings", "rewind", "faster"], + popupMatchHoverControls: true + } + }); + + chrome.tabs.executeScript + .mockImplementationOnce((tabId, details, callback) => { + callback([ + { speed: 1.25, preferred: false }, + { speed: 1.5, preferred: true } + ]); + }) + .mockImplementationOnce((tabId, details, callback) => { + callback([{ speed: 1.75, preferred: true }]); + }); + + chrome.tabs.sendMessage.mockImplementation((tabId, message, callback) => { + if (message.action === "run_action") { + callback({ speed: 1.75 }); + return; + } + callback({ speed: 1.0 }); + }); + + document.dispatchEvent(new window.Event("DOMContentLoaded")); + await flushAsyncWork(); + + const buttons = Array.from( + document.querySelectorAll("#popupControlBar button[data-action]") + ).map((button) => button.dataset.action); + expect(buttons).toEqual(["faster", "rewind"]); + expect(document.querySelector("#popupSpeed").textContent).toBe("1.50"); + + document.querySelector('#popupControlBar button[data-action="faster"]').click(); + await flushAsyncWork(); + + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( + 99, + { action: "run_action", actionName: "faster" }, + expect.any(Function) + ); + expect(document.querySelector("#popupSpeed").textContent).toBe("1.75"); + }); + + it("toggles enablement and closes after a successful refresh", async () => { + vi.useFakeTimers(); + + const chrome = bootPopup({ + syncData: { + enabled: false + } + }); + + await flushAsyncWork(); + + expect(document.querySelector("#enable").classList.contains("hide")).toBe(false); + expect(document.querySelector("#disable").classList.contains("hide")).toBe(true); + + document.querySelector("#enable").click(); + expect(chrome.storage.sync.set).toHaveBeenCalledWith( + { enabled: true }, + expect.any(Function) + ); + expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({ + path: { + 19: "icons/icon19.png", + 38: "icons/icon38.png", + 48: "icons/icon48.png" + } + }); + + document.querySelector("#refresh").click(); + expect(document.querySelector("#status").textContent).toContain("Closing"); + + vi.advanceTimersByTime(500); + expect(window.close).toHaveBeenCalled(); + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..b544fb8 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,25 @@ +import { afterEach, beforeEach, vi } from "vitest"; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + + if (typeof window !== "undefined") { + window.open = vi.fn(); + window.close = vi.fn(); + } + + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); +}); + +afterEach(() => { + vi.useRealTimers(); + delete globalThis.SpeederShared; + delete globalThis.restore_options; + if (typeof document !== "undefined") { + document.head.innerHTML = ""; + document.body.innerHTML = ""; + } + delete globalThis.chrome; +}); diff --git a/tests/shared.test.js b/tests/shared.test.js new file mode 100644 index 0000000..1d108c9 --- /dev/null +++ b/tests/shared.test.js @@ -0,0 +1,145 @@ +import controllerUtils from "../shared/controller-utils.js"; +import importExportUtils from "../shared/import-export.js"; +import keyBindingUtils from "../shared/key-bindings.js"; +import popupControls from "../shared/popup-controls.js"; +import siteRules from "../shared/site-rules.js"; + +describe("shared helpers", () => { + it("matches site rules and skips invalid regex patterns", () => { + const literalRule = { pattern: "example.com/watch" }; + const regexRule = { pattern: "/youtube\\.com\\/watch/i" }; + + expect( + siteRules.matchSiteRule("https://example.com/watch?v=1", [literalRule]) + ).toBe(literalRule); + expect( + siteRules.matchSiteRule("https://www.youtube.com/watch?v=2", [regexRule]) + ).toBe(regexRule); + expect( + siteRules.matchSiteRule("https://www.youtube.com/shorts/3", [ + { pattern: "/(/" }, + regexRule + ]) + ).toBeNull(); + expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true); + }); + + it("sanitizes and resolves popup button orders", () => { + const controllerButtonDefs = { + rewind: {}, + faster: {}, + advance: {}, + display: {}, + settings: {} + }; + + expect( + popupControls.sanitizeButtonOrder( + ["rewind", "settings", "rewind", "faster", "missing"], + controllerButtonDefs, + new Set(["settings"]) + ) + ).toEqual(["rewind", "faster"]); + + expect( + popupControls.resolvePopupButtons( + { + popupMatchHoverControls: true, + controllerButtons: ["advance", "display"], + popupControllerButtons: ["rewind"] + }, + { controllerButtons: ["faster", "advance"] }, + { + controllerButtonDefs, + defaultButtons: ["rewind", "display"], + excludedIds: ["settings"] + } + ) + ).toEqual(["faster", "advance"]); + + expect( + popupControls.resolvePopupButtons( + { + popupMatchHoverControls: false, + popupControllerButtons: ["rewind", "display"] + }, + { popupControllerButtons: ["advance", "settings", "advance"] }, + { + controllerButtonDefs, + defaultButtons: ["rewind", "display"], + excludedIds: ["settings"] + } + ) + ).toEqual(["advance"]); + }); + + it("normalizes controller locations and margins", () => { + expect(controllerUtils.normalizeControllerLocation("top-right")).toBe( + "top-right" + ); + expect(controllerUtils.normalizeControllerLocation("bogus")).toBe( + controllerUtils.defaultControllerLocation + ); + expect(controllerUtils.clampControllerMarginPx(300, 65)).toBe(200); + expect(controllerUtils.clampControllerMarginPx(-5, 65)).toBe(0); + expect(controllerUtils.getNextControllerLocation("top-left")).toBe( + "top-center" + ); + }); + + it("infers key binding codes from legacy formats", () => { + expect(keyBindingUtils.normalizeBindingKey("a")).toBe("A"); + expect(keyBindingUtils.normalizeBindingKey("Esc")).toBe("Escape"); + expect(keyBindingUtils.legacyBindingKeyToCode(" ")).toBe("Space"); + expect(keyBindingUtils.legacyKeyCodeToCode(90)).toBe("KeyZ"); + expect(keyBindingUtils.inferBindingCode({ key: "x" }, null)).toBe("KeyX"); + expect(keyBindingUtils.inferBindingCode({ keyCode: 107 }, null)).toBe( + "NumpadAdd" + ); + expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65); + }); + + it("builds and parses import/export payloads", () => { + expect( + importExportUtils.generateBackupFilename(new Date(2026, 3, 4, 8, 9, 10)) + ).toBe("speeder-backup_2026-04-04_08.09.10.json"); + + expect( + importExportUtils.buildBackupPayload( + { rememberSpeed: true }, + { customButtonIcons: {} }, + "2026-04-04T08:09:10Z" + ) + ).toEqual({ + version: "1.1", + exportDate: "2026-04-04T08:09:10.000Z", + settings: { rememberSpeed: true }, + localSettings: { customButtonIcons: {} } + }); + + expect( + importExportUtils.extractImportSettings({ + settings: { rememberSpeed: true }, + localSettings: { customButtonIcons: {} } + }) + ).toEqual({ + isWrappedBackup: true, + settings: { rememberSpeed: true }, + localSettings: { customButtonIcons: {} } + }); + + expect( + importExportUtils.parseImportText( + JSON.stringify({ rememberSpeed: true, keyBindings: [] }) + ) + ).toEqual({ + isWrappedBackup: false, + settings: { rememberSpeed: true, keyBindings: [] }, + localSettings: null + }); + + expect( + importExportUtils.extractImportSettings({ enabled: true }) + ).toBeNull(); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..bedeb66 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,12 @@ +const { defineConfig } = require("vitest/config"); + +module.exports = defineConfig({ + test: { + environment: "jsdom", + clearMocks: true, + globals: true, + restoreMocks: true, + include: ["tests/**/*.test.js"], + setupFiles: ["./tests/setup.js"] + } +}); From e6c56bcecb8681c9198e83c9fcee0071bd3f6085 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 7 Apr 2026 14:31:34 -0400 Subject: [PATCH 4/9] Allow zero controller opacity in settings --- options.js | 17 +++++++++++++---- tests/options.integration.test.js | 17 ++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/options.js b/options.js index f26c5b9..ae8e9b4 100644 --- a/options.js +++ b/options.js @@ -307,6 +307,11 @@ function clampMarginPxInput(el, fallback) { return controllerUtils.clampControllerMarginPx(el && el.value, fallback); } +function parseFiniteNumberOrFallback(value, fallback) { + var numericValue = parseFloat(value); + return Number.isFinite(numericValue) ? numericValue : fallback; +} + function syncSiteRuleField(ruleEl, rule, key, isCheckbox) { var input = ruleEl.querySelector(".site-" + key); if (!input) return; @@ -624,8 +629,10 @@ function save_options() { document.getElementById("controllerLocation").value ); settings.controllerOpacity = - parseFloat(document.getElementById("controllerOpacity").value) || - tcDefaults.controllerOpacity; + parseFiniteNumberOrFallback( + document.getElementById("controllerOpacity").value, + tcDefaults.controllerOpacity + ); settings.controllerMarginTop = clampMarginPxInput( document.getElementById("controllerMarginTop"), @@ -713,8 +720,10 @@ function save_options() { if (ruleEl.querySelector(".override-opacity").checked) { rule.controllerOpacity = - parseFloat(ruleEl.querySelector(".site-controllerOpacity").value) || - settings.controllerOpacity; + parseFiniteNumberOrFallback( + ruleEl.querySelector(".site-controllerOpacity").value, + settings.controllerOpacity + ); } if (ruleEl.querySelector(".override-subtitleNudge").checked) { diff --git a/tests/options.integration.test.js b/tests/options.integration.test.js index 4e14f13..4966525 100644 --- a/tests/options.integration.test.js +++ b/tests/options.integration.test.js @@ -78,7 +78,7 @@ describe("options page", () => { document.getElementById("rememberSpeed").checked = true; document.getElementById("hideWithControlsTimer").value = "20"; - document.getElementById("controllerOpacity").value = "0.55"; + document.getElementById("controllerOpacity").value = "0"; document.getElementById("controllerMarginTop").value = "250"; document.getElementById("controllerMarginBottom").value = "-4"; document.getElementById("enableSubtitleNudge").checked = true; @@ -98,6 +98,8 @@ describe("options page", () => { rule.querySelector(".site-pattern").value = "youtube.com"; rule.querySelector(".override-playback").checked = true; rule.querySelector(".site-rememberSpeed").checked = true; + rule.querySelector(".override-opacity").checked = true; + rule.querySelector(".site-controllerOpacity").value = "0"; rule.querySelector(".override-popup-controlbar").checked = true; rule.querySelector(".site-showPopupControlBar").checked = false; globalThis.populateControlBarZones( @@ -119,7 +121,7 @@ describe("options page", () => { expect(savedSettings.rememberSpeed).toBe(true); expect(savedSettings.hideWithControlsTimer).toBe(15); - expect(savedSettings.controllerOpacity).toBe(0.55); + expect(savedSettings.controllerOpacity).toBe(0); expect(savedSettings.controllerMarginTop).toBe(200); expect(savedSettings.controllerMarginBottom).toBe(0); expect(savedSettings.subtitleNudgeInterval).toBe(10); @@ -129,11 +131,12 @@ describe("options page", () => { expect(savedSettings.siteRules).toEqual( expect.arrayContaining([ expect.objectContaining({ - pattern: "youtube.com", - rememberSpeed: true, - showPopupControlBar: false, - popupControllerButtons: ["advance"] - }) + pattern: "youtube.com", + rememberSpeed: true, + controllerOpacity: 0, + showPopupControlBar: false, + popupControllerButtons: ["advance"] + }) ]) ); }); From f32d1b3f71f946649e5ea1e57c0f0bcb73cff789 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 7 Apr 2026 14:31:36 -0400 Subject: [PATCH 5/9] Accept raw settings backups during import --- shared/import-export.js | 41 ++++++++++++++++++- tests/importExport.integration.test.js | 54 +++++++++++++++++++++++++- tests/shared.test.js | 10 ++++- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/shared/import-export.js b/shared/import-export.js index 507d5f0..a51364d 100644 --- a/shared/import-export.js +++ b/shared/import-export.js @@ -8,6 +8,44 @@ root.SpeederShared = root.SpeederShared || {}; root.SpeederShared.importExport = exports; })(typeof globalThis !== "undefined" ? globalThis : this, function() { + var rawSettingsKeys = new Set([ + "audioBoolean", + "controllerButtons", + "controllerLocation", + "controllerMarginBottom", + "controllerMarginLeft", + "controllerMarginRight", + "controllerMarginTop", + "controllerOpacity", + "enableSubtitleNudge", + "enabled", + "forceLastSavedSpeed", + "hideWithControls", + "hideWithControlsTimer", + "hideWithYouTubeControls", + "keyBindings", + "lastSpeed", + "popupControllerButtons", + "popupMatchHoverControls", + "rememberSpeed", + "showPopupControlBar", + "siteRules", + "speed", + "startHidden", + "subtitleNudgeAmount", + "subtitleNudgeInterval" + ]); + + function isRecognizedRawSettingsObject(backup) { + if (!backup || typeof backup !== "object" || Array.isArray(backup)) { + return false; + } + + return Object.keys(backup).some(function(key) { + return rawSettingsKeys.has(key); + }); + } + function generateBackupFilename(now) { var date = now instanceof Date ? now : new Date(now || Date.now()); var year = date.getFullYear(); @@ -53,7 +91,7 @@ } else if ( backup && typeof backup === "object" && - (backup.keyBindings || backup.rememberSpeed !== undefined) + isRecognizedRawSettingsObject(backup) ) { settingsToImport = backup; } @@ -80,6 +118,7 @@ buildBackupPayload: buildBackupPayload, extractImportSettings: extractImportSettings, generateBackupFilename: generateBackupFilename, + isRecognizedRawSettingsObject: isRecognizedRawSettingsObject, parseImportText: parseImportText }; }); diff --git a/tests/importExport.integration.test.js b/tests/importExport.integration.test.js index f934be5..b044070 100644 --- a/tests/importExport.integration.test.js +++ b/tests/importExport.integration.test.js @@ -128,6 +128,58 @@ describe("import/export flows", () => { expect(globalThis.restore_options).toHaveBeenCalled(); }); + it("imports raw settings objects without touching local storage", async () => { + vi.useFakeTimers(); + const chrome = await setupImportExport({ + local: { customButtonIcons: { faster: { slug: "rocket" } } } + }); + + const originalCreateElement = document.createElement.bind(document); + let createdInput = null; + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + const el = originalCreateElement(tagName); + if (tagName === "input") { + createdInput = el; + el.click = vi.fn(); + } + return el; + }); + + globalThis.FileReader = class MockFileReader { + readAsText(file) { + this.onload({ + target: { + result: file.__text + } + }); + } + }; + + globalThis.importSettings(); + createdInput.onchange({ + target: { + files: [ + { + __text: JSON.stringify({ + enabled: false, + siteRules: [{ pattern: "example.com", enabled: false }] + }) + } + ] + } + }); + + expect(chrome.storage.local.clear).not.toHaveBeenCalled(); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + expect(chrome.storage.sync.set).toHaveBeenCalledWith( + { + enabled: false, + siteRules: [{ pattern: "example.com", enabled: false }] + }, + expect.any(Function) + ); + }); + it("clears stale local data when a wrapped backup has empty local settings", async () => { vi.useFakeTimers(); const chrome = await setupImportExport({ @@ -210,7 +262,7 @@ describe("import/export flows", () => { target: { files: [ { - __text: JSON.stringify({ enabled: true }) + __text: JSON.stringify({ wat: true }) } ] } diff --git a/tests/shared.test.js b/tests/shared.test.js index 1d108c9..70c9b69 100644 --- a/tests/shared.test.js +++ b/tests/shared.test.js @@ -140,6 +140,14 @@ describe("shared helpers", () => { expect( importExportUtils.extractImportSettings({ enabled: true }) - ).toBeNull(); + ).toEqual({ + isWrappedBackup: false, + settings: { enabled: true }, + localSettings: null + }); + + expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe( + false + ); }); }); From 0cb13905ff70520a4ffbbe0b4a66e8d6b6c8dd86 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 7 Apr 2026 14:31:39 -0400 Subject: [PATCH 6/9] Fix subtitle nudge site gating --- inject.js | 39 +++++++++++++++---- tests/inject.test.js | 90 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 tests/inject.test.js diff --git a/inject.js b/inject.js index 7f7318c..353c98f 100644 --- a/inject.js +++ b/inject.js @@ -38,7 +38,7 @@ var tc = { controllerButtons: ["rewind", "slower", "faster", "advance", "display"], defaultLogLevel: 3, logLevel: 3, - enableSubtitleNudge: true, // Enabled by default, but only activates on YouTube + enableSubtitleNudge: false, subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost subtitleNudgeAmount: 0.001, customButtonIcons: {} @@ -649,19 +649,32 @@ function isSubtitleNudgeSupported(video) { return Boolean(video); } +function isSubtitleNudgeAvailableForVideo(video) { + return isSubtitleNudgeSupported(video) && Boolean(tc.settings.enableSubtitleNudge); +} + function isSubtitleNudgeEnabledForVideo(video) { - if (!video || !video.vsc) return tc.settings.enableSubtitleNudge; + if (!isSubtitleNudgeAvailableForVideo(video)) return false; + + if (!video || !video.vsc) return true; if (typeof video.vsc.subtitleNudgeEnabledOverride === "boolean") { return video.vsc.subtitleNudgeEnabledOverride; } - return tc.settings.enableSubtitleNudge; + return true; } function setSubtitleNudgeEnabledForVideo(video, enabled) { if (!video || !video.vsc) return false; + if (!isSubtitleNudgeAvailableForVideo(video)) { + video.vsc.subtitleNudgeEnabledOverride = null; + video.vsc.stopSubtitleNudge(); + updateSubtitleNudgeIndicator(video); + return false; + } + var normalizedEnabled = Boolean(enabled); video.vsc.subtitleNudgeEnabledOverride = normalizedEnabled; @@ -725,14 +738,19 @@ function renderSubtitleNudgeIndicatorContent(target, isEnabled) { function updateSubtitleNudgeIndicator(video) { if (!video || !video.vsc) return; + var isAvailable = isSubtitleNudgeAvailableForVideo(video); var isEnabled = isSubtitleNudgeEnabledForVideo(video); - var title = isEnabled ? "Subtitle nudge enabled" : "Subtitle nudge disabled"; + var title = !isAvailable + ? "Subtitle nudge unavailable on this site" + : isEnabled + ? "Subtitle nudge enabled" + : "Subtitle nudge disabled"; var indicator = video.vsc.subtitleNudgeIndicator; if (indicator) { renderSubtitleNudgeIndicatorContent(indicator, isEnabled); indicator.dataset.enabled = isEnabled ? "true" : "false"; - indicator.dataset.supported = "true"; + indicator.dataset.supported = isAvailable ? "true" : "false"; indicator.title = title; indicator.setAttribute("aria-label", title); } @@ -741,7 +759,7 @@ function updateSubtitleNudgeIndicator(video) { if (flashEl) { renderSubtitleNudgeIndicatorContent(flashEl, isEnabled); flashEl.dataset.enabled = isEnabled ? "true" : "false"; - flashEl.dataset.supported = "true"; + flashEl.dataset.supported = isAvailable ? "true" : "false"; flashEl.setAttribute("aria-label", title); } } @@ -2580,7 +2598,14 @@ function runAction(action, value, e) { "toggleSubtitleNudge", "display" ]; - if (userDrivenActionsThatShowController.includes(action) && action !== "display") { + var subtitleNudgeActionBlocked = + (action === "toggleSubtitleNudge" || action === "nudge") && + !isSubtitleNudgeAvailableForVideo(v); + if ( + userDrivenActionsThatShowController.includes(action) && + action !== "display" && + !subtitleNudgeActionBlocked + ) { showController(controller, 2000, true); } if (v.classList.contains("vsc-cancelled")) return; diff --git a/tests/inject.test.js b/tests/inject.test.js new file mode 100644 index 0000000..fcb3eff --- /dev/null +++ b/tests/inject.test.js @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { createChromeMock, flushAsyncWork, loadScript } from "./helpers/browser.js"; + +function loadBlankDocument() { + document.open(); + document.write(""); + document.close(); +} + +async function bootInject({ sync = {}, local = {} } = {}) { + loadBlankDocument(); + globalThis.chrome = createChromeMock({ sync, local }); + window.chrome = globalThis.chrome; + globalThis.chrome.runtime.onMessage = { + addListener: vi.fn() + }; + const originalSyncGet = globalThis.chrome.storage.sync.get; + const originalLocalGet = globalThis.chrome.storage.local.get; + globalThis.chrome.storage.sync.get = vi.fn((keys, callback) => { + Promise.resolve().then(() => originalSyncGet(keys, callback)); + }); + globalThis.chrome.storage.local.get = vi.fn((keys, callback) => { + Promise.resolve().then(() => originalLocalGet(keys, callback)); + }); + globalThis.requestIdleCallback = (callback, options) => + setTimeout( + () => + callback({ + didTimeout: false, + timeRemaining() { + return 1; + } + }), + (options && options.timeout) || 0 + ); + globalThis.cancelIdleCallback = (id) => clearTimeout(id); + + loadScript("shared/controller-utils.js"); + loadScript("shared/key-bindings.js"); + loadScript("shared/site-rules.js"); + loadScript("ui-icons.js"); + loadScript("inject.js"); + + for (let i = 0; i < 3; i += 1) { + await flushAsyncWork(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe("inject runtime", () => { + it("keeps subtitle nudge disabled when the effective setting is off", async () => { + await bootInject({ + sync: { + enableSubtitleNudge: false + } + }); + + const stopSubtitleNudge = vi.fn(); + const startSubtitleNudge = vi.fn(); + const flashEl = document.createElement("span"); + const video = { + paused: false, + playbackRate: 1.5, + vsc: { + stopSubtitleNudge, + startSubtitleNudge, + subtitleNudgeEnabledOverride: null, + subtitleNudgeIndicator: null, + nudgeFlashIndicator: flashEl + } + }; + + expect(window.tc.settings.enableSubtitleNudge).toBe(false); + expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(false); + expect(window.setSubtitleNudgeEnabledForVideo(video, true)).toBe(false); + expect(video.vsc.subtitleNudgeEnabledOverride).toBeNull(); + expect(stopSubtitleNudge).toHaveBeenCalledTimes(1); + expect(startSubtitleNudge).not.toHaveBeenCalled(); + expect(flashEl.classList.contains("visible")).toBe(false); + + window.tc.settings.enableSubtitleNudge = true; + expect(window.setSubtitleNudgeEnabledForVideo(video, true)).toBe(true); + expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(true); + + window.tc.settings.enableSubtitleNudge = false; + expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(false); + + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}); From 6efe92a036d965e2c4f1c3fa1b5efcdf9ead14f1 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 7 Apr 2026 14:31:41 -0400 Subject: [PATCH 7/9] Refine site rule toggle and override UI --- options.css | 135 ++++++++++-- options.html | 28 ++- options.js | 336 +++++++++++++++--------------- tests/options.integration.test.js | 51 +++++ ui-icons.js | 3 + 5 files changed, 363 insertions(+), 190 deletions(-) diff --git a/options.css b/options.css index 9119cb5..e4c16a9 100644 --- a/options.css +++ b/options.css @@ -7,6 +7,17 @@ --text: #17191c; --muted: #626b76; --accent: #111827; + --switch-track-off: #c1cad6; + --switch-track-off-border: #aeb8c5; + --switch-track-on: #111827; + --switch-track-on-border: #111827; + --switch-thumb-off: #ffffff; + --switch-thumb-on: #ffffff; + --toggle-open-fg: #111827; + --toggle-open-bg: #eef2f6; + --toggle-open-border: #c5ccd5; + --toggle-open-hover-bg: #e4eaf1; + --toggle-open-hover-border: #b5c0cc; --danger: #b42318; } @@ -210,6 +221,7 @@ button:active { } button:focus-visible, +input[type="checkbox"]:focus-visible, input[type="text"]:focus, select:focus, textarea:focus { @@ -247,10 +259,49 @@ textarea:focus { } input[type="checkbox"] { - width: 16px; - height: 16px; - margin: 2px 0 0; - accent-color: var(--accent); + appearance: none; + -webkit-appearance: none; + position: relative; + width: 46px; + min-width: 46px; + height: 28px; + margin: 0; + border: 1px solid var(--switch-track-off-border); + border-radius: 999px; + background: var(--switch-track-off); + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, + box-shadow 120ms ease; + flex-shrink: 0; +} + +input[type="checkbox"]::before { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--switch-thumb-off); + box-shadow: 0 1px 2px rgba(17, 24, 39, 0.18), + inset 0 0 0 1px rgba(17, 24, 39, 0.08); + transition: transform 120ms ease, background-color 120ms ease; +} + +input[type="checkbox"]:checked { + background: var(--switch-track-on); + border-color: var(--switch-track-on-border); +} + +input[type="checkbox"]:checked::before { + transform: translateX(18px); + background: var(--switch-thumb-on); +} + +input[type="checkbox"]:disabled { + cursor: default; + opacity: 0.7; } label { @@ -322,6 +373,39 @@ label em { .toggle-site-rule { font-weight: 400; + color: var(--muted); +} + +.toggle-site-rule:hover { + color: var(--toggle-open-fg); + background: var(--toggle-open-hover-bg); + border-color: var(--toggle-open-hover-border); +} + +.toggle-site-rule .site-rule-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + pointer-events: none; +} + +.toggle-site-rule .site-rule-toggle-icon svg { + width: 18px; + height: 18px; +} + +.site-rule:not(.collapsed) .toggle-site-rule { + color: var(--toggle-open-fg); + background: var(--toggle-open-bg); + border-color: var(--toggle-open-border); +} + +.site-rule:not(.collapsed) .toggle-site-rule:hover { + color: var(--toggle-open-fg); + background: var(--toggle-open-hover-bg); + border-color: var(--toggle-open-hover-border); } .row { @@ -339,7 +423,7 @@ label em { } .row.row-checkbox { - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; } .row.row-checkbox input[type="checkbox"] { @@ -403,9 +487,9 @@ label em { .site-override-lead { display: grid; - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; gap: 16px; - align-items: flex-start; + align-items: center; font-weight: 600; margin-bottom: 8px; cursor: pointer; @@ -414,7 +498,6 @@ label em { .site-override-lead input[type="checkbox"] { justify-self: end; - margin-top: 3px; } .site-override-lead span { @@ -427,10 +510,19 @@ label em { .site-rule-override-section .site-autohide-container, .site-rule-override-section .site-playback-container, .site-rule-override-section .site-opacity-container, -.site-rule-override-section .site-subtitleNudge-container { +.site-rule-override-section .site-subtitleNudge-container, +.site-controlbar-container, +.site-popup-controlbar-container, +.site-shortcuts-container { padding-left: 4px; } +.site-override-disabled { + opacity: 0.48; + pointer-events: none; + user-select: none; +} + .cb-editor { display: flex; flex-direction: column; @@ -803,7 +895,7 @@ button.lucide-result-tile.lucide-picked { } .site-rule-option-checkbox { - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; } .site-rule-option-checkbox > input[type="checkbox"] { @@ -833,7 +925,7 @@ button.lucide-result-tile.lucide-picked { .site-rule-split-label { display: grid; - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: flex-start; width: 100%; @@ -845,7 +937,7 @@ button.lucide-result-tile.lucide-picked { .site-rule-split-label input[type="checkbox"] { justify-self: end; - margin-top: 3px; + margin-top: 0; } .site-rule-option-checkbox > .site-rule-split-label { @@ -889,8 +981,8 @@ button.lucide-result-tile.lucide-picked { .force-label { display: flex; - align-items: flex-start; - gap: 8px; + align-items: center; + gap: 10px; width: auto; margin: 0; color: var(--muted); @@ -898,7 +990,7 @@ button.lucide-result-tile.lucide-picked { } .force-label input { - margin-top: 2px; + margin-top: 0; } .action-row { @@ -956,7 +1048,7 @@ button.lucide-result-tile.lucide-picked { } .site-override-lead { - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; } .action-row button, @@ -1005,6 +1097,17 @@ button.lucide-result-tile.lucide-picked { --text: #f2f4f6; --muted: #a0a8b2; --accent: #f2f4f6; + --switch-track-off: #374151; + --switch-track-off-border: #4b5563; + --switch-track-on: #aab7c6; + --switch-track-on-border: #aab7c6; + --switch-thumb-off: #f8fafc; + --switch-thumb-on: #111315; + --toggle-open-fg: #f2f4f6; + --toggle-open-bg: #2b3138; + --toggle-open-border: #4b5563; + --toggle-open-hover-bg: #374151; + --toggle-open-hover-border: #64748b; --danger: #ff8a80; } diff --git a/options.html b/options.html index 0bd6c9e..189fddf 100644 --- a/options.html +++ b/options.html @@ -483,7 +483,15 @@