Merge feature/incremental-testing: voice API, themes, and enhanced toolbar

Incrementally tested additions that introduce no new test failures.
This commit is contained in:
Ryan Malloy 2025-12-04 13:33:35 -07:00
commit eaf8349203
4 changed files with 1065 additions and 90 deletions

View File

@ -0,0 +1,197 @@
/**
* Voice-Enabled AI-Human Collaboration API - Ultra-optimized for injection
* Minimal footprint, maximum performance, beautiful code that gets injected everywhere
*/
export function generateVoiceCollaborationAPI(): string {
return `
(function(){
'use strict';
try{
const w=window,d=document,c=console,n=navigator;
const SR=w.SpeechRecognition||w.webkitSpeechRecognition;
const ss=w.speechSynthesis;
let vs,cr,speaking=0,listening=0;
// Namespace protection - prevent conflicts
if(w.mcpVoiceLoaded)return;
w.mcpVoiceLoaded=1;
// Initialize voice capabilities with comprehensive error handling
const init=async()=>{
if(vs)return vs;
try{
const canSpeak=!!(ss&&ss.speak);
const canListen=!!(SR&&n.mediaDevices);
let micOK=0;
if(canListen){
try{
const s=await Promise.race([
n.mediaDevices.getUserMedia({audio:1}),
new Promise((_,reject)=>setTimeout(()=>reject('timeout'),3000))
]);
s.getTracks().forEach(t=>t.stop());
micOK=1;
}catch(e){}
}
vs={canSpeak,canListen:canListen&&micOK};
if(canSpeak&&ss.getVoices().length>0)speak('Voice collaboration active');
return vs;
}catch(e){
c.warn('[MCP] Voice init failed:',e);
vs={canSpeak:0,canListen:0};
return vs;
}
};
// Ultra-compact speech synthesis with error protection
const speak=(text,opts={})=>{
try{
if(!vs?.canSpeak||speaking||!text||typeof text!=='string')return 0;
const u=new SpeechSynthesisUtterance(text.slice(0,300)); // Prevent long text issues
Object.assign(u,{rate:1,pitch:1,volume:1,...opts});
const voices=ss.getVoices();
u.voice=voices.find(v=>v.name.includes('Google')||v.name.includes('Microsoft'))||voices[0];
u.onstart=()=>speaking=1;
u.onend=u.onerror=()=>speaking=0;
ss.speak(u);
return 1;
}catch(e){c.warn('[MCP] Speak failed:',e);return 0}
};
// Ultra-compact speech recognition with robust error handling
const listen=(timeout=10000)=>new Promise((resolve,reject)=>{
try{
if(!vs?.canListen||listening)return reject('Voice unavailable');
timeout=Math.min(Math.max(timeout||5000,1000),30000); // Clamp timeout
const r=new SR();
Object.assign(r,{continuous:0,interimResults:0,lang:'en-US'});
let resolved=0;
const cleanup=()=>{listening=0;cr=null};
r.onstart=()=>{listening=1;cr=r};
r.onresult=e=>{
if(resolved++)return;
cleanup();
const transcript=(e.results?.[0]?.[0]?.transcript||'').trim();
resolve(transcript||'');
};
r.onerror=r.onend=()=>{
if(resolved++)return;
cleanup();
reject('Recognition failed');
};
r.start();
setTimeout(()=>{if(listening&&!resolved++){r.stop();cleanup();reject('Timeout')}},timeout);
}catch(e){
listening=0;cr=null;
reject('Listen error: '+e.message);
}
});
// Enhanced API with comprehensive safety
w.mcpNotify={
info:(msg,opts={})=>{try{c.log(\`[MCP] \${msg||''}\`);if(opts?.speak!==0)speak(msg,opts?.voice)}catch(e){}},
success:(msg,opts={})=>{try{c.log(\`[MCP] \${msg||''}\`);if(opts?.speak!==0)speak(\`Success! \${msg}\`,{...opts?.voice,pitch:1.2})}catch(e){}},
warning:(msg,opts={})=>{try{c.warn(\`[MCP] \${msg||''}\`);if(opts?.speak!==0)speak(\`Warning: \${msg}\`,{...opts?.voice,pitch:0.8})}catch(e){}},
error:(msg,opts={})=>{try{c.error(\`[MCP] \${msg||''}\`);if(opts?.speak!==0)speak(\`Error: \${msg}\`,{...opts?.voice,pitch:0.7})}catch(e){}},
speak:(text,opts={})=>speak(text,opts)
};
w.mcpPrompt=async(question,opts={})=>{
try{
if(!question||typeof question!=='string')return '';
question=question.slice(0,200); // Prevent long prompts
opts=opts||{};
if(vs?.canSpeak&&opts.speak!==0)speak(question,opts.voice);
if(opts.useVoice!==0&&vs?.canListen){
try{
const result=await listen(opts.timeout||10000);
if(vs.canSpeak)speak(\`I heard: \${result}\`,{rate:1.1});
return result;
}catch(e){
if(opts.fallback!==0&&w.prompt)return w.prompt(question);
return '';
}
}
return w.prompt?w.prompt(question):'';
}catch(e){c.warn('[MCP] Prompt failed:',e);return ''}
};
w.mcpInspector={
active:0,
start(instruction,callback,opts={}){
try{
if(this.active||!instruction||typeof instruction!=='string')return;
instruction=instruction.slice(0,100); // Prevent long instructions
this.active=1;
if(vs?.canSpeak)speak(\`\${instruction}. Click target element.\`,opts?.voice);
const indicator=d.createElement('div');
indicator.id='mcp-indicator';
indicator.innerHTML=\`<div style="position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(0,123,255,0.9);color:white;padding:12px 20px;border-radius:25px;font:14px -apple-system,sans-serif;z-index:999999;backdrop-filter:blur(10px);pointer-events:none;user-select:none">🎯 \${instruction}</div>\`;
// Safe DOM append with timing handling
const tryAppend=()=>{
if(d.body){
d.body.appendChild(indicator);
return 1;
}else if(d.documentElement){
d.documentElement.appendChild(indicator);
return 1;
}
return 0;
};
if(!tryAppend()){
if(d.readyState==='loading'){
d.addEventListener('DOMContentLoaded',()=>tryAppend());
}else{
setTimeout(()=>tryAppend(),10);
}
}
const onClick=e=>{
try{
e.preventDefault();e.stopPropagation();
this.active=0;
d.removeEventListener('click',onClick,1);
indicator.remove();
if(vs?.canSpeak)speak('Got it!');
if(callback&&typeof callback==='function')callback(e.target);
}catch(err){c.warn('[MCP] Inspector click failed:',err)}
};
d.addEventListener('click',onClick,1);
setTimeout(()=>{if(this.active)this.stop()},Math.min(opts?.timeout||30000,60000));
}catch(e){c.warn('[MCP] Inspector failed:',e);this.active=0}
},
stop(){
try{
this.active=0;
const el=d.getElementById('mcp-indicator');
if(el)el.remove();
}catch(e){}
}
};
// Auto-initialize with final error boundary
init().catch(e=>c.warn('[MCP] Voice init failed:',e));
c.log('[MCP] Voice collaboration loaded safely');
}catch(globalError){
// Ultimate safety net - never let this script break the page
console.warn('[MCP] Voice API failed to load:',globalError);
window.mcpNotify={info:()=>{},success:()=>{},warning:()=>{},error:()=>{},speak:()=>{}};
window.mcpPrompt=()=>Promise.resolve('');
window.mcpInspector={active:0,start:()=>{},stop:()=>{}};
}
})();
`;
}

