Merge pull request #90 from multica-ai/feat/mobile-pwa-optimization

feat: mobile PWA optimization with improved onboarding UX
This commit is contained in:
Naiyuan Qing 2026-02-05 09:55:17 +08:00 committed by GitHub
commit 963bb6c0f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2212 additions and 268 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

View file

@ -0,0 +1,847 @@
# Super Multica Product Capabilities
> This document is the single source of truth for all product capabilities. It describes **what exists**, not how to design or how to use it. All subsequent documents (user journeys, UI design, copywriting, design systems) should reference this document.
---
## 1. Product Definition
**Super Multica** is a distributed AI Agent framework. Users can create, customize, and deploy AI Agents with persistent memory, fine-grained capability control, and multi-provider LLM support. Agents run locally on the user's machine; remote access is optional.
**Core architecture**:
```
Desktop App (standalone, recommended)
└─ Hub (embedded, manages agents)
└─ Agent Engine (LLM execution, sessions, skills, tools)
└─ (Optional) Gateway connection → remote clients (web/mobile)
```
---
## 2. User Roles
| Role | Definition | Platform | Authority |
|------|-----------|----------|-----------|
| **Owner** | Runs the Desktop app, owns Hub and Agents | Desktop (Electron) | Full: create/delete agents, approve devices, configure providers, manage profiles/skills |
| **Collaborator** | Connects to Owner's Agent via Gateway | Web / Mobile | Limited: chat with agent, view message history. No agent management. |
There is no formal role/permission system. The Owner is implicit admin by virtue of running the Hub.
---
## 3. Functional Modules
### 3.1 Agent Engine
The core execution unit. An Agent receives user messages, calls an LLM, executes tools, and returns responses.
#### 3.1.1 Agent Lifecycle
| State | Description |
|-------|-------------|
| Created | AsyncAgent instantiated, assigned UUIDv7 session ID |
| Idle | Awaiting `write()` call (user message) |
| Running | Processing message: LLM call → tool execution → response |
| Closed | Agent terminated, no further messages accepted |
Each `write()` call is queued. Messages are processed sequentially (one at a time).
#### 3.1.2 Agent Execution Loop
1. Receive user message via `write(content)`
2. Resolve API credentials (with auth profile rotation)
3. Build/update system prompt from profile
4. Call LLM provider with message history
5. If LLM requests tool calls → execute tools → feed results back to LLM → repeat
6. Save all messages to session storage
7. Check context window utilization → compact if needed
8. Emit events to subscribers (streaming to UI)
#### 3.1.3 Auth Profile Rotation
When an API call fails, the system classifies the error and may rotate to a different API key:
| Error Type | Examples | Rotates? |
|-----------|----------|----------|
| `auth` | 401, 403, invalid key | Yes |
| `rate_limit` | 429, rate limit exceeded | Yes |
| `billing` | Out of credits, quota exceeded | Yes |
| `timeout` | Connection timeout | Yes |
| `format` | 400, malformed request | No |
| `unknown` | Other errors | No |
Failed profiles enter cooldown. Rotation continues until success or all profiles exhausted.
Tracking file: `~/.super-multica/.auth-profiles/usage-stats.json`
#### 3.1.4 Subagent Spawning
Agents can spawn child agents via the `sessions_spawn` tool:
- Subagents get isolated sessions
- Tool restrictions: `sessions_spawn` denied (no nested spawning)
- System prompt mode: `minimal` or `none`
- Parameters: task (required), label, model override, cleanup policy (`delete` or `keep`), timeout
- Results announced back to parent automatically
#### 3.1.5 Agent Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `profileId` | string | none | Agent profile to load |
| `provider` | string | `kimi-coding` | LLM provider |
| `model` | string | provider default | Model within provider |
| `reasoningMode` | `off` / `on` / `stream` | `off` | Display thinking/reasoning |
| `compactionMode` | `count` / `tokens` / `summary` | `tokens` | Context compaction strategy |
| `contextWindowTokens` | number | 200,000 | Override model's context window |
| `enableSkills` | boolean | `true` | Enable skills system |
---
### 3.2 LLM Providers
Ten providers supported. Two auth methods: OAuth (CLI login) and API Key.
| ID | Display Name | Auth | Default Model | Available Models |
|----|-------------|------|---------------|------------------|
| `claude-code` | Claude Code | OAuth | claude-opus-4-5 | claude-opus-4-5, claude-sonnet-4-5, claude-haiku-4-5 |
| `openai-codex` | Codex | OAuth | gpt-5.2 | gpt-5.2, gpt-5.2-codex, gpt-5.1-codex, gpt-5.1-codex-mini, gpt-5.1-codex-max |
| `anthropic` | Anthropic | API Key | claude-sonnet-4-5 | claude-opus-4-5, claude-sonnet-4-5, claude-haiku-4-5 |
| `openai` | OpenAI | API Key | gpt-4o | gpt-4o, gpt-4o-mini, o1, o1-mini |
| `kimi-coding` | Kimi Code | API Key | kimi-k2-thinking | kimi-k2-thinking, k2p5 |
| `google` | Google AI | API Key | gemini-2.0-flash | gemini-2.0-flash, gemini-1.5-pro |
| `groq` | Groq | API Key | llama-3.3-70b-versatile | llama-3.3-70b-versatile, mixtral-8x7b-32768 |
| `mistral` | Mistral | API Key | mistral-large-latest | mistral-large-latest, codestral-latest |
| `xai` | xAI (Grok) | API Key | grok-beta | grok-beta, grok-vision-beta |
| `openrouter` | OpenRouter | API Key | anthropic/claude-3.5-sonnet | anthropic/claude-3.5-sonnet, openai/gpt-4o |
**Default provider fallback**: config > credentials.json5 > `kimi-coding`
**OAuth providers** require external CLI login (`claude login` / `codex login`).
**API Key providers** are configured in `~/.super-multica/credentials.json5`.
**Multiple API keys per provider** are supported via auth profiles (e.g., `openai`, `openai:backup`). The system rotates between them on failure.
---
### 3.3 Tools
Tools are capabilities the Agent can invoke during execution.
#### 3.3.1 Built-in Tools
| Tool | Category | Description |
|------|----------|-------------|
| `read` | File | Read file contents (with optional offset/limit) |
| `write` | File | Create or overwrite files |
| `edit` | File | Make precise edits to existing files |
| `glob` | File | Find files by pattern (default limit: 100, max: 1000) |
| `exec` | Runtime | Run shell commands (auto-backgrounds after 10s) |
| `process` | Runtime | Manage background processes (start, stop, list, output) |
| `web_search` | Web | Search the web (Brave or Perplexity provider) |
| `web_fetch` | Web | Fetch and extract URL content (markdown/text, max 50k chars, 15min cache) |
| `memory_get` | Memory | Read from agent's persistent memory |
| `memory_set` | Memory | Write to agent's persistent memory (max 1MB per value) |
| `memory_list` | Memory | List memory entries (default limit: 100, max: 1000) |
| `memory_delete` | Memory | Delete memory entries |
| `sessions_spawn` | Subagent | Spawn a child agent for a specific task |
#### 3.3.2 Tool Groups (shortcuts for policy)
| Group | Tools Included |
|-------|---------------|
| `group:fs` | read, write, edit, glob |
| `group:runtime` | exec, process |
| `group:web` | web_search, web_fetch |
| `group:memory` | memory_get, memory_set, memory_delete, memory_list |
| `group:subagent` | sessions_spawn |
| `group:core` | read, write, edit, glob, exec, process, web_search, web_fetch |
#### 3.3.3 Tool Policy System (3 layers)
| Layer | Scope | Description |
|-------|-------|-------------|
| 1. Global | All agents | `allow` / `deny` lists (wildcard supported: `mem*`, `*`) |
| 2. Provider | Per LLM provider | Narrower restrictions per provider (e.g., deny `exec` for Google) |
| 3. Subagent | Child agents only | `sessions_spawn` denied by default |
**Priority**: Deny always overrides Allow. Empty allow list = deny all.
#### 3.3.4 Exec Tool Details
- Default yield timeout: 10,000ms (auto-backgrounds if not complete)
- Supports `timeoutMs` for hard kill (SIGTERM)
- Output includes: stdout+stderr, exitCode, truncation flag, process ID if backgrounded
#### 3.3.5 Web Search Details
- Brave provider: up to 10 results, country filtering, freshness filters (`pd`/`pw`/`pm`/`py`)
- Perplexity provider: AI-synthesized answers
- Default count: 5 results, 1 hour cache
---
### 3.4 Profile System
A Profile defines an Agent's identity, personality, knowledge, and configuration.
#### 3.4.1 Profile File Structure
```
~/.super-multica/agent-profiles/{profileId}/
├── soul.md # Identity: name, role, personality, behavior boundaries
├── user.md # User information: name, preferences, context
├── workspace.md # Workspace conventions, coding standards, project rules
├── memory.md # Long-term knowledge base (read by agent at startup)
├── config.json # Optional: provider, model, thinking level, tool policy
├── memory/ # Key-value persistent memory storage
│ ├── key1.json
│ └── key2.json
└── skills/ # Profile-specific skills (override global)
└── {skill-name}/
└── SKILL.md
```
#### 3.4.2 Profile Config (config.json)
```json
{
"name": "Jarvis",
"style": "concise and direct",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"thinkingLevel": "medium",
"tools": {
"allow": ["group:fs", "web_fetch"],
"deny": ["exec"]
}
}
```
#### 3.4.3 Profile Operations
| Operation | CLI | Desktop |
|-----------|-----|---------|
| List profiles | `multica profile list` | Via Hub info |
| Create profile | `multica profile new <id>` | - |
| Interactive setup | `multica profile setup <id>` | - |
| View profile | `multica profile show <id>` | - |
| Edit in file manager | `multica profile edit <id>` | - |
| Delete profile | `multica profile delete <id>` | - |
**Profile ID rules**: alphanumeric, hyphens, underscores only.
#### 3.4.4 System Prompt Composition
The system prompt is built dynamically from profile files:
| Section | Source | Mode: full | Mode: minimal | Mode: none |
|---------|--------|-----------|--------------|-----------|
| Identity | soul.md + config | Yes | Partial | Single line |
| User | user.md | On-demand | No | No |
| Workspace | workspace.md | Yes | No | No |
| Memory | memory.md | On-demand | No | No |
| Safety | Built-in constitution | Yes | Yes | Yes |
| Tools | Active tool list | Yes | Core only | No |
| Skills | Skill instructions | Yes | No | No |
| Runtime | OS, model, hostname | Yes | Essential | No |
| Subagent | Task context | If applicable | Yes | Yes |
**Progressive disclosure**: soul.md, user.md, memory.md are loaded on-demand (not injected in full at startup) to save tokens.
---
### 3.5 Memory System
Agents can persistently store and recall information across sessions.
#### 3.5.1 Storage
- Location: `~/.super-multica/agent-profiles/{profileId}/memory/`
- Format: One JSON file per key
- Key rules: alphanumeric, underscore, dot, hyphen. Max 128 chars.
- Dots in keys are escaped as `__DOT__` in filenames
- Max value size: 1MB
#### 3.5.2 Entry Format
```json
{
"value": "any JSON value",
"description": "optional human-readable description",
"createdAt": 1717689600000,
"updatedAt": 1717689600000
}
```
#### 3.5.3 Memory Tools
| Tool | Input | Output |
|------|-------|--------|
| `memory_get` | `{ key }` | `{ found, value?, description?, updatedAt? }` |
| `memory_set` | `{ key, value, description? }` | `{ success, error? }` |
| `memory_delete` | `{ key }` | `{ success, existed, error? }` |
| `memory_list` | `{ prefix?, limit? }` | `{ keys[], total, truncated }` |
**Design principle**: Agents cannot "remember" mentally. All persistence must be file-based ("TEXT > BRAIN").
---
### 3.6 Skills System
Skills are modular, self-contained capabilities defined via `SKILL.md` files. They extend what an Agent can do.
#### 3.6.1 Skill File Format (SKILL.md)
```yaml
---
name: Skill Name
description: What this skill does
version: 1.0.0
metadata:
emoji: "📝"
os: [darwin, linux] # Platform restriction (optional)
always: false # Skip eligibility checks (optional)
tags: [productivity, coding]
requires:
bins: [node, npm] # ALL must exist in PATH
anyBins: [python3, python] # At least ONE must exist
env: [OPENAI_API_KEY] # ALL must be set
config: [custom.setting] # Config paths must be truthy
---
# Full markdown instructions follow...
```
#### 3.6.2 Skill Sources & Precedence
| Source | Location | Precedence |
|--------|----------|-----------|
| Bundled | `skills/` in project | Lowest |
| Global (user-installed) | `~/.super-multica/skills/` | Medium |
| Profile-specific | `~/.super-multica/agent-profiles/{id}/skills/` | Highest (overrides) |
Profile skills with the same ID completely replace global/bundled versions.
#### 3.6.3 Bundled Skills
| Skill | ID | Description | Requirements |
|-------|----|-------------|-------------|
| Git Commit Helper | `commit` | Create well-formatted conventional commits | `git` binary |
| Code Review | `code-review` | Structured code review with security focus | None |
| Profile Setup | `profile-setup` | Interactive wizard to personalize agent profile | None |
| Skill Creator | `skill-creator` | Create, edit, manage custom skills | None (always eligible) |
#### 3.6.4 Eligibility Check Sequence
1. Explicit disable in config → ineligible
2. Bundled + not in allowlist → ineligible
3. Platform mismatch (OS) → ineligible
4. `always: true` flag → eligible (skip remaining)
5. Missing required binary → ineligible
6. No alternative binary found → ineligible
7. Missing env var → ineligible
8. Missing config path → ineligible
9. All checks pass → eligible
Returns human-readable failure reasons (e.g., "Required binary not found: git").
#### 3.6.5 Skill Invocation
- **User invocation**: `/skillname args` in interactive CLI
- **Model invocation**: Agent reads skill instructions from system prompt and follows them
- **Hot reload**: File watcher detects SKILL.md changes, reloads automatically (250ms debounce)
#### 3.6.6 Skill Installation
```bash
multica skills add owner/repo # Clone entire repository
multica skills add owner/repo/skill-name # Clone single skill
multica skills add owner/repo@branch # Specific branch/tag
multica skills add owner/repo -p my-agent # Install to profile
```
---
### 3.7 Session Management
Sessions persist conversation history across interactions.
#### 3.7.1 Session Storage
- Location: `~/.super-multica/sessions/{sessionId}/session.jsonl`
- Format: JSON Lines (one JSON object per line)
- Session IDs: UUIDv7 (time-ordered)
- Each line is either a message entry, meta entry, or compaction entry
#### 3.7.2 Message Format
Messages follow the LLM API format:
```json
{"type": "message", "role": "user", "content": [{"type": "text", "text": "Hello"}]}
{"type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hi!"}, {"type": "tool_use", "id": "...", "name": "read", "input": {"path": "/foo"}}]}
{"type": "message", "role": "user", "content": [{"type": "tool_result", "tool_use_id": "...", "content": "file contents"}]}
```
#### 3.7.3 Session Metadata
```json
{"type": "meta", "provider": "anthropic", "model": "claude-sonnet-4-5", "reasoningMode": "off", "contextWindowTokens": 200000}
```
#### 3.7.4 Context Window Management
| Parameter | Value | Description |
|-----------|-------|-------------|
| Hard minimum | 16,000 tokens | Block execution below this |
| Warning threshold | 32,000 tokens | Warn if context window smaller |
| Default context | 200,000 tokens | Fallback if model unknown |
| Safety margin | 20% | Buffer for estimation inaccuracy |
| Compaction trigger | 80% utilization | Start compacting |
| Compaction target | 50% utilization | Target after compaction |
| Min keep messages | 10 | Never remove below this |
| Reserve tokens | 1,024 | Reserved for response generation |
#### 3.7.5 Compaction Modes
| Mode | Strategy | Speed | Quality |
|------|----------|-------|---------|
| `tokens` (default) | Remove oldest messages until reaching 50% target | Fast | Good (preserves recent context) |
| `count` | Remove oldest when count > 80, keep last 60 | Fastest | Adequate |
| `summary` | LLM generates incremental summary of removed messages | Slow (API call) | Best (preserves meaning) |
#### 3.7.6 Session Operations
| Operation | CLI Command |
|-----------|-------------|
| List sessions | `multica session list` |
| View session | `multica session show <id>` (supports partial ID) |
| Delete session | `multica session delete <id>` |
| Resume session | `multica --session <id> "continue..."` |
---
### 3.8 Hub
The Hub is the central coordinator. It manages agent lifecycle, routes messages, and handles device verification.
#### 3.8.1 Responsibilities
- Create, list, restore, close agents
- Persist agent metadata to disk (`~/.super-multica/agents/agents.json`)
- Route messages between local IPC and remote Gateway
- Handle device verification and whitelisting
- Process RPC requests from connected clients
#### 3.8.2 Hub RPC Methods
| Method | Description | Error Codes |
|--------|-------------|-------------|
| `verify` | Verify device with token | UNAUTHORIZED, REJECTED |
| `getAgentMessages` | Fetch message history (default: 50, offset: 0) | INVALID_PARAMS, AGENT_NOT_FOUND |
| `getHubInfo` | Get Hub ID and status | - |
| `listAgents` | List all agents | - |
| `createAgent` | Create new agent | - |
| `deleteAgent` | Delete agent | - |
| `updateGateway` | Update Gateway connection | - |
#### 3.8.3 Hub Singleton
One Hub per ecosystem. In Desktop mode, it's embedded in the Electron main process. It generates a persistent Hub ID stored at `~/.super-multica/hub-id`.
---
### 3.9 Gateway
NestJS WebSocket server that enables remote client access to the Hub.
#### 3.9.1 Purpose
Bridges remote clients (web/mobile) to the Hub. Not needed for local Desktop use.
#### 3.9.2 Connection Protocol
- Transport: Socket.io
- Path: `/ws`
- Port: 3000 (default)
#### 3.9.3 Timeouts
| Parameter | Value |
|-----------|-------|
| Ping interval | 25 seconds |
| Ping timeout | 20 seconds |
| RPC default timeout | 10 seconds |
| Verify timeout | 30 seconds |
| Reconnect delay | 1 second |
#### 3.9.4 Message Routing
- Each message has `from` (sender device ID) and `to` (target device ID)
- Gateway validates: sender is registered, `from` matches socket, target exists
- Supports streaming via `StreamAction` (message_start, message_update, message_end, tool events)
#### 3.9.5 Error Codes
| Code | Meaning |
|------|---------|
| NOT_REGISTERED | Sender not registered |
| INVALID_MESSAGE | `from` field mismatch |
| DEVICE_NOT_FOUND | Target device not online |
---
### 3.10 Device Pairing & Verification
How remote devices (web/mobile) connect to the Owner's Hub.
#### 3.10.1 QR Code Generation (Desktop)
The Desktop app generates a QR code containing:
```json
{
"type": "multica-connect",
"gateway": "http://localhost:3000",
"hubId": "uuid",
"agentId": "uuid",
"token": "random-uuid",
"expires": 1694000000000
}
```
- Token: one-time use, random UUID
- Expiry: 30 seconds from generation
- Auto-refresh: new token generated when expired
- Also available as URL: `multica://connect?gateway=...&hub=...&agent=...&token=...&exp=...`
#### 3.10.2 Connection Code Formats (accepted by client)
| Format | Example |
|--------|---------|
| JSON | `{"type":"multica-connect","gateway":"..."}` |
| Base64 JSON | Base64-encoded JSON string |
| URL | `multica://connect?gateway=...&hub=...&agent=...&token=...&exp=...` |
#### 3.10.3 Verification Flow
```
1. Mobile scans QR / pastes code
2. Client parses code, validates expiry
3. Client connects to Gateway via Socket.io
4. Gateway sends "registered" event
5. Client auto-sends "verify" RPC with token + device metadata
6. Hub validates token (one-time, checks expiry)
7. Hub triggers confirmation dialog on Desktop
- Shows: device name (parsed from User-Agent), device ID
- Options: "Allow" or "Reject"
- Timeout: 60 seconds (auto-reject)
8. If allowed: device added to whitelist, persisted to disk
9. If rejected: connection closed
```
#### 3.10.4 Device Whitelist
- Location: `~/.super-multica/client-devices/whitelist.json`
- Format:
```json
{
"version": 1,
"devices": [{
"deviceId": "uuid",
"agentId": "uuid",
"addedAt": 1694000000000,
"meta": {
"userAgent": "Mozilla/5.0...",
"platform": "Linux",
"language": "en-US"
}
}]
}
```
#### 3.10.5 Reconnection (whitelisted device)
Whitelisted devices reconnect without needing a new token or user confirmation. Hub checks `isAllowed(deviceId)` and returns immediately.
#### 3.10.6 Device Management (Desktop)
- View verified devices list with metadata
- Revoke individual devices (remove from whitelist)
- No fine-grained permissions (all-or-nothing access)
#### 3.10.7 Security Model
| Aspect | Detail |
|--------|--------|
| Token lifetime | 30 seconds |
| Token usage | One-time (deleted after consumption) |
| Token storage | In-memory only (lost on Hub restart) |
| Device ID | Browser: UUID in localStorage. Persistent until cleared. |
| Whitelist | Persisted to disk. Survives restarts. |
| Authorization | All verified devices have equal access |
| Message auth | Hub checks whitelist on every non-verify message |
---
### 3.11 Credentials System
#### 3.11.1 Files
| File | Purpose | Permissions |
|------|---------|-------------|
| `~/.super-multica/credentials.json5` | LLM providers + tool API keys | 0o600 |
| `~/.super-multica/skills.env.json5` | Skill/plugin environment variables | 0o600 |
Format: JSON5 (supports comments, trailing commas, unquoted keys).
#### 3.11.2 credentials.json5 Structure
```json5
{
version: 1,
llm: {
provider: "openai", // Default provider
providers: {
openai: { apiKey: "sk-...", model: "gpt-4o" },
anthropic: { apiKey: "sk-ant-...", model: "claude-sonnet-4-5" },
"openai:backup": { apiKey: "sk-..." }, // Auth profile for rotation
},
order: {
openai: ["openai", "openai:backup"], // Rotation order
},
},
tools: {
brave: { apiKey: "brv-..." },
perplexity: { apiKey: "pplx-...", model: "perplexity/sonar-pro" },
},
}
```
#### 3.11.3 skills.env.json5 Structure
```json5
{
env: {
LINEAR_API_KEY: "lin-...",
GITHUB_TOKEN: "ghp_...",
},
}
```
#### 3.11.4 Environment Variable Overrides
| Variable | Purpose |
|----------|---------|
| `SMC_CREDENTIALS_PATH` | Override credentials.json5 path |
| `SMC_SKILLS_ENV_PATH` | Override skills.env.json5 path |
| `SMC_CREDENTIALS_DISABLE=1` | Disable credentials loading |
---
## 4. Platform Details
### 4.1 Desktop App (Primary)
**Technology**: Electron + Vite + React 19
**Window**: 1200x800, context isolation enabled, node integration disabled
#### 4.1.1 Pages
| Route | Page | Purpose |
|-------|------|---------|
| `/` | Home | Hub status, QR code, provider selector, agent settings, device list |
| `/chat` | Chat | Message history, chat input, mode switcher (local/remote) |
| `/tools` | Tools | Tool listing and inspection |
| `/skills` | Skills | Skill listing and management |
**Navigation**: Tab bar at top (Home, Chat, Tools, Skills)
#### 4.1.2 Home Page Components
| Component | Description |
|-----------|-------------|
| QR Code | Left side. Shows connection code with 30s countdown. Refresh/copy link buttons. |
| Hub Status | Right side. Hub ID, connection state indicator (green/yellow/red). |
| Agent Settings | Agent name (editable). |
| Provider Selector | Dropdown showing all providers with availability status. API Key dialog or OAuth dialog based on provider type. |
| Device List | Verified devices with name, platform, revoke button. |
| Open Chat | Button. Disabled if Hub not connected. |
| Connect to Remote Agent | Button. Navigate to remote agent connection. |
#### 4.1.3 Chat Page Modes
| Mode | Transport | When Used |
|------|-----------|-----------|
| Local Agent | IPC (Electron) | Desktop user talks directly to embedded agent |
| Remote Agent | WebSocket via Gateway | Desktop user connects to another Hub's agent |
Mode switcher available at top of chat page.
#### 4.1.4 Desktop IPC Channels
| Channel | Direction | Purpose |
|---------|-----------|---------|
| `localChat:send` | Renderer → Main | Send message to agent |
| `localChat:subscribe` | Renderer → Main | Subscribe to agent events |
| `hub:device-confirm-request` | Main → Renderer | Show device confirmation dialog |
| `hub:device-confirm-response` | Renderer → Main | User's allow/reject decision |
---
### 4.2 Web App
**Technology**: Next.js 16 + App Router
**Port**: 3001
**Features**:
- Always requires Gateway connection (no local agent)
- Uses shared `@multica/ui` Chat component
- PWA-capable (service worker, offline page)
- Responsive layout (mobile-first)
- Light/dark theme toggle
**Page**: Single page rendering `<Chat />` component with `ConnectPrompt` for initial connection.
---
### 4.3 Mobile App
**Technology**: Expo + React Native
**Status**: Demo/prototype (hardcoded mock messages)
**Features**:
- QR code scanner for device pairing
- Keyboard-avoiding input bar
- Auto-expanding text input (max 120px)
- Auto-scroll to bottom on new messages
---
### 4.4 CLI
**Entry point**: `multica` (alias: `mu`)
#### 4.4.1 Commands
| Command | Description |
|---------|-------------|
| `multica` | Interactive chat mode (default) |
| `multica run "<prompt>"` | Non-interactive single prompt |
| `multica chat` | Explicit interactive mode |
| `multica session list/show/delete` | Session management |
| `multica profile list/new/setup/show/edit/delete` | Profile management |
| `multica skills list/status/install/add/remove` | Skill management |
| `multica tools list/groups/profiles` | Tool inspection |
| `multica credentials init/show/edit` | Credentials management |
| `multica dev [service]` | Development servers |
#### 4.4.2 Interactive Mode Commands
| Command | Description |
|---------|-------------|
| `/help` | Show help |
| `/exit` `/quit` `/q` | Exit |
| `/clear` | Clear session |
| `/session` | Show current session ID |
| `/new` | Start new session |
| `/multiline` | Toggle multi-line input mode |
| `/provider` | Show provider status |
| `/model [name]` | Switch model |
| `/{skillName} [args]` | Execute skill |
**Features**: Autocomplete (Shift+Tab), status bar (session/provider/model), multi-line mode (end with `.`).
#### 4.4.3 Development Servers
| Service | Command | Port |
|---------|---------|------|
| Desktop (default) | `multica dev` | Electron window |
| Gateway | `multica dev gateway` | 3000 |
| Web | `multica dev web` | 3001 |
| All | `multica dev all` | 3000 + 3001 |
---
## 5. UI Component Library
Shared package: `@multica/ui`. Used by Desktop, Web, and Mobile.
### 5.1 Chat Components
| Component | Props | Description |
|-----------|-------|-------------|
| `Chat` | (none, uses stores) | Full chat view: connect prompt + message list + input |
| `ChatInput` | `onSubmit`, `disabled`, `placeholder` | Tiptap editor. Enter=send, Shift+Enter=newline, IME-safe |
| `ChatInputRef` | (imperative) | `getText()`, `setText()`, `focus()`, `clear()` |
| `MessageList` | `messages`, `streamingIds` | Renders messages with markdown, tool calls, streaming |
| `ConnectPrompt` | (none, uses stores) | QR scan + paste code UI for remote connection |
| `ChatSkeleton` | (none) | Loading skeleton |
| `ToolCallItem` | `message` | Tool execution display: status dot, label, subtitle, expandable results |
### 5.2 Markdown Components
| Component | Props | Description |
|-----------|-------|-------------|
| `Markdown` | `children`, `mode` (`minimal`/`full`) | Rendered markdown with syntax highlighting |
| `StreamingMarkdown` | `content`, `isStreaming`, `mode` | Incremental markdown with animated cursor |
| `CodeBlock` | (internal) | Syntax-highlighted code block with copy button |
### 5.3 Base UI Components (Shadcn/UI)
button, input, textarea, card, dialog, alert-dialog, dropdown-menu, select, combobox, badge, label, field, input-group, switch, skeleton, separator, sheet, sidebar, tooltip, sonner (toasts)
### 5.4 Utility Components
| Component | Description |
|-----------|-------------|
| `QRScannerView` | Camera-based QR scanner |
| `QRScannerSheet` | Sheet variant of QR scanner |
| `Spinner` | Animated loading spinner |
| `ThemeProvider` | Light/dark theme context |
| `ThemeToggle` | Theme switch button |
---
## 6. Data Persistence Locations
| Data | Location | Format | Lifetime |
|------|----------|--------|----------|
| Credentials | `~/.super-multica/credentials.json5` | JSON5 | User-managed |
| Skills env | `~/.super-multica/skills.env.json5` | JSON5 | User-managed |
| Agent profiles | `~/.super-multica/agent-profiles/{id}/` | MD + JSON | User-managed |
| Agent memory | `~/.super-multica/agent-profiles/{id}/memory/` | JSON per key | Agent-managed |
| Sessions | `~/.super-multica/sessions/{id}/session.jsonl` | JSONL | Until deleted |
| Agent records | `~/.super-multica/agents/agents.json` | JSON | Persistent |
| Hub ID | `~/.super-multica/hub-id` | Plain text UUID | Generated once |
| Device whitelist | `~/.super-multica/client-devices/whitelist.json` | JSON | Until revoked |
| Auth profile stats | `~/.super-multica/.auth-profiles/usage-stats.json` | JSON | Runtime tracking |
| Verification tokens | In-memory | Map | Lost on restart |
| Browser device ID | localStorage: `multica-device` | UUID string | Until cleared |
| Saved connection | localStorage: `multica-connection` | JSON | Until disconnected |
---
## 7. Current Limitations
| Area | Limitation | Notes |
|------|-----------|-------|
| Agent count | Desktop creates 1 primary agent on startup | Hub API supports multi-agent (`createAgent`/`listAgents`), but UI only shows one |
| Device permissions | All-or-nothing access | No per-device capability restrictions |
| Role system | No formal RBAC | Owner is implicit admin |
| Mobile app | Demo/prototype | Hardcoded mock data, no real agent connection |
| Offline web | PWA shell only | Cannot function without Gateway |
| Skill marketplace | No registry | Install via GitHub URL only |
| Real-time collaboration | Single agent, sequential messages | No concurrent message processing |
| File upload | Not supported | Agent can only read files on Owner's filesystem |
---
*Document generated: 2026-02-05*
*Source: codebase analysis at commit fc6c3e3 on branch feat/mobile-pwa-optimization*

