chore: web-editor优化

This commit is contained in:
hangerye
2025-12-25 10:27:56 +08:00
parent 34696bca3b
commit a6b52c5479
40 changed files with 5501 additions and 945 deletions
+2 -1
View File
@@ -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": []
+22 -4
View File
@@ -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: [
@@ -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<StepExecutionAttempt> {
// 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)
@@ -41,6 +41,8 @@ async function executeClick<T extends 'click' | 'dblclick'>(
): Promise<ActionExecutionResult<T>> {
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<T extends 'click' | 'dblclick'>(
// 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<T extends 'click' | 'dblclick'>(
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,
@@ -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' };
},
@@ -751,6 +751,18 @@ export type ActionOutput<T extends ActionType> = T extends keyof ActionOutputsBy
export type ValidationResult = { ok: true } | { ok: false; errors: NonEmptyArray<string> };
/**
* 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 =
@@ -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<string>;
@@ -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<string>([
'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>): 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<ExecutionModeConfig>,
@@ -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,
};
}
@@ -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<StepExecutionResult> {
async execute(
ctx: ExecCtx,
step: Step,
_options: StepExecutionOptions,
): Promise<StepExecutionResult> {
// 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<typeof createStepExecutor>;
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<typeof createStepExecutor>;
@@ -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}`);
}
@@ -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<string, any>;
/** 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<StepClick['after']>);
@@ -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<string, any>;
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<string>, filtering invalid values
*/
function toStringSet(value: unknown): Set<string> {
const result = new Set<string>();
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<ExecutionModeConfig> = {};
// 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<string, any> = 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(
@@ -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<string>(Object.values(NODE_TYPES));
@@ -42,10 +43,22 @@ function filterValidEdges(edges: Edge[], nodeIds: Set<string>): 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<Flow> {
}
export async function listFlows(): Promise<Flow[]> {
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<Flow[]> {
}
export async function getFlow(flowId: string): Promise<Flow | undefined> {
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<Flow | undefined> {
}
export async function saveFlow(flow: Flow): Promise<void> {
await ensureMigratedFromLocal();
const normalizedFlow = normalizeFlowForSave(flow);
await IndexedDbStorage.flows.save(normalizedFlow);
}
export async function deleteFlow(flowId: string): Promise<void> {
await ensureMigratedFromLocal();
await IndexedDbStorage.flows.delete(flowId);
}
export async function listRuns(): Promise<RunRecord[]> {
await ensureMigratedFromLocal();
return await IndexedDbStorage.runs.list();
}
export async function appendRun(record: RunRecord): Promise<void> {
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<void> {
}
export async function listPublished(): Promise<PublishedFlowInfo[]> {
await ensureMigratedFromLocal();
return await IndexedDbStorage.published.list();
}
export async function publishFlow(flow: Flow, slug?: string): Promise<PublishedFlowInfo> {
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<PublishedF
}
export async function unpublishFlow(flowId: string): Promise<void> {
await ensureMigratedFromLocal();
await IndexedDbStorage.published.delete(flowId);
}
@@ -219,20 +241,79 @@ export async function exportAllFlows(): Promise<string> {
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<Flow[]> {
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<string, unknown>;
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<string, unknown>) : {};
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<FlowSchedule[]> {
await ensureMigratedFromLocal();
return await IndexedDbStorage.schedules.list();
}
export async function saveSchedule(s: FlowSchedule): Promise<void> {
await ensureMigratedFromLocal();
await IndexedDbStorage.schedules.save(s);
}
export async function removeSchedule(scheduleId: string): Promise<void> {
await ensureMigratedFromLocal();
await IndexedDbStorage.schedules.delete(scheduleId);
}
@@ -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<string, any>;
/** 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;
}
@@ -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<number>;
}
// Valid node types for type checking
const VALID_NODE_TYPES = new Set<string>(Object.values(NODE_TYPES));
export class RecordingSessionManager {
private state: RecordingSessionState = {
sessionId: '',
@@ -32,6 +36,12 @@ export class RecordingSessionManager {
stoppedTabs: new Set<number>(),
};
// Session-level caches for incremental DAG sync (cleared on session start/stop)
private stepIndexMap: Map<string, number> = new Map();
private nodeIndexMap: Map<string, number> = 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<void> {
// 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<number>([originTabId]),
stoppedTabs: new Set<number>(),
};
// 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<string, number>();
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.
*/
@@ -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<FlowTrigger[]> {
await ensureMigratedFromLocal();
return await IndexedDbStorage.triggers.list();
}
export async function saveTrigger(t: FlowTrigger): Promise<void> {
await ensureMigratedFromLocal();
await IndexedDbStorage.triggers.save(t);
}
export async function deleteTrigger(id: string): Promise<void> {
await ensureMigratedFromLocal();
await IndexedDbStorage.triggers.delete(id);
}
@@ -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) {
@@ -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);
@@ -441,6 +441,21 @@ async function stopRecording(): Promise<GifResult> {
// 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;
@@ -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';
@@ -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),
)
) {
@@ -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);
@@ -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<string, unknown>): 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<number, unknown> }
).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<ToolResult> {
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<ToolResult> {
// 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<ToolResult> {
// 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();
@@ -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';
};
@@ -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<readonly [number, number]> = [
[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;
}
@@ -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();
}
@@ -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<StopId, number>;
/** 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<StopId, number>();
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<HTMLElement>(
`.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)
@@ -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);
@@ -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();
}
@@ -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<readonly [number, number]> = [
[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);
}
@@ -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;
@@ -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 {
@@ -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();
@@ -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> = {}): ExecCtx {
return {
vars: {},
logger: vi.fn(),
...overrides,
};
}
/**
* Create a minimal ActionExecutionContext for testing
*/
export function createMockActionCtx(
overrides: Partial<ActionExecutionContext> = {},
): ActionExecutionContext {
return {
vars: {},
tabId: 1,
log: vi.fn(),
...overrides,
};
}
/**
* Create a minimal Step for testing
*/
export function createMockStep(type: string, overrides: Record<string, unknown> = {}): 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<string, unknown> = {}): 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<string, any> = 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
};
}
@@ -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<typeof vi.fn>;
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);
});
});
});
@@ -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> = {}): 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<string, unknown> = {}) {
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');
});
});
});
@@ -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<typeof vi.fn>;
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<typeof vi.fn>;
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);
}
});
});
+101 -24
View File
@@ -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 方向键调整 positionArrowLeft/Right/Up/Down 步进1Shift 步进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 模式下显示 GradientControlallowNone: 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`
+27 -22
View File
@@ -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 友好,暂不调整
+16 -52
View File
@@ -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',
+603 -29
View File
@@ -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 继续发送 Stepsbackground 在 `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<string, number> = new Map(); // stepId → 数组索引
private nodeIndexMap: Map<string, number> = 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<StepExecutionResult>;
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<string>([
'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<ExecutionModeConfig> = {};
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<Flow[]> {
await ensureMigratedFromLocal(); // ← 添加
const flows = await IndexedDbStorage.flows.list();
// ...
}
export async function getFlow(flowId: string): Promise<Flow | undefined> {
await ensureMigratedFromLocal(); // ← 添加
// ...
}
export async function saveFlow(flow: Flow): Promise<void> {
await ensureMigratedFromLocal(); // ← 添加
// ...
}
// 同样: deleteFlow, listRuns, appendRun, listPublished, publishFlow, unpublishFlow,
// exportFlow, exportAllFlows, importFlowFromJson, listSchedules, saveSchedule, removeSchedule
```
**importFlowFromJson 重写**:
```typescript
export async function importFlowFromJson(json: string): Promise<Flow[]> {
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<string>): 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<typeof vi.fn>;
```
**\_test-helpers.ts 工厂函数**:
```typescript
// 创建最小 ExecCtx
export function createMockExecCtx(overrides: Partial<ExecCtx> = {}): ExecCtx {
return { vars: {}, logger: vi.fn(), ...overrides };
}
// 创建最小 Step
export function createMockStep(type: string, overrides: Record<string, unknown> = {}): any {
return {
id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
type,
...overrides,
};
}
// 创建 mock ActionRegistry
export function createMockRegistry(handlers: Map<string, any> = 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: 清理旧类型**