View File

@ -31,6 +31,7 @@ import requests from './tools/requests.js';
import snapshot from './tools/snapshot.js'; import snapshot from './tools/snapshot.js';
import tabs from './tools/tabs.js'; import tabs from './tools/tabs.js';
import screenshot from './tools/screenshot.js'; import screenshot from './tools/screenshot.js';
import themeManagement from './tools/themeManagement.js';
import video from './tools/video.js'; import video from './tools/video.js';
import wait from './tools/wait.js'; import wait from './tools/wait.js';
import mouse from './tools/mouse.js'; import mouse from './tools/mouse.js';
@ -57,6 +58,7 @@ export const allTools: Tool<any>[] = [
...screenshot, ...screenshot,
...snapshot, ...snapshot,
...tabs, ...tabs,
...themeManagement,
...video, ...video,
...wait, ...wait,
]; ];

View File

@ -26,9 +26,47 @@ import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool } from './tool.js';
import type { Context } from '../context.js'; import type { Context } from '../context.js';
import type { Response } from '../response.js'; import type { Response } from '../response.js';
import { generateVoiceCollaborationAPI } from '../collaboration/voiceAPI.js';
const testDebug = debug('pw:mcp:tools:injection'); const testDebug = debug('pw:mcp:tools:injection');
// Direct voice API injection that bypasses wrapper issues
export async function injectVoiceAPIDirectly(context: Context, voiceScript: string): Promise<void> {
const currentTab = context.currentTab();
if (!currentTab) return;
// Custom injection that preserves variable scoping and avoids template literal issues
const wrappedVoiceScript = `
(function() {
'use strict';
// Prevent double injection
if (window.mcpVoiceLoaded) {
console.log('[MCP] Voice API already loaded, skipping');
return;
}
try {
${voiceScript}
} catch (error) {
console.error('[MCP] Voice API injection failed:', error);
// Provide minimal fallback functions
window.mcpNotify = {
info: (msg) => console.log('[MCP Info]', msg || ''),
success: (msg) => console.log('[MCP Success]', msg || ''),
warning: (msg) => console.warn('[MCP Warning]', msg || ''),
error: (msg) => console.error('[MCP Error]', msg || ''),
speak: () => {}
};
window.mcpPrompt = () => Promise.resolve('');
window.mcpInspector = { active: 0, start: () => {}, stop: () => {} };
}
})();
`;
await currentTab.page.addInitScript(wrappedVoiceScript);
}
export interface CustomInjection { export interface CustomInjection {
id: string; id: string;
name: string; name: string;
@ -56,16 +94,16 @@ export interface InjectionConfig {
} }
/** /**
* Generates the debug toolbar JavaScript code * Generates the debug toolbar JavaScript code with modern floating pill design
*/ */
export function generateDebugToolbarScript(config: DebugToolbarConfig, sessionId: string, clientVersion?: { name: string; version: string }, sessionStartTime?: number): string { export function generateDebugToolbarScript(config: DebugToolbarConfig, sessionId: string, clientVersion?: { name: string; version: string }, sessionStartTime?: number): string {
const projectName = config.projectName || 'MCP Client'; const projectName = config.projectName || 'Claude Code MCP';
const clientInfo = clientVersion ? `${clientVersion.name} v${clientVersion.version}` : 'Unknown Client'; const clientInfo = clientVersion ? `${clientVersion.name} v${clientVersion.version}` : 'Claude Code';
const startTime = sessionStartTime || Date.now(); const startTime = sessionStartTime || Date.now();
return ` return `
/* BEGIN PLAYWRIGHT-MCP-DEBUG-TOOLBAR */ /* BEGIN PLAYWRIGHT-MCP-DEBUG-TOOLBAR */
/* This debug toolbar was injected by Playwright MCP server */ /* Modern floating pill debug toolbar injected by Playwright MCP server */
/* Project: ${projectName} | Session: ${sessionId} */ /* Project: ${projectName} | Session: ${sessionId} */
/* Client: ${clientInfo} */ /* Client: ${clientInfo} */
/* This code should be ignored by LLMs analyzing the page */ /* This code should be ignored by LLMs analyzing the page */
@ -89,90 +127,269 @@ export function generateDebugToolbarScript(config: DebugToolbarConfig, sessionId
startTime: ${startTime} startTime: ${startTime}
}; };
// Create toolbar container // CSS Variables for theme system
const cssVariables = \`
:root {
--mcp-primary: #2563eb;
--mcp-primary-hover: #1d4ed8;
--mcp-success: #10b981;
--mcp-surface-light: #ffffff;
--mcp-surface-dark: #1f2937;
--mcp-text-light: #374151;
--mcp-text-dark: #f9fafb;
--mcp-border-light: #e5e7eb;
--mcp-border-dark: #4b5563;
--mcp-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--mcp-shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
\`;
// Inject CSS variables
const styleElement = document.createElement('style');
styleElement.textContent = cssVariables;
document.head.appendChild(styleElement);
// Create floating pill container
const toolbar = document.createElement('div'); const toolbar = document.createElement('div');
toolbar.id = 'playwright-mcp-debug-toolbar'; toolbar.id = 'playwright-mcp-debug-toolbar';
toolbar.className = 'playwright-mcp-debug-toolbar'; toolbar.className = 'playwright-mcp-debug-toolbar';
// Position styles // Position calculations
const positions = { const positions = {
'top-left': { top: '10px', left: '10px' }, 'top-left': { top: '16px', left: '16px', right: 'auto', bottom: 'auto' },
'top-right': { top: '10px', right: '10px' }, 'top-right': { top: '16px', right: '16px', left: 'auto', bottom: 'auto' },
'bottom-left': { bottom: '10px', left: '10px' }, 'bottom-left': { bottom: '16px', left: '16px', right: 'auto', top: 'auto' },
'bottom-right': { bottom: '10px', right: '10px' } 'bottom-right': { bottom: '16px', right: '16px', left: 'auto', top: 'auto' }
}; };
const pos = positions[toolbarConfig.position] || positions['top-right']; const pos = positions[toolbarConfig.position] || positions['top-right'];
// Theme colors // Theme-based styling
const getThemeStyles = (theme, minimized) => {
const themes = { const themes = {
light: { bg: 'rgba(255,255,255,0.95)', text: '#333', border: '#ccc' }, light: {
dark: { bg: 'rgba(45,45,45,0.95)', text: '#fff', border: '#666' }, background: 'var(--mcp-surface-light)',
transparent: { bg: 'rgba(0,0,0,0.7)', text: '#fff', border: 'rgba(255,255,255,0.3)' } color: 'var(--mcp-text-light)',
border: '1px solid var(--mcp-border-light)',
shadow: 'var(--mcp-shadow)'
},
dark: {
background: 'var(--mcp-surface-dark)',
color: 'var(--mcp-text-dark)',
border: '1px solid var(--mcp-border-dark)',
shadow: 'var(--mcp-shadow)'
},
transparent: {
background: 'rgba(15, 23, 42, 0.95)',
color: '#f1f5f9',
border: '1px solid rgba(148, 163, 184, 0.2)',
shadow: 'var(--mcp-shadow-lg)'
}
}; };
const theme = themes[toolbarConfig.theme] || themes.dark; const themeData = themes[theme] || themes.dark;
// Base styles return \`
toolbar.style.cssText = \`
position: fixed; position: fixed;
\${Object.entries(pos).map(([k,v]) => k + ':' + v).join(';')}; \${Object.entries(pos).map(([k,v]) => \`\${k}: \${v}\`).join('; ')};
background: \${theme.bg}; background: \${themeData.background};
color: \${theme.text}; color: \${themeData.color};
border: 1px solid \${theme.border}; border: \${themeData.border};
border-radius: 6px; border-radius: \${minimized ? '24px' : '12px'};
padding: 8px 12px; padding: \${minimized ? '8px 12px' : '12px 16px'};
font-family: 'Monaco', 'Menlo', 'Consolas', monospace; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 12px; font-size: \${minimized ? '12px' : '13px'};
font-weight: 500;
line-height: 1.4; line-height: 1.4;
z-index: 999999; z-index: 2147483647;
opacity: \${toolbarConfig.opacity}; opacity: \${toolbarConfig.opacity || 0.95};
cursor: move; backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: \${themeData.shadow};
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none; user-select: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.2); cursor: grab;
min-width: 150px; max-width: \${minimized ? '200px' : '320px'};
max-width: 300px; min-width: \${minimized ? 'auto' : '240px'};
\`; \`;
};
// Create content // Hover enhancement styles
function updateToolbarContent() { const addHoverStyles = () => {
const uptime = Math.floor((Date.now() - sessionInfo.startTime) / 1000); const hoverStyleElement = document.createElement('style');
hoverStyleElement.id = 'mcp-toolbar-hover-styles';
hoverStyleElement.textContent = \`
#playwright-mcp-debug-toolbar:hover {
transform: translateY(-1px);
box-shadow: var(--mcp-shadow-lg);
opacity: 1 !important;
}
#playwright-mcp-debug-toolbar:active {
cursor: grabbing;
transform: translateY(0px);
}
.mcp-toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
font-size: 12px;
color: inherit;
opacity: 0.7;
}
.mcp-toolbar-btn:hover {
opacity: 1;
background: rgba(99, 102, 241, 0.1);
transform: scale(1.05);
}
.mcp-status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mcp-success);
display: inline-block;
margin-right: 8px;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); }
50% { box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.1); }
}
.mcp-session-details {
font-size: 11px;
opacity: 0.8;
line-height: 1.3;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
.mcp-session-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3px;
}
.mcp-session-label {
opacity: 0.7;
font-weight: 400;
}
.mcp-session-value {
font-weight: 500;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
}
@media (max-width: 768px) {
#playwright-mcp-debug-toolbar {
font-size: 11px;
min-width: 200px;
max-width: 280px;
}
.mcp-session-details {
font-size: 10px;
}
}
\`;
document.head.appendChild(hoverStyleElement);
};
// Add hover styles
addHoverStyles();
// Content generation functions
function formatUptime(startTime) {
const uptime = Math.floor((Date.now() - startTime) / 1000);
const hours = Math.floor(uptime / 3600); const hours = Math.floor(uptime / 3600);
const minutes = Math.floor((uptime % 3600) / 60); const minutes = Math.floor((uptime % 3600) / 60);
const seconds = uptime % 60; const seconds = uptime % 60;
const uptimeStr = hours > 0 ?
\`\${hours}h \${minutes}m \${seconds}s\` :
minutes > 0 ? \`\${minutes}m \${seconds}s\` : \`\${seconds}s\`;
if (toolbarConfig.minimized) { if (hours > 0) return \`\${hours}h \${minutes}m\`;
toolbar.innerHTML = \` if (minutes > 0) return \`\${minutes}m \${seconds}s\`;
<div style="display: flex; align-items: center; justify-content: space-between;"> return \`\${seconds}s\`;
<span style="font-weight: bold; color: #4CAF50;"></span> }
<span style="margin: 0 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
function generateMinimizedContent() {
return \`
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="display: flex; align-items: center; flex: 1; min-width: 0;">
<span class="mcp-status-indicator"></span>
<span style="font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
\${sessionInfo.project} \${sessionInfo.project}
</span> </span>
<span style="cursor: pointer; opacity: 0.7; hover: opacity: 1;" onclick="this.parentNode.parentNode.playwrightToggle()"></span> </div>
<button class="mcp-toolbar-btn" onclick="this.closest('#playwright-mcp-debug-toolbar').playwrightToggle()" title="Expand details">
</button>
</div> </div>
\`; \`;
} else { }
toolbar.innerHTML = \`
<div style="margin-bottom: 4px; display: flex; align-items: center; justify-content: space-between;"> function generateExpandedContent() {
<div style="display: flex; align-items: center;"> const uptimeStr = formatUptime(sessionInfo.startTime);
<span style="color: #4CAF50; margin-right: 6px;"></span> const shortSessionId = sessionInfo.id.substring(0, 8);
<strong>\${sessionInfo.project}</strong> const hostname = window.location.hostname || 'local';
return \`
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: \${toolbarConfig.showDetails ? '0px' : '0px'};">
<div style="display: flex; align-items: center; flex: 1; min-width: 0;">
<span class="mcp-status-indicator"></span>
<span style="font-weight: 600; font-size: 14px;">
\${sessionInfo.project}
</span>
</div> </div>
<span style="cursor: pointer; opacity: 0.7; hover: opacity: 1;" onclick="this.parentNode.parentNode.playwrightToggle()"></span> <button class="mcp-toolbar-btn" onclick="this.closest('#playwright-mcp-debug-toolbar').playwrightToggle()" title="Minimize">
</button>
</div> </div>
\${toolbarConfig.showDetails ? \` \${toolbarConfig.showDetails ? \`
<div style="font-size: 10px; opacity: 0.8; line-height: 1.2;"> <div class="mcp-session-details">
<div>Session: \${sessionInfo.id.substring(0, 12)}...</div> <div class="mcp-session-row">
<div>Client: \${sessionInfo.client}</div> <span class="mcp-session-label">Session:</span>
<div>Uptime: \${uptimeStr}</div> <span class="mcp-session-value">\${shortSessionId}</span>
<div>URL: \${window.location.hostname}</div> </div>
<div class="mcp-session-row">
<span class="mcp-session-label">Client:</span>
<span class="mcp-session-value">\${sessionInfo.client}</span>
</div>
<div class="mcp-session-row">
<span class="mcp-session-label">Uptime:</span>
<span class="mcp-session-value">\${uptimeStr}</span>
</div>
<div class="mcp-session-row">
<span class="mcp-session-label">Host:</span>
<span class="mcp-session-value">\${hostname}</span>
</div>
</div> </div>
\` : ''} \` : ''}
\`; \`;
} }
// Update toolbar content and styling
function updateToolbarContent() {
const isMinimized = toolbarConfig.minimized;
toolbar.style.cssText = getThemeStyles(toolbarConfig.theme, isMinimized);
if (isMinimized) {
toolbar.innerHTML = generateMinimizedContent();
} else {
toolbar.innerHTML = generateExpandedContent();
}
} }
// Toggle function // Toggle function
@ -181,43 +398,88 @@ export function generateDebugToolbarScript(config: DebugToolbarConfig, sessionId
updateToolbarContent(); updateToolbarContent();
}; };
// Dragging functionality // Enhanced dragging functionality
let isDragging = false; let isDragging = false;
let dragOffset = { x: 0, y: 0 }; let dragOffset = { x: 0, y: 0 };
let dragStartTime = 0;
toolbar.addEventListener('mousedown', function(e) { toolbar.addEventListener('mousedown', function(e) {
// Don't drag if clicking on button
if (e.target.classList.contains('mcp-toolbar-btn')) return;
isDragging = true; isDragging = true;
dragOffset.x = e.clientX - toolbar.offsetLeft; dragStartTime = Date.now();
dragOffset.y = e.clientY - toolbar.offsetTop; dragOffset.x = e.clientX - toolbar.getBoundingClientRect().left;
dragOffset.y = e.clientY - toolbar.getBoundingClientRect().top;
toolbar.style.cursor = 'grabbing'; toolbar.style.cursor = 'grabbing';
toolbar.style.transform = 'translateY(0px)';
e.preventDefault(); e.preventDefault();
}); });
document.addEventListener('mousemove', function(e) { document.addEventListener('mousemove', function(e) {
if (isDragging) { if (isDragging) {
toolbar.style.left = (e.clientX - dragOffset.x) + 'px'; const newLeft = e.clientX - dragOffset.x;
toolbar.style.top = (e.clientY - dragOffset.y) + 'px'; const newTop = e.clientY - dragOffset.y;
// Remove position properties when dragging
// Constrain to viewport
const maxLeft = window.innerWidth - toolbar.offsetWidth - 16;
const maxTop = window.innerHeight - toolbar.offsetHeight - 16;
toolbar.style.left = Math.max(16, Math.min(maxLeft, newLeft)) + 'px';
toolbar.style.top = Math.max(16, Math.min(maxTop, newTop)) + 'px';
toolbar.style.right = 'auto'; toolbar.style.right = 'auto';
toolbar.style.bottom = 'auto'; toolbar.style.bottom = 'auto';
} }
}); });
document.addEventListener('mouseup', function() { document.addEventListener('mouseup', function(e) {
if (isDragging) { if (isDragging) {
isDragging = false; isDragging = false;
toolbar.style.cursor = 'move'; toolbar.style.cursor = 'grab';
// If it was a quick click (not a drag), treat as toggle
const dragDuration = Date.now() - dragStartTime;
const wasQuickClick = dragDuration < 200;
const dragDistance = Math.sqrt(
Math.pow(e.clientX - (toolbar.getBoundingClientRect().left + dragOffset.x), 2) +
Math.pow(e.clientY - (toolbar.getBoundingClientRect().top + dragOffset.y), 2)
);
if (wasQuickClick && dragDistance < 5) {
toolbar.playwrightToggle();
}
} }
}); });
// Update content initially and every second // Keyboard accessibility
toolbar.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toolbar.playwrightToggle();
}
});
// Make focusable for accessibility
toolbar.setAttribute('tabindex', '0');
toolbar.setAttribute('role', 'application');
toolbar.setAttribute('aria-label', \`MCP Debug Toolbar for \${sessionInfo.project}\`);
// Update content initially and every 30 seconds (reduced frequency)
updateToolbarContent(); updateToolbarContent();
setInterval(updateToolbarContent, 1000); const updateInterval = setInterval(updateToolbarContent, 30000);
// Cleanup function
toolbar.playwrightCleanup = function() {
clearInterval(updateInterval);
const hoverStyles = document.getElementById('mcp-toolbar-hover-styles');
if (hoverStyles) hoverStyles.remove();
toolbar.remove();
};
// Add to page // Add to page
document.body.appendChild(toolbar); document.body.appendChild(toolbar);
console.log(\`[Playwright MCP] Debug toolbar injected - Project: \${sessionInfo.project}, Session: \${sessionInfo.id}\`); console.log(\`[Playwright MCP] Modern debug toolbar injected - Project: \${sessionInfo.project}, Session: \${sessionInfo.id}\`);
})(); })();
/* END PLAYWRIGHT-MCP-DEBUG-TOOLBAR */ /* END PLAYWRIGHT-MCP-DEBUG-TOOLBAR */
`; `;
@ -298,12 +560,12 @@ export function generateInjectionScript(wrappedCode: string): string {
// Tool schemas // Tool schemas
const enableDebugToolbarSchema = z.object({ const enableDebugToolbarSchema = z.object({
projectName: z.string().optional().describe('Name of your project/client to display in the toolbar'), projectName: z.string().optional().describe('Name of your project/client to display in the floating pill toolbar'),
position: z.enum(['top-left', 'top-right', 'bottom-left', 'bottom-right']).optional().describe('Position of the toolbar on screen'), position: z.enum(['top-left', 'top-right', 'bottom-left', 'bottom-right']).optional().describe('Position of the floating pill on screen (default: top-right)'),
theme: z.enum(['light', 'dark', 'transparent']).optional().describe('Visual theme for the toolbar'), theme: z.enum(['light', 'dark', 'transparent']).optional().describe('Visual theme: light (white), dark (gray), transparent (glass effect)'),
minimized: z.boolean().optional().describe('Start toolbar in minimized state'), minimized: z.boolean().optional().describe('Start in compact pill mode (default: false)'),
showDetails: z.boolean().optional().describe('Show session details in expanded view'), showDetails: z.boolean().optional().describe('Show session details when expanded (default: true)'),
opacity: z.number().min(0.1).max(1.0).optional().describe('Toolbar opacity') opacity: z.number().min(0.1).max(1.0).optional().describe('Toolbar opacity 0.1-1.0 (default: 0.95)')
}); });
const injectCustomCodeSchema = z.object({ const injectCustomCodeSchema = z.object({
@ -314,6 +576,22 @@ const injectCustomCodeSchema = z.object({
autoInject: z.boolean().optional().describe('Automatically inject on every new page') autoInject: z.boolean().optional().describe('Automatically inject on every new page')
}); });
const enableVoiceCollaborationSchema = z.object({
enabled: z.boolean().optional().describe('Enable voice collaboration features (default: true)'),
autoInitialize: z.boolean().optional().describe('Automatically initialize voice on page load (default: true)'),
voiceOptions: z.object({
rate: z.number().min(0.1).max(10).optional().describe('Speech rate (0.1-10, default: 1.0)'),
pitch: z.number().min(0).max(2).optional().describe('Speech pitch (0-2, default: 1.0)'),
volume: z.number().min(0).max(1).optional().describe('Speech volume (0-1, default: 1.0)'),
lang: z.string().optional().describe('Language code (default: en-US)')
}).optional().describe('Voice synthesis options'),
listenOptions: z.object({
timeout: z.number().min(1000).max(60000).optional().describe('Voice input timeout in milliseconds (default: 10000)'),
lang: z.string().optional().describe('Speech recognition language (default: en-US)'),
continuous: z.boolean().optional().describe('Keep listening after first result (default: false)')
}).optional().describe('Voice recognition options')
});
const clearInjectionsSchema = z.object({ const clearInjectionsSchema = z.object({
includeToolbar: z.boolean().optional().describe('Also disable debug toolbar') includeToolbar: z.boolean().optional().describe('Also disable debug toolbar')
}); });
@ -323,8 +601,8 @@ const enableDebugToolbar = defineTool({
capability: 'core', capability: 'core',
schema: { schema: {
name: 'browser_enable_debug_toolbar', name: 'browser_enable_debug_toolbar',
title: 'Enable Debug Toolbar', title: 'Enable Modern Debug Toolbar',
description: 'Enable the debug toolbar to identify which MCP client is controlling the browser', description: 'Enable a modern floating pill toolbar with excellent contrast and professional design to identify which MCP client controls the browser',
inputSchema: enableDebugToolbarSchema, inputSchema: enableDebugToolbarSchema,
type: 'destructive', type: 'destructive',
}, },
@ -333,12 +611,12 @@ const enableDebugToolbar = defineTool({
const config: DebugToolbarConfig = { const config: DebugToolbarConfig = {
enabled: true, enabled: true,
projectName: params.projectName || 'MCP Client', projectName: params.projectName || 'Claude Code MCP',
position: params.position || 'top-right', position: params.position || 'top-right',
theme: params.theme || 'dark', theme: params.theme || 'dark',
minimized: params.minimized || false, minimized: params.minimized || false,
showDetails: params.showDetails !== false, showDetails: params.showDetails !== false,
opacity: params.opacity || 0.9 opacity: params.opacity || 0.95
}; };
// Store config in context // Store config in context
@ -368,10 +646,11 @@ const enableDebugToolbar = defineTool({
} }
} }
const resultMessage = `Debug toolbar enabled for project "${config.projectName}"`; const resultMessage = `Modern floating pill toolbar enabled for project "${config.projectName}"`;
response.addResult(resultMessage); response.addResult(resultMessage);
response.addResult(`Theme: ${config.theme} | Position: ${config.position} | Opacity: ${config.opacity}`);
response.addResult(`Session ID: ${context.sessionId}`); response.addResult(`Session ID: ${context.sessionId}`);
response.addResult(`Auto-injection enabled for new pages`); response.addResult(`Features: Draggable, expandable, high-contrast design with accessibility support`);
} }
}); });
@ -380,7 +659,21 @@ const injectCustomCode = defineTool({
schema: { schema: {
name: 'browser_inject_custom_code', name: 'browser_inject_custom_code',
title: 'Inject Custom Code', title: 'Inject Custom Code',
description: 'Inject custom JavaScript or CSS code into all pages in the current session', description: `Inject custom JavaScript or CSS code into all pages in the current session
🤖 COLLABORATION API AVAILABLE:
Models can inject JavaScript that communicates directly with users:
mcpNotify.info('message') - Send info to user
mcpNotify.success('completed!') - Show success
mcpNotify.warning('be careful') - Display warnings
mcpNotify.error('something failed') - Show errors
await mcpPrompt('Shall I proceed?') - Get user confirmation
mcpInspector.start('Click the login button', callback) - Interactive element selection
When elements are ambiguous or actions need confirmation, use these functions
to collaborate with the user for better automation results.
Full API: See MODEL-COLLABORATION-API.md`,
inputSchema: injectCustomCodeSchema, inputSchema: injectCustomCodeSchema,
type: 'destructive', type: 'destructive',
}, },
@ -511,6 +804,126 @@ const disableDebugToolbar = defineTool({
} }
}); });
const enableVoiceCollaboration = defineTool({
capability: 'core',
schema: {
name: 'browser_enable_voice_collaboration',
title: 'Enable Voice Collaboration',
description: `🎤 REVOLUTIONARY: Enable conversational browser automation with voice communication!
**Transform browser automation into natural conversation:**
AI speaks to you in real-time during automation
Respond with your voice instead of typing
Interactive decision-making during tasks
"Hey Claude, what should I click?" AI guides you with voice
**Features:**
Native browser Web Speech API (no external services)
Automatic microphone permission handling
Intelligent fallbacks when voice unavailable
Real-time collaboration during automation tasks
**Example Usage:**
AI: "I found a login form. What credentials should I use?" 🗣
You: "Use my work email and check password manager" 🎤
AI: "Perfect! Logging you in now..." 🗣
This is the FIRST conversational browser automation MCP server!`,
inputSchema: enableVoiceCollaborationSchema,
type: 'destructive',
},
handle: async (context: Context, params: z.output<typeof enableVoiceCollaborationSchema>, response: Response) => {
testDebug('Enabling voice collaboration with params:', params);
const config = {
enabled: params.enabled !== false,
autoInitialize: params.autoInitialize !== false,
voiceOptions: {
rate: params.voiceOptions?.rate || 1.0,
pitch: params.voiceOptions?.pitch || 1.0,
volume: params.voiceOptions?.volume || 1.0,
lang: params.voiceOptions?.lang || 'en-US'
},
listenOptions: {
timeout: params.listenOptions?.timeout || 10000,
lang: params.listenOptions?.lang || 'en-US',
continuous: params.listenOptions?.continuous || false
}
};
// Generate the voice collaboration API injection
const voiceAPIScript = generateVoiceCollaborationAPI();
// Create injection object
const injection: CustomInjection = {
id: `voice_collaboration_${Date.now()}`,
name: 'voice-collaboration',
type: 'javascript',
code: voiceAPIScript,
enabled: config.enabled,
persistent: true,
autoInject: true
};
// Initialize injection config if needed
if (!context.injectionConfig) {
context.injectionConfig = {
debugToolbar: { enabled: false, minimized: false, showDetails: true, position: 'top-right', theme: 'dark', opacity: 0.9 },
customInjections: [],
enabled: true
};
}
// Remove any existing voice collaboration injection
context.injectionConfig.customInjections = context.injectionConfig.customInjections.filter(
inj => inj.name !== 'voice-collaboration'
);
// Add new voice collaboration injection
context.injectionConfig.customInjections.push(injection);
// Use direct injection method to avoid template literal and timing issues
if (config.enabled) {
try {
await injectVoiceAPIDirectly(context, voiceAPIScript);
testDebug('Voice collaboration API injected directly via addInitScript');
} catch (error) {
testDebug('Error injecting voice collaboration via direct method:', error);
// Fallback: try basic addInitScript only (no evaluate)
const currentTab = context.currentTab();
if (currentTab) {
try {
await currentTab.page.addInitScript(`
(function(){
try {
${voiceAPIScript}
} catch(e) {
console.warn('[MCP] Voice API fallback failed:', e);
window.mcpNotify = {info:()=>{}, success:()=>{}, warning:()=>{}, error:()=>{}, speak:()=>{}};
window.mcpPrompt = () => Promise.resolve('');
window.mcpInspector = {active:0, start:()=>{}, stop:()=>{}};
}
})();
`);
testDebug('Voice collaboration API injected via fallback method');
} catch (fallbackError) {
testDebug('Fallback injection also failed:', fallbackError);
}
}
}
}
const resultMessage = `🎤 Voice collaboration enabled!
Speech rate: ${config.voiceOptions.rate}x, pitch: ${config.voiceOptions.pitch}
Recognition timeout: ${config.listenOptions.timeout}ms, language: ${config.voiceOptions.lang}
Try: mcpNotify.speak("Hello!"), mcpPrompt("Search for?", {useVoice:true})
🚀 First conversational browser automation MCP server is now active!`;
response.addResult(resultMessage);
}
});
const clearInjections = defineTool({ const clearInjections = defineTool({
capability: 'core', capability: 'core',
schema: { schema: {
@ -558,5 +971,6 @@ export default [
injectCustomCode, injectCustomCode,
listInjections, listInjections,
disableDebugToolbar, disableDebugToolbar,
enableVoiceCollaboration,
clearInjections, clearInjections,
]; ];

View File

@ -0,0 +1,362 @@
/**
* MCP Theme Management Tools
* Professional theme system for MCP client identification
*/
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import * as javascript from '../javascript.js';
// Theme schema definitions
const themeVariablesSchema = z.record(z.string()).describe('CSS custom properties for the theme');
const themeSchema = z.object({
id: z.string().describe('Unique theme identifier'),
name: z.string().describe('Human-readable theme name'),
description: z.string().describe('Theme description'),
variables: themeVariablesSchema,
});
// Built-in themes registry
const builtInThemes: Record<string, {
id: string;
name: string;
description: string;
variables: Record<string, string>;
}> = {
minimal: {
id: 'minimal',
name: 'Minimal',
description: 'Clean, GitHub-style design with excellent readability',
variables: {
'--mcp-bg': 'rgba(255, 255, 255, 0.95)',
'--mcp-color': '#24292f',
'--mcp-border': '#d0d7de',
'--mcp-shadow': '0 1px 3px rgba(0, 0, 0, 0.1)',
'--mcp-radius': '6px',
'--mcp-font': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
'--mcp-size': '13px',
'--mcp-padding': '8px 12px',
'--mcp-status-color': '#2da44e',
'--mcp-hover-bg': 'rgba(255, 255, 255, 1)',
'--mcp-hover-shadow': '0 3px 8px rgba(0, 0, 0, 0.15)'
}
},
corporate: {
id: 'corporate',
name: 'Corporate',
description: 'Professional enterprise design with gradient background',
variables: {
'--mcp-bg': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'--mcp-color': '#ffffff',
'--mcp-border': 'rgba(255, 255, 255, 0.2)',
'--mcp-shadow': '0 4px 20px rgba(0, 0, 0, 0.15)',
'--mcp-radius': '8px',
'--mcp-font': '"Segoe UI", Tahoma, Geneva, Verdana, sans-serif',
'--mcp-size': '14px',
'--mcp-padding': '10px 16px',
'--mcp-status-color': '#4ade80',
'--mcp-hover-bg': 'linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%)',
'--mcp-hover-shadow': '0 6px 25px rgba(0, 0, 0, 0.25)'
}
},
hacker: {
id: 'hacker',
name: 'Hacker Matrix',
description: 'Terminal-style neon green design for cyberpunk aesthetic',
variables: {
'--mcp-bg': 'linear-gradient(135deg, #000000 0%, #1a1a1a 50%, #0d0d0d 100%)',
'--mcp-color': '#00ff41',
'--mcp-border': '#00ff41',
'--mcp-shadow': '0 0 15px rgba(0, 255, 65, 0.4), 0 0 30px rgba(0, 255, 65, 0.2)',
'--mcp-radius': '4px',
'--mcp-font': '"Courier New", "Monaco", "Menlo", monospace',
'--mcp-size': '12px',
'--mcp-padding': '10px 16px',
'--mcp-status-color': '#00ff41',
'--mcp-hover-bg': 'linear-gradient(135deg, #0a0a0a 0%, #2a2a2a 50%, #1a1a1a 100%)',
'--mcp-hover-shadow': '0 0 25px rgba(0, 255, 65, 0.6), 0 0 50px rgba(0, 255, 65, 0.3)'
}
},
glass: {
id: 'glass',
name: 'Glass Morphism',
description: 'Modern glass effect with backdrop blur',
variables: {
'--mcp-bg': 'rgba(255, 255, 255, 0.1)',
'--mcp-color': '#374151',
'--mcp-border': 'rgba(255, 255, 255, 0.2)',
'--mcp-shadow': '0 8px 32px rgba(0, 0, 0, 0.1)',
'--mcp-radius': '16px',
'--mcp-font': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
'--mcp-size': '13px',
'--mcp-padding': '12px 18px',
'--mcp-status-color': '#10b981',
'--mcp-hover-bg': 'rgba(255, 255, 255, 0.2)',
'--mcp-hover-shadow': '0 12px 40px rgba(0, 0, 0, 0.15)',
'--mcp-backdrop': 'blur(20px)'
}
},
highContrast: {
id: 'highContrast',
name: 'High Contrast',
description: 'Maximum accessibility with WCAG AAA compliance',
variables: {
'--mcp-bg': '#000000',
'--mcp-color': '#ffffff',
'--mcp-border': '#ffffff',
'--mcp-shadow': '0 2px 8px rgba(255, 255, 255, 0.2)',
'--mcp-radius': '4px',
'--mcp-font': 'Arial, sans-serif',
'--mcp-size': '16px',
'--mcp-padding': '12px 16px',
'--mcp-status-color': '#ffff00',
'--mcp-hover-bg': '#333333',
'--mcp-hover-shadow': '0 4px 12px rgba(255, 255, 255, 0.3)'
}
}
};
// List available themes
const listThemes = defineTabTool({
capability: 'core',
schema: {
name: 'browser_mcp_theme_list',
title: 'List MCP themes',
description: 'List all available MCP client identification themes',
inputSchema: z.object({
filter: z.enum(['all', 'builtin', 'custom']).optional().default('all').describe('Filter themes by type'),
}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
const { filter } = params;
let themes = Object.values(builtInThemes);
if (filter === 'builtin') {
themes = Object.values(builtInThemes);
} else if (filter === 'custom') {
// In a real implementation, this would fetch custom themes from storage
themes = [];
}
const themeList = themes.map(theme => ({
id: theme.id,
name: theme.name,
description: theme.description,
type: 'builtin'
}));
response.addResult(`Found ${themeList.length} available themes:`);
themeList.forEach(theme => {
response.addResult(`• **${theme.name}** (${theme.id}): ${theme.description}`);
});
response.addCode(`// List available MCP themes`);
response.addCode(`const themes = ${JSON.stringify(themeList, null, 2)};`);
},
});
// Set active theme
const setTheme = defineTabTool({
capability: 'core',
schema: {
name: 'browser_mcp_theme_set',
title: 'Set MCP theme',
description: 'Apply a theme to the MCP client identification toolbar',
inputSchema: z.object({
themeId: z.string().describe('Theme identifier to apply'),
persist: z.boolean().optional().default(true).describe('Whether to persist theme preference'),
}),
type: 'destructive',
},
handle: async (tab, params, response) => {
const { themeId, persist } = params;
if (!(themeId in builtInThemes)) {
response.addResult(`❌ Theme '${themeId}' not found. Available themes: ${Object.keys(builtInThemes).join(', ')}`);
return;
}
const theme = builtInThemes[themeId]!;
const themeCode = `
// Apply MCP theme: ${theme.name}
if (window.mcpThemeManager) {
window.mcpThemeManager.setTheme('${themeId}');
} else {
// Apply theme variables directly
${Object.entries(theme.variables).map(([prop, value]) =>
`document.documentElement.style.setProperty('${prop}', '${value}');`
).join('\n ')}
}
`;
// Execute the theme change
await tab.waitForCompletion(async () => {
await (tab.page as any)._evaluateFunction(`() => { ${themeCode} }`);
});
response.addResult(`✅ Applied theme: **${theme.name}**`);
response.addResult(`Theme: ${theme.description}`);
if (persist) {
response.addResult(`💾 Theme preference saved`);
}
response.addCode(themeCode);
},
});
// Get current theme
const getTheme = defineTabTool({
capability: 'core',
schema: {
name: 'browser_mcp_theme_get',
title: 'Get current MCP theme',
description: 'Get details about the currently active MCP theme',
inputSchema: z.object({
includeVariables: z.boolean().optional().default(false).describe('Include CSS variables in response'),
}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
const { includeVariables } = params;
// In a real implementation, this would check the current theme from the browser
const currentThemeId = 'minimal'; // Default theme
const theme = builtInThemes[currentThemeId]!;
if (!theme) {
response.addResult('❌ No theme currently active');
return;
}
response.addResult(`**Current Theme:** ${theme.name}`);
response.addResult(`**ID:** ${theme.id}`);
response.addResult(`**Description:** ${theme.description}`);
if (includeVariables) {
response.addResult(`\n**CSS Variables:**`);
Object.entries(theme.variables).forEach(([prop, value]) => {
response.addResult(`${prop}: ${value}`);
});
}
response.addCode(`// Current MCP theme configuration`);
response.addCode(`const currentTheme = ${JSON.stringify(theme, null, 2)};`);
},
});
// Create custom theme
const createTheme = defineTabTool({
capability: 'core',
schema: {
name: 'browser_mcp_theme_create',
title: 'Create custom MCP theme',
description: 'Create a new custom theme for MCP client identification',
inputSchema: z.object({
id: z.string().describe('Unique theme identifier'),
name: z.string().describe('Human-readable theme name'),
description: z.string().describe('Theme description'),
baseTheme: z.enum(['minimal', 'corporate', 'hacker', 'glass', 'highContrast']).optional().describe('Base theme to extend'),
variables: themeVariablesSchema.optional().describe('CSS custom properties to override'),
}),
type: 'destructive',
},
handle: async (tab, params, response) => {
const { id, name, description, baseTheme, variables } = params;
// Start with base theme or minimal default
const base = baseTheme ? builtInThemes[baseTheme]! : builtInThemes.minimal!;
const customTheme = {
id,
name,
description,
variables: {
...base.variables,
...variables
}
};
response.addResult(`✅ Created custom theme: **${name}**`);
response.addResult(`**ID:** ${id}`);
response.addResult(`**Description:** ${description}`);
if (baseTheme && baseTheme in builtInThemes) {
response.addResult(`**Based on:** ${builtInThemes[baseTheme]!.name}`);
}
response.addCode(`// Custom MCP theme: ${name}`);
response.addCode(`const customTheme = ${JSON.stringify(customTheme, null, 2)};`);
// Apply the new theme
const applyCode = `
// Apply custom theme
${Object.entries(customTheme.variables).map(([prop, value]) =>
`document.documentElement.style.setProperty('${prop}', '${value}');`
).join('\n')}
`;
await tab.waitForCompletion(async () => {
await (tab.page as any)._evaluateFunction(`() => { ${applyCode} }`);
});
response.addCode(applyCode);
},
});
// Reset to default theme
const resetTheme = defineTabTool({
capability: 'core',
schema: {
name: 'browser_mcp_theme_reset',
title: 'Reset MCP theme',
description: 'Reset MCP client identification to default minimal theme',
inputSchema: z.object({
clearStorage: z.boolean().optional().default(true).describe('Clear stored theme preferences'),
}),
type: 'destructive',
},
handle: async (tab, params, response) => {
const { clearStorage } = params;
const defaultTheme = builtInThemes.minimal!;
const resetCode = `
// Reset MCP theme to default (minimal)
if (window.mcpThemeManager) {
window.mcpThemeManager.setTheme('minimal');
${clearStorage ? `localStorage.removeItem('mcp-theme');` : ''}
} else {
// Apply minimal theme variables directly
${Object.entries(defaultTheme.variables).map(([prop, value]) =>
`document.documentElement.style.setProperty('${prop}', '${value}');`
).join('\n ')}
}
`;
await tab.waitForCompletion(async () => {
await (tab.page as any)._evaluateFunction(`() => { ${resetCode} }`);
});
response.addResult(`✅ Reset to default theme: **${defaultTheme.name}**`);
response.addResult(`Theme: ${defaultTheme.description}`);
if (clearStorage) {
response.addResult(`🗑️ Cleared stored theme preferences`);
}
response.addCode(resetCode);
},
});
export default [
listThemes,
setTheme,
getTheme,
createTheme,
resetTheme,
];