View file

@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
"sideEffects": ["**/*.css"],
"exports": {
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs",
@ -18,6 +19,10 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@multica/store": "workspace:*",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"linkify-it": "^5.0.0",

View file

@ -0,0 +1,18 @@
.chat-input-editor .ProseMirror {
outline: none;
min-height: 2.5rem;
max-height: 200px;
overflow-y: auto;
}
.chat-input-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
pointer-events: none;
height: 0;
color: var(--muted-foreground);
}
.chat-input-editor.is-disabled .ProseMirror {
cursor: not-allowed;
}

View file

@ -1,9 +1,20 @@
"use client";
import { useRef } from "react";
import { useRef, useEffect, useImperativeHandle, forwardRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Button } from "@multica/ui/components/ui/button";
import { ArrowUpIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@multica/ui/lib/utils";
import "./chat-input.css";
export interface ChatInputRef {
getText: () => string;
setText: (text: string) => void;
focus: () => void;
clear: () => void;
}
interface ChatInputProps {
onSubmit?: (value: string) => void;
@ -11,45 +22,105 @@ interface ChatInputProps {
placeholder?: string;
}
export function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }, ref) {
// Use ref to avoid stale closure in Tiptap keydown handler
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
const handleSubmit = () => {
const value = textareaRef.current?.value ?? "";
if (!value.trim()) return;
onSubmit?.(value);
textareaRef.current!.value = "";
// reset height
textareaRef.current!.style.height = "auto";
};
const editor = useEditor({
extensions: [
StarterKit.configure({
// Disable all rich-text features — plain text only
heading: false,
bold: false,
italic: false,
strike: false,
code: false,
codeBlock: false,
blockquote: false,
bulletList: false,
orderedList: false,
listItem: false,
horizontalRule: false,
}),
Placeholder.configure({ placeholder }),
],
immediatelyRender: false,
editorProps: {
attributes: {
class:
"w-full resize-none bg-transparent px-1 py-1 text-base text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
},
handleKeyDown(_view, event) {
// Guard for IME composition (Chinese/Japanese input)
if (event.isComposing) return false;
return (
<div className={cn(
"bg-card rounded-xl p-3 border border-border transition-colors",
disabled && "cursor-not-allowed opacity-60"
)}>
<textarea
ref={textareaRef}
rows={2}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => {
e.target.style.height = "auto";
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSubmit();
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
const text = _view.state.doc.textContent;
if (!text.trim()) return true;
onSubmitRef.current?.(text);
// Clear editor after submit
_view.dispatch(
_view.state.tr
.delete(0, _view.state.doc.content.size)
.setMeta("addToHistory", false),
);
return true;
}
}}
className="w-full resize-none bg-transparent px-1 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
<div className="flex items-center justify-end pt-2">
<Button size="icon" onClick={handleSubmit} disabled={disabled}>
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUpIcon} />
</Button>
return false;
},
},
});
// Sync disabled state
useEffect(() => {
if (!editor) return;
editor.setEditable(!disabled);
}, [editor, disabled]);
// Sync placeholder
useEffect(() => {
if (!editor) return;
editor.extensionManager.extensions.find(
(ext) => ext.name === "placeholder",
)!.options.placeholder = placeholder;
// Force view update so placeholder re-renders
editor.view.dispatch(editor.state.tr);
}, [editor, placeholder]);
// Expose imperative API
useImperativeHandle(ref, () => ({
getText: () => editor?.state.doc.textContent ?? "",
setText: (text: string) => {
editor?.commands.setContent(text ? `<p>${text}</p>` : "");
},
focus: () => editor?.commands.focus(),
clear: () => editor?.commands.clearContent(),
}), [editor]);
const handleSubmit = () => {
if (!editor) return;
const text = editor.state.doc.textContent;
if (!text.trim()) return;
onSubmit?.(text);
editor.commands.clearContent();
};
return (
<div className={cn(
"chat-input-editor bg-card rounded-xl p-3 border border-border transition-colors",
disabled && "is-disabled cursor-not-allowed opacity-60",
)}>
<EditorContent editor={editor} />
<div className="flex items-center justify-end pt-2">
<Button size="icon" onClick={handleSubmit} disabled={disabled}>
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUpIcon} />
</Button>
</div>
</div>
</div>
);
}
);
},
);

