#
tokens: 43246/50000 2/574 files (page 45/60)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 45 of 60. Use http://codebase.md/hangwin/mcp-chrome?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .gitattributes
├── .github
│   └── workflows
│       └── build-release.yml
├── .gitignore
├── .husky
│   ├── commit-msg
│   └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│   └── extensions.json
├── app
│   ├── chrome-extension
│   │   ├── _locales
│   │   │   ├── de
│   │   │   │   └── messages.json
│   │   │   ├── en
│   │   │   │   └── messages.json
│   │   │   ├── ja
│   │   │   │   └── messages.json
│   │   │   ├── ko
│   │   │   │   └── messages.json
│   │   │   ├── zh_CN
│   │   │   │   └── messages.json
│   │   │   └── zh_TW
│   │   │       └── messages.json
│   │   ├── .env.example
│   │   ├── assets
│   │   │   └── vue.svg
│   │   ├── common
│   │   │   ├── agent-models.ts
│   │   │   ├── constants.ts
│   │   │   ├── element-marker-types.ts
│   │   │   ├── message-types.ts
│   │   │   ├── node-types.ts
│   │   │   ├── rr-v3-keepalive-protocol.ts
│   │   │   ├── step-types.ts
│   │   │   ├── tool-handler.ts
│   │   │   └── web-editor-types.ts
│   │   ├── entrypoints
│   │   │   ├── background
│   │   │   │   ├── element-marker
│   │   │   │   │   ├── element-marker-storage.ts
│   │   │   │   │   └── index.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── keepalive-manager.ts
│   │   │   │   ├── native-host.ts
│   │   │   │   ├── quick-panel
│   │   │   │   │   ├── agent-handler.ts
│   │   │   │   │   ├── commands.ts
│   │   │   │   │   └── tabs-handler.ts
│   │   │   │   ├── record-replay
│   │   │   │   │   ├── actions
│   │   │   │   │   │   ├── adapter.ts
│   │   │   │   │   │   ├── handlers
│   │   │   │   │   │   │   ├── assert.ts
│   │   │   │   │   │   │   ├── click.ts
│   │   │   │   │   │   │   ├── common.ts
│   │   │   │   │   │   │   ├── control-flow.ts
│   │   │   │   │   │   │   ├── delay.ts
│   │   │   │   │   │   │   ├── dom.ts
│   │   │   │   │   │   │   ├── drag.ts
│   │   │   │   │   │   │   ├── extract.ts
│   │   │   │   │   │   │   ├── fill.ts
│   │   │   │   │   │   │   ├── http.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── key.ts
│   │   │   │   │   │   │   ├── navigate.ts
│   │   │   │   │   │   │   ├── screenshot.ts
│   │   │   │   │   │   │   ├── script.ts
│   │   │   │   │   │   │   ├── scroll.ts
│   │   │   │   │   │   │   ├── tabs.ts
│   │   │   │   │   │   │   └── wait.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── registry.ts
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── engine
│   │   │   │   │   │   ├── constants.ts
│   │   │   │   │   │   ├── execution-mode.ts
│   │   │   │   │   │   ├── logging
│   │   │   │   │   │   │   └── run-logger.ts
│   │   │   │   │   │   ├── plugins
│   │   │   │   │   │   │   ├── breakpoint.ts
│   │   │   │   │   │   │   ├── manager.ts
│   │   │   │   │   │   │   └── types.ts
│   │   │   │   │   │   ├── policies
│   │   │   │   │   │   │   ├── retry.ts
│   │   │   │   │   │   │   └── wait.ts
│   │   │   │   │   │   ├── runners
│   │   │   │   │   │   │   ├── after-script-queue.ts
│   │   │   │   │   │   │   ├── control-flow-runner.ts
│   │   │   │   │   │   │   ├── step-executor.ts
│   │   │   │   │   │   │   ├── step-runner.ts
│   │   │   │   │   │   │   └── subflow-runner.ts
│   │   │   │   │   │   ├── scheduler.ts
│   │   │   │   │   │   ├── state-manager.ts
│   │   │   │   │   │   └── utils
│   │   │   │   │   │       └── expression.ts
│   │   │   │   │   ├── flow-runner.ts
│   │   │   │   │   ├── flow-store.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── legacy-types.ts
│   │   │   │   │   ├── nodes
│   │   │   │   │   │   ├── assert.ts
│   │   │   │   │   │   ├── click.ts
│   │   │   │   │   │   ├── conditional.ts
│   │   │   │   │   │   ├── download-screenshot-attr-event-frame-loop.ts
│   │   │   │   │   │   ├── drag.ts
│   │   │   │   │   │   ├── execute-flow.ts
│   │   │   │   │   │   ├── extract.ts
│   │   │   │   │   │   ├── fill.ts
│   │   │   │   │   │   ├── http.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── key.ts
│   │   │   │   │   │   ├── loops.ts
│   │   │   │   │   │   ├── navigate.ts
│   │   │   │   │   │   ├── script.ts
│   │   │   │   │   │   ├── scroll.ts
│   │   │   │   │   │   ├── tabs.ts
│   │   │   │   │   │   ├── types.ts
│   │   │   │   │   │   └── wait.ts
│   │   │   │   │   ├── recording
│   │   │   │   │   │   ├── browser-event-listener.ts
│   │   │   │   │   │   ├── content-injection.ts
│   │   │   │   │   │   ├── content-message-handler.ts
│   │   │   │   │   │   ├── flow-builder.ts
│   │   │   │   │   │   ├── recorder-manager.ts
│   │   │   │   │   │   └── session-manager.ts
│   │   │   │   │   ├── rr-utils.ts
│   │   │   │   │   ├── selector-engine.ts
│   │   │   │   │   ├── storage
│   │   │   │   │   │   └── indexeddb-manager.ts
│   │   │   │   │   ├── trigger-store.ts
│   │   │   │   │   └── types.ts
│   │   │   │   ├── record-replay-v3
│   │   │   │   │   ├── bootstrap.ts
│   │   │   │   │   ├── domain
│   │   │   │   │   │   ├── debug.ts
│   │   │   │   │   │   ├── errors.ts
│   │   │   │   │   │   ├── events.ts
│   │   │   │   │   │   ├── flow.ts
│   │   │   │   │   │   ├── ids.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── json.ts
│   │   │   │   │   │   ├── policy.ts
│   │   │   │   │   │   ├── triggers.ts
│   │   │   │   │   │   └── variables.ts
│   │   │   │   │   ├── engine
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── keepalive
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   └── offscreen-keepalive.ts
│   │   │   │   │   │   ├── kernel
│   │   │   │   │   │   │   ├── artifacts.ts
│   │   │   │   │   │   │   ├── breakpoints.ts
│   │   │   │   │   │   │   ├── debug-controller.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── kernel.ts
│   │   │   │   │   │   │   ├── recovery-kernel.ts
│   │   │   │   │   │   │   ├── runner.ts
│   │   │   │   │   │   │   └── traversal.ts
│   │   │   │   │   │   ├── plugins
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── register-v2-replay-nodes.ts
│   │   │   │   │   │   │   ├── registry.ts
│   │   │   │   │   │   │   ├── types.ts
│   │   │   │   │   │   │   └── v2-action-adapter.ts
│   │   │   │   │   │   ├── queue
│   │   │   │   │   │   │   ├── enqueue-run.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── leasing.ts
│   │   │   │   │   │   │   ├── queue.ts
│   │   │   │   │   │   │   └── scheduler.ts
│   │   │   │   │   │   ├── recovery
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   └── recovery-coordinator.ts
│   │   │   │   │   │   ├── storage
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   └── storage-port.ts
│   │   │   │   │   │   ├── transport
│   │   │   │   │   │   │   ├── events-bus.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── rpc-server.ts
│   │   │   │   │   │   │   └── rpc.ts
│   │   │   │   │   │   └── triggers
│   │   │   │   │   │       ├── command-trigger.ts
│   │   │   │   │   │       ├── context-menu-trigger.ts
│   │   │   │   │   │       ├── cron-trigger.ts
│   │   │   │   │   │       ├── dom-trigger.ts
│   │   │   │   │   │       ├── index.ts
│   │   │   │   │   │       ├── interval-trigger.ts
│   │   │   │   │   │       ├── manual-trigger.ts
│   │   │   │   │   │       ├── once-trigger.ts
│   │   │   │   │   │       ├── trigger-handler.ts
│   │   │   │   │   │       ├── trigger-manager.ts
│   │   │   │   │   │       └── url-trigger.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── storage
│   │   │   │   │       ├── db.ts
│   │   │   │   │       ├── events.ts
│   │   │   │   │       ├── flows.ts
│   │   │   │   │       ├── import
│   │   │   │   │       │   ├── index.ts
│   │   │   │   │       │   ├── v2-reader.ts
│   │   │   │   │       │   └── v2-to-v3.ts
│   │   │   │   │       ├── index.ts
│   │   │   │   │       ├── persistent-vars.ts
│   │   │   │   │       ├── queue.ts
│   │   │   │   │       ├── runs.ts
│   │   │   │   │       └── triggers.ts
│   │   │   │   ├── semantic-similarity.ts
│   │   │   │   ├── storage-manager.ts
│   │   │   │   ├── tools
│   │   │   │   │   ├── base-browser.ts
│   │   │   │   │   ├── browser
│   │   │   │   │   │   ├── bookmark.ts
│   │   │   │   │   │   ├── common.ts
│   │   │   │   │   │   ├── computer.ts
│   │   │   │   │   │   ├── console-buffer.ts
│   │   │   │   │   │   ├── console.ts
│   │   │   │   │   │   ├── dialog.ts
│   │   │   │   │   │   ├── download.ts
│   │   │   │   │   │   ├── element-picker.ts
│   │   │   │   │   │   ├── file-upload.ts
│   │   │   │   │   │   ├── gif-auto-capture.ts
│   │   │   │   │   │   ├── gif-enhanced-renderer.ts
│   │   │   │   │   │   ├── gif-recorder.ts
│   │   │   │   │   │   ├── history.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── inject-script.ts
│   │   │   │   │   │   ├── interaction.ts
│   │   │   │   │   │   ├── javascript.ts
│   │   │   │   │   │   ├── keyboard.ts
│   │   │   │   │   │   ├── network-capture-debugger.ts
│   │   │   │   │   │   ├── network-capture-web-request.ts
│   │   │   │   │   │   ├── network-capture.ts
│   │   │   │   │   │   ├── network-request.ts
│   │   │   │   │   │   ├── performance.ts
│   │   │   │   │   │   ├── read-page.ts
│   │   │   │   │   │   ├── screenshot.ts
│   │   │   │   │   │   ├── userscript.ts
│   │   │   │   │   │   ├── vector-search.ts
│   │   │   │   │   │   ├── web-fetcher.ts
│   │   │   │   │   │   └── window.ts
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── record-replay.ts
│   │   │   │   ├── utils
│   │   │   │   │   └── sidepanel.ts
│   │   │   │   └── web-editor
│   │   │   │       └── index.ts
│   │   │   ├── builder
│   │   │   │   ├── App.vue
│   │   │   │   ├── index.html
│   │   │   │   └── main.ts
│   │   │   ├── content.ts
│   │   │   ├── element-picker.content.ts
│   │   │   ├── offscreen
│   │   │   │   ├── gif-encoder.ts
│   │   │   │   ├── index.html
│   │   │   │   ├── main.ts
│   │   │   │   └── rr-keepalive.ts
│   │   │   ├── options
│   │   │   │   ├── App.vue
│   │   │   │   ├── index.html
│   │   │   │   └── main.ts
│   │   │   ├── popup
│   │   │   │   ├── App.vue
│   │   │   │   ├── components
│   │   │   │   │   ├── builder
│   │   │   │   │   │   ├── components
│   │   │   │   │   │   │   ├── Canvas.vue
│   │   │   │   │   │   │   ├── EdgePropertyPanel.vue
│   │   │   │   │   │   │   ├── KeyValueEditor.vue
│   │   │   │   │   │   │   ├── nodes
│   │   │   │   │   │   │   │   ├── node-util.ts
│   │   │   │   │   │   │   │   ├── NodeCard.vue
│   │   │   │   │   │   │   │   └── NodeIf.vue
│   │   │   │   │   │   │   ├── properties
│   │   │   │   │   │   │   │   ├── PropertyAssert.vue
│   │   │   │   │   │   │   │   ├── PropertyClick.vue
│   │   │   │   │   │   │   │   ├── PropertyCloseTab.vue
│   │   │   │   │   │   │   │   ├── PropertyDelay.vue
│   │   │   │   │   │   │   │   ├── PropertyDrag.vue
│   │   │   │   │   │   │   │   ├── PropertyExecuteFlow.vue
│   │   │   │   │   │   │   │   ├── PropertyExtract.vue
│   │   │   │   │   │   │   │   ├── PropertyFill.vue
│   │   │   │   │   │   │   │   ├── PropertyForeach.vue
│   │   │   │   │   │   │   │   ├── PropertyFormRenderer.vue
│   │   │   │   │   │   │   │   ├── PropertyFromSpec.vue
│   │   │   │   │   │   │   │   ├── PropertyHandleDownload.vue
│   │   │   │   │   │   │   │   ├── PropertyHttp.vue
│   │   │   │   │   │   │   │   ├── PropertyIf.vue
│   │   │   │   │   │   │   │   ├── PropertyKey.vue
│   │   │   │   │   │   │   │   ├── PropertyLoopElements.vue
│   │   │   │   │   │   │   │   ├── PropertyNavigate.vue
│   │   │   │   │   │   │   │   ├── PropertyOpenTab.vue
│   │   │   │   │   │   │   │   ├── PropertyScreenshot.vue
│   │   │   │   │   │   │   │   ├── PropertyScript.vue
│   │   │   │   │   │   │   │   ├── PropertyScroll.vue
│   │   │   │   │   │   │   │   ├── PropertySetAttribute.vue
│   │   │   │   │   │   │   │   ├── PropertySwitchFrame.vue
│   │   │   │   │   │   │   │   ├── PropertySwitchTab.vue
│   │   │   │   │   │   │   │   ├── PropertyTrigger.vue
│   │   │   │   │   │   │   │   ├── PropertyTriggerEvent.vue
│   │   │   │   │   │   │   │   ├── PropertyWait.vue
│   │   │   │   │   │   │   │   ├── PropertyWhile.vue
│   │   │   │   │   │   │   │   └── SelectorEditor.vue
│   │   │   │   │   │   │   ├── PropertyPanel.vue
│   │   │   │   │   │   │   ├── Sidebar.vue
│   │   │   │   │   │   │   └── TriggerPanel.vue
│   │   │   │   │   │   ├── model
│   │   │   │   │   │   │   ├── form-widget-registry.ts
│   │   │   │   │   │   │   ├── node-spec-registry.ts
│   │   │   │   │   │   │   ├── node-spec.ts
│   │   │   │   │   │   │   ├── node-specs-builtin.ts
│   │   │   │   │   │   │   ├── toast.ts
│   │   │   │   │   │   │   ├── transforms.ts
│   │   │   │   │   │   │   ├── ui-nodes.ts
│   │   │   │   │   │   │   ├── validation.ts
│   │   │   │   │   │   │   └── variables.ts
│   │   │   │   │   │   ├── store
│   │   │   │   │   │   │   └── useBuilderStore.ts
│   │   │   │   │   │   └── widgets
│   │   │   │   │   │       ├── FieldCode.vue
│   │   │   │   │   │       ├── FieldDuration.vue
│   │   │   │   │   │       ├── FieldExpression.vue
│   │   │   │   │   │       ├── FieldKeySequence.vue
│   │   │   │   │   │       ├── FieldSelector.vue
│   │   │   │   │   │       ├── FieldTargetLocator.vue
│   │   │   │   │   │       └── VarInput.vue
│   │   │   │   │   ├── ConfirmDialog.vue
│   │   │   │   │   ├── ElementMarkerManagement.vue
│   │   │   │   │   ├── icons
│   │   │   │   │   │   ├── BoltIcon.vue
│   │   │   │   │   │   ├── CheckIcon.vue
│   │   │   │   │   │   ├── DatabaseIcon.vue
│   │   │   │   │   │   ├── DocumentIcon.vue
│   │   │   │   │   │   ├── EditIcon.vue
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── MarkerIcon.vue
│   │   │   │   │   │   ├── RecordIcon.vue
│   │   │   │   │   │   ├── RefreshIcon.vue
│   │   │   │   │   │   ├── StopIcon.vue
│   │   │   │   │   │   ├── TabIcon.vue
│   │   │   │   │   │   ├── TrashIcon.vue
│   │   │   │   │   │   ├── VectorIcon.vue
│   │   │   │   │   │   └── WorkflowIcon.vue
│   │   │   │   │   ├── LocalModelPage.vue
│   │   │   │   │   ├── ModelCacheManagement.vue
│   │   │   │   │   ├── ProgressIndicator.vue
│   │   │   │   │   └── ScheduleDialog.vue
│   │   │   │   ├── index.html
│   │   │   │   ├── main.ts
│   │   │   │   └── style.css
│   │   │   ├── quick-panel.content.ts
│   │   │   ├── shared
│   │   │   │   ├── composables
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── useRRV3Rpc.ts
│   │   │   │   └── utils
│   │   │   │       ├── index.ts
│   │   │   │       └── rr-flow-convert.ts
│   │   │   ├── sidepanel
│   │   │   │   ├── App.vue
│   │   │   │   ├── components
│   │   │   │   │   ├── agent
│   │   │   │   │   │   ├── AttachmentPreview.vue
│   │   │   │   │   │   ├── ChatInput.vue
│   │   │   │   │   │   ├── CliSettings.vue
│   │   │   │   │   │   ├── ConnectionStatus.vue
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── MessageItem.vue
│   │   │   │   │   │   ├── MessageList.vue
│   │   │   │   │   │   ├── ProjectCreateForm.vue
│   │   │   │   │   │   └── ProjectSelector.vue
│   │   │   │   │   ├── agent-chat
│   │   │   │   │   │   ├── AgentChatShell.vue
│   │   │   │   │   │   ├── AgentComposer.vue
│   │   │   │   │   │   ├── AgentConversation.vue
│   │   │   │   │   │   ├── AgentOpenProjectMenu.vue
│   │   │   │   │   │   ├── AgentProjectMenu.vue
│   │   │   │   │   │   ├── AgentRequestThread.vue
│   │   │   │   │   │   ├── AgentSessionListItem.vue
│   │   │   │   │   │   ├── AgentSessionMenu.vue
│   │   │   │   │   │   ├── AgentSessionSettingsPanel.vue
│   │   │   │   │   │   ├── AgentSessionsView.vue
│   │   │   │   │   │   ├── AgentSettingsMenu.vue
│   │   │   │   │   │   ├── AgentTimeline.vue
│   │   │   │   │   │   ├── AgentTimelineItem.vue
│   │   │   │   │   │   ├── AgentTopBar.vue
│   │   │   │   │   │   ├── ApplyMessageChip.vue
│   │   │   │   │   │   ├── AttachmentCachePanel.vue
│   │   │   │   │   │   ├── ComposerDrawer.vue
│   │   │   │   │   │   ├── ElementChip.vue
│   │   │   │   │   │   ├── FakeCaretOverlay.vue
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── SelectionChip.vue
│   │   │   │   │   │   ├── timeline
│   │   │   │   │   │   │   ├── markstream-thinking.ts
│   │   │   │   │   │   │   ├── ThinkingNode.vue
│   │   │   │   │   │   │   ├── TimelineNarrativeStep.vue
│   │   │   │   │   │   │   ├── TimelineStatusStep.vue
│   │   │   │   │   │   │   ├── TimelineToolCallStep.vue
│   │   │   │   │   │   │   ├── TimelineToolResultCardStep.vue
│   │   │   │   │   │   │   └── TimelineUserPromptStep.vue
│   │   │   │   │   │   └── WebEditorChanges.vue
│   │   │   │   │   ├── AgentChat.vue
│   │   │   │   │   ├── rr-v3
│   │   │   │   │   │   └── DebuggerPanel.vue
│   │   │   │   │   ├── SidepanelNavigator.vue
│   │   │   │   │   └── workflows
│   │   │   │   │       ├── index.ts
│   │   │   │   │       ├── WorkflowListItem.vue
│   │   │   │   │       └── WorkflowsView.vue
│   │   │   │   ├── composables
│   │   │   │   │   ├── index.ts
│   │   │   │   │   ├── useAgentChat.ts
│   │   │   │   │   ├── useAgentChatViewRoute.ts
│   │   │   │   │   ├── useAgentInputPreferences.ts
│   │   │   │   │   ├── useAgentProjects.ts
│   │   │   │   │   ├── useAgentServer.ts
│   │   │   │   │   ├── useAgentSessions.ts
│   │   │   │   │   ├── useAgentTheme.ts
│   │   │   │   │   ├── useAgentThreads.ts
│   │   │   │   │   ├── useAttachments.ts
│   │   │   │   │   ├── useFakeCaret.ts
│   │   │   │   │   ├── useFloatingDrag.ts
│   │   │   │   │   ├── useOpenProjectPreference.ts
│   │   │   │   │   ├── useRRV3Debugger.ts
│   │   │   │   │   ├── useRRV3Rpc.ts
│   │   │   │   │   ├── useTextareaAutoResize.ts
│   │   │   │   │   ├── useWebEditorTxState.ts
│   │   │   │   │   └── useWorkflowsV3.ts
│   │   │   │   ├── index.html
│   │   │   │   ├── main.ts
│   │   │   │   ├── styles
│   │   │   │   │   └── agent-chat.css
│   │   │   │   └── utils
│   │   │   │       └── loading-texts.ts
│   │   │   ├── styles
│   │   │   │   └── tailwind.css
│   │   │   ├── web-editor-v2
│   │   │   │   ├── attr-ui-refactor.md
│   │   │   │   ├── constants.ts
│   │   │   │   ├── core
│   │   │   │   │   ├── css-compare.ts
│   │   │   │   │   ├── cssom-styles-collector.ts
│   │   │   │   │   ├── debug-source.ts
│   │   │   │   │   ├── design-tokens
│   │   │   │   │   │   ├── design-tokens-service.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── token-detector.ts
│   │   │   │   │   │   ├── token-resolver.ts
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── editor.ts
│   │   │   │   │   ├── element-key.ts
│   │   │   │   │   ├── event-controller.ts
│   │   │   │   │   ├── execution-tracker.ts
│   │   │   │   │   ├── hmr-consistency.ts
│   │   │   │   │   ├── locator.ts
│   │   │   │   │   ├── message-listener.ts
│   │   │   │   │   ├── payload-builder.ts
│   │   │   │   │   ├── perf-monitor.ts
│   │   │   │   │   ├── position-tracker.ts
│   │   │   │   │   ├── props-bridge.ts
│   │   │   │   │   ├── snap-engine.ts
│   │   │   │   │   ├── transaction-aggregator.ts
│   │   │   │   │   └── transaction-manager.ts
│   │   │   │   ├── drag
│   │   │   │   │   └── drag-reorder-controller.ts
│   │   │   │   ├── overlay
│   │   │   │   │   ├── canvas-overlay.ts
│   │   │   │   │   └── handles-controller.ts
│   │   │   │   ├── selection
│   │   │   │   │   └── selection-engine.ts
│   │   │   │   ├── ui
│   │   │   │   │   ├── breadcrumbs.ts
│   │   │   │   │   ├── floating-drag.ts
│   │   │   │   │   ├── icons.ts
│   │   │   │   │   ├── property-panel
│   │   │   │   │   │   ├── class-editor.ts
│   │   │   │   │   │   ├── components
│   │   │   │   │   │   │   ├── alignment-grid.ts
│   │   │   │   │   │   │   ├── icon-button-group.ts
│   │   │   │   │   │   │   ├── input-container.ts
│   │   │   │   │   │   │   ├── slider-input.ts
│   │   │   │   │   │   │   └── token-pill.ts
│   │   │   │   │   │   ├── components-tree.ts
│   │   │   │   │   │   ├── controls
│   │   │   │   │   │   │   ├── appearance-control.ts
│   │   │   │   │   │   │   ├── background-control.ts
│   │   │   │   │   │   │   ├── border-control.ts
│   │   │   │   │   │   │   ├── color-field.ts
│   │   │   │   │   │   │   ├── css-helpers.ts
│   │   │   │   │   │   │   ├── effects-control.ts
│   │   │   │   │   │   │   ├── gradient-control.ts
│   │   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   │   ├── layout-control.ts
│   │   │   │   │   │   │   ├── number-stepping.ts
│   │   │   │   │   │   │   ├── position-control.ts
│   │   │   │   │   │   │   ├── size-control.ts
│   │   │   │   │   │   │   ├── spacing-control.ts
│   │   │   │   │   │   │   ├── token-picker.ts
│   │   │   │   │   │   │   └── typography-control.ts
│   │   │   │   │   │   ├── css-defaults.ts
│   │   │   │   │   │   ├── css-panel.ts
│   │   │   │   │   │   ├── index.ts
│   │   │   │   │   │   ├── property-panel.ts
│   │   │   │   │   │   ├── props-panel.ts
│   │   │   │   │   │   └── types.ts
│   │   │   │   │   ├── shadow-host.ts
│   │   │   │   │   └── toolbar.ts
│   │   │   │   └── utils
│   │   │   │       └── disposables.ts
│   │   │   ├── web-editor-v2.ts
│   │   │   └── welcome
│   │   │       ├── App.vue
│   │   │       ├── index.html
│   │   │       └── main.ts
│   │   ├── env.d.ts
│   │   ├── eslint.config.js
│   │   ├── inject-scripts
│   │   │   ├── accessibility-tree-helper.js
│   │   │   ├── click-helper.js
│   │   │   ├── dom-observer.js
│   │   │   ├── element-marker.js
│   │   │   ├── element-picker.js
│   │   │   ├── fill-helper.js
│   │   │   ├── inject-bridge.js
│   │   │   ├── interactive-elements-helper.js
│   │   │   ├── keyboard-helper.js
│   │   │   ├── network-helper.js
│   │   │   ├── props-agent.js
│   │   │   ├── recorder.js
│   │   │   ├── screenshot-helper.js
│   │   │   ├── wait-helper.js
│   │   │   ├── web-editor.js
│   │   │   └── web-fetcher-helper.js
│   │   ├── LICENSE
│   │   ├── package.json
│   │   ├── public
│   │   │   ├── icon
│   │   │   │   ├── 128.png
│   │   │   │   ├── 16.png
│   │   │   │   ├── 32.png
│   │   │   │   ├── 48.png
│   │   │   │   └── 96.png
│   │   │   ├── libs
│   │   │   │   └── ort.min.js
│   │   │   └── wxt.svg
│   │   ├── README.md
│   │   ├── shared
│   │   │   ├── element-picker
│   │   │   │   ├── controller.ts
│   │   │   │   └── index.ts
│   │   │   ├── quick-panel
│   │   │   │   ├── core
│   │   │   │   │   ├── agent-bridge.ts
│   │   │   │   │   ├── search-engine.ts
│   │   │   │   │   └── types.ts
│   │   │   │   ├── index.ts
│   │   │   │   ├── providers
│   │   │   │   │   ├── index.ts
│   │   │   │   │   └── tabs-provider.ts
│   │   │   │   └── ui
│   │   │   │       ├── ai-chat-panel.ts
│   │   │   │       ├── index.ts
│   │   │   │       ├── markdown-renderer.ts
│   │   │   │       ├── message-renderer.ts
│   │   │   │       ├── panel-shell.ts
│   │   │   │       ├── quick-entries.ts
│   │   │   │       ├── search-input.ts
│   │   │   │       ├── shadow-host.ts
│   │   │   │       └── styles.ts
│   │   │   └── selector
│   │   │       ├── dom-path.ts
│   │   │       ├── fingerprint.ts
│   │   │       ├── generator.ts
│   │   │       ├── index.ts
│   │   │       ├── locator.ts
│   │   │       ├── shadow-dom.ts
│   │   │       ├── stability.ts
│   │   │       ├── strategies
│   │   │       │   ├── anchor-relpath.ts
│   │   │       │   ├── aria.ts
│   │   │       │   ├── css-path.ts
│   │   │       │   ├── css-unique.ts
│   │   │       │   ├── index.ts
│   │   │       │   ├── testid.ts
│   │   │       │   └── text.ts
│   │   │       └── types.ts
│   │   ├── tailwind.config.ts
│   │   ├── tests
│   │   │   ├── __mocks__
│   │   │   │   └── hnswlib-wasm-static.ts
│   │   │   ├── record-replay
│   │   │   │   ├── _test-helpers.ts
│   │   │   │   ├── adapter-policy.contract.test.ts
│   │   │   │   ├── flow-store-strip-steps.contract.test.ts
│   │   │   │   ├── high-risk-actions.integration.test.ts
│   │   │   │   ├── hybrid-actions.integration.test.ts
│   │   │   │   ├── script-control-flow.integration.test.ts
│   │   │   │   ├── session-dag-sync.contract.test.ts
│   │   │   │   ├── step-executor.contract.test.ts
│   │   │   │   └── tab-cursor.integration.test.ts
│   │   │   ├── record-replay-v3
│   │   │   │   ├── command-trigger.test.ts
│   │   │   │   ├── context-menu-trigger.test.ts
│   │   │   │   ├── cron-trigger.test.ts
│   │   │   │   ├── debugger.contract.test.ts
│   │   │   │   ├── dom-trigger.test.ts
│   │   │   │   ├── e2e.integration.test.ts
│   │   │   │   ├── events.contract.test.ts
│   │   │   │   ├── interval-trigger.test.ts
│   │   │   │   ├── manual-trigger.test.ts
│   │   │   │   ├── once-trigger.test.ts
│   │   │   │   ├── queue.contract.test.ts
│   │   │   │   ├── recovery.test.ts
│   │   │   │   ├── rpc-api.test.ts
│   │   │   │   ├── runner.onError.contract.test.ts
│   │   │   │   ├── scheduler-integration.test.ts
│   │   │   │   ├── scheduler.test.ts
│   │   │   │   ├── spec-smoke.test.ts
│   │   │   │   ├── trigger-manager.test.ts
│   │   │   │   ├── triggers.test.ts
│   │   │   │   ├── url-trigger.test.ts
│   │   │   │   ├── v2-action-adapter.test.ts
│   │   │   │   ├── v2-adapter-integration.test.ts
│   │   │   │   ├── v2-to-v3-conversion.test.ts
│   │   │   │   └── v3-e2e-harness.ts
│   │   │   ├── vitest.setup.ts
│   │   │   └── web-editor-v2
│   │   │       ├── design-tokens.test.ts
│   │   │       ├── drag-reorder-controller.test.ts
│   │   │       ├── event-controller.test.ts
│   │   │       ├── locator.test.ts
│   │   │       ├── property-panel-live-sync.test.ts
│   │   │       ├── selection-engine.test.ts
│   │   │       ├── snap-engine.test.ts
│   │   │       └── test-utils
│   │   │           └── dom.ts
│   │   ├── tsconfig.json
│   │   ├── types
│   │   │   ├── gifenc.d.ts
│   │   │   └── icons.d.ts
│   │   ├── utils
│   │   │   ├── cdp-session-manager.ts
│   │   │   ├── content-indexer.ts
│   │   │   ├── i18n.ts
│   │   │   ├── image-utils.ts
│   │   │   ├── indexeddb-client.ts
│   │   │   ├── lru-cache.ts
│   │   │   ├── model-cache-manager.ts
│   │   │   ├── offscreen-manager.ts
│   │   │   ├── output-sanitizer.ts
│   │   │   ├── screenshot-context.ts
│   │   │   ├── semantic-similarity-engine.ts
│   │   │   ├── simd-math-engine.ts
│   │   │   ├── text-chunker.ts
│   │   │   └── vector-database.ts
│   │   ├── vitest.config.ts
│   │   ├── workers
│   │   │   ├── ort-wasm-simd-threaded.jsep.mjs
│   │   │   ├── ort-wasm-simd-threaded.jsep.wasm
│   │   │   ├── ort-wasm-simd-threaded.mjs
│   │   │   ├── ort-wasm-simd-threaded.wasm
│   │   │   ├── simd_math_bg.wasm
│   │   │   ├── simd_math.js
│   │   │   └── similarity.worker.js
│   │   └── wxt.config.ts
│   └── native-server
│       ├── .npmignore
│       ├── debug.sh
│       ├── install.md
│       ├── jest.config.js
│       ├── package.json
│       ├── README.md
│       ├── src
│       │   ├── agent
│       │   │   ├── attachment-service.ts
│       │   │   ├── ccr-detector.ts
│       │   │   ├── chat-service.ts
│       │   │   ├── db
│       │   │   │   ├── client.ts
│       │   │   │   ├── index.ts
│       │   │   │   └── schema.ts
│       │   │   ├── directory-picker.ts
│       │   │   ├── engines
│       │   │   │   ├── claude.ts
│       │   │   │   ├── codex.ts
│       │   │   │   └── types.ts
│       │   │   ├── message-service.ts
│       │   │   ├── open-project.ts
│       │   │   ├── project-service.ts
│       │   │   ├── project-types.ts
│       │   │   ├── session-service.ts
│       │   │   ├── storage.ts
│       │   │   ├── stream-manager.ts
│       │   │   ├── tool-bridge.ts
│       │   │   └── types.ts
│       │   ├── cli.ts
│       │   ├── constant
│       │   │   └── index.ts
│       │   ├── file-handler.ts
│       │   ├── index.ts
│       │   ├── mcp
│       │   │   ├── mcp-server-stdio.ts
│       │   │   ├── mcp-server.ts
│       │   │   ├── register-tools.ts
│       │   │   └── stdio-config.json
│       │   ├── native-messaging-host.ts
│       │   ├── scripts
│       │   │   ├── browser-config.ts
│       │   │   ├── build.ts
│       │   │   ├── constant.ts
│       │   │   ├── doctor.ts
│       │   │   ├── postinstall.ts
│       │   │   ├── register-dev.ts
│       │   │   ├── register.ts
│       │   │   ├── report.ts
│       │   │   ├── run_host.bat
│       │   │   ├── run_host.sh
│       │   │   └── utils.ts
│       │   ├── server
│       │   │   ├── index.ts
│       │   │   ├── routes
│       │   │   │   ├── agent.ts
│       │   │   │   └── index.ts
│       │   │   └── server.test.ts
│       │   ├── shims
│       │   │   └── devtools.d.ts
│       │   ├── trace-analyzer.ts
│       │   ├── types
│       │   │   └── devtools-frontend.d.ts
│       │   └── util
│       │       └── logger.ts
│       └── tsconfig.json
├── commitlint.config.cjs
├── docs
│   ├── ARCHITECTURE_zh.md
│   ├── ARCHITECTURE.md
│   ├── CHANGELOG.md
│   ├── CONTRIBUTING_zh.md
│   ├── CONTRIBUTING.md
│   ├── ISSUE.md
│   ├── mcp-cli-config.md
│   ├── TOOLS_zh.md
│   ├── TOOLS.md
│   ├── TROUBLESHOOTING_zh.md
│   ├── TROUBLESHOOTING.md
│   ├── VisualEditor_zh.md
│   ├── VisualEditor.md
│   └── WINDOWS_INSTALL_zh.md
├── eslint.config.js
├── LICENSE
├── package.json
├── packages
│   ├── shared
│   │   ├── package.json
│   │   ├── src
│   │   │   ├── agent-types.ts
│   │   │   ├── constants.ts
│   │   │   ├── index.ts
│   │   │   ├── labels.ts
│   │   │   ├── node-spec-registry.ts
│   │   │   ├── node-spec.ts
│   │   │   ├── node-specs-builtin.ts
│   │   │   ├── rr-graph.ts
│   │   │   ├── step-types.ts
│   │   │   ├── tools.ts
│   │   │   └── types.ts
│   │   └── tsconfig.json
│   └── wasm-simd
│       ├── .gitignore
│       ├── BUILD.md
│       ├── Cargo.toml
│       ├── package.json
│       ├── README.md
│       └── src
│           └── lib.rs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prompt
│   ├── content-analize.md
│   ├── excalidraw-prompt.md
│   └── modify-web.md
├── README_zh.md
├── README.md
└── releases
    ├── chrome-extension
    │   └── latest
    │       └── chrome-mcp-server-lastest.zip
    └── README.md
