diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 15b7e24..775144c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -30,7 +30,8 @@ "Bash(pnpm compile:*)", "Bash(pnpm run compile)", "Bash(pnpm eslint:*)", - "Bash(pnpm add:*)" + "Bash(pnpm add:*)", + "Bash(pnpm test:*)" ], "deny": [], "ask": [] diff --git a/app/chrome-extension/common/constants.ts b/app/chrome-extension/common/constants.ts index 3d3b4b9..17f2ba6 100644 --- a/app/chrome-extension/common/constants.ts +++ b/app/chrome-extension/common/constants.ts @@ -79,6 +79,7 @@ export const FILE_TYPES = { export const NETWORK_FILTERS = { // Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr' EXCLUDED_DOMAINS: [ + // Google 'google-analytics.com', 'googletagmanager.com', 'analytics.google.com', @@ -88,20 +89,37 @@ export const NETWORK_FILTERS = { 'stats.g.doubleclick.net', 'adservice.google.com', 'pagead2.googlesyndication.com', + // Amazon 'amazon-adsystem.com', + // Microsoft 'bat.bing.com', 'clarity.ms', + // Facebook 'connect.facebook.net', 'facebook.com/tr', + // Twitter 'analytics.twitter.com', - 'static.hotjar.com', - 'script.hotjar.com', + 'ads-twitter.com', + // Other ad networks + 'ads.yahoo.com', + 'adroll.com', + 'adnxs.com', + 'criteo.com', + 'quantserve.com', + 'scorecardresearch.com', + // Analytics & session recording 'segment.io', 'amplitude.com', 'mixpanel.com', 'optimizely.com', - 'scorecardresearch.com', - 'quantserve.com', + 'static.hotjar.com', + 'script.hotjar.com', + 'crazyegg.com', + 'clicktale.net', + 'mouseflow.com', + 'fullstory.com', + // LinkedIn (tracking pixels) + 'linkedin.com/px', ], // Static resource extensions (used when includeStatic=false) STATIC_RESOURCE_EXTENSIONS: [ diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts index bb69c84..318cfb8 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts @@ -20,6 +20,7 @@ import type { ActionPolicy, ExecutableAction, ExecutableActionType, + ExecutionFlags, VariableStore, } from './types'; @@ -87,6 +88,8 @@ export function execCtxToActionCtx( stepId?: string; runId?: string; pushLog?: (entry: unknown) => void; + /** Execution flags to pass to action handlers */ + execution?: ExecutionFlags; }, ): ActionExecutionContext { // Use provided stepId for proper log attribution, fallback to 'action' only if not provided @@ -104,6 +107,7 @@ export function execCtxToActionCtx( }); }, pushLog: options?.pushLog, + execution: options?.execution, }; } @@ -376,6 +380,31 @@ export type StepExecutionAttempt = | { supported: true; result: ExecResult } | { supported: false; reason: string }; +/** + * Options for step executor + */ +export interface StepExecutorOptions { + runId?: string; + pushLog?: (entry: unknown) => void; + /** + * If true, throws on unsupported step types instead of returning { supported: false } + * Use this in strict mode where all steps must go through ActionRegistry + */ + strict?: boolean; + /** + * Skip ActionRegistry retry policy. + * When true, the action's retry policy is removed before execution. + * Use this when StepRunner already handles retry via withRetry(). + */ + skipRetry?: boolean; + /** + * Skip navigation waiting inside action handlers. + * When true, handlers like click/navigate skip their internal nav-wait logic. + * Use this when StepRunner already handles navigation waiting. + */ + skipNavWait?: boolean; +} + /** * Create a step executor that uses ActionRegistry * @@ -391,18 +420,10 @@ export function createStepExecutor(registry: ActionRegistry) { ctx: ExecCtx, step: Step, tabId: number, - options?: { - runId?: string; - pushLog?: (entry: unknown) => void; - /** - * If true, throws on unsupported step types instead of returning { supported: false } - * Use this in strict mode where all steps must go through ActionRegistry - */ - strict?: boolean; - }, + options?: StepExecutorOptions, ): Promise { // Convert step to action - const action = stepToAction(step); + let action = stepToAction(step); if (!action) { const reason = `Unsupported step type for ActionRegistry: ${step.type}`; @@ -412,6 +433,12 @@ export function createStepExecutor(registry: ActionRegistry) { return { supported: false, reason }; } + // Skip retry policy if StepRunner handles it + // This avoids double retry: StepRunner.withRetry() + ActionRegistry.retry + if (options?.skipRetry === true && action.policy?.retry) { + action = { ...action, policy: { ...action.policy, retry: undefined } }; + } + // Check if handler exists const handler = registry.get(action.type); if (!handler) { @@ -422,11 +449,16 @@ export function createStepExecutor(registry: ActionRegistry) { return { supported: false, reason }; } + // Build execution flags for handlers + const execution: ExecutionFlags | undefined = + options?.skipNavWait === true ? { skipNavWait: true } : undefined; + // Convert context with proper stepId for log attribution const actionCtx = execCtxToActionCtx(ctx, tabId, { stepId: step.id, runId: options?.runId, pushLog: options?.pushLog, + execution, }); // Execute via registry (includes retry, timeout, hooks) diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts index cc86db2..b4acbe2 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts @@ -41,6 +41,8 @@ async function executeClick( ): Promise> { const vars = ctx.vars; const tabId = ctx.tabId; + // Check if StepRunner owns nav-wait (skip internal nav-wait logic) + const skipNavWait = ctx.execution?.skipNavWait === true; if (typeof tabId !== 'number') { return failed('TAB_NOT_FOUND', 'No active tab found'); @@ -49,7 +51,8 @@ async function executeClick( // Ensure page is read before locating element await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} }); - const beforeUrl = await readTabUrl(tabId); + // Only read beforeUrl if we need to do nav-wait + const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId); const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget( action.params.target, vars, @@ -108,7 +111,12 @@ async function executeClick( logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy)); } - // Post-click wait handling + // Skip post-click wait if StepRunner handles it + if (skipNavWait) { + return { status: 'success' }; + } + + // Post-click wait handling (only when handler owns nav-wait) const waitMs = clampInt( action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, 0, diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts index 38330ad..0a2c974 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts @@ -33,17 +33,22 @@ export const navigateHandler: ActionHandler<'navigate'> = { run: async (ctx, action) => { const vars = ctx.vars; const tabId = ctx.tabId; + // Check if StepRunner owns nav-wait (skip internal nav-wait logic) + const skipNavWait = ctx.execution?.skipNavWait === true; if (typeof tabId !== 'number') { return failed('TAB_NOT_FOUND', 'No active tab found'); } - const beforeUrl = await readTabUrl(tabId); - const waitMs = clampInt( - action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, - 0, - ENGINE_CONSTANTS.MAX_WAIT_MS, - ); + // Only read beforeUrl and calculate waitMs if we need to do nav-wait + const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId); + const waitMs = skipNavWait + ? 0 + : clampInt( + action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, + 0, + ENGINE_CONSTANTS.MAX_WAIT_MS, + ); // Handle page refresh if (action.params.refresh) { @@ -58,8 +63,11 @@ export const navigateHandler: ActionHandler<'navigate'> = { return failed('NAVIGATION_FAILED', errorMsg); } - await waitForNavigationDone(beforeUrl, waitMs); - await ensureReadPageIfWeb(); + // Skip nav-wait if StepRunner handles it + if (!skipNavWait) { + await waitForNavigationDone(beforeUrl, waitMs); + await ensureReadPageIfWeb(); + } return { status: 'success' }; } @@ -85,8 +93,11 @@ export const navigateHandler: ActionHandler<'navigate'> = { return failed('NAVIGATION_FAILED', errorMsg); } - await waitForNavigationDone(beforeUrl, waitMs); - await ensureReadPageIfWeb(); + // Skip nav-wait if StepRunner handles it + if (!skipNavWait) { + await waitForNavigationDone(beforeUrl, waitMs); + await ensureReadPageIfWeb(); + } return { status: 'success' }; }, diff --git a/app/chrome-extension/entrypoints/background/record-replay/actions/types.ts b/app/chrome-extension/entrypoints/background/record-replay/actions/types.ts index be8fa9e..d85b996 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/actions/types.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/actions/types.ts @@ -751,6 +751,18 @@ export type ActionOutput = T extends keyof ActionOutputsBy export type ValidationResult = { ok: true } | { ok: false; errors: NonEmptyArray }; +/** + * Execution flags for coordinating with orchestrator policies. + * Used to avoid duplicate retry/nav-wait when StepRunner owns these policies. + */ +export interface ExecutionFlags { + /** + * When true, navigation waiting should be handled by StepRunner. + * Action handlers (click, navigate) should skip their internal nav-wait logic. + */ + skipNavWait?: boolean; +} + export interface ActionExecutionContext { vars: VariableStore; tabId: number; @@ -765,6 +777,11 @@ export interface ActionExecutionContext { * Action handlers may emit richer entries (e.g. selector fallback) via this hook. */ pushLog?: (entry: unknown) => void; + /** + * Execution flags provided by the orchestrator. + * Handlers should respect these flags to avoid duplicating StepRunner policies. + */ + execution?: ExecutionFlags; } export type ControlDirective = diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts index fc6a45c..2d7d9ef 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts @@ -35,7 +35,10 @@ export interface ExecutionModeConfig { /** * Step types that should use actions execution (allowlist) - * Only applies in hybrid mode. If empty, all supported types use actions. + * Only applies in hybrid mode. + * - If undefined: uses MINIMAL_HYBRID_ACTION_TYPES (safest default) + * - If empty Set (size=0): falls back to MIGRATED_ACTION_TYPES policy + * - If non-empty Set: only these types use actions */ actionsAllowlist?: Set; @@ -46,8 +49,11 @@ export interface ExecutionModeConfig { logFallbacks?: boolean; /** - * Skip ActionRegistry's built-in retry/timeout when StepRunner handles them - * @default true - StepRunner already handles retry/timeout via withRetry and deadline + * Skip ActionRegistry's built-in retry policy. + * When true, action.policy.retry is removed before execution. + * @default true - StepRunner already handles retry via withRetry() + * + * Note: ActionRegistry timeout is NOT disabled (provides per-action timeout safety). */ skipActionsRetry?: boolean; @@ -69,6 +75,29 @@ export const DEFAULT_EXECUTION_MODE_CONFIG: ExecutionModeConfig = { skipActionsNavWait: true, }; +/** + * Minimal allowlist for initial hybrid rollout. + * + * This keeps high-risk step types (navigation/click/tab management) on legacy + * until policy (retry/timeout/nav-wait) and tab cursor semantics are unified. + * + * These types are chosen for their low risk: + * - No navigation side effects + * - No tab management + * - No complex timing requirements + * - Simple input/output semantics + */ +export const MINIMAL_HYBRID_ACTION_TYPES = new Set([ + 'fill', // Form input - no navigation + 'key', // Keyboard input - no navigation + 'scroll', // Viewport manipulation - no navigation + 'drag', // Drag and drop - local operation + 'wait', // Condition waiting - no side effects + 'delay', // Simple delay - no side effects + 'screenshot', // Capture only - no side effects + 'assert', // Validation only - no side effects +]); + /** * Step types that are fully migrated and tested with ActionRegistry * These are safe to run in actions mode @@ -165,21 +194,34 @@ export function shouldUseActions(step: Step, config: ExecutionModeConfig): boole } /** - * Create a hybrid execution mode config for gradual migration - * Starts with only the most stable types enabled for actions + * Create a hybrid execution mode config for gradual migration. + * + * By default uses MINIMAL_HYBRID_ACTION_TYPES as allowlist, which excludes + * high-risk types (navigate/click/tab management) from actions execution. + * + * @param overrides - Optional overrides for the config + * @param overrides.actionsAllowlist - Set of step types to execute via actions. + * If provided with size > 0, only these types use actions. + * If empty Set, falls back to MIGRATED_ACTION_TYPES. + * If undefined, uses MINIMAL_HYBRID_ACTION_TYPES (safest default). */ export function createHybridConfig(overrides?: Partial): ExecutionModeConfig { return { ...DEFAULT_EXECUTION_MODE_CONFIG, mode: 'hybrid', - legacyOnlyTypes: LEGACY_ONLY_TYPES, + legacyOnlyTypes: new Set(LEGACY_ONLY_TYPES), + actionsAllowlist: new Set(MINIMAL_HYBRID_ACTION_TYPES), ...overrides, }; } /** - * Create a strict actions mode config for testing - * All steps must be handled by ActionRegistry or throw + * Create a strict actions mode config for testing. + * All steps must be handled by ActionRegistry or throw. + * + * Note: Even in actions mode, StepRunner remains the policy authority for + * retry/nav-wait. This ensures consistent behavior across all execution modes + * and avoids double-strategy issues. */ export function createActionsOnlyConfig( overrides?: Partial, @@ -187,8 +229,9 @@ export function createActionsOnlyConfig( return { ...DEFAULT_EXECUTION_MODE_CONFIG, mode: 'actions', - skipActionsRetry: false, - skipActionsNavWait: false, + // Keep StepRunner as policy authority - skip ActionRegistry's internal policies + skipActionsRetry: true, + skipActionsNavWait: true, ...overrides, }; } diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts index b3ec706..49e727e 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts @@ -68,9 +68,20 @@ export interface StepExecutorInterface { /** * Legacy step executor using nodes/executeStep + * + * This executor delegates to the existing node execution system. + * The options parameter is accepted but not used - retry/timeout/navigation + * waiting are handled by StepRunner to maintain existing behavior. */ export class LegacyStepExecutor implements StepExecutorInterface { - async execute(ctx: ExecCtx, step: Step): Promise { + async execute( + ctx: ExecCtx, + step: Step, + _options: StepExecutionOptions, + ): Promise { + // Note: tabId from options is not used here because legacy executeStep + // queries the active tab internally. In hybrid/actions mode, tabId is + // passed through to ActionRegistry handlers. const result = await legacyExecuteStep(ctx, step); return { result: result || {}, @@ -78,7 +89,7 @@ export class LegacyStepExecutor implements StepExecutorInterface { }; } - supports(): boolean { + supports(_stepType: string): boolean { // Legacy executor supports all step types via its own registry return true; } @@ -89,11 +100,18 @@ export class LegacyStepExecutor implements StepExecutorInterface { * * In strict mode, any unsupported step type throws an error. * This executor does NOT fall back to legacy - use HybridStepExecutor for fallback behavior. + * + * Respects ExecutionModeConfig for: + * - skipActionsRetry: Disables ActionRegistry retry (StepRunner owns retry) + * - skipActionsNavWait: Disables handler nav-wait (StepRunner owns nav-wait) */ export class ActionsStepExecutor implements StepExecutorInterface { private executor: ReturnType; - constructor(private registry: ActionRegistry) { + constructor( + private registry: ActionRegistry, + private config: ExecutionModeConfig, + ) { this.executor = createStepExecutor(registry); } @@ -108,6 +126,9 @@ export class ActionsStepExecutor implements StepExecutorInterface { runId: options.runId, pushLog: options.pushLog, strict: true, + // Pass policy skip flags from config (default to true = skip) + skipRetry: this.config.skipActionsRetry !== false, + skipNavWait: this.config.skipActionsNavWait !== false, })) as StepExecutionAttempt; // With strict=true, we should never get { supported: false } - it would throw instead @@ -130,6 +151,12 @@ export class ActionsStepExecutor implements StepExecutorInterface { /** * Hybrid step executor that tries actions first, falls back to legacy + * + * Respects ExecutionModeConfig for: + * - actionsAllowlist/legacyOnlyTypes: Controls which steps use actions vs legacy + * - skipActionsRetry: Disables ActionRegistry retry (StepRunner owns retry) + * - skipActionsNavWait: Disables handler nav-wait (StepRunner owns nav-wait) + * - logFallbacks: Whether to log when falling back to legacy */ export class HybridStepExecutor implements StepExecutorInterface { private actionsExecutor: ReturnType; @@ -161,6 +188,9 @@ export class HybridStepExecutor implements StepExecutorInterface { runId: options.runId, pushLog: options.pushLog, strict: false, // Don't throw on unsupported, return { supported: false } + // Pass policy skip flags from config (default to true = skip) + skipRetry: this.config.skipActionsRetry !== false, + skipNavWait: this.config.skipActionsNavWait !== false, })) as StepExecutionAttempt; if (attempt.supported) { @@ -209,7 +239,7 @@ export function createExecutor( if (!registry) { throw new Error('ActionRegistry required for actions execution mode'); } - return new ActionsStepExecutor(registry); + return new ActionsStepExecutor(registry, config); case 'hybrid': if (!registry) { @@ -217,8 +247,8 @@ export function createExecutor( } return new HybridStepExecutor(registry, config); - default: // TypeScript exhaustiveness check - { + default: { + // TypeScript exhaustiveness check const _exhaustive: never = config.mode; throw new Error(`Unknown execution mode: ${_exhaustive}`); } diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts index afd273a..6981091 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts @@ -1,9 +1,14 @@ -// step-runner.ts — encapsulates execution of a single step with policies and plugins +/** + * step-runner.ts + * + * Encapsulates execution of a single step with policies (retry, navigation wait) and plugins. + * Uses dependency-injected StepExecutorInterface for actual step execution, enabling + * seamless switching between legacy and ActionRegistry execution modes. + */ import type { Flow, Step, StepClick } from '../../types'; import { STEP_TYPES } from 'chrome-mcp-shared'; import type { ExecCtx, ExecResult } from '../../nodes'; -import { executeStep } from '../../nodes'; import { RunLogger } from '../logging/run-logger'; import { withRetry } from '../policies/retry'; import { @@ -16,6 +21,7 @@ import { ENGINE_CONSTANTS } from '../constants'; import { AfterScriptQueue } from './after-script-queue'; import { PluginManager } from '../plugins/manager'; import type { HookControl } from '../plugins/types'; +import type { StepExecutorInterface } from './step-executor'; // Narrow error-like value used for overlay reporting interface ErrorLike { @@ -28,14 +34,31 @@ function errorMessage(e: unknown): string { return String(e); } +/** + * Environment dependencies for StepRunner. + * Injected by Scheduler to allow flexible configuration and testing. + */ export interface StepRunEnv { + /** Unique identifier for this run */ runId: string; + /** The flow being executed */ flow: Flow; + /** Runtime variables */ vars: Record; + /** Run logger for recording execution events */ logger: RunLogger; + /** Plugin manager for hooks (beforeStep, afterStep, onRetry, onError) */ pluginManager: PluginManager; + /** Queue for deferred after-scripts */ afterScripts: AfterScriptQueue; - getRemainingBudgetMs: () => number; // global deadline budget calculator + /** Returns remaining time budget from global deadline (ms), Infinity if no deadline */ + getRemainingBudgetMs: () => number; + /** + * Step executor for actual step execution. + * Defaults to LegacyStepExecutor if not provided (for backwards compatibility). + * In future, Scheduler will inject ActionsStepExecutor or HybridStepExecutor. + */ + stepExecutor: StepExecutorInterface; } export class StepRunner { @@ -81,7 +104,24 @@ export class StepRunner { try { await withRetry( async () => { - const result = await executeStep(ctx, step); + // Execute step via injected executor (legacy, actions, or hybrid) + // tabId is expected to be set by Scheduler in ctx; fallback to active tab if missing + let tabId = ctx.tabId; + if (typeof tabId !== 'number') { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + tabId = tabs?.[0]?.id; + } + if (typeof tabId !== 'number') { + throw new Error('No active tab found for step execution'); + } + + const execResult = await this.env.stepExecutor.execute(ctx, step, { + tabId, + runId: this.env.runId, + pushLog: (entry) => this.env.logger.push(entry as any), + remainingBudgetMs: this.env.getRemainingBudgetMs(), + }); + const result = execResult.result; const remainingBudget = this.env.getRemainingBudgetMs(); if (step.type === STEP_TYPES.CLICK || step.type === STEP_TYPES.DBLCLICK) { const after = step.after ?? ({} as NonNullable); diff --git a/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts b/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts index 3a2004b..9bf5e89 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts @@ -21,6 +21,15 @@ import { StepRunner } from './runners/step-runner'; import { ControlFlowRunner } from './runners/control-flow-runner'; import { SubflowRunner } from './runners/subflow-runner'; import { ENGINE_CONSTANTS, LOG_STEP_IDS } from './constants'; +import { + DEFAULT_EXECUTION_MODE_CONFIG, + createActionsOnlyConfig, + createHybridConfig, + type ExecutionMode, + type ExecutionModeConfig, +} from './execution-mode'; +import { createExecutor, type StepExecutorInterface } from './runners/step-executor'; +import { createReplayActionRegistry } from '../actions/handlers'; export interface RunOptions { tabTarget?: 'current' | 'new'; @@ -32,24 +41,118 @@ export interface RunOptions { args?: Record; startNodeId?: string; plugins?: RunPlugin[]; + + /** + * Step execution mode switch. + * - 'legacy': Use existing nodes/executeStep (default, safest) + * - 'hybrid': Try ActionRegistry first, fall back to legacy + * - 'actions': Use ActionRegistry exclusively (strict mode) + */ + executionMode?: ExecutionMode; + + /** + * Hybrid mode only: allowlist of step types executed via ActionRegistry. + * - undefined: use MINIMAL_HYBRID_ACTION_TYPES (safest default) + * - []: disable allowlist, fall back to MIGRATED_ACTION_TYPES policy + * - ['fill', 'key', ...]: only these types use actions + */ + actionsAllowlist?: string[]; + + /** + * Hybrid mode only: denylist of step types forced to legacy. + * When omitted, createHybridConfig defaults to LEGACY_ONLY_TYPES. + */ + legacyOnlyTypes?: string[]; } +/** + * Type guard for ExecutionMode + */ +function isExecutionMode(value: unknown): value is ExecutionMode { + return value === 'legacy' || value === 'hybrid' || value === 'actions'; +} + +/** + * Convert array to Set, filtering invalid values + */ +function toStringSet(value: unknown): Set { + const result = new Set(); + if (!Array.isArray(value)) return result; + for (const item of value) { + if (typeof item === 'string') { + const trimmed = item.trim(); + if (trimmed) result.add(trimmed); + } + } + return result; +} + +/** + * Build ExecutionModeConfig from RunOptions. + * Defaults to legacy mode if executionMode is not specified. + * + * Note: Only array inputs for actionsAllowlist/legacyOnlyTypes are accepted. + * Non-array values are ignored to prevent accidental misconfiguration + * (e.g., passing a string instead of array would unexpectedly widen the allowlist). + */ +function buildExecutionModeConfig(options: RunOptions): ExecutionModeConfig { + const mode: ExecutionMode = isExecutionMode(options.executionMode) + ? options.executionMode + : DEFAULT_EXECUTION_MODE_CONFIG.mode; + + if (mode === 'hybrid') { + const overrides: Partial = {}; + // Only apply override if it's a valid array + // This prevents misconfiguration from widening the actions scope + if (Array.isArray(options.actionsAllowlist)) { + overrides.actionsAllowlist = toStringSet(options.actionsAllowlist); + } + if (Array.isArray(options.legacyOnlyTypes)) { + overrides.legacyOnlyTypes = toStringSet(options.legacyOnlyTypes); + } + return createHybridConfig(overrides); + } + + if (mode === 'actions') { + return createActionsOnlyConfig(); + } + + // Default: legacy mode + return { ...DEFAULT_EXECUTION_MODE_CONFIG }; +} + +/** + * ExecutionOrchestrator manages the lifecycle of a flow execution. + * + * Architecture: + * - Creates StepExecutor based on ExecutionModeConfig (legacy by default) + * - Injects StepExecutor into StepRunner for step execution + * - Manages tabId and passes it through ExecCtx + * - Handles DAG traversal, control flow, and cleanup + */ class ExecutionOrchestrator { - // moved to ENGINE_CONSTANTS.MAX_ITERATIONS - private runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - private startAt = Date.now(); - private logger = new RunLogger(this.runId); - // Initialized in constructor to avoid using `this.options` before it's set - private pluginManager: PluginManager; + private readonly runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + private readonly startAt = Date.now(); + private readonly logger = new RunLogger(this.runId); + private readonly pluginManager: PluginManager; + private readonly afterScripts = new AfterScriptQueue(this.logger); + + // Execution mode configuration (defaults to legacy for safety) + private readonly executionModeConfig: ExecutionModeConfig; + private readonly stepExecutor: StepExecutorInterface; + + // Runtime state private vars: Record = Object.create(null); + private tabId: number | null = null; private deadline = 0; private networkCaptureStarted = false; private paused = false; private failed = 0; - private executed = 0; // Count of actually executed steps (not skipped/trigger) + private executed = 0; private steps: Step[] = []; private prepareError: RunResult | null = null; - private afterScripts = new AfterScriptQueue(this.logger); + + // Runners private stepRunner: StepRunner; private controlFlowRunner!: ControlFlowRunner; private subflowRunner!: SubflowRunner; @@ -58,13 +161,32 @@ class ExecutionOrchestrator { private flow: Flow, private options: RunOptions = {}, ) { - for (const v of flow.variables || []) if (v.default !== undefined) this.vars[v.key] = v.default; + // Initialize variables from flow defaults and args + for (const v of flow.variables || []) { + if (v.default !== undefined) this.vars[v.key] = v.default; + } if (options.args) Object.assign(this.vars, options.args); + + // Set up global deadline const globalTimeout = Math.max(0, Number(options.timeoutMs || 0)); this.deadline = globalTimeout > 0 ? this.startAt + globalTimeout : 0; + + // Initialize plugin manager this.pluginManager = new PluginManager( options.plugins && options.plugins.length ? options.plugins : [breakpointPlugin()], ); + + // Create step executor based on execution mode configuration + // Default to legacy mode for maximum safety during migration + this.executionModeConfig = buildExecutionModeConfig(options); + + // Only create ActionRegistry when needed (hybrid or actions mode) + // This avoids unnecessary initialization overhead in legacy mode + const registry = + this.executionModeConfig.mode === 'legacy' ? undefined : createReplayActionRegistry(); + this.stepExecutor = createExecutor(this.executionModeConfig, registry); + + // Initialize step runner with injected executor this.stepRunner = new StepRunner({ runId: this.runId, flow: this.flow, @@ -74,6 +196,7 @@ class ExecutionOrchestrator { afterScripts: this.afterScripts, getRemainingBudgetMs: () => this.deadline > 0 ? Math.max(0, this.deadline - Date.now()) : Number.POSITIVE_INFINITY, + stepExecutor: this.stepExecutor, }); } @@ -121,6 +244,8 @@ class ExecutionOrchestrator { startUrl: this.options.startUrl || derivedStartUrl, refresh: this.options.refresh, }); + // Capture tabId for use in ExecCtx + this.tabId = ensured?.tabId ?? null; // register run state await runState.restore(); @@ -426,7 +551,14 @@ class ExecutionOrchestrator { ? this.options.startNodeId : findFirstExecutableRoot(); let guard = 0; - const ctx: ExecCtx = { vars: this.vars, logger: (e: RunLogEntry) => this.logger.push(e) }; + + // Create execution context with tabId from ensureTab + // tabId is managed by Scheduler and may be updated by openTab/switchTab actions + const ctx: ExecCtx = { + vars: this.vars, + tabId: this.tabId ?? undefined, + logger: (e: RunLogEntry) => this.logger.push(e), + }; if (currentId) { try { await this.logger.overlayAppend( diff --git a/app/chrome-extension/entrypoints/background/record-replay/flow-store.ts b/app/chrome-extension/entrypoints/background/record-replay/flow-store.ts index ebb2713..371c7fe 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/flow-store.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/flow-store.ts @@ -1,9 +1,10 @@ import type { Flow, RunRecord, NodeBase, Edge } from './types'; import { stepsToDAG, type RRNode, type RREdge } from 'chrome-mcp-shared'; import { NODE_TYPES } from '@/common/node-types'; -import { IndexedDbStorage } from './storage/indexeddb-manager'; +import { IndexedDbStorage, ensureMigratedFromLocal } from './storage/indexeddb-manager'; -// design note: simple local storage backed store for flows and run records +// Design note: IndexedDB-backed store for flows and run records. +// Includes lazy migration from chrome.storage.local for backwards compatibility. // Validate if a type string is a valid NodeType const VALID_NODE_TYPES = new Set(Object.values(NODE_TYPES)); @@ -42,10 +43,22 @@ function filterValidEdges(edges: Edge[], nodeIds: Set): Edge[] { * Normalize flow before saving: ensure nodes/edges exist for scheduler compatibility. * Only generates DAG from steps if nodes are missing or empty. * Preserves existing nodes/edges to avoid overwriting user edits. + * + * Also validates edges: removes edges referencing non-existent nodes to prevent + * runtime errors in scheduler's topoOrder calculation. */ function normalizeFlowForSave(flow: Flow): Flow { const hasNodes = Array.isArray(flow.nodes) && flow.nodes.length > 0; if (hasNodes) { + // Validate edges even when nodes exist (e.g., imported flows may have invalid edges) + const nodeIds = new Set(flow.nodes!.map((n) => n.id)); + if (Array.isArray(flow.edges) && flow.edges.length > 0) { + const validEdges = filterValidEdges(flow.edges, nodeIds); + if (validEdges.length !== flow.edges.length) { + // Some edges were invalid, return cleaned flow + return { ...flow, edges: validEdges }; + } + } return flow; } @@ -115,6 +128,7 @@ async function lazyNormalize(flow: Flow): Promise { } export async function listFlows(): Promise { + await ensureMigratedFromLocal(); const flows = await IndexedDbStorage.flows.list(); // Check if any flows need normalization const needsNorm = flows.some(needsNormalization); @@ -134,6 +148,7 @@ export async function listFlows(): Promise { } export async function getFlow(flowId: string): Promise { + await ensureMigratedFromLocal(); const flow = await IndexedDbStorage.flows.get(flowId); if (!flow) return undefined; // Lazy normalize if needed @@ -144,19 +159,23 @@ export async function getFlow(flowId: string): Promise { } export async function saveFlow(flow: Flow): Promise { + await ensureMigratedFromLocal(); const normalizedFlow = normalizeFlowForSave(flow); await IndexedDbStorage.flows.save(normalizedFlow); } export async function deleteFlow(flowId: string): Promise { + await ensureMigratedFromLocal(); await IndexedDbStorage.flows.delete(flowId); } export async function listRuns(): Promise { + await ensureMigratedFromLocal(); return await IndexedDbStorage.runs.list(); } export async function appendRun(record: RunRecord): Promise { + await ensureMigratedFromLocal(); const runs = await IndexedDbStorage.runs.list(); runs.push(record); // Trim to keep last 10 runs per flowId to avoid unbounded growth @@ -181,10 +200,12 @@ export async function appendRun(record: RunRecord): Promise { } export async function listPublished(): Promise { + await ensureMigratedFromLocal(); return await IndexedDbStorage.published.list(); } export async function publishFlow(flow: Flow, slug?: string): Promise { + await ensureMigratedFromLocal(); const info: PublishedFlowInfo = { id: flow.id, slug: slug || toSlug(flow.name) || flow.id, @@ -197,6 +218,7 @@ export async function publishFlow(flow: Flow, slug?: string): Promise { + await ensureMigratedFromLocal(); await IndexedDbStorage.published.delete(flowId); } @@ -219,20 +241,79 @@ export async function exportAllFlows(): Promise { return JSON.stringify({ flows }, null, 2); } +/** + * Import flows from JSON string. + * + * Supported formats: + * 1. Array of flows: [...flows] + * 2. Object with flows array: { flows: [...] } + * 3. Single flow with steps: { id, steps: [...] } + * 4. Single flow with nodes (new format): { id, nodes: [...], edges?: [...] } + * + * Flows are normalized on save (steps → nodes if needed). + */ export async function importFlowFromJson(json: string): Promise { + await ensureMigratedFromLocal(); const parsed = JSON.parse(json); - const flowsToImport: Flow[] = Array.isArray(parsed?.flows) - ? parsed.flows - : parsed?.id && parsed?.steps - ? [parsed as Flow] - : []; - if (!flowsToImport.length) throw new Error('invalid flow json'); + + // Detect candidates from various formats + const candidates: unknown[] = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.flows) + ? parsed.flows + : parsed?.id && (Array.isArray(parsed?.steps) || Array.isArray(parsed?.nodes)) + ? [parsed] + : []; + + if (!candidates.length) { + throw new Error('invalid flow json: no flows found'); + } + const nowIso = new Date().toISOString(); + const flowsToImport: Flow[] = []; + + for (const raw of candidates) { + if (!raw || typeof raw !== 'object') { + throw new Error('invalid flow json: flow must be an object'); + } + + const f = raw as Record; + const id = String(f.id || '').trim(); + if (!id) { + throw new Error('invalid flow json: missing id'); + } + + // Normalize fields with sensible defaults + const name = typeof f.name === 'string' && f.name.trim() ? f.name : id; + const version = Number.isFinite(Number(f.version)) ? Number(f.version) : 1; + const steps = Array.isArray(f.steps) ? f.steps : []; + + // Handle meta with proper timestamps + const existingMeta = + f.meta && typeof f.meta === 'object' ? (f.meta as Record) : {}; + const createdAt = typeof existingMeta.createdAt === 'string' ? existingMeta.createdAt : nowIso; + + const flow: Flow = { + ...(f as object), + id, + name, + version, + steps, + meta: { + ...existingMeta, + createdAt, + updatedAt: nowIso, + }, + } as Flow; + + flowsToImport.push(flow); + } + + // Save all flows (normalize on save) for (const f of flowsToImport) { - const meta = f.meta ?? (f.meta = { createdAt: nowIso, updatedAt: nowIso } as any); - meta.updatedAt = nowIso; await saveFlow(f); } + return flowsToImport; } @@ -250,13 +331,16 @@ export interface FlowSchedule { } export async function listSchedules(): Promise { + await ensureMigratedFromLocal(); return await IndexedDbStorage.schedules.list(); } export async function saveSchedule(s: FlowSchedule): Promise { + await ensureMigratedFromLocal(); await IndexedDbStorage.schedules.save(s); } export async function removeSchedule(scheduleId: string): Promise { + await ensureMigratedFromLocal(); await IndexedDbStorage.schedules.delete(scheduleId); } diff --git a/app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts b/app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts index a0adcf0..315888e 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts @@ -1,8 +1,23 @@ import type { RunLogEntry, Step, StepScript } from '../types'; +/** + * Execution context for step execution. + * Contains runtime state that may change during flow execution. + */ export interface ExecCtx { + /** Runtime variables accessible to steps */ vars: Record; + /** Logger function for recording execution events */ logger: (e: RunLogEntry) => void; + /** + * Current tab ID for this execution context. + * Managed by Scheduler, may change after openTab/switchTab actions. + */ + tabId?: number; + /** + * Current frame ID within the tab. + * Used for iframe targeting, 0 for main frame. + */ frameId?: number; } diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts index 9aaf506..7e987f4 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts @@ -1,6 +1,7 @@ -import type { Flow, Step, VariableDef } from '../types'; +import type { Edge, Flow, NodeBase, Step, VariableDef } from '../types'; import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; -import { appendSteps as appendFlowSteps } from './flow-builder'; +import { NODE_TYPES } from '@/common/node-types'; +import { mapStepToNodeConfig, stepsToDAG, EDGE_LABELS } from 'chrome-mcp-shared'; /** * Recording status state machine: @@ -22,6 +23,9 @@ export interface RecordingSessionState { stoppedTabs: Set; } +// Valid node types for type checking +const VALID_NODE_TYPES = new Set(Object.values(NODE_TYPES)); + export class RecordingSessionManager { private state: RecordingSessionState = { sessionId: '', @@ -32,6 +36,12 @@ export class RecordingSessionManager { stoppedTabs: new Set(), }; + // Session-level caches for incremental DAG sync (cleared on session start/stop) + private stepIndexMap: Map = new Map(); + private nodeIndexMap: Map = new Map(); + // Monotonic counter for edge id generation (avoids collision on delete/reorder) + private edgeSeq: number = 0; + getStatus(): RecordingStatus { return this.state.status; } @@ -61,6 +71,11 @@ export class RecordingSessionManager { } async startSession(flow: Flow, originTabId: number): Promise { + // Clear caches for fresh session + this.stepIndexMap.clear(); + this.nodeIndexMap.clear(); + this.edgeSeq = 0; + this.state = { sessionId: `sess_${Date.now()}`, status: 'recording', @@ -69,6 +84,9 @@ export class RecordingSessionManager { activeTabs: new Set([originTabId]), stoppedTabs: new Set(), }; + + // Initialize caches from existing flow data (supports resume scenarios) + this.rebuildCaches(); } /** @@ -139,6 +157,10 @@ export class RecordingSessionManager { this.state.originTabId = null; this.state.activeTabs.clear(); this.state.stoppedTabs.clear(); + // Clear caches + this.stepIndexMap.clear(); + this.nodeIndexMap.clear(); + this.edgeSeq = 0; return flow; } @@ -154,43 +176,95 @@ export class RecordingSessionManager { } /** - * Append or upsert steps to the flow. + * Append or upsert steps to the flow with incremental DAG sync. * Uses upsert semantics: if a step with the same id exists, update it in place. * This ensures fill steps get their final value even after initial flush. + * + * DAG sync: maintains flow.nodes/edges in lockstep with flow.steps during recording. + * - New step → create node + edge from previous node + * - Upsert step → update node.config and node.type + * - Invariant violation → fallback to full stepsToDAG rebuild */ appendSteps(steps: Step[]): void { const f = this.state.flow; if (!f || !Array.isArray(steps) || steps.length === 0) return; - // Ensure steps array exists - if (!f.steps) { - f.steps = []; + // Initialize arrays if missing + if (!Array.isArray(f.steps)) f.steps = []; + if (!Array.isArray(f.nodes)) f.nodes = []; + if (!Array.isArray(f.edges)) f.edges = []; + + const nodes = f.nodes; + const edges = f.edges; + + // Check invariants: nodes must be 1:1 with steps, edges must match linear chain + // If violated (e.g., imported flow, manual edit), rebuild DAG + if (!this.checkDagInvariant(f.steps, nodes, edges)) { + this.rebuildDag(); } - // Build a map of existing step ids for fast lookup - const existingStepMap = new Map(); - f.steps.forEach((s, idx) => { - if (s.id) existingStepMap.set(s.id, idx); - }); - - // Process each incoming step with upsert semantics + // Process each incoming step with upsert semantics + incremental DAG sync + let needsRebuild = false; for (const step of steps) { // Ensure step has an id if (!step.id) { step.id = `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; } - const existingIdx = existingStepMap.get(step.id); + const existingIdx = this.stepIndexMap.get(step.id); if (existingIdx !== undefined) { - // Upsert: update existing step in place (preserves order) + // Upsert: update existing step in place f.steps[existingIdx] = step; + + // Sync node: update config and type + const nodeIdx = this.nodeIndexMap.get(step.id); + if (nodeIdx === undefined || !nodes[nodeIdx]) { + needsRebuild = true; + continue; + } + nodes[nodeIdx] = { + ...nodes[nodeIdx], + type: this.toNodeType(step.type), + config: mapStepToNodeConfig(step), + }; } else { // Append: new step + const prevStepId = f.steps.length > 0 ? f.steps[f.steps.length - 1]?.id : undefined; + f.steps.push(step); - existingStepMap.set(step.id, f.steps.length - 1); + this.stepIndexMap.set(step.id, f.steps.length - 1); + + // Create corresponding node + const newNode: NodeBase = { + id: step.id, + type: this.toNodeType(step.type), + config: mapStepToNodeConfig(step), + }; + nodes.push(newNode); + this.nodeIndexMap.set(step.id, nodes.length - 1); + + // Create edge from previous node (if exists) + if (prevStepId) { + if (!this.nodeIndexMap.has(prevStepId)) { + needsRebuild = true; + continue; + } + const edgeId = `e_${this.edgeSeq++}_${prevStepId}_${step.id}`; + edges.push({ + id: edgeId, + from: prevStepId, + to: step.id, + label: EDGE_LABELS.DEFAULT, + }); + } } } + // Final invariant check: if any inconsistency detected, rebuild + if (needsRebuild || !this.checkDagInvariant(f.steps, nodes, edges)) { + this.rebuildDag(); + } + // Update meta timestamp try { if (f.meta) { @@ -203,6 +277,116 @@ export class RecordingSessionManager { this.broadcastTimelineUpdate(steps); } + /** + * Convert step type to valid NodeType with fallback to SCRIPT. + * Logs a warning for unknown types to help detect upstream type drift. + */ + private toNodeType(stepType: string): NodeBase['type'] { + if (VALID_NODE_TYPES.has(stepType)) { + return stepType as NodeBase['type']; + } + console.warn(`[RecordingSession] Unknown step type "${stepType}", falling back to "script"`); + return NODE_TYPES.SCRIPT; + } + + /** + * Check DAG invariant for linear recording: + * - nodes.length === steps.length + * - edges.length === max(0, steps.length - 1) + * - Last edge (if exists) points to the last step + */ + private checkDagInvariant(steps: Step[], nodes: NodeBase[], edges: Edge[]): boolean { + const stepCount = steps.length; + const expectedEdgeCount = Math.max(0, stepCount - 1); + + // Check node count matches step count + if (nodes.length !== stepCount) { + return false; + } + + // Check edge count matches expected linear chain + if (edges.length !== expectedEdgeCount) { + return false; + } + + // Check last edge points to last step (if edges exist) + if (edges.length > 0 && steps.length > 0) { + const lastEdge = edges[edges.length - 1]; + const lastStepId = steps[steps.length - 1]?.id; + if (lastEdge.to !== lastStepId) { + return false; + } + } + + return true; + } + + /** + * Rebuild caches from current flow state. + * Called on session start and after DAG rebuild. + */ + private rebuildCaches(): void { + const f = this.state.flow; + if (!f) return; + + this.stepIndexMap.clear(); + this.nodeIndexMap.clear(); + + if (Array.isArray(f.steps)) { + for (let i = 0; i < f.steps.length; i++) { + const id = f.steps[i]?.id; + if (id) this.stepIndexMap.set(id, i); + } + } + + if (Array.isArray(f.nodes)) { + for (let i = 0; i < f.nodes.length; i++) { + const id = f.nodes[i]?.id; + if (id) this.nodeIndexMap.set(id, i); + } + } + + // Sync edgeSeq to continue from current edge count (avoids id collision) + this.edgeSeq = Array.isArray(f.edges) ? f.edges.length : 0; + } + + /** + * Full DAG rebuild from steps. Used as fallback when invariants are violated. + * Clears existing nodes/edges and regenerates from scratch. + */ + private rebuildDag(): void { + const f = this.state.flow; + if (!f || !Array.isArray(f.steps)) return; + + const dag = stepsToDAG(f.steps); + + // Clear and repopulate nodes + if (!Array.isArray(f.nodes)) f.nodes = []; + f.nodes.length = 0; + for (const n of dag.nodes) { + f.nodes.push({ + id: n.id, + type: this.toNodeType(n.type), + config: n.config, + }); + } + + // Clear and repopulate edges + if (!Array.isArray(f.edges)) f.edges = []; + f.edges.length = 0; + for (const e of dag.edges) { + f.edges.push({ + id: e.id, + from: e.from, + to: e.to, + label: e.label, + }); + } + + // Rebuild caches + this.rebuildCaches(); + } + /** * Append variables to the flow. Deduplicates by key. */ diff --git a/app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts b/app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts index 30b3639..0c1c08a 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts @@ -1,4 +1,4 @@ -import { IndexedDbStorage } from './storage/indexeddb-manager'; +import { IndexedDbStorage, ensureMigratedFromLocal } from './storage/indexeddb-manager'; export type TriggerType = 'url' | 'contextMenu' | 'command' | 'dom'; @@ -37,14 +37,17 @@ export interface DomTrigger extends BaseTrigger { export type FlowTrigger = UrlTrigger | ContextMenuTrigger | CommandTrigger | DomTrigger; export async function listTriggers(): Promise { + await ensureMigratedFromLocal(); return await IndexedDbStorage.triggers.list(); } export async function saveTrigger(t: FlowTrigger): Promise { + await ensureMigratedFromLocal(); await IndexedDbStorage.triggers.save(t); } export async function deleteTrigger(id: string): Promise { + await ensureMigratedFromLocal(); await IndexedDbStorage.triggers.delete(id); } diff --git a/app/chrome-extension/entrypoints/background/tools/browser/computer.ts b/app/chrome-extension/entrypoints/background/tools/browser/computer.ts index f0af962..2850739 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/computer.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/computer.ts @@ -361,6 +361,13 @@ class ComputerTool extends BaseBrowserToolExecutor { try { if (params.ref) { await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']); + // Scroll element into view first to ensure it's visible + try { + await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: params.ref }); + } catch { + // Best effort - continue even if scroll fails + } + // Re-resolve coordinates after scroll const resolved = await this.sendMessageToTab(tab.id, { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: params.ref, @@ -378,7 +385,27 @@ class ComputerTool extends BaseBrowserToolExecutor { isXPath: selectorType === 'xpath', }); if (ensured && ensured.success) { - coord = project({ x: ensured.center.x, y: ensured.center.y }); + // Scroll element into view first to ensure it's visible + const resolvedRef = typeof ensured.ref === 'string' ? ensured.ref : undefined; + if (resolvedRef) { + try { + await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: resolvedRef }); + } catch { + // Best effort - continue even if scroll fails + } + // Re-resolve coordinates after scroll + const reResolved = await this.sendMessageToTab(tab.id, { + action: TOOL_MESSAGE_TYPES.RESOLVE_REF, + ref: resolvedRef, + }); + if (reResolved && reResolved.success) { + coord = project({ x: reResolved.center.x, y: reResolved.center.y }); + } else { + coord = project({ x: ensured.center.x, y: ensured.center.y }); + } + } else { + coord = project({ x: ensured.center.x, y: ensured.center.y }); + } resolvedBy = 'selector'; } } else if (params.coordinates) { diff --git a/app/chrome-extension/entrypoints/background/tools/browser/console.ts b/app/chrome-extension/entrypoints/background/tools/browser/console.ts index b7a4439..e524ac9 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/console.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/console.ts @@ -60,6 +60,8 @@ interface ConsoleResult { messageCount: number; exceptionCount: number; messageLimitReached: boolean; + droppedMessageCount: number; + droppedExceptionCount: number; } // 辅助函数 @@ -269,6 +271,8 @@ class ConsoleTool extends BaseBrowserToolExecutor { messageCount: read.messageCount, exceptionCount: read.exceptionCount, messageLimitReached: read.messageLimitReached, + droppedMessageCount: read.droppedMessageCount, + droppedExceptionCount: read.droppedExceptionCount, }; return { @@ -611,6 +615,8 @@ class ConsoleTool extends BaseBrowserToolExecutor { messageCount: messages.length, exceptionCount: exceptions.length, messageLimitReached: limitReached, + droppedMessageCount: 0, + droppedExceptionCount: 0, }; } catch (error: any) { console.error(`ConsoleTool: Error capturing console messages for tab ${tabId}:`, error); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts b/app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts index 6d3a57b..594b4ca 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts @@ -441,6 +441,21 @@ async function stopRecording(): Promise { // ignore } + // Best-effort final frame capture to preserve end state + try { + const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx); + await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, { + imageData: Array.from(frameData), + width: state.width, + height: state.height, + delay: state.frameDelayCs, + maxColors: state.maxColors, + }); + state.frameCount += 1; + } catch (error) { + console.warn('GIF recorder: Final frame capture error (non-fatal):', error); + } + const frameCount = state.frameCount; const durationMs = Date.now() - state.startTime; const filename = state.filename; diff --git a/app/chrome-extension/entrypoints/background/tools/browser/index.ts b/app/chrome-extension/entrypoints/background/tools/browser/index.ts index 265de3e..527fb0a 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/index.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/index.ts @@ -5,6 +5,8 @@ export { screenshotTool } from './screenshot'; export { webFetcherTool, getInteractiveElementsTool } from './web-fetcher'; export { clickTool, fillTool } from './interaction'; export { networkRequestTool } from './network-request'; +export { networkCaptureTool } from './network-capture'; +// Legacy exports (for internal use by networkCaptureTool) export { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger'; export { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request'; export { keyboardTool } from './keyboard'; diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts index 907c4ee..49cb973 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts @@ -2,6 +2,7 @@ import { createErrorResponse, ToolResult } from '@/common/tool-handler'; import { BaseBrowserToolExecutor } from '../base-browser'; import { TOOL_NAMES } from 'chrome-mcp-shared'; import { cdpSessionManager } from '@/utils/cdp-session-manager'; +import { NETWORK_FILTERS } from '@/common/constants'; interface NetworkDebuggerStartToolParams { url?: string; // URL to navigate to or focus. If not provided, uses active tab. @@ -35,80 +36,6 @@ interface NetworkRequestInfo { [key: string]: any; // Allow other properties from debugger events } -// Static resource file extensions list -const STATIC_RESOURCE_EXTENSIONS = [ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.bmp', - '.webp', - '.svg', - '.ico', - '.cur', - '.css', - '.woff', - '.woff2', - '.ttf', - '.eot', - '.otf', - '.mp3', - '.mp4', - '.avi', - '.mov', - '.webm', - '.ogg', - '.wav', - '.pdf', - '.zip', - '.rar', - '.7z', - '.iso', - '.dmg', - '.js', - '.jsx', - '.ts', - '.tsx', - '.map', // Source maps -]; - -// Ad and analytics domains list -const AD_ANALYTICS_DOMAINS = [ - 'google-analytics.com', - 'googletagmanager.com', - 'analytics.google.com', - 'doubleclick.net', - 'googlesyndication.com', - 'googleads.g.doubleclick.net', - 'facebook.com/tr', - 'connect.facebook.net', - 'bat.bing.com', - 'linkedin.com', // Often for tracking pixels/insights - 'analytics.twitter.com', - 'static.hotjar.com', - 'script.hotjar.com', - 'stats.g.doubleclick.net', - 'amazon-adsystem.com', - 'adservice.google.com', - 'pagead2.googlesyndication.com', - 'ads-twitter.com', - 'ads.yahoo.com', - 'adroll.com', - 'adnxs.com', - 'criteo.com', - 'quantserve.com', - 'scorecardresearch.com', - 'segment.io', - 'amplitude.com', - 'mixpanel.com', - 'optimizely.com', - 'crazyegg.com', - 'clicktale.net', - 'mouseflow.com', - 'fullstory.com', - 'clarity.ms', -]; - const DEBUGGER_PROTOCOL_VERSION = '1.3'; const MAX_RESPONSE_BODY_SIZE_BYTES = 1 * 1024 * 1024; // 1MB const DEFAULT_MAX_CAPTURE_TIME_MS = 3 * 60 * 1000; // 3 minutes @@ -366,92 +293,43 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { await this.stopCapture(tabId, true); // Pass a flag indicating it's an auto-stop } - // Static resource MIME types list (used when includeStatic is false) - private static STATIC_MIME_TYPES_TO_FILTER = [ - 'image/', // all image types (image/png, image/jpeg, etc.) - 'font/', // all font types (font/woff, font/ttf, etc.) - 'audio/', // all audio types - 'video/', // all video types - 'text/css', - // Note: text/javascript, application/javascript etc. are often filtered by extension. - // If script files need to be filtered by MIME type as well, add them here. - // 'application/javascript', - // 'application/x-javascript', - 'application/pdf', - 'application/zip', - 'application/octet-stream', // Often used for downloads or generic binary data - ]; - - // API-like response MIME types (these are generally NOT filtered, and we might want their bodies) - private static API_MIME_TYPES = [ - 'application/json', - 'application/xml', - 'text/xml', - // 'text/json' is not standard, but sometimes seen. 'application/json' is preferred. - 'text/plain', // Can be API response, handle with care. Often captured. - 'application/x-www-form-urlencoded', // Form submissions, can be API calls - 'application/graphql', - // Add other common API types if needed - ]; - + /** + * Check if URL should be filtered based on EXCLUDED_DOMAINS patterns. + * Uses full URL substring match to support patterns like 'facebook.com/tr'. + */ private shouldFilterRequestByUrl(url: string): boolean { - try { - const urlObj = new URL(url); - // Filter ad/analytics domains - if (AD_ANALYTICS_DOMAINS.some((domain) => urlObj.hostname.includes(domain))) { - // console.log(`NetworkDebuggerStartTool: Filtering ad/analytics domain: ${urlObj.hostname}`); - return true; - } - return false; - } catch (e) { - // Invalid URL? Log and don't filter. - console.error(`NetworkDebuggerStartTool: Error parsing URL for filtering: ${url}`, e); - return false; - } + const normalizedUrl = String(url || '').toLowerCase(); + if (!normalizedUrl) return false; + return NETWORK_FILTERS.EXCLUDED_DOMAINS.some((pattern) => normalizedUrl.includes(pattern)); } private shouldFilterRequestByExtension(url: string, includeStatic: boolean): boolean { - if (includeStatic) return false; // If including static, don't filter by extension + if (includeStatic) return false; try { const urlObj = new URL(url); const path = urlObj.pathname.toLowerCase(); - if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) { - // console.log(`NetworkDebuggerStartTool: Filtering static resource by extension: ${path}`); - return true; - } - return false; - } catch (e) { - console.error( - `NetworkDebuggerStartTool: Error parsing URL for extension filtering: ${url}`, - e, - ); + return NETWORK_FILTERS.STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext)); + } catch { return false; } } - // MIME type-based filtering, called after response is received private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean { - if (!mimeType) return false; // No MIME type, don't make a decision based on it here + if (!mimeType) return false; - // If API_MIME_TYPES contains this mimeType, we explicitly DON'T want to filter it by MIME. - if (NetworkDebuggerStartTool.API_MIME_TYPES.some((apiMime) => mimeType.startsWith(apiMime))) { + // Never filter API MIME types + if (NETWORK_FILTERS.API_MIME_TYPES.some((apiMime) => mimeType.startsWith(apiMime))) { return false; } - // If we are NOT including static files, then check against the list of static MIME types. + // Filter static MIME types when not including static resources if (!includeStatic) { - if ( - NetworkDebuggerStartTool.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) => - mimeType.startsWith(staticMime), - ) - ) { - // console.log(`NetworkDebuggerStartTool: Filtering static resource by MIME type: ${mimeType}`); - return true; - } + return NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) => + mimeType.startsWith(staticMime), + ); } - // Default: don't filter by MIME type if no other rule matched return false; } @@ -595,7 +473,7 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { const mimeType = requestInfo.mimeType || ''; // Prioritize API MIME types for body capture - if (NetworkDebuggerStartTool.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) { + if (NETWORK_FILTERS.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) { return true; } @@ -611,7 +489,7 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor { // unless it's a known non-API MIME type that slipped through (e.g. a script from a /api/ path) if ( mimeType && - NetworkDebuggerStartTool.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) => + NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) => mimeType.startsWith(staticMime), ) ) { diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts index d37d220..462808b 100644 --- a/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts @@ -202,31 +202,31 @@ class NetworkCaptureStartTool extends BaseBrowserToolExecutor { /** * Determine whether a request should be filtered (based on URL) + * Uses full URL substring match to support patterns like 'facebook.com/tr' */ private shouldFilterRequest(url: string, includeStatic: boolean): boolean { - try { - const urlObj = new URL(url); + const normalizedUrl = String(url || '').toLowerCase(); + if (!normalizedUrl) return false; - // Check if it's an ad or analytics domain - if (AD_ANALYTICS_DOMAINS.some((domain) => urlObj.hostname.includes(domain))) { - console.log(`NetworkCaptureV2: Filtering ad/analytics domain: ${urlObj.hostname}`); - return true; - } + // Check if it's an ad or analytics domain (full URL substring match) + if (AD_ANALYTICS_DOMAINS.some((pattern) => normalizedUrl.includes(pattern))) { + return true; + } - // If not including static resources, check extensions - if (!includeStatic) { + // If not including static resources, check extensions + if (!includeStatic) { + try { + const urlObj = new URL(url); const path = urlObj.pathname.toLowerCase(); if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) { - console.log(`NetworkCaptureV2: Filtering static resource by extension: ${path}`); return true; } + } catch { + return false; } - - return false; - } catch (e) { - console.error('NetworkCaptureV2: Error filtering URL:', e); - return false; } + + return false; } /** @@ -345,9 +345,14 @@ class NetworkCaptureStartTool extends BaseBrowserToolExecutor { } /** - * Set up request listeners + * Set up request listeners (idempotent - won't add duplicate listeners) */ private setupListeners(): void { + // Skip if listeners are already set up + if (this.listeners.onBeforeRequest) { + return; + } + // Before request is sent this.listeners.onBeforeRequest = (details: chrome.webRequest.WebRequestBodyDetails) => { const captureInfo = this.captureData.get(details.tabId); diff --git a/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts b/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts new file mode 100644 index 0000000..aebfd9f --- /dev/null +++ b/app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts @@ -0,0 +1,158 @@ +import { createErrorResponse, ToolResult } from '@/common/tool-handler'; +import { BaseBrowserToolExecutor } from '../base-browser'; +import { TOOL_NAMES } from 'chrome-mcp-shared'; +import { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request'; +import { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger'; + +type NetworkCaptureBackend = 'webRequest' | 'debugger'; + +interface NetworkCaptureToolParams { + action: 'start' | 'stop'; + needResponseBody?: boolean; + url?: string; + maxCaptureTime?: number; + inactivityTimeout?: number; + includeStatic?: boolean; +} + +/** + * Extract text content from ToolResult + */ +function getFirstText(result: ToolResult): string | undefined { + const first = result.content?.[0]; + return first && first.type === 'text' ? first.text : undefined; +} + +/** + * Decorate JSON result with additional fields + */ +function decorateJsonResult(result: ToolResult, extra: Record): ToolResult { + const text = getFirstText(result); + if (typeof text !== 'string') return result; + + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { + ...result, + content: [{ type: 'text', text: JSON.stringify({ ...parsed, ...extra }) }], + }; + } + } catch { + // If the underlying tool didn't return JSON, keep it as-is + } + return result; +} + +/** + * Check if debugger-based capture is active + */ +function isDebuggerCaptureActive(): boolean { + const captureData = ( + networkDebuggerStartTool as unknown as { captureData?: Map } + ).captureData; + return captureData instanceof Map && captureData.size > 0; +} + +/** + * Check if webRequest-based capture is active + */ +function isWebRequestCaptureActive(): boolean { + return networkCaptureStartTool.captureData.size > 0; +} + +/** + * Unified Network Capture Tool + * + * Provides a single entry point for network capture, automatically selecting + * the appropriate backend based on the `needResponseBody` parameter: + * - needResponseBody=false (default): uses webRequest API (lightweight, no debugger conflict) + * - needResponseBody=true: uses Debugger API (captures response body, may conflict with DevTools) + */ +class NetworkCaptureTool extends BaseBrowserToolExecutor { + name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE; + + async execute(args: NetworkCaptureToolParams): Promise { + const action = args?.action; + if (action !== 'start' && action !== 'stop') { + return createErrorResponse('Parameter [action] is required and must be one of: start, stop'); + } + + const wantBody = args?.needResponseBody === true; + const debuggerActive = isDebuggerCaptureActive(); + const webActive = isWebRequestCaptureActive(); + + if (action === 'start') { + return this.handleStart(args, wantBody, debuggerActive, webActive); + } + + return this.handleStop(args, debuggerActive, webActive); + } + + private async handleStart( + args: NetworkCaptureToolParams, + wantBody: boolean, + debuggerActive: boolean, + webActive: boolean, + ): Promise { + // Prevent any capture conflict (cross-mode or same-mode) + if (debuggerActive || webActive) { + const activeMode = debuggerActive ? 'debugger' : 'webRequest'; + return createErrorResponse( + `Network capture is already active in ${activeMode} mode. Stop it before starting a new capture.`, + ); + } + + const delegate = wantBody ? networkDebuggerStartTool : networkCaptureStartTool; + const backend: NetworkCaptureBackend = wantBody ? 'debugger' : 'webRequest'; + + const result = await delegate.execute({ + url: args.url, + maxCaptureTime: args.maxCaptureTime, + inactivityTimeout: args.inactivityTimeout, + includeStatic: args.includeStatic, + }); + + return decorateJsonResult(result, { backend, needResponseBody: wantBody }); + } + + private async handleStop( + args: NetworkCaptureToolParams, + debuggerActive: boolean, + webActive: boolean, + ): Promise { + // Determine which backend to stop + let backendToStop: NetworkCaptureBackend | null = null; + + // If user explicitly specified needResponseBody, try to stop that specific backend + if (args?.needResponseBody === true) { + backendToStop = debuggerActive ? 'debugger' : null; + } else if (args?.needResponseBody === false) { + backendToStop = webActive ? 'webRequest' : null; + } + + // If no explicit preference or the specified backend isn't active, auto-detect + if (!backendToStop) { + if (debuggerActive) { + backendToStop = 'debugger'; + } else if (webActive) { + backendToStop = 'webRequest'; + } + } + + if (!backendToStop) { + return createErrorResponse('No active network captures found in any tab.'); + } + + const delegateStop = + backendToStop === 'debugger' ? networkDebuggerStopTool : networkCaptureStopTool; + const result = await delegateStop.execute(); + + return decorateJsonResult(result, { + backend: backendToStop, + needResponseBody: backendToStop === 'debugger', + }); + } +} + +export const networkCaptureTool = new NetworkCaptureTool(); diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/floating-drag.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/floating-drag.ts index f70c2b6..c96fcb5 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/floating-drag.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/floating-drag.ts @@ -31,6 +31,22 @@ export interface FloatingDragOptions { onPositionChange: (position: FloatingPosition) => void; /** Margin from viewport edges in pixels */ clampMargin: number; + /** + * Delay drag activation to allow click interactions on the handle. + * + * When > 0, drag is only activated after: + * - Pointer held for at least this duration (ms), OR + * - Pointer moved beyond `moveThresholdPx` + * + * Use case: minimized toolbar where short click restores, long press drags. + * @default 0 (immediate drag) + */ + clickThresholdMs?: number; + /** + * Movement threshold (px) that activates drag when clickThresholdMs > 0. + * @default 0 + */ + moveThresholdPx?: number; } interface DragSession { @@ -40,6 +56,11 @@ interface DragSession { offsetY: number; targetWidth: number; targetHeight: number; + /** Starting client coordinates for move threshold calculation */ + startClientX: number; + startClientY: number; + /** Whether drag has been activated (always true when clickThresholdMs=0) */ + activated: boolean; } // ============================================================================= @@ -99,8 +120,15 @@ function roundPosition(position: FloatingPosition): FloatingPosition { export function installFloatingDrag(options: FloatingDragOptions): () => void { const { handleEl, targetEl, onPositionChange, clampMargin } = options; + // Parse delayed activation options + const clickThresholdMs = Math.max(0, options.clickThresholdMs ?? 0); + const moveThresholdPx = Math.max(0, options.moveThresholdPx ?? 0); + const delayedActivation = clickThresholdMs > 0; + const moveThresholdSq = moveThresholdPx * moveThresholdPx; + let session: DragSession | null = null; let disposed = false; + let activationTimer: number | null = null; function teardownWindowListeners(): void { window.removeEventListener('pointermove', onWindowPointerMove, WINDOW_CAPTURE); @@ -111,11 +139,20 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void { document.removeEventListener('visibilitychange', onVisibilityChange); } + function clearActivationTimer(): void { + if (activationTimer !== null) { + window.clearTimeout(activationTimer); + activationTimer = null; + } + } + function endDrag(pointerId: number): void { const s = session; if (!s) return; if (s.pointerId !== pointerId) return; + clearActivationTimer(); + try { handleEl.releasePointerCapture(pointerId); } catch { @@ -149,11 +186,54 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void { endDrag(s.pointerId); } + /** + * Suppress the next click event on handle to prevent accidental click after drag. + */ + function suppressClickOnce(): void { + const onClick = (e: MouseEvent) => { + blockEvent(e); + }; + handleEl.addEventListener('click', onClick, { capture: true, once: true }); + // Safety cleanup if no click fires (extended timeout for touch devices) + window.setTimeout(() => { + handleEl.removeEventListener('click', onClick, { capture: true }); + }, 300); + } + + /** + * Activate drag mode (when using delayed activation). + */ + function activateDrag(pointerId: number): void { + const s = session; + if (!s || s.pointerId !== pointerId || s.activated) return; + + s.activated = true; + handleEl.dataset.dragging = 'true'; + clearActivationTimer(); + + try { + handleEl.setPointerCapture(pointerId); + } catch { + // Pointer capture may fail on some elements/browsers + } + } + function onWindowPointerMove(event: PointerEvent): void { const s = session; if (!s) return; if (event.pointerId !== s.pointerId) return; + // Check if drag needs activation (delayed mode) + if (!s.activated) { + if (!delayedActivation || moveThresholdSq <= 0) return; + + const dx = event.clientX - s.startClientX; + const dy = event.clientY - s.startClientY; + if (dx * dx + dy * dy < moveThresholdSq) return; + + activateDrag(event.pointerId); + } + blockEvent(event); applyNextPosition({ @@ -167,7 +247,11 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void { if (!s) return; if (event.pointerId !== s.pointerId) return; - blockEvent(event); + // Only block event and suppress click if drag was activated + if (s.activated) { + blockEvent(event); + suppressClickOnce(); + } endDrag(event.pointerId); } @@ -176,30 +260,49 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void { if (!s) return; if (event.pointerId !== s.pointerId) return; - blockEvent(event); - cancelDrag(); + if (s.activated) { + blockEvent(event); + cancelDrag(); + } else { + endDrag(event.pointerId); + } } function onWindowKeyDown(event: KeyboardEvent): void { if (event.key !== 'Escape') return; - if (!session) return; + const s = session; + if (!s) return; - event.preventDefault(); - event.stopImmediatePropagation(); - event.stopPropagation(); - - cancelDrag(); + if (s.activated) { + event.preventDefault(); + event.stopImmediatePropagation(); + event.stopPropagation(); + cancelDrag(); + } else { + endDrag(s.pointerId); + } } function onWindowBlur(): void { - if (!session) return; - cancelDrag(); + const s = session; + if (!s) return; + + if (s.activated) { + cancelDrag(); + } else { + endDrag(s.pointerId); + } } function onVisibilityChange(): void { - if (!session) return; - if (document.visibilityState === 'hidden') { + const s = session; + if (!s) return; + if (document.visibilityState !== 'hidden') return; + + if (s.activated) { cancelDrag(); + } else { + endDrag(s.pointerId); } } @@ -214,7 +317,10 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void { if (event.button !== 0) return; if (!event.isPrimary) return; - blockEvent(event); + // Only block event immediately if not using delayed activation + if (!delayedActivation) { + blockEvent(event); + } const rect = targetEl.getBoundingClientRect(); const startPosition = roundPosition({ left: rect.left, top: rect.top }); @@ -226,9 +332,12 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void { offsetY: event.clientY - rect.top, targetWidth: rect.width, targetHeight: rect.height, + startClientX: event.clientX, + startClientY: event.clientY, + activated: !delayedActivation, }; - handleEl.dataset.dragging = 'true'; + handleEl.dataset.dragging = session.activated ? 'true' : 'false'; try { handleEl.setPointerCapture(event.pointerId); @@ -236,6 +345,15 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void { // Pointer capture may fail on some elements/browsers } + // Start activation timer for delayed mode + if (delayedActivation) { + clearActivationTimer(); + const pointerId = event.pointerId; + activationTimer = window.setTimeout(() => { + activateDrag(pointerId); + }, clickThresholdMs); + } + window.addEventListener('pointermove', onWindowPointerMove, WINDOW_CAPTURE); window.addEventListener('pointerup', onWindowPointerUp, WINDOW_CAPTURE); window.addEventListener('pointercancel', onWindowPointerCancel, WINDOW_CAPTURE); @@ -256,13 +374,18 @@ export function installFloatingDrag(options: FloatingDragOptions): () => void { // Best-effort teardown if a drag is active if (session) { try { - cancelDrag(); + if (session.activated) { + cancelDrag(); + } else { + endDrag(session.pointerId); + } } catch { // ignore } } teardownWindowListeners(); + clearActivationTimer(); session = null; handleEl.dataset.dragging = 'false'; }; diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/icons.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/icons.ts new file mode 100644 index 0000000..380c07a --- /dev/null +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/icons.ts @@ -0,0 +1,123 @@ +/** + * Shared SVG Icons for Web Editor UI + * + * All icons are created as inline SVG elements to: + * - Avoid external asset dependencies + * - Support theming via `currentColor` + * - Enable direct DOM manipulation + * + * Design standards: + * - ViewBox: 20x20 + * - Stroke width: 2px + * - Line caps/joins: round + */ + +// ============================================================================= +// Icon Factory Helpers +// ============================================================================= + +function createSvgElement(): SVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 20 20'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('aria-hidden', 'true'); + return svg; +} + +function createStrokePath(d: string): SVGPathElement { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + path.setAttribute('stroke', 'currentColor'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + return path; +} + +// ============================================================================= +// Icon Creators +// ============================================================================= + +/** + * Minus icon (—) for minimize button + */ +export function createMinusIcon(): SVGElement { + const svg = createSvgElement(); + svg.append(createStrokePath('M5 10h10')); + return svg; +} + +/** + * Plus icon (+) for restore/expand button + */ +export function createPlusIcon(): SVGElement { + const svg = createSvgElement(); + svg.append(createStrokePath('M10 5v10M5 10h10')); + return svg; +} + +/** + * Close icon (×) for close button + */ +export function createCloseIcon(): SVGElement { + const svg = createSvgElement(); + svg.append(createStrokePath('M6 6l8 8M14 6l-8 8')); + return svg; +} + +/** + * Grip icon (6 dots) for drag handle + */ +export function createGripIcon(): SVGElement { + const svg = createSvgElement(); + + const DOT_POSITIONS: ReadonlyArray = [ + [7, 6], + [13, 6], + [7, 10], + [13, 10], + [7, 14], + [13, 14], + ]; + + for (const [cx, cy] of DOT_POSITIONS) { + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', String(cx)); + circle.setAttribute('cy', String(cy)); + circle.setAttribute('r', '1.4'); + circle.setAttribute('fill', 'currentColor'); + svg.append(circle); + } + + return svg; +} + +/** + * Chevron icon (▼) for collapse/expand indicator + */ +export function createChevronIcon(): SVGElement { + const svg = createSvgElement(); + svg.classList.add('we-chevron'); + svg.append(createStrokePath('M7 8l3 3 3-3')); + return svg; +} + +/** + * Undo icon (↶) for undo button + */ +export function createUndoIcon(): SVGElement { + const svg = createSvgElement(); + // Arrow pointing left with curved tail + svg.append(createStrokePath('M4 10h10a3 3 0 0 0 0-6H7M4 10l3-3M4 10l3 3')); + return svg; +} + +/** + * Redo icon (↷) for redo button + */ +export function createRedoIcon(): SVGElement { + const svg = createSvgElement(); + // Arrow pointing right with curved tail + svg.append(createStrokePath('M16 10H6a3 3 0 0 1 0-6h7M16 10l-3-3M16 10l-3 3')); + return svg; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/border-control.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/border-control.ts index d0b0304..947618e 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/border-control.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/border-control.ts @@ -19,6 +19,7 @@ import type { DesignTokensService } from '../../../core/design-tokens'; import { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group'; import { createInputContainer, type InputContainer } from '../components/input-container'; import { createColorField, type ColorField } from './color-field'; +import { createGradientControl } from './gradient-control'; import { combineLengthValue, formatLengthForDisplay } from './css-helpers'; import { wireNumberStepping } from './number-stepping'; import type { DesignControl } from '../types'; @@ -31,6 +32,10 @@ const SVG_NS = 'http://www.w3.org/2000/svg'; const BORDER_STYLE_VALUES = ['solid', 'dashed', 'dotted', 'none'] as const; +/** Color type for border: solid uses border-color, gradient uses border-image-source */ +const BORDER_COLOR_TYPE_VALUES = ['solid', 'gradient'] as const; +type BorderColorType = (typeof BORDER_COLOR_TYPE_VALUES)[number]; + const BORDER_EDGE_VALUES = ['all', 'top', 'right', 'bottom', 'left'] as const; type BorderEdge = (typeof BORDER_EDGE_VALUES)[number]; @@ -109,14 +114,6 @@ function isFieldFocused(el: HTMLElement): boolean { } } -function normalizeLength(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) return ''; - if (/^-?(?:\d+|\d*\.\d+)$/.test(trimmed)) return `${trimmed}px`; - if (/^-?\d+\.$/.test(trimmed)) return `${trimmed.slice(0, -1)}px`; - return trimmed; -} - function readInlineValue(element: Element, property: string): string { try { const style = (element as HTMLElement).style; @@ -134,6 +131,17 @@ function readComputedValue(element: Element, property: string): string { } } +/** + * Infer border color type from border-image-source value. + * Returns 'gradient' if a gradient is detected, 'solid' otherwise. + */ +function inferBorderColorType(borderImageSource: string): BorderColorType { + const trimmed = borderImageSource.trim().toLowerCase(); + if (!trimmed || trimmed === 'none') return 'solid'; + if (/\b(?:linear|radial|conic)-gradient\s*\(/i.test(trimmed)) return 'gradient'; + return 'solid'; +} + function createBorderEdgeIcon(edge: BorderEdge): SVGElement { const svg = document.createElementNS(SVG_NS, 'svg'); svg.setAttribute('viewBox', '0 0 15 15'); @@ -244,6 +252,7 @@ export function createBorderControl(options: BorderControlOptions): DesignContro let currentTarget: Element | null = null; let currentBorderEdge: BorderEdge = 'all'; + let currentColorType: BorderColorType = 'solid'; const root = document.createElement('div'); root.className = 'we-field-group'; @@ -252,24 +261,6 @@ export function createBorderControl(options: BorderControlOptions): DesignContro // DOM Helpers // =========================================================================== - function createInputRow( - labelText: string, - ariaLabel: string, - ): { row: HTMLDivElement; input: HTMLInputElement } { - const row = document.createElement('div'); - row.className = 'we-field'; - const label = document.createElement('span'); - label.className = 'we-field-label'; - label.textContent = labelText; - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'we-input'; - input.autocomplete = 'off'; - input.setAttribute('aria-label', ariaLabel); - row.append(label, input); - return { row, input }; - } - function createSelectRow( labelText: string, ariaLabel: string, @@ -323,15 +314,41 @@ export function createBorderControl(options: BorderControlOptions): DesignContro borderEdgeMount.style.flex = '1'; borderEdgeRow.append(borderEdgeLabel, borderEdgeMount); - const { row: borderWidthRow, input: borderWidthInput } = createInputRow('Width', 'Border Width'); + // Border Width with InputContainer + const borderWidthRow = document.createElement('div'); + borderWidthRow.className = 'we-field'; + const borderWidthLabel = document.createElement('span'); + borderWidthLabel.className = 'we-field-label'; + borderWidthLabel.textContent = 'Width'; + const borderWidthContainer = createInputContainer({ + ariaLabel: 'Border Width', + inputMode: 'decimal', + prefix: null, + suffix: 'px', + }); + borderWidthRow.append(borderWidthLabel, borderWidthContainer.root); + const borderWidthInput = borderWidthContainer.input; + const { row: borderStyleRow, select: borderStyleSelect } = createSelectRow( 'Style', 'Border Style', BORDER_STYLE_VALUES, ); + + // Color Type selector (solid/gradient) + const { row: colorTypeRow, select: colorTypeSelect } = createSelectRow( + 'Type', + 'Border Color Type', + BORDER_COLOR_TYPE_VALUES, + ); + + // Solid color row const { row: borderColorRow, colorFieldContainer: borderColorContainer } = createColorRow('Color'); + // Gradient mount for border-image-source + const borderGradientMount = document.createElement('div'); + // Border Radius (unified + per-corner editing) const borderRadiusRow = document.createElement('div'); borderRadiusRow.className = 'we-field'; @@ -402,7 +419,8 @@ export function createBorderControl(options: BorderControlOptions): DesignContro borderRadiusCorners['bottom-right'].root, ); - borderRadiusControl.append(borderRadiusUnifiedRow, borderRadiusCornersGrid); + // Keep corners grid separate from the unified row for full-width display when expanded + borderRadiusControl.append(borderRadiusUnifiedRow); borderRadiusRow.append(borderRadiusLabel, borderRadiusControl); const borderRadiusField: BorderRadiusFieldState = { @@ -419,13 +437,31 @@ export function createBorderControl(options: BorderControlOptions): DesignContro cornersMaterialized: false, }; + // Create combined row for Width and Radius + const widthAndRadiusRow = document.createElement('div'); + widthAndRadiusRow.className = 'we-field-row'; + borderWidthRow.style.flex = '1'; + borderWidthRow.style.minWidth = '0'; + borderRadiusRow.style.flex = '1'; + borderRadiusRow.style.minWidth = '0'; + widthAndRadiusRow.append(borderWidthRow, borderRadiusRow); + wireNumberStepping(disposer, borderWidthInput, { mode: 'css-length' }); wireNumberStepping(disposer, borderRadiusUnified.input, { mode: 'css-length' }); for (const corner of BORDER_RADIUS_CORNERS) { wireNumberStepping(disposer, borderRadiusCorners[corner].input, { mode: 'css-length' }); } - root.append(borderEdgeRow, borderWidthRow, borderStyleRow, borderColorRow, borderRadiusRow); + // borderRadiusCornersGrid placed after widthAndRadiusRow to span full width when expanded + root.append( + borderEdgeRow, + widthAndRadiusRow, + borderRadiusCornersGrid, + borderStyleRow, + colorTypeRow, + borderColorRow, + borderGradientMount, + ); container.appendChild(root); disposer.add(() => root.remove()); @@ -479,6 +515,19 @@ export function createBorderControl(options: BorderControlOptions): DesignContro }); disposer.add(() => borderColorField.dispose()); + // =========================================================================== + // Gradient Control (for border-image-source) + // =========================================================================== + + const borderGradientControl = createGradientControl({ + container: borderGradientMount, + transactionManager, + tokensService, + property: 'border-image-source', + allowNone: true, + }); + disposer.add(() => borderGradientControl.dispose()); + // =========================================================================== // Field State Map // =========================================================================== @@ -607,6 +656,101 @@ export function createBorderControl(options: BorderControlOptions): DesignContro commitBorderRadiusTransaction(); } + // =========================================================================== + // Color Type (Solid / Gradient) + // =========================================================================== + + /** + * Update visibility of color-related rows based on currentColorType. + */ + function updateColorTypeVisibility(): void { + borderColorRow.hidden = currentColorType !== 'solid'; + borderGradientMount.hidden = currentColorType !== 'gradient'; + } + + /** + * Update edge selector disabled state based on color type. + * Gradient mode requires 'all' edges (border-image doesn't support per-edge). + */ + function updateEdgeSelectorState(): void { + const hasTarget = Boolean(currentTarget && currentTarget.isConnected); + + // In gradient mode, lock edge to 'all' since border-image applies to all edges + if (currentColorType === 'gradient') { + if (currentBorderEdge !== 'all') { + commitTransaction('border-width'); + commitTransaction('border-style'); + commitTransaction('border-color'); + currentBorderEdge = 'all'; + } + borderEdgeGroup.setValue('all'); + } + + borderEdgeGroup.setDisabled(!hasTarget || currentColorType === 'gradient'); + } + + /** + * Set border color type and apply necessary CSS changes. + * Uses multiStyle transaction to atomically set border-image properties. + */ + function setColorType(type: BorderColorType): void { + const target = currentTarget; + + currentColorType = type; + colorTypeSelect.value = type; + + updateColorTypeVisibility(); + updateEdgeSelectorState(); + + if (!target || !target.isConnected) return; + + // Use multiStyle to atomically manage border-image properties + const handle = transactionManager.beginMultiStyle(target, [ + 'border-image-source', + 'border-image-slice', + ]); + if (!handle) return; + + if (type === 'solid') { + // Clear border-image when switching to solid color + handle.set({ + 'border-image-source': 'none', + 'border-image-slice': '', + }); + } else { + // Set up border-image for gradient mode + const inlineSource = readInlineValue(target, 'border-image-source'); + const computedSource = readComputedValue(target, 'border-image-source'); + const currentSource = inlineSource || computedSource; + + // Use existing gradient or provide a default + const hasValidGradient = + currentSource && + currentSource.trim() && + currentSource.trim().toLowerCase() !== 'none' && + /\b(?:linear|radial|conic)-gradient\s*\(/i.test(currentSource); + + const gradientValue = hasValidGradient + ? currentSource + : 'linear-gradient(90deg, #000000, #ffffff)'; + + handle.set({ + 'border-image-source': gradientValue, + 'border-image-slice': '1', + }); + } + + handle.commit({ merge: true }); + } + + // Wire color type selector change event + disposer.listen(colorTypeSelect, 'change', () => { + const type = colorTypeSelect.value as BorderColorType; + setColorType(type); + borderGradientControl.refresh(); + syncAllFields(); + }); + // =========================================================================== // Field Synchronization // =========================================================================== @@ -682,6 +826,7 @@ export function createBorderControl(options: BorderControlOptions): DesignContro input.disabled = true; input.value = ''; input.placeholder = ''; + if (property === 'border-width') borderWidthContainer.setSuffix('px'); return; } @@ -692,7 +837,15 @@ export function createBorderControl(options: BorderControlOptions): DesignContro const inlineValue = readInlineValue(target, cssProperty); const computedValue = readComputedValue(target, cssProperty); - input.value = inlineValue || computedValue; + + // Use formatLengthForDisplay for border-width to set proper suffix + if (property === 'border-width') { + const formatted = formatLengthForDisplay(inlineValue || computedValue); + input.value = formatted.value; + borderWidthContainer.setSuffix(formatted.suffix); + } else { + input.value = inlineValue || computedValue; + } input.placeholder = ''; } else if (field.kind === 'select') { const select = field.element; @@ -742,7 +895,9 @@ export function createBorderControl(options: BorderControlOptions): DesignContro function syncAllFields(): void { for (const p of PROPS) syncField(p); const hasTarget = Boolean(currentTarget && currentTarget.isConnected); - borderEdgeGroup.setDisabled(!hasTarget); + colorTypeSelect.disabled = !hasTarget; + updateColorTypeVisibility(); + updateEdgeSelectorState(); } // =========================================================================== @@ -754,11 +909,16 @@ export function createBorderControl(options: BorderControlOptions): DesignContro if (field.kind !== 'text') return; const input = field.element; - const normalize = property === 'border-width' ? normalizeLength : (v: string) => v.trim(); + + // Use combineLengthValue for border-width to include suffix + const getNextValue = + property === 'border-width' + ? () => combineLengthValue(input.value, borderWidthContainer.getSuffixText()) + : () => input.value.trim(); disposer.listen(input, 'input', () => { const handle = beginTransaction(property); - if (handle) handle.set(normalize(input.value)); + if (handle) handle.set(getNextValue()); }); disposer.listen(input, 'blur', () => { @@ -932,11 +1092,50 @@ export function createBorderControl(options: BorderControlOptions): DesignContro if (disposer.isDisposed) return; if (element !== currentTarget) commitAllTransactions(); currentTarget = element; + + // Infer color type from border-image-source + if (element && element.isConnected) { + const borderImageSource = + readInlineValue(element, 'border-image-source') || + readComputedValue(element, 'border-image-source'); + currentColorType = inferBorderColorType(borderImageSource); + } else { + currentColorType = 'solid'; + } + colorTypeSelect.value = currentColorType; + + // In gradient mode, ensure edge is set to 'all' + if (currentColorType === 'gradient') { + currentBorderEdge = 'all'; + borderEdgeGroup.setValue('all'); + } + + // Update gradient control target + borderGradientControl.setTarget(element); syncAllFields(); } function refresh(): void { if (disposer.isDisposed) return; + + // Re-infer color type from element to handle external changes (CSS panel, Undo/Redo) + const target = currentTarget; + if (target && target.isConnected) { + const borderImageSource = + readInlineValue(target, 'border-image-source') || + readComputedValue(target, 'border-image-source'); + const inferredType = inferBorderColorType(borderImageSource); + if (inferredType !== currentColorType) { + currentColorType = inferredType; + colorTypeSelect.value = inferredType; + if (inferredType === 'gradient') { + currentBorderEdge = 'all'; + borderEdgeGroup.setValue('all'); + } + } + } + + borderGradientControl.refresh(); syncAllFields(); } diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/effects-control.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/effects-control.ts index 8dc0958..56c3eb7 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/effects-control.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/effects-control.ts @@ -1,20 +1,25 @@ /** - * Effects Control (Phase 5.1) + * Effects Control * - * Edits inline effect styles: - * - box-shadow (Drop Shadow / Inner Shadow) - * - filter: blur() (Layer Blur) - * - backdrop-filter: blur() (Backdrop Blur) + * Current scope: + * - Inline `box-shadow` list editor (Drop Shadow / Inner Shadow) + * + * Features: + * - Add/remove multiple shadow effects + * - Toggle visibility (hide/show) per shadow + * - Adjust panel for detailed editing (type, offset, blur, spread, color) * * Notes: - * - `box-shadow` can contain multiple comma-separated shadows; this control edits the first one. - * - `filter`/`backdrop-filter` can contain multiple functions; this control updates `blur()` and keeps others. + * - Rendering reads inline styles only (no computed fallback) + * - Hidden shadows are kept in memory for the current editor session */ import { Disposer } from '../../../utils/disposables'; import type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager'; import type { DesignTokensService } from '../../../core/design-tokens'; +import { createInputContainer, type InputContainer } from '../components/input-container'; import { createColorField, type ColorField } from './color-field'; +import { combineLengthValue, formatLengthForDisplay } from './css-helpers'; import { wireNumberStepping } from './number-stepping'; import type { DesignControl } from '../types'; @@ -480,9 +485,12 @@ export interface EffectsControlOptions { transactionManager: TransactionManager; /** Optional: Design tokens service for TokenPill/TokenPicker integration (Phase 5.3) */ tokensService?: DesignTokensService; + /** Optional: Container element for header actions (e.g., add button) */ + headerActionsContainer?: HTMLElement; } -export function createEffectsControl(options: EffectsControlOptions): DesignControl { +/** @deprecated Use `createEffectsControl` (box-shadow list) instead. */ +export function createLegacyEffectsControl(options: EffectsControlOptions): DesignControl { const { container, transactionManager, tokensService } = options; const disposer = new Disposer(); @@ -995,3 +1003,1399 @@ export function createEffectsControl(options: EffectsControlOptions): DesignCont return { setTarget, refresh, dispose }; } + +// ============================================================================= +// Box Shadow List (Effects v2) +// ============================================================================= + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const BOX_SHADOW_PROPERTY = 'box-shadow'; + +// 效果类型定义 +const EFFECT_TYPE_OPTIONS = [ + { value: 'drop-shadow', label: 'Drop Shadow', category: 'shadow' }, + { value: 'inner-shadow', label: 'Inner Shadow', category: 'shadow' }, + { value: 'layer-blur', label: 'Layer Blur', category: 'blur' }, + { value: 'backdrop-blur', label: 'Backdrop Blur', category: 'blur' }, +] as const; + +type EffectTypeValue = (typeof EFFECT_TYPE_OPTIONS)[number]['value']; +type EffectCategory = 'shadow' | 'blur'; + +// ----------------------------------------------------------------------------- +// ID Generation +// ----------------------------------------------------------------------------- + +let shadowItemIdCounter = 0; + +function createShadowItemId(): string { + try { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + } catch { + // Fallback to counter + } + shadowItemIdCounter += 1; + return `shadow_${shadowItemIdCounter}_${Date.now()}`; +} + +// ----------------------------------------------------------------------------- +// Effect Item Types +// ----------------------------------------------------------------------------- + +interface EffectItemBase { + id: string; + enabled: boolean; +} + +// Shadow 类型效果(Drop Shadow / Inner Shadow) +interface ShadowEffectItem extends EffectItemBase { + type: 'drop-shadow' | 'inner-shadow'; + kind: 'parsed'; + inset: boolean; + offsetX: string; + offsetY: string; + blurRadius: string; + spreadRadius: string; + color: string; +} + +// Blur 类型效果(Layer Blur / Backdrop Blur) +interface BlurEffectItem extends EffectItemBase { + type: 'layer-blur' | 'backdrop-blur'; + kind: 'parsed'; + radius: string; +} + +// 无法解析的原始效果 +interface RawEffectItem extends EffectItemBase { + type: 'raw'; + kind: 'raw'; + property: 'box-shadow' | 'filter' | 'backdrop-filter'; + rawText: string; +} + +type EffectItem = ShadowEffectItem | BlurEffectItem | RawEffectItem; + +function isShadowEffect(item: EffectItem): item is ShadowEffectItem { + return item.type === 'drop-shadow' || item.type === 'inner-shadow'; +} + +function isBlurEffect(item: EffectItem): item is BlurEffectItem { + return item.type === 'layer-blur' || item.type === 'backdrop-blur'; +} + +// ----------------------------------------------------------------------------- +// Effect Item Helpers +// ----------------------------------------------------------------------------- + +function createDefaultShadowEffect(): ShadowEffectItem { + return { + id: createShadowItemId(), + enabled: true, + type: 'drop-shadow', + kind: 'parsed', + inset: false, + offsetX: '0px', + offsetY: '4px', + blurRadius: '12px', + spreadRadius: '0px', + color: 'rgba(0, 0, 0, 0.15)', + }; +} + +function createDefaultBlurEffect(type: 'layer-blur' | 'backdrop-blur'): BlurEffectItem { + return { + id: createShadowItemId(), + enabled: true, + type, + kind: 'parsed', + radius: '8px', + }; +} + +function getEffectItemLabel(item: EffectItem): string { + const option = EFFECT_TYPE_OPTIONS.find((o) => o.value === item.type); + if (option) return option.label; + if (item.kind === 'raw') return 'Custom Effect'; + return 'Unknown Effect'; +} + +function effectItemKey(item: EffectItem): string { + if (item.kind === 'raw') return `raw:${item.property}:${item.rawText.trim()}`; + if (isShadowEffect(item)) { + const css = formatBoxShadow({ + inset: item.inset, + offsetX: item.offsetX, + offsetY: item.offsetY, + blurRadius: item.blurRadius, + spreadRadius: item.spreadRadius, + color: item.color, + }); + return `shadow:${item.type}:${css.toLowerCase()}`; + } + // isBlurEffect(item) must be true at this point + return `blur:${item.type}:${item.radius}`; +} + +// ----------------------------------------------------------------------------- +// Parsing & Formatting +// ----------------------------------------------------------------------------- + +function parseBoxShadowToEffects(raw: string): EffectItem[] { + const trimmed = raw.trim(); + if (!trimmed || trimmed.toLowerCase() === 'none') return []; + + const segments = splitTopLevel(trimmed, ',') + .map((s) => s.trim()) + .filter(Boolean); + + const out: EffectItem[] = []; + + for (const seg of segments) { + const parsed = parseBoxShadow(seg); + if (parsed) { + out.push({ + id: createShadowItemId(), + enabled: true, + type: parsed.inset ? 'inner-shadow' : 'drop-shadow', + kind: 'parsed', + inset: parsed.inset, + offsetX: parsed.offsetX, + offsetY: parsed.offsetY, + blurRadius: parsed.blurRadius, + spreadRadius: parsed.spreadRadius, + color: parsed.color, + }); + } else { + out.push({ + id: createShadowItemId(), + enabled: true, + type: 'raw', + kind: 'raw', + property: 'box-shadow', + rawText: seg, + }); + } + } + + return out; +} + +function parseFilterBlurToEffect( + raw: string, + type: 'layer-blur' | 'backdrop-blur', +): BlurEffectItem | null { + const radius = parseBlurRadius(raw); + if (!radius) return null; + + return { + id: createShadowItemId(), + enabled: true, + type, + kind: 'parsed', + radius, + }; +} + +function formatEffectsToBoxShadow(items: EffectItem[]): string { + const parts = items + .filter( + (item) => + item.enabled && + (isShadowEffect(item) || (item.kind === 'raw' && item.property === 'box-shadow')), + ) + .map((item) => { + if (item.kind === 'raw') return item.rawText.trim(); + if (isShadowEffect(item)) { + return formatBoxShadow({ + inset: item.inset, + offsetX: item.offsetX, + offsetY: item.offsetY, + blurRadius: item.blurRadius, + spreadRadius: item.spreadRadius, + color: item.color, + }); + } + return ''; + }) + .map((s) => s.trim()) + .filter(Boolean); + + return parts.join(', '); +} + +function getBlurEffectByType( + items: EffectItem[], + type: 'layer-blur' | 'backdrop-blur', +): BlurEffectItem | null { + const item = items.find((i) => i.type === type && i.enabled); + return item && isBlurEffect(item) ? item : null; +} + +function reconcileEffectItems( + prevItems: EffectItem[], + nextEnabledItems: EffectItem[], +): EffectItem[] { + const usedIds = new Set(); + const pool = new Map(); + + for (const item of prevItems) { + const key = effectItemKey(item); + const queue = pool.get(key) ?? []; + queue.push(item); + pool.set(key, queue); + } + + const reconciledEnabled = nextEnabledItems.map((item) => { + const key = effectItemKey(item); + const queue = pool.get(key); + const match = queue?.shift(); + if (match) { + usedIds.add(match.id); + return { ...item, id: match.id, enabled: true }; + } + return item; + }); + + // Keep session-only hidden effects (enabled=false) that are not present in CSS + const remainingHidden = prevItems.filter((item) => !item.enabled && !usedIds.has(item.id)); + + return [...reconciledEnabled, ...remainingHidden]; +} + +// ----------------------------------------------------------------------------- +// DOM Helpers +// ----------------------------------------------------------------------------- + +function getActiveElementInSameRoot(root: HTMLElement): Element | null { + try { + const rootNode = root.getRootNode(); + if (rootNode instanceof ShadowRoot) return rootNode.activeElement; + return document.activeElement; + } catch { + return null; + } +} + +function isFocusedWithin(root: HTMLElement): boolean { + const active = getActiveElementInSameRoot(root); + return active instanceof HTMLElement ? root.contains(active) : false; +} + +// ----------------------------------------------------------------------------- +// SVG Icons +// ----------------------------------------------------------------------------- + +function createSvgIcon(pathD: string, viewBox = '0 0 24 24'): SVGElement { + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', viewBox); + svg.setAttribute('fill', 'none'); + svg.setAttribute('aria-hidden', 'true'); + + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', pathD); + path.setAttribute('stroke', 'currentColor'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + svg.append(path); + + return svg; +} + +function createPlusIcon(): SVGElement { + return createSvgIcon('M12 5v14M5 12h14'); +} + +function createTrashIcon(): SVGElement { + return createSvgIcon('M9 6h6M10 6l.5-1.5h3L14 6M7 6l1 14h8l1-14'); +} + +function createAdjustIcon(): SVGElement { + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', '0 0 20 20'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('aria-hidden', 'true'); + + const lines = document.createElementNS(SVG_NS, 'path'); + lines.setAttribute('d', 'M4 5H16 M4 10H16 M4 15H16'); + lines.setAttribute('stroke', 'currentColor'); + lines.setAttribute('stroke-width', '2'); + lines.setAttribute('stroke-linecap', 'round'); + lines.setAttribute('stroke-linejoin', 'round'); + svg.append(lines); + + const knobs: ReadonlyArray = [ + [7, 5], + [13, 10], + [9, 15], + ]; + + for (const [cx, cy] of knobs) { + const circle = document.createElementNS(SVG_NS, 'circle'); + circle.setAttribute('cx', String(cx)); + circle.setAttribute('cy', String(cy)); + circle.setAttribute('r', '1.6'); + circle.setAttribute('fill', 'none'); + circle.setAttribute('stroke', 'currentColor'); + circle.setAttribute('stroke-width', '2'); + svg.append(circle); + } + + return svg; +} + +function createEyeIcon(enabled: boolean): SVGElement { + if (enabled) { + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('aria-hidden', 'true'); + + const outline = document.createElementNS(SVG_NS, 'path'); + outline.setAttribute('d', 'M2.5 12s3.5-7 9.5-7 9.5 7 9.5 7-3.5 7-9.5 7-9.5-7-9.5-7z'); + outline.setAttribute('stroke', 'currentColor'); + outline.setAttribute('stroke-width', '2'); + outline.setAttribute('stroke-linecap', 'round'); + outline.setAttribute('stroke-linejoin', 'round'); + + const iris = document.createElementNS(SVG_NS, 'circle'); + iris.setAttribute('cx', '12'); + iris.setAttribute('cy', '12'); + iris.setAttribute('r', '3'); + iris.setAttribute('stroke', 'currentColor'); + iris.setAttribute('stroke-width', '2'); + + svg.append(outline, iris); + return svg; + } + + return createSvgIcon( + 'M3 3l18 18M10.6 10.6A3 3 0 0012 15a3 3 0 002.4-4.4M9.5 5.8A10.7 10.7 0 0112 5c6 0 9.5 7 9.5 7a17.4 17.4 0 01-3.1 4.1M6.2 6.2A17.8 17.8 0 002.5 12s3.5 7 9.5 7c1 0 1.9-.2 2.8-.5', + ); +} + +function createIconButton(ariaLabel: string): HTMLButtonElement { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'we-effects-icon-btn'; + btn.setAttribute('aria-label', ariaLabel); + return btn; +} + +// ----------------------------------------------------------------------------- +// Item View Types +// ----------------------------------------------------------------------------- + +interface EffectItemViewBase { + id: string; + root: HTMLDivElement; + row: HTMLDivElement; + adjustBtn: HTMLButtonElement; + nameBtn: HTMLButtonElement; + eyeBtn: HTMLButtonElement; + deleteBtn: HTMLButtonElement; + popover: HTMLDivElement; + disposer: Disposer; + setOpen(open: boolean): void; + focusFirst(): void; + sync(item: EffectItem): void; + dispose(): void; +} + +interface ShadowEffectItemView extends EffectItemViewBase { + viewType: 'shadow'; + typeSelect: HTMLSelectElement; + offsetX: InputContainer; + offsetY: InputContainer; + blur: InputContainer; + spread: InputContainer; + colorField: ColorField; +} + +interface BlurEffectItemView extends EffectItemViewBase { + viewType: 'blur'; + typeSelect: HTMLSelectElement; + radiusInput: InputContainer; +} + +interface RawEffectItemView extends EffectItemViewBase { + viewType: 'raw'; + rawInput: HTMLInputElement; +} + +type EffectItemView = ShadowEffectItemView | BlurEffectItemView | RawEffectItemView; + +function getViewTypeForItem(item: EffectItem): EffectItemView['viewType'] { + if (item.kind === 'raw') return 'raw'; + if (isShadowEffect(item)) return 'shadow'; + if (isBlurEffect(item)) return 'blur'; + return 'raw'; +} + +// ----------------------------------------------------------------------------- +// Main Factory +// ----------------------------------------------------------------------------- + +export function createEffectsControl(options: EffectsControlOptions): DesignControl { + const { container, transactionManager, tokensService, headerActionsContainer } = options; + const disposer = new Disposer(); + + // 每个元素的 effect items 缓存(仅限当前编辑会话) + // 使用 WeakMap 的原因: + // 1. 隐藏的 effect 不会写入 CSS(enabled=false),但需要在会话内记住以便恢复 + // 2. WeakMap 保证元素被移除时自动释放内存,无需手动清理 + // 3. 只读取 inline style(不读 computed),因此缓存仅用于保留用户的隐藏操作 + const perTargetItems = new WeakMap(); + + let currentTarget: Element | null = null; + let currentItems: EffectItem[] = []; + let itemsById = new Map(); + let openItemId: string | null = null; + let activeHandle: StyleTransactionHandle | null = null; + let activeProperty: string | null = null; + + // Root container + const root = document.createElement('div'); + root.className = 'we-field-group we-effects'; + container.append(root); + disposer.add(() => root.remove()); + + // Add button - placed in header if available, otherwise in toolbar + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'we-effects-icon-btn'; + addBtn.setAttribute('aria-label', 'Add effect'); + addBtn.append(createPlusIcon()); + + if (headerActionsContainer) { + // 将 + 按钮放在 group header 的右侧(chevron 左边) + headerActionsContainer.insertBefore(addBtn, headerActionsContainer.firstChild); + disposer.add(() => addBtn.remove()); + } else { + // 回退:在内容区域显示 toolbar + const toolbar = document.createElement('div'); + toolbar.className = 'we-effects-toolbar'; + toolbar.append(addBtn); + root.append(toolbar); + } + + // Effect list container + const list = document.createElement('div'); + list.className = 'we-effects-list'; + + root.append(list); + + // View registry + const views = new Map(); + + // ------------------------------------------------------------------------- + // State Management + // ------------------------------------------------------------------------- + + function setCurrentItems(next: EffectItem[]): void { + currentItems = next; + itemsById = new Map(next.map((i) => [i.id, i])); + const target = currentTarget; + if (target) perTargetItems.set(target, next); + } + + function getItem(id: string): EffectItem | null { + return itemsById.get(id) ?? null; + } + + // ------------------------------------------------------------------------- + // Transaction Management + // ------------------------------------------------------------------------- + + function beginTransaction(property: string): StyleTransactionHandle | null { + if (disposer.isDisposed) return null; + const target = currentTarget; + if (!target || !target.isConnected) return null; + if (activeHandle && activeProperty === property) return activeHandle; + // Commit previous if different property + if (activeHandle) activeHandle.commit({ merge: true }); + activeHandle = transactionManager.beginStyle(target, property); + activeProperty = property; + return activeHandle; + } + + function commitTransaction(): void { + const handle = activeHandle; + activeHandle = null; + activeProperty = null; + if (handle) handle.commit({ merge: true }); + } + + function rollbackTransaction(): void { + const handle = activeHandle; + activeHandle = null; + activeProperty = null; + if (handle) handle.rollback(); + } + + function isEditing(): boolean { + // 只在有打开的 popover 或正在进行事务时阻止刷新 + // 避免过于宽泛的 focus 检测导致外部样式变化无法同步 + return activeHandle !== null || openItemId !== null; + } + + // ------------------------------------------------------------------------- + // Preview & Apply + // ------------------------------------------------------------------------- + + function previewCurrentItems(): void { + const target = currentTarget; + if (!target || !target.isConnected) return; + + // Preview box-shadow + const shadowHandle = beginTransaction(BOX_SHADOW_PROPERTY); + if (shadowHandle) { + shadowHandle.set(formatEffectsToBoxShadow(currentItems)); + } + + // Preview filter blur + const layerBlur = getBlurEffectByType(currentItems, 'layer-blur'); + if (layerBlur) { + const filterHandle = beginTransaction('filter'); + if (filterHandle) { + const existing = readInlineValue(target, 'filter'); + filterHandle.set(upsertBlurFunction(existing, layerBlur.radius)); + } + } + + // Preview backdrop-filter blur + const backdropBlur = getBlurEffectByType(currentItems, 'backdrop-blur'); + if (backdropBlur) { + const backdropHandle = beginTransaction('backdrop-filter'); + if (backdropHandle) { + const existing = readInlineValue(target, 'backdrop-filter'); + backdropHandle.set(upsertBlurFunction(existing, backdropBlur.radius)); + } + } + } + + function applyCurrentItemsDiscrete(): void { + const target = currentTarget; + if (!target || !target.isConnected) return; + commitTransaction(); + + // Apply box-shadow + transactionManager.applyStyle( + target, + BOX_SHADOW_PROPERTY, + formatEffectsToBoxShadow(currentItems), + { + merge: false, + }, + ); + + // Apply filter blur + const layerBlur = getBlurEffectByType(currentItems, 'layer-blur'); + const existingFilter = readInlineValue(target, 'filter'); + transactionManager.applyStyle( + target, + 'filter', + upsertBlurFunction(existingFilter, layerBlur?.radius ?? ''), + { + merge: false, + }, + ); + + // Apply backdrop-filter blur + const backdropBlur = getBlurEffectByType(currentItems, 'backdrop-blur'); + const existingBackdrop = readInlineValue(target, 'backdrop-filter'); + transactionManager.applyStyle( + target, + 'backdrop-filter', + upsertBlurFunction(existingBackdrop, backdropBlur?.radius ?? ''), + { + merge: false, + }, + ); + } + + // ------------------------------------------------------------------------- + // Popover Management + // ------------------------------------------------------------------------- + + function closePopover(opts?: { commit?: boolean; rollback?: boolean }): void { + const commit = opts?.commit ?? false; + const rollback = opts?.rollback ?? false; + + if (rollback) rollbackTransaction(); + else if (commit) commitTransaction(); + + const wasOpen = openItemId !== null; + openItemId = null; + for (const view of views.values()) view.setOpen(false); + + // 关闭后同步一次,确保 currentItems 与真实 inline 一致 + // 避免浏览器归一化/修正值后产生不一致 + if (wasOpen && !rollback) { + syncFromTarget(true); + } + } + + function setPopoverOpen(id: string | null): void { + if (id === openItemId) { + closePopover({ commit: true }); + return; + } + + closePopover({ commit: true }); + + if (!id) return; + const view = views.get(id); + if (!view) return; + + openItemId = id; + for (const [vid, v] of views) v.setOpen(vid === id); + view.focusFirst(); + } + + // ------------------------------------------------------------------------- + // Input Helpers + // ------------------------------------------------------------------------- + + function setLengthInput(containerRef: InputContainer, raw: string): void { + const formatted = formatLengthForDisplay(raw); + containerRef.input.value = formatted.value; + containerRef.setSuffix(formatted.suffix); + } + + // ------------------------------------------------------------------------- + // Effect Type Conversion + // ------------------------------------------------------------------------- + + /** + * Convert an effect item to a new type, preserving compatible fields. + */ + function createEffectItemWithType( + prev: EffectItem, + nextType: EffectTypeValue, + ): EffectItem | null { + if (prev.kind === 'raw') return null; + + // Convert to blur type + if (nextType === 'layer-blur' || nextType === 'backdrop-blur') { + const base = createDefaultBlurEffect(nextType); + // Map blur radius from previous effect + const mappedRadius = isBlurEffect(prev) + ? prev.radius + : isShadowEffect(prev) + ? prev.blurRadius + : base.radius; + return { + ...base, + id: prev.id, + enabled: prev.enabled, + radius: mappedRadius || base.radius, + }; + } + + // Convert to shadow type + const base = createDefaultShadowEffect(); + const shadowPrev = isShadowEffect(prev) ? prev : null; + const blurPrev = isBlurEffect(prev) ? prev : null; + const mappedBlurRadius = shadowPrev?.blurRadius ?? blurPrev?.radius ?? base.blurRadius; + + return { + ...base, + id: prev.id, + enabled: prev.enabled, + type: nextType, + inset: nextType === 'inner-shadow', + offsetX: shadowPrev?.offsetX ?? base.offsetX, + offsetY: shadowPrev?.offsetY ?? base.offsetY, + blurRadius: mappedBlurRadius || base.blurRadius, + spreadRadius: shadowPrev?.spreadRadius ?? base.spreadRadius, + color: shadowPrev?.color ?? base.color, + }; + } + + /** + * Update an effect item's type, potentially converting between shadow/blur. + */ + function updateEffectItemType(id: string, nextType: EffectTypeValue): void { + const prev = getItem(id); + if (!prev || prev.kind === 'raw') return; + if (prev.type === nextType) return; + + const nextItem = createEffectItemWithType(prev, nextType); + if (!nextItem) return; + + let nextItems = currentItems.map((it) => (it.id === id ? nextItem : it)); + + // Only one blur effect per type (filter/backdrop-filter) is supported + if (nextItem.type === 'layer-blur' || nextItem.type === 'backdrop-blur') { + nextItems = nextItems.filter((it) => it.id === id || it.type !== nextItem.type); + } + + setCurrentItems(nextItems); + renderList(); + applyCurrentItemsDiscrete(); + + // The view might have been recreated (shadow <-> blur), restore focus + if (openItemId === id) { + views.get(id)?.focusFirst(); + } + } + + // ------------------------------------------------------------------------- + // Item View Factory + // ------------------------------------------------------------------------- + + function createItemView(item: EffectItem): EffectItemView { + const itemDisposer = new Disposer(); + + const wrap = document.createElement('div'); + wrap.className = 'we-effects-item-wrap'; + + const row = document.createElement('div'); + row.className = 'we-effects-item'; + row.dataset.enabled = item.enabled ? 'true' : 'false'; + row.dataset.open = 'false'; + + const adjustBtn = createIconButton('Adjust effect'); + adjustBtn.append(createAdjustIcon()); + + const nameBtn = document.createElement('button'); + nameBtn.type = 'button'; + nameBtn.className = 'we-effects-name'; + + const eyeBtn = createIconButton('Toggle visibility'); + + const deleteBtn = createIconButton('Remove effect'); + deleteBtn.append(createTrashIcon()); + + row.append(adjustBtn, nameBtn, eyeBtn, deleteBtn); + + const popover = document.createElement('div'); + popover.className = 'we-effects-popover'; + popover.hidden = true; + + wrap.append(row, popover); + + // Common event handlers + itemDisposer.listen(adjustBtn, 'click', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setPopoverOpen(item.id); + }); + + itemDisposer.listen(nameBtn, 'click', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setPopoverOpen(item.id); + }); + + itemDisposer.listen(eyeBtn, 'click', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const it = getItem(item.id); + if (!it) return; + it.enabled = !it.enabled; + row.dataset.enabled = it.enabled ? 'true' : 'false'; + eyeBtn.replaceChildren(createEyeIcon(it.enabled)); + applyCurrentItemsDiscrete(); + }); + + itemDisposer.listen(deleteBtn, 'click', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (openItemId === item.id) closePopover({ commit: true }); + setCurrentItems(currentItems.filter((i) => i.id !== item.id)); + views.get(item.id)?.dispose(); + views.delete(item.id); + renderList(); + applyCurrentItemsDiscrete(); + }); + + // Raw shadow item view + if (item.kind === 'raw') { + const content = document.createElement('div'); + content.className = 'we-effects-popover-content'; + + const field = document.createElement('div'); + field.className = 'we-field'; + + const label = document.createElement('span'); + label.className = 'we-field-label'; + label.textContent = 'Value'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'we-input'; + input.autocomplete = 'off'; + input.spellcheck = false; + input.setAttribute('aria-label', 'Shadow value'); + + field.append(label, input); + content.append(field); + popover.append(content); + + itemDisposer.listen(input, 'input', () => { + const it = getItem(item.id); + if (!it || it.kind !== 'raw') return; + it.rawText = input.value; + previewCurrentItems(); + }); + + itemDisposer.listen(input, 'blur', () => { + commitTransaction(); + }); + + itemDisposer.listen(input, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitTransaction(); + input.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + closePopover({ rollback: true }); + syncFromTarget(true); + } + }); + + const view: RawEffectItemView = { + id: item.id, + viewType: 'raw', + root: wrap, + row, + adjustBtn, + nameBtn, + eyeBtn, + deleteBtn, + popover, + rawInput: input, + disposer: itemDisposer, + setOpen(open: boolean): void { + row.dataset.open = open ? 'true' : 'false'; + popover.hidden = !open; + }, + focusFirst(): void { + input.focus(); + input.select(); + }, + sync(next: EffectItem): void { + row.dataset.enabled = next.enabled ? 'true' : 'false'; + nameBtn.textContent = getEffectItemLabel(next); + eyeBtn.replaceChildren(createEyeIcon(next.enabled)); + if (next.kind === 'raw') input.value = next.rawText; + }, + dispose(): void { + itemDisposer.dispose(); + wrap.remove(); + }, + }; + + return view; + } + + // Blur effect view (Layer Blur / Backdrop Blur) + if (isBlurEffect(item)) { + const content = document.createElement('div'); + content.className = 'we-effects-popover-content'; + + // Type select (only blur types) + const typeField = document.createElement('div'); + typeField.className = 'we-field'; + + const typeLabel = document.createElement('span'); + typeLabel.className = 'we-field-label'; + typeLabel.textContent = 'Type'; + + const typeSelect = document.createElement('select'); + typeSelect.className = 'we-select'; + typeSelect.setAttribute('aria-label', 'Effect type'); + + for (const v of EFFECT_TYPE_OPTIONS) { + const opt = document.createElement('option'); + opt.value = v.value; + opt.textContent = v.label; + typeSelect.append(opt); + } + + typeField.append(typeLabel, typeSelect); + + // Radius input + const radiusField = document.createElement('div'); + radiusField.className = 'we-field'; + + const radiusLabel = document.createElement('span'); + radiusLabel.className = 'we-field-label'; + radiusLabel.textContent = 'Blur'; + + const radiusInput = createInputContainer({ + ariaLabel: 'Blur radius', + inputMode: 'decimal', + suffix: 'px', + }); + + radiusField.append(radiusLabel, radiusInput.root); + content.append(typeField, radiusField); + popover.append(content); + + wireNumberStepping(itemDisposer, radiusInput.input, { + mode: 'css-length', + min: 0, + step: 1, + shiftStep: 10, + altStep: 0.1, + }); + + itemDisposer.listen(radiusInput.input, 'input', () => { + const it = getItem(item.id); + if (!it || !isBlurEffect(it)) return; + it.radius = combineLengthValue(radiusInput.input.value, radiusInput.getSuffixText()); + previewCurrentItems(); + }); + + itemDisposer.listen(radiusInput.input, 'blur', () => { + commitTransaction(); + syncFromTarget(true); + }); + + itemDisposer.listen(radiusInput.input, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitTransaction(); + syncFromTarget(true); + radiusInput.input.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + closePopover({ rollback: true }); + syncFromTarget(true); + } + }); + + const onTypeChange = () => { + updateEffectItemType(item.id, typeSelect.value as EffectTypeValue); + }; + + itemDisposer.listen(typeSelect, 'input', onTypeChange); + itemDisposer.listen(typeSelect, 'change', onTypeChange); + + const view: BlurEffectItemView = { + id: item.id, + viewType: 'blur', + root: wrap, + row, + adjustBtn, + nameBtn, + eyeBtn, + deleteBtn, + popover, + disposer: itemDisposer, + typeSelect, + radiusInput, + setOpen(open: boolean): void { + row.dataset.open = open ? 'true' : 'false'; + popover.hidden = !open; + }, + focusFirst(): void { + radiusInput.input.focus(); + radiusInput.input.select(); + }, + sync(next: EffectItem): void { + row.dataset.enabled = next.enabled ? 'true' : 'false'; + nameBtn.textContent = getEffectItemLabel(next); + eyeBtn.replaceChildren(createEyeIcon(next.enabled)); + if (!isBlurEffect(next)) return; + typeSelect.value = next.type; + setLengthInput(radiusInput, next.radius); + }, + dispose(): void { + itemDisposer.dispose(); + wrap.remove(); + }, + }; + + return view; + } + + // Shadow effect view (Drop Shadow / Inner Shadow) + const content = document.createElement('div'); + content.className = 'we-effects-popover-content'; + + // Type select (only shadow types) + const typeField = document.createElement('div'); + typeField.className = 'we-field'; + + const typeLabel = document.createElement('span'); + typeLabel.className = 'we-field-label'; + typeLabel.textContent = 'Type'; + + const typeSelect = document.createElement('select'); + typeSelect.className = 'we-select'; + typeSelect.setAttribute('aria-label', 'Effect type'); + + for (const v of EFFECT_TYPE_OPTIONS) { + const opt = document.createElement('option'); + opt.value = v.value; + opt.textContent = v.label; + typeSelect.append(opt); + } + + typeField.append(typeLabel, typeSelect); + + // X/Y row + const xyRow = document.createElement('div'); + xyRow.className = 'we-field-row'; + + const x = createInputContainer({ + ariaLabel: 'Shadow offset X', + inputMode: 'decimal', + prefix: 'X', + suffix: 'px', + }); + const y = createInputContainer({ + ariaLabel: 'Shadow offset Y', + inputMode: 'decimal', + prefix: 'Y', + suffix: 'px', + }); + xyRow.append(x.root, y.root); + + // Blur/Spread row + const blurRow = document.createElement('div'); + blurRow.className = 'we-field-row'; + + const blur = createInputContainer({ + ariaLabel: 'Shadow blur radius', + inputMode: 'decimal', + prefix: 'B', + suffix: 'px', + }); + const spread = createInputContainer({ + ariaLabel: 'Shadow spread radius', + inputMode: 'decimal', + prefix: 'S', + suffix: 'px', + }); + blurRow.append(blur.root, spread.root); + + // Color field + const colorFieldRow = document.createElement('div'); + colorFieldRow.className = 'we-field'; + + const colorLabel = document.createElement('span'); + colorLabel.className = 'we-field-label'; + colorLabel.textContent = 'Color'; + + const colorMount = document.createElement('div'); + colorMount.style.minWidth = '0'; + + colorFieldRow.append(colorLabel, colorMount); + + content.append(typeField, xyRow, blurRow, colorFieldRow); + popover.append(content); + + // Wire number stepping + wireNumberStepping(itemDisposer, x.input, { mode: 'css-length' }); + wireNumberStepping(itemDisposer, y.input, { mode: 'css-length' }); + wireNumberStepping(itemDisposer, blur.input, { + mode: 'css-length', + min: 0, + step: 1, + shiftStep: 10, + altStep: 0.1, + }); + wireNumberStepping(itemDisposer, spread.input, { + mode: 'css-length', + step: 1, + shiftStep: 10, + altStep: 0.1, + }); + + // Create color field + const colorField = createColorField({ + container: colorMount, + ariaLabel: 'Shadow color', + tokensService, + getTokenTarget: () => currentTarget, + onInput: (value) => { + const it = getItem(item.id); + if (!it || !isShadowEffect(it)) return; + it.color = value; + previewCurrentItems(); + }, + onCommit: () => { + commitTransaction(); + }, + onCancel: () => { + rollbackTransaction(); + syncFromTarget(true); + }, + }); + itemDisposer.add(() => colorField.dispose()); + + // Wire length field handlers + const wireShadowLengthField = ( + containerRef: InputContainer, + key: keyof Pick, + ) => { + itemDisposer.listen(containerRef.input, 'input', () => { + const it = getItem(item.id); + if (!it || !isShadowEffect(it)) return; + const next = combineLengthValue(containerRef.input.value, containerRef.getSuffixText()); + it[key] = next; + previewCurrentItems(); + }); + + itemDisposer.listen(containerRef.input, 'blur', () => { + commitTransaction(); + syncFromTarget(true); + }); + + itemDisposer.listen(containerRef.input, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitTransaction(); + syncFromTarget(true); + containerRef.input.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + rollbackTransaction(); + syncFromTarget(true); + } + }); + }; + + wireShadowLengthField(x, 'offsetX'); + wireShadowLengthField(y, 'offsetY'); + wireShadowLengthField(blur, 'blurRadius'); + wireShadowLengthField(spread, 'spreadRadius'); + + // Type change handler + const onTypeChange = () => { + updateEffectItemType(item.id, typeSelect.value as EffectTypeValue); + }; + + itemDisposer.listen(typeSelect, 'input', onTypeChange); + itemDisposer.listen(typeSelect, 'change', onTypeChange); + + const view: ShadowEffectItemView = { + id: item.id, + viewType: 'shadow', + root: wrap, + row, + adjustBtn, + nameBtn, + eyeBtn, + deleteBtn, + popover, + disposer: itemDisposer, + typeSelect, + offsetX: x, + offsetY: y, + blur, + spread, + colorField, + setOpen(open: boolean): void { + row.dataset.open = open ? 'true' : 'false'; + popover.hidden = !open; + }, + focusFirst(): void { + typeSelect.focus(); + }, + sync(next: EffectItem): void { + row.dataset.enabled = next.enabled ? 'true' : 'false'; + nameBtn.textContent = getEffectItemLabel(next); + eyeBtn.replaceChildren(createEyeIcon(next.enabled)); + if (!isShadowEffect(next)) return; + typeSelect.value = next.type; + setLengthInput(x, next.offsetX); + setLengthInput(y, next.offsetY); + setLengthInput(blur, next.blurRadius); + setLengthInput(spread, next.spreadRadius); + colorField.setValue(next.color); + }, + dispose(): void { + itemDisposer.dispose(); + wrap.remove(); + }, + }; + + return view; + } + + // ------------------------------------------------------------------------- + // List Rendering + // ------------------------------------------------------------------------- + + function renderList(): void { + const ids = new Set(currentItems.map((i) => i.id)); + + // Remove stale views + for (const [id, view] of Array.from(views.entries())) { + if (!ids.has(id)) { + if (openItemId === id) openItemId = null; + view.dispose(); + views.delete(id); + } + } + + // Create/update views + for (const item of currentItems) { + const existing = views.get(item.id); + const expectedViewType = getViewTypeForItem(item); + if (!existing || existing.viewType !== expectedViewType) { + existing?.dispose(); + views.set(item.id, createItemView(item)); + } + + const view = views.get(item.id)!; + view.sync(item); + view.setOpen(openItemId === item.id); + list.append(view.root); + } + } + + // ------------------------------------------------------------------------- + // Sync from Target + // ------------------------------------------------------------------------- + + function syncFromTarget(force = false): void { + const target = currentTarget; + + if (!target || !target.isConnected) { + addBtn.disabled = true; + setCurrentItems([]); + closePopover({ commit: true }); + renderList(); + return; + } + + addBtn.disabled = false; + if (!force && isEditing()) return; + + // Parse box-shadow effects + const boxShadowInline = readInlineValue(target, BOX_SHADOW_PROPERTY); + const shadowEffects = parseBoxShadowToEffects(boxShadowInline); + + // Parse filter blur + const filterInline = readInlineValue(target, 'filter'); + const layerBlur = parseFilterBlurToEffect(filterInline, 'layer-blur'); + + // Parse backdrop-filter blur + const backdropInline = readInlineValue(target, 'backdrop-filter'); + const backdropBlur = parseFilterBlurToEffect(backdropInline, 'backdrop-blur'); + + // Combine all enabled effects + const nextEnabled: EffectItem[] = [ + ...shadowEffects, + ...(layerBlur ? [layerBlur] : []), + ...(backdropBlur ? [backdropBlur] : []), + ]; + + const prev = perTargetItems.get(target) ?? []; + setCurrentItems(reconcileEffectItems(prev, nextEnabled)); + renderList(); + } + + // ------------------------------------------------------------------------- + // Event Handlers + // ------------------------------------------------------------------------- + + disposer.listen(addBtn, 'click', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const target = currentTarget; + if (!target || !target.isConnected) return; + + closePopover({ commit: true }); + const newEffect = createDefaultShadowEffect(); + const next = [...currentItems, newEffect]; + setCurrentItems(next); + renderList(); + applyCurrentItemsDiscrete(); + setPopoverOpen(newEffect.id); + }); + + // Close popover when clicking outside the open item + // 使用 document 的捕获阶段监听,确保点击 Effects 控件外也能关闭 + const handleClickOutside = (e: MouseEvent) => { + const openId = openItemId; + if (!openId) return; + const view = views.get(openId); + if (!view) return; + + // Use composedPath to handle Shadow DOM event retargeting + const path = typeof e.composedPath === 'function' ? e.composedPath() : []; + const clickedInside = + path.length > 0 + ? path.includes(view.root) + : (() => { + const node = e.target as Node | null; + return !!(node && view.root.contains(node)); + })(); + + if (clickedInside) return; + closePopover({ commit: true }); + }; + + // 在 document 上监听捕获阶段的点击事件 + const doc = root.ownerDocument; + doc.addEventListener('click', handleClickOutside, true); + disposer.add(() => doc.removeEventListener('click', handleClickOutside, true)); + + // Escape closes the popover and rolls back the current preview transaction + // 在 root 上监听捕获阶段的键盘事件 + const handleEscape = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + if (!openItemId) return; + e.preventDefault(); + e.stopPropagation(); + closePopover({ rollback: true }); + syncFromTarget(true); + }; + + root.addEventListener('keydown', handleEscape, true); + disposer.add(() => root.removeEventListener('keydown', handleEscape, true)); + + // ------------------------------------------------------------------------- + // DesignControl Interface + // ------------------------------------------------------------------------- + + function setTarget(element: Element | null): void { + if (disposer.isDisposed) return; + + if (element !== currentTarget) { + commitTransaction(); + closePopover({ commit: true }); + } + + currentTarget = element; + + if (element && element.isConnected) { + setCurrentItems(perTargetItems.get(element) ?? []); + } else { + setCurrentItems([]); + } + + syncFromTarget(true); + } + + function refresh(): void { + if (disposer.isDisposed) return; + syncFromTarget(false); + } + + function dispose(): void { + commitTransaction(); + currentTarget = null; + for (const view of views.values()) view.dispose(); + views.clear(); + disposer.dispose(); + } + + // Initialize + syncFromTarget(true); + + return { setTarget, refresh, dispose }; +} diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/gradient-control.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/gradient-control.ts index 7d69638..62d4d3a 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/gradient-control.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/gradient-control.ts @@ -77,6 +77,19 @@ interface ThumbDragSession { thumbElement: HTMLElement; } +/** + * Keyboard session state for thumb stepping (Arrow keys). + * Maintains a snapshot for Escape rollback and keeps thumbs stable during stepping. + */ +interface ThumbKeyboardSession { + /** ID of the stop being adjusted */ + stopId: StopId; + /** Position snapshot before stepping started (for rollback on Escape) */ + initialPositions: Map; + /** The thumb element being adjusted (focus anchor) */ + thumbElement: HTMLElement; +} + /** Basic gradient stop (used in parsing and UI state) */ interface GradientStop { color: string; @@ -986,14 +999,33 @@ export interface GradientControlOptions { transactionManager: TransactionManager; /** Optional: Design tokens service for TokenPill/TokenPicker integration (Phase 5.3) */ tokensService?: DesignTokensService; + /** + * CSS property to write the gradient value to. + * Defaults to 'background-image'. + * Use 'border-image-source' for border gradient support. + */ + property?: string; + /** + * Whether to show the 'None' option in the gradient type selector. + * Defaults to true. + * Set to false for text gradient mode where 'none' would make text invisible. + */ + allowNone?: boolean; } export function createGradientControl(options: GradientControlOptions): DesignControl { - const { container, transactionManager, tokensService } = options; + const { + container, + transactionManager, + tokensService, + property: cssProperty = 'background-image', + allowNone = true, + } = options; const disposer = new Disposer(); let currentTarget: Element | null = null; - let currentType: GradientType = 'none'; + // Default type is 'linear' when allowNone is false, otherwise 'none' + let currentType: GradientType = allowNone ? 'none' : 'linear'; // Current stops array - supports N stops with stable identity let currentStops: StopModel[] = createDefaultStopModels(); @@ -1002,9 +1034,8 @@ export function createGradientControl(options: GradientControlOptions): DesignCo // Active thumb drag session (null when not dragging) let thumbDrag: ThumbDragSession | null = null; - // Legacy values for backward compatibility with current 2-stop UI - let stop1ColorValue = DEFAULT_STOP_1.color; - let stop2ColorValue = DEFAULT_STOP_2.color; + // Active thumb keyboard session (null when not stepping via arrow keys) + let thumbKeyboard: ThumbKeyboardSession | null = null; let backgroundHandle: StyleTransactionHandle | null = null; @@ -1066,50 +1097,19 @@ export function createGradientControl(options: GradientControlOptions): DesignCo return { row, select }; } - function createStopRow( - labelText: string, - posAriaLabel: string, - ): { - row: HTMLDivElement; - colorFieldContainer: HTMLDivElement; - posInput: HTMLInputElement; - } { - const row = document.createElement('div'); - row.className = 'we-field'; - - const label = document.createElement('span'); - label.className = 'we-field-label'; - label.textContent = labelText; - - const wrapper = document.createElement('div'); - wrapper.style.cssText = 'display:flex;flex:1;min-width:0;gap:4px;'; - - const colorFieldContainer = document.createElement('div'); - colorFieldContainer.style.cssText = 'flex:1;min-width:0;'; - - const posInput = document.createElement('input'); - posInput.type = 'text'; - posInput.className = 'we-input'; - posInput.autocomplete = 'off'; - posInput.spellcheck = false; - posInput.inputMode = 'decimal'; - posInput.setAttribute('aria-label', posAriaLabel); - posInput.style.cssText = 'flex:0 0 56px;text-align:right;'; - posInput.placeholder = '0'; - - wrapper.append(colorFieldContainer, posInput); - row.append(label, wrapper); - return { row, colorFieldContainer, posInput }; - } - // ------------------------------------------------------------------------- // Create UI Elements // ------------------------------------------------------------------------- + // Build gradient type options based on allowNone parameter + const gradientTypeOptions = allowNone + ? GRADIENT_TYPES + : GRADIENT_TYPES.filter((t) => t.value !== 'none'); + const { row: typeRow, select: typeSelect } = createSelectRow( 'Type', 'Gradient Type', - GRADIENT_TYPES, + gradientTypeOptions, ); // Gradient preview bar @@ -1160,18 +1160,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo stopsList.className = 'we-gradient-stops-list'; stopsList.setAttribute('role', 'list'); - const { - row: stop1Row, - colorFieldContainer: stop1ColorContainer, - posInput: stop1PosInput, - } = createStopRow('Stop 1', 'Stop 1 Position (%)'); - - const { - row: stop2Row, - colorFieldContainer: stop2ColorContainer, - posInput: stop2PosInput, - } = createStopRow('Stop 2', 'Stop 2 Position (%)'); - root.append( typeRow, gradientBarRow, @@ -1181,8 +1169,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo posYRow, stopsHeaderRow, stopsList, - stop1Row, - stop2Row, ); container.append(root); disposer.add(() => root.remove()); @@ -1212,63 +1198,84 @@ export function createGradientControl(options: GradientControlOptions): DesignCo shiftStep: 10, altStep: 0.1, }); - wireNumberStepping(disposer, stop1PosInput, { + + // --------------------------------------------------------------------------- + // Single Position Input bound to selectedStopId (Phase 7) + // Host is re-parented into the selected row's position editor slot. + // --------------------------------------------------------------------------- + const selectedStopPosHost = document.createElement('div'); + + const selectedStopPosInput = document.createElement('input'); + selectedStopPosInput.type = 'text'; + selectedStopPosInput.className = 'we-gradient-stop-pos-input'; + selectedStopPosInput.autocomplete = 'off'; + selectedStopPosInput.spellcheck = false; + selectedStopPosInput.inputMode = 'decimal'; + selectedStopPosInput.placeholder = '0'; + selectedStopPosInput.setAttribute('aria-label', 'Selected Stop Position (%)'); + selectedStopPosHost.append(selectedStopPosInput); + + // Enable keyboard stepping (↑/↓ to increment/decrement) + wireNumberStepping(disposer, selectedStopPosInput, { mode: 'number', min: 0, max: 100, step: 1, shiftStep: 10, - altStep: 0.1, - }); - wireNumberStepping(disposer, stop2PosInput, { - mode: 'number', - min: 0, - max: 100, - step: 1, - shiftStep: 10, - altStep: 0.1, }); - // Create color fields - const stop1ColorField: ColorField = createColorField({ - container: stop1ColorContainer, - ariaLabel: 'Stop 1 Color', - tokensService, - getTokenTarget: () => currentTarget, - onInput: (value) => { - stop1ColorValue = value; - previewGradient(); - }, - onCommit: () => { - commitTransaction(); - syncAllFields(); - }, - onCancel: () => { - rollbackTransaction(); - syncAllFields(true); - }, - }); - disposer.add(() => stop1ColorField.dispose()); + /** + * Commit the position edit: sort stops and finalize the transaction. + * Called on blur or Enter key. + */ + function commitSelectedStopPosition(): void { + // Commit-time sort ensures CSS output is monotonically ordered + sortCurrentStopsByPosition(); - const stop2ColorField: ColorField = createColorField({ - container: stop2ColorContainer, - ariaLabel: 'Stop 2 Color', - tokensService, - getTokenTarget: () => currentTarget, - onInput: (value) => { - stop2ColorValue = value; + // Only commit if we have an active transaction + if (backgroundHandle) { previewGradient(); - }, - onCommit: () => { commitTransaction(); - syncAllFields(); - }, - onCancel: () => { - rollbackTransaction(); - syncAllFields(true); - }, + } + syncAllFields(); + } + + /** + * Cancel the position edit and rollback to the original value. + * Called on Escape key. + */ + function cancelSelectedStopPosition(): void { + rollbackTransaction(); + syncAllFields(true); + } + + // Handle input changes - update model and preview in real-time + disposer.listen(selectedStopPosInput, 'input', () => { + const id = selectedStopId; + if (!id) return; + + const parsed = parseNumber(selectedStopPosInput.value); + if (parsed === null) return; + + // Update model and preview in real-time + setStopPositionById(id, parsed); + previewGradient(); + }); + + // Commit on blur + disposer.listen(selectedStopPosInput, 'blur', commitSelectedStopPosition); + + // Handle Enter/Escape keys + disposer.listen(selectedStopPosInput, 'keydown', (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitSelectedStopPosition(); + selectedStopPosInput.blur(); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelSelectedStopPosition(); + } }); - disposer.add(() => stop2ColorField.dispose()); // Single ColorField bound to selectedStopId (Phase 4E) // Host is re-parented into the selected row's editor slot. @@ -1286,17 +1293,9 @@ export function createGradientControl(options: GradientControlOptions): DesignCo const index = currentStops.findIndex((s) => s.id === id); if (index < 0) return; - // Update model (new path) + // Update model currentStops[index]!.color = value; - // Sync legacy values (old path - Phase 4H will remove) - if (index === 0) stop1ColorValue = value; - if (index === 1) stop2ColorValue = value; - - // Keep hidden legacy fields consistent - if (index === 0) stop1ColorField.setValue(value); - if (index === 1) stop2ColorField.setValue(value); - // Update placeholder when switching away from var() selectedStopColorField.setPlaceholder( needsColorPlaceholder(value) ? (currentStops[index]!.placeholderColor ?? '') : '', @@ -1327,7 +1326,7 @@ export function createGradientControl(options: GradientControlOptions): DesignCo if (backgroundHandle) return backgroundHandle; - backgroundHandle = transactionManager.beginStyle(target, 'background-image'); + backgroundHandle = transactionManager.beginStyle(target, cssProperty); return backgroundHandle; } @@ -1361,10 +1360,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo const clamped = clampPercent(position); currentStops[index]!.position = clamped; - - // Sync legacy position inputs for backward compatibility (Phase 4H will remove) - if (index === 0) stop1PosInput.value = String(Math.round(clamped)); - if (index === 1) stop2PosInput.value = String(Math.round(clamped)); } /** @@ -1378,12 +1373,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo stop.position = savedPos; } } - - // Sync legacy position inputs - const stop1 = currentStops[0]; - const stop2 = currentStops[1]; - if (stop1) stop1PosInput.value = String(Math.round(stop1.position)); - if (stop2) stop2PosInput.value = String(Math.round(stop2.position)); } /** @@ -1400,6 +1389,7 @@ export function createGradientControl(options: GradientControlOptions): DesignCo // Remove dragging visual state gradientBar.classList.remove('we-gradient-bar--dragging'); + session.thumbElement.classList.remove('we-gradient-thumb--dragging'); // Best-effort: release capture (e.g., Escape cancel while pointer is still down) try { @@ -1409,6 +1399,11 @@ export function createGradientControl(options: GradientControlOptions): DesignCo } if (commit) { + // Commit-time sort ensures CSS output is monotonically ordered + sortCurrentStopsByPosition(); + + // Update preview with sorted positions before committing + previewGradient(); commitTransaction(); syncAllFields(); } else { @@ -1432,6 +1427,163 @@ export function createGradientControl(options: GradientControlOptions): DesignCo return clampPercent(rawPercent); } + // ------------------------------------------------------------------------- + // Thumb Keyboard Stepping (Phase 9) + // ------------------------------------------------------------------------- + + /** + * Start a keyboard stepping session for a thumb. + * Similar to drag session but triggered by arrow keys. + */ + function startThumbKeyboardSession(stopId: StopId, thumbElement: HTMLElement): void { + if (thumbDrag) return; + if (currentType === 'none') return; + if (typeSelect.disabled) return; + + // If session already exists for this stop, don't restart + if (thumbKeyboard?.stopId === stopId) return; + + // If switching stops, commit previous session first + if (thumbKeyboard) { + endThumbKeyboard(true); + } + + // Snapshot all positions for potential rollback + const initialPositions = new Map(); + for (const stop of currentStops) { + initialPositions.set(stop.id, stop.position); + } + + thumbKeyboard = { stopId, initialPositions, thumbElement }; + beginTransaction(); + } + + /** + * End the keyboard stepping session. + * @param commit - If true, commit changes; if false, rollback to initial state + */ + function endThumbKeyboard(commit: boolean): void { + const session = thumbKeyboard; + if (!session) return; + thumbKeyboard = null; + + if (commit) { + // Commit-time sort keeps CSS output monotonic + sortCurrentStopsByPosition(); + previewGradient(); + commitTransaction(); + syncAllFields(); + } else { + restoreStopPositions(session.initialPositions); + rollbackTransaction(); + syncAllFields(true); + } + } + + /** + * Handle focus on a thumb - select the corresponding stop. + */ + function handleThumbFocus(event: FocusEvent): void { + if (thumbDrag) return; + if (currentType === 'none') return; + if (typeSelect.disabled) return; + + const thumb = event.currentTarget as HTMLElement; + const stopId = thumb.dataset.stopId; + if (!stopId) return; + + if (selectedStopId !== stopId) { + selectedStopId = stopId; + // Preserve thumbs to avoid focus loss during selection sync + updateGradientBar({ preserveThumbs: true }); + } + } + + /** + * Handle blur on a thumb - commit any active keyboard session. + */ + function handleThumbBlur(event: FocusEvent): void { + const session = thumbKeyboard; + if (!session) return; + + // Only commit if blur is from the session's thumb + const thumb = event.currentTarget as HTMLElement; + if (thumb !== session.thumbElement) return; + + // Commit on blur (similar to input field behavior) + endThumbKeyboard(true); + } + + /** + * Handle keydown on a thumb - arrow keys for stepping, Escape for cancel. + */ + function handleThumbKeyDown(event: KeyboardEvent): void { + // Preserve navigation shortcuts (Cmd/Ctrl + Arrow for cursor movement) + if (event.metaKey || event.ctrlKey) return; + if (thumbDrag) return; + if (currentType === 'none') return; + if (typeSelect.disabled) return; + + const thumb = event.currentTarget as HTMLElement; + const stopId = thumb.dataset.stopId; + if (!stopId) return; + + // Escape cancels the keyboard session + if (event.key === 'Escape') { + const session = thumbKeyboard; + if (!session || session.stopId !== stopId) return; + event.preventDefault(); + event.stopPropagation(); + endThumbKeyboard(false); + return; + } + + // Handle arrow keys for position adjustment + const isArrow = + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'ArrowUp' || + event.key === 'ArrowDown'; + if (!isArrow) return; + + event.preventDefault(); + event.stopPropagation(); + + // ArrowLeft/ArrowDown: decrease, ArrowRight/ArrowUp: increase + // Shift modifier: step by 10 instead of 1 + const sign = event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -1 : 1; + const step = event.shiftKey ? 10 : 1; + const delta = sign * step; + + // Ensure stop is selected and session is active + selectedStopId = stopId; + startThumbKeyboardSession(stopId, thumb); + + const idx = currentStops.findIndex((s) => s.id === stopId); + if (idx < 0) return; + + setStopPositionById(stopId, currentStops[idx]!.position + delta); + previewGradient(); + } + + /** + * Sync slider ARIA attributes on a thumb element. + * Provides accessible name and value for screen readers. + */ + function syncThumbSliderAria(thumb: HTMLElement, position: number): void { + const clamped = clampPercent(position); + const rounded = Math.round(clamped * 100) / 100; + const value = Object.is(rounded, -0) ? 0 : rounded; + + thumb.setAttribute('role', 'slider'); + thumb.setAttribute('aria-label', 'Gradient stop position'); + thumb.setAttribute('aria-valuemin', '0'); + thumb.setAttribute('aria-valuemax', '100'); + thumb.setAttribute('aria-valuenow', String(value)); + thumb.setAttribute('aria-valuetext', `${value}%`); + thumb.setAttribute('aria-orientation', 'horizontal'); + } + // ------------------------------------------------------------------------- // Stop Add/Delete (Phase 6) // ------------------------------------------------------------------------- @@ -1528,32 +1680,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo return rgbaToCss(interpolateRgba(leftRgba, rightRgba, t)); } - /** - * Sync legacy stop fields (stop1/stop2) from currentStops model. - * Required for backward compatibility until Phase 8 cleanup. - */ - function syncLegacyStopFieldsFromModels(): void { - const stop1 = currentStops[0]; - const stop2 = currentStops[1]; - if (!stop1 || !stop2) return; - - stop1ColorValue = stop1.color; - stop2ColorValue = stop2.color; - - stop1ColorField.setValue(stop1.color); - stop2ColorField.setValue(stop2.color); - - stop1ColorField.setPlaceholder( - needsColorPlaceholder(stop1.color) ? (stop1.placeholderColor ?? '') : '', - ); - stop2ColorField.setPlaceholder( - needsColorPlaceholder(stop2.color) ? (stop2.placeholderColor ?? '') : '', - ); - - stop1PosInput.value = String(Math.round(clampPercent(stop1.position))); - stop2PosInput.value = String(Math.round(clampPercent(stop2.position))); - } - /** * Get a suggested position for adding a new stop. * Returns the midpoint between the selected stop and its next neighbor. @@ -1630,7 +1756,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo selectedStopId = newStop.id; sortCurrentStopsByPosition(); - syncLegacyStopFieldsFromModels(); previewGradient(); commitTransaction(); @@ -1669,7 +1794,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo } sortCurrentStopsByPosition(); - syncLegacyStopFieldsFromModels(); previewGradient(); commitTransaction(); } @@ -1758,7 +1882,7 @@ export function createGradientControl(options: GradientControlOptions): DesignCo // Update position and color thumb.style.left = `${clampPercent(stop.position)}%`; thumb.style.backgroundColor = preview.color; - thumb.setAttribute('aria-label', `Select stop at ${Math.round(stop.position)}%`); + syncThumbSliderAria(thumb, stop.position); // Update active state const isActive = stopId === selectedStopId; @@ -1783,11 +1907,16 @@ export function createGradientControl(options: GradientControlOptions): DesignCo thumb.dataset.stopId = model.id; thumb.style.left = `${clampPercent(stop.position)}%`; thumb.style.backgroundColor = preview.color; - thumb.setAttribute('aria-label', `Select stop at ${Math.round(stop.position)}%`); + syncThumbSliderAria(thumb, stop.position); // Pointer event handlers for drag (Phase 5) thumb.addEventListener('pointerdown', handleThumbPointerDown); + // Keyboard and focus handlers (Phase 9) + thumb.addEventListener('keydown', handleThumbKeyDown); + thumb.addEventListener('focus', handleThumbFocus); + thumb.addEventListener('blur', handleThumbBlur); + gradientThumbs.append(thumb); } } @@ -1810,6 +1939,10 @@ export function createGradientControl(options: GradientControlOptions): DesignCo // Prevent re-entry if drag is already in progress if (thumbDrag) return; + // Defensive: don't allow drag when disabled or none type + if (currentType === 'none') return; + if (typeSelect.disabled) return; + // Only respond to primary button (left click) and primary pointer if (event.button !== 0) return; if (!event.isPrimary) return; @@ -1818,6 +1951,12 @@ export function createGradientControl(options: GradientControlOptions): DesignCo const stopId = thumb.dataset.stopId; if (!stopId) return; + // If a keyboard stepping session is active, transition to drag + // (share the same transaction handle) + if (thumbKeyboard) { + thumbKeyboard = null; + } + // Prevent default to avoid text selection, button activation, etc. event.preventDefault(); event.stopPropagation(); @@ -1839,8 +1978,9 @@ export function createGradientControl(options: GradientControlOptions): DesignCo thumbElement: thumb, }; - // Add visual feedback + // Add visual feedback - dragging thumb raised above others gradientBar.classList.add('we-gradient-bar--dragging'); + thumb.classList.add('we-gradient-thumb--dragging'); // Capture pointer for reliable tracking outside element bounds try { @@ -1934,13 +2074,17 @@ export function createGradientControl(options: GradientControlOptions): DesignCo if (currentType === 'none') return; if (models.length === 0 || stops.length === 0) return; - const formatPercentLabel = (value: number): string => { + /** + * Format position value for display (e.g., "50%") + */ + const formatPercentValue = (value: number): number => { const clamped = clampPercent(value); const rounded = Math.round(clamped * 100) / 100; - const normalized = Object.is(rounded, -0) ? 0 : rounded; - return `${normalized}%`; + return Object.is(rounded, -0) ? 0 : rounded; }; + const formatPercentLabel = (value: number): string => `${formatPercentValue(value)}%`; + // Build rows with original index for stable ordering const rows = stops .map((stop, index) => ({ @@ -1952,7 +2096,8 @@ export function createGradientControl(options: GradientControlOptions): DesignCo .filter((r) => Boolean(r.model && r.preview)) .sort((a, b) => a.stop.position - b.stop.position || a.index - b.index); - for (const r of rows) { + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + const r = rows[rowIndex]!; const model = r.model!; const stop = r.stop; const preview = r.preview!; @@ -1967,10 +2112,28 @@ export function createGradientControl(options: GradientControlOptions): DesignCo row.tabIndex = 0; row.setAttribute('aria-label', `Select stop at ${formatPercentLabel(stop.position)}`); - // Position column - const pos = document.createElement('span'); + // Position column (Phase 7: static + editor dual-mode) + const pos = document.createElement('div'); pos.className = 'we-gradient-stop-pos'; - pos.textContent = formatPercentLabel(stop.position); + + // Static display (shown when not selected) + const posStatic = document.createElement('span'); + posStatic.className = 'we-gradient-stop-pos-static'; + posStatic.textContent = formatPercentLabel(stop.position); + + // Position editor slot (shown when selected) + const posEditor = document.createElement('div'); + posEditor.className = 'we-gradient-stop-pos-editor'; + + if (isActive) { + posEditor.append(selectedStopPosHost); + // Avoid resetting while user is typing + if (!isPositionInputFocused()) { + selectedStopPosInput.value = String(formatPercentValue(stop.position)); + } + } + + pos.append(posStatic, posEditor); // Color column const color = document.createElement('div'); @@ -2026,7 +2189,14 @@ export function createGradientControl(options: GradientControlOptions): DesignCo removeStopById(model.id); }); - // Focus the color field input when needed + // Focus helpers for position and color inputs + const focusSelectedPosInput = () => { + queueMicrotask(() => { + selectedStopPosInput.focus(); + selectedStopPosInput.select(); + }); + }; + const focusSelectedColorField = () => { queueMicrotask(() => { const input = @@ -2035,11 +2205,12 @@ export function createGradientControl(options: GradientControlOptions): DesignCo }); }; - // Click to select - const selectThisRow = (opts?: { focusColor?: boolean }) => { + // Click to select (with optional focus target) + const selectThisRow = (opts?: { focusColor?: boolean; focusPosition?: boolean }) => { selectedStopId = model.id; updateGradientBar(); if (opts?.focusColor) focusSelectedColorField(); + if (opts?.focusPosition) focusSelectedPosInput(); }; row.addEventListener('click', (event) => { @@ -2049,14 +2220,55 @@ export function createGradientControl(options: GradientControlOptions): DesignCo }); row.addEventListener('keydown', (event: KeyboardEvent) => { - if (model.id === selectedStopId) return; - if (event.key === 'Enter' || event.key === ' ') { + // Don't hijack keys while user is editing text inputs inside the row + if (isTextInputLike(event.target)) return; + + // Arrow key navigation between rows (Phase 9) + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + + const nextIndex = + event.key === 'ArrowUp' + ? Math.max(0, rowIndex - 1) + : Math.min(rows.length - 1, rowIndex + 1); + if (nextIndex === rowIndex) return; + + const nextModel = rows[nextIndex]?.model; + if (!nextModel) return; + + selectedStopId = nextModel.id; + updateGradientBar(); + + // Focus the next row after DOM update + queueMicrotask(() => { + const nextRow = stopsList.querySelector( + `.we-gradient-stop-row[data-stop-id="${nextModel.id}"]`, + ); + nextRow?.focus(); + }); + return; + } + + // Enter/Space to select (only if not already selected) + if (model.id !== selectedStopId && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); selectThisRow(); } }); - // Clicking the color static area selects and focuses the editor + // Clicking the position area selects and focuses the position editor + posStatic.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (model.id === selectedStopId) { + focusSelectedPosInput(); + return; + } + selectThisRow({ focusPosition: true }); + }); + + // Clicking the color static area selects and focuses the color editor colorStatic.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); @@ -2081,9 +2293,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo stopsHeaderRow.hidden = currentType === 'none'; stopsList.hidden = currentType === 'none'; stopsAddBtn.disabled = typeSelect.disabled || currentType === 'none'; - // Phase 4E: hide legacy stop rows (Phase 4H will remove them entirely) - stop1Row.hidden = true; - stop2Row.hidden = true; } function setAllDisabled(disabled: boolean): void { @@ -2092,11 +2301,8 @@ export function createGradientControl(options: GradientControlOptions): DesignCo shapeSelect.disabled = disabled; posXInput.disabled = disabled; posYInput.disabled = disabled; - stop1PosInput.disabled = disabled; - stop2PosInput.disabled = disabled; stopsAddBtn.disabled = disabled; - stop1ColorField.setDisabled(disabled); - stop2ColorField.setDisabled(disabled); + selectedStopPosInput.disabled = disabled || currentType === 'none'; selectedStopColorField.setDisabled(disabled || currentType === 'none'); } @@ -2106,26 +2312,23 @@ export function createGradientControl(options: GradientControlOptions): DesignCo posXInput.value = ''; posYInput.value = ''; - stop1PosInput.value = String(DEFAULT_STOP_1.position); - stop2PosInput.value = String(DEFAULT_STOP_2.position); - // Reset stops array with new models (fresh IDs) currentStops = createDefaultStopModels(); selectedStopId = currentStops[0]?.id ?? null; - stop1ColorValue = DEFAULT_STOP_1.color; - stop2ColorValue = DEFAULT_STOP_2.color; - - stop1ColorField.setValue(DEFAULT_STOP_1.color); - stop2ColorField.setValue(DEFAULT_STOP_2.color); - stop1ColorField.setPlaceholder(''); - stop2ColorField.setPlaceholder(''); - if (!options.skipPreview) { updateGradientBar(); } } + /** + * Check if the position input is currently focused. + * Used to prevent list re-rendering while editing. + */ + function isPositionInputFocused(): boolean { + return isFieldFocused(selectedStopPosInput); + } + function isEditing(): boolean { return ( backgroundHandle !== null || @@ -2134,10 +2337,7 @@ export function createGradientControl(options: GradientControlOptions): DesignCo isFieldFocused(shapeSelect) || isFieldFocused(posXInput) || isFieldFocused(posYInput) || - isFieldFocused(stop1PosInput) || - isFieldFocused(stop2PosInput) || - stop1ColorField.isFocused() || - stop2ColorField.isFocused() || + isPositionInputFocused() || selectedStopColorField.isFocused() ); } @@ -2215,19 +2415,11 @@ export function createGradientControl(options: GradientControlOptions): DesignCo * Returns GradientStop[] for CSS generation (strips id field). */ function collectCurrentStops(): GradientStop[] { - const c1 = stop1ColorValue.trim() || DEFAULT_STOP_1.color; - const c2 = stop2ColorValue.trim() || DEFAULT_STOP_2.color; - const p1 = clampPercent(parseNumber(stop1PosInput.value) ?? DEFAULT_STOP_1.position); - const p2 = clampPercent(parseNumber(stop2PosInput.value) ?? DEFAULT_STOP_2.position); - - const defaultModels = createDefaultStopModels(); - const baseStops = currentStops.length >= 2 ? currentStops : defaultModels; - - return baseStops.map((s, i) => { - if (i === 0) return { color: c1, position: p1 }; - if (i === 1) return { color: c2, position: p2 }; - return { color: s.color.trim() || DEFAULT_STOP_1.color, position: clampPercent(s.position) }; - }); + const baseStops = currentStops.length >= 2 ? currentStops : createDefaultStopModels(); + return baseStops.map((s) => ({ + color: s.color.trim() || DEFAULT_STOP_1.color, + position: clampPercent(s.position), + })); } /** @@ -2242,12 +2434,14 @@ export function createGradientControl(options: GradientControlOptions): DesignCo function previewGradient(): void { if (disposer.isDisposed) return; - // Avoid re-rendering stops list while dragging or editing the selected ColorField, - // otherwise thumbs may lose pointer capture and the input can lose focus/caret. + // Avoid re-rendering stops list while dragging, keyboard stepping, or editing stop editors, + // otherwise thumbs may lose pointer capture/focus and inputs can lose focus/caret. const isDragging = thumbDrag !== null; + const isKeyboardStepping = thumbKeyboard !== null; + const isEditingStopFields = selectedStopColorField.isFocused() || isPositionInputFocused(); updateGradientBar({ - preserveThumbs: isDragging, - refreshStopsList: isDragging ? false : !selectedStopColorField.isFocused(), + preserveThumbs: isDragging || isKeyboardStepping, + refreshStopsList: isDragging || isKeyboardStepping ? false : !isEditingStopFields, }); const target = currentTarget; @@ -2268,8 +2462,10 @@ export function createGradientControl(options: GradientControlOptions): DesignCo if (!target || !target.isConnected) { setAllDisabled(true); - currentType = 'none'; - typeSelect.value = 'none'; + // Use 'linear' as default when 'none' is not allowed + const defaultType = allowNone ? 'none' : 'linear'; + currentType = defaultType; + typeSelect.value = defaultType; resetDefaults(); updateRowVisibility(); updateGradientBar(); @@ -2280,9 +2476,9 @@ export function createGradientControl(options: GradientControlOptions): DesignCo if (isEditing() && !force) return; - const inlineValue = readInlineValue(target, 'background-image'); + const inlineValue = readInlineValue(target, cssProperty); const needsComputed = !inlineValue || /\bvar\s*\(/i.test(inlineValue); - const computedValue = needsComputed ? readComputedValue(target, 'background-image') : ''; + const computedValue = needsComputed ? readComputedValue(target, cssProperty) : ''; const inlineParsed = !isNoneValue(inlineValue) ? parseGradient(inlineValue) : null; const computedParsed = !isNoneValue(computedValue) ? parseGradient(computedValue) : null; @@ -2318,8 +2514,10 @@ export function createGradientControl(options: GradientControlOptions): DesignCo resetDefaults({ skipPreview: true }); if (!parsed) { - currentType = 'none'; - typeSelect.value = 'none'; + // Use 'linear' as default when 'none' is not allowed + const defaultType = allowNone ? 'none' : 'linear'; + currentType = defaultType; + typeSelect.value = defaultType; updateRowVisibility(); updateGradientBar(); return; @@ -2348,27 +2546,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo selectedStopId = currentStops[0]?.id ?? null; } - // UI currently only shows first 2 stops - const stop1 = currentStops[0] ?? DEFAULT_STOP_1; - const stop2 = currentStops[1] ?? DEFAULT_STOP_2; - - stop1ColorValue = stop1.color; - stop2ColorValue = stop2.color; - - stop1ColorField.setValue(stop1.color); - stop2ColorField.setValue(stop2.color); - - // Set placeholders from the mapped values - stop1ColorField.setPlaceholder( - hasVarInInline && needsColorPlaceholder(stop1.color) ? (stop1.placeholderColor ?? '') : '', - ); - stop2ColorField.setPlaceholder( - hasVarInInline && needsColorPlaceholder(stop2.color) ? (stop2.placeholderColor ?? '') : '', - ); - - stop1PosInput.value = String(stop1.position); - stop2PosInput.value = String(stop2.position); - if (parsed.type === 'linear') { currentType = 'linear'; typeSelect.value = 'linear'; @@ -2454,8 +2631,6 @@ export function createGradientControl(options: GradientControlOptions): DesignCo wireTextInput(angleInput); wireTextInput(posXInput); wireTextInput(posYInput); - wireTextInput(stop1PosInput); - wireTextInput(stop2PosInput); // ------------------------------------------------------------------------- // Stop Add/Delete Interactions (Phase 6) diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/layout-control.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/layout-control.ts index 3891497..37fb619 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/layout-control.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/layout-control.ts @@ -741,8 +741,8 @@ export function createLayoutControl(options: LayoutControlOptions): DesignContro gridGapRow.hidden = true; // Adjust gridRow and gapRow to fit in two-column layout - gridRow.classList.add('we-grid-gap-col'); - gapRow.classList.add('we-grid-gap-col'); + gridRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--grid'); + gapRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--gap'); gridGapRow.append(gridRow, gapRow); diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/typography-control.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/typography-control.ts index cc9c620..f4951ce 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/typography-control.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/typography-control.ts @@ -18,6 +18,7 @@ import { Disposer } from '../../../utils/disposables'; import type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager'; import type { DesignTokensService } from '../../../core/design-tokens'; import { createColorField, type ColorField } from './color-field'; +import { createGradientControl } from './gradient-control'; import { createInputContainer, type InputContainer } from '../components/input-container'; import { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group'; import { combineLengthValue, formatLengthForDisplay, hasExplicitUnit } from './css-helpers'; @@ -34,6 +35,10 @@ const FONT_WEIGHT_VALUES = ['100', '200', '300', '400', '500', '600', '700', '80 const TEXT_ALIGN_VALUES = ['left', 'center', 'right', 'justify'] as const; const VERTICAL_ALIGN_VALUES = ['baseline', 'middle', 'top', 'bottom'] as const; +/** Text color type: solid uses 'color' property, gradient uses background-clip: text */ +const TEXT_COLOR_TYPE_VALUES = ['solid', 'gradient'] as const; +type TextColorType = (typeof TEXT_COLOR_TYPE_VALUES)[number]; + type TextAlignValue = (typeof TEXT_ALIGN_VALUES)[number]; type VerticalAlignValue = (typeof VERTICAL_ALIGN_VALUES)[number]; const FONT_FAMILY_PRESET_VALUES = [ @@ -258,6 +263,47 @@ function readComputedValue(element: Element, property: string): string { } } +/** + * Check if a value is a gradient background-image. + */ +function isGradientBackgroundValue(raw: string): boolean { + return /\b(?:linear|radial|conic)-gradient\s*\(/i.test(raw.trim()); +} + +/** + * Check if text-fill-color is transparent (for gradient text detection). + */ +function isTransparentTextFillColor(raw: string): boolean { + const v = raw.trim().toLowerCase(); + if (!v) return false; + if (v === 'transparent') return true; + // Some browsers compute transparent as rgba(..., 0) + if (/^rgba\([^)]*,\s*0\s*\)$/.test(v)) return true; + return false; +} + +/** + * Infer text color type from element's computed styles. + * Returns 'gradient' if background-clip: text pattern is detected. + */ +function inferTextColorType(target: Element): TextColorType { + const bgImage = + readInlineValue(target, 'background-image') || readComputedValue(target, 'background-image'); + const bgClip = + readInlineValue(target, '-webkit-background-clip') || + readComputedValue(target, '-webkit-background-clip'); + const textFill = + readInlineValue(target, '-webkit-text-fill-color') || + readComputedValue(target, '-webkit-text-fill-color'); + + const hasGradientBg = + bgImage && bgImage.toLowerCase() !== 'none' && isGradientBackgroundValue(bgImage); + const hasClipText = bgClip.toLowerCase().includes('text'); + const hasTransparentFill = isTransparentTextFillColor(textFill); + + return hasGradientBg && hasClipText && hasTransparentFill ? 'gradient' : 'solid'; +} + // ============================================================================= // Factory // ============================================================================= @@ -274,6 +320,7 @@ export function createTypographyControl(options: TypographyControlOptions): Desi const disposer = new Disposer(); let currentTarget: Element | null = null; + let currentTextColorType: TextColorType = 'solid'; const root = document.createElement('div'); root.className = 'we-field-group'; @@ -421,6 +468,27 @@ export function createTypographyControl(options: TypographyControlOptions): Desi verticalAlignMount.className = 'we-field-content'; verticalAlignRow.append(verticalAlignLabel, verticalAlignMount); + // --------------------------------------------------------------------------- + // Text Color Type selector (solid / gradient) + // --------------------------------------------------------------------------- + const textColorTypeRow = document.createElement('div'); + textColorTypeRow.className = 'we-field'; + + const textColorTypeLabel = document.createElement('span'); + textColorTypeLabel.className = 'we-field-label'; + textColorTypeLabel.textContent = 'Type'; + + const textColorTypeSelect = document.createElement('select'); + textColorTypeSelect.className = 'we-select'; + textColorTypeSelect.setAttribute('aria-label', 'Text Color Type'); + for (const v of TEXT_COLOR_TYPE_VALUES) { + const opt = document.createElement('option'); + opt.value = v; + opt.textContent = v.charAt(0).toUpperCase() + v.slice(1); + textColorTypeSelect.append(opt); + } + textColorTypeRow.append(textColorTypeLabel, textColorTypeSelect); + // --------------------------------------------------------------------------- // Color (with ColorField - TokenPill and TokenPicker are now built into ColorField) // --------------------------------------------------------------------------- @@ -436,15 +504,36 @@ export function createTypographyControl(options: TypographyControlOptions): Desi colorRow.append(colorLabel, colorFieldContainer); + // Gradient mount for text gradient (uses background-image + background-clip: text) + const textGradientMount = document.createElement('div'); + + // Create combined row for Size and Weight + const sizeAndWeightRow = document.createElement('div'); + sizeAndWeightRow.className = 'we-field-row'; + fontSizeRow.style.flex = '1'; + fontSizeRow.style.minWidth = '0'; + fontWeightRow.style.flex = '1'; + fontWeightRow.style.minWidth = '0'; + sizeAndWeightRow.append(fontSizeRow, fontWeightRow); + + // Create combined row for Line Height and Spacing + const lineHeightAndSpacingRow = document.createElement('div'); + lineHeightAndSpacingRow.className = 'we-field-row'; + lineHeightRow.style.flex = '1'; + lineHeightRow.style.minWidth = '0'; + letterSpacingRow.style.flex = '1'; + letterSpacingRow.style.minWidth = '0'; + lineHeightAndSpacingRow.append(lineHeightRow, letterSpacingRow); + root.append( fontFamilyRow, - fontSizeRow, - fontWeightRow, - lineHeightRow, - letterSpacingRow, + sizeAndWeightRow, + lineHeightAndSpacingRow, textAlignRow, verticalAlignRow, + textColorTypeRow, colorRow, + textGradientMount, ); container.append(root); disposer.add(() => root.remove()); @@ -514,6 +603,23 @@ export function createTypographyControl(options: TypographyControlOptions): Desi }); disposer.add(() => textColorField.dispose()); + // ------------------------------------------------------------------------- + // Text Gradient Control (uses background-image + background-clip: text) + // Note: This intentionally uses background-image which may conflict with + // Background control. Users should be aware that text gradient and element + // background cannot be used simultaneously on the same element. + // ------------------------------------------------------------------------- + const textGradientControl = createGradientControl({ + container: textGradientMount, + transactionManager, + tokensService, + property: 'background-image', + // Disable 'none' option since transparent text-fill-color with no background + // would make text invisible + allowNone: false, + }); + disposer.add(() => textGradientControl.dispose()); + // ------------------------------------------------------------------------- // Field state map // ------------------------------------------------------------------------- @@ -608,6 +714,79 @@ export function createTypographyControl(options: TypographyControlOptions): Desi for (const p of PROPS) commitTransaction(p); } + // ------------------------------------------------------------------------- + // Text Color Type (Solid / Gradient) + // ------------------------------------------------------------------------- + + /** + * Update visibility of color-related rows based on currentTextColorType. + */ + function updateTextColorTypeVisibility(): void { + colorRow.hidden = currentTextColorType !== 'solid'; + textGradientMount.hidden = currentTextColorType !== 'gradient'; + } + + /** + * Set text color type and apply necessary CSS changes. + * Uses multiStyle transaction to atomically set background-clip text properties. + */ + function setTextColorType(type: TextColorType): void { + const target = currentTarget; + + currentTextColorType = type; + textColorTypeSelect.value = type; + updateTextColorTypeVisibility(); + + if (!target || !target.isConnected) return; + + // Ensure we don't leave an open 'color' handle when switching modes + commitTransaction('color'); + + // Use multiStyle to atomically manage text gradient properties + const handle = transactionManager.beginMultiStyle(target, [ + 'background-image', + '-webkit-background-clip', + '-webkit-text-fill-color', + ]); + if (!handle) return; + + if (type === 'solid') { + // Clear text gradient properties when switching to solid color + handle.set({ + 'background-image': '', + '-webkit-background-clip': '', + '-webkit-text-fill-color': '', + }); + } else { + // Set up text gradient properties + const inlineBg = readInlineValue(target, 'background-image'); + const computedBg = readComputedValue(target, 'background-image'); + const currentBg = inlineBg || computedBg; + + // Use existing gradient or provide a default + const hasValidGradient = currentBg && isGradientBackgroundValue(currentBg); + const gradientValue = hasValidGradient + ? currentBg + : 'linear-gradient(90deg, #000000, #ffffff)'; + + handle.set({ + 'background-image': gradientValue, + '-webkit-background-clip': 'text', + '-webkit-text-fill-color': 'transparent', + }); + } + + handle.commit({ merge: true }); + } + + // Wire text color type selector change event + disposer.listen(textColorTypeSelect, 'change', () => { + const type = textColorTypeSelect.value as TextColorType; + setTextColorType(type); + textGradientControl.refresh(); + syncAllFields(); + }); + function syncField(property: TypographyProperty, force = false): void { const field = fields[property]; const target = currentTarget; @@ -786,6 +965,9 @@ export function createTypographyControl(options: TypographyControlOptions): Desi function syncAllFields(): void { for (const p of PROPS) syncField(p); + const hasTarget = Boolean(currentTarget && currentTarget.isConnected); + textColorTypeSelect.disabled = !hasTarget; + updateTextColorTypeVisibility(); } function wireSelect(property: TypographyProperty): void { @@ -942,12 +1124,36 @@ export function createTypographyControl(options: TypographyControlOptions): Desi if (disposer.isDisposed) return; if (element !== currentTarget) commitAllTransactions(); currentTarget = element; + + // Infer text color type from element styles + if (element && element.isConnected) { + currentTextColorType = inferTextColorType(element); + } else { + currentTextColorType = 'solid'; + } + textColorTypeSelect.value = currentTextColorType; + updateTextColorTypeVisibility(); + + // Update gradient control target + textGradientControl.setTarget(element); syncAllFields(); // Token picker target is now managed by ColorField internally via getTokenTarget callback } function refresh(): void { if (disposer.isDisposed) return; + + // Re-infer text color type from element to handle external changes (CSS panel, Undo/Redo) + const target = currentTarget; + if (target && target.isConnected) { + const inferredType = inferTextColorType(target); + if (inferredType !== currentTextColorType) { + currentTextColorType = inferredType; + textColorTypeSelect.value = inferredType; + } + } + + textGradientControl.refresh(); syncAllFields(); } diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/property-panel.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/property-panel.ts index 7994c64..bed8774 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/property-panel.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/property-panel.ts @@ -15,6 +15,7 @@ import { Disposer } from '../../utils/disposables'; import { installFloatingDrag, type FloatingPosition } from '../floating-drag'; +import { createChevronIcon, createCloseIcon, createGripIcon } from '../icons'; import type { PropertyPanel, PropertyPanelOptions, @@ -41,15 +42,15 @@ import { createPropsPanel, type PropsPanel } from './props-panel'; /** Control group configuration */ const CONTROL_GROUPS = [ - { id: 'position', label: 'Position' }, - { id: 'layout', label: 'Layout' }, - { id: 'size', label: 'Size' }, - { id: 'spacing', label: 'Spacing' }, - { id: 'typography', label: 'Typography' }, - { id: 'appearance', label: 'Appearance' }, - { id: 'border', label: 'Border' }, - { id: 'background', label: 'Background' }, - { id: 'effects', label: 'Effects' }, + { id: 'position', label: 'Position', collapsible: true }, + { id: 'layout', label: 'Layout', collapsible: true }, + { id: 'size', label: 'Size', collapsible: true }, + { id: 'spacing', label: 'Spacing', collapsible: true }, + { id: 'typography', label: 'Typography', collapsible: true }, + { id: 'appearance', label: 'Appearance', collapsible: true }, + { id: 'border', label: 'Border', collapsible: true }, + { id: 'background', label: 'Background', collapsible: true }, + { id: 'effects', label: 'Effects', collapsible: false }, ] as const; type ControlGroupId = (typeof CONTROL_GROUPS)[number]['id']; @@ -80,46 +81,6 @@ function formatTargetLabel(element: Element): string { return tag; } -/** - * Create chevron SVG icon for collapse/expand indicator - */ -function createChevronIcon(): SVGElement { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 20 20'); - svg.setAttribute('fill', 'none'); - svg.classList.add('we-chevron'); - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M7 8l3 3 3-3'); - path.setAttribute('stroke', 'currentColor'); - path.setAttribute('stroke-width', '2'); - path.setAttribute('stroke-linecap', 'round'); - path.setAttribute('stroke-linejoin', 'round'); - svg.append(path); - - return svg; -} - -/** - * Create close (X) SVG icon for window close button - */ -function createCloseIcon(): SVGElement { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 20 20'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('aria-hidden', 'true'); - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M6 6l8 8M14 6l-8 8'); - path.setAttribute('stroke', 'currentColor'); - path.setAttribute('stroke-width', '2'); - path.setAttribute('stroke-linecap', 'round'); - path.setAttribute('stroke-linejoin', 'round'); - svg.append(path); - - return svg; -} - /** * Create sliders SVG icon for property panel minimize/expand button */ @@ -139,7 +100,7 @@ function createSlidersIcon(): SVGElement { svg.append(lines); // Three knob circles at different positions - const knobs: Array<[number, number]> = [ + const knobs: ReadonlyArray = [ [7, 5], [13, 10], [9, 15], @@ -160,41 +121,16 @@ function createSlidersIcon(): SVGElement { } /** - * Create grip (drag handle) SVG icon + * Create a control group (optionally collapsible) */ -function createGripIcon(): SVGElement { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 20 20'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('aria-hidden', 'true'); - - // 6 dots in 2 columns - const dots: Array<[number, number]> = [ - [7, 6], - [13, 6], - [7, 10], - [13, 10], - [7, 14], - [13, 14], - ]; - - for (const [cx, cy] of dots) { - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx', String(cx)); - circle.setAttribute('cy', String(cy)); - circle.setAttribute('r', '1.4'); - circle.setAttribute('fill', 'currentColor'); - svg.append(circle); - } - - return svg; -} - -/** - * Create a collapsible control group - */ -function createControlGroup(groupId: string, label: string, disposer: Disposer): ControlGroup { +function createControlGroup( + groupId: string, + label: string, + disposer: Disposer, + opts?: { collapsible?: boolean }, +): ControlGroup { const uniqueId = `we_group_${groupId}_${++groupIdSeq}`; + const collapsible = opts?.collapsible ?? true; let collapsed = false; // Group container @@ -203,35 +139,56 @@ function createControlGroup(groupId: string, label: string, disposer: Disposer): root.dataset.group = groupId; root.dataset.collapsed = 'false'; - // Header button (clickable to toggle) - const header = document.createElement('button'); - header.type = 'button'; + // Header (div wrapper to allow button + actions) + const header = document.createElement('div'); header.className = 'we-group-header'; - header.setAttribute('aria-expanded', 'true'); - header.setAttribute('aria-controls', uniqueId); const labelSpan = document.createElement('span'); labelSpan.textContent = label; - header.append(labelSpan, createChevronIcon()); + // Toggle element (button when collapsible; static label otherwise) + let toggleEl: HTMLButtonElement | HTMLDivElement; + + if (collapsible) { + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'we-group-toggle'; + toggleBtn.setAttribute('aria-expanded', 'true'); + toggleBtn.setAttribute('aria-controls', uniqueId); + toggleBtn.append(labelSpan, createChevronIcon()); + + // Toggle handler + disposer.listen(toggleBtn, 'click', (event) => { + event.preventDefault(); + toggle(); + }); + + toggleEl = toggleBtn; + } else { + const staticLabel = document.createElement('div'); + staticLabel.className = 'we-group-toggle we-group-toggle--static'; + staticLabel.append(labelSpan); + toggleEl = staticLabel; + } + + // Actions container (for add buttons, etc.) + const headerActions = document.createElement('div'); + headerActions.className = 'we-group-header-actions'; + + header.append(toggleEl, headerActions); // Body container const body = document.createElement('div'); body.className = 'we-group-body'; body.id = uniqueId; - // Toggle handler - disposer.listen(header, 'click', (event) => { - event.preventDefault(); - toggle(); - }); - root.append(header, body); function setCollapsed(value: boolean): void { + if (!collapsible) return; collapsed = value; root.dataset.collapsed = collapsed ? 'true' : 'false'; - header.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + (toggleEl as HTMLButtonElement).setAttribute('aria-expanded', collapsed ? 'false' : 'true'); } function isCollapsed(): boolean { @@ -239,12 +196,14 @@ function createControlGroup(groupId: string, label: string, disposer: Disposer): } function toggle(): void { + if (!collapsible) return; setCollapsed(!collapsed); } return { root, body, + headerActions, setCollapsed, isCollapsed, toggle, @@ -408,8 +367,8 @@ export function createPropertyPanel(options: PropertyPanelOptions): PropertyPane designPanel.dataset.tabContent = 'design'; // Create control groups - for (const { id, label } of CONTROL_GROUPS) { - const group = createControlGroup(id, label, disposer); + for (const { id, label, collapsible } of CONTROL_GROUPS) { + const group = createControlGroup(id, label, disposer, { collapsible }); controlGroups.set(id, group); designPanel.append(group.root); } @@ -604,6 +563,7 @@ export function createPropertyPanel(options: PropertyPanelOptions): PropertyPane container: effectsGroup.body, transactionManager: options.transactionManager, tokensService: options.tokensService, + headerActionsContainer: effectsGroup.headerActions, }); controls.push(effectsControl); } diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/types.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/types.ts index b3bfa53..8ffa2d7 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/types.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/types.ts @@ -134,6 +134,9 @@ export interface ControlGroup { /** The body container where controls are mounted */ body: HTMLElement; + /** Optional: Container for header action buttons (e.g., add button) */ + headerActions?: HTMLElement; + /** Set collapsed state */ setCollapsed(collapsed: boolean): void; diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/shadow-host.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/shadow-host.ts index 4c9d911..37c9810 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/shadow-host.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/shadow-host.ts @@ -79,6 +79,15 @@ const SHADOW_HOST_STYLES = /* css */ ` --we-text-secondary: #737373; --we-text-muted: #a3a3a3; + /* Accent surfaces (used by CSS/Props panels) */ + --we-accent-info-bg: rgba(59, 130, 246, 0.08); + --we-accent-brand-bg: rgba(99, 102, 241, 0.12); + --we-accent-brand-border: rgba(99, 102, 241, 0.25); + --we-accent-warning-bg: rgba(251, 191, 36, 0.14); + --we-accent-warning-border: rgba(251, 191, 36, 0.25); + --we-accent-danger-bg: rgba(248, 113, 113, 0.12); + --we-accent-danger-border: rgba(248, 113, 113, 0.25); + /* Shadows - Tailwind-like shadow-xl */ --we-shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.05); --we-shadow-panel: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); @@ -418,15 +427,17 @@ const SHADOW_HOST_STYLES = /* css */ ` left: 50%; top: 16px; transform: translateX(-50%); - width: min(720px, calc(100vw - 32px)); + transform-origin: right top; + width: auto; + max-width: min(720px, calc(100vw - 32px)); display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 10px 14px; + gap: 8px; + padding: 6px 8px; background: var(--we-surface-bg); border: 1px solid var(--we-border-subtle); - border-radius: var(--we-radius-panel); + border-radius: 999px; box-shadow: var(--we-shadow-subtle); pointer-events: auto; user-select: none; @@ -449,21 +460,72 @@ const SHADOW_HOST_STYLES = /* css */ ` transform: none; } - /* Minimized toolbar - becomes a small icon button fixed at top-right (left of property panel) */ + /* Minimized toolbar - collapses from pill to circle */ .we-toolbar[data-minimized="true"] { + /* Reset to fixed position in top-right */ left: auto; right: calc(16px + var(--we-icon-btn-size) + 8px); top: 16px; bottom: auto; transform: none; - width: auto; - max-width: none; - padding: 0; - gap: 0; - background: transparent; - border: 0; - box-shadow: none; + /* Visual style */ + background: var(--we-surface-bg); + border: 1px solid var(--we-border-subtle); + box-shadow: var(--we-shadow-subtle); z-index: 10; + cursor: grab; + touch-action: none; + overflow: hidden; + } + + /* Hide sections in minimized state using CSS for smooth animation */ + .we-toolbar[data-minimized="true"] .we-toolbar-left, + .we-toolbar[data-minimized="true"] .we-toolbar-right { + max-width: 0; + padding: 0; + opacity: 0; + pointer-events: none; + } + + .we-toolbar[data-minimized="true"] .we-toolbar-center { + flex: 0; + max-width: 0; + min-width: 0; + opacity: 0; + pointer-events: none; + } + + /* Minimized + dragged: use floating position from inline styles */ + .we-toolbar[data-minimized="true"][data-dragged="true"] { + left: auto; + right: auto; + top: auto; + bottom: auto; + } + + /* Toolbar icon buttons hover effect (builder topbar style) */ + .we-toolbar .we-icon-btn { + transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + } + + .we-toolbar .we-icon-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + } + + .we-toolbar .we-icon-btn:active:not(:disabled) { + transform: translateY(0); + } + + /* Respect reduced motion preference */ + @media (prefers-reduced-motion: reduce) { + .we-toolbar, + .we-toolbar-left, + .we-toolbar-center, + .we-toolbar-right, + .we-toolbar .we-icon-btn { + transition: none; + } } .we-toolbar-left, @@ -471,6 +533,12 @@ const SHADOW_HOST_STYLES = /* css */ ` display: flex; align-items: center; gap: 8px; + /* Smooth transition for minimize/restore - cubic-bezier for fast-to-slow deceleration */ + transition: max-width 350ms cubic-bezier(0.16, 1, 0.3, 1), + opacity 250ms cubic-bezier(0.16, 1, 0.3, 1), + padding 350ms cubic-bezier(0.16, 1, 0.3, 1); + max-width: 500px; + overflow: hidden; } .we-toolbar-center { @@ -478,13 +546,12 @@ const SHADOW_HOST_STYLES = /* css */ ` display: flex; justify-content: center; min-width: 0; - } - - /* Force hidden state for toolbar sections during minimization */ - .we-toolbar-left[hidden], - .we-toolbar-center[hidden], - .we-toolbar-right[hidden] { - display: none; + /* Smooth transition for minimize/restore - cubic-bezier for fast-to-slow deceleration */ + transition: max-width 350ms cubic-bezier(0.16, 1, 0.3, 1), + opacity 250ms cubic-bezier(0.16, 1, 0.3, 1), + flex 350ms cubic-bezier(0.16, 1, 0.3, 1); + max-width: 300px; + overflow: hidden; } .we-toolbar-meta { @@ -923,26 +990,48 @@ const SHADOW_HOST_STYLES = /* css */ ` gap: 6px; padding: 0 0 8px 0; background: transparent; + } + + .we-group-toggle { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 0; + background: transparent; border: 0; cursor: pointer; color: #333333; font-size: 11px; font-weight: 600; text-align: left; - /* Normal case, matching design spec */ transition: color 0.1s ease; } - .we-group-header:hover { + .we-group-toggle:hover { color: var(--we-text-primary); } - .we-group-header:focus-visible { + .we-group-toggle:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--we-focus-ring); border-radius: 2px; } + .we-group-toggle--static { + cursor: default; + pointer-events: none; + } + + .we-group-header-actions { + display: flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; + } + .we-group-body { padding: 0; background: transparent; @@ -1318,14 +1407,14 @@ const SHADOW_HOST_STYLES = /* css */ ` min-width: 0; } - /* Hide labels only when both columns are visible (grid mode) */ - .we-grid-gap-col:not([hidden]) + .we-grid-gap-col:not([hidden]) .we-field-label, - .we-grid-gap-col:not([hidden]):has(+ .we-grid-gap-col:not([hidden])) .we-field-label { - display: none; + /* Keep Grid label space for alignment; hide text only when both columns are visible (grid mode) */ + .we-grid-gap-col--grid:not([hidden]):has(+ .we-grid-gap-col--gap:not([hidden])) .we-field-label { + visibility: hidden; } .we-grid-gap-col .we-field-content { width: 100%; + overflow: visible; } /* ========================================================================== @@ -1362,13 +1451,13 @@ const SHADOW_HOST_STYLES = /* css */ ` position: absolute; top: calc(100% + 4px); left: 0; - right: 0; + min-width: 220px; padding: 10px; background: var(--we-surface-bg); - border: 1px solid rgba(226, 232, 240, 0.95); + border: 1px solid var(--we-border-subtle); border-radius: 8px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); - z-index: 30; + z-index: 60; } .we-grid-dimensions-popover[hidden] { @@ -1498,6 +1587,131 @@ const SHADOW_HOST_STYLES = /* css */ ` gap: 6px; } + /* ========================================================================== + Effects (Box Shadow List) + ========================================================================== */ + + .we-effects-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; + } + + .we-effects-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + .we-effects-item-wrap { + position: relative; + } + + .we-effects-item { + height: 28px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 6px; + background: var(--we-control-bg); + border: 1px solid transparent; + border-radius: var(--we-radius-control); + transition: background-color 0.1s ease, border-color 0.1s ease, opacity 0.1s ease; + } + + .we-effects-item:hover { + background: var(--we-control-bg-hover); + } + + .we-effects-item[data-open="true"] { + background: var(--we-control-bg-focus); + border-color: var(--we-control-border-focus); + } + + .we-effects-item[data-enabled="false"] { + opacity: 0.55; + } + + .we-effects-name { + flex: 1; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + text-align: left; + font-size: 11px; + color: var(--we-text-primary); + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .we-effects-name:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--we-focus-ring); + border-radius: 4px; + } + + .we-effects-icon-btn { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 0; + border-radius: var(--we-radius-control); + color: var(--we-text-secondary); + cursor: pointer; + padding: 0; + transition: background-color 0.1s ease, color 0.1s ease; + } + + .we-effects-icon-btn:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.06); + color: var(--we-text-primary); + } + + .we-effects-icon-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--we-focus-ring); + } + + .we-effects-icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .we-effects-icon-btn svg { + width: 14px; + height: 14px; + } + + .we-effects-popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + width: 220px; + max-width: 220px; + padding: 10px; + background: var(--we-surface-bg); + border: 1px solid var(--we-border-subtle); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + z-index: 60; + } + + .we-effects-popover[hidden] { + display: none; + } + + .we-effects-popover-content { + display: flex; + flex-direction: column; + gap: 8px; + } + /* ========================================================================== Gradient Preview Bar (Phase 4B) ========================================================================== */ @@ -1535,6 +1749,7 @@ const SHADOW_HOST_STYLES = /* css */ ` top: 50%; left: 0; transform: translate(-50%, -50%); + z-index: 1; width: 32px; height: 32px; border-radius: 6px; @@ -1546,7 +1761,7 @@ const SHADOW_HOST_STYLES = /* css */ ` box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); touch-action: none; user-select: none; - transition: box-shadow 0.15s ease; + transition: box-shadow 0.15s ease, z-index 0s; } .we-gradient-thumb:hover { @@ -1562,14 +1777,20 @@ const SHADOW_HOST_STYLES = /* css */ ` 0 1px 3px rgba(0, 0, 0, 0.2); } - /* Selected thumb state */ + /* Selected thumb state - raise above unselected thumbs */ .we-gradient-thumb--active { + z-index: 2; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.4), 0 1px 3px rgba(0, 0, 0, 0.2); } - /* Dragging thumb state - cursor feedback on entire bar */ + /* Dragging thumb - always on top when overlapping at same position */ + .we-gradient-thumb--dragging { + z-index: 3; + } + + /* Dragging state - cursor feedback on entire bar */ .we-gradient-bar--dragging { cursor: grabbing; } @@ -1653,6 +1874,9 @@ const SHADOW_HOST_STYLES = /* css */ ` .we-gradient-stop-pos { flex: 0 0 auto; min-width: 44px; + display: flex; + align-items: center; + justify-content: flex-end; text-align: right; font-variant-numeric: tabular-nums; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; @@ -1661,6 +1885,56 @@ const SHADOW_HOST_STYLES = /* css */ ` padding: 3px 6px; border-radius: 6px; background: var(--we-control-bg); + cursor: pointer; + transition: box-shadow 0.15s ease; + } + + .we-gradient-stop-pos:hover { + background: var(--we-control-bg-hover, var(--we-control-bg)); + } + + .we-gradient-stop-pos:focus-within { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25); + } + + /* Static position display (visible when row is not selected) */ + .we-gradient-stop-pos-static { + display: block; + width: 100%; + text-align: right; + } + + /* Position editor slot (visible when row is selected) */ + .we-gradient-stop-pos-editor { + display: none; + width: 100%; + } + + /* Show editor and hide static in active row */ + .we-gradient-stop-row--active .we-gradient-stop-pos-static { + display: none; + } + + .we-gradient-stop-row--active .we-gradient-stop-pos-editor { + display: block; + } + + /* Position input styling */ + .we-gradient-stop-pos-input { + width: 100%; + border: 0; + padding: 0; + margin: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: right; + outline: none; + cursor: text; + } + + .we-gradient-stop-pos-input::placeholder { + color: var(--we-text-muted); } .we-gradient-stop-color { @@ -1805,9 +2079,9 @@ const SHADOW_HOST_STYLES = /* css */ ` align-items: center; gap: 6px; padding: 8px 10px; - border: 1px solid rgba(226, 232, 240, 0.9); - border-radius: 8px; - background: rgba(255, 255, 255, 0.9); + border: 1px solid var(--we-border-subtle); + border-radius: var(--we-radius-panel); + background: var(--we-surface-bg); font-family: system-ui, -apple-system, sans-serif; } @@ -1824,8 +2098,8 @@ const SHADOW_HOST_STYLES = /* css */ ` gap: 4px; padding: 3px 8px; border-radius: 999px; - background: rgba(99, 102, 241, 0.10); - border: 1px solid rgba(99, 102, 241, 0.25); + background: var(--we-accent-brand-bg); + border: 1px solid var(--we-accent-brand-border); color: #4338ca; font-size: 11px; line-height: 1.2; @@ -1862,16 +2136,16 @@ const SHADOW_HOST_STYLES = /* css */ ` min-width: 80px; padding: 5px 8px; font-size: 12px; - border: 1px solid rgba(226, 232, 240, 0.8); - border-radius: 6px; - background: rgba(255, 255, 255, 0.95); + border: 1px solid var(--we-border-subtle); + border-radius: var(--we-radius-control); + background: var(--we-control-bg-focus); outline: none; transition: border-color 0.15s ease, box-shadow 0.15s ease; } .we-class-input:focus { - border-color: rgba(99, 102, 241, 0.5); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + border-color: var(--we-control-border-focus); + box-shadow: 0 0 0 2px var(--we-focus-ring); } .we-class-input:disabled { @@ -1888,9 +2162,9 @@ const SHADOW_HOST_STYLES = /* css */ ` top: calc(100% + 4px); left: 0; right: 0; - background: white; - border: 1px solid rgba(226, 232, 240, 0.95); - border-radius: 8px; + background: var(--we-surface-bg); + border: 1px solid var(--we-border-subtle); + border-radius: var(--we-radius-panel); box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); overflow: hidden; z-index: 20; @@ -1925,9 +2199,9 @@ const SHADOW_HOST_STYLES = /* css */ ` .we-css-info { padding: 8px 10px; - background: rgba(59, 130, 246, 0.08); - border-radius: 6px; - color: #64748b; + background: var(--we-accent-info-bg); + border-radius: var(--we-radius-control); + color: var(--we-text-secondary); font-size: 10px; margin-bottom: 8px; } @@ -1946,8 +2220,9 @@ const SHADOW_HOST_STYLES = /* css */ ` .we-css-warning { padding: 6px 10px; - background: rgba(251, 191, 36, 0.15); - border-radius: 4px; + background: var(--we-accent-warning-bg); + border: 1px solid var(--we-accent-warning-border); + border-radius: var(--we-radius-control); color: #92400e; font-size: 10px; margin-bottom: 4px; @@ -1975,41 +2250,47 @@ const SHADOW_HOST_STYLES = /* css */ ` .we-css-sections { display: flex; flex-direction: column; - gap: 12px; + gap: 0; } .we-css-section { - border: 1px solid rgba(226, 232, 240, 0.9); - border-radius: 8px; - overflow: hidden; - background: rgba(255, 255, 255, 0.9); + border: 0; + border-radius: 0; + overflow: visible; + background: transparent; + } + + .we-css-section + .we-css-section { + border-top: 1px solid var(--we-border-section); + padding-top: 12px; + margin-top: 4px; } .we-css-section[data-kind="inherited"] { - background: rgba(248, 250, 252, 0.8); + background: transparent; } .we-css-section-header { - padding: 8px 10px; - background: rgba(241, 245, 249, 0.95); - border-bottom: 1px solid rgba(226, 232, 240, 0.6); + padding: 0 0 8px 0; + background: transparent; + border-bottom: 0; font-weight: 600; - color: #64748b; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.03em; + color: var(--we-text-primary); + font-size: 11px; + text-transform: none; + letter-spacing: normal; } .we-css-section-rules { - padding: 8px; + padding: 0; } .we-css-rule { margin-bottom: 12px; padding: 8px; - background: rgba(248, 250, 252, 0.6); - border-radius: 6px; - border: 1px solid rgba(226, 232, 240, 0.5); + background: var(--we-surface-secondary); + border-radius: var(--we-radius-control); + border: 1px solid var(--we-border-subtle); } .we-css-rule:last-child { @@ -2017,8 +2298,8 @@ const SHADOW_HOST_STYLES = /* css */ ` } .we-css-rule[data-origin="inline"] { - background: rgba(254, 243, 199, 0.3); - border-color: rgba(251, 191, 36, 0.3); + background: var(--we-accent-warning-bg); + border-color: var(--we-accent-warning-border); } .we-css-rule-header { @@ -2028,7 +2309,7 @@ const SHADOW_HOST_STYLES = /* css */ ` flex-wrap: wrap; margin-bottom: 6px; padding-bottom: 4px; - border-bottom: 1px dashed rgba(226, 232, 240, 0.6); + border-bottom: 1px dashed var(--we-border-subtle); } .we-css-rule-selector { @@ -2049,10 +2330,10 @@ const SHADOW_HOST_STYLES = /* css */ ` } .we-css-rule-spec { - color: #94a3b8; + color: var(--we-text-muted); font-size: 9px; padding: 1px 4px; - background: rgba(226, 232, 240, 0.5); + background: var(--we-control-bg); border-radius: 3px; } @@ -2421,9 +2702,9 @@ const SHADOW_HOST_STYLES = /* css */ ` } .we-props-meta { - border: 1px solid rgba(226, 232, 240, 0.9); - border-radius: 10px; - background: rgba(248, 250, 252, 0.8); + border: 1px solid var(--we-border-subtle); + border-radius: var(--we-radius-panel); + background: var(--we-surface-secondary); padding: 10px 12px; display: flex; flex-direction: column; @@ -2453,7 +2734,7 @@ const SHADOW_HOST_STYLES = /* css */ ` font-weight: 600; padding: 2px 6px; border-radius: 999px; - background: rgba(99, 102, 241, 0.12); + background: var(--we-accent-brand-bg); color: #1d4ed8; } @@ -2466,18 +2747,18 @@ const SHADOW_HOST_STYLES = /* css */ ` .we-props-warning { font-size: 11px; color: #92400e; - background: rgba(251, 191, 36, 0.14); - border: 1px solid rgba(251, 191, 36, 0.25); - border-radius: 8px; + background: var(--we-accent-warning-bg); + border: 1px solid var(--we-accent-warning-border); + border-radius: var(--we-radius-panel); padding: 6px 8px; } .we-props-error { font-size: 11px; color: #b91c1c; - background: rgba(248, 113, 113, 0.12); - border: 1px solid rgba(248, 113, 113, 0.25); - border-radius: 8px; + background: var(--we-accent-danger-bg); + border: 1px solid var(--we-accent-danger-border); + border-radius: var(--we-radius-panel); padding: 6px 8px; } @@ -2489,9 +2770,9 @@ const SHADOW_HOST_STYLES = /* css */ ` } .we-props-list { - border: 1px solid rgba(226, 232, 240, 0.9); - border-radius: 10px; - background: rgba(255, 255, 255, 0.85); + border: 1px solid var(--we-border-subtle); + border-radius: var(--we-radius-panel); + background: var(--we-surface-bg); overflow: hidden; } @@ -2508,13 +2789,13 @@ const SHADOW_HOST_STYLES = /* css */ ` .we-props-group { padding: 6px 10px; - background: rgba(241, 245, 249, 0.9); + background: var(--we-control-bg); color: #64748b; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; - border-top: 1px solid rgba(226, 232, 240, 0.7); + border-top: 1px solid var(--we-border-section); } .we-props-group:first-child { @@ -2535,7 +2816,7 @@ const SHADOW_HOST_STYLES = /* css */ ` align-items: center; gap: 10px; padding: 8px 10px; - border-top: 1px solid rgba(226, 232, 240, 0.7); + border-top: 1px solid var(--we-border-section); } .we-props-row:first-child { diff --git a/app/chrome-extension/entrypoints/web-editor-v2/ui/toolbar.ts b/app/chrome-extension/entrypoints/web-editor-v2/ui/toolbar.ts index 2bdd827..238ea49 100644 --- a/app/chrome-extension/entrypoints/web-editor-v2/ui/toolbar.ts +++ b/app/chrome-extension/entrypoints/web-editor-v2/ui/toolbar.ts @@ -16,6 +16,14 @@ import type { StructureOperationData } from '@/common/web-editor-types'; import { Disposer } from '../utils/disposables'; import { installFloatingDrag, type FloatingPosition } from './floating-drag'; +import { + createCloseIcon, + createGripIcon, + createMinusIcon, + createPlusIcon, + createRedoIcon, + createUndoIcon, +} from './icons'; // ============================================================================= // Types @@ -137,66 +145,6 @@ function formatStatusMessage(base: string, result?: ApplyResult): string { return req ? `${base} (${req})` : base; } -/** - * Create wand (spark) SVG icon for toolbar minimize/expand button - */ -function createWandIcon(): SVGElement { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 20 20'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('aria-hidden', 'true'); - - // Wand diagonal line - const wand = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - wand.setAttribute('d', 'M4 16l8-8M6 18l-2-2M12 8l2 2'); - wand.setAttribute('stroke', 'currentColor'); - wand.setAttribute('stroke-width', '2'); - wand.setAttribute('stroke-linecap', 'round'); - wand.setAttribute('stroke-linejoin', 'round'); - - // Spark effect at tip - const spark = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - spark.setAttribute('d', 'M14 3v3M12.5 4.5h3'); - spark.setAttribute('stroke', 'currentColor'); - spark.setAttribute('stroke-width', '2'); - spark.setAttribute('stroke-linecap', 'round'); - spark.setAttribute('stroke-linejoin', 'round'); - - svg.append(wand, spark); - return svg; -} - -/** - * Create grip (drag handle) SVG icon - */ -function createGripIcon(): SVGElement { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 20 20'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('aria-hidden', 'true'); - - // 6 dots in 2 columns - const dots: Array<[number, number]> = [ - [7, 6], - [13, 6], - [7, 10], - [13, 10], - [7, 14], - [13, 14], - ]; - - for (const [cx, cy] of dots) { - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx', String(cx)); - circle.setAttribute('cy', String(cy)); - circle.setAttribute('r', '1.4'); - circle.setAttribute('fill', 'currentColor'); - svg.append(circle); - } - - return svg; -} - // ============================================================================= // Status Reset Timer // ============================================================================= @@ -273,7 +221,7 @@ export function createToolbar(options: ToolbarOptions): Toolbar { dragHandle.type = 'button'; dragHandle.className = 'we-drag-handle'; dragHandle.setAttribute('aria-label', 'Drag toolbar'); - dragHandle.title = 'Drag'; + dragHandle.dataset.tooltip = 'Drag'; dragHandle.append(createGripIcon()); const title = document.createElement('div'); @@ -313,31 +261,37 @@ export function createToolbar(options: ToolbarOptions): Toolbar { applyBtn.textContent = 'Apply'; applyBtn.setAttribute('aria-label', 'Apply changes to code'); + // Undo button (icon style) const undoBtn = document.createElement('button'); undoBtn.type = 'button'; - undoBtn.className = 'we-btn'; - undoBtn.textContent = 'Undo'; + undoBtn.className = 'we-icon-btn'; undoBtn.setAttribute('aria-label', 'Undo last change'); + undoBtn.dataset.tooltip = 'Undo'; + undoBtn.append(createUndoIcon()); + // Redo button (icon style) const redoBtn = document.createElement('button'); redoBtn.type = 'button'; - redoBtn.className = 'we-btn'; - redoBtn.textContent = 'Redo'; + redoBtn.className = 'we-icon-btn'; redoBtn.setAttribute('aria-label', 'Redo last undone change'); + redoBtn.dataset.tooltip = 'Redo'; + redoBtn.append(createRedoIcon()); + // Close button (icon style) const closeBtn = document.createElement('button'); closeBtn.type = 'button'; - closeBtn.className = 'we-btn we-btn--danger'; - closeBtn.textContent = 'Close'; + closeBtn.className = 'we-icon-btn'; closeBtn.setAttribute('aria-label', 'Close Web Editor'); + closeBtn.dataset.tooltip = 'Close'; + closeBtn.append(createCloseIcon()); - // Minimize/expand button + // Minimize/restore button const minimizeBtn = document.createElement('button'); minimizeBtn.type = 'button'; minimizeBtn.className = 'we-icon-btn'; minimizeBtn.setAttribute('aria-label', 'Minimize toolbar'); - minimizeBtn.title = 'Minimize'; - minimizeBtn.append(createWandIcon()); + minimizeBtn.dataset.tooltip = 'Minimize'; + minimizeBtn.append(createMinusIcon()); // ========================================================================== // Structure Dropdown (Phase 5.5) @@ -561,8 +515,8 @@ export function createToolbar(options: ToolbarOptions): Toolbar { function syncFloatingPositionStyles(): void { root.dataset.dragged = floatingPosition ? 'true' : 'false'; - // While minimized, prefer the existing minimized layout (top-right) - if (!floatingPosition || minimized) { + // No floating position: use CSS-defined positioning + if (!floatingPosition) { root.style.left = ''; root.style.top = ''; root.style.right = ''; @@ -571,11 +525,14 @@ export function createToolbar(options: ToolbarOptions): Toolbar { return; } + // Apply floating position (works for both minimized and expanded states) root.style.left = `${floatingPosition.left}px`; root.style.top = `${floatingPosition.top}px`; root.style.right = 'auto'; root.style.bottom = 'auto'; - root.style.transform = 'none'; + // Don't override transform when minimized (preserves scale animation) + // Only clear transform when expanded to remove translateX(-50%) + root.style.transform = minimized ? '' : 'none'; } function setPosition(position: FloatingPosition | null): void { @@ -588,7 +545,7 @@ export function createToolbar(options: ToolbarOptions): Toolbar { return floatingPosition; } - // Install drag behavior + // Install drag behavior for normal state (via drag handle) disposer.add( installFloatingDrag({ handleEl: dragHandle, @@ -598,6 +555,13 @@ export function createToolbar(options: ToolbarOptions): Toolbar { }), ); + // Minimized drag state: allows dragging the entire minimized toolbar + let minimizedDragCleanup: (() => void) | null = null; + disposer.add(() => { + minimizedDragCleanup?.(); + minimizedDragCleanup = null; + }); + // Apply initial position (if provided) if (floatingPosition !== null) { setPosition(floatingPosition); @@ -628,32 +592,43 @@ export function createToolbar(options: ToolbarOptions): Toolbar { minimized = value; root.dataset.minimized = minimized ? 'true' : 'false'; - // Update minimize button label and tooltip - minimizeBtn.setAttribute('aria-label', minimized ? 'Expand toolbar' : 'Minimize toolbar'); - minimizeBtn.title = minimized ? 'Expand' : 'Minimize'; + // Update minimize button label, tooltip, and icon + minimizeBtn.setAttribute('aria-label', minimized ? 'Restore toolbar' : 'Minimize toolbar'); + minimizeBtn.dataset.tooltip = minimized ? 'Restore' : 'Minimize'; + minimizeBtn.replaceChildren(minimized ? createPlusIcon() : createMinusIcon()); if (minimized) { // Close dropdown before minimizing setStructureOpen(false); - // Move minimize button to root, hide sections + // Move minimize button to root for minimized state root.append(minimizeBtn); - left.hidden = true; - center.hidden = true; - right.hidden = true; - } else { - // Restore sections and button position - left.hidden = false; - center.hidden = false; - right.hidden = false; - right.insertBefore(minimizeBtn, closeBtn); - } - // Keep minimized layout stable while preserving stored floating position. - // When restoring, re-apply stored position (and clamp with current size). - if (!minimized && floatingPosition) { - setPosition(floatingPosition); + // Reset position to top-right corner when minimizing + setPosition(null); + + // Install delayed-activation drag on root for minimized state + if (!minimizedDragCleanup) { + minimizedDragCleanup = installFloatingDrag({ + handleEl: root, + targetEl: root, + clampMargin: CLAMP_MARGIN_PX, + onPositionChange: (pos) => setPosition(pos), + clickThresholdMs: 200, + moveThresholdPx: 5, + }); + } } else { + // Restore minimize button position + right.insertBefore(minimizeBtn, closeBtn); + + // Remove minimized drag handler + if (minimizedDragCleanup) { + minimizedDragCleanup(); + minimizedDragCleanup = null; + } + + // Keep position null when restoring to center the toolbar syncFloatingPositionStyles(); } } @@ -753,9 +728,20 @@ export function createToolbar(options: ToolbarOptions): Toolbar { // Minimize button disposer.listen(minimizeBtn, 'click', (event) => { event.preventDefault(); + event.stopPropagation(); setMinimized(!minimized); }); + // Click anywhere on minimized toolbar root to restore (short click, not drag) + disposer.listen(root, 'click', (event) => { + if (!minimized) return; + // Don't restore if clicking minimize button (handled separately) + const target = event.target; + if (target === minimizeBtn || (target instanceof Node && minimizeBtn.contains(target))) return; + event.preventDefault(); + setMinimized(false); + }); + // Structure button - toggle dropdown disposer.listen(structureBtn, 'click', (event) => { event.preventDefault(); diff --git a/app/chrome-extension/tests/record-replay/_test-helpers.ts b/app/chrome-extension/tests/record-replay/_test-helpers.ts new file mode 100644 index 0000000..6f509c4 --- /dev/null +++ b/app/chrome-extension/tests/record-replay/_test-helpers.ts @@ -0,0 +1,82 @@ +/** + * Test helpers for record-replay contract tests. + * + * Provides minimal factories and mocks for testing the execution pipeline + * without requiring real browser or tool dependencies. + */ + +import { vi } from 'vitest'; +import type { ExecCtx } from '@/entrypoints/background/record-replay/nodes/types'; +import type { ActionExecutionContext } from '@/entrypoints/background/record-replay/actions/types'; + +/** + * Create a minimal ExecCtx for testing + */ +export function createMockExecCtx(overrides: Partial = {}): ExecCtx { + return { + vars: {}, + logger: vi.fn(), + ...overrides, + }; +} + +/** + * Create a minimal ActionExecutionContext for testing + */ +export function createMockActionCtx( + overrides: Partial = {}, +): ActionExecutionContext { + return { + vars: {}, + tabId: 1, + log: vi.fn(), + ...overrides, + }; +} + +/** + * Create a minimal Step for testing + */ +export function createMockStep(type: string, overrides: Record = {}): any { + return { + id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + type, + ...overrides, + }; +} + +/** + * Create a minimal Flow for testing (with nodes/edges for scheduler) + */ +export function createMockFlow(overrides: Record = {}): any { + const id = `flow_${Date.now()}`; + return { + id, + name: 'Test Flow', + version: 1, + steps: [], + nodes: [], + edges: [], + variables: [], + meta: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ...overrides, + }; +} + +/** + * Create a mock ActionRegistry for testing + */ +export function createMockRegistry(handlers: Map = new Map()) { + const executeFn = vi.fn(async () => ({ status: 'success' as const })); + + return { + get: vi.fn((type: string) => handlers.get(type) || { type }), + execute: executeFn, + register: vi.fn(), + has: vi.fn((type: string) => handlers.has(type)), + _executeFn: executeFn, // Expose for assertions + }; +} diff --git a/app/chrome-extension/tests/record-replay/adapter-policy.contract.test.ts b/app/chrome-extension/tests/record-replay/adapter-policy.contract.test.ts new file mode 100644 index 0000000..1572f75 --- /dev/null +++ b/app/chrome-extension/tests/record-replay/adapter-policy.contract.test.ts @@ -0,0 +1,155 @@ +/** + * Adapter Policy Contract Tests + * + * Verifies that skipRetry and skipNavWait flags correctly modify + * action execution behavior: + * - skipRetry: removes action.policy.retry before execution + * - skipNavWait: sets ctx.execution.skipNavWait for handlers + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { createStepExecutor } from '@/entrypoints/background/record-replay/actions/adapter'; +import { createMockExecCtx, createMockStep } from './_test-helpers'; + +describe('adapter policy flags contract', () => { + let registryExecute: ReturnType; + let mockRegistry: any; + + beforeEach(() => { + registryExecute = vi.fn(async () => ({ status: 'success' })); + mockRegistry = { + get: vi.fn(() => ({ type: 'fill' })), // Returns truthy = handler exists + execute: registryExecute, + }; + }); + + describe('skipRetry flag', () => { + it('removes action.policy.retry when skipRetry is true', async () => { + const executor = createStepExecutor(mockRegistry); + + await executor( + createMockExecCtx(), + createMockStep('fill', { + retry: { count: 3, intervalMs: 100, backoff: 'exp' }, + target: { candidates: [{ type: 'css', value: '#input' }] }, + value: 'test', + }), + 1, // tabId + { skipRetry: true }, + ); + + expect(registryExecute).toHaveBeenCalledTimes(1); + const [, action] = registryExecute.mock.calls[0]; + expect(action.policy?.retry).toBeUndefined(); + }); + + it('preserves action.policy.retry when skipRetry is false', async () => { + const executor = createStepExecutor(mockRegistry); + + await executor( + createMockExecCtx(), + createMockStep('fill', { + retry: { count: 3, intervalMs: 100, backoff: 'exp' }, + target: { candidates: [{ type: 'css', value: '#input' }] }, + value: 'test', + }), + 1, + { skipRetry: false }, + ); + + expect(registryExecute).toHaveBeenCalledTimes(1); + const [, action] = registryExecute.mock.calls[0]; + expect(action.policy?.retry).toBeDefined(); + expect(action.policy.retry.retries).toBe(3); + }); + + it('preserves action.policy.retry when skipRetry is not specified', async () => { + const executor = createStepExecutor(mockRegistry); + + await executor( + createMockExecCtx(), + createMockStep('fill', { + retry: { count: 2, intervalMs: 50 }, + target: { candidates: [{ type: 'css', value: '#input' }] }, + value: 'test', + }), + 1, + {}, // No skipRetry + ); + + const [, action] = registryExecute.mock.calls[0]; + expect(action.policy?.retry).toBeDefined(); + }); + }); + + describe('skipNavWait flag', () => { + it('sets ctx.execution.skipNavWait when skipNavWait is true', async () => { + const executor = createStepExecutor(mockRegistry); + + await executor( + createMockExecCtx(), + createMockStep('click', { + target: { candidates: [{ type: 'css', value: '#btn' }] }, + }), + 1, + { skipNavWait: true }, + ); + + expect(registryExecute).toHaveBeenCalledTimes(1); + const [actionCtx] = registryExecute.mock.calls[0]; + expect(actionCtx.execution?.skipNavWait).toBe(true); + }); + + it('does not set ctx.execution when skipNavWait is false', async () => { + const executor = createStepExecutor(mockRegistry); + + await executor( + createMockExecCtx(), + createMockStep('click', { + target: { candidates: [{ type: 'css', value: '#btn' }] }, + }), + 1, + { skipNavWait: false }, + ); + + const [actionCtx] = registryExecute.mock.calls[0]; + expect(actionCtx.execution).toBeUndefined(); + }); + + it('does not set ctx.execution when skipNavWait is not specified', async () => { + const executor = createStepExecutor(mockRegistry); + + await executor( + createMockExecCtx(), + createMockStep('navigate', { + url: 'https://example.com', + }), + 1, + {}, // No skipNavWait + ); + + const [actionCtx] = registryExecute.mock.calls[0]; + expect(actionCtx.execution).toBeUndefined(); + }); + }); + + describe('combined flags', () => { + it('applies both skipRetry and skipNavWait together', async () => { + const executor = createStepExecutor(mockRegistry); + + await executor( + createMockExecCtx(), + createMockStep('click', { + retry: { count: 5, intervalMs: 200 }, + target: { candidates: [{ type: 'css', value: '#btn' }] }, + }), + 1, + { skipRetry: true, skipNavWait: true }, + ); + + const [actionCtx, action] = registryExecute.mock.calls[0]; + expect(action.policy?.retry).toBeUndefined(); + expect(actionCtx.execution?.skipNavWait).toBe(true); + }); + }); +}); diff --git a/app/chrome-extension/tests/record-replay/session-dag-sync.contract.test.ts b/app/chrome-extension/tests/record-replay/session-dag-sync.contract.test.ts new file mode 100644 index 0000000..c43e12a --- /dev/null +++ b/app/chrome-extension/tests/record-replay/session-dag-sync.contract.test.ts @@ -0,0 +1,318 @@ +/** + * Session DAG Sync Contract Tests + * + * Verifies that RecordingSessionManager correctly maintains flow.nodes/edges + * in lockstep with flow.steps during recording: + * - New step → create node + edge from previous node + * - Upsert step → update node.config and node.type + * - Invariant violation → fallback to full stepsToDAG rebuild + */ + +import { describe, expect, it, beforeEach } from 'vitest'; +import { RecordingSessionManager } from '@/entrypoints/background/record-replay/recording/session-manager'; +import type { Flow } from '@/entrypoints/background/record-replay/types'; + +function createTestFlow(overrides: Partial = {}): Flow { + return { + id: `test_flow_${Date.now()}`, + name: 'Test Flow', + version: 1, + steps: [], + meta: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ...overrides, + }; +} + +function createTestStep(type: string, id?: string, overrides: Record = {}) { + return { + id: id || `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + type, + ...overrides, + }; +} + +describe('RecordingSessionManager DAG sync', () => { + let manager: RecordingSessionManager; + + beforeEach(async () => { + manager = new RecordingSessionManager(); + }); + + describe('appendSteps creates nodes/edges', () => { + it('creates node for first step without edge', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + manager.appendSteps([createTestStep('click', 'step1')]); + + const f = manager.getFlow()!; + expect(f.nodes).toHaveLength(1); + expect(f.nodes![0].id).toBe('step1'); + expect(f.nodes![0].type).toBe('click'); + expect(f.edges).toHaveLength(0); // No edge for first step + }); + + it('creates node and edge for subsequent steps', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + manager.appendSteps([createTestStep('click', 'step1')]); + manager.appendSteps([createTestStep('fill', 'step2', { value: 'hello' })]); + + const f = manager.getFlow()!; + expect(f.nodes).toHaveLength(2); + expect(f.nodes![1].id).toBe('step2'); + expect(f.nodes![1].type).toBe('fill'); + + expect(f.edges).toHaveLength(1); + expect(f.edges![0].from).toBe('step1'); + expect(f.edges![0].to).toBe('step2'); + }); + + it('creates correct chain for multiple steps in single batch', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + manager.appendSteps([ + createTestStep('navigate', 'step1', { url: 'https://example.com' }), + createTestStep('click', 'step2'), + createTestStep('fill', 'step3', { value: 'test' }), + ]); + + const f = manager.getFlow()!; + expect(f.steps).toHaveLength(3); + expect(f.nodes).toHaveLength(3); + expect(f.edges).toHaveLength(2); + + // Verify chain: step1 → step2 → step3 + expect(f.edges![0].from).toBe('step1'); + expect(f.edges![0].to).toBe('step2'); + expect(f.edges![1].from).toBe('step2'); + expect(f.edges![1].to).toBe('step3'); + }); + }); + + describe('upsert updates node config', () => { + it('updates node config when step is upserted', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + // Initial step + manager.appendSteps([createTestStep('fill', 'step1', { value: 'initial' })]); + + // Upsert with new value + manager.appendSteps([createTestStep('fill', 'step1', { value: 'updated' })]); + + const f = manager.getFlow()!; + expect(f.steps).toHaveLength(1); + expect(f.nodes).toHaveLength(1); + expect(f.nodes![0].config?.value).toBe('updated'); + }); + + it('preserves edges when upserting', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + manager.appendSteps([ + createTestStep('click', 'step1'), + createTestStep('fill', 'step2', { value: 'initial' }), + ]); + + // Upsert step2 + manager.appendSteps([createTestStep('fill', 'step2', { value: 'updated' })]); + + const f = manager.getFlow()!; + expect(f.edges).toHaveLength(1); + expect(f.edges![0].from).toBe('step1'); + expect(f.edges![0].to).toBe('step2'); + }); + }); + + describe('invariant handling', () => { + it('rebuilds DAG when nodes count mismatches steps', async () => { + // Create flow with steps but no nodes (legacy scenario) + const flow = createTestFlow({ + steps: [ + { id: 'existing1', type: 'click' } as any, + { id: 'existing2', type: 'fill', value: 'test' } as any, + ], + nodes: undefined, + edges: undefined, + }); + await manager.startSession(flow, 1); + + // Append new step - should trigger rebuild first + manager.appendSteps([createTestStep('navigate', 'step3', { url: 'https://test.com' })]); + + const f = manager.getFlow()!; + // Should have rebuilt: 2 existing + 1 new = 3 + expect(f.steps).toHaveLength(3); + expect(f.nodes).toHaveLength(3); + expect(f.edges).toHaveLength(2); + }); + + it('handles empty flow gracefully', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + // Empty appendSteps should be no-op + manager.appendSteps([]); + + const f = manager.getFlow()!; + expect(f.steps).toHaveLength(0); + // nodes/edges may be undefined when no steps added, that's valid + expect(f.nodes?.length ?? 0).toBe(0); + expect(f.edges?.length ?? 0).toBe(0); + }); + }); + + describe('session lifecycle', () => { + it('clears caches on session stop', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + manager.appendSteps([createTestStep('click', 'step1')]); + + const stoppedFlow = await manager.stopSession(); + + expect(stoppedFlow).not.toBeNull(); + expect(stoppedFlow!.nodes).toHaveLength(1); + + // After stop, manager should have no flow + expect(manager.getFlow()).toBeNull(); + }); + + it('reinitializes caches on new session', async () => { + // First session + const flow1 = createTestFlow({ id: 'flow1' }); + await manager.startSession(flow1, 1); + manager.appendSteps([createTestStep('click', 'step1')]); + await manager.stopSession(); + + // Second session - should have fresh state + const flow2 = createTestFlow({ id: 'flow2' }); + await manager.startSession(flow2, 2); + manager.appendSteps([createTestStep('fill', 'step2')]); + + const f = manager.getFlow()!; + expect(f.id).toBe('flow2'); + expect(f.steps).toHaveLength(1); + expect(f.steps[0].id).toBe('step2'); + expect(f.nodes).toHaveLength(1); + expect(f.nodes![0].id).toBe('step2'); + }); + }); + + describe('node type conversion', () => { + it('converts valid step types to node types', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + manager.appendSteps([ + createTestStep('click', 'step1'), + createTestStep('fill', 'step2'), + createTestStep('navigate', 'step3'), + createTestStep('scroll', 'step4'), + ]); + + const f = manager.getFlow()!; + expect(f.nodes![0].type).toBe('click'); + expect(f.nodes![1].type).toBe('fill'); + expect(f.nodes![2].type).toBe('navigate'); + expect(f.nodes![3].type).toBe('scroll'); + }); + + it('falls back to script for unknown types', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + manager.appendSteps([createTestStep('unknown_type_xyz', 'step1')]); + + const f = manager.getFlow()!; + expect(f.nodes![0].type).toBe('script'); + }); + }); + + describe('edge id uniqueness', () => { + it('generates unique edge ids', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + manager.appendSteps([ + createTestStep('click', 's1'), + createTestStep('click', 's2'), + createTestStep('click', 's3'), + createTestStep('click', 's4'), + ]); + + const f = manager.getFlow()!; + const edgeIds = f.edges!.map((e) => e.id); + const uniqueIds = new Set(edgeIds); + + expect(uniqueIds.size).toBe(edgeIds.length); + }); + + it('uses monotonic sequence for edge ids', async () => { + const flow = createTestFlow(); + await manager.startSession(flow, 1); + + // Add steps in multiple batches + manager.appendSteps([createTestStep('click', 's1')]); + manager.appendSteps([createTestStep('click', 's2')]); + manager.appendSteps([createTestStep('click', 's3')]); + + const f = manager.getFlow()!; + // Edge ids should contain sequential numbers + expect(f.edges![0].id).toMatch(/^e_0_/); + expect(f.edges![1].id).toMatch(/^e_1_/); + }); + }); + + describe('edge invariant handling', () => { + it('rebuilds DAG when edges are missing', async () => { + // Create flow with steps and nodes but missing edges + const flow = createTestFlow({ + steps: [{ id: 's1', type: 'click' } as any, { id: 's2', type: 'fill' } as any], + nodes: [ + { id: 's1', type: 'click', config: {} }, + { id: 's2', type: 'fill', config: {} }, + ], + edges: [], // Missing edges! + }); + await manager.startSession(flow, 1); + + // Append should trigger rebuild due to edge invariant violation + manager.appendSteps([createTestStep('navigate', 's3')]); + + const f = manager.getFlow()!; + expect(f.steps).toHaveLength(3); + expect(f.nodes).toHaveLength(3); + // Should have rebuilt edges: s1→s2→s3 + expect(f.edges).toHaveLength(2); + }); + + it('rebuilds DAG when last edge points to wrong step', async () => { + // Create flow with corrupted edge pointing to wrong target + const flow = createTestFlow({ + steps: [{ id: 's1', type: 'click' } as any, { id: 's2', type: 'fill' } as any], + nodes: [ + { id: 's1', type: 'click', config: {} }, + { id: 's2', type: 'fill', config: {} }, + ], + edges: [{ id: 'e_0', from: 's1', to: 'wrong_id' }], // Wrong target! + }); + await manager.startSession(flow, 1); + + // Append should trigger rebuild due to edge invariant violation + manager.appendSteps([createTestStep('navigate', 's3')]); + + const f = manager.getFlow()!; + expect(f.edges).toHaveLength(2); + // Last edge should point to last step + expect(f.edges![1].to).toBe('s3'); + }); + }); +}); diff --git a/app/chrome-extension/tests/record-replay/step-executor.contract.test.ts b/app/chrome-extension/tests/record-replay/step-executor.contract.test.ts new file mode 100644 index 0000000..1538845 --- /dev/null +++ b/app/chrome-extension/tests/record-replay/step-executor.contract.test.ts @@ -0,0 +1,212 @@ +/** + * Step Executor Routing Contract Tests + * + * Verifies that step execution routes correctly based on ExecutionModeConfig: + * - legacy mode: always uses legacy executeStep + * - hybrid mode: uses actions for allowlisted types, legacy for others + * - actions mode: always uses ActionRegistry (strict) + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +// Mock legacy executeStep - must be defined inline in vi.mock factory +vi.mock('@/entrypoints/background/record-replay/nodes', () => ({ + executeStep: vi.fn(async () => ({})), +})); + +// Mock createStepExecutor from adapter - must be defined inline in vi.mock factory +vi.mock('@/entrypoints/background/record-replay/actions/adapter', () => ({ + createStepExecutor: vi.fn(() => vi.fn(async () => ({ supported: true, result: {} }))), + isActionSupported: vi.fn((type: string) => { + const supported = ['fill', 'key', 'scroll', 'click', 'navigate', 'delay', 'wait']; + return supported.includes(type); + }), +})); + +import { createMockExecCtx, createMockStep, createMockRegistry } from './_test-helpers'; +import { + DEFAULT_EXECUTION_MODE_CONFIG, + createHybridConfig, + createActionsOnlyConfig, + MINIMAL_HYBRID_ACTION_TYPES, +} from '@/entrypoints/background/record-replay/engine/execution-mode'; +import { + LegacyStepExecutor, + ActionsStepExecutor, + HybridStepExecutor, + createExecutor, +} from '@/entrypoints/background/record-replay/engine/runners/step-executor'; +import { executeStep as legacyExecuteStep } from '@/entrypoints/background/record-replay/nodes'; +import { createStepExecutor as createAdapterExecutor } from '@/entrypoints/background/record-replay/actions/adapter'; + +describe('ExecutionModeConfig contract', () => { + describe('DEFAULT_EXECUTION_MODE_CONFIG', () => { + it('defaults to legacy mode', () => { + expect(DEFAULT_EXECUTION_MODE_CONFIG.mode).toBe('legacy'); + }); + + it('defaults skipActionsRetry to true', () => { + expect(DEFAULT_EXECUTION_MODE_CONFIG.skipActionsRetry).toBe(true); + }); + + it('defaults skipActionsNavWait to true', () => { + expect(DEFAULT_EXECUTION_MODE_CONFIG.skipActionsNavWait).toBe(true); + }); + }); + + describe('createHybridConfig', () => { + it('sets mode to hybrid', () => { + const config = createHybridConfig(); + expect(config.mode).toBe('hybrid'); + }); + + it('uses MINIMAL_HYBRID_ACTION_TYPES as default allowlist', () => { + const config = createHybridConfig(); + expect(config.actionsAllowlist).toBeDefined(); + expect(config.actionsAllowlist?.has('fill')).toBe(true); + expect(config.actionsAllowlist?.has('key')).toBe(true); + expect(config.actionsAllowlist?.has('scroll')).toBe(true); + // High-risk types should NOT be in minimal allowlist + expect(config.actionsAllowlist?.has('click')).toBe(false); + expect(config.actionsAllowlist?.has('navigate')).toBe(false); + }); + + it('allows overriding actionsAllowlist', () => { + const config = createHybridConfig({ + actionsAllowlist: new Set(['fill', 'click']), + }); + expect(config.actionsAllowlist?.has('fill')).toBe(true); + expect(config.actionsAllowlist?.has('click')).toBe(true); + expect(config.actionsAllowlist?.has('key')).toBe(false); + }); + }); + + describe('createActionsOnlyConfig', () => { + it('sets mode to actions', () => { + const config = createActionsOnlyConfig(); + expect(config.mode).toBe('actions'); + }); + + it('keeps StepRunner as policy authority (skip flags true)', () => { + const config = createActionsOnlyConfig(); + expect(config.skipActionsRetry).toBe(true); + expect(config.skipActionsNavWait).toBe(true); + }); + }); +}); + +describe('LegacyStepExecutor', () => { + const mockLegacyExecuteStep = legacyExecuteStep as ReturnType; + + beforeEach(() => { + mockLegacyExecuteStep.mockClear(); + }); + + it('always uses legacy executeStep', async () => { + const executor = new LegacyStepExecutor(); + const ctx = createMockExecCtx(); + const step = createMockStep('fill'); + + await executor.execute(ctx, step, { tabId: 1 }); + + expect(mockLegacyExecuteStep).toHaveBeenCalledWith(ctx, step); + }); + + it('returns executor type as legacy', async () => { + const executor = new LegacyStepExecutor(); + const result = await executor.execute(createMockExecCtx(), createMockStep('click'), { + tabId: 1, + }); + + expect(result.executor).toBe('legacy'); + }); + + it('supports all step types', () => { + const executor = new LegacyStepExecutor(); + expect(executor.supports('fill')).toBe(true); + expect(executor.supports('unknown_type')).toBe(true); + }); +}); + +describe('HybridStepExecutor routing', () => { + const mockLegacyExecuteStep = legacyExecuteStep as ReturnType; + + beforeEach(() => { + mockLegacyExecuteStep.mockClear(); + }); + + it('uses legacy for non-allowlisted types', async () => { + const config = createHybridConfig({ actionsAllowlist: new Set(['fill']) }); + const mockReg = createMockRegistry(); + const executor = new HybridStepExecutor(mockReg as any, config); + + await executor.execute( + createMockExecCtx(), + createMockStep('click', { target: { candidates: [] } }), + { tabId: 1 }, + ); + + expect(mockLegacyExecuteStep).toHaveBeenCalled(); + }); + + it('returns legacy executor type for non-allowlisted types', async () => { + const config = createHybridConfig({ actionsAllowlist: new Set(['fill']) }); + const mockReg = createMockRegistry(); + const executor = new HybridStepExecutor(mockReg as any, config); + + const result = await executor.execute( + createMockExecCtx(), + createMockStep('navigate', { url: 'https://example.com' }), + { tabId: 1 }, + ); + + expect(result.executor).toBe('legacy'); + }); +}); + +describe('createExecutor factory', () => { + it('creates LegacyStepExecutor for legacy mode', () => { + const executor = createExecutor({ ...DEFAULT_EXECUTION_MODE_CONFIG, mode: 'legacy' }); + expect(executor).toBeInstanceOf(LegacyStepExecutor); + }); + + it('creates ActionsStepExecutor for actions mode', () => { + const mockReg = createMockRegistry(); + const executor = createExecutor(createActionsOnlyConfig(), mockReg as any); + expect(executor).toBeInstanceOf(ActionsStepExecutor); + }); + + it('creates HybridStepExecutor for hybrid mode', () => { + const mockReg = createMockRegistry(); + const executor = createExecutor(createHybridConfig(), mockReg as any); + expect(executor).toBeInstanceOf(HybridStepExecutor); + }); + + it('throws if actions mode has no registry', () => { + expect(() => createExecutor(createActionsOnlyConfig())).toThrow( + 'ActionRegistry required for actions execution mode', + ); + }); + + it('throws if hybrid mode has no registry', () => { + expect(() => createExecutor(createHybridConfig())).toThrow( + 'ActionRegistry required for hybrid execution mode', + ); + }); +}); + +describe('MINIMAL_HYBRID_ACTION_TYPES', () => { + it('contains only low-risk action types', () => { + const expected = ['fill', 'key', 'scroll', 'drag', 'wait', 'delay', 'screenshot', 'assert']; + for (const type of expected) { + expect(MINIMAL_HYBRID_ACTION_TYPES.has(type)).toBe(true); + } + }); + + it('excludes high-risk types (navigate, click, tab management)', () => { + const excluded = ['navigate', 'click', 'dblclick', 'openTab', 'switchTab', 'closeTab']; + for (const type of excluded) { + expect(MINIMAL_HYBRID_ACTION_TYPES.has(type)).toBe(false); + } + }); +}); diff --git a/linear.md b/linear.md index 1cf0626..37ac897 100644 --- a/linear.md +++ b/linear.md @@ -20,9 +20,9 @@ | Phase 1-4E | ✅ 已完成 | 核心 UI 重构(数据结构、解析、预览条、Thumbs、Stops列表、ColorField绑定) | | Phase 5 | ✅ 已完成 | Thumb 拖拽实现 | | Phase 6 | ✅ 已完成 | Add/Delete Stop 功能 | -| Phase 7 | ⏳ 待开始 | Position 输入编辑 | -| Phase 8 | ⏳ 待开始 | 清理旧代码路径(移除 stop1Row/stop2Row 等遗留代码) | -| Phase 9 | ⏳ 待开始 | 边界情况与可访问性 | +| Phase 7 | ✅ 已完成 | Position 输入编辑 | +| Phase 8 | ✅ 已完成 | 清理旧代码路径(移除 stop1Row/stop2Row 等遗留代码) | +| Phase 9 | ✅ 已完成 | 边界情况与可访问性 | --- @@ -270,35 +270,42 @@ disposer.listen(root, 'keydown', (event) => { --- -## 待完成任务 +## 已完成任务 -### Phase 7: Position 输入编辑 +### Phase 7: Position 输入编辑 ✅ -当前 stops list 中的 position 显示为静态文本。需要实现: +实现了 stops list 中的 position 可编辑功能: -- [ ] 点击 position 区域可编辑 -- [ ] 输入新值后更新模型和 UI -- [ ] 输入验证(0-100 范围) -- [ ] Enter 提交,Escape 取消 +- [x] 点击 position 区域可编辑(单例 `selectedStopPosInput` + host 复挂载模式) +- [x] 输入新值后更新模型和 UI(`setStopPositionById` + `previewGradient`) +- [x] 输入验证(0-100 范围,通过 `clampPercent` + `wireNumberStepping`) +- [x] Enter 提交,Escape 取消(`commitSelectedStopPosition` / `cancelSelectedStopPosition`) +- [x] commit-time 排序(`sortCurrentStopsByPosition`) +- [x] 聚焦 gating 防止列表重建打断编辑 -### Phase 8: 清理旧代码路径 +### Phase 8: 清理旧代码路径 ✅ -移除遗留的 2-stop 硬编码: +移除了所有遗留的 2-stop 硬编码: -- [ ] 移除 `stop1Row`, `stop2Row` DOM 元素 -- [ ] 移除 `stop1ColorValue`, `stop2ColorValue` 变量 -- [ ] 移除 `stop1ColorField`, `stop2ColorField` -- [ ] 移除 `stop1PosInput`, `stop2PosInput` -- [ ] 清理 `collectCurrentStops()` 中的遗留逻辑 -- [ ] 清理 `syncLegacyStopFieldsFromModels()` +- [x] 移除 `stop1Row`, `stop2Row` DOM 元素及 `createStopRow()` 函数 +- [x] 移除 `stop1ColorValue`, `stop2ColorValue` 变量 +- [x] 移除 `stop1ColorField`, `stop2ColorField` 及其 dispose +- [x] 移除 `stop1PosInput`, `stop2PosInput` 及其 wireNumberStepping/wireTextInput +- [x] 清理 `collectCurrentStops()` - 直接从 `currentStops` 读取 +- [x] 删除 `syncLegacyStopFieldsFromModels()` 函数 +- [x] 清理各函数中的 legacy 同步代码 -### Phase 9: 边界情况与可访问性 +### Phase 9: 边界情况与可访问性 ✅ -- [ ] 拖拽越界处理优化 -- [ ] 两个停止点位置相同时的 UI 处理 -- [ ] 完善 `aria-label` 属性 -- [ ] 键盘导航支持(Tab, 方向键) -- [ ] 屏幕阅读器测试 +实现了可访问性和边界情况处理: + +- [x] Thumb 重叠时选中态置顶(z-index 层级:默认1, active 2, dragging 3) +- [x] Thumb slider ARIA 属性(role="slider", aria-valuemin/max/now/text, aria-orientation) +- [x] Thumb 方向键调整 position(ArrowLeft/Right/Up/Down 步进1,Shift 步进10) +- [x] Thumb keyboard session 管理(类似 drag session,支持 Escape 取消) +- [x] Stops list 方向键导航(ArrowUp/Down 切换选中行) +- [x] 防御式检查(handleThumbPointerDown 添加 disabled/none 状态检查) +- [x] Thumb focus/blur 事件处理(聚焦选中,blur 提交) --- @@ -342,6 +349,76 @@ rollbackTransaction(); // 回滚(如 Escape 取消) --- +## Phase 10: 渐变色支持扩展 ✅ + +### 概述 + +将渐变色支持扩展到属性面板中所有使用颜色的地方,包括边框颜色和文字颜色。 + +### 实现详情 + +#### 10.1 gradient-control.ts 参数化 + +新增配置选项使 GradientControl 可复用: + +```typescript +interface GradientControlOptions { + // ... existing options + property?: string; // CSS 属性,默认 'background-image' + allowNone?: boolean; // 是否显示 None 选项,默认 true +} +``` + +- `property`: 用于 border-image-source 等非 background-image 场景 +- `allowNone`: 用于 text gradient 场景(禁用 None 避免文字不可见) + +#### 10.2 border-control.ts 渐变支持 + +**CSS 实现方案**: 使用 `border-image-source` + `border-image-slice: 1` + +**UI 变更**: + +- 新增 "Type" 选择器行(solid / gradient) +- gradient 模式下显示 GradientControl +- gradient 模式下锁定 Edge 为 "all"(border-image 不支持 per-edge) + +**关键函数**: + +- `inferBorderColorType()`: 从 border-image-source 推断颜色类型 +- `setColorType()`: 使用 multiStyle 事务切换模式 +- `updateEdgeSelectorState()`: gradient 模式下禁用 edge 选择 + +#### 10.3 typography-control.ts 渐变支持 + +**CSS 实现方案**: 使用 `background-image` + `-webkit-background-clip: text` + `-webkit-text-fill-color: transparent` + +**UI 变更**: + +- 新增 "Type" 选择器行(solid / gradient) +- gradient 模式下显示 GradientControl(allowNone: false) +- solid 模式下显示原有 ColorField + +**关键函数**: + +- `inferTextColorType()`: 检测 background-clip: text 模式 +- `setTextColorType()`: 使用 multiStyle 事务切换模式 +- `isGradientBackgroundValue()`: 检测渐变背景值 +- `isTransparentTextFillColor()`: 检测透明文字填充色 + +**已知限制**: + +- Text gradient 与 Background 控件共用 `background-image` 属性 +- 同一元素不能同时使用 text gradient 和 element background +- 这是 CSS 本身的限制,在文档中已明确说明 + +### 技术要点 + +1. **multiStyle 事务**: 切换模式时原子设置多个相关属性 +2. **refresh 重推断**: 处理外部变更(CSS 面板、Undo/Redo) +3. **dispose 管理**: 所有新增控件都正确注册 dispose + +--- + ## 参考 - 参考图片: `linear.png` diff --git a/mcp-compare.md b/mcp-compare.md index 018402c..b829690 100644 --- a/mcp-compare.md +++ b/mcp-compare.md @@ -633,17 +633,20 @@ refId?: string; // 聚焦到特定节点的子树 **完成情况**: -| 子任务 | 状态 | 说明 | -| ----------------- | --------- | ----------------------------------------------------------------------- | -| webRequest 版抓包 | ✅ 已完成 | Schema 已增强,添加 maxCaptureTime/inactivityTimeout/includeStatic 参数 | -| Debugger 版抓包 | ✅ 已完成 | Schema 已增强,添加 maxCaptureTime/inactivityTimeout/includeStatic 参数 | -| 统一过滤配置 | ✅ 已完成 | 过滤配置已统一到 `constants.ts` 的 `NETWORK_FILTERS` | -| Schema 描述增强 | ✅ 已完成 | 明确说明两个工具的区别和使用场景 | +| 子任务 | 状态 | 说明 | +| ----------------- | --------- | ---------------------------------------------------------------------------- | +| webRequest 版抓包 | ✅ 已完成 | Schema 已增强,添加 maxCaptureTime/inactivityTimeout/includeStatic 参数 | +| Debugger 版抓包 | ✅ 已完成 | Schema 已增强,添加 maxCaptureTime/inactivityTimeout/includeStatic 参数 | +| 统一过滤配置 | ✅ 已完成 | 过滤配置已统一到 `constants.ts` 的 `NETWORK_FILTERS` | +| Schema 描述增强 | ✅ 已完成 | 明确说明两个工具的区别和使用场景 | +| **统一接口** | ✅ 已完成 | 创建 `chrome_network_capture` 统一工具,通过 `needResponseBody` 参数选择后端 | -**决策调整**: 保留两套工具(webRequest 和 Debugger),通过增强描述引导用户选择: +**最终实现**: 创建了统一的 `chrome_network_capture` 工具: -- `chrome_network_capture_start/stop`: 轻量级,不占用 debugger,无 responseBody -- `chrome_network_debugger_start/stop`: 支持 responseBody,但会占用 debugger +- **接口**: `action: 'start' | 'stop'` + `needResponseBody?: boolean` +- `needResponseBody=false`(默认): 使用 webRequest API(轻量,不占用 debugger) +- `needResponseBody=true`: 使用 Debugger API(可以获取 response body) +- 原来的 4 个工具(`chrome_network_capture_start/stop`、`chrome_network_debugger_start/stop`)从 TOOL_SCHEMAS 移除,仅供内部使用 **涉及文件**: @@ -824,7 +827,7 @@ refId?: string; // 聚焦到特定节点的子树 | 输出脱敏 | cookie/token/JWT/Base64/Hex | 同等覆盖 | ✅ 一致 | | 输出限长 | 50KB 固定 | 50KB 默认,可配 `maxOutputBytes` | ✅ 项目更灵活 | | 超时 | 10s 固定 | 15s 默认,可配 `timeoutMs` | ⚠️ 默认值不同 | -| 返回结构 | 含 `tabContext.availableTabs` | 无 tab 列表 | ❌ 缺失 | +| 返回结构 | 含 `tabContext.availableTabs` | 无 tab 列表 | 不需要 | | 参数契约 | `action/text` | `code` | ⚠️ 接口不兼容 | ### 2. `chrome_gif_recorder` 差异 @@ -865,26 +868,28 @@ refId?: string; // 聚焦到特定节点的子树 ### 5. Network Capture 差异 -| 维度 | mcp-tools.js | 项目实现 | 影响 | -| -------- | ------------ | ---------------------------------------- | --------------- | -| 统一开关 | 无 | 未实现 `needResponseBody` 统一开关 | ⚠️ 保持两套工具 | -| 过滤配置 | 统一 | Debugger 版未复用 `NETWORK_FILTERS` 常量 | ⚠️ 代码不一致 | +| 维度 | mcp-tools.js | 项目实现 | 影响 | +| -------- | ------------ | ------------------------------------------------------ | --------------- | +| 统一开关 | 无 | 未实现 `needResponseBody` 统一开关 | ⚠️ 保持两套工具 | +| 过滤配置 | 统一 | ~~Debugger 版未复用 `NETWORK_FILTERS` 常量~~ ✅ 已修复 | ✅ 代码一致 | --- ## 九、后续优化建议 -### 高优先级 +### 已完成 ✅ -1. **GIF stop 补末帧**:与 mcp-tools 行为一致,确保录制完整性 -2. **Network 过滤配置统一**:Debugger 版复用 `NETWORK_FILTERS` 常量 +1. **Network 过滤配置统一**:Debugger 版已复用 `NETWORK_FILTERS` 常量,修复了 `facebook.com/tr` 匹配 bug +2. **GIF stop 补末帧**:与 mcp-tools 行为一致,确保录制完整性 +3. **Computer hover scrollIntoView**:ref/selector 路径现在会先滚动元素到视口中心再 hover +4. **Console 透出 dropped 计数**:buffer 模式返回 `droppedMessageCount/droppedExceptionCount` -### 中优先级 +### 中优先级(待定) -3. **Console buffer 容量扩大**:考虑从 2000 提升到 5000 -4. **GIF 增加 quality 参数**:控制输出质量和文件大小 +5. **Console buffer 容量扩大**:考虑从 2000 提升到 5000(需根据实际溢出情况决定) +6. **GIF 增加 quality 参数**:控制输出质量和文件大小 ### 低优先级(接口兼容性) -5. **tabContext 返回**:javascript/console 等工具增加 availableTabs 返回 -6. **zoom/modifiers 接口**:当前对象形式更 TS 友好,暂不调整 +7. **tabContext 返回**:javascript/console 等工具增加 availableTabs 返回 +8. **zoom/modifiers 接口**:当前对象形式更 TS 友好,暂不调整 diff --git a/packages/shared/src/tools.ts b/packages/shared/src/tools.ts index 760b4f7..0cba8a6 100644 --- a/packages/shared/src/tools.ts +++ b/packages/shared/src/tools.ts @@ -12,6 +12,8 @@ export const TOOL_NAMES = { CLICK: 'chrome_click_element', FILL: 'chrome_fill_or_select', GET_INTERACTIVE_ELEMENTS: 'chrome_get_interactive_elements', + NETWORK_CAPTURE: 'chrome_network_capture', + // Legacy tool names (kept for internal use, not exposed in TOOL_SCHEMAS) NETWORK_CAPTURE_START: 'chrome_network_capture_start', NETWORK_CAPTURE_STOP: 'chrome_network_capture_stop', NETWORK_REQUEST: 'chrome_network_request', @@ -594,16 +596,26 @@ export const TOOL_SCHEMAS: Tool[] = [ }, }, { - name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START, + name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE, description: - 'Start capturing network requests using Chrome Debugger API. Use this when you need responseBody content. Note: occupies debugger, may conflict with DevTools.', + 'Unified network capture tool. Use action="start" to begin capturing, action="stop" to end and retrieve results. Set needResponseBody=true to capture response bodies (uses Debugger API, may conflict with DevTools). Default mode uses webRequest API (lightweight, no debugger conflict, but no response body).', inputSchema: { type: 'object', properties: { + action: { + type: 'string', + enum: ['start', 'stop'], + description: 'Action to perform: "start" begins capture, "stop" ends and returns results', + }, + needResponseBody: { + type: 'boolean', + description: + 'When true, captures response body using Debugger API (default: false). Only use when you need to inspect response content.', + }, url: { type: 'string', description: - 'URL to capture network requests from. If not provided, uses the current active tab', + 'URL to capture network requests from. For action="start". If not provided, uses the current active tab.', }, maxCaptureTime: { type: 'number', @@ -618,17 +630,7 @@ export const TOOL_SCHEMAS: Tool[] = [ description: 'Include static resources like images/scripts/styles (default: false)', }, }, - required: [], - }, - }, - { - name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP, - description: - 'Stop capturing network requests using Chrome Debugger API and return the captured data with responseBody', - inputSchema: { - type: 'object', - properties: {}, - required: [], + required: ['action'], }, }, { @@ -644,44 +646,6 @@ export const TOOL_SCHEMAS: Tool[] = [ required: [], }, }, - { - name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START, - description: - 'Start capturing network requests using Chrome webRequest API. Lightweight, does not occupy debugger. Use chrome_network_debugger_start if you need responseBody.', - inputSchema: { - type: 'object', - properties: { - url: { - type: 'string', - description: - 'URL to capture network requests from. If not provided, uses the current active tab', - }, - maxCaptureTime: { - type: 'number', - description: 'Maximum capture time in milliseconds (default: 180000)', - }, - inactivityTimeout: { - type: 'number', - description: 'Stop after inactivity in milliseconds (default: 60000). Set 0 to disable.', - }, - includeStatic: { - type: 'boolean', - description: 'Include static resources like images/scripts/styles (default: false)', - }, - }, - required: [], - }, - }, - { - name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP, - description: - 'Stop capturing network requests using webRequest API and return the captured data (without responseBody)', - inputSchema: { - type: 'object', - properties: {}, - required: [], - }, - }, { name: TOOL_NAMES.BROWSER.HISTORY, description: 'Retrieve and search browsing history from Chrome', diff --git a/rr-refactor.md b/rr-refactor.md index df8c7e4..f527282 100644 --- a/rr-refactor.md +++ b/rr-refactor.md @@ -12,6 +12,59 @@ --- +## 整体进度概览 + +| 阶段 | 状态 | 完成时间 | 主要内容 | +| ---------------------- | --------- | -------- | -------------------------------------------------------------- | +| Phase 1.1 Action 系统 | ✅ 完成 | - | 27 种 Action 类型定义、执行器注册表 | +| Phase 1.2 选择器引擎 | ✅ 完成 | - | 6 种策略、指纹验证、Shadow DOM 支持 | +| Phase 1.3 数据模型统一 | 🔄 进行中 | - | P0-P3 完成,P4 待实施 | +| - M1 低风险接线 | ✅ 完成 | 2025-12 | StepRunner 依赖注入、tabId 管理 | +| - M2 可控启用 hybrid | ✅ 完成 | 2025-12 | 执行模式配置、最小 allowlist | +| - M2.1 双重策略修复 | ✅ 完成 | 2025-12 | skipRetry/skipNavWait 策略跳过 | +| - P1.0 存储层统一 | ✅ 完成 | 2025-12 | ensureMigratedFromLocal、importFlowFromJson | +| - M3-core 契约测试 | ✅ 完成 | 2025-12 | 42 个测试(adapter-policy + step-executor + session-dag-sync) | +| - P2 录制链路迁移 | ✅ 完成 | 2025-12 | 增量式 DAG 同步、双写方案 | +| - P4 清理旧类型 | ⏳ 待实施 | - | 删除 Step 联合类型、Flow.steps 字段 | +| Phase 2-7 | ⏳ 待实施 | - | 录制系统、回放引擎、Builder、高级功能 | + +**当前测试状态**: 197 个测试全部通过 + +--- + +## 下一步任务建议(供接手者参考) + +### 优先级 1: M3-full 完整集成测试 + +验证 hybrid 模式下各类型行为一致性,特别关注: + +- aria selector 定位 +- script when:'after' defer 执行时机 +- control-flow 条件求值 +- openTab/switchTab 后 ctx.tabId 更新 + +### 优先级 2: P4 清理旧类型 + +- 删除 `types.ts` 中的 `Step` 联合类型 +- 删除 `Flow.steps` 字段(或移至 `legacy-types.ts`) +- 更新 UI Builder 保存格式 + +### 优先级 3: UI 刷新机制修复 + +IndexedDB 迁移后,popup/sidepanel 不再监听 chrome.storage.local 变化: + +- 需要新的变更通知机制(可能通过 chrome.runtime.sendMessage) +- 或改用 IndexedDB observer / BroadcastChannel + +### 优先级 4: 录制期实时 DAG 展示(可选) + +当前 DAG 只在内存态,可考虑: + +- 将 nodes/edges 包含在 timeline 广播中 +- UI 端实时渲染 DAG 视图 + +--- + ## 实施进度 ### 已完成 @@ -50,12 +103,12 @@ #### Phase 1.3: 数据模型统一 🔄 -**当前状态**:P0、P3 已完成。P1、P2、P4 待后续迭代。 +**当前状态**:P0、P1、P2、P3 已完成。P4 待后续迭代。 - P0 ✅:录制产物转换为 DAG,可直接回放 +- P1 ✅:存储层统一(ensureMigratedFromLocal、importFlowFromJson 多格式支持) +- P2 ✅:录制链路迁移(增量式 DAG 同步,双写方案) - P3 ✅:22 个 Action Handlers 完整实现 + Scheduler 集成架构设计完成 -- P1 ⏳:存储层统一(IndexedDB schema、lazy normalize) -- P2 ⏳:录制链路迁移到 Action - P4 ⏳:清理旧 Step 类型 **核心问题**:录制与回放数据格式不一致 @@ -99,20 +152,139 @@ - [x] 添加 `filterValidEdges` 校验旧 edges 有效性,避免 topoOrder 崩溃 - 涉及文件:`packages/shared/src/rr-graph.ts`、`flow-store.ts` -**P1: 存储层统一(单一真源)** +**P1: 存储层统一(单一真源)** ✅ - [x] `flow-store.ts` 读写逻辑适配新 Flow(P0 已完成) -- [ ] `importFlowFromJson` 支持新旧格式自动识别(P0 已间接支持:导入后保存会触发 normalize) -- [ ] 考虑 IndexedDB schema 升级策略,这里不用考虑,因为还没有任何人使用,没有任何数据产生,直接升级即可 -- [ ] 迁移场景:`ensureMigratedFromLocal()` 需要做 lazy normalize(当前迁移不走 saveFlow) -- 涉及文件:`flow-store.ts`、`storage/indexeddb-manager.ts` +- [x] `importFlowFromJson` 支持 4 种格式自动识别(数组、{flows:[]}、单 flow with steps、单 flow with nodes) +- [x] `ensureMigratedFromLocal()` 调用已添加到所有存储入口点(listFlows, getFlow, saveFlow 等) +- [x] `normalizeFlowForSave` 增加 edges 有效性校验(过滤指向不存在节点的边) +- 涉及文件:`flow-store.ts`、`trigger-store.ts` -**P2: 录制链路迁移** +**P2: 录制链路迁移 - 增量式 DAG 同步** ✅ -- [ ] `flow-builder.ts` 改为写 `nodes: AnyAction[]` -- [ ] `content-message-handler.ts` 接收 Step 后转换为 Action -- [ ] 可选:修改 `recorder.js` 直接发送 Action -- 涉及文件:`flow-builder.ts`、`content-message-handler.ts`、`session-manager.ts` +采用"双写"方案:recorder.js 继续发送 Steps,background 在 `appendSteps` 时同步生成 nodes/edges。 + +- [x] `session-manager.ts:appendSteps` 增量生成 DAG + - 新 step → 创建 node + edge(从前一个 node) + - upsert step → 更新 node.config 和 node.type + - 维护 session 级缓存:stepIndexMap、nodeIndexMap、edgeSeq +- [x] 不变式检查:nodes.length === steps.length 且 edges.length === max(0, steps.length-1) 且 last edge → last step +- [x] 违反不变式时 fallback 全量 `stepsToDAG` 重建 +- [x] 类型安全:unknown step type 降级到 'script' 并输出警告日志 +- [x] 契约测试:15 个测试覆盖 DAG 同步场景(`session-dag-sync.contract.test.ts`) +- 涉及文件:`recording/session-manager.ts` + +##### P2 详细实现说明 + +**核心改动位置**: `app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts` + +**新增私有字段**: + +```typescript +// Session-level caches for incremental DAG sync (cleared on session start/stop) +private stepIndexMap: Map = new Map(); // stepId → 数组索引 +private nodeIndexMap: Map = new Map(); // nodeId → 数组索引 +private edgeSeq: number = 0; // 单调递增的 edge id 序号 +``` + +**Session 生命周期管理**: + +- `startSession()`: 清理所有缓存,调用 `rebuildCaches()` 初始化 +- `stopSession()`: 清理所有缓存 + +**增量 DAG 同步逻辑** (`appendSteps` 方法): + +```typescript +// 1. 初始化数组(如果缺失) +if (!Array.isArray(f.steps)) f.steps = []; +if (!Array.isArray(f.nodes)) f.nodes = []; +if (!Array.isArray(f.edges)) f.edges = []; + +// 2. 检查不变式,违反则 fallback 全量重建 +if (!this.checkDagInvariant(f.steps, nodes, edges)) { + this.rebuildDag(); +} + +// 3. 处理每个 step +for (const step of steps) { + if (this.stepIndexMap.has(step.id)) { + // Upsert: 更新 node.config 和 node.type + nodes[nodeIdx] = { + ...nodes[nodeIdx], + type: this.toNodeType(step.type), + config: mapStepToNodeConfig(step), + }; + } else { + // Append: 创建 node + edge + nodes.push({ + id: step.id, + type: this.toNodeType(step.type), + config: mapStepToNodeConfig(step), + }); + if (prevStepId) { + edges.push({ + id: `e_${this.edgeSeq++}_${prevStepId}_${step.id}`, + from: prevStepId, + to: step.id, + label: EDGE_LABELS.DEFAULT, + }); + } + } +} + +// 4. 最终不变式检查 +if (needsRebuild || !this.checkDagInvariant(f.steps, nodes, edges)) { + this.rebuildDag(); +} +``` + +**不变式检查** (`checkDagInvariant` 方法): + +```typescript +private checkDagInvariant(steps: Step[], nodes: NodeBase[], edges: Edge[]): boolean { + const stepCount = steps.length; + const expectedEdgeCount = Math.max(0, stepCount - 1); + + // 1. nodes 数量必须等于 steps 数量 + if (nodes.length !== stepCount) return false; + + // 2. edges 数量必须等于 steps.length - 1(线性链) + if (edges.length !== expectedEdgeCount) return false; + + // 3. 最后一条 edge 必须指向最后一个 step + if (edges.length > 0 && steps.length > 0) { + const lastEdge = edges[edges.length - 1]; + const lastStepId = steps[steps.length - 1]?.id; + if (lastEdge.to !== lastStepId) return false; + } + + return true; +} +``` + +**类型安全** (`toNodeType` 方法): + +```typescript +private toNodeType(stepType: string): NodeBase['type'] { + if (VALID_NODE_TYPES.has(stepType)) { + return stepType as NodeBase['type']; + } + console.warn(`[RecordingSession] Unknown step type "${stepType}", falling back to "script"`); + return NODE_TYPES.SCRIPT; +} +``` + +**测试覆盖** (`tests/record-replay/session-dag-sync.contract.test.ts`): + +- 首个 step 创建 node(无 edge) +- 后续 step 创建 node + edge +- 批量 step 正确链接 +- upsert 更新 node config +- upsert 保留 edges +- 不变式处理(nodes 缺失、edges 缺失、edges 指向错误) +- session 生命周期(start/stop 清理缓存) +- 类型转换(有效类型、未知类型降级) +- edge id 唯一性和单调序列 **P3: 回放引擎适配** ✅ @@ -280,26 +452,428 @@ export { } from './handlers'; ``` -##### 后续接入步骤(未完成) +##### 后续接入步骤 -1. **修改 StepRunner 依赖注入 StepExecutorInterface** - - 当前 `StepRunner` 直接调用 `executeStep`(`step-runner.ts:84`) - - 需要改为通过 `StepExecutorInterface.execute()` 调用 - - 由 `Scheduler` 创建 `ActionRegistry` + `createExecutor` 并注入 +**M1: 低风险接线(已完成 ✅)** -2. **解决双重策略问题** - - StepRunner 有 retry/timeout/nav-wait 策略(`step-runner.ts:82,106`) - - ActionRegistry 也有 retry/timeout 策略(`registry.ts:462,527`) - - 需明确唯一权威:使用 `skipActionsRetry/skipActionsNavWait` 配置控制 +1. ✅ **修改 StepRunner 依赖注入 StepExecutorInterface** + - `StepRunner` 现在通过注入的 `StepExecutorInterface.execute()` 调用 + - `Scheduler` 创建 `createExecutor(config)` 并注入到 `StepRunner` + - 默认使用 `legacy` 模式,保持原有行为不变 -3. **tabId 管理** - - 当前 ExecCtx 不携带 tabId - - openTab/switchTab 后需要更新 tabId - - 建议在 ExecCtx 中添加 `tabId` 字段并在 tab 切换时同步 +2. ✅ **tabId 管理** + - `ExecCtx` 已添加 `tabId?: number` 字段 + - `Scheduler` 从 `ensureTab()` 捕获 tabId 并传入 `ExecCtx` + - `StepRunner` 优先使用 `ctx.tabId`,fallback 到 active tab 查询 -4. **集成测试** - - 在 hybrid 模式下验证各类型行为一致性 - - 特别关注:aria selector、script when:'after' defer、control-flow 条件求值 +3. ✅ **双重策略问题(设计决策 + 实现)** + - retry/nav-wait 策略:`StepRunner` 作为权威 + - `ExecutionModeConfig.skipActionsRetry/skipActionsNavWait` 默认为 true + - 实现机制: + - `adapter.ts`: `skipRetry=true` 时移除 `action.policy.retry` + - `adapter.ts`: `skipNavWait=true` 时设置 `ctx.execution.skipNavWait` + - `click.ts/navigate.ts`: 检查 `ctx.execution?.skipNavWait` 跳过内部 nav-wait + - 注意:ActionRegistry timeout 保留(提供 per-action 超时保护) + +##### M1 详细实现说明 + +**修改文件清单**: +| 文件 | 改动内容 | +|------|----------| +| `nodes/types.ts` | `ExecCtx` 添加 `tabId?: number` 字段 | +| `engine/runners/step-executor.ts` | 实现 `StepExecutorInterface`、`LegacyStepExecutor`、`ActionsStepExecutor`、`HybridStepExecutor`、`createExecutor()` 工厂 | +| `engine/runners/step-runner.ts` | 构造函数接受 `StepExecutorInterface`,`executeNode()` 改为调用注入的执行器 | +| `engine/scheduler.ts` | `runFlow()` 创建执行器并注入到 `StepRunner` | + +**StepExecutorInterface 定义**: + +```typescript +export interface StepExecutionOptions { + tabId: number; + runId?: string; + pushLog?: (entry: unknown) => void; +} + +export interface StepExecutionResult { + executor: 'legacy' | 'actions'; + result: ExecResult; +} + +export interface StepExecutorInterface { + execute(ctx: ExecCtx, step: Step, options: StepExecutionOptions): Promise; + supports(stepType: string): boolean; +} +``` + +**执行器创建流程**: + +```typescript +// scheduler.ts +const modeConfig = buildExecutionModeConfig(options); +const registry = modeConfig.mode !== 'legacy' ? createReplayActionRegistry() : undefined; +const stepExecutor = createExecutor(modeConfig, registry); +const runner = new StepRunner(stepExecutor /* ... */); +``` + +**M2: 可控启用 hybrid(已完成 ✅)** + +1. ✅ **execution-mode.ts 新增最小 allowlist** + - `MINIMAL_HYBRID_ACTION_TYPES`: fill/key/scroll/drag/wait/delay/screenshot/assert + - 排除高风险类型(navigate/click/tab 管理)避免策略冲突 + - `createHybridConfig()` 默认使用最小 allowlist + +2. ✅ **scheduler.ts 支持执行模式切换** + - `RunOptions` 新增 `executionMode/actionsAllowlist/legacyOnlyTypes` 字段 + - `buildExecutionModeConfig()` 根据选项构建配置 + - 只在 hybrid/actions 模式下创建 `ActionRegistry` + - 健壮性改进:只接受数组输入,防止误配置 + +3. ⏳ **openTab/switchTab 后同步更新 `ctx.tabId`**(M3 验证时完善) + +**使用方式**: + +```typescript +// 默认 legacy(不传 executionMode) +runFlow(flow, {}); + +// 启用 hybrid(最小 allowlist) +runFlow(flow, { executionMode: 'hybrid' }); + +// 自定义 allowlist +runFlow(flow, { executionMode: 'hybrid', actionsAllowlist: ['fill', 'key'] }); + +// 使用 MIGRATED_ACTION_TYPES(传空数组) +runFlow(flow, { executionMode: 'hybrid', actionsAllowlist: [] }); +``` + +##### M2 详细实现说明 + +**修改文件清单**: +| 文件 | 改动内容 | +|------|----------| +| `engine/execution-mode.ts` | 新增 `MINIMAL_HYBRID_ACTION_TYPES`、`createHybridConfig()`、`createActionsOnlyConfig()` | +| `engine/scheduler.ts` | `RunOptions` 扩展、`buildExecutionModeConfig()` 实现 | + +**MINIMAL_HYBRID_ACTION_TYPES 定义**: + +```typescript +export const MINIMAL_HYBRID_ACTION_TYPES = new Set([ + 'fill', // 低风险:表单填充 + 'key', // 低风险:键盘输入 + 'scroll', // 低风险:滚动 + 'drag', // 低风险:拖拽 + 'wait', // 低风险:等待条件 + 'delay', // 低风险:延迟 + 'screenshot', // 低风险:截图 + 'assert', // 低风险:断言 +]); +// 排除高风险:navigate(导航)、click(点击)、tab 管理 +``` + +**RunOptions 扩展**: + +```typescript +export interface RunOptions { + // ... existing fields + executionMode?: ExecutionMode; // 'legacy' | 'hybrid' | 'actions' + actionsAllowlist?: string[]; // 允许使用 actions 的类型(hybrid 模式) + legacyOnlyTypes?: string[]; // 强制使用 legacy 的类型 +} +``` + +**buildExecutionModeConfig 实现**: + +```typescript +function buildExecutionModeConfig(options: RunOptions): ExecutionModeConfig { + const mode = isExecutionMode(options.executionMode) ? options.executionMode : 'legacy'; + + if (mode === 'hybrid') { + const overrides: Partial = {}; + if (Array.isArray(options.actionsAllowlist)) { + overrides.actionsAllowlist = toStringSet(options.actionsAllowlist); + } + if (Array.isArray(options.legacyOnlyTypes)) { + overrides.legacyOnlyTypes = toStringSet(options.legacyOnlyTypes); + } + return createHybridConfig(overrides); + } + + if (mode === 'actions') { + return createActionsOnlyConfig(); + } + + return { ...DEFAULT_EXECUTION_MODE_CONFIG }; +} +``` + +**M2.1: 双重策略问题修复(已完成 ✅)** + +**问题描述**: StepRunner 和 ActionRegistry 都有 retry/nav-wait 逻辑,会导致双重等待。 + +**解决方案**: StepRunner 作为策略权威,ActionRegistry 的内部策略可被跳过。 + +**修改文件清单**: +| 文件 | 改动内容 | +|------|----------| +| `actions/types.ts` | 新增 `ExecutionFlags` 接口、`ActionExecutionContext.execution` 字段 | +| `actions/adapter.ts` | `StepExecutorOptions` 新增 `skipRetry/skipNavWait`,实现策略跳过逻辑 | +| `actions/handlers/click.ts` | 检查 `ctx.execution?.skipNavWait` 跳过导航等待 | +| `actions/handlers/navigate.ts` | 检查 `ctx.execution?.skipNavWait` 跳过导航等待 | + +**ExecutionFlags 接口**: + +```typescript +export interface ExecutionFlags { + skipNavWait?: boolean; // 跳过 handler 内部的导航等待 +} + +export interface ActionExecutionContext { + // ... existing fields + execution?: ExecutionFlags; +} +``` + +**adapter.ts 策略跳过逻辑**: + +```typescript +export interface StepExecutorOptions { + runId?: string; + pushLog?: (entry: unknown) => void; + strict?: boolean; + skipRetry?: boolean; // 移除 action.policy.retry + skipNavWait?: boolean; // 设置 ctx.execution.skipNavWait +} + +// 在 createStepExecutor 中 +if (options?.skipRetry === true && action.policy?.retry) { + action = { ...action, policy: { ...action.policy, retry: undefined } }; +} +const execution: ExecutionFlags | undefined = + options?.skipNavWait === true ? { skipNavWait: true } : undefined; +``` + +**click.ts/navigate.ts 检查**: + +```typescript +const skipNavWait = ctx.execution?.skipNavWait === true; +if (skipNavWait) { + return { status: 'success' }; // 跳过导航等待 +} +// ... 正常导航等待逻辑 +``` + +**P1.0: 存储层统一 - 迁移与导入(已完成 ✅)** + +1. ✅ **启用 ensureMigratedFromLocal()** + - `flow-store.ts`: 所有读写入口添加迁移 gate + - `trigger-store.ts`: 所有读写入口添加迁移 gate + - 迁移逻辑:从 chrome.storage.local 读取旧数据 → 写入 IndexedDB + +2. ✅ **完善 importFlowFromJson()** + - 支持 4 种格式:数组、{ flows }、单个 steps、单个 nodes-only + - 更严格的字段验证(必须有 id) + - 自动补齐 name/version/steps/meta 默认值 + +3. ✅ **edges 一致性校验** + - `normalizeFlowForSave()` 在有 nodes 时也校验 edges + - 移除引用不存在 node 的 edges,防止 scheduler 运行时错误 + +##### P1.0 详细实现说明 + +**修改文件清单**: +| 文件 | 改动内容 | +|------|----------| +| `flow-store.ts` | 所有函数添加 `await ensureMigratedFromLocal()`;重写 `importFlowFromJson()` | +| `trigger-store.ts` | 所有函数添加 `await ensureMigratedFromLocal()` | + +**ensureMigratedFromLocal() 调用位置** (`flow-store.ts`): + +```typescript +export async function listFlows(): Promise { + await ensureMigratedFromLocal(); // ← 添加 + const flows = await IndexedDbStorage.flows.list(); + // ... +} + +export async function getFlow(flowId: string): Promise { + await ensureMigratedFromLocal(); // ← 添加 + // ... +} + +export async function saveFlow(flow: Flow): Promise { + await ensureMigratedFromLocal(); // ← 添加 + // ... +} + +// 同样: deleteFlow, listRuns, appendRun, listPublished, publishFlow, unpublishFlow, +// exportFlow, exportAllFlows, importFlowFromJson, listSchedules, saveSchedule, removeSchedule +``` + +**importFlowFromJson 重写**: + +```typescript +export async function importFlowFromJson(json: string): Promise { + await ensureMigratedFromLocal(); + const parsed = JSON.parse(json); + + // 支持 4 种格式 + const candidates: unknown[] = Array.isArray(parsed) + ? parsed // 格式1: 数组 + : Array.isArray(parsed?.flows) + ? parsed.flows // 格式2: { flows: [...] } + : parsed?.id && (Array.isArray(parsed?.steps) || Array.isArray(parsed?.nodes)) + ? [parsed] // 格式3/4: 单个 flow (steps 或 nodes) + : []; + + if (!candidates.length) { + throw new Error('invalid flow json: no flows found'); + } + + // 验证和规范化每个 flow + for (const raw of candidates) { + const id = String(f.id || '').trim(); + if (!id) throw new Error('invalid flow json: missing id'); + + // 自动补齐字段 + const name = typeof f.name === 'string' && f.name.trim() ? f.name : id; + const version = Number.isFinite(Number(f.version)) ? Number(f.version) : 1; + const steps = Array.isArray(f.steps) ? f.steps : []; + // ... + } + + // 保存(normalize on save) + for (const f of flowsToImport) { + await saveFlow(f); + } + + return flowsToImport; +} +``` + +**normalizeFlowForSave edges 校验** (`flow-store.ts:50`): + +```typescript +function normalizeFlowForSave(flow: Flow): Flow { + const hasNodes = Array.isArray(flow.nodes) && flow.nodes.length > 0; + if (hasNodes) { + // 即使有 nodes,也校验 edges(处理导入/手动编辑的脏数据) + const nodeIds = new Set(flow.nodes!.map((n) => n.id)); + if (Array.isArray(flow.edges) && flow.edges.length > 0) { + const validEdges = filterValidEdges(flow.edges, nodeIds); + if (validEdges.length !== flow.edges.length) { + return { ...flow, edges: validEdges }; // 返回清理后的 flow + } + } + return flow; + } + // ... 原有逻辑:从 steps 生成 nodes/edges +} + +function filterValidEdges(edges: Edge[], nodeIds: Set): Edge[] { + return edges.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to)); +} +``` + +**M3-core: 契约测试(已完成 ✅)** + +1. ✅ **测试基础设施** + - `tests/record-replay/_test-helpers.ts`: 工厂函数和 mock helpers + - 使用 vitest + mock,不依赖真实浏览器 + +2. ✅ **adapter-policy.contract.test.ts** (7 tests) + - `skipRetry` 移除 `action.policy.retry` 验证 + - `skipNavWait` 设置 `ctx.execution.skipNavWait` 验证 + - 组合 flags 验证 + +3. ✅ **step-executor.contract.test.ts** (20 tests) + - `DEFAULT_EXECUTION_MODE_CONFIG` 契约 + - `createHybridConfig` / `createActionsOnlyConfig` 契约 + - `LegacyStepExecutor` 行为验证 + - `HybridStepExecutor` 路由验证 + - `createExecutor` 工厂验证 + - `MINIMAL_HYBRID_ACTION_TYPES` 内容验证 + +4. ✅ **session-dag-sync.contract.test.ts** (15 tests) + - 首个 step 创建 node(无 edge) + - 后续 step 创建 node + edge + - 批量 step 正确链接 + - upsert 更新 node config / 保留 edges + - 不变式处理(nodes 缺失、edges 缺失、edges 指向错误) + - session 生命周期(start/stop 清理缓存) + - 类型转换(有效类型、未知类型降级) + - edge id 唯一性和单调序列 + +##### M3-core 详细实现说明 + +**测试文件清单**: +| 文件 | 测试数 | 覆盖内容 | +|------|--------|----------| +| `tests/record-replay/_test-helpers.ts` | - | 工厂函数:`createMockExecCtx`、`createMockActionCtx`、`createMockStep`、`createMockFlow`、`createMockRegistry` | +| `tests/record-replay/adapter-policy.contract.test.ts` | 7 | adapter.ts 的 skipRetry/skipNavWait 策略跳过逻辑 | +| `tests/record-replay/step-executor.contract.test.ts` | 20 | execution-mode.ts 配置契约、step-executor.ts 执行器路由 | +| `tests/record-replay/session-dag-sync.contract.test.ts` | 15 | session-manager.ts 的增量 DAG 同步逻辑 | + +**测试运行方式**: + +```bash +pnpm test # 运行所有测试 +pnpm test tests/record-replay/ # 运行 record-replay 相关测试 +``` + +**当前测试状态**: 197 个测试全部通过 + +**vitest mock 注意事项** (重要): + +```typescript +// ❌ 错误:mock 函数定义在 vi.mock 外部会导致 hoisting 错误 +const mockFn = vi.fn(); +vi.mock('./module', () => ({ fn: mockFn })); + +// ✅ 正确:mock 函数定义在 vi.mock 内部 +vi.mock('./module', () => ({ + fn: vi.fn(async () => ({ status: 'success' })), +})); + +// 获取 mock 引用 +import { fn } from './module'; +const mockFn = fn as ReturnType; +``` + +**\_test-helpers.ts 工厂函数**: + +```typescript +// 创建最小 ExecCtx +export function createMockExecCtx(overrides: Partial = {}): ExecCtx { + return { vars: {}, logger: vi.fn(), ...overrides }; +} + +// 创建最小 Step +export function createMockStep(type: string, overrides: Record = {}): any { + return { + id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + type, + ...overrides, + }; +} + +// 创建 mock ActionRegistry +export function createMockRegistry(handlers: Map = new Map()) { + const executeFn = vi.fn(async () => ({ status: 'success' as const })); + return { + get: vi.fn((type: string) => handlers.get(type) || { type }), + execute: executeFn, + register: vi.fn(), + has: vi.fn((type: string) => handlers.has(type)), + _executeFn: executeFn, // 暴露给测试断言 + }; +} +``` + +**M3-full: 完整集成测试(待实施)** + +1. [ ] 在 hybrid 模式下验证各类型行为一致性 +2. [ ] 特别关注:aria selector、script when:'after' defer、control-flow 条件求值 +3. [ ] openTab/switchTab 后更新 ctx.tabId **P4: 清理旧类型**