View file

@ -59,7 +59,7 @@ export function Chat() {
<ConnectPrompt />
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Send a message to start the conversation
Your Agent is ready
</div>
) : (
<MessageList messages={messages} streamingIds={streamingIds} />
@ -88,7 +88,7 @@ export function Chat() {
<ChatInput
onSubmit={handleSend}
disabled={!isConnected}
placeholder={!isConnected ? "Connect first..." : "Type a message..."}
placeholder={!isConnected ? "Scan QR code to get started" : "Ask your Agent..."}
/>
</footer>
</div>

View file

@ -1,163 +1,187 @@
"use client";
import { useState, useEffect, useCallback, lazy, Suspense, useRef } from "react";
import { useState, useCallback, useRef } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { toast } from "@multica/ui/components/ui/sonner";
import {
useConnectionStore,
parseConnectionCode,
saveConnection,
} from "@multica/store";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { HugeiconsIcon } from "@hugeicons/react";
import { Camera01Icon, TextIcon } from "@hugeicons/core-free-icons";
const LazyQrScannerView = lazy(() =>
import("@multica/ui/components/qr-scanner-view").then((m) => ({
default: m.QrScannerView,
})),
);
import {
Camera01Icon,
TextIcon,
CheckmarkCircle02Icon,
Alert02Icon,
} from "@hugeicons/core-free-icons";
import { QrScannerView } from "@multica/ui/components/qr-scanner-view";
type Mode = "scan" | "paste";
type PasteState = "idle" | "success" | "error";
export function ConnectPrompt() {
const gwState = useConnectionStore((s) => s.connectionState);
const [mode, setMode] = useState<Mode>("scan");
const [codeInput, setCodeInput] = useState("");
const [mode, setMode] = useState<Mode>("paste"); // SSR-safe default
const [canScan, setCanScan] = useState(false);
const scannedRef = useRef(false);
const [pasteState, setPasteState] = useState<PasteState>("idle");
const [pasteError, setPasteError] = useState<string | null>(null);
const isMobile = useIsMobile();
const validatingRef = useRef(false);
// Detect mobile + camera capability, auto-switch to scan mode
useEffect(() => {
const isTouchDevice =
"ontouchstart" in window || navigator.maxTouchPoints > 0;
const isNarrow = window.innerWidth < 768;
const hasGetUserMedia = !!navigator.mediaDevices?.getUserMedia;
if (hasGetUserMedia) {
setCanScan(true);
if (isTouchDevice && isNarrow) {
setMode("scan");
}
}
}, []);
// Handle paste-mode connect
const handleConnect = useCallback(() => {
const trimmed = codeInput.trim();
if (!trimmed) return;
const tryConnect = useCallback((raw: string) => {
const trimmed = raw.trim();
if (!trimmed || validatingRef.current) return;
validatingRef.current = true;
try {
const info = parseConnectionCode(trimmed);
saveConnection(info);
useConnectionStore.getState().connect(info);
setCodeInput("");
setPasteState("success");
navigator.vibrate?.(50);
// Let the user see the success state before connecting
setTimeout(() => {
saveConnection(info);
useConnectionStore.getState().connect(info);
}, 600);
} catch (e) {
toast.error((e as Error).message);
}
}, [codeInput]);
// Handle QR scan result — auto-connect, no button needed
const handleQrScan = useCallback((data: string) => {
// Prevent duplicate connects from rapid successive scans
if (scannedRef.current) return;
scannedRef.current = true;
try {
const info = parseConnectionCode(data);
saveConnection(info);
useConnectionStore.getState().connect(info);
} catch (e) {
toast.error((e as Error).message);
// Allow re-scan on error (invalid/expired code)
scannedRef.current = false;
setPasteState("error");
setPasteError((e as Error).message || "Invalid code");
navigator.vibrate?.([30, 50, 30]);
setTimeout(() => {
setPasteState("idle");
setPasteError(null);
setCodeInput("");
}, 2000);
} finally {
validatingRef.current = false;
}
}, []);
const handleScanError = useCallback((msg: string) => {
toast.error(msg);
setMode("paste");
// Auto-validate on paste
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
const text = e.clipboardData.getData("text");
if (!text.trim()) return;
// Let the textarea update visually first, then validate
setTimeout(() => tryConnect(text), 50);
},
[tryConnect],
);
// Promise-based handler for QrScannerView
const handleScanResult = useCallback(async (data: string) => {
const info = parseConnectionCode(data);
saveConnection(info);
useConnectionStore.getState().connect(info);
}, []);
const isConnecting = gwState === "connecting" || gwState === "connected";
// Mobile: scanner only, no tabs, no paste
if (isMobile) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
<div className="text-center space-y-1">
<p className="text-base font-medium">Scan to start</p>
<p className="text-xs text-muted-foreground">
Scan a Multica QR code to start chatting
</p>
{isConnecting && (
<p className="text-sm text-foreground/70 animate-pulse">
Connecting to Agent...
</p>
)}
</div>
<QrScannerView onResult={handleScanResult} fullscreen />
</div>
);
}
// Desktop: tab toggle (scan / paste), same-size panels
return (
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
<div className="text-center space-y-1">
<p className="text-sm text-muted-foreground">
<p className="text-base font-medium">
{mode === "scan" ? "Scan to start" : "Paste to start"}
</p>
<p className="text-xs text-muted-foreground">
{mode === "scan"
? "Scan QR code to connect"
: "Paste a connection code to start"}
? "Scan a Multica QR code to start chatting"
: "Paste a Multica connection code to start chatting"}
</p>
{isConnecting && (
<p className="text-xs text-muted-foreground/60 animate-pulse">
Connecting...
<p className="text-sm text-foreground/70 animate-pulse">
Connecting to Agent...
</p>
)}
</div>
{/* Mode toggle — only show if camera is available */}
{canScan && (
<div className="flex gap-1 bg-muted rounded-lg p-1">
<Button
variant={mode === "scan" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => {
scannedRef.current = false;
setMode("scan");
}}
>
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
Scan
</Button>
<Button
variant={mode === "paste" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("paste")}
>
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
Paste
</Button>
</div>
)}
{/* Mode toggle */}
<div className="flex gap-1 bg-muted rounded-lg p-1">
<Button
variant={mode === "scan" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("scan")}
>
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
Scan
</Button>
<Button
variant={mode === "paste" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("paste")}
>
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
Paste
</Button>
</div>
{/* Content */}
<div className="w-full max-w-sm space-y-3">
{/* Content — same max-width for both modes */}
<div className="w-full max-w-[320px]">
{mode === "scan" ? (
<Suspense
fallback={
<div className="h-[280px] animate-pulse bg-muted rounded-xl" />
}
>
<LazyQrScannerView
onScan={handleQrScan}
onError={handleScanError}
/>
</Suspense>
<QrScannerView onResult={handleScanResult} />
) : (
<>
<Textarea
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
placeholder="Paste connection code here..."
className="text-xs font-mono min-h-[100px] resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleConnect();
}
}}
/>
<Button
size="sm"
onClick={handleConnect}
disabled={!codeInput.trim() || gwState === "connecting"}
className="w-full text-xs"
>
Connect
</Button>
</>
<div className="aspect-square rounded-xl bg-muted flex flex-col items-center justify-center p-4">
{pasteState === "idle" && (
<Textarea
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
onPaste={handlePaste}
autoFocus={true}
placeholder="Paste connection code here..."
className="text-xs font-mono flex-1 resize-none bg-transparent! border-0 focus-visible:ring-0 shadow-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
tryConnect(codeInput);
}
}}
/>
)}
{pasteState === "success" && (
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
className="size-14 text-(--tool-success) animate-in zoom-in duration-300"
/>
)}
{pasteState === "error" && (
<div className="flex flex-col items-center justify-center gap-2">
<HugeiconsIcon
icon={Alert02Icon}
className="size-12 text-(--tool-error)"
/>
{pasteError && (
<p className="text-xs text-destructive bg-destructive/10 px-3 py-1.5 rounded-full">
{pasteError}
</p>
)}
</div>
)}
</div>
)}
</div>
</div>