```

# Files

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/layout-control.ts:
--------------------------------------------------------------------------------

```typescript
   1 | /**
   2 |  * Layout Control (Phase 3.4 + 4.1/4.2 - Refactored)
   3 |  *
   4 |  * Edits inline layout styles:
   5 |  * - display (icon button group): block/inline/inline-block/flex/grid/none
   6 |  * - flex-direction (icon button group, shown when display=flex)
   7 |  * - justify-content + align-items (content bars, shown when display=flex)
   8 |  * - grid-template-columns/rows (dimensions picker, shown when display=grid)
   9 |  * - flex-wrap (select, shown when display=flex)
  10 |  * - gap (input, shown when display=flex/grid)
  11 |  */
  12 | 
  13 | import { Disposer } from '../../../utils/disposables';
  14 | import type {
  15 |   MultiStyleTransactionHandle,
  16 |   StyleTransactionHandle,
  17 |   TransactionManager,
  18 | } from '../../../core/transaction-manager';
  19 | import type { DesignControl } from '../types';
  20 | import { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group';
  21 | import { createInputContainer, type InputContainer } from '../components/input-container';
  22 | import { combineLengthValue, formatLengthForDisplay } from './css-helpers';
  23 | import { wireNumberStepping } from './number-stepping';
  24 | 
  25 | // =============================================================================
  26 | // Constants
  27 | // =============================================================================
  28 | 
  29 | const SVG_NS = 'http://www.w3.org/2000/svg';
  30 | 
  31 | const DISPLAY_VALUES = ['block', 'inline', 'inline-block', 'flex', 'grid', 'none'] as const;
  32 | const FLEX_DIRECTION_VALUES = ['row', 'column', 'row-reverse', 'column-reverse'] as const;
  33 | const FLEX_WRAP_VALUES = ['nowrap', 'wrap', 'wrap-reverse'] as const;
  34 | const ALIGNMENT_AXIS_VALUES = ['flex-start', 'center', 'flex-end'] as const;
  35 | const GRID_DIMENSION_MAX = 12;
  36 | 
  37 | type DisplayValue = (typeof DISPLAY_VALUES)[number];
  38 | type FlexDirectionValue = (typeof FLEX_DIRECTION_VALUES)[number];
  39 | type AlignmentAxisValue = (typeof ALIGNMENT_AXIS_VALUES)[number];
  40 | 
  41 | /** Single-property field keys */
  42 | type LayoutProperty = 'display' | 'flex-direction' | 'flex-wrap' | 'row-gap' | 'column-gap';
  43 | 
  44 | /** All field keys including composite fields */
  45 | type FieldKey = LayoutProperty | 'alignment' | 'grid-dimensions';
  46 | 
  47 | // =============================================================================
  48 | // Field State Types
  49 | // =============================================================================
  50 | 
  51 | interface DisplayFieldState {
  52 |   kind: 'display-group';
  53 |   property: 'display';
  54 |   group: IconButtonGroup<DisplayValue>;
  55 |   handle: StyleTransactionHandle | null;
  56 |   row: HTMLElement;
  57 | }
  58 | 
  59 | interface FlexDirectionFieldState {
  60 |   kind: 'flex-direction-group';
  61 |   property: 'flex-direction';
  62 |   group: IconButtonGroup<FlexDirectionValue>;
  63 |   handle: StyleTransactionHandle | null;
  64 |   row: HTMLElement;
  65 | }
  66 | 
  67 | interface SelectFieldState {
  68 |   kind: 'select';
  69 |   property: 'flex-wrap';
  70 |   element: HTMLSelectElement;
  71 |   handle: StyleTransactionHandle | null;
  72 |   row: HTMLElement;
  73 | }
  74 | 
  75 | interface InputFieldState {
  76 |   kind: 'input';
  77 |   property: 'row-gap' | 'column-gap';
  78 |   element: HTMLInputElement;
  79 |   container: InputContainer;
  80 |   handle: StyleTransactionHandle | null;
  81 |   row: HTMLElement;
  82 | }
  83 | 
  84 | interface FlexAlignmentFieldState {
  85 |   kind: 'flex-alignment';
  86 |   properties: readonly ['justify-content', 'align-items'];
  87 |   justifyGroup: IconButtonGroup<AlignmentAxisValue>;
  88 |   alignGroup: IconButtonGroup<AlignmentAxisValue>;
  89 |   handle: MultiStyleTransactionHandle | null;
  90 |   row: HTMLElement;
  91 | }
  92 | 
  93 | interface GridDimensionsFieldState {
  94 |   kind: 'grid-dimensions';
  95 |   properties: readonly ['grid-template-columns', 'grid-template-rows'];
  96 |   previewButton: HTMLButtonElement;
  97 |   previewColsValue: HTMLSpanElement;
  98 |   previewRowsValue: HTMLSpanElement;
  99 |   popover: HTMLDivElement;
 100 |   colsContainer: InputContainer;
 101 |   rowsContainer: InputContainer;
 102 |   matrix: HTMLDivElement;
 103 |   tooltip: HTMLDivElement;
 104 |   cells: HTMLButtonElement[];
 105 |   handle: MultiStyleTransactionHandle | null;
 106 |   row: HTMLElement;
 107 | }
 108 | 
 109 | type FieldState =
 110 |   | DisplayFieldState
 111 |   | FlexDirectionFieldState
 112 |   | SelectFieldState
 113 |   | InputFieldState
 114 |   | FlexAlignmentFieldState
 115 |   | GridDimensionsFieldState;
 116 | 
 117 | // =============================================================================
 118 | // Helpers
 119 | // =============================================================================
 120 | 
 121 | function isFieldFocused(el: HTMLElement): boolean {
 122 |   try {
 123 |     const rootNode = el.getRootNode();
 124 |     if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;
 125 |     return document.activeElement === el;
 126 |   } catch {
 127 |     return false;
 128 |   }
 129 | }
 130 | 
 131 | function readInlineValue(element: Element, property: string): string {
 132 |   try {
 133 |     const style = (element as HTMLElement).style;
 134 |     return style?.getPropertyValue?.(property)?.trim() ?? '';
 135 |   } catch {
 136 |     return '';
 137 |   }
 138 | }
 139 | 
 140 | function readComputedValue(element: Element, property: string): string {
 141 |   try {
 142 |     return window.getComputedStyle(element).getPropertyValue(property).trim();
 143 |   } catch {
 144 |     return '';
 145 |   }
 146 | }
 147 | 
 148 | function isDisplayValue(value: string): value is DisplayValue {
 149 |   return (DISPLAY_VALUES as readonly string[]).includes(value);
 150 | }
 151 | 
 152 | function isFlexDirectionValue(value: string): value is FlexDirectionValue {
 153 |   return (FLEX_DIRECTION_VALUES as readonly string[]).includes(value);
 154 | }
 155 | 
 156 | function isAlignmentAxisValue(value: string): value is AlignmentAxisValue {
 157 |   return (ALIGNMENT_AXIS_VALUES as readonly string[]).includes(value);
 158 | }
 159 | 
 160 | /**
 161 |  * Map computed display values to the closest option value.
 162 |  */
 163 | function normalizeDisplayValue(computed: string): string {
 164 |   const trimmed = computed.trim();
 165 |   if (trimmed === 'inline-flex') return 'flex';
 166 |   if (trimmed === 'inline-grid') return 'grid';
 167 |   return trimmed;
 168 | }
 169 | 
 170 | function clampInt(value: number, min: number, max: number): number {
 171 |   if (!Number.isFinite(value)) return min;
 172 |   return Math.min(max, Math.max(min, Math.trunc(value)));
 173 | }
 174 | 
 175 | /**
 176 |  * Split CSS value into top-level tokens (respects parentheses depth).
 177 |  */
 178 | function splitTopLevelTokens(value: string): string[] {
 179 |   const tokens: string[] = [];
 180 |   let depth = 0;
 181 |   let current = '';
 182 | 
 183 |   for (let i = 0; i < value.length; i++) {
 184 |     const ch = value[i]!;
 185 |     if (ch === '(') depth++;
 186 |     if (ch === ')' && depth > 0) depth--;
 187 | 
 188 |     if (depth === 0 && /\s/.test(ch)) {
 189 |       const t = current.trim();
 190 |       if (t) tokens.push(t);
 191 |       current = '';
 192 |       continue;
 193 |     }
 194 |     current += ch;
 195 |   }
 196 | 
 197 |   const tail = current.trim();
 198 |   if (tail) tokens.push(tail);
 199 |   return tokens;
 200 | }
 201 | 
 202 | function parseRepeatCount(token: string): number | null {
 203 |   const match = token.match(/^repeat\(\s*(\d+)\s*,/i);
 204 |   if (!match) return null;
 205 |   const n = parseInt(match[1]!, 10);
 206 |   return Number.isFinite(n) && n > 0 ? n : null;
 207 | }
 208 | 
 209 | /**
 210 |  * Count grid tracks from grid-template-columns/rows value.
 211 |  */
 212 | function countGridTracks(raw: string): number | null {
 213 |   const trimmed = raw.trim();
 214 |   if (!trimmed || trimmed === 'none') return null;
 215 | 
 216 |   const tokens = splitTopLevelTokens(trimmed);
 217 |   let count = 0;
 218 | 
 219 |   for (const t of tokens) {
 220 |     // Ignore line-name tokens like [col-start]
 221 |     if (/^\[.*\]$/.test(t)) continue;
 222 |     count += parseRepeatCount(t) ?? 1;
 223 |   }
 224 | 
 225 |   return count > 0 ? count : null;
 226 | }
 227 | 
 228 | function formatGridTemplate(count: number): string {
 229 |   const n = clampInt(count, 1, GRID_DIMENSION_MAX);
 230 |   return n === 1 ? '1fr' : `repeat(${n}, 1fr)`;
 231 | }
 232 | 
 233 | // =============================================================================
 234 | // SVG Icon Helpers
 235 | // =============================================================================
 236 | 
 237 | function createBaseIconSvg(): SVGSVGElement {
 238 |   const svg = document.createElementNS(SVG_NS, 'svg');
 239 |   svg.setAttribute('viewBox', '0 0 15 15');
 240 |   svg.setAttribute('fill', 'none');
 241 |   svg.setAttribute('aria-hidden', 'true');
 242 |   svg.setAttribute('focusable', 'false');
 243 |   return svg;
 244 | }
 245 | 
 246 | function applyStroke(el: SVGElement, strokeWidth = '1.2'): void {
 247 |   el.setAttribute('stroke', 'currentColor');
 248 |   el.setAttribute('stroke-width', strokeWidth);
 249 |   el.setAttribute('stroke-linecap', 'round');
 250 |   el.setAttribute('stroke-linejoin', 'round');
 251 | }
 252 | 
 253 | function createDisplayIcon(value: DisplayValue): SVGElement {
 254 |   const svg = createBaseIconSvg();
 255 | 
 256 |   // 容器边框(虚线矩形表示容器)
 257 |   const container = document.createElementNS(SVG_NS, 'rect');
 258 |   container.setAttribute('x', '2');
 259 |   container.setAttribute('y', '2');
 260 |   container.setAttribute('width', '11');
 261 |   container.setAttribute('height', '11');
 262 |   container.setAttribute('rx', '1.5');
 263 |   container.setAttribute('stroke', 'currentColor');
 264 |   container.setAttribute('stroke-width', '1');
 265 |   container.setAttribute('stroke-dasharray', '2 1');
 266 |   container.setAttribute('fill', 'none');
 267 |   container.setAttribute('opacity', '0.5');
 268 | 
 269 |   const addBlock = (x: number, y: number, w: number, h: number) => {
 270 |     const rect = document.createElementNS(SVG_NS, 'rect');
 271 |     rect.setAttribute('x', String(x));
 272 |     rect.setAttribute('y', String(y));
 273 |     rect.setAttribute('width', String(w));
 274 |     rect.setAttribute('height', String(h));
 275 |     rect.setAttribute('rx', '0.5');
 276 |     rect.setAttribute('fill', 'currentColor');
 277 |     svg.append(rect);
 278 |   };
 279 | 
 280 |   const addLine = (x: number, y: number, w: number) => {
 281 |     const line = document.createElementNS(SVG_NS, 'rect');
 282 |     line.setAttribute('x', String(x));
 283 |     line.setAttribute('y', String(y));
 284 |     line.setAttribute('width', String(w));
 285 |     line.setAttribute('height', '1');
 286 |     line.setAttribute('rx', '0.5');
 287 |     line.setAttribute('fill', 'currentColor');
 288 |     svg.append(line);
 289 |   };
 290 | 
 291 |   switch (value) {
 292 |     case 'block':
 293 |       // 两个全宽的块级元素,垂直堆叠
 294 |       addBlock(3.5, 3.5, 8, 3);
 295 |       addBlock(3.5, 8.5, 8, 3);
 296 |       break;
 297 |     case 'inline':
 298 |       // 三行文本表示内联流
 299 |       addLine(3.5, 4.5, 8);
 300 |       addLine(3.5, 7.5, 5);
 301 |       addLine(3.5, 10.5, 6.5);
 302 |       break;
 303 |     case 'inline-block':
 304 |       // 左边一个块,右边两行文本
 305 |       addBlock(3.5, 4.5, 3.5, 6);
 306 |       addLine(8, 5.5, 4);
 307 |       addLine(8, 8.5, 3);
 308 |       break;
 309 |     case 'flex':
 310 |       // 三个水平排列的弹性子项
 311 |       addBlock(3.5, 4.5, 2.5, 6);
 312 |       addBlock(6.5, 4.5, 2.5, 6);
 313 |       addBlock(9.5, 4.5, 2.5, 6);
 314 |       break;
 315 |     case 'grid':
 316 |       // 2x2 网格布局
 317 |       addBlock(3.5, 3.5, 3.5, 3.5);
 318 |       addBlock(8, 3.5, 3.5, 3.5);
 319 |       addBlock(3.5, 8, 3.5, 3.5);
 320 |       addBlock(8, 8, 3.5, 3.5);
 321 |       break;
 322 |     case 'none': {
 323 |       // 禁用符号:斜线
 324 |       const slash = document.createElementNS(SVG_NS, 'path');
 325 |       slash.setAttribute('d', 'M4 11L11 4');
 326 |       slash.setAttribute('stroke', 'currentColor');
 327 |       slash.setAttribute('stroke-width', '1.5');
 328 |       slash.setAttribute('stroke-linecap', 'round');
 329 |       svg.append(slash);
 330 |       break;
 331 |     }
 332 |   }
 333 | 
 334 |   svg.prepend(container);
 335 |   return svg;
 336 | }
 337 | 
 338 | function createFlowIcon(direction: FlexDirectionValue): SVGElement {
 339 |   const svg = createBaseIconSvg();
 340 |   const path = document.createElementNS(SVG_NS, 'path');
 341 |   applyStroke(path, '1.5');
 342 | 
 343 |   const DIRECTION_PATHS: Record<FlexDirectionValue, string> = {
 344 |     row: 'M2 7.5H13M10 4.5L13 7.5L10 10.5',
 345 |     'row-reverse': 'M13 7.5H2M5 4.5L2 7.5L5 10.5',
 346 |     column: 'M7.5 2V13M4.5 10L7.5 13L10.5 10',
 347 |     'column-reverse': 'M7.5 13V2M4.5 5L7.5 2L10.5 5',
 348 |   };
 349 | 
 350 |   path.setAttribute('d', DIRECTION_PATHS[direction]);
 351 |   svg.append(path);
 352 |   return svg;
 353 | }
 354 | 
 355 | function createHorizontalAlignIcon(value: AlignmentAxisValue): SVGElement {
 356 |   const svg = createBaseIconSvg();
 357 | 
 358 |   // 容器边框(虚线矩形表示容器)
 359 |   const container = document.createElementNS(SVG_NS, 'rect');
 360 |   container.setAttribute('x', '2');
 361 |   container.setAttribute('y', '2');
 362 |   container.setAttribute('width', '11');
 363 |   container.setAttribute('height', '11');
 364 |   container.setAttribute('rx', '1.5');
 365 |   container.setAttribute('stroke', 'currentColor');
 366 |   container.setAttribute('stroke-width', '1');
 367 |   container.setAttribute('stroke-dasharray', '2 1');
 368 |   container.setAttribute('fill', 'none');
 369 |   container.setAttribute('opacity', '0.5');
 370 | 
 371 |   // 内容块的 X 坐标根据对齐方式不同
 372 |   const blockX: Record<AlignmentAxisValue, number> = {
 373 |     'flex-start': 3.5, // 左对齐
 374 |     center: 5.5, // 居中对齐
 375 |     'flex-end': 7.5, // 右对齐
 376 |   };
 377 | 
 378 |   // 两个小方块表示子元素(水平方向排列变为垂直方向排列)
 379 |   const block1 = document.createElementNS(SVG_NS, 'rect');
 380 |   block1.setAttribute('x', String(blockX[value]));
 381 |   block1.setAttribute('y', '4');
 382 |   block1.setAttribute('width', '4');
 383 |   block1.setAttribute('height', '3');
 384 |   block1.setAttribute('rx', '0.5');
 385 |   block1.setAttribute('fill', 'currentColor');
 386 | 
 387 |   const block2 = document.createElementNS(SVG_NS, 'rect');
 388 |   block2.setAttribute('x', String(blockX[value]));
 389 |   block2.setAttribute('y', '8');
 390 |   block2.setAttribute('width', '4');
 391 |   block2.setAttribute('height', '3');
 392 |   block2.setAttribute('rx', '0.5');
 393 |   block2.setAttribute('fill', 'currentColor');
 394 | 
 395 |   svg.append(container, block1, block2);
 396 |   return svg;
 397 | }
 398 | 
 399 | function createVerticalAlignIcon(value: AlignmentAxisValue): SVGElement {
 400 |   const svg = createBaseIconSvg();
 401 | 
 402 |   // 容器边框(虚线矩形表示容器)
 403 |   const container = document.createElementNS(SVG_NS, 'rect');
 404 |   container.setAttribute('x', '2');
 405 |   container.setAttribute('y', '2');
 406 |   container.setAttribute('width', '11');
 407 |   container.setAttribute('height', '11');
 408 |   container.setAttribute('rx', '1.5');
 409 |   container.setAttribute('stroke', 'currentColor');
 410 |   container.setAttribute('stroke-width', '1');
 411 |   container.setAttribute('stroke-dasharray', '2 1');
 412 |   container.setAttribute('fill', 'none');
 413 |   container.setAttribute('opacity', '0.5');
 414 | 
 415 |   // 内容块的 Y 坐标根据对齐方式不同
 416 |   const blockY: Record<AlignmentAxisValue, number> = {
 417 |     'flex-start': 3.5, // 顶部对齐
 418 |     center: 5.5, // 居中对齐
 419 |     'flex-end': 7.5, // 底部对齐
 420 |   };
 421 | 
 422 |   // 两个小方块表示子元素
 423 |   const block1 = document.createElementNS(SVG_NS, 'rect');
 424 |   block1.setAttribute('x', '4');
 425 |   block1.setAttribute('y', String(blockY[value]));
 426 |   block1.setAttribute('width', '3');
 427 |   block1.setAttribute('height', '4');
 428 |   block1.setAttribute('rx', '0.5');
 429 |   block1.setAttribute('fill', 'currentColor');
 430 | 
 431 |   const block2 = document.createElementNS(SVG_NS, 'rect');
 432 |   block2.setAttribute('x', '8');
 433 |   block2.setAttribute('y', String(blockY[value]));
 434 |   block2.setAttribute('width', '3');
 435 |   block2.setAttribute('height', '4');
 436 |   block2.setAttribute('rx', '0.5');
 437 |   block2.setAttribute('fill', 'currentColor');
 438 | 
 439 |   svg.append(container, block1, block2);
 440 |   return svg;
 441 | }
 442 | 
 443 | function createGapIcon(): SVGElement {
 444 |   const svg = createBaseIconSvg();
 445 |   const path = document.createElementNS(SVG_NS, 'path');
 446 |   path.setAttribute('stroke', 'currentColor');
 447 |   path.setAttribute('d', 'M1.5 4.5H13.5M1.5 10.5H13.5');
 448 |   svg.append(path);
 449 |   return svg;
 450 | }
 451 | 
 452 | function createGridColumnsIcon(): SVGElement {
 453 |   const svg = createBaseIconSvg();
 454 | 
 455 |   const r1 = document.createElementNS(SVG_NS, 'rect');
 456 |   r1.setAttribute('x', '3');
 457 |   r1.setAttribute('y', '4');
 458 |   r1.setAttribute('width', '3.5');
 459 |   r1.setAttribute('height', '7');
 460 |   r1.setAttribute('rx', '1');
 461 |   applyStroke(r1);
 462 | 
 463 |   const r2 = document.createElementNS(SVG_NS, 'rect');
 464 |   r2.setAttribute('x', '8.5');
 465 |   r2.setAttribute('y', '4');
 466 |   r2.setAttribute('width', '3.5');
 467 |   r2.setAttribute('height', '7');
 468 |   r2.setAttribute('rx', '1');
 469 |   applyStroke(r2);
 470 | 
 471 |   svg.append(r1, r2);
 472 |   return svg;
 473 | }
 474 | 
 475 | function createGridRowsIcon(): SVGElement {
 476 |   const svg = createBaseIconSvg();
 477 | 
 478 |   const r1 = document.createElementNS(SVG_NS, 'rect');
 479 |   r1.setAttribute('x', '4');
 480 |   r1.setAttribute('y', '3');
 481 |   r1.setAttribute('width', '7');
 482 |   r1.setAttribute('height', '3.5');
 483 |   r1.setAttribute('rx', '1');
 484 |   applyStroke(r1);
 485 | 
 486 |   const r2 = document.createElementNS(SVG_NS, 'rect');
 487 |   r2.setAttribute('x', '4');
 488 |   r2.setAttribute('y', '8.5');
 489 |   r2.setAttribute('width', '7');
 490 |   r2.setAttribute('height', '3.5');
 491 |   r2.setAttribute('rx', '1');
 492 |   applyStroke(r2);
 493 | 
 494 |   svg.append(r1, r2);
 495 |   return svg;
 496 | }
 497 | 
 498 | // =============================================================================
 499 | // Factory
 500 | // =============================================================================
 501 | 
 502 | export interface LayoutControlOptions {
 503 |   container: HTMLElement;
 504 |   transactionManager: TransactionManager;
 505 | }
 506 | 
 507 | export function createLayoutControl(options: LayoutControlOptions): DesignControl {
 508 |   const { container, transactionManager } = options;
 509 |   const disposer = new Disposer();
 510 | 
 511 |   let currentTarget: Element | null = null;
 512 | 
 513 |   const root = document.createElement('div');
 514 |   root.className = 'we-field-group';
 515 | 
 516 |   // ---------------------------------------------------------------------------
 517 |   // Display row (icon button group)
 518 |   // ---------------------------------------------------------------------------
 519 |   const displayRow = document.createElement('div');
 520 |   displayRow.className = 'we-field';
 521 | 
 522 |   const displayLabel = document.createElement('span');
 523 |   displayLabel.className = 'we-field-label';
 524 |   displayLabel.textContent = 'Display';
 525 | 
 526 |   const displayMount = document.createElement('div');
 527 |   displayMount.className = 'we-field-content';
 528 | 
 529 |   displayRow.append(displayLabel, displayMount);
 530 | 
 531 |   const displayGroup = createIconButtonGroup<DisplayValue>({
 532 |     container: displayMount,
 533 |     ariaLabel: 'Display',
 534 |     columns: 6,
 535 |     items: DISPLAY_VALUES.map((v) => ({
 536 |       value: v,
 537 |       ariaLabel: v,
 538 |       title: v,
 539 |       icon: createDisplayIcon(v),
 540 |     })),
 541 |     onChange: (value) => {
 542 |       const handle = beginTransaction('display');
 543 |       if (handle) handle.set(value);
 544 |       commitTransaction('display');
 545 |       syncAllFields();
 546 |     },
 547 |   });
 548 |   disposer.add(() => displayGroup.dispose());
 549 | 
 550 |   // ---------------------------------------------------------------------------
 551 |   // Flex direction row (icon button group)
 552 |   // ---------------------------------------------------------------------------
 553 |   const directionRow = document.createElement('div');
 554 |   directionRow.className = 'we-field';
 555 | 
 556 |   const directionLabel = document.createElement('span');
 557 |   directionLabel.className = 'we-field-label';
 558 |   directionLabel.textContent = 'Flow';
 559 | 
 560 |   const directionMount = document.createElement('div');
 561 |   directionMount.className = 'we-field-content';
 562 | 
 563 |   directionRow.append(directionLabel, directionMount);
 564 | 
 565 |   const directionGroup = createIconButtonGroup<FlexDirectionValue>({
 566 |     container: directionMount,
 567 |     ariaLabel: 'Flex direction',
 568 |     columns: 4,
 569 |     items: FLEX_DIRECTION_VALUES.map((dir) => ({
 570 |       value: dir,
 571 |       ariaLabel: dir.replace('-', ' '),
 572 |       title: dir.replace('-', ' '),
 573 |       icon: createFlowIcon(dir),
 574 |     })),
 575 |     onChange: (value) => {
 576 |       const handle = beginTransaction('flex-direction');
 577 |       if (handle) handle.set(value);
 578 |       commitTransaction('flex-direction');
 579 |       syncAllFields();
 580 |     },
 581 |   });
 582 |   disposer.add(() => directionGroup.dispose());
 583 |   directionGroup.setValue(null);
 584 | 
 585 |   // ---------------------------------------------------------------------------
 586 |   // Flex wrap row (select)
 587 |   // ---------------------------------------------------------------------------
 588 |   const wrapRow = document.createElement('div');
 589 |   wrapRow.className = 'we-field';
 590 |   const wrapLabel = document.createElement('span');
 591 |   wrapLabel.className = 'we-field-label';
 592 |   wrapLabel.textContent = 'Wrap';
 593 |   const wrapSelect = document.createElement('select');
 594 |   wrapSelect.className = 'we-select';
 595 |   wrapSelect.setAttribute('aria-label', 'flex-wrap');
 596 |   for (const v of FLEX_WRAP_VALUES) {
 597 |     const opt = document.createElement('option');
 598 |     opt.value = v;
 599 |     opt.textContent = v;
 600 |     wrapSelect.append(opt);
 601 |   }
 602 |   wrapRow.append(wrapLabel, wrapSelect);
 603 | 
 604 |   // ---------------------------------------------------------------------------
 605 |   // Alignment row (content bars for justify-content + align-items)
 606 |   // ---------------------------------------------------------------------------
 607 |   const alignmentRow = document.createElement('div');
 608 |   alignmentRow.className = 'we-field';
 609 | 
 610 |   const alignmentLabel = document.createElement('span');
 611 |   alignmentLabel.className = 'we-field-label';
 612 |   alignmentLabel.textContent = 'Align';
 613 | 
 614 |   const alignmentMount = document.createElement('div');
 615 |   alignmentMount.className = 'we-field-content';
 616 |   alignmentMount.style.display = 'flex';
 617 |   alignmentMount.style.gap = '4px';
 618 | 
 619 |   alignmentRow.append(alignmentLabel, alignmentMount);
 620 | 
 621 |   // Justify group with H label
 622 |   const justifyWrapper = document.createElement('div');
 623 |   justifyWrapper.style.flex = '1';
 624 |   justifyWrapper.style.minWidth = '0';
 625 |   justifyWrapper.style.display = 'flex';
 626 |   justifyWrapper.style.flexDirection = 'column';
 627 |   justifyWrapper.style.gap = '2px';
 628 | 
 629 |   const justifyHint = document.createElement('span');
 630 |   justifyHint.className = 'we-field-hint';
 631 |   justifyHint.textContent = 'H';
 632 | 
 633 |   const justifyMount = document.createElement('div');
 634 | 
 635 |   justifyWrapper.append(justifyHint, justifyMount);
 636 | 
 637 |   // Align group with V label
 638 |   const alignWrapper = document.createElement('div');
 639 |   alignWrapper.style.flex = '1';
 640 |   alignWrapper.style.minWidth = '0';
 641 |   alignWrapper.style.display = 'flex';
 642 |   alignWrapper.style.flexDirection = 'column';
 643 |   alignWrapper.style.gap = '2px';
 644 | 
 645 |   const alignHint = document.createElement('span');
 646 |   alignHint.className = 'we-field-hint';
 647 |   alignHint.textContent = 'V';
 648 | 
 649 |   const alignMount = document.createElement('div');
 650 | 
 651 |   alignWrapper.append(alignHint, alignMount);
 652 | 
 653 |   alignmentMount.append(justifyWrapper, alignWrapper);
 654 | 
 655 |   const justifyGroup = createIconButtonGroup<AlignmentAxisValue>({
 656 |     container: justifyMount,
 657 |     ariaLabel: 'Justify content',
 658 |     columns: 3,
 659 |     items: ALIGNMENT_AXIS_VALUES.map((v) => ({
 660 |       value: v,
 661 |       ariaLabel: `justify-content: ${v}`,
 662 |       title: v,
 663 |       icon: createHorizontalAlignIcon(v),
 664 |     })),
 665 |     onChange: (justifyContent) => {
 666 |       const handle = beginAlignmentTransaction();
 667 |       if (!handle) return;
 668 |       const alignItems = alignGroup.getValue() ?? 'center';
 669 |       handle.set({ 'justify-content': justifyContent, 'align-items': alignItems });
 670 |       commitAlignmentTransaction();
 671 |       syncAllFields();
 672 |     },
 673 |   });
 674 | 
 675 |   const alignGroup = createIconButtonGroup<AlignmentAxisValue>({
 676 |     container: alignMount,
 677 |     ariaLabel: 'Align items',
 678 |     columns: 3,
 679 |     items: ALIGNMENT_AXIS_VALUES.map((v) => ({
 680 |       value: v,
 681 |       ariaLabel: `align-items: ${v}`,
 682 |       title: v,
 683 |       icon: createVerticalAlignIcon(v),
 684 |     })),
 685 |     onChange: (alignItems) => {
 686 |       const handle = beginAlignmentTransaction();
 687 |       if (!handle) return;
 688 |       const justifyContent = justifyGroup.getValue() ?? 'center';
 689 |       handle.set({ 'justify-content': justifyContent, 'align-items': alignItems });
 690 |       commitAlignmentTransaction();
 691 |       syncAllFields();
 692 |     },
 693 |   });
 694 | 
 695 |   disposer.add(() => justifyGroup.dispose());
 696 |   disposer.add(() => alignGroup.dispose());
 697 |   justifyGroup.setValue(null);
 698 |   alignGroup.setValue(null);
 699 | 
 700 |   // ---------------------------------------------------------------------------
 701 |   // Grid dimensions row (grid-template-columns/rows)
 702 |   // ---------------------------------------------------------------------------
 703 |   const gridRow = document.createElement('div');
 704 |   gridRow.className = 'we-field';
 705 | 
 706 |   const gridLabel = document.createElement('span');
 707 |   gridLabel.className = 'we-field-label';
 708 |   gridLabel.textContent = 'Grid';
 709 | 
 710 |   const gridMount = document.createElement('div');
 711 |   gridMount.className = 'we-field-content';
 712 |   gridMount.style.position = 'relative';
 713 | 
 714 |   gridRow.append(gridLabel, gridMount);
 715 | 
 716 |   const gridPreviewButton = document.createElement('button');
 717 |   gridPreviewButton.type = 'button';
 718 |   gridPreviewButton.className = 'we-grid-dimensions-preview';
 719 |   gridPreviewButton.setAttribute('aria-label', 'Grid dimensions');
 720 |   gridPreviewButton.setAttribute('aria-expanded', 'false');
 721 |   gridPreviewButton.setAttribute('aria-haspopup', 'dialog');
 722 | 
 723 |   // Single line preview: cols × rows
 724 |   const gridPreviewColsValue = document.createElement('span');
 725 |   gridPreviewColsValue.textContent = '1';
 726 |   const gridPreviewTimes = document.createElement('span');
 727 |   gridPreviewTimes.textContent = ' × ';
 728 |   const gridPreviewRowsValue = document.createElement('span');
 729 |   gridPreviewRowsValue.textContent = '1';
 730 | 
 731 |   gridPreviewButton.append(gridPreviewColsValue, gridPreviewTimes, gridPreviewRowsValue);
 732 | 
 733 |   const gridPopover = document.createElement('div');
 734 |   gridPopover.className = 'we-grid-dimensions-popover';
 735 |   gridPopover.hidden = true;
 736 | 
 737 |   const gridInputs = document.createElement('div');
 738 |   gridInputs.className = 'we-grid-dimensions-inputs';
 739 | 
 740 |   const colsContainer = createInputContainer({
 741 |     ariaLabel: 'Grid columns',
 742 |     inputMode: 'numeric',
 743 |     prefix: createGridColumnsIcon(),
 744 |     suffix: null,
 745 |   });
 746 |   colsContainer.root.style.width = '72px';
 747 |   colsContainer.root.style.flex = '0 0 auto';
 748 | 
 749 |   const times = document.createElement('span');
 750 |   times.className = 'we-grid-dimensions-times';
 751 |   times.textContent = '×';
 752 | 
 753 |   const rowsContainer = createInputContainer({
 754 |     ariaLabel: 'Grid rows',
 755 |     inputMode: 'numeric',
 756 |     prefix: createGridRowsIcon(),
 757 |     suffix: null,
 758 |   });
 759 |   rowsContainer.root.style.width = '72px';
 760 |   rowsContainer.root.style.flex = '0 0 auto';
 761 | 
 762 |   gridInputs.append(colsContainer.root, times, rowsContainer.root);
 763 | 
 764 |   const matrix = document.createElement('div');
 765 |   matrix.className = 'we-grid-dimensions-matrix';
 766 |   matrix.setAttribute('role', 'grid');
 767 | 
 768 |   const cells: HTMLButtonElement[] = [];
 769 |   for (let r = 1; r <= GRID_DIMENSION_MAX; r++) {
 770 |     for (let c = 1; c <= GRID_DIMENSION_MAX; c++) {
 771 |       const cell = document.createElement('button');
 772 |       cell.type = 'button';
 773 |       cell.className = 'we-grid-dimensions-cell';
 774 |       cell.dataset.row = String(r);
 775 |       cell.dataset.col = String(c);
 776 |       cell.setAttribute('role', 'gridcell');
 777 |       cell.setAttribute('aria-label', `${c} × ${r}`);
 778 |       cells.push(cell);
 779 |       matrix.append(cell);
 780 |     }
 781 |   }
 782 | 
 783 |   const tooltip = document.createElement('div');
 784 |   tooltip.className = 'we-grid-dimensions-tooltip';
 785 |   tooltip.hidden = true;
 786 | 
 787 |   gridPopover.append(gridInputs, matrix, tooltip);
 788 |   gridMount.append(gridPreviewButton, gridPopover);
 789 | 
 790 |   wireNumberStepping(disposer, colsContainer.input, {
 791 |     mode: 'number',
 792 |     integer: true,
 793 |     min: 1,
 794 |     max: GRID_DIMENSION_MAX,
 795 |   });
 796 |   wireNumberStepping(disposer, rowsContainer.input, {
 797 |     mode: 'number',
 798 |     integer: true,
 799 |     min: 1,
 800 |     max: GRID_DIMENSION_MAX,
 801 |   });
 802 | 
 803 |   // ---------------------------------------------------------------------------
 804 |   // Gap row (row-gap and column-gap inputs) - vertical layout
 805 |   // ---------------------------------------------------------------------------
 806 |   const gapRow = document.createElement('div');
 807 |   gapRow.className = 'we-field';
 808 |   const gapLabel = document.createElement('span');
 809 |   gapLabel.className = 'we-field-label';
 810 |   gapLabel.textContent = 'Gap';
 811 | 
 812 |   const gapMount = document.createElement('div');
 813 |   gapMount.className = 'we-field-content';
 814 | 
 815 |   const gapInputs = document.createElement('div');
 816 |   gapInputs.className = 'we-grid-gap-inputs';
 817 | 
 818 |   const rowGapContainer = createInputContainer({
 819 |     ariaLabel: 'Row gap',
 820 |     inputMode: 'decimal',
 821 |     prefix: createGridRowsIcon(),
 822 |     suffix: 'px',
 823 |   });
 824 | 
 825 |   const columnGapContainer = createInputContainer({
 826 |     ariaLabel: 'Column gap',
 827 |     inputMode: 'decimal',
 828 |     prefix: createGridColumnsIcon(),
 829 |     suffix: 'px',
 830 |   });
 831 | 
 832 |   gapInputs.append(rowGapContainer.root, columnGapContainer.root);
 833 |   gapMount.append(gapInputs);
 834 |   gapRow.append(gapLabel, gapMount);
 835 | 
 836 |   wireNumberStepping(disposer, rowGapContainer.input, { mode: 'css-length' });
 837 |   wireNumberStepping(disposer, columnGapContainer.input, { mode: 'css-length' });
 838 | 
 839 |   // ---------------------------------------------------------------------------
 840 |   // Grid + Gap combined row (two columns when display=grid)
 841 |   // ---------------------------------------------------------------------------
 842 |   const gridGapRow = document.createElement('div');
 843 |   gridGapRow.className = 'we-grid-gap-row';
 844 |   gridGapRow.hidden = true;
 845 | 
 846 |   // Adjust gridRow and gapRow to fit in two-column layout
 847 |   gridRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--grid');
 848 |   gapRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--gap');
 849 | 
 850 |   gridGapRow.append(gridRow, gapRow);
 851 | 
 852 |   // ---------------------------------------------------------------------------
 853 |   // Assemble DOM
 854 |   // ---------------------------------------------------------------------------
 855 |   root.append(displayRow, directionRow, wrapRow, alignmentRow, gridGapRow);
 856 |   container.append(root);
 857 |   disposer.add(() => root.remove());
 858 | 
 859 |   // ---------------------------------------------------------------------------
 860 |   // Field State Registry
 861 |   // ---------------------------------------------------------------------------
 862 |   const fields: Record<FieldKey, FieldState> = {
 863 |     display: {
 864 |       kind: 'display-group',
 865 |       property: 'display',
 866 |       group: displayGroup,
 867 |       handle: null,
 868 |       row: displayRow,
 869 |     },
 870 |     'flex-direction': {
 871 |       kind: 'flex-direction-group',
 872 |       property: 'flex-direction',
 873 |       group: directionGroup,
 874 |       handle: null,
 875 |       row: directionRow,
 876 |     },
 877 |     'flex-wrap': {
 878 |       kind: 'select',
 879 |       property: 'flex-wrap',
 880 |       element: wrapSelect,
 881 |       handle: null,
 882 |       row: wrapRow,
 883 |     },
 884 |     alignment: {
 885 |       kind: 'flex-alignment',
 886 |       properties: ['justify-content', 'align-items'] as const,
 887 |       justifyGroup,
 888 |       alignGroup,
 889 |       handle: null,
 890 |       row: alignmentRow,
 891 |     },
 892 |     'grid-dimensions': {
 893 |       kind: 'grid-dimensions',
 894 |       properties: ['grid-template-columns', 'grid-template-rows'] as const,
 895 |       previewButton: gridPreviewButton,
 896 |       previewColsValue: gridPreviewColsValue,
 897 |       previewRowsValue: gridPreviewRowsValue,
 898 |       popover: gridPopover,
 899 |       colsContainer,
 900 |       rowsContainer,
 901 |       matrix,
 902 |       tooltip,
 903 |       cells,
 904 |       handle: null,
 905 |       row: gridRow,
 906 |     },
 907 |     'row-gap': {
 908 |       kind: 'input',
 909 |       property: 'row-gap',
 910 |       element: rowGapContainer.input,
 911 |       container: rowGapContainer,
 912 |       handle: null,
 913 |       row: gapRow,
 914 |     },
 915 |     'column-gap': {
 916 |       kind: 'input',
 917 |       property: 'column-gap',
 918 |       element: columnGapContainer.input,
 919 |       container: columnGapContainer,
 920 |       handle: null,
 921 |       row: gapRow,
 922 |     },
 923 |   };
 924 | 
 925 |   /** Single-property fields for iteration */
 926 |   const STYLE_PROPS: readonly LayoutProperty[] = [
 927 |     'display',
 928 |     'flex-direction',
 929 |     'flex-wrap',
 930 |     'row-gap',
 931 |     'column-gap',
 932 |   ];
 933 |   /** All field keys for iteration */
 934 |   const FIELD_KEYS: readonly FieldKey[] = [
 935 |     'display',
 936 |     'flex-direction',
 937 |     'flex-wrap',
 938 |     'alignment',
 939 |     'grid-dimensions',
 940 |     'row-gap',
 941 |     'column-gap',
 942 |   ];
 943 | 
 944 |   // ---------------------------------------------------------------------------
 945 |   // Transaction Management
 946 |   // ---------------------------------------------------------------------------
 947 | 
 948 |   function beginTransaction(property: LayoutProperty): StyleTransactionHandle | null {
 949 |     if (disposer.isDisposed) return null;
 950 |     const target = currentTarget;
 951 |     if (!target || !target.isConnected) return null;
 952 | 
 953 |     const field = fields[property];
 954 |     if (field.kind === 'flex-alignment' || field.kind === 'grid-dimensions') return null;
 955 |     if (field.handle) return field.handle;
 956 | 
 957 |     const handle = transactionManager.beginStyle(target, property);
 958 |     field.handle = handle;
 959 |     return handle;
 960 |   }
 961 | 
 962 |   function commitTransaction(property: LayoutProperty): void {
 963 |     const field = fields[property];
 964 |     if (field.kind === 'flex-alignment' || field.kind === 'grid-dimensions') return;
 965 |     const handle = field.handle;
 966 |     field.handle = null;
 967 |     if (handle) handle.commit({ merge: true });
 968 |   }
 969 | 
 970 |   function rollbackTransaction(property: LayoutProperty): void {
 971 |     const field = fields[property];
 972 |     if (field.kind === 'flex-alignment' || field.kind === 'grid-dimensions') return;
 973 |     const handle = field.handle;
 974 |     field.handle = null;
 975 |     if (handle) handle.rollback();
 976 |   }
 977 | 
 978 |   function beginAlignmentTransaction(): MultiStyleTransactionHandle | null {
 979 |     if (disposer.isDisposed) return null;
 980 |     const target = currentTarget;
 981 |     if (!target || !target.isConnected) return null;
 982 | 
 983 |     const field = fields.alignment;
 984 |     if (field.kind !== 'flex-alignment') return null;
 985 |     if (field.handle) return field.handle;
 986 | 
 987 |     const handle = transactionManager.beginMultiStyle(target, [...field.properties]);
 988 |     field.handle = handle;
 989 |     return handle;
 990 |   }
 991 | 
 992 |   function commitAlignmentTransaction(): void {
 993 |     const field = fields.alignment;
 994 |     if (field.kind !== 'flex-alignment') return;
 995 |     const handle = field.handle;
 996 |     field.handle = null;
 997 |     handle?.commit({ merge: true });
 998 |   }
 999 | 
1000 |   function beginGridTransaction(): MultiStyleTransactionHandle | null {
1001 |     if (disposer.isDisposed) return null;
1002 |     const target = currentTarget;
1003 |     if (!target || !target.isConnected) return null;
1004 | 
1005 |     const field = fields['grid-dimensions'];
1006 |     if (field.kind !== 'grid-dimensions') return null;
1007 |     if (field.handle) return field.handle;
1008 | 
1009 |     const handle = transactionManager.beginMultiStyle(target, [...field.properties]);
1010 |     field.handle = handle;
1011 |     return handle;
1012 |   }
1013 | 
1014 |   function commitGridTransaction(): void {
1015 |     const field = fields['grid-dimensions'];
1016 |     if (field.kind !== 'grid-dimensions') return;
1017 |     const handle = field.handle;
1018 |     field.handle = null;
1019 |     handle?.commit({ merge: true });
1020 |   }
1021 | 
1022 |   function rollbackGridTransaction(): void {
1023 |     const field = fields['grid-dimensions'];
1024 |     if (field.kind !== 'grid-dimensions') return;
1025 |     const handle = field.handle;
1026 |     field.handle = null;
1027 |     handle?.rollback();
1028 |   }
1029 | 
1030 |   function commitAllTransactions(): void {
1031 |     for (const p of STYLE_PROPS) commitTransaction(p);
1032 |     commitAlignmentTransaction();
1033 |     commitGridTransaction();
1034 |   }
1035 | 
1036 |   // ---------------------------------------------------------------------------
1037 |   // Visibility Control
1038 |   // ---------------------------------------------------------------------------
1039 | 
1040 |   function updateVisibility(): void {
1041 |     const target = currentTarget;
1042 |     const rawDisplay = target
1043 |       ? readInlineValue(target, 'display') || readComputedValue(target, 'display')
1044 |       : (displayGroup.getValue() ?? 'block');
1045 |     const displayValue = normalizeDisplayValue(rawDisplay);
1046 | 
1047 |     const trimmed = displayValue.trim();
1048 |     const isFlex = trimmed === 'flex' || trimmed === 'inline-flex';
1049 |     const isGrid = trimmed === 'grid' || trimmed === 'inline-grid';
1050 |     const isFlexOrGrid = isFlex || isGrid;
1051 | 
1052 |     directionRow.hidden = !isFlex;
1053 |     wrapRow.hidden = !isFlex;
1054 |     alignmentRow.hidden = !isFlex;
1055 | 
1056 |     // Grid + Gap row visibility
1057 |     gridGapRow.hidden = !isFlexOrGrid;
1058 |     gridRow.hidden = !isGrid;
1059 |     gapRow.hidden = !isFlexOrGrid;
1060 |   }
1061 | 
1062 |   // ---------------------------------------------------------------------------
1063 |   // Field Synchronization
1064 |   // ---------------------------------------------------------------------------
1065 | 
1066 |   function syncField(key: FieldKey, force = false): void {
1067 |     const field = fields[key];
1068 |     const target = currentTarget;
1069 | 
1070 |     // Handle display icon button group
1071 |     if (field.kind === 'display-group') {
1072 |       const group = field.group;
1073 | 
1074 |       if (!target || !target.isConnected) {
1075 |         group.setDisabled(true);
1076 |         group.setValue(null);
1077 |         return;
1078 |       }
1079 | 
1080 |       group.setDisabled(false);
1081 |       const isEditing = field.handle !== null;
1082 |       if (isEditing && !force) return;
1083 | 
1084 |       const inline = readInlineValue(target, 'display');
1085 |       const computed = readComputedValue(target, 'display');
1086 |       let raw = (inline || computed).trim();
1087 |       raw = normalizeDisplayValue(raw);
1088 |       group.setValue(isDisplayValue(raw) ? raw : 'block');
1089 |       return;
1090 |     }
1091 | 
1092 |     // Handle flex-direction icon button group
1093 |     if (field.kind === 'flex-direction-group') {
1094 |       const group = field.group;
1095 | 
1096 |       if (!target || !target.isConnected) {
1097 |         group.setDisabled(true);
1098 |         group.setValue(null);
1099 |         return;
1100 |       }
1101 | 
1102 |       group.setDisabled(false);
1103 |       const isEditing = field.handle !== null;
1104 |       if (isEditing && !force) return;
1105 | 
1106 |       const inline = readInlineValue(target, 'flex-direction');
1107 |       const computed = readComputedValue(target, 'flex-direction');
1108 |       const raw = (inline || computed).trim();
1109 |       group.setValue(isFlexDirectionValue(raw) ? raw : null);
1110 |       return;
1111 |     }
1112 | 
1113 |     // Handle flex alignment (content bars)
1114 |     if (field.kind === 'flex-alignment') {
1115 |       if (!target || !target.isConnected) {
1116 |         field.justifyGroup.setDisabled(true);
1117 |         field.alignGroup.setDisabled(true);
1118 |         field.justifyGroup.setValue(null);
1119 |         field.alignGroup.setValue(null);
1120 |         return;
1121 |       }
1122 | 
1123 |       field.justifyGroup.setDisabled(false);
1124 |       field.alignGroup.setDisabled(false);
1125 |       const isEditing = field.handle !== null;
1126 |       if (isEditing && !force) return;
1127 | 
1128 |       const justifyInline = readInlineValue(target, 'justify-content');
1129 |       const justifyComputed = readComputedValue(target, 'justify-content');
1130 |       const alignInline = readInlineValue(target, 'align-items');
1131 |       const alignComputed = readComputedValue(target, 'align-items');
1132 | 
1133 |       const justifyRaw = (justifyInline || justifyComputed).trim();
1134 |       const alignRaw = (alignInline || alignComputed).trim();
1135 | 
1136 |       if (isAlignmentAxisValue(justifyRaw) && isAlignmentAxisValue(alignRaw)) {
1137 |         field.justifyGroup.setValue(justifyRaw);
1138 |         field.alignGroup.setValue(alignRaw);
1139 |       } else {
1140 |         field.justifyGroup.setValue(null);
1141 |         field.alignGroup.setValue(null);
1142 |       }
1143 |       return;
1144 |     }
1145 | 
1146 |     // Handle grid dimensions (grid-template-columns/rows)
1147 |     if (field.kind === 'grid-dimensions') {
1148 |       const {
1149 |         previewButton,
1150 |         popover,
1151 |         colsContainer,
1152 |         rowsContainer,
1153 |         tooltip,
1154 |         cells: gridCells,
1155 |       } = field;
1156 | 
1157 |       if (!target || !target.isConnected) {
1158 |         previewButton.disabled = true;
1159 |         field.previewColsValue.textContent = '—';
1160 |         field.previewRowsValue.textContent = '—';
1161 |         previewButton.setAttribute('aria-expanded', 'false');
1162 |         popover.hidden = true;
1163 |         colsContainer.input.disabled = true;
1164 |         rowsContainer.input.disabled = true;
1165 |         tooltip.hidden = true;
1166 |         for (const cell of gridCells) {
1167 |           cell.dataset.active = 'false';
1168 |           cell.dataset.selected = 'false';
1169 |         }
1170 |         return;
1171 |       }
1172 | 
1173 |       previewButton.disabled = false;
1174 |       colsContainer.input.disabled = false;
1175 |       rowsContainer.input.disabled = false;
1176 | 
1177 |       const isEditing =
1178 |         field.handle !== null ||
1179 |         isFieldFocused(colsContainer.input) ||
1180 |         isFieldFocused(rowsContainer.input);
1181 |       if (isEditing && !force) return;
1182 | 
1183 |       const colsRaw =
1184 |         readInlineValue(target, 'grid-template-columns') ||
1185 |         readComputedValue(target, 'grid-template-columns');
1186 |       const rowsRaw =
1187 |         readInlineValue(target, 'grid-template-rows') ||
1188 |         readComputedValue(target, 'grid-template-rows');
1189 | 
1190 |       const cols = clampInt(countGridTracks(colsRaw) ?? 1, 1, GRID_DIMENSION_MAX);
1191 |       const rows = clampInt(countGridTracks(rowsRaw) ?? 1, 1, GRID_DIMENSION_MAX);
1192 | 
1193 |       colsContainer.input.value = String(cols);
1194 |       rowsContainer.input.value = String(rows);
1195 |       field.previewColsValue.textContent = String(cols);
1196 |       field.previewRowsValue.textContent = String(rows);
1197 |       // Update aria-label for screen readers
1198 |       previewButton.setAttribute('aria-label', `Grid: ${cols} columns, ${rows} rows`);
1199 | 
1200 |       // Default rendering uses current values
1201 |       tooltip.hidden = true;
1202 |       for (const cell of gridCells) {
1203 |         const c = parseInt(cell.dataset.col ?? '0', 10);
1204 |         const r = parseInt(cell.dataset.row ?? '0', 10);
1205 |         const selected = c > 0 && r > 0 && c <= cols && r <= rows;
1206 |         cell.dataset.selected = selected ? 'true' : 'false';
1207 |         cell.dataset.active = selected ? 'true' : 'false';
1208 |       }
1209 |       return;
1210 |     }
1211 | 
1212 |     // Handle input field (row-gap / column-gap)
1213 |     if (field.kind === 'input') {
1214 |       const input = field.element;
1215 | 
1216 |       if (!target || !target.isConnected) {
1217 |         input.disabled = true;
1218 |         input.value = '';
1219 |         input.placeholder = '';
1220 |         field.container.setSuffix('px');
1221 |         return;
1222 |       }
1223 | 
1224 |       input.disabled = false;
1225 |       const isEditing = field.handle !== null || isFieldFocused(input);
1226 |       if (isEditing && !force) return;
1227 | 
1228 |       const inlineValue = readInlineValue(target, field.property);
1229 |       const displayValue = inlineValue || readComputedValue(target, field.property);
1230 |       const formatted = formatLengthForDisplay(displayValue);
1231 |       input.value = formatted.value;
1232 |       field.container.setSuffix(formatted.suffix);
1233 |       input.placeholder = '';
1234 |       return;
1235 |     }
1236 | 
1237 |     // Handle select field (flex-wrap)
1238 |     if (field.kind === 'select') {
1239 |       const select = field.element;
1240 | 
1241 |       if (!target || !target.isConnected) {
1242 |         select.disabled = true;
1243 |         return;
1244 |       }
1245 | 
1246 |       select.disabled = false;
1247 |       const isEditing = field.handle !== null || isFieldFocused(select);
1248 |       if (isEditing && !force) return;
1249 | 
1250 |       const inline = readInlineValue(target, field.property);
1251 |       const computed = readComputedValue(target, field.property);
1252 |       const val = inline || computed;
1253 | 
1254 |       const hasOption = Array.from(select.options).some((o) => o.value === val);
1255 |       select.value = hasOption ? val : (select.options[0]?.value ?? '');
1256 |     }
1257 |   }
1258 | 
1259 |   function syncAllFields(): void {
1260 |     for (const key of FIELD_KEYS) syncField(key);
1261 |     updateVisibility();
1262 |   }
1263 | 
1264 |   // ---------------------------------------------------------------------------
1265 |   // Event Wiring
1266 |   // ---------------------------------------------------------------------------
1267 | 
1268 |   function wireSelect(property: 'flex-wrap'): void {
1269 |     const field = fields[property];
1270 |     if (field.kind !== 'select') return;
1271 |     const select = field.element;
1272 | 
1273 |     const preview = () => {
1274 |       const handle = beginTransaction(property);
1275 |       if (handle) handle.set(select.value);
1276 |     };
1277 | 
1278 |     disposer.listen(select, 'input', preview);
1279 |     disposer.listen(select, 'change', preview);
1280 |     disposer.listen(select, 'blur', () => {
1281 |       commitTransaction(property);
1282 |       syncAllFields();
1283 |     });
1284 | 
1285 |     disposer.listen(select, 'keydown', (e: KeyboardEvent) => {
1286 |       if (e.key === 'Enter') {
1287 |         e.preventDefault();
1288 |         commitTransaction(property);
1289 |         syncAllFields();
1290 |         select.blur();
1291 |       } else if (e.key === 'Escape') {
1292 |         e.preventDefault();
1293 |         rollbackTransaction(property);
1294 |         syncField(property, true);
1295 |       }
1296 |     });
1297 |   }
1298 | 
1299 |   function wireInput(property: 'row-gap' | 'column-gap'): void {
1300 |     const field = fields[property];
1301 |     if (field.kind !== 'input') return;
1302 |     const input = field.element;
1303 | 
1304 |     disposer.listen(input, 'input', () => {
1305 |       const handle = beginTransaction(property);
1306 |       if (!handle) return;
1307 |       const suffix = field.container.getSuffixText();
1308 |       handle.set(combineLengthValue(input.value, suffix));
1309 |     });
1310 | 
1311 |     disposer.listen(input, 'blur', () => {
1312 |       commitTransaction(property);
1313 |       syncAllFields();
1314 |     });
1315 | 
1316 |     disposer.listen(input, 'keydown', (e: KeyboardEvent) => {
1317 |       if (e.key === 'Enter') {
1318 |         e.preventDefault();
1319 |         commitTransaction(property);
1320 |         syncAllFields();
1321 |         input.blur();
1322 |       } else if (e.key === 'Escape') {
1323 |         e.preventDefault();
1324 |         rollbackTransaction(property);
1325 |         syncField(property, true);
1326 |       }
1327 |     });
1328 |   }
1329 | 
1330 |   wireSelect('flex-wrap');
1331 |   wireInput('row-gap');
1332 |   wireInput('column-gap');
1333 | 
1334 |   // ---------------------------------------------------------------------------
1335 |   // Grid Dimensions Picker Wiring
1336 |   // ---------------------------------------------------------------------------
1337 | 
1338 |   let gridHoverCols: number | null = null;
1339 |   let gridHoverRows: number | null = null;
1340 | 
1341 |   function renderGridSelection(field: GridDimensionsFieldState, cols: number, rows: number): void {
1342 |     const activeCols = gridHoverCols ?? cols;
1343 |     const activeRows = gridHoverRows ?? rows;
1344 | 
1345 |     for (const cell of field.cells) {
1346 |       const c = parseInt(cell.dataset.col ?? '0', 10);
1347 |       const r = parseInt(cell.dataset.row ?? '0', 10);
1348 |       const selected = c > 0 && r > 0 && c <= cols && r <= rows;
1349 |       const active = c > 0 && r > 0 && c <= activeCols && r <= activeRows;
1350 |       cell.dataset.selected = selected ? 'true' : 'false';
1351 |       cell.dataset.active = active ? 'true' : 'false';
1352 |     }
1353 | 
1354 |     if (gridHoverCols !== null && gridHoverRows !== null) {
1355 |       field.tooltip.textContent = `${gridHoverCols} × ${gridHoverRows}`;
1356 |       field.tooltip.hidden = false;
1357 |     } else {
1358 |       field.tooltip.hidden = true;
1359 |     }
1360 |   }
1361 | 
1362 |   function setGridPopoverOpen(field: GridDimensionsFieldState, open: boolean): void {
1363 |     field.popover.hidden = !open;
1364 |     field.previewButton.setAttribute('aria-expanded', open ? 'true' : 'false');
1365 | 
1366 |     // Reset hover when opening/closing
1367 |     gridHoverCols = null;
1368 |     gridHoverRows = null;
1369 | 
1370 |     const cols = clampInt(
1371 |       parseInt(field.colsContainer.input.value || '1', 10) || 1,
1372 |       1,
1373 |       GRID_DIMENSION_MAX,
1374 |     );
1375 |     const rows = clampInt(
1376 |       parseInt(field.rowsContainer.input.value || '1', 10) || 1,
1377 |       1,
1378 |       GRID_DIMENSION_MAX,
1379 |     );
1380 |     renderGridSelection(field, cols, rows);
1381 |   }
1382 | 
1383 |   function previewGridDimensions(cols: number, rows: number): void {
1384 |     const handle = beginGridTransaction();
1385 |     if (!handle) return;
1386 |     handle.set({
1387 |       'grid-template-columns': formatGridTemplate(cols),
1388 |       'grid-template-rows': formatGridTemplate(rows),
1389 |     });
1390 |   }
1391 | 
1392 |   const gridField = fields['grid-dimensions'];
1393 |   if (gridField.kind === 'grid-dimensions') {
1394 |     disposer.listen(gridField.previewButton, 'click', (e: MouseEvent) => {
1395 |       e.preventDefault();
1396 |       setGridPopoverOpen(gridField, gridField.popover.hidden);
1397 |       if (!gridField.popover.hidden) {
1398 |         gridField.colsContainer.input.focus();
1399 |         gridField.colsContainer.input.select();
1400 |       }
1401 |     });
1402 | 
1403 |     // Close popover when clicking outside
1404 |     // Use capture phase to catch clicks in Shadow DOM
1405 |     const rootNode = gridField.previewButton.getRootNode() as Document | ShadowRoot;
1406 |     const clickTarget = rootNode instanceof ShadowRoot ? rootNode : document;
1407 | 
1408 |     const handleOutsideClick = (e: Event): void => {
1409 |       if (gridField.kind !== 'grid-dimensions') return;
1410 |       if (gridField.popover.hidden) return;
1411 |       const target = e.target as Node;
1412 |       // Check if click is inside gridRow (which contains both button and popover)
1413 |       if (!gridRow.contains(target)) {
1414 |         setGridPopoverOpen(gridField, false);
1415 |       }
1416 |     };
1417 | 
1418 |     clickTarget.addEventListener('click', handleOutsideClick, true);
1419 |     disposer.add(() => {
1420 |       clickTarget.removeEventListener('click', handleOutsideClick, true);
1421 |     });
1422 | 
1423 |     // Inputs: live preview, blur commit, ESC rollback
1424 |     const wireGridInput = (input: HTMLInputElement) => {
1425 |       disposer.listen(input, 'input', () => {
1426 |         const cols = clampInt(
1427 |           parseInt(gridField.colsContainer.input.value || '1', 10) || 1,
1428 |           1,
1429 |           GRID_DIMENSION_MAX,
1430 |         );
1431 |         const rows = clampInt(
1432 |           parseInt(gridField.rowsContainer.input.value || '1', 10) || 1,
1433 |           1,
1434 |           GRID_DIMENSION_MAX,
1435 |         );
1436 |         renderGridSelection(gridField, cols, rows);
1437 |         previewGridDimensions(cols, rows);
1438 |       });
1439 | 
1440 |       disposer.listen(input, 'blur', () => {
1441 |         commitGridTransaction();
1442 |         syncAllFields();
1443 |       });
1444 | 
1445 |       disposer.listen(input, 'keydown', (ev: KeyboardEvent) => {
1446 |         if (ev.key === 'Enter') {
1447 |           ev.preventDefault();
1448 |           commitGridTransaction();
1449 |           syncAllFields();
1450 |           return;
1451 |         }
1452 |         if (ev.key === 'Escape') {
1453 |           ev.preventDefault();
1454 |           rollbackGridTransaction();
1455 |           setGridPopoverOpen(gridField, false);
1456 |           syncField('grid-dimensions', true);
1457 |         }
1458 |       });
1459 |     };
1460 | 
1461 |     wireGridInput(gridField.colsContainer.input);
1462 |     wireGridInput(gridField.rowsContainer.input);
1463 | 
1464 |     // Matrix hover + click select
1465 |     disposer.listen(gridField.matrix, 'mouseover', (e: MouseEvent) => {
1466 |       const el = e.target as HTMLElement;
1467 |       const cell = el.closest('.we-grid-dimensions-cell') as HTMLButtonElement | null;
1468 |       if (!cell) return;
1469 |       gridHoverCols = clampInt(parseInt(cell.dataset.col ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);
1470 |       gridHoverRows = clampInt(parseInt(cell.dataset.row ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);
1471 |       const cols = clampInt(
1472 |         parseInt(gridField.colsContainer.input.value || '1', 10) || 1,
1473 |         1,
1474 |         GRID_DIMENSION_MAX,
1475 |       );
1476 |       const rows = clampInt(
1477 |         parseInt(gridField.rowsContainer.input.value || '1', 10) || 1,
1478 |         1,
1479 |         GRID_DIMENSION_MAX,
1480 |       );
1481 |       renderGridSelection(gridField, cols, rows);
1482 |     });
1483 | 
1484 |     disposer.listen(gridField.matrix, 'mouseleave', () => {
1485 |       gridHoverCols = null;
1486 |       gridHoverRows = null;
1487 |       const cols = clampInt(
1488 |         parseInt(gridField.colsContainer.input.value || '1', 10) || 1,
1489 |         1,
1490 |         GRID_DIMENSION_MAX,
1491 |       );
1492 |       const rows = clampInt(
1493 |         parseInt(gridField.rowsContainer.input.value || '1', 10) || 1,
1494 |         1,
1495 |         GRID_DIMENSION_MAX,
1496 |       );
1497 |       renderGridSelection(gridField, cols, rows);
1498 |     });
1499 | 
1500 |     disposer.listen(gridField.matrix, 'click', (e: MouseEvent) => {
1501 |       const el = e.target as HTMLElement;
1502 |       const cell = el.closest('.we-grid-dimensions-cell') as HTMLButtonElement | null;
1503 |       if (!cell) return;
1504 |       const cols = clampInt(parseInt(cell.dataset.col ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);
1505 |       const rows = clampInt(parseInt(cell.dataset.row ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);
1506 |       gridField.colsContainer.input.value = String(cols);
1507 |       gridField.rowsContainer.input.value = String(rows);
1508 |       previewGridDimensions(cols, rows);
1509 |       commitGridTransaction();
1510 |       setGridPopoverOpen(gridField, false);
1511 |       syncAllFields();
1512 |     });
1513 |   }
1514 | 
1515 |   function setTarget(element: Element | null): void {
1516 |     if (disposer.isDisposed) return;
1517 |     if (element !== currentTarget) commitAllTransactions();
1518 |     currentTarget = element;
1519 |     syncAllFields();
1520 |   }
1521 | 
1522 |   function refresh(): void {
1523 |     if (disposer.isDisposed) return;
1524 |     syncAllFields();
1525 |   }
1526 | 
1527 |   function dispose(): void {
1528 |     commitAllTransactions();
1529 |     currentTarget = null;
1530 |     disposer.dispose();
1531 |   }
1532 | 
1533 |   syncAllFields();
1534 | 
1535 |   return { setTarget, refresh, dispose };
1536 | }
1537 | 
```

--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/computer.ts:
--------------------------------------------------------------------------------

```typescript
   1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
   2 | import { BaseBrowserToolExecutor } from '../base-browser';
   3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
   4 | import { ERROR_MESSAGES, TIMEOUTS } from '@/common/constants';
   5 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
   6 | import { clickTool, fillTool } from './interaction';
   7 | import { keyboardTool } from './keyboard';
   8 | import { screenshotTool } from './screenshot';
   9 | import { screenshotContextManager, scaleCoordinates } from '@/utils/screenshot-context';
  10 | import { cdpSessionManager } from '@/utils/cdp-session-manager';
  11 | import {
  12 |   captureFrameOnAction,
  13 |   isAutoCaptureActive,
  14 |   type ActionMetadata,
  15 |   type ActionType,
  16 | } from './gif-recorder';
  17 | 
  18 | type MouseButton = 'left' | 'right' | 'middle';
  19 | 
  20 | interface Coordinates {
  21 |   x: number;
  22 |   y: number;
  23 | }
  24 | 
  25 | interface ZoomRegion {
  26 |   x0: number;
  27 |   y0: number;
  28 |   x1: number;
  29 |   y1: number;
  30 | }
  31 | 
  32 | interface Modifiers {
  33 |   altKey?: boolean;
  34 |   ctrlKey?: boolean;
  35 |   metaKey?: boolean;
  36 |   shiftKey?: boolean;
  37 | }
  38 | 
  39 | interface ComputerParams {
  40 |   action:
  41 |     | 'left_click'
  42 |     | 'right_click'
  43 |     | 'double_click'
  44 |     | 'triple_click'
  45 |     | 'left_click_drag'
  46 |     | 'scroll'
  47 |     | 'type'
  48 |     | 'key'
  49 |     | 'hover'
  50 |     | 'wait'
  51 |     | 'fill'
  52 |     | 'fill_form'
  53 |     | 'resize_page'
  54 |     | 'scroll_to'
  55 |     | 'zoom'
  56 |     | 'screenshot';
  57 |   // click/scroll coordinates in screenshot space (if screenshot context exists) or viewport space
  58 |   coordinates?: Coordinates; // for click/scroll; for drag, this is endCoordinates
  59 |   startCoordinates?: Coordinates; // for drag start
  60 |   // Optional element refs (from chrome_read_page) as alternative to coordinates
  61 |   ref?: string; // click target or drag end
  62 |   startRef?: string; // drag start
  63 |   scrollDirection?: 'up' | 'down' | 'left' | 'right';
  64 |   scrollAmount?: number;
  65 |   text?: string; // for type/key
  66 |   repeat?: number; // for key action (1-100)
  67 |   modifiers?: Modifiers; // for click actions
  68 |   region?: ZoomRegion; // for zoom action
  69 |   duration?: number; // seconds for wait
  70 |   // For fill
  71 |   selector?: string;
  72 |   selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css')
  73 |   value?: string;
  74 |   frameId?: number; // Target frame for selector/ref resolution
  75 |   tabId?: number; // target existing tab id
  76 |   windowId?: number;
  77 |   background?: boolean; // avoid focusing/activating
  78 | }
  79 | 
  80 | // Minimal CDP helper encapsulated here to avoid scattering CDP code
  81 | class CDPHelper {
  82 |   static async attach(tabId: number): Promise<void> {
  83 |     await cdpSessionManager.attach(tabId, 'computer');
  84 |   }
  85 | 
  86 |   static async detach(tabId: number): Promise<void> {
  87 |     await cdpSessionManager.detach(tabId, 'computer');
  88 |   }
  89 | 
  90 |   static async send(tabId: number, method: string, params?: object): Promise<any> {
  91 |     return await cdpSessionManager.sendCommand(tabId, method, params);
  92 |   }
  93 | 
  94 |   static async dispatchMouseEvent(tabId: number, opts: any) {
  95 |     const params: any = {
  96 |       type: opts.type,
  97 |       x: Math.round(opts.x),
  98 |       y: Math.round(opts.y),
  99 |       modifiers: opts.modifiers || 0,
 100 |     };
 101 |     if (
 102 |       opts.type === 'mousePressed' ||
 103 |       opts.type === 'mouseReleased' ||
 104 |       opts.type === 'mouseMoved'
 105 |     ) {
 106 |       params.button = opts.button || 'none';
 107 |       if (opts.type === 'mousePressed' || opts.type === 'mouseReleased') {
 108 |         params.clickCount = opts.clickCount || 1;
 109 |       }
 110 |       // Per CDP: buttons is ignored for mouseWheel
 111 |       params.buttons = opts.buttons !== undefined ? opts.buttons : 0;
 112 |     }
 113 |     if (opts.type === 'mouseWheel') {
 114 |       params.deltaX = opts.deltaX || 0;
 115 |       params.deltaY = opts.deltaY || 0;
 116 |     }
 117 |     await this.send(tabId, 'Input.dispatchMouseEvent', params);
 118 |   }
 119 | 
 120 |   static async insertText(tabId: number, text: string) {
 121 |     await this.send(tabId, 'Input.insertText', { text });
 122 |   }
 123 | 
 124 |   static modifierMask(mods: string[]): number {
 125 |     const map: Record<string, number> = {
 126 |       alt: 1,
 127 |       ctrl: 2,
 128 |       control: 2,
 129 |       meta: 4,
 130 |       cmd: 4,
 131 |       command: 4,
 132 |       win: 4,
 133 |       windows: 4,
 134 |       shift: 8,
 135 |     };
 136 |     let mask = 0;
 137 |     for (const m of mods) mask |= map[m] || 0;
 138 |     return mask;
 139 |   }
 140 | 
 141 |   // Enhanced key mapping for common non-character keys
 142 |   private static KEY_ALIASES: Record<string, { key: string; code?: string; text?: string }> = {
 143 |     enter: { key: 'Enter', code: 'Enter' },
 144 |     return: { key: 'Enter', code: 'Enter' },
 145 |     backspace: { key: 'Backspace', code: 'Backspace' },
 146 |     delete: { key: 'Delete', code: 'Delete' },
 147 |     tab: { key: 'Tab', code: 'Tab' },
 148 |     escape: { key: 'Escape', code: 'Escape' },
 149 |     esc: { key: 'Escape', code: 'Escape' },
 150 |     space: { key: ' ', code: 'Space', text: ' ' },
 151 |     pageup: { key: 'PageUp', code: 'PageUp' },
 152 |     pagedown: { key: 'PageDown', code: 'PageDown' },
 153 |     home: { key: 'Home', code: 'Home' },
 154 |     end: { key: 'End', code: 'End' },
 155 |     arrowup: { key: 'ArrowUp', code: 'ArrowUp' },
 156 |     arrowdown: { key: 'ArrowDown', code: 'ArrowDown' },
 157 |     arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft' },
 158 |     arrowright: { key: 'ArrowRight', code: 'ArrowRight' },
 159 |   };
 160 | 
 161 |   private static resolveKeyDef(token: string): { key: string; code?: string; text?: string } {
 162 |     const t = (token || '').toLowerCase();
 163 |     if (this.KEY_ALIASES[t]) return this.KEY_ALIASES[t];
 164 |     if (/^f([1-9]|1[0-2])$/.test(t)) {
 165 |       return { key: t.toUpperCase(), code: t.toUpperCase() };
 166 |     }
 167 |     if (t.length === 1) {
 168 |       const upper = t.toUpperCase();
 169 |       return { key: upper, code: `Key${upper}`, text: t };
 170 |     }
 171 |     return { key: token };
 172 |   }
 173 | 
 174 |   static async dispatchSimpleKey(tabId: number, token: string) {
 175 |     const def = this.resolveKeyDef(token);
 176 |     if (def.text && def.text.length === 1) {
 177 |       await this.insertText(tabId, def.text);
 178 |       return;
 179 |     }
 180 |     await this.send(tabId, 'Input.dispatchKeyEvent', {
 181 |       type: 'rawKeyDown',
 182 |       key: def.key,
 183 |       code: def.code,
 184 |     });
 185 |     await this.send(tabId, 'Input.dispatchKeyEvent', {
 186 |       type: 'keyUp',
 187 |       key: def.key,
 188 |       code: def.code,
 189 |     });
 190 |   }
 191 | 
 192 |   static async dispatchKeyChord(tabId: number, chord: string) {
 193 |     const parts = chord.split('+');
 194 |     const modifiers: string[] = [];
 195 |     let keyToken = '';
 196 |     for (const pRaw of parts) {
 197 |       const p = pRaw.trim().toLowerCase();
 198 |       if (
 199 |         ['ctrl', 'control', 'alt', 'shift', 'cmd', 'meta', 'command', 'win', 'windows'].includes(p)
 200 |       )
 201 |         modifiers.push(p);
 202 |       else keyToken = pRaw.trim();
 203 |     }
 204 |     const mask = this.modifierMask(modifiers);
 205 |     const def = this.resolveKeyDef(keyToken);
 206 |     await this.send(tabId, 'Input.dispatchKeyEvent', {
 207 |       type: 'rawKeyDown',
 208 |       key: def.key,
 209 |       code: def.code,
 210 |       text: def.text,
 211 |       modifiers: mask,
 212 |     });
 213 |     await this.send(tabId, 'Input.dispatchKeyEvent', {
 214 |       type: 'keyUp',
 215 |       key: def.key,
 216 |       code: def.code,
 217 |       modifiers: mask,
 218 |     });
 219 |   }
 220 | }
 221 | 
 222 | class ComputerTool extends BaseBrowserToolExecutor {
 223 |   name = TOOL_NAMES.BROWSER.COMPUTER;
 224 | 
 225 |   async execute(args: ComputerParams): Promise<ToolResult> {
 226 |     const params = args || ({} as ComputerParams);
 227 |     if (!params.action) return createErrorResponse('Action parameter is required');
 228 | 
 229 |     try {
 230 |       const explicit = await this.tryGetTab(args.tabId);
 231 |       const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId));
 232 |       if (!tab.id)
 233 |         return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
 234 | 
 235 |       // Execute the action and capture frame on success
 236 |       const result = await this.executeAction(params, tab);
 237 | 
 238 |       // Trigger auto-capture on successful actions (except screenshot which is read-only)
 239 |       if (!result.isError && params.action !== 'screenshot' && params.action !== 'wait') {
 240 |         const actionType = this.mapActionToCapture(params.action);
 241 |         if (actionType) {
 242 |           // Convert to viewport-space coordinates for GIF overlays
 243 |           // params.coordinates may be screenshot-space when screenshot context exists
 244 |           const ctx = screenshotContextManager.getContext(tab.id);
 245 |           const toViewport = (c?: Coordinates): { x: number; y: number } | undefined => {
 246 |             if (!c) return undefined;
 247 |             if (!ctx) return { x: c.x, y: c.y };
 248 |             const scaled = scaleCoordinates(c.x, c.y, ctx);
 249 |             return { x: scaled.x, y: scaled.y };
 250 |           };
 251 | 
 252 |           const endCoords = toViewport(params.coordinates);
 253 |           const startCoords = toViewport(params.startCoordinates);
 254 | 
 255 |           await this.triggerAutoCapture(tab.id, actionType, {
 256 |             coordinateSpace: 'viewport',
 257 |             coordinates: endCoords,
 258 |             startCoordinates: startCoords,
 259 |             endCoordinates: actionType === 'drag' ? endCoords : undefined,
 260 |             text: params.text,
 261 |             ref: params.ref,
 262 |           });
 263 |         }
 264 |       }
 265 | 
 266 |       return result;
 267 |     } catch (error) {
 268 |       console.error('Error in computer tool:', error);
 269 |       return createErrorResponse(
 270 |         `Failed to execute action: ${error instanceof Error ? error.message : String(error)}`,
 271 |       );
 272 |     }
 273 |   }
 274 | 
 275 |   private mapActionToCapture(action: string): ActionType | null {
 276 |     const mapping: Record<string, ActionType> = {
 277 |       left_click: 'click',
 278 |       right_click: 'right_click',
 279 |       double_click: 'double_click',
 280 |       triple_click: 'triple_click',
 281 |       left_click_drag: 'drag',
 282 |       scroll: 'scroll',
 283 |       type: 'type',
 284 |       key: 'key',
 285 |       hover: 'hover',
 286 |       fill: 'fill',
 287 |       fill_form: 'fill',
 288 |       resize_page: 'other',
 289 |       scroll_to: 'scroll',
 290 |       zoom: 'other',
 291 |     };
 292 |     return mapping[action] || null;
 293 |   }
 294 | 
 295 |   private async executeAction(params: ComputerParams, tab: chrome.tabs.Tab): Promise<ToolResult> {
 296 |     if (!tab.id) {
 297 |       return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
 298 |     }
 299 | 
 300 |     // Helper to project coordinates using screenshot context when available
 301 |     const project = (c?: Coordinates): Coordinates | undefined => {
 302 |       if (!c) return undefined;
 303 |       const ctx = screenshotContextManager.getContext(tab.id!);
 304 |       if (!ctx) return c;
 305 |       const scaled = scaleCoordinates(c.x, c.y, ctx);
 306 |       return { x: scaled.x, y: scaled.y };
 307 |     };
 308 | 
 309 |     switch (params.action) {
 310 |       case 'resize_page': {
 311 |         const width = Number((params as any).coordinates?.x || (params as any).text);
 312 |         const height = Number((params as any).coordinates?.y || (params as any).value);
 313 |         const w = Number((params as any).width ?? width);
 314 |         const h = Number((params as any).height ?? height);
 315 |         if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
 316 |           return createErrorResponse('Provide width and height for resize_page (positive numbers)');
 317 |         }
 318 |         try {
 319 |           // Prefer precise CDP emulation
 320 |           await CDPHelper.attach(tab.id);
 321 |           try {
 322 |             await CDPHelper.send(tab.id, 'Emulation.setDeviceMetricsOverride', {
 323 |               width: Math.round(w),
 324 |               height: Math.round(h),
 325 |               deviceScaleFactor: 0,
 326 |               mobile: false,
 327 |               screenWidth: Math.round(w),
 328 |               screenHeight: Math.round(h),
 329 |             });
 330 |           } finally {
 331 |             await CDPHelper.detach(tab.id);
 332 |           }
 333 |         } catch (e) {
 334 |           // Fallback: window resize
 335 |           if (tab.windowId !== undefined) {
 336 |             await chrome.windows.update(tab.windowId, {
 337 |               width: Math.round(w),
 338 |               height: Math.round(h),
 339 |             });
 340 |           } else {
 341 |             return createErrorResponse(
 342 |               `Failed to resize via CDP and cannot determine windowId: ${e instanceof Error ? e.message : String(e)}`,
 343 |             );
 344 |           }
 345 |         }
 346 |         return {
 347 |           content: [
 348 |             {
 349 |               type: 'text',
 350 |               text: JSON.stringify({ success: true, action: 'resize_page', width: w, height: h }),
 351 |             },
 352 |           ],
 353 |           isError: false,
 354 |         };
 355 |       }
 356 |       case 'hover': {
 357 |         // Resolve target point from ref | selector | coordinates
 358 |         let coord: Coordinates | undefined = undefined;
 359 |         let resolvedBy: 'ref' | 'selector' | 'coordinates' | undefined;
 360 | 
 361 |         try {
 362 |           if (params.ref) {
 363 |             await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
 364 |             // Scroll element into view first to ensure it's visible
 365 |             try {
 366 |               await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: params.ref });
 367 |             } catch {
 368 |               // Best effort - continue even if scroll fails
 369 |             }
 370 |             // Re-resolve coordinates after scroll
 371 |             const resolved = await this.sendMessageToTab(tab.id, {
 372 |               action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
 373 |               ref: params.ref,
 374 |             });
 375 |             if (resolved && resolved.success) {
 376 |               coord = project({ x: resolved.center.x, y: resolved.center.y });
 377 |               resolvedBy = 'ref';
 378 |             }
 379 |           } else if (params.selector) {
 380 |             await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
 381 |             const selectorType = params.selectorType || 'css';
 382 |             const ensured = await this.sendMessageToTab(tab.id, {
 383 |               action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
 384 |               selector: params.selector,
 385 |               isXPath: selectorType === 'xpath',
 386 |             });
 387 |             if (ensured && ensured.success) {
 388 |               // Scroll element into view first to ensure it's visible
 389 |               const resolvedRef = typeof ensured.ref === 'string' ? ensured.ref : undefined;
 390 |               if (resolvedRef) {
 391 |                 try {
 392 |                   await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: resolvedRef });
 393 |                 } catch {
 394 |                   // Best effort - continue even if scroll fails
 395 |                 }
 396 |                 // Re-resolve coordinates after scroll
 397 |                 const reResolved = await this.sendMessageToTab(tab.id, {
 398 |                   action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
 399 |                   ref: resolvedRef,
 400 |                 });
 401 |                 if (reResolved && reResolved.success) {
 402 |                   coord = project({ x: reResolved.center.x, y: reResolved.center.y });
 403 |                 } else {
 404 |                   coord = project({ x: ensured.center.x, y: ensured.center.y });
 405 |                 }
 406 |               } else {
 407 |                 coord = project({ x: ensured.center.x, y: ensured.center.y });
 408 |               }
 409 |               resolvedBy = 'selector';
 410 |             }
 411 |           } else if (params.coordinates) {
 412 |             coord = project(params.coordinates);
 413 |             resolvedBy = 'coordinates';
 414 |           }
 415 |         } catch (e) {
 416 |           // fall through to error handling below
 417 |         }
 418 | 
 419 |         if (!coord)
 420 |           return createErrorResponse(
 421 |             'Provide ref or selector or coordinates for hover, or failed to resolve target',
 422 |           );
 423 |         {
 424 |           const stale = ((): any => {
 425 |             if (!params.coordinates) return null;
 426 |             const getHostname = (url: string): string => {
 427 |               try {
 428 |                 return new URL(url).hostname;
 429 |               } catch {
 430 |                 return '';
 431 |               }
 432 |             };
 433 |             const currentHostname = getHostname(tab.url || '');
 434 |             const ctx = screenshotContextManager.getContext(tab.id!);
 435 |             const contextHostname = (ctx as any)?.hostname as string | undefined;
 436 |             if (contextHostname && contextHostname !== currentHostname) {
 437 |               return createErrorResponse(
 438 |                 `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during hover. Capture a new screenshot or use ref/selector.`,
 439 |               );
 440 |             }
 441 |             return null;
 442 |           })();
 443 |           if (stale) return stale;
 444 |         }
 445 | 
 446 |         try {
 447 |           await CDPHelper.attach(tab.id);
 448 |           try {
 449 |             // Move pointer to target. We can dispatch a single mouseMoved; browsers will generate mouseover/mouseenter as needed.
 450 |             await CDPHelper.dispatchMouseEvent(tab.id, {
 451 |               type: 'mouseMoved',
 452 |               x: coord.x,
 453 |               y: coord.y,
 454 |               button: 'none',
 455 |               buttons: 0,
 456 |             });
 457 |           } finally {
 458 |             await CDPHelper.detach(tab.id);
 459 |           }
 460 | 
 461 |           // Optional hold to allow UI (menus/tooltips) to appear
 462 |           const holdMs = Math.max(
 463 |             0,
 464 |             Math.min(params.duration ? params.duration * 1000 : 400, 5000),
 465 |           );
 466 |           if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
 467 | 
 468 |           return {
 469 |             content: [
 470 |               {
 471 |                 type: 'text',
 472 |                 text: JSON.stringify({
 473 |                   success: true,
 474 |                   action: 'hover',
 475 |                   coordinates: coord,
 476 |                   resolvedBy,
 477 |                   transport: 'cdp',
 478 |                 }),
 479 |               },
 480 |             ],
 481 |             isError: false,
 482 |           };
 483 |         } catch (error) {
 484 |           console.warn('[ComputerTool] CDP hover failed, attempting DOM fallback', error);
 485 |           return await this.domHoverFallback(tab.id, coord, resolvedBy, params.ref);
 486 |         }
 487 |       }
 488 |       case 'left_click':
 489 |       case 'right_click': {
 490 |         // Calculate CDP modifier mask for click events
 491 |         const modifiersMask = CDPHelper.modifierMask(
 492 |           [
 493 |             params.modifiers?.altKey ? 'alt' : undefined,
 494 |             params.modifiers?.ctrlKey ? 'ctrl' : undefined,
 495 |             params.modifiers?.metaKey ? 'meta' : undefined,
 496 |             params.modifiers?.shiftKey ? 'shift' : undefined,
 497 |           ].filter((v): v is string => typeof v === 'string'),
 498 |         );
 499 | 
 500 |         if (params.ref) {
 501 |           // Prefer DOM click via ref
 502 |           const domResult = await clickTool.execute({
 503 |             ref: params.ref,
 504 |             waitForNavigation: false,
 505 |             timeout: TIMEOUTS.DEFAULT_WAIT * 5,
 506 |             button: params.action === 'right_click' ? 'right' : 'left',
 507 |             modifiers: params.modifiers,
 508 |           });
 509 |           return domResult;
 510 |         }
 511 |         if (params.selector) {
 512 |           // Support selector-based click
 513 |           const domResult = await clickTool.execute({
 514 |             selector: params.selector,
 515 |             selectorType: params.selectorType,
 516 |             frameId: params.frameId,
 517 |             waitForNavigation: false,
 518 |             timeout: TIMEOUTS.DEFAULT_WAIT * 5,
 519 |             button: params.action === 'right_click' ? 'right' : 'left',
 520 |             modifiers: params.modifiers,
 521 |           });
 522 |           return domResult;
 523 |         }
 524 |         if (!params.coordinates)
 525 |           return createErrorResponse('Provide ref, selector, or coordinates for click action');
 526 |         {
 527 |           const stale = ((): any => {
 528 |             const getHostname = (url: string): string => {
 529 |               try {
 530 |                 return new URL(url).hostname;
 531 |               } catch {
 532 |                 return '';
 533 |               }
 534 |             };
 535 |             const currentHostname = getHostname(tab.url || '');
 536 |             const ctx = screenshotContextManager.getContext(tab.id!);
 537 |             const contextHostname = (ctx as any)?.hostname as string | undefined;
 538 |             if (contextHostname && contextHostname !== currentHostname) {
 539 |               return createErrorResponse(
 540 |                 `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during ${params.action}. Capture a new screenshot or use ref/selector.`,
 541 |               );
 542 |             }
 543 |             return null;
 544 |           })();
 545 |           if (stale) return stale;
 546 |         }
 547 |         const coord = project(params.coordinates)!;
 548 |         // Prefer DOM path via existing click tool
 549 |         const domResult = await clickTool.execute({
 550 |           coordinates: coord,
 551 |           waitForNavigation: false,
 552 |           timeout: TIMEOUTS.DEFAULT_WAIT * 5,
 553 |           button: params.action === 'right_click' ? 'right' : 'left',
 554 |           modifiers: params.modifiers,
 555 |         });
 556 |         if (!domResult.isError) {
 557 |           return domResult; // Standardized response from click tool
 558 |         }
 559 |         // Fallback to CDP if DOM failed
 560 |         try {
 561 |           await CDPHelper.attach(tab.id);
 562 |           const button: MouseButton = params.action === 'right_click' ? 'right' : 'left';
 563 |           const clickCount = 1;
 564 |           await CDPHelper.dispatchMouseEvent(tab.id, {
 565 |             type: 'mouseMoved',
 566 |             x: coord.x,
 567 |             y: coord.y,
 568 |             button: 'none',
 569 |             buttons: 0,
 570 |             modifiers: modifiersMask,
 571 |           });
 572 |           for (let i = 1; i <= clickCount; i++) {
 573 |             await CDPHelper.dispatchMouseEvent(tab.id, {
 574 |               type: 'mousePressed',
 575 |               x: coord.x,
 576 |               y: coord.y,
 577 |               button,
 578 |               buttons: button === 'left' ? 1 : 2,
 579 |               clickCount: i,
 580 |               modifiers: modifiersMask,
 581 |             });
 582 |             await CDPHelper.dispatchMouseEvent(tab.id, {
 583 |               type: 'mouseReleased',
 584 |               x: coord.x,
 585 |               y: coord.y,
 586 |               button,
 587 |               buttons: 0,
 588 |               clickCount: i,
 589 |               modifiers: modifiersMask,
 590 |             });
 591 |           }
 592 |           await CDPHelper.detach(tab.id);
 593 |           return {
 594 |             content: [
 595 |               {
 596 |                 type: 'text',
 597 |                 text: JSON.stringify({
 598 |                   success: true,
 599 |                   action: params.action,
 600 |                   coordinates: coord,
 601 |                 }),
 602 |               },
 603 |             ],
 604 |             isError: false,
 605 |           };
 606 |         } catch (e) {
 607 |           await CDPHelper.detach(tab.id);
 608 |           return createErrorResponse(
 609 |             `CDP click failed: ${e instanceof Error ? e.message : String(e)}`,
 610 |           );
 611 |         }
 612 |       }
 613 |       case 'double_click':
 614 |       case 'triple_click': {
 615 |         // Calculate CDP modifier mask for click events
 616 |         const modifiersMask = CDPHelper.modifierMask(
 617 |           [
 618 |             params.modifiers?.altKey ? 'alt' : undefined,
 619 |             params.modifiers?.ctrlKey ? 'ctrl' : undefined,
 620 |             params.modifiers?.metaKey ? 'meta' : undefined,
 621 |             params.modifiers?.shiftKey ? 'shift' : undefined,
 622 |           ].filter((v): v is string => typeof v === 'string'),
 623 |         );
 624 | 
 625 |         if (!params.coordinates && !params.ref && !params.selector)
 626 |           return createErrorResponse(
 627 |             'Provide ref, selector, or coordinates for double/triple click',
 628 |           );
 629 |         let coord = params.coordinates ? project(params.coordinates)! : (undefined as any);
 630 |         // If ref is provided, resolve center via accessibility helper
 631 |         if (params.ref) {
 632 |           try {
 633 |             await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
 634 |             const resolved = await this.sendMessageToTab(tab.id, {
 635 |               action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
 636 |               ref: params.ref,
 637 |             });
 638 |             if (resolved && resolved.success) {
 639 |               coord = project({ x: resolved.center.x, y: resolved.center.y })!;
 640 |             }
 641 |           } catch (e) {
 642 |             // ignore and use provided coordinates
 643 |           }
 644 |         } else if (params.selector) {
 645 |           // Support selector-based click
 646 |           try {
 647 |             await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
 648 |             const selectorType = params.selectorType || 'css';
 649 |             const ensured = await this.sendMessageToTab(
 650 |               tab.id,
 651 |               {
 652 |                 action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
 653 |                 selector: params.selector,
 654 |                 isXPath: selectorType === 'xpath',
 655 |               },
 656 |               params.frameId,
 657 |             );
 658 |             if (ensured && ensured.success) {
 659 |               coord = project({ x: ensured.center.x, y: ensured.center.y })!;
 660 |             }
 661 |           } catch (e) {
 662 |             // ignore
 663 |           }
 664 |         }
 665 |         if (!coord) return createErrorResponse('Failed to resolve coordinates from ref/selector');
 666 |         {
 667 |           const stale = ((): any => {
 668 |             if (!params.coordinates) return null;
 669 |             const getHostname = (url: string): string => {
 670 |               try {
 671 |                 return new URL(url).hostname;
 672 |               } catch {
 673 |                 return '';
 674 |               }
 675 |             };
 676 |             const currentHostname = getHostname(tab.url || '');
 677 |             const ctx = screenshotContextManager.getContext(tab.id!);
 678 |             const contextHostname = (ctx as any)?.hostname as string | undefined;
 679 |             if (contextHostname && contextHostname !== currentHostname) {
 680 |               return createErrorResponse(
 681 |                 `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during ${params.action}. Capture a new screenshot or use ref/selector.`,
 682 |               );
 683 |             }
 684 |             return null;
 685 |           })();
 686 |           if (stale) return stale;
 687 |         }
 688 |         try {
 689 |           await CDPHelper.attach(tab.id);
 690 |           const button: MouseButton = 'left';
 691 |           const clickCount = params.action === 'double_click' ? 2 : 3;
 692 |           await CDPHelper.dispatchMouseEvent(tab.id, {
 693 |             type: 'mouseMoved',
 694 |             x: coord.x,
 695 |             y: coord.y,
 696 |             button: 'none',
 697 |             buttons: 0,
 698 |             modifiers: modifiersMask,
 699 |           });
 700 |           for (let i = 1; i <= clickCount; i++) {
 701 |             await CDPHelper.dispatchMouseEvent(tab.id, {
 702 |               type: 'mousePressed',
 703 |               x: coord.x,
 704 |               y: coord.y,
 705 |               button,
 706 |               buttons: 1,
 707 |               clickCount: i,
 708 |               modifiers: modifiersMask,
 709 |             });
 710 |             await CDPHelper.dispatchMouseEvent(tab.id, {
 711 |               type: 'mouseReleased',
 712 |               x: coord.x,
 713 |               y: coord.y,
 714 |               button,
 715 |               buttons: 0,
 716 |               clickCount: i,
 717 |               modifiers: modifiersMask,
 718 |             });
 719 |           }
 720 |           await CDPHelper.detach(tab.id);
 721 |           return {
 722 |             content: [
 723 |               {
 724 |                 type: 'text',
 725 |                 text: JSON.stringify({
 726 |                   success: true,
 727 |                   action: params.action,
 728 |                   coordinates: coord,
 729 |                 }),
 730 |               },
 731 |             ],
 732 |             isError: false,
 733 |           };
 734 |         } catch (e) {
 735 |           await CDPHelper.detach(tab.id);
 736 |           return createErrorResponse(
 737 |             `CDP ${params.action} failed: ${e instanceof Error ? e.message : String(e)}`,
 738 |           );
 739 |         }
 740 |       }
 741 |       case 'left_click_drag': {
 742 |         if (!params.startCoordinates && !params.startRef)
 743 |           return createErrorResponse('Provide startRef or startCoordinates for drag');
 744 |         if (!params.coordinates && !params.ref)
 745 |           return createErrorResponse('Provide ref or end coordinates for drag');
 746 |         let start = params.startCoordinates
 747 |           ? project(params.startCoordinates)!
 748 |           : (undefined as any);
 749 |         let end = params.coordinates ? project(params.coordinates)! : (undefined as any);
 750 |         {
 751 |           const stale = ((): any => {
 752 |             if (!params.startCoordinates && !params.coordinates) return null;
 753 |             const getHostname = (url: string): string => {
 754 |               try {
 755 |                 return new URL(url).hostname;
 756 |               } catch {
 757 |                 return '';
 758 |               }
 759 |             };
 760 |             const currentHostname = getHostname(tab.url || '');
 761 |             const ctx = screenshotContextManager.getContext(tab.id!);
 762 |             const contextHostname = (ctx as any)?.hostname as string | undefined;
 763 |             if (contextHostname && contextHostname !== currentHostname) {
 764 |               return createErrorResponse(
 765 |                 `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during left_click_drag. Capture a new screenshot or use ref/selector.`,
 766 |               );
 767 |             }
 768 |             return null;
 769 |           })();
 770 |           if (stale) return stale;
 771 |         }
 772 |         if (params.startRef || params.ref) {
 773 |           await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
 774 |         }
 775 |         if (params.startRef) {
 776 |           try {
 777 |             const resolved = await this.sendMessageToTab(tab.id, {
 778 |               action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
 779 |               ref: params.startRef,
 780 |             });
 781 |             if (resolved && resolved.success)
 782 |               start = project({ x: resolved.center.x, y: resolved.center.y })!;
 783 |           } catch {
 784 |             // ignore
 785 |           }
 786 |         }
 787 |         if (params.ref) {
 788 |           try {
 789 |             const resolved = await this.sendMessageToTab(tab.id, {
 790 |               action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
 791 |               ref: params.ref,
 792 |             });
 793 |             if (resolved && resolved.success)
 794 |               end = project({ x: resolved.center.x, y: resolved.center.y })!;
 795 |           } catch {
 796 |             // ignore
 797 |           }
 798 |         }
 799 |         if (!start || !end) return createErrorResponse('Failed to resolve drag coordinates');
 800 |         try {
 801 |           await CDPHelper.attach(tab.id);
 802 |           await CDPHelper.dispatchMouseEvent(tab.id, {
 803 |             type: 'mouseMoved',
 804 |             x: start.x,
 805 |             y: start.y,
 806 |             button: 'none',
 807 |             buttons: 0,
 808 |           });
 809 |           await CDPHelper.dispatchMouseEvent(tab.id, {
 810 |             type: 'mousePressed',
 811 |             x: start.x,
 812 |             y: start.y,
 813 |             button: 'left',
 814 |             buttons: 1,
 815 |             clickCount: 1,
 816 |           });
 817 |           await CDPHelper.dispatchMouseEvent(tab.id, {
 818 |             type: 'mouseMoved',
 819 |             x: end.x,
 820 |             y: end.y,
 821 |             button: 'left',
 822 |             buttons: 1,
 823 |           });
 824 |           await CDPHelper.dispatchMouseEvent(tab.id, {
 825 |             type: 'mouseReleased',
 826 |             x: end.x,
 827 |             y: end.y,
 828 |             button: 'left',
 829 |             buttons: 0,
 830 |             clickCount: 1,
 831 |           });
 832 |           await CDPHelper.detach(tab.id);
 833 |           return {
 834 |             content: [
 835 |               {
 836 |                 type: 'text',
 837 |                 text: JSON.stringify({ success: true, action: 'left_click_drag', start, end }),
 838 |               },
 839 |             ],
 840 |             isError: false,
 841 |           };
 842 |         } catch (e) {
 843 |           await CDPHelper.detach(tab.id);
 844 |           return createErrorResponse(`Drag failed: ${e instanceof Error ? e.message : String(e)}`);
 845 |         }
 846 |       }
 847 |       case 'scroll': {
 848 |         if (!params.coordinates && !params.ref)
 849 |           return createErrorResponse('Provide ref or coordinates for scroll');
 850 |         let coord = params.coordinates ? project(params.coordinates)! : (undefined as any);
 851 |         if (params.ref) {
 852 |           try {
 853 |             await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
 854 |             const resolved = await this.sendMessageToTab(tab.id, {
 855 |               action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
 856 |               ref: params.ref,
 857 |             });
 858 |             if (resolved && resolved.success)
 859 |               coord = project({ x: resolved.center.x, y: resolved.center.y })!;
 860 |           } catch {
 861 |             // ignore
 862 |           }
 863 |         }
 864 |         if (!coord) return createErrorResponse('Failed to resolve scroll coordinates');
 865 |         {
 866 |           const stale = ((): any => {
 867 |             if (!params.coordinates) return null;
 868 |             const getHostname = (url: string): string => {
 869 |               try {
 870 |                 return new URL(url).hostname;
 871 |               } catch {
 872 |                 return '';
 873 |               }
 874 |             };
 875 |             const currentHostname = getHostname(tab.url || '');
 876 |             const ctx = screenshotContextManager.getContext(tab.id!);
 877 |             const contextHostname = (ctx as any)?.hostname as string | undefined;
 878 |             if (contextHostname && contextHostname !== currentHostname) {
 879 |               return createErrorResponse(
 880 |                 `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during scroll. Capture a new screenshot or use ref/selector.`,
 881 |               );
 882 |             }
 883 |             return null;
 884 |           })();
 885 |           if (stale) return stale;
 886 |         }
 887 |         const direction = params.scrollDirection || 'down';
 888 |         const amount = Math.max(1, Math.min(params.scrollAmount || 3, 10));
 889 |         // Convert to deltas (~100px per tick)
 890 |         const unit = 100;
 891 |         let deltaX = 0,
 892 |           deltaY = 0;
 893 |         if (direction === 'up') deltaY = -amount * unit;
 894 |         if (direction === 'down') deltaY = amount * unit;
 895 |         if (direction === 'left') deltaX = -amount * unit;
 896 |         if (direction === 'right') deltaX = amount * unit;
 897 |         try {
 898 |           await CDPHelper.attach(tab.id);
 899 |           await CDPHelper.dispatchMouseEvent(tab.id, {
 900 |             type: 'mouseWheel',
 901 |             x: coord.x,
 902 |             y: coord.y,
 903 |             deltaX,
 904 |             deltaY,
 905 |           });
 906 |           await CDPHelper.detach(tab.id);
 907 |           return {
 908 |             content: [
 909 |               {
 910 |                 type: 'text',
 911 |                 text: JSON.stringify({
 912 |                   success: true,
 913 |                   action: 'scroll',
 914 |                   coordinates: coord,
 915 |                   deltaX,
 916 |                   deltaY,
 917 |                 }),
 918 |               },
 919 |             ],
 920 |             isError: false,
 921 |           };
 922 |         } catch (e) {
 923 |           await CDPHelper.detach(tab.id);
 924 |           return createErrorResponse(
 925 |             `Scroll failed: ${e instanceof Error ? e.message : String(e)}`,
 926 |           );
 927 |         }
 928 |       }
 929 |       case 'type': {
 930 |         if (!params.text) return createErrorResponse('Text parameter is required for type action');
 931 |         try {
 932 |           // Optional focus via ref before typing
 933 |           if (params.ref) {
 934 |             await clickTool.execute({
 935 |               ref: params.ref,
 936 |               waitForNavigation: false,
 937 |               timeout: TIMEOUTS.DEFAULT_WAIT * 5,
 938 |             });
 939 |           }
 940 |           await CDPHelper.attach(tab.id);
 941 |           // Use CDP insertText to avoid complex KeyboardEvent emulation for long text
 942 |           await CDPHelper.insertText(tab.id, params.text);
 943 |           await CDPHelper.detach(tab.id);
 944 |           return {
 945 |             content: [
 946 |               {
 947 |                 type: 'text',
 948 |                 text: JSON.stringify({
 949 |                   success: true,
 950 |                   action: 'type',
 951 |                   length: params.text.length,
 952 |                 }),
 953 |               },
 954 |             ],
 955 |             isError: false,
 956 |           };
 957 |         } catch (e) {
 958 |           await CDPHelper.detach(tab.id);
 959 |           // Fallback to DOM-based keyboard tool
 960 |           const res = await keyboardTool.execute({
 961 |             keys: params.text.split('').join(','),
 962 |             delay: 0,
 963 |             selector: undefined,
 964 |           });
 965 |           return res;
 966 |         }
 967 |       }
 968 |       case 'fill': {
 969 |         if (!params.ref && !params.selector) {
 970 |           return createErrorResponse('Provide ref or selector and a value for fill');
 971 |         }
 972 |         // Reuse existing fill tool to leverage robust DOM event behavior
 973 |         const res = await fillTool.execute({
 974 |           selector: params.selector as any,
 975 |           selectorType: params.selectorType as any,
 976 |           ref: params.ref as any,
 977 |           value: params.value as any,
 978 |         } as any);
 979 |         return res;
 980 |       }
 981 |       case 'fill_form': {
 982 |         const elements = (params as any).elements as Array<{
 983 |           ref: string;
 984 |           value: string | number | boolean;
 985 |         }>;
 986 |         if (!Array.isArray(elements) || elements.length === 0) {
 987 |           return createErrorResponse('elements must be a non-empty array for fill_form');
 988 |         }
 989 |         const results: Array<{ ref: string; ok: boolean; error?: string }> = [];
 990 |         for (const item of elements) {
 991 |           if (!item || !item.ref) {
 992 |             results.push({ ref: String(item?.ref || ''), ok: false, error: 'missing ref' });
 993 |             continue;
 994 |           }
 995 |           try {
 996 |             const r = await fillTool.execute({
 997 |               ref: item.ref as any,
 998 |               value: item.value as any,
 999 |             } as any);
1000 |             const ok = !r.isError;
1001 |             results.push({ ref: item.ref, ok, error: ok ? undefined : 'failed' });
1002 |           } catch (e) {
1003 |             results.push({
1004 |               ref: item.ref,
1005 |               ok: false,
1006 |               error: String(e instanceof Error ? e.message : e),
1007 |             });
1008 |           }
1009 |         }
1010 |         const successCount = results.filter((r) => r.ok).length;
1011 |         return {
1012 |           content: [
1013 |             {
1014 |               type: 'text',
1015 |               text: JSON.stringify({
1016 |                 success: true,
1017 |                 action: 'fill_form',
1018 |                 filled: successCount,
1019 |                 total: results.length,
1020 |                 results,
1021 |               }),
1022 |             },
1023 |           ],
1024 |           isError: false,
1025 |         };
1026 |       }
1027 |       case 'key': {
1028 |         if (!params.text)
1029 |           return createErrorResponse(
1030 |             'text is required for key action (e.g., "Backspace Backspace Enter" or "cmd+a")',
1031 |           );
1032 |         const tokens = params.text.trim().split(/\s+/).filter(Boolean);
1033 |         const repeat = params.repeat ?? 1;
1034 |         if (!Number.isInteger(repeat) || repeat < 1 || repeat > 100) {
1035 |           return createErrorResponse('repeat must be an integer between 1 and 100 for key action');
1036 |         }
1037 |         try {
1038 |           // Optional focus via ref before key events
1039 |           if (params.ref) {
1040 |             await clickTool.execute({
1041 |               ref: params.ref,
1042 |               waitForNavigation: false,
1043 |               timeout: TIMEOUTS.DEFAULT_WAIT * 5,
1044 |             });
1045 |           }
1046 |           await CDPHelper.attach(tab.id);
1047 |           for (let i = 0; i < repeat; i++) {
1048 |             for (const t of tokens) {
1049 |               if (t.includes('+')) await CDPHelper.dispatchKeyChord(tab.id, t);
1050 |               else await CDPHelper.dispatchSimpleKey(tab.id, t);
1051 |             }
1052 |           }
1053 |           await CDPHelper.detach(tab.id);
1054 |           return {
1055 |             content: [
1056 |               {
1057 |                 type: 'text',
1058 |                 text: JSON.stringify({ success: true, action: 'key', keys: tokens, repeat }),
1059 |               },
1060 |             ],
1061 |             isError: false,
1062 |           };
1063 |         } catch (e) {
1064 |           await CDPHelper.detach(tab.id);
1065 |           // Fallback to DOM keyboard simulation (comma-separated combinations)
1066 |           const keysStr = tokens.join(',');
1067 |           const repeatedKeys =
1068 |             repeat === 1 ? keysStr : Array.from({ length: repeat }, () => keysStr).join(',');
1069 |           const res = await keyboardTool.execute({ keys: repeatedKeys });
1070 |           return res;
1071 |         }
1072 |       }
1073 |       case 'wait': {
1074 |         const hasTextCondition =
1075 |           typeof (params as any).text === 'string' && (params as any).text.trim().length > 0;
1076 |         if (hasTextCondition) {
1077 |           try {
1078 |             // Conditional wait for text appearance/disappearance using content script
1079 |             await this.injectContentScript(
1080 |               tab.id,
1081 |               ['inject-scripts/wait-helper.js'],
1082 |               false,
1083 |               'ISOLATED',
1084 |               true,
1085 |             );
1086 |             const appear = (params as any).appear !== false; // default to true
1087 |             const timeoutMs = Math.max(
1088 |               0,
1089 |               Math.min(((params as any).timeout as number) || 10000, 120000),
1090 |             );
1091 |             const resp = await this.sendMessageToTab(tab.id, {
1092 |               action: TOOL_MESSAGE_TYPES.WAIT_FOR_TEXT,
1093 |               text: (params as any).text,
1094 |               appear,
1095 |               timeout: timeoutMs,
1096 |             });
1097 |             if (!resp || resp.success !== true) {
1098 |               return createErrorResponse(
1099 |                 resp && resp.reason === 'timeout'
1100 |                   ? `wait_for timed out after ${timeoutMs}ms for text: ${(params as any).text}`
1101 |                   : `wait_for failed: ${resp && resp.error ? resp.error : 'unknown error'}`,
1102 |               );
1103 |             }
1104 |             return {
1105 |               content: [
1106 |                 {
1107 |                   type: 'text',
1108 |                   text: JSON.stringify({
1109 |                     success: true,
1110 |                     action: 'wait_for',
1111 |                     appear,
1112 |                     text: (params as any).text,
1113 |                     matched: resp.matched || null,
1114 |                     tookMs: resp.tookMs,
1115 |                   }),
1116 |                 },
1117 |               ],
1118 |               isError: false,
1119 |             };
1120 |           } catch (e) {
1121 |             return createErrorResponse(
1122 |               `wait_for failed: ${e instanceof Error ? e.message : String(e)}`,
1123 |             );
1124 |           }
1125 |         } else {
1126 |           const seconds = Math.max(0, Math.min((params as any).duration || 0, 30));
1127 |           if (!seconds)
1128 |             return createErrorResponse('Duration parameter is required and must be > 0');
1129 |           await new Promise((r) => setTimeout(r, seconds * 1000));
1130 |           return {
1131 |             content: [
1132 |               {
1133 |                 type: 'text',
1134 |                 text: JSON.stringify({ success: true, action: 'wait', duration: seconds }),
1135 |               },
1136 |             ],
1137 |             isError: false,
1138 |           };
1139 |         }
1140 |       }
1141 |       case 'scroll_to': {
1142 |         if (!params.ref) {
1143 |           return createErrorResponse('ref is required for scroll_to action');
1144 |         }
1145 |         try {
1146 |           await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
1147 |           const resp = await this.sendMessageToTab(tab.id, {
1148 |             action: 'focusByRef',
1149 |             ref: params.ref,
1150 |           });
1151 |           if (!resp || resp.success !== true) {
1152 |             return createErrorResponse(resp?.error || 'scroll_to failed: element not found');
1153 |           }
1154 |           return {
1155 |             content: [
1156 |               {
1157 |                 type: 'text',
1158 |                 text: JSON.stringify({
1159 |                   success: true,
1160 |                   action: 'scroll_to',
1161 |                   ref: params.ref,
1162 |                 }),
1163 |               },
1164 |             ],
1165 |             isError: false,
1166 |           };
1167 |         } catch (e) {
1168 |           return createErrorResponse(
1169 |             `scroll_to failed: ${e instanceof Error ? e.message : String(e)}`,
1170 |           );
1171 |         }
1172 |       }
1173 |       case 'zoom': {
1174 |         const region = params.region;
1175 |         if (!region) {
1176 |           return createErrorResponse('region is required for zoom action');
1177 |         }
1178 |         const x0 = Number(region.x0);
1179 |         const y0 = Number(region.y0);
1180 |         const x1 = Number(region.x1);
1181 |         const y1 = Number(region.y1);
1182 |         if (![x0, y0, x1, y1].every(Number.isFinite)) {
1183 |           return createErrorResponse('region must contain finite numbers (x0, y0, x1, y1)');
1184 |         }
1185 |         if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0) {
1186 |           return createErrorResponse('Invalid region: require x0>=0, y0>=0 and x1>x0, y1>y0');
1187 |         }
1188 | 
1189 |         // Project coordinates from screenshot space to viewport space
1190 |         const p0 = project({ x: x0, y: y0 })!;
1191 |         const p1 = project({ x: x1, y: y1 })!;
1192 |         const rx0 = Math.min(p0.x, p1.x);
1193 |         const ry0 = Math.min(p0.y, p1.y);
1194 |         const rx1 = Math.max(p0.x, p1.x);
1195 |         const ry1 = Math.max(p0.y, p1.y);
1196 |         const w = rx1 - rx0;
1197 |         const h = ry1 - ry0;
1198 |         if (w <= 0 || h <= 0) {
1199 |           return createErrorResponse('Invalid region after projection');
1200 |         }
1201 | 
1202 |         // Security check: verify domain hasn't changed since last screenshot
1203 |         {
1204 |           const getHostname = (url: string): string => {
1205 |             try {
1206 |               return new URL(url).hostname;
1207 |             } catch {
1208 |               return '';
1209 |             }
1210 |           };
1211 |           const ctx = screenshotContextManager.getContext(tab.id!);
1212 |           const contextHostname = (ctx as any)?.hostname as string | undefined;
1213 |           const currentHostname = getHostname(tab.url || '');
1214 |           if (contextHostname && contextHostname !== currentHostname) {
1215 |             return createErrorResponse(
1216 |               `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during zoom. Capture a new screenshot first.`,
1217 |             );
1218 |           }
1219 |         }
1220 | 
1221 |         try {
1222 |           await CDPHelper.attach(tab.id);
1223 |           const metrics: any = await CDPHelper.send(tab.id, 'Page.getLayoutMetrics', {});
1224 |           const viewport = metrics?.layoutViewport ||
1225 |             metrics?.visualViewport || {
1226 |               clientWidth: 800,
1227 |               clientHeight: 600,
1228 |               pageX: 0,
1229 |               pageY: 0,
1230 |             };
1231 |           const vw = Math.round(Number(viewport.clientWidth || 800));
1232 |           const vh = Math.round(Number(viewport.clientHeight || 600));
1233 |           if (rx1 > vw || ry1 > vh) {
1234 |             await CDPHelper.detach(tab.id);
1235 |             return createErrorResponse(
1236 |               `Region exceeds viewport boundaries (${vw}x${vh}). Choose a region within the visible viewport.`,
1237 |             );
1238 |           }
1239 |           const pageX = Number(viewport.pageX || 0);
1240 |           const pageY = Number(viewport.pageY || 0);
1241 | 
1242 |           const shot: any = await CDPHelper.send(tab.id, 'Page.captureScreenshot', {
1243 |             format: 'png',
1244 |             captureBeyondViewport: false,
1245 |             fromSurface: true,
1246 |             clip: {
1247 |               x: pageX + rx0,
1248 |               y: pageY + ry0,
1249 |               width: w,
1250 |               height: h,
1251 |               scale: 1,
1252 |             },
1253 |           });
1254 |           await CDPHelper.detach(tab.id);
1255 | 
1256 |           const base64Data = String(shot?.data || '');
1257 |           if (!base64Data) {
1258 |             return createErrorResponse('Failed to capture zoom screenshot via CDP');
1259 |           }
1260 |           return {
1261 |             content: [
1262 |               {
1263 |                 type: 'text',
1264 |                 text: JSON.stringify({
1265 |                   success: true,
1266 |                   action: 'zoom',
1267 |                   mimeType: 'image/png',
1268 |                   base64Data,
1269 |                   region: { x0: rx0, y0: ry0, x1: rx1, y1: ry1 },
1270 |                 }),
1271 |               },
1272 |             ],
1273 |             isError: false,
1274 |           };
1275 |         } catch (e) {
1276 |           await CDPHelper.detach(tab.id);
1277 |           return createErrorResponse(`zoom failed: ${e instanceof Error ? e.message : String(e)}`);
1278 |         }
1279 |       }
1280 |       case 'screenshot': {
1281 |         // Reuse existing screenshot tool; it already supports base64 save option
1282 |         const result = await screenshotTool.execute({
1283 |           name: 'computer',
1284 |           storeBase64: true,
1285 |           fullPage: false,
1286 |         });
1287 |         return result;
1288 |       }
1289 |       default:
1290 |         return createErrorResponse(`Unsupported action: ${params.action}`);
1291 |     }
1292 |   }
1293 | 
1294 |   /**
1295 |    * DOM-based hover fallback when CDP is unavailable
1296 |    * Tries ref-based approach first (works with iframes), falls back to coordinates
1297 |    */
1298 |   private async domHoverFallback(
1299 |     tabId: number,
1300 |     coord?: Coordinates,
1301 |     resolvedBy?: 'ref' | 'selector' | 'coordinates',
1302 |     ref?: string,
1303 |   ): Promise<ToolResult> {
1304 |     // Try ref-based approach first (handles iframes correctly)
1305 |     if (ref) {
1306 |       try {
1307 |         const resp = await this.sendMessageToTab(tabId, {
1308 |           action: TOOL_MESSAGE_TYPES.DISPATCH_HOVER_FOR_REF,
1309 |           ref,
1310 |         });
1311 |         if (resp?.success) {
1312 |           return {
1313 |             content: [
1314 |               {
1315 |                 type: 'text',
1316 |                 text: JSON.stringify({
1317 |                   success: true,
1318 |                   action: 'hover',
1319 |                   resolvedBy: 'ref',
1320 |                   transport: 'dom-ref',
1321 |                   target: resp.target,
1322 |                 }),
1323 |               },
1324 |             ],
1325 |             isError: false,
1326 |           };
1327 |         }
1328 |       } catch (error) {
1329 |         console.warn('[ComputerTool] DOM ref hover failed, falling back to coordinates', error);
1330 |       }
1331 |     }
1332 | 
1333 |     // Fallback to coordinate-based approach
1334 |     if (!coord) {
1335 |       return createErrorResponse('Hover fallback requires coordinates or ref');
1336 |     }
1337 | 
1338 |     try {
1339 |       const [injection] = await chrome.scripting.executeScript({
1340 |         target: { tabId },
1341 |         world: 'MAIN',
1342 |         func: (point) => {
1343 |           const target = document.elementFromPoint(point.x, point.y);
1344 |           if (!target) {
1345 |             return { success: false, error: 'No element found at coordinates' };
1346 |           }
1347 | 
1348 |           // Dispatch hover-related events
1349 |           for (const type of ['mousemove', 'mouseover', 'mouseenter']) {
1350 |             target.dispatchEvent(
1351 |               new MouseEvent(type, {
1352 |                 bubbles: true,
1353 |                 cancelable: true,
1354 |                 clientX: point.x,
1355 |                 clientY: point.y,
1356 |                 view: window,
1357 |               }),
1358 |             );
1359 |           }
1360 | 
1361 |           return {
1362 |             success: true,
1363 |             target: {
1364 |               tagName: target.tagName,
1365 |               id: target.id,
1366 |               className: target.className,
1367 |               text: target.textContent?.trim()?.slice(0, 100) || '',
1368 |             },
1369 |           };
1370 |         },
1371 |         args: [coord],
1372 |       });
1373 | 
1374 |       const payload = injection?.result;
1375 |       if (!payload?.success) {
1376 |         return createErrorResponse(payload?.error || 'DOM hover fallback failed');
1377 |       }
1378 | 
1379 |       return {
1380 |         content: [
1381 |           {
1382 |             type: 'text',
1383 |             text: JSON.stringify({
1384 |               success: true,
1385 |               action: 'hover',
1386 |               coordinates: coord,
1387 |               resolvedBy,
1388 |               transport: 'dom',
1389 |               target: payload.target,
1390 |             }),
1391 |           },
1392 |         ],
1393 |         isError: false,
1394 |       };
1395 |     } catch (error) {
1396 |       return createErrorResponse(
1397 |         `DOM hover fallback failed: ${error instanceof Error ? error.message : String(error)}`,
1398 |       );
1399 |     }
1400 |   }
1401 | 
1402 |   /**
1403 |    * Trigger GIF auto-capture after a successful action.
1404 |    * This is a no-op if auto-capture is not active.
1405 |    */
1406 |   private async triggerAutoCapture(
1407 |     tabId: number,
1408 |     actionType: ActionType,
1409 |     metadata?: Partial<ActionMetadata>,
1410 |   ): Promise<void> {
1411 |     if (!isAutoCaptureActive(tabId)) {
1412 |       return;
1413 |     }
1414 | 
1415 |     try {
1416 |       await captureFrameOnAction(tabId, {
1417 |         type: actionType,
1418 |         ...metadata,
1419 |       });
1420 |     } catch (error) {
1421 |       // Log but don't fail the main action
1422 |       console.warn('[ComputerTool] Auto-capture failed:', error);
1423 |     }
1424 |   }
1425 | }
1426 | 
1427 | export const computerTool = new ComputerTool();
1428 | 
```
Page 45/60FirstPrevNextLast