<template>
<div class="h-full w-full bg-slate-50 relative agent-theme" :data-agent-theme="currentTheme">
<!-- Sidepanel Navigator - only show on workflows/element-markers pages -->
<SidepanelNavigator
v-if="activeTab !== 'agent-chat'"
:activeTab="activeTab"
@change="handleTabChange"
/>
<!-- Workflows Tab -->
<div v-show="activeTab === 'workflows'" class="h-full">
<WorkflowsView
:flows="filtered"
:runs="runs"
:triggers="triggers"
:only-bound="onlyBound"
:open-run-id="openRunId"
@refresh="handleWorkflowRefresh"
@create="createFlow"
@run="run"
@edit="edit"
@delete="remove"
@export="exportFlow"
@update:only-bound="onlyBound = $event"
@toggle-run="toggleRun"
@create-trigger="createTrigger"
@edit-trigger="editTrigger"
@remove-trigger="removeTrigger"
/>
</div>
<!-- Agent Chat Tab -->
<div v-show="activeTab === 'agent-chat'" class="h-full">
<AgentChat />
</div>
<!-- Element Markers Tab -->
<div v-show="activeTab === 'element-markers'" class="element-markers-content">
<div class="px-4 py-4">
<!-- Toolbar: Search + Add Button -->
<div class="em-toolbar">
<div class="em-search-wrapper">
<svg class="em-search-icon" viewBox="0 0 20 20" width="16" height="16">
<path
fill="currentColor"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
/>
</svg>
<input
v-model="markerSearch"
class="em-search-input"
placeholder="搜索标注名称、选择器..."
type="text"
/>
<button
v-if="markerSearch"
class="em-search-clear"
type="button"
@click="markerSearch = ''"
>
<svg viewBox="0 0 20 20" width="14" height="14">
<path
fill="currentColor"
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<button class="em-add-btn" @click="openMarkerEditor()" title="新增标注">
<svg viewBox="0 0 20 20" width="18" height="18">
<path
fill="currentColor"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
/>
</svg>
</button>
</div>
<!-- Modal: Add/Edit Marker -->
<div v-if="markerEditorOpen" class="em-modal-overlay" @click.self="closeMarkerEditor">
<div class="em-modal">
<div class="em-modal-header">
<h3 class="em-modal-title">{{ editingMarkerId ? '编辑标注' : '新增标注' }}</h3>
<button class="em-modal-close" @click="closeMarkerEditor">
<svg viewBox="0 0 20 20" width="18" height="18">
<path
fill="currentColor"
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<form @submit.prevent="saveMarker" class="em-form">
<div class="em-form-row">
<div class="em-field">
<label class="em-field-label">名称</label>
<input
v-model="markerForm.name"
class="em-input"
placeholder="例如: 登录按钮"
required
/>
</div>
</div>
<div class="em-form-row em-form-row-multi">
<div class="em-field">
<label class="em-field-label">选择器类型</label>
<div class="em-select-wrapper">
<select v-model="markerForm.selectorType" class="em-select">
<option value="css">CSS Selector</option>
<option value="xpath">XPath</option>
</select>
</div>
</div>
<div class="em-field">
<label class="em-field-label">匹配类型</label>
<div class="em-select-wrapper">
<select v-model="markerForm.matchType" class="em-select">
<option value="prefix">路径前缀</option>
<option value="exact">精确匹配</option>
<option value="host">域名</option>
</select>
</div>
</div>
</div>
<div class="em-form-row">
<div class="em-field">
<label class="em-field-label">选择器</label>
<textarea
v-model="markerForm.selector"
class="em-textarea"
placeholder="CSS 选择器或 XPath"
rows="3"
required
></textarea>
</div>
</div>
<div class="em-modal-actions">
<button type="button" class="em-btn em-btn-ghost" @click="closeMarkerEditor">
取消
</button>
<button type="submit" class="em-btn em-btn-primary">
{{ editingMarkerId ? '更新' : '保存' }}
</button>
</div>
</form>
</div>
</div>
<!-- Markers List -->
<div v-if="filteredMarkers.length > 0" class="em-list">
<!-- Statistics (compact) -->
<div class="em-stats-bar">
<span class="em-stats-text">
<template v-if="markerSearch">
筛选出 <strong>{{ filteredMarkers.length }}</strong> 个标注 (共
{{ markers.length }} 个,{{ groupedMarkers.length }} 个域名)
</template>
<template v-else>
共 <strong>{{ markers.length }}</strong> 个标注,
<strong>{{ groupedMarkers.length }}</strong> 个域名
</template>
</span>
</div>
<!-- Grouped Markers by Domain -->
<div
v-for="domainGroup in groupedMarkers"
:key="domainGroup.domain"
class="em-domain-group"
>
<!-- Domain Header -->
<div class="em-domain-header" @click="toggleDomain(domainGroup.domain)">
<div class="em-domain-info">
<svg
class="em-domain-icon"
:class="{ 'em-domain-icon-expanded': expandedDomains.has(domainGroup.domain) }"
viewBox="0 0 20 20"
width="16"
height="16"
>
<path fill="currentColor" d="M6 8l4 4 4-4" />
</svg>
<h3 class="em-domain-name">{{ domainGroup.domain }}</h3>
<span class="em-domain-count">{{ domainGroup.count }} 个标注</span>
</div>
</div>
<!-- URLs and Markers -->
<div v-if="expandedDomains.has(domainGroup.domain)" class="em-domain-content">
<div class="em-content-wrapper">
<div v-for="urlGroup in domainGroup.urls" :key="urlGroup.url" class="em-url-group">
<div class="em-url-header">
<svg class="em-url-icon" viewBox="0 0 16 16" width="12" height="12">
<path
fill="currentColor"
d="M4 4a1 1 0 011-1h6a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1V4zm2 1v1h4V5H6zm0 3v1h4V8H6z"
/>
</svg>
<span class="em-url-path">{{ urlGroup.url }}</span>
</div>
<div class="em-markers-list">
<div v-for="marker in urlGroup.markers" :key="marker.id" class="em-marker-item">
<div class="em-marker-row-top">
<span class="em-marker-name">{{ marker.name }}</span>
<div class="em-marker-actions">
<button
class="em-action-btn em-action-verify"
@click="validateMarker(marker)"
title="验证"
>
<svg viewBox="0 0 24 24" width="14" height="14">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
class="em-action-btn em-action-edit"
@click="editMarker(marker)"
title="编辑"
>
<svg viewBox="0 0 24 24" width="14" height="14">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
class="em-action-btn em-action-delete"
@click="deleteMarker(marker)"
title="删除"
>
<svg viewBox="0 0 24 24" width="14" height="14">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<div class="em-marker-row-bottom">
<code class="em-marker-selector" :title="marker.selector">{{
marker.selector
}}</code>
<div class="em-marker-tags">
<span class="em-tag">{{ marker.selectorType || 'css' }}</span>
<span class="em-tag">{{ marker.matchType }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- No search results -->
<div v-else-if="markers.length > 0 && filteredMarkers.length === 0" class="em-empty">
<p>未找到匹配的标注</p>
<button class="em-btn em-btn-ghost em-empty-btn" @click="markerSearch = ''">
清除搜索
</button>
</div>
<!-- Empty state -->
<div v-else class="em-empty">
<p>暂无标注元素</p>
<button class="em-btn em-btn-primary em-empty-btn" @click="openMarkerEditor()">
新增标注
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, onUnmounted, watch } from 'vue';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
import AgentChat from './components/AgentChat.vue';
import SidepanelNavigator from './components/SidepanelNavigator.vue';
import { WorkflowsView } from './components/workflows';
import { useAgentTheme } from './composables/useAgentTheme';
import { useWorkflowsV3, type FlowLite } from './composables/useWorkflowsV3';
// Agent theme for consistent styling
const { theme: currentTheme, initTheme } = useAgentTheme();
// Tab state - default to AgentChat
const activeTab = ref<'workflows' | 'element-markers' | 'agent-chat'>('agent-chat');
// Handle tab change and update URL for deep linking
function handleTabChange(tab: 'workflows' | 'element-markers' | 'agent-chat') {
activeTab.value = tab;
// Update URL params for deep link
const url = new URL(window.location.href);
url.searchParams.set('tab', tab);
history.replaceState(null, '', url.toString());
// Note: loadMarkers is already called by the watch on activeTab, no need to call here
}
// Workflows state - using V3 data layer
const workflowsV3 = useWorkflowsV3({ autoConnect: true });
const { flows, runs, triggers } = workflowsV3;
const onlyBound = ref(false);
const search = ref('');
const currentUrl = ref('');
const openRunId = ref<string | null>(null);
// Element markers state
const currentPageUrl = ref('');
const markers = ref<ElementMarker[]>([]);
const editingMarkerId = ref<string | null>(null);
const markerForm = ref<UpsertMarkerRequest>({
url: '',
name: '',
selector: '',
selectorType: 'css',
matchType: 'prefix',
});
const expandedDomains = ref<Set<string>>(new Set());
const markerSearch = ref('');
const markerEditorOpen = ref(false);
// Filter markers based on search term
const filteredMarkers = computed(() => {
const query = markerSearch.value.trim().toLowerCase();
if (!query) return markers.value;
return markers.value.filter((m) => {
const name = (m.name || '').toLowerCase();
const selector = (m.selector || '').toLowerCase();
const url = (m.url || '').toLowerCase();
return name.includes(query) || selector.includes(query) || url.includes(query);
});
});
// Group markers by domain and URL
const groupedMarkers = computed(() => {
const groups = new Map<string, Map<string, ElementMarker[]>>();
for (const marker of filteredMarkers.value) {
// Use pre-normalized fields from storage instead of reparsing URLs
const domain = marker.host || '(本地文件)';
const fullUrl = marker.url || '(未知URL)';
if (!groups.has(domain)) {
groups.set(domain, new Map());
}
const domainGroup = groups.get(domain)!;
if (!domainGroup.has(fullUrl)) {
domainGroup.set(fullUrl, []);
}
domainGroup.get(fullUrl)!.push(marker);
}
// Convert to array and sort
return Array.from(groups.entries())
.map(([domain, urlMap]) => ({
domain,
count: Array.from(urlMap.values()).reduce((sum, arr) => sum + arr.length, 0),
urls: Array.from(urlMap.entries())
.map(([url, markers]) => ({ url, markers }))
.sort((a, b) => a.url.localeCompare(b.url)),
}))
.sort((a, b) => a.domain.localeCompare(b.domain));
});
const totalMarkersCount = computed(() => filteredMarkers.value.length);
const filtered = computed(() => {
const list = onlyBound.value ? flows.value.filter(isBoundToCurrent) : flows.value;
const q = search.value.trim().toLowerCase();
if (!q) return list;
return list.filter((f) => {
const name = String(f.name || '').toLowerCase();
const domain = String(f?.meta?.domain || '').toLowerCase();
const tags = ((f?.meta?.tags || []) as any[]).join(',').toLowerCase();
return name.includes(q) || domain.includes(q) || tags.includes(q);
});
});
function isBoundToCurrent(f: FlowLite) {
try {
const bindings = f?.meta?.bindings || [];
if (!bindings.length) return false;
if (!currentUrl.value) return true;
const u = new URL(currentUrl.value);
return bindings.some((b: any) => {
// Support both V3 'kind' and V2 'type' field names
const bindingType = b.kind || b.type;
if (bindingType === 'domain') return u.hostname.includes(b.value);
if (bindingType === 'path') return u.pathname.startsWith(b.value);
if (bindingType === 'url') return (u.href || '').startsWith(b.value);
return false;
});
} catch {
return false;
}
}
// V3 Workflows methods - delegating to composable
async function handleWorkflowRefresh() {
await workflowsV3.refresh();
}
async function exportFlow(id: string) {
try {
const flowData = await workflowsV3.exportFlow(id);
if (flowData) {
const blob = new Blob([JSON.stringify(flowData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `workflow-${id}.json`;
a.click();
URL.revokeObjectURL(url);
}
} catch (e) {
console.warn('Export failed:', e);
}
}
function createTrigger() {
// V3 Trigger management not yet implemented
alert('V3 Trigger 管理尚未实现,暂时无法创建触发器');
}
function editTrigger(_id: string) {
// V3 Trigger management not yet implemented
alert('V3 Trigger 管理尚未实现,暂时无法编辑触发器');
}
async function removeTrigger(id: string) {
await workflowsV3.deleteTrigger(id);
}
function toggleRun(id: string) {
openRunId.value = openRunId.value === id ? null : id;
}
async function run(id: string) {
try {
const result = await workflowsV3.runFlow(id);
if (!result) console.warn('回放失败');
} catch {}
}
function edit(id: string) {
// V3 Builder not yet implemented - show message
alert('V3 Builder 尚未实现,暂时无法编辑工作流');
// TODO: openBuilder({ flowId: id });
}
function createFlow() {
// V3 Builder not yet implemented - show message
alert('V3 Builder 尚未实现,暂时无法创建工作流');
// TODO: openBuilder({ newFlow: true });
}
async function remove(id: string) {
try {
const ok = confirm('确认删除该工作流?此操作不可恢复');
if (!ok) return;
await workflowsV3.deleteFlow(id);
} catch {}
}
function openBuilder(opts: { flowId?: string; newFlow?: boolean }) {
// Open dedicated builder window for better UX
const url = new URL(chrome.runtime.getURL('builder.html'));
if (opts.flowId) url.searchParams.set('flowId', opts.flowId);
if (opts.newFlow) url.searchParams.set('new', '1');
chrome.windows.create({ url: url.toString(), type: 'popup', width: 1280, height: 800 });
}
// Element markers functions
function openMarkerEditor(marker?: ElementMarker) {
if (marker) {
editingMarkerId.value = marker.id;
markerForm.value = {
url: marker.url,
name: marker.name,
selector: marker.selector,
selectorType: marker.selectorType || 'css',
listMode: marker.listMode,
matchType: marker.matchType || 'prefix',
action: marker.action,
};
} else {
resetForm();
}
markerEditorOpen.value = true;
}
function closeMarkerEditor() {
markerEditorOpen.value = false;
resetForm();
}
function resetForm() {
markerForm.value = {
url: currentPageUrl.value,
name: '',
selector: '',
selectorType: 'css',
matchType: 'prefix',
};
editingMarkerId.value = null;
}
async function loadMarkers() {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
currentPageUrl.value = String(tab?.url || '');
// Only update form URL when not editing - prevents polluting edited marker's URL
if (!editingMarkerId.value) {
markerForm.value.url = currentPageUrl.value;
}
// Load all markers from all pages
const res: any = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_ALL,
});
if (res?.success) {
markers.value = res.markers || [];
}
} catch (e) {
console.error('Failed to load markers:', e);
}
}
async function saveMarker() {
try {
if (!markerForm.value.selector) return;
const isEditing = !!editingMarkerId.value;
// Only set URL for new markers, not when editing existing ones
if (!isEditing) {
markerForm.value.url = currentPageUrl.value;
}
let res: any;
if (isEditing) {
// Use UPDATE for editing to preserve createdAt
const existingMarker = markers.value.find((m) => m.id === editingMarkerId.value);
if (existingMarker) {
const updatedMarker: ElementMarker = {
...existingMarker,
...markerForm.value,
id: editingMarkerId.value!,
};
res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_UPDATE,
marker: updatedMarker,
});
} else {
// Fallback to SAVE if existing marker not found in local state
console.warn('Editing marker not found in local state, falling back to SAVE');
res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE,
marker: { ...markerForm.value, id: editingMarkerId.value },
});
}
} else {
// Use SAVE for new markers
res = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE,
marker: { ...markerForm.value },
});
}
if (res?.success) {
closeMarkerEditor();
await loadMarkers();
}
} catch (e) {
console.error('Failed to save marker:', e);
}
}
function editMarker(marker: ElementMarker) {
openMarkerEditor(marker);
}
function cancelEdit() {
closeMarkerEditor();
}
async function deleteMarker(marker: ElementMarker) {
try {
const confirmed = confirm(`确定要删除标注 "${marker.name}" 吗?`);
if (!confirmed) return;
const res: any = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE,
id: marker.id,
});
if (res?.success) {
await loadMarkers();
}
} catch (e) {
console.error('Failed to delete marker:', e);
}
}
async function validateMarker(marker: ElementMarker) {
try {
const res: any = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE,
selector: marker.selector,
selectorType: marker.selectorType || 'css',
action: 'hover',
listMode: !!marker.listMode,
} as any);
// Trigger highlight in the page
if (res?.tool?.ok !== false) {
await highlightInTab(marker);
}
} catch (e) {
console.error('Failed to validate marker:', e);
}
}
/**
* Check if element-marker.js is already injected in the tab
* Uses a short timeout to avoid hanging on unresponsive tabs
*/
async function isMarkerInjected(tabId: number): Promise<boolean> {
try {
const response = await Promise.race([
chrome.tabs.sendMessage(tabId, { action: 'element_marker_ping' }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 300)),
]);
return response?.status === 'pong';
} catch {
return false;
}
}
async function highlightInTab(marker: ElementMarker) {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tabId = tabs[0]?.id;
if (!tabId) return;
// Check if already injected via ping to avoid duplicate injection
const alreadyInjected = await isMarkerInjected(tabId);
if (!alreadyInjected) {
try {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inject-scripts/element-marker.js'],
world: 'ISOLATED',
});
} catch {
// Script injection may fail on some pages
}
}
// Send highlight message to content script
await chrome.tabs.sendMessage(tabId, {
action: 'element_marker_highlight',
selector: marker.selector,
selectorType: marker.selectorType || 'css',
listMode: !!marker.listMode,
});
} catch (e) {
// Ignore errors (tab might not support content scripts)
console.error('Failed to highlight in tab:', e);
}
}
function toggleDomain(domain: string) {
if (expandedDomains.value.has(domain)) {
expandedDomains.value.delete(domain);
} else {
expandedDomains.value.add(domain);
}
// Trigger reactivity
expandedDomains.value = new Set(expandedDomains.value);
}
// Watch tab changes to load data
watch(activeTab, async (newTab, oldTab) => {
// Only load if tab actually changed (avoid double-loading on mount)
if (newTab === 'element-markers' && oldTab !== undefined) {
await loadMarkers();
}
});
// Auto-expand domains when search matches
watch(markerSearch, (query) => {
if (!query.trim()) return;
// Expand all domains that have matching markers
const domainsToExpand = new Set<string>();
for (const group of groupedMarkers.value) {
domainsToExpand.add(group.domain);
}
expandedDomains.value = domainsToExpand;
});
onMounted(async () => {
// Initialize theme
await initTheme();
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentUrl.value = String(tab?.url || '');
} catch {}
// Check URL params for initial tab
const params = new URLSearchParams(window.location.search);
const tabParam = params.get('tab');
if (tabParam === 'element-markers') {
activeTab.value = 'element-markers';
await loadMarkers();
} else if (tabParam === 'agent-chat') {
activeTab.value = 'agent-chat';
} else if (tabParam === 'workflows') {
activeTab.value = 'workflows';
}
// V3 workflows data is auto-refreshed by useWorkflowsV3 composable
// No need to manually call refresh here
// V2 push-based refresh is no longer needed - V3 uses event subscription
// Keeping commented for reference:
// const onMessage = (message: { type?: string }) => {
// if (message?.type === BACKGROUND_MESSAGE_TYPES.RR_FLOWS_CHANGED) refresh();
// };
// chrome.runtime.onMessage.addListener(onMessage);
});
onUnmounted(() => {
// V3 workflows cleanup is handled by useWorkflowsV3 composable
// No additional cleanup needed
});
</script>
<style scoped>
/* reuse popup styles; only tune list item spacing for sidepanel width */
.rr-item {
margin-bottom: 8px;
}
.rr-actions button {
margin-left: 6px;
}
/* Element Markers Styles - Using agent-theme tokens */
.element-markers-content {
padding-bottom: 24px;
color: var(--ac-text, #262626);
}
.em-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.em-form-row {
display: flex;
flex-direction: column;
gap: 12px;
}
.em-form-row-multi {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.em-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.em-field-label {
font-size: 12px;
font-weight: 500;
color: var(--ac-text-subtle, #737373);
}
.em-input {
width: 100%;
height: 44px;
padding: 0 16px;
background: var(--ac-surface-muted, #f5f5f5);
border: none;
border-radius: var(--ac-radius-inner, 10px);
font-size: 14px;
color: var(--ac-text, #262626);
font-family: inherit;
outline: none;
transition: background var(--ac-motion-fast, 150ms) ease;
}
.em-input:focus {
background: var(--ac-hover-bg, #e5e5e5);
}
.em-textarea {
width: 100%;
min-height: 80px;
padding: 12px 16px;
background: var(--ac-surface-muted, #f5f5f5);
border: none;
border-radius: var(--ac-radius-inner, 10px);
font-size: 14px;
color: var(--ac-text, #262626);
font-family: var(--ac-font-mono, 'Monaco', 'Menlo', 'Ubuntu Mono', monospace);
outline: none;
transition: background var(--ac-motion-fast, 150ms) ease;
resize: vertical;
}
.em-textarea:focus {
background: var(--ac-hover-bg, #e5e5e5);
}
.em-select-wrapper {
position: relative;
}
.em-select {
width: 100%;
height: 44px;
padding: 0 40px 0 16px;
background: var(--ac-surface-muted, #f5f5f5);
border: none;
border-radius: var(--ac-radius-inner, 10px);
font-size: 14px;
color: var(--ac-text, #262626);
font-family: inherit;
outline: none;
cursor: pointer;
appearance: none;
}
.em-select-wrapper::after {
content: '';
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--ac-text-subtle, #737373);
pointer-events: none;
}
.em-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.em-btn {
flex: 1;
height: 44px;
border: none;
border-radius: var(--ac-radius-button, 10px);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--ac-motion-fast, 150ms) ease;
}
.em-btn-primary {
background: var(--ac-accent, #d97757);
color: var(--ac-accent-contrast, #ffffff);
}
.em-btn-primary:hover {
background: var(--ac-accent-hover, #c4664a);
transform: translateY(-1px);
box-shadow: var(--ac-shadow-float, 0 4px 12px rgba(0, 0, 0, 0.15));
}
.em-btn-ghost {
background: var(--ac-surface-muted, #f5f5f5);
color: var(--ac-text, #404040);
}
.em-btn-ghost:hover {
background: var(--ac-hover-bg, #e5e5e5);
}
.em-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.em-empty {
text-align: center;
padding: 48px 20px;
color: var(--ac-text-subtle, #a3a3a3);
font-size: 14px;
}
/* Toolbar */
.em-toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
align-items: center;
}
.em-search-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.em-search-icon {
position: absolute;
left: 12px;
color: var(--ac-text-muted, #737373);
pointer-events: none;
}
.em-search-input {
width: 100%;
height: 40px;
padding: 0 36px;
background: var(--ac-surface-muted, #f5f5f5);
border: none;
border-radius: var(--ac-radius-inner, 10px);
font-size: 14px;
color: var(--ac-text, #262626);
outline: none;
transition: background var(--ac-motion-fast, 150ms) ease;
}
.em-search-input:focus {
background: var(--ac-hover-bg, #e5e5e5);
}
.em-search-input::placeholder {
color: var(--ac-text-muted, #a3a3a3);
}
.em-search-clear {
position: absolute;
right: 8px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 50%;
color: var(--ac-text-muted, #737373);
cursor: pointer;
transition: all var(--ac-motion-fast, 150ms) ease;
}
.em-search-clear:hover {
background: var(--ac-hover-bg, #e5e5e5);
color: var(--ac-text, #262626);
}
.em-add-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--ac-accent, #d97757);
border: none;
border-radius: var(--ac-radius-button, 10px);
color: var(--ac-accent-contrast, #ffffff);
cursor: pointer;
transition: all var(--ac-motion-fast, 150ms) ease;
flex-shrink: 0;
}
.em-add-btn:hover {
background: var(--ac-accent-hover, #c4664a);
transform: translateY(-1px);
box-shadow: var(--ac-shadow-float, 0 4px 12px rgba(0, 0, 0, 0.15));
}
/* Modal */
.em-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 150ms ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.em-modal {
width: calc(100% - 32px);
max-width: 480px;
max-height: calc(100vh - 64px);
background: var(--ac-surface, #ffffff);
border-radius: var(--ac-radius-card, 12px);
box-shadow: var(--ac-shadow-float, 0 8px 32px rgba(0, 0, 0, 0.2));
overflow: hidden;
animation: slideUp 200ms ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.em-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--ac-border, #e5e5e5);
}
.em-modal-title {
font-size: 16px;
font-weight: 600;
color: var(--ac-text, #262626);
margin: 0;
}
.em-modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--ac-radius-button, 8px);
color: var(--ac-text-muted, #737373);
cursor: pointer;
transition: all var(--ac-motion-fast, 150ms) ease;
}
.em-modal-close:hover {
background: var(--ac-hover-bg, #f5f5f5);
color: var(--ac-text, #262626);
}
.em-modal .em-form {
padding: 20px;
}
.em-modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
.em-modal-actions .em-btn {
flex: none;
min-width: 80px;
}
/* Statistics Bar (compact) */
.em-stats-bar {
padding: 10px 16px;
background: var(--ac-surface-muted, #f5f5f5);
border-radius: var(--ac-radius-inner, 8px);
}
.em-stats-text {
font-size: 13px;
color: var(--ac-text-muted, #737373);
}
.em-stats-text strong {
color: var(--ac-text, #262626);
font-weight: 600;
}
.em-domain-header {
background: var(--ac-surface, #ffffff);
border: var(--ac-border-width, 1px) solid var(--ac-border, #e7e5e4);
border-radius: var(--ac-radius-card, 12px);
padding: 6px 12px;
cursor: pointer;
transition: all var(--ac-motion-fast, 150ms) ease;
user-select: none;
}
.em-domain-header:hover {
background: var(--ac-hover-bg, #f5f5f4);
box-shadow: var(--ac-shadow-float, 0 4px 12px rgba(0, 0, 0, 0.1));
}
.em-domain-info {
display: flex;
align-items: center;
gap: 12px;
}
.em-domain-icon {
flex-shrink: 0;
color: var(--ac-text-muted, #525252);
transition: transform var(--ac-motion-fast, 150ms) ease;
}
.em-domain-icon-expanded {
transform: rotate(0deg);
}
.em-domain-icon:not(.em-domain-icon-expanded) {
transform: rotate(-90deg);
}
.em-domain-name {
font-size: 16px;
font-weight: 600;
color: var(--ac-text, #262626);
margin: 0;
flex: 1;
}
.em-domain-count {
font-size: 13px;
color: var(--ac-text-muted, #737373);
background: var(--ac-surface-muted, rgba(255, 255, 255, 0.6));
padding: 4px 12px;
border-radius: var(--ac-radius-button, 12px);
font-weight: 500;
}
/* Domain Content */
.em-domain-content {
animation: slideDown 200ms ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Content wrapper with left border for visual hierarchy */
.em-content-wrapper {
margin-left: 8px;
margin-top: 8px;
padding-left: 12px;
border-left: 2px solid var(--ac-border, #e5e5e5);
}
/* URL Group */
.em-url-group {
margin-bottom: 12px;
}
.em-url-group:last-child {
margin-bottom: 0;
}
.em-url-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 0;
}
.em-url-icon {
color: var(--ac-text-muted, #a3a3a3);
flex-shrink: 0;
}
.em-url-path {
font-size: 12px;
color: var(--ac-text-muted, #737373);
font-family: var(--ac-font-mono, 'Monaco', 'Menlo', 'Ubuntu Mono', monospace);
word-break: break-all;
line-height: 1.4;
}
/* Markers List */
.em-markers-list {
display: flex;
flex-direction: column;
gap: 2px;
}
/* Marker Item - Two row layout */
.em-marker-item {
padding: 8px 10px;
border-radius: var(--ac-radius-inner, 6px);
background: var(--ac-hover-bg, rgba(0, 0, 0, 0.03));
margin-bottom: 4px;
}
.em-marker-item:last-child {
margin-bottom: 0;
}
.em-marker-item:hover {
background: var(--ac-hover-bg, rgba(0, 0, 0, 0.05));
}
/* Top row: name + actions */
.em-marker-row-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.em-marker-name {
font-size: 13px;
font-weight: 600;
color: var(--ac-text, #262626);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.em-marker-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.em-action-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--ac-radius-button, 6px);
cursor: pointer;
transition: all var(--ac-motion-fast, 150ms) ease;
}
.em-action-btn svg {
fill: none;
stroke: currentColor;
stroke-width: 2;
}
.em-action-btn.em-action-verify {
background: var(--ac-accent-subtle, rgba(217, 119, 87, 0.1));
color: var(--ac-accent, #d97757);
}
.em-action-btn.em-action-verify:hover {
background: var(--ac-accent-subtle, rgba(217, 119, 87, 0.18));
}
.em-action-btn.em-action-edit {
background: var(--ac-surface-muted, #f5f5f5);
color: var(--ac-text-muted, #737373);
}
.em-action-btn.em-action-edit:hover {
background: var(--ac-hover-bg, #e5e5e5);
color: var(--ac-text, #262626);
}
.em-action-btn.em-action-delete {
background: var(--ac-danger-subtle, rgba(239, 68, 68, 0.08));
color: var(--ac-danger, #ef4444);
}
.em-action-btn.em-action-delete:hover {
background: var(--ac-danger-subtle, rgba(239, 68, 68, 0.15));
}
/* Bottom row: selector + tags */
.em-marker-row-bottom {
display: flex;
align-items: center;
gap: 8px;
}
.em-marker-selector {
font-size: 11px;
font-family: var(--ac-font-mono, 'Monaco', 'Menlo', 'Ubuntu Mono', monospace);
color: var(--ac-text-muted, #737373);
background: var(--ac-surface-muted, #f5f5f5);
padding: 2px 6px;
border-radius: 4px;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
}
.em-marker-tags {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.em-tag {
font-size: 9px;
padding: 2px 5px;
background: transparent;
color: var(--ac-text-muted, #a3a3a3);
border: 1px solid var(--ac-border, #e5e5e5);
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
/* Empty state button */
.em-empty-btn {
margin-top: 16px;
width: auto;
padding: 0 24px;
}
</style>