View file

@ -0,0 +1,40 @@
"use client"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@multica/ui/components/ui/sheet"
import { QrScannerView } from "@multica/ui/components/qr-scanner-view"
export interface QrScannerSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
onResult: (data: string) => Promise<void>
}
export function QrScannerSheet({
open,
onOpenChange,
onResult,
}: QrScannerSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="px-4 pb-8">
{/* Drag handle */}
<div className="mx-auto mt-2 mb-1 h-1 w-10 rounded-full bg-muted-foreground/30" />
<SheetHeader>
<SheetTitle className="text-center">Scan Connection Code</SheetTitle>
</SheetHeader>
<div className="mt-4">
<QrScannerView
open={open}
onResult={onResult}
onClose={() => onOpenChange(false)}
/>
</div>
</SheetContent>
</Sheet>
)
}

View file

@ -1,49 +1,190 @@
"use client"
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
import "./qr-scanner.css"
interface QrScannerViewProps {
onScan: (data: string) => void
onError?: (error: string) => void
import { useState, useCallback, useRef, useEffect } from "react"
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
import { Spinner } from "@multica/ui/components/spinner"
import { HugeiconsIcon } from "@hugeicons/react"
import {
Camera01Icon,
Cancel01Icon,
CheckmarkCircle02Icon,
Alert02Icon,
FlashlightIcon,
} from "@hugeicons/core-free-icons"
type ScannerState =
| "idle"
| "requesting"
| "scanning"
| "detected"
| "success"
| "error"
export interface QrScannerProps {
onResult: (data: string) => Promise<void>
onClose?: () => void
open?: boolean
/** When true, scanning state renders as a fullscreen overlay (mobile). */
fullscreen?: boolean
}
/**
* Camera viewfinder for QR code scanning.
*
* Renders a live camera feed with a decorative scan frame overlay.
* Uses getUserMedia via the qr-scanner library (WebWorker-based decoding).
* iOS requires playsinline + muted + autoplay on the <video> element.
*/
export function QrScannerView({ onScan, onError }: QrScannerViewProps) {
const { videoRef, isScanning, error, hasCamera } = useQrScanner({
onScan,
onError,
enabled: true,
const ACTIVE_STATES: ScannerState[] = [
"scanning",
"detected",
"success",
"error",
]
export function QrScannerView({
onResult,
onClose,
open,
fullscreen = false,
}: QrScannerProps) {
const [state, setState] = useState<ScannerState>("idle")
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const stateRef = useRef(state)
stateRef.current = state
const startRef = useRef<(() => Promise<void>) | null>(null)
const handleScan = useCallback(
(data: string) => {
if (stateRef.current !== "scanning") return
setState("detected")
navigator.vibrate?.(50)
setTimeout(async () => {
try {
await onResult(data)
setState("success")
navigator.vibrate?.(50)
} catch (e) {
setErrorMessage((e as Error).message || "Invalid code")
setState("error")
navigator.vibrate?.([30, 50, 30])
setTimeout(() => {
setErrorMessage(null)
setState("scanning")
startRef.current?.()
}, 3000)
}
}, 200)
},
[onResult],
)
const {
videoRef,
hasCamera,
hasFlash,
toggleFlash,
start: scannerStart,
stop: scannerStop,
pause: scannerPause,
} = useQrScanner({
onScan: handleScan,
enabled: false,
})
startRef.current = scannerStart
useEffect(() => {
if (state === "detected" || state === "success") {
scannerPause()
}
}, [state, scannerPause])
useEffect(() => {
if (open === false) {
scannerStop()
setState("idle")
setErrorMessage(null)
}
}, [open, scannerStop])
useEffect(() => {
return () => scannerStop()
}, [scannerStop])
// Double-rAF: wait for video element to mount before starting scanner
useEffect(() => {
if (state !== "requesting") return
const raf = requestAnimationFrame(() => {
requestAnimationFrame(async () => {
try {
await scannerStart()
setState("scanning")
} catch {
setState("idle")
}
})
})
return () => cancelAnimationFrame(raf)
}, [state, scannerStart])
const handleStart = useCallback(async () => {
try {
const perm = await navigator.permissions?.query({
name: "camera" as PermissionName,
})
if (perm?.state === "denied") {
setErrorMessage(
"Camera access denied. Please enable it in your browser settings.",
)
onClose?.()
return
}
} catch {
// Safari doesn't support camera permission query
}
setState("requesting")
}, [onClose])
const handleClose = useCallback(() => {
scannerStop()
setState("idle")
setErrorMessage(null)
}, [scannerStop])
if (!hasCamera) {
return (
<div className="flex items-center justify-center h-[280px] rounded-xl bg-muted">
<div className="flex items-center justify-center h-[320px] rounded-xl bg-muted">
<p className="text-sm text-muted-foreground">No camera available</p>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-[280px] rounded-xl bg-muted gap-2">
<p className="text-sm text-muted-foreground">Camera access denied</p>
<p className="text-xs text-muted-foreground/60">
Switch to paste mode below
</p>
</div>
)
}
const isActive = ACTIVE_STATES.includes(state)
return (
<div className="relative w-full max-w-[280px] mx-auto">
{/* Camera feed */}
<div className="relative aspect-square rounded-xl overflow-hidden bg-black">
const bracketColor =
state === "success"
? "border-[color:var(--tool-success)]"
: state === "error"
? "border-[color:var(--tool-error)]"
: state === "detected"
? "border-primary"
: "border-white/30"
const bracketAnimation =
state === "scanning"
? "animate-scan-breathe"
: state === "error"
? "animate-scan-shake"
: ""
const viewfinder = (
<div
className={
fullscreen && isActive
? "relative w-full h-full"
: "relative aspect-square rounded-xl overflow-hidden bg-muted"
}
>
{/* Video — only mounted after idle */}
{state !== "idle" && (
<video
ref={videoRef}
autoPlay
@ -51,32 +192,140 @@ export function QrScannerView({ onScan, onError }: QrScannerViewProps) {
muted
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{/* Scan frame overlay */}
{/* Idle */}
{state === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
<button
type="button"
onClick={handleStart}
className="flex items-center justify-center size-16 rounded-full bg-foreground/10 hover:bg-foreground/20 transition-colors"
>
<HugeiconsIcon
icon={Camera01Icon}
className="size-7 text-muted-foreground"
/>
</button>
<p className="text-xs text-muted-foreground">Tap to open camera</p>
</div>
)}
{/* Requesting */}
{state === "requesting" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
<Spinner className="text-muted-foreground" />
<p className="text-xs text-muted-foreground">
Requesting camera...
</p>
</div>
)}
{/* Fixed centered brackets — always same position, color changes per state */}
{isActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="relative w-3/4 h-3/4">
{/* Corner accents */}
<div className="absolute -top-1 -left-1 w-5 h-5 border-t-2 border-l-2 border-white/70 rounded-tl-md" />
<div className="absolute -top-1 -right-1 w-5 h-5 border-t-2 border-r-2 border-white/70 rounded-tr-md" />
<div className="absolute -bottom-1 -left-1 w-5 h-5 border-b-2 border-l-2 border-white/70 rounded-bl-md" />
<div className="absolute -bottom-1 -right-1 w-5 h-5 border-b-2 border-r-2 border-white/70 rounded-br-md" />
<div
className={`relative w-3/4 h-3/4 max-w-[280px] max-h-[280px] ${bracketAnimation}`}
>
<div
className={`absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 ${bracketColor} rounded-tl-md transition-colors duration-200`}
/>
<div
className={`absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 ${bracketColor} rounded-tr-md transition-colors duration-200`}
/>
<div
className={`absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 ${bracketColor} rounded-bl-md transition-colors duration-200`}
/>
<div
className={`absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 ${bracketColor} rounded-br-md transition-colors duration-200`}
/>
</div>
</div>
)}
{/* Loading state */}
{!isScanning && !error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
<p className="text-xs text-white/80 animate-pulse">
Starting camera...
{/* Close button */}
{(state === "scanning" || state === "detected") && (
<button
type="button"
onClick={handleClose}
className="absolute top-3 left-3 flex items-center justify-center size-8 rounded-full bg-black/40 hover:bg-black/60 transition-colors z-10"
>
<HugeiconsIcon
icon={Cancel01Icon}
className="size-4 text-white"
strokeWidth={2}
/>
</button>
)}
{/* Flash toggle */}
{state === "scanning" && hasFlash && (
<button
type="button"
onClick={toggleFlash}
className="absolute top-3 right-3 flex items-center justify-center size-8 rounded-full bg-black/40 hover:bg-black/60 transition-colors"
>
<HugeiconsIcon
icon={FlashlightIcon}
className="size-4 text-white"
/>
</button>
)}
{/* Success — full overlay */}
{state === "success" && (
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--tool-success)]/15 animate-in fade-in duration-200">
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
className="size-14 text-[color:var(--tool-success)] animate-in zoom-in duration-300"
/>
</div>
)}
{/* Error — full overlay */}
{state === "error" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-[color:var(--tool-error)]/15 animate-in fade-in duration-200">
<HugeiconsIcon
icon={Alert02Icon}
className="size-12 text-[color:var(--tool-error)]"
/>
{errorMessage && (
<p className="text-xs text-white bg-black/60 px-3 py-1.5 rounded-full">
{errorMessage}
</p>
</div>
)}
</div>
)}
</div>
)}
{/* Hint text */}
<p className="text-xs text-muted-foreground text-center mt-3">
Point camera at QR code on desktop
</p>
{/* Fullscreen hint */}
{state === "scanning" && fullscreen && (
<p className="absolute bottom-8 inset-x-0 text-xs text-white/60 text-center">
Align QR code within the frame
</p>
)}
</div>
)
if (fullscreen && isActive) {
return (
<>
<div className="relative w-full max-w-[320px] mx-auto">
<div className="aspect-square rounded-xl bg-muted" />
</div>
<div className="fixed inset-0 z-50 bg-black">{viewfinder}</div>
</>
)
}
return (
<div className="relative w-full max-w-[320px] mx-auto">
{viewfinder}
{state === "scanning" && !fullscreen && (
<p className="text-xs text-muted-foreground text-center mt-3">
Point at a Multica QR code
</p>
)}
</div>
)
}

View file

@ -0,0 +1,29 @@
/* Scanner corner bracket breathing pulse */
@keyframes scan-breathe {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
/* Error shake */
@keyframes scan-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(2px); }
}
@utility animate-scan-breathe {
animation: scan-breathe 2s ease-in-out infinite;
}
@utility animate-scan-shake {
animation: scan-shake 0.4s ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
.animate-scan-breathe,
.animate-scan-shake {
animation: none;
}
}

