diff --git a/.agents/skills/frontend-design/LICENSE.txt b/.agents/skills/frontend-design/LICENSE.txt
new file mode 100644
index 00000000..f433b1a5
--- /dev/null
+++ b/.agents/skills/frontend-design/LICENSE.txt
@@ -0,0 +1,177 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md
new file mode 100644
index 00000000..5be498e2
--- /dev/null
+++ b/.agents/skills/frontend-design/SKILL.md
@@ -0,0 +1,42 @@
+---
+name: frontend-design
+description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
+license: Complete terms in LICENSE.txt
+---
+
+This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
+
+The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
+
+## Design Thinking
+
+Before coding, understand the context and commit to a BOLD aesthetic direction:
+- **Purpose**: What problem does this interface solve? Who uses it?
+- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
+- **Constraints**: Technical requirements (framework, performance, accessibility).
+- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
+
+**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
+
+Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
+- Production-grade and functional
+- Visually striking and memorable
+- Cohesive with a clear aesthetic point-of-view
+- Meticulously refined in every detail
+
+## Frontend Aesthetics Guidelines
+
+Focus on:
+- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
+- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
+- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
+- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
+- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
+
+NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
+
+Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
+
+**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
+
+Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
diff --git a/.agents/skills/vercel-composition-patterns/AGENTS.md b/.agents/skills/vercel-composition-patterns/AGENTS.md
new file mode 100644
index 00000000..558bf9aa
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/AGENTS.md
@@ -0,0 +1,946 @@
+# React Composition Patterns
+
+**Version 1.0.0**
+Engineering
+January 2026
+
+> **Note:**
+> This document is mainly for agents and LLMs to follow when maintaining,
+> generating, or refactoring React codebases using composition. Humans
+> may also find it useful, but guidance here is optimized for automation
+> and consistency by AI-assisted workflows.
+
+---
+
+## Abstract
+
+Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale.
+
+---
+
+## Table of Contents
+
+1. [Component Architecture](#1-component-architecture) — **HIGH**
+ - 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation)
+ - 1.2 [Use Compound Components](#12-use-compound-components)
+2. [State Management](#2-state-management) — **MEDIUM**
+ - 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui)
+ - 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection)
+ - 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components)
+3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM**
+ - 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants)
+ - 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props)
+4. [React 19 APIs](#4-react-19-apis) — **MEDIUM**
+ - 4.1 [React 19 API Changes](#41-react-19-api-changes)
+
+---
+
+## 1. Component Architecture
+
+**Impact: HIGH**
+
+Fundamental patterns for structuring components to avoid prop
+proliferation and enable flexible composition.
+
+### 1.1 Avoid Boolean Prop Proliferation
+
+**Impact: CRITICAL (prevents unmaintainable component variants)**
+
+Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
+
+component behavior. Each boolean doubles possible states and creates
+
+unmaintainable conditional logic. Use composition instead.
+
+**Incorrect: boolean props create exponential complexity**
+
+```tsx
+function Composer({
+ onSubmit,
+ isThread,
+ channelId,
+ isDMThread,
+ dmId,
+ isEditing,
+ isForwarding,
+}: Props) {
+ return (
+
+ )
+}
+```
+
+**Correct: composition eliminates conditionals**
+
+```tsx
+// Channel composer
+function ChannelComposer() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Thread composer - adds "also send to channel" field
+function ThreadComposer({ channelId }: { channelId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Edit composer - different footer actions
+function EditComposer() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+```
+
+Each variant is explicit about what it renders. We can share internals without
+
+sharing a single monolithic parent.
+
+### 1.2 Use Compound Components
+
+**Impact: HIGH (enables flexible composition without prop drilling)**
+
+Structure complex components as compound components with a shared context. Each
+
+subcomponent accesses shared state via context, not props. Consumers compose the
+
+pieces they need.
+
+**Incorrect: monolithic component with render props**
+
+```tsx
+function Composer({
+ renderHeader,
+ renderFooter,
+ renderActions,
+ showAttachments,
+ showFormatting,
+ showEmojis,
+}: Props) {
+ return (
+
+ )
+}
+```
+
+**Correct: compound components with shared context**
+
+```tsx
+const ComposerContext = createContext(null)
+
+function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+function ComposerFrame({ children }: { children: React.ReactNode }) {
+ return
+}
+
+function ComposerInput() {
+ const {
+ state,
+ actions: { update },
+ meta: { inputRef },
+ } = use(ComposerContext)
+ return (
+ update((s) => ({ ...s, input: text }))}
+ />
+ )
+}
+
+function ComposerSubmit() {
+ const {
+ actions: { submit },
+ } = use(ComposerContext)
+ return
+}
+
+// Export as compound component
+const Composer = {
+ Provider: ComposerProvider,
+ Frame: ComposerFrame,
+ Input: ComposerInput,
+ Submit: ComposerSubmit,
+ Header: ComposerHeader,
+ Footer: ComposerFooter,
+ Attachments: ComposerAttachments,
+ Formatting: ComposerFormatting,
+ Emojis: ComposerEmojis,
+}
+```
+
+**Usage:**
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+```
+
+Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
+
+---
+
+## 2. State Management
+
+**Impact: MEDIUM**
+
+Patterns for lifting state and managing shared context across
+composed components.
+
+### 2.1 Decouple State Management from UI
+
+**Impact: MEDIUM (enables swapping state implementations without changing UI)**
+
+The provider component should be the only place that knows how state is managed.
+
+UI components consume the context interface—they don't know if state comes from
+
+useState, Zustand, or a server sync.
+
+**Incorrect: UI coupled to state implementation**
+
+```tsx
+function ChannelComposer({ channelId }: { channelId: string }) {
+ // UI component knows about global state implementation
+ const state = useGlobalChannelState(channelId)
+ const { submit, updateInput } = useChannelSync(channelId)
+
+ return (
+
+ sync.updateInput(text)}
+ />
+ sync.submit()} />
+
+ )
+}
+```
+
+**Correct: state management isolated in provider**
+
+```tsx
+// Provider handles all state management details
+function ChannelProvider({
+ channelId,
+ children,
+}: {
+ channelId: string
+ children: React.ReactNode
+}) {
+ const { state, update, submit } = useGlobalChannel(channelId)
+ const inputRef = useRef(null)
+
+ return (
+
+ {children}
+
+ )
+}
+
+// UI component only knows about the context interface
+function ChannelComposer() {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+// Usage
+function Channel({ channelId }: { channelId: string }) {
+ return (
+
+
+
+ )
+}
+```
+
+**Different providers, same UI:**
+
+```tsx
+// Local state for ephemeral forms
+function ForwardMessageProvider({ children }) {
+ const [state, setState] = useState(initialState)
+ const forwardMessage = useForwardMessage()
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Global synced state for channels
+function ChannelProvider({ channelId, children }) {
+ const { state, update, submit } = useGlobalChannel(channelId)
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+The same `Composer.Input` component works with both providers because it only
+
+depends on the context interface, not the implementation.
+
+### 2.2 Define Generic Context Interfaces for Dependency Injection
+
+**Impact: HIGH (enables dependency-injectable state across use-cases)**
+
+Define a **generic interface** for your component context with three parts:
+
+`state`, `actions`, and `meta`. This interface is a contract that any provider
+
+can implement—enabling the same UI components to work with completely different
+
+state implementations.
+
+**Core principle:** Lift state, compose internals, make state
+
+dependency-injectable.
+
+**Incorrect: UI coupled to specific state implementation**
+
+```tsx
+function ComposerInput() {
+ // Tightly coupled to a specific hook
+ const { input, setInput } = useChannelComposerState()
+ return
+}
+```
+
+**Correct: generic interface enables dependency injection**
+
+```tsx
+// Define a GENERIC interface that any provider can implement
+interface ComposerState {
+ input: string
+ attachments: Attachment[]
+ isSubmitting: boolean
+}
+
+interface ComposerActions {
+ update: (updater: (state: ComposerState) => ComposerState) => void
+ submit: () => void
+}
+
+interface ComposerMeta {
+ inputRef: React.RefObject
+}
+
+interface ComposerContextValue {
+ state: ComposerState
+ actions: ComposerActions
+ meta: ComposerMeta
+}
+
+const ComposerContext = createContext(null)
+```
+
+**UI components consume the interface, not the implementation:**
+
+```tsx
+function ComposerInput() {
+ const {
+ state,
+ actions: { update },
+ meta,
+ } = use(ComposerContext)
+
+ // This component works with ANY provider that implements the interface
+ return (
+ update((s) => ({ ...s, input: text }))}
+ />
+ )
+}
+```
+
+**Different providers implement the same interface:**
+
+```tsx
+// Provider A: Local state for ephemeral forms
+function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState(initialState)
+ const inputRef = useRef(null)
+ const submit = useForwardMessage()
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Provider B: Global synced state for channels
+function ChannelProvider({ channelId, children }: Props) {
+ const { state, update, submit } = useGlobalChannel(channelId)
+ const inputRef = useRef(null)
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+**The same composed UI works with both:**
+
+```tsx
+// Works with ForwardMessageProvider (local state)
+
+
+
+
+
+
+
+// Works with ChannelProvider (global synced state)
+
+
+
+
+
+
+```
+
+**Custom UI outside the component can access state and actions:**
+
+```tsx
+function ForwardMessageDialog() {
+ return (
+
+
+
+ )
+}
+
+// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
+function ForwardButton() {
+ const {
+ actions: { submit },
+ } = use(ComposerContext)
+ return
+}
+
+// This preview lives OUTSIDE Composer.Frame but can read composer's state!
+function MessagePreview() {
+ const { state } = use(ComposerContext)
+ return
+}
+```
+
+The provider boundary is what matters—not the visual nesting. Components that
+
+need shared state don't have to be inside the `Composer.Frame`. They just need
+
+to be within the provider.
+
+The `ForwardButton` and `MessagePreview` are not visually inside the composer
+
+box, but they can still access its state and actions. This is the power of
+
+lifting state into providers.
+
+The UI is reusable bits you compose together. The state is dependency-injected
+
+by the provider. Swap the provider, keep the UI.
+
+### 2.3 Lift State into Provider Components
+
+**Impact: HIGH (enables state sharing outside component boundaries)**
+
+Move state management into dedicated provider components. This allows sibling
+
+components outside the main UI to access and modify state without prop drilling
+
+or awkward refs.
+
+**Incorrect: state trapped inside component**
+
+```tsx
+function ForwardMessageComposer() {
+ const [state, setState] = useState(initialState)
+ const forwardMessage = useForwardMessage()
+
+ return (
+
+
+
+
+ )
+}
+
+// Problem: How does this button access composer state?
+function ForwardMessageDialog() {
+ return (
+
+ )
+}
+```
+
+**Incorrect: useEffect to sync state up**
+
+```tsx
+function ForwardMessageDialog() {
+ const [input, setInput] = useState('')
+ return (
+
+ )
+}
+
+function ForwardMessageComposer({ onInputChange }) {
+ const [state, setState] = useState(initialState)
+ useEffect(() => {
+ onInputChange(state.input) // Sync on every change 😬
+ }, [state.input])
+}
+```
+
+**Incorrect: reading state from ref on submit**
+
+```tsx
+function ForwardMessageDialog() {
+ const stateRef = useRef(null)
+ return (
+
+ )
+}
+```
+
+**Correct: state lifted to provider**
+
+```tsx
+function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState(initialState)
+ const forwardMessage = useForwardMessage()
+ const inputRef = useRef(null)
+
+ return (
+
+ {children}
+
+ )
+}
+
+function ForwardMessageDialog() {
+ return (
+
+
+
+ )
+}
+
+function ForwardButton() {
+ const { actions } = use(Composer.Context)
+ return
+}
+```
+
+The ForwardButton lives outside the Composer.Frame but still has access to the
+
+submit action because it's within the provider. Even though it's a one-off
+
+component, it can still access the composer's state and actions from outside the
+
+UI itself.
+
+**Key insight:** Components that need shared state don't have to be visually
+
+nested inside each other—they just need to be within the same provider.
+
+---
+
+## 3. Implementation Patterns
+
+**Impact: MEDIUM**
+
+Specific techniques for implementing compound components and
+context providers.
+
+### 3.1 Create Explicit Component Variants
+
+**Impact: MEDIUM (self-documenting code, no hidden conditionals)**
+
+Instead of one component with many boolean props, create explicit variant
+
+components. Each variant composes the pieces it needs. The code documents
+
+itself.
+
+**Incorrect: one component, many modes**
+
+```tsx
+// What does this component actually render?
+
+```
+
+**Correct: explicit variants**
+
+```tsx
+// Immediately clear what this renders
+
+
+// Or
+
+
+// Or
+
+```
+
+Each implementation is unique, explicit and self-contained. Yet they can each
+
+use shared parts.
+
+**Implementation:**
+
+```tsx
+function ThreadComposer({ channelId }: { channelId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function EditMessageComposer({ messageId }: { messageId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function ForwardMessageComposer({ messageId }: { messageId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+```
+
+Each variant is explicit about:
+
+- What provider/state it uses
+
+- What UI elements it includes
+
+- What actions are available
+
+No boolean prop combinations to reason about. No impossible states.
+
+### 3.2 Prefer Composing Children Over Render Props
+
+**Impact: MEDIUM (cleaner composition, better readability)**
+
+Use `children` for composition instead of `renderX` props. Children are more
+
+readable, compose naturally, and don't require understanding callback
+
+signatures.
+
+**Incorrect: render props**
+
+```tsx
+function Composer({
+ renderHeader,
+ renderFooter,
+ renderActions,
+}: {
+ renderHeader?: () => React.ReactNode
+ renderFooter?: () => React.ReactNode
+ renderActions?: () => React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+// Usage is awkward and inflexible
+return (
+ }
+ renderFooter={() => (
+ <>
+
+
+ >
+ )}
+ renderActions={() => }
+ />
+)
+```
+
+**Correct: compound components with children**
+
+```tsx
+function ComposerFrame({ children }: { children: React.ReactNode }) {
+ return
+}
+
+function ComposerFooter({ children }: { children: React.ReactNode }) {
+ return
+}
+
+// Usage is flexible
+return (
+
+
+
+
+
+
+
+
+
+)
+```
+
+**When render props are appropriate:**
+
+```tsx
+// Render props work well when you need to pass data back
+}
+/>
+```
+
+Use render props when the parent needs to provide data or state to the child.
+
+Use children when composing static structure.
+
+---
+
+## 4. React 19 APIs
+
+**Impact: MEDIUM**
+
+React 19+ only. Don't use `forwardRef`; use `use()` instead of `useContext()`.
+
+### 4.1 React 19 API Changes
+
+**Impact: MEDIUM (cleaner component definitions and context usage)**
+
+> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
+
+In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
+
+**Incorrect: forwardRef in React 19**
+
+```tsx
+const ComposerInput = forwardRef((props, ref) => {
+ return
+})
+```
+
+**Correct: ref as a regular prop**
+
+```tsx
+function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref }) {
+ return
+}
+```
+
+**Incorrect: useContext in React 19**
+
+```tsx
+const value = useContext(MyContext)
+```
+
+**Correct: use instead of useContext**
+
+```tsx
+const value = use(MyContext)
+```
+
+`use()` can also be called conditionally, unlike `useContext()`.
+
+---
+
+## References
+
+1. [https://react.dev](https://react.dev)
+2. [https://react.dev/learn/passing-data-deeply-with-context](https://react.dev/learn/passing-data-deeply-with-context)
+3. [https://react.dev/reference/react/use](https://react.dev/reference/react/use)
diff --git a/.agents/skills/vercel-composition-patterns/README.md b/.agents/skills/vercel-composition-patterns/README.md
new file mode 100644
index 00000000..01f359b0
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/README.md
@@ -0,0 +1,60 @@
+# React Composition Patterns
+
+A structured repository for React composition patterns that scale. These
+patterns help avoid boolean prop proliferation by using compound components,
+lifting state, and composing internals.
+
+## Structure
+
+- `rules/` - Individual rule files (one per rule)
+ - `_sections.md` - Section metadata (titles, impacts, descriptions)
+ - `_template.md` - Template for creating new rules
+ - `area-description.md` - Individual rule files
+- `metadata.json` - Document metadata (version, organization, abstract)
+- **`AGENTS.md`** - Compiled output (generated)
+
+## Rules
+
+### Component Architecture (CRITICAL)
+
+- `architecture-avoid-boolean-props.md` - Don't add boolean props to customize
+ behavior
+- `architecture-compound-components.md` - Structure as compound components with
+ shared context
+
+### State Management (HIGH)
+
+- `state-lift-state.md` - Lift state into provider components
+- `state-context-interface.md` - Define clear context interfaces
+ (state/actions/meta)
+- `state-decouple-implementation.md` - Decouple state management from UI
+
+### Implementation Patterns (MEDIUM)
+
+- `patterns-children-over-render-props.md` - Prefer children over renderX props
+- `patterns-explicit-variants.md` - Create explicit component variants
+
+## Core Principles
+
+1. **Composition over configuration** — Instead of adding props, let consumers
+ compose
+2. **Lift your state** — State in providers, not trapped in components
+3. **Compose your internals** — Subcomponents access context, not props
+4. **Explicit variants** — Create ThreadComposer, EditComposer, not Composer
+ with isThread
+
+## Creating a New Rule
+
+1. Copy `rules/_template.md` to `rules/area-description.md`
+2. Choose the appropriate area prefix:
+ - `architecture-` for Component Architecture
+ - `state-` for State Management
+ - `patterns-` for Implementation Patterns
+3. Fill in the frontmatter and content
+4. Ensure you have clear examples with explanations
+
+## Impact Levels
+
+- `CRITICAL` - Foundational patterns, prevents unmaintainable code
+- `HIGH` - Significant maintainability improvements
+- `MEDIUM` - Good practices for cleaner code
diff --git a/.agents/skills/vercel-composition-patterns/SKILL.md b/.agents/skills/vercel-composition-patterns/SKILL.md
new file mode 100644
index 00000000..d07025bf
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/SKILL.md
@@ -0,0 +1,89 @@
+---
+name: vercel-composition-patterns
+description:
+ React composition patterns that scale. Use when refactoring components with
+ boolean prop proliferation, building flexible component libraries, or
+ designing reusable APIs. Triggers on tasks involving compound components,
+ render props, context providers, or component architecture. Includes React 19
+ API changes.
+license: MIT
+metadata:
+ author: vercel
+ version: '1.0.0'
+---
+
+# React Composition Patterns
+
+Composition patterns for building flexible, maintainable React components. Avoid
+boolean prop proliferation by using compound components, lifting state, and
+composing internals. These patterns make codebases easier for both humans and AI
+agents to work with as they scale.
+
+## When to Apply
+
+Reference these guidelines when:
+
+- Refactoring components with many boolean props
+- Building reusable component libraries
+- Designing flexible component APIs
+- Reviewing component architecture
+- Working with compound components or context providers
+
+## Rule Categories by Priority
+
+| Priority | Category | Impact | Prefix |
+| -------- | ----------------------- | ------ | --------------- |
+| 1 | Component Architecture | HIGH | `architecture-` |
+| 2 | State Management | MEDIUM | `state-` |
+| 3 | Implementation Patterns | MEDIUM | `patterns-` |
+| 4 | React 19 APIs | MEDIUM | `react19-` |
+
+## Quick Reference
+
+### 1. Component Architecture (HIGH)
+
+- `architecture-avoid-boolean-props` - Don't add boolean props to customize
+ behavior; use composition
+- `architecture-compound-components` - Structure complex components with shared
+ context
+
+### 2. State Management (MEDIUM)
+
+- `state-decouple-implementation` - Provider is the only place that knows how
+ state is managed
+- `state-context-interface` - Define generic interface with state, actions, meta
+ for dependency injection
+- `state-lift-state` - Move state into provider components for sibling access
+
+### 3. Implementation Patterns (MEDIUM)
+
+- `patterns-explicit-variants` - Create explicit variant components instead of
+ boolean modes
+- `patterns-children-over-render-props` - Use children for composition instead
+ of renderX props
+
+### 4. React 19 APIs (MEDIUM)
+
+> **⚠️ React 19+ only.** Skip this section if using React 18 or earlier.
+
+- `react19-no-forwardref` - Don't use `forwardRef`; use `use()` instead of `useContext()`
+
+## How to Use
+
+Read individual rule files for detailed explanations and code examples:
+
+```
+rules/architecture-avoid-boolean-props.md
+rules/state-context-interface.md
+```
+
+Each rule file contains:
+
+- Brief explanation of why it matters
+- Incorrect code example with explanation
+- Correct code example with explanation
+- Additional context and references
+
+## Full Compiled Document
+
+For the complete guide with all rules expanded: `AGENTS.md`
diff --git a/.agents/skills/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md b/.agents/skills/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md
new file mode 100644
index 00000000..ccee19ce
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md
@@ -0,0 +1,100 @@
+---
+title: Avoid Boolean Prop Proliferation
+impact: CRITICAL
+impactDescription: prevents unmaintainable component variants
+tags: composition, props, architecture
+---
+
+## Avoid Boolean Prop Proliferation
+
+Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
+component behavior. Each boolean doubles possible states and creates
+unmaintainable conditional logic. Use composition instead.
+
+**Incorrect (boolean props create exponential complexity):**
+
+```tsx
+function Composer({
+ onSubmit,
+ isThread,
+ channelId,
+ isDMThread,
+ dmId,
+ isEditing,
+ isForwarding,
+}: Props) {
+ return (
+
+ )
+}
+```
+
+**Correct (composition eliminates conditionals):**
+
+```tsx
+// Channel composer
+function ChannelComposer() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Thread composer - adds "also send to channel" field
+function ThreadComposer({ channelId }: { channelId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Edit composer - different footer actions
+function EditComposer() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+```
+
+Each variant is explicit about what it renders. We can share internals without
+sharing a single monolithic parent.
diff --git a/.agents/skills/vercel-composition-patterns/rules/architecture-compound-components.md b/.agents/skills/vercel-composition-patterns/rules/architecture-compound-components.md
new file mode 100644
index 00000000..e5e3043c
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/rules/architecture-compound-components.md
@@ -0,0 +1,112 @@
+---
+title: Use Compound Components
+impact: HIGH
+impactDescription: enables flexible composition without prop drilling
+tags: composition, compound-components, architecture
+---
+
+## Use Compound Components
+
+Structure complex components as compound components with a shared context. Each
+subcomponent accesses shared state via context, not props. Consumers compose the
+pieces they need.
+
+**Incorrect (monolithic component with render props):**
+
+```tsx
+function Composer({
+ renderHeader,
+ renderFooter,
+ renderActions,
+ showAttachments,
+ showFormatting,
+ showEmojis,
+}: Props) {
+ return (
+
+ )
+}
+```
+
+**Correct (compound components with shared context):**
+
+```tsx
+const ComposerContext = createContext(null)
+
+function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+function ComposerFrame({ children }: { children: React.ReactNode }) {
+ return
+}
+
+function ComposerInput() {
+ const {
+ state,
+ actions: { update },
+ meta: { inputRef },
+ } = use(ComposerContext)
+ return (
+ update((s) => ({ ...s, input: text }))}
+ />
+ )
+}
+
+function ComposerSubmit() {
+ const {
+ actions: { submit },
+ } = use(ComposerContext)
+ return
+}
+
+// Export as compound component
+const Composer = {
+ Provider: ComposerProvider,
+ Frame: ComposerFrame,
+ Input: ComposerInput,
+ Submit: ComposerSubmit,
+ Header: ComposerHeader,
+ Footer: ComposerFooter,
+ Attachments: ComposerAttachments,
+ Formatting: ComposerFormatting,
+ Emojis: ComposerEmojis,
+}
+```
+
+**Usage:**
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+```
+
+Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
diff --git a/.agents/skills/vercel-composition-patterns/rules/patterns-children-over-render-props.md b/.agents/skills/vercel-composition-patterns/rules/patterns-children-over-render-props.md
new file mode 100644
index 00000000..d4345ee3
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/rules/patterns-children-over-render-props.md
@@ -0,0 +1,87 @@
+---
+title: Prefer Composing Children Over Render Props
+impact: MEDIUM
+impactDescription: cleaner composition, better readability
+tags: composition, children, render-props
+---
+
+## Prefer Children Over Render Props
+
+Use `children` for composition instead of `renderX` props. Children are more
+readable, compose naturally, and don't require understanding callback
+signatures.
+
+**Incorrect (render props):**
+
+```tsx
+function Composer({
+ renderHeader,
+ renderFooter,
+ renderActions,
+}: {
+ renderHeader?: () => React.ReactNode
+ renderFooter?: () => React.ReactNode
+ renderActions?: () => React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+// Usage is awkward and inflexible
+return (
+ }
+ renderFooter={() => (
+ <>
+
+
+ >
+ )}
+ renderActions={() => }
+ />
+)
+```
+
+**Correct (compound components with children):**
+
+```tsx
+function ComposerFrame({ children }: { children: React.ReactNode }) {
+ return
+}
+
+function ComposerFooter({ children }: { children: React.ReactNode }) {
+ return
+}
+
+// Usage is flexible
+return (
+
+
+
+
+
+
+
+
+
+)
+```
+
+**When render props are appropriate:**
+
+```tsx
+// Render props work well when you need to pass data back
+}
+/>
+```
+
+Use render props when the parent needs to provide data or state to the child.
+Use children when composing static structure.
diff --git a/.agents/skills/vercel-composition-patterns/rules/patterns-explicit-variants.md b/.agents/skills/vercel-composition-patterns/rules/patterns-explicit-variants.md
new file mode 100644
index 00000000..56e32e8b
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/rules/patterns-explicit-variants.md
@@ -0,0 +1,100 @@
+---
+title: Create Explicit Component Variants
+impact: MEDIUM
+impactDescription: self-documenting code, no hidden conditionals
+tags: composition, variants, architecture
+---
+
+## Create Explicit Component Variants
+
+Instead of one component with many boolean props, create explicit variant
+components. Each variant composes the pieces it needs. The code documents
+itself.
+
+**Incorrect (one component, many modes):**
+
+```tsx
+// What does this component actually render?
+
+```
+
+**Correct (explicit variants):**
+
+```tsx
+// Immediately clear what this renders
+
+
+// Or
+
+
+// Or
+
+```
+
+Each implementation is unique, explicit and self-contained. Yet they can each
+use shared parts.
+
+**Implementation:**
+
+```tsx
+function ThreadComposer({ channelId }: { channelId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function EditMessageComposer({ messageId }: { messageId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function ForwardMessageComposer({ messageId }: { messageId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+```
+
+Each variant is explicit about:
+
+- What provider/state it uses
+- What UI elements it includes
+- What actions are available
+
+No boolean prop combinations to reason about. No impossible states.
diff --git a/.agents/skills/vercel-composition-patterns/rules/react19-no-forwardref.md b/.agents/skills/vercel-composition-patterns/rules/react19-no-forwardref.md
new file mode 100644
index 00000000..e0d8f8a7
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/rules/react19-no-forwardref.md
@@ -0,0 +1,42 @@
+---
+title: React 19 API Changes
+impact: MEDIUM
+impactDescription: cleaner component definitions and context usage
+tags: react19, refs, context, hooks
+---
+
+## React 19 API Changes
+
+> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
+
+In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
+
+**Incorrect (forwardRef in React 19):**
+
+```tsx
+const ComposerInput = forwardRef((props, ref) => {
+ return
+})
+```
+
+**Correct (ref as a regular prop):**
+
+```tsx
+function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref }) {
+ return
+}
+```
+
+**Incorrect (useContext in React 19):**
+
+```tsx
+const value = useContext(MyContext)
+```
+
+**Correct (use instead of useContext):**
+
+```tsx
+const value = use(MyContext)
+```
+
+`use()` can also be called conditionally, unlike `useContext()`.
diff --git a/.agents/skills/vercel-composition-patterns/rules/state-context-interface.md b/.agents/skills/vercel-composition-patterns/rules/state-context-interface.md
new file mode 100644
index 00000000..d961bede
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/rules/state-context-interface.md
@@ -0,0 +1,191 @@
+---
+title: Define Generic Context Interfaces for Dependency Injection
+impact: HIGH
+impactDescription: enables dependency-injectable state across use-cases
+tags: composition, context, state, typescript, dependency-injection
+---
+
+## Define Generic Context Interfaces for Dependency Injection
+
+Define a **generic interface** for your component context with three parts:
+`state`, `actions`, and `meta`. This interface is a contract that any provider
+can implement—enabling the same UI components to work with completely different
+state implementations.
+
+**Core principle:** Lift state, compose internals, make state
+dependency-injectable.
+
+**Incorrect (UI coupled to specific state implementation):**
+
+```tsx
+function ComposerInput() {
+ // Tightly coupled to a specific hook
+ const { input, setInput } = useChannelComposerState()
+ return
+}
+```
+
+**Correct (generic interface enables dependency injection):**
+
+```tsx
+// Define a GENERIC interface that any provider can implement
+interface ComposerState {
+ input: string
+ attachments: Attachment[]
+ isSubmitting: boolean
+}
+
+interface ComposerActions {
+ update: (updater: (state: ComposerState) => ComposerState) => void
+ submit: () => void
+}
+
+interface ComposerMeta {
+ inputRef: React.RefObject
+}
+
+interface ComposerContextValue {
+ state: ComposerState
+ actions: ComposerActions
+ meta: ComposerMeta
+}
+
+const ComposerContext = createContext(null)
+```
+
+**UI components consume the interface, not the implementation:**
+
+```tsx
+function ComposerInput() {
+ const {
+ state,
+ actions: { update },
+ meta,
+ } = use(ComposerContext)
+
+ // This component works with ANY provider that implements the interface
+ return (
+ update((s) => ({ ...s, input: text }))}
+ />
+ )
+}
+```
+
+**Different providers implement the same interface:**
+
+```tsx
+// Provider A: Local state for ephemeral forms
+function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState(initialState)
+ const inputRef = useRef(null)
+ const submit = useForwardMessage()
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Provider B: Global synced state for channels
+function ChannelProvider({ channelId, children }: Props) {
+ const { state, update, submit } = useGlobalChannel(channelId)
+ const inputRef = useRef(null)
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+**The same composed UI works with both:**
+
+```tsx
+// Works with ForwardMessageProvider (local state)
+
+
+
+
+
+
+
+// Works with ChannelProvider (global synced state)
+
+
+
+
+
+
+```
+
+**Custom UI outside the component can access state and actions:**
+
+The provider boundary is what matters—not the visual nesting. Components that
+need shared state don't have to be inside the `Composer.Frame`. They just need
+to be within the provider.
+
+```tsx
+function ForwardMessageDialog() {
+ return (
+
+
+
+ )
+}
+
+// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
+function ForwardButton() {
+ const {
+ actions: { submit },
+ } = use(ComposerContext)
+ return
+}
+
+// This preview lives OUTSIDE Composer.Frame but can read composer's state!
+function MessagePreview() {
+ const { state } = use(ComposerContext)
+ return
+}
+```
+
+The `ForwardButton` and `MessagePreview` are not visually inside the composer
+box, but they can still access its state and actions. This is the power of
+lifting state into providers.
+
+The UI is reusable bits you compose together. The state is dependency-injected
+by the provider. Swap the provider, keep the UI.
diff --git a/.agents/skills/vercel-composition-patterns/rules/state-decouple-implementation.md b/.agents/skills/vercel-composition-patterns/rules/state-decouple-implementation.md
new file mode 100644
index 00000000..71a5afaa
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/rules/state-decouple-implementation.md
@@ -0,0 +1,113 @@
+---
+title: Decouple State Management from UI
+impact: MEDIUM
+impactDescription: enables swapping state implementations without changing UI
+tags: composition, state, architecture
+---
+
+## Decouple State Management from UI
+
+The provider component should be the only place that knows how state is managed.
+UI components consume the context interface—they don't know if state comes from
+useState, Zustand, or a server sync.
+
+**Incorrect (UI coupled to state implementation):**
+
+```tsx
+function ChannelComposer({ channelId }: { channelId: string }) {
+ // UI component knows about global state implementation
+ const state = useGlobalChannelState(channelId)
+ const { submit, updateInput } = useChannelSync(channelId)
+
+ return (
+
+ sync.updateInput(text)}
+ />
+ sync.submit()} />
+
+ )
+}
+```
+
+**Correct (state management isolated in provider):**
+
+```tsx
+// Provider handles all state management details
+function ChannelProvider({
+ channelId,
+ children,
+}: {
+ channelId: string
+ children: React.ReactNode
+}) {
+ const { state, update, submit } = useGlobalChannel(channelId)
+ const inputRef = useRef(null)
+
+ return (
+
+ {children}
+
+ )
+}
+
+// UI component only knows about the context interface
+function ChannelComposer() {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+// Usage
+function Channel({ channelId }: { channelId: string }) {
+ return (
+
+
+
+ )
+}
+```
+
+**Different providers, same UI:**
+
+```tsx
+// Local state for ephemeral forms
+function ForwardMessageProvider({ children }) {
+ const [state, setState] = useState(initialState)
+ const forwardMessage = useForwardMessage()
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Global synced state for channels
+function ChannelProvider({ channelId, children }) {
+ const { state, update, submit } = useGlobalChannel(channelId)
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+The same `Composer.Input` component works with both providers because it only
+depends on the context interface, not the implementation.
diff --git a/.agents/skills/vercel-composition-patterns/rules/state-lift-state.md b/.agents/skills/vercel-composition-patterns/rules/state-lift-state.md
new file mode 100644
index 00000000..d7fe27b5
--- /dev/null
+++ b/.agents/skills/vercel-composition-patterns/rules/state-lift-state.md
@@ -0,0 +1,125 @@
+---
+title: Lift State into Provider Components
+impact: HIGH
+impactDescription: enables state sharing outside component boundaries
+tags: composition, state, context, providers
+---
+
+## Lift State into Provider Components
+
+Move state management into dedicated provider components. This allows sibling
+components outside the main UI to access and modify state without prop drilling
+or awkward refs.
+
+**Incorrect (state trapped inside component):**
+
+```tsx
+function ForwardMessageComposer() {
+ const [state, setState] = useState(initialState)
+ const forwardMessage = useForwardMessage()
+
+ return (
+
+
+
+
+ )
+}
+
+// Problem: How does this button access composer state?
+function ForwardMessageDialog() {
+ return (
+
+ )
+}
+```
+
+**Incorrect (useEffect to sync state up):**
+
+```tsx
+function ForwardMessageDialog() {
+ const [input, setInput] = useState('')
+ return (
+
+ )
+}
+
+function ForwardMessageComposer({ onInputChange }) {
+ const [state, setState] = useState(initialState)
+ useEffect(() => {
+ onInputChange(state.input) // Sync on every change 😬
+ }, [state.input])
+}
+```
+
+**Incorrect (reading state from ref on submit):**
+
+```tsx
+function ForwardMessageDialog() {
+ const stateRef = useRef(null)
+ return (
+
+ )
+}
+```
+
+**Correct (state lifted to provider):**
+
+```tsx
+function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState(initialState)
+ const forwardMessage = useForwardMessage()
+ const inputRef = useRef(null)
+
+ return (
+
+ {children}
+
+ )
+}
+
+function ForwardMessageDialog() {
+ return (
+
+
+
+ )
+}
+
+function ForwardButton() {
+ const { actions } = use(Composer.Context)
+ return
+}
+```
+
+The ForwardButton lives outside the Composer.Frame but still has access to the
+submit action because it's within the provider. Even though it's a one-off
+component, it can still access the composer's state and actions from outside the
+UI itself.
+
+**Key insight:** Components that need shared state don't have to be visually
+nested inside each other—they just need to be within the same provider.
diff --git a/.agents/skills/vercel-react-best-practices/AGENTS.md b/.agents/skills/vercel-react-best-practices/AGENTS.md
new file mode 100644
index 00000000..94c3c844
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/AGENTS.md
@@ -0,0 +1,2975 @@
+# React Best Practices
+
+**Version 1.0.0**
+Vercel Engineering
+January 2026
+
+> **Note:**
+> This document is mainly for agents and LLMs to follow when maintaining,
+> generating, or refactoring React and Next.js codebases. Humans
+> may also find it useful, but guidance here is optimized for automation
+> and consistency by AI-assisted workflows.
+
+---
+
+## Abstract
+
+Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
+
+---
+
+## Table of Contents
+
+1. [Eliminating Waterfalls](#1-eliminating-waterfalls) — **CRITICAL**
+ - 1.1 [Defer Await Until Needed](#11-defer-await-until-needed)
+ - 1.2 [Dependency-Based Parallelization](#12-dependency-based-parallelization)
+ - 1.3 [Prevent Waterfall Chains in API Routes](#13-prevent-waterfall-chains-in-api-routes)
+ - 1.4 [Promise.all() for Independent Operations](#14-promiseall-for-independent-operations)
+ - 1.5 [Strategic Suspense Boundaries](#15-strategic-suspense-boundaries)
+2. [Bundle Size Optimization](#2-bundle-size-optimization) — **CRITICAL**
+ - 2.1 [Avoid Barrel File Imports](#21-avoid-barrel-file-imports)
+ - 2.2 [Conditional Module Loading](#22-conditional-module-loading)
+ - 2.3 [Defer Non-Critical Third-Party Libraries](#23-defer-non-critical-third-party-libraries)
+ - 2.4 [Dynamic Imports for Heavy Components](#24-dynamic-imports-for-heavy-components)
+ - 2.5 [Preload Based on User Intent](#25-preload-based-on-user-intent)
+3. [Server-Side Performance](#3-server-side-performance) — **HIGH**
+ - 3.1 [Authenticate Server Actions Like API Routes](#31-authenticate-server-actions-like-api-routes)
+ - 3.2 [Avoid Duplicate Serialization in RSC Props](#32-avoid-duplicate-serialization-in-rsc-props)
+ - 3.3 [Cross-Request LRU Caching](#33-cross-request-lru-caching)
+ - 3.4 [Hoist Static I/O to Module Level](#34-hoist-static-io-to-module-level)
+ - 3.5 [Minimize Serialization at RSC Boundaries](#35-minimize-serialization-at-rsc-boundaries)
+ - 3.6 [Parallel Data Fetching with Component Composition](#36-parallel-data-fetching-with-component-composition)
+ - 3.7 [Per-Request Deduplication with React.cache()](#37-per-request-deduplication-with-reactcache)
+ - 3.8 [Use after() for Non-Blocking Operations](#38-use-after-for-non-blocking-operations)
+4. [Client-Side Data Fetching](#4-client-side-data-fetching) — **MEDIUM-HIGH**
+ - 4.1 [Deduplicate Global Event Listeners](#41-deduplicate-global-event-listeners)
+ - 4.2 [Use Passive Event Listeners for Scrolling Performance](#42-use-passive-event-listeners-for-scrolling-performance)
+ - 4.3 [Use SWR for Automatic Deduplication](#43-use-swr-for-automatic-deduplication)
+ - 4.4 [Version and Minimize localStorage Data](#44-version-and-minimize-localstorage-data)
+5. [Re-render Optimization](#5-re-render-optimization) — **MEDIUM**
+ - 5.1 [Calculate Derived State During Rendering](#51-calculate-derived-state-during-rendering)
+ - 5.2 [Defer State Reads to Usage Point](#52-defer-state-reads-to-usage-point)
+ - 5.3 [Do not wrap a simple expression with a primitive result type in useMemo](#53-do-not-wrap-a-simple-expression-with-a-primitive-result-type-in-usememo)
+ - 5.4 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#54-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant)
+ - 5.5 [Extract to Memoized Components](#55-extract-to-memoized-components)
+ - 5.6 [Narrow Effect Dependencies](#56-narrow-effect-dependencies)
+ - 5.7 [Put Interaction Logic in Event Handlers](#57-put-interaction-logic-in-event-handlers)
+ - 5.8 [Subscribe to Derived State](#58-subscribe-to-derived-state)
+ - 5.9 [Use Functional setState Updates](#59-use-functional-setstate-updates)
+ - 5.10 [Use Lazy State Initialization](#510-use-lazy-state-initialization)
+ - 5.11 [Use Transitions for Non-Urgent Updates](#511-use-transitions-for-non-urgent-updates)
+ - 5.12 [Use useRef for Transient Values](#512-use-useref-for-transient-values)
+6. [Rendering Performance](#6-rendering-performance) — **MEDIUM**
+ - 6.1 [Animate SVG Wrapper Instead of SVG Element](#61-animate-svg-wrapper-instead-of-svg-element)
+ - 6.2 [CSS content-visibility for Long Lists](#62-css-content-visibility-for-long-lists)
+ - 6.3 [Hoist Static JSX Elements](#63-hoist-static-jsx-elements)
+ - 6.4 [Optimize SVG Precision](#64-optimize-svg-precision)
+ - 6.5 [Prevent Hydration Mismatch Without Flickering](#65-prevent-hydration-mismatch-without-flickering)
+ - 6.6 [Suppress Expected Hydration Mismatches](#66-suppress-expected-hydration-mismatches)
+ - 6.7 [Use Activity Component for Show/Hide](#67-use-activity-component-for-showhide)
+ - 6.8 [Use Explicit Conditional Rendering](#68-use-explicit-conditional-rendering)
+ - 6.9 [Use useTransition Over Manual Loading States](#69-use-usetransition-over-manual-loading-states)
+7. [JavaScript Performance](#7-javascript-performance) — **LOW-MEDIUM**
+ - 7.1 [Avoid Layout Thrashing](#71-avoid-layout-thrashing)
+ - 7.2 [Build Index Maps for Repeated Lookups](#72-build-index-maps-for-repeated-lookups)
+ - 7.3 [Cache Property Access in Loops](#73-cache-property-access-in-loops)
+ - 7.4 [Cache Repeated Function Calls](#74-cache-repeated-function-calls)
+ - 7.5 [Cache Storage API Calls](#75-cache-storage-api-calls)
+ - 7.6 [Combine Multiple Array Iterations](#76-combine-multiple-array-iterations)
+ - 7.7 [Early Length Check for Array Comparisons](#77-early-length-check-for-array-comparisons)
+ - 7.8 [Early Return from Functions](#78-early-return-from-functions)
+ - 7.9 [Hoist RegExp Creation](#79-hoist-regexp-creation)
+ - 7.10 [Use Loop for Min/Max Instead of Sort](#710-use-loop-for-minmax-instead-of-sort)
+ - 7.11 [Use Set/Map for O(1) Lookups](#711-use-setmap-for-o1-lookups)
+ - 7.12 [Use toSorted() Instead of sort() for Immutability](#712-use-tosorted-instead-of-sort-for-immutability)
+8. [Advanced Patterns](#8-advanced-patterns) — **LOW**
+ - 8.1 [Initialize App Once, Not Per Mount](#81-initialize-app-once-not-per-mount)
+ - 8.2 [Store Event Handlers in Refs](#82-store-event-handlers-in-refs)
+ - 8.3 [useEffectEvent for Stable Callback Refs](#83-useeffectevent-for-stable-callback-refs)
+
+---
+
+## 1. Eliminating Waterfalls
+
+**Impact: CRITICAL**
+
+Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
+
+### 1.1 Defer Await Until Needed
+
+**Impact: HIGH (avoids blocking unused code paths)**
+
+Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
+
+**Incorrect: blocks both branches**
+
+```typescript
+async function handleRequest(userId: string, skipProcessing: boolean) {
+ const userData = await fetchUserData(userId)
+
+ if (skipProcessing) {
+ // Returns immediately but still waited for userData
+ return { skipped: true }
+ }
+
+ // Only this branch uses userData
+ return processUserData(userData)
+}
+```
+
+**Correct: only blocks when needed**
+
+```typescript
+async function handleRequest(userId: string, skipProcessing: boolean) {
+ if (skipProcessing) {
+ // Returns immediately without waiting
+ return { skipped: true }
+ }
+
+ // Fetch only when needed
+ const userData = await fetchUserData(userId)
+ return processUserData(userData)
+}
+```
+
+**Another example: early return optimization**
+
+```typescript
+// Incorrect: always fetches permissions
+async function updateResource(resourceId: string, userId: string) {
+ const permissions = await fetchPermissions(userId)
+ const resource = await getResource(resourceId)
+
+ if (!resource) {
+ return { error: 'Not found' }
+ }
+
+ if (!permissions.canEdit) {
+ return { error: 'Forbidden' }
+ }
+
+ return await updateResourceData(resource, permissions)
+}
+
+// Correct: fetches only when needed
+async function updateResource(resourceId: string, userId: string) {
+ const resource = await getResource(resourceId)
+
+ if (!resource) {
+ return { error: 'Not found' }
+ }
+
+ const permissions = await fetchPermissions(userId)
+
+ if (!permissions.canEdit) {
+ return { error: 'Forbidden' }
+ }
+
+ return await updateResourceData(resource, permissions)
+}
+```
+
+This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
+
+### 1.2 Dependency-Based Parallelization
+
+**Impact: CRITICAL (2-10× improvement)**
+
+For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
+
+**Incorrect: profile waits for config unnecessarily**
+
+```typescript
+const [user, config] = await Promise.all([
+ fetchUser(),
+ fetchConfig()
+])
+const profile = await fetchProfile(user.id)
+```
+
+**Correct: config and profile run in parallel**
+
+```typescript
+import { all } from 'better-all'
+
+const { user, config, profile } = await all({
+ async user() { return fetchUser() },
+ async config() { return fetchConfig() },
+ async profile() {
+ return fetchProfile((await this.$.user).id)
+ }
+})
+```
+
+**Alternative without extra dependencies:**
+
+```typescript
+const userPromise = fetchUser()
+const profilePromise = userPromise.then(user => fetchProfile(user.id))
+
+const [user, config, profile] = await Promise.all([
+ userPromise,
+ fetchConfig(),
+ profilePromise
+])
+```
+
+We can also create all the promises first, and do `Promise.all()` at the end.
+
+Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
+
+### 1.3 Prevent Waterfall Chains in API Routes
+
+**Impact: CRITICAL (2-10× improvement)**
+
+In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
+
+**Incorrect: config waits for auth, data waits for both**
+
+```typescript
+export async function GET(request: Request) {
+ const session = await auth()
+ const config = await fetchConfig()
+ const data = await fetchData(session.user.id)
+ return Response.json({ data, config })
+}
+```
+
+**Correct: auth and config start immediately**
+
+```typescript
+export async function GET(request: Request) {
+ const sessionPromise = auth()
+ const configPromise = fetchConfig()
+ const session = await sessionPromise
+ const [config, data] = await Promise.all([
+ configPromise,
+ fetchData(session.user.id)
+ ])
+ return Response.json({ data, config })
+}
+```
+
+For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
+
+### 1.4 Promise.all() for Independent Operations
+
+**Impact: CRITICAL (2-10× improvement)**
+
+When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
+
+**Incorrect: sequential execution, 3 round trips**
+
+```typescript
+const user = await fetchUser()
+const posts = await fetchPosts()
+const comments = await fetchComments()
+```
+
+**Correct: parallel execution, 1 round trip**
+
+```typescript
+const [user, posts, comments] = await Promise.all([
+ fetchUser(),
+ fetchPosts(),
+ fetchComments()
+])
+```
+
+### 1.5 Strategic Suspense Boundaries
+
+**Impact: HIGH (faster initial paint)**
+
+Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
+
+**Incorrect: wrapper blocked by data fetching**
+
+```tsx
+async function Page() {
+ const data = await fetchData() // Blocks entire page
+
+ return (
+
+
Sidebar
+
Header
+
+
+
+
Footer
+
+ )
+}
+```
+
+The entire layout waits for data even though only the middle section needs it.
+
+**Correct: wrapper shows immediately, data streams in**
+
+```tsx
+function Page() {
+ return (
+
+
Sidebar
+
Header
+
+ }>
+
+
+
+
Footer
+
+ )
+}
+
+async function DataDisplay() {
+ const data = await fetchData() // Only blocks this component
+ return
{data.content}
+}
+```
+
+Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
+
+**Alternative: share promise across components**
+
+```tsx
+function Page() {
+ // Start fetch immediately, but don't await
+ const dataPromise = fetchData()
+
+ return (
+
+}
+
+function DataSummary({ dataPromise }: { dataPromise: Promise }) {
+ const data = use(dataPromise) // Reuses the same promise
+ return
{data.summary}
+}
+```
+
+Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
+
+**When NOT to use this pattern:**
+
+- Critical data needed for layout decisions (affects positioning)
+
+- SEO-critical content above the fold
+
+- Small, fast queries where suspense overhead isn't worth it
+
+- When you want to avoid layout shift (loading → content jump)
+
+**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
+
+---
+
+## 2. Bundle Size Optimization
+
+**Impact: CRITICAL**
+
+Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
+
+### 2.1 Avoid Barrel File Imports
+
+**Impact: CRITICAL (200-800ms import cost, slow builds)**
+
+Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).
+
+Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.
+
+**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
+
+**Incorrect: imports entire library**
+
+```tsx
+import { Check, X, Menu } from 'lucide-react'
+// Loads 1,583 modules, takes ~2.8s extra in dev
+// Runtime cost: 200-800ms on every cold start
+
+import { Button, TextField } from '@mui/material'
+// Loads 2,225 modules, takes ~4.2s extra in dev
+```
+
+**Correct: imports only what you need**
+
+```tsx
+import Check from 'lucide-react/dist/esm/icons/check'
+import X from 'lucide-react/dist/esm/icons/x'
+import Menu from 'lucide-react/dist/esm/icons/menu'
+// Loads only 3 modules (~2KB vs ~1MB)
+
+import Button from '@mui/material/Button'
+import TextField from '@mui/material/TextField'
+// Loads only what you use
+```
+
+**Alternative: Next.js 13.5+**
+
+```js
+// next.config.js - use optimizePackageImports
+module.exports = {
+ experimental: {
+ optimizePackageImports: ['lucide-react', '@mui/material']
+ }
+}
+
+// Then you can keep the ergonomic barrel imports:
+import { Check, X, Menu } from 'lucide-react'
+// Automatically transformed to direct imports at build time
+```
+
+Direct imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
+
+Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`.
+
+Reference: [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
+
+### 2.2 Conditional Module Loading
+
+**Impact: HIGH (loads large data only when needed)**
+
+Load large data or modules only when a feature is activated.
+
+**Example: lazy-load animation frames**
+
+```tsx
+function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch> }) {
+ const [frames, setFrames] = useState(null)
+
+ useEffect(() => {
+ if (enabled && !frames && typeof window !== 'undefined') {
+ import('./animation-frames.js')
+ .then(mod => setFrames(mod.frames))
+ .catch(() => setEnabled(false))
+ }
+ }, [enabled, frames, setEnabled])
+
+ if (!frames) return
+ return
+}
+```
+
+The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
+
+### 2.3 Defer Non-Critical Third-Party Libraries
+
+**Impact: MEDIUM (loads after hydration)**
+
+Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
+
+**Incorrect: blocks initial bundle**
+
+```tsx
+import { Analytics } from '@vercel/analytics/react'
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+```
+
+**Correct: loads after hydration**
+
+```tsx
+import dynamic from 'next/dynamic'
+
+const Analytics = dynamic(
+ () => import('@vercel/analytics/react').then(m => m.Analytics),
+ { ssr: false }
+)
+
+export default function RootLayout({ children }) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+```
+
+### 2.4 Dynamic Imports for Heavy Components
+
+**Impact: CRITICAL (directly affects TTI and LCP)**
+
+Use `next/dynamic` to lazy-load large components not needed on initial render.
+
+**Incorrect: Monaco bundles with main chunk ~300KB**
+
+```tsx
+import { MonacoEditor } from './monaco-editor'
+
+function CodePanel({ code }: { code: string }) {
+ return
+}
+```
+
+**Correct: Monaco loads on demand**
+
+```tsx
+import dynamic from 'next/dynamic'
+
+const MonacoEditor = dynamic(
+ () => import('./monaco-editor').then(m => m.MonacoEditor),
+ { ssr: false }
+)
+
+function CodePanel({ code }: { code: string }) {
+ return
+}
+```
+
+### 2.5 Preload Based on User Intent
+
+**Impact: MEDIUM (reduces perceived latency)**
+
+Preload heavy bundles before they're needed to reduce perceived latency.
+
+**Example: preload on hover/focus**
+
+```tsx
+function EditorButton({ onClick }: { onClick: () => void }) {
+ const preload = () => {
+ if (typeof window !== 'undefined') {
+ void import('./monaco-editor')
+ }
+ }
+
+ return (
+
+ )
+}
+```
+
+**Example: preload when feature flag is enabled**
+
+```tsx
+function FlagsProvider({ children, flags }: Props) {
+ useEffect(() => {
+ if (flags.editorEnabled && typeof window !== 'undefined') {
+ void import('./monaco-editor').then(mod => mod.init())
+ }
+ }, [flags.editorEnabled])
+
+ return
+ {children}
+
+}
+```
+
+The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
+
+---
+
+## 3. Server-Side Performance
+
+**Impact: HIGH**
+
+Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
+
+### 3.1 Authenticate Server Actions Like API Routes
+
+**Impact: CRITICAL (prevents unauthorized access to server mutations)**
+
+Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.
+
+Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation."
+
+**Incorrect: no authentication check**
+
+```typescript
+'use server'
+
+export async function deleteUser(userId: string) {
+ // Anyone can call this! No auth check
+ await db.user.delete({ where: { id: userId } })
+ return { success: true }
+}
+```
+
+**Correct: authentication inside the action**
+
+```typescript
+'use server'
+
+import { verifySession } from '@/lib/auth'
+import { unauthorized } from '@/lib/errors'
+
+export async function deleteUser(userId: string) {
+ // Always check auth inside the action
+ const session = await verifySession()
+
+ if (!session) {
+ throw unauthorized('Must be logged in')
+ }
+
+ // Check authorization too
+ if (session.user.role !== 'admin' && session.user.id !== userId) {
+ throw unauthorized('Cannot delete other users')
+ }
+
+ await db.user.delete({ where: { id: userId } })
+ return { success: true }
+}
+```
+
+**With input validation:**
+
+```typescript
+'use server'
+
+import { verifySession } from '@/lib/auth'
+import { z } from 'zod'
+
+const updateProfileSchema = z.object({
+ userId: z.string().uuid(),
+ name: z.string().min(1).max(100),
+ email: z.string().email()
+})
+
+export async function updateProfile(data: unknown) {
+ // Validate input first
+ const validated = updateProfileSchema.parse(data)
+
+ // Then authenticate
+ const session = await verifySession()
+ if (!session) {
+ throw new Error('Unauthorized')
+ }
+
+ // Then authorize
+ if (session.user.id !== validated.userId) {
+ throw new Error('Can only update own profile')
+ }
+
+ // Finally perform the mutation
+ await db.user.update({
+ where: { id: validated.userId },
+ data: {
+ name: validated.name,
+ email: validated.email
+ }
+ })
+
+ return { success: true }
+}
+```
+
+Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
+
+### 3.2 Avoid Duplicate Serialization in RSC Props
+
+**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
+
+RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.
+
+**Incorrect: duplicates array**
+
+```tsx
+// RSC: sends 6 strings (2 arrays × 3 items)
+
+```
+
+**Correct: sends 3 strings**
+
+```tsx
+// RSC: send once
+
+
+// Client: transform there
+'use client'
+const sorted = useMemo(() => [...usernames].sort(), [usernames])
+```
+
+**Nested deduplication behavior:**
+
+```tsx
+// string[] - duplicates everything
+usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
+
+// object[] - duplicates array structure only
+users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
+```
+
+Deduplication works recursively. Impact varies by data type:
+
+- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
+
+- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
+
+**Operations breaking deduplication: create new references**
+
+- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
+
+- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
+
+**More examples:**
+
+```tsx
+// ❌ Bad
+ u.active)} />
+
+
+// ✅ Good
+
+
+// Do filtering/destructuring in client
+```
+
+**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
+
+### 3.3 Cross-Request LRU Caching
+
+**Impact: HIGH (caches across requests)**
+
+`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
+
+**Implementation:**
+
+```typescript
+import { LRUCache } from 'lru-cache'
+
+const cache = new LRUCache({
+ max: 1000,
+ ttl: 5 * 60 * 1000 // 5 minutes
+})
+
+export async function getUser(id: string) {
+ const cached = cache.get(id)
+ if (cached) return cached
+
+ const user = await db.user.findUnique({ where: { id } })
+ cache.set(id, user)
+ return user
+}
+
+// Request 1: DB query, result cached
+// Request 2: cache hit, no DB query
+```
+
+Use when sequential user actions hit multiple endpoints needing the same data within seconds.
+
+**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.
+
+**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
+
+Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
+
+### 3.4 Hoist Static I/O to Module Level
+
+**Impact: HIGH (avoids repeated file/network I/O per request)**
+
+When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation.
+
+**Incorrect: reads font file on every request**
+
+**Correct: loads once at module initialization**
+
+**Alternative: synchronous file reads with Node.js fs**
+
+**General Node.js example: loading config or templates**
+
+**When to use this pattern:**
+
+- Loading fonts for OG image generation
+
+- Loading static logos, icons, or watermarks
+
+- Reading configuration files that don't change at runtime
+
+- Loading email templates or other static templates
+
+- Any static asset that's the same across all requests
+
+**When NOT to use this pattern:**
+
+- Assets that vary per request or user
+
+- Files that may change during runtime (use caching with TTL instead)
+
+- Large files that would consume too much memory if kept loaded
+
+- Sensitive data that shouldn't persist in memory
+
+**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** Module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties.
+
+**In traditional serverless:** Each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled.
+
+### 3.5 Minimize Serialization at RSC Boundaries
+
+**Impact: HIGH (reduces data transfer size)**
+
+The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.
+
+**Incorrect: serializes all 50 fields**
+
+```tsx
+async function Page() {
+ const user = await fetchUser() // 50 fields
+ return
+}
+
+'use client'
+function Profile({ user }: { user: User }) {
+ return
{user.name}
// uses 1 field
+}
+```
+
+**Correct: serializes only 1 field**
+
+```tsx
+async function Page() {
+ const user = await fetchUser()
+ return
+}
+
+'use client'
+function Profile({ name }: { name: string }) {
+ return
{name}
+}
+```
+
+### 3.6 Parallel Data Fetching with Component Composition
+
+**Impact: CRITICAL (eliminates server-side waterfalls)**
+
+React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
+
+**Incorrect: Sidebar waits for Page's fetch to complete**
+
+```tsx
+export default async function Page() {
+ const header = await fetchHeader()
+ return (
+
+
{header}
+
+
+ )
+}
+
+async function Sidebar() {
+ const items = await fetchSidebarItems()
+ return
+}
+```
+
+**Correct: both fetch simultaneously**
+
+```tsx
+async function Header() {
+ const data = await fetchHeader()
+ return
+}
+```
+
+Reference: [https://react.dev/learn/you-might-not-need-an-effect](https://react.dev/learn/you-might-not-need-an-effect)
+
+### 5.2 Defer State Reads to Usage Point
+
+**Impact: MEDIUM (avoids unnecessary subscriptions)**
+
+Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
+
+**Incorrect: subscribes to all searchParams changes**
+
+```tsx
+function ShareButton({ chatId }: { chatId: string }) {
+ const searchParams = useSearchParams()
+
+ const handleShare = () => {
+ const ref = searchParams.get('ref')
+ shareChat(chatId, { ref })
+ }
+
+ return
+}
+```
+
+**Correct: reads on demand, no subscription**
+
+```tsx
+function ShareButton({ chatId }: { chatId: string }) {
+ const handleShare = () => {
+ const params = new URLSearchParams(window.location.search)
+ const ref = params.get('ref')
+ shareChat(chatId, { ref })
+ }
+
+ return
+}
+```
+
+### 5.3 Do not wrap a simple expression with a primitive result type in useMemo
+
+**Impact: LOW-MEDIUM (wasted computation on every render)**
+
+When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.
+
+Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
+
+**Incorrect:**
+
+```tsx
+function Header({ user, notifications }: Props) {
+ const isLoading = useMemo(() => {
+ return user.isLoading || notifications.isLoading
+ }, [user.isLoading, notifications.isLoading])
+
+ if (isLoading) return
+ // return some markup
+}
+```
+
+**Correct:**
+
+```tsx
+function Header({ user, notifications }: Props) {
+ const isLoading = user.isLoading || notifications.isLoading
+
+ if (isLoading) return
+ // return some markup
+}
+```
+
+### 5.4 Extract Default Non-primitive Parameter Value from Memoized Component to Constant
+
+**Impact: MEDIUM (restores memoization by using a constant for default value)**
+
+When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.
+
+To address this issue, extract the default value into a constant.
+
+**Incorrect: `onClick` has different values on every rerender**
+
+```tsx
+const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
+ // ...
+})
+
+// Used without optional onClick
+
+```
+
+**Correct: stable default value**
+
+```tsx
+const NOOP = () => {};
+
+const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
+ // ...
+})
+
+// Used without optional onClick
+
+```
+
+### 5.5 Extract to Memoized Components
+
+**Impact: MEDIUM (enables early returns)**
+
+Extract expensive work into memoized components to enable early returns before computation.
+
+**Incorrect: computes avatar even when loading**
+
+```tsx
+function Profile({ user, loading }: Props) {
+ const avatar = useMemo(() => {
+ const id = computeAvatarId(user)
+ return
+ }, [user])
+
+ if (loading) return
+ return
{avatar}
+}
+```
+
+**Correct: skips computation when loading**
+
+```tsx
+const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
+ const id = useMemo(() => computeAvatarId(user), [user])
+ return
+})
+
+function Profile({ user, loading }: Props) {
+ if (loading) return
+ return (
+
+
+
+ )
+}
+```
+
+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.
+
+### 5.6 Narrow Effect Dependencies
+
+**Impact: LOW (minimizes effect re-runs)**
+
+Specify primitive dependencies instead of objects to minimize effect re-runs.
+
+**Incorrect: re-runs on any user field change**
+
+```tsx
+useEffect(() => {
+ console.log(user.id)
+}, [user])
+```
+
+**Correct: re-runs only when id changes**
+
+```tsx
+useEffect(() => {
+ console.log(user.id)
+}, [user.id])
+```
+
+**For derived state, compute outside effect:**
+
+```tsx
+// Incorrect: runs on width=767, 766, 765...
+useEffect(() => {
+ if (width < 768) {
+ enableMobileMode()
+ }
+}, [width])
+
+// Correct: runs only on boolean transition
+const isMobile = width < 768
+useEffect(() => {
+ if (isMobile) {
+ enableMobileMode()
+ }
+}, [isMobile])
+```
+
+### 5.7 Put Interaction Logic in Event Handlers
+
+**Impact: MEDIUM (avoids effect re-runs and duplicate side effects)**
+
+If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.
+
+**Incorrect: event modeled as state + effect**
+
+```tsx
+function Form() {
+ const [submitted, setSubmitted] = useState(false)
+ const theme = useContext(ThemeContext)
+
+ useEffect(() => {
+ if (submitted) {
+ post('/api/register')
+ showToast('Registered', theme)
+ }
+ }, [submitted, theme])
+
+ return
+}
+```
+
+**Correct: do it in the handler**
+
+```tsx
+function Form() {
+ const theme = useContext(ThemeContext)
+
+ function handleSubmit() {
+ post('/api/register')
+ showToast('Registered', theme)
+ }
+
+ return
+}
+```
+
+Reference: [https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
+
+### 5.8 Subscribe to Derived State
+
+**Impact: MEDIUM (reduces re-render frequency)**
+
+Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
+
+**Incorrect: re-renders on every pixel change**
+
+```tsx
+function Sidebar() {
+ const width = useWindowWidth() // updates continuously
+ const isMobile = width < 768
+ return
+}
+```
+
+**Correct: re-renders only when boolean changes**
+
+```tsx
+function Sidebar() {
+ const isMobile = useMediaQuery('(max-width: 767px)')
+ return
+}
+```
+
+### 5.9 Use Functional setState Updates
+
+**Impact: MEDIUM (prevents stale closures and unnecessary callback recreations)**
+
+When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
+
+**Incorrect: requires state as dependency**
+
+```tsx
+function TodoList() {
+ const [items, setItems] = useState(initialItems)
+
+ // Callback must depend on items, recreated on every items change
+ const addItems = useCallback((newItems: Item[]) => {
+ setItems([...items, ...newItems])
+ }, [items]) // ❌ items dependency causes recreations
+
+ // Risk of stale closure if dependency is forgotten
+ const removeItem = useCallback((id: string) => {
+ setItems(items.filter(item => item.id !== id))
+ }, []) // ❌ Missing items dependency - will use stale items!
+
+ return
+}
+```
+
+The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.
+
+**Correct: stable callbacks, no stale closures**
+
+```tsx
+function TodoList() {
+ const [items, setItems] = useState(initialItems)
+
+ // Stable callback, never recreated
+ const addItems = useCallback((newItems: Item[]) => {
+ setItems(curr => [...curr, ...newItems])
+ }, []) // ✅ No dependencies needed
+
+ // Always uses latest state, no stale closure risk
+ const removeItem = useCallback((id: string) => {
+ setItems(curr => curr.filter(item => item.id !== id))
+ }, []) // ✅ Safe and stable
+
+ return
+}
+```
+
+**Benefits:**
+
+1. **Stable callback references** - Callbacks don't need to be recreated when state changes
+
+2. **No stale closures** - Always operates on the latest state value
+
+3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
+
+4. **Prevents bugs** - Eliminates the most common source of React closure bugs
+
+**When to use functional updates:**
+
+- Any setState that depends on the current state value
+
+- Inside useCallback/useMemo when state is needed
+
+- Event handlers that reference state
+
+- Async operations that update state
+
+**When direct updates are fine:**
+
+- Setting state to a static value: `setCount(0)`
+
+- Setting state from props/arguments only: `setName(newName)`
+
+- State doesn't depend on previous value
+
+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
+
+### 5.10 Use Lazy State Initialization
+
+**Impact: MEDIUM (wasted computation on every render)**
+
+Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
+
+**Incorrect: runs on every render**
+
+```tsx
+function FilteredList({ items }: { items: Item[] }) {
+ // buildSearchIndex() runs on EVERY render, even after initialization
+ const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
+ const [query, setQuery] = useState('')
+
+ // When query changes, buildSearchIndex runs again unnecessarily
+ return
+}
+
+function UserProfile() {
+ // JSON.parse runs on every render
+ const [settings, setSettings] = useState(
+ JSON.parse(localStorage.getItem('settings') || '{}')
+ )
+
+ return
+}
+```
+
+**Correct: runs only once**
+
+```tsx
+function FilteredList({ items }: { items: Item[] }) {
+ // buildSearchIndex() runs ONLY on initial render
+ const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
+ const [query, setQuery] = useState('')
+
+ return
+}
+
+function UserProfile() {
+ // JSON.parse runs only on initial render
+ const [settings, setSettings] = useState(() => {
+ const stored = localStorage.getItem('settings')
+ return stored ? JSON.parse(stored) : {}
+ })
+
+ return
+}
+```
+
+Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
+
+For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
+
+### 5.11 Use Transitions for Non-Urgent Updates
+
+**Impact: MEDIUM (maintains UI responsiveness)**
+
+Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
+
+**Incorrect: blocks UI on every scroll**
+
+```tsx
+function ScrollTracker() {
+ const [scrollY, setScrollY] = useState(0)
+ useEffect(() => {
+ const handler = () => setScrollY(window.scrollY)
+ window.addEventListener('scroll', handler, { passive: true })
+ return () => window.removeEventListener('scroll', handler)
+ }, [])
+}
+```
+
+**Correct: non-blocking updates**
+
+```tsx
+import { startTransition } from 'react'
+
+function ScrollTracker() {
+ const [scrollY, setScrollY] = useState(0)
+ useEffect(() => {
+ const handler = () => {
+ startTransition(() => setScrollY(window.scrollY))
+ }
+ window.addEventListener('scroll', handler, { passive: true })
+ return () => window.removeEventListener('scroll', handler)
+ }, [])
+}
+```
+
+### 5.12 Use useRef for Transient Values
+
+**Impact: MEDIUM (avoids unnecessary re-renders on frequent updates)**
+
+When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
+
+**Incorrect: renders every update**
+
+```tsx
+function Tracker() {
+ const [lastX, setLastX] = useState(0)
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => setLastX(e.clientX)
+ window.addEventListener('mousemove', onMove)
+ return () => window.removeEventListener('mousemove', onMove)
+ }, [])
+
+ return (
+
+ )
+}
+```
+
+**Correct: no re-render for tracking**
+
+```tsx
+function Tracker() {
+ const lastXRef = useRef(0)
+ const dotRef = useRef(null)
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => {
+ lastXRef.current = e.clientX
+ const node = dotRef.current
+ if (node) {
+ node.style.transform = `translateX(${e.clientX}px)`
+ }
+ }
+ window.addEventListener('mousemove', onMove)
+ return () => window.removeEventListener('mousemove', onMove)
+ }, [])
+
+ return (
+
+ )
+}
+```
+
+---
+
+## 6. Rendering Performance
+
+**Impact: MEDIUM**
+
+Optimizing the rendering process reduces the work the browser needs to do.
+
+### 6.1 Animate SVG Wrapper Instead of SVG Element
+
+**Impact: LOW (enables hardware acceleration)**
+
+Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `
+ )
+}
+```
+
+This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
+
+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.
+
+### 6.4 Optimize SVG Precision
+
+**Impact: LOW (reduces file size)**
+
+Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
+
+**Incorrect: excessive precision**
+
+```svg
+
+```
+
+**Correct: 1 decimal place**
+
+```svg
+
+```
+
+**Automate with SVGO:**
+
+```bash
+npx svgo --precision=1 --multipass icon.svg
+```
+
+### 6.5 Prevent Hydration Mismatch Without Flickering
+
+**Impact: MEDIUM (avoids visual flicker and hydration errors)**
+
+When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
+
+**Incorrect: breaks SSR**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+ // localStorage is not available on server - throws error
+ const theme = localStorage.getItem('theme') || 'light'
+
+ return (
+
+ )
+}
+```
+
+Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
+
+**Correct: no flicker, no hydration mismatch**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+ return (
+ <>
+
+ {children}
+
+
+ >
+ )
+}
+```
+
+The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
+
+This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
+
+### 6.6 Suppress Expected Hydration Mismatches
+
+**Impact: LOW-MEDIUM (avoids noisy hydration warnings for known differences)**
+
+In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.
+
+**Incorrect: known mismatch warnings**
+
+```tsx
+function Timestamp() {
+ return {new Date().toLocaleString()}
+}
+```
+
+**Correct: suppress expected mismatch only**
+
+```tsx
+function Timestamp() {
+ return (
+
+ {new Date().toLocaleString()}
+
+ )
+}
+```
+
+### 6.7 Use Activity Component for Show/Hide
+
+**Impact: MEDIUM (preserves state/DOM)**
+
+Use React's `` to preserve state/DOM for expensive components that frequently toggle visibility.
+
+**Usage:**
+
+```tsx
+import { Activity } from 'react'
+
+function Dropdown({ isOpen }: Props) {
+ return (
+
+
+
+ )
+}
+```
+
+Avoids expensive re-renders and state loss.
+
+### 6.8 Use Explicit Conditional Rendering
+
+**Impact: LOW (prevents rendering 0 or NaN)**
+
+Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
+
+**Incorrect: renders "0" when count is 0**
+
+```tsx
+function Badge({ count }: { count: number }) {
+ return (
+
+ {count && {count}}
+
+ )
+}
+
+// When count = 0, renders:
0
+// When count = 5, renders:
5
+```
+
+**Correct: renders nothing when count is 0**
+
+```tsx
+function Badge({ count }: { count: number }) {
+ return (
+
+ {count > 0 ? {count} : null}
+
+ )
+}
+
+// When count = 0, renders:
+// When count = 5, renders:
5
+```
+
+### 6.9 Use useTransition Over Manual Loading States
+
+**Impact: LOW (reduces re-renders and improves code clarity)**
+
+Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
+
+**Incorrect: manual loading state**
+
+```tsx
+function SearchResults() {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+
+ const handleSearch = async (value: string) => {
+ setIsLoading(true)
+ setQuery(value)
+ const data = await fetchResults(value)
+ setResults(data)
+ setIsLoading(false)
+ }
+
+ return (
+ <>
+ handleSearch(e.target.value)} />
+ {isLoading && }
+
+ >
+ )
+}
+```
+
+**Correct: useTransition with built-in pending state**
+
+```tsx
+import { useTransition, useState } from 'react'
+
+function SearchResults() {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [isPending, startTransition] = useTransition()
+
+ const handleSearch = (value: string) => {
+ setQuery(value) // Update input immediately
+
+ startTransition(async () => {
+ // Fetch and update results
+ const data = await fetchResults(value)
+ setResults(data)
+ })
+ }
+
+ return (
+ <>
+ handleSearch(e.target.value)} />
+ {isPending && }
+
+ >
+ )
+}
+```
+
+**Benefits:**
+
+- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
+
+- **Error resilience**: Pending state correctly resets even if the transition throws
+
+- **Better responsiveness**: Keeps the UI responsive during updates
+
+- **Interrupt handling**: New transitions automatically cancel pending ones
+
+Reference: [https://react.dev/reference/react/useTransition](https://react.dev/reference/react/useTransition)
+
+---
+
+## 7. JavaScript Performance
+
+**Impact: LOW-MEDIUM**
+
+Micro-optimizations for hot paths can add up to meaningful improvements.
+
+### 7.1 Avoid Layout Thrashing
+
+**Impact: MEDIUM (prevents forced synchronous layouts and reduces performance bottlenecks)**
+
+Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.
+
+**This is OK: browser batches style changes**
+
+```typescript
+function updateElementStyles(element: HTMLElement) {
+ // Each line invalidates style, but browser batches the recalculation
+ element.style.width = '100px'
+ element.style.height = '200px'
+ element.style.backgroundColor = 'blue'
+ element.style.border = '1px solid black'
+}
+```
+
+**Incorrect: interleaved reads and writes force reflows**
+
+```typescript
+function layoutThrashing(element: HTMLElement) {
+ element.style.width = '100px'
+ const width = element.offsetWidth // Forces reflow
+ element.style.height = '200px'
+ const height = element.offsetHeight // Forces another reflow
+}
+```
+
+**Correct: batch writes, then read once**
+
+```typescript
+function updateElementStyles(element: HTMLElement) {
+ // Batch all writes together
+ element.style.width = '100px'
+ element.style.height = '200px'
+ element.style.backgroundColor = 'blue'
+ element.style.border = '1px solid black'
+
+ // Read after all writes are done (single reflow)
+ const { width, height } = element.getBoundingClientRect()
+}
+```
+
+**Correct: batch reads, then writes**
+
+```typescript
+function updateElementStyles(element: HTMLElement) {
+ element.classList.add('highlighted-box')
+
+ const { width, height } = element.getBoundingClientRect()
+}
+```
+
+**Better: use CSS classes**
+
+**React example:**
+
+```tsx
+// Incorrect: interleaving style changes with layout queries
+function Box({ isHighlighted }: { isHighlighted: boolean }) {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (ref.current && isHighlighted) {
+ ref.current.style.width = '100px'
+ const width = ref.current.offsetWidth // Forces layout
+ ref.current.style.height = '200px'
+ }
+ }, [isHighlighted])
+
+ return
+}
+```
+
+**Why this matters in React:**
+
+1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
+
+2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
+
+**Browser support: fallback for older browsers**
+
+```typescript
+// Fallback for older browsers
+const sorted = [...items].sort((a, b) => a.value - b.value)
+```
+
+`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
+
+**Other immutable array methods:**
+
+- `.toSorted()` - immutable sort
+
+- `.toReversed()` - immutable reverse
+
+- `.toSpliced()` - immutable splice
+
+- `.with()` - immutable element replacement
+
+---
+
+## 8. Advanced Patterns
+
+**Impact: LOW**
+
+Advanced patterns for specific cases that require careful implementation.
+
+### 8.1 Initialize App Once, Not Per Mount
+
+**Impact: LOW-MEDIUM (avoids duplicate init in development)**
+
+Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
+
+**Incorrect: runs twice in dev, re-runs on remount**
+
+```tsx
+function Comp() {
+ useEffect(() => {
+ loadFromStorage()
+ checkAuthToken()
+ }, [])
+
+ // ...
+}
+```
+
+**Correct: once per app load**
+
+```tsx
+let didInit = false
+
+function Comp() {
+ useEffect(() => {
+ if (didInit) return
+ didInit = true
+ loadFromStorage()
+ checkAuthToken()
+ }, [])
+
+ // ...
+}
+```
+
+Reference: [https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
+
+### 8.2 Store Event Handlers in Refs
+
+**Impact: LOW (stable subscriptions)**
+
+Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
+
+**Incorrect: re-subscribes on every render**
+
+```tsx
+function useWindowEvent(event: string, handler: (e) => void) {
+ useEffect(() => {
+ window.addEventListener(event, handler)
+ return () => window.removeEventListener(event, handler)
+ }, [event, handler])
+}
+```
+
+**Correct: stable subscription**
+
+```tsx
+import { useEffectEvent } from 'react'
+
+function useWindowEvent(event: string, handler: (e) => void) {
+ const onEvent = useEffectEvent(handler)
+
+ useEffect(() => {
+ window.addEventListener(event, onEvent)
+ return () => window.removeEventListener(event, onEvent)
+ }, [event])
+}
+```
+
+**Alternative: use `useEffectEvent` if you're on latest React:**
+
+`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
+
+### 8.3 useEffectEvent for Stable Callback Refs
+
+**Impact: LOW (prevents effect re-runs)**
+
+Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
+
+**Incorrect: effect re-runs on every callback change**
+
+```tsx
+function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
+ const [query, setQuery] = useState('')
+
+ useEffect(() => {
+ const timeout = setTimeout(() => onSearch(query), 300)
+ return () => clearTimeout(timeout)
+ }, [query, onSearch])
+}
+```
+
+**Correct: using React's useEffectEvent**
+
+```tsx
+import { useEffectEvent } from 'react';
+
+function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
+ const [query, setQuery] = useState('')
+ const onSearchEvent = useEffectEvent(onSearch)
+
+ useEffect(() => {
+ const timeout = setTimeout(() => onSearchEvent(query), 300)
+ return () => clearTimeout(timeout)
+ }, [query])
+}
+```
+
+---
+
+## References
+
+1. [https://react.dev](https://react.dev)
+2. [https://nextjs.org](https://nextjs.org)
+3. [https://swr.vercel.app](https://swr.vercel.app)
+4. [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
+5. [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
+6. [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
+7. [https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
diff --git a/.agents/skills/vercel-react-best-practices/README.md b/.agents/skills/vercel-react-best-practices/README.md
new file mode 100644
index 00000000..f283e1c0
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/README.md
@@ -0,0 +1,123 @@
+# React Best Practices
+
+A structured repository for creating and maintaining React Best Practices optimized for agents and LLMs.
+
+## Structure
+
+- `rules/` - Individual rule files (one per rule)
+ - `_sections.md` - Section metadata (titles, impacts, descriptions)
+ - `_template.md` - Template for creating new rules
+ - `area-description.md` - Individual rule files
+- `src/` - Build scripts and utilities
+- `metadata.json` - Document metadata (version, organization, abstract)
+- __`AGENTS.md`__ - Compiled output (generated)
+- __`test-cases.json`__ - Test cases for LLM evaluation (generated)
+
+## Getting Started
+
+1. Install dependencies:
+ ```bash
+ pnpm install
+ ```
+
+2. Build AGENTS.md from rules:
+ ```bash
+ pnpm build
+ ```
+
+3. Validate rule files:
+ ```bash
+ pnpm validate
+ ```
+
+4. Extract test cases:
+ ```bash
+ pnpm extract-tests
+ ```
+
+## Creating a New Rule
+
+1. Copy `rules/_template.md` to `rules/area-description.md`
+2. Choose the appropriate area prefix:
+ - `async-` for Eliminating Waterfalls (Section 1)
+ - `bundle-` for Bundle Size Optimization (Section 2)
+ - `server-` for Server-Side Performance (Section 3)
+ - `client-` for Client-Side Data Fetching (Section 4)
+ - `rerender-` for Re-render Optimization (Section 5)
+ - `rendering-` for Rendering Performance (Section 6)
+ - `js-` for JavaScript Performance (Section 7)
+ - `advanced-` for Advanced Patterns (Section 8)
+3. Fill in the frontmatter and content
+4. Ensure you have clear examples with explanations
+5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
+
+## Rule File Structure
+
+Each rule file should follow this structure:
+
+```markdown
+---
+title: Rule Title Here
+impact: MEDIUM
+impactDescription: Optional description
+tags: tag1, tag2, tag3
+---
+
+## Rule Title Here
+
+Brief explanation of the rule and why it matters.
+
+**Incorrect (description of what's wrong):**
+
+```typescript
+// Bad code example
+```
+
+**Correct (description of what's right):**
+
+```typescript
+// Good code example
+```
+
+Optional explanatory text after examples.
+
+Reference: [Link](https://example.com)
+
+## File Naming Convention
+
+- Files starting with `_` are special (excluded from build)
+- Rule files: `area-description.md` (e.g., `async-parallel.md`)
+- Section is automatically inferred from filename prefix
+- Rules are sorted alphabetically by title within each section
+- IDs (e.g., 1.1, 1.2) are auto-generated during build
+
+## Impact Levels
+
+- `CRITICAL` - Highest priority, major performance gains
+- `HIGH` - Significant performance improvements
+- `MEDIUM-HIGH` - Moderate-high gains
+- `MEDIUM` - Moderate performance improvements
+- `LOW-MEDIUM` - Low-medium gains
+- `LOW` - Incremental improvements
+
+## Scripts
+
+- `pnpm build` - Compile rules into AGENTS.md
+- `pnpm validate` - Validate all rule files
+- `pnpm extract-tests` - Extract test cases for LLM evaluation
+- `pnpm dev` - Build and validate
+
+## Contributing
+
+When adding or modifying rules:
+
+1. Use the correct filename prefix for your section
+2. Follow the `_template.md` structure
+3. Include clear bad/good examples with explanations
+4. Add appropriate tags
+5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
+6. Rules are automatically sorted by title - no need to manage numbers!
+
+## Acknowledgments
+
+Originally created by [@shuding](https://x.com/shuding) at [Vercel](https://vercel.com).
diff --git a/.agents/skills/vercel-react-best-practices/SKILL.md b/.agents/skills/vercel-react-best-practices/SKILL.md
new file mode 100644
index 00000000..7e887d4e
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/SKILL.md
@@ -0,0 +1,137 @@
+---
+name: vercel-react-best-practices
+description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
+license: MIT
+metadata:
+ author: vercel
+ version: "1.0.0"
+---
+
+# Vercel React Best Practices
+
+Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 58 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
+
+## When to Apply
+
+Reference these guidelines when:
+- Writing new React components or Next.js pages
+- Implementing data fetching (client or server-side)
+- Reviewing code for performance issues
+- Refactoring existing React/Next.js code
+- Optimizing bundle size or load times
+
+## Rule Categories by Priority
+
+| Priority | Category | Impact | Prefix |
+|----------|----------|--------|--------|
+| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
+| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
+| 3 | Server-Side Performance | HIGH | `server-` |
+| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
+| 5 | Re-render Optimization | MEDIUM | `rerender-` |
+| 6 | Rendering Performance | MEDIUM | `rendering-` |
+| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
+| 8 | Advanced Patterns | LOW | `advanced-` |
+
+## Quick Reference
+
+### 1. Eliminating Waterfalls (CRITICAL)
+
+- `async-defer-await` - Move await into branches where actually used
+- `async-parallel` - Use Promise.all() for independent operations
+- `async-dependencies` - Use better-all for partial dependencies
+- `async-api-routes` - Start promises early, await late in API routes
+- `async-suspense-boundaries` - Use Suspense to stream content
+
+### 2. Bundle Size Optimization (CRITICAL)
+
+- `bundle-barrel-imports` - Import directly, avoid barrel files
+- `bundle-dynamic-imports` - Use next/dynamic for heavy components
+- `bundle-defer-third-party` - Load analytics/logging after hydration
+- `bundle-conditional` - Load modules only when feature is activated
+- `bundle-preload` - Preload on hover/focus for perceived speed
+
+### 3. Server-Side Performance (HIGH)
+
+- `server-auth-actions` - Authenticate server actions like API routes
+- `server-cache-react` - Use React.cache() for per-request deduplication
+- `server-cache-lru` - Use LRU cache for cross-request caching
+- `server-dedup-props` - Avoid duplicate serialization in RSC props
+- `server-hoist-static-io` - Hoist static I/O (fonts, logos) to module level
+- `server-serialization` - Minimize data passed to client components
+- `server-parallel-fetching` - Restructure components to parallelize fetches
+- `server-after-nonblocking` - Use after() for non-blocking operations
+
+### 4. Client-Side Data Fetching (MEDIUM-HIGH)
+
+- `client-swr-dedup` - Use SWR for automatic request deduplication
+- `client-event-listeners` - Deduplicate global event listeners
+- `client-passive-event-listeners` - Use passive listeners for scroll
+- `client-localstorage-schema` - Version and minimize localStorage data
+
+### 5. Re-render Optimization (MEDIUM)
+
+- `rerender-defer-reads` - Don't subscribe to state only used in callbacks
+- `rerender-memo` - Extract expensive work into memoized components
+- `rerender-memo-with-default-value` - Hoist default non-primitive props
+- `rerender-dependencies` - Use primitive dependencies in effects
+- `rerender-derived-state` - Subscribe to derived booleans, not raw values
+- `rerender-derived-state-no-effect` - Derive state during render, not effects
+- `rerender-functional-setstate` - Use functional setState for stable callbacks
+- `rerender-lazy-state-init` - Pass function to useState for expensive values
+- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives
+- `rerender-move-effect-to-event` - Put interaction logic in event handlers
+- `rerender-transitions` - Use startTransition for non-urgent updates
+- `rerender-use-ref-transient-values` - Use refs for transient frequent values
+
+### 6. Rendering Performance (MEDIUM)
+
+- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element
+- `rendering-content-visibility` - Use content-visibility for long lists
+- `rendering-hoist-jsx` - Extract static JSX outside components
+- `rendering-svg-precision` - Reduce SVG coordinate precision
+- `rendering-hydration-no-flicker` - Use inline script for client-only data
+- `rendering-hydration-suppress-warning` - Suppress expected mismatches
+- `rendering-activity` - Use Activity component for show/hide
+- `rendering-conditional-render` - Use ternary, not && for conditionals
+- `rendering-usetransition-loading` - Prefer useTransition for loading state
+
+### 7. JavaScript Performance (LOW-MEDIUM)
+
+- `js-batch-dom-css` - Group CSS changes via classes or cssText
+- `js-index-maps` - Build Map for repeated lookups
+- `js-cache-property-access` - Cache object properties in loops
+- `js-cache-function-results` - Cache function results in module-level Map
+- `js-cache-storage` - Cache localStorage/sessionStorage reads
+- `js-combine-iterations` - Combine multiple filter/map into one loop
+- `js-length-check-first` - Check array length before expensive comparison
+- `js-early-exit` - Return early from functions
+- `js-hoist-regexp` - Hoist RegExp creation outside loops
+- `js-min-max-loop` - Use loop for min/max instead of sort
+- `js-set-map-lookups` - Use Set/Map for O(1) lookups
+- `js-tosorted-immutable` - Use toSorted() for immutability
+
+### 8. Advanced Patterns (LOW)
+
+- `advanced-event-handler-refs` - Store event handlers in refs
+- `advanced-init-once` - Initialize app once per app load
+- `advanced-use-latest` - useLatest for stable callback refs
+
+## How to Use
+
+Read individual rule files for detailed explanations and code examples:
+
+```
+rules/async-parallel.md
+rules/bundle-barrel-imports.md
+```
+
+Each rule file contains:
+- Brief explanation of why it matters
+- Incorrect code example with explanation
+- Correct code example with explanation
+- Additional context and references
+
+## Full Compiled Document
+
+For the complete guide with all rules expanded: `AGENTS.md`
diff --git a/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md b/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md
new file mode 100644
index 00000000..97e7ade2
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md
@@ -0,0 +1,55 @@
+---
+title: Store Event Handlers in Refs
+impact: LOW
+impactDescription: stable subscriptions
+tags: advanced, hooks, refs, event-handlers, optimization
+---
+
+## Store Event Handlers in Refs
+
+Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
+
+**Incorrect (re-subscribes on every render):**
+
+```tsx
+function useWindowEvent(event: string, handler: (e) => void) {
+ useEffect(() => {
+ window.addEventListener(event, handler)
+ return () => window.removeEventListener(event, handler)
+ }, [event, handler])
+}
+```
+
+**Correct (stable subscription):**
+
+```tsx
+function useWindowEvent(event: string, handler: (e) => void) {
+ const handlerRef = useRef(handler)
+ useEffect(() => {
+ handlerRef.current = handler
+ }, [handler])
+
+ useEffect(() => {
+ const listener = (e) => handlerRef.current(e)
+ window.addEventListener(event, listener)
+ return () => window.removeEventListener(event, listener)
+ }, [event])
+}
+```
+
+**Alternative: use `useEffectEvent` if you're on latest React:**
+
+```tsx
+import { useEffectEvent } from 'react'
+
+function useWindowEvent(event: string, handler: (e) => void) {
+ const onEvent = useEffectEvent(handler)
+
+ useEffect(() => {
+ window.addEventListener(event, onEvent)
+ return () => window.removeEventListener(event, onEvent)
+ }, [event])
+}
+```
+
+`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
diff --git a/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md b/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md
new file mode 100644
index 00000000..73ee38e5
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md
@@ -0,0 +1,42 @@
+---
+title: Initialize App Once, Not Per Mount
+impact: LOW-MEDIUM
+impactDescription: avoids duplicate init in development
+tags: initialization, useEffect, app-startup, side-effects
+---
+
+## Initialize App Once, Not Per Mount
+
+Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
+
+**Incorrect (runs twice in dev, re-runs on remount):**
+
+```tsx
+function Comp() {
+ useEffect(() => {
+ loadFromStorage()
+ checkAuthToken()
+ }, [])
+
+ // ...
+}
+```
+
+**Correct (once per app load):**
+
+```tsx
+let didInit = false
+
+function Comp() {
+ useEffect(() => {
+ if (didInit) return
+ didInit = true
+ loadFromStorage()
+ checkAuthToken()
+ }, [])
+
+ // ...
+}
+```
+
+Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
diff --git a/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md b/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md
new file mode 100644
index 00000000..9c7cb501
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md
@@ -0,0 +1,39 @@
+---
+title: useEffectEvent for Stable Callback Refs
+impact: LOW
+impactDescription: prevents effect re-runs
+tags: advanced, hooks, useEffectEvent, refs, optimization
+---
+
+## useEffectEvent for Stable Callback Refs
+
+Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
+
+**Incorrect (effect re-runs on every callback change):**
+
+```tsx
+function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
+ const [query, setQuery] = useState('')
+
+ useEffect(() => {
+ const timeout = setTimeout(() => onSearch(query), 300)
+ return () => clearTimeout(timeout)
+ }, [query, onSearch])
+}
+```
+
+**Correct (using React's useEffectEvent):**
+
+```tsx
+import { useEffectEvent } from 'react';
+
+function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
+ const [query, setQuery] = useState('')
+ const onSearchEvent = useEffectEvent(onSearch)
+
+ useEffect(() => {
+ const timeout = setTimeout(() => onSearchEvent(query), 300)
+ return () => clearTimeout(timeout)
+ }, [query])
+}
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md b/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md
new file mode 100644
index 00000000..6feda1ef
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md
@@ -0,0 +1,38 @@
+---
+title: Prevent Waterfall Chains in API Routes
+impact: CRITICAL
+impactDescription: 2-10× improvement
+tags: api-routes, server-actions, waterfalls, parallelization
+---
+
+## Prevent Waterfall Chains in API Routes
+
+In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
+
+**Incorrect (config waits for auth, data waits for both):**
+
+```typescript
+export async function GET(request: Request) {
+ const session = await auth()
+ const config = await fetchConfig()
+ const data = await fetchData(session.user.id)
+ return Response.json({ data, config })
+}
+```
+
+**Correct (auth and config start immediately):**
+
+```typescript
+export async function GET(request: Request) {
+ const sessionPromise = auth()
+ const configPromise = fetchConfig()
+ const session = await sessionPromise
+ const [config, data] = await Promise.all([
+ configPromise,
+ fetchData(session.user.id)
+ ])
+ return Response.json({ data, config })
+}
+```
+
+For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
diff --git a/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md b/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md
new file mode 100644
index 00000000..ea7082a3
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md
@@ -0,0 +1,80 @@
+---
+title: Defer Await Until Needed
+impact: HIGH
+impactDescription: avoids blocking unused code paths
+tags: async, await, conditional, optimization
+---
+
+## Defer Await Until Needed
+
+Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
+
+**Incorrect (blocks both branches):**
+
+```typescript
+async function handleRequest(userId: string, skipProcessing: boolean) {
+ const userData = await fetchUserData(userId)
+
+ if (skipProcessing) {
+ // Returns immediately but still waited for userData
+ return { skipped: true }
+ }
+
+ // Only this branch uses userData
+ return processUserData(userData)
+}
+```
+
+**Correct (only blocks when needed):**
+
+```typescript
+async function handleRequest(userId: string, skipProcessing: boolean) {
+ if (skipProcessing) {
+ // Returns immediately without waiting
+ return { skipped: true }
+ }
+
+ // Fetch only when needed
+ const userData = await fetchUserData(userId)
+ return processUserData(userData)
+}
+```
+
+**Another example (early return optimization):**
+
+```typescript
+// Incorrect: always fetches permissions
+async function updateResource(resourceId: string, userId: string) {
+ const permissions = await fetchPermissions(userId)
+ const resource = await getResource(resourceId)
+
+ if (!resource) {
+ return { error: 'Not found' }
+ }
+
+ if (!permissions.canEdit) {
+ return { error: 'Forbidden' }
+ }
+
+ return await updateResourceData(resource, permissions)
+}
+
+// Correct: fetches only when needed
+async function updateResource(resourceId: string, userId: string) {
+ const resource = await getResource(resourceId)
+
+ if (!resource) {
+ return { error: 'Not found' }
+ }
+
+ const permissions = await fetchPermissions(userId)
+
+ if (!permissions.canEdit) {
+ return { error: 'Forbidden' }
+ }
+
+ return await updateResourceData(resource, permissions)
+}
+```
+
+This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
diff --git a/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md b/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md
new file mode 100644
index 00000000..0484ebab
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md
@@ -0,0 +1,51 @@
+---
+title: Dependency-Based Parallelization
+impact: CRITICAL
+impactDescription: 2-10× improvement
+tags: async, parallelization, dependencies, better-all
+---
+
+## Dependency-Based Parallelization
+
+For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
+
+**Incorrect (profile waits for config unnecessarily):**
+
+```typescript
+const [user, config] = await Promise.all([
+ fetchUser(),
+ fetchConfig()
+])
+const profile = await fetchProfile(user.id)
+```
+
+**Correct (config and profile run in parallel):**
+
+```typescript
+import { all } from 'better-all'
+
+const { user, config, profile } = await all({
+ async user() { return fetchUser() },
+ async config() { return fetchConfig() },
+ async profile() {
+ return fetchProfile((await this.$.user).id)
+ }
+})
+```
+
+**Alternative without extra dependencies:**
+
+We can also create all the promises first, and do `Promise.all()` at the end.
+
+```typescript
+const userPromise = fetchUser()
+const profilePromise = userPromise.then(user => fetchProfile(user.id))
+
+const [user, config, profile] = await Promise.all([
+ userPromise,
+ fetchConfig(),
+ profilePromise
+])
+```
+
+Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
diff --git a/.agents/skills/vercel-react-best-practices/rules/async-parallel.md b/.agents/skills/vercel-react-best-practices/rules/async-parallel.md
new file mode 100644
index 00000000..64133f6c
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/async-parallel.md
@@ -0,0 +1,28 @@
+---
+title: Promise.all() for Independent Operations
+impact: CRITICAL
+impactDescription: 2-10× improvement
+tags: async, parallelization, promises, waterfalls
+---
+
+## Promise.all() for Independent Operations
+
+When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
+
+**Incorrect (sequential execution, 3 round trips):**
+
+```typescript
+const user = await fetchUser()
+const posts = await fetchPosts()
+const comments = await fetchComments()
+```
+
+**Correct (parallel execution, 1 round trip):**
+
+```typescript
+const [user, posts, comments] = await Promise.all([
+ fetchUser(),
+ fetchPosts(),
+ fetchComments()
+])
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md b/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md
new file mode 100644
index 00000000..1fbc05b0
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md
@@ -0,0 +1,99 @@
+---
+title: Strategic Suspense Boundaries
+impact: HIGH
+impactDescription: faster initial paint
+tags: async, suspense, streaming, layout-shift
+---
+
+## Strategic Suspense Boundaries
+
+Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
+
+**Incorrect (wrapper blocked by data fetching):**
+
+```tsx
+async function Page() {
+ const data = await fetchData() // Blocks entire page
+
+ return (
+
+
Sidebar
+
Header
+
+
+
+
Footer
+
+ )
+}
+```
+
+The entire layout waits for data even though only the middle section needs it.
+
+**Correct (wrapper shows immediately, data streams in):**
+
+```tsx
+function Page() {
+ return (
+
+
Sidebar
+
Header
+
+ }>
+
+
+
+
Footer
+
+ )
+}
+
+async function DataDisplay() {
+ const data = await fetchData() // Only blocks this component
+ return
{data.content}
+}
+```
+
+Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
+
+**Alternative (share promise across components):**
+
+```tsx
+function Page() {
+ // Start fetch immediately, but don't await
+ const dataPromise = fetchData()
+
+ return (
+
+ )
+}
+```
+
+Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.
+
+See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
diff --git a/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md b/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md
new file mode 100644
index 00000000..180f8ac8
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md
@@ -0,0 +1,80 @@
+---
+title: Cache Repeated Function Calls
+impact: MEDIUM
+impactDescription: avoid redundant computation
+tags: javascript, cache, memoization, performance
+---
+
+## Cache Repeated Function Calls
+
+Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
+
+**Incorrect (redundant computation):**
+
+```typescript
+function ProjectList({ projects }: { projects: Project[] }) {
+ return (
+
+ {projects.map(project => {
+ // slugify() called 100+ times for same project names
+ const slug = slugify(project.name)
+
+ return
+ })}
+
+ )
+}
+```
+
+This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md b/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md
new file mode 100644
index 00000000..7e866f58
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md
@@ -0,0 +1,40 @@
+---
+title: Use Explicit Conditional Rendering
+impact: LOW
+impactDescription: prevents rendering 0 or NaN
+tags: rendering, conditional, jsx, falsy-values
+---
+
+## Use Explicit Conditional Rendering
+
+Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
+
+**Incorrect (renders "0" when count is 0):**
+
+```tsx
+function Badge({ count }: { count: number }) {
+ return (
+
+ {count && {count}}
+
+ )
+}
+
+// When count = 0, renders:
0
+// When count = 5, renders:
5
+```
+
+**Correct (renders nothing when count is 0):**
+
+```tsx
+function Badge({ count }: { count: number }) {
+ return (
+
+ {count > 0 ? {count} : null}
+
+ )
+}
+
+// When count = 0, renders:
+// When count = 5, renders:
+ )
+}
+```
+
+This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
+
+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.
diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md b/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md
new file mode 100644
index 00000000..5cf0e79b
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md
@@ -0,0 +1,82 @@
+---
+title: Prevent Hydration Mismatch Without Flickering
+impact: MEDIUM
+impactDescription: avoids visual flicker and hydration errors
+tags: rendering, ssr, hydration, localStorage, flicker
+---
+
+## Prevent Hydration Mismatch Without Flickering
+
+When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
+
+**Incorrect (breaks SSR):**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+ // localStorage is not available on server - throws error
+ const theme = localStorage.getItem('theme') || 'light'
+
+ return (
+
+ )
+}
+```
+
+Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
+
+**Correct (no flicker, no hydration mismatch):**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+ return (
+ <>
+
+ {children}
+
+
+ >
+ )
+}
+```
+
+The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
+
+This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md b/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md
new file mode 100644
index 00000000..24ba2513
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md
@@ -0,0 +1,30 @@
+---
+title: Suppress Expected Hydration Mismatches
+impact: LOW-MEDIUM
+impactDescription: avoids noisy hydration warnings for known differences
+tags: rendering, hydration, ssr, nextjs
+---
+
+## Suppress Expected Hydration Mismatches
+
+In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.
+
+**Incorrect (known mismatch warnings):**
+
+```tsx
+function Timestamp() {
+ return {new Date().toLocaleString()}
+}
+```
+
+**Correct (suppress expected mismatch only):**
+
+```tsx
+function Timestamp() {
+ return (
+
+ {new Date().toLocaleString()}
+
+ )
+}
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md b/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md
new file mode 100644
index 00000000..6d771286
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md
@@ -0,0 +1,28 @@
+---
+title: Optimize SVG Precision
+impact: LOW
+impactDescription: reduces file size
+tags: rendering, svg, optimization, svgo
+---
+
+## Optimize SVG Precision
+
+Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
+
+**Incorrect (excessive precision):**
+
+```svg
+
+```
+
+**Correct (1 decimal place):**
+
+```svg
+
+```
+
+**Automate with SVGO:**
+
+```bash
+npx svgo --precision=1 --multipass icon.svg
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md b/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md
new file mode 100644
index 00000000..0c1b0b98
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md
@@ -0,0 +1,75 @@
+---
+title: Use useTransition Over Manual Loading States
+impact: LOW
+impactDescription: reduces re-renders and improves code clarity
+tags: rendering, transitions, useTransition, loading, state
+---
+
+## Use useTransition Over Manual Loading States
+
+Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
+
+**Incorrect (manual loading state):**
+
+```tsx
+function SearchResults() {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+
+ const handleSearch = async (value: string) => {
+ setIsLoading(true)
+ setQuery(value)
+ const data = await fetchResults(value)
+ setResults(data)
+ setIsLoading(false)
+ }
+
+ return (
+ <>
+ handleSearch(e.target.value)} />
+ {isLoading && }
+
+ >
+ )
+}
+```
+
+**Correct (useTransition with built-in pending state):**
+
+```tsx
+import { useTransition, useState } from 'react'
+
+function SearchResults() {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [isPending, startTransition] = useTransition()
+
+ const handleSearch = (value: string) => {
+ setQuery(value) // Update input immediately
+
+ startTransition(async () => {
+ // Fetch and update results
+ const data = await fetchResults(value)
+ setResults(data)
+ })
+ }
+
+ return (
+ <>
+ handleSearch(e.target.value)} />
+ {isPending && }
+
+ >
+ )
+}
+```
+
+**Benefits:**
+
+- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
+- **Error resilience**: Pending state correctly resets even if the transition throws
+- **Better responsiveness**: Keeps the UI responsive during updates
+- **Interrupt handling**: New transitions automatically cancel pending ones
+
+Reference: [useTransition](https://react.dev/reference/react/useTransition)
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md b/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md
new file mode 100644
index 00000000..e867c95f
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md
@@ -0,0 +1,39 @@
+---
+title: Defer State Reads to Usage Point
+impact: MEDIUM
+impactDescription: avoids unnecessary subscriptions
+tags: rerender, searchParams, localStorage, optimization
+---
+
+## Defer State Reads to Usage Point
+
+Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
+
+**Incorrect (subscribes to all searchParams changes):**
+
+```tsx
+function ShareButton({ chatId }: { chatId: string }) {
+ const searchParams = useSearchParams()
+
+ const handleShare = () => {
+ const ref = searchParams.get('ref')
+ shareChat(chatId, { ref })
+ }
+
+ return
+}
+```
+
+**Correct (reads on demand, no subscription):**
+
+```tsx
+function ShareButton({ chatId }: { chatId: string }) {
+ const handleShare = () => {
+ const params = new URLSearchParams(window.location.search)
+ const ref = params.get('ref')
+ shareChat(chatId, { ref })
+ }
+
+ return
+}
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md b/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md
new file mode 100644
index 00000000..47a4d926
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md
@@ -0,0 +1,45 @@
+---
+title: Narrow Effect Dependencies
+impact: LOW
+impactDescription: minimizes effect re-runs
+tags: rerender, useEffect, dependencies, optimization
+---
+
+## Narrow Effect Dependencies
+
+Specify primitive dependencies instead of objects to minimize effect re-runs.
+
+**Incorrect (re-runs on any user field change):**
+
+```tsx
+useEffect(() => {
+ console.log(user.id)
+}, [user])
+```
+
+**Correct (re-runs only when id changes):**
+
+```tsx
+useEffect(() => {
+ console.log(user.id)
+}, [user.id])
+```
+
+**For derived state, compute outside effect:**
+
+```tsx
+// Incorrect: runs on width=767, 766, 765...
+useEffect(() => {
+ if (width < 768) {
+ enableMobileMode()
+ }
+}, [width])
+
+// Correct: runs only on boolean transition
+const isMobile = width < 768
+useEffect(() => {
+ if (isMobile) {
+ enableMobileMode()
+ }
+}, [isMobile])
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md b/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md
new file mode 100644
index 00000000..3d9fe405
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md
@@ -0,0 +1,40 @@
+---
+title: Calculate Derived State During Rendering
+impact: MEDIUM
+impactDescription: avoids redundant renders and state drift
+tags: rerender, derived-state, useEffect, state
+---
+
+## Calculate Derived State During Rendering
+
+If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.
+
+**Incorrect (redundant state and effect):**
+
+```tsx
+function Form() {
+ const [firstName, setFirstName] = useState('First')
+ const [lastName, setLastName] = useState('Last')
+ const [fullName, setFullName] = useState('')
+
+ useEffect(() => {
+ setFullName(firstName + ' ' + lastName)
+ }, [firstName, lastName])
+
+ return
+}
+```
+
+References: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md b/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md
new file mode 100644
index 00000000..e5c899f6
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md
@@ -0,0 +1,29 @@
+---
+title: Subscribe to Derived State
+impact: MEDIUM
+impactDescription: reduces re-render frequency
+tags: rerender, derived-state, media-query, optimization
+---
+
+## Subscribe to Derived State
+
+Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
+
+**Incorrect (re-renders on every pixel change):**
+
+```tsx
+function Sidebar() {
+ const width = useWindowWidth() // updates continuously
+ const isMobile = width < 768
+ return
+}
+```
+
+**Correct (re-renders only when boolean changes):**
+
+```tsx
+function Sidebar() {
+ const isMobile = useMediaQuery('(max-width: 767px)')
+ return
+}
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md b/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md
new file mode 100644
index 00000000..b004ef45
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md
@@ -0,0 +1,74 @@
+---
+title: Use Functional setState Updates
+impact: MEDIUM
+impactDescription: prevents stale closures and unnecessary callback recreations
+tags: react, hooks, useState, useCallback, callbacks, closures
+---
+
+## Use Functional setState Updates
+
+When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
+
+**Incorrect (requires state as dependency):**
+
+```tsx
+function TodoList() {
+ const [items, setItems] = useState(initialItems)
+
+ // Callback must depend on items, recreated on every items change
+ const addItems = useCallback((newItems: Item[]) => {
+ setItems([...items, ...newItems])
+ }, [items]) // ❌ items dependency causes recreations
+
+ // Risk of stale closure if dependency is forgotten
+ const removeItem = useCallback((id: string) => {
+ setItems(items.filter(item => item.id !== id))
+ }, []) // ❌ Missing items dependency - will use stale items!
+
+ return
+}
+```
+
+The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.
+
+**Correct (stable callbacks, no stale closures):**
+
+```tsx
+function TodoList() {
+ const [items, setItems] = useState(initialItems)
+
+ // Stable callback, never recreated
+ const addItems = useCallback((newItems: Item[]) => {
+ setItems(curr => [...curr, ...newItems])
+ }, []) // ✅ No dependencies needed
+
+ // Always uses latest state, no stale closure risk
+ const removeItem = useCallback((id: string) => {
+ setItems(curr => curr.filter(item => item.id !== id))
+ }, []) // ✅ Safe and stable
+
+ return
+}
+```
+
+**Benefits:**
+
+1. **Stable callback references** - Callbacks don't need to be recreated when state changes
+2. **No stale closures** - Always operates on the latest state value
+3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
+4. **Prevents bugs** - Eliminates the most common source of React closure bugs
+
+**When to use functional updates:**
+
+- Any setState that depends on the current state value
+- Inside useCallback/useMemo when state is needed
+- Event handlers that reference state
+- Async operations that update state
+
+**When direct updates are fine:**
+
+- Setting state to a static value: `setCount(0)`
+- Setting state from props/arguments only: `setName(newName)`
+- State doesn't depend on previous value
+
+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md b/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md
new file mode 100644
index 00000000..4ecb350f
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md
@@ -0,0 +1,58 @@
+---
+title: Use Lazy State Initialization
+impact: MEDIUM
+impactDescription: wasted computation on every render
+tags: react, hooks, useState, performance, initialization
+---
+
+## Use Lazy State Initialization
+
+Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
+
+**Incorrect (runs on every render):**
+
+```tsx
+function FilteredList({ items }: { items: Item[] }) {
+ // buildSearchIndex() runs on EVERY render, even after initialization
+ const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
+ const [query, setQuery] = useState('')
+
+ // When query changes, buildSearchIndex runs again unnecessarily
+ return
+}
+
+function UserProfile() {
+ // JSON.parse runs on every render
+ const [settings, setSettings] = useState(
+ JSON.parse(localStorage.getItem('settings') || '{}')
+ )
+
+ return
+}
+```
+
+**Correct (runs only once):**
+
+```tsx
+function FilteredList({ items }: { items: Item[] }) {
+ // buildSearchIndex() runs ONLY on initial render
+ const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
+ const [query, setQuery] = useState('')
+
+ return
+}
+
+function UserProfile() {
+ // JSON.parse runs only on initial render
+ const [settings, setSettings] = useState(() => {
+ const stored = localStorage.getItem('settings')
+ return stored ? JSON.parse(stored) : {}
+ })
+
+ return
+}
+```
+
+Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
+
+For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md b/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md
new file mode 100644
index 00000000..63570491
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md
@@ -0,0 +1,38 @@
+---
+
+title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant
+impact: MEDIUM
+impactDescription: restores memoization by using a constant for default value
+tags: rerender, memo, optimization
+
+---
+
+## Extract Default Non-primitive Parameter Value from Memoized Component to Constant
+
+When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.
+
+To address this issue, extract the default value into a constant.
+
+**Incorrect (`onClick` has different values on every rerender):**
+
+```tsx
+const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
+ // ...
+})
+
+// Used without optional onClick
+
+```
+
+**Correct (stable default value):**
+
+```tsx
+const NOOP = () => {};
+
+const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
+ // ...
+})
+
+// Used without optional onClick
+
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md b/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md
new file mode 100644
index 00000000..f8982ab6
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md
@@ -0,0 +1,44 @@
+---
+title: Extract to Memoized Components
+impact: MEDIUM
+impactDescription: enables early returns
+tags: rerender, memo, useMemo, optimization
+---
+
+## Extract to Memoized Components
+
+Extract expensive work into memoized components to enable early returns before computation.
+
+**Incorrect (computes avatar even when loading):**
+
+```tsx
+function Profile({ user, loading }: Props) {
+ const avatar = useMemo(() => {
+ const id = computeAvatarId(user)
+ return
+ }, [user])
+
+ if (loading) return
+ return
{avatar}
+}
+```
+
+**Correct (skips computation when loading):**
+
+```tsx
+const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
+ const id = useMemo(() => computeAvatarId(user), [user])
+ return
+})
+
+function Profile({ user, loading }: Props) {
+ if (loading) return
+ return (
+
+
+
+ )
+}
+```
+
+**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md b/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md
new file mode 100644
index 00000000..dd58a1af
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md
@@ -0,0 +1,45 @@
+---
+title: Put Interaction Logic in Event Handlers
+impact: MEDIUM
+impactDescription: avoids effect re-runs and duplicate side effects
+tags: rerender, useEffect, events, side-effects, dependencies
+---
+
+## Put Interaction Logic in Event Handlers
+
+If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.
+
+**Incorrect (event modeled as state + effect):**
+
+```tsx
+function Form() {
+ const [submitted, setSubmitted] = useState(false)
+ const theme = useContext(ThemeContext)
+
+ useEffect(() => {
+ if (submitted) {
+ post('/api/register')
+ showToast('Registered', theme)
+ }
+ }, [submitted, theme])
+
+ return
+}
+```
+
+**Correct (do it in the handler):**
+
+```tsx
+function Form() {
+ const theme = useContext(ThemeContext)
+
+ function handleSubmit() {
+ post('/api/register')
+ showToast('Registered', theme)
+ }
+
+ return
+}
+```
+
+Reference: [Should this code move to an event handler?](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md b/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md
new file mode 100644
index 00000000..59dfab0f
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md
@@ -0,0 +1,35 @@
+---
+title: Do not wrap a simple expression with a primitive result type in useMemo
+impact: LOW-MEDIUM
+impactDescription: wasted computation on every render
+tags: rerender, useMemo, optimization
+---
+
+## Do not wrap a simple expression with a primitive result type in useMemo
+
+When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.
+Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
+
+**Incorrect:**
+
+```tsx
+function Header({ user, notifications }: Props) {
+ const isLoading = useMemo(() => {
+ return user.isLoading || notifications.isLoading
+ }, [user.isLoading, notifications.isLoading])
+
+ if (isLoading) return
+ // return some markup
+}
+```
+
+**Correct:**
+
+```tsx
+function Header({ user, notifications }: Props) {
+ const isLoading = user.isLoading || notifications.isLoading
+
+ if (isLoading) return
+ // return some markup
+}
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md b/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md
new file mode 100644
index 00000000..d99f43f7
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md
@@ -0,0 +1,40 @@
+---
+title: Use Transitions for Non-Urgent Updates
+impact: MEDIUM
+impactDescription: maintains UI responsiveness
+tags: rerender, transitions, startTransition, performance
+---
+
+## Use Transitions for Non-Urgent Updates
+
+Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
+
+**Incorrect (blocks UI on every scroll):**
+
+```tsx
+function ScrollTracker() {
+ const [scrollY, setScrollY] = useState(0)
+ useEffect(() => {
+ const handler = () => setScrollY(window.scrollY)
+ window.addEventListener('scroll', handler, { passive: true })
+ return () => window.removeEventListener('scroll', handler)
+ }, [])
+}
+```
+
+**Correct (non-blocking updates):**
+
+```tsx
+import { startTransition } from 'react'
+
+function ScrollTracker() {
+ const [scrollY, setScrollY] = useState(0)
+ useEffect(() => {
+ const handler = () => {
+ startTransition(() => setScrollY(window.scrollY))
+ }
+ window.addEventListener('scroll', handler, { passive: true })
+ return () => window.removeEventListener('scroll', handler)
+ }, [])
+}
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md b/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md
new file mode 100644
index 00000000..cf04b81f
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md
@@ -0,0 +1,73 @@
+---
+title: Use useRef for Transient Values
+impact: MEDIUM
+impactDescription: avoids unnecessary re-renders on frequent updates
+tags: rerender, useref, state, performance
+---
+
+## Use useRef for Transient Values
+
+When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
+
+**Incorrect (renders every update):**
+
+```tsx
+function Tracker() {
+ const [lastX, setLastX] = useState(0)
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => setLastX(e.clientX)
+ window.addEventListener('mousemove', onMove)
+ return () => window.removeEventListener('mousemove', onMove)
+ }, [])
+
+ return (
+
+ )
+}
+```
+
+**Correct (no re-render for tracking):**
+
+```tsx
+function Tracker() {
+ const lastXRef = useRef(0)
+ const dotRef = useRef(null)
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => {
+ lastXRef.current = e.clientX
+ const node = dotRef.current
+ if (node) {
+ node.style.transform = `translateX(${e.clientX}px)`
+ }
+ }
+ window.addEventListener('mousemove', onMove)
+ return () => window.removeEventListener('mousemove', onMove)
+ }, [])
+
+ return (
+
+ )
+}
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md b/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md
new file mode 100644
index 00000000..e8f5b260
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md
@@ -0,0 +1,73 @@
+---
+title: Use after() for Non-Blocking Operations
+impact: MEDIUM
+impactDescription: faster response times
+tags: server, async, logging, analytics, side-effects
+---
+
+## Use after() for Non-Blocking Operations
+
+Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response.
+
+**Incorrect (blocks response):**
+
+```tsx
+import { logUserAction } from '@/app/utils'
+
+export async function POST(request: Request) {
+ // Perform mutation
+ await updateDatabase(request)
+
+ // Logging blocks the response
+ const userAgent = request.headers.get('user-agent') || 'unknown'
+ await logUserAction({ userAgent })
+
+ return new Response(JSON.stringify({ status: 'success' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ })
+}
+```
+
+**Correct (non-blocking):**
+
+```tsx
+import { after } from 'next/server'
+import { headers, cookies } from 'next/headers'
+import { logUserAction } from '@/app/utils'
+
+export async function POST(request: Request) {
+ // Perform mutation
+ await updateDatabase(request)
+
+ // Log after response is sent
+ after(async () => {
+ const userAgent = (await headers()).get('user-agent') || 'unknown'
+ const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
+
+ logUserAction({ sessionCookie, userAgent })
+ })
+
+ return new Response(JSON.stringify({ status: 'success' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ })
+}
+```
+
+The response is sent immediately while logging happens in the background.
+
+**Common use cases:**
+
+- Analytics tracking
+- Audit logging
+- Sending notifications
+- Cache invalidation
+- Cleanup tasks
+
+**Important notes:**
+
+- `after()` runs even if the response fails or redirects
+- Works in Server Actions, Route Handlers, and Server Components
+
+Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)
diff --git a/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md b/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md
new file mode 100644
index 00000000..ee82c044
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md
@@ -0,0 +1,96 @@
+---
+title: Authenticate Server Actions Like API Routes
+impact: CRITICAL
+impactDescription: prevents unauthorized access to server mutations
+tags: server, server-actions, authentication, security, authorization
+---
+
+## Authenticate Server Actions Like API Routes
+
+**Impact: CRITICAL (prevents unauthorized access to server mutations)**
+
+Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.
+
+Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation."
+
+**Incorrect (no authentication check):**
+
+```typescript
+'use server'
+
+export async function deleteUser(userId: string) {
+ // Anyone can call this! No auth check
+ await db.user.delete({ where: { id: userId } })
+ return { success: true }
+}
+```
+
+**Correct (authentication inside the action):**
+
+```typescript
+'use server'
+
+import { verifySession } from '@/lib/auth'
+import { unauthorized } from '@/lib/errors'
+
+export async function deleteUser(userId: string) {
+ // Always check auth inside the action
+ const session = await verifySession()
+
+ if (!session) {
+ throw unauthorized('Must be logged in')
+ }
+
+ // Check authorization too
+ if (session.user.role !== 'admin' && session.user.id !== userId) {
+ throw unauthorized('Cannot delete other users')
+ }
+
+ await db.user.delete({ where: { id: userId } })
+ return { success: true }
+}
+```
+
+**With input validation:**
+
+```typescript
+'use server'
+
+import { verifySession } from '@/lib/auth'
+import { z } from 'zod'
+
+const updateProfileSchema = z.object({
+ userId: z.string().uuid(),
+ name: z.string().min(1).max(100),
+ email: z.string().email()
+})
+
+export async function updateProfile(data: unknown) {
+ // Validate input first
+ const validated = updateProfileSchema.parse(data)
+
+ // Then authenticate
+ const session = await verifySession()
+ if (!session) {
+ throw new Error('Unauthorized')
+ }
+
+ // Then authorize
+ if (session.user.id !== validated.userId) {
+ throw new Error('Can only update own profile')
+ }
+
+ // Finally perform the mutation
+ await db.user.update({
+ where: { id: validated.userId },
+ data: {
+ name: validated.name,
+ email: validated.email
+ }
+ })
+
+ return { success: true }
+}
+```
+
+Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
diff --git a/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md b/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md
new file mode 100644
index 00000000..ef6938aa
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md
@@ -0,0 +1,41 @@
+---
+title: Cross-Request LRU Caching
+impact: HIGH
+impactDescription: caches across requests
+tags: server, cache, lru, cross-request
+---
+
+## Cross-Request LRU Caching
+
+`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
+
+**Implementation:**
+
+```typescript
+import { LRUCache } from 'lru-cache'
+
+const cache = new LRUCache({
+ max: 1000,
+ ttl: 5 * 60 * 1000 // 5 minutes
+})
+
+export async function getUser(id: string) {
+ const cached = cache.get(id)
+ if (cached) return cached
+
+ const user = await db.user.findUnique({ where: { id } })
+ cache.set(id, user)
+ return user
+}
+
+// Request 1: DB query, result cached
+// Request 2: cache hit, no DB query
+```
+
+Use when sequential user actions hit multiple endpoints needing the same data within seconds.
+
+**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.
+
+**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
+
+Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
diff --git a/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md b/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md
new file mode 100644
index 00000000..87c9ca33
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md
@@ -0,0 +1,76 @@
+---
+title: Per-Request Deduplication with React.cache()
+impact: MEDIUM
+impactDescription: deduplicates within request
+tags: server, cache, react-cache, deduplication
+---
+
+## Per-Request Deduplication with React.cache()
+
+Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
+
+**Usage:**
+
+```typescript
+import { cache } from 'react'
+
+export const getCurrentUser = cache(async () => {
+ const session = await auth()
+ if (!session?.user?.id) return null
+ return await db.user.findUnique({
+ where: { id: session.user.id }
+ })
+})
+```
+
+Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
+
+**Avoid inline objects as arguments:**
+
+`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.
+
+**Incorrect (always cache miss):**
+
+```typescript
+const getUser = cache(async (params: { uid: number }) => {
+ return await db.user.findUnique({ where: { id: params.uid } })
+})
+
+// Each call creates new object, never hits cache
+getUser({ uid: 1 })
+getUser({ uid: 1 }) // Cache miss, runs query again
+```
+
+**Correct (cache hit):**
+
+```typescript
+const getUser = cache(async (uid: number) => {
+ return await db.user.findUnique({ where: { id: uid } })
+})
+
+// Primitive args use value equality
+getUser(1)
+getUser(1) // Cache hit, returns cached result
+```
+
+If you must pass objects, pass the same reference:
+
+```typescript
+const params = { uid: 1 }
+getUser(params) // Query runs
+getUser(params) // Cache hit (same reference)
+```
+
+**Next.js-Specific Note:**
+
+In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks:
+
+- Database queries (Prisma, Drizzle, etc.)
+- Heavy computations
+- Authentication checks
+- File system operations
+- Any non-fetch async work
+
+Use `React.cache()` to deduplicate these operations across your component tree.
+
+Reference: [React.cache documentation](https://react.dev/reference/react/cache)
diff --git a/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md b/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md
new file mode 100644
index 00000000..fb24a256
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md
@@ -0,0 +1,65 @@
+---
+title: Avoid Duplicate Serialization in RSC Props
+impact: LOW
+impactDescription: reduces network payload by avoiding duplicate serialization
+tags: server, rsc, serialization, props, client-components
+---
+
+## Avoid Duplicate Serialization in RSC Props
+
+**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
+
+RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.
+
+**Incorrect (duplicates array):**
+
+```tsx
+// RSC: sends 6 strings (2 arrays × 3 items)
+
+```
+
+**Correct (sends 3 strings):**
+
+```tsx
+// RSC: send once
+
+
+// Client: transform there
+'use client'
+const sorted = useMemo(() => [...usernames].sort(), [usernames])
+```
+
+**Nested deduplication behavior:**
+
+Deduplication works recursively. Impact varies by data type:
+
+- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
+- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
+
+```tsx
+// string[] - duplicates everything
+usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
+
+// object[] - duplicates array structure only
+users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
+```
+
+**Operations breaking deduplication (create new references):**
+
+- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
+- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
+
+**More examples:**
+
+```tsx
+// ❌ Bad
+ u.active)} />
+
+
+// ✅ Good
+
+
+// Do filtering/destructuring in client
+```
+
+**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
diff --git a/.agents/skills/vercel-react-best-practices/rules/server-hoist-static-io.md b/.agents/skills/vercel-react-best-practices/rules/server-hoist-static-io.md
new file mode 100644
index 00000000..5b642b69
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/server-hoist-static-io.md
@@ -0,0 +1,142 @@
+---
+title: Hoist Static I/O to Module Level
+impact: HIGH
+impactDescription: avoids repeated file/network I/O per request
+tags: server, io, performance, next.js, route-handlers, og-image
+---
+
+## Hoist Static I/O to Module Level
+
+**Impact: HIGH (avoids repeated file/network I/O per request)**
+
+When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation.
+
+**Incorrect: reads font file on every request**
+
+```typescript
+// app/api/og/route.tsx
+import { ImageResponse } from 'next/og'
+
+export async function GET(request: Request) {
+ // Runs on EVERY request - expensive!
+ const fontData = await fetch(
+ new URL('./fonts/Inter.ttf', import.meta.url)
+ ).then(res => res.arrayBuffer())
+
+ const logoData = await fetch(
+ new URL('./images/logo.png', import.meta.url)
+ ).then(res => res.arrayBuffer())
+
+ return new ImageResponse(
+
+
+ Hello World
+
,
+ { fonts: [{ name: 'Inter', data: fontData }] }
+ )
+}
+```
+
+**Correct: loads once at module initialization**
+
+```typescript
+// app/api/og/route.tsx
+import { ImageResponse } from 'next/og'
+
+// Module-level: runs ONCE when module is first imported
+const fontData = fetch(
+ new URL('./fonts/Inter.ttf', import.meta.url)
+).then(res => res.arrayBuffer())
+
+const logoData = fetch(
+ new URL('./images/logo.png', import.meta.url)
+).then(res => res.arrayBuffer())
+
+export async function GET(request: Request) {
+ // Await the already-started promises
+ const [font, logo] = await Promise.all([fontData, logoData])
+
+ return new ImageResponse(
+
+
+ Hello World
+
,
+ { fonts: [{ name: 'Inter', data: font }] }
+ )
+}
+```
+
+**Alternative: synchronous file reads with Node.js fs**
+
+```typescript
+// app/api/og/route.tsx
+import { ImageResponse } from 'next/og'
+import { readFileSync } from 'fs'
+import { join } from 'path'
+
+// Synchronous read at module level - blocks only during module init
+const fontData = readFileSync(
+ join(process.cwd(), 'public/fonts/Inter.ttf')
+)
+
+const logoData = readFileSync(
+ join(process.cwd(), 'public/images/logo.png')
+)
+
+export async function GET(request: Request) {
+ return new ImageResponse(
+
+
+ Hello World
+
,
+ { fonts: [{ name: 'Inter', data: fontData }] }
+ )
+}
+```
+
+**General Node.js example: loading config or templates**
+
+```typescript
+// Incorrect: reads config on every call
+export async function processRequest(data: Data) {
+ const config = JSON.parse(
+ await fs.readFile('./config.json', 'utf-8')
+ )
+ const template = await fs.readFile('./template.html', 'utf-8')
+
+ return render(template, data, config)
+}
+
+// Correct: loads once at module level
+const configPromise = fs.readFile('./config.json', 'utf-8')
+ .then(JSON.parse)
+const templatePromise = fs.readFile('./template.html', 'utf-8')
+
+export async function processRequest(data: Data) {
+ const [config, template] = await Promise.all([
+ configPromise,
+ templatePromise
+ ])
+
+ return render(template, data, config)
+}
+```
+
+**When to use this pattern:**
+
+- Loading fonts for OG image generation
+- Loading static logos, icons, or watermarks
+- Reading configuration files that don't change at runtime
+- Loading email templates or other static templates
+- Any static asset that's the same across all requests
+
+**When NOT to use this pattern:**
+
+- Assets that vary per request or user
+- Files that may change during runtime (use caching with TTL instead)
+- Large files that would consume too much memory if kept loaded
+- Sensitive data that shouldn't persist in memory
+
+**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** Module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties.
+
+**In traditional serverless:** Each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled.
diff --git a/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md b/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md
new file mode 100644
index 00000000..1affc835
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md
@@ -0,0 +1,83 @@
+---
+title: Parallel Data Fetching with Component Composition
+impact: CRITICAL
+impactDescription: eliminates server-side waterfalls
+tags: server, rsc, parallel-fetching, composition
+---
+
+## Parallel Data Fetching with Component Composition
+
+React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
+
+**Incorrect (Sidebar waits for Page's fetch to complete):**
+
+```tsx
+export default async function Page() {
+ const header = await fetchHeader()
+ return (
+
+ )
+}
+
+export default function Page() {
+ return (
+
+
+
+ )
+}
+```
diff --git a/.agents/skills/vercel-react-best-practices/rules/server-serialization.md b/.agents/skills/vercel-react-best-practices/rules/server-serialization.md
new file mode 100644
index 00000000..39c5c416
--- /dev/null
+++ b/.agents/skills/vercel-react-best-practices/rules/server-serialization.md
@@ -0,0 +1,38 @@
+---
+title: Minimize Serialization at RSC Boundaries
+impact: HIGH
+impactDescription: reduces data transfer size
+tags: server, rsc, serialization, props
+---
+
+## Minimize Serialization at RSC Boundaries
+
+The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.
+
+**Incorrect (serializes all 50 fields):**
+
+```tsx
+async function Page() {
+ const user = await fetchUser() // 50 fields
+ return
+}
+
+'use client'
+function Profile({ user }: { user: User }) {
+ return
{user.name}
// uses 1 field
+}
+```
+
+**Correct (serializes only 1 field):**
+
+```tsx
+async function Page() {
+ const user = await fetchUser()
+ return
+}
+
+'use client'
+function Profile({ name }: { name: string }) {
+ return
{name}
+}
+```
diff --git a/.agents/skills/web-design-guidelines/SKILL.md b/.agents/skills/web-design-guidelines/SKILL.md
new file mode 100644
index 00000000..ceae92ab
--- /dev/null
+++ b/.agents/skills/web-design-guidelines/SKILL.md
@@ -0,0 +1,39 @@
+---
+name: web-design-guidelines
+description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
+metadata:
+ author: vercel
+ version: "1.0.0"
+ argument-hint:
+---
+
+# Web Interface Guidelines
+
+Review files for compliance with Web Interface Guidelines.
+
+## How It Works
+
+1. Fetch the latest guidelines from the source URL below
+2. Read the specified files (or prompt user for files/pattern)
+3. Check against all rules in the fetched guidelines
+4. Output findings in the terse `file:line` format
+
+## Guidelines Source
+
+Fetch fresh guidelines before each review:
+
+```
+https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
+```
+
+Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
+
+## Usage
+
+When a user provides a file or pattern argument:
+1. Fetch guidelines from the source URL above
+2. Read the specified files
+3. Apply all rules from the fetched guidelines
+4. Output findings using the format specified in the guidelines
+
+If no files specified, ask the user which files to review.
diff --git a/.claude/rules/i18n.md b/.claude/rules/i18n.md
new file mode 100644
index 00000000..aa2bbe6f
--- /dev/null
+++ b/.claude/rules/i18n.md
@@ -0,0 +1,17 @@
+---
+paths: "packages/extension/public/_locales/*/messages.json"
+---
+
+# アプリケーションの国際化(i18n)ルール
+
+- UXライティングの観点から、ユーザーに分かりやすく、かつ、シンプルな表現を心がけること
+- 翻訳の際には、文化的な違いを考慮し、適切な表現を選ぶこと
+- 指示された文章が分かりづらい場合は適切な表現を提案すること
+- 使用しなくなったエントリーは削除すること
+- `packages/extension/public/_locales/en/messages.json` のみ、翻訳の助けとなる説明を必要に応じてdescriptionキーへ設定する。以下の目的で使用する。
+ - 用語の統一
+ - 文章の意図や背景を伝える
+ - コードやUIとの対応関係を明確にする
+- 言語ファイルの修正完了後、以下のテストにより検証を行うこと
+ - テストファイル: `packages/extension/src/test/locales.test.ts`
+ - テスト実行コマンド: `yarn workspace @selection-command/extension test:run src/test/locales.test.ts`
diff --git a/.claude/settings.json b/.claude/settings.json
index db07a616..3501db27 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,4 +1,5 @@
{
+ "plansDirectory": "./docs",
"hooks": {
"PostToolUse": [
{
@@ -12,4 +13,4 @@
}
]
}
-}
\ No newline at end of file
+}
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index fbe5e4c3..1befd793 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -2,41 +2,56 @@
"permissions": {
"allow": [
"Bash(cat:*)",
+ "Bash(curl:*)",
"Bash(do echo \"=== $lang ===\")",
+ "Bash(do echo:*)",
"Bash(do)",
"Bash(done)",
"Bash(echo:*)",
"Bash(find:*)",
"Bash(for lang in de en es fr hi id it ja ko ms pt_BR pt_PT ru zh_CN)",
- "Bash(for lang in de en es fr hi id it ko ms pt_BR pt_PT ru zh_CN)",
+ "Bash(for lang:*)",
"Bash(gh api:*)",
"Bash(gh pr:*)",
+ "Bash(git stash:*)",
"Bash(grep:*)",
+ "Bash(head:*)",
"Bash(jq:*)",
"Bash(ls:*)",
"Bash(mkdir:*)",
"Bash(mv:*)",
"Bash(node:*)",
- "Bash(pkill:*)",
+ "Bash(npx tsc:*)",
+ "Bash(npx tsx:*)",
"Bash(rg:*)",
"Bash(rm:*)",
"Bash(sed:*)",
"Bash(tsc:*)",
+ "Bash(while read:*)",
"Bash(yarn add:*)",
"Bash(yarn build)",
+ "Bash(yarn build:*)",
+ "Bash(yarn check-ids)",
"Bash(yarn eslint:*)",
"Bash(yarn lint)",
"Bash(yarn lint:*)",
+ "Bash(yarn list:*)",
"Bash(yarn test:*)",
+ "Bash(yarn test:run:*)",
"Bash(yarn tsc)",
"Bash(yarn tsc:*)",
+ "Bash(yarn check-ids)",
"Bash(yarn workspace @selection-command/extension:*)",
"Bash(yarn workspace @selection-command/hub:*)",
+ "Bash(yarn workspace:*)",
+ "WebFetch(domain:api.github.com)",
"WebFetch(domain:developer.chrome.com)",
"WebFetch(domain:docs.dndkit.com)",
"WebFetch(domain:github.com)",
+ "WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:react-hook-form.com)",
"WebFetch(domain:stackoverflow.com)",
+ "WebFetch(domain:ujiro99.github.io)",
"WebFetch(domain:www.npmjs.com)",
"WebFetch(domain:www.radix-ui.com)",
"WebSearch",
@@ -45,8 +60,10 @@
"mcp__serena__find_referencing_symbols",
"mcp__serena__find_symbol",
"mcp__serena__get_symbols_overview",
+ "mcp__serena__initial_instructions",
"mcp__serena__insert_after_symbol",
"mcp__serena__list_dir",
+ "mcp__serena__list_memories",
"mcp__serena__onboarding",
"mcp__serena__read_memory",
"mcp__serena__replace_symbol_body",
@@ -54,9 +71,7 @@
"mcp__serena__think_about_collected_information",
"mcp__serena__think_about_task_adherence",
"mcp__serena__think_about_whether_you_are_done",
- "mcp__serena__write_memory",
- "Bash(yarn build:*)",
- "mcp__serena__initial_instructions"
+ "mcp__serena__write_memory"
],
"deny": []
}
diff --git a/.claude/skills/frontend-design b/.claude/skills/frontend-design
new file mode 120000
index 00000000..712f694a
--- /dev/null
+++ b/.claude/skills/frontend-design
@@ -0,0 +1 @@
+../../.agents/skills/frontend-design
\ No newline at end of file
diff --git a/.claude/skills/vercel-composition-patterns b/.claude/skills/vercel-composition-patterns
new file mode 120000
index 00000000..55a19e8f
--- /dev/null
+++ b/.claude/skills/vercel-composition-patterns
@@ -0,0 +1 @@
+../../.agents/skills/vercel-composition-patterns
\ No newline at end of file
diff --git a/.claude/skills/vercel-react-best-practices b/.claude/skills/vercel-react-best-practices
new file mode 120000
index 00000000..e567923b
--- /dev/null
+++ b/.claude/skills/vercel-react-best-practices
@@ -0,0 +1 @@
+../../.agents/skills/vercel-react-best-practices
\ No newline at end of file
diff --git a/.claude/skills/web-design-guidelines b/.claude/skills/web-design-guidelines
new file mode 120000
index 00000000..886b26de
--- /dev/null
+++ b/.claude/skills/web-design-guidelines
@@ -0,0 +1 @@
+../../.agents/skills/web-design-guidelines
\ No newline at end of file
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..c4c65f86
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,36 @@
+# Copilot Instructions
+
+このリポジトリでのレビューでは、次のルールを守ってください。
+
+## 言語とトーン
+
+- コメント・概要・提案・Pull Requestはすべて日本語で書いてください。
+- コミットメッセージは英語で書いてください。
+- 指摘は丁寧かつ簡潔に、箇条書きで書いてください。
+
+## レビューの優先順位
+
+1. バグや仕様の誤り
+2. セキュリティ・パフォーマンス上の問題
+3. 設計やインタフェースの一貫性
+4. 型安全性やテストの不足
+5. 可読性やリファクタリング提案
+6. 重複やデッドコード
+
+## プロジェクトについて
+
+以下を参照してください。
+
+@AGENTS.md
+
+## レビュー粒度
+
+- 変更が大きい場合は、全行ではなく「重大な問題のある箇所」と「代表例」を中心にコメントしてください。
+- リネームやコメントのみの変更では、問題がなければ「特に問題ありません」とだけ返してください。
+
+## 出力フォーマット
+
+- 次の見出しでレビュー結果を出力してください:
+ - 概要
+ - 懸念点
+ - 改善提案
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
index 205b0fe2..bb941198 100644
--- a/.github/workflows/claude-code-review.yml
+++ b/.github/workflows/claude-code-review.yml
@@ -1,4 +1,4 @@
-name: Claude Code Review
+name: Claude Code Review in Japanese
on:
pull_request:
@@ -18,6 +18,11 @@ jobs:
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
+ if: |
+ !contains(github.event.pull_request.title, '[skip-review]') &&
+ !contains(github.event.pull_request.title, '[WIP]') &&
+ github.event.pull_request.draft == false
+
runs-on: ubuntu-latest
permissions:
contents: read
@@ -51,7 +56,9 @@ jobs:
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
+ 結果は日本語で記載してください。
+ 結果には具体的なコードのファイルパスと行番号を含めてください。
+
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
-
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0c628c32..6728359b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -29,9 +29,6 @@ jobs:
- name: Run lint
run: yarn lint
- - name: Run tests
- run: yarn test --run
-
- name: Run tests with coverage
run: yarn test:coverage --run
@@ -40,3 +37,50 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
+
+ e2e:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "lts/*"
+ cache: "yarn"
+
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: Build extension
+ run: yarn build:e2e
+
+ - name: Cache Playwright browsers
+ id: playwright-cache
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-chromium-${{ hashFiles('**/package.json', 'yarn.lock') }}-v1
+ restore-keys: |
+ playwright-chromium-
+
+ - name: Install Playwright browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ run: npx playwright install chromium --with-deps
+
+ - name: Install Playwright system dependencies
+ if: steps.playwright-cache.outputs.cache-hit == 'true'
+ run: npx playwright install-deps chromium
+
+ - name: Run E2E tests
+ run: yarn test:e2e
+
+ - name: Upload E2E test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-test-results
+ path: packages/extension/test-results/
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 5b9e41d3..dd31d090 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,8 @@
.env.development.local
.env.test.local
.env.production.local
+.git
+tsconfig.tsbuildinfo
npm-debug.log*
yarn-debug.log*
@@ -36,6 +38,11 @@ yarn-error.log*
# Sentry Config File
.env.sentry-build-plugin
+# Playwright
+**/playwright-report
+**/test-results
+**/playwright/.cache
+
# Serena
.serena/cache
diff --git a/.serena/project.yml b/.serena/project.yml
index 53d89295..b40c0729 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -1,10 +1,3 @@
-# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
-# * For C, use cpp
-# * For JavaScript, use typescript
-# Special requirements:
-# * csharp: Requires the presence of a .sln file in the project folder.
-language: typescript
-
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
@@ -63,5 +56,85 @@ excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
-
+# the name by which the project can be referenced within Serena
project_name: "selection-command"
+
+# list of mode names to that are always to be included in the set of active modes
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this setting overrides the global configuration.
+# Set this to [] to disable base modes for this project.
+# Set this to a list of mode names to always include the respective modes for this project.
+base_modes:
+
+# list of mode names that are to be activated by default.
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+default_modes:
+
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+included_optional_tools: []
+
+# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
+# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+fixed_tools: []
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: utf-8
+
+# list of languages for which language servers are started; choose from:
+# al bash clojure cpp csharp
+# csharp_omnisharp dart elixir elm erlang
+# fortran fsharp go groovy haskell
+# java julia kotlin lua markdown
+# matlab nix pascal perl php
+# php_phpactor powershell python python_jedi r
+# rego ruby ruby_solargraph rust scala
+# swift terraform toml typescript typescript_vts
+# vue yaml zig
+# powershell python python_jedi r rego
+# ruby ruby_solargraph rust scala swift
+# terraform toml typescript typescript_vts vue
+# yaml zig
+#
+languages:
+- typescript
+
+# time budget (seconds) per tool call for the retrieval of additional symbol information
+# such as docstrings or parameter information.
+# This overrides the corresponding setting in the global configuration; see the documentation there.
+# If null or missing, use the setting from the global configuration.
+symbol_info_budget:
+
+# The language backend to use for this project.
+# If not set, the global setting from serena_config.yml is used.
+# Valid values: LSP, JetBrains
+# Note: the backend is fixed at startup. If a project with a different backend
+# is activated post-init, an error will be returned.
+language_backend:
+
+# line ending convention to use when writing source files.
+# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
+# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
+line_ending:
+
+# list of regex patterns which, when matched, mark a memory entry as read‑only.
+# Extends the list from the global configuration, merging the two lists.
+read_only_memory_patterns: []
+
+# list of regex patterns for memories to completely ignore.
+# Matching memories will not appear in list_memories or activate_project output
+# and cannot be accessed via read_memory or write_memory.
+# To access ignored memory files, use the read_file tool on the raw file path.
+# Extends the list from the global configuration, merging the two lists.
+# Example: ["_archive/.*", "_episodes/.*"]
+ignored_memory_patterns: []
+
+# advanced configuration option allowing to configure language server-specific options.
+# Maps the language key to the options.
+# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
+# No documentation on options means no options are available.
+ls_specific_settings: {}
diff --git a/CLAUDE.md b/AGENTS.md
similarity index 96%
rename from CLAUDE.md
rename to AGENTS.md
index 9581c6d0..99b6bf21 100644
--- a/CLAUDE.md
+++ b/AGENTS.md
@@ -1,8 +1,6 @@
-# CLAUDE.md
+# AGENTS.md
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-このファイルは、このリポジトリのコードを扱う際にClaude Code (claude.ai/code)に対するガイダンスを提供します。
+このファイルは、このリポジトリのコードを扱う際にAI Agentに対するガイダンスを提供します。
## 基本ルール
@@ -61,9 +59,11 @@ yarn clean # 全パッケージのクリーンアップ
```bash
yarn dev # 開発モード
yarn build # ビルド
+yarn build:e2e # e2e用にビルド
yarn test # テスト実行
yarn test:ui # テストUIモード
yarn test:coverage # カバレッジ測定
+yarn test:e2e # playwright test
yarn lint # ESLint実行
yarn zip # 配布用zip作成
```
@@ -199,9 +199,7 @@ interface PageActionOption {
**Hub開発:**
-- commands.jsonの手動更新後は`yarn tags`でタグ統計更新
- 多言語対応時は各言語ファイルの更新が必要
-- 分析データは`yarn analytics`で手動更新
**テスト:**
diff --git a/docs/design/side-pane-link.md b/docs/design/side-pane-link.md
new file mode 100644
index 00000000..54f1f242
--- /dev/null
+++ b/docs/design/side-pane-link.md
@@ -0,0 +1,52 @@
+## 1. 対象範囲
+
+- selection command から開かれた **side panel 内の画面遷移** を、実際のブラウザナビゲーションではなく「side panel の URL 差し替え」として扱う。
+- 実装対象:
+ - side panel 内で動作する content script
+ - service worker(background)
+ - side panel 表示状態を持つ BgData(状態管理層)
+
+---
+
+## 2. side panel 上かどうかの判定要件
+
+- 「この content script が side panel 上で動いているか」を判定するための条件:
+ - BgData が保持する「side panel を表示中の tabId リスト」に、現在のタブ ID が含まれていること。
+ - かつ、以下のいずれかを満たすこと:
+ - 現在の URL が「side panel 用 URL」であると判定できる。
+ - side panel とメインコンテンツが同じ URL を表示している場合は、ウィンドウ位置やレイアウト情報から「side panel 側」であることを判定すること。
+
+---
+
+## 3. side panel 内でのページ遷移フック要件(content script)
+
+- side panel 上で動作していると判定できた場合のみ、遷移フックを有効化する。
+- フック対象:
+ - ユーザーのリンククリックなど、ページ遷移を引き起こす操作。
+ - ただし、`target="_blank"` など、意図的に別タブ・ウィンドウで開くことを指定されている場合はフックを無効化する
+- フック時の挙動:
+ - ブラウザ標準のページ遷移は発生させない(キャンセルする)。
+ - 遷移先 URL を取得し、service worker に「side panel の URL 更新要求」として通知する。
+
+---
+
+## 4. service worker による side panel URL 更新要件
+
+- content script からの「side panel の URL 更新要求」を受け取ったら、以下を行う:
+ - 対象 tabId が「side panel 表示中タブリスト」に含まれていることを確認する。
+ - 対象の side panel の URL を更新するための API を呼び出し、side panel の表示内容を遷移先 URL に切り替える。
+- URL 更新後の状態管理:
+ - BgData 上で、その tabId の side panel に紐づく「現在 URL」を更新する。
+ - 必要に応じて、履歴やその他の管理情報も更新できるようにしておく。
+
+---
+
+## 5. 状態管理要件(BgData)
+
+- 少なくとも以下の情報を持つ:
+ - side panel を表示中の tabId の集合(リストまたはセット)。
+ - 各 tabId ごとの side panel の現在 URL(+必要に応じて追加情報)。
+- 更新タイミング(例):
+ - side panel オープン/クローズ時。
+ - service worker が URL 更新 API を呼び出したタイミング。
+ - タブクローズ時など、不要になった状態のクリア。
diff --git a/docs/misty-hugging-llama.md b/docs/misty-hugging-llama.md
new file mode 100644
index 00000000..5df98f97
--- /dev/null
+++ b/docs/misty-hugging-llama.md
@@ -0,0 +1,136 @@
+# Plan: `generateUUIDFromObject` と `cmd2uuid` を shared パッケージへ移植
+
+## Context
+
+`generateUUIDFromObject` と `cmd2uuid` が3箇所で重複実装されている:
+
+- `packages/hub/src/lib/utils.ts` — sync版 (Node.js `crypto`)
+- `packages/extension/src/services/uuid.ts` — async版 (Web Crypto API)
+- `packages/extension/scripts/check-command-ids.mjs` — sync版の手動複製
+
+共通ロジックを `packages/shared/src/utils/uuid.ts` に集約し、hub とスクリプトから共有版を利用する。
+
+## 設計方針
+
+### 環境制約
+
+- **Hub (Next.js)**: Node.js 環境 → sync OK
+- **check-command-ids.mjs**: Node.js スクリプト → sync OK、`.ts` ファイルを直接 import 済み
+- **Extension**: ブラウザ環境 → Node.js `crypto` は使用不可、Web Crypto API (async) が必要
+
+### 方針
+
+- shared に **sync 版** (`crypto.createHash`) を実装する
+- `utils/index.ts` のバレルエクスポートには **追加しない** (extension が `@shared` 経由で自動的にバンドルしないように)
+- Hub とスクリプトは `@shared/utils/uuid` から直接 import する
+- Extension は既存の async 版を維持する(ブラウザ環境で Node.js crypto は使えないため)
+
+## 変更内容
+
+### 1. shared パッケージに `uuid` 依存を追加
+
+**File: `packages/shared/package.json`**
+
+- `dependencies` に `"uuid": "^11.0.3"` を追加
+
+### 2. `packages/shared/src/utils/uuid.ts` を実装
+
+```typescript
+import { normalizeObject } from "./common";
+import { isSearchCommand, isPageActionCommand } from "./type-guards";
+import { v5 as uuidv5 } from "uuid";
+import { createHash } from "crypto";
+import type { SelectionCommand } from "../types";
+
+// UUID namespace from https://ujiro99.github.io/selection-command/
+const UUID_NAMESPACE = "fe352db3-6a8e-5d07-9aaf-c45a2e9d9f5c";
+
+/**
+ * Generate UUID from object, using UUIDv5.
+ * Property order independent - same content produces same UUID regardless of key order.
+ * NOTE: Uses Node.js crypto - not available in browser. For browser, use async version.
+ */
+export function generateUUIDFromObject(obj: object): string {
+ const normalizedObj = normalizeObject(obj);
+ const objString = JSON.stringify(normalizedObj);
+ const hash = createHash("sha1").update(objString).digest("hex");
+ return uuidv5(hash, UUID_NAMESPACE);
+}
+
+type CommandContent = Omit<
+ SelectionCommand,
+ "id" | "tags" | "addedAt" | "description"
+>;
+
+/**
+ * Generate UUID from command content.
+ * Extracts relevant fields based on command type before generating UUID.
+ */
+export function cmd2uuid(cmd: CommandContent): string {
+ if (isSearchCommand(cmd)) {
+ return generateUUIDFromObject({
+ title: cmd.title,
+ searchUrl: cmd.searchUrl,
+ iconUrl: cmd.iconUrl,
+ openMode: cmd.openMode,
+ openModeSecondary: cmd.openModeSecondary,
+ spaceEncoding: cmd.spaceEncoding,
+ });
+ } else if (isPageActionCommand(cmd)) {
+ return generateUUIDFromObject({
+ title: cmd.title,
+ iconUrl: cmd.iconUrl,
+ openMode: cmd.openMode,
+ pageActionOption: cmd.pageActionOption,
+ });
+ } else {
+ throw new Error("Invalid command");
+ }
+}
+```
+
+**注意**: `utils/index.ts` には `export * from "./uuid"` を **追加しない**。
+
+### 3. Hub パッケージの更新
+
+**File: `packages/hub/src/lib/utils.ts`**
+
+- `generateUUIDFromObject` の実装を削除
+- `import { generateUUIDFromObject } from "@shared/utils/uuid"` として re-export
+
+**File: `packages/hub/src/features/command.ts`**
+
+- `cmd2uuid` のローカル実装を削除
+- `import { cmd2uuid } from "@shared/utils/uuid"` に変更
+- `generateUUIDFromObject` の import 元を `@shared/utils/uuid` に変更(tag ID 生成で使用)
+
+### 4. check-command-ids.mjs の更新
+
+**File: `packages/extension/scripts/check-command-ids.mjs`**
+
+- ローカルの `generateUUIDFromObject` 関数を削除
+- `import { generateUUIDFromObject } from "../../shared/src/utils/uuid.ts"` に変更
+ (スクリプトは既に `../../shared/src/utils/common.ts` を同様に import 済み)
+
+### 5. Extension は変更なし
+
+- `packages/extension/src/services/uuid.ts` はそのまま維持
+- ブラウザ環境では Web Crypto API (async) が必要なため、shared の sync 版は使えない
+
+## 既存の再利用可能リソース
+
+| リソース | パス | 状態 |
+| ---------------------- | ------------------------------------------ | -------------- |
+| `normalizeObject` | `packages/shared/src/utils/common.ts` | ✅ 既に shared |
+| `isSearchCommand` | `packages/shared/src/utils/type-guards.ts` | ✅ 既に shared |
+| `isPageActionCommand` | `packages/shared/src/utils/type-guards.ts` | ✅ 既に shared |
+| `SelectionCommand` 型 | `packages/shared/src/types/command.ts` | ✅ 既に shared |
+| `SearchCommand` 型 | `packages/shared/src/types/command.ts` | ✅ 既に shared |
+| `PageActionCommand` 型 | `packages/shared/src/types/command.ts` | ✅ 既に shared |
+
+## 検証方法
+
+1. **ID 整合性テスト**: `node packages/extension/scripts/check-command-ids.mjs` を実行し、既存の全コマンド ID が MISMATCH にならないことを確認
+2. **Hub ビルド**: `cd packages/hub && yarn build` が成功することを確認
+3. **Hub の既存テスト**: uuid 関連のテストがあれば実行
+4. **Extension ビルド**: `cd packages/extension && yarn build` が成功することを確認(shared の変更が extension に影響しないこと)
diff --git a/docs/test/e2e-test-spec.md b/docs/test/e2e-test-spec.md
new file mode 100644
index 00000000..2481c374
--- /dev/null
+++ b/docs/test/e2e-test-spec.md
@@ -0,0 +1,990 @@
+# E2Eテスト仕様書
+
+## 概要
+
+本仕様書は Selection Command 拡張機能の E2E テスト対象項目を定義したものです。
+自動テスト対象として選定された項目(◯)をカバーします。
+
+テストフレームワーク: Playwright
+テストページ: `https://ujiro99.github.io/selection-command/en/test`
+設定ページ: `chrome-extension://{extensionId}/src/options_page.html`
+
+---
+
+## テスト項目一覧
+
+| テストID | 分類 | 機能1 | 機能2 | 実装状況 |
+| -------- | ------------------ | ---------------------------- | ----------------------------- | -------- |
+| E2E-10 | 起動方法 | テキスト選択 | - | 実装済 |
+| E2E-11 | 起動方法 | キー入力 | Shift | 実装済 |
+| E2E-12 | 起動方法 | 左クリック長押し | 150ms | 実装済 |
+| E2E-15 | メニュースタイル | 横並び | - | 実装済 |
+| E2E-16 | メニュースタイル | 縦並び | - | 実装済 |
+| E2E-20 | 検索コマンド | OpenMode | Popup | 実装済 |
+| E2E-21 | 検索コマンド | OpenMode | Tab | 実装済 |
+| E2E-22 | 検索コマンド | OpenMode | Window | 実装済 |
+| E2E-23 | 検索コマンド | OpenMode | SidePanel | スキップ |
+| E2E-24 | 検索コマンド | フォルダ格納 | none | 実装済 |
+| E2E-25 | 検索コマンド | フォルダ格納 | Shop | 実装済 |
+| E2E-26 | 検索コマンド | フォルダ格納 | AI → Lang | 実装済 |
+| E2E-30 | 単機能コマンド | Copy Text | - | 実装済 |
+| E2E-31 | 単機能コマンド | Link Popup | 複数リンクを開く | 実装済 |
+| E2E-40 | PageActionコマンド | 記録 | クリック | 実装済 |
+| E2E-41 | PageActionコマンド | 記録 | Enter | 実装済 |
+| E2E-42 | PageActionコマンド | 記録 | テキスト入力(input) | 実装済 |
+| E2E-43 | PageActionコマンド | 記録 | テキスト入力(contentEditable) | 実装済 |
+| E2E-44 | PageActionコマンド | 記録 | スクロール | 実装済 |
+| E2E-45 | PageActionコマンド | 再生 | Character Counter | 実装済 |
+| E2E-46 | PageActionコマンド | 再生(バックグラウンドタブ) | Sakuraチェッカー | 実装済 |
+| E2E-50 | AiPromptコマンド | OpenMode | Popup | 実装済 |
+| E2E-51 | AiPromptコマンド | OpenMode | Tab | 実装済 |
+| E2E-52 | AiPromptコマンド | クリップボード | Popup | 実装済 |
+| E2E-60 | リンクプレビュー | 起動方法 | Shiftキー + クリック | 実装済 |
+| E2E-61 | リンクプレビュー | 起動方法 | ドラッグ | 実装済 |
+| E2E-62 | リンクプレビュー | 起動方法 | 左クリック長押し | 実装済 |
+| E2E-63 | リンクプレビュー | 画像DLリンクのプレビュー | - | 実装済 |
+| E2E-64 | リンクプレビュー | Open Mode | Window | 実装済 |
+| E2E-65 | リンクプレビュー | Open Mode | SidePanel | スキップ |
+| E2E-66 | リンクプレビュー | ドラッグ距離 | 50px | 実装済 |
+| E2E-70 | ユーザースタイル | 余白サイズ | 1 | 実装済 |
+| E2E-71 | ユーザースタイル | 余白サイズ | 1.5 | 実装済 |
+| E2E-72 | ユーザースタイル | 背景色 | 設定 | 実装済 |
+| E2E-73 | ユーザースタイル | 背景色 | 削除 | 実装済 |
+| E2E-74 | ユーザースタイル | 文字色 | 設定 | 実装済 |
+| E2E-75 | ユーザースタイル | 文字色 | 削除 | 実装済 |
+| E2E-80 | 設定画面 | インポート | ポップアップ | 実装済 |
+| E2E-81 | 設定画面 | インポート | リンクプレビュー | 実装済 |
+| E2E-82 | 設定画面 | インポート | コマンド100件以上 | 実装済 |
+| E2E-83 | 設定画面 | エクスポート | - | 実装済 |
+| E2E-84 | 設定画面 | リセット | - | 実装済 |
+| E2E-90 | ハブ | PageActionコマンド | インストール | 実装済 |
+| E2E-91 | ハブ | 追加 | - | 実装済 |
+| E2E-92 | ハブ | 削除 | - | 実装済 |
+
+---
+
+## 詳細仕様
+
+---
+
+### 起動方法
+
+---
+
+#### E2E-10: テキスト選択によるポップアップ表示
+
+**事前条件**
+
+- 拡張機能がインストールされている
+- 起動方法がデフォルト設定(テキスト選択)
+
+**手順**
+
+1. テストページを開く
+2. ページ内のテキストをダブルクリックして選択する
+
+**期待動作**
+
+- ポップアップメニューが表示される(`data-state="open"` が DOM に存在する)
+
+---
+
+#### E2E-11: Shiftキー押下によるポップアップ表示
+
+**事前条件**
+
+- 拡張機能がインストールされている
+- 起動方法を `KEYBOARD / SHIFT` に設定済み
+
+**手順**
+
+1. テストページを開く
+2. `startupMethod` を `{ method: STARTUP_METHOD.KEYBOARD, keyboardParam: KEYBOARD.SHIFT }` に設定する
+3. テキストを選択する
+4. `Shift` キーを押下する
+
+**期待動作**
+
+- ポップアップメニューが表示される
+- ポップアップ表示遅延が `0` になる
+
+---
+
+#### E2E-12: 左クリック長押し(150ms)によるポップアップ表示
+
+**事前条件**
+
+- 拡張機能がインストールされている
+- 起動方法を `左クリック長押し / 150ms` に設定済み
+
+**手順**
+
+1. テストページを開く
+2. `startupMethod` を `{ method: STARTUP_METHOD.LONG_PRESS, longPressParam: 150 }` に設定する
+3. テキスト要素を 150ms 以上左クリック長押しする
+
+**期待動作**
+
+- ポップアップメニューが表示される
+
+---
+
+### メニュースタイル
+
+---
+
+#### E2E-15: メニュー横並び表示
+
+**事前条件**
+
+- 拡張機能がインストールされている
+- `popupPlacement` を横並び(`horizontal`)に設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+
+**期待動作**
+
+- ポップアップメニューが横並びで表示される(メニューバーの flex 方向が `row`、またはボタンが横一列に配置される)
+
+---
+
+#### E2E-16: メニュー縦並び表示
+
+**事前条件**
+
+- 拡張機能がインストールされている
+- `popupPlacement` を縦並び(`vertical`)に設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+
+**期待動作**
+
+- ポップアップメニューが縦並びで表示される(ボタンが縦一列に配置される)
+
+---
+
+### 検索コマンド
+
+---
+
+#### E2E-20: 検索コマンド / OpenMode: Popup
+
+**事前条件**
+
+- テスト設定(`test-settings.json`)がインポートされている
+- 1番目のコマンドが OpenMode: `Popup` のテスト用検索コマンドである
+
+**手順**
+
+1. 設定ページでテスト設定をインポートする
+2. テストページを開く
+3. テキストを選択してポップアップメニューを表示する
+4. ポップアップメニューの最初のボタンをクリックする
+
+**期待動作**
+
+- `WindowType.popup` の新しいウィンドウが開く
+- 開いたウィンドウの URL に選択テキストが含まれる(`?k=` パラメータなど)
+
+---
+
+#### E2E-21: 検索コマンド / OpenMode: Tab
+
+**事前条件**
+
+- OpenMode が `Tab` のコマンドが設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+3. OpenMode: `Tab` のコマンドをクリックする
+
+**期待動作**
+
+- 新しいタブが開く(ポップアップウィンドウではなく通常タブ)
+- 開いたタブの URL に選択テキストが含まれる
+
+---
+
+#### E2E-22: 検索コマンド / OpenMode: Window
+
+**事前条件**
+
+- OpenMode が `Window` のコマンドが設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+3. OpenMode: `Window` のコマンドをクリックする
+
+**期待動作**
+
+- 通常のブラウザウィンドウ(`WindowType.window`)が新たに開く
+- 開いたウィンドウの URL に選択テキストが含まれる
+
+---
+
+#### E2E-23: 検索コマンド / OpenMode: SidePanel
+
+**事前条件**
+
+- OpenMode が `SidePanel` のコマンドが設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+3. OpenMode: `SidePanel` のコマンドをクリックする
+
+**期待動作**
+
+- 現在タブのサイドパネルが開く
+- サイドパネル内に選択テキストを使った検索結果が表示される
+
+---
+
+#### E2E-24: 検索コマンド / フォルダ格納: none(ルート直下)
+
+**事前条件**
+
+- フォルダに属さないコマンドが設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+
+**期待動作**
+
+- ポップアップメニューのルート直下にコマンドのボタンが表示される(フォルダアイコンを経由せず直接表示)
+
+---
+
+#### E2E-25: 検索コマンド / フォルダ格納: Shop
+
+**事前条件**
+
+- `Shop` フォルダに格納されたコマンドが設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+3. ポップアップメニュー内の `Shop` フォルダをクリックする
+
+**期待動作**
+
+- `Shop` フォルダが展開される
+- フォルダ内にコマンドが表示される
+
+---
+
+#### E2E-26: 検索コマンド / フォルダ格納: AI → Lang(ネストフォルダ)
+
+**事前条件**
+
+- `AI` フォルダの中の `Lang` フォルダに格納されたコマンドが設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+3. `AI` フォルダをクリックして展開する
+4. `Lang` サブフォルダをクリックして展開する
+
+**期待動作**
+
+- `Lang` フォルダ内にコマンドが表示される
+
+---
+
+### 単機能コマンド
+
+---
+
+#### E2E-30: Copy Text コマンド
+
+**事前条件**
+
+- `Copy Text` コマンドが設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+3. `Copy Text` コマンドをクリックする
+
+**期待動作**
+
+- 選択テキストがクリップボードにコピーされる
+- クリップボードの内容が選択テキストと一致する
+
+---
+
+#### E2E-31: Link Popup コマンド(複数リンクを開く)
+
+**事前条件**
+
+- `Link Popup` コマンドが設定済み
+- テストページに複数のリンクが含まれる
+
+**手順**
+
+1. テストページを開く
+2. 複数のリンクテキストを含む範囲を選択してポップアップメニューを表示する
+3. `Link Popup` コマンドをクリックする
+
+**期待動作**
+
+- 選択範囲内の各リンクがそれぞれ個別の `WindowType.popup` ウィンドウで開く
+
+---
+
+### PageActionコマンド
+
+---
+
+#### E2E-40: PageAction 記録 / クリック
+
+**事前条件**
+
+- PageAction コマンドが設定済み
+- テストページに PageAction で操作可能なクリック要素が存在する
+
+**手順**
+
+1. テストページを開く
+2. PageAction の記録を開始する
+3. ページ内の要素をクリックする
+4. 記録を終了する
+
+**期待動作**
+
+- クリック操作が PageAction のステップとして記録される(記録データに `click` アクションが含まれる)
+
+---
+
+#### E2E-41: PageAction 記録 / Enter キー
+
+**事前条件**
+
+- PageAction コマンドが設定済み
+- テストページにフォーカス可能な入力要素が存在する
+
+**手順**
+
+1. テストページを開く
+2. PageAction の記録を開始する
+3. 入力要素にフォーカスして Enter キーを押下する
+4. 記録を終了する
+
+**期待動作**
+
+- Enter キー押下が PageAction のステップとして記録される(記録データに `key: Enter` アクションが含まれる)
+
+---
+
+#### E2E-42: PageAction 記録 / テキスト入力(input)
+
+**事前条件**
+
+- PageAction コマンドが設定済み
+- テストページに `` 要素が存在する
+
+**手順**
+
+1. テストページを開く
+2. PageAction の記録を開始する
+3. `` 要素にテキストを入力する
+4. 記録を終了する
+
+**期待動作**
+
+- テキスト入力操作が PageAction のステップとして記録される(記録データに `input` タイプのアクションと入力テキストが含まれる)
+
+---
+
+#### E2E-43: PageAction 記録 / テキスト入力(contentEditable)
+
+**事前条件**
+
+- PageAction コマンドが設定済み
+- テストページに `contentEditable` 要素が存在する
+
+**手順**
+
+1. テストページを開く
+2. PageAction の記録を開始する
+3. `contentEditable` 要素にテキストを入力する
+4. 記録を終了する
+
+**期待動作**
+
+- テキスト入力操作が PageAction のステップとして記録される(記録データに `contentEditable` タイプのアクションと入力テキストが含まれる)
+
+---
+
+#### E2E-44: PageAction 記録 / スクロール
+
+**事前条件**
+
+- PageAction コマンドが設定済み
+- テストページがスクロール可能な長さを持つ
+
+**手順**
+
+1. テストページを開く
+2. PageAction の記録を開始する
+3. ページをスクロールする
+4. 記録を終了する
+
+**期待動作**
+
+- スクロール操作が PageAction のステップとして記録される(記録データに `scroll` アクションとスクロール量が含まれる)
+
+---
+
+#### E2E-45: PageAction 再生 / Character Counter
+
+**事前条件**
+
+- Character Counter用 PageAction コマンドが設定済み
+- テストページが開かれている
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+3. Character Counter PageAction コマンドをクリックする
+
+**期待動作**
+
+- Character Counterのページが開く(新しいタブ)
+- 開いたページの URL にテストページの URL が含まれる
+- Character Counterのページ内に選択テキストが入力される
+
+---
+
+#### E2E-46: PageAction 再生(バックグラウンドタブ)/ Sakuraチェッカー
+
+**事前条件**
+
+- バックグラウンドタブ実行設定の Sakuraチェッカー用 PageAction コマンドが設定済み
+
+**手順**
+
+1. テストページを開く
+2. テキストを選択してポップアップメニューを表示する
+3. バックグラウンドタブ設定の Sakuraチェッカー PageAction コマンドをクリックする
+
+**期待動作**
+
+- Sakuraチェッカーのページがバックグラウンドタブとして開く(現在タブはテストページのまま)
+- 開いたタブの URL にテストページの URL が含まれる
+
+---
+
+### AiPromptコマンド
+
+---
+
+#### E2E-50: AiPromptコマンド / OpenMode: Popup
+
+**事前条件**
+
+- テスト設定(`test-settings.json`)がインポートされている
+- OpenMode が `Popup` の AiPrompt コマンド「Gemini - 日本語」が `AI` フォルダ内に設定済み
+
+**手順**
+
+1. テストページを開く
+2. `h2` テキストを選択してポップアップメニューを表示する
+3. `AI` フォルダ(`title="AI"` のボタン)をクリックして展開する
+4. 「Gemini - 日本語」メニュー項目をクリックする
+
+**期待動作**
+
+- `WindowType.popup` の新しいウィンドウが開く
+- 開いたウィンドウの URL が `/gemini/` にマッチする
+- ウィンドウ内に AI プロンプトテキスト「以下について解説してください。」が表示される
+
+---
+
+#### E2E-51: AiPromptコマンド / OpenMode: Tab
+
+**事前条件**
+
+- テスト設定(`test-settings.json`)がインポートされている
+- OpenMode が `Tab` の AiPrompt コマンド「選択テキストの相互翻訳」が `AI` フォルダ内に設定済み
+
+**手順**
+
+1. テストページを開く
+2. XPath `//h2[contains(text(), 'Browser')]` で "Browser" を含む `h2` テキストを選択してポップアップメニューを表示する
+3. `AI` フォルダ(`title="AI"` のボタン)をクリックして展開する
+4. 「選択テキストの相互翻訳」メニュー項目をクリックする
+
+**期待動作**
+
+- 新しいタブが開く(ポップアップウィンドウではなく通常タブ)
+- 開いたタブの URL が `/gemini/` にマッチする
+- タブ内に選択テキスト「Browser」が表示される
+
+---
+
+#### E2E-52: AiPromptコマンド / クリップボード展開(Popup)
+
+**事前条件**
+
+- テスト設定(`test-settings.json`)がインポートされている
+- `{{Clipboard}}` プレースホルダーを使用した AiPrompt コマンド「クリップボード展開テスト」が `AI` フォルダ内に設定済み
+- OpenMode が `Popup`
+
+**手順**
+
+1. クリップボードへの読み書き権限を付与する
+2. クリップボードに任意のテキスト(例: `test clipboard content`)をセットする
+3. テストページを開く
+4. `h2` テキストを選択してポップアップメニューを表示する
+5. `AI` フォルダ(`title="AI"` のボタン)をクリックして展開する
+6. 「クリップボード展開テスト」メニュー項目をクリックする
+
+**期待動作**
+
+- `WindowType.popup` の新しいウィンドウが開く
+- 開いたウィンドウの URL が `/gemini/` にマッチする
+- ウィンドウ内にクリップボードの内容(`test clipboard content`)が表示される(`{{Clipboard}}` が展開されている)
+
+---
+
+### リンクプレビュー
+
+---
+
+#### E2E-60: リンクプレビュー起動 / Shiftキー + クリック
+
+**事前条件**
+
+- リンクプレビュー機能が有効
+- テストページにリンクが存在する
+
+**手順**
+
+1. テストページを開く
+2. リンク要素を `Shift` キーを押しながらクリックする
+
+**期待動作**
+
+- `WindowType.popup` の新しいウィンドウが開く
+- ウィンドウにリンク先の URL が読み込まれる
+
+---
+
+#### E2E-61: リンクプレビュー起動 / ドラッグ
+
+**事前条件**
+
+- リンクプレビュー機能が有効
+- テストページにリンクが存在する
+
+**手順**
+
+1. テストページを開く
+2. リンク要素をドラッグする(ドラッグ距離はプレビュー起動閾値以上)
+
+**期待動作**
+
+- `WindowType.popup` の新しいウィンドウが開く
+- ウィンドウにリンク先の URL が読み込まれる
+
+---
+
+#### E2E-62: リンクプレビュー起動 / 左クリック長押し
+
+**事前条件**
+
+- リンクプレビュー機能が有効
+- テストページにリンクが存在する
+
+**手順**
+
+1. テストページを開く
+2. リンク要素を左クリックで長押しする(設定した長押し時間以上)
+
+**期待動作**
+
+- `WindowType.popup` の新しいウィンドウが開く
+- ウィンドウにリンク先の URL が読み込まれる
+
+---
+
+#### E2E-63: リンクプレビュー / 画像ダウンロードリンク
+
+**事前条件**
+
+- リンクプレビュー機能が有効
+- テストページに Unsplash 等の画像ダウンロードリンクが存在する
+
+**手順**
+
+1. テストページを開く(または `https://unsplash.com/s/photos/sample` に相当するリンクを含むページへ遷移)
+2. 画像ダウンロードリンクをプレビュー操作(Shift+クリック / ドラッグ)する
+
+**期待動作**
+
+- `WindowType.popup` の新しいウィンドウが開く
+- ウィンドウにリンク先ページが表示される(ダウンロードではなくプレビュー表示)
+
+---
+
+#### E2E-64: リンクプレビュー / OpenMode: Window
+
+**事前条件**
+
+- リンクプレビューの OpenMode が `Window` に設定済み
+- テストページにリンクが存在する
+
+**手順**
+
+1. テストページを開く
+2. リンクをプレビュー操作する
+
+**期待動作**
+
+- `WindowType.window` の通常ブラウザウィンドウが開く
+- ウィンドウにリンク先の URL が読み込まれる
+
+---
+
+#### E2E-65: リンクプレビュー / OpenMode: SidePanel
+
+**事前条件**
+
+- リンクプレビューの OpenMode が `SidePanel` に設定済み
+- テストページにリンクが存在する
+
+**手順**
+
+1. テストページを開く
+2. リンクをプレビュー操作する
+
+**期待動作**
+
+- 現在タブのサイドパネルが開く
+- サイドパネルにリンク先の URL が読み込まれる
+
+---
+
+#### E2E-66: リンクプレビュー / ドラッグ距離 50px
+
+**事前条件**
+
+- リンクプレビュー機能が有効
+- ドラッグ起動距離が `50px` に設定済み
+- テストページにリンクが存在する
+
+**手順**
+
+1. テストページを開く
+2. リンク要素を 50px 以上ドラッグする
+
+**期待動作**
+
+- プレビューウィンドウが表示される
+
+**追加確認**
+
+- 49px 以下のドラッグではプレビューが表示されないこと
+
+---
+
+### ユーザースタイル
+
+---
+
+#### E2E-70: 余白サイズ変更(1)
+
+**事前条件**
+
+- ユーザースタイルの余白サイズが設定可能な状態
+
+**手順**
+
+1. 設定ページで余白サイズを `1` に設定する
+2. テストページを開く
+3. テキストを選択してポップアップメニューを表示する
+
+**期待動作**
+
+- ポップアップメニューの余白サイズがデフォルト(1.5)より狭くなる
+- メニューボタンの padding が CSS 変数で `1rem` ベースの値になっている
+
+---
+
+#### E2E-71: 余白サイズ変更(1.5 デフォルト)
+
+**事前条件**
+
+- 余白サイズが `1` 以外の値に変更された状態
+
+**手順**
+
+1. 設定ページで余白サイズを `1.5` に戻す
+2. テストページを開く
+3. テキストを選択してポップアップメニューを表示する
+
+**期待動作**
+
+- ポップアップメニューの余白サイズがデフォルト(1.5)に戻る
+
+---
+
+#### E2E-72: 背景色の設定
+
+**事前条件**
+
+- ユーザースタイルの背景色が設定可能な状態
+
+**手順**
+
+1. 設定ページでポップアップメニューの背景色を任意の色(例: `#ff0000`)に設定する
+2. テストページを開く
+3. テキストを選択してポップアップメニューを表示する
+
+**期待動作**
+
+- ポップアップメニューの背景色が設定した色に変更される
+
+---
+
+#### E2E-73: 背景色の削除
+
+**事前条件**
+
+- 背景色が設定済みの状態
+
+**手順**
+
+1. 設定ページで背景色の設定を削除する
+2. テストページを開く
+3. テキストを選択してポップアップメニューを表示する
+
+**期待動作**
+
+- ポップアップメニューの背景色がデフォルト(白)に戻る
+- 背景色の CSS 変数が未設定になる
+
+---
+
+#### E2E-74: 文字色の設定
+
+**事前条件**
+
+- 設定ページが開ける状態
+- ユーザースタイルに文字色(`font-color`)が未設定の状態
+
+**手順**
+
+1. 設定ページを開く
+2. ユーザースタイルの追加ボタン(`data-testid="user-style-add-button"`)をクリックする
+3. ダイアログで変数名に「文字色(`font-color`)」を選択する
+4. 値に任意の色(例: `#ff0000`)を入力する
+5. 保存ボタン(`data-testid="user-style-save-button"`)をクリックする
+6. テストページを開く
+7. テキストを選択してポップアップメニューを表示する
+
+**期待動作**
+
+- ユーザースタイルのリスト(`data-testid="user-style-item"`)に文字色の設定が追加される
+- ポップアップメニューのテキスト色が設定した色(`rgb(255, 0, 0)`)に変更される
+
+---
+
+#### E2E-75: 文字色の削除
+
+**事前条件**
+
+- 文字色(`font-color`)が設定済みの状態
+- 他にユーザースタイル設定(例: `padding-scale`)も存在する状態
+
+**手順**
+
+1. 設定ページを開く
+2. ユーザースタイルリストで文字色のアイテムの削除ボタン(`data-testid="user-style-remove-button"`)をクリックする
+3. 確認ダイアログで OK ボタン(`data-testid="user-style-remove-ok-button"`)をクリックする
+4. テストページを開く
+5. テキストを選択してポップアップメニューを表示する
+
+**期待動作**
+
+- ユーザースタイルのリストから文字色の設定が削除される
+- ポップアップメニューの文字色の CSS 変数(`--font-color`)が未設定になる
+- 他のユーザースタイル設定(`padding-scale` 等)はリストに残り影響を受けない
+
+---
+
+### 設定画面
+
+---
+
+#### E2E-80: インポート / ポップアップ設定
+
+**事前条件**
+
+- ポップアップコマンドを含む設定ファイル(JSON)が用意されている
+
+**手順**
+
+1. 設定ページを開く
+2. インポートボタンをクリックする
+3. ポップアップコマンドを含む設定ファイルを選択する
+4. OK ボタンをクリックする
+
+**期待動作**
+
+- インポートが成功する
+- 設定ページが再読み込みされる
+- インポートしたポップアップコマンドが一覧に表示される
+
+---
+
+#### E2E-81: インポート / リンクプレビュー設定
+
+**事前条件**
+
+- リンクプレビュー設定を含む設定ファイル(JSON)が用意されている
+
+**手順**
+
+1. 設定ページを開く
+2. インポートボタンをクリックして、リンクプレビュー設定を含むファイルをインポートする
+3. OK をクリックする
+
+**期待動作**
+
+- リンクプレビュー設定が反映される
+- 設定ページのリンクプレビューセクションにインポートした設定値が表示される
+
+---
+
+#### E2E-82: インポート / コマンド100件以上
+
+**事前条件**
+
+- 100件以上のコマンドを含む設定ファイル(JSON)が用意されている
+
+**手順**
+
+1. 設定ページを開く
+2. 100件以上のコマンドを含む設定ファイルをインポートする
+3. OK をクリックする
+
+**期待動作**
+
+- インポートが成功する(タイムアウトや件数上限エラーが発生しない)
+- インポートしたコマンド件数がすべてストレージに保存される
+
+---
+
+#### E2E-83: エクスポート
+
+**事前条件**
+
+- 設定が1件以上存在する
+
+**手順**
+
+1. 設定ページを開く
+2. エクスポートボタンをクリックする
+
+**期待動作**
+
+- 設定ファイル(JSON)がダウンロードされる
+- エクスポートされたファイルに現在の設定(コマンド、ユーザー設定等)が含まれる
+
+---
+
+#### E2E-84: リセット
+
+**事前条件**
+
+- デフォルトとは異なる設定が保存されている
+
+**手順**
+
+1. 設定ページを開く
+2. リセットボタンをクリックする
+3. 確認ダイアログで OK をクリックする
+
+**期待動作**
+
+- 設定がデフォルト値にリセットされる
+- コマンド一覧がデフォルトコマンドのみになる
+
+---
+
+### ハブ
+
+---
+
+#### E2E-90: ハブ / PageActionコマンド インストール
+
+**事前条件**
+
+- Selection Command Hub にアクセスできる
+- Hub 上に PageAction コマンドが公開されている
+
+**手順**
+
+1. Hub ページを開く
+2. PageAction コマンドのインストールボタンをクリックする
+3. インストールを確認する
+
+**期待動作**
+
+- PageAction コマンドが拡張機能の設定に追加される
+- 設定ページのコマンド一覧にインストールしたコマンドが表示される
+
+---
+
+#### E2E-91: ハブ / コマンド追加
+
+**事前条件**
+
+- Selection Command Hub にアクセスできる
+- Hub 上にダウンロード可能なコマンドが存在する
+
+**手順**
+
+1. Hub ページを開く
+2. 任意のコマンドのダウンロードボタンをクリックする
+
+**期待動作**
+
+- コマンドが拡張機能の設定に追加される
+- Hub ページのダウンロードボタンが「追加済み」状態に変化する
+
+---
+
+#### E2E-92: ハブ / コマンド削除
+
+**事前条件**
+
+- Hub からコマンドが追加済みの状態(E2E-91 実施後)
+
+**手順**
+
+1. 設定ページで Hub から追加したコマンドを削除する
+2. Hub ページを再度開く(またはリロードする)
+
+**期待動作**
+
+- 削除したコマンドに対応する Hub ページのダウンロードボタンが復活する(「追加済み」表示が解除される)
diff --git a/package.json b/package.json
index 204e224d..e97f9b1c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "selection-command-monorepo",
- "version": "0.14.2",
+ "version": "0.17.0",
"private": true,
"description": "Selection Command - Monorepo for Chrome Extension and Hub",
"author": "ujiro99",
@@ -13,17 +13,22 @@
"dev:hub": "yarn workspace @selection-command/hub dev",
"build": "yarn workspaces run build",
"build:extension": "yarn workspace @selection-command/extension build",
+ "build:e2e": "yarn workspace @selection-command/extension build:e2e",
"build:hub": "yarn workspace @selection-command/hub build",
"lint": "yarn workspaces run lint",
"test": "yarn workspace @selection-command/extension test:run; yarn workspace @selection-command/hub test:run",
"test:coverage": "yarn workspaces run test:coverage",
+ "test:e2e": "yarn workspace @selection-command/extension test:e2e",
"tsc": "yarn workspaces run tsc -b",
- "clean": "yarn workspaces run clean && rm -rf node_modules"
+ "clean": "yarn workspaces run clean && rm -rf node_modules",
+ "add-command": "yarn workspace @selection-command/hub add-command",
+ "check-ids": "yarn workspace @selection-command/extension check-ids",
+ "pretty-quick": "pretty-quick"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
+ "@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.7.0",
- "@testing-library/react": "^16.3.0",
"@types/node": "^20.10.6",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
@@ -42,5 +47,12 @@
"engines": {
"node": ">=18.0.0",
"yarn": ">=1.22.0"
+ },
+ "dependencies": {},
+ "prettier": {
+ "semi": false,
+ "singleQuote": false,
+ "tabWidth": 2,
+ "trailingComma": "all"
}
}
diff --git a/packages/extension/CLAUDE.md b/packages/extension/AGENTS.md
similarity index 83%
rename from packages/extension/CLAUDE.md
rename to packages/extension/AGENTS.md
index 616572cb..f8573d42 100644
--- a/packages/extension/CLAUDE.md
+++ b/packages/extension/AGENTS.md
@@ -1,8 +1,8 @@
# CLAUDE.md
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+This file provides guidance to AI Agent when working with code in this repository.
-このファイルは、このリポジトリのコードを扱う際にClaude Code (claude.ai/code)に対するガイダンスを提供します。
+このファイルは、このリポジトリのコードを扱う際にAI Agentに対するガイダンスを提供します。
## 基本ルール
@@ -10,17 +10,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **コードのスタイル**: TypeScriptのコーディング規約に従うこと。特に、変数名、関数名はキャメルケースを使用し、クラス名はパスカルケースを使用する。
- **コード内の言語**: コード内のコメントは英語で記述すること。
- **コードの品質**: コードは読みやすく、保守しやすいように書くこと。コメントは必要な箇所へ記載し、複雑なロジックには説明を加える。
-- **テキストのエンコーディング**: UTF-8を使用すること。
## 開発コマンド
- `yarn dev` - Viteを使用した開発モードの開始
- `yarn build` - 拡張機能のビルド(TypeScriptコンパイル + Viteビルドを実行)
+- `yarn build:e2e` - E2Eテスト用にビルド(E2Eテスト実行前に必ずこれを実行して最新のビルドを生成すること)
- `yarn lint` - ESLintを実行してコード品質をチェック
- `yarn test` - Vitestを使用したテストの実行
- `yarn test:ui` - VitestのUIモードでテストを実行
- `yarn test:coverage` - テストカバレッジを測定
- `yarn test src/path/to/file.test.ts` - 単一テストファイルの実行
+- `yarn test:e2e` - playwrightを使用したE2Eテストの実行
- `yarn pretty-quick` - Prettierによるコード整形
- `yarn zip` - ビルドされたdistフォルダから配布可能な拡張機能のzipファイルを作成
@@ -56,8 +57,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**主要機能:**
+- **検索コマンド** - 選択テキストを使用した検索URLの生成と新しいポップアップ|ウィンドウ|タブ|サイドパネルでのオープン
+- **AIプロンプト** - 選択テキストをあらかじめ定義したAI用のプロンプトテンプレートに展開し、AIサービスに送信
- **ページアクション** - ブラウザ自動化シーケンスの記録と再生
-- **コマンドハブ** - コマンドの共有と発見のためのWebインターフェース(`pages/`内の独立したNext.jsアプリ)
- **コンテキストメニュー** - 選択したテキストに対する右クリックアクション
- **設定管理** - 構成とユーザー設定のインポート/エクスポート
@@ -70,6 +72,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **スタイリング**: CSS Modules + Tailwind CSS(ver.3)
- **状態管理**: React hooks with Chrome extension storage APIs
- **テスト**: Vitest with jsdom for unit/integration testing
+- **e2eテスト**: Playwright for browser automation testing
- **コード品質**: ESLint for code quality
### プロジェクト構造の注意事項
@@ -104,11 +107,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **テスト設計書**: 各機能のテストケースを明確に定義
- 機能ごとにテストケースを分類し、優先順位を付ける
- 正常系、異常系、境界値テストを含める
-- **ユニットテスト**: 各関数やコンポーネントの個別テスト
- - テスト間の副作用を最小限に抑えるため、モックの使用は最低限にする
- - Arrange-Act-Assertパターンを使用
- - 共通的なモックは `src/test/setup.ts` に配置
- **テストケースの名前**: 機能名と期待される動作を明示的に記述する
- テストケース名の接頭辞は、機能名の略称と通し番号を使用(例: `SU-` for Storage Usage)
- 後から追加した場合は、さらに記号を付けて区別(例: SU-01-a)
- 例: ✅ SU-01: 正常系: 基本的な使用量計算が正しく行われる
+- **ユニットテスト**: 各関数やコンポーネントの個別テスト
+ - テスト間の副作用を最小限に抑えるため、モックの使用は最低限にする
+ - Arrange-Act-Assertパターンを使用
+ - 共通的なモックは `src/test/setup.ts` に配置
+- **e2eテスト**: Playwrightを使用した、シナリオベースのブラウザ自動化テスト
+ - Arrange-Act-Assertパターンを使用
+ - Page Object Model パターンを使用して、頻出する操作を抽象化する
+ - `packages/extension/e2e/pages/` にページオブジェクトを配置
+ - 詳細な設計書は `docs/test/e2e-test-spec.md` を参照、更新すること
+ - テストの実行前には、必ず `yarn build:e2e` を実行して最新のe2e向けビルドを生成すること
diff --git a/packages/extension/e2e/ai-prompt.spec.ts b/packages/extension/e2e/ai-prompt.spec.ts
new file mode 100644
index 00000000..3c03c0c7
--- /dev/null
+++ b/packages/extension/e2e/ai-prompt.spec.ts
@@ -0,0 +1,112 @@
+import { test, expect } from "./fixtures"
+import { TestPage } from "./pages/TestPage"
+import { OptionsPage } from "./pages/OptionsPage"
+import { attachConsole } from "./utils/logConsole"
+
+test.describe("AiPrompt Commands", () => {
+ test.beforeEach(async ({ context, extensionId, getCommands }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.importSettings()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-50: Verify that an AiPrompt command with OpenMode Popup opens a popup window.
+ */
+ test("E2E-50: AiPrompt command opens result in a popup window", async ({
+ context,
+ page,
+ }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText("h2")
+ const menubar = await testPage.getMenuBar()
+
+ // Open the AI folder (icon-only; find by title attribute)
+ await menubar.locator('[title="AI"]').click()
+
+ const [popupPage] = await Promise.all([
+ context.waitForEvent("page"),
+ page.locator("[role='menuitem'][aria-label='Gemini - 日本語']").click(),
+ ])
+ await popupPage.waitForLoadState("domcontentloaded")
+
+ // A popup window opened — URL should be a Gemini/AI service URL
+ expect(popupPage.url()).not.toBe("")
+ expect(popupPage.url()).toMatch(/gemini/)
+
+ // Verify that the prompt text is inserted into the input field of the AI service
+ const locator = popupPage.locator("text=以下について解説してください。")
+ await locator.waitFor({ state: "visible", timeout: 5000 })
+ expect(await locator.textContent()).toContain(
+ "以下について解説してください。",
+ )
+ })
+
+ /**
+ * E2E-51: Verify that an AiPrompt command with OpenMode Tab opens a new tab.
+ */
+ test("E2E-51: AiPrompt command opens result in a new tab", async ({
+ context,
+ page,
+ }) => {
+ attachConsole(page)
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText("//h2[contains(text(), 'Browser')]")
+ const menubar = await testPage.getMenuBar()
+
+ await menubar.locator('[title="AI"]').click()
+
+ const [newPage] = await Promise.all([
+ context.waitForEvent("page"),
+ page.locator("[role='menuitem'][aria-label='選択テキストの相互翻訳']").click(),
+ ])
+ await newPage.waitForLoadState("domcontentloaded")
+
+ expect(newPage.url()).not.toBe("")
+ expect(newPage.url()).toMatch(/gemini/)
+
+ const locator = newPage.locator("text=Browser")
+ await locator.waitFor({ state: "visible", timeout: 5000 })
+ expect(await locator.textContent()).toContain("Browser")
+ })
+
+ /**
+ * E2E-52: Verify that an AiPrompt command with clipboard placeholder opens a popup window.
+ */
+ test("E2E-52: AiPrompt clipboard placeholder opens a popup window", async ({
+ page,
+ context,
+ }) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"])
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ // Set clipboard content
+ await page.evaluate(() =>
+ navigator.clipboard.writeText("test clipboard content"),
+ )
+
+ await testPage.selectText("h2")
+ const menubar = await testPage.getMenuBar()
+
+ await menubar.locator('[title="AI"]').click()
+
+ const [popupPage] = await Promise.all([
+ context.waitForEvent("page"),
+ page
+ .locator("[role='menuitem'][aria-label='クリップボード展開テスト']")
+ .click(),
+ ])
+ await popupPage.waitForURL(/gemini/)
+ await popupPage.waitForLoadState("domcontentloaded")
+
+ const locator = popupPage.locator("text=test clipboard content").first()
+ await locator.waitFor({ state: "visible", timeout: 5000 })
+ expect(await locator.textContent()).toContain("test clipboard content")
+ })
+})
diff --git a/packages/extension/e2e/data/test-settings-100-commands.json b/packages/extension/e2e/data/test-settings-100-commands.json
new file mode 100644
index 00000000..21cb4445
--- /dev/null
+++ b/packages/extension/e2e/data/test-settings-100-commands.json
@@ -0,0 +1,1474 @@
+{
+ "commands": [
+ {
+ "id": "$$drag-1",
+ "openMode": "previewPopup",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "title": "Link Preview"
+ },
+ {
+ "id": "cmd-bulk-001",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 001"
+ },
+ {
+ "id": "cmd-bulk-002",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 002"
+ },
+ {
+ "id": "cmd-bulk-003",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 003"
+ },
+ {
+ "id": "cmd-bulk-004",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 004"
+ },
+ {
+ "id": "cmd-bulk-005",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 005"
+ },
+ {
+ "id": "cmd-bulk-006",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 006"
+ },
+ {
+ "id": "cmd-bulk-007",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 007"
+ },
+ {
+ "id": "cmd-bulk-008",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 008"
+ },
+ {
+ "id": "cmd-bulk-009",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 009"
+ },
+ {
+ "id": "cmd-bulk-010",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 010"
+ },
+ {
+ "id": "cmd-bulk-011",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 011"
+ },
+ {
+ "id": "cmd-bulk-012",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 012"
+ },
+ {
+ "id": "cmd-bulk-013",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 013"
+ },
+ {
+ "id": "cmd-bulk-014",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 014"
+ },
+ {
+ "id": "cmd-bulk-015",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 015"
+ },
+ {
+ "id": "cmd-bulk-016",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 016"
+ },
+ {
+ "id": "cmd-bulk-017",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 017"
+ },
+ {
+ "id": "cmd-bulk-018",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 018"
+ },
+ {
+ "id": "cmd-bulk-019",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 019"
+ },
+ {
+ "id": "cmd-bulk-020",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 020"
+ },
+ {
+ "id": "cmd-bulk-021",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 021"
+ },
+ {
+ "id": "cmd-bulk-022",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 022"
+ },
+ {
+ "id": "cmd-bulk-023",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 023"
+ },
+ {
+ "id": "cmd-bulk-024",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 024"
+ },
+ {
+ "id": "cmd-bulk-025",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 025"
+ },
+ {
+ "id": "cmd-bulk-026",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 026"
+ },
+ {
+ "id": "cmd-bulk-027",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 027"
+ },
+ {
+ "id": "cmd-bulk-028",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 028"
+ },
+ {
+ "id": "cmd-bulk-029",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 029"
+ },
+ {
+ "id": "cmd-bulk-030",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 030"
+ },
+ {
+ "id": "cmd-bulk-031",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 031"
+ },
+ {
+ "id": "cmd-bulk-032",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 032"
+ },
+ {
+ "id": "cmd-bulk-033",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 033"
+ },
+ {
+ "id": "cmd-bulk-034",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 034"
+ },
+ {
+ "id": "cmd-bulk-035",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 035"
+ },
+ {
+ "id": "cmd-bulk-036",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 036"
+ },
+ {
+ "id": "cmd-bulk-037",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 037"
+ },
+ {
+ "id": "cmd-bulk-038",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 038"
+ },
+ {
+ "id": "cmd-bulk-039",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 039"
+ },
+ {
+ "id": "cmd-bulk-040",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 040"
+ },
+ {
+ "id": "cmd-bulk-041",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 041"
+ },
+ {
+ "id": "cmd-bulk-042",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 042"
+ },
+ {
+ "id": "cmd-bulk-043",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 043"
+ },
+ {
+ "id": "cmd-bulk-044",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 044"
+ },
+ {
+ "id": "cmd-bulk-045",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 045"
+ },
+ {
+ "id": "cmd-bulk-046",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 046"
+ },
+ {
+ "id": "cmd-bulk-047",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 047"
+ },
+ {
+ "id": "cmd-bulk-048",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 048"
+ },
+ {
+ "id": "cmd-bulk-049",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 049"
+ },
+ {
+ "id": "cmd-bulk-050",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 050"
+ },
+ {
+ "id": "cmd-bulk-051",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 051"
+ },
+ {
+ "id": "cmd-bulk-052",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 052"
+ },
+ {
+ "id": "cmd-bulk-053",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 053"
+ },
+ {
+ "id": "cmd-bulk-054",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 054"
+ },
+ {
+ "id": "cmd-bulk-055",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 055"
+ },
+ {
+ "id": "cmd-bulk-056",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 056"
+ },
+ {
+ "id": "cmd-bulk-057",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 057"
+ },
+ {
+ "id": "cmd-bulk-058",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 058"
+ },
+ {
+ "id": "cmd-bulk-059",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 059"
+ },
+ {
+ "id": "cmd-bulk-060",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 060"
+ },
+ {
+ "id": "cmd-bulk-061",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 061"
+ },
+ {
+ "id": "cmd-bulk-062",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 062"
+ },
+ {
+ "id": "cmd-bulk-063",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 063"
+ },
+ {
+ "id": "cmd-bulk-064",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 064"
+ },
+ {
+ "id": "cmd-bulk-065",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 065"
+ },
+ {
+ "id": "cmd-bulk-066",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 066"
+ },
+ {
+ "id": "cmd-bulk-067",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 067"
+ },
+ {
+ "id": "cmd-bulk-068",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 068"
+ },
+ {
+ "id": "cmd-bulk-069",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 069"
+ },
+ {
+ "id": "cmd-bulk-070",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 070"
+ },
+ {
+ "id": "cmd-bulk-071",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 071"
+ },
+ {
+ "id": "cmd-bulk-072",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 072"
+ },
+ {
+ "id": "cmd-bulk-073",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 073"
+ },
+ {
+ "id": "cmd-bulk-074",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 074"
+ },
+ {
+ "id": "cmd-bulk-075",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 075"
+ },
+ {
+ "id": "cmd-bulk-076",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 076"
+ },
+ {
+ "id": "cmd-bulk-077",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 077"
+ },
+ {
+ "id": "cmd-bulk-078",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 078"
+ },
+ {
+ "id": "cmd-bulk-079",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 079"
+ },
+ {
+ "id": "cmd-bulk-080",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 080"
+ },
+ {
+ "id": "cmd-bulk-081",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 081"
+ },
+ {
+ "id": "cmd-bulk-082",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 082"
+ },
+ {
+ "id": "cmd-bulk-083",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 083"
+ },
+ {
+ "id": "cmd-bulk-084",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 084"
+ },
+ {
+ "id": "cmd-bulk-085",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 085"
+ },
+ {
+ "id": "cmd-bulk-086",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 086"
+ },
+ {
+ "id": "cmd-bulk-087",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 087"
+ },
+ {
+ "id": "cmd-bulk-088",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 088"
+ },
+ {
+ "id": "cmd-bulk-089",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 089"
+ },
+ {
+ "id": "cmd-bulk-090",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 090"
+ },
+ {
+ "id": "cmd-bulk-091",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 091"
+ },
+ {
+ "id": "cmd-bulk-092",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 092"
+ },
+ {
+ "id": "cmd-bulk-093",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 093"
+ },
+ {
+ "id": "cmd-bulk-094",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 094"
+ },
+ {
+ "id": "cmd-bulk-095",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 095"
+ },
+ {
+ "id": "cmd-bulk-096",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 096"
+ },
+ {
+ "id": "cmd-bulk-097",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 097"
+ },
+ {
+ "id": "cmd-bulk-098",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 098"
+ },
+ {
+ "id": "cmd-bulk-099",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 099"
+ },
+ {
+ "id": "cmd-bulk-100",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 100"
+ },
+ {
+ "id": "cmd-bulk-101",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Test Command 101"
+ }
+ ],
+ "folders": [],
+ "linkCommand": {
+ "enabled": "Enable",
+ "openMode": "previewPopup",
+ "showIndicator": true,
+ "sidePanelAutoHide": false,
+ "startupMethod": {
+ "keyboardParam": "Shift",
+ "leftClickHoldParam": 200,
+ "method": "keyboard",
+ "threshold": 150
+ }
+ },
+ "pageRules": [],
+ "popupPlacement": {
+ "align": "start",
+ "alignOffset": 0,
+ "side": "top",
+ "sideOffset": 0
+ },
+ "settingVersion": "0.16.0",
+ "shortcuts": {
+ "shortcuts": []
+ },
+ "startupMethod": {
+ "method": "textSelection"
+ },
+ "style": "horizontal",
+ "userStyles": [
+ {
+ "name": "popup-delay",
+ "value": 250
+ },
+ {
+ "name": "popup-duration",
+ "value": 150
+ },
+ {
+ "name": "padding-scale",
+ "value": "1.5"
+ }
+ ],
+ "windowOption": {
+ "popupAutoCloseDelay": 0,
+ "sidePanelAutoHide": false
+ }
+}
diff --git a/packages/extension/e2e/data/test-settings-link-preview.json b/packages/extension/e2e/data/test-settings-link-preview.json
new file mode 100644
index 00000000..4de21f54
--- /dev/null
+++ b/packages/extension/e2e/data/test-settings-link-preview.json
@@ -0,0 +1,60 @@
+{
+ "commands": [
+ {
+ "id": "$$drag-1",
+ "openMode": "previewPopup",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "title": "Link Preview"
+ }
+ ],
+ "folders": [],
+ "linkCommand": {
+ "enabled": "Enable",
+ "openMode": "previewSidePanel",
+ "showIndicator": true,
+ "sidePanelAutoHide": true,
+ "startupMethod": {
+ "keyboardParam": "Shift",
+ "leftClickHoldParam": 200,
+ "method": "keyboard",
+ "threshold": 50
+ }
+ },
+ "pageRules": [],
+ "popupPlacement": {
+ "align": "start",
+ "alignOffset": 0,
+ "side": "top",
+ "sideOffset": 0
+ },
+ "settingVersion": "0.16.0",
+ "shortcuts": {
+ "shortcuts": []
+ },
+ "startupMethod": {
+ "method": "textSelection"
+ },
+ "style": "horizontal",
+ "userStyles": [
+ {
+ "name": "popup-delay",
+ "value": 250
+ },
+ {
+ "name": "popup-duration",
+ "value": 150
+ },
+ {
+ "name": "padding-scale",
+ "value": "1.5"
+ }
+ ],
+ "windowOption": {
+ "popupAutoCloseDelay": 0,
+ "sidePanelAutoHide": false
+ }
+}
diff --git a/packages/extension/e2e/data/test-settings.json b/packages/extension/e2e/data/test-settings.json
new file mode 100644
index 00000000..26ca763b
--- /dev/null
+++ b/packages/extension/e2e/data/test-settings.json
@@ -0,0 +1,557 @@
+{
+ "commands": [
+ {
+ "id": "$$drag-1",
+ "openMode": "previewPopup",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "title": "Link Preview"
+ },
+ {
+ "iconUrl": "https://ujiro99.github.io/selection-command/favicon.ico",
+ "id": "13b4e831-7fac-4b72-a1ae-c82655b3819e",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "テストページ検索"
+ },
+ {
+ "iconUrl": "https://www.google.com/favicon.ico",
+ "id": "0cb9dbbc-c0cf-53c6-93e5-016363705216",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "222d6489-4eca-48fd-8590-fceb30545bab",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://google.com/search?q=%s",
+ "spaceEncoding": "plus",
+ "title": "Google"
+ },
+ {
+ "iconUrl": "https://www.google.com/favicon.ico",
+ "id": "26c47b36-c3c8-528c-9ad2-c972dfc6f4df",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "222d6489-4eca-48fd-8590-fceb30545bab",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://google.com/search?q=%s&tbm=isch",
+ "spaceEncoding": "plus",
+ "title": "Google Image"
+ },
+ {
+ "iconUrl": "https://www.amazon.co.jp/favicon.ico",
+ "id": "9d61d45c-36ab-5ebf-ad42-d3f3a42810bf",
+ "openMode": "tab",
+ "openModeSecondary": "tab",
+ "parentFolderId": "222d6489-4eca-48fd-8590-fceb30545bab",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://www.amazon.co.jp/s?k=%s",
+ "spaceEncoding": "plus",
+ "title": "Amazon"
+ },
+ {
+ "iconUrl": "https://s.yimg.jp/c/icon/s/bsc/2.0/favicon.ico",
+ "id": "2bcb5d3a-15b6-5e3f-b59d-94b0fdc68ea9",
+ "openMode": "popup",
+ "openModeSecondary": "tab",
+ "parentFolderId": "222d6489-4eca-48fd-8590-fceb30545bab",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://search.yahoo.co.jp/search?p=%s",
+ "spaceEncoding": "plus",
+ "title": "Yahoo! Japan"
+ },
+ {
+ "iconUrl": "https://www.youtube.com/s/desktop/f574e7a2/img/favicon_32x32.png",
+ "id": "2b6fee1e-6500-5421-af79-6fa53ddc25c1",
+ "openMode": "tab",
+ "openModeSecondary": "tab",
+ "parentFolderId": "a3495269-0a4d-4866-a519-bca75ed1c246",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://www.youtube.com/results?search_query=%s",
+ "spaceEncoding": "plus",
+ "title": "Youtube"
+ },
+ {
+ "iconUrl": "https://assets.nflxext.com/ffe/siteui/common/icons/nficon2016.ico",
+ "id": "fb9cb6ad-76e3-5aa8-82a7-ade233edcec0",
+ "openMode": "tab",
+ "openModeSecondary": "tab",
+ "parentFolderId": "a3495269-0a4d-4866-a519-bca75ed1c246",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://www.netflix.com/search?q=%s",
+ "spaceEncoding": "plus",
+ "title": "Netflix"
+ },
+ {
+ "aiPromptOption": {
+ "openMode": "popup",
+ "prompt": "以下について解説してください。\n{{SelectedText}}",
+ "serviceId": "gemini"
+ },
+ "iconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg",
+ "id": "1d320825-1e78-5f98-b73c-1bb48412e98c",
+ "openMode": "aiPrompt",
+ "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "title": "Gemini - 日本語"
+ },
+ {
+ "aiPromptOption": {
+ "openMode": "sidePanel",
+ "prompt": "以下のURLのページを日本語で要約してください。\n{{Url}}",
+ "serviceId": "gemini"
+ },
+ "iconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg",
+ "id": "afe67f66-fc8d-555f-9e51-2d1491906faf",
+ "openMode": "aiPrompt",
+ "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "title": "ページの概要生成"
+ },
+ {
+ "aiPromptOption": {
+ "openMode": "sidePanel",
+ "prompt": "以下のYouTube動画の内容を日本語で要約してください。\n{{Url}}",
+ "serviceId": "gemini"
+ },
+ "iconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg",
+ "id": "7afd0cb7-45a4-5943-a00d-b04d12317eb1",
+ "openMode": "aiPrompt",
+ "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "title": "YouTubeの概要生成"
+ },
+ {
+ "aiPromptOption": {
+ "openMode": "tab",
+ "prompt": "以下のテキストを日本語と英語の間で翻訳してください。\n{{SelectedText}}",
+ "serviceId": "gemini"
+ },
+ "iconUrl": "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg",
+ "id": "a8d027bf-7926-56c4-ad4d-610ef10c22b3",
+ "openMode": "aiPrompt",
+ "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "title": "選択テキストの相互翻訳"
+ },
+ {
+ "id": "b1856c00-d775-5fdc-89e9-94d1b6f786e5",
+ "title": "Sakura Checker",
+ "openMode": "pageAction",
+ "parentFolderId": "0f2167ab-2e1b-4972-954c-71eec058ab14",
+ "iconUrl": "https://sakura-checker.jp/images/favicon.ico",
+ "pageActionOption": {
+ "startUrl": "https://sakura-checker.jp/",
+ "openMode": "backgroundTab",
+ "steps": [
+ {
+ "id": "diaq1mthv",
+ "param": {
+ "type": "start",
+ "label": "Start"
+ }
+ },
+ {
+ "id": "hoi75oly8",
+ "param": {
+ "type": "click",
+ "label": "word",
+ "selector": "//*[@name='word']",
+ "selectorType": "xpath"
+ }
+ },
+ {
+ "id": "2boq9korh",
+ "param": {
+ "type": "input",
+ "label": "word",
+ "selector": "//*[@name='word']",
+ "selectorType": "xpath",
+ "value": "{{Url}}"
+ }
+ },
+ {
+ "id": "thuq16r8o",
+ "param": {
+ "type": "click",
+ "label": "submit",
+ "selector": "//*[@class='button is-primary is-medium']",
+ "selectorType": "xpath"
+ }
+ },
+ {
+ "id": "bcs0vx9bn",
+ "param": {
+ "type": "scroll",
+ "label": "x: 0, y: 266",
+ "x": 0,
+ "y": 266
+ }
+ },
+ {
+ "id": "8viuw3650",
+ "param": {
+ "type": "end",
+ "label": "End"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "iconUrl": "https://ssl.gstatic.com/docs/doclist/images/drive_2022q3_32dp.png",
+ "id": "dd05d527-92db-5102-9a88-4a5b31fa7512",
+ "openMode": "tab",
+ "openModeSecondary": "tab",
+ "parentFolderId": "01710cf1-ec8b-497f-8d1f-9cb716567bc4",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://drive.google.com/drive/search?q=%s",
+ "spaceEncoding": "plus",
+ "title": "Drive"
+ },
+ {
+ "iconUrl": "https://ssl.gstatic.com/translate/favicon.ico",
+ "id": "9a3fca67-e618-5dd3-9ecd-9eb2d088041a",
+ "openMode": "tab",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://translate.google.co.jp/?hl=ja&sl=auto&text=%s&op=translate",
+ "spaceEncoding": "plus",
+ "title": "en to ja"
+ },
+ {
+ "id": "cmd-test-window",
+ "openMode": "window",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "テストページ (Window)"
+ },
+ {
+ "id": "cmd-test-sidepanel",
+ "openMode": "sidePanel",
+ "openModeSecondary": "tab",
+ "parentFolderId": "RootFolder",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://ujiro99.github.io/selection-command/en/test?k=%s",
+ "spaceEncoding": "plus",
+ "title": "テストページ (SidePanel)"
+ },
+ {
+ "id": "cmd-test-copy",
+ "openMode": "copy",
+ "parentFolderId": "RootFolder",
+ "revision": 0,
+ "title": "テキストコピー",
+ "iconUrl": "https://cdn0.iconfinder.com/data/icons/phosphor-light-vol-2/256/copy-light-1024.png"
+ },
+ {
+ "id": "cmd-test-linkpopup",
+ "openMode": "linkPopup",
+ "parentFolderId": "RootFolder",
+ "revision": 0,
+ "title": "リンクポップアップ",
+ "iconUrl": "https://cdn3.iconfinder.com/data/icons/fluent-regular-24px-vol-5/24/ic_fluent_open_24_regular-1024.png",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ }
+ },
+ {
+ "id": "cmd-test-deepl",
+ "openMode": "tab",
+ "openModeSecondary": "tab",
+ "parentFolderId": "lang-folder-001",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "searchUrl": "https://www.deepl.com/translator#auto/ja/%s",
+ "spaceEncoding": "plus",
+ "title": "DeepL"
+ },
+ {
+ "aiPromptOption": {
+ "openMode": "popup",
+ "prompt": "以下のテキストについて説明してください。\n{{Clipboard}}",
+ "serviceId": "gemini"
+ },
+ "id": "cmd-test-ai-clipboard",
+ "openMode": "aiPrompt",
+ "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae",
+ "popupOption": {
+ "height": 700,
+ "width": 600
+ },
+ "revision": 0,
+ "title": "クリップボード展開テスト"
+ },
+ {
+ "id": "cmd-test-pa-tab",
+ "title": "Character Counter (Tab)",
+ "openMode": "pageAction",
+ "parentFolderId": "0f2167ab-2e1b-4972-954c-71eec058ab14",
+ "pageActionOption": {
+ "openMode": "tab",
+ "startUrl": "https://web-toolbox.dev/tools/character-counter",
+ "steps": [
+ {
+ "delayMs": 0,
+ "id": "pa-tab-s1",
+ "param": {
+ "label": "Start",
+ "type": "start",
+ "url": "https://web-toolbox.dev/tools/character-counter"
+ },
+ "skipRenderWait": false
+ },
+ {
+ "delayMs": 500,
+ "id": "pa-tab-s2",
+ "param": {
+ "label": "文字入力",
+ "selector": "//textarea",
+ "selectorType": "xpath",
+ "type": "input",
+ "value": "{{SelectedText}}"
+ },
+ "skipRenderWait": false
+ },
+ {
+ "delayMs": 0,
+ "id": "pa-tab-s3",
+ "param": {
+ "label": "End",
+ "type": "end"
+ },
+ "skipRenderWait": false
+ }
+ ]
+ },
+ "revision": 0
+ },
+ {
+ "id": "cmd-test-pa-bgtab",
+ "title": "Character Counter (BG Tab)",
+ "openMode": "pageAction",
+ "parentFolderId": "0f2167ab-2e1b-4972-954c-71eec058ab14",
+ "pageActionOption": {
+ "openMode": "backgroundTab",
+ "startUrl": "https://web-toolbox.dev/tools/character-counter",
+ "steps": [
+ {
+ "delayMs": 0,
+ "id": "pa-bgtab-s1",
+ "param": {
+ "label": "Start",
+ "type": "start",
+ "url": "https://web-toolbox.dev/tools/character-counter"
+ },
+ "skipRenderWait": false
+ },
+ {
+ "delayMs": 500,
+ "id": "pa-bgtab-s2",
+ "param": {
+ "label": "文字入力",
+ "selector": "//textarea",
+ "selectorType": "xpath",
+ "type": "input",
+ "value": "{{SelectedText}}"
+ },
+ "skipRenderWait": false
+ },
+ {
+ "delayMs": 0,
+ "id": "pa-bgtab-s3",
+ "param": {
+ "label": "End",
+ "type": "end"
+ },
+ "skipRenderWait": false
+ }
+ ]
+ },
+ "revision": 0
+ }
+ ],
+ "folders": [
+ {
+ "iconUrl": "https://cdn3.iconfinder.com/data/icons/feather-5/24/search-1024.png",
+ "id": "222d6489-4eca-48fd-8590-fceb30545bab",
+ "onlyIcon": true,
+ "title": "Search"
+ },
+ {
+ "iconSvg": "",
+ "iconUrl": "",
+ "id": "0f2167ab-2e1b-4972-954c-71eec058ab14",
+ "onlyIcon": true,
+ "title": "Action"
+ },
+ {
+ "iconSvg": "",
+ "iconUrl": "",
+ "id": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae",
+ "onlyIcon": true,
+ "title": "AI"
+ },
+ {
+ "iconSvg": "",
+ "iconUrl": "",
+ "id": "a3495269-0a4d-4866-a519-bca75ed1c246",
+ "onlyIcon": true,
+ "title": "Media"
+ },
+ {
+ "iconSvg": "",
+ "iconUrl": "",
+ "id": "01710cf1-ec8b-497f-8d1f-9cb716567bc4",
+ "title": "Work"
+ },
+ {
+ "iconUrl": "",
+ "id": "lang-folder-001",
+ "parentFolderId": "e4994c63-cfa7-4e49-9dfe-a79e6120a1ae",
+ "title": "Lang"
+ }
+ ],
+ "linkCommand": {
+ "enabled": "Enable",
+ "openMode": "previewPopup",
+ "showIndicator": true,
+ "sidePanelAutoHide": false,
+ "startupMethod": {
+ "keyboardParam": "Shift",
+ "leftClickHoldParam": 200,
+ "method": "keyboard",
+ "threshold": 150
+ }
+ },
+ "pageRules": [],
+ "popupPlacement": {
+ "align": "start",
+ "alignOffset": 0,
+ "side": "top",
+ "sideOffset": 0
+ },
+ "settingVersion": "0.16.0",
+ "shortcuts": {
+ "shortcuts": [
+ {
+ "commandId": "_placeholder_",
+ "id": "slot_1",
+ "noSelectionBehavior": "useClipboard"
+ },
+ {
+ "commandId": "_placeholder_",
+ "id": "slot_2",
+ "noSelectionBehavior": "useClipboard"
+ },
+ {
+ "commandId": "_placeholder_",
+ "id": "slot_3",
+ "noSelectionBehavior": "useClipboard"
+ }
+ ]
+ },
+ "startupMethod": {
+ "method": "textSelection"
+ },
+ "style": "horizontal",
+ "userStyles": [
+ {
+ "name": "popup-delay",
+ "value": 250
+ },
+ {
+ "name": "popup-duration",
+ "value": 150
+ },
+ {
+ "name": "padding-scale",
+ "value": "1.5"
+ },
+ {
+ "name": "image-scale",
+ "value": "1.1"
+ },
+ {
+ "name": "font-scale",
+ "value": "1.1"
+ }
+ ],
+ "windowOption": {
+ "popupAutoCloseDelay": 0,
+ "sidePanelAutoHide": false
+ }
+}
diff --git a/packages/extension/e2e/extension.spec.ts b/packages/extension/e2e/extension.spec.ts
new file mode 100644
index 00000000..eb3a8e7a
--- /dev/null
+++ b/packages/extension/e2e/extension.spec.ts
@@ -0,0 +1,13 @@
+import { test, expect } from "./fixtures"
+import { TestPage } from "./pages/TestPage"
+import { APP_ID } from "../src/const"
+
+/**
+ * E2E-01: Verify that the extension content script is injected into the test page.
+ * Checks that the root element with APP_ID exists in the DOM.
+ */
+test("E2E-01: extension is injected into the test page", async ({ page }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await expect(page.locator(`#${APP_ID}`)).toBeAttached()
+})
diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts
new file mode 100644
index 00000000..1e507beb
--- /dev/null
+++ b/packages/extension/e2e/fixtures.ts
@@ -0,0 +1,212 @@
+import {
+ test as base,
+ chromium,
+ Page,
+ type BrowserContext,
+} from "@playwright/test"
+import path from "path"
+import { fileURLToPath } from "url"
+import { attachSWConsole } from "./utils/logConsole"
+import {
+ STORAGE_KEY,
+ LOCAL_STORAGE_KEY,
+ CMD_PREFIX,
+} from "@/services/storage/const"
+
+import type { UserSettings, Command } from "@/types"
+import type { CommandMetadata } from "@/types/command"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const pathToExtension = path.join(__dirname, "../dist")
+
+type StorageChangeMap = {
+ [key: string]: chrome.storage.StorageChange
+}
+export type WaitForStorageChange = () => Promise
+
+type Fixtures = {
+ context: BrowserContext
+ extensionId: string
+ extensionBackground: Page
+ getUserSettings: () => Promise
+ setUserSettings: (newSettings: Partial) => Promise
+ getCommands: () => Promise
+ isAllWindowsNormal: () => Promise
+}
+
+/**
+ * Custom test fixture that launches Chrome with the extension loaded.
+ */
+export const test = base.extend({
+ // eslint-disable-next-line no-empty-pattern
+ context: async ({}, use) => {
+ // When running with --debug, PWDEBUG is set; show the browser window in that case.
+ const isDebug = !!process.env.PWDEBUG
+ const context = await chromium.launchPersistentContext("", {
+ // headless: false is required so Playwright doesn't restrict extension loading.
+ // --headless=new enables Chrome's new headless mode that supports extensions,
+ // allowing tests to run in CI without a display.
+ headless: false,
+ args: [
+ // Omit --headless=new in debug mode so the browser window is visible.
+ ...(!isDebug ? ["--headless=new"] : []),
+ `--disable-extensions-except=${pathToExtension}`,
+ `--load-extension=${pathToExtension}`,
+ ],
+ })
+
+ // Wait for the service worker to be ready before proceeding, so tests can interact with it immediately.
+ let [sw] = context.serviceWorkers()
+ if (!sw) {
+ sw = await context.waitForEvent("serviceworker")
+ }
+ attachSWConsole(sw)
+
+ await use(context)
+ await context.close()
+ },
+
+ page: async ({ context }, use) => {
+ const page = await context.newPage()
+ await use(page)
+ },
+
+ extensionId: async ({ context }, use) => {
+ // MV3: Get the extension ID from the service worker
+ let [serviceWorker] = context.serviceWorkers()
+ if (!serviceWorker) {
+ serviceWorker = await context.waitForEvent("serviceworker")
+ }
+ const extensionId = serviceWorker.url().split("/")[2]
+ await use(extensionId)
+ },
+
+ extensionBackground: async ({ context, extensionId }, use) => {
+ const bg = await context.newPage()
+ await bg.goto(`chrome-extension://${extensionId}/src/options_page.html`)
+ await use(bg)
+ await bg.close()
+ },
+
+ getCommands: async ({ context }, use) => {
+ let [serviceWorker] = context.serviceWorkers()
+ if (!serviceWorker) {
+ serviceWorker = await context.waitForEvent("serviceworker")
+ }
+ await use(async () => {
+ const result = await serviceWorker.evaluate(
+ async ({ CMD_META_SYNC, CMD_META_LOCAL, CMD_PREFIX }) => {
+ // 1. Load commands from sync storage.
+ const { [CMD_META_SYNC]: syncMetaData } =
+ await chrome.storage.sync.get<{
+ [CMD_META_SYNC]: CommandMetadata
+ }>(CMD_META_SYNC)
+ const syncCount = syncMetaData?.count ?? 0
+
+ const cmdSyncKey = (idx: number): string => `${CMD_PREFIX}${idx}`
+ const syncKeys = Array.from({ length: syncCount }, (_, i) =>
+ cmdSyncKey(i),
+ )
+ const syncResult = await chrome.storage.sync.get(syncKeys)
+ const syncCommands = syncKeys.map((key) => syncResult[key] as Command)
+
+ // 2. Load commands from local storage
+ const { [CMD_META_LOCAL]: localMetaData } =
+ await chrome.storage.local.get<{
+ [CMD_META_LOCAL]: CommandMetadata
+ }>(CMD_META_LOCAL)
+ const localCount = localMetaData?.count ?? 0
+
+ const cmdLocalKey = (idx: number): string =>
+ `${CMD_PREFIX}local-${idx}`
+ const localKeys = Array.from({ length: localCount }, (_, i) =>
+ cmdLocalKey(i),
+ )
+ const localResult = await chrome.storage.local.get(localKeys)
+ const localCommands = localKeys.map(
+ (key) => localResult[key] as Command,
+ )
+
+ return [...syncCommands, ...localCommands].filter(
+ (cmd) => cmd != null,
+ )
+ },
+ {
+ CMD_META_SYNC: `${STORAGE_KEY.SYNC_COMMAND_METADATA}` as const,
+ CMD_META_LOCAL: LOCAL_STORAGE_KEY.LOCAL_COMMAND_METADATA as const,
+ CMD_PREFIX,
+ },
+ )
+ return result
+ })
+ },
+
+ getUserSettings: async ({ context }, use) => {
+ let [serviceWorker] = context.serviceWorkers()
+ if (!serviceWorker) {
+ serviceWorker = await context.waitForEvent("serviceworker")
+ }
+
+ await use(async () => {
+ const result = await serviceWorker.evaluate(
+ async ({ USER_KEY }) => {
+ const result = await chrome.storage.sync.get<{
+ [USER_KEY]: UserSettings
+ }>(USER_KEY)
+ return result[USER_KEY]
+ },
+ { USER_KEY: `${STORAGE_KEY.USER}` as const },
+ )
+ return result
+ })
+ },
+
+ setUserSettings: async ({ context }, use) => {
+ let [serviceWorker] = context.serviceWorkers()
+ if (!serviceWorker) {
+ serviceWorker = await context.waitForEvent("serviceworker")
+ }
+
+ await use(async (newSettings: Partial) => {
+ const result = await serviceWorker.evaluate(
+ async ({
+ settings,
+ USER_KEY,
+ }: {
+ settings: Partial
+ USER_KEY: `${STORAGE_KEY.USER}`
+ }) => {
+ const stored = await chrome.storage.sync.get<{
+ [USER_KEY]: UserSettings
+ }>(USER_KEY)
+ const updatedSettings = {
+ ...stored[USER_KEY],
+ ...settings,
+ }
+ await chrome.storage.sync.set({
+ [USER_KEY]: updatedSettings,
+ })
+ return updatedSettings
+ },
+ { settings: newSettings, USER_KEY: `${STORAGE_KEY.USER}` as const },
+ )
+ return result
+ })
+ },
+
+ isAllWindowsNormal: async ({ context }, use) => {
+ let [serviceWorker] = context.serviceWorkers()
+ if (!serviceWorker) {
+ serviceWorker = await context.waitForEvent("serviceworker")
+ }
+ await use(async () => {
+ const result = await serviceWorker.evaluate(async () => {
+ const windows = await chrome.windows.getAll({ populate: false })
+ return !windows.some((win) => win.type !== "normal")
+ })
+ return result
+ })
+ },
+})
+
+export const expect = test.expect
diff --git a/packages/extension/e2e/generated-command-urls.ts b/packages/extension/e2e/generated-command-urls.ts
new file mode 100644
index 00000000..5f99adc9
--- /dev/null
+++ b/packages/extension/e2e/generated-command-urls.ts
@@ -0,0 +1,37 @@
+/**
+ * Auto-generated by scripts/generate-e2e-urls.ts from defaultSettings.ts.
+ * Do not edit manually. Run "yarn build:e2e" to regenerate.
+ */
+
+export type UrlEntry = { title: string; locale: string; searchUrl: string }
+
+export const COMMAND_URLS: UrlEntry[] = [
+ { title: "Google", locale: "en", searchUrl: "https://google.com/search?q=%s" },
+ { title: "Google Image", locale: "en", searchUrl: "https://google.com/search?q=%s&tbm=isch" },
+ { title: "Amazon", locale: "en", searchUrl: "https://www.amazon.com/s?k=%s" },
+ { title: "Youtube", locale: "en", searchUrl: "https://www.youtube.com/results?search_query=%s" },
+ { title: "Netflix", locale: "en", searchUrl: "https://www.netflix.com/search?q=%s" },
+ { title: "Pinterest", locale: "en", searchUrl: "https://www.pinterest.com/search/pins/?q=%s" },
+ { title: "Drive", locale: "en", searchUrl: "https://drive.google.com/drive/search?q=%s" },
+ { title: "Amazon", locale: "ja", searchUrl: "https://www.amazon.co.jp/s?k=%s" },
+ { title: "Yahoo! Japan", locale: "ja", searchUrl: "https://search.yahoo.co.jp/search?p=%s" },
+ { title: "en to ja", locale: "ja", searchUrl: "https://translate.google.co.jp/?hl=ja&sl=auto&text=%s&op=translate" },
+ { title: "百度", locale: "zh", searchUrl: "https://www.baidu.com/s?wd=%s" },
+ { title: "京东", locale: "zh", searchUrl: "https://search.jd.com/Search?keyword=%s" },
+ { title: "哔哩哔哩", locale: "zh", searchUrl: "https://search.bilibili.com/all?keyword=%s" },
+ { title: "네이버", locale: "ko", searchUrl: "https://search.naver.com/search.naver?query=%s" },
+ { title: "Яндекс", locale: "ru", searchUrl: "https://yandex.ru/search/?text=%s" },
+ { title: "ВКонтакте", locale: "ru", searchUrl: "https://vk.com/search?c%5Bq%5D=%s" },
+ { title: "eBay", locale: "de", searchUrl: "https://www.ebay.de/sch/i.html?_nkw=%s" },
+ { title: "Amazon", locale: "es", searchUrl: "https://www.amazon.es/s?k=%s" },
+ { title: "eBay", locale: "es", searchUrl: "https://www.ebay.es/sch/i.html?_nkw=%s" },
+ { title: "El Corte Inglés", locale: "es", searchUrl: "https://www.elcorteingles.es/search-nwx/?s=%s" },
+ { title: "AliExpress", locale: "es", searchUrl: "https://es.aliexpress.com/w/wholesale-%s.html" },
+ { title: "OLX", locale: "pt", searchUrl: "https://www.olx.pt/ads/?q=%s" },
+ { title: "Tokopedia", locale: "id", searchUrl: "https://www.tokopedia.com/search?st=product&q=%s" },
+ { title: "Shopee", locale: "id", searchUrl: "https://shopee.co.id/search?keyword=%s" },
+ { title: "Shopee", locale: "ms", searchUrl: "https://shopee.com.my/search?keyword=%s" },
+ { title: "Lazada", locale: "ms", searchUrl: "https://www.lazada.com.my/catalog/?q=%s" },
+ { title: "eBay.it", locale: "it", searchUrl: "https://www.ebay.it/sch/i.html?_nkw=%s" },
+ { title: "Zalando.it", locale: "it", searchUrl: "https://www.zalando.it/catalogo/?q=%s" },
+]
diff --git a/packages/extension/e2e/hub.spec.ts b/packages/extension/e2e/hub.spec.ts
new file mode 100644
index 00000000..b5bcb6b6
--- /dev/null
+++ b/packages/extension/e2e/hub.spec.ts
@@ -0,0 +1,177 @@
+import { test, expect } from "./fixtures"
+import { OptionsPage } from "./pages/OptionsPage"
+
+const HUB_URL = "https://ujiro99.github.io/selection-command"
+
+const tryGetCommandId = (commandData: string | null): string => {
+ if (!commandData) {
+ throw new Error("Hub button is missing required data-command attribute")
+ }
+ let parsedCommand: unknown
+ try {
+ parsedCommand = JSON.parse(commandData)
+ } catch (error) {
+ throw new Error(
+ `Failed to parse data-command JSON from Hub button: ${(error as Error).message}`,
+ )
+ }
+ const commandId =
+ typeof parsedCommand === "object" &&
+ parsedCommand !== null &&
+ "id" in parsedCommand &&
+ typeof (parsedCommand as { id: unknown }).id === "string"
+ ? (parsedCommand as { id: string }).id
+ : null
+ if (!commandId) {
+ throw new Error(
+ `Parsed data-command JSON does not contain a valid "id": ${commandData}`,
+ )
+ }
+ return commandId
+}
+
+test.describe("Command Hub", () => {
+ /**
+ * E2E-90: Verify that a PageAction command can be installed from the Hub.
+ */
+ test("E2E-90: install PageAction command from Hub", async ({
+ context,
+ extensionId,
+ getCommands,
+ page,
+ }) => {
+ // Reset to a clean state first
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.resetSettings()
+ await optionsPage.close()
+
+ const commandsBefore = await getCommands()
+ const countBefore = commandsBefore?.length ?? 0
+
+ // Navigate to the Hub
+ await page.goto(HUB_URL)
+ await page.waitForLoadState("domcontentloaded")
+
+ // Find a download button for a PageAction command on the Hub page.
+ // The extension injects download functionality for buttons with data-command attribute.
+ const downloadButton = page
+ .locator('button[data-command*=\'"openMode":"pageAction"\']')
+ .filter({ hasNot: page.locator('[data-installed="true"]') })
+ .first()
+ await downloadButton.waitFor({ state: "visible", timeout: 5000 })
+ await downloadButton.click()
+ await expect
+ .poll(
+ async () => {
+ const commands = await getCommands()
+ return (commands?.length ?? 0) > countBefore
+ },
+ { timeout: 5000 },
+ )
+ .toBe(true)
+ })
+
+ /**
+ * E2E-91: Verify that clicking a download button on the Hub adds the command.
+ */
+ test("E2E-91: download button on Hub adds command to settings", async ({
+ context,
+ extensionId,
+ getCommands,
+ page,
+ }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.resetSettings()
+ await optionsPage.close()
+
+ const commandsBefore = await getCommands()
+ const countBefore = commandsBefore?.length ?? 0
+
+ await page.goto(HUB_URL)
+ await page.waitForLoadState("domcontentloaded")
+
+ // Find any available download button
+ const downloadButton = page
+ .locator('button[data-command*=\'"openMode":"popup"\']')
+ .filter({ hasNot: page.locator('[data-installed="true"]') })
+ .first()
+
+ await downloadButton.waitFor({ state: "visible", timeout: 5000 })
+
+ // Get the command identifier for verification
+ const commandData = await downloadButton.getAttribute("data-command")
+ tryGetCommandId(commandData)
+
+ await downloadButton.click()
+ await expect
+ .poll(
+ async () => {
+ const commands = await getCommands()
+ return (commands?.length ?? 0) > countBefore
+ },
+ { timeout: 5000 },
+ )
+ .toBe(true)
+ })
+
+ /**
+ * E2E-92: Verify that deleting a hub-installed command restores the download button.
+ */
+ test("E2E-92: deleting a hub-installed command restores the download button", async ({
+ context,
+ extensionId,
+ getCommands,
+ page,
+ }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.resetSettings()
+ await optionsPage.close()
+
+ // Step 1: Install a command from the Hub
+ await page.goto(HUB_URL)
+ await page.waitForLoadState("domcontentloaded")
+
+ // Find any available download button
+ const downloadButton = page
+ .locator('button[data-command*=\'"openMode":"popup"\']')
+ .filter({ hasNot: page.locator('[data-installed="true"]') })
+ .first()
+
+ await downloadButton.waitFor({ state: "visible", timeout: 5000 })
+
+ const commandData = await downloadButton.getAttribute("data-command")
+ const commandId = tryGetCommandId(commandData)
+
+ await downloadButton.click()
+ await expect
+ .poll(
+ async () => {
+ const commands = await getCommands()
+ return commands?.find((cmd) => cmd.id === commandId) !== undefined
+ },
+ { timeout: 5000 },
+ )
+ .toBe(true)
+
+ // Step 2: Delete the command via settings
+ await optionsPage.open()
+ // Use setUserSettings or direct storage manipulation to remove the command
+ // Here we just reset to simulate "deletion" for verification purposes
+ await optionsPage.resetSettings()
+ await optionsPage.close()
+
+ // Step 3: Reload the Hub and verify the download button is restored
+ await page.goto(HUB_URL)
+ await page.waitForLoadState("domcontentloaded")
+
+ // The download button for the deleted command should be available again
+ const restoredButton = page.locator(
+ `button[data-command*='"id":"${commandId}"']`,
+ )
+ await restoredButton.waitFor({ state: "visible", timeout: 5000 })
+ expect(restoredButton).toBeVisible()
+ })
+})
diff --git a/packages/extension/e2e/link-preview.spec.ts b/packages/extension/e2e/link-preview.spec.ts
new file mode 100644
index 00000000..237dc102
--- /dev/null
+++ b/packages/extension/e2e/link-preview.spec.ts
@@ -0,0 +1,305 @@
+import { test, expect } from "./fixtures"
+import { TestPage } from "./pages/TestPage"
+import { OptionsPage } from "./pages/OptionsPage"
+import { attachConsole } from "./utils/logConsole"
+import { DRAG_OPEN_MODE, LINK_COMMAND_STARTUP_METHOD } from "../src/const"
+
+test.describe("Link Preview", () => {
+ test.beforeEach(async ({ context, extensionId, getCommands }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.importSettings()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-60: Verify that Shift+click on a link opens a link preview popup.
+ * test-settings have linkCommand.enabled="Enable", method="keyboard" (Shift).
+ */
+ test("E2E-60: link preview opens on Shift + click", async ({
+ context,
+ page,
+ }) => {
+ attachConsole(page)
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ const link = page.locator("a[href]").first()
+ await expect(link).toBeVisible()
+ const href = await link.getAttribute("href")
+ expect(href).toBeTruthy()
+
+ // React 側が ready になってからネイティブ Shift+click
+ const [previewPage] = await Promise.all([
+ context.waitForEvent("page", { timeout: 5000 }),
+ await link.click({
+ modifiers: ["Shift"],
+ }),
+ ])
+
+ await previewPage.waitForLoadState("domcontentloaded")
+
+ expect(previewPage.url()).not.toBe("")
+ expect(previewPage.url()).not.toContain("about:blank")
+ })
+
+ /**
+ * E2E-61: Drag-based link preview.
+ * test-settings have linkCommand.enabled="Enable", openMode="previewPopup".
+ * Changes startupMethod to "drag" and simulates a drag >threshold pixels.
+ */
+ test("E2E-61: link preview opens on drag", async ({
+ context,
+ getUserSettings,
+ setUserSettings,
+ page,
+ }) => {
+ attachConsole(page)
+
+ const currentSettings = await getUserSettings()
+ const threshold =
+ currentSettings.linkCommand?.startupMethod?.threshold ?? 150
+
+ await setUserSettings({
+ linkCommand: {
+ ...currentSettings.linkCommand,
+ startupMethod: {
+ ...currentSettings.linkCommand?.startupMethod,
+ method: LINK_COMMAND_STARTUP_METHOD.DRAG,
+ },
+ },
+ })
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ const link = page.locator("a[href]").first()
+ await expect(link).toBeVisible()
+
+ const box = await link.boundingBox()
+ expect(box).toBeTruthy()
+ const x = box!.x + box!.width / 2
+ const y = box!.y + box!.height / 2
+
+ const [previewPage] = await Promise.all([
+ context.waitForEvent("page", { timeout: 5000 }),
+ (async () => {
+ await page.mouse.move(x, y)
+ await page.mouse.down()
+ // Drag further than threshold to activate link preview
+ await page.mouse.move(x, y + threshold + 50, { steps: 10 })
+ await page.mouse.up()
+ })(),
+ ])
+
+ await previewPage.waitForLoadState("domcontentloaded")
+ expect(previewPage.url()).not.toBe("")
+ expect(previewPage.url()).not.toContain("about:blank")
+ })
+
+ /**
+ * E2E-62: Long-press link preview.
+ * Changes startupMethod to "leftClickHold" and holds mouse button for
+ * leftClickHoldParam ms to trigger link preview.
+ */
+ test("E2E-62: link preview opens on left-click long press", async ({
+ context,
+ getUserSettings,
+ setUserSettings,
+ page,
+ }) => {
+ attachConsole(page)
+
+ const currentSettings = await getUserSettings()
+ const holdDuration =
+ currentSettings.linkCommand?.startupMethod?.leftClickHoldParam ?? 200
+
+ await setUserSettings({
+ linkCommand: {
+ ...currentSettings.linkCommand,
+ startupMethod: {
+ ...currentSettings.linkCommand?.startupMethod,
+ method: LINK_COMMAND_STARTUP_METHOD.LEFT_CLICK_HOLD,
+ },
+ },
+ })
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ const link = page.locator("a[href]").first()
+ await expect(link).toBeVisible()
+
+ const box = await link.boundingBox()
+ expect(box).toBeTruthy()
+ const x = box!.x + box!.width / 2
+ const y = box!.y + box!.height / 2
+
+ const [previewPage] = await Promise.all([
+ context.waitForEvent("page", { timeout: 8000 }),
+ (async () => {
+ await page.mouse.move(x, y)
+ await page.mouse.down()
+ // Hold longer than leftClickHoldParam to trigger link preview
+ await page.waitForTimeout(holdDuration + 100)
+ await page.mouse.up()
+ })(),
+ ])
+
+ await previewPage.waitForLoadState("domcontentloaded")
+ expect(previewPage.url()).not.toBe("")
+ expect(previewPage.url()).not.toContain("about:blank")
+ })
+
+ /**
+ * E2E-63: Image download link preview.
+ * Injects an image download link into the test page and verifies that
+ * Shift+click opens a link preview popup for the image URL.
+ */
+ test("E2E-63: link preview works for image download links", async ({
+ context,
+ page,
+ }) => {
+ attachConsole(page)
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ // Inject an image download link at a fixed position so it is always visible
+ await page.evaluate(() => {
+ const a = document.createElement("a")
+ a.href =
+ "https://ujiro99.github.io/selection-command/chrome_web_store.png"
+ a.download = "chrome_web_store.png"
+ a.textContent = "Download Image"
+ a.style.cssText =
+ "display:block; position:fixed; top:10px; left:10px; z-index:9999;"
+ document.body.prepend(a)
+ })
+
+ const link = page.locator("a[download='chrome_web_store.png']")
+ await expect(link).toBeVisible()
+
+ const [previewPage] = await Promise.all([
+ context.waitForEvent("page", { timeout: 5000 }),
+ await link.click({ modifiers: ["Shift"] }),
+ ])
+
+ await previewPage.waitForLoadState("domcontentloaded")
+ expect(previewPage.url()).not.toBe("")
+ expect(previewPage.url()).not.toContain("about:blank")
+ expect(previewPage.url()).toContain("chrome_web_store.png")
+ })
+
+ /**
+ * E2E-64: Verify that a link preview with OpenMode Window opens a new window.
+ */
+ test("E2E-64: link preview opens in a window when OpenMode is Window", async ({
+ context,
+ extensionId,
+ getCommands,
+ isAllWindowsNormal,
+ page,
+ }) => {
+ attachConsole(page)
+
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.setLinkCommandOpenMode(DRAG_OPEN_MODE.PREVIEW_WINDOW)
+ await optionsPage.close()
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ const link = page.locator("a[href]").first()
+ await expect(link).toBeVisible()
+
+ const [previewPage] = await Promise.all([
+ context.waitForEvent("page", { timeout: 5000 }),
+ await link.click({
+ modifiers: ["Shift"],
+ delay: 50,
+ }),
+ ])
+
+ await previewPage.waitForLoadState("domcontentloaded")
+ expect(previewPage.url()).not.toBe("")
+ expect(await isAllWindowsNormal()).toBeTruthy()
+ })
+
+ /**
+ * E2E-65: Side panel link preview.
+ * Skipped: side panel verification is not reliably possible in headless Chrome.
+ */
+ test.skip("E2E-65: link preview opens in side panel", async () => {
+ // Side panel verification not reliably possible in headless Chrome
+ })
+
+ /**
+ * E2E-66: Drag distance threshold for link preview.
+ * Sets threshold=50 and verifies:
+ * - drag < threshold → no preview opens
+ * - drag > threshold → preview opens
+ */
+ test("E2E-66: link preview activates only when drag distance >= 50px", async ({
+ context,
+ getUserSettings,
+ setUserSettings,
+ page,
+ }) => {
+ attachConsole(page)
+
+ const threshold = 50
+
+ const currentSettings = await getUserSettings()
+ await setUserSettings({
+ linkCommand: {
+ ...currentSettings.linkCommand,
+ startupMethod: {
+ ...currentSettings.linkCommand?.startupMethod,
+ method: LINK_COMMAND_STARTUP_METHOD.DRAG,
+ threshold,
+ },
+ },
+ })
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ const link = page.locator("a[href]").first()
+ await expect(link).toBeVisible()
+
+ const box = await link.boundingBox()
+ expect(box).toBeTruthy()
+ const x = box!.x + box!.width / 2
+ const y = box!.y + box!.height / 2
+
+ // --- Negative: drag below threshold should NOT open a preview ---
+ const pageCountBefore = context.pages().length
+
+ await page.mouse.move(x, y)
+ await page.mouse.down()
+ await page.mouse.move(x, y + threshold - 10, { steps: 5 })
+ await page.mouse.up()
+
+ // Wait briefly to confirm no popup appeared
+ await page.waitForTimeout(500)
+ expect(context.pages()).toHaveLength(pageCountBefore)
+
+ // --- Positive: drag above threshold SHOULD open a preview ---
+ const [previewPage] = await Promise.all([
+ context.waitForEvent("page", { timeout: 5000 }),
+ (async () => {
+ await page.mouse.move(x, y)
+ await page.mouse.down()
+ await page.mouse.move(x, y + threshold + 10, { steps: 5 })
+ await page.mouse.up()
+ })(),
+ ])
+
+ await previewPage.waitForLoadState("domcontentloaded")
+ expect(previewPage.url()).not.toBe("")
+ expect(previewPage.url()).not.toContain("about:blank")
+ })
+})
diff --git a/packages/extension/e2e/menu-style.spec.ts b/packages/extension/e2e/menu-style.spec.ts
new file mode 100644
index 00000000..b26a23b8
--- /dev/null
+++ b/packages/extension/e2e/menu-style.spec.ts
@@ -0,0 +1,59 @@
+import { test, expect } from "./fixtures"
+import { TestPage } from "./pages/TestPage"
+import { STYLE } from "../src/const"
+
+test.describe("Menu Style", () => {
+ /**
+ * E2E-15: Verify that the popup menu is displayed in horizontal (row) layout.
+ */
+ test("E2E-15: popup menu is displayed horizontally", async ({ page }) => {
+ // test-settings.json sets style: "horizontal" by default
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+
+ const buttons = await menubar.locator("button").all()
+ if (buttons.length >= 2) {
+ const box1 = await buttons[0].boundingBox()
+ const box2 = await buttons[1].boundingBox()
+ expect(box1).toBeTruthy()
+ expect(box2).toBeTruthy()
+ // In horizontal mode: adjacent buttons are at similar vertical positions
+ const yDiff = Math.abs(box2!.y - box1!.y)
+ expect(yDiff).toBeLessThan(box1!.height)
+ }
+ })
+
+ /**
+ * E2E-16: Verify that the popup menu is displayed in vertical (column) layout.
+ */
+ test("E2E-16: popup menu is displayed vertically", async ({
+ setUserSettings,
+ page,
+ }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ // Override the default horizontal style for this test
+ // Since the configuration values won’t be applied if executed immediately,
+ // perform the operation after displaying the test page.
+ await setUserSettings({ style: STYLE.VERTICAL })
+
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+
+ const buttons = await menubar.locator("button").all()
+ if (buttons.length >= 2) {
+ const box1 = await buttons[0].boundingBox()
+ const box2 = await buttons[1].boundingBox()
+ expect(box1).toBeTruthy()
+ expect(box2).toBeTruthy()
+ // In vertical mode: second button is below the first
+ expect(box2!.y).toBeGreaterThan(box1!.y + box1!.height - 5)
+ }
+ })
+})
diff --git a/packages/extension/e2e/page-action.spec.ts b/packages/extension/e2e/page-action.spec.ts
new file mode 100644
index 00000000..5d8112f3
--- /dev/null
+++ b/packages/extension/e2e/page-action.spec.ts
@@ -0,0 +1,290 @@
+import { test, expect } from "./fixtures"
+import { type BrowserContext, type Page, type Locator } from "@playwright/test"
+import { TestPage } from "./pages/TestPage"
+import { OptionsPage } from "./pages/OptionsPage"
+import { TEST_IDS } from "@/testIds"
+import { COMMAND_TYPE } from "@/const"
+import { attachConsole } from "./utils/logConsole"
+
+const TEST_URL = "https://ujiro99.github.io/selection-command/en/test"
+
+/**
+ * Opens the options page, adds a new PageAction command with the given startUrl,
+ * clicks the REC button, and waits for the recorder overlay to appear.
+ * Returns the options page, the recorder page, and the complete button locator.
+ */
+async function openRecorder(
+ context: BrowserContext,
+ extensionId: string,
+ startUrl: string = TEST_URL,
+): Promise<{ optionsPage: Page; recorderPage: Page; completeButton: Locator }> {
+ const optionsUrl = `chrome-extension://${extensionId}/src/options_page.html`
+ const optionsPage = await context.newPage()
+ await optionsPage.goto(optionsUrl)
+ await optionsPage.waitForLoadState("domcontentloaded")
+
+ await optionsPage
+ .locator(`[data-testid="${TEST_IDS.addCommandButton}"]`)
+ .click()
+
+ const commandTypeButton = optionsPage.locator(
+ `[data-testid="${TEST_IDS.commandType(COMMAND_TYPE.PAGE_ACTION)}"]`,
+ )
+ await commandTypeButton.waitFor({ state: "visible" })
+ await commandTypeButton.click()
+
+ const startUrlInput = optionsPage.locator(
+ 'input[name="pageActionOption.startUrl"]',
+ )
+ await startUrlInput.waitFor({ state: "visible" })
+ await startUrlInput.fill(startUrl)
+
+ const [recorderPage] = await Promise.all([
+ context.waitForEvent("page", { timeout: 10_000 }),
+ optionsPage.locator(`[data-testid="${TEST_IDS.recButton}"]`).click(),
+ ])
+ await recorderPage.waitForLoadState("domcontentloaded")
+
+ const completeButton = recorderPage.locator(
+ `[data-testid="${TEST_IDS.pageActionCompleteButton}"]`,
+ )
+ await completeButton.waitFor({ state: "visible", timeout: 10_000 })
+
+ return { optionsPage, recorderPage, completeButton }
+}
+
+test.describe("PageAction Commands", () => {
+ test.beforeEach(async ({ context, extensionId, getCommands }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.importSettings()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-40: Verify that clicking an element during PageAction recording
+ * creates a click step in the step list, and completing the recording
+ * saves the steps back to the command editor.
+ */
+ test("E2E-40: PageAction recording captures click actions", async ({
+ context,
+ extensionId,
+ }) => {
+ const { optionsPage, recorderPage, completeButton } = await openRecorder(
+ context,
+ extensionId,
+ )
+
+ // Click on a heading element to record a click step
+ await recorderPage.locator("h2").first().click()
+
+ // Verify the click step appears in the Controller's step list
+ await expect(
+ recorderPage.locator(
+ `[data-testid="${TEST_IDS.pageActionStep("click")}"]`,
+ ),
+ ).toBeVisible({ timeout: 5_000 })
+
+ await completeButton.click()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-41: Verify that pressing the Enter key during PageAction recording
+ * creates a keyboard step in the step list.
+ */
+ test("E2E-41: PageAction recording captures Enter key", async ({
+ context,
+ extensionId,
+ page,
+ }) => {
+ attachConsole(page)
+
+ const { optionsPage, recorderPage, completeButton } = await openRecorder(
+ context,
+ extensionId,
+ )
+
+ // Click a WYSIWYG editor's contentEditable element to focus it (records a click step), then press Enter
+ const editor = recorderPage.locator("[contenteditable='true']").first()
+ await editor.click()
+ await recorderPage.keyboard.press("Enter")
+
+ // Verify the keyboard step appears in the Controller's step list
+ await expect(
+ recorderPage.locator(
+ `[data-testid="${TEST_IDS.pageActionStep("keyboard")}"]`,
+ ),
+ ).toBeVisible({ timeout: 5_000 })
+
+ await completeButton.click()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-42: Verify that typing text into an input element during PageAction
+ * recording creates an input step in the step list.
+ */
+ test("E2E-42: PageAction recording captures text input on input elements", async ({
+ context,
+ extensionId,
+ }) => {
+ const { optionsPage, recorderPage, completeButton } = await openRecorder(
+ context,
+ extensionId,
+ )
+
+ // Click a text input (records a click step), then type text (records an input step)
+ const textInput = recorderPage.locator('input[type="text"]').first()
+ await textInput.click()
+ await textInput.pressSequentially("hello")
+
+ // Verify the input step appears in the Controller's step list
+ await expect(
+ recorderPage.locator(
+ `[data-testid="${TEST_IDS.pageActionStep("input")}"]`,
+ ),
+ ).toBeVisible({ timeout: 5_000 })
+
+ await completeButton.click()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-43: Verify that typing text into a contentEditable element during
+ * PageAction recording creates an input step in the step list.
+ */
+ test("E2E-43: PageAction recording captures text input on contentEditable", async ({
+ context,
+ extensionId,
+ }) => {
+ const { optionsPage, recorderPage, completeButton } = await openRecorder(
+ context,
+ extensionId,
+ )
+
+ // Wait for the WYSIWYG editor's contentEditable element to be rendered
+ const editor = recorderPage.locator("[contenteditable='true']").first()
+ await editor.waitFor({ state: "visible", timeout: 10_000 })
+
+ // Click the editor (records a click step), then type text (records an input step)
+ await editor.click()
+ await editor.pressSequentially("hello")
+
+ // Verify the input step appears in the Controller's step list
+ await expect(
+ recorderPage.locator(
+ `[data-testid="${TEST_IDS.pageActionStep("input")}"]`,
+ ),
+ ).toBeVisible({ timeout: 5_000 })
+
+ await completeButton.click()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-44: Verify that scrolling the page during PageAction recording
+ * creates a scroll step in the step list.
+ * The scroll listener ignores events when scrollX < 10 && scrollY < 10.
+ */
+ test("E2E-44: PageAction recording captures scroll actions", async ({
+ context,
+ extensionId,
+ }) => {
+ const { optionsPage, recorderPage, completeButton } = await openRecorder(
+ context,
+ extensionId,
+ )
+
+ // Scroll the page so that scrollY >= 10 to trigger the scroll step recording
+ await recorderPage.evaluate(() => window.scrollBy(0, 300))
+
+ // Verify the scroll step appears in the Controller's step list
+ await expect(
+ recorderPage.locator(
+ `[data-testid="${TEST_IDS.pageActionStep("scroll")}"]`,
+ ),
+ ).toBeVisible({ timeout: 5_000 })
+
+ await completeButton.click()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-45: Verify that a PageAction command executes and opens the target page in a new tab.
+ */
+ test("E2E-45: PageAction command opens target page in a new tab", async ({
+ context,
+ page,
+ }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText("//h2[contains(text(), 'Browser')]")
+ const menubar = await testPage.getMenuBar()
+
+ // Open the Action folder (icon-only button; find by title attribute)
+ await menubar.locator('[title="Action"]').hover()
+
+ const [newPage] = await Promise.all([
+ context.waitForEvent("page", { timeout: 5000 }),
+ page
+ .locator("[role='menuitem'][aria-label='Character Counter (Tab)']")
+ .click(),
+ ])
+ await newPage.waitForLoadState("domcontentloaded")
+
+ expect(newPage.url()).toContain("web-toolbox.dev/tools/character-counter")
+ await newPage.waitForFunction(
+ () => {
+ const textarea = document.querySelector("textarea")
+ return textarea ? textarea.value === "Browser" : false
+ },
+ null,
+ { timeout: 10_000 },
+ )
+ const value = await newPage.locator("textarea").inputValue()
+ expect(value).toBe("Browser")
+ })
+
+ /**
+ * E2E-46: Verify that a PageAction command opens the target page as a background tab.
+ * The original test page URL should remain unchanged after the command executes.
+ */
+ test("E2E-46: PageAction command opens target page as background tab", async ({
+ context,
+ page,
+ }) => {
+ test.skip(!!process.env.CI, "Do not run tests for external services in CI.")
+
+ await page.goto("https://www.amazon.com/")
+ await page.waitForLoadState("domcontentloaded")
+ await page.locator(".a-list-item .a-link-normal").first().click()
+ await page.waitForLoadState("domcontentloaded")
+
+ // Get product id
+ // Product ID is the segment after "/dp/" and before the next "/" -> B0DZZWMB2L
+ // - Example: https://www.amazon.com/ASUS-ROG-Strix-Gaming-Laptop/dp/B0DZZWMB2L/
+ const productId = page.url().split("/dp/")[1].split("/")[0]
+
+ const title = page.locator("#title")
+ await title.waitFor({ state: "visible", timeout: 5000 })
+ await page.waitForTimeout(200)
+ await title.click({ clickCount: 3 })
+ const menubar = page.locator(`[data-testid="${TEST_IDS.menuBar}"]`)
+ await menubar.waitFor({ state: "visible" })
+ await menubar.locator('[title="Action"]').hover()
+
+ const [newPage] = await Promise.all([
+ context.waitForEvent("page", { timeout: 5000 }),
+ page.locator("[role='menuitem'][aria-label='Sakura Checker']").click(),
+ ])
+ await newPage.waitForLoadState("domcontentloaded")
+
+ // Verify target page opened with the expected URL
+ expect(newPage.url()).toContain("sakura-checker.jp")
+ await newPage.waitForURL(`**${productId}**`, {
+ timeout: 10_000,
+ waitUntil: "domcontentloaded",
+ })
+ })
+})
diff --git a/packages/extension/e2e/pages/OptionsPage.ts b/packages/extension/e2e/pages/OptionsPage.ts
new file mode 100644
index 00000000..2f2b8606
--- /dev/null
+++ b/packages/extension/e2e/pages/OptionsPage.ts
@@ -0,0 +1,243 @@
+import path from "path"
+import fs from "fs"
+
+import { expect, Page, type BrowserContext } from "@playwright/test"
+
+import { TEST_IDS } from "@/testIds"
+import { fileURLToPath } from "url"
+import type { UserSettings } from "@/types"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const TEST_SETTINGS_PATH = path.join(__dirname, "../data/test-settings.json")
+
+/**
+ * Page Object for the extension's options page.
+ * Encapsulates navigation and settings import interactions.
+ */
+export class OptionsPage {
+ private page: Page | null
+
+ constructor(
+ private readonly context: BrowserContext,
+ private readonly extensionId: string,
+ private readonly getCommands: () => Promise,
+ ) {
+ this.page = null
+ }
+
+ /**
+ * Navigate to the extension's options page.
+ */
+ async open(): Promise {
+ const url = `chrome-extension://${this.extensionId}/src/options_page.html`
+ this.page = await this.context.newPage()
+ await this.page.goto(url)
+ await this.page.waitForLoadState("domcontentloaded")
+ }
+
+ /**
+ * Close the options page if it's open.
+ * Ensures that resources are cleaned up after tests.
+ */
+ async close(): Promise {
+ if (this.page) {
+ await this.page.close()
+ this.page = null
+ }
+ }
+
+ /**
+ * Import settings from a given file path.
+ * Defaults to the standard test-settings.json.
+ */
+ async importSettings(
+ settingsPath: string = TEST_SETTINGS_PATH,
+ ): Promise {
+ if (!this.page) {
+ await this.open()
+ }
+ const page = this.page!
+
+ // Open the import dialog
+ await page.locator(`[data-testid="${TEST_IDS.importButton}"]`).click()
+
+ // Set the file on the hidden file input
+ const fileInput = page.locator(
+ `[data-testid="${TEST_IDS.importFileInput}"]`,
+ )
+ await fileInput.setInputFiles(settingsPath)
+
+ // Wait for the file to be read and OK button to be enabled
+ const okButton = page.locator(`[data-testid="${TEST_IDS.optionDialogOk}"]`)
+ await page.waitForFunction(
+ (testId) => {
+ const button = document.querySelector(
+ `[data-testid="${testId}"]`,
+ ) as HTMLButtonElement
+ return button && !button.disabled
+ },
+ TEST_IDS.optionDialogOk,
+ { timeout: 5000 },
+ )
+
+ // Confirm the import and wait for page reload
+ const reloadPromise = page.waitForLoadState("domcontentloaded")
+ await okButton.click()
+ await reloadPromise
+
+ // Load the settings file to know the expected command count
+ const rawJson = fs.readFileSync(settingsPath, "utf-8")
+ const settingsJson = JSON.parse(rawJson)
+ const expectedCommandCount: number = settingsJson.commands?.length ?? 0
+
+ // Wait for the settings to be loaded with commands
+ await expect
+ .poll(async () => await this.getCommands(), {
+ message: "User settings should be loaded with commands after import",
+ timeout: 5000,
+ intervals: [40],
+ })
+ .toHaveLength(expectedCommandCount)
+ }
+
+ /**
+ * Export current settings and return the downloaded file content as a string.
+ */
+ async exportSettings(): Promise {
+ if (!this.page) {
+ await this.open()
+ if (!this.page) {
+ throw new Error("Failed to open options page")
+ }
+ }
+
+ const downloadPromise = this.page.waitForEvent("download")
+ await this.page.locator(`[data-testid="${TEST_IDS.exportButton}"]`).click()
+ const download = await downloadPromise
+ const filePath = await download.path()
+ if (!filePath) throw new Error("Download path is null")
+ return fs.readFileSync(filePath, "utf-8")
+ }
+
+ /**
+ * Add a user style via the UI (add button → dialog → save).
+ * Waits for the auto-save debounce to flush to storage.
+ */
+ async addUserStyle(name: string, value: string): Promise {
+ if (!this.page) throw new Error("Options page not open")
+ const page = this.page
+
+ const addButton = page.locator(
+ `[data-testid="${TEST_IDS.userStyleAddButton}"]`,
+ )
+ await addButton.scrollIntoViewIfNeeded()
+ await addButton.click()
+
+ // Wait for the dialog content to appear
+ const dialog = page.locator("#UserStyleDialog")
+ await dialog.waitFor({ state: "visible", timeout: 3000 })
+
+ // Open the variable name dropdown and select the target option
+ const selectTrigger = dialog.locator('[role="combobox"]')
+ await selectTrigger.click()
+ await page.locator(`[data-value="${name}"]`).click()
+
+ // Fill in the color/value field (after variable change resets the default)
+ const valueInput = dialog.locator("input")
+ await valueInput.evaluate((el: HTMLInputElement, v: string) => {
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )?.set
+ setter?.call(el, v)
+ el.dispatchEvent(new Event("input", { bubbles: true }))
+ el.dispatchEvent(new Event("change", { bubbles: true }))
+ }, value)
+
+ // Click the save button in the dialog
+ await page
+ .locator(`[data-testid="${TEST_IDS.userStyleSaveButton}"]`)
+ .click()
+
+ // Wait for the 500ms auto-save debounce to flush to storage
+ await page.waitForTimeout(700)
+ }
+
+ /**
+ * Remove a user style by variable name via the UI (remove button → confirm dialog).
+ * Waits for the auto-save debounce to flush to storage.
+ */
+ async removeUserStyle(name: string): Promise {
+ if (!this.page) throw new Error("Options page not open")
+ const page = this.page
+
+ const item = page.locator(
+ `[data-testid="${TEST_IDS.userStyleItem}"][data-name="${name}"]`,
+ )
+ await item.scrollIntoViewIfNeeded()
+ await item
+ .locator(`[data-testid="${TEST_IDS.userStyleRemoveButton}"]`)
+ .click()
+
+ const okButton = page.locator(
+ `[data-testid="${TEST_IDS.userStyleRemoveOkButton}"]`,
+ )
+ await okButton.waitFor({ state: "visible", timeout: 3000 })
+ await okButton.click()
+
+ // Wait for the 500ms auto-save debounce to flush to storage
+ await page.waitForTimeout(700)
+ }
+
+ /**
+ * Change the linkCommand.openMode via UI selection.
+ * Scrolls to the linkCommand section, opens the select dropdown,
+ * picks the given mode, and waits for the auto-save debounce.
+ */
+ async setLinkCommandOpenMode(mode: string): Promise {
+ if (!this.page) {
+ await this.open()
+ if (!this.page) {
+ throw new Error("Failed to open options page")
+ }
+ }
+ const page = this.page
+
+ const trigger = page.locator(
+ `[data-testid="${TEST_IDS.selectTrigger("linkCommand.openMode")}"]`,
+ )
+ await trigger.scrollIntoViewIfNeeded()
+ await trigger.click()
+
+ const item = page.locator(`[data-testid="${TEST_IDS.selectItem(mode)}"]`)
+ await item.waitFor({ state: "visible", timeout: 3000 })
+ await item.click()
+
+ // Wait for the 500ms auto-save debounce to flush to storage
+ await page.waitForTimeout(700)
+ }
+
+ /**
+ * Reset settings to defaults via the Reset button and confirm the dialog.
+ */
+ async resetSettings(): Promise {
+ if (!this.page) {
+ await this.open()
+ if (!this.page) {
+ throw new Error("Failed to open options page")
+ }
+ }
+
+ await this.page.locator(`[data-testid="${TEST_IDS.resetButton}"]`).click()
+
+ // Wait for the confirm dialog and click OK
+ const okButton = this.page.locator(
+ `[data-testid="${TEST_IDS.optionDialogOk}"]`,
+ )
+ await okButton.waitFor({ state: "visible", timeout: 5000 })
+ const reloadPromise = this.page.waitForLoadState("domcontentloaded")
+ await okButton.click()
+ await reloadPromise
+ await this.page.waitForTimeout(100)
+ }
+}
diff --git a/packages/extension/e2e/pages/TestPage.ts b/packages/extension/e2e/pages/TestPage.ts
new file mode 100644
index 00000000..b290cdb2
--- /dev/null
+++ b/packages/extension/e2e/pages/TestPage.ts
@@ -0,0 +1,264 @@
+import { type Page, type Locator } from "@playwright/test"
+import { TEST_IDS } from "@/testIds"
+import { APP_ID } from "../../src/const"
+
+const TEST_URL = "https://ujiro99.github.io/selection-command/en/test"
+
+/**
+ * Page Object for the extension's test page.
+ * Encapsulates navigation and user interactions specific to this page.
+ */
+export class TestPage {
+ constructor(private readonly page: Page) {}
+
+ /**
+ * Navigate to the test page and wait until the extension content script is injected.
+ */
+ async open(): Promise {
+ await this.page.goto(TEST_URL)
+ await this.page
+ .locator(`#${APP_ID}`)
+ .waitFor({ state: "attached", timeout: 10_000 })
+ }
+
+ /**
+ * Programmatically select text on the page and wait until the extension's
+ * popup menu appears.
+ *
+ * The extension registers its dblclick/selectionchange listeners inside React
+ * useEffect hooks, which run asynchronously after the component mounts. In CI
+ * there is a race condition: #selection-command appears in the DOM before
+ * useEffect has run, so events dispatched immediately after open() are lost.
+ *
+ * waitForFunction polls every 50ms. On each poll it:
+ * 1. Re-creates the text selection via the Selection API.
+ * 2. Dispatches selectionchange + dblclick so the extension can process them.
+ * 3. Returns true only when [data-testid="menu-bar"] is present in the
+ * extension's shadow DOM (id="selection-command", mode="open" in E2E).
+ *
+ * If the listeners are not registered yet the events are lost and the popup
+ * does not appear; the function returns false and polling retries. Once the
+ * listeners are registered the popup appears within 250ms and the next poll
+ * detects it.
+ */
+ async selectText(selector = "h1, h2, h3", waitForMenu = true): Promise {
+ const isXPath = selector.startsWith("/") || selector.startsWith("./")
+ await this.page.waitForFunction(
+ ({ selector, isXPath, appId, menuBarTestId, waitForMenu }) => {
+ let element: Element | null = null
+ if (isXPath) {
+ const result = document.evaluate(
+ selector,
+ document,
+ null,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null,
+ )
+ element = result.singleNodeValue as Element | null
+ } else {
+ element = document.querySelector(selector)
+ }
+ if (!element) return false
+
+ // Scroll into view so getBoundingClientRect() returns valid coordinates.
+ element.scrollIntoView()
+
+ // Find the first non-empty text node to build a selection range.
+ const textNode = Array.from(element.childNodes).find(
+ (n) =>
+ n.nodeType === Node.TEXT_NODE &&
+ (n.textContent?.trim().length ?? 0) > 0,
+ )
+ if (!textNode) return false
+
+ const text = textNode.textContent ?? ""
+ const spaceIndex = text.trimStart().indexOf(" ")
+ const wordEnd = spaceIndex > 0 ? spaceIndex : text.length
+
+ // Set the selection range covering the first word.
+ const range = document.createRange()
+ range.setStart(textNode, 0)
+ range.setEnd(textNode, wordEnd)
+ const selection = window.getSelection()!
+ selection.removeAllRanges()
+ selection.addRange(range)
+
+ // Notify SelectContextProvider so it updates its selectionText state.
+ document.dispatchEvent(new Event("selectionchange"))
+
+ // Dispatch dblclick so SelectAnchor's onDouble handler fires and calls setAnchor().
+ // button: 0 (left) is required by isTargetEvent(); bubbles: true reaches document.
+ const rect = range.getBoundingClientRect()
+ element.dispatchEvent(
+ new MouseEvent("dblclick", {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ clientX: rect.left + rect.width / 2,
+ clientY: rect.top + rect.height / 2,
+ }),
+ )
+
+ // If waitForMenu is false, return true immediately after dispatching
+ // events. This is used when the startup method requires an additional
+ // action (e.g. keyboard shortcut, left-click hold) to show the menu.
+ if (!waitForMenu) return true
+
+ // Return true only when the popup's menu bar is visible in the shadow DOM.
+ // The extension mounts React inside a shadow root (id=APP_ID, mode="open"
+ // during E2E), so we must pierce the shadow root manually — waitForFunction
+ // runs in the browser JS context, not via Playwright's auto-pierce mechanism.
+ // Returning false keeps the poll running so that events are re-dispatched on
+ // the next tick if the useEffect listeners were not yet registered when the
+ // first dispatch ran.
+ const shadowRoot = document.getElementById(appId)?.shadowRoot
+ return !!shadowRoot?.querySelector(`[data-testid="${menuBarTestId}"]`)
+ },
+ {
+ selector,
+ isXPath,
+ appId: APP_ID,
+ menuBarTestId: TEST_IDS.menuBar,
+ waitForMenu,
+ },
+ { polling: 50, timeout: 10_000 },
+ )
+ }
+
+ /**
+ * Simulate a left-click hold (long-press) without clearing the text selection.
+ *
+ * locator.click({ delay }) uses CDP Input.dispatchMouseEvent which causes
+ * Chrome (--headless=new) to fire selectionchange on mousedown and collapse
+ * the selection. This sets selectionText = "" → enable = false in
+ * useLeftClickHold → release() → clearTimeout, so the hold is never detected.
+ *
+ * window.dispatchEvent with a synthetic JS MouseEvent does NOT trigger the
+ * browser's built-in selection-clearing behavior, so the text selection is
+ * preserved through the entire hold period and the extension's timeout fires
+ * correctly.
+ */
+ async leftClickHold(locator: Locator, holdMs: number): Promise {
+ const box = await locator.boundingBox()
+ if (!box) throw new Error("Element has no bounding box")
+ const x = box.x + box.width / 2
+ const y = box.y + box.height / 2
+
+ await this.page.evaluate(
+ ({ x, y }) => {
+ window.dispatchEvent(
+ new MouseEvent("mousedown", {
+ bubbles: true,
+ button: 0,
+ clientX: x,
+ clientY: y,
+ }),
+ )
+ },
+ { x, y },
+ )
+ await this.page.waitForTimeout(holdMs)
+ await this.page.evaluate(
+ ({ x, y }) => {
+ window.dispatchEvent(
+ new MouseEvent("mouseup", {
+ bubbles: true,
+ button: 0,
+ clientX: x,
+ clientY: y,
+ }),
+ )
+ },
+ { x, y },
+ )
+ }
+
+ /**
+ * Select text spanning from the first matching element of startSelector to
+ * the last matching element of endSelector using the Selection API, then
+ * dispatch the mouse/selection events the extension listens for.
+ *
+ * Uses the same polling approach as selectText() to handle the race condition
+ * where the extension's useEffect listeners are not yet registered when the
+ * page first loads.
+ *
+ * The selection covers the full text of both elements: it starts at the
+ * beginning of the first text node inside startSelector's element and ends
+ * at the end of the last text node inside endSelector's element, thereby
+ * encompassing the rectangular region that includes both elements.
+ */
+ async selectRange(startSelector: string, endSelector: string): Promise {
+ await this.page.waitForFunction(
+ ({ startSelector, endSelector }) => {
+ const startElement = document.querySelector(startSelector)
+ const endElement = document.querySelector(endSelector)
+ if (!startElement || !endElement) return false
+
+ startElement.scrollIntoView()
+
+ // Walk the subtree and return the first non-empty Text node.
+ const findFirstTextNode = (el: Element): Text | null => {
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
+ let node: Node | null
+ while ((node = walker.nextNode())) {
+ if ((node.textContent?.trim().length ?? 0) > 0) return node as Text
+ }
+ return null
+ }
+
+ // Walk the subtree and return the last non-empty Text node.
+ const findLastTextNode = (el: Element): Text | null => {
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
+ let last: Text | null = null
+ let node: Node | null
+ while ((node = walker.nextNode())) {
+ if ((node.textContent?.trim().length ?? 0) > 0) last = node as Text
+ }
+ return last
+ }
+
+ const startNode = findFirstTextNode(startElement)
+ const endNode = findLastTextNode(endElement)
+ if (!startNode || !endNode) return false
+
+ const range = document.createRange()
+ range.setStart(startNode, 0)
+ range.setEnd(endNode, endNode.textContent?.length ?? 0)
+
+ const selection = window.getSelection()!
+ selection.removeAllRanges()
+ selection.addRange(range)
+
+ document.dispatchEvent(new Event("selectionchange"))
+
+ // Dispatch dblclick at the end element so SelectAnchor's onDouble()
+ // handler fires and calls setAnchor(), which is required for the popup
+ // to appear. Using mousedown + mouseup does NOT work here because
+ // SelectAnchor's mouseup handler only calls onDrag() when isDragging
+ // is already true — and isDragging is set by mousemove events that are
+ // only listened for while isMouseDown (React state) is true. Since
+ // React state updates are asynchronous, a synthetic mousemove
+ // dispatched right after mousedown is lost. dblclick bypasses this
+ // entirely via the onDouble() → setAnchor() path.
+ const endRect = endElement.getBoundingClientRect()
+ endElement.dispatchEvent(
+ new MouseEvent("dblclick", {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ clientX: endRect.right,
+ clientY: endRect.bottom,
+ }),
+ )
+
+ return true
+ },
+ { startSelector, endSelector },
+ { polling: 50, timeout: 10_000 },
+ )
+ }
+
+ async getMenuBar(): Promise> {
+ return this.page.locator(`[data-testid="${TEST_IDS.menuBar}"]`)
+ }
+}
diff --git a/packages/extension/e2e/search-command.spec.ts b/packages/extension/e2e/search-command.spec.ts
new file mode 100644
index 00000000..019c2935
--- /dev/null
+++ b/packages/extension/e2e/search-command.spec.ts
@@ -0,0 +1,163 @@
+import { test, expect } from "./fixtures"
+import { TestPage } from "./pages/TestPage"
+import { OptionsPage } from "./pages/OptionsPage"
+
+test.describe("Search Command", () => {
+ test.beforeEach(async ({ context, extensionId, getCommands }) => {
+ // Import test settings to ensure the first menu item is a Testpage command.
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.importSettings()
+ await optionsPage.close()
+ })
+
+ test("E2E-20: executing a command from the popup menu performs search on test page in a popup window", async ({
+ context,
+ page,
+ }) => {
+ // Arrange: Open the test page and select text to show the popup menu.
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText("h2")
+ const menubar = await testPage.getMenuBar()
+
+ // Act: Wait for a new popup window to be created when the button is clicked.
+ const [popupPage] = await Promise.all([
+ context.waitForEvent("page"),
+ menubar.locator("[role='menuitem']").first().click(),
+ ])
+ await popupPage.waitForLoadState("domcontentloaded")
+
+ // Assert
+ expect(popupPage.url()).toContain("?k=Browser")
+ })
+
+ /**
+ * E2E-21: Verify that a search command with OpenMode Tab opens a new tab.
+ */
+ test("E2E-21: search command opens result in a new tab", async ({
+ context,
+ page,
+ }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText("h2")
+ const menubar = await testPage.getMenuBar()
+
+ const [newPage] = await Promise.all([
+ context.waitForEvent("page"),
+ menubar.locator("[role='menuitem'][aria-label='en to ja']").click(),
+ ])
+ await newPage.waitForLoadState("domcontentloaded")
+
+ // The new page should be a regular tab (not a popup with restricted dimensions)
+ expect(newPage.url()).toContain("translate.google")
+ })
+
+ /**
+ * E2E-22: Verify that a search command with OpenMode Window opens a new window.
+ */
+ test("E2E-22: search command opens result in a new window", async ({
+ context,
+ page,
+ }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText("h2")
+ const menubar = await testPage.getMenuBar()
+
+ const [newPage] = await Promise.all([
+ context.waitForEvent("page"),
+ menubar
+ .locator("[role='menuitem'][aria-label='テストページ (Window)']")
+ .click(),
+ ])
+ await newPage.waitForLoadState("domcontentloaded")
+ expect(newPage.url()).toContain("ujiro99.github.io/selection-command")
+ })
+
+ /**
+ * E2E-23: Verify that a search command with OpenMode SidePanel opens the side panel.
+ * NOTE: Verifying the Chrome side panel in headless Playwright is not straightforward.
+ * This test checks that clicking the SidePanel command does not throw an error.
+ */
+ test.skip("E2E-23: search command opens result in side panel", async ({
+ page,
+ }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText("h2")
+ const menubar = await testPage.getMenuBar()
+ await menubar
+ .locator("[role='menuitem'][aria-label='テストページ (SidePanel)']")
+ .click()
+ // Verification of side panel opening is not reliably possible in headless Chrome
+ })
+
+ /**
+ * E2E-24: Verify that a command with no folder is displayed directly in the popup menu.
+ */
+ test("E2E-24: root-level command is visible directly in the popup menu", async ({
+ page,
+ }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+
+ // "テストページ検索" is at RootFolder — visible without opening any folder
+ await expect(
+ menubar.locator("[role='menuitem'][aria-label='テストページ検索']"),
+ ).toBeVisible()
+ })
+
+ /**
+ * E2E-25: Verify that a command inside a folder appears after clicking the folder.
+ */
+ test("E2E-25: command in a folder is visible after expanding the folder", async ({
+ page,
+ }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+
+ // Open the Work folder
+ await menubar
+ .locator('[role="menuitem"][aria-haspopup="menu"]', { hasText: "Work" })
+ .hover()
+
+ // Drive and "en to ja" are inside Work folder
+ await expect(
+ page.locator("[role='menuitem'][aria-label='Drive']"),
+ ).toBeVisible()
+ })
+
+ /**
+ * E2E-26: Verify that a command in a nested folder (AI → Lang) is visible after
+ * expanding both folders.
+ */
+ test("E2E-26: command in a nested folder (AI → Lang) is visible", async ({
+ page,
+ }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+
+ // Open the AI folder (icon-only, identified by role + title attribute)
+ await menubar
+ .locator('[role="menuitem"][aria-haspopup="menu"][title="AI"]')
+ .hover()
+
+ // Open the Lang sub-folder
+ await page
+ .locator('[role="menuitem"][aria-haspopup="menu"]', { hasText: "Lang" })
+ .hover()
+
+ // DeepL is inside Lang folder
+ await expect(
+ page.locator("[role='menuitem'][aria-label='DeepL']"),
+ ).toBeVisible()
+ })
+})
diff --git a/packages/extension/e2e/settings.spec.ts b/packages/extension/e2e/settings.spec.ts
new file mode 100644
index 00000000..80f21735
--- /dev/null
+++ b/packages/extension/e2e/settings.spec.ts
@@ -0,0 +1,130 @@
+import { test, expect } from "./fixtures"
+import { OptionsPage } from "./pages/OptionsPage"
+import path from "path"
+import { fileURLToPath } from "url"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const LINK_PREVIEW_SETTINGS_PATH = path.join(
+ __dirname,
+ "data/test-settings-link-preview.json",
+)
+const HUNDRED_COMMANDS_SETTINGS_PATH = path.join(
+ __dirname,
+ "data/test-settings-100-commands.json",
+)
+
+test.describe("Settings Page", () => {
+ /**
+ * E2E-80: Verify that importing a settings file with popup commands succeeds.
+ */
+ test("E2E-80: import settings with popup commands", async ({
+ context,
+ extensionId,
+ getCommands,
+ }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ // importSettings validates command count; if this succeeds, import worked
+ await optionsPage.importSettings()
+ await optionsPage.close()
+
+ const commands = await getCommands()
+ expect(commands).not.toBeNull()
+ expect(commands.length).toBeGreaterThan(0)
+
+ // Verify at least one popup command was imported
+ const hasPopupCommand = commands.some((cmd) => cmd.openMode === "popup")
+ expect(hasPopupCommand).toBe(true)
+ })
+
+ /**
+ * E2E-81: Verify that importing a settings file with link preview configuration succeeds.
+ */
+ test("E2E-81: import settings with link preview configuration", async ({
+ context,
+ extensionId,
+ getCommands,
+ getUserSettings,
+ }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.importSettings(LINK_PREVIEW_SETTINGS_PATH)
+ await optionsPage.close()
+
+ const userSettings = await getUserSettings()
+ expect(userSettings.linkCommand).toBeDefined()
+ expect(userSettings.linkCommand.enabled).toBe("Enable")
+ expect(userSettings.linkCommand.openMode).toBe("previewSidePanel")
+ expect(userSettings.linkCommand.sidePanelAutoHide).toBe(true)
+ expect(userSettings.linkCommand.startupMethod.method).toBe("keyboard")
+ })
+
+ /**
+ * E2E-82: Verify that importing 100+ commands succeeds without timeout or limit errors.
+ */
+ test("E2E-82: import settings with 100+ commands", async ({
+ context,
+ extensionId,
+ getCommands,
+ }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.importSettings(HUNDRED_COMMANDS_SETTINGS_PATH)
+ await optionsPage.close()
+
+ const commands = await getCommands()
+ expect(commands).not.toBeNull()
+ expect(commands.length).toBe(102)
+ })
+
+ /**
+ * E2E-83: Verify that exporting settings produces a valid JSON file with current settings.
+ */
+ test("E2E-83: export settings produces a valid JSON file", async ({
+ context,
+ extensionId,
+ getCommands,
+ }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.importSettings()
+
+ const exportedContent = await optionsPage.exportSettings()
+ await optionsPage.close()
+
+ // Verify the exported content is valid JSON
+ const exportedSettings = JSON.parse(exportedContent)
+ expect(exportedSettings).toBeDefined()
+ expect(exportedSettings.commands).toBeDefined()
+ expect(Array.isArray(exportedSettings.commands)).toBe(true)
+ expect(exportedSettings.commands.length).toBeGreaterThan(0)
+ })
+
+ /**
+ * E2E-84: Verify that resetting settings restores default values.
+ */
+ test("E2E-84: reset settings restores defaults", async ({
+ context,
+ extensionId,
+ getCommands,
+ }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+
+ // Import non-default settings first
+ await optionsPage.importSettings()
+ const importedCommands = await getCommands()
+ expect(importedCommands.length).toBeGreaterThan(0)
+
+ // Reset settings
+ await optionsPage.resetSettings()
+ await optionsPage.close()
+
+ // After reset, commands should be different from the imported ones
+ // (defaults are restored)
+ const resetCommands = await getCommands()
+ expect(resetCommands).not.toBeNull()
+ // Default command count is less than the test settings count
+ expect(resetCommands.length).not.toBe(importedCommands.length)
+ })
+})
diff --git a/packages/extension/e2e/single-command.spec.ts b/packages/extension/e2e/single-command.spec.ts
new file mode 100644
index 00000000..51bb93ee
--- /dev/null
+++ b/packages/extension/e2e/single-command.spec.ts
@@ -0,0 +1,73 @@
+import { test, expect } from "./fixtures"
+import { TestPage } from "./pages/TestPage"
+import { OptionsPage } from "./pages/OptionsPage"
+
+test.describe("Single Function Commands", () => {
+ test.beforeEach(async ({ context, extensionId, getCommands }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.importSettings()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-30: Verify that the Copy Text command copies the selected text to the clipboard.
+ */
+ test("E2E-30: copy text command copies selected text to clipboard", async ({
+ page,
+ context,
+ }) => {
+ // Arrange: open the test page, grant clipboard permissions, and select text
+ await context.grantPermissions(["clipboard-read", "clipboard-write"])
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText("h1")
+ const selectedText = await page.evaluate(
+ () => window.getSelection()?.toString() ?? "",
+ )
+ expect(selectedText.length).toBeGreaterThan(1)
+
+ // Act: click the "テキストコピー" menu item
+ const menubar = await testPage.getMenuBar()
+ await menubar.locator("[role='menuitem'][aria-label='テキストコピー']").click()
+ await page.waitForTimeout(100)
+
+ // Assert: clipboard content matches the selected text
+ const clipboardText = await page.evaluate(async () => {
+ try {
+ return await navigator.clipboard.readText()
+ } catch {
+ return null
+ }
+ })
+ expect(clipboardText).toBe(selectedText)
+ })
+
+ /**
+ * E2E-31: Verify that the Link Popup command opens each link in the selected range in a popup window.
+ */
+ test("E2E-31: link popup command opens each selected link in a popup window", async ({
+ page,
+ context,
+ }) => {
+ // Arrange: open the test page and select a range spanning multiple links
+ const testPage = new TestPage(page)
+ await testPage.open()
+ const initialPageCount = context.pages().length
+ await testPage.selectRange(
+ "footer a[href$='terms']",
+ "footer a[href$='cookie']",
+ )
+
+ // Act: click the "リンクポップアップ" menu item and wait for a new page to open
+ const menubar = await testPage.getMenuBar()
+ await Promise.all([
+ context.waitForEvent("page", { timeout: 5000 }),
+ menubar.locator("[role='menuitem'][aria-label='リンクポップアップ']").click(),
+ ])
+
+ // Assert: at least one new popup window was opened
+ const newPageCount = context.pages().length
+ expect(newPageCount).toBeGreaterThan(initialPageCount)
+ })
+})
diff --git a/packages/extension/e2e/startup.spec.ts b/packages/extension/e2e/startup.spec.ts
new file mode 100644
index 00000000..96be5a9c
--- /dev/null
+++ b/packages/extension/e2e/startup.spec.ts
@@ -0,0 +1,69 @@
+import { test, expect } from "./fixtures"
+import { TestPage } from "./pages/TestPage"
+import { STARTUP_METHOD, KEYBOARD } from "../src/const"
+
+/**
+ * E2E-10: Verify that the popup menu appears when text is selected on the page.
+ * Double-clicking on a word triggers text selection and shows the popup menu.
+ */
+test("E2E-10: popup menu appears on text selection", async ({ page }) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ await testPage.selectText()
+
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+})
+
+test("E2E-11: popup menu appears on text selection and press a ShiftKey", async ({
+ setUserSettings,
+ page,
+}) => {
+ // Arrange: Set the startup method to "keyboard".
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ // Act: Set the startup method to "keyboard" and dispatch a Shift key press after selecting text.
+ await setUserSettings({
+ startupMethod: {
+ method: STARTUP_METHOD.KEYBOARD,
+ keyboardParam: KEYBOARD.SHIFT,
+ },
+ })
+ await testPage.selectText("h1, h2, h3", false)
+ await page.keyboard.press(KEYBOARD.SHIFT)
+
+ // Assert
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+})
+
+/**
+ * E2E-12: Verify that the popup menu appears when left-click is held for 150ms or longer.
+ */
+test("E2E-12: popup menu appears on left-click hold (150ms)", async ({
+ setUserSettings,
+ page,
+}) => {
+ const testPage = new TestPage(page)
+ await testPage.open()
+
+ await setUserSettings({
+ startupMethod: {
+ method: STARTUP_METHOD.LEFT_CLICK_HOLD,
+ leftClickHoldParam: 150,
+ },
+ })
+
+ await testPage.selectText("h1, h2", false)
+
+ // Long-press via synthetic MouseEvent to preserve text selection in headless mode.
+ // locator.click({ delay }) uses CDP which fires selectionchange on mousedown and
+ // clears selectionText → disables useLeftClickHold → cancels the hold timeout.
+ const locator = page.locator("h1, h2").first()
+ await testPage.leftClickHold(locator, 150 + 10)
+
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+})
diff --git a/packages/extension/e2e/url-status.spec.ts b/packages/extension/e2e/url-status.spec.ts
new file mode 100644
index 00000000..d2732037
--- /dev/null
+++ b/packages/extension/e2e/url-status.spec.ts
@@ -0,0 +1,26 @@
+import { test, expect } from "@playwright/test"
+import { COMMAND_URLS } from "./generated-command-urls"
+
+/**
+ * E2E tests to verify that all search URLs used in default commands
+ * across all supported locales are accessible and return HTTP 200.
+ *
+ * URL list is auto-generated from defaultSettings.ts.
+ * Run "yarn build:e2e" to regenerate e2e/generated-command-urls.ts.
+ * The %s placeholder is replaced with "test" for each request.
+ */
+
+test.describe("E2E-URL: Default command URLs return HTTP 200", () => {
+ test.skip(!!process.env.CI, "Do not run tests for external services in CI.")
+
+ for (const { title, locale, searchUrl } of COMMAND_URLS) {
+ const url = searchUrl.replace("%s", "test")
+ test(`${title} (${locale}): ${url}`, async ({ request }) => {
+ const response = await request.get(url, { timeout: 30_000 })
+ expect(
+ response.status(),
+ `Expected HTTP 200 for ${url}, got ${response.status()}`,
+ ).toBe(200)
+ })
+ }
+})
diff --git a/packages/extension/e2e/user-style.spec.ts b/packages/extension/e2e/user-style.spec.ts
new file mode 100644
index 00000000..fc363248
--- /dev/null
+++ b/packages/extension/e2e/user-style.spec.ts
@@ -0,0 +1,270 @@
+import { test, expect } from "./fixtures"
+import { TestPage } from "./pages/TestPage"
+import { OptionsPage } from "./pages/OptionsPage"
+import { STYLE_VARIABLE } from "@/const"
+
+test.describe("User Styles", () => {
+ test.beforeEach(async ({ context, extensionId, getCommands }) => {
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.importSettings()
+ await optionsPage.close()
+ })
+
+ /**
+ * E2E-70: Verify that setting padding-scale to 1 makes the popup menu narrower
+ * than the default (1.5).
+ */
+ test("E2E-70: padding-scale 1 makes menu items smaller than default", async ({
+ getUserSettings,
+ setUserSettings,
+ page,
+ }) => {
+ // First measure the default (1.5) button height
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+
+ const defaultButton = menubar.locator("button").first()
+ const defaultBox = await defaultButton.boundingBox()
+ expect(defaultBox).toBeTruthy()
+ const defaultHeight = defaultBox!.height
+
+ // Close menu by clicking on the selected element to deselect text
+ await page.locator("h1, h2, h3").first().click()
+ await page.waitForTimeout(100)
+
+ // Change padding-scale to 1
+ const currentSettings = await getUserSettings()
+ const updatedStyles = currentSettings.userStyles.map((s) =>
+ s.name === "padding-scale" ? { ...s, value: "1" } : s,
+ )
+ await setUserSettings({ userStyles: updatedStyles })
+
+ // Re-open the menu
+ await testPage.selectText()
+ const menubar2 = await testPage.getMenuBar()
+ await expect(menubar2).toBeVisible()
+
+ const scaledButton = menubar2.locator("button").first()
+ const scaledBox = await scaledButton.boundingBox()
+ expect(scaledBox).toBeTruthy()
+
+ // With padding-scale=1 the button height should be smaller
+ expect(scaledBox!.height).toBeLessThan(defaultHeight)
+ })
+
+ /**
+ * E2E-71: Verify that restoring padding-scale to 1.5 brings back the default size.
+ */
+ test("E2E-71: padding-scale 1.5 restores default menu item size", async ({
+ getUserSettings,
+ setUserSettings,
+ page,
+ }) => {
+ // Set padding-scale to 1 first
+ const currentSettings = await getUserSettings()
+ const smallStyles = currentSettings.userStyles.map((s) =>
+ s.name === "padding-scale" ? { ...s, value: "1" } : s,
+ )
+ await setUserSettings({ userStyles: smallStyles })
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+
+ const smallBox = await menubar.locator("button").first().boundingBox()
+ expect(smallBox).toBeTruthy()
+
+ // Close menu by clicking on the selected element to deselect text
+ await page.locator("h1, h2, h3").first().click()
+ await page.waitForTimeout(100)
+
+ // Restore padding-scale to 1.5
+ const settings2 = await getUserSettings()
+ const defaultStyles = settings2.userStyles.map((s) =>
+ s.name === "padding-scale" ? { ...s, value: "1.5" } : s,
+ )
+ await setUserSettings({ userStyles: defaultStyles })
+
+ // Re-open the menu
+ await testPage.selectText()
+ const menubar2 = await testPage.getMenuBar()
+ await expect(menubar2).toBeVisible()
+
+ const defaultBox = await menubar2.locator("button").first().boundingBox()
+ expect(defaultBox).toBeTruthy()
+
+ // Default (1.5) button should be larger than scale=1
+ expect(defaultBox!.height).toBeGreaterThan(smallBox!.height)
+ })
+
+ /**
+ * E2E-72: Verify that setting a background color changes the popup menu background.
+ */
+ test("E2E-72: setting background color changes the popup menu background", async ({
+ getUserSettings,
+ setUserSettings,
+ page,
+ }) => {
+ const currentSettings = await getUserSettings()
+ // Add or update background-color style variable
+ const hasBackgroundColor = currentSettings.userStyles.some(
+ (s) => s.name === STYLE_VARIABLE.BACKGROUND_COLOR,
+ )
+ const updatedStyles = hasBackgroundColor
+ ? currentSettings.userStyles.map((s) =>
+ s.name === STYLE_VARIABLE.BACKGROUND_COLOR
+ ? { ...s, value: "#ff0000" }
+ : s,
+ )
+ : [
+ ...currentSettings.userStyles,
+ { name: STYLE_VARIABLE.BACKGROUND_COLOR, value: "#ff0000" },
+ ]
+
+ await setUserSettings({ userStyles: updatedStyles })
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+
+ // Read the CSS custom property from the menu element via the locator
+ const bgColor = await menubar.evaluate(
+ (el) =>
+ getComputedStyle(el).getPropertyValue("--background-color").trim() ||
+ getComputedStyle(el).backgroundColor,
+ )
+
+ // The background-color style variable should be set to red
+ expect(bgColor).toContain("rgb(255, 0, 0)")
+ })
+
+ /**
+ * E2E-73: Verify that deleting the background color resets the popup menu background to white.
+ */
+ test("E2E-73: deleting background color resets popup menu to default background", async ({
+ getUserSettings,
+ setUserSettings,
+ page,
+ }) => {
+ // First, set a background color
+ const currentSettings = await getUserSettings()
+ const withBg = [
+ ...currentSettings.userStyles.filter(
+ (s) => s.name !== STYLE_VARIABLE.BACKGROUND_COLOR,
+ ),
+ { name: STYLE_VARIABLE.BACKGROUND_COLOR, value: "#ff0000" },
+ ]
+ await setUserSettings({ userStyles: withBg })
+
+ // Then remove it
+ const settings2 = await getUserSettings()
+ const withoutBg = settings2.userStyles.filter(
+ (s) => s.name !== "background-color",
+ )
+ await setUserSettings({ userStyles: withoutBg })
+
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+
+ // The background-color custom property should not be set to red
+ const bgColor = await menubar.evaluate((el) =>
+ getComputedStyle(el).getPropertyValue("--background-color").trim(),
+ )
+
+ expect(bgColor).not.toBe("#ff0000")
+ expect(bgColor).not.toContain("rgb(255, 0, 0)")
+ })
+
+ /**
+ * E2E-74: Verify that adding font-color via the settings UI applies to the popup menu.
+ */
+ test("E2E-74: adding font-color via UI applies to popup menu", async ({
+ context,
+ extensionId,
+ getCommands,
+ page,
+ }) => {
+ // Add font-color via the options page UI
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.addUserStyle(STYLE_VARIABLE.FONT_COLOR, "#ff0000")
+ await optionsPage.close()
+
+ // Verify the font color is applied in the popup menu
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+
+ const fontColor = await menubar.evaluate((el) => getComputedStyle(el).color)
+ expect(fontColor).toContain("rgb(255, 0, 0)")
+ })
+
+ /**
+ * E2E-75: Verify that removing font-color via the settings UI resets the popup menu
+ * font color, and that other user styles are unaffected.
+ */
+ test("E2E-75: removing font-color via UI resets popup menu font color", async ({
+ context,
+ extensionId,
+ getCommands,
+ getUserSettings,
+ setUserSettings,
+ page,
+ }) => {
+ // Pre-condition: add font-color on top of the existing styles
+ const currentSettings = await getUserSettings()
+ await setUserSettings({
+ userStyles: [
+ ...currentSettings.userStyles,
+ { name: STYLE_VARIABLE.FONT_COLOR, value: "#ff0000" },
+ ],
+ })
+
+ // Remove font-color via the options page UI
+ const optionsPage = new OptionsPage(context, extensionId, getCommands)
+ await optionsPage.open()
+ await optionsPage.removeUserStyle(STYLE_VARIABLE.FONT_COLOR)
+ await optionsPage.close()
+
+ // Verify font color is back to default in the popup menu
+ const testPage = new TestPage(page)
+ await testPage.open()
+ await testPage.selectText()
+ const menubar = await testPage.getMenuBar()
+ await expect(menubar).toBeVisible()
+
+ const fontColor = await menubar.evaluate((el) => getComputedStyle(el).color)
+ expect(fontColor).not.toContain("rgb(255, 0, 0)")
+
+ // Verify other user styles (e.g. padding-scale) are unaffected
+ const updatedSettings = await getUserSettings()
+ expect(
+ updatedSettings.userStyles.some(
+ (s) => s.name === STYLE_VARIABLE.PADDING_SCALE,
+ ),
+ ).toBe(true)
+ expect(
+ updatedSettings.userStyles.some(
+ (s) => s.name === STYLE_VARIABLE.IMAGE_SCALE,
+ ),
+ ).toBe(true)
+ expect(
+ updatedSettings.userStyles.some(
+ (s) => s.name === STYLE_VARIABLE.FONT_SCALE,
+ ),
+ ).toBe(true)
+ })
+})
diff --git a/packages/extension/e2e/utils/logConsole.ts b/packages/extension/e2e/utils/logConsole.ts
new file mode 100644
index 00000000..35b3f931
--- /dev/null
+++ b/packages/extension/e2e/utils/logConsole.ts
@@ -0,0 +1,34 @@
+import type { ConsoleMessage, Page, Worker } from "@playwright/test"
+
+function attachConsoleListener(target: Page | Worker, prefix: string): void {
+ target.on("console", async (msg: ConsoleMessage) => {
+ if (!process.env.PWDEBUG) return
+ try {
+ const type = msg.type().charAt(0).toUpperCase()
+ const location = msg.location()
+ const header = `${prefix}[${type}]`
+ const footer = `@ ${location.url}:${location.lineNumber}`
+
+ const args = await Promise.all(msg.args().map((a) => a.jsonValue()))
+ const formatted = args.map((v) =>
+ typeof v === "string" ? v : JSON.stringify(v, null, 2),
+ )
+
+ console.log(header, " ", ...formatted, "\n ", footer)
+ } catch (e) {
+ console.log(
+ `${prefix}[${msg.type().charAt(0).toUpperCase()}]:`,
+ msg.text(),
+ )
+ console.warn(`Failed to process console message from ${prefix}`)
+ }
+ })
+}
+
+export function attachConsole(page: Page): void {
+ attachConsoleListener(page, "Browser")
+}
+
+export function attachSWConsole(sw: Worker): void {
+ attachConsoleListener(sw, "SW")
+}
diff --git a/packages/extension/eslint.config.mjs b/packages/extension/eslint.config.mjs
index ed2d340c..bb94d85a 100644
--- a/packages/extension/eslint.config.mjs
+++ b/packages/extension/eslint.config.mjs
@@ -16,4 +16,11 @@ export default tseslint.config(...rootConfig, {
{ allowConstantExport: true },
],
},
+},
+{
+ // Playwright fixtures use a `use` callback that conflicts with React hooks rules
+ files: ["e2e/**/*.{ts,tsx}"],
+ rules: {
+ "react-hooks/rules-of-hooks": "off",
+ },
})
diff --git a/packages/extension/macros/importIfProvider.ts b/packages/extension/macros/importIfProvider.ts
deleted file mode 100644
index 48310937..00000000
--- a/packages/extension/macros/importIfProvider.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { defineMacroProvider, defineMacro } from "vite-plugin-macro"
-
-export function provideImportIf({ mode }: { mode: string }) {
- const importIfMacro = defineMacro("importIf")
- .withSignature("(targetMode: string, path: string)")
- .withHandler(({ path, args }, { template }) => {
- if (!path.parentPath.isExpressionStatement()) {
- throw new Error(
- "importIf macro can only be used at the top level (standalone line).",
- )
- }
-
- const targetMode = args[0]
- const importPath = args[1]
-
- if (!targetMode.isStringLiteral() || !importPath.isStringLiteral()) {
- throw new Error("Literal string only")
- }
-
- // Generate import only when mode matches the specified targetMode
- if (mode === targetMode.node.value) {
- path.parentPath.replaceWith(
- template.statement.ast(`import "${importPath.node.value}"`),
- )
- } else {
- // Remove statement when condition doesn't match
- path.parentPath.remove()
- }
- })
-
- return defineMacroProvider({
- id: "import-if",
- exports: {
- "@import-if": { macros: [importIfMacro] },
- },
- })
-}
diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json
index 2e720089..addd9092 100644
--- a/packages/extension/manifest.json
+++ b/packages/extension/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_extName__",
"description": "__MSG_extDescription__",
- "version": "0.14.2",
+ "version": "0.17.0",
"default_locale": "en",
"icons": {
"128": "icon128.png"
@@ -31,7 +31,8 @@
"contextMenus",
"declarativeNetRequest",
"clipboardRead",
- "system.display"
+ "system.display",
+ "sidePanel"
],
"host_permissions": ["http://*/*", "https://*/*"],
"web_accessible_resources": [
diff --git a/packages/extension/package.json b/packages/extension/package.json
index d84c3af6..fa62b2f5 100644
--- a/packages/extension/package.json
+++ b/packages/extension/package.json
@@ -1,20 +1,22 @@
{
"name": "@selection-command/extension",
- "version": "0.14.2",
+ "version": "0.17.0",
"private": true,
"author": "ujiro99",
"license": "MIT",
+ "type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "build:e2e": "vite-node --config scripts/vite.config.ts scripts/generate-e2e-urls.ts && tsc -b && vite build --mode e2e",
"lint": "eslint .",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest --coverage",
- "pretty-quick": "pretty-quick",
- "precommit": "pretty-quick --staged",
- "zip": "npm-build-zip --source=dist --destination=build"
+ "check-ids": "vite-node --config scripts/vite.config.ts scripts/check-command-ids.ts",
+ "zip": "npm-build-zip --source=dist --destination=build",
+ "test:e2e": "playwright test"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -36,6 +38,7 @@
"@sentry/react": "^9.42.0",
"@sentry/vite-plugin": "^4.0.1",
"@testing-library/user-event": "^14.6.0",
+ "bowser": "^2.14.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"colorthief": "^2.6.0",
@@ -50,7 +53,7 @@
"react-multi-progress": "^1.3.0",
"react-textarea-autosize": "^8.5.3",
"react-transition-group": "^4.4.5",
- "sonner": "github:ujiro99/sonner",
+ "sonner": "git+https://github.com/ujiro99/sonner.git",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
@@ -59,7 +62,8 @@
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.30",
"@testing-library/dom": "^10.4.0",
- "@types/chrome": "^0.0.293",
+ "@testing-library/react": "^16.3.0",
+ "@types/chrome": "^0.1.36",
"@types/jest": "^29.5.11",
"@types/platform": "^1.3.6",
"@types/react-transition-group": "^4.4.10",
@@ -72,10 +76,10 @@
"glob": "^10.3.10",
"globals": "^15.14.0",
"npm-build-zip": "^1.0.4",
+ "tsx": "^4.21.0",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5",
"vite-plugin-css-injected-by-js": "^3.5.2",
- "vite-plugin-macro": "^0.2.0",
"webextension-polyfill": "^0.10.0"
},
"browserslist": {
@@ -85,11 +89,5 @@
"development": [
"last 3 chrome version"
]
- },
- "prettier": {
- "semi": false,
- "singleQuote": false,
- "tabWidth": 2,
- "trailingComma": "all"
}
}
diff --git a/packages/extension/playwright.config.ts b/packages/extension/playwright.config.ts
new file mode 100644
index 00000000..1d41e608
--- /dev/null
+++ b/packages/extension/playwright.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from "@playwright/test"
+
+export default defineConfig({
+ testDir: "./e2e",
+ timeout: 30000,
+ retries: 1,
+ // Extension tests use launchPersistentContext, which can conflict when run in parallel.
+ workers: 1,
+ outputDir: "test-results",
+ use: {
+ screenshot: "only-on-failure",
+ },
+})
diff --git a/packages/extension/postcss.config.js b/packages/extension/postcss.config.cjs
similarity index 100%
rename from packages/extension/postcss.config.js
rename to packages/extension/postcss.config.cjs
diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json
index c61b37ec..a613c950 100644
--- a/packages/extension/public/_locales/de/messages.json
+++ b/packages/extension/public/_locales/de/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Menü-Anzeigeanimation"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Popup-Autoschluss-Verzögerung"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Stellen Sie die Verzögerungszeit ein, bevor das Popup nach dem Verlust des Fokus automatisch geschlossen wird. Stellen Sie 0 ein oder lassen Sie es leer für sofortiges Schließen.\nMaximum: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (sofort schließen)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Seitenbereich automatisch ausblenden"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Blendet den Seitenbereich automatisch aus, wenn in den Hauptbereich geklickt wird, während der Seitenbereich über einen Befehl geöffnet ist."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Seitenbereich automatisch ausblenden"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Blendet den Seitenbereich automatisch aus, wenn in den Hauptbereich geklickt wird, während die Linkvorschau angezeigt wird."
+ },
"Option_inherit": {
"message": "Erben"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "Wird automatisch aktualisiert, wenn die Start-URL geändert wird."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Wird als Menü-Symbol angezeigt."
+ },
"Option_iconUrl_autofill": {
"message": "Automatisch ausfüllen"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Prozent (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Bindestrich (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Unterstrich (_)"
+ },
"Option_openMode": {
"message": "Öffnungsmodus"
},
+ "Option_displayMode": {
+ "message": "Fenster-Anzeigemethode"
+ },
"Option_openMode_popup": {
"message": "Popup"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Hintergrund-Tab"
},
+ "Option_openMode_sidePanel": {
+ "message": "Seitenpanel"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Im Seitenpanel öffnen."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "Seitenaktion"
},
- "Option_displayMode": {
- "message": "Fenster-Anzeigemethode"
+ "Option_openMode_aiPrompt": {
+ "message": "KI-Prompt"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Führt einen vordefinierten Prompt auf einem KI-Dienst aus"
},
"Option_commandType_title": {
"message": "Befehlstyp wählen"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Webseiten-Operationen aufzeichnen und wiedergeben"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "KI-Prompt"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Führt einen vordefinierten Prompt auf einem KI-Dienst aus"
+ },
"Option_commandType_copy_title": {
"message": "Text kopieren"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "Fenster"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Seitenbereich"
+ },
"Option_openModeSecondary": {
"message": " ┗ Strg + Klick"
},
"Option_parentFolderId": {
"message": "Ordner"
},
+ "Option_parentFolder": {
+ "message": "Übergeordneter Ordner"
+ },
+ "Option_parentFolder_desc": {
+ "message": "Übergeordneten Ordner auswählen"
+ },
+ "Option_rootFolder": {
+ "message": "Stamm (kein Übergeordnet)"
+ },
"Option_copyOption": {
"message": "Kopierformat"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL der Seite, auf der die Aktion beginnt."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "Bei \"Aktueller Tab\" können nur Seiten ausgeführt werden, die dieser URL entsprechen. Sie können den Platzhalter \"*\" verwenden."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL der Seite, von der die Aktionsaufzeichnung gestartet wird. Wird nur im Einstellungsbildschirm verwendet."
+ },
+ "Option_pageUrl": {
+ "message": "Seiten-URL"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Kann nur auf Seiten ausgeführt werden, die dieser URL entsprechen. Sie können den Platzhalter \"*\" verwenden."
+ },
"Option_pageAction_title": {
"message": "Aktionen"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "Verzögerungszeit"
},
+ "Option_aiPrompt_service": {
+ "message": "KI-Dienst"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Wählen Sie den zu verwendenden KI-Dienst aus"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Prompt"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Prompt-Vorlage. Verwenden Sie das Einfügemenü, um Variablen hinzuzufügen (ausgewählter Text, URL, Zwischenablage)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Prompt hier eingeben..."
+ },
+ "Option_userVariables": {
+ "message": "Benutzervariablen"
+ },
+ "Option_userVariables_desc": {
+ "message": "Benutzerdefinierte Variablen für Eingabefelder definieren (max. 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "Variablenname"
+ },
+ "Option_userVariable_value": {
+ "message": "Wert"
+ },
+ "Option_userVariable_add": {
+ "message": "Variable hinzufügen"
+ },
+ "Option_userVariable_name_required": {
+ "message": "Variablenname ist erforderlich"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "Variablenname muss mit Buchstabe beginnen und darf nur Buchstaben, Zahlen und Unterstriche enthalten"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "Variablenname bereits vorhanden"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "Maximal $max$ Variablen erlaubt",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "Sofortbefehl"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Führt einen angegebenen Befehl sofort aus, wenn beim Halten einer Modifizierertaste Text ausgewählt wird."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Sofortbefehl aktivieren"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "Wenn aktiviert, werden Befehle sofort ausgeführt, ohne das Menü anzuzeigen."
+ },
+ "Option_instantCommandId": {
+ "message": "Auszuführender Befehl"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Wählen Sie den Befehl aus, der zusammen mit der Modifizierertaste ausgeführt werden soll."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Befehl auswählen..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Modifizierertaste"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Wählen Sie die Taste aus, die beim Auswählen von Text gedrückt werden soll."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Taste auswählen..."
+ },
"Option_linkCommand": {
"message": "Link-Vorschau"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Zeigt die verbleibende Zeit bis zur Vorschau an."
},
+ "Option_windowSettings": {
+ "message": "Fenstereinstellungen"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Konfigurieren Sie das Fensterverhalten und Popup-Einstellungen."
+ },
"Option_folders": {
"message": "Ordner"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Rahmenfarbe"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Schriftfarbe"
+ },
"Option_userStyles_desc_border_color": {
"message": "Geben Sie die Rahmenfarbe des Menüs an. Standard: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Geben Sie die Schriftfarbe an. Standard: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Schriftgrößen-Skalierung"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "Variablenname muss mit einem Buchstaben beginnen und darf nur Buchstaben, Zahlen und Unterstriche enthalten"
+ },
"Option_zod_string_min": {
"message": "Bitte geben Sie eine Zeichenkette mit $key$ oder mehr Zeichen ein.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "Bitte geben Sie im URL-Format ein."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "Seiten-URL ist erforderlich, wenn der aktuelle Tab-Modus ausgewählt ist."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "Der ausgewählte Text wird eingefügt",
"description": "for INSERT.SELECTED_TEXT"
@@ -905,6 +1073,12 @@
"Option_openMode_backgroundTab_desc": {
"message": "Tab im Hintergrund öffnen.Rechts vom aktuellen Tab anzeigen."
},
+ "Option_openMode_currentTab": {
+ "message": "Aktueller Tab"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Im aktuellen aktiven Tab ausführen.URL muss mit der aufgezeichneten Start-URL übereinstimmen."
+ },
"Option_title_desc": {
"message": "Wird als Befehlstitel angezeigt."
},
@@ -917,30 +1091,15 @@
"Option_openModeSecondary_desc": {
"message": "Verhalten beim Strg + Klicken des Menüs."
},
- "Option_parentFolder": {
- "message": "Übergeordneter Ordner"
- },
- "Option_parentFolder_desc": {
- "message": "Übergeordneten Ordner auswählen"
- },
- "Option_rootFolder": {
- "message": "Stamm (kein Übergeordnet)"
- },
"Option_searchUrlAssist": {
"message": "KI-Assistenz"
},
+ "Option_searchUrlAssist_title": {
+ "message": "Such-URL-Assistenz"
+ },
"Option_searchUrlAssist_desc": {
"message": "Geben Sie ein Suchschlüsselwort und eine Suchergebnis-URL ein, dann klicken Sie auf die Ausführen-Schaltfläche.\nKI generiert eine Such-URL."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Mit Gemini ausführen"
- },
- "Option_searchUrlAssist_executing": {
- "message": "Wird gestartet..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "Verwendung"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) Suchschlüsselwort"
},
@@ -953,6 +1112,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "Verwendung"
+ },
"Option_searchUrlAssist_step1": {
"message": "Geben Sie ein beliebiges Wort in \"(1) Suchschlüsselwort\" ein"
},
@@ -968,8 +1130,11 @@
"Option_searchUrlAssist_step5": {
"message": "Kopiere die generierte Such-URL und füge sie in das Such-URL-Feld des Befehls ein"
},
- "Option_searchUrlAssist_title": {
- "message": "Such-URL-Assistenz"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Mit Gemini ausführen"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "Wird gestartet..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "Bitte geben Sie ein Suchschlüsselwort ein"
@@ -977,42 +1142,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "Bitte geben Sie eine gültige URL ein"
},
- "Option_userVariable_add": {
- "message": "Variable hinzufügen"
- },
- "Option_userVariable_max_reached": {
- "message": "Maximal $max$ Variablen erlaubt",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "Variablenname"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Variablenname bereits vorhanden"
- },
- "Option_userVariable_name_invalid": {
- "message": "Variablenname muss mit Buchstabe beginnen und darf nur Buchstaben, Zahlen und Unterstriche enthalten"
- },
- "Option_userVariable_name_required": {
- "message": "Variablenname ist erforderlich"
- },
- "Option_userVariable_value": {
- "message": "Wert"
- },
- "Option_userVariables": {
- "message": "Benutzervariablen"
- },
- "Option_userVariables_desc": {
- "message": "Benutzerdefinierte Variablen für Eingabefelder definieren (max. 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "Variablenname muss mit einem Buchstaben beginnen und darf nur Buchstaben, Zahlen und Unterstriche enthalten"
- },
"Option_commandType": {
"message": "Befehlstyp"
},
@@ -1096,5 +1225,23 @@
},
"prompthistory_banner_description": {
"message": "Ein neues Tool zum Wiederverwenden von Prompts für ChatGPT und mehr✨"
+ },
+ "Option_windowState": {
+ "message": "Fenstergröße"
+ },
+ "Option_windowState_desc": {
+ "message": "Anzuzeigende Fenstergröße"
+ },
+ "Option_windowState_normal": {
+ "message": "Normal"
+ },
+ "Option_windowState_maximized": {
+ "message": "Maximiert"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Vollbild"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "Kann auf dieser Seite nicht ausgeführt werden (Seiten-URL stimmt nicht überein)"
}
}
diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json
index 077388d3..f24cd1f0 100644
--- a/packages/extension/public/_locales/en/messages.json
+++ b/packages/extension/public/_locales/en/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Menu Display Animation"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Popup Auto-Close Delay"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Set the delay time before the popup automatically closes after losing focus. Set to 0 or leave empty for immediate close.\nMaximum: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (close immediately)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Side Panel Auto-Hide"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Automatically hide the side panel when the main panel is clicked while the side panel is open via a command."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Side Panel Auto-Hide"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Automatically hide the side panel when the main panel is clicked while displaying the link preview."
+ },
"Option_inherit": {
"message": "Inherit"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "Automatically updated when the Start URL is changed."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Displayed as the menu icon."
+ },
"Option_iconUrl_autofill": {
"message": "Autofill"
},
@@ -263,6 +287,12 @@
"Option_spaceEncoding_percent": {
"message": "Percent (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Dash (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Underscore (_)"
+ },
"Option_openMode": {
"message": "Open Mode"
},
@@ -281,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Background Tab"
},
+ "Option_openMode_sidePanel": {
+ "message": "Side Panel"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Open in side panel."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -296,6 +332,12 @@
"Option_openMode_pageAction": {
"message": "Page Action"
},
+ "Option_openMode_aiPrompt": {
+ "message": "AI Prompt"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Run a predefined prompt on an AI service"
+ },
"Option_commandType_title": {
"message": "Select Command Type"
},
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Record and replay web page operations"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "AI Prompt"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Run a predefined prompt on an AI service"
+ },
"Option_commandType_copy_title": {
"message": "Copy Text"
},
@@ -353,6 +401,9 @@
"Option_openMode_previewWindow": {
"message": "Window"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Side Panel"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + Click"
},
@@ -395,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL of the page where the action starts."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "For \"Current Tab\", only pages matching this URL can be executed. You can use the wildcard \"*\"."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL of the page to start recording actions. Used only in the settings screen."
+ },
+ "Option_pageUrl": {
+ "message": "Page URL"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Only pages matching this URL can be executed. You can use the wildcard \"*\"."
+ },
"Option_pageAction_title": {
"message": "Actions"
},
@@ -413,6 +476,21 @@
"Option_pageAction_delay": {
"message": "Delay time"
},
+ "Option_aiPrompt_service": {
+ "message": "AI Service"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Select the AI service to use"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Prompt"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Prompt template. Use the insert menu to add variables (selected text, URL, clipboard)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Enter your prompt here..."
+ },
"Option_userVariables": {
"message": "User Variables"
},
@@ -446,6 +524,36 @@
}
}
},
+ "Option_instantCommand": {
+ "message": "Instant Command"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Execute a specified command instantly when selecting text while holding a modifier key."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Enable Instant Command"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "When enabled, execute commands instantly without showing the menu."
+ },
+ "Option_instantCommandId": {
+ "message": "Command to Execute"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Select the command to execute when combined with the modifier key."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Select a command..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Modifier Key"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Select the key to hold when selecting text."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Select a key..."
+ },
"Option_linkCommand": {
"message": "Link Preview"
},
@@ -500,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Displays the remaining to the preview."
},
+ "Option_windowSettings": {
+ "message": "Window Settings"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Configure window behavior and popup settings."
+ },
"Option_folders": {
"message": "Folders"
},
@@ -587,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Border Color"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Font color"
+ },
"Option_userStyles_desc_border_color": {
"message": "Specify the border color of the menu. Default: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Specify font color. Default: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Font size scaling"
},
@@ -692,6 +812,10 @@
"Option_zod_url": {
"message": "Please enter in URL format."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "Page URL is required when Current Tab mode is selected.",
+ "description": "Zod validation error shown on the pageUrl field when openMode is CURRENT_TAB and pageUrl is empty"
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "Selected text will be inserted",
"description": "for INSERT.SELECTED_TEXT"
@@ -953,6 +1077,12 @@
"Option_openMode_backgroundTab_desc": {
"message": "Open tab in background.Show to the right of current tab."
},
+ "Option_openMode_currentTab": {
+ "message": "Current Tab"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Run on the current active tab.URL must match the recorded start URL."
+ },
"Option_title_desc": {
"message": "Displayed as the command title."
},
@@ -1099,5 +1229,23 @@
},
"prompthistory_banner_description": {
"message": "A new tool to reuse prompts for ChatGPT and more✨"
+ },
+ "Option_windowState": {
+ "message": "Window Size"
+ },
+ "Option_windowState_desc": {
+ "message": "Window size to display"
+ },
+ "Option_windowState_normal": {
+ "message": "Normal"
+ },
+ "Option_windowState_maximized": {
+ "message": "Maximized"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Fullscreen"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "Cannot run on this page (page URL does not match)"
}
-}
+}
\ No newline at end of file
diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json
index 086be0b4..36342050 100644
--- a/packages/extension/public/_locales/es/messages.json
+++ b/packages/extension/public/_locales/es/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Animación de Visualización del Menú"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Retardo de Cierre Automático de Popup"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Establezca el tiempo de retardo antes de que el popup se cierre automáticamente después de perder el foco. Establezca 0 o déjelo vacío para cierre inmediato.\nMáximo: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (cerrar inmediatamente)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Ocultar panel lateral automáticamente"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Oculta automáticamente el panel lateral cuando se hace clic en el panel principal mientras el panel lateral está abierto a través de un comando."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Ocultar panel lateral automáticamente"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Oculta automáticamente el panel lateral cuando se hace clic en el panel principal mientras se muestra la vista previa del enlace."
+ },
"Option_inherit": {
"message": "Heredar"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "Se actualiza automáticamente cuando se cambia la URL de inicio."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Se muestra como el icono del menú."
+ },
"Option_iconUrl_autofill": {
"message": "Autocompletar"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Porcentaje (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Guión (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Guión bajo (_)"
+ },
"Option_openMode": {
"message": "Modo de Apertura"
},
+ "Option_displayMode": {
+ "message": "Método de Visualización de Ventana"
+ },
"Option_openMode_popup": {
"message": "Emergente"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Pestaña en segundo plano"
},
+ "Option_openMode_sidePanel": {
+ "message": "Panel lateral"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Abrir en panel lateral."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "Acción de Página"
},
- "Option_displayMode": {
- "message": "Método de Visualización de Ventana"
+ "Option_openMode_aiPrompt": {
+ "message": "Prompt IA"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Ejecuta un prompt predefinido en un servicio de IA"
},
"Option_commandType_title": {
"message": "Seleccionar Tipo de Comando"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Grabar y reproducir operaciones de páginas web"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "Prompt IA"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Ejecuta un prompt predefinido en un servicio de IA"
+ },
"Option_commandType_copy_title": {
"message": "Copiar Texto"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "Ventana"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Panel lateral"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + Clic"
},
"Option_parentFolderId": {
"message": "Carpeta"
},
+ "Option_parentFolder": {
+ "message": "Carpeta Padre"
+ },
+ "Option_parentFolder_desc": {
+ "message": "Seleccionar carpeta padre"
+ },
+ "Option_rootFolder": {
+ "message": "Raíz (sin padre)"
+ },
"Option_copyOption": {
"message": "Formato de copia"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL de la página donde comienza la acción."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "Para \"Pestaña Actual\", solo se pueden ejecutar páginas que coincidan con esta URL. Puede usar el comodín \"*\"."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL de la página para iniciar la grabación de acciones. Se usa solo en la pantalla de configuración."
+ },
+ "Option_pageUrl": {
+ "message": "URL de la página"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Solo se puede ejecutar en páginas que coincidan con esta URL. Puede usar el comodín «*»."
+ },
"Option_pageAction_title": {
"message": "Acciones"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "Tiempo de retraso"
},
+ "Option_aiPrompt_service": {
+ "message": "Servicio IA"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Selecciona el servicio de IA a utilizar"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Prompt"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Plantilla de prompt. Usa el menú de inserción para agregar variables (texto seleccionado, URL, portapapeles)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Ingresa tu prompt aquí..."
+ },
+ "Option_userVariables": {
+ "message": "Variables de Usuario"
+ },
+ "Option_userVariables_desc": {
+ "message": "Definir variables personalizadas para campos de entrada (máx 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "Nombre de variable"
+ },
+ "Option_userVariable_value": {
+ "message": "Valor"
+ },
+ "Option_userVariable_add": {
+ "message": "Agregar variable"
+ },
+ "Option_userVariable_name_required": {
+ "message": "El nombre de variable es requerido"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "El nombre de variable debe comenzar con letra y contener solo letras, números y guiones bajos"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "El nombre de variable ya existe"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "Máximo $max$ variables permitidas",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "Comando Instantáneo"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Ejecuta un comando especificado instantáneamente cuando se selecciona texto mientras se mantiene presionada una tecla modificadora."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Habilitar Comando Instantáneo"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "Cuando está habilitado, los comandos se ejecutarán instantáneamente sin mostrar el menú."
+ },
+ "Option_instantCommandId": {
+ "message": "Comando a ejecutar"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Seleccione el comando que se ejecutará junto con la tecla modificadora."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Seleccionar comando..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Tecla Modificadora"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Seleccione la tecla que se debe mantener presionada al seleccionar texto."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Seleccionar tecla..."
+ },
"Option_linkCommand": {
"message": "Vista Previa de Enlace"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Muestra el tiempo restante hasta la vista previa."
},
+ "Option_windowSettings": {
+ "message": "Configuración de Ventana"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Configure el comportamiento de la ventana y la configuración de ventanas emergentes."
+ },
"Option_folders": {
"message": "Carpetas"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Color del Borde"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Color de fuente"
+ },
"Option_userStyles_desc_border_color": {
"message": "Especifique el color del borde del menú. Predeterminado: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Especificar el color de fuente. Predeterminado: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Escala de tamaño de fuente"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "El nombre de variable debe comenzar con una letra y contener solo letras, números y guiones bajos"
+ },
"Option_zod_string_min": {
"message": "Por favor, introduzca una cadena de $key$ caracteres o más.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "Por favor, introduzca en formato URL."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "La URL de página es obligatoria cuando se selecciona el modo de pestaña actual."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "Se insertará el texto seleccionado",
"description": "for INSERT.SELECTED_TEXT"
@@ -905,6 +1073,12 @@
"Option_openMode_backgroundTab_desc": {
"message": "Abrir pestaña en segundo plano.Mostrar a la derecha de la pestaña actual."
},
+ "Option_openMode_currentTab": {
+ "message": "Pestaña actual"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Ejecutar en la pestaña activa actual.La URL debe coincidir con la URL de inicio registrada."
+ },
"Option_title_desc": {
"message": "Se muestra como el título del comando."
},
@@ -917,30 +1091,15 @@
"Option_openModeSecondary_desc": {
"message": "Comportamiento al hacer Ctrl + clic en el menú."
},
- "Option_parentFolder": {
- "message": "Carpeta Padre"
- },
- "Option_parentFolder_desc": {
- "message": "Seleccionar carpeta padre"
- },
- "Option_rootFolder": {
- "message": "Raíz (sin padre)"
- },
"Option_searchUrlAssist": {
"message": "Asistente IA"
},
+ "Option_searchUrlAssist_title": {
+ "message": "Asistente de URL de Búsqueda"
+ },
"Option_searchUrlAssist_desc": {
"message": "Ingrese una palabra clave de búsqueda y una URL de la página de resultados de búsqueda, luego haga clic en el botón de ejecutar.\nLa IA generará una URL de búsqueda."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Ejecutar con Gemini"
- },
- "Option_searchUrlAssist_executing": {
- "message": "Ejecutando..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "Modo de uso"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) Palabra clave de búsqueda"
},
@@ -953,6 +1112,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "Modo de uso"
+ },
"Option_searchUrlAssist_step1": {
"message": "Ingrese cualquier palabra en \"(1) Palabra clave de búsqueda\""
},
@@ -968,8 +1130,11 @@
"Option_searchUrlAssist_step5": {
"message": "Copia la URL de búsqueda generada y pégala en el campo de URL de búsqueda del comando"
},
- "Option_searchUrlAssist_title": {
- "message": "Asistente de URL de Búsqueda"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Ejecutar con Gemini"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "Ejecutando..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "Por favor ingrese una palabra clave de búsqueda"
@@ -977,42 +1142,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "Por favor ingrese una URL válida"
},
- "Option_userVariable_add": {
- "message": "Agregar variable"
- },
- "Option_userVariable_max_reached": {
- "message": "Máximo $max$ variables permitidas",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "Nombre de variable"
- },
- "Option_userVariable_name_duplicate": {
- "message": "El nombre de variable ya existe"
- },
- "Option_userVariable_name_invalid": {
- "message": "El nombre de variable debe comenzar con letra y contener solo letras, números y guiones bajos"
- },
- "Option_userVariable_name_required": {
- "message": "El nombre de variable es requerido"
- },
- "Option_userVariable_value": {
- "message": "Valor"
- },
- "Option_userVariables": {
- "message": "Variables de Usuario"
- },
- "Option_userVariables_desc": {
- "message": "Definir variables personalizadas para campos de entrada (máx 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "El nombre de variable debe comenzar con una letra y contener solo letras, números y guiones bajos"
- },
"Option_commandType": {
"message": "Tipo de Comando"
},
@@ -1096,5 +1225,23 @@
},
"prompthistory_banner_description": {
"message": "Una nueva herramienta para reutilizar prompts en ChatGPT y más✨"
+ },
+ "Option_windowState": {
+ "message": "Tamaño de ventana"
+ },
+ "Option_windowState_desc": {
+ "message": "Tamaño de la ventana a mostrar"
+ },
+ "Option_windowState_normal": {
+ "message": "Normal"
+ },
+ "Option_windowState_maximized": {
+ "message": "Maximizado"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Pantalla completa"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "No se puede ejecutar en esta página (la URL de la página no coincide)"
}
}
diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json
index b0345b71..341b2bca 100644
--- a/packages/extension/public/_locales/fr/messages.json
+++ b/packages/extension/public/_locales/fr/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Animation d'Affichage du Menu"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Délai de Fermeture Automatique du Popup"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Définissez le délai avant que le popup se ferme automatiquement après avoir perdu le focus. Définissez 0 ou laissez vide pour une fermeture immédiate.\nMaximum: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (fermer immédiatement)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Masquage automatique du panneau latéral"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Masque automatiquement le panneau latéral lorsque le panneau principal est cliqué pendant que le panneau latéral est ouvert via une commande."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Masquage automatique du panneau latéral"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Masque automatiquement le panneau latéral lorsque le panneau principal est cliqué pendant l'affichage de l'aperçu du lien."
+ },
"Option_inherit": {
"message": "Hériter"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "Mis à jour automatiquement lorsque l'URL de démarrage est modifiée."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Affiché comme icône du menu."
+ },
"Option_iconUrl_autofill": {
"message": "Remplissage automatique"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Pourcentage (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Tiret (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Underscore (_)"
+ },
"Option_openMode": {
"message": "Mode d'Ouverture"
},
+ "Option_displayMode": {
+ "message": "Méthode d'Affichage de Fenêtre"
+ },
"Option_openMode_popup": {
"message": "Pop-up"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Onglet en arrière-plan"
},
+ "Option_openMode_sidePanel": {
+ "message": "Panneau latéral"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Ouvrir dans le panneau latéral."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "Action de Page"
},
- "Option_displayMode": {
- "message": "Méthode d'Affichage de Fenêtre"
+ "Option_openMode_aiPrompt": {
+ "message": "Prompt IA"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Exécute un prompt prédéfini sur un service IA"
},
"Option_commandType_title": {
"message": "Sélectionner le Type de Commande"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Enregistrer et reproduire les opérations de pages web"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "Prompt IA"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Exécute un prompt prédéfini sur un service IA"
+ },
"Option_commandType_copy_title": {
"message": "Copier le Texte"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "Fenêtre"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Panneau latéral"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + Clic"
},
"Option_parentFolderId": {
"message": "Dossier"
},
+ "Option_parentFolder": {
+ "message": "Dossier Parent"
+ },
+ "Option_parentFolder_desc": {
+ "message": "Sélectionner le dossier parent"
+ },
+ "Option_rootFolder": {
+ "message": "Racine (sans parent)"
+ },
"Option_copyOption": {
"message": "Format de copie"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL de la page où l'action commence."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "Pour \"Onglet Actuel\", seules les pages correspondant à cette URL peuvent être exécutées. Vous pouvez utiliser le caractère générique \"*\"."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL de la page pour démarrer l'enregistrement des actions. Utilisée uniquement dans l'écran des paramètres."
+ },
+ "Option_pageUrl": {
+ "message": "URL de la page"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Peut s'exécuter uniquement sur les pages correspondant à cette URL. Vous pouvez utiliser le caractère générique « * »."
+ },
"Option_pageAction_title": {
"message": "Actions"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "Temps de délai"
},
+ "Option_aiPrompt_service": {
+ "message": "Service IA"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Sélectionner le service IA à utiliser"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Prompt"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Modèle de prompt. Utilisez le menu d'insertion pour ajouter des variables (texte sélectionné, URL, presse-papiers)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Saisissez votre prompt ici..."
+ },
+ "Option_userVariables": {
+ "message": "Variables Utilisateur"
+ },
+ "Option_userVariables_desc": {
+ "message": "Définir des variables personnalisées pour les champs de saisie (max 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "Nom de variable"
+ },
+ "Option_userVariable_value": {
+ "message": "Valeur"
+ },
+ "Option_userVariable_add": {
+ "message": "Ajouter une variable"
+ },
+ "Option_userVariable_name_required": {
+ "message": "Le nom de variable est requis"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "Le nom de variable doit commencer par une lettre et ne contenir que des lettres, chiffres et traits de soulignement"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "Le nom de variable existe déjà"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "Maximum $max$ variables autorisées",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "Commande Instantanée"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Exécute une commande spécifiée instantanément lorsque du texte est sélectionné en maintenant une touche de modification."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Activer la Commande Instantanée"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "Lorsque activé, les commandes seront exécutées instantanément sans afficher le menu."
+ },
+ "Option_instantCommandId": {
+ "message": "Commande à exécuter"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Sélectionnez la commande qui sera exécutée avec la touche de modification."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Sélectionner une commande..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Touche de Modification"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Sélectionnez la touche à maintenir enfoncée lors de la sélection de texte."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Sélectionner une touche..."
+ },
"Option_linkCommand": {
"message": "Aperçu du Lien"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Affiche le temps restant jusqu'à l'aperçu."
},
+ "Option_windowSettings": {
+ "message": "Paramètres de Fenêtre"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Configurez le comportement de la fenêtre et les paramètres de popup."
+ },
"Option_folders": {
"message": "Dossiers"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Couleur de la Bordure"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Couleur de police"
+ },
"Option_userStyles_desc_border_color": {
"message": "Spécifiez la couleur de la bordure du menu. Par défaut : #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Spécifier la couleur de police. Par défaut: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Échelle de taille de police"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "Le nom de variable doit commencer par une lettre et ne contenir que des lettres, chiffres et traits de soulignement"
+ },
"Option_zod_string_min": {
"message": "Veuillez saisir une chaîne de $key$ caractères ou plus.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "Veuillez saisir au format URL."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "L'URL de la page est obligatoire lorsque le mode onglet actuel est sélectionné."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "Le texte sélectionné sera inséré",
"description": "for INSERT.SELECTED_TEXT"
@@ -905,6 +1073,12 @@
"Option_openMode_backgroundTab_desc": {
"message": "Ouvrir l'onglet en arrière-plan.Afficher à droite de l'onglet actuel."
},
+ "Option_openMode_currentTab": {
+ "message": "Onglet actuel"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Exécuter sur l'onglet actif actuel.L'URL doit correspondre à l'URL de départ enregistrée."
+ },
"Option_title_desc": {
"message": "Affiché comme titre de commande."
},
@@ -917,30 +1091,15 @@
"Option_openModeSecondary_desc": {
"message": "Comportement lors du Ctrl + clic sur le menu."
},
- "Option_parentFolder": {
- "message": "Dossier Parent"
- },
- "Option_parentFolder_desc": {
- "message": "Sélectionner le dossier parent"
- },
- "Option_rootFolder": {
- "message": "Racine (sans parent)"
- },
"Option_searchUrlAssist": {
"message": "Assistant IA"
},
+ "Option_searchUrlAssist_title": {
+ "message": "Assistant d'URL de Recherche"
+ },
"Option_searchUrlAssist_desc": {
"message": "Entrez un mot-clé de recherche et l'URL de la page de résultats de recherche, puis cliquez sur le bouton d'exécution.\nL'IA générera une URL de recherche."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Exécuter avec Gemini"
- },
- "Option_searchUrlAssist_executing": {
- "message": "Exécution en cours..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "Mode d'utilisation"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) Mot-clé de recherche"
},
@@ -953,6 +1112,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "Mode d'utilisation"
+ },
"Option_searchUrlAssist_step1": {
"message": "Entrez n'importe quel mot dans \"(1) Mot-clé de recherche\""
},
@@ -968,8 +1130,11 @@
"Option_searchUrlAssist_step5": {
"message": "Copiez l'URL de recherche générée et collez-la dans le champ URL de recherche de la commande"
},
- "Option_searchUrlAssist_title": {
- "message": "Assistant d'URL de Recherche"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Exécuter avec Gemini"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "Exécution en cours..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "Veuillez entrer un mot-clé de recherche"
@@ -977,42 +1142,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "Veuillez entrer une URL valide"
},
- "Option_userVariable_add": {
- "message": "Ajouter une variable"
- },
- "Option_userVariable_max_reached": {
- "message": "Maximum $max$ variables autorisées",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "Nom de variable"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Le nom de variable existe déjà"
- },
- "Option_userVariable_name_invalid": {
- "message": "Le nom de variable doit commencer par une lettre et ne contenir que des lettres, chiffres et traits de soulignement"
- },
- "Option_userVariable_name_required": {
- "message": "Le nom de variable est requis"
- },
- "Option_userVariable_value": {
- "message": "Valeur"
- },
- "Option_userVariables": {
- "message": "Variables Utilisateur"
- },
- "Option_userVariables_desc": {
- "message": "Définir des variables personnalisées pour les champs de saisie (max 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "Le nom de variable doit commencer par une lettre et ne contenir que des lettres, chiffres et traits de soulignement"
- },
"Option_commandType": {
"message": "Type de Commande"
},
@@ -1096,5 +1225,23 @@
},
"prompthistory_banner_description": {
"message": "Un nouvel outil pour réutiliser les prompts pour ChatGPT et plus✨"
+ },
+ "Option_windowState": {
+ "message": "Taille de la fenêtre"
+ },
+ "Option_windowState_desc": {
+ "message": "Taille de la fenêtre à afficher"
+ },
+ "Option_windowState_normal": {
+ "message": "Normal"
+ },
+ "Option_windowState_maximized": {
+ "message": "Maximisé"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Plein écran"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "Impossible d'exécuter sur cette page (l'URL de la page ne correspond pas)"
}
}
diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json
index ec4a58c2..c7fe7ee7 100644
--- a/packages/extension/public/_locales/hi/messages.json
+++ b/packages/extension/public/_locales/hi/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "मेनू प्रदर्शन एनिमेशन"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "पॉपअप ऑटो-क्लोज़ विलंब"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "फोकस खोने के बाद पॉपअप के स्वचालित रूप से बंद होने से पहले विलंब का समय सेट करें। तुरंत बंद करने के लिए 0 सेट करें या खाली छोड़ दें।\nअधिकतम: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (तुरंत बंद करें)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "साइड पैनल स्वतः छिपाएं"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "कमांड के माध्यम से साइड पैनल खुले होने पर मुख्य पैनल पर क्लिक करने पर साइड पैनल को स्वतः छिपा देता है।"
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "साइड पैनल स्वतः छिपाएं"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "लिंक पूर्वावलोकन प्रदर्शित करते समय मुख्य पैनल पर क्लिक करने पर साइड पैनल को स्वतः छिपा देता है।"
+ },
"Option_inherit": {
"message": "प्राप्त करें"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "प्रारंभिक URL के संशोधित होने पर आइकन URL स्वचालित रूप से अपडेट होगा।"
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "मेनू आइकन के रूप में प्रदर्शित होता है।"
+ },
"Option_iconUrl_autofill": {
"message": "स्वतः भरें"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "प्रतिशत (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "डैश (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "अंडरस्कोर (_)"
+ },
"Option_openMode": {
"message": "खुलने का मोड"
},
+ "Option_displayMode": {
+ "message": "विंडो प्रदर्शन विधि"
+ },
"Option_openMode_popup": {
"message": "पॉपअप"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "बैकग्राउंड टैब"
},
+ "Option_openMode_sidePanel": {
+ "message": "साइड पैनल"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "साइड पैनल में खोलें।"
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "पेज एक्शन"
},
- "Option_displayMode": {
- "message": "विंडो प्रदर्शन विधि"
+ "Option_openMode_aiPrompt": {
+ "message": "AI प्रॉम्प्ट"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "AI सेवा पर पूर्व-निर्धारित प्रॉम्प्ट चलाएं"
},
"Option_commandType_title": {
"message": "कमांड प्रकार चुनें"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "वेब पेज ऑपरेशन रिकॉर्ड और रीप्ले करें"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "AI प्रॉम्प्ट"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "AI सेवा पर पूर्व-निर्धारित प्रॉम्प्ट चलाएं"
+ },
"Option_commandType_copy_title": {
"message": "टेक्स्ट कॉपी करें"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "विंडो"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "साइड पैनल"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + क्लिक"
},
"Option_parentFolderId": {
"message": "फ़ोल्डर"
},
+ "Option_parentFolder": {
+ "message": "मूल फ़ोल्डर"
+ },
+ "Option_parentFolder_desc": {
+ "message": "मूल फ़ोल्डर चुनें"
+ },
+ "Option_rootFolder": {
+ "message": "मूल (कोई माता-पिता नहीं)"
+ },
"Option_copyOption": {
"message": "कॉपी प्रारूप"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "पेज का URL जहां एक्शन शुरू होता है।"
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "\"वर्तमान टैब\" के लिए, केवल इस URL से मेल खाने वाले पेज ही चलाए जा सकते हैं। आप वाइल्डकार्ड \"*\" का उपयोग कर सकते हैं।"
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "क्रियाएं रिकॉर्ड करना शुरू करने के लिए पृष्ठ का URL। केवल सेटिंग स्क्रीन में उपयोग किया जाता है।"
+ },
+ "Option_pageUrl": {
+ "message": "पृष्ठ URL"
+ },
+ "Option_pageUrl_desc": {
+ "message": "केवल इस URL से मेल खाने वाले पृष्ठों पर ही निष्पादित किया जा सकता है। आप वाइल्डकार्ड «*» का उपयोग कर सकते हैं।"
+ },
"Option_pageAction_title": {
"message": "एक्शन"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "प्रतीक्षा समय"
},
+ "Option_aiPrompt_service": {
+ "message": "AI सेवा"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "उपयोग करने के लिए AI सेवा चुनें"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "प्रॉम्प्ट"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "प्रॉम्प्ट टेम्पलेट। वेरिएबल जोड़ने के लिए इन्सर्ट मेनू का उपयोग करें (चुना गया टेक्स्ट, URL, क्लिपबोर्ड)।"
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "यहाँ अपना प्रॉम्प्ट दर्ज करें..."
+ },
+ "Option_userVariables": {
+ "message": "उपयोगकर्ता चर"
+ },
+ "Option_userVariables_desc": {
+ "message": "इनपुट फ़ील्ड के लिए कस्टम वेरिएबल परिभाषित करें (अधिकतम 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "वेरिएबल नाम"
+ },
+ "Option_userVariable_value": {
+ "message": "मान"
+ },
+ "Option_userVariable_add": {
+ "message": "वेरिएबल जोड़ें"
+ },
+ "Option_userVariable_name_required": {
+ "message": "वेरिएबल नाम आवश्यक है"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "वेरिएबल नाम अक्षर से शुरू होना चाहिए और केवल अक्षर, संख्या और अंडरस्कोर हो सकते हैं"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "वेरिएबल नाम पहले से मौजूद है"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "अधिकतम $max$ वेरिएबल की अनुमति",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "तत्काल आदेश"
+ },
+ "Option_instantCommand_desc": {
+ "message": "एक संशोधक कुंजी दबाए रखने के दौरान टेक्स्ट का चयन करने पर निर्दिष्ट आदेश तुरंत निष्पादित करता है।"
+ },
+ "Option_instantCommandEnabled": {
+ "message": "तत्काल आदेश सक्षम करें"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "सक्षम होने पर, मेनू प्रदर्शित किए बिना आदेश तुरंत निष्पादित होंगे।"
+ },
+ "Option_instantCommandId": {
+ "message": "निष्पादित करने के लिए आदेश"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "संशोधक कुंजी के साथ निष्पादित करने के लिए आदेश चुनें।"
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "आदेश चुनें..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "संशोधक कुंजी"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "टेक्स्ट का चयन करते समय दबाए रखने के लिए कुंजी चुनें।"
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "कुंजी चुनें..."
+ },
"Option_linkCommand": {
"message": "लिंक प्रीव्यू"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "प्रीव्यू तक शेष समय दिखाएं।"
},
+ "Option_windowSettings": {
+ "message": "विंडो सेटिंग्स"
+ },
+ "Option_windowSettings_desc": {
+ "message": "विंडो व्यवहार और पॉपअप सेटिंग्स को कॉन्फ़िगर करें।"
+ },
"Option_folders": {
"message": "फ़ोल्डर्स"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "बॉर्डर रंग"
},
+ "Option_userStyles_option_font_color": {
+ "message": "फ़ॉन्ट का रंग"
+ },
"Option_userStyles_desc_border_color": {
"message": "मेनू का बॉर्डर रंग निर्दिष्ट करें। डिफ़ॉल्ट: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "फ़ॉन्ट का रंग निर्दिष्ट करें। डिफ़ॉल्ट: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "फ़ॉन्ट साइज़ स्केल"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "वेरिएबल नाम अक्षर से शुरू होना चाहिए और केवल अक्षर, संख्या और अंडरस्कोर हो सकते हैं"
+ },
"Option_zod_string_min": {
"message": "कम से कम $key$ वर्णों की स्ट्रिंग दर्ज करें।",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "URL प्रारूप में दर्ज करें।"
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "वर्तमान टैब मोड चुने जाने पर पेज URL आवश्यक है।"
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "चयनित टेक्स्ट डाला जाएगा",
"description": "for INSERT.SELECTED_TEXT"
@@ -905,6 +1073,12 @@
"Option_openMode_backgroundTab_desc": {
"message": "बैकग्राउंड में टैब खोलें।वर्तमान टैब के दाईं ओर दिखाएँ।"
},
+ "Option_openMode_currentTab": {
+ "message": "वर्तमान टैब"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "वर्तमान सक्रिय टैब पर चलाएं।URL रिकॉर्ड की गई प्रारंभ URL से मेल खाना चाहिए।"
+ },
"Option_title_desc": {
"message": "कमांड शीर्षक के रूप में प्रदर्शित होता है।"
},
@@ -917,30 +1091,15 @@
"Option_openModeSecondary_desc": {
"message": "मेन्यू पर Ctrl + क्लिक करने पर व्यवहार।"
},
- "Option_parentFolder": {
- "message": "मूल फ़ोल्डर"
- },
- "Option_parentFolder_desc": {
- "message": "मूल फ़ोल्डर चुनें"
- },
- "Option_rootFolder": {
- "message": "मूल (कोई माता-पिता नहीं)"
- },
"Option_searchUrlAssist": {
"message": "AI सहायक"
},
+ "Option_searchUrlAssist_title": {
+ "message": "खोज URL सहायक"
+ },
"Option_searchUrlAssist_desc": {
"message": "खोज कीवर्ड और खोज परिणाम पेज का URL दर्ज करें, फिर एक्जीक्यूट बटन दबाएं।\nAI एक खोज URL गेनरेट करेगा।"
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Gemini के साथ चलाएं"
- },
- "Option_searchUrlAssist_executing": {
- "message": "चला रहा है..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "उपयोग की विधि"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) खोज कीवर्ड"
},
@@ -953,6 +1112,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "उपयोग की विधि"
+ },
"Option_searchUrlAssist_step1": {
"message": "\"(1) खोज कीवर्ड\" में कोई भी शब्द दर्ज करें"
},
@@ -968,8 +1130,11 @@
"Option_searchUrlAssist_step5": {
"message": "जेनरेट किया गया खोज URL कॉपी करके कमांड के खोज URL फ़ील्ड में पेस्ट करें"
},
- "Option_searchUrlAssist_title": {
- "message": "खोज URL सहायक"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Gemini के साथ चलाएं"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "चला रहा है..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "कृपया खोज कीवर्ड दर्ज करें"
@@ -977,42 +1142,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "कृपया वैध URL दर्ज करें"
},
- "Option_userVariable_add": {
- "message": "वेरिएबल जोड़ें"
- },
- "Option_userVariable_max_reached": {
- "message": "अधिकतम $max$ वेरिएबल की अनुमति",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "वेरिएबल नाम"
- },
- "Option_userVariable_name_duplicate": {
- "message": "वेरिएबल नाम पहले से मौजूद है"
- },
- "Option_userVariable_name_invalid": {
- "message": "वेरिएबल नाम अक्षर से शुरू होना चाहिए और केवल अक्षर, संख्या और अंडरस्कोर हो सकते हैं"
- },
- "Option_userVariable_name_required": {
- "message": "वेरिएबल नाम आवश्यक है"
- },
- "Option_userVariable_value": {
- "message": "मान"
- },
- "Option_userVariables": {
- "message": "उपयोगकर्ता चर"
- },
- "Option_userVariables_desc": {
- "message": "इनपुट फ़ील्ड के लिए कस्टम वेरिएबल परिभाषित करें (अधिकतम 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "वेरिएबल नाम अक्षर से शुरू होना चाहिए और केवल अक्षर, संख्या और अंडरस्कोर हो सकते हैं"
- },
"Option_commandType": {
"message": "कमांड प्रकार"
},
@@ -1096,5 +1225,23 @@
},
"prompthistory_banner_description": {
"message": "ChatGPT और अधिक के लिए प्रॉम्प्ट का पुन: उपयोग करने के लिए एक नया उपकरण।✨"
+ },
+ "Option_windowState": {
+ "message": "विंडो का आकार"
+ },
+ "Option_windowState_desc": {
+ "message": "प्रदर्शित करने के लिए विंडो का आकार"
+ },
+ "Option_windowState_normal": {
+ "message": "सामान्य"
+ },
+ "Option_windowState_maximized": {
+ "message": "अधिकतम"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "पूर्ण स्क्रीन"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "इस पेज पर नहीं चला सकते (पृष्ठ URL मेल नहीं खाता)"
}
}
diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json
index ad3b4398..0dbd2d78 100644
--- a/packages/extension/public/_locales/id/messages.json
+++ b/packages/extension/public/_locales/id/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Animasi Tampilan Menu"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Penundaan Penutupan Otomatis Popup"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Atur waktu penundaan sebelum popup secara otomatis menutup setelah kehilangan fokus. Atur ke 0 atau biarkan kosong untuk penutupan segera.\nMaksimum: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (tutup segera)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Sembunyikan Panel Samping Otomatis"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Secara otomatis menyembunyikan panel samping saat panel utama diklik saat panel samping terbuka melalui perintah."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Sembunyikan Panel Samping Otomatis"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Secara otomatis menyembunyikan panel samping saat panel utama diklik saat menampilkan pratinjau tautan."
+ },
"Option_inherit": {
"message": "Warisi"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "Diperbarui secara otomatis saat URL Mulai diubah."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Ditampilkan sebagai ikon menu."
+ },
"Option_iconUrl_autofill": {
"message": "Isi Otomatis"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Persen (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Tanda hubung (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Garis bawah (_)"
+ },
"Option_openMode": {
"message": "Mode Buka"
},
+ "Option_displayMode": {
+ "message": "Metode Tampilan Jendela"
+ },
"Option_openMode_popup": {
"message": "Pop-up"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Tab Latar Belakang"
},
+ "Option_openMode_sidePanel": {
+ "message": "Panel Samping"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Buka di panel samping."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "Tindakan Halaman"
},
- "Option_displayMode": {
- "message": "Metode Tampilan Jendela"
+ "Option_openMode_aiPrompt": {
+ "message": "Prompt AI"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Jalankan prompt yang telah ditentukan pada layanan AI"
},
"Option_commandType_title": {
"message": "Pilih Jenis Perintah"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Rekam dan putar operasi halaman web"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "Prompt AI"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Jalankan prompt yang telah ditentukan pada layanan AI"
+ },
"Option_commandType_copy_title": {
"message": "Salin Teks"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "Jendela"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Panel Samping"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + Klik"
},
"Option_parentFolderId": {
"message": "Folder"
},
+ "Option_parentFolder": {
+ "message": "Folder Induk"
+ },
+ "Option_parentFolder_desc": {
+ "message": "Pilih folder induk"
+ },
+ "Option_rootFolder": {
+ "message": "Root (tidak ada induk)"
+ },
"Option_copyOption": {
"message": "Format salinan"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL halaman di mana tindakan dimulai."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "Untuk \"Tab Saat Ini\", hanya halaman yang cocok dengan URL ini yang dapat dijalankan. Anda dapat menggunakan wildcard \"*\"."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL halaman untuk memulai perekaman tindakan. Hanya digunakan di layar pengaturan."
+ },
+ "Option_pageUrl": {
+ "message": "URL Halaman"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Hanya dapat dijalankan di halaman yang sesuai dengan URL ini. Anda dapat menggunakan wildcard «*»."
+ },
"Option_pageAction_title": {
"message": "Tindakan"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "Waktu tunda"
},
+ "Option_aiPrompt_service": {
+ "message": "Layanan AI"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Pilih layanan AI yang akan digunakan"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Prompt"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Template prompt. Gunakan menu sisipan untuk menambahkan variabel (teks pilihan, URL, clipboard)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Masukkan prompt Anda di sini..."
+ },
+ "Option_userVariables": {
+ "message": "Variabel Pengguna"
+ },
+ "Option_userVariables_desc": {
+ "message": "Tentukan variabel kustom untuk field input (maks 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "Nama variabel"
+ },
+ "Option_userVariable_value": {
+ "message": "Nilai"
+ },
+ "Option_userVariable_add": {
+ "message": "Tambah variabel"
+ },
+ "Option_userVariable_name_required": {
+ "message": "Nama variabel diperlukan"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "Nama variabel harus dimulai dengan huruf dan hanya berisi huruf, angka, dan garis bawah"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "Nama variabel sudah ada"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "Maksimal $max$ variabel diizinkan",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "Perintah Instan"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Mengeksekusi perintah yang ditentukan secara instan saat teks dipilih sambil menahan tombol pengubah."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Aktifkan Perintah Instan"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "Saat diaktifkan, perintah akan dieksekusi secara instan tanpa menampilkan menu."
+ },
+ "Option_instantCommandId": {
+ "message": "Perintah yang akan dieksekusi"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Pilih perintah yang akan dieksekusi bersama tombol pengubah."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Pilih perintah..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Tombol Pengubah"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Pilih tombol yang harus ditekan saat memilih teks."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Pilih tombol..."
+ },
"Option_linkCommand": {
"message": "Pratinjau Tautan"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Menampilkan sisa ke pratinjau."
},
+ "Option_windowSettings": {
+ "message": "Pengaturan Jendela"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Konfigurasi perilaku jendela dan pengaturan popup."
+ },
"Option_folders": {
"message": "Folder"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Warna Border"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Warna font"
+ },
"Option_userStyles_desc_border_color": {
"message": "Tentukan warna border menu. Default: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Tentukan warna font. Default: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Penskalaan ukuran font"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "Nama variabel harus dimulai dengan huruf dan hanya berisi huruf, angka, dan garis bawah"
+ },
"Option_zod_string_min": {
"message": "Silakan masukkan string $key$ karakter atau lebih.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "Silakan masukkan dalam format URL."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "URL halaman wajib diisi saat mode tab saat ini dipilih."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "Teks yang dipilih akan dimasukkan",
"description": "for INSERT.SELECTED_TEXT"
@@ -908,6 +1076,12 @@
"Option_openMode_backgroundTab_desc": {
"message": "Buka tab di latar belakang.Tampilkan di sebelah kanan tab saat ini."
},
+ "Option_openMode_currentTab": {
+ "message": "Tab Saat Ini"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Jalankan di tab aktif saat ini.URL harus cocok dengan URL awal yang direkam."
+ },
"Option_title_desc": {
"message": "Ditampilkan sebagai judul perintah."
},
@@ -920,30 +1094,15 @@
"Option_openModeSecondary_desc": {
"message": "Perilaku saat Ctrl + klik menu."
},
- "Option_parentFolder": {
- "message": "Folder Induk"
- },
- "Option_parentFolder_desc": {
- "message": "Pilih folder induk"
- },
- "Option_rootFolder": {
- "message": "Root (tidak ada induk)"
- },
"Option_searchUrlAssist": {
"message": "Asisten AI"
},
+ "Option_searchUrlAssist_title": {
+ "message": "Asisten URL Pencarian"
+ },
"Option_searchUrlAssist_desc": {
"message": "Masukkan kata kunci pencarian dan URL halaman hasil pencarian, lalu klik tombol eksekusi.\nAI akan menghasilkan URL pencarian."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Jalankan dengan Gemini"
- },
- "Option_searchUrlAssist_executing": {
- "message": "Menjalankan..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "Cara penggunaan"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) Kata kunci pencarian"
},
@@ -956,6 +1115,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "Cara penggunaan"
+ },
"Option_searchUrlAssist_step1": {
"message": "Masukkan kata apa pun di \"(1) Kata kunci pencarian\""
},
@@ -971,8 +1133,11 @@
"Option_searchUrlAssist_step5": {
"message": "Salin URL pencarian yang dihasilkan dan tempelkan ke bidang URL pencarian perintah"
},
- "Option_searchUrlAssist_title": {
- "message": "Asisten URL Pencarian"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Jalankan dengan Gemini"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "Menjalankan..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "Silakan masukkan kata kunci pencarian"
@@ -980,42 +1145,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "Silakan masukkan URL yang valid"
},
- "Option_userVariable_add": {
- "message": "Tambah variabel"
- },
- "Option_userVariable_max_reached": {
- "message": "Maksimal $max$ variabel diizinkan",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "Nama variabel"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Nama variabel sudah ada"
- },
- "Option_userVariable_name_invalid": {
- "message": "Nama variabel harus dimulai dengan huruf dan hanya berisi huruf, angka, dan garis bawah"
- },
- "Option_userVariable_name_required": {
- "message": "Nama variabel diperlukan"
- },
- "Option_userVariable_value": {
- "message": "Nilai"
- },
- "Option_userVariables": {
- "message": "Variabel Pengguna"
- },
- "Option_userVariables_desc": {
- "message": "Tentukan variabel kustom untuk field input (maks 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "Nama variabel harus dimulai dengan huruf dan hanya berisi huruf, angka, dan garis bawah"
- },
"Option_commandType": {
"message": "Jenis Perintah"
},
@@ -1099,5 +1228,23 @@
},
"prompthistory_banner_description": {
"message": "Alat baru untuk menggunakan kembali prompt untuk ChatGPT dan lainnya✨"
+ },
+ "Option_windowState": {
+ "message": "Ukuran jendela"
+ },
+ "Option_windowState_desc": {
+ "message": "Ukuran jendela yang ditampilkan"
+ },
+ "Option_windowState_normal": {
+ "message": "Normal"
+ },
+ "Option_windowState_maximized": {
+ "message": "Maksimalkan"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Layar penuh"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "Tidak dapat dijalankan di halaman ini (URL Halaman tidak cocok)"
}
}
diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json
index a1740851..9ebca135 100644
--- a/packages/extension/public/_locales/it/messages.json
+++ b/packages/extension/public/_locales/it/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Animazione Visualizzazione Menu"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Ritardo Chiusura Automatica Popup"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Imposta il tempo di ritardo prima che il popup si chiuda automaticamente dopo aver perso il focus. Imposta 0 o lascia vuoto per la chiusura immediata.\nMassimo: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (chiudi immediatamente)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Nascondi automaticamente il pannello laterale"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Nasconde automaticamente il pannello laterale quando si fa clic sul pannello principale mentre il pannello laterale è aperto tramite un comando."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Nascondi automaticamente il pannello laterale"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Nasconde automaticamente il pannello laterale quando si fa clic sul pannello principale durante la visualizzazione dell'anteprima del collegamento."
+ },
"Option_inherit": {
"message": "Eredita"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "L'URL dell'icona verrà aggiornato automaticamente quando l'URL iniziale viene modificato."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Visualizzato come icona del menu."
+ },
"Option_iconUrl_autofill": {
"message": "Compilazione automatica"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Percentuale (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Trattino (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Trattino basso (_)"
+ },
"Option_openMode": {
"message": "Modalità apertura"
},
+ "Option_displayMode": {
+ "message": "Metodo di Visualizzazione Finestra"
+ },
"Option_openMode_popup": {
"message": "Pop-up"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Scheda in background"
},
+ "Option_openMode_sidePanel": {
+ "message": "Pannello laterale"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Apri nel pannello laterale."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "Azione pagina"
},
- "Option_displayMode": {
- "message": "Metodo di Visualizzazione Finestra"
+ "Option_openMode_aiPrompt": {
+ "message": "Prompt AI"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Esegui un prompt predefinito su un servizio AI"
},
"Option_commandType_title": {
"message": "Seleziona Tipo di Comando"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Registra e riproduci operazioni delle pagine web"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "Prompt AI"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Esegui un prompt predefinito su un servizio AI"
+ },
"Option_commandType_copy_title": {
"message": "Copia Testo"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "Finestra"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Pannello laterale"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + clic"
},
"Option_parentFolderId": {
"message": "Cartella"
},
+ "Option_parentFolder": {
+ "message": "Cartella Genitore"
+ },
+ "Option_parentFolder_desc": {
+ "message": "Seleziona cartella genitore"
+ },
+ "Option_rootFolder": {
+ "message": "Radice (nessun genitore)"
+ },
"Option_copyOption": {
"message": "Formato copia"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL della pagina dove inizia l'azione."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "Per \"Scheda Corrente\", è possibile eseguire solo le pagine corrispondenti a questo URL. È possibile utilizzare il carattere jolly \"*\"."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL della pagina da cui avviare la registrazione delle azioni. Utilizzato solo nella schermata delle impostazioni."
+ },
+ "Option_pageUrl": {
+ "message": "URL della pagina"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Può essere eseguito solo sulle pagine corrispondenti a questo URL. È possibile utilizzare il carattere jolly «*»."
+ },
"Option_pageAction_title": {
"message": "Azione"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "Tempo di attesa"
},
+ "Option_aiPrompt_service": {
+ "message": "Servizio AI"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Seleziona il servizio AI da utilizzare"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Prompt"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Modello prompt. Usa il menu di inserimento per aggiungere variabili (testo selezionato, URL, appunti)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Inserisci il tuo prompt qui..."
+ },
+ "Option_userVariables": {
+ "message": "Variabili Utente"
+ },
+ "Option_userVariables_desc": {
+ "message": "Definisci variabili personalizzate per i campi di input (max 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "Nome variabile"
+ },
+ "Option_userVariable_value": {
+ "message": "Valore"
+ },
+ "Option_userVariable_add": {
+ "message": "Aggiungi variabile"
+ },
+ "Option_userVariable_name_required": {
+ "message": "Il nome della variabile è richiesto"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "Il nome della variabile deve iniziare con una lettera e contenere solo lettere, numeri e trattini bassi"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "Il nome della variabile esiste già"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "Massimo $max$ variabili consentite",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "Comando Istantaneo"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Esegue un comando specificato istantaneamente quando il testo viene selezionato tenendo premuto un tasto modificatore."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Abilita Comando Istantaneo"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "Quando abilitato, i comandi verranno eseguiti istantaneamente senza visualizzare il menu."
+ },
+ "Option_instantCommandId": {
+ "message": "Comando da eseguire"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Seleziona il comando che verrà eseguito insieme al tasto modificatore."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Seleziona comando..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Tasto Modificatore"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Seleziona il tasto da tenere premuto durante la selezione del testo."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Seleziona tasto..."
+ },
"Option_linkCommand": {
"message": "Anteprima link"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Mostra il tempo rimanente fino all'anteprima."
},
+ "Option_windowSettings": {
+ "message": "Impostazioni Finestra"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Configura il comportamento della finestra e le impostazioni popup."
+ },
"Option_folders": {
"message": "Cartelle"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Colore bordo"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Colore carattere"
+ },
"Option_userStyles_desc_border_color": {
"message": "Specifica il colore del bordo del menu. Predefinito: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Specifica il colore del carattere. Predefinito: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Scala dimensione font"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "Il nome della variabile deve iniziare con una lettera e contenere solo lettere, numeri e trattini bassi"
+ },
"Option_zod_string_min": {
"message": "Inserisci una stringa con almeno $key$ caratteri.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "Inserisci nel formato URL."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "L'URL della pagina è obbligatorio quando è selezionata la modalità scheda corrente."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "Il testo selezionato verrà inserito",
"description": "for INSERT.SELECTED_TEXT"
@@ -903,41 +1071,14 @@
"message": "Mostra scheda e attivala.Mostra a destra della scheda corrente."
},
"Option_openMode_backgroundTab_desc": {
- "Option_userVariables": {
- "message": "Variabili Utente"
- },
- "Option_userVariables_tooltip": {
- "message": "Definisci variabili personalizzate da utilizzare nelle tue Page Actions. Queste variabili possono essere utilizzate nei campi di input con {{nomeVariabile}}."
- },
- "Option_userVariable_name": {
- "message": "Nome"
- },
- "Option_userVariable_value": {
- "message": "Valore"
- },
- "Option_userVariable_add": {
- "message": "Aggiungi Variabile"
- },
- "Option_userVariable_name_required": {
- "message": "Il nome della variabile è richiesto"
- },
- "Option_userVariable_name_invalid": {
- "message": "Il nome deve iniziare con una lettera e può contenere solo lettere, numeri e underscore"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Il nome della variabile esiste già"
- },
- "Option_userVariable_max_reached": {
- "message": "Sono consentite massimo $max$ variabili",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
"message": "Apri scheda in background.Mostra a destra della scheda corrente."
},
+ "Option_openMode_currentTab": {
+ "message": "Scheda corrente"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Esegui sulla scheda attiva corrente.L'URL deve corrispondere all'URL di avvio registrata."
+ },
"Option_title_desc": {
"message": "Visualizzato come titolo del comando."
},
@@ -950,30 +1091,15 @@
"Option_openModeSecondary_desc": {
"message": "Comportamento quando si fa Ctrl + clic sul menu."
},
- "Option_parentFolder": {
- "message": "Cartella Genitore"
- },
- "Option_parentFolder_desc": {
- "message": "Seleziona cartella genitore"
- },
- "Option_rootFolder": {
- "message": "Radice (nessun genitore)"
- },
"Option_searchUrlAssist": {
"message": "Assistente IA"
},
+ "Option_searchUrlAssist_title": {
+ "message": "Assistente URL di Ricerca"
+ },
"Option_searchUrlAssist_desc": {
"message": "Inserisci una parola chiave di ricerca e l'URL della pagina dei risultati di ricerca, poi clicca il pulsante esegui.\nL'IA genererà un URL di ricerca."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Esegui con Gemini"
- },
- "Option_searchUrlAssist_executing": {
- "message": "Esecuzione in corso..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "Modalità d'uso"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) Parola chiave di ricerca"
},
@@ -986,6 +1112,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "Modalità d'uso"
+ },
"Option_searchUrlAssist_step1": {
"message": "Inserisci qualsiasi parola in \"(1) Parola chiave di ricerca\""
},
@@ -1001,8 +1130,11 @@
"Option_searchUrlAssist_step5": {
"message": "Copia e incolla l'URL di ricerca generato nel campo URL di ricerca del comando"
},
- "Option_searchUrlAssist_title": {
- "message": "Assistente URL di Ricerca"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Esegui con Gemini"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "Esecuzione in corso..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "Inserisci una parola chiave di ricerca"
@@ -1010,42 +1142,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "Inserisci un URL valido"
},
- "Option_userVariable_add": {
- "message": "Aggiungi variabile"
- },
- "Option_userVariable_max_reached": {
- "message": "Massimo $max$ variabili consentite",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "Nome variabile"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Il nome della variabile esiste già"
- },
- "Option_userVariable_name_invalid": {
- "message": "Il nome della variabile deve iniziare con una lettera e contenere solo lettere, numeri e trattini bassi"
- },
- "Option_userVariable_name_required": {
- "message": "Il nome della variabile è richiesto"
- },
- "Option_userVariable_value": {
- "message": "Valore"
- },
- "Option_userVariables": {
- "message": "Variabili Utente"
- },
- "Option_userVariables_desc": {
- "message": "Definisci variabili personalizzate per i campi di input (max 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "Il nome della variabile deve iniziare con una lettera e contenere solo lettere, numeri e trattini bassi"
- },
"Option_commandType": {
"message": "Tipo di Comando"
},
@@ -1129,5 +1225,23 @@
},
"prompthistory_banner_description": {
"message": "Un nuovo strumento per riutilizzare i prompt per ChatGPT e altro✨"
+ },
+ "Option_windowState": {
+ "message": "Dimensione finestra"
+ },
+ "Option_windowState_desc": {
+ "message": "Dimensione della finestra da visualizzare"
+ },
+ "Option_windowState_normal": {
+ "message": "Normale"
+ },
+ "Option_windowState_maximized": {
+ "message": "Massimizzato"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Schermo intero"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "Impossibile eseguire su questa pagina (l'URL della pagina non corrisponde)"
}
}
diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json
index 5d956f30..551e0c9d 100644
--- a/packages/extension/public/_locales/ja/messages.json
+++ b/packages/extension/public/_locales/ja/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "メニュー表示アニメーション"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "ポップアップ自動クローズまでの時間"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "ポップアップウィンドウのフォーカスが外れてから自動的に閉じるまでの時間を設定します。0または未設定の場合は即座に閉じます。 \n最大:10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (即座に閉じる)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "サイドパネル自動非表示"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "コマンドによりサイドパネルを表示している時、メインのパネルをクリックしたときに、サイドパネルを自動的に非表示にします。"
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "サイドパネル自動非表示"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "リンクプレビューの表示中、メインのパネルをクリックしたときに、サイドパネルを自動的に非表示にします。"
+ },
"Option_inherit": {
"message": "継承"
},
@@ -230,18 +251,12 @@
"Option_title": {
"message": "タイトル"
},
- "Option_title_desc": {
- "message": "コマンドのタイトルとして表示されます。"
- },
"Option_searchUrl": {
"message": "検索URL"
},
"Option_searchUrl_desc": {
"message": "`%s`を選択テキストに置き換えて表示します。"
},
- "Option_searchUrl_desc_api": {
- "message": "URLに`fetch api`を実行します。"
- },
"Option_iconUrl": {
"message": "アイコンURL"
},
@@ -254,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "開始URLを変更すると自動で更新します。"
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "メニューのアイコンとして表示されます。"
+ },
"Option_iconUrl_autofill": {
"message": "自動検出"
},
@@ -269,15 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Percent (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Dash (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Underscore (_)"
+ },
"Option_openMode": {
"message": "Open Mode"
},
"Option_displayMode": {
"message": "ウィンドウ表示方法"
},
- "Option_displayMode_desc": {
- "message": "ウィンドウの表示方法です。"
- },
"Option_openMode_popup": {
"message": "ポップアップ"
},
@@ -290,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "バックグラウンドタブ"
},
+ "Option_openMode_sidePanel": {
+ "message": "サイドパネル"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "サイドパネルで開く。"
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -305,17 +332,11 @@
"Option_openMode_pageAction": {
"message": "ページアクション"
},
- "Option_openMode_popup_desc": {
- "message": "シンプルなUIのウィンドウ。フォーカスを外すと自動で消える。"
+ "Option_openMode_aiPrompt": {
+ "message": "AIプロンプト"
},
- "Option_openMode_window_desc": {
- "message": "新しいウィンドウを開く。"
- },
- "Option_openMode_tab_desc": {
- "message": "タブを表示してアクティブにする。表示中タブの右側に表示する。"
- },
- "Option_openMode_backgroundTab_desc": {
- "message": "タブをバックグラウンドで開く。表示中タブの右側に表示する。"
+ "Option_openMode_aiPrompt_desc": {
+ "message": "AIサービスで定義済みのプロンプトを実行します"
},
"Option_commandType_title": {
"message": "コマンドの種類を選ぶ"
@@ -323,9 +344,6 @@
"Option_commandType_description": {
"message": "作成するコマンドの種類を選択してください"
},
- "Option_commandType": {
- "message": "コマンドの種類"
- },
"Option_commandType_search_title": {
"message": "検索"
},
@@ -338,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Webページ上の操作を記録・再生します"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "AIプロンプト"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "AIサービスで定義済みのプロンプトを実行します"
+ },
"Option_commandType_copy_title": {
"message": "テキストコピー"
},
@@ -362,9 +386,6 @@
"Option_commandType_api_desc": {
"message": "APIにfetchリクエストを送信します"
},
- "Option_commandType_on_edit": {
- "message": "一度保存したコマンドの種類は変更できません"
- },
"Option_commandGroup_webPage_title": {
"message": "Webページ起動"
},
@@ -374,72 +395,18 @@
"Option_commandGroup_experimental_title": {
"message": "実験的"
},
- "Option_openModeCard_popup_title": {
- "message": "ポップアップ"
- },
- "Option_openModeCard_popup_desc": {
- "message": "選択テキストをポップアップウィンドウで開きます"
- },
- "Option_openModeCard_window_title": {
- "message": "新しいウィンドウ"
- },
- "Option_openModeCard_window_desc": {
- "message": "選択テキストを新しいウィンドウで開きます"
- },
- "Option_openModeCard_tab_title": {
- "message": "新しいタブ"
- },
- "Option_openModeCard_tab_desc": {
- "message": "選択テキストを新しいタブで開きます"
- },
- "Option_openModeCard_backgroundTab_title": {
- "message": "バックグラウンドタブ"
- },
- "Option_openModeCard_backgroundTab_desc": {
- "message": "選択テキストをバックグラウンドタブで開きます"
- },
- "Option_openModeCard_api_title": {
- "message": "API"
- },
- "Option_openModeCard_api_desc": {
- "message": "APIにリクエストを送信して結果を表示します"
- },
- "Option_openModeCard_pageAction_title": {
- "message": "ページアクション"
- },
- "Option_openModeCard_pageAction_desc": {
- "message": "ブラウザ操作を記録・再生します"
- },
- "Option_openModeCard_linkPopup_title": {
- "message": "リンクポップアップ"
- },
- "Option_openModeCard_linkPopup_desc": {
- "message": "選択されたリンクをポップアップで開きます"
- },
- "Option_openModeCard_copy_title": {
- "message": "テキストコピー"
- },
- "Option_openModeCard_copy_desc": {
- "message": "選択テキストをクリップボードにコピーします"
- },
- "Option_openModeCard_getTextStyles_title": {
- "message": "スタイル取得"
- },
- "Option_openModeCard_getTextStyles_desc": {
- "message": "選択テキストのスタイル情報を取得します"
- },
"Option_openMode_previewPopup": {
- "message": "Popup"
+ "message": "ポップアップ"
},
"Option_openMode_previewWindow": {
- "message": "Window"
+ "message": "ウィンドウ"
+ },
+ "Option_openMode_previewSidePanel": {
+ "message": "サイドパネル"
},
"Option_openModeSecondary": {
"message": " ┗ Ctrl + クリック"
},
- "Option_openModeSecondary_desc": {
- "message": "メニューをCtrl + クリックしたときの動作です。"
- },
"Option_parentFolderId": {
"message": "フォルダ"
},
@@ -479,6 +446,18 @@
"Option_startUrl_desc": {
"message": "アクションを開始するページのURL"
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "「現在のタブ」の場合は、このURLにマッチしたページのみ実行できます。ワイルドカード「*」を使用できます。"
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "アクションの記録を開始するページのURL。設定画面でのみ使用します。"
+ },
+ "Option_pageUrl": {
+ "message": "ページURL"
+ },
+ "Option_pageUrl_desc": {
+ "message": "このURLにマッチしたページのみ実行できます。ワイルドカード「*」を使用できます。"
+ },
"Option_pageAction_title": {
"message": "Actions"
},
@@ -497,6 +476,21 @@
"Option_pageAction_delay": {
"message": "遅延時間"
},
+ "Option_aiPrompt_service": {
+ "message": "AIサービス"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "使用するAIサービスを選択してください"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "プロンプト"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "プロンプトのテンプレート。挿入メニューで変数(選択テキスト、URL、クリップボード)を追加できます。"
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "プロンプトをここに入力..."
+ },
"Option_userVariables": {
"message": "ユーザー変数"
},
@@ -530,6 +524,36 @@
}
}
},
+ "Option_instantCommand": {
+ "message": "インスタントコマンド"
+ },
+ "Option_instantCommand_desc": {
+ "message": "モディファイアキーを押しながらテキスト選択すると、指定したコマンドを即座に実行します。"
+ },
+ "Option_instantCommandEnabled": {
+ "message": "インスタントコマンドを有効化"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "この機能を有効にすると、メニューを表示せずにコマンドを即座に実行できます。"
+ },
+ "Option_instantCommandId": {
+ "message": "実行するコマンド"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "モディファイアキーと組み合わせて実行するコマンドを選択してください。"
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "コマンドを選択..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "モディファイアキー"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "テキスト選択時に押すキーを選択してください。"
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "キーを選択..."
+ },
"Option_linkCommand": {
"message": "リンクプレビュー"
},
@@ -584,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "プレビューまでの残りを表示します。"
},
+ "Option_windowSettings": {
+ "message": "ウィンドウ設定"
+ },
+ "Option_windowSettings_desc": {
+ "message": "ウィンドウやポップアップの設定を行います。"
+ },
"Option_folders": {
"message": "フォルダ"
},
@@ -620,12 +650,12 @@
"Option_pageRules": {
"message": "ページルール"
},
- "Option_pageRules_desc": {
- "message": "サイト毎の設定値です。"
- },
"Option_pageRules_tooltip": {
"message": "ページルールを作成します。"
},
+ "Option_pageRules_desc": {
+ "message": "サイト毎の設定値です。"
+ },
"Option_pageRules_add": {
"message": "ページルール編集"
},
@@ -671,21 +701,21 @@
"Option_userStyles_option_border_color": {
"message": "枠線色"
},
+ "Option_userStyles_option_font_color": {
+ "message": "文字の色"
+ },
"Option_userStyles_desc_border_color": {
"message": "メニューの枠線色を指定します。デフォルト: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "文字の色を指定します。デフォルト: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "文字サイズの倍率"
},
"Option_userStyles_desc_font_scale": {
"message": "文字サイズの倍率を指定します。デフォルト: 1"
},
- "Option_userStyles_option_font_color": {
- "message": "文字の色"
- },
- "Option_userStyles_desc_font_color": {
- "message": "文字の色を指定します。デフォルト: #0F172A"
- },
"Option_userStyles_option_image_scale": {
"message": "アイコンサイズの倍率"
},
@@ -782,6 +812,9 @@
"Option_zod_url": {
"message": "URL形式で入力してください。"
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "現在のタブ選択中は、ページURLは必須です。"
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "選択テキストが挿入されます"
},
@@ -929,9 +962,6 @@
"review_request_button": {
"message": "レビューする"
},
- "review_request_later": {
- "message": "後で"
- },
"Option_shortcuts": {
"message": "ショートカットキー"
},
@@ -944,14 +974,14 @@
"Option_shortcuts_settings_link": {
"message": "キー設定"
},
- "Option_shortcut_not_set": {
- "message": "未設定"
+ "Option_shortcut_text_selection_only": {
+ "message": "テキスト選択中のみ"
},
"Option_shortcut_select_placeholder": {
"message": "コマンドを選択"
},
- "Option_shortcut_text_selection_only": {
- "message": "テキスト選択中のみ"
+ "Option_shortcut_not_set": {
+ "message": "未設定"
},
"Option_shortcut_no_selection_behavior": {
"message": "テキスト未選択時"
@@ -1028,6 +1058,36 @@
"Option_RestoreFromBackup_failed": {
"message": "バックアップからの復元に失敗しました。"
},
+ "Option_openMode_popup_desc": {
+ "message": "シンプルなUIのウィンドウ。フォーカスを外すと自動で消える。"
+ },
+ "Option_openMode_window_desc": {
+ "message": "新しいウィンドウを開く。"
+ },
+ "Option_openMode_tab_desc": {
+ "message": "タブを表示してアクティブにする。表示中タブの右側に表示する。"
+ },
+ "Option_openMode_backgroundTab_desc": {
+ "message": "タブをバックグラウンドで開く。表示中タブの右側に表示する。"
+ },
+ "Option_openMode_currentTab": {
+ "message": "現在のタブ"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "現在アクティブなタブで実行する。URLが記録時の開始URLと一致する必要がある。"
+ },
+ "Option_title_desc": {
+ "message": "コマンドのタイトルとして表示されます。"
+ },
+ "Option_searchUrl_desc_api": {
+ "message": "URLに`fetch api`を実行します。"
+ },
+ "Option_displayMode_desc": {
+ "message": "ウィンドウの表示方法です。"
+ },
+ "Option_openModeSecondary_desc": {
+ "message": "メニューをCtrl + クリックしたときの動作です。"
+ },
"Option_searchUrlAssist": {
"message": "AI アシスト"
},
@@ -1079,6 +1139,69 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "有効なURLを入力してください"
},
+ "Option_commandType": {
+ "message": "コマンドの種類"
+ },
+ "Option_commandType_on_edit": {
+ "message": "一度保存したコマンドの種類は変更できません"
+ },
+ "Option_openModeCard_api_desc": {
+ "message": "APIにリクエストを送信して結果を表示します"
+ },
+ "Option_openModeCard_api_title": {
+ "message": "API"
+ },
+ "Option_openModeCard_backgroundTab_desc": {
+ "message": "選択テキストをバックグラウンドタブで開きます"
+ },
+ "Option_openModeCard_backgroundTab_title": {
+ "message": "バックグラウンドタブ"
+ },
+ "Option_openModeCard_copy_desc": {
+ "message": "選択テキストをクリップボードにコピーします"
+ },
+ "Option_openModeCard_copy_title": {
+ "message": "テキストコピー"
+ },
+ "Option_openModeCard_getTextStyles_desc": {
+ "message": "選択テキストのスタイル情報を取得します"
+ },
+ "Option_openModeCard_getTextStyles_title": {
+ "message": "スタイル取得"
+ },
+ "Option_openModeCard_linkPopup_desc": {
+ "message": "選択されたリンクをポップアップで開きます"
+ },
+ "Option_openModeCard_linkPopup_title": {
+ "message": "リンクポップアップ"
+ },
+ "Option_openModeCard_pageAction_desc": {
+ "message": "ブラウザ操作を記録・再生します"
+ },
+ "Option_openModeCard_pageAction_title": {
+ "message": "ページアクション"
+ },
+ "Option_openModeCard_popup_desc": {
+ "message": "選択テキストをポップアップウィンドウで開きます"
+ },
+ "Option_openModeCard_popup_title": {
+ "message": "ポップアップ"
+ },
+ "Option_openModeCard_tab_desc": {
+ "message": "選択テキストを新しいタブで開きます"
+ },
+ "Option_openModeCard_tab_title": {
+ "message": "新しいタブ"
+ },
+ "Option_openModeCard_window_desc": {
+ "message": "選択テキストを新しいウィンドウで開きます"
+ },
+ "Option_openModeCard_window_title": {
+ "message": "新しいウィンドウ"
+ },
+ "review_request_later": {
+ "message": "後で"
+ },
"developersupport_title": {
"message": "開発者を応援"
},
@@ -1099,5 +1222,23 @@
},
"prompthistory_banner_description": {
"message": "ChatGPTなどでプロンプトを再利用できる新しいツール✨"
+ },
+ "Option_windowState": {
+ "message": "ウィンドウのサイズ"
+ },
+ "Option_windowState_desc": {
+ "message": "表示するウィンドウのサイズ"
+ },
+ "Option_windowState_normal": {
+ "message": "通常"
+ },
+ "Option_windowState_maximized": {
+ "message": "最大化"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "フルスクリーン"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "このページでは実行できません(ページURLが一致しません)"
}
-}
+}
\ No newline at end of file
diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json
index f245eef4..de7b7c79 100644
--- a/packages/extension/public/_locales/ko/messages.json
+++ b/packages/extension/public/_locales/ko/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "메뉴 표시 애니메이션"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "팝업 자동 닫기 지연"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "포커스를 잃은 후 팝업이 자동으로 닫히기 전 지연 시간을 설정합니다. 즉시 닫으려면 0으로 설정하거나 비워 두십시오.\n최대: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (즉시 닫기)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "사이드 패널 자동 숨기기"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "명령을 통해 사이드 패널이 열려 있는 동안 메인 패널을 클릭하면 사이드 패널이 자동으로 숨겨집니다."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "사이드 패널 자동 숨기기"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "링크 미리보기 표시 중 메인 패널을 클릭하면 사이드 패널이 자동으로 숨겨집니다."
+ },
"Option_inherit": {
"message": "상속"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "시작 URL이 변경되면 자동으로 업데이트됩니다."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "메뉴 아이콘으로 표시됩니다."
+ },
"Option_iconUrl_autofill": {
"message": "자동 채우기"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "퍼센트 (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "대시 (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "밑줄 (_)"
+ },
"Option_openMode": {
"message": "열기 모드"
},
+ "Option_displayMode": {
+ "message": "창 표시 방법"
+ },
"Option_openMode_popup": {
"message": "팝업"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "백그라운드 탭"
},
+ "Option_openMode_sidePanel": {
+ "message": "사이드 패널"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "사이드 패널에서 열기."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "페이지 작업"
},
- "Option_displayMode": {
- "message": "창 표시 방법"
+ "Option_openMode_aiPrompt": {
+ "message": "AI 프롬프트"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "AI 서비스에서 미리 정의된 프롬프트를 실행합니다"
},
"Option_commandType_title": {
"message": "명령 유형 선택"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "웹 페이지 작업을 기록하고 재생합니다"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "AI 프롬프트"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "AI 서비스에서 미리 정의된 프롬프트를 실행합니다"
+ },
"Option_commandType_copy_title": {
"message": "텍스트 복사"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "창"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "사이드 패널"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + 클릭"
},
"Option_parentFolderId": {
"message": "폴더"
},
+ "Option_parentFolder": {
+ "message": "상위 폴더"
+ },
+ "Option_parentFolder_desc": {
+ "message": "상위 폴더를 선택하세요"
+ },
+ "Option_rootFolder": {
+ "message": "루트 (상위 없음)"
+ },
"Option_copyOption": {
"message": "복사 형식"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "작업이 시작되는 페이지의 URL."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "\"현재 탭\"의 경우 이 URL과 일치하는 페이지에서만 실행할 수 있습니다. 와일드카드 \"*\"를 사용할 수 있습니다."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "액션 기록을 시작할 페이지의 URL. 설정 화면에서만 사용됩니다."
+ },
+ "Option_pageUrl": {
+ "message": "페이지 URL"
+ },
+ "Option_pageUrl_desc": {
+ "message": "이 URL과 일치하는 페이지에서만 실행할 수 있습니다. 와일드카드 「*」를 사용할 수 있습니다."
+ },
"Option_pageAction_title": {
"message": "작업"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "지연 시간"
},
+ "Option_aiPrompt_service": {
+ "message": "AI 서비스"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "사용할 AI 서비스를 선택하세요"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "프롬프트"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "프롬프트 템플릿. 삽입 메뉴를 사용하여 변수(선택된 텍스트, URL, 클립보드)를 추가하세요."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "여기에 프롬프트를 입력하세요..."
+ },
+ "Option_userVariables": {
+ "message": "사용자 변수"
+ },
+ "Option_userVariables_desc": {
+ "message": "입력 필드에 대한 사용자 정의 변수 정의 (최대 5개)"
+ },
+ "Option_userVariable_name": {
+ "message": "변수 이름"
+ },
+ "Option_userVariable_value": {
+ "message": "값"
+ },
+ "Option_userVariable_add": {
+ "message": "변수 추가"
+ },
+ "Option_userVariable_name_required": {
+ "message": "변수 이름이 필요합니다"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "변수 이름은 문자로 시작하고 문자, 숫자, 언더스코어만 포함해야 합니다"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "변수 이름이 이미 존재합니다"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "최대 $max$개의 변수가 허용됩니다",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "즉시 명령"
+ },
+ "Option_instantCommand_desc": {
+ "message": "수정자 키를 누른 상태에서 텍스트를 선택할 때 지정된 명령을 즉시 실행합니다."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "즉시 명령 활성화"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "활성화하면 메뉴를 표시하지 않고 명령이 즉시 실행됩니다."
+ },
+ "Option_instantCommandId": {
+ "message": "실행할 명령"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "수정자 키와 함께 실행할 명령을 선택하십시오."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "명령 선택..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "수정자 키"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "텍스트를 선택할 때 누를 키를 선택하십시오."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "키 선택..."
+ },
"Option_linkCommand": {
"message": "링크 미리보기"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "미리보기까지 남은 시간을 표시합니다."
},
+ "Option_windowSettings": {
+ "message": "창 설정"
+ },
+ "Option_windowSettings_desc": {
+ "message": "창 동작 및 팝업 설정을 구성합니다."
+ },
"Option_folders": {
"message": "폴더"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "테두리 색상"
},
+ "Option_userStyles_option_font_color": {
+ "message": "글꼴 색상"
+ },
"Option_userStyles_desc_border_color": {
"message": "메뉴의 테두리 색상을 지정합니다. 기본값: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "글꼴 색상을 지정합니다. 기본값: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "글꼴 크기 스케일"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "변수 이름은 문자로 시작하고 문자, 숫자, 언더스코어만 포함해야 합니다"
+ },
"Option_zod_string_min": {
"message": "$key$자 이상의 문자열을 입력하세요.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "URL 형식으로 입력하세요."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "현재 탭 모드가 선택된 경우 페이지 URL은 필수입니다."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "선택한 텍스트가 삽입됩니다",
"description": "for INSERT.SELECTED_TEXT"
@@ -903,41 +1071,14 @@
"message": "탭을 표시하고 활성화.현재 탭 오른쪽에 표시합니다."
},
"Option_openMode_backgroundTab_desc": {
- "Option_userVariables": {
- "message": "사용자 변수"
- },
- "Option_userVariables_tooltip": {
- "message": "페이지 액션에서 사용할 사용자 정의 변수를 정의합니다. 이러한 변수는 입력 필드에서 {{변수명}} 형태로 사용할 수 있습니다."
- },
- "Option_userVariable_name": {
- "message": "이름"
- },
- "Option_userVariable_value": {
- "message": "값"
- },
- "Option_userVariable_add": {
- "message": "변수 추가"
- },
- "Option_userVariable_name_required": {
- "message": "변수 이름이 필요합니다"
- },
- "Option_userVariable_name_invalid": {
- "message": "이름은 문자로 시작해야 하며 문자, 숫자, 언더스코어만 포함할 수 있습니다"
- },
- "Option_userVariable_name_duplicate": {
- "message": "변수 이름이 이미 존재합니다"
- },
- "Option_userVariable_max_reached": {
- "message": "최대 $max$개의 변수가 허용됩니다",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
"message": "백그라운드에서 탭 열기.현재 탭 오른쪽에 표시합니다."
},
+ "Option_openMode_currentTab": {
+ "message": "현재 탭"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "현재 활성 탭에서 실행합니다.URL은 기록된 시작 URL과 일치해야 합니다."
+ },
"Option_title_desc": {
"message": "명령 제목으로 표시됩니다."
},
@@ -950,30 +1091,15 @@
"Option_openModeSecondary_desc": {
"message": "메뉴를 Ctrl + 클릭했을 때의 동작입니다."
},
- "Option_parentFolder": {
- "message": "상위 폴더"
- },
- "Option_parentFolder_desc": {
- "message": "상위 폴더를 선택하세요"
- },
- "Option_rootFolder": {
- "message": "루트 (상위 없음)"
- },
"Option_searchUrlAssist": {
"message": "AI 어시스턴트"
},
+ "Option_searchUrlAssist_title": {
+ "message": "검색 URL 어시스턴트"
+ },
"Option_searchUrlAssist_desc": {
"message": "검색 키워드와 검색 결과 페이지 URL을 입력하고 실행 버튼을 클릭하세요.\nAI가 검색 URL을 생성합니다."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Gemini로 실행"
- },
- "Option_searchUrlAssist_executing": {
- "message": "실행 중..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "사용 방법"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) 검색 키워드"
},
@@ -986,6 +1112,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "사용 방법"
+ },
"Option_searchUrlAssist_step1": {
"message": "\"(1) 검색 키워드\"에 임의의 단어 입력"
},
@@ -1001,8 +1130,11 @@
"Option_searchUrlAssist_step5": {
"message": "생성된 검색 URL을 복사하여 명령의 검색 URL 필드에 붙여넣기"
},
- "Option_searchUrlAssist_title": {
- "message": "검색 URL 어시스턴트"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Gemini로 실행"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "실행 중..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "검색 키워드를 입력하세요"
@@ -1010,42 +1142,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "유효한 URL을 입력하세요"
},
- "Option_userVariable_add": {
- "message": "변수 추가"
- },
- "Option_userVariable_max_reached": {
- "message": "최대 $max$개의 변수가 허용됩니다",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "변수 이름"
- },
- "Option_userVariable_name_duplicate": {
- "message": "변수 이름이 이미 존재합니다"
- },
- "Option_userVariable_name_invalid": {
- "message": "변수 이름은 문자로 시작하고 문자, 숫자, 언더스코어만 포함해야 합니다"
- },
- "Option_userVariable_name_required": {
- "message": "변수 이름이 필요합니다"
- },
- "Option_userVariable_value": {
- "message": "값"
- },
- "Option_userVariables": {
- "message": "사용자 변수"
- },
- "Option_userVariables_desc": {
- "message": "입력 필드에 대한 사용자 정의 변수 정의 (최대 5개)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "변수 이름은 문자로 시작하고 문자, 숫자, 언더스코어만 포함해야 합니다"
- },
"Option_commandType": {
"message": "명령어 유형"
},
@@ -1129,5 +1225,23 @@
},
"prompthistory_banner_description": {
"message": "ChatGPT 등에서 프롬프트를 재사용할 수 있는 새로운 도구✨"
+ },
+ "Option_windowState": {
+ "message": "창 크기"
+ },
+ "Option_windowState_desc": {
+ "message": "표시할 창 크기"
+ },
+ "Option_windowState_normal": {
+ "message": "보통"
+ },
+ "Option_windowState_maximized": {
+ "message": "최대화"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "전체 화면"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "이 페이지에서 실행할 수 없습니다 (페이지 URL이 일치하지 않습니다)"
}
}
diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json
index 07b20f43..4727e99b 100644
--- a/packages/extension/public/_locales/ms/messages.json
+++ b/packages/extension/public/_locales/ms/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Animasi Paparan Menu"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Kelewatan Penutupan Auto Popup"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Tetapkan masa kelewatan sebelum popup menutup secara automatik selepas kehilangan fokus. Tetapkan ke 0 atau biarkan kosong untuk penutupan segera.\nMaksimum: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (tutup segera)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Sembunyikan Panel Sisi Secara Automatik"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Menyembunyikan panel sisi secara automatik apabila panel utama diklik semasa panel sisi dibuka melalui perintah."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Sembunyikan Panel Sisi Secara Automatik"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Menyembunyikan panel sisi secara automatik apabila panel utama diklik semasa memaparkan pratonton pautan."
+ },
"Option_inherit": {
"message": "Warisi"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "Dikemas kini secara automatik apabila URL Permulaan diubah."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Dipaparkan sebagai ikon menu."
+ },
"Option_iconUrl_autofill": {
"message": "Isi Automatik"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Peratus (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Sempang (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Garis bawah (_)"
+ },
"Option_openMode": {
"message": "Mod Buka"
},
+ "Option_displayMode": {
+ "message": "Kaedah Paparan Tetingkap"
+ },
"Option_openMode_popup": {
"message": "Pop-up"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Tab Latar Belakang"
},
+ "Option_openMode_sidePanel": {
+ "message": "Panel Sisi"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Buka dalam panel sisi."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "Tindakan Halaman"
},
- "Option_displayMode": {
- "message": "Kaedah Paparan Tetingkap"
+ "Option_openMode_aiPrompt": {
+ "message": "Prompt AI"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Jalankan prompt yang telah ditetapkan pada perkhidmatan AI"
},
"Option_commandType_title": {
"message": "Pilih Jenis Arahan"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Rakam dan mainkan operasi halaman web"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "Prompt AI"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Jalankan prompt yang telah ditetapkan pada perkhidmatan AI"
+ },
"Option_commandType_copy_title": {
"message": "Salin Teks"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "Tetingkap"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Panel Sisi"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + Klik"
},
"Option_parentFolderId": {
"message": "Folder"
},
+ "Option_parentFolder": {
+ "message": "Folder Induk"
+ },
+ "Option_parentFolder_desc": {
+ "message": "Pilih folder induk"
+ },
+ "Option_rootFolder": {
+ "message": "Akar (tiada induk)"
+ },
"Option_copyOption": {
"message": "Format salinan"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL halaman di mana tindakan bermula."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "Untuk \"Tab Semasa\", hanya halaman yang sepadan dengan URL ini boleh dilaksanakan. Anda boleh menggunakan kad liar \"*\"."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL halaman untuk memulakan rakaman tindakan. Digunakan hanya di skrin tetapan."
+ },
+ "Option_pageUrl": {
+ "message": "URL Halaman"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Hanya boleh dilaksanakan pada halaman yang sepadan dengan URL ini. Anda boleh menggunakan wildcard «*»."
+ },
"Option_pageAction_title": {
"message": "Tindakan"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "Masa kelewatan"
},
+ "Option_aiPrompt_service": {
+ "message": "Perkhidmatan AI"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Pilih perkhidmatan AI yang hendak digunakan"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Prompt"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Templat prompt. Gunakan menu sisipan untuk menambah pemboleh ubah (teks dipilih, URL, papan klip)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Masukkan prompt anda di sini..."
+ },
+ "Option_userVariables": {
+ "message": "Pembolehubah Pengguna"
+ },
+ "Option_userVariables_desc": {
+ "message": "Takrifkan pembolehubah tersuai untuk medan input (maks 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "Nama pembolehubah"
+ },
+ "Option_userVariable_value": {
+ "message": "Nilai"
+ },
+ "Option_userVariable_add": {
+ "message": "Tambah pembolehubah"
+ },
+ "Option_userVariable_name_required": {
+ "message": "Nama pembolehubah diperlukan"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "Nama pembolehubah mesti bermula dengan huruf dan hanya mengandungi huruf, nombor dan garis bawah"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "Nama pembolehubah sudah wujud"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "Maksimum $max$ pembolehubah dibenarkan",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "Arahan Segera"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Melaksanakan arahan yang ditentukan dengan serta-merta apabila teks dipilih sambil menahan kekunci pengubah."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Dayakan Arahan Segera"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "Apabila didayakan, arahan akan dilaksanakan dengan serta-merta tanpa memaparkan menu."
+ },
+ "Option_instantCommandId": {
+ "message": "Arahan untuk dilaksanakan"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Pilih arahan yang akan dilaksanakan bersama kekunci pengubah."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Pilih arahan..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Kekunci Pengubah"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Pilih kekunci yang perlu ditekan apabila memilih teks."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Pilih kekunci..."
+ },
"Option_linkCommand": {
"message": "Pratonton Pautan"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Memaparkan baki ke pratonton."
},
+ "Option_windowSettings": {
+ "message": "Tetapan Tetingkap"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Konfigurasi tingkah laku tetingkap dan tetapan popup."
+ },
"Option_folders": {
"message": "Folder"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Warna Sempadan"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Warna fon"
+ },
"Option_userStyles_desc_border_color": {
"message": "Nyatakan warna sempadan menu. Lalai: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Tentukan warna fon. Lalai: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Penskalaan saiz fon"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "Nama pembolehubah mesti bermula dengan huruf dan hanya mengandungi huruf, nombor dan garis bawah"
+ },
"Option_zod_string_min": {
"message": "Sila masukkan rentetan $key$ aksara atau lebih.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "Sila masukkan dalam format URL."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "URL halaman diperlukan apabila mod tab semasa dipilih."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "Teks yang dipilih akan dimasukkan",
"description": "for INSERT.SELECTED_TEXT"
@@ -906,40 +1074,13 @@
"message": "Papar tab dan aktifkan.Papar di sebelah kanan tab semasa."
},
"Option_openMode_backgroundTab_desc": {
- "Option_userVariables": {
- "message": "Pembolehubah Pengguna"
- },
- "Option_userVariables_tooltip": {
- "message": "Tentukan pembolehubah tersuai untuk digunakan dalam Tindakan Halaman anda. Pembolehubah ini boleh digunakan dalam medan input dengan {{namaPembolehubah}}."
- },
- "Option_userVariable_name": {
- "message": "Nama"
- },
- "Option_userVariable_value": {
- "message": "Nilai"
- },
- "Option_userVariable_add": {
- "message": "Tambah Pembolehubah"
- },
- "Option_userVariable_name_required": {
- "message": "Nama pembolehubah diperlukan"
- },
- "Option_userVariable_name_invalid": {
- "message": "Nama mesti bermula dengan huruf dan hanya boleh mengandungi huruf, nombor dan garis bawah"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Nama pembolehubah sudah wujud"
- },
- "Option_userVariable_max_reached": {
- "message": "Maksimum $max$ pembolehubah dibenarkan",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "message": "Buka tab di latar belakang.Papar di sebelah kanan tab semasa."
+ "message": "Buka tab di latar belakang.Paparkan di sebelah kanan tab semasa."
+ },
+ "Option_openMode_currentTab": {
+ "message": "Tab Semasa"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Jalankan pada tab aktif semasa.URL mesti sepadan dengan URL permulaan yang direkodkan."
},
"Option_title_desc": {
"message": "Dipaparkan sebagai tajuk arahan."
@@ -953,30 +1094,15 @@
"Option_openModeSecondary_desc": {
"message": "Tingkah laku semasa Ctrl + klik menu."
},
- "Option_parentFolder": {
- "message": "Folder Induk"
- },
- "Option_parentFolder_desc": {
- "message": "Pilih folder induk"
- },
- "Option_rootFolder": {
- "message": "Akar (tiada induk)"
- },
"Option_searchUrlAssist": {
"message": "Pembantu AI"
},
+ "Option_searchUrlAssist_title": {
+ "message": "Pembantu URL Carian"
+ },
"Option_searchUrlAssist_desc": {
"message": "Masukkan kata kunci carian dan URL halaman hasil carian, kemudian klik butang laksana.\nAI akan menjana URL carian."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Jalankan dengan Gemini"
- },
- "Option_searchUrlAssist_executing": {
- "message": "Sedang menjalankan..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "Cara penggunaan"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) Kata kunci carian"
},
@@ -989,6 +1115,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "Cara penggunaan"
+ },
"Option_searchUrlAssist_step1": {
"message": "Masukkan sebarang perkataan dalam \"(1) Kata kunci carian\""
},
@@ -1004,8 +1133,11 @@
"Option_searchUrlAssist_step5": {
"message": "Salin URL carian yang dijana dan tampal ke medan URL carian arahan"
},
- "Option_searchUrlAssist_title": {
- "message": "Pembantu URL Carian"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Jalankan dengan Gemini"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "Sedang menjalankan..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "Sila masukkan kata kunci carian"
@@ -1013,42 +1145,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "Sila masukkan URL yang sah"
},
- "Option_userVariable_add": {
- "message": "Tambah pembolehubah"
- },
- "Option_userVariable_max_reached": {
- "message": "Maksimum $max$ pembolehubah dibenarkan",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "Nama pembolehubah"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Nama pembolehubah sudah wujud"
- },
- "Option_userVariable_name_invalid": {
- "message": "Nama pembolehubah mesti bermula dengan huruf dan hanya mengandungi huruf, nombor dan garis bawah"
- },
- "Option_userVariable_name_required": {
- "message": "Nama pembolehubah diperlukan"
- },
- "Option_userVariable_value": {
- "message": "Nilai"
- },
- "Option_userVariables": {
- "message": "Pembolehubah Pengguna"
- },
- "Option_userVariables_desc": {
- "message": "Takrifkan pembolehubah tersuai untuk medan input (maks 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "Nama pembolehubah mesti bermula dengan huruf dan hanya mengandungi huruf, nombor dan garis bawah"
- },
"Option_commandType": {
"message": "Jenis Arahan"
},
@@ -1132,5 +1228,23 @@
},
"prompthistory_banner_description": {
"message": "Alat baharu untuk menggunakan semula gesaan untuk ChatGPT dan lain-lain✨"
+ },
+ "Option_windowState": {
+ "message": "Saiz tetingkap"
+ },
+ "Option_windowState_desc": {
+ "message": "Saiz tetingkap untuk dipaparkan"
+ },
+ "Option_windowState_normal": {
+ "message": "Normal"
+ },
+ "Option_windowState_maximized": {
+ "message": "Dimaksimumkan"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Skrin penuh"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "Tidak boleh dijalankan pada halaman ini (URL Halaman tidak sepadan)"
}
}
diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json
index 698cefef..7d12fc41 100644
--- a/packages/extension/public/_locales/pt_BR/messages.json
+++ b/packages/extension/public/_locales/pt_BR/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Animação de Exibição do Menu"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Atraso de Fechamento Automático do Popup"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Defina o tempo de atraso antes que o popup feche automaticamente após perder o foco. Defina como 0 ou deixe vazio para fechamento imediato.\nMáximo: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (fechar imediatamente)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Ocultar painel lateral automaticamente"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Oculta automaticamente o painel lateral quando o painel principal é clicado enquanto o painel lateral está aberto via um comando."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Ocultar painel lateral automaticamente"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Oculta automaticamente o painel lateral quando o painel principal é clicado durante a exibição da pré-visualização do link."
+ },
"Option_inherit": {
"message": "Herdar"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "Atualizado automaticamente quando a URL Inicial é alterada."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Exibido como ícone do menu."
+ },
"Option_iconUrl_autofill": {
"message": "Preenchimento Automático"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Porcentagem (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Hífen (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Sublinhado (_)"
+ },
"Option_openMode": {
"message": "Modo de Abertura"
},
+ "Option_displayMode": {
+ "message": "Método de Exibição de Janela"
+ },
"Option_openMode_popup": {
"message": "Pop-up"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Aba em segundo plano"
},
+ "Option_openMode_sidePanel": {
+ "message": "Painel lateral"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Abrir no painel lateral."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "Ação de Página"
},
- "Option_displayMode": {
- "message": "Método de Exibição de Janela"
+ "Option_openMode_aiPrompt": {
+ "message": "Prompt de IA"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Execute um prompt predefinido em um serviço de IA"
},
"Option_commandType_title": {
"message": "Selecionar Tipo de Comando"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Gravar e reproduzir operações de páginas web"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "Prompt de IA"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Execute um prompt predefinido em um serviço de IA"
+ },
"Option_commandType_copy_title": {
"message": "Copiar Texto"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "Janela"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Painel lateral"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + Clique"
},
"Option_parentFolderId": {
"message": "Pasta"
},
+ "Option_parentFolder": {
+ "message": "Pasta Pai"
+ },
+ "Option_parentFolder_desc": {
+ "message": "Selecionar pasta pai"
+ },
+ "Option_rootFolder": {
+ "message": "Raiz (sem pai)"
+ },
"Option_copyOption": {
"message": "Formato de cópia"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL da página onde a ação começa."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "Para \"Aba Atual\", somente páginas que correspondam a esta URL podem ser executadas. Você pode usar o curinga \"*\"."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL da página para iniciar a gravação de ações. Usado apenas na tela de configurações."
+ },
+ "Option_pageUrl": {
+ "message": "URL da página"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Pode ser executado apenas em páginas que correspondam a esta URL. Você pode usar o curinga «*»."
+ },
"Option_pageAction_title": {
"message": "Ações"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "Tempo de atraso"
},
+ "Option_aiPrompt_service": {
+ "message": "Serviço de IA"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Selecione o serviço de IA a ser usado"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Prompt"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Modelo de prompt. Use o menu de inserção para adicionar variáveis (texto selecionado, URL, área de transferência)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Digite seu prompt aqui..."
+ },
+ "Option_userVariables": {
+ "message": "Variáveis do Usuário"
+ },
+ "Option_userVariables_desc": {
+ "message": "Definir variáveis personalizadas para campos de entrada (máx 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "Nome da variável"
+ },
+ "Option_userVariable_value": {
+ "message": "Valor"
+ },
+ "Option_userVariable_add": {
+ "message": "Adicionar variável"
+ },
+ "Option_userVariable_name_required": {
+ "message": "Nome da variável é obrigatório"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "Nome da variável deve começar com letra e conter apenas letras, números e sublinhados"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "Nome da variável já existe"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "Máximo de $max$ variáveis permitidas",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "Comando Instantâneo"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Executa um comando especificado instantaneamente quando o texto é selecionado enquanto mantém pressionada uma tecla modificadora."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Ativar Comando Instantâneo"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "Quando ativado, os comandos serão executados instantaneamente sem exibir o menu."
+ },
+ "Option_instantCommandId": {
+ "message": "Comando a executar"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Selecione o comando que será executado junto com a tecla modificadora."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Selecionar comando..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Tecla Modificadora"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Selecione a tecla a ser pressionada ao selecionar texto."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Selecionar tecla..."
+ },
"Option_linkCommand": {
"message": "Visualização de Link"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Exibe o restante para a visualização."
},
+ "Option_windowSettings": {
+ "message": "Configurações da Janela"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Configure o comportamento da janela e as configurações de popup."
+ },
"Option_folders": {
"message": "Pastas"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Cor da Borda"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Cor da fonte"
+ },
"Option_userStyles_desc_border_color": {
"message": "Especifique a cor da borda do menu. Padrão: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Especificar a cor da fonte. Padrão: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Escala do tamanho da fonte"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "Nome da variável deve começar com letra e conter apenas letras, números e sublinhados"
+ },
"Option_zod_string_min": {
"message": "Por favor, insira uma string de $key$ caracteres ou mais.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "Por favor, insira no formato URL."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "O URL da página é obrigatório quando o modo de aba atual é selecionado."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "O texto selecionado será inserido",
"description": "for INSERT.SELECTED_TEXT"
@@ -906,40 +1074,13 @@
"message": "Exibir aba e ativá-la.Exibir à direita da aba atual."
},
"Option_openMode_backgroundTab_desc": {
- "Option_userVariables": {
- "message": "Variáveis do Usuário"
- },
- "Option_userVariables_tooltip": {
- "message": "Defina variáveis personalizadas para usar em suas Page Actions. Essas variáveis podem ser usadas em campos de entrada com {{nomeVariável}}."
- },
- "Option_userVariable_name": {
- "message": "Nome"
- },
- "Option_userVariable_value": {
- "message": "Valor"
- },
- "Option_userVariable_add": {
- "message": "Adicionar Variável"
- },
- "Option_userVariable_name_required": {
- "message": "O nome da variável é obrigatório"
- },
- "Option_userVariable_name_invalid": {
- "message": "O nome deve começar com uma letra e pode conter apenas letras, números e sublinhados"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Nome da variável já existe"
- },
- "Option_userVariable_max_reached": {
- "message": "Máximo de $max$ variáveis permitidas",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "message": "Abrir aba em segundo plano.Exibir à direita da aba atual."
+ "message": "Abrir aba em segundo plano.Mostrar à direita da aba atual."
+ },
+ "Option_openMode_currentTab": {
+ "message": "Aba atual"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Executar na aba ativa atual.A URL deve corresponder à URL inicial gravada."
},
"Option_title_desc": {
"message": "Exibido como título do comando."
@@ -953,30 +1094,15 @@
"Option_openModeSecondary_desc": {
"message": "Comportamento ao fazer Ctrl + clique no menu."
},
- "Option_parentFolder": {
- "message": "Pasta Pai"
- },
- "Option_parentFolder_desc": {
- "message": "Selecionar pasta pai"
- },
- "Option_rootFolder": {
- "message": "Raiz (sem pai)"
- },
"Option_searchUrlAssist": {
"message": "Assistente IA"
},
+ "Option_searchUrlAssist_title": {
+ "message": "Assistente de URL de Pesquisa"
+ },
"Option_searchUrlAssist_desc": {
"message": "Digite uma palavra-chave de pesquisa e a URL da página de resultados de pesquisa, depois clique no botão executar.\nA IA gerará uma URL de pesquisa."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Executar com Gemini"
- },
- "Option_searchUrlAssist_executing": {
- "message": "Executando..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "Modo de usar"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) Palavra-chave de pesquisa"
},
@@ -989,6 +1115,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "Modo de usar"
+ },
"Option_searchUrlAssist_step1": {
"message": "Digite qualquer palavra em \"(1) Palavra-chave de pesquisa\""
},
@@ -1004,8 +1133,11 @@
"Option_searchUrlAssist_step5": {
"message": "Copie a URL de pesquisa gerada e cole no campo URL de pesquisa do comando"
},
- "Option_searchUrlAssist_title": {
- "message": "Assistente de URL de Pesquisa"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Executar com Gemini"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "Executando..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "Por favor, digite uma palavra-chave de pesquisa"
@@ -1013,42 +1145,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "Por favor, digite uma URL válida"
},
- "Option_userVariable_add": {
- "message": "Adicionar variável"
- },
- "Option_userVariable_max_reached": {
- "message": "Máximo de $max$ variáveis permitidas",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "Nome da variável"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Nome da variável já existe"
- },
- "Option_userVariable_name_invalid": {
- "message": "Nome da variável deve começar com letra e conter apenas letras, números e sublinhados"
- },
- "Option_userVariable_name_required": {
- "message": "Nome da variável é obrigatório"
- },
- "Option_userVariable_value": {
- "message": "Valor"
- },
- "Option_userVariables": {
- "message": "Variáveis do Usuário"
- },
- "Option_userVariables_desc": {
- "message": "Definir variáveis personalizadas para campos de entrada (máx 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "Nome da variável deve começar com letra e conter apenas letras, números e sublinhados"
- },
"Option_commandType": {
"message": "Tipo de Comando"
},
@@ -1132,5 +1228,23 @@
},
"prompthistory_banner_description": {
"message": "Uma nova ferramenta para reutilizar prompts no ChatGPT e mais✨"
+ },
+ "Option_windowState": {
+ "message": "Tamanho da janela"
+ },
+ "Option_windowState_desc": {
+ "message": "Tamanho da janela a ser exibida"
+ },
+ "Option_windowState_normal": {
+ "message": "Normal"
+ },
+ "Option_windowState_maximized": {
+ "message": "Maximizado"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Tela cheia"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "Não é possível executar nesta página (URL da página não corresponde)"
}
}
diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json
index adaf6a58..d1e2548e 100644
--- a/packages/extension/public/_locales/pt_PT/messages.json
+++ b/packages/extension/public/_locales/pt_PT/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Animação de Exibição do Menu"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Atraso de Fecho Automático do Popup"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Defina o tempo de atraso antes que o popup feche automaticamente após perder o foco. Defina como 0 ou deixe vazio para fecho imediato.\nMáximo: 10000 ms"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (fechar imediatamente)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Ocultar painel lateral automaticamente"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Oculta automaticamente o painel lateral quando o painel principal é clicado enquanto o painel lateral está aberto via um comando."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Ocultar painel lateral automaticamente"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Oculta automaticamente o painel lateral quando o painel principal é clicado durante a visualização da pré-visualização do link."
+ },
"Option_inherit": {
"message": "Herdar"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "Atualizado automaticamente quando a URL Inicial é alterada."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Apresentado como ícone do menu."
+ },
"Option_iconUrl_autofill": {
"message": "Preenchimento Automático"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Percentagem (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Hífen (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Sublinhado (_)"
+ },
"Option_openMode": {
"message": "Modo de Abertura"
},
+ "Option_displayMode": {
+ "message": "Método de Exibição de Janela"
+ },
"Option_openMode_popup": {
"message": "Pop-up"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Separador em segundo plano"
},
+ "Option_openMode_sidePanel": {
+ "message": "Painel lateral"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Abrir no painel lateral."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "Ação de Página"
},
- "Option_displayMode": {
- "message": "Método de Exibição de Janela"
+ "Option_openMode_aiPrompt": {
+ "message": "Prompt de IA"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Execute um prompt predefinido num serviço de IA"
},
"Option_commandType_title": {
"message": "Selecionar Tipo de Comando"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Gravar e reproduzir operações de páginas web"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "Prompt de IA"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Execute um prompt predefinido num serviço de IA"
+ },
"Option_commandType_copy_title": {
"message": "Copiar Texto"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "Janela"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Painel lateral"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + Clique"
},
"Option_parentFolderId": {
"message": "Pasta"
},
+ "Option_parentFolder": {
+ "message": "Pasta Pai"
+ },
+ "Option_parentFolder_desc": {
+ "message": "Selecionar pasta pai"
+ },
+ "Option_rootFolder": {
+ "message": "Raiz (sem pai)"
+ },
"Option_copyOption": {
"message": "Formato de cópia"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL da página onde a ação começa."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "Para \"Separador Atual\", apenas as páginas que correspondam a este URL podem ser executadas. Pode utilizar o curinga \"*\"."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL da página para iniciar a gravação de ações. Utilizado apenas no ecrã de definições."
+ },
+ "Option_pageUrl": {
+ "message": "URL da página"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Pode ser executado apenas em páginas que correspondam a este URL. Pode utilizar o carácter universal «*»."
+ },
"Option_pageAction_title": {
"message": "Ações"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "Tempo de atraso"
},
+ "Option_aiPrompt_service": {
+ "message": "Serviço de IA"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Selecione o serviço de IA a utilizar"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Prompt"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Modelo de prompt. Use o menu de inserção para adicionar variáveis (texto selecionado, URL, área de transferência)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Introduza o seu prompt aqui..."
+ },
+ "Option_userVariables": {
+ "message": "Variáveis do Utilizador"
+ },
+ "Option_userVariables_desc": {
+ "message": "Definir variáveis personalizadas para campos de entrada (máx 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "Nome da variável"
+ },
+ "Option_userVariable_value": {
+ "message": "Valor"
+ },
+ "Option_userVariable_add": {
+ "message": "Adicionar variável"
+ },
+ "Option_userVariable_name_required": {
+ "message": "Nome da variável é obrigatório"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "Nome da variável deve começar com letra e conter apenas letras, números e sublinhados"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "Nome da variável já existe"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "Máximo de $max$ variáveis permitidas",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "Comando Instantâneo"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Executa um comando especificado instantaneamente quando o texto é selecionado enquanto mantém premida uma tecla modificadora."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Ativar Comando Instantâneo"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "Quando ativado, os comandos serão executados instantaneamente sem apresentar o menu."
+ },
+ "Option_instantCommandId": {
+ "message": "Comando a executar"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Selecione o comando que será executado juntamente com a tecla modificadora."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Selecionar comando..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Tecla Modificadora"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Selecione a tecla a ser premida ao selecionar texto."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Selecionar tecla..."
+ },
"Option_linkCommand": {
"message": "Visualização de Link"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Exibe o restante para a visualização."
},
+ "Option_windowSettings": {
+ "message": "Definições da Janela"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Configure o comportamento da janela e as definições de popup."
+ },
"Option_folders": {
"message": "Pastas"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Cor da Borda"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Cor do tipo de letra"
+ },
"Option_userStyles_desc_border_color": {
"message": "Especifique a cor da borda do menu. Padrão: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Especificar a cor do tipo de letra. Predefinido: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Escala do tamanho da fonte"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "Nome da variável deve começar com letra e conter apenas letras, números e sublinhados"
+ },
"Option_zod_string_min": {
"message": "Por favor, insira uma string de $key$ caracteres ou mais.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "Por favor, insira no formato URL."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "O URL da página é obrigatório quando o modo de separador atual é selecionado."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "O texto selecionado será inserido",
"description": "for INSERT.SELECTED_TEXT"
@@ -906,40 +1074,13 @@
"message": "Exibir separador e activá-lo.Exibir à direita do separador actual."
},
"Option_openMode_backgroundTab_desc": {
- "Option_userVariables": {
- "message": "Variáveis do Utilizador"
- },
- "Option_userVariables_tooltip": {
- "message": "Defina variáveis personalizadas para utilizar nas suas Page Actions. Estas variáveis podem ser utilizadas em campos de entrada com {{nomeVariável}}."
- },
- "Option_userVariable_name": {
- "message": "Nome"
- },
- "Option_userVariable_value": {
- "message": "Valor"
- },
- "Option_userVariable_add": {
- "message": "Adicionar Variável"
- },
- "Option_userVariable_name_required": {
- "message": "O nome da variável é obrigatório"
- },
- "Option_userVariable_name_invalid": {
- "message": "O nome deve começar com uma letra e pode conter apenas letras, números e sublinhados"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Nome da variável já existe"
- },
- "Option_userVariable_max_reached": {
- "message": "Máximo de $max$ variáveis permitidas",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "message": "Abrir separador em segundo plano.Exibir à direita do separador actual."
+ "message": "Abrir separador em segundo plano.Mostrar à direita do separador atual."
+ },
+ "Option_openMode_currentTab": {
+ "message": "Separador atual"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Executar no separador ativo atual.O URL deve corresponder ao URL de início gravado."
},
"Option_title_desc": {
"message": "Exibido como título do comando."
@@ -953,30 +1094,15 @@
"Option_openModeSecondary_desc": {
"message": "Comportamento ao fazer Ctrl + clique no menu."
},
- "Option_parentFolder": {
- "message": "Pasta Pai"
- },
- "Option_parentFolder_desc": {
- "message": "Selecionar pasta pai"
- },
- "Option_rootFolder": {
- "message": "Raiz (sem pai)"
- },
"Option_searchUrlAssist": {
"message": "Assistente IA"
},
+ "Option_searchUrlAssist_title": {
+ "message": "Assistente de URL de Pesquisa"
+ },
"Option_searchUrlAssist_desc": {
"message": "Digite uma palavra-chave de pesquisa e o URL da página de resultados de pesquisa, depois clique no botão executar.\nA IA gerará um URL de pesquisa."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Executar com Gemini"
- },
- "Option_searchUrlAssist_executing": {
- "message": "A executar..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "Como usar"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) Palavra-chave de pesquisa"
},
@@ -989,6 +1115,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "Como usar"
+ },
"Option_searchUrlAssist_step1": {
"message": "Digite qualquer palavra em \"(1) Palavra-chave de pesquisa\""
},
@@ -1004,8 +1133,11 @@
"Option_searchUrlAssist_step5": {
"message": "Copie o URL de pesquisa gerado e cole no campo URL de pesquisa do comando"
},
- "Option_searchUrlAssist_title": {
- "message": "Assistente de URL de Pesquisa"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Executar com Gemini"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "A executar..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "Por favor, digite uma palavra-chave de pesquisa"
@@ -1013,42 +1145,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "Por favor, digite uma URL válida"
},
- "Option_userVariable_add": {
- "message": "Adicionar variável"
- },
- "Option_userVariable_max_reached": {
- "message": "Máximo de $max$ variáveis permitidas",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "Nome da variável"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Nome da variável já existe"
- },
- "Option_userVariable_name_invalid": {
- "message": "Nome da variável deve começar com letra e conter apenas letras, números e sublinhados"
- },
- "Option_userVariable_name_required": {
- "message": "Nome da variável é obrigatório"
- },
- "Option_userVariable_value": {
- "message": "Valor"
- },
- "Option_userVariables": {
- "message": "Variáveis do Utilizador"
- },
- "Option_userVariables_desc": {
- "message": "Definir variáveis personalizadas para campos de entrada (máx 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "Nome da variável deve começar com letra e conter apenas letras, números e sublinhados"
- },
"Option_commandType": {
"message": "Tipo de Comando"
},
@@ -1132,5 +1228,23 @@
},
"prompthistory_banner_description": {
"message": "Uma nova ferramenta para reutilizar prompts no ChatGPT e mais✨"
+ },
+ "Option_windowState": {
+ "message": "Tamanho da janela"
+ },
+ "Option_windowState_desc": {
+ "message": "Tamanho da janela a apresentar"
+ },
+ "Option_windowState_normal": {
+ "message": "Normal"
+ },
+ "Option_windowState_maximized": {
+ "message": "Maximizado"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Ecrã inteiro"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "Não é possível executar nesta página (URL da página não corresponde)"
}
}
diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json
index a62190b5..92f355a1 100644
--- a/packages/extension/public/_locales/ru/messages.json
+++ b/packages/extension/public/_locales/ru/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "Анимация Отображения Меню"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "Задержка Автозакрытия Всплывающего Окна"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "Установите время задержки перед автоматическим закрытием всплывающего окна после потери фокуса. Установите 0 или оставьте пустым для немедленного закрытия.\nМаксимум: 10000 мс"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0 (закрыть немедленно)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "Автоматически скрывать боковую панель"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "Автоматически скрывает боковую панель при клике на основную панель, пока боковая панель открыта через команду."
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "Автоматически скрывать боковую панель"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "Автоматически скрывает боковую панель при клике на основную панель во время отображения предварительного просмотра ссылки."
+ },
"Option_inherit": {
"message": "Наследовать"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "URL иконки будет автоматически обновлен при изменении начального URL."
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "Отображается как значок меню."
+ },
"Option_iconUrl_autofill": {
"message": "Автозаполнение"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "Процент (%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "Дефис (-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "Подчёркивание (_)"
+ },
"Option_openMode": {
"message": "Режим открытия"
},
+ "Option_displayMode": {
+ "message": "Способ отображения окна"
+ },
"Option_openMode_popup": {
"message": "Всплывающее окно"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "Фоновая вкладка"
},
+ "Option_openMode_sidePanel": {
+ "message": "Боковая панель"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "Открыть в боковой панели."
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "Действие страницы"
},
- "Option_displayMode": {
- "message": "Способ отображения окна"
+ "Option_openMode_aiPrompt": {
+ "message": "AI-промпт"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "Запускает предопределённый промпт в сервисе ИИ"
},
"Option_commandType_title": {
"message": "Выбрать тип команды"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "Записывать и воспроизводить операции веб-страниц"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "AI-промпт"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "Запускает предопределённый промпт в сервисе ИИ"
+ },
"Option_commandType_copy_title": {
"message": "Копировать текст"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "Окно"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "Боковая панель"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + клик"
},
"Option_parentFolderId": {
"message": "Папка"
},
+ "Option_parentFolder": {
+ "message": "Родительская Папка"
+ },
+ "Option_parentFolder_desc": {
+ "message": "Выберите родительскую папку"
+ },
+ "Option_rootFolder": {
+ "message": "Корень (нет родителя)"
+ },
"Option_copyOption": {
"message": "Формат копирования"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "URL страницы, где начинается действие."
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "Для \"Текущей вкладки\" могут быть выполнены только страницы, соответствующие этому URL. Вы можете использовать подстановочный знак \"*\"."
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "URL страницы для начала записи действий. Используется только на экране настроек."
+ },
+ "Option_pageUrl": {
+ "message": "URL страницы"
+ },
+ "Option_pageUrl_desc": {
+ "message": "Можно запускать только на страницах, соответствующих этому URL. Можно использовать подстановочный знак «*»."
+ },
"Option_pageAction_title": {
"message": "Действие"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "Время ожидания"
},
+ "Option_aiPrompt_service": {
+ "message": "Сервис ИИ"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "Выберите сервис ИИ для использования"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "Промпт"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "Шаблон промпта. Используйте меню вставки для добавления переменных (выбранный текст, URL, буфер обмена)."
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "Введите ваш промпт здесь..."
+ },
+ "Option_userVariables": {
+ "message": "Пользовательские Переменные"
+ },
+ "Option_userVariables_desc": {
+ "message": "Определить пользовательские переменные для полей ввода (макс 5)"
+ },
+ "Option_userVariable_name": {
+ "message": "Имя переменной"
+ },
+ "Option_userVariable_value": {
+ "message": "Значение"
+ },
+ "Option_userVariable_add": {
+ "message": "Добавить переменную"
+ },
+ "Option_userVariable_name_required": {
+ "message": "Имя переменной обязательно"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "Имя переменной должно начинаться с буквы и содержать только буквы, цифры и подчеркивания"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "Имя переменной уже существует"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "Максимум $max$ переменных разрешено",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "Мгновенная команда"
+ },
+ "Option_instantCommand_desc": {
+ "message": "Мгновенно выполняет указанную команду при выборе текста с удержанием клавиши-модификатора."
+ },
+ "Option_instantCommandEnabled": {
+ "message": "Включить мгновенную команду"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "Когда включено, команды будут выполняться мгновенно без отображения меню."
+ },
+ "Option_instantCommandId": {
+ "message": "Команда для выполнения"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "Выберите команду, которая будет выполняться вместе с клавишей-модификатором."
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "Выбрать команду..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "Клавиша-модификатор"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "Выберите клавишу, которую нужно удерживать при выборе текста."
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "Выбрать клавишу..."
+ },
"Option_linkCommand": {
"message": "Предпросмотр ссылки"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "Показывать оставшееся время до предпросмотра."
},
+ "Option_windowSettings": {
+ "message": "Настройки Окна"
+ },
+ "Option_windowSettings_desc": {
+ "message": "Настройте поведение окна и параметры всплывающих окон."
+ },
"Option_folders": {
"message": "Папки"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "Цвет границы"
},
+ "Option_userStyles_option_font_color": {
+ "message": "Цвет шрифта"
+ },
"Option_userStyles_desc_border_color": {
"message": "Укажите цвет границы меню. По умолчанию: #F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "Укажите цвет шрифта. По умолчанию: #0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "Масштаб размера шрифта"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "Имя переменной должно начинаться с буквы и содержать только буквы, цифры и подчеркивания"
+ },
"Option_zod_string_min": {
"message": "Введите строку длиной не менее $key$ символов.",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "Введите в формате URL."
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "URL страницы обязателен при выборе режима текущей вкладки."
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "Будет вставлен выбранный текст",
"description": "for INSERT.SELECTED_TEXT"
@@ -903,41 +1071,14 @@
"message": "Показать вкладку и активировать.Показать справа от текущей вкладки."
},
"Option_openMode_backgroundTab_desc": {
- "Option_userVariables": {
- "message": "Пользовательские переменные"
- },
- "Option_userVariables_tooltip": {
- "message": "Определите пользовательские переменные для использования в ваших Page Actions. Эти переменные можно использовать в полях ввода с {{имяПеременной}}."
- },
- "Option_userVariable_name": {
- "message": "Имя"
- },
- "Option_userVariable_value": {
- "message": "Значение"
- },
- "Option_userVariable_add": {
- "message": "Добавить переменную"
- },
- "Option_userVariable_name_required": {
- "message": "Имя переменной обязательно"
- },
- "Option_userVariable_name_invalid": {
- "message": "Имя должно начинаться с буквы и может содержать только буквы, цифры и подчеркивания"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Имя переменной уже существует"
- },
- "Option_userVariable_max_reached": {
- "message": "Разрешено максимум $max$ переменных",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
"message": "Открыть вкладку в фоновом режиме.Показать справа от текущей вкладки."
},
+ "Option_openMode_currentTab": {
+ "message": "Текущая вкладка"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "Выполнить в текущей активной вкладке.URL должен совпадать с записанным начальным URL."
+ },
"Option_title_desc": {
"message": "Отображается как заголовок команды."
},
@@ -950,30 +1091,15 @@
"Option_openModeSecondary_desc": {
"message": "Поведение при Ctrl + клике по меню."
},
- "Option_parentFolder": {
- "message": "Родительская Папка"
- },
- "Option_parentFolder_desc": {
- "message": "Выберите родительскую папку"
- },
- "Option_rootFolder": {
- "message": "Корень (нет родителя)"
- },
"Option_searchUrlAssist": {
"message": "Помощник ИИ"
},
+ "Option_searchUrlAssist_title": {
+ "message": "Помощник URL Поиска"
+ },
"Option_searchUrlAssist_desc": {
"message": "Введите ключевое слово поиска и URL страницы результатов поиска, затем нажмите кнопку выполнить.\nИИ создаст URL поиска."
},
- "Option_searchUrlAssist_executeButton": {
- "message": "Выполнить с Gemini"
- },
- "Option_searchUrlAssist_executing": {
- "message": "Выполнение..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "Способ использования"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) Ключевое слово поиска"
},
@@ -986,6 +1112,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "Способ использования"
+ },
"Option_searchUrlAssist_step1": {
"message": "Введите любое слово в \"(1) Ключевое слово поиска\""
},
@@ -1001,8 +1130,11 @@
"Option_searchUrlAssist_step5": {
"message": "Скопируйте созданный URL поиска и вставьте в поле URL поиска команды"
},
- "Option_searchUrlAssist_title": {
- "message": "Помощник URL Поиска"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "Выполнить с Gemini"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "Выполнение..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "Пожалуйста, введите ключевое слово поиска"
@@ -1010,42 +1142,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "Пожалуйста, введите действительный URL"
},
- "Option_userVariable_add": {
- "message": "Добавить переменную"
- },
- "Option_userVariable_max_reached": {
- "message": "Максимум $max$ переменных разрешено",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "Имя переменной"
- },
- "Option_userVariable_name_duplicate": {
- "message": "Имя переменной уже существует"
- },
- "Option_userVariable_name_invalid": {
- "message": "Имя переменной должно начинаться с буквы и содержать только буквы, цифры и подчеркивания"
- },
- "Option_userVariable_name_required": {
- "message": "Имя переменной обязательно"
- },
- "Option_userVariable_value": {
- "message": "Значение"
- },
- "Option_userVariables": {
- "message": "Пользовательские Переменные"
- },
- "Option_userVariables_desc": {
- "message": "Определить пользовательские переменные для полей ввода (макс 5)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "Имя переменной должно начинаться с буквы и содержать только буквы, цифры и подчеркивания"
- },
"Option_commandType": {
"message": "Тип Команды"
},
@@ -1129,5 +1225,23 @@
},
"prompthistory_banner_description": {
"message": "Новый инструмент для повторного использования подсказок для ChatGPT и других✨"
+ },
+ "Option_windowState": {
+ "message": "Размер окна"
+ },
+ "Option_windowState_desc": {
+ "message": "Размер отображаемого окна"
+ },
+ "Option_windowState_normal": {
+ "message": "Обычное"
+ },
+ "Option_windowState_maximized": {
+ "message": "Развёрнуто"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "Полный экран"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "Невозможно выполнить на этой странице (URL страницы не совпадает)"
}
}
diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json
index d346cf1e..f0f82309 100644
--- a/packages/extension/public/_locales/zh_CN/messages.json
+++ b/packages/extension/public/_locales/zh_CN/messages.json
@@ -203,6 +203,27 @@
"Option_popupAnimation": {
"message": "菜单显示动画"
},
+ "Option_popupAutoCloseDelay": {
+ "message": "弹出窗口自动关闭延迟"
+ },
+ "Option_popupAutoCloseDelay_desc": {
+ "message": "设置弹出窗口失去焦点后自动关闭前的延迟时间。设置为0或留空表示立即关闭。\n最大值:10000毫秒"
+ },
+ "Option_popupAutoCloseDelay_placeholder": {
+ "message": "0(立即关闭)"
+ },
+ "Option_sidePanelAutoHide": {
+ "message": "侧边栏自动隐藏"
+ },
+ "Option_sidePanelAutoHide_desc": {
+ "message": "通过命令打开侧边栏时,点击主面板自动隐藏侧边栏。"
+ },
+ "Option_sidePanelAutoHide_link": {
+ "message": "侧边栏自动隐藏"
+ },
+ "Option_sidePanelAutoHide_link_desc": {
+ "message": "显示链接预览时,点击主面板自动隐藏侧边栏。"
+ },
"Option_inherit": {
"message": "获取"
},
@@ -248,6 +269,9 @@
"Option_iconUrl_desc_pageAction": {
"message": "修改初始URL时图标URL将自动更新。"
},
+ "Option_iconUrl_desc_aiPrompt": {
+ "message": "显示为菜单图标。"
+ },
"Option_iconUrl_autofill": {
"message": "自动填充"
},
@@ -263,9 +287,18 @@
"Option_spaceEncoding_percent": {
"message": "百分比(%20)"
},
+ "Option_spaceEncoding_dash": {
+ "message": "连字符(-)"
+ },
+ "Option_spaceEncoding_underscore": {
+ "message": "下划线(_)"
+ },
"Option_openMode": {
"message": "打开模式"
},
+ "Option_displayMode": {
+ "message": "窗口显示方式"
+ },
"Option_openMode_popup": {
"message": "弹出窗口"
},
@@ -278,6 +311,12 @@
"Option_openMode_backgroundTab": {
"message": "后台标签页"
},
+ "Option_openMode_sidePanel": {
+ "message": "侧边栏"
+ },
+ "Option_openMode_sidePanel_desc": {
+ "message": "在侧边栏中打开。"
+ },
"Option_openMode_api": {
"message": "API"
},
@@ -293,8 +332,11 @@
"Option_openMode_pageAction": {
"message": "页面操作"
},
- "Option_displayMode": {
- "message": "窗口显示方式"
+ "Option_openMode_aiPrompt": {
+ "message": "AI提示词"
+ },
+ "Option_openMode_aiPrompt_desc": {
+ "message": "在AI服务上运行预定义的提示词"
},
"Option_commandType_title": {
"message": "选择命令类型"
@@ -314,6 +356,12 @@
"Option_commandType_pageAction_desc": {
"message": "录制和重放网页操作"
},
+ "Option_commandType_aiPrompt_title": {
+ "message": "AI提示词"
+ },
+ "Option_commandType_aiPrompt_desc": {
+ "message": "在AI服务上运行预定义的提示词"
+ },
"Option_commandType_copy_title": {
"message": "复制文本"
},
@@ -353,12 +401,24 @@
"Option_openMode_previewWindow": {
"message": "窗口"
},
+ "Option_openMode_previewSidePanel": {
+ "message": "侧边栏"
+ },
"Option_openModeSecondary": {
"message": " ┗ Ctrl + 点击"
},
"Option_parentFolderId": {
"message": "文件夹"
},
+ "Option_parentFolder": {
+ "message": "父文件夹"
+ },
+ "Option_parentFolder_desc": {
+ "message": "选择父文件夹"
+ },
+ "Option_rootFolder": {
+ "message": "根目录(无父级)"
+ },
"Option_copyOption": {
"message": "复制格式"
},
@@ -386,6 +446,18 @@
"Option_startUrl_desc": {
"message": "操作开始的页面URL。"
},
+ "Option_startUrl_desc_currentTab": {
+ "message": "对于\"当前标签页\",只有与此URL匹配的页面才能执行。可以使用通配符\"*\"。"
+ },
+ "Option_startUrl_desc_currentTab_recorder": {
+ "message": "开始录制动作的页面URL。仅在设置画面中使用。"
+ },
+ "Option_pageUrl": {
+ "message": "页面URL"
+ },
+ "Option_pageUrl_desc": {
+ "message": "仅能在匹配此URL的页面上执行。可以使用通配符「*」。"
+ },
"Option_pageAction_title": {
"message": "操作"
},
@@ -404,6 +476,84 @@
"Option_pageAction_delay": {
"message": "等待时间"
},
+ "Option_aiPrompt_service": {
+ "message": "AI服务"
+ },
+ "Option_aiPrompt_service_desc": {
+ "message": "选择要使用的AI服务"
+ },
+ "Option_aiPrompt_prompt": {
+ "message": "提示词"
+ },
+ "Option_aiPrompt_prompt_desc": {
+ "message": "提示词模板。使用插入菜单添加变量(选中文本、URL、剪贴板)。"
+ },
+ "Option_aiPrompt_prompt_placeholder": {
+ "message": "在此输入您的提示词..."
+ },
+ "Option_userVariables": {
+ "message": "用户变量"
+ },
+ "Option_userVariables_desc": {
+ "message": "为输入字段定义自定义变量(最多 5 个)"
+ },
+ "Option_userVariable_name": {
+ "message": "变量名"
+ },
+ "Option_userVariable_value": {
+ "message": "值"
+ },
+ "Option_userVariable_add": {
+ "message": "添加变量"
+ },
+ "Option_userVariable_name_required": {
+ "message": "变量名是必需的"
+ },
+ "Option_userVariable_name_invalid": {
+ "message": "变量名必须以字母开头,且只能包含字母、数字和下划线"
+ },
+ "Option_userVariable_name_duplicate": {
+ "message": "变量名已存在"
+ },
+ "Option_userVariable_max_reached": {
+ "message": "最多允许$max$个变量",
+ "placeholders": {
+ "max": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "Option_instantCommand": {
+ "message": "即时命令"
+ },
+ "Option_instantCommand_desc": {
+ "message": "在按住修饰键的同时选择文本时立即执行指定的命令。"
+ },
+ "Option_instantCommandEnabled": {
+ "message": "启用即时命令"
+ },
+ "Option_instantCommandEnabled_desc": {
+ "message": "启用后,命令将立即执行而不显示菜单。"
+ },
+ "Option_instantCommandId": {
+ "message": "要执行的命令"
+ },
+ "Option_instantCommandId_desc": {
+ "message": "选择要与修饰键一起执行的命令。"
+ },
+ "Option_instantCommandId_placeholder": {
+ "message": "选择命令..."
+ },
+ "Option_instantCommandModifierKey": {
+ "message": "修饰键"
+ },
+ "Option_instantCommandModifierKey_desc": {
+ "message": "选择选择文本时要按住的键。"
+ },
+ "Option_instantCommandModifierKey_placeholder": {
+ "message": "选择键..."
+ },
"Option_linkCommand": {
"message": "链接预览"
},
@@ -458,6 +608,12 @@
"Option_showIndicator_desc": {
"message": "显示预览前的剩余时间。"
},
+ "Option_windowSettings": {
+ "message": "窗口设置"
+ },
+ "Option_windowSettings_desc": {
+ "message": "配置窗口行为和弹出窗口设置。"
+ },
"Option_folders": {
"message": "文件夹"
},
@@ -545,9 +701,15 @@
"Option_userStyles_option_border_color": {
"message": "边框颜色"
},
+ "Option_userStyles_option_font_color": {
+ "message": "字体颜色"
+ },
"Option_userStyles_desc_border_color": {
"message": "指定菜单的边框颜色。默认:#F3F4F6"
},
+ "Option_userStyles_desc_font_color": {
+ "message": "指定字体颜色。默认值:#0F172A"
+ },
"Option_userStyles_option_font_scale": {
"message": "字体大小缩放"
},
@@ -626,6 +788,9 @@
}
}
},
+ "Option_zod_invalid_variable_name": {
+ "message": "变量名必须以字母开头,且只能包含字母、数字和下划线"
+ },
"Option_zod_string_min": {
"message": "请输入至少$key$个字符的字符串。",
"placeholders": {
@@ -647,6 +812,9 @@
"Option_zod_url": {
"message": "请以URL格式输入。"
},
+ "zod_pageUrl_required_for_currentTab": {
+ "message": "选择当前标签页模式时,页面 URL 为必填项。"
+ },
"PageAction_InputMenu_mark_selectedText": {
"message": "将插入选中的文本",
"description": "for INSERT.SELECTED_TEXT"
@@ -903,41 +1071,14 @@
"message": "显示标签页并激活。显示在当前标签页的右侧。"
},
"Option_openMode_backgroundTab_desc": {
- "Option_userVariables": {
- "message": "用户变量"
- },
- "Option_userVariables_tooltip": {
- "message": "定义在页面操作中使用的自定义变量。这些变量可以在输入字段中使用{{变量名}}的形式。"
- },
- "Option_userVariable_name": {
- "message": "名称"
- },
- "Option_userVariable_value": {
- "message": "值"
- },
- "Option_userVariable_add": {
- "message": "添加变量"
- },
- "Option_userVariable_name_required": {
- "message": "变量名是必需的"
- },
- "Option_userVariable_name_invalid": {
- "message": "名称必须以字母开头,只能包含字母、数字和下划线"
- },
- "Option_userVariable_name_duplicate": {
- "message": "变量名已存在"
- },
- "Option_userVariable_max_reached": {
- "message": "最多允许$max$个变量",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
"message": "在后台打开标签页。显示在当前标签页的右侧。"
},
+ "Option_openMode_currentTab": {
+ "message": "当前标签页"
+ },
+ "Option_openMode_currentTab_desc": {
+ "message": "在当前活动标签页中运行。URL 必须与记录的起始 URL 匹配。"
+ },
"Option_title_desc": {
"message": "显示为命令标题。"
},
@@ -950,30 +1091,15 @@
"Option_openModeSecondary_desc": {
"message": "Ctrl + 点击菜单时的行为。"
},
- "Option_parentFolder": {
- "message": "父文件夹"
- },
- "Option_parentFolder_desc": {
- "message": "选择父文件夹"
- },
- "Option_rootFolder": {
- "message": "根目录(无父级)"
- },
"Option_searchUrlAssist": {
"message": "AI 助手"
},
+ "Option_searchUrlAssist_title": {
+ "message": "搜索 URL 助手"
+ },
"Option_searchUrlAssist_desc": {
"message": "输入搜索关键词和搜索结果页面的 URL,然后点击执行按钮。\nAI 将生成搜索 URL。"
},
- "Option_searchUrlAssist_executeButton": {
- "message": "使用 Gemini 执行"
- },
- "Option_searchUrlAssist_executing": {
- "message": "正在执行..."
- },
- "Option_searchUrlAssist_howToUse": {
- "message": "使用方法"
- },
"Option_searchUrlAssist_searchKeyword": {
"message": "(1) 搜索关键词"
},
@@ -986,6 +1112,9 @@
"Option_searchUrlAssist_searchResultUrl_placeholder": {
"message": "https://www.google.com/search?q=test&..."
},
+ "Option_searchUrlAssist_howToUse": {
+ "message": "使用方法"
+ },
"Option_searchUrlAssist_step1": {
"message": "在“(1) 搜索关键词”中输入任意单词"
},
@@ -1001,8 +1130,11 @@
"Option_searchUrlAssist_step5": {
"message": "复制生成的搜索 URL 并粘贴到命令的搜索 URL 字段"
},
- "Option_searchUrlAssist_title": {
- "message": "搜索 URL 助手"
+ "Option_searchUrlAssist_executeButton": {
+ "message": "使用 Gemini 执行"
+ },
+ "Option_searchUrlAssist_executing": {
+ "message": "正在执行..."
},
"Option_searchUrlAssist_validation_keyword_required": {
"message": "请输入搜索关键词"
@@ -1010,42 +1142,6 @@
"Option_searchUrlAssist_validation_url_invalid": {
"message": "请输入有效的 URL"
},
- "Option_userVariable_add": {
- "message": "添加变量"
- },
- "Option_userVariable_max_reached": {
- "message": "最多允许 $max$ 个变量",
- "placeholders": {
- "max": {
- "content": "$1",
- "example": "5"
- }
- }
- },
- "Option_userVariable_name": {
- "message": "变量名"
- },
- "Option_userVariable_name_duplicate": {
- "message": "变量名已存在"
- },
- "Option_userVariable_name_invalid": {
- "message": "变量名必须以字母开头,且只能包含字母、数字和下划线"
- },
- "Option_userVariable_name_required": {
- "message": "变量名是必需的"
- },
- "Option_userVariable_value": {
- "message": "值"
- },
- "Option_userVariables": {
- "message": "用户变量"
- },
- "Option_userVariables_desc": {
- "message": "为输入字段定义自定义变量(最多 5 个)"
- },
- "Option_zod_invalid_variable_name": {
- "message": "变量名必须以字母开头,且只能包含字母、数字和下划线"
- },
"Option_commandType": {
"message": "命令类型"
},
@@ -1129,5 +1225,23 @@
},
"prompthistory_banner_description": {
"message": "用于在 ChatGPT 等中重用提示的新工具✨"
+ },
+ "Option_windowState": {
+ "message": "窗口大小"
+ },
+ "Option_windowState_desc": {
+ "message": "显示窗口的大小"
+ },
+ "Option_windowState_normal": {
+ "message": "正常"
+ },
+ "Option_windowState_maximized": {
+ "message": "最大化"
+ },
+ "Option_windowState_fullscreen": {
+ "message": "全屏"
+ },
+ "Menu_disabled_urlNotMatch": {
+ "message": "无法在此页面运行(页面URL不匹配)"
}
}
diff --git a/packages/extension/public/setting/open_mode/currentTab.png b/packages/extension/public/setting/open_mode/currentTab.png
new file mode 100644
index 00000000..8e2aca3a
Binary files /dev/null and b/packages/extension/public/setting/open_mode/currentTab.png differ
diff --git a/packages/extension/public/setting/open_mode/side_panel.png b/packages/extension/public/setting/open_mode/side_panel.png
new file mode 100644
index 00000000..f33aae05
Binary files /dev/null and b/packages/extension/public/setting/open_mode/side_panel.png differ
diff --git a/packages/extension/scripts/check-command-ids.mjs b/packages/extension/scripts/check-command-ids.mjs
new file mode 100644
index 00000000..acf250c4
--- /dev/null
+++ b/packages/extension/scripts/check-command-ids.mjs
@@ -0,0 +1,237 @@
+#!/usr/bin/env tsx
+
+import { cmd2uuid } from "../../shared/src/utils/uuid.ts"
+
+/**
+ * Generates UUIDs for each CMD_* command in defaultSettings.ts using the same
+ * logic as cmd2uuid (uuid.ts), then compares with the currently set id.
+ *
+ * Output format (per line):
+ * <生成id>
+ *
+ * Usage:
+ * yarn check-ids
+ * # or
+ * tsx scripts/check-command-ids.mjs
+ */
+import fs from "fs"
+import path from "path"
+import { fileURLToPath } from "url"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const SRC_PATH = path.resolve(
+ __dirname,
+ "../src/services/option/defaultSettings.ts",
+)
+
+const src = fs.readFileSync(SRC_PATH, "utf-8")
+
+// ---- Constants (mirroring the TypeScript enums) ----
+const OPEN_MODE = {
+ POPUP: "popup",
+ WINDOW: "window",
+ TAB: "tab",
+ BACKGROUND_TAB: "backgroundTab",
+ SIDE_PANEL: "sidePanel",
+ API: "api",
+ PAGE_ACTION: "pageAction",
+ AI_PROMPT: "aiPrompt",
+}
+
+const CHECKABLE_MODES = new Set([
+ OPEN_MODE.POPUP,
+ OPEN_MODE.TAB,
+ OPEN_MODE.WINDOW,
+ OPEN_MODE.BACKGROUND_TAB,
+ OPEN_MODE.SIDE_PANEL,
+ OPEN_MODE.AI_PROMPT,
+ OPEN_MODE.PAGE_ACTION,
+])
+
+const DRAG_OPEN_MODE = {
+ PREVIEW_POPUP: "previewPopup",
+ PREVIEW_WINDOW: "previewWindow",
+ PREVIEW_SIDE_PANEL: "previewSidePanel",
+}
+
+const SPACE_ENCODING = {
+ PLUS: "plus",
+ PERCENT: "percent",
+ DASH: "dash",
+ UNDERSCORE: "underscore",
+}
+
+// ---- Resolve enum references in a value string ----
+
+function resolveValue(raw) {
+ const trimmed = raw.trim().replace(/,\s*$/, "")
+
+ // String literal
+ const strMatch = trimmed.match(/^"([^"]*)"$/)
+ if (strMatch) return strMatch[1]
+
+ // OPEN_MODE.*
+ const omMatch = trimmed.match(/^OPEN_MODE\.(\w+)$/)
+ if (omMatch) return OPEN_MODE[omMatch[1]] ?? trimmed
+
+ // DRAG_OPEN_MODE.*
+ const domMatch = trimmed.match(/^DRAG_OPEN_MODE\.(\w+)$/)
+ if (domMatch) return DRAG_OPEN_MODE[domMatch[1]] ?? trimmed
+
+ // SPACE_ENCODING.*
+ const seMatch = trimmed.match(/^SPACE_ENCODING\.(\w+)$/)
+ if (seMatch) return SPACE_ENCODING[seMatch[1]] ?? trimmed
+
+ return trimmed
+}
+
+// ---- Parse object body into key-value pairs (top-level only) ----
+
+function parseObjectBody(body) {
+ const result = {}
+ let depth = 0
+ let inBlockComment = false
+ let inString = null
+ let escapeNext = false
+ const lines = body.split("\n")
+ for (const line of lines) {
+ // Track brace/bracket depth to skip nested structures.
+ // クォート内やコメント内に出現する括弧は depth に影響させない。
+ escapeNext = false
+ let i = 0
+ while (i < line.length) {
+ const ch = line[i]
+ const next = i + 1 < line.length ? line[i + 1] : ""
+
+ // 文字列リテラル内
+ if (inString) {
+ if (escapeNext) {
+ escapeNext = false
+ i++
+ continue
+ }
+ if (ch === "\\") {
+ escapeNext = true
+ i++
+ continue
+ }
+ if (ch === inString) {
+ inString = null
+ }
+ i++
+ continue
+ }
+
+ // ブロックコメント内
+ if (inBlockComment) {
+ if (ch === "*" && next === "/") {
+ inBlockComment = false
+ i += 2
+ continue
+ }
+ i++
+ continue
+ }
+
+ // 行コメント開始
+ if (ch === "/" && next === "/") {
+ break
+ }
+
+ // ブロックコメント開始
+ if (ch === "/" && next === "*") {
+ inBlockComment = true
+ i += 2
+ continue
+ }
+
+ // 文字列開始
+ if (ch === '"' || ch === "'" || ch === "`") {
+ inString = ch
+ i++
+ continue
+ }
+
+ // 括弧カウント(文字列・コメント以外のみ)
+ if (ch === "{" || ch === "[") {
+ depth++
+ } else if (ch === "}" || ch === "]") {
+ depth--
+ }
+
+ i++
+ }
+
+ // Only parse top-level key: value pairs (depth === 0 before this line)
+ if (depth > 0) continue
+ const m = line.match(/^\s*(\w+)\s*:\s*(.+)/)
+ if (m) {
+ const key = m[1]
+ const val = m[2].trim().replace(/,\s*$/, "")
+ // Skip nested objects/arrays
+ if (val.startsWith("{") || val.startsWith("[")) continue
+ result[key] = resolveValue(val)
+ }
+ }
+ return result
+}
+
+// ---- Find matching brace ----
+
+function findMatchingBrace(text, openIdx) {
+ let depth = 0
+ for (let i = openIdx; i < text.length; i++) {
+ if (text[i] === "{") depth++
+ else if (text[i] === "}") {
+ depth--
+ if (depth === 0) return i
+ }
+ }
+ return -1
+}
+
+// ---- Extract CMD_* definitions ----
+
+const cmdStartRegex = /^const (CMD_\w+)\s*=\s*\{/gm
+const commands = []
+let m
+
+while ((m = cmdStartRegex.exec(src)) !== null) {
+ const name = m[1]
+ const openIdx = src.indexOf("{", m.index + m[0].length - 1)
+ const closeIdx = findMatchingBrace(src, openIdx)
+ if (closeIdx < 0) continue
+
+ const body = src.slice(openIdx + 1, closeIdx)
+ const parsed = parseObjectBody(body)
+ commands.push({ name, parsed, body })
+}
+
+// ---- Generate UUID and compare ----
+
+const PAD_NAME = 28
+const PAD_ID = 38
+
+console.log(
+ `${"CONST".padEnd(PAD_NAME)} ${"GENERATED_ID".padEnd(PAD_ID)} CHECK`,
+)
+console.log("-".repeat(PAD_NAME + PAD_ID + 10))
+
+for (const { name, parsed } of commands) {
+ const currentId = parsed.id
+ const openMode = parsed.openMode
+
+ // Determine command type
+ if (CHECKABLE_MODES.has(openMode)) {
+ const generatedId = cmd2uuid(parsed)
+ const check = currentId === generatedId ? "OK" : "MISMATCH"
+ console.log(
+ `${name.padEnd(PAD_NAME)} ${generatedId.padEnd(PAD_ID)} ${check}${check === "MISMATCH" ? ` (current: ${currentId})` : ""}`,
+ )
+ } else {
+ // Drag commands or other special types
+ console.log(
+ `${name.padEnd(PAD_NAME)} ${"(special - N/A)".padEnd(PAD_ID)} SKIP`,
+ )
+ }
+}
diff --git a/packages/extension/scripts/check-command-ids.ts b/packages/extension/scripts/check-command-ids.ts
new file mode 100644
index 00000000..bd5f090a
--- /dev/null
+++ b/packages/extension/scripts/check-command-ids.ts
@@ -0,0 +1,73 @@
+#!/usr/bin/env vite-node
+import { cmd2uuid } from "@shared/utils/uuid"
+import {
+ DefaultCommands,
+ LOCALE_COMMANDS,
+} from "@/services/option/defaultSettings"
+import { OPEN_MODE } from "@/const"
+
+/**
+ * Generates UUIDs for each default command using cmd2uuid (uuid.ts),
+ * then compares with the currently set id.
+ *
+ * Usage:
+ * yarn check-ids
+ */
+
+// Modes where cmd2uuid can generate a deterministic ID
+const CHECKABLE_MODES = new Set([
+ OPEN_MODE.POPUP,
+ OPEN_MODE.TAB,
+ OPEN_MODE.WINDOW,
+ OPEN_MODE.BACKGROUND_TAB,
+ OPEN_MODE.SIDE_PANEL,
+ OPEN_MODE.AI_PROMPT,
+ OPEN_MODE.PAGE_ACTION,
+])
+
+// Collect all unique commands from DefaultCommands and all locales
+const seen = new Set()
+const commands: Array<{ title: string; parsed: Record }> = []
+
+for (const cmd of DefaultCommands) {
+ const c = cmd as Record
+ if (seen.has(c.id)) continue
+ seen.add(c.id)
+ commands.push({ title: c.title, parsed: c })
+}
+
+for (const cmds of Object.values(LOCALE_COMMANDS)) {
+ for (const cmd of cmds) {
+ const c = cmd as Record
+ if (seen.has(c.id)) continue
+ seen.add(c.id)
+ commands.push({ title: c.title, parsed: c })
+ }
+}
+
+// Generate UUID and compare
+const PAD_NAME = 36
+const PAD_ID = 38
+
+console.log(
+ `${"TITLE".padEnd(PAD_NAME)} ${"GENERATED_ID".padEnd(PAD_ID)} CHECK`,
+)
+console.log("-".repeat(PAD_NAME + PAD_ID + 10))
+
+for (const { title, parsed } of commands) {
+ const currentId = parsed.id
+ const openMode = parsed.openMode
+
+ if (CHECKABLE_MODES.has(openMode)) {
+ const generatedId = cmd2uuid(parsed)
+ const check = currentId === generatedId ? "OK" : "MISMATCH"
+ console.log(
+ `${title.padEnd(PAD_NAME)} ${generatedId.padEnd(PAD_ID)} ${check}${check === "MISMATCH" ? ` (current: ${currentId})` : ""}`,
+ )
+ } else {
+ // Drag commands or other special types
+ console.log(
+ `${title.padEnd(PAD_NAME)} ${"(special - N/A)".padEnd(PAD_ID)} SKIP`,
+ )
+ }
+}
diff --git a/packages/extension/scripts/e2e-ignore-urls.txt b/packages/extension/scripts/e2e-ignore-urls.txt
new file mode 100644
index 00000000..4fbab1e2
--- /dev/null
+++ b/packages/extension/scripts/e2e-ignore-urls.txt
@@ -0,0 +1,12 @@
+https://lista.mercadolivre.com.br/%s
+https://www.amazon.com.br/s?k=%s
+https://www.amazon.de/s?k=%s
+https://www.amazon.fr/s?k=%s
+https://www.amazon.in/s?k=%s
+https://www.amazon.it/s?k=%s
+https://www.coupang.com/np/search?q=%s
+https://www.flipkart.com/search?q=%s
+https://www.leboncoin.fr/recherche?text=%s
+https://www.ozon.ru/search/?text=%s
+https://www.wildberries.ru/catalog/0/search.aspx?search=%s
+https://www.zhihu.com/search?q=%s
diff --git a/packages/extension/scripts/generate-e2e-urls.mjs b/packages/extension/scripts/generate-e2e-urls.mjs
new file mode 100644
index 00000000..e7f13c97
--- /dev/null
+++ b/packages/extension/scripts/generate-e2e-urls.mjs
@@ -0,0 +1,267 @@
+#!/usr/bin/env node
+/**
+ * Extracts search URLs from defaultSettings.ts and generates
+ * e2e/generated-command-urls.ts for the URL status e2e test.
+ *
+ * Invoked automatically as part of "yarn build:e2e".
+ */
+import fs from "fs"
+import path from "path"
+import { fileURLToPath } from "url"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const SRC_PATH = path.resolve(
+ __dirname,
+ "../src/services/option/defaultSettings.ts",
+)
+const OUT_PATH = path.resolve(__dirname, "../e2e/generated-command-urls.ts")
+const IGNORE_PATH = path.resolve(__dirname, "e2e-ignore-urls.txt")
+
+// Load ignored URLs from the ignore list
+const ignoreUrls = new Set(
+ fs
+ .readFileSync(IGNORE_PATH, "utf-8")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith("#")),
+)
+
+const src = fs.readFileSync(SRC_PATH, "utf-8")
+
+// -------------------------------------------------------------------
+// Helper: find the index of the matching closing brace for an opening
+// brace at `openIdx`.
+// NOTE: Braces inside string literals and comments are ignored.
+// -------------------------------------------------------------------
+function findMatchingBrace(text, openIdx) {
+ let depth = 0
+ let inSingleQuote = false
+ let inDoubleQuote = false
+ let inTemplateLiteral = false
+ let inLineComment = false
+ let inBlockComment = false
+
+ for (let i = openIdx; i < text.length; i++) {
+ const ch = text[i]
+ const next = text[i + 1]
+ const prev = text[i - 1]
+
+ // 行コメントの終了判定
+ if (inLineComment) {
+ if (ch === "\n") {
+ inLineComment = false
+ }
+ continue
+ }
+
+ // ブロックコメントの終了判定
+ if (inBlockComment) {
+ if (ch === "*" && next === "/") {
+ inBlockComment = false
+ i++ // "*/" を飛ばす
+ }
+ continue
+ }
+
+ // いずれの文字列リテラル内でもない場合のみコメント開始を判定
+ if (!inSingleQuote && !inDoubleQuote && !inTemplateLiteral) {
+ if (ch === "/" && next === "/") {
+ inLineComment = true
+ i++ // "//" を飛ばす
+ continue
+ }
+ if (ch === "/" && next === "*") {
+ inBlockComment = true
+ i++ // "/*" を飛ばす
+ continue
+ }
+ }
+
+ // コメント外でのみ文字列リテラルの開始/終了を判定
+ if (!inLineComment && !inBlockComment) {
+ if (!inDoubleQuote && !inTemplateLiteral && ch === "'" && prev !== "\\") {
+ inSingleQuote = !inSingleQuote
+ continue
+ }
+ if (!inSingleQuote && !inTemplateLiteral && ch === '"' && prev !== "\\") {
+ inDoubleQuote = !inDoubleQuote
+ continue
+ }
+ if (!inSingleQuote && !inDoubleQuote && ch === "`" && prev !== "\\") {
+ inTemplateLiteral = !inTemplateLiteral
+ continue
+ }
+ }
+
+ // いずれかの文字列リテラル内では波括弧を無視
+ if (inSingleQuote || inDoubleQuote || inTemplateLiteral) {
+ continue
+ }
+
+ // コード本体のみで波括弧のネストをカウント
+ if (ch === "{") {
+ depth++
+ } else if (ch === "}") {
+ depth--
+ if (depth === 0) {
+ return i
+ }
+ }
+ }
+
+ return -1
+}
+
+// -------------------------------------------------------------------
+// Helper: extract title and searchUrl from an object body string.
+// Returns null if searchUrl is missing or empty.
+// -------------------------------------------------------------------
+function extractTitleAndUrl(body) {
+ const titleMatch = body.match(/title:\s*"([^"]*)"/)
+ const urlMatch = body.match(/searchUrl:\s*\n?\s*"([^"]*)"/)
+ if (titleMatch && urlMatch && urlMatch[1]) {
+ return { title: titleMatch[1], searchUrl: urlMatch[1] }
+ }
+ return null
+}
+
+// -------------------------------------------------------------------
+// 1. Extract CMD_* variable definitions → Map
+// -------------------------------------------------------------------
+const cmdDefs = new Map()
+const cmdStartRegex = /^const (CMD_\w+)\s*=\s*\{/gm
+let m
+while ((m = cmdStartRegex.exec(src)) !== null) {
+ const name = m[1]
+ const openIdx = src.indexOf("{", m.index + m[0].length - 1)
+ const closeIdx = findMatchingBrace(src, openIdx)
+ if (closeIdx < 0) continue
+
+ const body = src.slice(openIdx + 1, closeIdx)
+ const parsed = extractTitleAndUrl(body)
+ cmdDefs.set(name, {
+ title: parsed ? parsed.title : "",
+ searchUrl: parsed ? parsed.searchUrl : "",
+ })
+}
+
+// -------------------------------------------------------------------
+// 2. Extract DefaultCommands literal array → en entries
+// -------------------------------------------------------------------
+const defaultEntries = []
+const defaultArrayStart = src.indexOf("export const DefaultCommands = [")
+if (defaultArrayStart >= 0) {
+ const arrayOpenBracket = src.indexOf("[", defaultArrayStart)
+ // Find the matching ] — simple bracket counting
+ let depth = 0
+ let arrayEnd = -1
+ for (let i = arrayOpenBracket; i < src.length; i++) {
+ if (src[i] === "[") depth++
+ else if (src[i] === "]") {
+ depth--
+ if (depth === 0) {
+ arrayEnd = i
+ break
+ }
+ }
+ }
+ if (arrayEnd >= 0) {
+ const arrBody = src.slice(arrayOpenBracket + 1, arrayEnd)
+ // Extract each top-level { ... } object inside the array
+ let braceDepth = 0
+ let objStart = -1
+ for (let i = 0; i < arrBody.length; i++) {
+ if (arrBody[i] === "{") {
+ if (braceDepth === 0) objStart = i
+ braceDepth++
+ } else if (arrBody[i] === "}") {
+ braceDepth--
+ if (braceDepth === 0 && objStart >= 0) {
+ const objBody = arrBody.slice(objStart + 1, i)
+ const parsed = extractTitleAndUrl(objBody)
+ if (parsed) {
+ defaultEntries.push({ ...parsed, locale: "en" })
+ }
+ objStart = -1
+ }
+ }
+ }
+ }
+}
+
+// -------------------------------------------------------------------
+// 3. Extract getDefaultCommands locale blocks
+// -------------------------------------------------------------------
+const localeEntries = []
+const funcStart = src.indexOf("export function getDefaultCommands")
+if (funcStart >= 0) {
+ const funcBody = src.slice(funcStart)
+
+ // Match each locale block following this pattern:
+ // // ja: Japan
+ // if (lang.startsWith("ja")) {
+ // return [
+ // CMD_XXX,
+ // ...
+ // ] as Command[]
+ // }
+ // Also handles exact match: if (lang === "pt-br") { ... }
+ const blockRegex =
+ /\/\/\s*([\w-]+):.*\n\s*if\s*\(.*\)\s*\{\s*\n\s*return\s*\[([\s\S]*?)\]\s*as\s*Command\[\]/g
+ let blockMatch
+ while ((blockMatch = blockRegex.exec(funcBody)) !== null) {
+ const locale = blockMatch[1]
+ const returnBody = blockMatch[2]
+
+ const cmdRefRegex = /(CMD_\w+)/g
+ let cmdRef
+ while ((cmdRef = cmdRefRegex.exec(returnBody)) !== null) {
+ const def = cmdDefs.get(cmdRef[1])
+ if (def && def.searchUrl) {
+ localeEntries.push({
+ title: def.title,
+ searchUrl: def.searchUrl,
+ locale,
+ })
+ }
+ }
+ }
+}
+
+// -------------------------------------------------------------------
+// 4. Combine and deduplicate by searchUrl
+// -------------------------------------------------------------------
+const allEntries = [...defaultEntries, ...localeEntries]
+const seen = new Set()
+const unique = []
+for (const entry of allEntries) {
+ if (!seen.has(entry.searchUrl) && !ignoreUrls.has(entry.searchUrl)) {
+ seen.add(entry.searchUrl)
+ unique.push(entry)
+ }
+}
+
+// -------------------------------------------------------------------
+// 5. Write generated TypeScript file
+// -------------------------------------------------------------------
+const lines = unique
+ .map(
+ (e) =>
+ ` { title: ${JSON.stringify(e.title)}, locale: ${JSON.stringify(e.locale)}, searchUrl: ${JSON.stringify(e.searchUrl)} },`,
+ )
+ .join("\n")
+
+const output = `/**
+ * Auto-generated by scripts/generate-e2e-urls.mjs from defaultSettings.ts.
+ * Do not edit manually. Run "yarn build:e2e" to regenerate.
+ */
+
+export type UrlEntry = { title: string; locale: string; searchUrl: string }
+
+export const COMMAND_URLS: UrlEntry[] = [
+${lines}
+]
+`
+
+fs.writeFileSync(OUT_PATH, output, "utf-8")
+console.log(`Generated ${OUT_PATH} with ${unique.length} URL entries.`)
diff --git a/packages/extension/scripts/generate-e2e-urls.ts b/packages/extension/scripts/generate-e2e-urls.ts
new file mode 100644
index 00000000..fe1bf87b
--- /dev/null
+++ b/packages/extension/scripts/generate-e2e-urls.ts
@@ -0,0 +1,81 @@
+#!/usr/bin/env vite-node
+/**
+ * Extracts search URLs from defaultSettings.ts and generates
+ * e2e/generated-command-urls.ts for the URL status e2e test.
+ *
+ * Invoked automatically as part of "yarn build:e2e".
+ */
+import fs from "fs"
+import path from "path"
+import { fileURLToPath } from "url"
+import {
+ DefaultCommands,
+ LOCALE_COMMANDS,
+} from "@/services/option/defaultSettings"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const OUT_PATH = path.resolve(__dirname, "../e2e/generated-command-urls.ts")
+const IGNORE_PATH = path.resolve(__dirname, "e2e-ignore-urls.txt")
+
+// Load ignored URLs from the ignore list
+const ignoreUrls = new Set(
+ fs
+ .readFileSync(IGNORE_PATH, "utf-8")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith("#")),
+)
+
+type Entry = { title: string; locale: string; searchUrl: string }
+
+// Collect all entries: DefaultCommands as "en", then locale-specific ones
+const allEntries: Entry[] = []
+
+for (const cmd of DefaultCommands) {
+ const c = cmd as Record
+ if (c.searchUrl) {
+ allEntries.push({ title: c.title, locale: "en", searchUrl: c.searchUrl })
+ }
+}
+
+for (const [locale, cmds] of Object.entries(LOCALE_COMMANDS)) {
+ for (const cmd of cmds) {
+ const c = cmd as Record
+ if (c.searchUrl) {
+ allEntries.push({ title: c.title, locale, searchUrl: c.searchUrl })
+ }
+ }
+}
+
+// Deduplicate by searchUrl
+const seen = new Set()
+const unique: Entry[] = []
+for (const entry of allEntries) {
+ if (!seen.has(entry.searchUrl) && !ignoreUrls.has(entry.searchUrl)) {
+ seen.add(entry.searchUrl)
+ unique.push(entry)
+ }
+}
+
+// Write generated TypeScript file
+const lines = unique
+ .map(
+ (e) =>
+ ` { title: ${JSON.stringify(e.title)}, locale: ${JSON.stringify(e.locale)}, searchUrl: ${JSON.stringify(e.searchUrl)} },`,
+ )
+ .join("\n")
+
+const output = `/**
+ * Auto-generated by scripts/generate-e2e-urls.ts from defaultSettings.ts.
+ * Do not edit manually. Run "yarn build:e2e" to regenerate.
+ */
+
+export type UrlEntry = { title: string; locale: string; searchUrl: string }
+
+export const COMMAND_URLS: UrlEntry[] = [
+${lines}
+]
+`
+
+fs.writeFileSync(OUT_PATH, output, "utf-8")
+console.log(`Generated ${OUT_PATH} with ${unique.length} URL entries.`)
diff --git a/packages/extension/scripts/vite.config.ts b/packages/extension/scripts/vite.config.ts
new file mode 100644
index 00000000..b4a41a89
--- /dev/null
+++ b/packages/extension/scripts/vite.config.ts
@@ -0,0 +1,18 @@
+import path from "path"
+import { defineConfig } from "vite"
+import packageJson from "../package.json"
+
+// Minimal vite config for scripts run via vite-node.
+// Only includes alias resolution and define — no browser plugins.
+export default defineConfig({
+ define: {
+ __APP_NAME__: JSON.stringify(packageJson.name),
+ __APP_VERSION__: JSON.stringify(packageJson.version),
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "../src"),
+ "@shared": path.resolve(__dirname, "../../shared/src"),
+ },
+ },
+})
diff --git a/packages/extension/src/action/aiPrompt.ts b/packages/extension/src/action/aiPrompt.ts
new file mode 100644
index 00000000..18eb6c19
--- /dev/null
+++ b/packages/extension/src/action/aiPrompt.ts
@@ -0,0 +1,179 @@
+import { Ipc, BgCommand, SidePanelPendingAction } from "@/services/ipc"
+import { getWindowPosition } from "@/services/screen"
+import { isValidString, generateRandomID } from "@/lib/utils"
+import {
+ OPEN_MODE,
+ PAGE_ACTION_OPEN_MODE,
+ PAGE_ACTION_CONTROL,
+ PAGE_ACTION_EVENT,
+ SelectorType,
+} from "@/const"
+import { PopupOption } from "@/services/option/defaultSettings"
+import type { ExecuteCommandParams, PageActionStep, UrlParam } from "@/types"
+import type { OpenAndRunProps } from "@/services/pageAction/background"
+import type { OpenSidePanelProps } from "@/services/chrome"
+import { findAiService } from "@/services/aiPrompt"
+import { isAiPromptType } from "@/types/schema"
+import { INSERT, toInsertTemplate } from "@/services/pageAction"
+import { Storage, SESSION_STORAGE_KEY } from "@/services/storage"
+
+// Map OPEN_MODE to PAGE_ACTION_OPEN_MODE for openAndRun
+const toPageActionMode = (mode: OPEN_MODE): PAGE_ACTION_OPEN_MODE => {
+ switch (mode) {
+ case OPEN_MODE.TAB:
+ return PAGE_ACTION_OPEN_MODE.TAB
+ case OPEN_MODE.BACKGROUND_TAB:
+ return PAGE_ACTION_OPEN_MODE.BACKGROUND_TAB
+ case OPEN_MODE.WINDOW:
+ return PAGE_ACTION_OPEN_MODE.WINDOW
+ default:
+ return PAGE_ACTION_OPEN_MODE.POPUP
+ }
+}
+
+export const AiPrompt = {
+ async execute({
+ selectionText,
+ command,
+ position,
+ useSecondary,
+ useClipboard,
+ }: ExecuteCommandParams) {
+ if (!isAiPromptType(command)) {
+ console.error("command is not for AiPrompt.")
+ return
+ }
+
+ const aiPromptOption = command.aiPromptOption
+ const service = await findAiService(aiPromptOption.serviceId)
+
+ if (!service) {
+ console.error(`AI service not found: ${aiPromptOption.serviceId}`)
+ return
+ }
+
+ if (!isValidString(service.url)) {
+ console.error("AI service URL is not valid.")
+ return
+ }
+
+ // Join multiple selectors with comma to support fallback matching via querySelector
+ const inputSelector = service.inputSelectors.join(", ")
+ const submitSelector = service.submitSelectors.join(", ")
+
+ const steps: PageActionStep[] = [
+ {
+ id: generateRandomID(),
+ delayMs: 0,
+ skipRenderWait: false,
+ param: {
+ type: PAGE_ACTION_CONTROL.start,
+ label: "Start",
+ mode: "aiPrompt",
+ },
+ },
+ {
+ id: generateRandomID(),
+ delayMs: 200,
+ skipRenderWait: false,
+ param: {
+ type: PAGE_ACTION_EVENT.input,
+ label: "Input prompt",
+ selector: inputSelector,
+ selectorType: SelectorType.css,
+ value: aiPromptOption.prompt,
+ },
+ },
+ {
+ id: generateRandomID(),
+ delayMs: 200,
+ skipRenderWait: false,
+ param: {
+ type: PAGE_ACTION_EVENT.click,
+ label: "Submit",
+ selector: submitSelector,
+ selectorType: SelectorType.css,
+ },
+ },
+ {
+ id: generateRandomID(),
+ delayMs: 0,
+ skipRenderWait: false,
+ param: {
+ type: PAGE_ACTION_CONTROL.end,
+ label: "End",
+ },
+ },
+ ]
+
+ // Checks if any step requires clipboard data
+ const needClipboard = aiPromptOption.prompt.includes(
+ toInsertTemplate(INSERT.CLIPBOARD),
+ )
+
+ // Handle side panel mode: store pending steps in session storage, then open
+ // the side panel. The background onConnect handler will pick up the pending
+ // steps when the side panel content script establishes a port connection.
+ // Clipboard reading is deferred to the background script context to avoid
+ // browser security restrictions on navigator.clipboard in content scripts.
+ if (aiPromptOption.openMode === OPEN_MODE.SIDE_PANEL) {
+ const pending: SidePanelPendingAction = {
+ url: service.url,
+ steps,
+ selectedText: selectionText,
+ srcUrl: location.href,
+ clipboardText: "",
+ useClipboard: needClipboard || (useClipboard ?? false),
+ }
+ try {
+ await Storage.set(
+ SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING,
+ pending,
+ )
+ } catch (e) {
+ console.error("Failed to store pending side panel action:", e)
+ return
+ }
+ Ipc.send(BgCommand.openSidePanel, {
+ url: service.url,
+ })
+ return
+ }
+
+ // position is required for non-SIDE_PANEL modes (e.g. popup placement)
+ if (position === null) {
+ console.error("position is null.")
+ return
+ }
+
+ const baseMode = toPageActionMode(aiPromptOption.openMode)
+ const openMode = useSecondary
+ ? baseMode === PAGE_ACTION_OPEN_MODE.TAB
+ ? PAGE_ACTION_OPEN_MODE.WINDOW
+ : baseMode === PAGE_ACTION_OPEN_MODE.WINDOW
+ ? PAGE_ACTION_OPEN_MODE.TAB
+ : PAGE_ACTION_OPEN_MODE.TAB
+ : baseMode
+
+ const windowPosition = await getWindowPosition()
+
+ const url: UrlParam = {
+ searchUrl: service.url,
+ selectionText,
+ useClipboard: needClipboard || (useClipboard ?? false),
+ }
+
+ Ipc.send(BgCommand.openAndRunPageAction, {
+ commandId: command.id,
+ url,
+ steps,
+ top: Math.floor(windowPosition.top + position.y),
+ left: Math.floor(windowPosition.left + position.x),
+ height: command.popupOption?.height ?? PopupOption.height,
+ width: command.popupOption?.width ?? PopupOption.width,
+ selectedText: selectionText,
+ srcUrl: location.href,
+ openMode,
+ })
+ },
+}
diff --git a/packages/extension/src/action/background.ts b/packages/extension/src/action/background.ts
index ce6d0757..db3be62f 100644
--- a/packages/extension/src/action/background.ts
+++ b/packages/extension/src/action/background.ts
@@ -3,18 +3,22 @@ import { Popup } from "./popup"
import { Window } from "./window"
import { Tab } from "./tab"
import { BackgroundTab } from "./backgroundTab"
+import { SidePanel } from "./sidePanel"
import { Api } from "./api"
import { PageAction } from "./pageAction"
+import { AiPrompt } from "./aiPrompt"
import { executeAction } from "./executor"
import type { ExecuteCommandParams } from "@/types"
-export const actionsForBackground = {
+const actionsForBackground = {
[OPEN_MODE_BG.POPUP]: Popup,
[OPEN_MODE_BG.WINDOW]: Window,
[OPEN_MODE_BG.TAB]: Tab,
[OPEN_MODE_BG.BACKGROUND_TAB]: BackgroundTab,
+ [OPEN_MODE_BG.SIDE_PANEL]: SidePanel,
[OPEN_MODE_BG.API]: Api,
[OPEN_MODE_BG.PAGE_ACTION]: PageAction,
+ [OPEN_MODE_BG.AI_PROMPT]: AiPrompt,
}
export async function execute({
diff --git a/packages/extension/src/action/helper.test.ts b/packages/extension/src/action/helper.test.ts
new file mode 100644
index 00000000..475a770f
--- /dev/null
+++ b/packages/extension/src/action/helper.test.ts
@@ -0,0 +1,550 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import {
+ navigateSidePanel,
+ openSidePanel,
+ closeSidePanel,
+ sidePanelClosed,
+} from "./helper"
+import { BgData } from "@/services/backgroundData"
+import {
+ openSidePanel as _openSidePanel,
+ closeSidePanel as _closeSidePanel,
+ updateSidePanelUrl,
+} from "@/services/chrome"
+import { enhancedSettings } from "@/services/settings/enhancedSettings"
+import { incrementCommandExecutionCount } from "@/services/commandMetrics"
+
+// Mock dependencies
+vi.mock("@/services/backgroundData", () => ({
+ BgData: {
+ get: vi.fn(),
+ update: vi.fn(),
+ },
+}))
+
+vi.mock("@/services/chrome", () => ({
+ openSidePanel: vi.fn(),
+ closeSidePanel: vi.fn(),
+ updateSidePanelUrl: vi.fn(),
+}))
+
+vi.mock("@/services/settings/enhancedSettings", () => ({
+ enhancedSettings: {
+ get: vi.fn(),
+ },
+}))
+
+vi.mock("@/services/commandMetrics", () => ({
+ incrementCommandExecutionCount: vi.fn().mockResolvedValue(undefined),
+}))
+
+describe("helper", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe("navigateSidePanel", () => {
+ it("NSP-01: Should return false when tabId is null", () => {
+ const param = { url: "https://example.com", tabId: null }
+ const sender = {} as any
+
+ const result = navigateSidePanel(param, sender)
+
+ expect(result).toBe(false)
+ })
+
+ it("NSP-02: Should return false for invalid URL", () => {
+ const param = { url: "not-a-valid-url", tabId: 123 }
+ const sender = {} as any
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [{ tabId: 123, isLinkCommand: false }],
+ } as any)
+
+ const result = navigateSidePanel(param, sender)
+
+ expect(result).toBe(false)
+ })
+
+ it("NSP-03: Should return false for javascript: protocol", () => {
+ const param = { url: "javascript:alert('test')", tabId: 123 }
+ const sender = {} as any
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [{ tabId: 123, isLinkCommand: false }],
+ } as any)
+
+ const result = navigateSidePanel(param, sender)
+
+ expect(result).toBe(false)
+ })
+
+ it("NSP-04: Should return false for data: protocol", () => {
+ const param = { url: "data:text/html,
Test
", tabId: 123 }
+ const sender = {} as any
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [{ tabId: 123, isLinkCommand: false }],
+ } as any)
+
+ const result = navigateSidePanel(param, sender)
+
+ expect(result).toBe(false)
+ })
+
+ it("NSP-05: Should return false when tab is not in sidePanelTabs", () => {
+ const param = { url: "https://example.com", tabId: 123 }
+ const sender = {} as any
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [
+ { tabId: 456, isLinkCommand: false },
+ { tabId: 789, isLinkCommand: false },
+ ], // Different tab IDs
+ } as any)
+
+ const result = navigateSidePanel(param, sender)
+
+ expect(result).toBe(false)
+ })
+
+ it("NSP-06: Should update URL when all conditions are met", async () => {
+ const tabId = 123
+ const url = "https://example.com"
+ const param = { url, tabId }
+ const sender = {} as any
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [{ tabId, isLinkCommand: false }],
+ sidePanelUrls: {},
+ } as any)
+
+ vi.mocked(updateSidePanelUrl).mockResolvedValue(undefined)
+ vi.mocked(BgData.update).mockResolvedValue(true)
+
+ const result = navigateSidePanel(param, sender)
+
+ expect(result).toBe(false)
+
+ // Wait for async operations
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ expect(updateSidePanelUrl).toHaveBeenCalledWith({ url, tabId })
+ })
+
+ it("NSP-07: Should handle updateSidePanelUrl errors gracefully", async () => {
+ const tabId = 123
+ const url = "https://example.com"
+ const param = { url, tabId }
+ const sender = {} as any
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [{ tabId, isLinkCommand: false }],
+ sidePanelUrls: {},
+ } as any)
+
+ vi.mocked(updateSidePanelUrl).mockRejectedValue(
+ new Error("Update failed"),
+ )
+
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {})
+
+ const result = navigateSidePanel(param, sender)
+
+ expect(result).toBe(false)
+
+ // Wait for async operations
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "[navigateSidePanel] Error:",
+ expect.any(Error),
+ )
+
+ consoleErrorSpy.mockRestore()
+ })
+
+ it("NSP-08: Should update BgData.sidePanelUrls after successful URL update", async () => {
+ const tabId = 123
+ const url = "https://example.com"
+ const param = { url, tabId }
+ const sender = {} as any
+
+ const mockData = {
+ sidePanelTabs: [{ tabId, isLinkCommand: false }],
+ sidePanelUrls: {},
+ } as any
+
+ vi.mocked(BgData.get).mockReturnValue(mockData)
+
+ vi.mocked(updateSidePanelUrl).mockResolvedValue(undefined)
+ vi.mocked(BgData.update).mockImplementation((updater) => {
+ if (typeof updater === "function") {
+ const result = updater(mockData)
+ expect(result.sidePanelUrls?.[tabId]).toBe(url)
+ }
+ return Promise.resolve(true)
+ })
+
+ navigateSidePanel(param, sender)
+
+ // Wait for async operations
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ expect(BgData.update).toHaveBeenCalledWith(expect.any(Function))
+ })
+ })
+
+ describe("openSidePanel", () => {
+ it("OSP-01: Should use sender.tab.id when available", async () => {
+ const tabId = 123
+ const param = { url: "https://example.com", isLinkCommand: false }
+ const sender = { tab: { id: tabId } } as any
+ const response = vi.fn()
+ const mockBgData = { sidePanelTabs: [] } as any
+
+ vi.mocked(_openSidePanel).mockResolvedValue({ tabId } as any)
+ vi.mocked(incrementCommandExecutionCount).mockResolvedValue(undefined)
+ vi.mocked(BgData.update).mockImplementation((updater) => {
+ if (typeof updater === "function") {
+ updater(mockBgData)
+ }
+ return Promise.resolve(true)
+ })
+
+ const result = openSidePanel(param, sender, response)
+
+ expect(result).toBe(true)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(_openSidePanel).toHaveBeenCalledWith({ ...param, tabId })
+ })
+
+ it("OSP-02: Should fall back to bgData.activeTabId when sender.tab.id is null", async () => {
+ const activeTabId = 456
+ const param = { url: "https://example.com", isLinkCommand: false }
+ const sender = {} as any // No tab.id
+ const response = vi.fn()
+ const mockBgData = { activeTabId, sidePanelTabs: [] } as any
+
+ vi.mocked(BgData.get).mockReturnValue(mockBgData)
+ vi.mocked(_openSidePanel).mockResolvedValue({ tabId: activeTabId } as any)
+ vi.mocked(incrementCommandExecutionCount).mockResolvedValue(undefined)
+ vi.mocked(BgData.update).mockImplementation((updater) => {
+ if (typeof updater === "function") {
+ updater(mockBgData)
+ }
+ return Promise.resolve(true)
+ })
+
+ const result = openSidePanel(param, sender, response)
+
+ expect(result).toBe(true)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(_openSidePanel).toHaveBeenCalledWith({
+ ...param,
+ tabId: activeTabId,
+ })
+ expect(response).toHaveBeenCalledWith(true)
+ })
+
+ it("OSP-03: Should return false when both sender.tab.id and bgData.activeTabId are null", () => {
+ const param = { url: "https://example.com", isLinkCommand: false }
+ const sender = {} as any
+ const response = vi.fn()
+
+ vi.mocked(BgData.get).mockReturnValue({
+ activeTabId: null,
+ sidePanelTabs: [],
+ } as any)
+
+ const consoleWarnSpy = vi
+ .spyOn(console, "warn")
+ .mockImplementation(() => {})
+
+ const result = openSidePanel(param, sender, response)
+
+ expect(result).toBe(false)
+ expect(response).toHaveBeenCalledWith(false)
+ expect(_openSidePanel).not.toHaveBeenCalled()
+
+ consoleWarnSpy.mockRestore()
+ })
+
+ it("OSP-04: Should register tab in BgData.sidePanelTabs with correct isLinkCommand after openSidePanel succeeds", async () => {
+ const tabId = 123
+ const param = { url: "https://example.com", isLinkCommand: true }
+ const sender = { tab: { id: tabId } } as any
+ const response = vi.fn()
+ const mockBgData = { sidePanelTabs: [] } as any
+
+ vi.mocked(_openSidePanel).mockResolvedValue({ tabId } as any)
+ vi.mocked(incrementCommandExecutionCount).mockResolvedValue(undefined)
+ vi.mocked(BgData.update).mockImplementation((updater) => {
+ if (typeof updater === "function") {
+ const result = updater(mockBgData)
+ expect(result.sidePanelTabs).toContainEqual({
+ tabId,
+ isLinkCommand: true,
+ })
+ }
+ return Promise.resolve(true)
+ })
+
+ openSidePanel(param, sender, response)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(response).toHaveBeenCalledWith(true)
+ expect(BgData.update).toHaveBeenCalledWith(expect.any(Function))
+ })
+
+ it("OSP-05: Should call response(false) when _openSidePanel rejects", async () => {
+ const tabId = 123
+ const param = { url: "https://example.com", isLinkCommand: false }
+ const sender = { tab: { id: tabId } } as any
+ const response = vi.fn()
+
+ vi.mocked(_openSidePanel).mockRejectedValue(new Error("Panel error"))
+
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {})
+
+ openSidePanel(param, sender, response)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(response).toHaveBeenCalledWith(false)
+
+ consoleErrorSpy.mockRestore()
+ })
+ })
+
+ describe("closeSidePanel", () => {
+ it("CSP-01: Should return false when sender.tab.id is null", () => {
+ const sender = {} as any
+ const response = vi.fn()
+
+ const result = closeSidePanel(undefined, sender, response)
+
+ expect(result).toBe(false)
+ expect(response).not.toHaveBeenCalled()
+ })
+
+ it("CSP-02: Should close panel when isLinkCommand=true and linkCommand.sidePanelAutoHide=true", async () => {
+ const tabId = 123
+ const sender = { tab: { id: tabId } } as any
+ const response = vi.fn()
+
+ vi.mocked(enhancedSettings.get).mockResolvedValue({
+ linkCommand: { sidePanelAutoHide: true },
+ windowOption: { sidePanelAutoHide: false },
+ } as any)
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [{ tabId, isLinkCommand: true }],
+ } as any)
+
+ vi.mocked(_closeSidePanel).mockResolvedValue(undefined)
+
+ const result = closeSidePanel(undefined, sender, response)
+
+ expect(result).toBe(true)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(_closeSidePanel).toHaveBeenCalledWith(tabId)
+ expect(response).toHaveBeenCalledWith(true)
+ })
+
+ it("CSP-03: Should NOT close panel when isLinkCommand=true and linkCommand.sidePanelAutoHide=false", async () => {
+ const tabId = 123
+ const sender = { tab: { id: tabId } } as any
+ const response = vi.fn()
+
+ vi.mocked(enhancedSettings.get).mockResolvedValue({
+ linkCommand: { sidePanelAutoHide: false },
+ windowOption: { sidePanelAutoHide: true },
+ } as any)
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [{ tabId, isLinkCommand: true }],
+ } as any)
+
+ const result = closeSidePanel(undefined, sender, response)
+
+ expect(result).toBe(true)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(_closeSidePanel).not.toHaveBeenCalled()
+ expect(response).toHaveBeenCalledWith(true)
+ })
+
+ it("CSP-04: Should close panel when isLinkCommand=false and windowOption.sidePanelAutoHide=true", async () => {
+ const tabId = 123
+ const sender = { tab: { id: tabId } } as any
+ const response = vi.fn()
+
+ vi.mocked(enhancedSettings.get).mockResolvedValue({
+ linkCommand: { sidePanelAutoHide: false },
+ windowOption: { sidePanelAutoHide: true },
+ } as any)
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [{ tabId, isLinkCommand: false }],
+ } as any)
+
+ vi.mocked(_closeSidePanel).mockResolvedValue(undefined)
+
+ const result = closeSidePanel(undefined, sender, response)
+
+ expect(result).toBe(true)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(_closeSidePanel).toHaveBeenCalledWith(tabId)
+ expect(response).toHaveBeenCalledWith(true)
+ })
+
+ it("CSP-05: Should NOT close panel when isLinkCommand=false and windowOption.sidePanelAutoHide=false", async () => {
+ const tabId = 123
+ const sender = { tab: { id: tabId } } as any
+ const response = vi.fn()
+
+ vi.mocked(enhancedSettings.get).mockResolvedValue({
+ linkCommand: { sidePanelAutoHide: true },
+ windowOption: { sidePanelAutoHide: false },
+ } as any)
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [{ tabId, isLinkCommand: false }],
+ } as any)
+
+ const result = closeSidePanel(undefined, sender, response)
+
+ expect(result).toBe(true)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(_closeSidePanel).not.toHaveBeenCalled()
+ expect(response).toHaveBeenCalledWith(true)
+ })
+
+ it("CSP-06: Should call response(true) without closing panel when tab is not in sidePanelTabs", async () => {
+ const tabId = 123
+ const sender = { tab: { id: tabId } } as any
+ const response = vi.fn()
+
+ vi.mocked(enhancedSettings.get).mockResolvedValue({
+ linkCommand: { sidePanelAutoHide: true },
+ windowOption: { sidePanelAutoHide: true },
+ } as any)
+
+ vi.mocked(BgData.get).mockReturnValue({
+ sidePanelTabs: [], // Tab not found
+ } as any)
+
+ const result = closeSidePanel(undefined, sender, response)
+
+ expect(result).toBe(true)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(_closeSidePanel).not.toHaveBeenCalled()
+ expect(response).toHaveBeenCalledWith(true)
+ })
+
+ it("CSP-07: Should call response(false) when enhancedSettings.get() rejects", async () => {
+ const tabId = 123
+ const sender = { tab: { id: tabId } } as any
+ const response = vi.fn()
+
+ vi.mocked(enhancedSettings.get).mockRejectedValue(
+ new Error("Settings error"),
+ )
+
+ const consoleWarnSpy = vi
+ .spyOn(console, "warn")
+ .mockImplementation(() => {})
+
+ const result = closeSidePanel(undefined, sender, response)
+
+ expect(result).toBe(true)
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(response).toHaveBeenCalledWith(false)
+
+ consoleWarnSpy.mockRestore()
+ })
+ })
+
+ describe("sidePanelClosed", () => {
+ it("SPC-01: Should return early without calling BgData.update when tabId is undefined", async () => {
+ await sidePanelClosed(undefined)
+
+ expect(BgData.update).not.toHaveBeenCalled()
+ })
+
+ it("SPC-02: Should remove tab from sidePanelTabs and sidePanelUrls when tabId is provided", async () => {
+ const tabId = 123
+ const mockData = {
+ sidePanelTabs: [
+ { tabId, isLinkCommand: false },
+ { tabId: 456, isLinkCommand: false },
+ ],
+ sidePanelUrls: {
+ [tabId]: "https://example.com",
+ 456: "https://other.com",
+ },
+ } as any
+
+ vi.mocked(BgData.update).mockImplementation((updater) => {
+ if (typeof updater === "function") {
+ const result = updater(mockData)
+ expect(result.sidePanelTabs).not.toContainEqual({
+ tabId,
+ isLinkCommand: false,
+ })
+ expect(result.sidePanelTabs).toContainEqual({
+ tabId: 456,
+ isLinkCommand: false,
+ })
+ expect(result.sidePanelUrls![tabId]).toBeUndefined()
+ expect(result.sidePanelUrls![456]).toBe("https://other.com")
+ }
+ return Promise.resolve(true)
+ })
+
+ await sidePanelClosed(tabId)
+
+ expect(BgData.update).toHaveBeenCalledWith(expect.any(Function))
+ })
+
+ it("SPC-03: Should catch and log warning when BgData.update throws", async () => {
+ const tabId = 123
+
+ vi.mocked(BgData.update).mockRejectedValue(new Error("Update failed"))
+
+ const consoleWarnSpy = vi
+ .spyOn(console, "warn")
+ .mockImplementation(() => {})
+
+ await sidePanelClosed(tabId)
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ "Failed to cleanup side panel:",
+ expect.any(Error),
+ )
+
+ consoleWarnSpy.mockRestore()
+ })
+ })
+})
diff --git a/packages/extension/src/action/helper.ts b/packages/extension/src/action/helper.ts
index f6b6b441..ae3b62d6 100644
--- a/packages/extension/src/action/helper.ts
+++ b/packages/extension/src/action/helper.ts
@@ -1,22 +1,26 @@
-import { escapeJson } from "@/lib/utils"
+import { escapeJson, toUrl } from "@/lib/utils"
import {
openPopupWindow,
openPopupWindowMultiple,
openTab as openTabWithClipboard,
+ openSidePanel as _openSidePanel,
+ closeSidePanel as _closeSidePanel,
+ updateSidePanelUrl as _updateSidePanelUrl,
OpenPopupsProps,
OpenPopupProps,
+ OpenPopupAndClickProps,
OpenTabProps,
+ OpenSidePanelProps,
} from "@/services/chrome"
+import { registerSidePanelTab } from "@/services/pageAction/background-sidePanel"
import { incrementCommandExecutionCount } from "@/services/commandMetrics"
-import { Ipc, TabCommand } from "@/services/ipc"
+import { enhancedSettings } from "@/services/settings/enhancedSettings"
+import { Ipc, TabCommand, NavigateSidePanelProps } from "@/services/ipc"
+import { BgData } from "@/services/backgroundData"
import type { CommandVariable } from "@/types"
type Sender = chrome.runtime.MessageSender
-type OpenPopupAndClickProps = OpenPopupProps & {
- selector: string
-}
-
type execApiProps = {
url: string
pageUrl: string
@@ -36,7 +40,7 @@ export const openPopup = (
await openPopupWindow(param)
response(true)
} catch (error) {
- console.error("Failed to execute openPopups:", error)
+ console.error("Failed to execute openPopupWindow:", error)
response(false)
}
})
@@ -53,7 +57,7 @@ export const openPopups = (
await openPopupWindowMultiple(param)
response(true)
} catch (error) {
- console.error("Failed to execute openPopups:", error)
+ console.error("Failed to execute openPopupWindowMultiple:", error)
response(false)
}
})
@@ -98,6 +102,158 @@ export const openTab = (
return true
}
+export const openSidePanel = (
+ param: OpenSidePanelProps,
+ sender: Sender,
+ response: (res: unknown) => void,
+): boolean => {
+ let tabId = sender.tab?.id
+ if (tabId == null) {
+ const bgData = BgData.get()
+ if (bgData.activeTabId == null) {
+ console.warn("No active tab ID available for opening side panel")
+ response(false)
+ return false
+ }
+ tabId = bgData.activeTabId
+ }
+
+ // Since it needs to be tied to a user action, avoid asynchronous processing
+ // and open the side panel immediately.
+ _openSidePanel({
+ ...param,
+ tabId,
+ })
+ .then(() => {
+ incrementCommandExecutionCount(tabId)
+ })
+ .then(() => {
+ // Register the tab ID for tracking
+ if (tabId) {
+ const newEntry = { tabId, isLinkCommand: param.isLinkCommand ?? false }
+ return BgData.update((data) => ({
+ sidePanelTabs: data.sidePanelTabs.some((t) => t.tabId === tabId)
+ ? data.sidePanelTabs.map((t) => (t.tabId === tabId ? newEntry : t))
+ : [...data.sidePanelTabs, newEntry],
+ }))
+ }
+ })
+ .then(() => {
+ registerSidePanelTab(tabId, toUrl(param.url))
+ })
+ .then(() => {
+ response(true)
+ })
+ .catch((error) => {
+ console.error("Error during side panel operations:", error)
+ response(false)
+ })
+
+ return true
+}
+
+export const closeSidePanel = (
+ _: unknown,
+ sender: Sender,
+ response: (res: unknown) => void,
+) => {
+ const tabId = sender.tab?.id
+ if (tabId == null) {
+ return false
+ }
+ enhancedSettings
+ .get()
+ .then(async (settings) => {
+ const bgData = BgData.get()
+ const tab = bgData.sidePanelTabs.find((t) => t.tabId === tabId)
+ if (tab) {
+ const autoHideEnabled = tab.isLinkCommand
+ ? settings.linkCommand.sidePanelAutoHide
+ : settings.windowOption.sidePanelAutoHide
+ if (autoHideEnabled) {
+ await _closeSidePanel(tabId)
+ }
+ }
+ response(true)
+ })
+ .catch((err) => {
+ console.warn("Failed to handle panel click:", err)
+ response(false)
+ })
+
+ return true
+}
+
+/**
+ * Handle side panel closed event for a tab
+ * @param {number} tabId - The ID of the tab whose side panel was closed
+ * @return {Promise} A promise that resolves when the side panel closed event is handled
+ * This function is called when a side panel is closed, either by user action or programmatically.
+ */
+export const sidePanelClosed = async (tabId?: number): Promise => {
+ if (tabId == null) return
+ try {
+ await BgData.update((data) => {
+ const { [tabId]: _, ...rest } = data.sidePanelUrls
+ return {
+ sidePanelTabs: data.sidePanelTabs.filter((t) => t.tabId !== tabId),
+ sidePanelUrls: rest,
+ }
+ })
+ } catch (e) {
+ console.warn("Failed to cleanup side panel:", e)
+ }
+}
+
+export const navigateSidePanel = (
+ param: NavigateSidePanelProps,
+ _sender: Sender,
+): boolean => {
+ const { url, tabId } = param
+
+ // URL validation
+ try {
+ const urlObj = new URL(url)
+ if (urlObj.protocol === "javascript:" || urlObj.protocol === "data:") {
+ console.warn("[navigateSidePanel] Invalid protocol:", urlObj.protocol)
+ return false
+ }
+ } catch (e) {
+ console.error("[navigateSidePanel] Invalid URL:", url, e)
+ return false
+ }
+
+ // Tab ID validation
+ if (tabId == null) {
+ console.warn("[navigateSidePanel] No tab ID")
+ return false
+ }
+
+ // Check if tab is in sidePanelTabs
+ const bgData = BgData.get()
+ if (!bgData.sidePanelTabs.some((t) => t.tabId === tabId)) {
+ console.warn("[navigateSidePanel] Tab is not in sidePanelTabs:", tabId)
+ return false
+ }
+
+ // Fire-and-forget: update URL in the background
+ _updateSidePanelUrl({ url, tabId })
+ .then(() => {
+ // Update BgData's sidePanelUrls
+ return BgData.update((data) => ({
+ sidePanelUrls: {
+ ...data.sidePanelUrls,
+ [tabId]: url,
+ },
+ }))
+ })
+ .catch((error) => {
+ console.error("[navigateSidePanel] Error:", error)
+ })
+
+ return false
+}
+
function bindVariables(
str: string,
variables: CommandVariable[],
diff --git a/packages/extension/src/action/index.ts b/packages/extension/src/action/index.ts
index 89332cae..2ecfb215 100644
--- a/packages/extension/src/action/index.ts
+++ b/packages/extension/src/action/index.ts
@@ -3,10 +3,12 @@ import { Popup } from "./popup"
import { Window } from "./window"
import { Tab } from "./tab"
import { BackgroundTab } from "./backgroundTab"
+import { SidePanel } from "./sidePanel"
import { Api } from "./api"
import { SelectedLinkPopup } from "./selectedLinkPopup"
import { Copy } from "./copy"
import { PageAction } from "./pageAction"
+import { AiPrompt } from "./aiPrompt"
import { Option } from "./option"
import { GetStyles as GetTextStyles } from "./getStyles"
import { AddPageRule } from "./addPageRule"
@@ -18,10 +20,12 @@ export const actions = {
[OPEN_MODE.WINDOW]: Window,
[OPEN_MODE.TAB]: Tab,
[OPEN_MODE.BACKGROUND_TAB]: BackgroundTab,
+ [OPEN_MODE.SIDE_PANEL]: SidePanel,
[OPEN_MODE.API]: Api,
[OPEN_MODE.LINK_POPUP]: SelectedLinkPopup,
[OPEN_MODE.COPY]: Copy,
[OPEN_MODE.PAGE_ACTION]: PageAction,
+ [OPEN_MODE.AI_PROMPT]: AiPrompt,
[OPEN_MODE.GET_TEXT_STYLES]: GetTextStyles,
[OPEN_MODE.OPTION]: Option,
[OPEN_MODE.ADD_PAGE_RULE]: AddPageRule,
diff --git a/packages/extension/src/action/linkPreview.test.ts b/packages/extension/src/action/linkPreview.test.ts
new file mode 100644
index 00000000..f16e3ca8
--- /dev/null
+++ b/packages/extension/src/action/linkPreview.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { LinkPreview } from "./linkPreview"
+import { Ipc, BgCommand } from "@/services/ipc"
+import { findAnchorElementFromParent } from "@/services/dom"
+import { getScreenSize } from "@/services/screen"
+import { DRAG_OPEN_MODE } from "@/const"
+
+// Mock dependencies
+vi.mock("@/services/ipc", () => ({
+ Ipc: {
+ send: vi.fn(),
+ },
+ BgCommand: {
+ openSidePanel: "openSidePanel",
+ openPopups: "openPopups",
+ openPopupAndClick: "openPopupAndClick",
+ },
+}))
+
+vi.mock("@/services/dom", () => ({
+ findAnchorElementFromParent: vi.fn(),
+}))
+
+vi.mock("@/services/screen", () => ({
+ getScreenSize: vi.fn(),
+}))
+
+describe("LinkPreview", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe("execute", () => {
+ const createCommand = (openMode: string) =>
+ ({
+ id: "test-command",
+ openMode,
+ popupOption: { height: 600, width: 800 },
+ }) as any
+
+ it("LP-01: Should send openSidePanel when openMode is PREVIEW_SIDE_PANEL and href is available", async () => {
+ const href = "https://example.com"
+ vi.mocked(findAnchorElementFromParent).mockReturnValue({
+ href,
+ } as HTMLAnchorElement)
+
+ const command = createCommand(DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL)
+ const position = { x: 100, y: 200 }
+ const target = document.createElement("div")
+
+ await LinkPreview.execute({
+ command,
+ position,
+ target,
+ selectionText: "",
+ })
+
+ expect(Ipc.send).toHaveBeenCalledWith(BgCommand.openSidePanel, {
+ url: href,
+ isLinkCommand: true,
+ })
+ })
+
+ it("LP-02: Should NOT send openSidePanel when openMode is PREVIEW_SIDE_PANEL and href is empty", async () => {
+ vi.mocked(findAnchorElementFromParent).mockReturnValue({
+ href: "",
+ } as HTMLAnchorElement)
+
+ const command = createCommand(DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL)
+ const position = { x: 100, y: 200 }
+ const target = document.createElement("div")
+
+ await LinkPreview.execute({
+ command,
+ position,
+ target,
+ selectionText: "",
+ })
+
+ expect(Ipc.send).not.toHaveBeenCalled()
+ })
+
+ it("LP-03: Should return early after PREVIEW_SIDE_PANEL handling without calling openPopups", async () => {
+ const href = "https://example.com"
+ vi.mocked(findAnchorElementFromParent).mockReturnValue({
+ href,
+ } as HTMLAnchorElement)
+ vi.mocked(getScreenSize).mockResolvedValue({} as any)
+
+ const command = createCommand(DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL)
+ const position = { x: 100, y: 200 }
+ const target = document.createElement("div")
+
+ await LinkPreview.execute({
+ command,
+ position,
+ target,
+ selectionText: "",
+ })
+
+ expect(Ipc.send).toHaveBeenCalledTimes(1)
+ expect(Ipc.send).not.toHaveBeenCalledWith(
+ BgCommand.openPopups,
+ expect.any(Object),
+ )
+ })
+
+ it("LP-04: Should not execute when position or target is null", async () => {
+ const command = createCommand(DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL)
+
+ await LinkPreview.execute({
+ command,
+ position: null,
+ target: null,
+ selectionText: "",
+ })
+
+ expect(Ipc.send).not.toHaveBeenCalled()
+ expect(findAnchorElementFromParent).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/extension/src/action/linkPreview.ts b/packages/extension/src/action/linkPreview.ts
index f55f24d1..aed87a34 100644
--- a/packages/extension/src/action/linkPreview.ts
+++ b/packages/extension/src/action/linkPreview.ts
@@ -4,10 +4,14 @@ import {
findClickableElement,
getSelectorFromElement,
} from "@/services/dom"
-import { getScreenSize } from "@/services/screen"
import { DRAG_OPEN_MODE, POPUP_TYPE } from "@/const"
import { isEmpty } from "@/lib/utils"
import type { ExecuteCommandParams } from "@/types"
+import type {
+ OpenPopupsProps,
+ OpenSidePanelProps,
+ OpenPopupAndClickProps,
+} from "@/services/chrome"
export const LinkPreview = {
async execute({ command, position, target }: ExecuteCommandParams) {
@@ -15,20 +19,29 @@ export const LinkPreview = {
const elm = findAnchorElementFromParent(target) as HTMLAnchorElement
const href = elm?.href
+ if (command.openMode === DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL) {
+ if (!isEmpty(href)) {
+ Ipc.send(BgCommand.openSidePanel, {
+ url: href,
+ isLinkCommand: true,
+ })
+ }
+ return
+ }
+
const type =
command.openMode === DRAG_OPEN_MODE.PREVIEW_POPUP
? POPUP_TYPE.POPUP
: POPUP_TYPE.NORMAL
if (!isEmpty(href)) {
- Ipc.send(BgCommand.openPopups, {
+ Ipc.send(BgCommand.openPopups, {
commandId: command.id,
urls: [href],
top: Math.floor(position.y),
left: Math.floor(position.x),
height: command.popupOption?.height,
width: command.popupOption?.width,
- screen: await getScreenSize(),
type,
})
return
@@ -37,16 +50,16 @@ export const LinkPreview = {
console.warn("Href not found, trying to find clickable element")
const clickElm = findClickableElement(target)
+
if (clickElm) {
const selector = getSelectorFromElement(clickElm)
- Ipc.send(BgCommand.openPopupAndClick, {
+ Ipc.send(BgCommand.openPopupAndClick, {
commandId: command.id,
- urls: [location.href],
+ url: location.href,
top: Math.floor(position.y),
left: Math.floor(position.x),
height: command.popupOption?.height,
width: command.popupOption?.width,
- screen: await getScreenSize(),
selector,
type,
})
diff --git a/packages/extension/src/action/pageAction.ts b/packages/extension/src/action/pageAction.ts
index 63339347..f6651301 100644
--- a/packages/extension/src/action/pageAction.ts
+++ b/packages/extension/src/action/pageAction.ts
@@ -1,10 +1,11 @@
import { Ipc, BgCommand } from "@/services/ipc"
-import { getScreenSize, getWindowPosition } from "@/services/screen"
+import { getWindowPosition } from "@/services/screen"
import { isValidString, isPageActionCommand } from "@/lib/utils"
-import { PAGE_ACTION_OPEN_MODE } from "@/const"
+import { PAGE_ACTION_OPEN_MODE, PAGE_ACTION_EVENT } from "@/const"
import { PopupOption } from "@/services/option/defaultSettings"
import type { ExecuteCommandParams, UrlParam } from "@/types"
import type { OpenAndRunProps } from "@/services/pageAction/background"
+import { INSERT, toInsertTemplate } from "@/services/pageAction"
type PageActionParams = {
userVariables?: Array<{ name: string; value: string }>
@@ -33,10 +34,18 @@ export const PageAction = {
return
}
+ // Checks if any step requires clipboard data
+ const needClipboard = command.pageActionOption.steps.some((step) => {
+ return (
+ step.param.type === PAGE_ACTION_EVENT.input &&
+ step.param.value.includes(toInsertTemplate(INSERT.CLIPBOARD))
+ )
+ })
+
const url: UrlParam = {
searchUrl: command.pageActionOption.startUrl,
selectionText,
- useClipboard: useClipboard ?? false,
+ useClipboard: needClipboard || (useClipboard ?? false),
}
const openMode = useSecondary
@@ -44,21 +53,20 @@ export const PageAction = {
? PAGE_ACTION_OPEN_MODE.WINDOW
: command.pageActionOption.openMode === PAGE_ACTION_OPEN_MODE.WINDOW
? PAGE_ACTION_OPEN_MODE.TAB
- : PAGE_ACTION_OPEN_MODE.TAB
+ : PAGE_ACTION_OPEN_MODE.TAB // Open in new tab when secondary is pressed
: command.pageActionOption.openMode
const windowPosition = await getWindowPosition()
- const screen = await getScreenSize()
Ipc.send(BgCommand.openAndRunPageAction, {
commandId: command.id,
url,
+ pageUrl: command.pageActionOption.pageUrl,
steps: command.pageActionOption.steps,
top: Math.floor(windowPosition.top + position.y),
left: Math.floor(windowPosition.left + position.x),
height: command.popupOption?.height ?? PopupOption.height,
width: command.popupOption?.width ?? PopupOption.width,
- screen,
selectedText: selectionText,
srcUrl: location.href,
openMode,
diff --git a/packages/extension/src/action/popup.ts b/packages/extension/src/action/popup.ts
index 8da9ca58..744a0ff7 100644
--- a/packages/extension/src/action/popup.ts
+++ b/packages/extension/src/action/popup.ts
@@ -1,6 +1,6 @@
import { Ipc, BgCommand } from "@/services/ipc"
import { isValidString } from "@/lib/utils"
-import { getScreenSize, getWindowPosition } from "@/services/screen"
+import { getWindowPosition } from "@/services/screen"
import { POPUP_TYPE, SPACE_ENCODING } from "@/const"
import { PopupOption } from "@/services/option/defaultSettings"
import type { ExecuteCommandParams } from "@/types"
@@ -23,7 +23,6 @@ export const Popup = {
}
const windowPosition = await getWindowPosition()
- const screen = await getScreenSize()
Ipc.send(BgCommand.openPopup, {
commandId: command.id,
@@ -37,7 +36,6 @@ export const Popup = {
left: Math.floor(windowPosition.left + position.x),
height: command.popupOption?.height ?? PopupOption.height,
width: command.popupOption?.width ?? PopupOption.width,
- screen,
type: POPUP_TYPE.POPUP,
})
},
diff --git a/packages/extension/src/action/selectedLinkPopup.ts b/packages/extension/src/action/selectedLinkPopup.ts
index 273b277b..aa146cfb 100644
--- a/packages/extension/src/action/selectedLinkPopup.ts
+++ b/packages/extension/src/action/selectedLinkPopup.ts
@@ -1,19 +1,20 @@
import { Ipc, BgCommand } from "@/services/ipc"
import { linksInSelection } from "@/services/dom"
-import { getScreenSize } from "@/services/screen"
+import { OpenPopupsProps } from "@/services/chrome"
+import { POPUP_TYPE } from "@/const"
import type { ExecuteCommandParams } from "@/types"
export const SelectedLinkPopup = {
async execute({ command, position }: ExecuteCommandParams) {
if (position) {
- Ipc.send(BgCommand.openPopups, {
+ Ipc.send(BgCommand.openPopups, {
commandId: command.id,
urls: linksInSelection(),
top: Math.floor(window.screenTop + position.y),
left: Math.floor(window.screenLeft + position.x + 20),
height: command.popupOption?.height,
width: command.popupOption?.width,
- screen: await getScreenSize(),
+ type: POPUP_TYPE.POPUP,
})
}
},
diff --git a/packages/extension/src/action/sidePanel.ts b/packages/extension/src/action/sidePanel.ts
new file mode 100644
index 00000000..bd25ec90
--- /dev/null
+++ b/packages/extension/src/action/sidePanel.ts
@@ -0,0 +1,54 @@
+import { isValidString, toUrl, isEmpty } from "@/lib/utils"
+import { SPACE_ENCODING } from "@/const"
+import type { ExecuteCommandParams, ShowToastParam } from "@/types"
+import type { OpenSidePanelProps } from "@/services/chrome"
+import { Ipc, BgCommand, TabCommand } from "@/services/ipc"
+import { t } from "@/services/i18n"
+
+export const SidePanel = {
+ async execute({
+ selectionText,
+ command,
+ useClipboard,
+ }: ExecuteCommandParams) {
+ if (!isValidString(command.searchUrl)) {
+ console.error("searchUrl is not valid.")
+ return
+ }
+ // Read clipboard text for interpolation, but don't block execution if it fails.
+ let clipboardText: string = ""
+ try {
+ if (useClipboard && isEmpty(selectionText)) {
+ clipboardText = await navigator.clipboard.readText()
+ }
+ } catch (e) {
+ console.warn("Failed to read clipboard text:", e)
+
+ const tabId = await Ipc.getActiveTabId()
+ await Ipc.sendTab(tabId, TabCommand.showToast, {
+ title: t("clipboard_error_title"),
+ description: t("clipboard_error_description"),
+ action: t("clipboard_error_action"),
+ })
+ }
+
+ try {
+ const url = toUrl(
+ {
+ searchUrl: command.searchUrl,
+ spaceEncoding: command.spaceEncoding ?? SPACE_ENCODING.PLUS,
+ selectionText,
+ useClipboard: useClipboard ?? false,
+ },
+ clipboardText,
+ )
+
+ Ipc.send(BgCommand.openSidePanel, {
+ url,
+ })
+ } catch (error) {
+ console.error("Failed to open side panel:", error)
+ throw error
+ }
+ },
+}
diff --git a/packages/extension/src/action/window.ts b/packages/extension/src/action/window.ts
index f803f6b1..f73dee71 100644
--- a/packages/extension/src/action/window.ts
+++ b/packages/extension/src/action/window.ts
@@ -1,6 +1,6 @@
import { Ipc, BgCommand } from "@/services/ipc"
import { isValidString } from "@/lib/utils"
-import { getScreenSize, getWindowPosition } from "@/services/screen"
+import { getWindowPosition } from "@/services/screen"
import { POPUP_TYPE, SPACE_ENCODING } from "@/const"
import { PopupOption } from "@/services/option/defaultSettings"
import type { OpenPopupProps } from "@/services/chrome"
@@ -23,7 +23,6 @@ export const Window = {
}
const windowPosition = await getWindowPosition()
- const screen = await getScreenSize()
Ipc.send(BgCommand.openPopup, {
commandId: command.id,
@@ -37,8 +36,8 @@ export const Window = {
left: Math.floor(windowPosition.left + position.x),
height: command.popupOption?.height ?? PopupOption.height,
width: command.popupOption?.width ?? PopupOption.width,
- screen,
type: POPUP_TYPE.NORMAL,
+ windowState: command.windowState,
})
},
}
diff --git a/packages/extension/src/background_script.test.ts b/packages/extension/src/background_script.test.ts
index 5efa0473..321fb893 100644
--- a/packages/extension/src/background_script.test.ts
+++ b/packages/extension/src/background_script.test.ts
@@ -7,6 +7,7 @@ import { POPUP_ENABLED, LINK_COMMAND_ENABLED } from "@/const"
// Mock dependencies
vi.mock("@/services/settings/enhancedSettings")
vi.mock("@/services/settings/settings")
+vi.mock("@/services/settings/settingsCache")
vi.mock("@/services/storage")
vi.mock("@/services/chrome")
vi.mock("@/services/backgroundData")
@@ -378,3 +379,212 @@ describe("Background Script Migration", () => {
)
})
})
+
+describe("Popup Auto-Close Delay", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it("PAC-01: should close popups immediately when delay is not set", async () => {
+ // Mock WindowStackManager
+ const mockGetWindowsToClose = vi
+ .fn()
+ .mockResolvedValue([{ id: 100, commandId: "test", srcWindowId: 1 }])
+ const mockRemoveWindow = vi.fn().mockResolvedValue(undefined)
+
+ vi.doMock("@/services/windowStackManager", () => ({
+ WindowStackManager: {
+ getWindowsToClose: mockGetWindowsToClose,
+ removeWindow: mockRemoveWindow,
+ },
+ }))
+
+ // Mock closeWindow
+ const mockCloseWindow = vi.fn().mockResolvedValue(undefined)
+ vi.doMock("@/services/chrome", () => ({
+ closeWindow: mockCloseWindow,
+ windowExists: vi.fn(),
+ getCurrentTab: vi.fn().mockResolvedValue({ id: 1 }),
+ }))
+
+ // Mock enhancedSettings to return no delay
+ mockEnhancedSettings.getSection.mockResolvedValue({
+ windowOption: {
+ popupAutoCloseDelay: undefined,
+ },
+ } as any)
+
+ // Mock Storage
+ const mockStorageSet = vi.fn().mockResolvedValue(undefined)
+ vi.doMock("@/services/storage", () => ({
+ Storage: {
+ set: mockStorageSet,
+ },
+ SESSION_STORAGE_KEY: {
+ SELECTION_TEXT: "selectionText",
+ },
+ }))
+
+ // Clear module cache and re-import
+ vi.resetModules()
+ await import("./background_script")
+
+ // Get the registered listener
+ const listenerCalls = (chrome.windows.onFocusChanged.addListener as any)
+ .mock.calls
+ expect(listenerCalls.length).toBeGreaterThan(0)
+ const focusChangedListener = listenerCalls[listenerCalls.length - 1][0]
+
+ // Trigger focus change
+ await focusChangedListener(200)
+
+ // Wait for async operations
+ await vi.runAllTimersAsync()
+
+ // Verify window was closed immediately (no setTimeout)
+ expect(mockCloseWindow).toHaveBeenCalledWith(100, "onFocusChanged")
+ expect(mockRemoveWindow).toHaveBeenCalledWith(100)
+ })
+
+ it("PAC-02: should delay popup close when delay is set", async () => {
+ const delay = 1000 // 1 second
+
+ // Mock WindowStackManager
+ const mockGetWindowsToClose = vi
+ .fn()
+ .mockResolvedValue([{ id: 100, commandId: "test", srcWindowId: 1 }])
+ const mockRemoveWindow = vi.fn().mockResolvedValue(undefined)
+
+ vi.doMock("@/services/windowStackManager", () => ({
+ WindowStackManager: {
+ getWindowsToClose: mockGetWindowsToClose,
+ removeWindow: mockRemoveWindow,
+ },
+ }))
+
+ // Mock closeWindow
+ const mockCloseWindow = vi.fn().mockResolvedValue(undefined)
+ vi.doMock("@/services/chrome", () => ({
+ closeWindow: mockCloseWindow,
+ windowExists: vi.fn(),
+ getCurrentTab: vi.fn().mockResolvedValue({ id: 1 }),
+ }))
+
+ // Mock enhancedSettings to return delay
+ mockEnhancedSettings.getSection.mockResolvedValue({
+ windowOption: {
+ popupAutoCloseDelay: delay,
+ },
+ } as any)
+
+ // Mock Storage
+ const mockStorageSet = vi.fn().mockResolvedValue(undefined)
+ vi.doMock("@/services/storage", () => ({
+ Storage: {
+ set: mockStorageSet,
+ },
+ SESSION_STORAGE_KEY: {
+ SELECTION_TEXT: "selectionText",
+ },
+ }))
+
+ // Clear module cache and re-import
+ vi.resetModules()
+ await import("./background_script")
+
+ // Get the registered listener
+ const listenerCalls = (chrome.windows.onFocusChanged.addListener as any)
+ .mock.calls
+ const focusChangedListener = listenerCalls[listenerCalls.length - 1][0]
+
+ // Trigger focus change
+ await focusChangedListener(200)
+
+ // Window should not be closed immediately
+ expect(mockCloseWindow).not.toHaveBeenCalled()
+
+ // Advance timers by the delay amount
+ await vi.advanceTimersByTimeAsync(delay)
+
+ // Now window should be closed
+ expect(mockCloseWindow).toHaveBeenCalledWith(100, "onFocusChanged")
+ expect(mockRemoveWindow).toHaveBeenCalledWith(100)
+ })
+
+ it("PAC-03: should cancel timeout when focus returns before delay", async () => {
+ const delay = 1000 // 1 second
+
+ // Mock WindowStackManager - first returns windows to close, then empty array
+ const mockGetWindowsToClose = vi
+ .fn()
+ .mockResolvedValueOnce([{ id: 100, commandId: "test", srcWindowId: 1 }])
+ .mockResolvedValueOnce([]) // No windows to close when focus returns
+ const mockRemoveWindow = vi.fn().mockResolvedValue(undefined)
+
+ vi.doMock("@/services/windowStackManager", () => ({
+ WindowStackManager: {
+ getWindowsToClose: mockGetWindowsToClose,
+ removeWindow: mockRemoveWindow,
+ },
+ }))
+
+ // Mock closeWindow
+ const mockCloseWindow = vi.fn().mockResolvedValue(undefined)
+ vi.doMock("@/services/chrome", () => ({
+ closeWindow: mockCloseWindow,
+ windowExists: vi.fn(),
+ getCurrentTab: vi.fn().mockResolvedValue({ id: 1 }),
+ }))
+
+ // Mock enhancedSettings to return delay
+ mockEnhancedSettings.getSection.mockResolvedValue({
+ windowOption: {
+ popupAutoCloseDelay: delay,
+ },
+ } as any)
+
+ // Mock Storage
+ const mockStorageSet = vi.fn().mockResolvedValue(undefined)
+ vi.doMock("@/services/storage", () => ({
+ Storage: {
+ set: mockStorageSet,
+ },
+ SESSION_STORAGE_KEY: {
+ SELECTION_TEXT: "selectionText",
+ },
+ }))
+
+ // Clear module cache and re-import
+ vi.resetModules()
+ await import("./background_script")
+
+ // Get the registered listener
+ const listenerCalls = (chrome.windows.onFocusChanged.addListener as any)
+ .mock.calls
+ const focusChangedListener = listenerCalls[listenerCalls.length - 1][0]
+
+ // Trigger focus change (popup loses focus)
+ await focusChangedListener(200)
+
+ // Window should not be closed yet
+ expect(mockCloseWindow).not.toHaveBeenCalled()
+
+ // Advance timers only halfway
+ await vi.advanceTimersByTimeAsync(delay / 2)
+
+ // Focus returns to popup (no windows to close)
+ await focusChangedListener(100)
+
+ // Advance remaining time
+ await vi.advanceTimersByTimeAsync(delay / 2 + 100)
+
+ // Window should NOT be closed because timeout was cancelled
+ expect(mockCloseWindow).not.toHaveBeenCalled()
+ expect(mockRemoveWindow).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts
index 98f80b5f..a224e4c1 100644
--- a/packages/extension/src/background_script.ts
+++ b/packages/extension/src/background_script.ts
@@ -9,21 +9,21 @@ import {
} from "@/const"
import { executeActionProps } from "@/services/contextMenus"
import { Ipc, BgCommand, TabCommand, CONNECTION_APP } from "@/services/ipc"
+import type { IpcCallback } from "@/services/ipc"
import { Settings } from "@/services/settings/settings"
import { enhancedSettings } from "@/services/settings/enhancedSettings"
import { PopupOption, PopupPlacement } from "@/services/option/defaultSettings"
import * as PageActionBackground from "@/services/pageAction/background"
import { BgData } from "@/services/backgroundData"
import { ContextMenu } from "@/services/contextMenus"
-import { closeWindow, windowExists } from "@/services/chrome"
+import { closeWindow, windowExists, getCurrentTab } from "@/services/chrome"
import { WindowStackManager } from "@/services/windowStackManager"
+import { PopupAutoClose } from "@/services/popupAutoClose"
import { isSearchCommand, isPageActionCommand } from "@/lib/utils"
import { execute } from "@/action/background"
import * as ActionHelper from "@/action/helper"
-import type { IpcCallback } from "@/services/ipc"
import type { WindowType } from "@/types"
import { Storage, SESSION_STORAGE_KEY } from "@/services/storage"
-import { updateActiveScreenId } from "@/services/screen"
import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics"
import { importIf } from "@import-if"
@@ -50,6 +50,15 @@ const getTabId = (
return false
}
+const getActiveTabId = (
+ _: unknown,
+ _sender: Sender,
+ response: (res: unknown) => void,
+) => {
+ getCurrentTab().then((tab) => response(tab?.id))
+ return true
+}
+
const onConnect = async function (port: chrome.runtime.Port) {
if (port.name !== CONNECTION_APP) return
port.onDisconnect.addListener(() => onDisconnect(port))
@@ -58,6 +67,9 @@ const onConnect = async function (port: chrome.runtime.Port) {
BgData.update((data) => ({
connectedTabs: [...data.connectedTabs, tabId],
}))
+ } else {
+ // Side panel pages have no tab.id (port.sender.origin is set instead).
+ await PageActionBackground.handleSidePanelConnect(port)
}
}
const onDisconnect = async function (port: chrome.runtime.Port) {
@@ -83,6 +95,26 @@ const commandFuncs = {
[BgCommand.openPopups]: ActionHelper.openPopups,
[BgCommand.openPopupAndClick]: ActionHelper.openPopupAndClick,
[BgCommand.openTab]: ActionHelper.openTab,
+ [BgCommand.openSidePanel]: (
+ param: Parameters[0],
+ sender: Sender,
+ response: (res: unknown) => void,
+ ): boolean => {
+ return ActionHelper.openSidePanel(
+ param,
+ sender,
+ async (result: unknown) => {
+ // If the side panel was already open (port retained), execute pending action
+ // via the existing port without waiting for a new onConnect event.
+ if (result === true) {
+ await PageActionBackground.handleSidePanelOpened()
+ }
+ response(result)
+ },
+ )
+ },
+ [BgCommand.closeSidePanel]: ActionHelper.closeSidePanel,
+ [BgCommand.navigateSidePanel]: ActionHelper.navigateSidePanel,
[BgCommand.execApi]: ActionHelper.execApi,
[BgCommand.openOption]: (): boolean => {
@@ -196,6 +228,7 @@ const commandFuncs = {
) => {
const handleOpenInTab = async () => {
let w: WindowType | undefined
+ const targetUrl = sender.tab?.url ?? sender.url
const stack = await WindowStackManager.getStack()
for (const layer of stack) {
@@ -208,7 +241,7 @@ const commandFuncs = {
}
if (!w || w.srcWindowId == null) {
console.warn("window not found", sender.tab?.windowId)
- chrome.tabs.create({ url: sender.url })
+ chrome.tabs.create({ url: targetUrl })
await closeWindow(sender.tab?.windowId as number, "openInTab")
await WindowStackManager.removeWindow(sender.tab?.windowId as number)
response(true)
@@ -216,8 +249,8 @@ const commandFuncs = {
}
let targetId: number | undefined
- const windowIdExists = await windowExists(w.srcWindowId)
- if (windowIdExists) {
+ const { exists } = await windowExists(w.srcWindowId)
+ if (exists) {
targetId = w.srcWindowId
} else {
const current = await chrome.windows.getCurrent()
@@ -228,10 +261,7 @@ const commandFuncs = {
}
if (targetId) {
- chrome.tabs.create({
- url: sender.url,
- windowId: targetId,
- })
+ chrome.tabs.create({ url: targetUrl, windowId: targetId })
await closeWindow(sender.tab?.windowId as number, "openInTab")
await WindowStackManager.removeWindow(sender.tab?.windowId as number)
response(true)
@@ -273,9 +303,11 @@ const commandFuncs = {
return
}
- // Remove the window.
- await closeWindow(windowId, "onHidden")
- await WindowStackManager.removeWindow(windowId)
+ // Schedule popup window to close with configured delay
+ const window = layer.find((w) => w.id === windowId)
+ if (window) {
+ await PopupAutoClose.scheduleClose([window], "onHidden")
+ }
response(false)
}
@@ -306,6 +338,7 @@ const commandFuncs = {
},
[BgCommand.getTabId]: getTabId,
+ [BgCommand.getActiveTabId]: getActiveTabId,
//
// PageAction
@@ -344,6 +377,16 @@ const updateWindowSize = async (
}
}
+const updateActiveTabId = async (activeTabId?: number) => {
+ if (activeTabId == null) {
+ const activeTab = await getCurrentTab()
+ activeTabId = activeTab?.id
+ }
+ if (activeTabId != null) {
+ await BgData.update({ activeTabId })
+ }
+}
+
chrome.action.onClicked.addListener(() => {
chrome.tabs.create({
url: OPTION_PAGE_PATH,
@@ -358,17 +401,17 @@ chrome.windows.onFocusChanged.addListener(async (windowId: number) => {
return
}
- // Update active screen ID
- await updateActiveScreenId(windowId)
+ // Update active tab ID
+ await updateActiveTabId()
+
+ // Update active tab ID
+ await updateActiveTabId()
// Get windows to close based on focus change
const windowsToClose = await WindowStackManager.getWindowsToClose(windowId)
- // Execute close for all windows that need to be closed
- for (const window of windowsToClose) {
- await closeWindow(window.id, "onFocusChanged")
- await WindowStackManager.removeWindow(window.id)
- }
+ // Schedule popup windows to close with configured delay
+ await PopupAutoClose.scheduleClose(windowsToClose)
})
chrome.windows.onRemoved.addListener((windowId: number) => {
@@ -408,12 +451,8 @@ chrome.tabs.onActivated.addListener(async (activeInfo) => {
console.error("Failed to close menu:", error)
}
- // Get the active tab's window and update screen ID
try {
- const tab = await chrome.tabs.get(activeInfo.tabId)
- if (tab.windowId) {
- await updateActiveScreenId(tab.windowId)
- }
+ await updateActiveTabId(activeInfo.tabId)
} catch (error) {
console.error("Failed to get active screen ID:", error)
}
@@ -427,26 +466,35 @@ if (isDebug) {
})
}
-chrome.runtime.onInstalled.addListener((details) => {
- ContextMenu.init()
+chrome.runtime.onInstalled.addListener(async (details) => {
+ try {
+ // Initialize default settings on install
+ if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) {
+ await Settings.reset()
+ }
- chrome.storage.session.setAccessLevel({
- accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS",
- })
+ await ContextMenu.init()
- if (
- details.reason === chrome.runtime.OnInstalledReason.INSTALL ||
- details.reason === chrome.runtime.OnInstalledReason.UPDATE
- ) {
- // Set uninstall survey URL
- chrome.runtime.setUninstallURL(`${HUB_URL}/uninstall`)
- }
+ chrome.storage.session.setAccessLevel({
+ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS",
+ })
- // Check for daily backup on startup
- checkAndPerformDailyBackup()
+ if (
+ details.reason === chrome.runtime.OnInstalledReason.INSTALL ||
+ details.reason === chrome.runtime.OnInstalledReason.UPDATE
+ ) {
+ // Set uninstall survey URL
+ chrome.runtime.setUninstallURL(`${HUB_URL}/uninstall`)
+ }
- // Check for weekly backup on startup
- checkAndPerformWeeklyBackup()
+ // Check for daily backup on startup
+ await checkAndPerformDailyBackup()
+
+ // Check for weekly backup on startup
+ await checkAndPerformWeeklyBackup()
+ } catch (error) {
+ console.error("Error during onInstalled initialization:", error)
+ }
})
chrome.runtime.onStartup.addListener(() => {
@@ -533,19 +581,12 @@ chrome.commands.onCommand.addListener(async (commandName) => {
return
}
- // Get active tab
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
const command = settings.commands.find((c) => c.id === shortcut.commandId)
if (!command) {
console.warn(`Command not found: ${shortcut.commandId}`)
return
}
- const enableSendTab =
- tab?.id &&
- !tab.url?.startsWith("chrome") &&
- !tab.url?.includes("chromewebstore.google.com")
-
const selectionText = await Storage.get(
SESSION_STORAGE_KEY.SELECTION_TEXT,
)
@@ -566,6 +607,13 @@ chrome.commands.onCommand.addListener(async (commandName) => {
}
}
+ // Get active tab
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
+ const enableSendTab =
+ tab?.id &&
+ !tab.url?.startsWith("chrome") &&
+ !tab.url?.includes("chromewebstore.google.com")
+
let ret: unknown
if (enableSendTab) {
// Execute command in tab
@@ -601,6 +649,16 @@ chrome.commands.onCommand.addListener(async (commandName) => {
}
})
+// SidePanel auto-hide functionality
+// Track tabs with active side panels
+chrome.tabs.onRemoved.addListener((tabId) => {
+ ActionHelper.sidePanelClosed(tabId)
+ updateActiveTabId()
+})
+chrome.sidePanel.onClosed.addListener(({ tabId }) =>
+ ActionHelper.sidePanelClosed(tabId),
+)
+
// Export functions for testing
export const testExports = {
commandFuncs,
diff --git a/packages/extension/src/components/App.tsx b/packages/extension/src/components/App.tsx
index 6bd9b169..18e346c2 100644
--- a/packages/extension/src/components/App.tsx
+++ b/packages/extension/src/components/App.tsx
@@ -15,8 +15,12 @@ import { SelectContextProvider } from "@/providers/SelectContextProvider"
import { TabContextProvider } from "@/providers/TabContextProvider"
import { Ipc, TabCommand } from "@/services/ipc"
import { Settings } from "@/services/settings/settings"
+import { useDetectInstantCommand } from "@/hooks/useDetectInstantCommand"
+import { BgData } from "@/services/backgroundData"
import type { ShowToastParam } from "@/types"
+BgData.init()
+
type Props = {
rootElm: HTMLElement
}
@@ -25,6 +29,9 @@ export function App({ rootElm }: Props) {
const [positionElm, setPositionElm] = useState(null)
const [isHover, setIsHover] = useState(false)
+ // Detect and execute instant command
+ useDetectInstantCommand()
+
useEffect(() => {
const handleShowToast = (
param: ShowToastParam,
diff --git a/packages/extension/src/components/Icon.tsx b/packages/extension/src/components/Icon.tsx
deleted file mode 100644
index 239538d0..00000000
--- a/packages/extension/src/components/Icon.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from "react"
-
-type Props = {
- name: string
- className?: string
- style?: React.CSSProperties
-}
-
-export function Icon(props: Props): JSX.Element {
- const href = `#icon-${props.name}`
-
- const className = props.className ?? "h-full w-full"
-
- return (
-
- )
-}
diff --git a/packages/extension/src/components/OpenInTab.css b/packages/extension/src/components/OpenInTab.css
index d2958f10..a992e532 100644
--- a/packages/extension/src/components/OpenInTab.css
+++ b/packages/extension/src/components/OpenInTab.css
@@ -3,37 +3,22 @@
top: 5px;
right: 5px;
z-index: 2147483647;
+ visibility: visible !important;
}
.OpenInTab__button {
background: #f9f9f9;
- border-radius: 6px;
border: none;
- filter: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07))
- drop-shadow(0 2px 2px rgb(0 0 0 / 0.06));
- display: flex;
- align-items: center;
- padding: 4px 8px;
- cursor: pointer;
- color: var(--gray-400);
- font-size: 12px;
+ filter: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06));
transition:
color 250ms,
background-color 250ms;
}
+
.OpenInTab__button:hover {
- color: var(--gray-700);
background: #fff;
}
.OpenInTab__icon {
- width: 20px;
- height: 20px;
- margin-right: 4px;
- padding-bottom: 2px;
- fill: var(--gray-400);
- transition: fill 250ms;
-}
-.OpenInTab__icon:hover {
- fill: var(--gray-700);
+ transition: color 250ms;
}
diff --git a/packages/extension/src/components/OpenInTab.tsx b/packages/extension/src/components/OpenInTab.tsx
index 49be78dd..6e533f73 100644
--- a/packages/extension/src/components/OpenInTab.tsx
+++ b/packages/extension/src/components/OpenInTab.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
import { Ipc, BgCommand } from "@/services/ipc"
-import { Icon } from "../components/Icon"
+import { ExternalLink } from "lucide-react"
import "./OpenInTab.css"
let isPageUnloading = false
@@ -44,13 +44,13 @@ export function OpenInTab(): JSX.Element {
return (
<>
{enableOpenInTab && (
-
+
diff --git a/packages/extension/src/components/Popup.tsx b/packages/extension/src/components/Popup.tsx
index c69ee5b9..67f61e95 100644
--- a/packages/extension/src/components/Popup.tsx
+++ b/packages/extension/src/components/Popup.tsx
@@ -4,6 +4,8 @@ import { Menu } from "@/components/menu/Menu"
import { useUserSettings } from "@/hooks/useSettings"
import { useDetectStartup } from "@/hooks/useDetectStartup"
import { useTabCommandReceiver } from "@/hooks/useTabCommandReceiver"
+import { useSidePanelNavigation } from "@/hooks/useSidePanelNavigation"
+import { useSidePanelAutoClose } from "@/hooks/useSidePanelAutoClose"
import { hexToHsl, isMac, onHover, cn } from "@/lib/utils"
import { t } from "@/services/i18n"
import { STYLE_VARIABLE, EXIT_DURATION, SIDE, ALIGN } from "@/const"
@@ -27,6 +29,9 @@ export const popupContext = createContext({} as ContextType)
export const Popup = forwardRef(
(props: PopupProps, ref) => {
useTabCommandReceiver()
+ useSidePanelNavigation()
+ useSidePanelAutoClose()
+
const { userSettings } = useUserSettings()
const [inTransition, setInTransition] = useState(false)
const [shouldRender, setShouldRender] = useState(false)
diff --git a/packages/extension/src/components/SelectAnchor.test.tsx b/packages/extension/src/components/SelectAnchor.test.tsx
new file mode 100644
index 00000000..288d6f34
--- /dev/null
+++ b/packages/extension/src/components/SelectAnchor.test.tsx
@@ -0,0 +1,219 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import { render, act } from "@testing-library/react"
+import { SelectAnchor } from "./SelectAnchor"
+
+// Mock hooks
+const mockSetTarget = vi.fn()
+const mockSelectionText = { current: "" }
+
+vi.mock("@/hooks/useSelectContext", () => ({
+ useSelectContext: () => ({
+ setTarget: mockSetTarget,
+ selectionText: mockSelectionText.current,
+ }),
+}))
+
+vi.mock("@/hooks/useSettings", () => ({
+ useUserSettings: () => ({
+ userSettings: {
+ startupMethod: {
+ method: "textSelection",
+ leftClickHoldParam: 200,
+ },
+ },
+ loading: false,
+ error: null,
+ }),
+}))
+
+vi.mock("@/hooks/useLeftClickHold", () => ({
+ useLeftClickHold: () => ({
+ detectHold: false,
+ detectHoldLink: false,
+ position: { x: 0, y: 0 },
+ progress: 0,
+ linkElement: null,
+ }),
+}))
+
+vi.mock("@/components/LinkClickGuard", () => ({
+ LinkClickGuard: () => null,
+}))
+
+// Mock DOM functions
+const mockGetSelectionText = vi.fn((): string => "")
+const mockGetInputSelectionEndPoint = vi.fn(
+ (_el: HTMLInputElement | HTMLTextAreaElement) =>
+ null as { x: number; y: number } | null,
+)
+const mockGetEditableSelectionEndPoint = vi.fn(
+ () => null as { x: number; y: number } | null,
+)
+const mockIsInputOrTextarea = vi.fn(
+ (
+ _target: EventTarget | null,
+ ): _target is HTMLInputElement | HTMLTextAreaElement => false,
+)
+const mockIsEditable = vi.fn((_e: unknown): boolean => false)
+
+vi.mock("@/services/dom", () => ({
+ getSelectionText: () => mockGetSelectionText(),
+ isInputOrTextarea: (t: EventTarget | null) => mockIsInputOrTextarea(t),
+ getInputSelectionEndPoint: (el: HTMLInputElement | HTMLTextAreaElement) =>
+ mockGetInputSelectionEndPoint(el),
+ getEditableSelectionEndPoint: () => mockGetEditableSelectionEndPoint(),
+ isEditable: (e: unknown) => mockIsEditable(e),
+}))
+
+describe("SelectAnchor", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockSelectionText.current = ""
+ mockGetSelectionText.mockReturnValue("")
+ mockIsInputOrTextarea.mockReturnValue(false as never)
+ mockIsEditable.mockReturnValue(false)
+ mockGetInputSelectionEndPoint.mockReturnValue(null)
+ mockGetEditableSelectionEndPoint.mockReturnValue(null)
+ })
+
+ afterEach(() => {
+ document.body.innerHTML = ""
+ })
+
+ it("SA-01: Shift+Arrow key in input triggers setAnchor", () => {
+ const input = document.createElement("input")
+ input.type = "text"
+ input.value = "hello world"
+ document.body.appendChild(input)
+ input.focus()
+
+ // Mock: activeElement is an input with selection
+ mockIsInputOrTextarea.mockReturnValue(true as never)
+ mockGetSelectionText.mockReturnValue("hello")
+ mockGetInputSelectionEndPoint.mockReturnValue({ x: 50, y: 20 })
+
+ render()
+
+ // Simulate Shift+ArrowRight keyup
+ act(() => {
+ const event = new KeyboardEvent("keyup", {
+ key: "ArrowRight",
+ shiftKey: true,
+ bubbles: true,
+ })
+ document.dispatchEvent(event)
+ })
+
+ expect(mockGetSelectionText).toHaveBeenCalled()
+ expect(mockGetInputSelectionEndPoint).toHaveBeenCalled()
+ })
+
+ it("SA-02: Shift+Arrow key in contenteditable triggers setAnchor", () => {
+ const div = document.createElement("div")
+ div.contentEditable = "true"
+ document.body.appendChild(div)
+ div.focus()
+
+ mockIsInputOrTextarea.mockReturnValue(false as never)
+ mockIsEditable.mockReturnValue(true)
+ mockGetSelectionText.mockReturnValue("selected text")
+ mockGetEditableSelectionEndPoint.mockReturnValue({ x: 100, y: 30 })
+
+ render()
+
+ act(() => {
+ const event = new KeyboardEvent("keyup", {
+ key: "ArrowLeft",
+ shiftKey: true,
+ bubbles: true,
+ })
+ document.dispatchEvent(event)
+ })
+
+ expect(mockGetSelectionText).toHaveBeenCalled()
+ expect(mockGetEditableSelectionEndPoint).toHaveBeenCalled()
+ })
+
+ it("SA-03: non-selection key in input does not trigger selection logic", () => {
+ const input = document.createElement("input")
+ input.type = "text"
+ document.body.appendChild(input)
+ input.focus()
+
+ mockIsInputOrTextarea.mockReturnValue(true as never)
+ mockIsEditable.mockReturnValue(false)
+
+ render()
+
+ // Simulate a regular key (no shift, not Ctrl+A)
+ act(() => {
+ const event = new KeyboardEvent("keyup", {
+ key: "b",
+ shiftKey: false,
+ ctrlKey: false,
+ metaKey: false,
+ bubbles: true,
+ })
+ document.dispatchEvent(event)
+ })
+
+ // getSelectionText should NOT be called because:
+ // - no shift key
+ // - not Ctrl+A / Cmd+A
+ // - not Meta/Control key
+ // - point is null (no existing selection)
+ expect(mockGetSelectionText).not.toHaveBeenCalled()
+ })
+
+ it("SA-04: Ctrl+A in input triggers selection logic", () => {
+ const input = document.createElement("input")
+ input.type = "text"
+ input.value = "hello"
+ document.body.appendChild(input)
+ input.focus()
+
+ mockIsInputOrTextarea.mockReturnValue(true as never)
+ mockGetSelectionText.mockReturnValue("hello")
+ mockGetInputSelectionEndPoint.mockReturnValue({ x: 80, y: 20 })
+
+ render()
+
+ act(() => {
+ const event = new KeyboardEvent("keyup", {
+ key: "a",
+ ctrlKey: true,
+ bubbles: true,
+ })
+ document.dispatchEvent(event)
+ })
+
+ expect(mockGetSelectionText).toHaveBeenCalled()
+ })
+
+ it("SA-05: keyup outside input/editable does not trigger input selection logic", () => {
+ // activeElement is document.body (not input/editable)
+ mockIsInputOrTextarea.mockReturnValue(false as never)
+ mockIsEditable.mockReturnValue(false)
+
+ render()
+
+ act(() => {
+ const event = new KeyboardEvent("keyup", {
+ key: "ArrowRight",
+ shiftKey: true,
+ bubbles: true,
+ })
+ document.dispatchEvent(event)
+ })
+
+ // Should not call input/editable specific functions
+ expect(mockGetInputSelectionEndPoint).not.toHaveBeenCalled()
+ expect(mockGetEditableSelectionEndPoint).not.toHaveBeenCalled()
+ })
+
+ it("SA-06: renders nothing when point is null", () => {
+ const { container } = render()
+ // When no selection has been made, the component returns null
+ expect(container.innerHTML).toBe("")
+ })
+})
diff --git a/packages/extension/src/components/SelectAnchor.tsx b/packages/extension/src/components/SelectAnchor.tsx
index ca247fd9..e63646a0 100644
--- a/packages/extension/src/components/SelectAnchor.tsx
+++ b/packages/extension/src/components/SelectAnchor.tsx
@@ -5,7 +5,13 @@ import { useSelectContext } from "@/hooks/useSelectContext"
import { useLeftClickHold } from "@/hooks/useLeftClickHold"
import { MOUSE, EXIT_DURATION, STARTUP_METHOD } from "@/const"
import { isEmpty, isPopup } from "@/lib/utils"
-import { getSelectionText } from "@/services/dom"
+import {
+ getSelectionText,
+ isInputOrTextarea,
+ getInputSelectionEndPoint,
+ getEditableSelectionEndPoint,
+ isEditable,
+} from "@/services/dom"
import { Point } from "@/types"
const SIZE = 40
@@ -53,6 +59,8 @@ export const SelectAnchor = forwardRef((_props, ref) => {
const s = document.getSelection()
if (s && s.rangeCount > 0) {
setTarget(s.getRangeAt(0).startContainer.parentElement as Element)
+ } else if (isInputOrTextarea(document.activeElement)) {
+ setTarget(document.activeElement)
} else {
setTarget(null)
}
@@ -152,7 +160,50 @@ export const SelectAnchor = forwardRef((_props, ref) => {
}, [point, isMouseDown, setIsDragging, releaseAnchor])
useEffect(() => {
- const onKeyUp = () => {
+ const onKeyUp = (e: KeyboardEvent) => {
+ const active = document.activeElement
+ const inFormControl = isInputOrTextarea(active)
+ const inEditable = isEditable(active)
+ if (inFormControl || inEditable) {
+ // When no selection is active, only process keys that can create one
+ // (Shift+arrows, Ctrl/Cmd+A, etc.) to avoid unnecessary computation.
+ // When a selection is already active (point != null), process all keys
+ // so we can detect deselection and call releaseAnchor.
+ // Include modifier keyups (Meta/Control) because on macOS,
+ // Cmd+A may not fire a keyup for "a" — only the Meta keyup fires.
+ const mayChangeSelection =
+ e.shiftKey ||
+ (e.key === "a" && (e.ctrlKey || e.metaKey)) ||
+ e.key === "Meta" ||
+ e.key === "Control"
+ if (!mayChangeSelection && !point) {
+ return
+ }
+ const text = getSelectionText()
+ if (text) {
+ let selectionEndPoint: Point | null = null
+ if (inFormControl) {
+ // Use mirror div technique since Range.getBoundingClientRect() is not available for form controls.
+ selectionEndPoint = getInputSelectionEndPoint(active)
+ if (!selectionEndPoint) {
+ const rect = active.getBoundingClientRect()
+ selectionEndPoint = { x: rect.right, y: rect.bottom }
+ }
+ } else {
+ // Use Range API to get the actual selection end position in contenteditable elements.
+ selectionEndPoint = getEditableSelectionEndPoint()
+ }
+ if (selectionEndPoint) {
+ selectionEndPoint.y -= 8 // Adjust to match the mouse cursor selection.
+ setAnchor(selectionEndPoint)
+ } else {
+ releaseAnchor(true)
+ }
+ return
+ }
+ releaseAnchor(true)
+ return
+ }
if (!selectionText) {
releaseAnchor(true)
}
diff --git a/packages/extension/src/components/Tooltip.tsx b/packages/extension/src/components/Tooltip.tsx
index 6f5fd22f..319d3253 100644
--- a/packages/extension/src/components/Tooltip.tsx
+++ b/packages/extension/src/components/Tooltip.tsx
@@ -63,14 +63,14 @@ export function Tooltip(props: PopupProps) {
{shouldRender && (
- {props.text}
+