View file

@ -3,6 +3,11 @@
import { useRef, useState, useEffect, useCallback } from "react"
import type QrScannerLib from "qr-scanner"
export interface Point {
x: number
y: number
}
export interface UseQrScannerOptions {
onScan: (data: string) => void
onError?: (error: string) => void
@ -14,25 +19,34 @@ export interface UseQrScannerResult {
isScanning: boolean
error: string | null
hasCamera: boolean
cornerPoints: Point[] | null
hasFlash: boolean
toggleFlash: () => Promise<void>
start: () => Promise<void>
stop: () => void
pause: () => void
}
/**
* Hook wrapping qr-scanner lifecycle.
*
* - Dynamically imports qr-scanner (keeps it out of SSR bundles)
* - Creates/destroys scanner instance based on `enabled`
* - Creates/destroys scanner instance based on `enabled` or manual start/stop
* - Exposes cornerPoints from scan results, flash control, and lifecycle methods
* - Releases camera stream on cleanup
*/
export function useQrScanner({
onScan,
onError,
enabled = true,
enabled = false,
}: UseQrScannerOptions): UseQrScannerResult {
const videoRef = useRef<HTMLVideoElement | null>(null)
const scannerRef = useRef<QrScannerLib | null>(null)
const [isScanning, setIsScanning] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasCamera, setHasCamera] = useState(true)
const [cornerPoints, setCornerPoints] = useState<Point[] | null>(null)
const [hasFlash, setHasFlash] = useState(false)
// Stable callback refs to avoid re-creating scanner on every render
const onScanRef = useRef(onScan)
@ -52,67 +66,128 @@ export function useQrScanner({
return () => { cancelled = true }
}, [])
// Start/stop scanner based on `enabled` and video element
const createScanner = useCallback(async () => {
if (!videoRef.current || !hasCamera) return
const mod = await import("qr-scanner")
const QrScanner = mod.default
// Destroy previous instance if any
if (scannerRef.current) {
scannerRef.current.stop()
scannerRef.current.destroy()
scannerRef.current = null
}
const scanner = new QrScanner(
videoRef.current,
(result) => {
const points = result.cornerPoints as Point[] | undefined
setCornerPoints(points?.length ? points : null)
onScanRef.current(result.data)
},
{
preferredCamera: "environment",
maxScansPerSecond: 5,
returnDetailedScanResult: true,
highlightScanRegion: false,
highlightCodeOutline: false,
onDecodeError: (err) => {
// "No QR code found" fires every frame — ignore it
if (typeof err === "string" && err.includes("No QR code found")) return
console.warn("[QrScanner] decode error:", err)
},
},
)
scannerRef.current = scanner
return scanner
}, [hasCamera])
const start = useCallback(async () => {
setError(null)
setCornerPoints(null)
try {
let scanner = scannerRef.current
if (!scanner) {
scanner = (await createScanner()) ?? null
if (!scanner) return
}
await scanner.start()
setIsScanning(true)
// Check flash availability after camera starts
try {
const flash = await scanner.hasFlash()
setHasFlash(flash)
} catch {
setHasFlash(false)
}
} catch (err) {
const msg = (err as Error).message || "Camera access failed"
setError(msg)
setIsScanning(false)
onErrorRef.current?.(msg)
}
}, [createScanner])
const stop = useCallback(() => {
if (scannerRef.current) {
scannerRef.current.stop()
scannerRef.current.destroy()
scannerRef.current = null
}
setIsScanning(false)
setCornerPoints(null)
setHasFlash(false)
}, [])
const pause = useCallback(() => {
scannerRef.current?.pause()
setIsScanning(false)
}, [])
const toggleFlash = useCallback(async () => {
if (!scannerRef.current) return
try {
await scannerRef.current.toggleFlash()
} catch {
// Flash not supported or other error — silently ignore
}
}, [])
// Auto-start/stop based on `enabled` prop (backwards compatible)
useEffect(() => {
if (!enabled || !videoRef.current || !hasCamera) return
let destroyed = false
const video = videoRef.current
import("qr-scanner").then((mod) => {
if (destroyed) return
const QrScanner = mod.default
const scanner = new QrScanner(
video,
(result) => {
console.log("[QrScanner] scanned:", result.data)
onScanRef.current(result.data)
},
{
preferredCamera: "environment",
maxScansPerSecond: 5,
returnDetailedScanResult: true,
highlightScanRegion: false,
highlightCodeOutline: false,
onDecodeError: (err) => {
// "No QR code found" fires every frame — ignore it
if (typeof err === "string" && err.includes("No QR code found")) return
console.warn("[QrScanner] decode error:", err)
},
},
)
scannerRef.current = scanner
scanner
.start()
.then(() => {
if (!destroyed) {
console.log("[QrScanner] started successfully")
setIsScanning(true)
setError(null)
}
})
.catch((err: Error) => {
if (destroyed) return
const msg = err.message || "Camera access failed"
setError(msg)
setIsScanning(false)
onErrorRef.current?.(msg)
})
})
if (enabled) {
start()
} else if (!enabled && scannerRef.current) {
stop()
}
}, [enabled, start, stop])
// Cleanup on unmount
useEffect(() => {
return () => {
destroyed = true
if (scannerRef.current) {
scannerRef.current.stop()
scannerRef.current.destroy()
scannerRef.current = null
}
setIsScanning(false)
}
}, [enabled, hasCamera])
}, [])
return { videoRef, isScanning, error, hasCamera }
return {
videoRef,
isScanning,
error,
hasCamera,
cornerPoints,
hasFlash,
toggleFlash,
start,
stop,
pause,
}
}

590
pnpm-lock.yaml generated
View file

@ -476,6 +476,18 @@ importers:
'@multica/store':
specifier: workspace:*
version: link:../store
'@tiptap/extension-placeholder':
specifier: ^3.19.0
version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
'@tiptap/pm':
specifier: ^3.19.0
version: 3.19.0
'@tiptap/react':
specifier: ^3.19.0
version: 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tiptap/starter-kit':
specifier: ^3.19.0
version: 3.19.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -2720,6 +2732,9 @@ packages:
'@react-navigation/routers@7.5.3':
resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==}
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rn-primitives/portal@1.3.0':
resolution: {integrity: sha512-a2DSce7TcSfcs0cCngLadAJOvx/+mdH9NRu+GxkX8NPRsGGhJvDEOqouMgDqLwx7z9mjXoUaZcwaVcemUSW9/A==}
peerDependencies:
@ -3220,6 +3235,160 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tiptap/core@3.19.0':
resolution: {integrity: sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==}
peerDependencies:
'@tiptap/pm': ^3.19.0
'@tiptap/extension-blockquote@3.19.0':
resolution: {integrity: sha512-y3UfqY9KD5XwWz3ndiiJ089Ij2QKeiXy/g1/tlAN/F1AaWsnkHEHMLxCP1BIqmMpwsX7rZjMLN7G5Lp7c9682A==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-bold@3.19.0':
resolution: {integrity: sha512-UZgb1d0XK4J/JRIZ7jW+s4S6KjuEDT2z1PPM6ugcgofgJkWQvRZelCPbmtSFd3kwsD+zr9UPVgTh9YIuGQ8t+Q==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-bubble-menu@3.19.0':
resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
'@tiptap/extension-bullet-list@3.19.0':
resolution: {integrity: sha512-F9uNnqd0xkJbMmRxVI5RuVxwB9JaCH/xtRqOUNQZnRBt7IdAElCY+Dvb4hMCtiNv+enGM/RFGJuFHR9TxmI7rw==}
peerDependencies:
'@tiptap/extension-list': ^3.19.0
'@tiptap/extension-code-block@3.19.0':
resolution: {integrity: sha512-b/2qR+tMn8MQb+eaFYgVk4qXnLNkkRYmwELQ8LEtEDQPxa5Vl7J3eu8+4OyoIFhZrNDZvvoEp80kHMCP8sI6rg==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
'@tiptap/extension-code@3.19.0':
resolution: {integrity: sha512-2kqqQIXBXj2Or+4qeY3WoE7msK+XaHKL6EKOcKlOP2BW8eYqNTPzNSL+PfBDQ3snA7ljZQkTs/j4GYDj90vR1A==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-document@3.19.0':
resolution: {integrity: sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-dropcursor@3.19.0':
resolution: {integrity: sha512-sf3dEZXiLvsGqVK2maUIzXY6qtYYCvBumag7+VPTMGQ0D4hiZ1X/4ukt4+6VXDg5R2WP1CoIt/QvUetUjWNhbQ==}
peerDependencies:
'@tiptap/extensions': ^3.19.0
'@tiptap/extension-floating-menu@3.19.0':
resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
'@tiptap/extension-gapcursor@3.19.0':
resolution: {integrity: sha512-w7DACS4oSZaDWjz7gropZHPc9oXqC9yERZTcjWxyORuuIh1JFf0TRYspleK+OK28plK/IftojD/yUDn1MTRhvA==}
peerDependencies:
'@tiptap/extensions': ^3.19.0
'@tiptap/extension-hard-break@3.19.0':
resolution: {integrity: sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-heading@3.19.0':
resolution: {integrity: sha512-uLpLlfyp086WYNOc0ekm1gIZNlEDfmzOhKzB0Hbyi6jDagTS+p9mxUNYeYOn9jPUxpFov43+Wm/4E24oY6B+TQ==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-horizontal-rule@3.19.0':
resolution: {integrity: sha512-iqUHmgMGhMgYGwG6L/4JdelVQ5Mstb4qHcgTGd/4dkcUOepILvhdxajPle7OEdf9sRgjQO6uoAU5BVZVC26+ng==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
'@tiptap/extension-italic@3.19.0':
resolution: {integrity: sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-link@3.19.0':
resolution: {integrity: sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
'@tiptap/extension-list-item@3.19.0':
resolution: {integrity: sha512-VsSKuJz4/Tb6ZmFkXqWpDYkRzmaLTyE6dNSEpNmUpmZ32sMqo58mt11/huADNwfBFB0Ve7siH/VnFNIJYY3xvg==}
peerDependencies:
'@tiptap/extension-list': ^3.19.0
'@tiptap/extension-list-keymap@3.19.0':
resolution: {integrity: sha512-bxgmAgA3RzBGA0GyTwS2CC1c+QjkJJq9hC+S6PSOWELGRiTbwDN3MANksFXLjntkTa0N5fOnL27vBHtMStURqw==}
peerDependencies:
'@tiptap/extension-list': ^3.19.0
'@tiptap/extension-list@3.19.0':
resolution: {integrity: sha512-N6nKbFB2VwMsPlCw67RlAtYSK48TAsAUgjnD+vd3ieSlIufdQnLXDFUP6hFKx9mwoUVUgZGz02RA6bkxOdYyTw==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
'@tiptap/extension-ordered-list@3.19.0':
resolution: {integrity: sha512-cxGsINquwHYE1kmhAcLNLHAofmoDEG6jbesR5ybl7tU5JwtKVO7S/xZatll2DU1dsDAXWPWEeeMl4e/9svYjCg==}
peerDependencies:
'@tiptap/extension-list': ^3.19.0
'@tiptap/extension-paragraph@3.19.0':
resolution: {integrity: sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-placeholder@3.19.0':
resolution: {integrity: sha512-i15OfgyI4IDCYAcYSKUMnuZkYuUInfanjf9zquH8J2BETiomf/jZldVCp/QycMJ8DOXZ38fXDc99wOygnSNySg==}
peerDependencies:
'@tiptap/extensions': ^3.19.0
'@tiptap/extension-strike@3.19.0':
resolution: {integrity: sha512-xYpabHsv7PccLUBQaP8AYiFCnYbx6P93RHPd0lgNwhdOjYFd931Zy38RyoxPHAgbYVmhf1iyx7lpuLtBnhS5dA==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-text@3.19.0':
resolution: {integrity: sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extension-underline@3.19.0':
resolution: {integrity: sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/extensions@3.19.0':
resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
'@tiptap/pm@3.19.0':
resolution: {integrity: sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==}
'@tiptap/react@3.19.0':
resolution: {integrity: sha512-GQQMUUXMpNd8tRjc1jDK3tDRXFugJO7C928EqmeBcBzTKDrFIJ3QUoZKEPxUNb6HWhZ2WL7q00fiMzsv4DNSmg==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/starter-kit@3.19.0':
resolution: {integrity: sha512-dTCkHEz+Y8ADxX7h+xvl6caAj+3nII/wMB1rTQchSuNKqJTOrzyUsCWm094+IoZmLT738wANE0fRIgziNHs/ug==}
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
@ -3309,9 +3478,15 @@ packages:
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/mime-types@2.1.4':
resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==}
@ -3356,6 +3531,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@11.0.0':
resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==}
deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.
@ -4388,6 +4566,9 @@ packages:
crc@3.8.0:
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-fetch@3.2.0:
resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
@ -5204,6 +5385,10 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@5.4.0:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@ -6350,6 +6535,9 @@ packages:
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
load-esm@1.0.3:
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
engines: {node: '>=13.2.0'}
@ -6446,6 +6634,10 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@ -6513,6 +6705,9 @@ packages:
mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@ -7055,6 +7250,9 @@ packages:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
@ -7350,6 +7548,64 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
prosemirror-changeset@2.3.1:
resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==}
prosemirror-collab@1.3.1:
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
prosemirror-dropcursor@1.8.2:
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
prosemirror-gapcursor@1.4.0:
resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==}
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
prosemirror-inputrules@1.5.1:
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-markdown@1.13.4:
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
prosemirror-menu@1.2.5:
resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==}
prosemirror-model@1.25.4:
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
prosemirror-schema-basic@1.2.4:
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-trailing-node@3.0.0:
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
peerDependencies:
prosemirror-model: ^1.22.1
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.11.0:
resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==}
prosemirror-view@1.41.5:
resolution: {integrity: sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@ -7364,6 +7620,10 @@ packages:
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -7768,6 +8028,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@ -8832,6 +9095,9 @@ packages:
vlq@1.0.1:
resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
@ -11932,6 +12198,8 @@ snapshots:
dependencies:
nanoid: 3.3.11
'@remirror/core-constants@3.0.0': {}
'@rn-primitives/portal@1.3.0(@types/react@19.1.17)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0))':
dependencies:
react: 19.1.0
@ -12475,6 +12743,187 @@ snapshots:
tailwindcss: 4.1.18
vite: 5.4.21(@types/node@25.0.10)(lightningcss@1.30.2)(terser@5.46.0)
'@tiptap/core@3.19.0(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/pm': 3.19.0
'@tiptap/extension-blockquote@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-bold@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@floating-ui/dom': 1.7.5
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
optional: true
'@tiptap/extension-bullet-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
'@tiptap/extension-code@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-document@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@floating-ui/dom': 1.7.5
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
optional: true
'@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-heading@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
'@tiptap/extension-italic@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-link@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
linkifyjs: 4.3.2
'@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
'@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-placeholder@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-strike@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-text@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-underline@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
'@tiptap/pm@3.19.0':
dependencies:
prosemirror-changeset: 2.3.1
prosemirror-collab: 1.3.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.4.0
prosemirror-history: 1.5.0
prosemirror-inputrules: 1.5.1
prosemirror-keymap: 1.2.3
prosemirror-markdown: 1.13.4
prosemirror-menu: 1.2.5
prosemirror-model: 1.25.4
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.5
'@tiptap/react@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
'@types/react': 19.1.17
'@types/react-dom': 19.2.3(@types/react@19.1.17)
'@types/use-sync-external-store': 0.0.6
fast-equals: 5.4.0
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/starter-kit@3.19.0':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/extension-blockquote': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-bold': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-bullet-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
'@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-code-block': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-document': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
'@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
'@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-heading': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-italic': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-link': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
'@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
'@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
'@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extension-underline': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
@ -12584,10 +13033,17 @@ snapshots:
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/mdurl@2.0.0': {}
'@types/mime-types@2.1.4': {}
'@types/ms@2.1.0': {}
@ -12630,6 +13086,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/uuid@11.0.0':
dependencies:
uuid: 13.0.0
@ -13845,6 +14303,8 @@ snapshots:
buffer: 5.7.1
optional: true
crelt@1.0.6: {}
cross-fetch@3.2.0:
dependencies:
node-fetch: 2.7.0
@ -14459,7 +14919,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
@ -14490,7 +14950,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -14988,6 +15448,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-equals@5.4.0: {}
fast-glob@3.3.1:
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -16230,6 +16692,8 @@ snapshots:
dependencies:
uc.micro: 2.1.0
linkifyjs@4.3.2: {}
load-esm@1.0.3: {}
locate-path@5.0.0:
@ -16313,6 +16777,15 @@ snapshots:
dependencies:
tmpl: 1.0.5
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
markdown-table@3.0.4: {}
marked@15.0.12: {}
@ -16481,6 +16954,8 @@ snapshots:
mdn-data@2.0.14: {}
mdurl@2.0.0: {}
media-typer@0.3.0: {}
media-typer@1.1.0: {}
@ -17242,6 +17717,8 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.1.2
orderedmap@2.1.1: {}
outvariant@1.4.3: {}
own-keys@1.0.1:
@ -17544,6 +18021,109 @@ snapshots:
property-information@7.1.0: {}
prosemirror-changeset@2.3.1:
dependencies:
prosemirror-transform: 1.11.0
prosemirror-collab@1.3.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.5
prosemirror-gapcursor@1.4.0:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.5
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.5
rope-sequence: 1.3.4
prosemirror-inputrules@1.5.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.4:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.0
prosemirror-model: 1.25.4
prosemirror-menu@1.2.5:
dependencies:
crelt: 1.0.6
prosemirror-commands: 1.7.1
prosemirror-history: 1.5.0
prosemirror-state: 1.4.4
prosemirror-model@1.25.4:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.5
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.5
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.5
prosemirror-transform@1.11.0:
dependencies:
prosemirror-model: 1.25.4
prosemirror-view@1.41.5:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@ -17569,6 +18149,8 @@ snapshots:
end-of-stream: 1.4.5
once: 1.4.0
punycode.js@2.3.1: {}
punycode@2.3.1: {}
qr-scanner@1.4.2:
@ -18112,6 +18694,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.57.0
fsevents: 2.3.3
rope-sequence@1.3.4: {}
router@2.2.0:
dependencies:
debug: 4.4.3
@ -19302,6 +19886,8 @@ snapshots:
vlq@1.0.1: {}
w3c-keyname@2.2.8: {}
walker@1.0.8:
dependencies:
makeerror: 1.0.12