diff --git a/.opencode/agents/check.md b/.claude/agents/trellis-check.md similarity index 51% rename from .opencode/agents/check.md rename to .claude/agents/trellis-check.md index c7e7fed..0c0ffbc 100644 --- a/.opencode/agents/check.md +++ b/.claude/agents/trellis-check.md @@ -1,38 +1,13 @@ --- +name: trellis-check description: | Code quality check expert. Reviews code changes against specs and self-fixes issues. -mode: subagent -permission: - read: allow - write: allow - edit: allow - bash: allow - glob: allow - grep: allow - mcp__exa__*: allow +tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa --- # Check Agent You are the Check Agent in the Trellis workflow. -## Context Self-Loading - -**If you see "# Check Agent Task" header with pre-loaded context above, skip this section.** - -Otherwise, load context yourself: - -1. Read `.trellis/.current-task` → get task directory (e.g., `.trellis/tasks/xxx`) -2. Read `{task_dir}/check.jsonl` (or `spec.jsonl` as fallback) -3. For each entry in JSONL: - - If `path` is a file → Read it - - If `path` is a directory → Read all `.md` files in it -4. Read `{task_dir}/prd.md` for requirements understanding -5. Read `.opencode/commands/trellis/finish-work.md` for checklist - -Then proceed with the workflow below using the loaded context. - ---- - ## Context Before checking, read: @@ -89,32 +64,6 @@ If failed, fix issues and re-run. --- -## Completion Markers (Ralph Loop) - -**CRITICAL**: You are in a loop controlled by the Ralph Loop system. -The loop will NOT stop until you output ALL required completion markers. - -Completion markers are generated from `check.jsonl` in the task directory. -Each entry's `reason` field becomes a marker: `{REASON}_FINISH` - -For example, if check.jsonl contains: -```json -{"file": "...", "reason": "TypeCheck"} -{"file": "...", "reason": "Lint"} -{"file": "...", "reason": "CodeReview"} -``` - -You MUST output these markers when each check passes: -- `TYPECHECK_FINISH` - After typecheck passes -- `LINT_FINISH` - After lint passes -- `CODEREVIEW_FINISH` - After code review passes - -If check.jsonl doesn't exist or has no reasons, output: `ALL_CHECKS_FINISH` - -**The loop will block you from stopping until all markers are present in your output.** - ---- - ## Report Format ```markdown @@ -136,11 +85,10 @@ If check.jsonl doesn't exist or has no reasons, output: `ALL_CHECKS_FINISH` ### Verification Results -- TypeCheck: Passed TYPECHECK_FINISH -- Lint: Passed LINT_FINISH +- TypeCheck: Passed +- Lint: Passed ### Summary Checked X files, found Y issues, all fixed. -ALL_CHECKS_FINISH ``` diff --git a/.opencode/agents/implement.md b/.claude/agents/trellis-implement.md similarity index 68% rename from .opencode/agents/implement.md rename to .claude/agents/trellis-implement.md index d481d66..02d8138 100644 --- a/.opencode/agents/implement.md +++ b/.claude/agents/trellis-implement.md @@ -1,38 +1,13 @@ --- +name: trellis-implement description: | Code implementation expert. Understands specs and requirements, then implements features. No git commit allowed. -mode: subagent -permission: - read: allow - write: allow - edit: allow - bash: allow - glob: allow - grep: allow - mcp__exa__*: allow +tools: Read, Write, Edit, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa --- # Implement Agent You are the Implement Agent in the Trellis workflow. -## Context Self-Loading - -**If you see "# Implement Agent Task" header with pre-loaded context above, skip this section.** - -Otherwise, load context yourself: - -1. Read `.trellis/.current-task` → get task directory (e.g., `.trellis/tasks/xxx`) -2. Read `{task_dir}/implement.jsonl` (or `spec.jsonl` as fallback) -3. For each entry in JSONL: - - If `path` is a file → Read it - - If `path` is a directory → Read all `.md` files in it -4. Read `{task_dir}/prd.md` for requirements -5. Read `{task_dir}/info.md` for technical design (if exists) - -Then proceed with the workflow below using the loaded context. - ---- - ## Context Before implementing, read: @@ -65,9 +40,8 @@ Before implementing, read: Read relevant specs based on task type: -- Backend: `.trellis/spec/backend/` -- Frontend: `.trellis/spec/frontend/` -- Guides: `.trellis/spec/guides/` +- Spec layers: `.trellis/spec///` +- Shared guides: `.trellis/spec/guides/` ### 2. Understand Requirements diff --git a/.claude/agents/trellis-research.md b/.claude/agents/trellis-research.md new file mode 100644 index 0000000..ce9d5f6 --- /dev/null +++ b/.claude/agents/trellis-research.md @@ -0,0 +1,137 @@ +--- +name: trellis-research +description: | + Code and tech search expert. Finds files, patterns, and tech solutions, and PERSISTS every finding to the current task's research/ directory. No code modifications outside that directory. +tools: Read, Write, Glob, Grep, Bash, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa, Skill, mcp__chrome-devtools__* +--- +# Research Agent + +You are the Research Agent in the Trellis workflow. + +## Core Principle + +**You do one thing: find, explain, and PERSIST information.** + +Conversations get compacted; files don't. Every research output MUST end up as a file under `{TASK_DIR}/research/`. Returning findings only through the chat reply is a failure — the caller cannot read them next session. + +--- + +## Core Responsibilities + +1. **Internal Search** — locate files/components, understand code logic, discover patterns (Glob, Grep, Read) +2. **External Search** — library docs, API references, best practices (web search) +3. **Persist** — write each research topic to `{TASK_DIR}/research/.md` +4. **Report** — return file paths + one-line summaries to the main agent (not full content) + +--- + +## Workflow + +### Step 1: Resolve Current Task + +Run `python3 ./.trellis/scripts/task.py current --source` → active task path. If no active task is set, ask the user where to write output; do NOT guess. + +Ensure `{TASK_DIR}/research/` exists: + +```bash +mkdir -p /research +``` + +### Step 2: Understand Search Request + +Classify: internal / external / mixed. Determine scope (global / specific directory) and expected shape (file list / pattern notes / tech comparison). + +### Step 3: Execute Search + +Run independent searches in parallel (Glob + Grep + web) for efficiency. + +### Step 4: Persist Each Topic + +For each distinct research topic, Write a markdown file at `{TASK_DIR}/research/.md`. Use the File Format below. + +### Step 5: Report to Main Agent + +Reply with ONLY: + +- List of files written (paths relative to repo root) +- One-line summary per file +- Any critical caveats that the main agent needs to know right now + +Do NOT paste full research content into the reply. The files are the contract. + +--- + +## Scope Limits (Strict) + +### Write ALLOWED + +- `{TASK_DIR}/research/*.md` — your own output +- Creating `{TASK_DIR}/research/` if it doesn't exist (via `mkdir -p`) + +### Write FORBIDDEN + +- Code files (`src/`, `lib/`, …) +- Spec files (`.trellis/spec/`) — main agent should use `update-spec` skill instead +- `.trellis/scripts/`, `.trellis/workflow.md`, platform config (`.claude/`, `.cursor/`, etc.) +- Other task directories +- Any git operation (commit / push / branch / merge) + +If the user asks you to edit code, decline and suggest spawning `implement` instead. + +--- + +## File Format + +Each `{TASK_DIR}/research/.md` should follow: + +```markdown +# Research: + +- **Query**: +- **Scope**: +- **Date**: + +## Findings + +### Files Found + +| File Path | Description | +|---|---| +| `src/services/xxx.ts` | Main implementation | +| `src/types/xxx.ts` | Type definitions | + +### Code Patterns + + + +### External References + +- [Library X docs](url) — + +### Related Specs + +- `.trellis/spec/xxx.md` — + +## Caveats / Not Found + + +``` + +--- + +## Guidelines + +### DO + +- Provide specific file paths and line numbers +- Quote actual code snippets +- Persist every topic to its own file +- Return file paths in your reply, not the full content +- Mark "not found" explicitly when searches come up empty + +### DON'T + +- Don't write code or modify files outside `{TASK_DIR}/research/` +- Don't guess uncertain info +- Don't paste full research text into the reply (files are the deliverable) +- Don't propose improvements or critique implementation (that's not your role) diff --git a/.claude/commands/trellis/continue.md b/.claude/commands/trellis/continue.md new file mode 100644 index 0000000..5261d97 --- /dev/null +++ b/.claude/commands/trellis/continue.md @@ -0,0 +1,55 @@ +# Continue Current Task + +Resume work on the current task — pick up at the right phase/step in `.trellis/workflow.md`. + +--- + +## Step 1: Load Current Context + +```bash +python3 ./.trellis/scripts/get_context.py +``` + +Confirms: current task, git state, recent commits. + +## Step 2: Load the Phase Index + +```bash +python3 ./.trellis/scripts/get_context.py --mode phase +``` + +Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping. + +## Step 3: Decide Where You Are + +`get_context.py` shows the active task's `status` field. Route by `status` + artifact presence: + +- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) +- `status=planning` + `prd.md` exists + `implement.jsonl` not curated (only the seed `_example` row) → **1.3** +- `status=planning` + `prd.md` + curated `implement.jsonl` → **1.4** (run `task.py start` to enter Phase 2) +- `status=in_progress` + implementation not started → **2.1** +- `status=in_progress` + implementation done, not yet checked → **2.2** +- `status=in_progress` + check passed → **3.1** +- `status=completed` (rare; usually archived immediately) → archive flow + +Phase rules (full detail in `.trellis/workflow.md`): + +1. Run steps **in order** within a phase — `[required]` steps must not be skipped +2. `[once]` steps are already done if the output exists (e.g., `prd.md` for 1.1; `implement.jsonl` with curated entries for 1.3) — skip them +3. You may go back to an earlier phase if discoveries require it + +## Step 4: Load the Specific Step + +Once you know which step to resume at: + +```bash +python3 ./.trellis/scripts/get_context.py --mode phase --step --platform claude +``` + +Follow the loaded instructions. After each `[required]` step completes, move to the next. + +--- + +## Reference + +Full workflow, skill routing table, and the DO-NOT-skip table live in `.trellis/workflow.md`. This command is only an entry point — the canonical guidance is there. diff --git a/.claude/commands/trellis/finish-work.md b/.claude/commands/trellis/finish-work.md new file mode 100644 index 0000000..ab751c6 --- /dev/null +++ b/.claude/commands/trellis/finish-work.md @@ -0,0 +1,66 @@ +# Finish Work + +Wrap up the current session: archive the active task (and any other completed-but-unarchived tasks the user wants to clean up) and record the session journal. Code commits are NOT done here — those happen in workflow Phase 3.4 before you invoke this command. + +## Step 1: Survey current state + +```bash +python3 ./.trellis/scripts/get_context.py --mode record +``` + +This prints: + +- **My active tasks** — review whether any besides the current one are actually done (code merged, AC met) and should be archived this round. +- **Git status** — quick visual on what's dirty. +- **Recent commits** — you'll need their hashes in Step 4 for `--commit`. + +If `--mode record` surfaces other completed tasks not tied to the current session, surface them to the user with a one-shot confirmation: "These N tasks look done — archive them too in this round? [y/N]". Default is no; the current active task is always archived in Step 3 regardless. + +## Step 2: Sanity check — classify dirty paths + +Run: + +```bash +git status --porcelain +``` + +Filter out paths under `.trellis/workspace/` and `.trellis/tasks/` — those are managed by `add_session.py` and `task.py archive` auto-commits and will appear dirty as part of this skill's own work. + +For each remaining dirty path, decide whether it belongs to **the current task** or to **other parallel work** (e.g., another terminal window editing the same repo). Heuristics: + +- Paths referenced in the current task's `prd.md` / `implement.jsonl` / `check.jsonl` → current task +- Paths in code areas matching the task's stated scope, or that you remember editing this session → current task +- Paths in unrelated areas you have no recollection of touching this session → other parallel work + +Then route: + +- **Any remaining path looks like current-task work** — bail out with: + > "Working tree has uncommitted code changes from this task: ``. Return to workflow Phase 3.4 to commit them before running `/trellis:finish-work`." + + Do NOT run `git commit` here. Do NOT prompt the user to commit. The user goes back to Phase 3.4 and the AI drives the batched commit there. +- **All remaining paths look unrelated** (other parallel-window work) — report them once and continue to Step 3: + > "FYI, dirty files outside this task's scope — leaving them for the other window: ``." +- **Genuinely unsure** — ask the user once: "Are `` this task's work I forgot to commit, or another window's? (commit / ignore)" — then route per their answer. + +## Step 3: Archive task(s) + +```bash +python3 ./.trellis/scripts/task.py archive +``` + +At minimum: the current active task (if any). Plus any extra tasks the user confirmed in Step 1. Each archive produces a `chore(task): archive ...` commit via the script's auto-commit. + +If there is no active task and the user did not confirm any cleanup archives, skip this step. + +## Step 4: Record session journal + +```bash +python3 ./.trellis/scripts/add_session.py \ + --title "Session Title" \ + --commit "hash1,hash2" \ + --summary "Brief summary" +``` + +Use the work-commit hashes produced in Phase 3.4 (visible in Step 1's `Recent commits` list, or via `git log --oneline`) for `--commit`. Do not include the archive commit hashes from Step 3. This produces a `chore: record journal` commit. + +Final git log order: `` → `chore(task): archive ...` (one or more) → `chore: record journal`. diff --git a/.claude/hooks/inject-subagent-context.py b/.claude/hooks/inject-subagent-context.py new file mode 100755 index 0000000..f6cd24e --- /dev/null +++ b/.claude/hooks/inject-subagent-context.py @@ -0,0 +1,746 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Multi-Platform Sub-Agent Context Injection Hook + +Injects task-specific context when sub-agents (implement, check, research) are spawned. + +Core Design Philosophy: +- Hook is responsible for injecting all context, subagent works autonomously with complete info +- Each agent has a dedicated jsonl file defining its context +- No resume needed, no segmentation, behavior controlled by code not prompt + +Trigger: PreToolUse (before Task tool call) + +Context Source: Trellis active task resolver points to task directory +- implement.jsonl - Implement agent dedicated context +- check.jsonl - Check agent dedicated context +- prd.md - Requirements document +- info.md - Technical design +- codex-review-output.txt - Code Review results +""" +from __future__ import annotations + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import json +import os +import sys +from pathlib import Path +from typing import Any + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform.startswith("win"): + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + + +# ============================================================================= +# Path Constants (change here to rename directories) +# ============================================================================= + +DIR_WORKFLOW = ".trellis" +DIR_SPEC = "spec" +FILE_TASK_JSON = "task.json" + +# ============================================================================= +# Subagent Constants (change here to rename subagent types) +# ============================================================================= + +AGENT_IMPLEMENT = "trellis-implement" +AGENT_CHECK = "trellis-check" +AGENT_RESEARCH = "trellis-research" + +# Agents that require a task directory +AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, AGENT_CHECK) +# All supported agents +AGENTS_ALL = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_RESEARCH) + + +def find_repo_root(start_path: str) -> str | None: + """ + Find git repo root from start_path upwards + + Returns: + Repo root path, or None if not found + """ + current = Path(start_path).resolve() + while current != current.parent: + if (current / ".git").exists(): + return str(current) + current = current.parent + return None + + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def get_current_task(repo_root: str, input_data: dict) -> str | None: + """Resolve current task directory through the unified active task resolver.""" + scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.active_task import resolve_active_task # type: ignore[import-not-found] + except Exception: + return None + + active = resolve_active_task( + Path(repo_root), + input_data, + platform=_detect_platform(input_data), + ) + return active.task_path + + +def read_file_content(base_path: str, file_path: str) -> str | None: + """Read file content, return None if file doesn't exist""" + full_path = os.path.join(base_path, file_path) + if os.path.exists(full_path) and os.path.isfile(full_path): + try: + with open(full_path, "r", encoding="utf-8") as f: + return f.read() + except Exception: + return None + return None + + +def read_directory_contents( + base_path: str, dir_path: str, max_files: int = 20 +) -> list[tuple[str, str]]: + """ + Read all .md files in a directory + + Args: + base_path: Base path (usually repo_root) + dir_path: Directory relative path + max_files: Max files to read (prevent huge directories) + + Returns: + [(file_path, content), ...] + """ + full_path = os.path.join(base_path, dir_path) + if not os.path.exists(full_path) or not os.path.isdir(full_path): + return [] + + results = [] + try: + # Only read .md files, sorted by filename + md_files = sorted( + [ + f + for f in os.listdir(full_path) + if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f)) + ] + ) + + for filename in md_files[:max_files]: + file_full_path = os.path.join(full_path, filename) + relative_path = os.path.join(dir_path, filename) + try: + with open(file_full_path, "r", encoding="utf-8") as f: + content = f.read() + results.append((relative_path, content)) + except Exception: + continue + except Exception: + pass + + return results + + +def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[tuple[str, str]]: + """ + Read all file/directory contents referenced in jsonl file + + Schema: + {"file": "path/to/file.md", "reason": "..."} + {"file": "path/to/dir/", "type": "directory", "reason": "..."} + {"_example": "..."} # seed row — skipped (no `file` field) + + Rows without a ``file`` field (e.g. the self-describing seed line written + by ``task.py create`` before the agent has curated entries) are skipped + silently. If the resulting entry list is empty, a stderr warning is + emitted so the operator can debug missing context. + + Returns: + [(path, content), ...] + """ + full_path = os.path.join(base_path, jsonl_path) + if not os.path.exists(full_path): + print( + f"[inject-subagent-context] WARN: {jsonl_path} not found — " + f"sub-agent will receive only prd.md", + file=sys.stderr, + ) + return [] + + results = [] + saw_real_entry = False + try: + with open(full_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + file_path = item.get("file") or item.get("path") + entry_type = item.get("type", "file") + + if not file_path: + # Seed / comment row — skip silently + continue + + saw_real_entry = True + if entry_type == "directory": + # Read all .md files in directory + dir_contents = read_directory_contents(base_path, file_path) + results.extend(dir_contents) + else: + # Read single file + content = read_file_content(base_path, file_path) + if content: + results.append((file_path, content)) + except json.JSONDecodeError: + continue + except Exception: + pass + + if not saw_real_entry: + print( + f"[inject-subagent-context] WARN: {jsonl_path} has no curated " + f"entries (only seed / empty) — sub-agent will receive only " + f"prd.md. See workflow.md Phase 1.3 for curation guidance.", + file=sys.stderr, + ) + + return results + + + + +def get_agent_context(repo_root: str, task_dir: str, agent_type: str) -> str: + """ + Get context from {agent_type}.jsonl for the specified agent. + Only reads implement.jsonl or check.jsonl (the two JSONL files the task system creates). + """ + context_parts = [] + + agent_jsonl = f"{task_dir}/{agent_type}.jsonl" + for file_path, content in read_jsonl_entries(repo_root, agent_jsonl): + context_parts.append(f"=== {file_path} ===\n{content}") + + return "\n\n".join(context_parts) + + +def get_implement_context(repo_root: str, task_dir: str) -> str: + """ + Complete context for Implement Agent + + Read order: + 1. All files in implement.jsonl (dev specs) + 2. prd.md (requirements) + 3. info.md (technical design) + """ + context_parts = [] + + # 1. Read implement.jsonl + base_context = get_agent_context(repo_root, task_dir, "implement") + if base_context: + context_parts.append(base_context) + + # 2. Requirements document + prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") + if prd_content: + context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}") + + # 3. Technical design + info_content = read_file_content(repo_root, f"{task_dir}/info.md") + if info_content: + context_parts.append( + f"=== {task_dir}/info.md (Technical Design) ===\n{info_content}" + ) + + return "\n\n".join(context_parts) + + +def get_check_context(repo_root: str, task_dir: str) -> str: + """ + Context for Check Agent: check.jsonl + prd.md + """ + context_parts = [] + + for file_path, content in read_jsonl_entries(repo_root, f"{task_dir}/check.jsonl"): + context_parts.append(f"=== {file_path} ===\n{content}") + + prd_content = read_file_content(repo_root, f"{task_dir}/prd.md") + if prd_content: + context_parts.append(f"=== {task_dir}/prd.md (Requirements) ===\n{prd_content}") + + return "\n\n".join(context_parts) + + +def get_finish_context(repo_root: str, task_dir: str) -> str: + """ + Context for Finish phase: reuses check.jsonl + prd.md + (Finish is a final check, same context source.) + """ + return get_check_context(repo_root, task_dir) + + + +def build_implement_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Implement""" + return f"""# Implement Agent Task + +You are the Implement Agent in the Multi-Agent Pipeline. + +## Your Context + +All the information you need has been prepared for you: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Understand specs** - All dev specs are injected above, understand them +2. **Understand requirements** - Read requirements document and technical design +3. **Implement feature** - Implement following specs and design +4. **Self-check** - Ensure code quality against check specs + +## Important Constraints + +- Do NOT execute git commit, only code modifications +- Follow all dev specs injected above +- Report list of modified/created files when done""" + + +def build_check_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Check""" + return f"""# Check Agent Task + +You are the Check Agent in the Multi-Agent Pipeline (code and cross-layer checker). + +## Your Context + +All check specs and dev specs you need: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Get changes** - Run `git diff --name-only` and `git diff` to get code changes +2. **Check against specs** - Check item by item against specs above +3. **Self-fix** - Fix issues directly, don't just report +4. **Run verification** - Run project's lint and typecheck commands + +## Important Constraints + +- Fix issues yourself, don't just report +- Must execute complete checklist in check specs +- Pay special attention to impact radius analysis (L1-L5)""" + + +def build_finish_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Finish (final check before PR)""" + return f"""# Finish Agent Task + +You are performing the final check before creating a PR. + +## Your Context + +Finish checklist and requirements: + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Review changes** - Run `git diff --name-only` to see all changed files +2. **Verify requirements** - Check each requirement in prd.md is implemented +3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions + - If new pattern/convention found: read target spec file → update it → update index.md if needed + - If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md + - If pure code fix with no new patterns: skip this step +4. **Run final checks** - Execute lint and typecheck +5. **Confirm ready** - Ensure code is ready for PR + +## Important Constraints + +- You MAY update spec files when gaps are detected (use update-spec.md as guide) +- MUST read the target spec file BEFORE editing (avoid duplicating existing content) +- Do NOT update specs for trivial changes (typos, formatting, obvious fixes) +- If critical CODE issues found, report them clearly (fix specs, not code) +- Verify all acceptance criteria in prd.md are met""" + + + +def get_research_context(repo_root: str, task_dir: str | None) -> str: + """ + Context for Research Agent — project structure overview for spec directories. + + `task_dir` kept for signature parity with get_implement_context / get_check_context + so the dispatcher can call them uniformly. + """ + _ = task_dir + context_parts = [] + + # 1. Project structure overview (dynamically discover spec directories) + spec_path = f"{DIR_WORKFLOW}/{DIR_SPEC}" + spec_root = Path(repo_root) / DIR_WORKFLOW / DIR_SPEC + + # Build spec tree dynamically + tree_lines = [f"{spec_path}/"] + if spec_root.is_dir(): + pkg_dirs = sorted(d for d in spec_root.iterdir() if d.is_dir()) + for i, pkg_dir in enumerate(pkg_dirs): + is_last = i == len(pkg_dirs) - 1 + prefix = "└── " if is_last else "├── " + layers = sorted(d.name for d in pkg_dir.iterdir() if d.is_dir()) + layer_info = f" ({', '.join(layers)})" if layers else "" + tree_lines.append(f"{prefix}{pkg_dir.name}/{layer_info}") + + spec_tree = "\n".join(tree_lines) + + project_structure = f"""## Project Spec Directory Structure + +``` +{spec_tree} +``` + +To get structured package info, run: `python3 ./{DIR_WORKFLOW}/scripts/get_context.py --mode packages` + +## Search Tips + +- Spec files: `{spec_path}/**/*.md` +- Code search: Use Glob and Grep tools +- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa""" + + context_parts.append(project_structure) + + return "\n\n".join(context_parts) + + +def build_research_prompt(original_prompt: str, context: str) -> str: + """Build complete prompt for Research""" + return f"""# Research Agent Task + +You are the Research Agent in the Multi-Agent Pipeline (search researcher). + +## Core Principle + +**You do one thing: find and explain information.** + +You are a documenter, not a reviewer. + +## Project Info + +{context} + +--- + +## Your Task + +{original_prompt} + +--- + +## Workflow + +1. **Understand query** - Determine search type (internal/external) and scope +2. **Plan search** - List search steps for complex queries +3. **Execute search** - Execute multiple independent searches in parallel +4. **Organize results** - Output structured report + +## Search Tools + +| Tool | Purpose | +|------|---------| +| Glob | Search by filename pattern | +| Grep | Search by content | +| Read | Read file content | +| mcp__exa__web_search_exa | External web search | +| mcp__exa__get_code_context_exa | External code/doc search | + +## Strict Boundaries + +**Only allowed**: Describe what exists, where it is, how it works + +**Forbidden** (unless explicitly asked): +- Suggest improvements +- Criticize implementation +- Recommend refactoring +- Modify any files + +## Report Format + +Provide structured search results including: +- List of files found (with paths) +- Code pattern analysis (if applicable) +- Related spec documents +- External references (if any)""" + + +def _string_value(value: Any) -> str: + if isinstance(value, str): + stripped = value.strip() + return stripped + return "" + + +def _extract_subagent_name(value: Any) -> str: + """Extract a sub-agent name from common platform encodings. + + Cursor's native Task args encode custom sub-agents as a protobuf oneof, + which can appear in hook JSON as either ``{"custom": {"name": "..."}}`` + or ``{"type": {"case": "custom", "value": {"name": "..."}}}``. + """ + direct = _string_value(value) + if direct: + return direct + + if not isinstance(value, dict): + return "" + + for key in ("name", "subagent_type_name", "subagentTypeName"): + direct = _string_value(value.get(key)) + if direct: + return direct + + custom = value.get("custom") + if isinstance(custom, dict): + custom_name = _string_value(custom.get("name")) + if custom_name: + return custom_name + + oneof = value.get("type") + if isinstance(oneof, dict): + case_name = _string_value(oneof.get("case")) + if case_name == "custom": + nested_value = oneof.get("value") + if isinstance(nested_value, dict): + custom_name = _string_value(nested_value.get("name")) + if custom_name: + return custom_name + if case_name: + return case_name + + case_name = _string_value(value.get("case")) + if case_name == "custom": + nested_value = value.get("value") + if isinstance(nested_value, dict): + custom_name = _string_value(nested_value.get("name")) + if custom_name: + return custom_name + if case_name: + return case_name + + for agent_name in AGENTS_ALL: + if agent_name in value: + return agent_name + + return "" + + +def _extract_subagent_type(tool_input: dict) -> str: + for key in ( + "subagent_type", + "subagentType", + "subagent_type_name", + "subagentTypeName", + "agent_type", + "agentType", + "name", + ): + agent_name = _extract_subagent_name(tool_input.get(key)) + if agent_name: + return agent_name + return "" + + +def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]: + """Parse hook input across different platform formats. + + Returns (subagent_type, original_prompt, tool_input). + Handles: + - Claude Code / Qoder / CodeBuddy / Droid: tool_name=Task|Agent, tool_input.subagent_type + - Cursor: tool_name=Task|Subagent, tool_input.subagent_type + - Copilot CLI: toolName=task (camelCase key, lowercase value) + - Gemini CLI: tool_name IS the agent name (BeforeTool matcher already filtered) + - Kiro: agentSpawn hook, agent_name field at top level + """ + tool_input = input_data.get("tool_input", {}) + + # Standard format: Task/Agent tool with subagent_type + tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "") + if tool_name.lower() in ("task", "agent", "subagent"): + return ( + _extract_subagent_type(tool_input), + tool_input.get("prompt", ""), + tool_input, + ) + + # Kiro: agentSpawn hook passes agent_name at top level + agent_name = input_data.get("agent_name", "") + if agent_name: + return agent_name, tool_input.get("prompt", input_data.get("prompt", "")), tool_input + + # Gemini CLI: BeforeTool where tool_name IS the agent name + # (matcher already ensured it's one of our agents) + if tool_name in AGENTS_ALL: + return tool_name, tool_input.get("prompt", ""), tool_input + + # Copilot CLI: toolName field (camelCase), value might be the agent name + tool_name_camel = input_data.get("toolName", "") + if tool_name_camel in AGENTS_ALL: + return tool_name_camel, input_data.get("toolArgs", ""), tool_input + + return "", "", tool_input + + +def main(): + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + sys.exit(0) + + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError: + sys.exit(0) + + subagent_type, original_prompt, tool_input = _parse_hook_input(input_data) + cwd = input_data.get("cwd", os.getcwd()) + + # Only handle subagent types we care about + if subagent_type not in AGENTS_ALL: + sys.exit(0) + + # Find repo root + repo_root = find_repo_root(cwd) + if not repo_root: + sys.exit(0) + + # Get current task directory (research doesn't require it) + task_dir = get_current_task(repo_root, input_data) + + # implement/check need task directory + if subagent_type in AGENTS_REQUIRE_TASK: + if not task_dir: + sys.exit(0) + # Check if task directory exists + task_dir_full = os.path.join(repo_root, task_dir) + if not os.path.exists(task_dir_full): + sys.exit(0) + + # Check for [finish] marker in prompt (check agent with finish context) + is_finish_phase = "[finish]" in original_prompt.lower() + + # Get context and build prompt based on subagent type + if subagent_type == AGENT_IMPLEMENT: + assert task_dir is not None # validated above + context = get_implement_context(repo_root, task_dir) + new_prompt = build_implement_prompt(original_prompt, context) + elif subagent_type == AGENT_CHECK: + assert task_dir is not None # validated above + if is_finish_phase: + # Finish phase: use finish context (lighter, focused on final verification) + context = get_finish_context(repo_root, task_dir) + new_prompt = build_finish_prompt(original_prompt, context) + else: + # Regular check phase: use check context (full specs for self-fix loop) + context = get_check_context(repo_root, task_dir) + new_prompt = build_check_prompt(original_prompt, context) + elif subagent_type == AGENT_RESEARCH: + # Research can work without task directory + context = get_research_context(repo_root, task_dir) + new_prompt = build_research_prompt(original_prompt, context) + else: + sys.exit(0) + + if not context: + sys.exit(0) + + # Return updated input — use a multi-format output that covers all platforms. + # Most platforms ignore unrecognized fields, so we include multiple formats. + # The platform picks whichever fields it understands. + updated = {**tool_input, "prompt": new_prompt} + output = { + # Claude Code / Qoder / CodeBuddy / Droid format + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": updated, + }, + # Cursor format + "permission": "allow", + "updated_input": updated, + # Gemini format + "updatedInput": updated, + } + + print(json.dumps(output, ensure_ascii=False)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/inject-workflow-state.py b/.claude/hooks/inject-workflow-state.py new file mode 100755 index 0000000..1a8986b --- /dev/null +++ b/.claude/hooks/inject-workflow-state.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Trellis per-turn breadcrumb hook (UserPromptSubmit / BeforeAgent equivalent). + +Runs on every user prompt. Resolves the active task through Trellis' +session-aware active task resolver and emits a short +block reminding the main AI what task is active and its expected flow. + +The emitted ``hookEventName`` field is platform-aware: most hosts expect +``UserPromptSubmit`` (Claude Code naming, also accepted by Cursor / Qoder / +CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed +its per-turn event to ``BeforeAgent`` and its schema validator rejects the +legacy name. ``_detect_platform`` picks the right value at runtime. +Breadcrumb text is pulled exclusively from workflow.md +[workflow-state:STATUS] tag blocks — workflow.md is the single source of +truth. There are no fallback dicts in this script: when workflow.md is +missing or a tag is absent, the breadcrumb degrades to a generic +"Refer to workflow.md for current step." line so users see (and fix) +the broken state instead of the hook silently masking it. + +Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder, +CodeBuddy, Droid, Gemini, Copilot). Kiro is not wired (no per-turn +hook entry point). Written to each platform's hooks directory via +writeSharedHooks() at init time. + +Silent exit 0 cases (no output): + - No .trellis/ directory found (not a Trellis project) + - task.json malformed or missing status +""" +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path +from typing import Optional + + +# --------------------------------------------------------------------------- +# CWD-robust Trellis root discovery (fixes hook-path-robustness for this hook) +# --------------------------------------------------------------------------- + +def find_trellis_root(start: Path) -> Optional[Path]: + """Walk up from start to find directory containing .trellis/. + + Handles CWD drift: subdirectory launches, monorepo packages, etc. + Returns None if no .trellis/ found (silent no-op). + """ + cur = start.resolve() + while cur != cur.parent: + if (cur / ".trellis").is_dir(): + return cur + cur = cur.parent + return None + + +# --------------------------------------------------------------------------- +# Active task discovery +# --------------------------------------------------------------------------- + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".codex" in script_parts: + return "codex" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def _resolve_active_task(root: Path, input_data: dict): + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_active_task # type: ignore[import-not-found] + + return resolve_active_task(root, input_data, platform=_detect_platform(input_data)) + + +def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]: + """Return (task_id, status, source) from the current active task.""" + active = _resolve_active_task(root, input_data) + if not active.task_path: + return None + + task_dir = Path(active.task_path) + if not task_dir.is_absolute(): + task_dir = root / task_dir + if active.stale: + return task_dir.name, f"stale_{active.source_type}", active.source + + task_json = task_dir / "task.json" + if not task_json.is_file(): + return None + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + task_id = data.get("id") or task_dir.name + status = data.get("status", "") + if not isinstance(status, str) or not status: + return None + return task_id, status, active.source + + +# --------------------------------------------------------------------------- +# Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults +# --------------------------------------------------------------------------- + +# Supports STATUS values with letters, digits, underscores, hyphens +# (so "in-review" / "blocked-by-team" work alongside "in_progress"). +_TAG_RE = re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-state:\1\]", + re.DOTALL, +) + +def load_breadcrumbs(root: Path) -> dict[str, str]: + """Parse workflow.md for [workflow-state:STATUS] blocks. + + Returns {status: body_text}. workflow.md is the single source of + truth — there are no fallback dicts in this script. Missing tags + (or a missing/unreadable workflow.md) fall back to a generic line + in build_breadcrumb so users see the broken state and fix + workflow.md, rather than the hook silently masking the issue. + """ + workflow = root / ".trellis" / "workflow.md" + if not workflow.is_file(): + return {} + try: + content = workflow.read_text(encoding="utf-8") + except OSError: + return {} + + result: dict[str, str] = {} + for match in _TAG_RE.finditer(content): + status = match.group(1) + body = match.group(2).strip() + if body: + result[status] = body + return result + + +def build_breadcrumb( + task_id: Optional[str], + status: str, + templates: dict[str, str], + source: str | None = None, +) -> str: + """Build the ... block. + + - Known status (tag present in workflow.md) → detailed template body + - Unknown status (no tag, or workflow.md missing) → generic + "Refer to workflow.md for current step." line + - `no_task` pseudo-status (task_id is None) → header omits task info + """ + body = templates.get(status) + if body is None: + body = "Refer to workflow.md for current step." + header = f"Status: {status}" if task_id is None else f"Task: {task_id} ({status})" + if source: + header = f"{header}\nSource: {source}" + return f"\n{header}\n{body}\n" + + +# --------------------------------------------------------------------------- +# Entry +# --------------------------------------------------------------------------- + +def main() -> int: + if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return 0 + + try: + data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + data = {} + + cwd_str = data.get("cwd") or os.getcwd() + cwd = Path(cwd_str) + + root = find_trellis_root(cwd) + if root is None: + return 0 # not a Trellis project + + templates = load_breadcrumbs(root) + task = get_active_task(root, data) + if task is None: + # No active task — still emit a breadcrumb nudging AI toward + # trellis-brainstorm + task.py create when user describes real work. + breadcrumb = build_breadcrumb(None, "no_task", templates) + else: + task_id, status, source = task + breadcrumb = build_breadcrumb(task_id, status, templates, source) + + # Gemini CLI 0.40.x rejects "UserPromptSubmit" — its per-turn event is + # named "BeforeAgent". Other platforms (Claude/Cursor/Qoder/CodeBuddy/ + # Droid/Codex/Copilot) accept the original Claude-style name. + hook_event_name = ( + "BeforeAgent" if _detect_platform(data) == "gemini" else "UserPromptSubmit" + ) + + output = { + "hookSpecificOutput": { + "hookEventName": hook_event_name, + "additionalContext": breadcrumb, + } + } + print(json.dumps(output)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/hooks/session-start.py b/.claude/hooks/session-start.py new file mode 100755 index 0000000..229291b --- /dev/null +++ b/.claude/hooks/session-start.py @@ -0,0 +1,769 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Session Start Hook - Inject structured context +""" +from __future__ import annotations + +# IMPORTANT: Suppress all warnings FIRST +import warnings +warnings.filterwarnings("ignore") + +import json +import os +import re +import shlex +import subprocess +import sys +from io import StringIO +from pathlib import Path + + +def _normalize_windows_shell_path(path_str: str) -> str: + """Normalize Unix-style shell paths to real Windows paths. + + On Windows, shells like Git Bash / MSYS2 / Cygwin may report paths like + `/d/Users/...` or `/cygdrive/d/Users/...`. `Path.resolve()` will misinterpret + these as `D:/d/Users...` on drive D: (or similar), breaking repo root + detection. + + This function is intentionally conservative: it only rewrites patterns that + unambiguously represent a drive letter mount. + """ + if not isinstance(path_str, str) or not path_str: + return path_str + + # Only relevant on Windows; keep other platforms untouched. + if not sys.platform.startswith("win"): + return path_str + + p = path_str.strip() + + # Already a Windows drive path (C:\... or C:/...) + if re.match(r"^[A-Za-z]:[\/]", p): + return p + + # MSYS/Git-Bash style: /c/Users/... or /d/Work/... + m = re.match(r"^/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + return f"{drive}:\\{rest.replace('/', '\\')}" + + # Cygwin style: /cygdrive/c/Users/... + m = re.match(r"^/cygdrive/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + return f"{drive}:\\{rest.replace('/', '\\')}" + + # WSL mounted drive (sometimes leaked into env): /mnt/c/Users/... + m = re.match(r"^/mnt/([A-Za-z])/(.*)", p) + if m: + drive, rest = m.group(1).upper(), m.group(2) + return f"{drive}:\\{rest.replace('/', '\\')}" + + return path_str + + +FIRST_REPLY_NOTICE = """ +On the first visible assistant reply in this session, begin with exactly one short Chinese sentence: +Trellis SessionStart 已注入:workflow、当前任务状态、开发者身份、git 状态、active tasks、spec 索引已加载。 +Then continue directly with the user's request. This notice is one-shot: do not repeat it after the first assistant reply in the same session. +""" + +# IMPORTANT: Force stdout to use UTF-8 on Windows +# This fixes UnicodeEncodeError when outputting non-ASCII characters +if sys.platform.startswith("win"): + import io as _io + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + elif hasattr(sys.stdout, "detach"): + sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] + + + +def _has_curated_jsonl_entry(jsonl_path: Path) -> bool: + """Return True iff jsonl has at least one row with a ``file`` field. + + A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no + ``file`` key) — that is NOT "ready". Readiness requires at least one + curated entry. Matches the contract used by hook-inject and pull-based + sub-agent context loaders. + """ + try: + for line in jsonl_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict) and row.get("file"): + return True + except (OSError, UnicodeDecodeError): + return False + return False + + +def should_skip_injection() -> bool: + """Check if any platform's non-interactive flag is set, or if Trellis + hooks are explicitly disabled via TRELLIS_HOOKS=0 / TRELLIS_DISABLE_HOOKS=1. + """ + if os.environ.get("TRELLIS_HOOKS") == "0": + return True + if os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": + return True + non_interactive_vars = [ + "CLAUDE_NON_INTERACTIVE", + "QODER_NON_INTERACTIVE", + "CODEBUDDY_NON_INTERACTIVE", + "FACTORY_NON_INTERACTIVE", + "CURSOR_NON_INTERACTIVE", + "GEMINI_NON_INTERACTIVE", + "KIRO_NON_INTERACTIVE", + "COPILOT_NON_INTERACTIVE", + ] + return any(os.environ.get(var) == "1" for var in non_interactive_vars) + + +def read_file(path: Path, fallback: str = "") -> str: + try: + return path.read_text(encoding="utf-8") + except (FileNotFoundError, PermissionError): + return fallback + + +def _detect_platform(input_data: dict) -> str | None: + if isinstance(input_data.get("cursor_version"), str): + return "cursor" + env_map = { + "CLAUDE_PROJECT_DIR": "claude", + "CURSOR_PROJECT_DIR": "cursor", + "CODEBUDDY_PROJECT_DIR": "codebuddy", + "FACTORY_PROJECT_DIR": "droid", + "GEMINI_PROJECT_DIR": "gemini", + "QODER_PROJECT_DIR": "qoder", + "KIRO_PROJECT_DIR": "kiro", + "COPILOT_PROJECT_DIR": "copilot", + } + for env_name, platform in env_map.items(): + if os.environ.get(env_name): + return platform + script_parts = set(Path(sys.argv[0]).parts) + if ".claude" in script_parts: + return "claude" + if ".cursor" in script_parts: + return "cursor" + if ".codex" in script_parts: + return "codex" + if ".gemini" in script_parts: + return "gemini" + if ".qoder" in script_parts: + return "qoder" + if ".codebuddy" in script_parts: + return "codebuddy" + if ".factory" in script_parts: + return "droid" + if ".kiro" in script_parts: + return "kiro" + return None + + +def _resolve_context_key(trellis_dir: Path, input_data: dict) -> str | None: + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_context_key # type: ignore[import-not-found] + + return resolve_context_key(input_data, platform=_detect_platform(input_data)) + + +def _persist_context_key_for_bash(context_key: str | None) -> None: + """Expose Trellis session identity to later Claude Code Bash commands. + + Claude Code SessionStart hooks can append exports to CLAUDE_ENV_FILE; those + variables are then available to Bash tools in the same conversation. Without + this bridge, `task.py start` has hook stdin during SessionStart but no + session identity when the AI later runs it as a normal shell command. + """ + if not context_key: + return + env_file = os.environ.get("CLAUDE_ENV_FILE") + if not env_file: + return + try: + with open(env_file, "a", encoding="utf-8") as handle: + handle.write(f"export TRELLIS_CONTEXT_ID={shlex.quote(context_key)}\n") + except OSError: + pass + + +def _resolve_active_task(trellis_dir: Path, input_data: dict): + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + from common.active_task import resolve_active_task # type: ignore[import-not-found] + + return resolve_active_task( + trellis_dir.parent, + input_data, + platform=_detect_platform(input_data), + ) + + +def run_script(script_path: Path, context_key: str | None = None) -> str: + try: + if script_path.suffix == ".py": + # Add PYTHONIOENCODING to force UTF-8 in subprocess + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + if context_key: + env["TRELLIS_CONTEXT_ID"] = context_key + cmd = [sys.executable, "-W", "ignore", str(script_path)] + else: + env = os.environ.copy() + if context_key: + env["TRELLIS_CONTEXT_ID"] = context_key + cmd = [str(script_path)] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + cwd=script_path.parent.parent.parent, + env=env, + ) + return result.stdout if result.returncode == 0 else "No context available" + except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): + return "No context available" + + +def _normalize_task_ref(task_ref: str) -> str: + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith("tasks/"): + return f".trellis/{normalized}" + + return normalized + + +def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path: + normalized = _normalize_task_ref(task_ref) + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + if normalized.startswith(".trellis/"): + return trellis_dir.parent / path_obj + return trellis_dir / "tasks" / path_obj + + +def _get_task_status(trellis_dir: Path, input_data: dict) -> str: + """Check current task status and return structured status string with explicit next action. + + Returns a block with three fields: + - Status: current state + - Task: task identifier (when applicable) + - Next-Action: explicit skill/command/tool call the AI should invoke + """ + active = _resolve_active_task(trellis_dir, input_data) + + # Case 1: No active task — waiting for user to describe intent + if not active.task_path: + return ( + "Status: NO ACTIVE TASK\n" + f"Source: {active.source}\n" + "Next-Action: After the user describes their intent, load skill `trellis-brainstorm` " + "to clarify requirements and create a task via `python3 ./.trellis/scripts/task.py create`.\n" + "Research reminder: for research-heavy tasks (comparing tools, reading external docs, " + "cross-platform surveys), spawn `trellis-research` sub-agents via the Task tool — " + "they persist findings to `{TASK_DIR}/research/*.md` and keep main context clean. " + "Do NOT do 10+ inline WebFetch/WebSearch in the main conversation.\n" + "User override (per-turn escape hatch): if the user's first message explicitly opts " + "out of the workflow (\"跳过 trellis\" / \"别走流程\" / \"小修一下\" / \"直接改\" / " + "\"skip trellis\" / \"no task\" / \"just do it\"), honor it for this turn — " + "acknowledge briefly and proceed without creating a task. Per-turn only." + ) + + # Case 2: Stale pointer — task dir was deleted + task_ref = active.task_path + task_dir = _resolve_task_dir(trellis_dir, task_ref) + if active.stale or not task_dir.is_dir(): + return ( + f"Status: STALE POINTER\nTask: {task_ref}\n" + f"Source: {active.source}\n" + f"Next-Action: Run `python3 ./.trellis/scripts/task.py finish` to clear the stale pointer, " + "then ask the user what to work on next." + ) + + # Read task.json + task_json_path = task_dir / "task.json" + task_data = {} + if task_json_path.is_file(): + try: + task_data = json.loads(task_json_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, PermissionError): + pass + + task_title = task_data.get("title", task_ref) + task_status = task_data.get("status", "unknown") + + # Case 3: Task completed — time to archive + if task_status == "completed": + return ( + f"Status: COMPLETED\nTask: {task_title}\n" + f"Source: {active.source}\n" + f"Next-Action: Load skill `trellis-update-spec` to capture learnings, " + f"then archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}`." + ) + + has_prd = (task_dir / "prd.md").is_file() + + # Case 4: No PRD — still in Plan phase + if not has_prd: + return ( + f"Status: PLANNING\nTask: {task_title}\n" + f"Source: {active.source}\n" + "Next-Action: Load skill `trellis-brainstorm` to clarify requirements with the user " + "and produce prd.md in the task directory.\n" + "Research reminder: when the task needs external research (tool comparison, docs, " + "conventions survey), spawn `trellis-research` sub-agents — don't WebFetch/WebSearch " + "inline in the main session. Findings go to `{task_dir}/research/*.md`; PRD only links to them." + ) + + # Case 4b: PRD exists but implement.jsonl has only seed (no curated entries) — Phase 1.3 gate + implement_jsonl = task_dir / "implement.jsonl" + if implement_jsonl.is_file() and not _has_curated_jsonl_entry(implement_jsonl): + return ( + f"Status: PLANNING (Phase 1.3)\nTask: {task_title}\n" + f"Source: {active.source}\n" + "Next-Action: Curate `implement.jsonl` and `check.jsonl` with the spec + research files " + "the Phase 2 sub-agents will need. Only spec paths (`.trellis/spec/**/*.md`) and research " + "files (`{TASK_DIR}/research/*.md`) — no code paths. Run " + "`python3 ./.trellis/scripts/get_context.py --mode packages` to list available specs, " + "then edit the jsonl files or use `python3 ./.trellis/scripts/task.py add-context`. " + "See `.trellis/workflow.md` Phase 1.3 for details." + ) + + # Case 5: PRD + curated jsonl (or agent-less platform with no jsonl) — enter Execute phase + return ( + f"Status: READY\nTask: {task_title}\n" + f"Source: {active.source}\n" + "Next required action: dispatch `trellis-implement` per Phase 2.1. " + "For agent-capable platforms, the default is to NOT edit code in the main session. " + "After implementation, dispatch `trellis-check` per Phase 2.2 before reporting completion.\n" + "Sub-agent roster: `trellis-implement` (writes code), `trellis-check` (verifies + self-fixes), " + "`trellis-research` (persists findings to `research/*.md` — use when you'd otherwise do " + "multiple WebFetch/WebSearch inline).\n" + "User override (per-turn escape hatch): if the user's CURRENT message explicitly tells the " + "main session to handle it directly (\"你直接改\" / \"别派 sub-agent\" / \"main session 写就行\" / " + "\"do it inline\" / \"不用 sub-agent\"), honor it for this turn and edit code directly. " + "Per-turn only; do NOT invent an override the user did not say." + ) + + +def _load_trellis_config(trellis_dir: Path, input_data: dict) -> tuple: + """Load Trellis config for session-start decisions. + + Returns: + (is_mono, packages_dict, spec_scope, task_pkg, default_pkg) + """ + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + + try: + from common.config import get_default_package, get_packages, get_spec_scope, is_monorepo # type: ignore[import-not-found] + from common.paths import get_current_task # type: ignore[import-not-found] + + repo_root = trellis_dir.parent + is_mono = is_monorepo(repo_root) + packages = get_packages(repo_root) or {} + scope = get_spec_scope(repo_root) + + # Get active task's package + task_pkg = None + current = get_current_task( + repo_root, + input_data, + platform=_detect_platform(input_data), + ) + if current: + task_json = repo_root / current / "task.json" + if task_json.is_file(): + try: + data = json.loads(task_json.read_text(encoding="utf-8")) + if isinstance(data, dict): + tp = data.get("package") + if isinstance(tp, str) and tp: + task_pkg = tp + except (json.JSONDecodeError, OSError): + pass + + default_pkg = get_default_package(repo_root) + return is_mono, packages, scope, task_pkg, default_pkg + except Exception: + return False, {}, None, None, None + + +def _check_legacy_spec(trellis_dir: Path, is_mono: bool, packages: dict) -> str | None: + """Check for legacy spec directory structure in monorepo. + + Returns warning message if legacy structure detected, None otherwise. + """ + if not is_mono or not packages: + return None + + spec_dir = trellis_dir / "spec" + if not spec_dir.is_dir(): + return None + + # Check for legacy flat spec dirs (spec/backend/, spec/frontend/ with index.md) + has_legacy = False + for legacy_name in ("backend", "frontend"): + legacy_dir = spec_dir / legacy_name + if legacy_dir.is_dir() and (legacy_dir / "index.md").is_file(): + has_legacy = True + break + + if not has_legacy: + return None + + # Check which packages are missing spec// directory + missing = [ + name for name in sorted(packages.keys()) + if not (spec_dir / name).is_dir() + ] + + if not missing: + return None # All packages have spec dirs + + if len(missing) == len(packages): + return ( + f"[!] Legacy spec structure detected: found `spec/backend/` or `spec/frontend/` " + f"but no package-scoped `spec//` directories.\n" + f"Monorepo packages: {', '.join(sorted(packages.keys()))}\n" + f"Please reorganize: `spec/backend/` -> `spec//backend/`" + ) + return ( + f"[!] Partial spec migration detected: packages {', '.join(missing)} " + f"still missing `spec//` directory.\n" + f"Please complete migration for all packages." + ) + + +def _resolve_spec_scope( + is_mono: bool, + packages: dict, + scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve which packages should have their specs injected. + + Returns: + Set of package names to include, or None for full scan. + """ + if not is_mono or not packages: + return None # Single-repo: full scan + + if scope is None: + return None # No scope configured: full scan + + if isinstance(scope, str) and scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None # Fallback to full scan + + if isinstance(scope, list): + valid = set() + for entry in scope: + if entry in packages: + valid.add(entry) + else: + print( + f"Warning: spec_scope contains unknown package: {entry}, ignoring", + file=sys.stderr, + ) + + if valid: + # Warn if active task is out of scope + if task_pkg and task_pkg not in valid: + print( + f"Warning: active task package '{task_pkg}' is out of configured spec_scope", + file=sys.stderr, + ) + return valid + + # All entries invalid: fallback chain + print( + "Warning: all spec_scope entries invalid, falling back to task/default/full", + file=sys.stderr, + ) + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None # Full scan + + return None # Unknown scope type: full scan + + +def _extract_range(content: str, start_header: str, end_header: str) -> str: + """Extract lines starting at `## start_header` up to (but excluding) `## end_header`. + + Both parameters are full header lines WITHOUT the `## ` prefix (e.g. "Phase Index"). + Returns empty string if start header is not found. + End header missing → extracts to end of file. + """ + lines = content.splitlines() + start: int | None = None + end: int = len(lines) + start_match = f"## {start_header}" + end_match = f"## {end_header}" + for i, line in enumerate(lines): + stripped = line.strip() + if start is None and stripped == start_match: + start = i + continue + if start is not None and stripped == end_match: + end = i + break + if start is None: + return "" + return "\n".join(lines[start:end]).rstrip() + + +_BREADCRUMB_TAG_RE = re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]", + re.DOTALL, +) + + +def _strip_breadcrumb_tag_blocks(content: str) -> str: + """Remove `[workflow-state:STATUS]...[/workflow-state:STATUS]` blocks. + + The tag blocks live inside `## Phase Index` (since v0.5.0-rc.0, when + they were colocated with their phase summaries) and are consumed by the + UserPromptSubmit hook (`inject-workflow-state.py`). The session-start + payload already covers the full step bodies, so re-inlining the + breadcrumbs here would just duplicate context. + """ + return _BREADCRUMB_TAG_RE.sub("", content) + + +def _build_workflow_overview(workflow_path: Path) -> str: + """Inject the workflow guide for the session. + + Contents: + 1. Section index (all `## ` headings — navigation) + 2. Phase Index section (rules, skill routing table, anti-rationalization table) + 3. Phase 1/2/3 step-level details (the actual how-to for each step) + + The meta sections (Core Principles / Trellis System / Customizing + Trellis) are NOT injected — Core Principles is short prose the AI can + Read on demand; Trellis System lists reference commands duplicated in + step bodies; Customizing Trellis is for forks. Workflow-state breadcrumb + tag blocks (which now live inside Phase Index since v0.5.0-rc.0) are + stripped from the extracted range — they're consumed by the + UserPromptSubmit hook, not the session-start preamble. + + Total budget: Phase Index ~2 KB + Phase 1/2/3 ~7 KB = ~9 KB. + """ + content = read_file(workflow_path) + if not content: + return "No workflow.md found" + + out_lines = [ + "# Development Workflow — Section Index", + "Full guide: .trellis/workflow.md (read on demand)", + "", + "## Table of Contents", + ] + for line in content.splitlines(): + if line.startswith("## "): + out_lines.append(line) + out_lines += ["", "---", ""] + + # Extract Phase Index through the end of Phase 3 (before "Customizing + # Trellis" — the docs-for-forks footer added in v0.5.0-rc.0). Since + # sections appear in order Phase Index → Phase 1 → Phase 2 → Phase 3 → + # Customizing Trellis, a single range grab captures all four. The + # breadcrumb tag blocks now embedded inside Phase Index are stripped so + # they don't duplicate the per-turn UserPromptSubmit injection. + phases = _extract_range( + content, "Phase Index", "Customizing Trellis (for forks)" + ) + if phases: + out_lines.append(_strip_breadcrumb_tag_blocks(phases).rstrip()) + + return "\n".join(out_lines).rstrip() + + +def main(): + if should_skip_injection(): + sys.exit(0) + + try: + hook_input = json.loads(sys.stdin.read()) + if not isinstance(hook_input, dict): + hook_input = {} + except (json.JSONDecodeError, ValueError): + hook_input = {} + + # Try platform-specific env vars, hook cwd, fallback to cwd + project_dir_env_vars = [ + "CLAUDE_PROJECT_DIR", + "QODER_PROJECT_DIR", + "CODEBUDDY_PROJECT_DIR", + "FACTORY_PROJECT_DIR", + "CURSOR_PROJECT_DIR", + "GEMINI_PROJECT_DIR", + "KIRO_PROJECT_DIR", + "COPILOT_PROJECT_DIR", + ] + project_dir = None + for var in project_dir_env_vars: + val = os.environ.get(var) + if val: + project_dir = Path(_normalize_windows_shell_path(val)).resolve() + break + if project_dir is None: + project_dir = Path(_normalize_windows_shell_path(hook_input.get("cwd", "."))).resolve() + + trellis_dir = project_dir / ".trellis" + context_key = _resolve_context_key(trellis_dir, hook_input) + _persist_context_key_for_bash(context_key) + + # Load config for scope filtering and legacy detection + is_mono, packages, scope_config, task_pkg, default_pkg = _load_trellis_config( + trellis_dir, + hook_input, + ) + allowed_pkgs = _resolve_spec_scope(is_mono, packages, scope_config, task_pkg, default_pkg) + + output = StringIO() + + output.write(""" +You are starting a new session in a Trellis-managed project. +Read and follow all instructions below carefully. + + +""") + output.write(FIRST_REPLY_NOTICE) + output.write("\n\n") + + # Legacy migration warning + legacy_warning = _check_legacy_spec(trellis_dir, is_mono, packages) + if legacy_warning: + output.write(f"\n{legacy_warning}\n\n\n") + + output.write("\n") + context_script = trellis_dir / "scripts" / "get_context.py" + output.write(run_script(context_script, context_key)) + output.write("\n\n\n") + + output.write("\n") + output.write(_build_workflow_overview(trellis_dir / "workflow.md")) + output.write("\n\n\n") + + output.write("\n") + output.write( + "Project spec indexes are listed by path below. Each index contains a " + "**Pre-Development Checklist** listing the specific guideline files to " + "read before coding.\n\n" + "- If you're spawning an implement/check sub-agent, context is injected " + "or loaded by the sub-agent via `{task}/implement.jsonl` / `check.jsonl`. " + "You do NOT need to read these indexes yourself.\n" + "- For agent-capable platforms, the default is to dispatch " + "`trellis-implement` and `trellis-check` (so JSONL context is loaded by " + "the sub-agents) rather than editing code in the main session. " + "Honor a per-turn user override only if the user's current message " + "explicitly opts out (see below for override phrases).\n\n" + ) + + # guides/ is cross-package thinking — always include inline (small, broadly useful) + guides_index = trellis_dir / "spec" / "guides" / "index.md" + if guides_index.is_file(): + output.write("## guides (inlined — cross-package thinking guides)\n") + output.write(read_file(guides_index)) + output.write("\n\n") + + # Other spec indexes — paths only (main agent reads on demand; + # sub-agents get their specific specs via jsonl injection) + paths: list[str] = [] + spec_dir = trellis_dir / "spec" + if spec_dir.is_dir(): + for sub in sorted(spec_dir.iterdir()): + if not sub.is_dir() or sub.name.startswith("."): + continue + if sub.name == "guides": + continue # already inlined above + + index_file = sub / "index.md" + if index_file.is_file(): + # Flat spec dir (single-repo layer like spec/backend/) + paths.append(f".trellis/spec/{sub.name}/index.md") + else: + # Nested package dirs (monorepo: spec///index.md) + # Apply scope filter + if allowed_pkgs is not None and sub.name not in allowed_pkgs: + continue + for nested in sorted(sub.iterdir()): + if not nested.is_dir(): + continue + nested_index = nested / "index.md" + if nested_index.is_file(): + paths.append( + f".trellis/spec/{sub.name}/{nested.name}/index.md" + ) + + if paths: + output.write("## Available spec indexes (read on demand)\n") + for p in paths: + output.write(f"- {p}\n") + output.write("\n") + + output.write( + "Discover more via: " + "`python3 ./.trellis/scripts/get_context.py --mode packages`\n" + ) + output.write("\n\n") + + # Check task status and inject structured tag + task_status = _get_task_status(trellis_dir, hook_input) + output.write(f"\n{task_status}\n\n\n") + + output.write(""" +Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them. +When the user sends the first message, follow and the workflow guide. +If a task is READY, execute its Next required action without asking whether to continue. +""") + + result = { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": output.getvalue(), + } + } + + # Output JSON - stdout is already configured for UTF-8 + print(json.dumps(result, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..fa84a55 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,73 @@ +{ + "env": { + "CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1" + }, + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 10 + } + ] + }, + { + "matcher": "clear", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 10 + } + ] + }, + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/session-start.py", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Task", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/inject-subagent-context.py", + "timeout": 30 + } + ] + }, + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/inject-subagent-context.py", + "timeout": 30 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/inject-workflow-state.py", + "timeout": 5 + } + ] + } + ] + }, + "enabledPlugins": {} +} diff --git a/.claude/skills/trellis-before-dev/SKILL.md b/.claude/skills/trellis-before-dev/SKILL.md new file mode 100644 index 0000000..9c6ec9c --- /dev/null +++ b/.claude/skills/trellis-before-dev/SKILL.md @@ -0,0 +1,34 @@ +--- +name: trellis-before-dev +description: "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards." +--- + +Read the relevant development guidelines before starting your task. + +Execute these steps: + +1. **Discover packages and their spec layers**: + ```bash + python3 ./.trellis/scripts/get_context.py --mode packages + ``` + +2. **Identify which specs apply** to your task based on: + - Which package you're modifying (e.g., `cli/`, `docs-site/`) + - What type of work (backend, frontend, unit-test, docs, etc.) + +3. **Read the spec index** for each relevant module: + ```bash + cat .trellis/spec///index.md + ``` + Follow the **"Pre-Development Checklist"** section in the index. + +4. **Read the specific guideline files** listed in the Pre-Development Checklist that are relevant to your task. The index is NOT the goal — it points you to the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). Read those files to understand the coding standards and patterns. + +5. **Always read shared guides**: + ```bash + cat .trellis/spec/guides/index.md + ``` + +6. Understand the coding standards and patterns you need to follow, then proceed with your development plan. + +This step is **mandatory** before writing any code. diff --git a/.opencode/commands/trellis/brainstorm.md b/.claude/skills/trellis-brainstorm/SKILL.md similarity index 81% rename from .opencode/commands/trellis/brainstorm.md rename to .claude/skills/trellis-brainstorm/SKILL.md index bc2b8af..e160187 100644 --- a/.opencode/commands/trellis/brainstorm.md +++ b/.claude/skills/trellis-brainstorm/SKILL.md @@ -1,3 +1,8 @@ +--- +name: trellis-brainstorm +description: "Guides collaborative requirements discovery before implementation. Creates task directory, seeds PRD, asks high-value questions one at a time, researches technical choices, and converges on MVP scope. Use when requirements are unclear, there are multiple valid approaches, or the user describes a new feature or complex task." +--- + # Brainstorm - Requirements Discovery (AI Coding Enhanced) Guide AI through collaborative requirements discovery **before implementation**, optimized for AI coding workflows: @@ -11,7 +16,7 @@ Guide AI through collaborative requirements discovery **before implementation**, ## When to Use -Triggered from `/trellis:start` when the user describes a development task, especially when: +Triggered from /trellis:start when the user describes a development task, especially when: * requirements are unclear or evolving * there are multiple valid implementation paths @@ -57,6 +62,9 @@ Before any Q&A, ensure a task exists. If none exists, create one immediately. TASK_DIR=$(python3 ./.trellis/scripts/task.py create "brainstorm: " --slug ) ``` +Use a slug without a date prefix. `task.py create` adds the `MM-DD-` +directory prefix automatically. + Create/seed `prd.md` immediately with what you know: ```markdown @@ -184,18 +192,61 @@ Examples: * The user asks for "best practice", "how others do it", "recommendation" * The user can't reasonably enumerate options -### Research steps +### Delegate to `trellis-research` sub-agent (don't research inline) -1. Identify 2–4 comparable tools/patterns +For each research topic, **spawn a `trellis-research` sub-agent via the Task tool** — don't do WebFetch / WebSearch / `gh api` inline in the main conversation. + +Why: +- The sub-agent has its own context window → doesn't pollute brainstorm context with raw tool output +- It persists findings to `{TASK_DIR}/research/.md` (the contract — see `workflow.md` Phase 1.2) +- It returns only `{file path, one-line summary}` to the main agent +- Independent topics can be **parallelized** — spawn multiple sub-agents in one tool call + +Agent type: `trellis-research` +Task description template: "Research ; persist findings to `{TASK_DIR}/research/.md`." + +❌ Bad (what you must NOT do): +``` +Main agent: WebFetch(url-A) → WebFetch(url-B) → Bash(gh api ...) + → WebSearch(q1) → WebSearch(q2) → ... (10+ inline calls) + → Write(research/topic.md) +``` +→ Pollutes main context with raw HTML/JSON, burns tokens. + +✅ Good: +``` +Main agent: Task(subagent_type="trellis-research", + prompt="Research topic A; persist to research/topic-a.md") + + Task(subagent_type="trellis-research", + prompt="Research topic B; persist to research/topic-b.md") + + Task(subagent_type="trellis-research", + prompt="Research topic C; persist to research/topic-c.md") +→ Reads research/topic-{a,b,c}.md after they finish. +``` + +### Research steps (to pass into each sub-agent prompt) + +Each `trellis-research` sub-agent should: + +1. Identify 2–4 comparable tools/patterns for its topic 2. Summarize common conventions and why they exist 3. Map conventions onto our repo constraints -4. Produce **2–3 feasible approaches** for our project +4. Write findings to `{TASK_DIR}/research/.md` + +Main agent then reads the persisted files and produces **2–3 feasible approaches** in PRD. ### Research output format (PRD) -Add a section in PRD (either within Technical Notes or as its own): +The PRD itself should only reference the persisted research files, not duplicate their content. Add a `## Research References` section pointing at `research/*.md`. + +Optionally, add a convergence section with feasible approaches derived from the research: ```markdown +## Research References + +* [`research/.md`](research/.md) — +* [`research/.md`](research/.md) — + ## Research Notes ### What similar tools do diff --git a/.opencode/commands/trellis/break-loop.md b/.claude/skills/trellis-break-loop/SKILL.md similarity index 88% rename from .opencode/commands/trellis/break-loop.md rename to .claude/skills/trellis-break-loop/SKILL.md index 9905751..ef2b50c 100644 --- a/.opencode/commands/trellis/break-loop.md +++ b/.claude/skills/trellis-break-loop/SKILL.md @@ -1,6 +1,11 @@ +--- +name: trellis-break-loop +description: "Deep bug analysis to break the fix-forget-repeat cycle. Analyzes root cause category, why fixes failed, prevention mechanisms, and captures knowledge into specs. Use after fixing a bug to prevent the same class of bugs." +--- + # Break the Loop - Deep Bug Analysis -When debug is complete, use this command for deep analysis to break the "fix bug -> forget -> repeat" cycle. +When debug is complete, use this for deep analysis to break the "fix bug -> forget -> repeat" cycle. --- @@ -37,7 +42,7 @@ What mechanisms would prevent this from happening again? |------|-------------|---------| | **Documentation** | Write it down so people know | Update thinking guide | | **Architecture** | Make the error impossible structurally | Type-safe wrappers | -| **Compile-time** | TypeScript strict, no any | Signature change causes compile error | +| **Compile-time** | Strict type checking, no escape hatches | Signature change causes compile error | | **Runtime** | Monitoring, alerts, scans | Detect orphan entities | | **Test Coverage** | E2E tests, integration tests | Verify full flow | | **Code Review** | Checklist, PR template | "Did you check X?" | @@ -56,10 +61,10 @@ What broader problems does this bug reveal? Solidify insights into the system: - [ ] Update `.trellis/spec/guides/` thinking guides -- [ ] Update `.trellis/spec/backend/` or `frontend/` docs +- [ ] Update relevant `.trellis/spec/` docs - [ ] Create issue record (if applicable) - [ ] Create feature ticket for root fix -- [ ] Update check commands if needed +- [ ] Update check guidelines if needed --- diff --git a/.claude/skills/trellis-check/SKILL.md b/.claude/skills/trellis-check/SKILL.md new file mode 100644 index 0000000..16b3dc4 --- /dev/null +++ b/.claude/skills/trellis-check/SKILL.md @@ -0,0 +1,92 @@ +--- +name: trellis-check +description: "Comprehensive quality verification: spec compliance, lint, type-check, tests, cross-layer data flow, code reuse, and consistency checks. Use when code is written and needs quality verification, before committing changes, or to catch context drift during long sessions." +--- + +# Code Quality Check + +Comprehensive quality verification for recently written code. Combines spec compliance, cross-layer safety, and pre-commit checks. + +--- + +## Step 1: Identify What Changed + +```bash +git diff --name-only HEAD +git status +``` + +## Step 2: Read Applicable Specs + +```bash +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +For each changed package/layer, read the spec index and follow its **Quality Check** section: + +```bash +cat .trellis/spec///index.md +``` + +Read the specific guideline files referenced — the index is a pointer, not the goal. + +## Step 3: Run Project Checks + +Run the project's lint, type-check, and test commands. Fix any failures before proceeding. + +## Step 4: Review Against Checklist + +### Code Quality + +- [ ] Linter passes? +- [ ] Type checker passes (if applicable)? +- [ ] Tests pass? +- [ ] No debug logging left in? +- [ ] No suppressed warnings or type-safety bypasses? + +### Test Coverage + +- [ ] New function → unit test added? +- [ ] Bug fix → regression test added? +- [ ] Changed behavior → existing tests updated? + +### Spec Sync + +- [ ] Does `.trellis/spec/` need updates? (new patterns, conventions, lessons learned) + +> "If I fixed a bug or discovered something non-obvious, should I document it so future me won't hit the same issue?" → If YES, update the relevant spec doc. + +## Step 5: Cross-Layer Dimensions (if applicable) + +Skip this step if your change is confined to a single layer. + +### A. Data Flow (changes touch 3+ layers) + +- [ ] Read flow traces correctly: Storage → Service → API → UI +- [ ] Write flow traces correctly: UI → API → Service → Storage +- [ ] Types/schemas correctly passed between layers? +- [ ] Errors properly propagated to caller? + +### B. Code Reuse (modifying constants, creating utilities) + +- [ ] Searched for existing similar code before creating new? + ```bash + grep -r "pattern" src/ + ``` +- [ ] If 2+ places define same value → extracted to shared constant? +- [ ] After batch modification, all occurrences updated? + +### C. Import/Dependency (creating new files) + +- [ ] Correct import paths (relative vs absolute)? +- [ ] No circular dependencies? + +### D. Same-Layer Consistency + +- [ ] Other places using the same concept are consistent? + +--- + +## Step 6: Report and Fix + +Report violations found and fix them directly. Re-run project checks after fixes. diff --git a/.claude/skills/trellis-meta/SKILL.md b/.claude/skills/trellis-meta/SKILL.md new file mode 100644 index 0000000..590bfac --- /dev/null +++ b/.claude/skills/trellis-meta/SKILL.md @@ -0,0 +1,73 @@ +--- +name: trellis-meta +description: "Understand and customize the local Trellis architecture inside a user project. Use when modifying .trellis plus platform hooks, settings, agents, skills, commands, prompts, or workflows generated by trellis init." +--- + +# Trellis Meta + +This skill is for local Trellis users who have already run `trellis init` in a project. After reading it, an AI should understand the Trellis architecture, operating model, and customization entry points inside that user project, then modify the generated `.trellis/` and platform directory files according to the user's request. + +The default operating scope is local files in the user project: + +- `.trellis/`: workflow, config, tasks, spec, workspace, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not assume the user has the Trellis source repository. Do not default to modifying the global npm install directory or `node_modules`. + +## How To Use + +1. Read `references/local-architecture/overview.md` first to establish the local Trellis system model. +2. If the request involves a specific AI tool, read `references/platform-files/platform-map.md` and the relevant platform file notes. +3. If the user wants to change behavior, read `references/customize-local/overview.md`, then open the specific customization topic. +4. Before editing, read the actual files in the user project and treat local content as authoritative. + +## References + +### Local Architecture + +- `references/local-architecture/overview.md`: The three-layer local Trellis architecture and customization principles. +- `references/local-architecture/generated-files.md`: Files generated by `trellis init` and their customization boundaries. +- `references/local-architecture/workflow.md`: Phases, routing, and workflow-state blocks in `.trellis/workflow.md`. +- `references/local-architecture/task-system.md`: Task directories, active tasks, JSONL context, and task runtime. +- `references/local-architecture/spec-system.md`: How `.trellis/spec/` is organized and injected. +- `references/local-architecture/workspace-memory.md`: `.trellis/workspace/`, journals, and cross-session memory. +- `references/local-architecture/context-injection.md`: Hooks, sub-agent preludes, and context injection paths. + +### Platform Files + +- `references/platform-files/overview.md`: How shared `.trellis/` files relate to platform directories. +- `references/platform-files/platform-map.md`: Platform directories and paths for skills, agents, hooks, and extensions. +- `references/platform-files/hooks-and-settings.md`: How settings/config files, hooks, plugins, and extensions connect to Trellis. +- `references/platform-files/agents.md`: Local file responsibilities for `trellis-research`, `trellis-implement`, and `trellis-check`. +- `references/platform-files/skills-and-commands.md`: Differences between skills, commands, prompts, and workflows, plus how to change them. + +### Local Customization + +- `references/customize-local/overview.md`: Choose the right local customization entry point for the user's request. +- `references/customize-local/change-workflow.md`: Change phases, routing, next actions, and workflow-state. +- `references/customize-local/change-task-lifecycle.md`: Change task creation, status, archive behavior, and hooks. +- `references/customize-local/change-context-loading.md`: Change how tasks, specs, journals, and hook context are loaded. +- `references/customize-local/change-hooks.md`: Change platform hooks, settings, and shell session bridges. +- `references/customize-local/change-agents.md`: Change research, implement, and check agent behavior. +- `references/customize-local/change-skills-or-commands.md`: Add or modify local skills, commands, prompts, and workflows. +- `references/customize-local/change-spec-structure.md`: Adjust the project spec structure under `.trellis/spec/`. +- `references/customize-local/add-project-local-conventions.md`: Put team rules into project-local specs or local skills. + +## Current Rules + +- `.trellis/workflow.md` is the local workflow source of truth. +- `.trellis/config.yaml` is the project-level Trellis configuration and task hook configuration entry point. +- `.trellis/spec/` stores the user's project-specific coding conventions and design constraints. +- `.trellis/tasks/` stores task PRDs, technical notes, research files, and JSONL context. +- `.trellis/workspace/` stores developer journals and cross-session memory. +- Platform settings/config files decide which hooks, agents, skills, commands, prompts, and workflows actually run. +- `.trellis/.template-hashes.json` and `.trellis/.runtime/` are management/runtime state files. Confirm necessity before editing them. + +## Do Not + +- Do not treat Trellis upstream source code as the default target for local customization. +- Do not modify the global npm install directory or `node_modules/@mindfoldhq/trellis` to implement project needs. +- Do not overwrite user-modified local files with default templates. +- Do not put team-private project rules into the public `trellis-meta`; put project rules in `.trellis/spec/` or a project-local skill. +- Do not describe removed historical mechanisms as current Trellis behavior. diff --git a/.claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md b/.claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md new file mode 100644 index 0000000..608aaa6 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md @@ -0,0 +1,83 @@ +# Add Project-Local Conventions + +Often the user does not need to change Trellis mechanics; they need local AI to understand their team's conventions. In that case, prefer `.trellis/spec/` or a project-local skill instead of editing `trellis-meta`. + +## Where To Put Things + +| Content type | Location | +| --- | --- | +| Rules code must follow | `.trellis/spec//` | +| Cross-layer thinking methods | `.trellis/spec/guides/` | +| AI capability for a project-specific flow | Platform-local skill | +| One-off task material | `.trellis/tasks//` | +| Session summary | `.trellis/workspace//journal-N.md` | + +## Create A Project-Local Skill + +If the user wants AI to know "how this project customizes Trellis," create a local skill: + +```text +.claude/skills/trellis-local/ +└── SKILL.md +``` + +Example: + +```md +--- +name: trellis-local +description: "Project-local Trellis customizations for this repository. Use when changing this project's Trellis workflow, hooks, local agents, or team-specific conventions." +--- + +# Trellis Local + +## Local Scope + +This skill documents this repository's Trellis customizations only. + +## Custom Workflow Rules + +- ... + +## Local Hook Changes + +- ... + +## Local Agent Changes + +- ... +``` + +For multi-platform projects, place equivalent versions in other platform skill directories, or use `.agents/skills/` for platforms that support the shared layer. + +## Write To `.trellis/spec/` + +If the content is a coding convention, write it to spec. Examples: + +```text +.trellis/spec/backend/error-handling.md +.trellis/spec/frontend/components.md +.trellis/spec/guides/cross-platform-thinking-guide.md +``` + +After writing it, update the corresponding `index.md` so AI can find the new rule from the entry point. + +## Make The Current Task Use New Conventions + +After writing a spec, add it to the current task context: + +```bash +python3 ./.trellis/scripts/task.py add-context implement ".trellis/spec/backend/error-handling.md" "Error handling conventions" +python3 ./.trellis/scripts/task.py add-context check ".trellis/spec/backend/error-handling.md" "Review error handling" +``` + +## Do Not Store Project-Private Rules In `trellis-meta` + +`trellis-meta` is a public skill for understanding Trellis architecture and local customization entry points. Put project-private content in: + +- `.trellis/spec/` +- a project-local skill +- the current task +- workspace journal + +This prevents future updates to Trellis's built-in `trellis-meta` from overwriting the team's own conventions. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-agents.md b/.claude/skills/trellis-meta/references/customize-local/change-agents.md new file mode 100644 index 0000000..9b63531 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-agents.md @@ -0,0 +1,54 @@ +# Change Local Agents + +When the user wants to change `trellis-research`, `trellis-implement`, or `trellis-check` behavior, edit platform agent files in the user project. + +## Read These Files First + +1. Target platform agent directory +2. `.trellis/workflow.md` Phase 2 / research routing +3. Current task `prd.md` +4. Current task `implement.jsonl` / `check.jsonl` +5. Relevant hook or agent prelude + +## Common Paths + +| Platform | Path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +Use the actual paths in the user project as authoritative. + +## Common Needs + +| Need | Which agent to edit | +| --- | --- | +| Research must write files, not only reply in chat | `trellis-research` | +| Certain local specs must be read before implementation | `trellis-implement` + `implement.jsonl` configuration rules | +| Specific commands must run during checking | `trellis-check` | +| Agent must not modify certain directories | The corresponding agent's write boundary instructions | +| Agent output format must be fixed | The corresponding agent's final/reporting instructions | + +## Modification Principles + +1. **Preserve role boundaries**: research investigates and persists; implement writes implementation; check reviews and fixes. +2. **Do not hard-code project specs into agents**: long-term specs belong in `.trellis/spec/`; agents are responsible for reading them. +3. **Make read order explicit**: active task -> PRD -> info -> JSONL -> spec/research. +4. **Make write boundaries explicit**: which directories may be written and which may not. +5. **Synchronize across platforms**: when the user configured multiple platforms, decide whether to change only the current platform or all platform agents. + +## Agent Pull Platforms + +If an agent file contains a prelude for "read task/context after startup," do not remove those steps when editing. Otherwise the agent will work only from chat context and bypass Trellis's core mechanism. + +## Hook Push Platforms + +If context is injected by a hook, the agent file should still retain responsibility boundaries. Do not remove PRD/spec requirements from the agent just because a hook injects context. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md b/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md new file mode 100644 index 0000000..556b4e3 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-context-loading.md @@ -0,0 +1,81 @@ +# Change Local Context Loading + +Context loading determines when AI reads workflow, task, spec, research, workspace, and git status. Read this page when the user says "AI does not know the current task," "the agent did not read specs," or "there is too much/too little context." + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/scripts/get_context.py` +3. `.trellis/scripts/common/session_context.py` +4. `.trellis/scripts/common/task_context.py` +5. `.trellis/scripts/common/active_task.py` +6. Current platform hooks or agent files +7. The current task's `implement.jsonl` / `check.jsonl` + +## Context Sources + +| Source | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow and next-action hints. | +| `.trellis/tasks//prd.md` | Current task requirements. | +| `.trellis/tasks//implement.jsonl` | Spec/research to read before implementation. | +| `.trellis/tasks//check.jsonl` | Spec/research to read during checking. | +| `.trellis/spec/` | Project specs. | +| `.trellis/workspace/` | Session records. | +| git status | Current working tree changes. | + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Inject more/less information in new sessions | `session_context.py` or the platform `session-start` hook. | +| Change hints on each user input | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The `inject-workflow-state` hook is parser-only and reads the block verbatim. | +| Agent did not read specs | Task JSONL, agent prelude, `inject-subagent-context` hook. | +| Active task is lost | `active_task.py` and platform session identity propagation. | +| Change JSONL validation rules | `task_context.py`. | + +## JSONL Rules + +`implement.jsonl` / `check.jsonl` are the key context loading interface: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-x/research/api.md", "reason": "API research"} +``` + +Include only spec/research files. Do not put code files that will be modified into these manifests; agents read code files themselves during implementation. + +## Change Session Context + +If the user wants every new session to see more project state, edit: + +- `.trellis/scripts/common/session_context.py` +- the corresponding platform `session-start` hook + +Context cannot grow without bound. Prefer injecting indexes and paths so the AI can read detailed files on demand. + +## Change Sub-Agent Context + +First determine which mode the platform uses: + +- hook push: edit the `inject-subagent-context` hook. +- agent pull: edit the read steps in the corresponding `trellis-implement` / `trellis-check` agent file. + +In both modes, make sure the agent ultimately reads: + +1. active task +2. `prd.md` +3. `info.md` if present +4. the corresponding JSONL +5. spec/research referenced by the JSONL + +## Troubleshooting Order + +```bash +python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/task.py list-context +python3 ./.trellis/scripts/task.py validate +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +Confirm the task and JSONL are correct before editing hooks/agents. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-hooks.md b/.claude/skills/trellis-meta/references/customize-local/change-hooks.md new file mode 100644 index 0000000..5c1ed7a --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-hooks.md @@ -0,0 +1,57 @@ +# Change Local Hooks + +Hooks are the automation layer that connects a platform to Trellis. When the user wants to change "when context is injected," "how shell commands inherit a session," or "which files are read before an agent starts," hooks are usually the edit point. + +## Read These Files First + +1. Target platform settings/config, such as `.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json` +2. Target platform hooks directory +3. `.trellis/scripts/common/active_task.py` +4. `.trellis/scripts/common/session_context.py` +5. `.trellis/workflow.md` + +## Common Hook Types + +| Hook | Purpose | +| --- | --- | +| session-start | Injects a Trellis overview when a session starts, clears, or compacts. | +| workflow-state | Injects a state hint on each user input. | +| sub-agent context | Injects PRD/spec/research before an agent starts. | +| shell session bridge | Lets `task.py` commands in shell see the same session identity. | + +## Modification Steps + +1. Find the hook registration in settings/config. +2. Confirm the registered script path exists. +3. Read the hook script and identify inputs, outputs, and called `.trellis/scripts/`. +4. Modify hook behavior. +5. If the hook depends on workflow content, synchronize `.trellis/workflow.md`. + +## Example: Change New-Session Injection Content + +First find the session-start hook: + +```text +.claude/settings.json +.claude/hooks/session-start.py +``` + +If the hook ultimately calls `.trellis/scripts/get_context.py` or `session_context.py`, editing the local script is usually more robust than hard-coding content in the hook. + +## Example: Agent Did Not Read JSONL + +First confirm: + +```bash +python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/task.py validate +``` + +If the task and JSONL are correct, determine whether the platform uses hook push or agent pull. For hook push, edit `inject-subagent-context`; for agent pull, edit the agent file. + +## Notes + +- Settings handle registration, hook scripts handle behavior; inspect both together. +- Different platforms support different hook events. Do not directly copy another platform's settings. +- Hooks should read project-local `.trellis/`; they should not depend on Trellis upstream source paths. +- Hook failures should produce visible errors so AI does not silently lose context. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md b/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md new file mode 100644 index 0000000..84590a1 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md @@ -0,0 +1,78 @@ +# Change Local Skills, Commands, Prompts, And Workflows + +When the user wants to change AI entry points, auto-trigger rules, or explicit command behavior, edit skills, commands, prompts, or workflows in local platform directories. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Target platform skill/command/prompt/workflow directory +3. Related agent or hook files +4. Whether project rules already exist in `.trellis/spec/` + +## Which Entry Type To Choose + +| Goal | Recommendation | +| --- | --- | +| AI should automatically know a capability | Add or modify a skill. | +| User wants to trigger manually with a command | Add or modify a command/prompt/workflow. | +| Team project conventions | Prefer `.trellis/spec/` or a project-local skill. | +| Change Trellis flow semantics | Synchronize `.trellis/workflow.md`. | + +## Modify A Skill + +A skill is usually: + +```text +/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should be short and responsible for triggering/routing. Put long content in `references/` so AI can read it on demand. + +The frontmatter description should specify when to use the skill. Example: + +```yaml +description: "Use when customizing this project's deployment workflow and release checklist." +``` + +Do not write vague descriptions such as "helpful project skill"; they can trigger incorrectly. + +## Modify A Command/Prompt/Workflow + +Explicit entry points should state: + +- How the user triggers it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +If a command only repeats workflow rules, prefer making it reference/read `.trellis/workflow.md` instead of maintaining a second copy of the flow. + +## Common Paths + +| Platform | Entry directories | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Kilo / Antigravity / Windsurf | workflows + skills | + +## Add A Project-Local Skill + +If the user wants to document team-private customizations, create a project-local skill, for example: + +```text +.claude/skills/project-trellis-local/ +└── SKILL.md +``` + +For multi-platform projects, add equivalent versions in each platform skill directory, or use `.agents/skills/` on platforms that support the shared layer. + +## Notes + +- Do not mix every platform's syntax into one file. +- Do not change only one platform entry point while claiming all platforms are supported. +- Do not hide long-term engineering conventions inside a command; write them to `.trellis/spec/`. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-spec-structure.md b/.claude/skills/trellis-meta/references/customize-local/change-spec-structure.md new file mode 100644 index 0000000..358de51 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-spec-structure.md @@ -0,0 +1,83 @@ +# Change Local Spec Structure + +When the user wants to change the engineering conventions AI follows, add new spec layers, or adjust monorepo package mapping, edit `.trellis/spec/` and `.trellis/config.yaml`. + +## Read These Files First + +1. `.trellis/config.yaml` +2. `.trellis/spec/` +3. `.trellis/workflow.md` Phase 1.3 and Phase 3.3 +4. Current task `implement.jsonl` / `check.jsonl` + +## Common Needs + +| Need | Edit location | +| --- | --- | +| Add backend/frontend/docs/test spec layer | `.trellis/spec//` or `.trellis/spec///` | +| Add shared thinking guides | `.trellis/spec/guides/` | +| Adjust monorepo packages | `packages` in `.trellis/config.yaml` | +| Change default package | `default_package` in `.trellis/config.yaml` | +| Control spec scanning scope | `spec_scope` in `.trellis/config.yaml` | +| Make a task read a new spec | Task `implement.jsonl` / `check.jsonl` | + +## Add A Spec Layer + +Single-repository example: + +```text +.trellis/spec/security/ +├── index.md +└── auth.md +``` + +Monorepo example: + +```text +.trellis/spec/webapp/security/ +├── index.md +└── auth.md +``` + +`index.md` should include: + +- What code this layer applies to. +- Pre-Development Checklist. +- Quality Check. +- Links to specific guideline files. + +## Update Context + +Adding a spec does not mean every task automatically reads it. The current task must reference it in JSONL: + +```bash +python3 ./.trellis/scripts/task.py add-context implement ".trellis/spec/webapp/security/index.md" "Security conventions" +python3 ./.trellis/scripts/task.py add-context check ".trellis/spec/webapp/security/index.md" "Security review rules" +``` + +## Change Monorepo Packages + +Example `.trellis/config.yaml`: + +```yaml +packages: + webapp: + path: apps/web + api: + path: apps/api +default_package: webapp +``` + +After editing, run: + +```bash +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +Use this output to confirm AI can see the correct packages and spec layers. + +## Notes + +- Specs are user project conventions and can be changed according to project needs. +- Do not put temporary task information into specs; put temporary information in the task. +- Do not put long-term conventions only in agents or commands; preserve them in specs. +- After changing spec structure, check whether existing task JSONL files still point to files that exist. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md b/.claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md new file mode 100644 index 0000000..a7a340f --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md @@ -0,0 +1,90 @@ +# Change Local Task Lifecycle + +Task lifecycle includes creation, start, context configuration, finish, archive, parent/child tasks, and lifecycle hooks. The default customization targets are `.trellis/tasks/`, `.trellis/config.yaml`, and `.trellis/scripts/`. + +## Read These Files First + +1. `.trellis/workflow.md` +2. `.trellis/config.yaml` +3. `.trellis/scripts/task.py` +4. `.trellis/scripts/common/task_store.py` +5. `.trellis/scripts/common/task_utils.py` +6. The current task's `.trellis/tasks//task.json` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Automatically sync an external system after task creation | `hooks.after_create` in `.trellis/config.yaml`. | +| Automatically update status after task start | `hooks.after_start` in `.trellis/config.yaml`. | +| Run a script after task finish | `hooks.after_finish` in `.trellis/config.yaml`. | +| Clean external resources after archive | `hooks.after_archive` in `.trellis/config.yaml`. | +| Change default task fields | `.trellis/scripts/common/task_store.py`. | +| Change task parsing/search | `.trellis/scripts/common/task_utils.py`. | +| Change active task behavior | `.trellis/scripts/common/active_task.py`. | + +## lifecycle hooks + +`.trellis/config.yaml` supports: + +```yaml +hooks: + after_create: + - "python3 .trellis/scripts/hooks/my_sync.py create" + after_start: + - "python3 .trellis/scripts/hooks/my_sync.py start" + after_finish: + - "python3 .trellis/scripts/hooks/my_sync.py finish" + after_archive: + - "python3 .trellis/scripts/hooks/my_sync.py archive" +``` + +Hook commands receive the `TASK_JSON_PATH` environment variable, pointing to the current task's `task.json`. Hook failures should usually warn, but not block the main task operation. + +## Change Task Fields + +If the user wants to add project-local fields, prefer putting them under `meta` in `task.json` to avoid breaking existing scripts' assumptions about standard fields. + +Example: + +```json +"meta": { + "linearIssue": "ENG-123", + "risk": "high" +} +``` + +If standard fields really need to change, inspect every local script that reads `task.json`. + +## Change Active Task + +Active task is session-level state stored in `.trellis/.runtime/sessions/`. Do not fall back to a global `.current-task` model. If the user wants to change active task behavior, edit: + +- `.trellis/scripts/common/active_task.py` +- platform hooks or shell session bridges +- active task descriptions in `.trellis/workflow.md` + +### `task.py create` Sets the Active Pointer + +`cmd_create` in `.trellis/scripts/common/task_store.py` calls `set_active_task` best-effort right after writing the new task directory. The behavior: + +- When the calling shell carries session identity (`TRELLIS_CONTEXT_ID` env var, or any platform-specific session env that `resolve_context_key` recognizes — see `active_task.py:_ENV_SESSION_KEYS`), the per-session pointer at `.trellis/.runtime/sessions/.json` is rewritten to point at the new task. The task's `status=planning` and `[workflow-state:planning]` fires on the very next `UserPromptSubmit`. +- When session identity is unavailable (raw CLI invocation outside an AI session, or a platform that doesn't propagate identity to shell), the task directory is still created and `status=planning` is still written, but the active pointer is left untouched. The user can attach the task later with `task.py start ` once they're back in an AI session. + +This makes `[workflow-state:planning]` the live breadcrumb during the brainstorm and JSONL curation work that follows `task.py create`. The pre-R7 behavior left the breadcrumb stuck on `no_task` until `task.py start`, so the planning block was effectively dead text. + +If you fork `task.py` to add a new creation path (e.g. an external import that bypasses `cmd_create`), audit whether your path also calls `set_active_task`. Without that call, your created tasks will not surface as active. The full status writer table is in `.trellis/spec/cli/backend/workflow-state-contract.md`. + +## Modification Steps + +1. Confirm the current task with `python3 ./.trellis/scripts/task.py current --source`. +2. Read the current task's `task.json` and confirm status and fields. +3. For configuration needs, edit `.trellis/config.yaml` first. +4. For script behavior needs, then edit `.trellis/scripts/`. +5. If the AI flow changed, synchronize `.trellis/workflow.md`. + +## Do Not + +- Do not directly edit `.trellis/.runtime/sessions/` to "fix" business state. +- Do not hard-code project-private fields into scripts; prefer `meta`. +- Do not default to asking the user to fork Trellis CLI. diff --git a/.claude/skills/trellis-meta/references/customize-local/change-workflow.md b/.claude/skills/trellis-meta/references/customize-local/change-workflow.md new file mode 100644 index 0000000..4231845 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/change-workflow.md @@ -0,0 +1,64 @@ +# Change Local Workflow + +When the user wants to change Trellis phases, next-action hints, whether to create tasks, whether to use sub-agents, or when to check/wrap up, edit `.trellis/workflow.md` first. + +## Read These Files First + +1. `.trellis/workflow.md` +2. Entry files for the current platform, such as skills/commands/prompts/workflows +3. The current task's `task.json` and `prd.md` + +## Common Needs And Edit Points + +| Need | Edit point | +| --- | --- | +| Change phase names or phase order | `Phase Index` and the corresponding Phase sections. | +| Change whether to create a task when there is no task | `[workflow-state:no_task]` state block. | +| Change the next step during planning | Phase 1 and `[workflow-state:planning]`. | +| Change whether an agent is required during in_progress | Phase 2 and `[workflow-state:in_progress]`. | +| Change wrap-up after completion | Phase 3 and `[workflow-state:completed]`. | +| Change which skill a user intent triggers | `Skill Routing` table. | + +## Modification Steps + +1. Find the relevant section in `.trellis/workflow.md`. +2. When changing rules, keep explicit trigger conditions and next actions. +3. If adding or renaming a skill/agent, synchronize the corresponding files in platform directories. +4. Workflow-state changes only need an edit to the `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook is parser-only — it reads whatever you put in the block. Keep the opening and closing tags' STATUS strings identical (`[workflow-state:foo]…[/workflow-state:foo]`); mismatched STATUS pairs are silently dropped. +5. Make the AI reread `.trellis/workflow.md`; do not keep using rules from the old conversation. + +## Example: Relax Task Creation Requirements + +To change when task creation can be skipped, usually edit `[workflow-state:no_task]`: + +```md +[workflow-state:no_task] +Task is not required when the answer is a one-reply explanation, no files are changed, and no research is needed. +[/workflow-state:no_task] +``` + +If the formal Phase 1 flow also needs to change, synchronize the Phase 1 section. + +## Example: One Platform Does Not Use Sub-Agents + +If the user wants only one platform to avoid sub-agents, first confirm whether that platform has a separate group in the workflow. Then change Phase 2 routing for that platform group instead of deleting all `trellis-implement` / `trellis-check` instructions across platforms. + +## `/trellis:continue` Route Table + +`/trellis:continue` resumes a task by deciding which phase step to load next. The decision combines `task.json.status` with the presence of artifacts inside the task directory. The mapping is fixed in the command itself; forks that add custom statuses must extend both the workflow.md tag block and this table. + +| `status` | Artifact state | Resume at | +| --- | --- | --- | +| `planning` | `prd.md` missing | Phase 1.1 (load `trellis-brainstorm`) | +| `planning` | `prd.md` exists, `implement.jsonl` only has the seed `_example` row | Phase 1.3 (curate JSONL context) | +| `planning` | `prd.md` exists, `implement.jsonl` curated | Phase 1.4 (run `task.py start`) | +| `in_progress` | no implementation in conversation history | Phase 2.1 (`trellis-implement`) | +| `in_progress` | implementation done, no `trellis-check` run | Phase 2.2 (`trellis-check`) | +| `in_progress` | check passed | Phase 3.1 (verify quality + spec update) | +| `completed` | task is still in active tree | Phase 3.5 (run `/trellis:finish-work` to archive) | + +When you add a custom status (e.g. `in-review`), add a `[workflow-state:in-review]` block in `.trellis/workflow.md` for the per-turn breadcrumb AND extend this route table — usually by editing the `/trellis:continue` command file (`.{platform}/commands/trellis/continue.md` or equivalent) to add a row that decides where to resume from. Without the route entry, `/trellis:continue` will fall through to a default branch and the user will not land on the step you intended. + +## Notes + +`.trellis/workflow.md` is the local project workflow, not an immutable template. The user can adapt it to team habits. After editing it, platform entry files may still contain old descriptions, so inspect them too. diff --git a/.claude/skills/trellis-meta/references/customize-local/overview.md b/.claude/skills/trellis-meta/references/customize-local/overview.md new file mode 100644 index 0000000..b53b090 --- /dev/null +++ b/.claude/skills/trellis-meta/references/customize-local/overview.md @@ -0,0 +1,55 @@ +# Local Customization Overview + +This directory is for local AI working in a user project where Trellis was installed through npm and `trellis init` has already been run. The AI should modify generated `.trellis/` and platform directories inside the project, not Trellis CLI upstream source code. + +## First Determine What The User Actually Wants To Change + +| User wording | Read first | +| --- | --- | +| "Change the Trellis flow / phases / next prompt" | `change-workflow.md` | +| "Change task creation, status, archive, or hooks" | `change-task-lifecycle.md` | +| "AI did not read context / change injected content" | `change-context-loading.md` | +| "A platform hook is not behaving as expected" | `change-hooks.md` | +| "Change implement/check/research agent behavior" | `change-agents.md` | +| "Add a skill/command/workflow/prompt" | `change-skills-or-commands.md` | +| "Adjust the project spec structure" | `change-spec-structure.md` | +| "Add team conventions and local notes" | `add-project-local-conventions.md` | + +## General Operation Order + +1. **Confirm platform and directories**: inspect which directories exist, such as `.claude/`, `.codex/`, `.cursor/`. +2. **Confirm the current active task**: run `python3 ./.trellis/scripts/task.py current --source`. +3. **Read the local source of truth**: prefer `.trellis/workflow.md`, `.trellis/config.yaml`, and relevant platform files. +4. **Modify narrowly**: edit only files related to the user's request. +5. **Synchronize semantics**: if a shared flow changes, check whether platform entry points also need changes; if a platform entry changes, check whether `.trellis/workflow.md` still agrees. + +## Local File Priority + +| Layer | Files | +| --- | --- | +| Workflow | `.trellis/workflow.md` | +| Project configuration | `.trellis/config.yaml` | +| Task material | `.trellis/tasks//` | +| Project specs | `.trellis/spec/` | +| Runtime scripts | `.trellis/scripts/` | +| Platform integration | `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, and similar directories | +| Shared skill | `.agents/skills/` | + +## Things Not To Do By Default + +- Do not edit the global npm install directory. +- Do not edit `node_modules/@mindfoldhq/trellis`. +- Do not assume the user has the Trellis GitHub repository. +- Do not overwrite local files already modified by the user with default templates. +- Do not put team project rules into public `trellis-meta`; project rules belong in `.trellis/spec/` or a local skill. + +## When To Inspect Upstream Source + +Switch to an upstream source-code perspective only when the user explicitly expresses one of these goals: + +- "I want to open a PR to Trellis" +- "I want to change npm package publish contents" +- "I want to fork Trellis" +- "I want to modify the generation logic for `trellis init/update`" + +Otherwise, default to modifying local Trellis files inside the user project. diff --git a/.claude/skills/trellis-meta/references/local-architecture/context-injection.md b/.claude/skills/trellis-meta/references/local-architecture/context-injection.md new file mode 100644 index 0000000..fae6fa5 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/context-injection.md @@ -0,0 +1,68 @@ +# Local Context Injection System + +Trellis context injection aims to make AI read the right files at the right time instead of relying on model memory. In a user project, injection is implemented by `.trellis/` scripts together with platform hooks, agents, and skills. + +## Injected Context Types + +| Type | Source | Purpose | +| --- | --- | --- | +| session context | `.trellis/scripts/get_context.py` | Current developer, git status, active task, active tasks, journal, packages. | +| workflow context | `.trellis/workflow.md` | Current Trellis flow and next action. | +| spec context | `.trellis/spec/` + task JSONL | Specs that must be followed during implementation/checking. | +| task context | `.trellis/tasks//prd.md`, `info.md`, `research/` | Current task requirements, design, and research. | +| platform context | Platform hooks/settings/agents | Lets different AI tools read the files above through their own mechanisms. | + +## session-start + +Platforms with session-start support inject a Trellis overview when a session starts, clears, compacts, or receives a similar event. Injected content usually includes: + +- workflow summary. +- current task status. +- active tasks. +- spec index paths. +- developer identity and git status. + +If the user feels the AI does not know the current task in a new session, first check whether the platform's session-start hook or equivalent mechanism is installed and running. + +## workflow-state + +workflow-state is a lightweight hint injected around each user turn. Based on current task status, it selects a block from `.trellis/workflow.md`, such as `no_task`, `planning`, `in_progress`, or `completed`. + +If the user wants to change "what the AI should do next in a given state," edit the corresponding state block in `.trellis/workflow.md` first. + +## sub-agent context + +Implement and check agents need task context. Trellis has two loading modes: + +1. **hook push**: a platform hook injects `prd.md` and the files referenced by `implement.jsonl` / `check.jsonl` before the agent starts. +2. **agent pull**: the agent definition instructs the agent to read the active task, PRD, and JSONL context after startup. + +In both modes, JSONL files in the task directory are the key interface. + +## JSONL Reading Rules + +`implement.jsonl` and `check.jsonl` contain one JSON object per line: + +```jsonl +{"file": ".trellis/spec/backend/index.md", "reason": "Backend rules"} +``` + +Readers should skip seed rows without a `file` field. When configuring JSONL, the AI should include only spec/research files, not pre-register code files that will be modified. + +## Active Task And Context Key + +Active task state lives in `.trellis/.runtime/sessions/` and is isolated per session. Hooks try to resolve the context key from platform events, environment variables, transcript paths, or `TRELLIS_CONTEXT_ID`. + +If shell commands cannot see the same context key, `task.py current --source` may report no active task. In that case, check whether the platform passes session identity into the shell instead of hand-writing a global current-task file. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change session-start injected content | The platform's `session-start` hook or plugin file. | +| Change per-turn workflow-state rules | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The platform workflow-state hook parses these blocks verbatim and embeds no fallback text. | +| Change how sub-agents read context | Platform agent definitions, the `inject-subagent-context` hook, or agent preludes. | +| Change JSONL validation/display | `.trellis/scripts/common/task_context.py`. | +| Change active task resolution | `.trellis/scripts/common/active_task.py`. | + +When modifying context injection, verify two things: new sessions can see the correct task, and sub-agents can see the correct PRD/spec/research. diff --git a/.claude/skills/trellis-meta/references/local-architecture/generated-files.md b/.claude/skills/trellis-meta/references/local-architecture/generated-files.md new file mode 100644 index 0000000..66f832d --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/generated-files.md @@ -0,0 +1,80 @@ +# Local Files Generated After Init + +`trellis init` writes the Trellis runtime into the user project. Later, `trellis update` tries to update Trellis-managed template files, but it uses `.trellis/.template-hashes.json` to determine which files have already been modified by the user. + +This page only describes files that are visible and editable inside the user project. + +## `.trellis/` + +```text +.trellis/ +├── workflow.md +├── config.yaml +├── .developer +├── .version +├── .template-hashes.json +├── .runtime/ +├── scripts/ +├── spec/ +├── tasks/ +└── workspace/ +``` + +| Path | Usually editable? | Notes | +| --- | --- | --- | +| `.trellis/workflow.md` | Yes | Local workflow documentation and AI routing rules. | +| `.trellis/config.yaml` | Yes | Project configuration, hooks, packages, journal line limits, and related settings. | +| `.trellis/spec/` | Yes | Project specs, intended to be updated regularly by users and AI. | +| `.trellis/tasks/` | Yes | Task material and research artifacts, maintained by the task workflow. | +| `.trellis/workspace/` | Yes | Session records, usually written by `add_session.py`. | +| `.trellis/scripts/` | Carefully | Local runtime. It can be customized, but only after understanding the call chain. | +| `.trellis/.runtime/` | No | Runtime state, usually written automatically by hooks/scripts. | +| `.trellis/.developer` | Carefully | Current developer identity. | +| `.trellis/.version` | No | Trellis version record used by update/migration logic. | +| `.trellis/.template-hashes.json` | No | Template hash record. Do not hand-write business rules here. | + +## Platform Directories + +Different platforms generate different directories. Common categories: + +| Category | Example paths | Purpose | +| --- | --- | --- | +| hooks | `.claude/hooks/`, `.codex/hooks/`, `.cursor/hooks/` | Inject session context, workflow-state, and sub-agent context. | +| settings | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Tell the platform when to run hooks or plugins. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define agents such as `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Skills that auto-trigger or can be read by AI. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Explicit user-invoked command or workflow entry points. | + +When modifying a platform directory, also confirm whether `.trellis/workflow.md` still describes the same flow. + +## Meaning Of Template Hashes + +`.trellis/.template-hashes.json` records the content hash from the last time Trellis wrote a template file. `trellis update` uses it to distinguish three cases: + +| Case | Update behavior | +| --- | --- | +| File was not modified by the user | It can be updated automatically. | +| File was modified by the user | Prompt the user to overwrite, keep, or generate `.new`. | +| File is no longer a current template | It may be deleted, renamed, or preserved according to migration rules. | + +When an AI customizes local Trellis files, it does not need to maintain hashes manually. It is normal for Trellis update to recognize the result as "modified by the user." + +## Local Customization Boundaries + +Editable by default: + +- `.trellis/workflow.md` +- `.trellis/config.yaml` +- `.trellis/spec/**` +- `.trellis/scripts/**` +- Platform hooks, settings, agents, skills, commands, prompts, and workflows + +Do not edit by default: + +- Global npm install directory +- `node_modules/@mindfoldhq/trellis` +- Trellis GitHub repository source code +- Concrete state files under `.trellis/.runtime/**` +- Hash contents inside `.trellis/.template-hashes.json` + +Switch to the Trellis CLI source-code perspective only when the user explicitly wants to contribute upstream. diff --git a/.claude/skills/trellis-meta/references/local-architecture/overview.md b/.claude/skills/trellis-meta/references/local-architecture/overview.md new file mode 100644 index 0000000..99c7f73 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/overview.md @@ -0,0 +1,51 @@ +# Local Trellis Architecture Overview + +`trellis-meta` is for user projects that have already run `trellis init`. The user's machine usually has only the npm-installed `trellis` command plus the Trellis files generated inside the project; it may not have the Trellis CLI source code. + +Therefore, when an AI uses this skill, the default customization target is local files inside the user project: + +- `.trellis/`: workflow, tasks, specs, memory, scripts, and runtime state. +- Platform directories: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. +- Shared skill layer: `.agents/skills/`. + +Do not default to guiding the user to fork the Trellis CLI repository. Treat upstream source code as the operating target only when the user explicitly says they want to change Trellis upstream source, publish an npm package, or contribute a PR. + +## Local System Model + +Trellis provides three layers inside a user project: + +1. **Workflow layer**: `.trellis/workflow.md` defines phases, routing, next actions, and prompt blocks. +2. **Persistence layer**: `.trellis/tasks/`, `.trellis/spec/`, and `.trellis/workspace/` store tasks, specs, and session memory. +3. **Platform integration layer**: hooks, settings, agents, skills, commands, prompts, and workflows in platform directories connect the Trellis workflow to different AI tools. + +All three layers live inside the user project, so an AI can read and modify them directly. + +## Core Paths + +| Path | Purpose | +| --- | --- | +| `.trellis/workflow.md` | Workflow phases, skill routing, and workflow-state prompt blocks. | +| `.trellis/config.yaml` | Project configuration, task lifecycle hooks, monorepo package configuration, and journal configuration. | +| `.trellis/spec/` | The user's project-specific coding conventions and thinking guides. | +| `.trellis/tasks/` | Each task's PRD, technical notes, research files, and JSONL context. | +| `.trellis/workspace/` | Per-developer journals and cross-session memory. | +| `.trellis/scripts/` | Local Python runtime used by commands, hooks, and context injection. | +| `.trellis/.runtime/` | Session-level runtime state, such as the current task pointer. | +| `.trellis/.template-hashes.json` | Template hashes for Trellis-managed files, used by update to determine whether local files were modified by the user. | + +## AI Customization Principles + +1. **Find the local source of truth first**: Do not edit from memory. Read `.trellis/workflow.md`, `.trellis/config.yaml`, the relevant platform directory, and related task files first. +2. **Edit the user project, not the npm package cache**: Modify generated files inside the project, not `node_modules` or the global npm install directory. +3. **Keep platform files aligned with `.trellis/`**: If workflow routing changes, also check whether platform skills or commands still describe the same flow. +4. **Put project-specific rules in `.trellis/spec/` or a local skill**: Do not put team conventions into `trellis-meta`. +5. **Preserve user changes**: If a file was already modified locally, work from the current content instead of overwriting it with a default template. + +## How To Use This Directory + +- To understand which files exist after init, read `generated-files.md`. +- To change phases, routing, or next actions, read `workflow.md`. +- To change the task model, JSONL context, or active task behavior, read `task-system.md`. +- To change coding convention injection, read `spec-system.md`. +- To understand journals and cross-session memory, read `workspace-memory.md`. +- To change hooks or sub-agent context loading, read `context-injection.md`. diff --git a/.claude/skills/trellis-meta/references/local-architecture/spec-system.md b/.claude/skills/trellis-meta/references/local-architecture/spec-system.md new file mode 100644 index 0000000..61281f0 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/spec-system.md @@ -0,0 +1,102 @@ +# Local Spec System + +`.trellis/spec/` is the user's project-specific engineering spec library. Trellis is not about making AI memorize conventions; it injects relevant specs or requires the AI to read them at the right time. + +## Directory Model + +A common single-repository structure: + +```text +.trellis/spec/ +├── backend/ +│ ├── index.md +│ └── ... +├── frontend/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +A common monorepo structure: + +```text +.trellis/spec/ +├── cli/ +│ ├── backend/ +│ │ ├── index.md +│ │ └── ... +│ └── unit-test/ +│ ├── index.md +│ └── ... +├── docs-site/ +│ └── docs/ +│ ├── index.md +│ └── ... +└── guides/ + ├── index.md + └── ... +``` + +`index.md` is the entry point for each layer. It should list the Pre-Development Checklist and Quality Check. Specific guidelines live in other Markdown files in the same directory. + +## Package Configuration + +`.trellis/config.yaml` can declare packages: + +```yaml +packages: + cli: + path: packages/cli + docs-site: + path: docs-site + type: submodule +default_package: cli +``` + +The AI can run: + +```bash +python3 ./.trellis/scripts/get_context.py --mode packages +``` + +This command lists packages and spec layers for the current project. Use this output as the reference when configuring context JSONL. + +## How Specs Enter Tasks + +Before a task enters implementation, Phase 1.3 should write relevant specs into `implement.jsonl` / `check.jsonl`: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "CLI backend conventions"} +{"file": ".trellis/spec/cli/unit-test/conventions.md", "reason": "Test expectations"} +``` + +Sub-agents or platform preludes read these JSONL files and load the referenced specs. On platforms without sub-agent support, the AI should read the relevant specs directly according to the workflow. + +## What Specs Should Contain + +Specs should contain executable engineering conventions for the project, not generic best practices: + +- Where files should live. +- How error handling should be expressed. +- Input/output contracts for APIs, hooks, and commands. +- Patterns that are forbidden. +- Cases that require tests. +- Project-specific pitfalls and how to avoid them. + +When the AI learns a new rule during implementation or debugging, it should update `.trellis/spec/` rather than only summarizing it in chat. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Add a new spec layer | `.trellis/spec///index.md` and corresponding guideline files. | +| Change monorepo spec mapping | `packages` / `default_package` / `spec_scope` in `.trellis/config.yaml`. | +| Change which specs AI reads before implementation | The task's `implement.jsonl`. | +| Change which specs AI reads during checking | The task's `check.jsonl`. | +| Change when specs should be updated | Phase 3.3 in `.trellis/workflow.md` and the `trellis-update-spec` skill. | + +## Boundaries + +`.trellis/spec/` is the user's project specification, not a permanent copy of Trellis built-in templates. The AI should encourage the user to update it according to the actual project code instead of treating Trellis default templates as immutable documents. diff --git a/.claude/skills/trellis-meta/references/local-architecture/task-system.md b/.claude/skills/trellis-meta/references/local-architecture/task-system.md new file mode 100644 index 0000000..64ad00d --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/task-system.md @@ -0,0 +1,101 @@ +# Local Task System + +The Trellis task system is stored entirely under `.trellis/tasks/` in the user project. Each task is a directory containing requirements, context, research, state, and relationship information. + +## Task Directory Structure + +```text +.trellis/tasks/ +├── 04-28-example-task/ +│ ├── task.json +│ ├── prd.md +│ ├── info.md +│ ├── implement.jsonl +│ ├── check.jsonl +│ └── research/ +└── archive/ + └── 2026-04/ +``` + +| File | Purpose | +| --- | --- | +| `task.json` | Task metadata: status, assignee, priority, branch, parent/child tasks, and similar fields. | +| `prd.md` | Requirements document; the most important business context during implementation. | +| `info.md` | Optional technical design. | +| `implement.jsonl` | List of spec/research files the implement agent must read first. | +| `check.jsonl` | List of spec/research files the check agent must read first. | +| `research/` | Research artifacts. Complex findings should not live only in chat. | + +## `task.json` + +`task.json` records task status and metadata. Common fields: + +| Field | Meaning | +| --- | --- | +| `id` / `name` / `title` | Task identity and title. | +| `status` | Status such as `planning`, `in_progress`, `review`, or `completed`. | +| `priority` | `P0`, `P1`, `P2`, `P3`. | +| `creator` / `assignee` | Creator and assignee. | +| `package` | Target package in a monorepo; may be empty. | +| `branch` / `base_branch` | Working branch and PR target branch. | +| `children` / `parent` | Parent/child task relationships. | +| `commit` / `pr_url` | Commit and PR information after completion. | +| `meta` | Extension fields. | + +The AI should not treat phase numbers as task status. Task progress is mainly determined by `status`, `prd.md`, whether JSONL context is configured, and the phase descriptions in `workflow.md`. + +## Active Task + +The user sees a "current task," but Trellis stores active task state per session. + +```text +.trellis/.runtime/sessions/.json +``` + +`task.py start` writes the task path into the runtime session file for the current session. `task.py current --source` shows the current task and where it came from. Different AI windows can point to different tasks without overwriting each other. + +If the platform or shell environment has no stable session identity, `task.py start` may be unable to set the active task. The AI should read the error, inspect the platform hook/session environment, and not fall back to a shared global pointer. + +## JSONL Context + +`implement.jsonl` and `check.jsonl` are context manifests for sub-agents to read first. + +Format: + +```jsonl +{"file": ".trellis/spec/cli/backend/index.md", "reason": "Backend conventions"} +{"file": ".trellis/tasks/04-28-example/research/api.md", "reason": "API research"} +``` + +Rules: + +- Include spec and research files. +- Do not include code files that are about to be modified. +- Do not treat temporary conclusions in chat as the only context. +- Seed rows have no `file` field; they only prompt the AI to fill in real entries. + +## Common Commands + +```bash +python3 ./.trellis/scripts/task.py create "" --slug <slug> +python3 ./.trellis/scripts/task.py start <task> +python3 ./.trellis/scripts/task.py current --source +python3 ./.trellis/scripts/task.py add-context <task> implement <file> <reason> +python3 ./.trellis/scripts/task.py validate <task> +python3 ./.trellis/scripts/task.py finish +python3 ./.trellis/scripts/task.py archive <task> +``` + +When modifying the task system, the AI should prefer script commands to maintain structure. Edit JSON/Markdown directly only when scripts do not cover the need. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change the default task template | `.trellis/scripts/common/task_store.py` and task creation instructions. | +| Change status semantics | `.trellis/workflow.md`, workflow-state hook logic, and task usage conventions. | +| Add task lifecycle actions | `hooks.after_*` in `.trellis/config.yaml`. | +| Change context rules | Phase 1.3 in `.trellis/workflow.md` and related platform agent/hook instructions. | +| Change archive policy | `.trellis/scripts/common/task_store.py` / `task_utils.py`. | + +These are local files in the user project. Do not default to editing Trellis CLI source code unless the user wants to contribute upstream. diff --git a/.claude/skills/trellis-meta/references/local-architecture/workflow.md b/.claude/skills/trellis-meta/references/local-architecture/workflow.md new file mode 100644 index 0000000..f0659ff --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/workflow.md @@ -0,0 +1,75 @@ +# Local Workflow System + +`.trellis/workflow.md` is the Trellis workflow source of truth inside the user project. An AI does not need Trellis source code to understand how the current project should move tasks forward; this file is enough. + +## File Responsibilities + +`.trellis/workflow.md` has three responsibilities: + +1. **Explain workflow phases**: Plan, Execute, Finish. +2. **Define skill routing**: which skill or agent the AI should use when the user expresses a certain intent. +3. **Provide workflow-state prompt blocks**: hooks can inject the prompt block for the current state into the conversation. + +## Current Phase Model + +```text +Phase 1: Plan -> clarify what to build, produce prd.md and required research +Phase 2: Execute -> implement against the PRD and specs, then check +Phase 3: Finish -> final verification, preserve lessons, and wrap up +``` + +Each phase contains numbered steps, such as `1.3 Configure context`. These numbers are not runtime fields in `task.json`; they are workflow structure for AI and humans to read. + +## Skill Routing + +`workflow.md` separates routing by platform capability: + +- Platforms with sub-agent support: dispatch `trellis-implement` by default for implementation and `trellis-check` for checking. +- Platforms without sub-agent support: the main session reads skills such as `trellis-before-dev`, then executes directly. + +When changing local AI behavior, update the routing descriptions in `workflow.md` first, then check whether the corresponding platform skill, command, or agent files need to stay in sync. + +## Workflow-State Prompt Blocks + +The bottom of `workflow.md` can contain state blocks like this: + +```text +[workflow-state:no_task] +... +[/workflow-state:no_task] +``` + +Hooks choose the right block based on current task status and inject it into the conversation. Common states include: + +| State | Meaning | +| --- | --- | +| `no_task` | The current session has no active task. | +| `planning` | The task is still in requirements, research, or context configuration. | +| `in_progress` | The task has entered implementation and checking. | +| `completed` | The task is complete and waiting for wrap-up or archive. | + +If the user wants to change policies such as "whether to create a task when there is no task," "when task creation may be skipped," or "whether sub-agents are required," edit these state blocks and the routing table above them. + +## Local Modification Patterns + +Common changes: + +| Goal | Edit point | +| --- | --- | +| Add a phase | Update the Phase Index, phase body, routing, and state blocks. | +| Change task creation policy | Update the `no_task` state block and Phase 1 description. | +| Change the default implementation/check path | Update Phase 2 and skill routing. | +| Change the wrap-up flow | Update Phase 3 and `finish-work` related descriptions. Note the current split: Phase 3.4 = AI-driven code commits (batched, user-confirmed), Phase 3.5 = `/finish-work` (archive + record session). `/finish-work` refuses to run if the working tree is dirty. | +| Change platform differences | Update routing descriptions grouped by platform. | + +After editing, make the AI reread `.trellis/workflow.md`; do not assume the flow from the old conversation is still valid. + +## Relationship To Platform Files + +`workflow.md` is the semantic center of the local workflow, but each platform can also have its own entry files: + +- skills, such as `trellis-brainstorm` and `trellis-check`. +- commands/prompts/workflows, such as continue and finish-work. +- hooks, such as session-start or workflow-state injection. + +If only `workflow.md` changes, platform entry files may still contain old language. When the user wants to change "what the AI actually does," also inspect the relevant platform directory. diff --git a/.claude/skills/trellis-meta/references/local-architecture/workspace-memory.md b/.claude/skills/trellis-meta/references/local-architecture/workspace-memory.md new file mode 100644 index 0000000..c2958f2 --- /dev/null +++ b/.claude/skills/trellis-meta/references/local-architecture/workspace-memory.md @@ -0,0 +1,71 @@ +# Local Workspace Memory System + +`.trellis/workspace/` stores cross-session memory. Its purpose is to let AI and humans understand what happened before across different windows and different days. + +## Directory Structure + +```text +.trellis/workspace/ +├── index.md +└── <developer>/ + ├── index.md + ├── journal-1.md + └── journal-2.md +``` + +| File | Purpose | +| --- | --- | +| `.trellis/.developer` | Current developer identity. | +| `.trellis/workspace/index.md` | Global workspace overview. | +| `.trellis/workspace/<developer>/index.md` | Session index for a developer. | +| `.trellis/workspace/<developer>/journal-N.md` | Session journal. | + +## Developer Identity + +Run this the first time: + +```bash +python3 ./.trellis/scripts/init_developer.py <name> +``` + +This creates `.trellis/.developer` and the corresponding workspace directory. The AI should not change developer identity casually; if the identity is wrong, first confirm who is using the current project. + +## Journal + +`journal-N.md` records completed or partially completed work from each session. By default, each journal holds about 2000 lines; after that it rotates to the next file. + +Common command for recording a session: + +```bash +python3 ./.trellis/scripts/add_session.py \ + --title "Session title" \ + --summary "What changed" \ + --commit "abc1234" +``` + +Planning or review work without a commit can also be recorded by using `--no-commit` or an empty commit value. + +## Relationship Between Workspace Memory And Tasks + +| System | What it stores | +| --- | --- | +| `.trellis/tasks/` | Requirements, design, research, and state for a specific task. | +| `.trellis/workspace/` | Work records across tasks and sessions. | +| `.trellis/spec/` | Engineering knowledge preserved as long-term conventions. | + +If information is only useful for the current task, put it in the task directory. +If information describes what happened in the current session, put it in the workspace journal. +If information should be followed every time code is written in the future, put it in spec. + +## Local Customization Points + +| Need | Edit location | +| --- | --- | +| Change maximum journal lines | `max_journal_lines` in `.trellis/config.yaml`. | +| Change session auto-commit message | `session_commit_message` in `.trellis/config.yaml`. | +| Change session content format | `.trellis/scripts/add_session.py`. | +| Change how workspace is displayed in context | `.trellis/scripts/common/session_context.py`. | + +## AI Usage Rules + +The AI should not treat workspace as the only source of truth. When resuming a task, read the current task first, then use workspace for background. After a task is complete, record important process notes in workspace; if long-term rules emerged, update spec. diff --git a/.claude/skills/trellis-meta/references/platform-files/agents.md b/.claude/skills/trellis-meta/references/platform-files/agents.md new file mode 100644 index 0000000..efbacfa --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/agents.md @@ -0,0 +1,79 @@ +# Agents + +Trellis agent files define specialized roles. Common Trellis agents in a user project are: + +- `trellis-research` +- `trellis-implement` +- `trellis-check` + +File locations and formats differ by platform, but responsibility boundaries should stay consistent. + +## Agent Responsibilities + +| Agent | Responsibility | +| --- | --- | +| `trellis-research` | Investigate the question and write findings into the current task's `research/`. | +| `trellis-implement` | Implement against `prd.md`, `info.md`, `implement.jsonl`, and related spec/research. | +| `trellis-check` | Review changes, fix discovered issues, and run necessary checks. | + +Agent files should not become generic chat prompts. They should define input sources, write boundaries, whether code may be changed, and how results are reported. + +## Common Paths + +| Platform | Agent path | +| --- | --- | +| Claude Code | `.claude/agents/trellis-*.md` | +| Cursor | `.cursor/agents/trellis-*.md` | +| OpenCode | `.opencode/agents/trellis-*.md` | +| Codex | `.codex/agents/trellis-*.toml` | +| Kiro | `.kiro/agents/trellis-*.json` | +| Gemini CLI | `.gemini/agents/trellis-*.md` | +| Qoder | `.qoder/agents/trellis-*.md` | +| CodeBuddy | `.codebuddy/agents/trellis-*.md` | +| Factory Droid | `.factory/droids/trellis-*.md` | +| Pi Agent | `.pi/agents/trellis-*.md` | + +GitHub Copilot agent/prompt support is provided by a combination of directories such as `.github/agents/`, `.github/prompts/`, and `.github/skills/`; inspect the files actually generated in the user project. + +Main-session workflow platforms such as Kilo, Antigravity, and Windsurf may not have Trellis sub-agent files. They usually rely on workflows/skills to guide the main session. + +## Two Context Loading Modes + +### hook push + +The platform hook injects task context before the agent starts. The agent file itself can focus more on responsibilities and boundaries. + +Common on platforms that support agent hooks. + +### agent pull + +The agent file instructs the agent to read after startup: + +- `python3 ./.trellis/scripts/task.py current --source` +- current task `prd.md` +- `info.md` +- `implement.jsonl` or `check.jsonl` +- spec/research files referenced by JSONL + +This mode fits platforms whose hooks cannot reliably rewrite sub-agent prompts. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Implement agent must follow extra restrictions | The platform's `trellis-implement` agent file. | +| Check agent must run project-specific commands | `trellis-check` agent file, and `.trellis/spec/` if needed. | +| Research agent must output a fixed format | `trellis-research` agent file. | +| Agent cannot read task context | Agent prelude or `inject-subagent-context` hook. | +| Add a project-specific agent | Platform agent directory + related workflow/command/skill entry point. | + +## Modification Principles + +1. **Keep responsibilities single-purpose**. Do not mix research, implement, and check responsibilities into one agent. +2. **Specify the read order**. Agents must know to start from the active task and then find the PRD and JSONL. +3. **Specify write boundaries**. Research usually only writes `research/`; implement can write code; check can fix issues. +4. **Keep semantics synchronized in multi-platform projects**. If the user configured Claude, Codex, and Cursor together, decide whether changes to one platform's agent also need to be applied to others. + +## Do Not Default To Editing Upstream Templates + +Local AI should default to modifying platform agent files inside the user project. Discuss upstream template source only when the user explicitly wants to contribute the change back to Trellis. diff --git a/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md b/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md new file mode 100644 index 0000000..94156a8 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md @@ -0,0 +1,69 @@ +# Hooks And Settings + +Hooks/settings are the entry layer that connects a platform to Trellis. They decide which scripts, plugins, or extensions a platform runs for which events. + +## Settings Responsibilities + +settings/config files usually register: + +- session-start hook: injects a Trellis overview when a new session starts or context resets. +- workflow-state hook: parses `[workflow-state:STATUS]` blocks from `.trellis/workflow.md` and emits the body matching the current task `status` on each user input. Parser-only; the script does not embed fallback content. +- sub-agent context hook: injects task context when implementation/check/research agents start. +- shell/session bridge: lets shell commands see the same Trellis session identity. +- platform plugin or extension entry points. + +Common files: + +| Platform | settings/config | +| --- | --- | +| Claude Code | `.claude/settings.json` | +| Cursor | `.cursor/hooks.json` | +| Codex | `.codex/hooks.json`, `.codex/config.toml` | +| OpenCode | `.opencode/package.json`, `.opencode/plugins/*` | +| Kiro | `.kiro/hooks/` + platform config | +| Gemini CLI | `.gemini/settings.json` | +| Qoder | `.qoder/settings.json` | +| CodeBuddy | `.codebuddy/settings.json` | +| GitHub Copilot | `.github/copilot/hooks.json` | +| Factory Droid | `.factory/settings.json` | +| Pi Agent | `.pi/settings.json`, `.pi/extensions/trellis/` | + +Whether these files exist in a project depends on which `trellis init --<platform>` flags the user ran. + +## Hook Script Types + +| Script | Purpose | +| --- | --- | +| `session-start.py` | Generates session-start context. | +| `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. | +| `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. | +| `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. | + +Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| AI should see more/less context in a new session | Platform `session-start` hook. | +| Per-turn hint policy should change | `[workflow-state:STATUS]` block in `.trellis/workflow.md`. The hook parses workflow.md verbatim — no script edit required. | +| Sub-agent cannot read PRD/spec | `inject-subagent-context` hook or agent prelude. | +| `task.py current` in shell has no active task | Shell/session bridge hook or platform environment variable configuration. | +| Disable an automatic injection | The corresponding hook registration in settings/config. | + +## Modification Principles + +1. **Settings wire things up; hooks define behavior**. If only the hook changes, the platform may never call it. If only settings change, behavior may not change. +2. **Confirm platform event names first**. Different platforms use different names for SessionStart, UserPromptSubmit, AgentSpawn, shell execution, and similar events. +3. **Hooks read local `.trellis/`, not upstream source**. `.trellis/scripts/` and `.trellis/workflow.md` in the user project are the default targets. +4. **Errors must be visible**. Hook failures should tell the user what was not injected instead of silently leaving the AI without context. + +## Troubleshooting Path + +If the user says "AI did not read Trellis state": + +1. Check whether the platform settings register the hook. +2. Check whether the hook file exists. +3. Manually run the `.trellis/scripts/get_context.py` or `task.py current --source` command that the hook depends on. +4. Check whether active task state exists in `.trellis/.runtime/sessions/`. +5. Check whether the platform shell passes session identity. diff --git a/.claude/skills/trellis-meta/references/platform-files/overview.md b/.claude/skills/trellis-meta/references/platform-files/overview.md new file mode 100644 index 0000000..60ae1df --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/overview.md @@ -0,0 +1,59 @@ +# Platform Files Overview + +Trellis connects the same local architecture to different AI tools. `.trellis/` stores the shared runtime; platform directories store adapter files that define how each AI tool enters Trellis. + +When a local AI modifies Trellis, it should distinguish two file categories first: + +- **Shared files**: `.trellis/workflow.md`, `.trellis/tasks/`, `.trellis/spec/`, `.trellis/scripts/`. +- **Platform files**: `.claude/`, `.codex/`, `.cursor/`, `.opencode/`, `.kiro/`, `.gemini/`, `.qoder/`, `.codebuddy/`, `.github/`, `.factory/`, `.pi/`, `.kilocode/`, `.agent/`, `.windsurf/`, and similar directories. + +Platform files do not store business state. They let the corresponding AI tool read Trellis state, call Trellis scripts, and load Trellis skills/agents/hooks. + +## Platform File Categories + +| Category | Common paths | Purpose | +| --- | --- | --- | +| settings/config | `.claude/settings.json`, `.codex/hooks.json`, `.qoder/settings.json` | Register hooks, plugins, extensions, or platform behavior. | +| hooks/plugins/extensions | `.claude/hooks/`, `.opencode/plugins/`, `.pi/extensions/` | Inject context at session start, user input, agent startup, shell execution, and similar events. | +| agents | `.claude/agents/`, `.codex/agents/`, `.kiro/agents/` | Define `trellis-research`, `trellis-implement`, and `trellis-check`. | +| skills | `.claude/skills/`, `.agents/skills/`, `.qoder/skills/` | Capability descriptions that auto-trigger or can be read on demand. | +| commands/prompts/workflows | `.cursor/commands/`, `.github/prompts/`, `.windsurf/workflows/` | Entry points explicitly invoked by the user. | + +## Three Platform Integration Modes + +### 1. Hook / Extension Driven + +These platforms can trigger scripts or plugins on specific events and actively inject Trellis context into AI. + +Common capabilities: + +- session-start injection of a `.trellis/` overview. +- workflow-state hints for each user turn. +- PRD/spec/research injection when sub-agents start. +- Shell commands inheriting session identity. + +To change "when the AI knows what," inspect hooks/plugins/extensions and settings first. + +### 2. Agent Prelude / Pull-Based + +Some platforms cannot reliably let hooks rewrite sub-agent prompts, so the agent file itself instructs the agent to read the active task, PRD, and JSONL context after startup. + +To change how sub-agents load context, inspect the agent files themselves. + +### 3. Main-Session Workflow + +Some platforms do not have Trellis sub-agent or hook capabilities. They rely on workflows/skills/commands to guide the main-session AI to read files, run scripts, and move tasks forward. + +To change behavior, inspect platform workflows/skills/commands and `.trellis/workflow.md`. + +## Local Modification Order + +When the user asks to customize behavior for a platform, the AI should inspect files in this order: + +1. Read `.trellis/workflow.md` to confirm the shared flow. +2. Read the target platform's settings/config to see which hooks/agents/skills/commands are registered. +3. Read the target platform's agents/skills/commands/hooks. +4. Modify the local file closest to the user's need. +5. If the change affects the shared flow, synchronize `.trellis/workflow.md` or `.trellis/spec/`. + +Do not modify only platform files and forget the shared workflow. Do not modify only `.trellis/workflow.md` and forget that platform entry points may still contain old descriptions. diff --git a/.claude/skills/trellis-meta/references/platform-files/platform-map.md b/.claude/skills/trellis-meta/references/platform-files/platform-map.md new file mode 100644 index 0000000..b5576f4 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/platform-map.md @@ -0,0 +1,74 @@ +# Platform File Map + +This page lists common Trellis file locations in a user project by platform. Whether a platform directory exists in an actual project depends on which `trellis init --<platform>` commands the user ran. + +## Matrix + +| Platform | CLI flag | Main directory | Skill directory | Agent directory | Hooks/extensions | +| --- | --- | --- | --- | --- | --- | +| Claude Code | `--claude` | `.claude/` | `.claude/skills/` | `.claude/agents/` | `.claude/hooks/` + `.claude/settings.json` | +| Cursor | `--cursor` | `.cursor/` | `.cursor/skills/` | `.cursor/agents/` | `.cursor/hooks.json` + `.cursor/hooks/` | +| OpenCode | `--opencode` | `.opencode/` | `.opencode/skills/` | `.opencode/agents/` | `.opencode/plugins/` | +| Codex | `--codex` | `.codex/` | `.agents/skills/` | `.codex/agents/` | `.codex/hooks/` + `.codex/hooks.json` | +| Kilo | `--kilo` | `.kilocode/` | `.kilocode/skills/` | Usually none | `.kilocode/workflows/` | +| Kiro | `--kiro` | `.kiro/` | `.kiro/skills/` | `.kiro/agents/` | `.kiro/hooks/` | +| Gemini CLI | `--gemini` | `.gemini/` | `.agents/skills/` | `.gemini/agents/` | `.gemini/settings.json` + `.gemini/hooks/` | +| Antigravity | `--antigravity` | `.agent/` | `.agent/skills/` | Usually none | `.agent/workflows/` | +| Windsurf | `--windsurf` | `.windsurf/` | `.windsurf/skills/` | Usually none | `.windsurf/workflows/` | +| Qoder | `--qoder` | `.qoder/` | `.qoder/skills/` | `.qoder/agents/` | `.qoder/hooks/` + `.qoder/settings.json` | +| CodeBuddy | `--codebuddy` | `.codebuddy/` | `.codebuddy/skills/` | `.codebuddy/agents/` | `.codebuddy/hooks/` + `.codebuddy/settings.json` | +| GitHub Copilot | `--copilot` | `.github/` | `.github/skills/` | `.github/agents/` | `.github/copilot/hooks/` + prompts | +| Factory Droid | `--droid` | `.factory/` | `.factory/skills/` | `.factory/droids/` | `.factory/hooks/` + settings | +| Pi Agent | `--pi` | `.pi/` | `.pi/skills/` | `.pi/agents/` | `.pi/extensions/trellis/` + `.pi/settings.json` | + +## Capability Groups + +### Trellis Sub-Agent Support + +These platforms usually have `trellis-research`, `trellis-implement`, and `trellis-check` files: + +- Claude Code +- Cursor +- OpenCode +- Codex +- Kiro +- Gemini CLI +- Qoder +- CodeBuddy +- GitHub Copilot +- Factory Droid +- Pi Agent + +When changing implementation/check/research behavior, look for the corresponding platform agent files first. + +### Main-Session Workflow Platforms + +These platforms rely more on workflows/skills to guide the main session: + +- Kilo +- Antigravity +- Windsurf + +When changing behavior, inspect workflows and skills first. Do not assume Trellis sub-agents exist. + +### Shared `.agents/skills/` + +Codex writes the shared `.agents/skills/` layer. Some tools that support agentskills.io can also read this directory. If the user wants multiple compatible tools to share one skill, consider `.agents/skills/` first, but do not assume every platform reads it. + +## Decision Rules When Modifying Platform Files + +1. User specified a platform: modify only that platform directory unless shared workflow/spec files must also change. +2. User says "all platforms should do this": synchronize equivalent entry points platform by platform; do not modify only one directory. +3. User only says "my AI": inspect the configuration directories that actually exist in the project and infer the current AI platform. +4. User wants project rules: prefer `.trellis/spec/` or a project-local skill. +5. User wants Trellis behavior: edit `.trellis/workflow.md` plus platform hooks/agents/skills/commands. + +## When Paths Differ + +Platform ecosystems change, and user projects may already be customized. If this table disagrees with local files, use the actual settings/config in the user project as authoritative: + +- Check the hook that settings registers. +- Check the script that a command/prompt/workflow points to. +- Judge behavior by the read rules currently written in the agent file. + +Do not delete a custom file just because it is not listed in this path table. diff --git a/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md b/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md new file mode 100644 index 0000000..816c666 --- /dev/null +++ b/.claude/skills/trellis-meta/references/platform-files/skills-and-commands.md @@ -0,0 +1,83 @@ +# Skills, Commands, Prompts, And Workflows + +Skills and commands are textual entry points for user interaction with Trellis. Different platforms use different names, but their core purpose is the same: tell the AI how to enter the Trellis flow when the user expresses a certain intent. + +## Conceptual Differences + +| Type | Trigger mode | Best for | +| --- | --- | --- | +| skill | AI auto-match or explicit user mention | Long-term capabilities, workflow rules, modification guides. | +| command | Explicit user invocation | Clear operation entry points such as continue and finish-work. | +| prompt | Explicit user invocation or platform selection | Similar to command, but in a platform prompt format. | +| workflow | Explicit user selection or platform auto-match | Guides the main session when no sub-agent/hook exists. | + +Trellis workflow skills usually share one semantic set: brainstorm, before-dev, check, update-spec, break-loop. Multi-file built-in skills such as `trellis-meta` use layered references. + +## Common Paths + +| Platform | Common entries | +| --- | --- | +| Claude Code | `.claude/skills/`, `.claude/commands/` | +| Cursor | `.cursor/skills/`, `.cursor/commands/` | +| OpenCode | `.opencode/skills/`, `.opencode/commands/` | +| Codex | `.agents/skills/`, `.codex/skills/` | +| Kilo | `.kilocode/skills/`, `.kilocode/workflows/` | +| Kiro | `.kiro/skills/` | +| Gemini CLI | `.agents/skills/`, `.gemini/commands/` | +| Antigravity | `.agent/skills/`, `.agent/workflows/` | +| Windsurf | `.windsurf/skills/`, `.windsurf/workflows/` | +| Qoder | `.qoder/skills/`, `.qoder/commands/` | +| CodeBuddy | `.codebuddy/skills/`, `.codebuddy/commands/` | +| GitHub Copilot | `.github/skills/`, `.github/prompts/` | +| Factory Droid | `.factory/skills/`, `.factory/commands/` | +| Pi Agent | `.pi/skills/` | + +In a user project, use the files actually generated by init as authoritative. + +## Skill Structure + +A common skill is a directory: + +```text +trellis-meta/ +├── SKILL.md +└── references/ +``` + +`SKILL.md` should tell the AI: + +- When to use this skill. +- Which reference to read first for the current task. +- What not to do. + +References hold longer explanations so the entry file does not contain everything. + +## Command/Prompt/Workflow Structure + +Commands, prompts, and workflows are usually single files. Their content should include: + +- When to use it. +- Which `.trellis/` files to read. +- Which scripts to run. +- How to report after completion. + +They should not store task state; task state belongs in `.trellis/tasks/` and `.trellis/.runtime/`. + +## Local Change Scenarios + +| User need | Edit location | +| --- | --- | +| Change AI auto-trigger rules | The corresponding skill's frontmatter description. | +| Change user command behavior | The corresponding command/prompt/workflow file. | +| Add a project-local skill | Platform skill directory, or shared `.agents/skills/`. | +| Let multiple platforms share one capability | Write equivalent skills in each platform skill directory, or use the `.agents/skills/` shared layer on platforms that support it. | +| Change finish/continue entry points | Platform commands/prompts/workflows. | + +## Modification Principles + +1. **Keep entry files short; references carry long content**. This matters especially for multi-file skills like `trellis-meta`. +2. **Make trigger descriptions specific**. A description that is too broad can mis-trigger; one that is too narrow may not trigger. +3. **Keep the same semantics consistent across platforms**. File formats can differ, but behavior descriptions should match. +4. **Put project-specific capabilities in local skills**. Do not put team-private flows into public `trellis-meta`. + +If the user only wants local AI to know one more project rule, usually create a project-local skill or update `.trellis/spec/` instead of changing a Trellis built-in workflow skill. diff --git a/.opencode/commands/trellis/update-spec.md b/.claude/skills/trellis-update-spec/SKILL.md similarity index 86% rename from .opencode/commands/trellis/update-spec.md rename to .claude/skills/trellis-update-spec/SKILL.md index 3f0b2e7..557bc4e 100644 --- a/.opencode/commands/trellis/update-spec.md +++ b/.claude/skills/trellis-update-spec/SKILL.md @@ -1,6 +1,11 @@ +--- +name: trellis-update-spec +description: "Captures executable contracts and coding conventions into .trellis/spec/ documents. Use when learning something valuable from debugging, implementing, or discussion that should be preserved for future sessions." +--- + # Update Code-Spec - Capture Executable Contracts -When you learn something valuable (from debugging, implementing, or discussion), use this command to update the relevant code-spec documents. +When you learn something valuable (from debugging, implementing, or discussion), use this to update the relevant code-spec documents. **Timing**: After completing a task, fixing a bug, or discovering a new pattern @@ -40,13 +45,13 @@ For triggered tasks, include all sections below: | Trigger | Example | Target Spec | |---------|---------|-------------| -| **Implemented a feature** | Added template download with giget | Relevant `backend/` or `frontend/` file | -| **Made a design decision** | Used type field + mapping table for extensibility | Relevant code-spec + "Design Decisions" section | -| **Fixed a bug** | Found a subtle issue with error handling | `backend/error-handling.md` | -| **Discovered a pattern** | Found a better way to structure code | Relevant `backend/` or `frontend/` file | -| **Hit a gotcha** | Learned that X must be done before Y | Relevant code-spec + "Common Mistakes" section | -| **Established a convention** | Team agreed on naming pattern | `quality-guidelines.md` | -| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item, not detailed rules) | +| **Implemented a feature** | Added a new integration or module | Relevant spec file | +| **Made a design decision** | Chose extensibility pattern over simplicity | Relevant spec + "Design Decisions" section | +| **Fixed a bug** | Found a subtle issue with error handling | Relevant spec (e.g., error-handling docs) | +| **Discovered a pattern** | Found a better way to structure code | Relevant spec file | +| **Hit a gotcha** | Learned that X must be done before Y | Relevant spec + "Common Mistakes" section | +| **Established a convention** | Team agreed on naming pattern | Quality guidelines | +| **New thinking trigger** | "Don't forget to check X before doing Y" | `guides/*.md` (as a checklist item) | **Key Insight**: Code-spec updates are NOT just for problems. Every feature implementation contains design decisions and contracts that future AI/developers need to execute safely. @@ -56,10 +61,7 @@ For triggered tasks, include all sections below: ``` .trellis/spec/ -├── backend/ # Backend coding standards -│ ├── index.md # Overview and links -│ └── *.md # Topic-specific guidelines -├── frontend/ # Frontend coding standards +├── <layer>/ # Per-layer coding standards (e.g., backend/, frontend/, api/) │ ├── index.md # Overview and links │ └── *.md # Topic-specific guidelines └── guides/ # Thinking checklists (NOT coding specs!) @@ -71,20 +73,20 @@ For triggered tasks, include all sections below: | Type | Location | Purpose | Content Style | |------|----------|---------|---------------| -| **Code-Spec** | `backend/*.md`, `frontend/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | +| **Code-Spec** | `<layer>/*.md` | Tell AI "how to implement safely" | Signatures, contracts, matrices, cases, test points | | **Guide** | `guides/*.md` | Help AI "what to think about" | Checklists, questions, pointers to specs | **Decision Rule**: Ask yourself: -- "This is **how to write** the code" → Put in `backend/` or `frontend/` +- "This is **how to write** the code" → Put in a spec layer directory - "This is **what to consider** before writing" → Put in `guides/` **Example**: | Learning | Wrong Location | Correct Location | |----------|----------------|------------------| -| "Use `reconfigure()` not `TextIOWrapper` for Windows stdout" | ❌ `guides/cross-platform-thinking-guide.md` | ✅ `backend/script-conventions.md` | -| "Remember to check encoding when writing cross-platform code" | ❌ `backend/script-conventions.md` | ✅ `guides/cross-platform-thinking-guide.md` | +| "Use API X not API Y for this task" | ❌ `guides/` (too specific for a thinking guide) | ✅ Relevant spec file (concrete convention) | +| "Remember to check X when doing Y" | ❌ Spec file (too abstract for a spec) | ✅ `guides/` (thinking checklist) | **Guides should be short checklists that point to specs**, not duplicate the detailed rules. @@ -339,7 +341,7 @@ Development Flow: ``` - `/trellis:break-loop` - Analyzes bugs deeply, often reveals spec updates needed -- `/trellis:update-spec` - Actually makes the updates (this command) +- `/trellis:update-spec` - Actually makes the updates - `/trellis:finish-work` - Reminds you to check if specs need updates --- diff --git a/.env.example b/.env.example index 6c953ec..02ff720 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ # 运行时配置 ############ ERYAO_RUNTIME__ENVIRONMENT=dev +ERYAO_RUNTIME__DEBUG=true ERYAO_RUNTIME__LOG_LEVEL=INFO ERYAO_RUNTIME__SQL_LOG_QUERIES=false ERYAO_RUNTIME__TRUSTED_PROXY_IPS='["127.0.0.1", "172.18.0.1"]' @@ -105,9 +106,24 @@ ERYAO_TEST__CODE=123456 # Apple IAP 配置 ############ ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen -# Apple IAP 环境识别。auto 表示以后端验签后的 Apple transaction environment 为准。 -ERYAO_APPLE_IAP__ENVIRONMENT=auto # Server API 密钥(可选,用于主动查询交易状态) ERYAO_APPLE_IAP__SERVER_API_KEY_ID= ERYAO_APPLE_IAP__SERVER_API_PRIVATE_KEY= ERYAO_APPLE_IAP__SERVER_API_ISSUER_ID= +# 沙盒测试账号(仅用于手动测试,不用于后端验证) +ERYAO_APPLE_IAP__SANDBOX_TESTER_EMAIL= +ERYAO_APPLE_IAP__SANDBOX_TESTER_PASSWORD= +# Server Notifications V2 URL(在 App Store Connect 中配置) +# 格式: https://<your-domain>/api/v1/payments/apple/notifications +ERYAO_APPLE_IAP__SERVER_NOTIFICATIONS_URL= + +############ +# CREEM Payment 配置 +############ +# 生产环境: https://api.creem.io +# 测试环境: https://test-api.creem.io +ERYAO_CREEM__API_KEY= +ERYAO_CREEM__WEBHOOK_SECRET= +ERYAO_CREEM__BASE_URL=https://api.creem.io +# 支付成功后跳转链接,需包含语言前缀 +ERYAO_CREEM__SUCCESS_URL=https://meeyao.com/en/store?payment=success diff --git a/.gitignore b/.gitignore index b6898b1..934440c 100644 --- a/.gitignore +++ b/.gitignore @@ -197,6 +197,15 @@ unlinked_spec.ds # IDE .idea/ +# Codex local workspace helpers and runtime state +.codex/ + +# Web (Astro) +node_modules/ +.astro/ +web/dist/ +web/package-lock.json + # Android related **/android/**/gradle-wrapper.jar .gradle/ @@ -239,7 +248,8 @@ unlinked_spec.ds **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* **/ios/Podfile.lock -**/ios/Runner.xcodeproj/ +**/ios/Runner.xcodeproj/xcuserdata/ +**/ios/Runner.xcodeproj/project.xcworkspace/ **/ios/Runner.xcworkspace/ # macOS diff --git a/.opencode/agents/debug.md b/.opencode/agents/debug.md deleted file mode 100644 index 3bf02d5..0000000 --- a/.opencode/agents/debug.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -description: | - Issue fixing expert. Understands issues, fixes against specs, and verifies fixes. Precise fixes only. -mode: subagent -permission: - read: allow - write: allow - edit: allow - bash: allow - glob: allow - grep: allow - mcp__exa__*: allow ---- -# Debug Agent - -You are the Debug Agent in the Trellis workflow. - -## Context Self-Loading - -**If you see "# Debug Agent Task" header with pre-loaded context above, skip this section.** - -Otherwise, load context yourself: - -1. Read `.trellis/.current-task` → get task directory (e.g., `.trellis/tasks/xxx`) -2. Read `{task_dir}/debug.jsonl` (or `spec.jsonl` as fallback) -3. For each entry in JSONL: - - If `path` is a file → Read it - - If `path` is a directory → Read all `.md` files in it -4. Read `{task_dir}/codex-review-output.txt` if exists (Codex review results) - -Then proceed with the workflow below using the loaded context. - ---- - -## Context - -Before debugging, read: -- `.trellis/spec/` - Development guidelines -- Error messages or issue descriptions provided - -## Core Responsibilities - -1. **Understand issues** - Analyze error messages or reported issues -2. **Fix against specs** - Fix issues following dev specs -3. **Verify fixes** - Run typecheck to ensure no new issues -4. **Report results** - Report fix status - ---- - -## Workflow - -### Step 1: Understand Issues - -Parse the issue, categorize by priority: - -- `[P1]` - Must fix (blocking) -- `[P2]` - Should fix (important) -- `[P3]` - Optional fix (nice to have) - -### Step 2: Research if Needed - -If you need additional info: - -```bash -# Check knowledge base -ls .trellis/big-question/ -``` - -### Step 3: Fix One by One - -For each issue: - -1. Locate the exact position -2. Fix following specs -3. Run typecheck to verify - -### Step 4: Verify - -Run project's lint and typecheck commands to verify fixes. - -If fix introduces new issues: - -1. Revert the fix -2. Use a more complete solution -3. Re-verify - ---- - -## Report Format - -```markdown -## Fix Report - -### Issues Fixed - -1. `[P1]` `<file>:<line>` - <what was fixed> -2. `[P2]` `<file>:<line>` - <what was fixed> - -### Issues Not Fixed - -- `<file>:<line>` - <reason why not fixed> - -### Verification - -- TypeCheck: Pass -- Lint: Pass - -### Summary - -Fixed X/Y issues. Z issues require discussion. -``` - ---- - -## Guidelines - -### DO - -- Precise fixes for reported issues -- Follow specs -- Verify each fix - -### DON'T - -- Don't refactor surrounding code -- Don't add new features -- Don't modify unrelated files -- Don't use non-null assertion (`x!` operator) -- Don't execute git commit diff --git a/.opencode/agents/dispatch.md b/.opencode/agents/dispatch.md deleted file mode 100644 index 1ca3332..0000000 --- a/.opencode/agents/dispatch.md +++ /dev/null @@ -1,223 +0,0 @@ ---- -description: | - Multi-Agent Pipeline main dispatcher. Pure dispatcher. Only responsible for calling subagents and scripts in phase order. -mode: primary -permission: - read: allow - write: deny - edit: deny - bash: allow - glob: deny - grep: deny - task: allow - mcp__exa__*: allow ---- -# Dispatch Agent - -You are the Dispatch Agent in the Multi-Agent Pipeline (pure dispatcher). - -## Working Directory Convention - -Current Task is specified by `.trellis/.current-task` file, content is the relative path to task directory. - -Task directory path format: `.trellis/tasks/{MM}-{DD}-{name}/` - -This directory contains all context files for the current task: - -- `task.json` - Task configuration -- `prd.md` - Requirements document -- `info.md` - Technical design (optional) -- `implement.jsonl` - Implement context -- `check.jsonl` - Check context -- `debug.jsonl` - Debug context - -## Core Principles - -1. **You are a pure dispatcher** - Only responsible for calling subagents and scripts in order -2. **You don't read specs/requirements** - Hook will auto-inject all context to subagents -3. **You don't need resume** - Hook injects complete context on each subagent call -4. **You only need simple commands** - Tell subagent "start working" is enough - ---- - -## Startup Flow - -### Step 1: Determine Current Task Directory - -Read `.trellis/.current-task` to get current task directory path: - -```bash -TASK_DIR=$(cat .trellis/.current-task) -# e.g.: .trellis/tasks/02-03-my-feature -``` - -### Step 2: Read Task Configuration - -```bash -cat ${TASK_DIR}/task.json -``` - -Get the `next_action` array, which defines the list of phases to execute. - -### Step 3: Execute in Phase Order - -Execute each step in `phase` order. - -> **Note**: You do NOT need to manually update `current_phase`. The Hook automatically updates it when you call Task with a subagent. - ---- - -## Phase Handling - -> Hook will auto-inject all specs, requirements, and technical design to subagent context. -> Dispatch only needs to issue simple call commands. - -### action: "implement" - -``` -Task( - subagent_type: "implement", - prompt: "Implement the feature described in prd.md in the task directory", - model: "opus", - run_in_background: true -) -``` - -Hook will auto-inject: - -- All spec files from implement.jsonl -- Requirements document (prd.md) -- Technical design (info.md) - -Implement receives complete context and autonomously: read → understand → implement. - -### action: "check" - -``` -Task( - subagent_type: "check", - prompt: "Check code changes, fix issues yourself", - model: "opus", - run_in_background: true -) -``` - -Hook will auto-inject: - -- finish-work.md -- check-cross-layer.md -- check-backend.md -- check-frontend.md -- All spec files from check.jsonl - -### action: "debug" - -``` -Task( - subagent_type: "debug", - prompt: "Fix the issues described in the task context", - model: "opus", - run_in_background: true -) -``` - -Hook will auto-inject: - -- All spec files from debug.jsonl -- Error context if available - -### action: "finish" - -``` -Task( - subagent_type: "check", - prompt: "[finish] Execute final completion check before PR", - model: "opus", - run_in_background: true -) -``` - -**Important**: The `[finish]` marker in prompt triggers different context injection: -- finish-work.md checklist -- update-spec.md (spec update process and templates) -- prd.md for verifying requirements are met - -The finish agent actively updates spec docs when it detects new patterns or contracts in the changes. - -This is different from regular "check" which has full specs for self-fix loop. - -### action: "create-pr" - -This action creates a Pull Request from the feature branch. Run it via Bash: - -```bash -python3 ./.trellis/scripts/multi_agent/create_pr.py -``` - -This will: -1. Stage and commit all changes (excluding workspace) -2. Push to origin -3. Create a Draft PR using `gh pr create` -4. Update task.json with status="review", pr_url, and current_phase - -**Note**: This is the only action that performs git commit, as it's the final step after all implementation and checks are complete. - ---- - -## Calling Subagents - -### Basic Pattern - -``` -task_id = Task( - subagent_type: "implement", // or "check", "debug" - prompt: "Simple task description", - model: "opus", - run_in_background: true -) - -// Poll for completion -for i in 1..N: - result = TaskOutput(task_id, block=true, timeout=300000) - if result.status == "completed": - break -``` - -### Timeout Settings - -| Phase | Max Time | Poll Count | -|-------|----------|------------| -| implement | 30 min | 6 times | -| check | 15 min | 3 times | -| debug | 20 min | 4 times | - ---- - -## Error Handling - -### Timeout - -If a subagent times out, notify the user and ask for guidance: - -``` -"Subagent {phase} timed out after {time}. Options: -1. Retry the same phase -2. Skip to next phase -3. Abort the pipeline" -``` - -### Subagent Failure - -If a subagent reports failure, read the output and decide: - -- If recoverable: call debug agent to fix -- If not recoverable: notify user and ask for guidance - ---- - -## Key Constraints - -1. **Do not read spec/requirement files directly** - Let Hook inject to subagents -2. **Only commit via create-pr action** - Use `multi_agent/create_pr.py` at the end of pipeline -3. **All subagents should use opus model for complex tasks** -4. **Keep dispatch logic simple** - Complex logic belongs in subagents diff --git a/.opencode/agents/research.md b/.opencode/agents/research.md deleted file mode 100644 index 0c1c196..0000000 --- a/.opencode/agents/research.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -description: | - Code and tech search expert. Pure research, no code modifications. Finds files, patterns, and tech solutions. -mode: subagent -permission: - read: allow - write: deny - edit: deny - bash: deny - glob: allow - grep: allow - mcp__exa__*: allow - mcp__chrome-devtools__*: allow ---- -# Research Agent - -You are the Research Agent in the Trellis workflow. - -## Context Self-Loading - -**If you see "# Research Agent Task" header with pre-loaded context above, skip this section.** - -Otherwise, if task-specific research is needed: - -1. Read `.trellis/.current-task` → get task directory (if exists) -2. Read `{task_dir}/research.jsonl` if exists -3. For each entry in JSONL: - - If `path` is a file → Read it - - If `path` is a directory → Read all `.md` files in it - -Project spec locations for reference: -- `.trellis/spec/backend/` - Backend standards -- `.trellis/spec/frontend/` - Frontend standards -- `.trellis/spec/guides/` - Thinking guides -- `.trellis/big-question/` - Known issues and pitfalls - ---- - -## Core Principle - -**You do one thing: find and explain information.** - -You are a documenter, not a reviewer. Your job is to help get the information needed. - ---- - -## Core Responsibilities - -### 1. Internal Search (Project Code) - -| Search Type | Goal | Tools | -|-------------|------|-------| -| **WHERE** | Locate files/components | Glob, Grep | -| **HOW** | Understand code logic | Read, Grep | -| **PATTERN** | Discover existing patterns | Grep, Read | - -### 2. External Search (Tech Solutions) - -Use web search for best practices and code examples. - ---- - -## Strict Boundaries - -### Only Allowed - -- Describe **what exists** -- Describe **where it is** -- Describe **how it works** -- Describe **how components interact** - -### Forbidden (unless explicitly asked) - -- Suggest improvements -- Criticize implementation -- Recommend refactoring -- Modify any files -- Execute git commands - ---- - -## Workflow - -### Step 1: Understand Search Request - -Analyze the query, determine: - -- Search type (internal/external/mixed) -- Search scope (global/specific directory) -- Expected output (file list/code patterns/tech solutions) - -### Step 2: Execute Search - -Execute multiple independent searches in parallel for efficiency. - -### Step 3: Organize Results - -Output structured results in report format. - ---- - -## Report Format - -```markdown -## Search Results - -### Query - -{original query} - -### Files Found - -| File Path | Description | -|-----------|-------------| -| `src/services/xxx.ts` | Main implementation | -| `src/types/xxx.ts` | Type definitions | - -### Code Pattern Analysis - -{Describe discovered patterns, cite specific files and line numbers} - -### Related Spec Documents - -- `.trellis/spec/xxx.md` - {description} - -### Not Found - -{If some content was not found, explain} -``` - ---- - -## Guidelines - -### DO - -- Provide specific file paths and line numbers -- Quote actual code snippets -- Distinguish "definitely found" and "possibly related" -- Explain search scope and limitations - -### DON'T - -- Don't guess uncertain info -- Don't omit important search results -- Don't add improvement suggestions in report (unless explicitly asked) -- Don't modify any files diff --git a/.opencode/agents/trellis-plan.md b/.opencode/agents/trellis-plan.md deleted file mode 100644 index 7a7c19a..0000000 --- a/.opencode/agents/trellis-plan.md +++ /dev/null @@ -1,427 +0,0 @@ ---- -description: | - Multi-Agent Pipeline planner. Analyzes requirements and produces a fully configured task directory ready for dispatch. -mode: primary -permission: - read: allow - write: allow - edit: allow - bash: allow - glob: allow - grep: allow - task: allow ---- -# Plan Agent - -You are the Plan Agent in the Multi-Agent Pipeline. - -**Your job**: Evaluate requirements and, if valid, transform them into a fully configured task directory. - -**You have the power to reject** - If a requirement is unclear, incomplete, unreasonable, or potentially harmful, you MUST refuse to proceed and clean up. - ---- - -## CRITICAL: You MUST Execute Tools - -**DO NOT just output text descriptions of what you would do.** -**You MUST actually execute bash commands and use tools to perform actions.** - -When this prompt says "run this command", you must use the bash tool to execute it. -When this prompt says "write this file", you must use the write tool to create it. - ---- - -## Step 0: Read Environment Variables (REQUIRED FIRST STEP) - -**IMMEDIATELY execute this bash command to read your input:** - -```bash -echo "PLAN_TASK_NAME=$PLAN_TASK_NAME" -echo "PLAN_DEV_TYPE=$PLAN_DEV_TYPE" -echo "PLAN_REQUIREMENT=$PLAN_REQUIREMENT" -echo "PLAN_TASK_DIR=$PLAN_TASK_DIR" -``` - -This gives you the task configuration. Store these values for use in subsequent steps. - ---- - -## Step 1: Evaluate Requirement (CRITICAL) - -Now evaluate the requirement from `$PLAN_REQUIREMENT`: - -### Reject If: - -1. **Unclear or Vague** - - "Make it better" / "Fix the bugs" / "Improve performance" - - No specific outcome defined - - Cannot determine what "done" looks like - -2. **Incomplete Information** - - Missing critical details to implement - - References unknown systems or files - - Depends on decisions not yet made - -3. **Out of Scope for This Project** - - Requirement doesn't match the project's purpose - - Requires changes to external systems - - Not technically feasible with current architecture - -4. **Potentially Harmful** - - Security vulnerabilities (intentional backdoors, data exfiltration) - - Destructive operations without clear justification - - Circumventing access controls - -5. **Too Large / Should Be Split** - - Multiple unrelated features bundled together - - Would require touching too many systems - - Cannot be completed in a reasonable scope - -### If Rejecting: - -**You MUST execute these commands using the bash tool. Do not just describe them.** - -**Step R1: Update task.json status** - Execute this bash command: -```bash -jq '.status = "rejected"' "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ - && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" -``` - -**Step R2: Write REJECTED.md** - Use the write tool to create `$PLAN_TASK_DIR/REJECTED.md` with this content: -```markdown -# Plan Rejected - -## Reason -<category from above> - -## Details -<specific explanation of why this requirement cannot proceed> - -## Suggestions -- <what the user should clarify or change> -- <how to make the requirement actionable> - -## To Retry - -1. Delete this directory: - ```bash - rm -rf <task_dir> - ``` - -2. Run with revised requirement: - ```bash - python3 ./.trellis/scripts/multi_agent/plan.py --name "<name>" --type "<type>" --requirement "<revised requirement>" - ``` -``` - -**Step R3: Print summary** - Execute: -```bash -echo "=== PLAN REJECTED ===" -echo "" -echo "Reason: <category>" -echo "Details: <brief explanation>" -echo "" -echo "See: $PLAN_TASK_DIR/REJECTED.md" -``` - -**Step R4: Stop** - Do not proceed to acceptance workflow. - -**The task directory is kept** with: -- `task.json` (status: "rejected") -- `REJECTED.md` (full explanation) -- `.plan-log` (execution log) - -This allows the user to review why it was rejected. - -### If Accepting: - -Continue to Step 1. The requirement is: -- Clear and specific -- Has a defined outcome -- Is technically feasible -- Is appropriately scoped - ---- - -## Input - -You receive input via environment variables (set by plan.py): - -```bash -PLAN_TASK_NAME # Task name (e.g., "user-auth") -PLAN_DEV_TYPE # Development type: backend | frontend | fullstack -PLAN_REQUIREMENT # Requirement description from user -PLAN_TASK_DIR # Pre-created task directory path -``` - -Read them at startup: - -```bash -echo "Task: $PLAN_TASK_NAME" -echo "Type: $PLAN_DEV_TYPE" -echo "Requirement: $PLAN_REQUIREMENT" -echo "Directory: $PLAN_TASK_DIR" -``` - -## Output (if accepted) - -A complete task directory containing: - -``` -${PLAN_TASK_DIR}/ -├── task.json # Updated with branch, scope, dev_type -├── prd.md # Requirements document -├── implement.jsonl # Implement phase context -├── check.jsonl # Check phase context -└── debug.jsonl # Debug phase context -``` - ---- - -## Workflow (After Acceptance) - -### Step 1: Initialize Context Files - -```bash -python3 ./.trellis/scripts/task.py init-context "$PLAN_TASK_DIR" "$PLAN_DEV_TYPE" -``` - -This creates base jsonl files with standard specs for the dev type. - -### Step 2: Analyze Codebase with Research Agent - -Call research agent to find relevant specs and code patterns: - -``` -Task( - subagent_type: "research", - prompt: "Analyze what specs and code patterns are needed for this task. - -Task: ${PLAN_REQUIREMENT} -Dev Type: ${PLAN_DEV_TYPE} - -Instructions: -1. Search .trellis/spec/ for relevant spec files -2. Search the codebase for related modules and patterns -3. Identify files that should be added to jsonl context - -Output format (use exactly this format): - -## implement.jsonl -- path: <relative file path>, reason: <why needed> -- path: <relative file path>, reason: <why needed> - -## check.jsonl -- path: <relative file path>, reason: <why needed> - -## debug.jsonl -- path: <relative file path>, reason: <why needed> - -## Suggested Scope -<single word for commit scope, e.g., auth, api, ui> - -## Technical Notes -<any important technical considerations for prd.md>", - model: "opus" -) -``` - -### Step 3: Add Context Entries - -Parse research agent output and add entries to jsonl files: - -```bash -# For each entry in implement.jsonl section: -python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" implement "<path>" "<reason>" - -# For each entry in check.jsonl section: -python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" check "<path>" "<reason>" - -# For each entry in debug.jsonl section: -python3 ./.trellis/scripts/task.py add-context "$PLAN_TASK_DIR" debug "<path>" "<reason>" -``` - -### Step 4: Write prd.md - -Create the requirements document: - -```bash -cat > "$PLAN_TASK_DIR/prd.md" << 'EOF' -# Task: ${PLAN_TASK_NAME} - -## Overview -[Brief description of what this feature does] - -## Requirements -- [Requirement 1] -- [Requirement 2] -- ... - -## Acceptance Criteria -- [ ] [Criterion 1] -- [ ] [Criterion 2] -- ... - -## Technical Notes -[Any technical considerations from research agent] - -## Out of Scope -- [What this feature does NOT include] -EOF -``` - -**Guidelines for prd.md**: -- Be specific and actionable -- Include acceptance criteria that can be verified -- Add technical notes from research agent -- Define what's out of scope to prevent scope creep - -### Step 5: Configure Task Metadata - -```bash -# Set branch name -python3 ./.trellis/scripts/task.py set-branch "$PLAN_TASK_DIR" "feature/${PLAN_TASK_NAME}" - -# Set scope (from research agent suggestion) -python3 ./.trellis/scripts/task.py set-scope "$PLAN_TASK_DIR" "<scope>" - -# Update dev_type in task.json -jq --arg type "$PLAN_DEV_TYPE" '.dev_type = $type' \ - "$PLAN_TASK_DIR/task.json" > "$PLAN_TASK_DIR/task.json.tmp" \ - && mv "$PLAN_TASK_DIR/task.json.tmp" "$PLAN_TASK_DIR/task.json" -``` - -### Step 6: Validate Configuration - -```bash -python3 ./.trellis/scripts/task.py validate "$PLAN_TASK_DIR" -``` - -If validation fails, fix the invalid paths and re-validate. - -### Step 7: Output Summary - -Print a summary for the caller: - -```bash -echo "=== Plan Complete ===" -echo "Task Directory: $PLAN_TASK_DIR" -echo "" -echo "Files created:" -ls -la "$PLAN_TASK_DIR" -echo "" -echo "Context summary:" -python3 ./.trellis/scripts/task.py list-context "$PLAN_TASK_DIR" -echo "" -echo "Ready for: python3 ./.trellis/scripts/multi_agent/start.py $PLAN_TASK_DIR" -``` - ---- - -## Key Principles - -1. **Reject early, reject clearly** - Don't waste time on bad requirements -2. **Research before configure** - Always call research agent to understand the codebase -3. **Validate all paths** - Every file in jsonl must exist -4. **Be specific in prd.md** - Vague requirements lead to wrong implementations -5. **Include acceptance criteria** - Check agent needs to verify something concrete -6. **Set appropriate scope** - This affects commit message format - ---- - -## Error Handling - -### Research Agent Returns No Results - -If research agent finds no relevant specs: -- Use only the base specs from init-context -- Add a note in prd.md that this is a new area without existing patterns - -### Path Not Found - -If add-context fails because path doesn't exist: -- Skip that entry -- Log a warning -- Continue with other entries - -### Validation Fails - -If final validation fails: -- Read the error output -- Remove invalid entries from jsonl files -- Re-validate - ---- - -## Examples - -### Example: Accepted Requirement - -``` -Input: - PLAN_TASK_NAME = "add-rate-limiting" - PLAN_DEV_TYPE = "backend" - PLAN_REQUIREMENT = "Add rate limiting to API endpoints using a sliding window algorithm. Limit to 100 requests per minute per IP. Return 429 status when exceeded." - -Result: ACCEPTED - Clear, specific, has defined behavior - -Output: - .trellis/tasks/02-03-add-rate-limiting/ - ├── task.json # branch: feature/add-rate-limiting, scope: api - ├── prd.md # Detailed requirements with acceptance criteria - ├── implement.jsonl # Backend specs + existing middleware patterns - ├── check.jsonl # Quality guidelines + API testing specs - └── debug.jsonl # Error handling specs -``` - -### Example: Rejected - Vague Requirement - -``` -Input: - PLAN_REQUIREMENT = "Make the API faster" - -Result: REJECTED - -=== PLAN REJECTED === - -Reason: Unclear or Vague - -Details: -"Make the API faster" does not specify: -- Which endpoints need optimization -- Current performance baseline -- Target performance metrics -- Acceptable trade-offs (memory, complexity) - -Suggestions: -- Identify specific slow endpoints with response times -- Define target latency (e.g., "GET /users should respond in <100ms") -- Specify if caching, query optimization, or architecture changes are acceptable -``` - -### Example: Rejected - Too Large - -``` -Input: - PLAN_REQUIREMENT = "Add user authentication, authorization, password reset, 2FA, OAuth integration, and audit logging" - -Result: REJECTED - -=== PLAN REJECTED === - -Reason: Too Large / Should Be Split - -Details: -This requirement bundles 6 distinct features that should be implemented separately: -1. User authentication (login/logout) -2. Authorization (roles/permissions) -3. Password reset flow -4. Two-factor authentication -5. OAuth integration -6. Audit logging - -Suggestions: -- Start with basic authentication first -- Create separate features for each capability -- Consider dependencies (auth before authz, etc.) -``` diff --git a/.opencode/commands/android-test.md b/.opencode/commands/android-test.md deleted file mode 100644 index 5dad31f..0000000 --- a/.opencode/commands/android-test.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: Run an Android automation test through Midscene Skills ---- - -You are running an Android mobile UI automation task for this project. - -Interpret the user arguments as the exact natural-language test goal: - -$ARGUMENTS - -Execution requirements: - -1. Verify that adb is available and that at least one Android device or emulator is connected. -2. If no Android target is available, stop and report that the Android automation prerequisite is missing. -3. Use the installed Midscene Android skill workflow to execute the requested UI actions on the Android emulator or device. -4. Prefer acting on the current development build of the app when applicable. -5. Capture visible evidence during the run when useful, especially the final screen state. -6. At the end, report: - - whether the flow succeeded - - the exact failing step if any - - what was observed on screen - - what should be fixed next if this looked like a product bug - -Do not only describe a test plan. Actually perform the automation when prerequisites are available. diff --git a/.opencode/commands/doc-update.md b/.opencode/commands/doc-update.md deleted file mode 100644 index 79cb251..0000000 --- a/.opencode/commands/doc-update.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -description: 审查并更新 docs/protocols,确保与当前代码实现一致 ---- - -你现在要执行一次“协议文档一致性审查与更新”,目标是让 `docs/protocols/` 成为项目协议与数据格式的最新事实来源,并与当前代码实现保持一致。 - -## 执行目标 - -1. 审查 `docs/protocols/` 下所有协议文档是否过期、缺失或与实现不一致。 -2. 在发现差异时,优先更新协议文档(而不是先改代码),明确兼容策略。 -3. 输出结构化审查结果:发现的问题、已更新内容、仍待确认项。 - -## 约束 - -- 审查范围优先限定在:`docs/protocols/**` 与本次协议相关代码(`backend/**`、`apps/**`)。 -- 不做无关重构,不改动与协议无关模块。 -- 禁止“吞错式”描述:若不确定,明确标记为待确认,不要假设正确。 -- 若涉及破坏性变更,必须在文档中写明迁移与回滚策略。 - -## 步骤 - -1. **建立协议清单** - - 列出 `docs/protocols/` 中所有文档。 - - 为每份文档提取:涉及的接口、事件、字段、枚举、状态码、错误结构。 - -2. **建立实现映射** - - 在 `backend/**`、`apps/**` 中定位对应实现与调用点。 - - 对每个协议项建立“文档 -> 实现”映射(文件路径 + 关键符号/接口名)。 - -3. **逐项比对并分级** - - 比对以下维度: - - 请求/响应结构与字段可选性 - - 字段命名、类型、默认值、约束 - - 错误码/错误体格式 - - 版本号、兼容说明、废弃说明 - - 给每个差异打级别: - - CRITICAL:会导致客户端/服务端不兼容 - - HIGH:行为偏差明显,容易引发线上错误 - - MEDIUM:文档缺失或描述不完整 - - LOW:措辞、示例、格式问题 - -4. **更新文档(优先)** - - 先更新 `docs/protocols/**`,使其反映当前真实实现。 - - 每处更新都要补充“兼容策略”: - - `backward-compatible`(向后兼容)或 - - `requires-migration`(需要迁移) - - 如为 `requires-migration`,补充迁移步骤与回滚注意事项。 - -5. **一致性复核** - - 复查所有变更是否与实现一致。 - - 如仓库有协议相关测试/校验脚本,执行最小必要验证。 - -## 输出格式(必须) - -1. **Protocol Audit Scope** - - 本次审查的文档列表 - - 对应实现文件映射 - -2. **Findings** - - 按 CRITICAL/HIGH/MEDIUM/LOW 分组列出差异 - - 每条包含:文档位置、实现位置、差异说明、影响范围 - -3. **Doc Updates Applied** - - 列出已更新的文档与关键修改点 - - 标注每项兼容策略(`backward-compatible` / `requires-migration`) - -4. **Open Questions** - - 仍需产品/后端/前端确认的点 - -5. **Verification** - - 列出执行的验证命令与结果(通过/失败) - -## 完成标准 - -- `docs/protocols/` 覆盖当前实现的真实协议。 -- 所有发现的关键差异(CRITICAL/HIGH)要么已修正文档,要么被明确记录为阻塞项。 -- 输出报告完整,后续协作者可据此继续推进。 diff --git a/.opencode/commands/ios-test.md b/.opencode/commands/ios-test.md deleted file mode 100644 index 8f4a255..0000000 --- a/.opencode/commands/ios-test.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: Run an iOS automation test through Midscene Skills ---- - -You are running an iOS mobile UI automation task for this project. - -Interpret the user arguments as the exact natural-language test goal: - -$ARGUMENTS - -Execution requirements: - -1. Verify that WebDriverAgent is reachable at http://localhost:8100/status before doing any iOS action. -2. If WebDriverAgent is not ready, stop and report that the iOS automation prerequisite is missing. -3. Use the installed Midscene iOS skill workflow to execute the requested UI actions on the iOS simulator or device. -4. Prefer acting on the current development build of the app when applicable. -5. Capture visible evidence during the run when useful, especially the final screen state. -6. At the end, report: - - whether the flow succeeded - - the exact failing step if any - - what was observed on screen - - what should be fixed next if this looked like a product bug - -Do not only describe a test plan. Actually perform the automation when prerequisites are available. diff --git a/.opencode/commands/trellis/before-backend-dev.md b/.opencode/commands/trellis/before-backend-dev.md deleted file mode 100644 index 7dfcd36..0000000 --- a/.opencode/commands/trellis/before-backend-dev.md +++ /dev/null @@ -1,13 +0,0 @@ -Read the backend development guidelines before starting your development task. - -Execute these steps: -1. Read `.trellis/spec/backend/index.md` to understand available guidelines -2. Based on your task, read the relevant guideline files: - - Database work → `.trellis/spec/backend/database-guidelines.md` - - Error handling → `.trellis/spec/backend/error-handling.md` - - Logging → `.trellis/spec/backend/logging-guidelines.md` - - Type questions → `.trellis/spec/backend/type-safety.md` -3. Understand the coding standards and patterns you need to follow -4. Then proceed with your development plan - -This step is **mandatory** before writing any backend code. diff --git a/.opencode/commands/trellis/before-frontend-dev.md b/.opencode/commands/trellis/before-frontend-dev.md deleted file mode 100644 index 9687edc..0000000 --- a/.opencode/commands/trellis/before-frontend-dev.md +++ /dev/null @@ -1,13 +0,0 @@ -Read the frontend development guidelines before starting your development task. - -Execute these steps: -1. Read `.trellis/spec/frontend/index.md` to understand available guidelines -2. Based on your task, read the relevant guideline files: - - Component work → `.trellis/spec/frontend/component-guidelines.md` - - Hook work → `.trellis/spec/frontend/hook-guidelines.md` - - State management → `.trellis/spec/frontend/state-management.md` - - Type questions → `.trellis/spec/frontend/type-safety.md` -3. Understand the coding standards and patterns you need to follow -4. Then proceed with your development plan - -This step is **mandatory** before writing any frontend code. diff --git a/.opencode/commands/trellis/check-backend.md b/.opencode/commands/trellis/check-backend.md deleted file mode 100644 index 886f5c9..0000000 --- a/.opencode/commands/trellis/check-backend.md +++ /dev/null @@ -1,13 +0,0 @@ -Check if the code you just wrote follows the backend development guidelines. - -Execute these steps: -1. Run `git status` to see modified files -2. Read `.trellis/spec/backend/index.md` to understand which guidelines apply -3. Based on what you changed, read the relevant guideline files: - - Database changes → `.trellis/spec/backend/database-guidelines.md` - - Error handling → `.trellis/spec/backend/error-handling.md` - - Logging changes → `.trellis/spec/backend/logging-guidelines.md` - - Type changes → `.trellis/spec/backend/type-safety.md` - - Any changes → `.trellis/spec/backend/quality-guidelines.md` -4. Review your code against the guidelines -5. Report any violations and fix them if found diff --git a/.opencode/commands/trellis/check-cross-layer.md b/.opencode/commands/trellis/check-cross-layer.md deleted file mode 100644 index 591d39b..0000000 --- a/.opencode/commands/trellis/check-cross-layer.md +++ /dev/null @@ -1,153 +0,0 @@ -# Cross-Layer Check - -Check if your changes considered all dimensions. Most bugs come from "didn't think of it", not lack of technical skill. - -> **Note**: This is a **post-implementation** safety net. Ideally, read the [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) **before** writing code. - ---- - -## Related Documents - -| Document | Purpose | Timing | -|----------|---------|--------| -| [Pre-Implementation Checklist](.trellis/spec/guides/pre-implementation-checklist.md) | Questions before coding | **Before** writing code | -| [Code Reuse Thinking Guide](.trellis/spec/guides/code-reuse-thinking-guide.md) | Pattern recognition | During implementation | -| **`/trellis:check-cross-layer`** (this) | Verification check | **After** implementation | - ---- - -## Execution Steps - -### 1. Identify Change Scope - -```bash -git status -git diff --name-only -``` - -### 2. Select Applicable Check Dimensions - -Based on your change type, execute relevant checks below: - ---- - -## Dimension A: Cross-Layer Data Flow (Required when 3+ layers) - -**Trigger**: Changes involve 3 or more layers - -| Layer | Common Locations | -|-------|------------------| -| API/Routes | `routes/`, `api/`, `handlers/`, `controllers/` | -| Service/Business Logic | `services/`, `lib/`, `core/`, `domain/` | -| Database/Storage | `db/`, `models/`, `repositories/`, `schema/` | -| UI/Presentation | `components/`, `views/`, `templates/`, `pages/` | -| Utility | `utils/`, `helpers/`, `common/` | - -**Checklist**: -- [ ] Read flow: Database -> Service -> API -> UI -- [ ] Write flow: UI -> API -> Service -> Database -- [ ] Types/schemas correctly passed between layers? -- [ ] Errors properly propagated to caller? -- [ ] Loading/pending states handled at each layer? - -**Detailed Guide**: `.trellis/spec/guides/cross-layer-thinking-guide.md` - ---- - -## Dimension B: Code Reuse (Required when modifying constants/config) - -**Trigger**: -- Modifying UI constants (label, icon, color) -- Modifying any hardcoded value -- Seeing similar code in multiple places -- Creating a new utility/helper function -- Just finished batch modifications across files - -**Checklist**: -- [ ] Search first: How many places define this value? - ```bash - # Search in source files (adjust extensions for your project) - grep -r "value-to-change" src/ - ``` -- [ ] If 2+ places define same value -> Should extract to shared constant -- [ ] After modification, all usage sites updated? -- [ ] If creating utility: Does similar utility already exist? - -**Detailed Guide**: `.trellis/spec/guides/code-reuse-thinking-guide.md` - ---- - -## Dimension B2: New Utility Functions - -**Trigger**: About to create a new utility/helper function - -**Checklist**: -- [ ] Search for existing similar utilities first - ```bash - grep -r "functionNamePattern" src/ - ``` -- [ ] If similar exists, can you extend it instead? -- [ ] If creating new, is it in the right location (shared vs domain-specific)? - ---- - -## Dimension B3: After Batch Modifications - -**Trigger**: Just modified similar patterns in multiple files - -**Checklist**: -- [ ] Did you check ALL files with similar patterns? - ```bash - grep -r "patternYouChanged" src/ - ``` -- [ ] Any files missed that should also be updated? -- [ ] Should this pattern be abstracted to prevent future duplication? - ---- - -## Dimension C: Import/Dependency Paths (Required when creating new files) - -**Trigger**: Creating new source files - -**Checklist**: -- [ ] Using correct import paths (relative vs absolute)? -- [ ] No circular dependencies? -- [ ] Consistent with project's module organization? - ---- - -## Dimension D: Same-Layer Consistency - -**Trigger**: -- Modifying display logic or formatting -- Same domain concept used in multiple places - -**Checklist**: -- [ ] Search for other places using same concept - ```bash - grep -r "ConceptName" src/ - ``` -- [ ] Are these usages consistent? -- [ ] Should they share configuration/constants? - ---- - -## Common Issues Quick Reference - -| Issue | Root Cause | Prevention | -|-------|------------|------------| -| Changed one place, missed others | Didn't search impact scope | `grep` before changing | -| Data lost at some layer | Didn't check data flow | Trace data source to destination | -| Type/schema mismatch | Cross-layer types inconsistent | Use shared type definitions | -| UI/output inconsistent | Same concept in multiple places | Extract shared constants | -| Similar utility exists | Didn't search first | Search before creating | -| Batch fix incomplete | Didn't verify all occurrences | grep after fixing | - ---- - -## Output - -Report: -1. Which dimensions your changes involve -2. Check results for each dimension -3. Issues found and fix suggestions diff --git a/.opencode/commands/trellis/check-frontend.md b/.opencode/commands/trellis/check-frontend.md deleted file mode 100644 index 3771ae3..0000000 --- a/.opencode/commands/trellis/check-frontend.md +++ /dev/null @@ -1,13 +0,0 @@ -Check if the code you just wrote follows the frontend development guidelines. - -Execute these steps: -1. Run `git status` to see modified files -2. Read `.trellis/spec/frontend/index.md` to understand which guidelines apply -3. Based on what you changed, read the relevant guideline files: - - Component changes → `.trellis/spec/frontend/component-guidelines.md` - - Hook changes → `.trellis/spec/frontend/hook-guidelines.md` - - State changes → `.trellis/spec/frontend/state-management.md` - - Type changes → `.trellis/spec/frontend/type-safety.md` - - Any changes → `.trellis/spec/frontend/quality-guidelines.md` -4. Review your code against the guidelines -5. Report any violations and fix them if found diff --git a/.opencode/commands/trellis/create-command.md b/.opencode/commands/trellis/create-command.md deleted file mode 100644 index bc12b6f..0000000 --- a/.opencode/commands/trellis/create-command.md +++ /dev/null @@ -1,154 +0,0 @@ -# Create New Slash Command - -Create a new slash command in both `.cursor/commands/` (with `trellis-` prefix) and `.opencode/commands/trellis/` directories based on user requirements. - -## Usage - -``` -/trellis:create-command <command-name> <description> -``` - -**Example**: -``` -/trellis:create-command review-pr Check PR code changes against project guidelines -``` - -## Execution Steps - -### 1. Parse Input - -Extract from user input: -- **Command name**: Use kebab-case (e.g., `review-pr`) -- **Description**: What the command should accomplish - -### 2. Analyze Requirements - -Determine command type based on description: -- **Initialization**: Read docs, establish context -- **Pre-development**: Read guidelines, check dependencies -- **Code check**: Validate code quality and guideline compliance -- **Recording**: Record progress, questions, structure changes -- **Generation**: Generate docs, code templates - -### 3. Generate Command Content - -Based on command type, generate appropriate content: - -**Simple command** (1-3 lines): -```markdown -Concise instruction describing what to do -``` - -**Complex command** (with steps): -```markdown -# Command Title - -Command description - -## Steps - -### 1. First Step -Specific action - -### 2. Second Step -Specific action - -## Output Format (if needed) - -Template -``` - -### 4. Create Files - -Create in both directories: -- `.cursor/commands/trellis-<command-name>.md` -- `.opencode/commands/trellis/<command-name>.md` - -### 5. Confirm Creation - -Output result: -``` -[OK] Created Slash Command: /<command-name> - -File paths: -- .cursor/commands/trellis-<command-name>.md -- .opencode/commands/trellis/<command-name>.md - -Usage: -/trellis:<command-name> - -Description: -<description> -``` - -## Command Content Guidelines - -### [OK] Good command content - -1. **Clear and concise**: Immediately understandable -2. **Executable**: AI can follow steps directly -3. **Well-scoped**: Clear boundaries of what to do and not do -4. **Has output**: Specifies expected output format (if needed) - -### [X] Avoid - -1. **Too vague**: e.g., "optimize code" -2. **Too complex**: Single command should not exceed 100 lines -3. **Duplicate functionality**: Check if similar command exists first - -## Naming Conventions - -| Command Type | Prefix | Example | -|--------------|--------|---------| -| Session Start | `start` | `start` | -| Pre-development | `before-` | `before-frontend-dev` | -| Check | `check-` | `check-frontend` | -| Record | `record-` | `record-session` | -| Generate | `generate-` | `generate-api-doc` | -| Update | `update-` | `update-changelog` | -| Other | Verb-first | `review-code`, `sync-data` | - -## Example - -### Input -``` -/trellis:create-command review-pr Check PR code changes against project guidelines -``` - -### Generated Command Content -```markdown -# PR Code Review - -Check current PR code changes against project guidelines. - -## Steps - -### 1. Get Changed Files -```bash -git diff main...HEAD --name-only -``` - -### 2. Categorized Review - -**Frontend files** (`apps/web/`): -- Reference `.trellis/spec/frontend/index.md` - -**Backend files** (`packages/api/`): -- Reference `.trellis/spec/backend/index.md` - -### 3. Output Review Report - -Format: - -## PR Review Report - -### Changed Files -- [file list] - -### Check Results -- [OK] Passed items -- [X] Issues found - -### Suggestions -- [improvement suggestions] -``` diff --git a/.opencode/commands/trellis/finish-work.md b/.opencode/commands/trellis/finish-work.md deleted file mode 100644 index b82b153..0000000 --- a/.opencode/commands/trellis/finish-work.md +++ /dev/null @@ -1,144 +0,0 @@ -# Finish Work - Pre-Commit Checklist - -Before submitting or committing, use this checklist to ensure work completeness. - -**Timing**: After code is written and tested, before commit - ---- - -## Checklist - -### 1. Code Quality - -```bash -# Must pass -pnpm lint -pnpm type-check -pnpm test -``` - -- [ ] `pnpm lint` passes with 0 errors? -- [ ] `pnpm type-check` passes with no type errors? -- [ ] Tests pass? -- [ ] No `console.log` statements (use logger)? -- [ ] No non-null assertions (the `x!` operator)? -- [ ] No `any` types? - -### 2. Code-Spec Sync - -**Code-Spec Docs**: -- [ ] Does `.trellis/spec/backend/` need updates? - - New patterns, new modules, new conventions -- [ ] Does `.trellis/spec/frontend/` need updates? - - New components, new hooks, new patterns -- [ ] Does `.trellis/spec/guides/` need updates? - - New cross-layer flows, lessons from bugs - -**Key Question**: -> "If I fixed a bug or discovered something non-obvious, should I document it so future me (or others) won't hit the same issue?" - -If YES -> Update the relevant code-spec doc. - -### 2.5. Code-Spec Hard Block (Infra/Cross-Layer) - -If this change touches infra or cross-layer contracts, this is a blocking checklist: - -- [ ] Spec content is executable (real signatures/contracts), not principle-only text -- [ ] Includes file path + command/API name + payload field names -- [ ] Includes validation and error matrix -- [ ] Includes Good/Base/Bad cases -- [ ] Includes required tests and assertion points - -**Block Rule**: -In pipeline mode, the finish agent will automatically detect and execute spec updates when gaps are found. -If running this checklist manually, ensure spec sync is complete before committing — run `/trellis:update-spec` if needed. - -### 3. API Changes - -If you modified API endpoints: - -- [ ] Input schema updated? -- [ ] Output schema updated? -- [ ] API documentation updated? -- [ ] Client code updated to match? - -### 4. Database Changes - -If you modified database schema: - -- [ ] Migration file created? -- [ ] Schema file updated? -- [ ] Related queries updated? -- [ ] Seed data updated (if applicable)? - -### 5. Cross-Layer Verification - -If the change spans multiple layers: - -- [ ] Data flows correctly through all layers? -- [ ] Error handling works at each boundary? -- [ ] Types are consistent across layers? -- [ ] Loading states handled? - -### 6. Manual Testing - -- [ ] Feature works in browser/app? -- [ ] Edge cases tested? -- [ ] Error states tested? -- [ ] Works after page refresh? - ---- - -## Quick Check Flow - -```bash -# 1. Code checks -pnpm lint && pnpm type-check - -# 2. View changes -git status -git diff --name-only - -# 3. Based on changed files, check relevant items above -``` - ---- - -## Common Oversights - -| Oversight | Consequence | Check | -|-----------|-------------|-------| -| Code-spec docs not updated | Others don't know the change | Check .trellis/spec/ | -| Spec text is abstract only | Easy regressions in infra/cross-layer changes | Require signature/contract/matrix/cases/tests | -| Migration not created | Schema out of sync | Check db/migrations/ | -| Types not synced | Runtime errors | Check shared types | -| Tests not updated | False confidence | Run full test suite | -| Console.log left in | Noisy production logs | Search for console.log | - ---- - -## Relationship to Other Commands - -``` -Development Flow: - Write code -> Test -> /trellis:finish-work -> git commit -> /trellis:record-session - | | - Ensure completeness Record progress - -Debug Flow: - Hit bug -> Fix -> /trellis:break-loop -> Knowledge capture - | - Deep analysis -``` - -- `/trellis:finish-work` - Check work completeness (this command) -- `/trellis:record-session` - Record session and commits -- `/trellis:break-loop` - Deep analysis after debugging - ---- - -## Core Principle - -> **Delivery includes not just code, but also documentation, verification, and knowledge capture.** - -Complete work = Code + Docs + Tests + Verification diff --git a/.opencode/commands/trellis/integrate-skill.md b/.opencode/commands/trellis/integrate-skill.md deleted file mode 100644 index cacafd5..0000000 --- a/.opencode/commands/trellis/integrate-skill.md +++ /dev/null @@ -1,219 +0,0 @@ -# Integrate Claude Skill into Project Guidelines - -Adapt and integrate a Claude global skill into your project's development guidelines (not directly into project code). - -## Usage - -``` -/trellis:integrate-skill <skill-name> -``` - -**Examples**: -``` -/trellis:integrate-skill frontend-design -/trellis:integrate-skill mcp-builder -``` - -## Core Principle - -> [!] **Important**: The goal of skill integration is to update **development guidelines**, not to generate project code directly. -> -> - Guidelines content -> Write to `.trellis/spec/{target}/doc.md` -> - Code examples -> Place in `.trellis/spec/{target}/examples/skills/<skill-name>/` -> - Example files -> Use `.template` suffix (e.g., `component.tsx.template`) to avoid IDE errors -> -> Where `{target}` is `frontend` or `backend`, determined by skill type. - -## Execution Steps - -### 1. Read Skill Content - -```bash -openskills read <skill-name> -``` - -If the skill doesn't exist, prompt user to check available skills: -```bash -# Available skills are listed in AGENTS.md under <available_skills> -``` - -### 2. Determine Integration Target - -Based on skill type, determine which guidelines to update: - -| Skill Category | Integration Target | -|----------------|-------------------| -| UI/Frontend (`frontend-design`, `web-artifacts-builder`) | `.trellis/spec/frontend/` | -| Backend/API (`mcp-builder`) | `.trellis/spec/backend/` | -| Documentation (`doc-coauthoring`, `docx`, `pdf`) | `.trellis/` or create dedicated guidelines | -| Testing (`webapp-testing`) | `.trellis/spec/frontend/` (E2E) | - -### 3. Analyze Skill Content - -Extract from the skill: -- **Core concepts**: How the skill works and key concepts -- **Best practices**: Recommended approaches -- **Code patterns**: Reusable code templates -- **Caveats**: Common issues and solutions - -### 4. Execute Integration - -#### 4.1 Update Guidelines Document - -Add a new section to the corresponding `doc.md`: - -```markdown -@@@section:skill-<skill-name> -## # <Skill Name> Integration Guide - -### Overview -[Core functionality and use cases of the skill] - -### Project Adaptation -[How to use this skill in the current project] - -### Usage Steps -1. [Step 1] -2. [Step 2] - -### Caveats -- [Project-specific constraints] -- [Differences from default behavior] - -### Reference Examples -See `examples/skills/<skill-name>/` - -@@@/section:skill-<skill-name> -``` - -#### 4.2 Create Examples Directory (if code examples exist) - -```bash -# Directory structure ({target} = frontend or backend) -.trellis/spec/{target}/ -|-- doc.md # Add skill-related section -|-- index.md # Update index -+-- examples/ - +-- skills/ - +-- <skill-name>/ - |-- README.md # Example documentation - |-- example-1.ts.template # Code example (use .template suffix) - +-- example-2.tsx.template -``` - -**File naming conventions**: -- Code files: `<name>.<ext>.template` (e.g., `component.tsx.template`) -- Config files: `<name>.config.template` (e.g., `tailwind.config.template`) -- Documentation: `README.md` (normal suffix) - -#### 4.3 Update Index File - -Add to the Quick Navigation table in `index.md`: - -```markdown -| <Skill-related task> | <Section name> | `skill-<skill-name>` | -``` - -### 5. Generate Integration Report - ---- - -## Skill Integration Report: `<skill-name>` - -### # Overview -- **Skill description**: [Functionality description] -- **Integration target**: `.trellis/spec/{target}/` - -### # Tech Stack Compatibility - -| Skill Requirement | Project Status | Compatibility | -|-------------------|----------------|---------------| -| [Tech 1] | [Project tech] | [OK]/[!]/[X] | - -### # Integration Locations - -| Type | Path | -|------|------| -| Guidelines doc | `.trellis/spec/{target}/doc.md` (section: `skill-<name>`) | -| Code examples | `.trellis/spec/{target}/examples/skills/<name>/` | -| Index update | `.trellis/spec/{target}/index.md` | - -> `{target}` = `frontend` or `backend` - -### # Dependencies (if needed) - -```bash -# Install required dependencies (adjust for your package manager) -npm install <package> -# or -pnpm add <package> -# or -yarn add <package> -``` - -### [OK] Completed Changes - -- [ ] Added `@@@section:skill-<name>` section to `doc.md` -- [ ] Added index entry to `index.md` -- [ ] Created example files in `examples/skills/<name>/` -- [ ] Example files use `.template` suffix - -### # Related Guidelines - -- [Existing related section IDs] - ---- - -## 6. Optional: Create Usage Command - -If this skill is frequently used, create a shortcut command: - -```bash -/trellis:create-command use-<skill-name> Use <skill-name> skill following project guidelines -``` - -## Common Skill Integration Reference - -| Skill | Integration Target | Examples Directory | -|-------|-------------------|-------------------| -| `frontend-design` | `frontend` | `examples/skills/frontend-design/` | -| `mcp-builder` | `backend` | `examples/skills/mcp-builder/` | -| `webapp-testing` | `frontend` | `examples/skills/webapp-testing/` | -| `doc-coauthoring` | `.trellis/` | N/A (documentation workflow only) | - -## Example: Integrating `mcp-builder` Skill - -### Directory Structure - -``` -.trellis/spec/backend/ -|-- doc.md # Add MCP section -|-- index.md # Add index entry -+-- examples/ - +-- skills/ - +-- mcp-builder/ - |-- README.md - |-- server.ts.template - |-- tools.ts.template - +-- types.ts.template -``` - -### New Section in doc.md - -```markdown -@@@section:skill-mcp-builder -## # MCP Server Development Guide - -### Overview -Create LLM-callable tool services using MCP (Model Context Protocol). - -### Project Adaptation -- Place services in a dedicated directory -- Follow existing TypeScript and type definition conventions -- Use project's logging system - -### Reference Examples -See `examples/skills/mcp-builder/` - -@@@/section:skill-mcp-builder -``` diff --git a/.opencode/commands/trellis/migrate-specs.md b/.opencode/commands/trellis/migrate-specs.md deleted file mode 100644 index e69de29..0000000 diff --git a/.opencode/commands/trellis/onboard.md b/.opencode/commands/trellis/onboard.md deleted file mode 100644 index 732f80d..0000000 --- a/.opencode/commands/trellis/onboard.md +++ /dev/null @@ -1,358 +0,0 @@ -You are a senior developer onboarding a new team member to this project's AI-assisted workflow system. - -YOUR ROLE: Be a mentor and teacher. Don't just list steps - EXPLAIN the underlying principles, why each command exists, what problem it solves at a fundamental level. - -## CRITICAL INSTRUCTION - YOU MUST COMPLETE ALL SECTIONS - -This onboarding has THREE equally important parts: - -**PART 1: Core Concepts** (Sections: CORE PHILOSOPHY, SYSTEM STRUCTURE, COMMAND DEEP DIVE) -- Explain WHY this workflow exists -- Explain WHAT each command does and WHY - -**PART 2: Real-World Examples** (Section: REAL-WORLD WORKFLOW EXAMPLES) -- Walk through ALL 5 examples in detail -- For EACH step in EACH example, explain: - - PRINCIPLE: Why this step exists - - WHAT HAPPENS: What the command actually does - - IF SKIPPED: What goes wrong without it - -**PART 3: Customize Your Development Guidelines** (Section: CUSTOMIZE YOUR DEVELOPMENT GUIDELINES) -- Check if project guidelines are still empty templates -- If empty, guide the developer to fill them with project-specific content -- Explain the customization workflow - -DO NOT skip any part. All three parts are essential: -- Part 1 teaches the concepts -- Part 2 shows how concepts work in practice -- Part 3 ensures the project has proper guidelines for AI to follow - -After completing ALL THREE parts, ask the developer about their first task. - ---- - -## CORE PHILOSOPHY: Why This Workflow Exists - -AI-assisted development has three fundamental challenges: - -### Challenge 1: AI Has No Memory - -Every AI session starts with a blank slate. Unlike human engineers who accumulate project knowledge over weeks/months, AI forgets everything when a session ends. - -**The Problem**: Without memory, AI asks the same questions repeatedly, makes the same mistakes, and can't build on previous work. - -**The Solution**: The `.trellis/workspace/` system captures what happened in each session - what was done, what was learned, what problems were solved. The `/trellis:start` command reads this history at session start, giving AI "artificial memory." - -### Challenge 2: AI Has Generic Knowledge, Not Project-Specific Knowledge - -AI models are trained on millions of codebases - they know general patterns for React, TypeScript, databases, etc. But they don't know YOUR project's conventions. - -**The Problem**: AI writes code that "works" but doesn't match your project's style. It uses patterns that conflict with existing code. It makes decisions that violate unwritten team rules. - -**The Solution**: The `.trellis/spec/` directory contains project-specific guidelines. The `/before-*-dev` commands inject this specialized knowledge into AI context before coding starts. - -### Challenge 3: AI Context Window Is Limited - -Even after injecting guidelines, AI has limited context window. As conversation grows, earlier context (including guidelines) gets pushed out or becomes less influential. - -**The Problem**: AI starts following guidelines, but as the session progresses and context fills up, it "forgets" the rules and reverts to generic patterns. - -**The Solution**: The `/check-*` commands re-verify code against guidelines AFTER writing, catching drift that occurred during development. The `/trellis:finish-work` command does a final holistic review. - ---- - -## SYSTEM STRUCTURE - -``` -.trellis/ -|-- .developer # Your identity (gitignored) -|-- workflow.md # Complete workflow documentation -|-- workspace/ # "AI Memory" - session history -| |-- index.md # All developers' progress -| +-- {developer}/ # Per-developer directory -| |-- index.md # Personal progress index -| +-- journal-N.md # Session records (max 2000 lines) -|-- tasks/ # Task tracking (unified) -| +-- {MM}-{DD}-{slug}/ # Task directory -| |-- task.json # Task metadata -| +-- prd.md # Requirements doc -|-- spec/ # "AI Training Data" - project knowledge -| |-- frontend/ # Frontend conventions -| |-- backend/ # Backend conventions -| +-- guides/ # Thinking patterns -+-- scripts/ # Automation tools -``` - -### Understanding spec/ subdirectories - -**frontend/** - Single-layer frontend knowledge: -- Component patterns (how to write components in THIS project) -- State management rules (Redux? Zustand? Context?) -- Styling conventions (CSS modules? Tailwind? Styled-components?) -- Hook patterns (custom hooks, data fetching) - -**backend/** - Single-layer backend knowledge: -- API design patterns (REST? GraphQL? tRPC?) -- Database conventions (query patterns, migrations) -- Error handling standards -- Logging and monitoring rules - -**guides/** - Cross-layer thinking guides: -- Code reuse thinking guide -- Cross-layer thinking guide -- Pre-implementation checklists - ---- - -## COMMAND DEEP DIVE - -### /trellis:start - Restore AI Memory - -**WHY IT EXISTS**: -When a human engineer joins a project, they spend days/weeks learning: What is this project? What's been built? What's in progress? What's the current state? - -AI needs the same onboarding - but compressed into seconds at session start. - -**WHAT IT ACTUALLY DOES**: -1. Reads developer identity (who am I in this project?) -2. Checks git status (what branch? uncommitted changes?) -3. Reads recent session history from `workspace/` (what happened before?) -4. Identifies active features (what's in progress?) -5. Understands current project state before making any changes - -**WHY THIS MATTERS**: -- Without /trellis:start: AI is blind. It might work on wrong branch, conflict with others' work, or redo already-completed work. -- With /trellis:start: AI knows project context, can continue where previous session left off, avoids conflicts. - ---- - -### /trellis:before-frontend-dev and /trellis:before-backend-dev - Inject Specialized Knowledge - -**WHY IT EXISTS**: -AI models have "pre-trained knowledge" - general patterns from millions of codebases. But YOUR project has specific conventions that differ from generic patterns. - -**WHAT IT ACTUALLY DOES**: -1. Reads `.trellis/spec/frontend/` or `.trellis/spec/backend/` -2. Loads project-specific patterns into AI's working context: - - Component naming conventions - - State management patterns - - Database query patterns - - Error handling standards - -**WHY THIS MATTERS**: -- Without before-*-dev: AI writes generic code that doesn't match project style. -- With before-*-dev: AI writes code that looks like the rest of the codebase. - ---- - -### /trellis:check-frontend and /trellis:check-backend - Combat Context Drift - -**WHY IT EXISTS**: -AI context window has limited capacity. As conversation progresses, guidelines injected at session start become less influential. This causes "context drift." - -**WHAT IT ACTUALLY DOES**: -1. Re-reads the guidelines that were injected earlier -2. Compares written code against those guidelines -3. Runs type checker and linter -4. Identifies violations and suggests fixes - -**WHY THIS MATTERS**: -- Without check-*: Context drift goes unnoticed, code quality degrades. -- With check-*: Drift is caught and corrected before commit. - ---- - -### /trellis:check-cross-layer - Multi-Dimension Verification - -**WHY IT EXISTS**: -Most bugs don't come from lack of technical skill - they come from "didn't think of it": -- Changed a constant in one place, missed 5 other places -- Modified database schema, forgot to update the API layer -- Created a utility function, but similar one already exists - -**WHAT IT ACTUALLY DOES**: -1. Identifies which dimensions your change involves -2. For each dimension, runs targeted checks: - - Cross-layer data flow - - Code reuse analysis - - Import path validation - - Consistency checks - ---- - -### /trellis:finish-work - Holistic Pre-Commit Review - -**WHY IT EXISTS**: -The `/check-*` commands focus on code quality within a single layer. But real changes often have cross-cutting concerns. - -**WHAT IT ACTUALLY DOES**: -1. Reviews all changes holistically -2. Checks cross-layer consistency -3. Identifies broader impacts -4. Checks if new patterns should be documented - ---- - -### /trellis:record-session - Persist Memory for Future - -**WHY IT EXISTS**: -All the context AI built during this session will be lost when session ends. The next session's `/trellis:start` needs this information. - -**WHAT IT ACTUALLY DOES**: -1. Records session summary to `workspace/{developer}/journal-N.md` -2. Captures what was done, learned, and what's remaining -3. Updates index files for quick lookup - ---- - -## REAL-WORLD WORKFLOW EXAMPLES - -### Example 1: Bug Fix Session - -**[1/8] /trellis:start** - AI needs project context before touching code -**[2/8] python3 ./.trellis/scripts/task.py create "Fix bug" --slug fix-bug** - Track work for future reference -**[3/8] /trellis:before-frontend-dev** - Inject project-specific frontend knowledge -**[4/8] Investigate and fix the bug** - Actual development work -**[5/8] /trellis:check-frontend** - Re-verify code against guidelines -**[6/8] /trellis:finish-work** - Holistic cross-layer review -**[7/8] Human tests and commits** - Human validates before code enters repo -**[8/8] /trellis:record-session** - Persist memory for future sessions - -### Example 2: Planning Session (No Code) - -**[1/4] /trellis:start** - Context needed even for non-coding work -**[2/4] python3 ./.trellis/scripts/task.py create "Planning task" --slug planning-task** - Planning is valuable work -**[3/4] Review docs, create subtask list** - Actual planning work -**[4/4] /trellis:record-session (with --summary)** - Planning decisions must be recorded - -### Example 3: Code Review Fixes - -**[1/6] /trellis:start** - Resume context from previous session -**[2/6] /trellis:before-backend-dev** - Re-inject guidelines before fixes -**[3/6] Fix each CR issue** - Address feedback with guidelines in context -**[4/6] /trellis:check-backend** - Verify fixes didn't introduce new issues -**[5/6] /trellis:finish-work** - Document lessons from CR -**[6/6] Human commits, then /trellis:record-session** - Preserve CR lessons - -### Example 4: Large Refactoring - -**[1/5] /trellis:start** - Clear baseline before major changes -**[2/5] Plan phases** - Break into verifiable chunks -**[3/5] Execute phase by phase with /check-* after each** - Incremental verification -**[4/5] /trellis:finish-work** - Check if new patterns should be documented -**[5/5] Record with multiple commit hashes** - Link all commits to one feature - -### Example 5: Debug Session - -**[1/6] /trellis:start** - See if this bug was investigated before -**[2/6] /trellis:before-backend-dev** - Guidelines might document known gotchas -**[3/6] Investigation** - Actual debugging work -**[4/6] /trellis:check-backend** - Verify debug changes don't break other things -**[5/6] /trellis:finish-work** - Debug findings might need documentation -**[6/6] Human commits, then /trellis:record-session** - Debug knowledge is valuable - ---- - -## KEY RULES TO EMPHASIZE - -1. **AI NEVER commits** - Human tests and approves. AI prepares, human validates. -2. **Guidelines before code** - /before-*-dev commands inject project knowledge. -3. **Check after code** - /check-* commands catch context drift. -4. **Record everything** - /trellis:record-session persists memory. - ---- - -# PART 3: Customize Your Development Guidelines - -After explaining Part 1 and Part 2, check if the project's development guidelines need customization. - -## Step 1: Check Current Guidelines Status - -Check if `.trellis/spec/` contains empty templates or customized guidelines: - -```bash -# Check if files are still empty templates (look for placeholder text) -grep -l "To be filled by the team" .trellis/spec/backend/*.md 2>/dev/null | wc -l -grep -l "To be filled by the team" .trellis/spec/frontend/*.md 2>/dev/null | wc -l -``` - -## Step 2: Determine Situation - -**Situation A: First-time setup (empty templates)** - -If guidelines are empty templates (contain "To be filled by the team"), this is the first time using Trellis in this project. - -Explain to the developer: - -"I see that the development guidelines in `.trellis/spec/` are still empty templates. This is normal for a new Trellis setup! - -The templates contain placeholder text that needs to be replaced with YOUR project's actual conventions. Without this, `/before-*-dev` commands won't provide useful guidance. - -**Your first task should be to fill in these guidelines:** - -1. Look at your existing codebase -2. Identify the patterns and conventions already in use -3. Document them in the guideline files - -For example, for `.trellis/spec/backend/database-guidelines.md`: -- What ORM/query library does your project use? -- How are migrations managed? -- What naming conventions for tables/columns? - -Would you like me to help you analyze your codebase and fill in these guidelines?" - -**Situation B: Guidelines already customized** - -If guidelines have real content (no "To be filled" placeholders), this is an existing setup. - -Explain to the developer: - -"Great! Your team has already customized the development guidelines. You can start using `/before-*-dev` commands right away. - -I recommend reading through `.trellis/spec/` to familiarize yourself with the team's coding standards." - -## Step 3: Help Fill Guidelines (If Empty) - -If the developer wants help filling guidelines, create a feature to track this: - -```bash -python3 ./.trellis/scripts/task.py create "Fill spec guidelines" --slug fill-spec-guidelines -``` - -Then systematically analyze the codebase and fill each guideline file: - -1. **Analyze the codebase** - Look at existing code patterns -2. **Document conventions** - Write what you observe, not ideals -3. **Include examples** - Reference actual files in the project -4. **List forbidden patterns** - Document anti-patterns the team avoids - -Work through one file at a time: -- `backend/directory-structure.md` -- `backend/database-guidelines.md` -- `backend/error-handling.md` -- `backend/quality-guidelines.md` -- `backend/logging-guidelines.md` -- `frontend/directory-structure.md` -- `frontend/component-guidelines.md` -- `frontend/hook-guidelines.md` -- `frontend/state-management.md` -- `frontend/quality-guidelines.md` -- `frontend/type-safety.md` - ---- - -## Completing the Onboard Session - -After covering all three parts, summarize: - -"You're now onboarded to the Trellis workflow system! Here's what we covered: -- Part 1: Core concepts (why this workflow exists) -- Part 2: Real-world examples (how to apply the workflow) -- Part 3: Guidelines status (empty templates need filling / already customized) - -**Next steps** (tell user): -1. Run `/trellis:record-session` to record this onboard session -2. [If guidelines empty] Start filling in `.trellis/spec/` guidelines -3. [If guidelines ready] Start your first development task - -What would you like to do first?" diff --git a/.opencode/commands/trellis/parallel.md b/.opencode/commands/trellis/parallel.md deleted file mode 100644 index 172f689..0000000 --- a/.opencode/commands/trellis/parallel.md +++ /dev/null @@ -1,194 +0,0 @@ -# Multi-Agent Pipeline Orchestrator - -You are the Multi-Agent Pipeline Orchestrator Agent, running in the main repository, responsible for collaborating with users to manage parallel development tasks. - -## Role Definition - -- **You are in the main repository**, not in a worktree -- **You don't write code directly** - code work is done by agents in worktrees -- **You are responsible for planning and dispatching**: discuss requirements, create plans, configure context, start worktree agents -- **Delegate complex analysis to research agent**: finding specs, analyzing code structure - ---- - -## Operation Types - -Operations in this document are categorized as: - -| Marker | Meaning | Executor | -|--------|---------|----------| -| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | -| `[USER]` | Slash commands executed by user | User | - ---- - -## Startup Flow - -### Step 1: Understand Trellis Workflow `[AI]` - -First, read the workflow guide to understand the development process: - -```bash -cat .trellis/workflow.md # Development process, conventions, and quick start guide -``` - -### Step 2: Get Current Status `[AI]` - -```bash -python3 ./.trellis/scripts/get_context.py -``` - -### Step 3: Read Project Guidelines `[AI]` - -```bash -cat .trellis/spec/frontend/index.md # Frontend guidelines index -cat .trellis/spec/backend/index.md # Backend guidelines index -cat .trellis/spec/guides/index.md # Thinking guides -``` - -### Step 4: Ask User for Requirements - -Ask the user: - -1. What feature to develop? -2. Which modules are involved? -3. Development type? (backend / frontend / fullstack) - ---- - -## Planning: Choose Your Approach - -Based on requirement complexity, choose one of these approaches: - -### Option A: Plan Agent (Recommended for complex features) `[AI]` - -Use when: -- Requirements need analysis and validation -- Multiple modules or cross-layer changes -- Unclear scope that needs research - -```bash -python3 ./.trellis/scripts/multi_agent/plan.py \ - --name "<feature-name>" \ - --type "<backend|frontend|fullstack>" \ - --requirement "<user requirement description>" \ - --platform opencode -``` - -Plan Agent will: -1. Evaluate requirement validity (may reject if unclear/too large) -2. Call research agent to analyze codebase -3. Create and configure task directory -4. Write prd.md with acceptance criteria -5. Output ready-to-use task directory - -After plan.py completes, start the worktree agent: - -```bash -python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform opencode -``` - -### Option B: Manual Configuration (For simple/clear features) `[AI]` - -Use when: -- Requirements are already clear and specific -- You know exactly which files are involved -- Simple, well-scoped changes - -#### Step 1: Create Task Directory - -```bash -# title is task description, --slug for task directory name -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name>) -``` - -#### Step 2: Configure Task - -```bash -# Initialize jsonl context files -python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <dev_type> - -# Set branch and scope -python3 ./.trellis/scripts/task.py set-branch "$TASK_DIR" feature/<name> -python3 ./.trellis/scripts/task.py set-scope "$TASK_DIR" <scope> -``` - -#### Step 3: Add Context (optional: use research agent) - -```bash -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" -``` - -#### Step 4: Create prd.md - -```bash -cat > "$TASK_DIR/prd.md" << 'EOF' -# Feature: <name> - -## Requirements -- ... - -## Acceptance Criteria -- ... -EOF -``` - -#### Step 5: Validate and Start - -```bash -python3 ./.trellis/scripts/task.py validate "$TASK_DIR" -python3 ./.trellis/scripts/multi_agent/start.py "$TASK_DIR" --platform opencode -``` - ---- - -## After Starting: Report Status - -Tell the user the agent has started and provide monitoring commands. - ---- - -## User Available Commands `[USER]` - -The following slash commands are for users (not AI): - -| Command | Description | -|---------|-------------| -| `/trellis:parallel` | Start Multi-Agent Pipeline (this command) | -| `/trellis:start` | Start normal development mode (single process) | -| `/trellis:record-session` | Record session progress | -| `/trellis:finish-work` | Pre-completion checklist | - ---- - -## Monitoring Commands (for user reference) - -Tell the user they can use these commands to monitor: - -```bash -python3 ./.trellis/scripts/multi_agent/status.py # Overview -python3 ./.trellis/scripts/multi_agent/status.py --log <name> # View log -python3 ./.trellis/scripts/multi_agent/status.py --watch <name> # Real-time monitoring -python3 ./.trellis/scripts/multi_agent/cleanup.py <branch> # Cleanup worktree -``` - ---- - -## Pipeline Phases - -The dispatch agent in worktree will automatically execute: - -1. implement → Implement feature -2. check → Check code quality -3. finish → Final verification -4. create-pr → Create PR - ---- - -## Core Rules - -- **Don't write code directly** - delegate to agents in worktree -- **Don't execute git commit** - agent does it via create-pr action -- **Delegate complex analysis to research** - finding specs, analyzing code structure -- **Subagents use globally configured model** - inherits from user's OpenCode config diff --git a/.opencode/commands/trellis/record-session.md b/.opencode/commands/trellis/record-session.md deleted file mode 100644 index 4a7e6ff..0000000 --- a/.opencode/commands/trellis/record-session.md +++ /dev/null @@ -1,61 +0,0 @@ -[!] **Prerequisite**: This command should only be used AFTER the human has tested and committed the code. - -**Do NOT run `git commit` directly** — the scripts below handle their own commits for `.trellis/` metadata. You only need to read git history (`git log`, `git status`, `git diff`) and run the Python scripts. - ---- - -## Record Work Progress - -### Step 1: Get Context & Check Tasks - -```bash -python3 ./.trellis/scripts/get_context.py --mode record -``` - -[!] Archive tasks whose work is **actually done** — judge by work status, not the `status` field in task.json: -- Code committed? → Archive it (don't wait for PR) -- All acceptance criteria met? → Archive it -- Don't skip archiving just because `status` still says `planning` or `in_progress` - -```bash -python3 ./.trellis/scripts/task.py archive <task-name> -``` - -### Step 2: One-Click Add Session - -```bash -# Method 1: Simple parameters -python3 ./.trellis/scripts/add_session.py \ - --title "Session Title" \ - --commit "hash1,hash2" \ - --summary "Brief summary of what was done" - -# Method 2: Pass detailed content via stdin -cat << 'EOF' | python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash" -| Feature | Description | -|---------|-------------| -| New API | Added user authentication endpoint | -| Frontend | Updated login form | - -**Updated Files**: -- `packages/api/modules/auth/router.ts` -- `apps/web/modules/auth/components/login-form.tsx` -EOF -``` - -**Auto-completes**: -- [OK] Appends session to journal-N.md -- [OK] Auto-detects line count, creates new file if >2000 lines -- [OK] Updates index.md (Total Sessions +1, Last Active, line stats, history) -- [OK] Auto-commits .trellis/workspace and .trellis/tasks changes - ---- - -## Script Command Reference - -| Command | Purpose | -|---------|---------| -| `python3 ./.trellis/scripts/get_context.py --mode record` | Get context for record-session | -| `python3 ./.trellis/scripts/add_session.py --title "..." --commit "..."` | **One-click add session (recommended)** | -| `python3 ./.trellis/scripts/task.py archive <name>` | Archive completed task (auto-commits) | -| `python3 ./.trellis/scripts/task.py list` | List active tasks | diff --git a/.opencode/commands/trellis/start.md b/.opencode/commands/trellis/start.md deleted file mode 100644 index 4040de8..0000000 --- a/.opencode/commands/trellis/start.md +++ /dev/null @@ -1,346 +0,0 @@ -# Start Session - -Initialize your AI development session and begin working on tasks. - ---- - -## Operation Types - -| Marker | Meaning | Executor | -|--------|---------|----------| -| `[AI]` | Bash scripts or Task calls executed by AI | You (AI) | -| `[USER]` | Slash commands executed by user | User | - ---- - -## Initialization `[AI]` - -### Step 1: Understand Development Workflow - -First, read the workflow guide to understand the development process: - -```bash -cat .trellis/workflow.md -``` - -**Follow the instructions in workflow.md** - it contains: -- Core principles (Read Before Write, Follow Standards, etc.) -- File system structure -- Development process -- Best practices - -### Step 2: Get Current Context - -```bash -python3 ./.trellis/scripts/get_context.py -``` - -This shows: developer identity, git status, current task (if any), active tasks. - -### Step 3: Read Guidelines Index - -```bash -cat .trellis/spec/frontend/index.md # Frontend guidelines -cat .trellis/spec/backend/index.md # Backend guidelines -cat .trellis/spec/guides/index.md # Thinking guides -``` - -> **Important**: The index files are navigation — they list the actual guideline files (e.g., `error-handling.md`, `conventions.md`, `mock-strategies.md`). -> At this step, just read the indexes to understand what's available. -> When you start actual development, you MUST go back and read the specific guideline files relevant to your task, as listed in the index's Pre-Development Checklist. - -### Step 4: Report and Ask - -Report what you learned and ask: "What would you like to work on?" - ---- - -## Task Classification - -When user describes a task, classify it: - -| Type | Criteria | Workflow | -|------|----------|----------| -| **Question** | User asks about code, architecture, or how something works | Answer directly | -| **Trivial Fix** | Typo fix, comment update, single-line change, < 5 minutes | Direct Edit | -| **Simple Task** | Clear goal, 1-2 files, well-defined scope | Quick confirm → Task Workflow | -| **Complex Task** | Vague goal, multiple files, architectural decisions | **Brainstorm → Task Workflow** | - -### Decision Rule - -> **If in doubt, use Brainstorm + Task Workflow.** -> -> Task Workflow ensures code-spec context is injected to agents, resulting in higher quality code. -> The overhead is minimal, but the benefit is significant. - ---- - -## Question / Trivial Fix - -For questions or trivial fixes, work directly: - -1. Answer question or make the fix -2. If code was changed, remind user to run `/trellis:finish-work` - ---- - -## Complex Task - Brainstorm First - -For complex or vague tasks, **automatically start the brainstorm process** — do NOT skip directly to implementation. - -See `/trellis:brainstorm` for the full process. Summary: - -1. **Acknowledge and classify** - State your understanding -2. **Create task directory** - Track evolving requirements in `prd.md` -3. **Ask questions one at a time** - Update PRD after each answer -4. **Propose approaches** - For architectural decisions -5. **Confirm final requirements** - Get explicit approval -6. **Proceed to Task Workflow** - With clear requirements in PRD - -> **Subtask Decomposition**: If brainstorm reveals multiple independent work items, -> consider creating subtasks using `--parent` flag or `add-subtask` command. -> See `/trellis:brainstorm` Step 8 for details. - ---- - -## Task Workflow (Development Tasks) - -**Why this workflow?** -- Research Agent analyzes what code-spec files are needed -- Code-spec files are configured in jsonl files -- Implement Agent receives code-spec context via Hook injection -- Check Agent verifies against code-spec requirements -- Result: Code that follows project conventions automatically - -### Overview: Two Entry Points - -``` -From Brainstorm (Complex Task): - PRD confirmed → Research → Configure Context → Activate → Implement → Check → Complete - -From Simple Task: - Confirm → Create Task → Write PRD → Research → Configure Context → Activate → Implement → Check → Complete -``` - -**Key principle: Research happens AFTER requirements are clear (PRD exists).** - ---- - -### Phase 1: Establish Requirements - -#### Path A: From Brainstorm (skip to Phase 2) - -PRD and task directory already exist from brainstorm. Skip directly to Phase 2. - -#### Path B: From Simple Task - -**Step 1: Confirm Understanding** `[AI]` - -Quick confirm: -- What is the goal? -- What type of development? (frontend / backend / fullstack) -- Any specific requirements or constraints? - -**Step 2: Create Task Directory** `[AI]` - -```bash -TASK_DIR=$(python3 ./.trellis/scripts/task.py create "<title>" --slug <name>) -``` - -**Step 3: Write PRD** `[AI]` - -Create `prd.md` in the task directory with: - -```markdown -# <Task Title> - -## Goal -<What we're trying to achieve> - -## Requirements -- <Requirement 1> -- <Requirement 2> - -## Acceptance Criteria -- [ ] <Criterion 1> -- [ ] <Criterion 2> - -## Technical Notes -<Any technical decisions or constraints> -``` - ---- - -### Phase 2: Prepare for Implementation (shared) - -> Both paths converge here. PRD and task directory must exist before proceeding. - -**Step 4: Code-Spec Depth Check** `[AI]` - -If the task touches infra or cross-layer contracts, do not start implementation until code-spec depth is defined. - -Trigger this requirement when the change includes any of: -- New or changed command/API signatures -- Database schema or migration changes -- Infra integrations (storage, queue, cache, secrets, env contracts) -- Cross-layer payload transformations - -Must-have before proceeding: -- [ ] Target code-spec files to update are identified -- [ ] Concrete contract is defined (signature, fields, env keys) -- [ ] Validation and error matrix is defined -- [ ] At least one Good/Base/Bad case is defined - -**Step 5: Research the Codebase** `[AI]` - -Based on the confirmed PRD, call Research Agent to find relevant specs and patterns: - -``` -Task( - subagent_type: "research", - prompt: "Analyze the codebase for this task: - - Task: <goal from PRD> - Type: <frontend/backend/fullstack> - - Please find: - 1. Relevant code-spec files in .trellis/spec/ - 2. Existing code patterns to follow (find 2-3 examples) - 3. Files that will likely need modification - - Output: - ## Relevant Code-Specs - - <path>: <why it's relevant> - - ## Code Patterns Found - - <pattern>: <example file path> - - ## Files to Modify - - <path>: <what change>", - model: "opus" -) -``` - -**Step 6: Configure Context** `[AI]` - -Initialize default context: - -```bash -python3 ./.trellis/scripts/task.py init-context "$TASK_DIR" <type> -# type: backend | frontend | fullstack -``` - -Add code-spec files found by Research Agent: - -```bash -# For each relevant code-spec and code pattern: -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" implement "<path>" "<reason>" -python3 ./.trellis/scripts/task.py add-context "$TASK_DIR" check "<path>" "<reason>" -``` - -**Step 7: Activate Task** `[AI]` - -```bash -python3 ./.trellis/scripts/task.py start "$TASK_DIR" -``` - -This sets `.current-task` so hooks can inject context. - ---- - -### Phase 3: Execute (shared) - -**Step 8: Implement** `[AI]` - -Call Implement Agent (code-spec context is auto-injected by hook): - -``` -Task( - subagent_type: "implement", - prompt: "Implement the task described in prd.md. - - Follow all code-spec files that have been injected into your context. - Run lint and typecheck before finishing.", - model: "opus" -) -``` - -**Step 9: Check Quality** `[AI]` - -Call Check Agent (code-spec context is auto-injected by hook): - -``` -Task( - subagent_type: "check", - prompt: "Review all code changes against the code-spec requirements. - - Fix any issues you find directly. - Ensure lint and typecheck pass.", - model: "opus" -) -``` - -**Step 10: Complete** `[AI]` - -1. Verify lint and typecheck pass -2. Report what was implemented -3. Remind user to: - - Test the changes - - Commit when ready - - Run `/trellis:record-session` to record this session - ---- - -## Continuing Existing Task - -If `get_context.py` shows a current task: - -1. Read the task's `prd.md` to understand the goal -2. Check `task.json` for current status and phase -3. Ask user: "Continue working on <task-name>?" - -If yes, resume from the appropriate step (usually Step 7 or 8). - ---- - -## Commands Reference - -### User Commands `[USER]` - -| Command | When to Use | -|---------|-------------| -| `/trellis:start` | Begin a session (this command) | -| `/trellis:brainstorm` | Clarify vague requirements (called from start) | -| `/trellis:parallel` | Complex tasks needing isolated worktree | -| `/trellis:finish-work` | Before committing changes | -| `/trellis:record-session` | After completing a task | - -### AI Scripts `[AI]` - -| Script | Purpose | -|--------|---------| -| `python3 ./.trellis/scripts/get_context.py` | Get session context | -| `python3 ./.trellis/scripts/task.py create` | Create task directory | -| `python3 ./.trellis/scripts/task.py init-context` | Initialize jsonl files | -| `python3 ./.trellis/scripts/task.py add-context` | Add code-spec/context file to jsonl | -| `python3 ./.trellis/scripts/task.py start` | Set current task | -| `python3 ./.trellis/scripts/task.py finish` | Clear current task | -| `python3 ./.trellis/scripts/task.py archive` | Archive completed task | - -### Sub Agents `[AI]` - -| Agent | Purpose | Hook Injection | -|-------|---------|----------------| -| research | Analyze codebase | No (reads directly) | -| implement | Write code | Yes (implement.jsonl) | -| check | Review & fix | Yes (check.jsonl) | -| debug | Fix specific issues | Yes (debug.jsonl) | - ---- - -## Key Principle - -> **Code-spec context is injected, not remembered.** -> -> The Task Workflow ensures agents receive relevant code-spec context automatically. -> This is more reliable than hoping the AI "remembers" conventions. diff --git a/.opencode/lib/trellis-context.js b/.opencode/lib/trellis-context.js deleted file mode 100644 index 972802d..0000000 --- a/.opencode/lib/trellis-context.js +++ /dev/null @@ -1,436 +0,0 @@ -/** - * Trellis Context Manager - * - * Unified context management for OpenCode plugins. - * Handles detection of oh-my-opencode, .claude/hooks/, and other edge cases. - * - * Usage: - * import { TrellisContext } from "./trellis-context.js" - * const ctx = new TrellisContext(directory) - * if (ctx.shouldSkipHook("session-start")) return - */ - -import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs" -import { join } from "path" -import { homedir, platform } from "os" -import { execSync } from "child_process" - -// Python command: Windows uses 'python', macOS/Linux use 'python3' -const PYTHON_CMD = platform() === "win32" ? "python" : "python3" - -// Debug logging -const DEBUG_LOG = "/tmp/trellis-plugin-debug.log" - -function debugLog(prefix, ...args) { - const timestamp = new Date().toISOString() - const msg = `[${timestamp}] [${prefix}] ${args.map(a => typeof a === "object" ? JSON.stringify(a) : a).join(" ")}\n` - try { - appendFileSync(DEBUG_LOG, msg) - } catch { - // ignore - } -} - -/** - * Trellis Context Manager - * - * Centralized logic for: - * - Detecting oh-my-opencode installation - * - Checking .claude/hooks/ presence - * - Determining which plugin should handle each hook - */ -export class TrellisContext { - constructor(directory) { - this.directory = directory - this._omoInstalled = null - this._omoHooksEnabled = null - this._claudeHooksPresent = {} - - debugLog("context", "TrellisContext initialized", { directory }) - } - - // ============================================================ - // oh-my-opencode Detection - // ============================================================ - - /** - * Check if oh-my-opencode is installed - * - * Detection order: - * 1. Check if oh-my-opencode.json exists (most reliable) - * 2. Fallback: check opencode.json plugin list - */ - isOmoInstalled() { - if (this._omoInstalled !== null) { - return this._omoInstalled - } - - try { - const configDir = join(homedir(), ".config", "opencode") - - // Method 1: Check oh-my-opencode.json existence (omo-specific config) - const omoConfigPath = join(configDir, "oh-my-opencode.json") - if (existsSync(omoConfigPath)) { - this._omoInstalled = true - debugLog("context", "omo installed: oh-my-opencode.json exists") - return true - } - - // Method 2: Fallback to plugin list check - const configPath = join(configDir, "opencode.json") - if (!existsSync(configPath)) { - this._omoInstalled = false - debugLog("context", "omo not installed: no config files") - return false - } - - const content = readFileSync(configPath, "utf-8") - const config = JSON.parse(content) - const plugins = config.plugin || [] - - this._omoInstalled = plugins.some(p => - typeof p === "string" && p.toLowerCase().includes("oh-my-opencode") - ) - - debugLog("context", "omo installed (plugin list):", this._omoInstalled) - return this._omoInstalled - } catch (e) { - debugLog("context", "omo detection error:", e.message) - this._omoInstalled = false - return false - } - } - - /** - * Check if omo's claude_code.hooks is enabled - * Reads oh-my-opencode.json or defaults to true - */ - isOmoHooksEnabled() { - if (this._omoHooksEnabled !== null) { - return this._omoHooksEnabled - } - - if (!this.isOmoInstalled()) { - this._omoHooksEnabled = false - return false - } - - try { - // Check global config - const globalConfig = join(homedir(), ".config", "opencode", "oh-my-opencode.json") - if (existsSync(globalConfig)) { - const content = readFileSync(globalConfig, "utf-8") - const config = JSON.parse(content) - if (config.claude_code?.hooks === false) { - this._omoHooksEnabled = false - debugLog("context", "omo hooks disabled in global config") - return false - } - } - - // Check project config - const projectConfig = join(this.directory, "oh-my-opencode.json") - if (existsSync(projectConfig)) { - const content = readFileSync(projectConfig, "utf-8") - const config = JSON.parse(content) - if (config.claude_code?.hooks === false) { - this._omoHooksEnabled = false - debugLog("context", "omo hooks disabled in project config") - return false - } - } - - // Default: enabled - this._omoHooksEnabled = true - debugLog("context", "omo hooks enabled (default)") - return true - } catch (e) { - debugLog("context", "omo hooks detection error:", e.message) - this._omoHooksEnabled = true // Default to enabled - return true - } - } - - // ============================================================ - // .claude/hooks/ Detection - // ============================================================ - - /** - * Check if a specific .claude/hooks/ file exists - */ - hasClaudeHook(hookName) { - if (hookName in this._claudeHooksPresent) { - return this._claudeHooksPresent[hookName] - } - - const hookPath = join(this.directory, ".claude", "hooks", `${hookName}.py`) - const exists = existsSync(hookPath) - - this._claudeHooksPresent[hookName] = exists - debugLog("context", `claude hook ${hookName}:`, exists) - return exists - } - - // ============================================================ - // Trellis Project Detection - // ============================================================ - - /** - * Check if this is a Trellis-managed project - */ - isTrellisProject() { - return existsSync(join(this.directory, ".trellis")) - } - - /** - * Get current task directory from .trellis/.current-task - */ - getCurrentTask() { - try { - const currentTaskPath = join(this.directory, ".trellis", ".current-task") - if (!existsSync(currentTaskPath)) { - return null - } - return readFileSync(currentTaskPath, "utf-8").trim() - } catch { - return null - } - } - - // ============================================================ - // Hook Decision Logic - // ============================================================ - - /** - * Determine if our plugin should skip this hook - * (because omo will handle it via .claude/hooks/) - * - * @param {string} hookName - Hook name without extension (e.g., "session-start") - * @returns {boolean} - true if we should skip, false if we should handle - */ - shouldSkipHook(hookName) { - // Not a Trellis project? Skip. - if (!this.isTrellisProject()) { - debugLog("context", `shouldSkipHook(${hookName}): skip - not Trellis project`) - return true - } - - // omo not installed? We handle it. - if (!this.isOmoInstalled()) { - debugLog("context", `shouldSkipHook(${hookName}): handle - omo not installed`) - return false - } - - // omo installed but hooks disabled? We handle it. - if (!this.isOmoHooksEnabled()) { - debugLog("context", `shouldSkipHook(${hookName}): handle - omo hooks disabled`) - return false - } - - // omo installed + hooks enabled + .claude/hooks/ exists? Skip (omo handles). - if (this.hasClaudeHook(hookName)) { - debugLog("context", `shouldSkipHook(${hookName}): skip - omo will handle via .claude/hooks/`) - return true - } - - // omo installed but no .claude/hooks/ file? We handle it. - debugLog("context", `shouldSkipHook(${hookName}): handle - no .claude/hooks/ file`) - return false - } - - // ============================================================ - // File Reading Utilities - // ============================================================ - - /** - * Read a file, return null on error - */ - readFile(filePath) { - try { - if (existsSync(filePath)) { - return readFileSync(filePath, "utf-8") - } - } catch { - // Ignore read errors - } - return null - } - - /** - * Read a file relative to project directory - */ - readProjectFile(relativePath) { - return this.readFile(join(this.directory, relativePath)) - } - - /** - * Run a Python script and return output - */ - runScript(scriptPath, cwd = null) { - try { - const result = execSync(`${PYTHON_CMD} "${scriptPath}"`, { - cwd: cwd || this.directory, - timeout: 10000, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"] - }) - return result || "" - } catch { - return "" - } - } - - // ============================================================ - // JSONL Reading - // ============================================================ - - /** - * Read all .md files in a directory - * @param {string} dirPath - Directory path relative to project root - * @param {number} maxFiles - Max files to read (prevent huge directories) - * @returns {Array<{path: string, content: string}>} - */ - readDirectoryMdFiles(dirPath, maxFiles = 20) { - const results = [] - const fullPath = join(this.directory, dirPath) - - if (!existsSync(fullPath)) { - return results - } - - try { - const files = readdirSync(fullPath) - .filter(f => f.endsWith(".md")) - .sort() - .slice(0, maxFiles) - - for (const filename of files) { - const filePath = join(dirPath, filename) - const content = this.readProjectFile(filePath) - if (content) { - results.push({ path: filePath, content }) - } - } - } catch { - // Ignore directory read errors - } - - return results - } - - /** - * Read a JSONL file and load referenced files/directories - * Supports: - * {"file": "path/to/file.md", "reason": "..."} - * {"file": "path/to/dir/", "type": "directory", "reason": "..."} - */ - readJsonlWithFiles(jsonlPath) { - const results = [] - const content = this.readFile(jsonlPath) - if (!content) return results - - for (const line of content.split("\n")) { - if (!line.trim()) continue - try { - const item = JSON.parse(line) - const file = item.file || item.path - const entryType = item.type || "file" - - if (!file) continue - - if (entryType === "directory") { - // Read all .md files in directory - const dirEntries = this.readDirectoryMdFiles(file) - results.push(...dirEntries) - } else { - // Read single file - const fullPath = join(this.directory, file) - const fileContent = this.readFile(fullPath) - if (fileContent) { - results.push({ path: file, content: fileContent }) - } - } - } catch { - // Ignore parse errors for individual lines - } - } - return results - } - - /** - * Build context string from file entries - */ - buildContextFromEntries(entries) { - return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n") - } -} - -// ============================================================ -// Context Collector (for synthetic message injection) -// ============================================================ - -/** - * Simple context collector for cross-hook communication - * Similar to oh-my-opencode's contextCollector - */ -class ContextCollector { - constructor() { - this.pending = new Map() - this.processed = new Set() - } - - /** - * Store context for a session - */ - store(sessionID, content) { - this.pending.set(sessionID, { - content, - timestamp: Date.now() - }) - debugLog("collector", "stored context for session:", sessionID, "length:", content.length) - } - - /** - * Check if session has pending context - */ - hasPending(sessionID) { - return this.pending.has(sessionID) - } - - /** - * Get and consume pending context - */ - consume(sessionID) { - const pending = this.pending.get(sessionID) - this.pending.delete(sessionID) - return pending - } - - /** - * Mark session as processed (for first-message-only injection) - */ - markProcessed(sessionID) { - this.processed.add(sessionID) - } - - /** - * Check if session was already processed - */ - isProcessed(sessionID) { - return this.processed.has(sessionID) - } - - /** - * Clear session state - */ - clear(sessionID) { - this.pending.delete(sessionID) - this.processed.delete(sessionID) - } -} - -// Singleton instance -export const contextCollector = new ContextCollector() - -// Export debug log for plugins -export { debugLog } diff --git a/.opencode/opencode.json.example b/.opencode/opencode.json.example deleted file mode 100644 index 63f11c1..0000000 --- a/.opencode/opencode.json.example +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "bash": { - "*": "allow", - "git checkout --*": "ask", - "git checkout -- *": "ask", - "git restore *": "ask", - "git reset --hard*": "ask", - "git reset HEAD*": "ask", - "git revert*": "ask", - "git clean*": "ask", - "git stash drop*": "ask" - } - }, - "mcp": { - "supabase": { - "type": "remote", - "enabled": true, - "url": "" - } - } -} diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json deleted file mode 100644 index 28ed456..0000000 --- a/.opencode/package-lock.json +++ /dev/null @@ -1,376 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "1.14.22" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.14.22", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.22.tgz", - "integrity": "sha512-lJlukegf5ECEHm9Y0NxCjXNfUArpPSUHP6hc+M4VCJ3NFk8uzzVsIXAzPS9Hvf2ltzjEYD/ulCOTi6pleeZ6yw==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.14.22", - "effect": "4.0.0-beta.48", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.99", - "@opentui/solid": ">=0.1.99" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.14.22", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.22.tgz", - "integrity": "sha512-1PjkrZRAwm9ocfTwOleP/e31HYtLVODb2E1hYTRHMmvF2rmAdCm7lztguYVkAPn/B6koGpFvhslTQH7j+38Fjw==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/effect": { - "version": "4.0.0-beta.48", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", - "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", - "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" - } - }, - "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/msgpackr": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", - "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/.opencode/plugin/inject-subagent-context.js b/.opencode/plugin/inject-subagent-context.js deleted file mode 100644 index 3ccbed7..0000000 --- a/.opencode/plugin/inject-subagent-context.js +++ /dev/null @@ -1,538 +0,0 @@ -/** - * Trellis Context Injection Plugin - * - * Injects context when Task tool is called with supported subagent types. - * Uses OpenCode's tool.execute.before hook. - * - * Compatibility: - * - If oh-my-opencode handles via .claude/hooks/, this plugin skips - * - Otherwise, this plugin handles injection - */ - -import { existsSync, writeFileSync } from "fs" -import { join } from "path" -import { TrellisContext, debugLog } from "../lib/trellis-context.js" - -// Supported subagent types -const AGENTS_ALL = ["implement", "check", "debug", "research"] -const AGENTS_REQUIRE_TASK = ["implement", "check", "debug"] -// Agents that don't update phase (can be called at any time) -const AGENTS_NO_PHASE_UPDATE = ["debug", "research"] - -/** - * Update current_phase in task.json based on subagent_type - */ -function updateCurrentPhase(ctx, taskDir, subagentType) { - if (AGENTS_NO_PHASE_UPDATE.includes(subagentType)) { - return - } - - const taskJsonPath = join(ctx.directory, taskDir, "task.json") - const content = ctx.readFile(taskJsonPath) - if (!content) return - - try { - const taskData = JSON.parse(content) - const currentPhase = taskData.current_phase || 0 - const nextActions = taskData.next_action || [] - - // Map action names to subagent types - const actionToAgent = { - "implement": "implement", - "check": "check", - "finish": "check" // finish uses check agent - } - - // Find the next phase that matches this subagent_type - let newPhase = null - for (const action of nextActions) { - const phaseNum = action.phase || 0 - const actionName = action.action || "" - const expectedAgent = actionToAgent[actionName] - - // Only consider phases after current_phase - if (phaseNum > currentPhase && expectedAgent === subagentType) { - newPhase = phaseNum - break - } - } - - if (newPhase !== null) { - taskData.current_phase = newPhase - writeFileSync(taskJsonPath, JSON.stringify(taskData, null, 2)) - debugLog("inject", "Updated current_phase to:", newPhase) - } - } catch (e) { - debugLog("inject", "Error updating phase:", e.message) - } -} - -/** - * Get context for implement agent - */ -function getImplementContext(ctx, taskDir) { - const parts = [] - - // 1. Read implement.jsonl (or fallback to spec.jsonl) - let jsonlPath = join(ctx.directory, taskDir, "implement.jsonl") - let entries = ctx.readJsonlWithFiles(jsonlPath) - - if (entries.length === 0) { - // Fallback to spec.jsonl - jsonlPath = join(ctx.directory, taskDir, "spec.jsonl") - entries = ctx.readJsonlWithFiles(jsonlPath) - } - - if (entries.length > 0) { - parts.push(ctx.buildContextFromEntries(entries)) - } - - // 2. Requirements document - const prd = ctx.readProjectFile(join(taskDir, "prd.md")) - if (prd) { - parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`) - } - - // 3. Technical design - const info = ctx.readProjectFile(join(taskDir, "info.md")) - if (info) { - parts.push(`=== ${taskDir}/info.md (Technical Design) ===\n${info}`) - } - - return parts.join("\n\n") -} - -/** - * Get context for check agent - */ -function getCheckContext(ctx, taskDir) { - const parts = [] - - // 1. Read check.jsonl - const jsonlPath = join(ctx.directory, taskDir, "check.jsonl") - const entries = ctx.readJsonlWithFiles(jsonlPath) - - if (entries.length > 0) { - parts.push(ctx.buildContextFromEntries(entries)) - } else { - // Fallback: hardcoded check files + spec.jsonl - const checkFiles = [ - [".opencode/commands/trellis/finish-work.md", "Finish work checklist"], - [".opencode/commands/trellis/check-cross-layer.md", "Cross-layer check spec"], - [".opencode/commands/trellis/check-backend.md", "Backend check spec"], - [".opencode/commands/trellis/check-frontend.md", "Frontend check spec"], - ] - for (const [f, description] of checkFiles) { - const content = ctx.readProjectFile(f) - if (content) { - parts.push(`=== ${f} (${description}) ===\n${content}`) - } - } - - // Add spec.jsonl - const specJsonlPath = join(ctx.directory, taskDir, "spec.jsonl") - const specEntries = ctx.readJsonlWithFiles(specJsonlPath) - for (const entry of specEntries) { - parts.push(`=== ${entry.path} (Dev spec) ===\n${entry.content}`) - } - } - - // 2. Requirements document - const prd = ctx.readProjectFile(join(taskDir, "prd.md")) - if (prd) { - parts.push(`=== ${taskDir}/prd.md (Requirements - for understanding intent) ===\n${prd}`) - } - - return parts.join("\n\n") -} - -/** - * Get context for finish phase (final check before PR) - */ -function getFinishContext(ctx, taskDir) { - const parts = [] - - // 1. Try finish.jsonl first - const jsonlPath = join(ctx.directory, taskDir, "finish.jsonl") - const entries = ctx.readJsonlWithFiles(jsonlPath) - - if (entries.length > 0) { - parts.push(ctx.buildContextFromEntries(entries)) - } else { - // Fallback: only finish-work.md (lightweight) - const finishWork = ctx.readProjectFile(".opencode/commands/trellis/finish-work.md") - if (finishWork) { - parts.push(`=== .opencode/commands/trellis/finish-work.md (Finish checklist) ===\n${finishWork}`) - } - } - - // 2. Spec update process (for active spec sync) - const updateSpec = ctx.readProjectFile(".opencode/commands/trellis/update-spec.md") - if (updateSpec) { - parts.push(`=== .opencode/commands/trellis/update-spec.md (Spec update process) ===\n${updateSpec}`) - } - - // 3. Requirements document (for verifying requirements are met) - const prd = ctx.readProjectFile(join(taskDir, "prd.md")) - if (prd) { - parts.push(`=== ${taskDir}/prd.md (Requirements - verify all met) ===\n${prd}`) - } - - return parts.join("\n\n") -} - -/** - * Get context for debug agent - */ -function getDebugContext(ctx, taskDir) { - const parts = [] - - // 1. Read debug.jsonl (or fallback to spec.jsonl + check files) - const jsonlPath = join(ctx.directory, taskDir, "debug.jsonl") - const entries = ctx.readJsonlWithFiles(jsonlPath) - - if (entries.length > 0) { - parts.push(ctx.buildContextFromEntries(entries)) - } else { - // Fallback: use spec.jsonl + hardcoded check files - const specJsonlPath = join(ctx.directory, taskDir, "spec.jsonl") - const specEntries = ctx.readJsonlWithFiles(specJsonlPath) - for (const entry of specEntries) { - parts.push(`=== ${entry.path} (Dev spec) ===\n${entry.content}`) - } - - const checkFiles = [ - [".opencode/commands/trellis/check-backend.md", "Backend check spec"], - [".opencode/commands/trellis/check-frontend.md", "Frontend check spec"], - [".opencode/commands/trellis/check-cross-layer.md", "Cross-layer check spec"], - ] - for (const [f, description] of checkFiles) { - const content = ctx.readProjectFile(f) - if (content) { - parts.push(`=== ${f} (${description}) ===\n${content}`) - } - } - } - - // 2. Codex review output (if exists) - const codex = ctx.readProjectFile(join(taskDir, "codex-review-output.txt")) - if (codex) { - parts.push(`=== ${taskDir}/codex-review-output.txt (Codex Review Results) ===\n${codex}`) - } - - return parts.join("\n\n") -} - -/** - * Get context for research agent - */ -function getResearchContext(ctx, taskDir) { - const parts = [] - - parts.push(`## Project Spec Directory Structure - -\`\`\` -.trellis/spec/ -├── shared/ # Cross-project common specs -├── frontend/ # Frontend standards -├── backend/ # Backend standards -└── guides/ # Thinking guides - -.trellis/big-question/ # Known issues and pitfalls -\`\`\` - -## Search Tips - -- Spec files: \`.trellis/spec/**/*.md\` -- Known issues: \`.trellis/big-question/\` -- Code search: Use Glob and Grep tools -- Tech solutions: Use mcp__exa__web_search_exa or mcp__exa__get_code_context_exa`) - - if (taskDir) { - const jsonlPath = join(ctx.directory, taskDir, "research.jsonl") - const entries = ctx.readJsonlWithFiles(jsonlPath) - if (entries.length > 0) { - parts.push("\n## Additional Search Context\n") - parts.push(ctx.buildContextFromEntries(entries)) - } - } - - return parts.join("\n\n") -} - -/** - * Build enhanced prompt with context - */ -function buildPrompt(agentType, originalPrompt, context, isFinish = false) { - const templates = { - implement: `# Implement Agent Task - -You are the Implement Agent in the Multi-Agent Pipeline. - -## Your Context - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Understand specs** - All dev specs are injected above -2. **Understand requirements** - Read requirements and technical design -3. **Implement feature** - Follow specs and design -4. **Self-check** - Ensure code quality - -## Important Constraints - -- Do NOT execute git commit -- Follow all dev specs injected above -- Report list of modified/created files when done`, - - check: isFinish ? `# Finish Agent Task - -You are performing the final check before creating a PR. - -## Your Context - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Review changes** - Run \`git diff --name-only\` to see all changed files -2. **Verify requirements** - Check each requirement in prd.md is implemented -3. **Spec sync** - Analyze whether changes introduce new patterns, contracts, or conventions - - If new pattern/convention found: read target spec file → update it → update index.md if needed - - If infra/cross-layer change: follow the 7-section mandatory template from update-spec.md - - If pure code fix with no new patterns: skip this step -4. **Run final checks** - Execute lint and typecheck -5. **Confirm ready** - Ensure code is ready for PR - -## Important Constraints - -- You MAY update spec files when gaps are detected (use update-spec.md as guide) -- MUST read the target spec file BEFORE editing (avoid duplicating existing content) -- Do NOT update specs for trivial changes (typos, formatting, obvious fixes) -- If critical CODE issues found, report them clearly (fix specs, not code) -- Verify all acceptance criteria in prd.md are met` : - `# Check Agent Task - -You are the Check Agent in the Multi-Agent Pipeline. - -## Your Context - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Get changes** - Run \`git diff --name-only\` and \`git diff\` -2. **Check against specs** - Check item by item -3. **Self-fix** - Fix issues directly, don't just report -4. **Run verification** - Run lint and typecheck - -## Important Constraints - -- Fix issues yourself, don't just report -- Must execute complete checklist`, - - debug: `# Debug Agent Task - -You are the Debug Agent in the Multi-Agent Pipeline. - -## Your Context - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Understand issues** - Analyze issues pointed out -2. **Locate code** - Find positions that need fixing -3. **Fix against specs** - Fix following dev specs -4. **Verify fixes** - Run typecheck - -## Important Constraints - -- Do NOT execute git commit -- Run typecheck after each fix`, - - research: `# Research Agent Task - -You are the Research Agent in the Multi-Agent Pipeline. - -## Core Principle - -**You do one thing: find and explain information.** - -## Project Info - -${context} - ---- - -## Your Task - -${originalPrompt} - ---- - -## Workflow - -1. **Understand query** - Determine search type and scope -2. **Plan search** - List search steps -3. **Execute search** - Run multiple searches in parallel -4. **Organize results** - Output structured report - -## Strict Boundaries - -**Only allowed**: Describe what exists, where it is, how it works - -**Forbidden**: Suggest improvements, criticize implementation, modify files` - } - - return templates[agentType] || originalPrompt -} - -export default async ({ directory }) => { - const ctx = new TrellisContext(directory) - debugLog("inject", "Plugin loaded, directory:", directory) - - return { - // ========================================================================== - // ⚠️ KNOWN LIMITATION: OpenCode project-level plugins cannot intercept subagents - // - // This hook will NOT be triggered because: - // 1. Project-level plugins (.opencode/plugin/) don't support tool.execute.before - // 2. Only global plugins (npm packages) have full hook permissions - // 3. This is a known OpenCode architecture limitation (see Issue #5894) - // - // SOLUTION: Trellis + OpenCode users must install oh-my-opencode (omo) - // - omo is a global plugin with full hook permissions - // - omo reads .claude/settings.json and executes Python hooks - // - .claude/hooks/inject-subagent-context.py handles the actual injection - // - // References: - // - https://github.com/sst/opencode/issues/5894 (plugin hooks don't intercept subagent) - // - https://github.com/sst/opencode/issues/2588 (subagent inherit context) - // ========================================================================== - "tool.execute.before": async (input, output) => { - try { - debugLog("inject", "tool.execute.before called, tool:", input?.tool) - - // Only handle Task tool - const toolName = input?.tool?.toLowerCase() - if (toolName !== "task") { - return - } - - const args = output?.args || {} - const subagentType = args.subagent_type - const originalPrompt = args.prompt || "" - - debugLog("inject", "Task tool called, subagent_type:", subagentType) - - // Only handle supported agent types - if (!AGENTS_ALL.includes(subagentType)) { - debugLog("inject", "Skipping - unsupported subagent_type") - return - } - - // Check if we should skip (omo will handle) - if (ctx.shouldSkipHook("inject-subagent-context")) { - debugLog("inject", "Skipping - omo will handle via .claude/hooks/") - return - } - - // Read current task - const taskDir = ctx.getCurrentTask() - - // Agents requiring task directory - if (AGENTS_REQUIRE_TASK.includes(subagentType)) { - if (!taskDir) { - debugLog("inject", "Skipping - no current task") - return - } - const taskDirFull = join(directory, taskDir) - if (!existsSync(taskDirFull)) { - debugLog("inject", "Skipping - task directory not found") - return - } - - // Update current_phase in task.json - updateCurrentPhase(ctx, taskDir, subagentType) - } - - // Check for [finish] marker - const isFinish = originalPrompt.toLowerCase().includes("[finish]") - - // Get context based on agent type - let context = "" - switch (subagentType) { - case "implement": - context = getImplementContext(ctx, taskDir) - break - case "check": - // Use finish context for [finish] phase (lighter, focused on final verification) - // Use check context for regular check (full specs for self-fix loop) - context = isFinish - ? getFinishContext(ctx, taskDir) - : getCheckContext(ctx, taskDir) - break - case "debug": - context = getDebugContext(ctx, taskDir) - break - case "research": - context = getResearchContext(ctx, taskDir) - break - } - - if (!context) { - debugLog("inject", "No context to inject") - return - } - - // Build enhanced prompt - const newPrompt = buildPrompt(subagentType, originalPrompt, context, isFinish) - - // Update the tool input - output.args = { - ...args, - prompt: newPrompt - } - - debugLog("inject", "Injected context for", subagentType, "prompt length:", newPrompt.length) - - } catch (error) { - debugLog("inject", "Error in tool.execute.before:", error.message, error.stack) - } - } - } -} diff --git a/.opencode/plugin/session-start.js b/.opencode/plugin/session-start.js deleted file mode 100644 index d455d66..0000000 --- a/.opencode/plugin/session-start.js +++ /dev/null @@ -1,325 +0,0 @@ -/* global process */ -/** - * Trellis Session Start Plugin - * - * Injects context when user sends the first message in a session. - * Uses OpenCode's chat.message + experimental.chat.messages.transform hooks. - * - * Compatibility: - * - If oh-my-opencode handles via .claude/hooks/, this plugin skips - * - Otherwise, this plugin handles injection - */ - -import { existsSync, readFileSync, readdirSync, statSync } from "fs" -import { join } from "path" -import { TrellisContext, contextCollector, debugLog } from "../lib/trellis-context.js" - - -/** - * Check current task status and return structured status string. - * JavaScript equivalent of _get_task_status in Claude's session-start.py. - */ -function getTaskStatus(directory) { - const trellisDir = join(directory, ".trellis") - const currentTaskFile = join(trellisDir, ".current-task") - - if (!existsSync(currentTaskFile)) { - return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" - } - - let taskRef - try { - taskRef = readFileSync(currentTaskFile, "utf-8").trim() - } catch { - return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" - } - - if (!taskRef) { - return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on" - } - - // Resolve task directory - let taskDir - if (taskRef.startsWith("/")) { - taskDir = taskRef - } else if (taskRef.startsWith(".trellis/")) { - taskDir = join(directory, taskRef) - } else { - taskDir = join(trellisDir, "tasks", taskRef) - } - - if (!existsSync(taskDir)) { - return `Status: STALE POINTER\nTask: ${taskRef}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish` - } - - // Read task.json - let taskData = {} - const taskJsonPath = join(taskDir, "task.json") - if (existsSync(taskJsonPath)) { - try { - taskData = JSON.parse(readFileSync(taskJsonPath, "utf-8")) - } catch { - // Ignore parse errors - } - } - - const taskTitle = taskData.title || taskRef - const taskStatus = taskData.status || "unknown" - - if (taskStatus === "completed") { - const dirName = taskDir.split("/").pop() - return `Status: COMPLETED\nTask: ${taskTitle}\nNext: Archive with \`python3 ./.trellis/scripts/task.py archive ${dirName}\` or start a new task` - } - - // Check if context is configured (jsonl files exist and non-empty) - let hasContext = false - for (const jsonlName of ["implement.jsonl", "check.jsonl", "spec.jsonl"]) { - const jsonlPath = join(taskDir, jsonlName) - if (existsSync(jsonlPath)) { - try { - const st = statSync(jsonlPath) - if (st.size > 0) { - hasContext = true - break - } - } catch { - // Ignore stat errors - } - } - } - - const hasPrd = existsSync(join(taskDir, "prd.md")) - - if (!hasPrd) { - return `Status: NOT READY\nTask: ${taskTitle}\nMissing: prd.md not created\nNext: Write PRD, then research → init-context → start` - } - - if (!hasContext) { - return `Status: NOT READY\nTask: ${taskTitle}\nMissing: Context not configured (no jsonl files)\nNext: Complete Phase 2 (research → init-context → start) before implementing` - } - - return `Status: READY\nTask: ${taskTitle}\nNext: Continue with implement or check` -} - -/** - * Build session context for injection - */ -function buildSessionContext(ctx) { - const directory = ctx.directory - const trellisDir = join(directory, ".trellis") - const claudeDir = join(directory, ".claude") - const opencodeDir = join(directory, ".opencode") - - const parts = [] - - // 1. Header - parts.push(`<trellis-context> -You are starting a new session in a Trellis-managed project. -Read and follow all instructions below carefully. -</trellis-context>`) - - // 2. Current Context (dynamic) - const contextScript = join(trellisDir, "scripts", "get_context.py") - if (existsSync(contextScript)) { - const output = ctx.runScript(contextScript) - if (output) { - parts.push("<current-state>") - parts.push(output) - parts.push("</current-state>") - } - } - - // 3. Workflow Guide - const workflow = ctx.readProjectFile(".trellis/workflow.md") - if (workflow) { - parts.push("<workflow>") - parts.push(workflow) - parts.push("</workflow>") - } - - // 4. Guidelines Index (dynamic discovery, matching Claude's session-start.py) - parts.push("<guidelines>") - parts.push("**Note**: The guidelines below are index files — they list available guideline documents and their locations.") - parts.push("During actual development, you MUST read the specific guideline files listed in each index's Pre-Development Checklist.\n") - - const specDir = join(directory, ".trellis", "spec") - if (existsSync(specDir)) { - try { - const subs = readdirSync(specDir).filter(name => { - if (name.startsWith(".")) return false - try { - return statSync(join(specDir, name)).isDirectory() - } catch { - return false - } - }).sort() - - for (const sub of subs) { - const indexFile = join(specDir, sub, "index.md") - if (existsSync(indexFile)) { - // Flat spec dir: spec/<layer>/index.md - const content = ctx.readFile(indexFile) - if (content) { - parts.push(`## ${sub}\n${content}\n`) - } - } else { - // Nested package dirs (monorepo): spec/<pkg>/<layer>/index.md - try { - const nested = readdirSync(join(specDir, sub)).filter(name => { - try { - return statSync(join(specDir, sub, name)).isDirectory() - } catch { - return false - } - }).sort() - - for (const layer of nested) { - const nestedIndex = join(specDir, sub, layer, "index.md") - if (existsSync(nestedIndex)) { - const content = ctx.readFile(nestedIndex) - if (content) { - parts.push(`## ${sub}/${layer}\n${content}\n`) - } - } - } - } catch { - // Ignore directory read errors - } - } - } - } catch { - // Ignore spec directory read errors - } - } - - parts.push("</guidelines>") - - // 5. Session Instructions - try both .claude and .opencode - let startMd = ctx.readFile(join(claudeDir, "commands", "trellis", "start.md")) - if (!startMd) { - startMd = ctx.readFile(join(opencodeDir, "commands", "trellis", "start.md")) - } - if (startMd) { - parts.push("<instructions>") - parts.push(startMd) - parts.push("</instructions>") - } - - // 6. Task status (R2: check task state for session resume) - const taskStatus = getTaskStatus(directory) - parts.push(`<task-status>\n${taskStatus}\n</task-status>`) - - // 7. Final directive (R3: active, not passive) - parts.push(`<ready> -Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them. -Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request. -If there is an active task, ask whether to continue it. -</ready>`) - - return parts.join("\n\n") -} - -export default async ({ directory }) => { - const ctx = new TrellisContext(directory) - debugLog("session", "Plugin loaded, directory:", directory) - - return { - // chat.message - triggered when user sends a message - "chat.message": async (input) => { - try { - const sessionID = input.sessionID - const agent = input.agent || "unknown" - debugLog("session", "chat.message called, sessionID:", sessionID, "agent:", agent) - - // Skip in non-interactive mode - if (process.env.OPENCODE_NON_INTERACTIVE === "1") { - debugLog("session", "Skipping - non-interactive mode") - return - } - - // Check if we should skip (omo will handle) - if (ctx.shouldSkipHook("session-start")) { - debugLog("session", "Skipping - omo will handle via .claude/hooks/") - return - } - - // Only inject on first message - if (contextCollector.isProcessed(sessionID)) { - debugLog("session", "Skipping - session already processed") - return - } - - // Mark session as processed - contextCollector.markProcessed(sessionID) - - // Build and store context - const context = buildSessionContext(ctx) - debugLog("session", "Built context, length:", context.length) - - contextCollector.store(sessionID, context) - debugLog("session", "Context stored for session:", sessionID) - - } catch (error) { - debugLog("session", "Error in chat.message:", error.message, error.stack) - } - }, - - // experimental.chat.messages.transform - modify messages before sending to AI - "experimental.chat.messages.transform": async (input, output) => { - try { - const { messages } = output - debugLog("session", "messages.transform called, messageCount:", messages?.length) - - if (!messages || messages.length === 0) { - return - } - - // Find last user message - let lastUserMessageIndex = -1 - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].info?.role === "user") { - lastUserMessageIndex = i - break - } - } - - if (lastUserMessageIndex === -1) { - debugLog("session", "No user message found") - return - } - - const lastUserMessage = messages[lastUserMessageIndex] - const sessionID = lastUserMessage.info?.sessionID - - debugLog("session", "Found user message, sessionID:", sessionID) - - if (!sessionID || !contextCollector.hasPending(sessionID)) { - debugLog("session", "No pending context for session") - return - } - - // Get and consume pending context - const pending = contextCollector.consume(sessionID) - - // Find first text part - const textPartIndex = lastUserMessage.parts?.findIndex( - p => p.type === "text" && p.text !== undefined - ) - - if (textPartIndex === -1) { - debugLog("session", "No text part found in user message") - return - } - - // Prepend context to the text part (same approach as omo) - const originalText = lastUserMessage.parts[textPartIndex].text || "" - lastUserMessage.parts[textPartIndex].text = `${pending.content}\n\n---\n\n${originalText}` - - debugLog("session", "Injected context by prepending to text, length:", pending.content.length) - - } catch (error) { - debugLog("session", "Error in messages.transform:", error.message, error.stack) - } - } - } -} diff --git a/.trellis/.gitignore b/.trellis/.gitignore index 46135ba..5a991ea 100644 --- a/.trellis/.gitignore +++ b/.trellis/.gitignore @@ -4,6 +4,9 @@ # Current task pointer (each dev works on different task) .current-task +# Session/window scoped runtime state +.runtime/ + # Ralph Loop state file .ralph-state.json diff --git a/.trellis/.template-hashes.json b/.trellis/.template-hashes.json index 58baeea..0c883c4 100644 --- a/.trellis/.template-hashes.json +++ b/.trellis/.template-hashes.json @@ -1,823 +1,65 @@ { - ".trellis/config.yaml": "fe1fba0961e589c6f49190f5e19d4edb0d5bf894dba8468f06882c6e1c5e2aa1", - ".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c", - ".trellis/scripts/add_session.py": "7c869be8146e6f675bd95e424909ff301ea0a8f8fd82a4f056f6d320e755a406", - ".trellis/scripts/common/__init__.py": "301724230abcce6e9fc99054c12d21c30eea7bc3b330ae6350aa3b6158461273", - ".trellis/scripts/common/cli_adapter.py": "66ef4f75470807b531490a6b6928604eb59781148fe3c5412f39e132ffab0850", - ".trellis/scripts/common/config.py": "909257b442d7d1e7a2596996622c4f2f010d8c1343e1efd088ef8615d99554c7", - ".trellis/scripts/common/developer.py": "69f6145c4c48953677de3ba06f487ba2a1675f4d66153346ab40594bb06a01c9", - ".trellis/scripts/common/git_context.py": "f154d358c858f7bcfc21a03c9b909af3a8dfa20be37b2c5012d84b8e0588b493", - ".trellis/scripts/common/paths.py": "058f333fb80c71c90ddc131742e8e64949c2f1ed07c1254d8f7232506d891ffc", - ".trellis/scripts/common/phase.py": "f9bdd553c7a278b97736b04c066ed06d8baa2ef179ed8219befcf6c27afcc9cd", - ".trellis/scripts/common/registry.py": "6c65db45a487ef839b0a4b5b20abe201547269c20c7257254293a89dc01b56dc", - ".trellis/scripts/common/task_queue.py": "6de22c7731465ee52d2b5cd4853b191d3cf869bf259fbc93079b426ba1c3756c", - ".trellis/scripts/common/task_utils.py": "e19c290d90f9a779db161aeb9fefda27852847fbc67d358d471530b8ede64131", - ".trellis/scripts/common/worktree.py": "434880e02dfa2e92f0c717ed2a28e4cdee681ea10c329a2438d533bdbc612408", - ".trellis/scripts/create_bootstrap.py": "aa5dd1f39a77b2f4bb827fd14ce7a83fb51870e77f556fe508afce3f8eac0b4e", - ".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5", - ".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f", - ".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e", - ".trellis/scripts/multi_agent/__init__.py": "af6fceb4d9a64da04be03ba0f5a6daf71066503eca832b8b58d8a7d4b2844fa4", - ".trellis/scripts/multi_agent/cleanup.py": "db50c4fbb32261905a8278c2760b33029f187963cd4e448938e57f3db3facd6c", - ".trellis/scripts/multi_agent/create_pr.py": "6a2423aba5720a2150c32349faa957cdc59c6bb96511e56c79ca08d92d69c666", - ".trellis/scripts/multi_agent/plan.py": "242b870b7667f730c910d629f16d44d5d3fd0a58f6451d9003c175fb2e77cee5", - ".trellis/scripts/multi_agent/start.py": "32ed1a13405b7c71881b2507a79e1a3733bc3fcedbc92fcee0d733ce00d759d0", - ".trellis/scripts/multi_agent/status.py": "5fc46b6d605c69b6044967a6b33ffb0c9d6f99dd919374572ac614222864a811", - ".trellis/scripts/task.py": "ecf52885a698dc93af67fd693825a2f71163ab86b5c2abe76d8aa2e2caa44372", - ".trellis/workflow.md": "9b6d6e8027bd2cf32d9efd7ef77d6524c59fcaa4ad6052f72d028a07a5fd69a7", - ".trellis/worktree.yaml": "c57de79e40d5f748f099625ed4a17d5f0afbf25cac598aced0b3c964e7b7c226", - ".opencode/agents/check.md": "39763ef458f95a2b38c0fbc9cd79df8c66909086e0ace4e4e80c536f58d09aed", - ".opencode/agents/debug.md": "0bac1d723fb3634ea95c471a22245eff2b4c9d6bd98bc66cafacf6a0092609bb", - ".opencode/agents/dispatch.md": "23d7834c540907c98f7988661849db5d949ee394470952215c373aef926fec81", - ".opencode/agents/implement.md": "540ce5cd7b2c2281ce520ed487bf0bbc4773169646f8224d7c363af293def396", - ".opencode/agents/research.md": "094829f1572e65c0d954c648c9638440e3279b02f2538f2abe0d9706b90e6fa2", - ".opencode/agents/trellis-plan.md": "36de06c7eddbff290acb3c200f30af96291048e492ce2f2d8b7038662eeb572b", - ".opencode/bun.lock": "31e0d053588da5aaeb7c3fce5de22d5878df9af7f3ffc48775992698f8614fe9", - ".opencode/commands/android-test.md": "59a50131df27fd26c287970f88443f0fa4d20a0ebb52f8c879fcc4d5aeb2b891", - ".opencode/commands/doc-update.md": "e8d9b6c122dc45d52c59a0a8c4d7363b58eb883e54118f951cab0fbc38de4d84", - ".opencode/commands/ios-test.md": "2dac0c13d8e0f026816f7ad235030f4f83b474b89c796413e0a20d00ebc15bdd", - ".opencode/commands/trellis/before-backend-dev.md": "7e35444de2a5779ef39944f17f566ea21d2ed7f4994246f4cfe6ebf9a11dd3e3", - ".opencode/commands/trellis/before-frontend-dev.md": "a6225f9d123dbd4a7aec822652030cae50be3f5b308297015e04d42b23a27b2a", - ".opencode/commands/trellis/brainstorm.md": "7c7731eda092275a5d87f2569a69584f3c39b544a126a76e727a1e9d250c4a65", - ".opencode/commands/trellis/break-loop.md": "ba4dd4022dde1e4bbcfc1cc99e6a118e51b9db95bd962d88f1c29d0c9c433112", - ".opencode/commands/trellis/check-backend.md": "4e81a28d681ea770f780df55a212fd504ce21ee49b44ba16023b74b5c243cef3", - ".opencode/commands/trellis/check-cross-layer.md": "b9ab24515ead84330d6634f6ad912ca3547db3a36139d62c5688161824097d60", - ".opencode/commands/trellis/check-frontend.md": "5e8e3b682032ba0dd6bb843dd4826fff0159f78a7084964ccb119c6cf98b3d91", - ".opencode/commands/trellis/create-command.md": "230640908f2863f0cf2d7dc0cd2b61782b77d75fc02636d6d46b22d00ccb3465", - ".opencode/commands/trellis/finish-work.md": "dd147ab880063f4359322618f39ac0e84e1494aa9f67883dcd82e947f6b5d8bb", - ".opencode/commands/trellis/integrate-skill.md": "3940442485341832257c595ddfb45582e2d60e5a4716f2bd15b7bce0498b130a", - ".opencode/commands/trellis/migrate-specs.md": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ".opencode/commands/trellis/onboard.md": "a5dbd5db094b13fd006ec856efa53a688e209bcdc3ed1680b63b15f1e3293ab4", - ".opencode/commands/trellis/parallel.md": "82e7a5214b48ffdea9063109f89a8428d7c077e0beb4cc86d4836394e47a1e21", - ".opencode/commands/trellis/record-session.md": "0c4f61283c2f262c1f9c900d9207309107497d4ac848cca86eb62bc5b7189fe7", - ".opencode/commands/trellis/start.md": "5e6141d6f7bc06fbd7de453d64e204416f1007ca0a55a8a8ca9aad100f3b3572", - ".opencode/commands/trellis/update-spec.md": "ff4d5a0405a763e61936f5b9df175fd25ea20ec5c20fa999855020ab78a919b6", - ".opencode/lib/trellis-context.js": "8974c446808852152d1f9cd1165d5dad3c215e9c9206339d83fedca464f886a8", - ".opencode/node_modules/@opencode-ai/plugin/dist/example.d.ts": "ba1871db5442e49c90630686e08323d6d8303b1749d0752646e69701572560e0", - ".opencode/node_modules/@opencode-ai/plugin/dist/example.js": "df6bc51eb345aa69a3bdfe389a5f7b1368fc5973e45f9166763b8ed3f6606f03", - ".opencode/node_modules/@opencode-ai/plugin/dist/index.d.ts": "072ed6da227611707565cfb175149035aee26b1e5b9ef9bf9f1691090a9a9fcc", - ".opencode/node_modules/@opencode-ai/plugin/dist/index.js": "a3d26196e07062e858fc0ee44cfbddeb6143be740f9c93df4286b162a8b3e298", - ".opencode/node_modules/@opencode-ai/plugin/dist/shell.d.ts": "340d8057526217987a3a797d93a03b6e6c9a9e1cd8e7c46a676ea008a710afe1", - ".opencode/node_modules/@opencode-ai/plugin/dist/shell.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/@opencode-ai/plugin/dist/tool.d.ts": "10c4747fb7470f341a977f88fa3c66bf05593cd835a25185f996244c5b638073", - ".opencode/node_modules/@opencode-ai/plugin/dist/tool.js": "a01230d42fa1055ddb4b378e0128f156e2db07f6f6eadbf2da174eb634380b16", - ".opencode/node_modules/@opencode-ai/plugin/dist/tui.d.ts": "fa0ce667c42213f02b5fb7beba5a76c2800225d847268b547f6e81f7e66d2593", - ".opencode/node_modules/@opencode-ai/plugin/dist/tui.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/@opencode-ai/plugin/package.json": "9a3801d112f0b81abebf95bf371b607c4dec57a8595a603a7ffe33a2c350cabc", - ".opencode/node_modules/@opencode-ai/sdk/dist/client.d.ts": "84edb938f2b673df750cf83e9516218404e9df7e32e66f2f063c813d7f4d0e62", - ".opencode/node_modules/@opencode-ai/sdk/dist/client.js": "3a6229169864892dfbd1919c99e9e377f4a1458e708237ab789476843f415bd2", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/client.gen.d.ts": "358599da1d31542e1d3ebf35a4a98665395ea8eaf0063826b8838974f7e826e5", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/client.gen.js": "a4303bf5538ac7652dd7ab06f709024356a674318e452f71bf391ffe6b30a727", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/index.d.ts": "06240cf9eca32a7ea5436ee67c8420bc1ddacfaf0d28cd17d09dd56a65c8fb9a", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/index.js": "190026b1b139eaf999907d9be7af64a4485dcf07e15f392fbb46c391f625aa58", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/types.gen.d.ts": "11f6e61b31e0c89eae79ce790895bf85b918572c32c2de45998f7cbacbb3b963", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/utils.gen.d.ts": "77fed0312d2ec2b08afb890b7760b5f2328f6e09065ea05b5fc35d5294fbb434", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client/utils.gen.js": "723a458bd538ada79b036e49a2c28d29e674647b51e28b61ba3fd8074ae41d84", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client.gen.d.ts": "30243dd321042c89cab45896e85d4d7c0fd8e0ba184e9b2163201945eaed71be", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/client.gen.js": "aba08480d2ca9591f619b13fd0cb2bee3a4a9648004141229b7a2f67391bcde1", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/auth.gen.d.ts": "96135fbf1dab9f857c9df88a191fdc435517f7a5b021d09cad0c290a1dcec436", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/auth.gen.js": "5daa30e9a92a9fb4c8df37212c6bf421e6102dcb6070b32a7cdb978ecc27c230", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/bodySerializer.gen.d.ts": "44fd7a3b2fac384971e638548c227835a2f8c3c2232edff7e0f951a0e136e562", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/bodySerializer.gen.js": "133af6e45e6541018e1cdff6a59d610421c97e3215ffcb1447b682a74a278d6b", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/params.gen.d.ts": "d23fac93be3afb1c83269864f13b5e5fc246b05cb21b7e019e38403e4ef1aba5", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/params.gen.js": "6176cffd7187e9192957bb3f5df926d84f3b952079b2c33ce00f2ee37d7b401e", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/pathSerializer.gen.d.ts": "c36ce4ffa39eae9d8d5f3262bbff170f55b375156bc4eeadadfeede2820aa4c2", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/pathSerializer.gen.js": "f92d4e9209d6d6bcfc3b8bf0152f85f9004aa9a3163d1113d132651c2c022581", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/queryKeySerializer.gen.d.ts": "60d1baa9e7b198ea9221564bc5000c97fd9fb9a9d74974cb08f0176192612b2b", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/queryKeySerializer.gen.js": "cc69f01bfc81230f20a2355c6785d8f83e37d79119d55d5c1f44f099ff4740ff", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.d.ts": "c41ccf7ec4fecea9d6e5b72ae7f20c91bbe85a8a82caada5ae8a3bc5a56c8926", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/serverSentEvents.gen.js": "ee09b085675f234fd6c35aa125c237202a8b728f885b24ab65e7d05ffbb2dd43", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/types.gen.d.ts": "80498d31235c22ee711a9fb6f0ca2bcb8fbf5c9a787f3d3a76962b440df70013", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/utils.gen.d.ts": "46f46fcafdf1b4e003679a35ee1a2d9c3a7e31b780fb437903fb1a6273483d52", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/core/utils.gen.js": "90a6a76e6d717839cb6fbaec696fe17a1f407ba696513366315fb30f5793b871", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/sdk.gen.d.ts": "5f71dae22207eb5119b2e005e046c0404a29454292e07c68668e39c9931414cd", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/sdk.gen.js": "2c40e32e5a6c06a4a0c02a78c010906ea2f9fefbea104abadf29e4dccc05d51f", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts": "abd950a6eb114f55ae2210538340b4167a5d6e2f270864433417a9417c9c59b2", - ".opencode/node_modules/@opencode-ai/sdk/dist/gen/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/index.d.ts": "2a7a35e3f1ead49569fe0423ae65055184f9384b97999cd90bb99878f727e585", - ".opencode/node_modules/@opencode-ai/sdk/dist/index.js": "c89e7660b6c14b282f497cf590f1693a49a421bba437a2d3ef2c2f6b51326958", - ".opencode/node_modules/@opencode-ai/sdk/dist/server.d.ts": "579f61b51c0e5dc0119ab49fc536130749280c7a23e489e753fcce08c229ce2c", - ".opencode/node_modules/@opencode-ai/sdk/dist/server.js": "79afd73e4c83f595b3ab91193cf6a9359e95506e4825b8f433d47926afdaab14", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/client.d.ts": "19d2c965297a2f7eedec208f00b8f988e83cff5e87fd58b9b2bcba9f4e481814", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/client.js": "43bf4990b39b59fcbc8fb90c5ab50ee070b0f72c8a143d0410edb5683fa6e6c0", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/client.gen.d.ts": "358599da1d31542e1d3ebf35a4a98665395ea8eaf0063826b8838974f7e826e5", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/client.gen.js": "90b45d194961c72dd2775b39af02c69fd49587a8a4f4d56d3407b12b8073447d", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/index.d.ts": "55122db1dc0aa0340759af31319c40309930899ee2d67c82cd0d3cfc68b3c29c", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/index.js": "cbbc149808c7dd0c00ceac3df03a948f8f701ec049a763ce14567abe306a7a54", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/types.gen.d.ts": "481c7349554c63b0930b792ad2f0174f7d0666b3ec11c203f72d3b5be22f1d3b", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/utils.gen.d.ts": "53be801bc15e23f6ebb90c6675ad1803a6488f19b0b36c11f309e9899e74e75f", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client/utils.gen.js": "c593c1f1fe32737c849a284677d9a1e0643b125bcb5727b1afc7638d7f01eee8", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client.gen.d.ts": "c1af887105801d15dd6eeb88cb42e680d33e4a1d7cb6372011b3224bd81d7c4d", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/client.gen.js": "a5f22d5f97899072c3a6afe04312f0b4838a811458427627f60473c63c0ead5d", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/auth.gen.d.ts": "96135fbf1dab9f857c9df88a191fdc435517f7a5b021d09cad0c290a1dcec436", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/auth.gen.js": "5daa30e9a92a9fb4c8df37212c6bf421e6102dcb6070b32a7cdb978ecc27c230", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/bodySerializer.gen.d.ts": "010b37dfe9c2cbc48d874bcab4074917ea836e630dc577a9e2d03e97da2236e5", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/bodySerializer.gen.js": "133af6e45e6541018e1cdff6a59d610421c97e3215ffcb1447b682a74a278d6b", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/params.gen.d.ts": "210945863764365e1e6b4e9c7e6fdd76e3b4aac91f38b7a5a0830ac112d57ac3", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/params.gen.js": "b3c95661ddaec7c7e54fb17bca6a51961ea5b383daa218fe6c862f05fcf594d2", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/pathSerializer.gen.d.ts": "c36ce4ffa39eae9d8d5f3262bbff170f55b375156bc4eeadadfeede2820aa4c2", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/pathSerializer.gen.js": "f92d4e9209d6d6bcfc3b8bf0152f85f9004aa9a3163d1113d132651c2c022581", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/queryKeySerializer.gen.d.ts": "60d1baa9e7b198ea9221564bc5000c97fd9fb9a9d74974cb08f0176192612b2b", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/queryKeySerializer.gen.js": "cc69f01bfc81230f20a2355c6785d8f83e37d79119d55d5c1f44f099ff4740ff", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.d.ts": "844af05b12599165533995c74c309effc0106729f86e8596c2c9ec4868b80d94", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/serverSentEvents.gen.js": "218952938cd653a781ef512a60de49e4b558e5fb25558b27d96ada68b5eda4bc", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/types.gen.d.ts": "997352ab110eb2f15bd22e39882b9bfcf884b84b4d57cc0d24dd9037b36681c5", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/utils.gen.d.ts": "6be7640d8a8bcbeb9054bf83ab782d68e4cd761f3738bf776615d5ae9b729cbd", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/core/utils.gen.js": "110a18cdb229ab980a39aa222eca37e5a3b75773199555b7f8d2d110f588b4de", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/sdk.gen.d.ts": "500931c9b40d897c07c39fa2a7aace12e46a8757339598a214520843e55d3cf3", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/sdk.gen.js": "ea83d35534f04b309100f9d1d1fb4d0f5f931c3499c48cacf6c15c6b87059fa0", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts": "66beddf56fbb2238485b53b6b4343acb5b79694d8bf4edaf8291259d64e822ef", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.js": "17ebe2baaa8d2f0c94afaaaae74a46e43e2b163a0f4ffd69adb39ad57c318e78", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/index.d.ts": "2a7a35e3f1ead49569fe0423ae65055184f9384b97999cd90bb99878f727e585", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/index.js": "c89e7660b6c14b282f497cf590f1693a49a421bba437a2d3ef2c2f6b51326958", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/server.d.ts": "579f61b51c0e5dc0119ab49fc536130749280c7a23e489e753fcce08c229ce2c", - ".opencode/node_modules/@opencode-ai/sdk/dist/v2/server.js": "79afd73e4c83f595b3ab91193cf6a9359e95506e4825b8f433d47926afdaab14", - ".opencode/node_modules/@opencode-ai/sdk/package.json": "6d9580a225220796ff6c19feb1ff2b53b2d8e7db22e83e9f997bda241e5a3850", - ".opencode/node_modules/zod/LICENSE": "3f1189b28e3866e0d979968d466b78f813f76827cfdca1fbb124cc0a5c8841f8", - ".opencode/node_modules/zod/README.md": "67485f7fe9fda912f02235894aa782f4354ca2f568b4735db005d0ee52390628", - ".opencode/node_modules/zod/index.cjs": "2a3455cecff4f7021c92d0a3e2e5fc170c0448dc5cc261160127e08f4888a9c2", - ".opencode/node_modules/zod/index.d.cts": "29f823cbe0166e10e7176a94afe609a24b9e5af3858628c541ff8ce1727023cd", - ".opencode/node_modules/zod/index.d.ts": "c733a1897d6b4b30dad6998597f6896b265b094a65534359ada34b08ecf8932c", - ".opencode/node_modules/zod/index.js": "c733a1897d6b4b30dad6998597f6896b265b094a65534359ada34b08ecf8932c", - ".opencode/node_modules/zod/locales/index.cjs": "77c515c23956a04462d5c8fb207321bbbaf6e231b86faa4ecd98dfaec82a40e7", - ".opencode/node_modules/zod/locales/index.d.cts": "79152153afe093c320d882e1d66640fdf7a853fcab8e897ab7f10cbac937cb2e", - ".opencode/node_modules/zod/locales/index.d.ts": "0b98ccbe349eb9774d8c96fa381335cef127140996f91a4eea47b7742b55f51d", - ".opencode/node_modules/zod/locales/index.js": "0b98ccbe349eb9774d8c96fa381335cef127140996f91a4eea47b7742b55f51d", - ".opencode/node_modules/zod/locales/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/mini/index.cjs": "c3ef916ed5e1bb397f04352f010f97961e98f610c13ecc05c25a384ef0e5a5a2", - ".opencode/node_modules/zod/mini/index.d.cts": "d34c4532b0004150342d04ab1a6f61d19751c4fc7c465c72ec582b180b0904c0", - ".opencode/node_modules/zod/mini/index.d.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/mini/index.js": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/mini/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/package.json": "67f2058ef56c9209df51e6fe0dd1395eb25af454974de2978ced56008e6fad5e", - ".opencode/node_modules/zod/src/index.ts": "c733a1897d6b4b30dad6998597f6896b265b094a65534359ada34b08ecf8932c", - ".opencode/node_modules/zod/src/locales/index.ts": "0b98ccbe349eb9774d8c96fa381335cef127140996f91a4eea47b7742b55f51d", - ".opencode/node_modules/zod/src/mini/index.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/src/v3/ZodError.ts": "e4386fe8f2a49d774c7e1aff4c015c125ac4a0dcf70d5aa6883f167278ca141f", - ".opencode/node_modules/zod/src/v3/benchmarks/datetime.ts": "0d1a81e5608b286c1843aaecb20bd7d1f62b6674a610a51dc7d1043bed05963d", - ".opencode/node_modules/zod/src/v3/benchmarks/discriminatedUnion.ts": "33e4f792dbfdc735a72f92fcc117d3dbaea8e7ff3937d523bef3df44814805d3", - ".opencode/node_modules/zod/src/v3/benchmarks/index.ts": "27f4322ad98e28575d5849a7612c6acea55cd5be8a1571d1885a656e8a9d5620", - ".opencode/node_modules/zod/src/v3/benchmarks/ipv4.ts": "3ef475926d7e95a5880cd9d7bd780f98642189010046bb9748e27e50360da333", - ".opencode/node_modules/zod/src/v3/benchmarks/object.ts": "ea0f6d4b3e2c6f47bf1739a53473997da4aaacc1e5f59e57f00cfb6b7f59a4e5", - ".opencode/node_modules/zod/src/v3/benchmarks/primitives.ts": "706d51c7048aff5108dd71b33b5c66d354a6856be0a48b23bc32e55d284c31d0", - ".opencode/node_modules/zod/src/v3/benchmarks/realworld.ts": "9bcc944a5e875ca191ca10542c113dde3c754e45a196400726f251b045e14215", - ".opencode/node_modules/zod/src/v3/benchmarks/string.ts": "ed17ef186249dc4f1da01a2d5e0bd3c87da6d0d2dcad89e8afa05e0920d98536", - ".opencode/node_modules/zod/src/v3/benchmarks/union.ts": "9eec8e3e58cb4d3dd76be11874187f71a99c27e06c639de0cc9d8d928687fe0a", - ".opencode/node_modules/zod/src/v3/errors.ts": "cb8debd524102d5a38dbaadd72b220f68349dbac51ac9663569c1807f64b3772", - ".opencode/node_modules/zod/src/v3/external.ts": "5d26d2e47e2352def36f89a3e8bf8581da22b7f857e07ef3114cd52cf4813445", - ".opencode/node_modules/zod/src/v3/helpers/enumUtil.ts": "dffcb43b363e6804128c415c128c32dbbda302049cfce7662b2f0e17eeae044e", - ".opencode/node_modules/zod/src/v3/helpers/errorUtil.ts": "808aa0875577c556006ca193a5d4bd35a2ade57e50e09199fd9cbf6e780f0a31", - ".opencode/node_modules/zod/src/v3/helpers/parseUtil.ts": "cb94690c02dce392b98ca364de0d4b24f42db715fbb1759a08bcfb7fa6674645", - ".opencode/node_modules/zod/src/v3/helpers/partialUtil.ts": "68848db44869461bc20373abdcd002e32cb3759e2d5801ba3d9894d2c23db0b1", - ".opencode/node_modules/zod/src/v3/helpers/typeAliases.ts": "d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5", - ".opencode/node_modules/zod/src/v3/helpers/util.ts": "df1b9ea5a29a591f273555e240142431617e686aa6a97f172193770c26841a52", - ".opencode/node_modules/zod/src/v3/index.ts": "3db2efd285e7328d8014b54a7fce3f4861ebcdc655df40517092ed0050983617", - ".opencode/node_modules/zod/src/v3/locales/en.ts": "f729055c5f4acb839084e5c66c80bdecca02a78df6b048224e56e634322cb223", - ".opencode/node_modules/zod/src/v3/standard-schema.ts": "e14e38f67bfc8d76e208366f639b73b3292f2bd46c97a44f43535c2389927ece", - ".opencode/node_modules/zod/src/v3/tests/Mocker.ts": "b7732bb15ed6ab38f61dd4a15f088af109b0a99eccb50cdd9f542a17bb935f5e", - ".opencode/node_modules/zod/src/v3/tests/all-errors.test.ts": "d629da33805d24b17d1f6383009acfbf3b769bd4f2d8191a9720d68e4e64ab7d", - ".opencode/node_modules/zod/src/v3/tests/anyunknown.test.ts": "fac8ebb9d3007f7ef1ea320eed85b22b3248fde6bb1fe645a1a513b69442c014", - ".opencode/node_modules/zod/src/v3/tests/array.test.ts": "e7d3813182c59e511d350bebbd2a25b349868cc73d057b23b1d85a4d927b3711", - ".opencode/node_modules/zod/src/v3/tests/async-parsing.test.ts": "45f829362cc917d9505e244ea0d9a06d71a1953747dc31e57780388e82ad5afa", - ".opencode/node_modules/zod/src/v3/tests/async-refinements.test.ts": "cfd6752223678338c949f1716deeff8c18807f31343d6a4544f494d10e5acf60", - ".opencode/node_modules/zod/src/v3/tests/base.test.ts": "989d8ff33e43dbeb3d08d8f659a3269d22fb573a86d48c72da58ab84e484e532", - ".opencode/node_modules/zod/src/v3/tests/bigint.test.ts": "799b38ffff2d2a977493846e127d6f76e5cdb346d8886a2d2c76616253170f45", - ".opencode/node_modules/zod/src/v3/tests/branded.test.ts": "d0d7213caa6b727fe72a4bd99631984c4038ecbe8f67001840b447ca4b2085b0", - ".opencode/node_modules/zod/src/v3/tests/catch.test.ts": "e4b1727836b5cdca66337af180a756fab2b712f61d49d17326a90c0a54080be5", - ".opencode/node_modules/zod/src/v3/tests/coerce.test.ts": "30dec33039db38b3c2baecbee09c74afc1d9adc68a6229a35628a1bac4539db9", - ".opencode/node_modules/zod/src/v3/tests/complex.test.ts": "51d676be11ddb5a0b987471c6e49b819b4f6b2aba77fa23565deb4591439cf40", - ".opencode/node_modules/zod/src/v3/tests/custom.test.ts": "6837c23478b39dc6304a98bbbba4a1df9b973d40f6243f0939bfbc339d96b01d", - ".opencode/node_modules/zod/src/v3/tests/date.test.ts": "7d352eab070ae8f4fcbc61674f6b238159699ec4b96754ec1aac0cac74091c34", - ".opencode/node_modules/zod/src/v3/tests/deepmasking.test.ts": "8b6cef1cdb8eb422ebb50b24e4f88742f13eaf0abf83b08a3980f2406a4b6ac6", - ".opencode/node_modules/zod/src/v3/tests/default.test.ts": "92067a119b984ab2750a1eba8e1a5573276aeaed95056e9e283f4a3bb54e5d5e", - ".opencode/node_modules/zod/src/v3/tests/description.test.ts": "f1b75327fd817bb63c3b2e3be0ff9aff7511e2a391713756d8cb26cbfd3d5aa5", - ".opencode/node_modules/zod/src/v3/tests/discriminated-unions.test.ts": "5bd00d9f7eeac9dd60faa03af58b5a915040f54b93ff77c4eab0d4a18f8b9e9c", - ".opencode/node_modules/zod/src/v3/tests/enum.test.ts": "3fcf9f740cd53f52431abda4c61a3732dde44c3a9f1033159e9c0c92fcebd09a", - ".opencode/node_modules/zod/src/v3/tests/error.test.ts": "1c77f7fefb917210ac238159c8116bd159158c260008bb6fd2f8a91ba9e55538", - ".opencode/node_modules/zod/src/v3/tests/firstparty.test.ts": "9906d7da8e62f7b9b7e5cd9553bea0872ad8a0b259214034108cec8a624cfc1d", - ".opencode/node_modules/zod/src/v3/tests/firstpartyschematypes.test.ts": "9be5a0881151df777293172088b2039ed7e1e6f4a98a7557cdd0211f108c9b96", - ".opencode/node_modules/zod/src/v3/tests/function.test.ts": "93b2a5008501e4d883c838e567a6d3da18f88f31fb71a7ba5615664572f89783", - ".opencode/node_modules/zod/src/v3/tests/generics.test.ts": "d1a4c54239acff333f76231084c48133257fefe14f504d0cab055979b5a9e723", - ".opencode/node_modules/zod/src/v3/tests/instanceof.test.ts": "b11897351fcf312dee317408b7aa27929ba37538c468588429f4f4ae3c5fa4ce", - ".opencode/node_modules/zod/src/v3/tests/intersection.test.ts": "c560d1fbca45e049f07eb2a382f7ee4c678164825cf5725b014312eac9412c8c", - ".opencode/node_modules/zod/src/v3/tests/language-server.source.ts": "aeb84d6fb14e6511a3509a0545770d869986f397ee02da640730d4ff97c6be4a", - ".opencode/node_modules/zod/src/v3/tests/language-server.test.ts": "c75a03c145d1b0f28c62cba881aae1db600912a1df3be99d1996a18da776c498", - ".opencode/node_modules/zod/src/v3/tests/literal.test.ts": "a22faed6c08e04c75d6bb0180d5e1fafc4645b15445a479f338016ee4855c362", - ".opencode/node_modules/zod/src/v3/tests/map.test.ts": "bb52432835e9fa70a94f444fc9e7c2d8e6ac5eb905cc3b8ae54846e52fa57360", - ".opencode/node_modules/zod/src/v3/tests/masking.test.ts": "557f4bc4d8f4ab29afd87f07cf70fa4de60d765fc86fbc5eb4b788f34dd825a2", - ".opencode/node_modules/zod/src/v3/tests/mocker.test.ts": "46f64d2995e29c5838189ed10cd1b185b44c33dbc21418364a80ac19f285362b", - ".opencode/node_modules/zod/src/v3/tests/nan.test.ts": "2ef6416fc58f94964cb889d28f8b7019a90365d24229d8146c845b8c7b0b8610", - ".opencode/node_modules/zod/src/v3/tests/nativeEnum.test.ts": "6f64363193a447f71a8aa5440461bdb5451c6c4433fb4fca785ef4798d5b52fb", - ".opencode/node_modules/zod/src/v3/tests/nullable.test.ts": "c51336535632e1404455bca333ccafc143191600047251a3e3e13f551693c516", - ".opencode/node_modules/zod/src/v3/tests/number.test.ts": "d56a65ec9bc03482c2b23eecb97c5213b564b5aaafeaf205d2a1f54a2c91e1d8", - ".opencode/node_modules/zod/src/v3/tests/object-augmentation.test.ts": "69777e89506b2d8157c98e176dde94a7c664db7228485c52a18154ac4b794382", - ".opencode/node_modules/zod/src/v3/tests/object-in-es5-env.test.ts": "687d2c52ddaaca24ab3d3e301cba042a7560888570af23b50a0597d3012d500c", - ".opencode/node_modules/zod/src/v3/tests/object.test.ts": "489ba37da9b2117c730aaa847099c7899ba0a376cf612495f41c42c29808fb45", - ".opencode/node_modules/zod/src/v3/tests/optional.test.ts": "0edb7a6478e931bf256f3d71d1367d2b8199b4082101dba97cc306e4dc3c92c9", - ".opencode/node_modules/zod/src/v3/tests/parseUtil.test.ts": "822a6d836b05ab38eadf26592420eba6be694dc7c1d654388efbb601af00c288", - ".opencode/node_modules/zod/src/v3/tests/parser.test.ts": "7c8b720c921618f6c89eb08eae80ba45602fa04dafe9c7b9b861512954d70f9b", - ".opencode/node_modules/zod/src/v3/tests/partials.test.ts": "2fe9fe6cdfa085eed053894acdd3ef32c5425f7da0aa81ea1043c737fcd7cf41", - ".opencode/node_modules/zod/src/v3/tests/pickomit.test.ts": "cd05293952b5fb0ca26eeb9ba6cd69a26f58fa5c62eb35f9ce1eb1ac9e00ccd6", - ".opencode/node_modules/zod/src/v3/tests/pipeline.test.ts": "b285aac721b744da8c5a0a093c9ed57244643e1e333df576922e67bfaa9e16ab", - ".opencode/node_modules/zod/src/v3/tests/preprocess.test.ts": "7433e95a0504f7a250b7beaac30a5e8f500c315d99b3badb978a0b28ed7a61dd", - ".opencode/node_modules/zod/src/v3/tests/primitive.test.ts": "d0214e93de931a8e6b89b3cb89df1cbf7299d6be00abcdde71ffdcb811953dea", - ".opencode/node_modules/zod/src/v3/tests/promise.test.ts": "c03c19525c56cea7808d982bd23ba89b5a48db13934e8d4824e13ee0a249d19b", - ".opencode/node_modules/zod/src/v3/tests/readonly.test.ts": "dc0ffe451674c8938b67bdcb63deff7958dd1f0e5ec69a485bba470c978817c1", - ".opencode/node_modules/zod/src/v3/tests/record.test.ts": "ebefc7ad59b2246787408efe7aaf2f7beec0b5c90c70e79990dcbf241c2d6c95", - ".opencode/node_modules/zod/src/v3/tests/recursive.test.ts": "9ccc037ef698ceeb4e6986e02aaee71ff318c4dc0f875b9253bff6ba14db9137", - ".opencode/node_modules/zod/src/v3/tests/refine.test.ts": "2f3a329952b6f0109028a142225d719b05307a18f7395297711f725b599933f8", - ".opencode/node_modules/zod/src/v3/tests/safeparse.test.ts": "5aa7597109dea6647d0ab022a4aab694c44acfeae53386ea6366b806fbd3e872", - ".opencode/node_modules/zod/src/v3/tests/set.test.ts": "eeadd9dabd30b857c5de8526503cb1e5c8b6aa45a99c4708958ca1f822bb3da2", - ".opencode/node_modules/zod/src/v3/tests/standard-schema.test.ts": "f74076e43f44c887b3bf7d26d5ec686b8070359e63fe373e6c45b6c7708c025c", - ".opencode/node_modules/zod/src/v3/tests/string.test.ts": "19ac9a6b0a7c610038d93dd9f97d453ad3a54bd5e1b1dab99e25d544872d93cd", - ".opencode/node_modules/zod/src/v3/tests/transformer.test.ts": "e3da81596e0077326f442153a741c9ba25669b8b5eb025a8ea7fc1eb0106faf7", - ".opencode/node_modules/zod/src/v3/tests/tuple.test.ts": "e8a48c8791387c94076c7eee8c9b7e18712a45356c7167f7c43bedd54544614f", - ".opencode/node_modules/zod/src/v3/tests/unions.test.ts": "0c32f20d663b30438bb8ff834b7f066722160720a25a90e6513a66b9baa4604d", - ".opencode/node_modules/zod/src/v3/tests/validations.test.ts": "10ae103a7c7d9e5ce8bdacf9ef30bc44a52cdadf64fe365f492d27dcb432b482", - ".opencode/node_modules/zod/src/v3/tests/void.test.ts": "6bdad3b7ba624eb3bd03c0bfea611de46c10aba62d03df9bd2ba49bafedc15cb", - ".opencode/node_modules/zod/src/v3/types.ts": "1cfe15702dbccfc592bb46679bb6979c6b1b44b9790114a01447436849f0900d", - ".opencode/node_modules/zod/src/v4/classic/checks.ts": "925c0df406a04a1d0441c0e51f5c08dd1f7534a1919ebd525a7dc271aafb20a6", - ".opencode/node_modules/zod/src/v4/classic/coerce.ts": "9cf6429965d7d55dc4e50999af50cbad5b57b5f3372bba13038133aba62ba600", - ".opencode/node_modules/zod/src/v4/classic/compat.ts": "1333987ab926fb40f4708758fc674c50be1f05540b62fd1de5cb8ccda574e532", - ".opencode/node_modules/zod/src/v4/classic/errors.ts": "840c80756be1f61952e0a65234029e31fb3aae32e3eca4a68ea969f809a81159", - ".opencode/node_modules/zod/src/v4/classic/external.ts": "58af150e9fb50909d26d4f795743ab20f8807ea5098ee59145d52ccc3b4aa3bf", - ".opencode/node_modules/zod/src/v4/classic/index.ts": "8f199f0404574864bd10cac8311858f39d50d418609a0ef089dfd444d4977840", - ".opencode/node_modules/zod/src/v4/classic/iso.ts": "e3215f0650c1256d63299dec334fbbcb0b92517286618f1c24223b5294fd5fb4", - ".opencode/node_modules/zod/src/v4/classic/parse.ts": "07b4d6d890ca610989b15429a0c551d6ecc0dcae8e2d6f96933b678e389d1422", - ".opencode/node_modules/zod/src/v4/classic/schemas.ts": "a2b12003d2afaf67832200bd3adbbfa567fcbd7eec0ed7356b487a1b2cae6f62", - ".opencode/node_modules/zod/src/v4/classic/tests/anyunknown.test.ts": "c5fbbcc05862414d2bf8d8c33e90b15f07d2542e56408e668bc4ab8dcdae5758", - ".opencode/node_modules/zod/src/v4/classic/tests/array.test.ts": "a73d624a81a7308e901b2584c491eb40dcb05f04c2acd96fb90f96bff5547058", - ".opencode/node_modules/zod/src/v4/classic/tests/assignability.test.ts": "40dfb44f27f5ca10cd854d050e203a228ef616424eda3ade56e0a1e70bb34c92", - ".opencode/node_modules/zod/src/v4/classic/tests/async-parsing.test.ts": "5854e0b2712dea24b27f8d0b5dad384e06eaa785539e1388df941fb4fe6ff80b", - ".opencode/node_modules/zod/src/v4/classic/tests/async-refinements.test.ts": "ac573cf82efdba4cf92c86ac959f63177c4622338609848869f778c0d125cdb2", - ".opencode/node_modules/zod/src/v4/classic/tests/base.test.ts": "0401d2c75ed792b13bf811fc77801c485b89671460a4d52439cf1908fe94f2b7", - ".opencode/node_modules/zod/src/v4/classic/tests/bigint.test.ts": "85cbf87b9df6b154ad7d3f5df030b38fb2c2761dcbc2d4a2ddc7ba06de95433d", - ".opencode/node_modules/zod/src/v4/classic/tests/brand.test.ts": "bceffeda14e55a4c6f6d4f2e30fe9a1d8d1199d50bae767c0ac0c4d4aeb41414", - ".opencode/node_modules/zod/src/v4/classic/tests/catch.test.ts": "de6705d2346629d186396674c7e773b8c8193208337972b2d6cb99be064fbab1", - ".opencode/node_modules/zod/src/v4/classic/tests/coalesce.test.ts": "e03d0cc88ac34605b99061da2e0ce22a7e7fbca0467819096994bcf63bb5b8f9", - ".opencode/node_modules/zod/src/v4/classic/tests/codec-examples.test.ts": "3498ad27ece4f8d97a9588728a909d6fee046b7ef8d6d9f442ce702193f0d789", - ".opencode/node_modules/zod/src/v4/classic/tests/codec.test.ts": "7e21189d8f75aa63d8a164945b9ed7fa06d621a8b5c13c323b32039eb30b46d2", - ".opencode/node_modules/zod/src/v4/classic/tests/coerce.test.ts": "b1c494141f3120e5f4253c2bbe1131468d32281e8a9513f27fb40fee3142b55a", - ".opencode/node_modules/zod/src/v4/classic/tests/continuability.test.ts": "634af853f6509497f8d441045039fd1b6f43fcece4be813ec56c3f25134b53d0", - ".opencode/node_modules/zod/src/v4/classic/tests/custom.test.ts": "130ec42a9e192f5b4f43a890303921cb0ec7f1e878a4f0d6a1aacfe863ff1b91", - ".opencode/node_modules/zod/src/v4/classic/tests/date.test.ts": "e578d7bea1019b22cbc675788e5cc7123f9c3fc2ed90e8a8cb222ddb0cdb8b76", - ".opencode/node_modules/zod/src/v4/classic/tests/datetime.test.ts": "b511934d118fa8dc605900ab770a4f71fededd603f57a83540993e31b40c4fdc", - ".opencode/node_modules/zod/src/v4/classic/tests/default.test.ts": "e6cdf299ffff3de4ddc745c018065484ff01575033c130fdc671f6e84879c94e", - ".opencode/node_modules/zod/src/v4/classic/tests/description.test.ts": "c7f59501ffad7792ae76c178e6098de70c3da062280dc788f3354187335cfd8f", - ".opencode/node_modules/zod/src/v4/classic/tests/discriminated-unions.test.ts": "8098b485b5f6279a609d21aaf51fb2f56177c84577bd903b028f0e5e5a6c9dea", - ".opencode/node_modules/zod/src/v4/classic/tests/enum.test.ts": "2a76a8b5c6116fb06a249284afaf3aba4512a790264f86871857a0b5f6af7650", - ".opencode/node_modules/zod/src/v4/classic/tests/error-utils.test.ts": "61f208ad1009e950137ad8da3ebc8ab78a44d2a23fafea788c0bd4247bf7de86", - ".opencode/node_modules/zod/src/v4/classic/tests/error.test.ts": "f38b436b7cba83cb35993e05fc7feeb7e8e1dc6695dd069936e232412f6fce5a", - ".opencode/node_modules/zod/src/v4/classic/tests/file.test.ts": "3c6d15d9c9230a1047e2036aa7c56ee9bed22d8b91dccab6c7afeec42fe978e9", - ".opencode/node_modules/zod/src/v4/classic/tests/firstparty.test.ts": "60e754acdfeb6bac8dc94fe59f15df0b0a94e9fbead52d230f27ea687aa85cc9", - ".opencode/node_modules/zod/src/v4/classic/tests/function.test.ts": "d052609b0510d42fc956e427a797fbd42cedfc7f2b9f401a200f5c9dc46428bf", - ".opencode/node_modules/zod/src/v4/classic/tests/generics.test.ts": "e4dc0d13796ff0d0ef8b5286dd168afec62ddeef66fae429cc9860b2bbd8f80b", - ".opencode/node_modules/zod/src/v4/classic/tests/hash.test.ts": "14633fa651f6bbdd23ef23a8ce4c4520c3981f7462980f8b8cef8ee5fdc85ef5", - ".opencode/node_modules/zod/src/v4/classic/tests/index.test.ts": "ba5dfbd8b70a5fd894b239ffe1e9beecb7d5603641ff7b375dce062b3f626229", - ".opencode/node_modules/zod/src/v4/classic/tests/instanceof.test.ts": "483a97ca348233bcf363859b2354dd6f1974d131091cc6b1953f8ae974b27503", - ".opencode/node_modules/zod/src/v4/classic/tests/intersection.test.ts": "f443b615937d929a381a7a8b1b0ec17f63f1514e8a5d8059d63e4ad4d52c1334", - ".opencode/node_modules/zod/src/v4/classic/tests/json.test.ts": "ee8aeddf8696bb9dc97d360217b4140ed9434ec6c6e8598ccda18094c6981c2d", - ".opencode/node_modules/zod/src/v4/classic/tests/lazy.test.ts": "4c3c1ac8620955f7f19713771ef48d8401fa0deecd0e1dfda205fecf89f8c8cc", - ".opencode/node_modules/zod/src/v4/classic/tests/literal.test.ts": "a861896be65d22ddcc0cb10bfcb3bc8bd8ed0397406617d0cb1f76f69768def6", - ".opencode/node_modules/zod/src/v4/classic/tests/map.test.ts": "2847a83a1ab7c56e6bc75f16589ebc732eac91a4b9a4ed95b347669a47b116af", - ".opencode/node_modules/zod/src/v4/classic/tests/nan.test.ts": "2ff494cc0c8c785f29b0b4c2eff2e93b3f829b518266d78a05ee40fea9ffc11a", - ".opencode/node_modules/zod/src/v4/classic/tests/nested-refine.test.ts": "8e3f3492563a93adaded8b352e03a087f1c5c2f6a5fbc853ca4e2706ea006799", - ".opencode/node_modules/zod/src/v4/classic/tests/nonoptional.test.ts": "f14e374f6e33d7ada15398cde5248cb66f3c946c9734382f467c3c7ea4f5fc44", - ".opencode/node_modules/zod/src/v4/classic/tests/nullable.test.ts": "a749dc3a8ca78ac05c94abd82958c692b492e4ed6d93c6c05ad1b9a769067850", - ".opencode/node_modules/zod/src/v4/classic/tests/number.test.ts": "08680e3f8f46c1438b6305fa3fdde01ab779c8082b53187e1578bfd717ea3460", - ".opencode/node_modules/zod/src/v4/classic/tests/object.test.ts": "fe0edca4f092f0b478f4e9ea307d3f2ab6c996465d4395627320043c99694fe6", - ".opencode/node_modules/zod/src/v4/classic/tests/optional.test.ts": "61addfd84555a58342a1073ac090ca8f0b8d8fbdfa8a0c7bc9b7f852fc23ddb7", - ".opencode/node_modules/zod/src/v4/classic/tests/partial.test.ts": "1ddc98d1b019d447895db51ec71c3caa8afbafa1bf252c7982225e2aa2bb6fde", - ".opencode/node_modules/zod/src/v4/classic/tests/pickomit.test.ts": "fd55dd310c6197a27fdd486dfbb94649079b422a2951188c4b4ea784a693a1db", - ".opencode/node_modules/zod/src/v4/classic/tests/pipe.test.ts": "9383a52f0f85e97ff34e7bd485bedf2c1a5ef3ffd37ace47001de7b885b7adfd", - ".opencode/node_modules/zod/src/v4/classic/tests/prefault.test.ts": "1943868cdc707eef785d48dd9d73ff10f764beed2b706ac73a0aed85a428b740", - ".opencode/node_modules/zod/src/v4/classic/tests/preprocess.test.ts": "04f3f845eda2e0f36a35c5caa871e7e1169b733bbe20ec04a09b2b374a254357", - ".opencode/node_modules/zod/src/v4/classic/tests/primitive.test.ts": "b5a2f74d03039eff1d20229ab35594d1e9a808dc50b943e802724525338f9f1f", - ".opencode/node_modules/zod/src/v4/classic/tests/promise.test.ts": "b7d58a54d480429314f23cd52c2a78baefaa5c1e87f4bfd0b13a9e30d586c754", - ".opencode/node_modules/zod/src/v4/classic/tests/prototypes.test.ts": "c6e493d089995c8aecc728b99bd2dbcb6f56137be4537c3e2fd657838c582ebc", - ".opencode/node_modules/zod/src/v4/classic/tests/readonly.test.ts": "c2ddd379f4e85caa2856ba0b784c44be87c079cef18ba4108ba698fa9fecb611", - ".opencode/node_modules/zod/src/v4/classic/tests/record.test.ts": "25b6ce62b9a6736d821ea62e07106aaa73a42de7f8824ffc97463e5095a110be", - ".opencode/node_modules/zod/src/v4/classic/tests/recursive-types.test.ts": "d2d4570462ad84cf6b3fb17d99ad4d4c242b4e5d078c1775b530b8f721ba9407", - ".opencode/node_modules/zod/src/v4/classic/tests/refine.test.ts": "df89ea9a88dd60b38001b3a31b643f99d207f39a6d225e6bc107d6c44d4682d0", - ".opencode/node_modules/zod/src/v4/classic/tests/registries.test.ts": "4325f184075634098ec061b2b6070bc7b27c55301191a444d41662e029ce6e33", - ".opencode/node_modules/zod/src/v4/classic/tests/set.test.ts": "4e3d1d08df1bcd2e659a2f6de0f12f816e756ac9ec98b253cdb931ffe8ecc951", - ".opencode/node_modules/zod/src/v4/classic/tests/standard-schema.test.ts": "af5fef06f1d9f695d39842ae98c29555f05c432d2fe5abd361a20c748de39c7c", - ".opencode/node_modules/zod/src/v4/classic/tests/string-formats.test.ts": "d6b61815603bbfc8a7acaeac23d88773195c45991c7b85dffc85e23f24b37640", - ".opencode/node_modules/zod/src/v4/classic/tests/string.test.ts": "bf41f94cf272b01900fd4cc0086aa6bc276c181ab430c45010c74ce0a7c665e8", - ".opencode/node_modules/zod/src/v4/classic/tests/stringbool.test.ts": "6a5f06aa81c442476c2c656607ec580707afbb08df67eb813ce7dd0f20c42fdd", - ".opencode/node_modules/zod/src/v4/classic/tests/template-literal.test.ts": "9662e3535763a01abef78e5775aa82559b4286b0cdf1aa71ecc3e3123358def5", - ".opencode/node_modules/zod/src/v4/classic/tests/to-json-schema.test.ts": "f5c70e5db4f70be9b2fb7a24f54a329b20123fa61d54cdf120e1dc8d0d7af879", - ".opencode/node_modules/zod/src/v4/classic/tests/transform.test.ts": "250d1faea61566e13c1067b3c2fe900af97219c19cfc4a530d48a3047d40f048", - ".opencode/node_modules/zod/src/v4/classic/tests/tuple.test.ts": "96d3aa6cfec7dfd9febdee31b1aca837c0a79c378678b9e833fa5479cff9fbfc", - ".opencode/node_modules/zod/src/v4/classic/tests/union.test.ts": "f286b4b36f5097f2b127e632400b2627b714e4b4a82872adbe0e47198866dc39", - ".opencode/node_modules/zod/src/v4/classic/tests/validations.test.ts": "b2dd61b3b20b082cee04ef5d988cb4852909f9f0e71bbaf2a91f1a681bc674be", - ".opencode/node_modules/zod/src/v4/classic/tests/void.test.ts": "a1e5ff5f759a09e192764d4f153443bc5735cf8b6db6d810e21f0dddb414e805", - ".opencode/node_modules/zod/src/v4/core/api.ts": "6fb05b277c361f635d0aa0c368b12ac8873d497a62d31c9b83b8e64c38a2b3e3", - ".opencode/node_modules/zod/src/v4/core/checks.ts": "8159fb8da563bdaff4691d7e335c4512644b8fe83eb5d52c1b120357eeb18202", - ".opencode/node_modules/zod/src/v4/core/config.ts": "3c3ec0c03ee7387685f7a5e0155d9d55f251ffe4cfb5f4447bcbd380aed3d847", - ".opencode/node_modules/zod/src/v4/core/core.ts": "ab5c675bf8aae560c8f98f20060bae665969ebac1206470b787015897913795b", - ".opencode/node_modules/zod/src/v4/core/doc.ts": "496e63b612809bf031a1964bb2e74c1af4c1a4ba1f8e5cd6f164818ea135205a", - ".opencode/node_modules/zod/src/v4/core/errors.ts": "e31623519621b96a48f3b77c9ee06ed03a56ebe9e3084ec355028ab000d2c2f8", - ".opencode/node_modules/zod/src/v4/core/index.ts": "494aaa47240956231f7de5184f7f0828eaa15ba5c5f59fbda42da7b1348258ce", - ".opencode/node_modules/zod/src/v4/core/json-schema.ts": "42b03730793dc873cce370997da2b1347b00d27effab1793173d7fe7bf422f0c", - ".opencode/node_modules/zod/src/v4/core/parse.ts": "d57d675f81cc59fb20fbc25305f38379af0600bdfe2779343cc2b4dfc5bc97bf", - ".opencode/node_modules/zod/src/v4/core/regexes.ts": "2d2760dcf3fe783459076b0d4abfbc339e6950d49a5546f8a48238424e499f6f", - ".opencode/node_modules/zod/src/v4/core/registries.ts": "6b4495ef30e97ed0d2a596627db079c529778230b30a2349d60211fd7d1b0a62", - ".opencode/node_modules/zod/src/v4/core/schemas.ts": "a2622476b8e40f9437a30b3c43060292a8288134b68ef229aa6197ef5f37fa93", - ".opencode/node_modules/zod/src/v4/core/standard-schema.ts": "a735603c641e08e97ac18157e4a427334ed7026ecc49ddb8abd8e3fc8d841294", - ".opencode/node_modules/zod/src/v4/core/tests/extend.test.ts": "e4705c146e848c2d9a3b4051136e6c38a1b176ce2e223e474de29e227f4fc078", - ".opencode/node_modules/zod/src/v4/core/tests/index.test.ts": "f9a514f603c4350f532d14c5a3fd4748935c4ffb1ab90fa8e8df8671163a7524", - ".opencode/node_modules/zod/src/v4/core/tests/locales/be.test.ts": "d6c1dbf1710130c39d58aa149c5f33cea1e445f05fda5fff6c6e6393358d699d", - ".opencode/node_modules/zod/src/v4/core/tests/locales/en.test.ts": "5bb1540b21984c313c14c4b6e91e14c8b4ec40dd42d0f30bdf24c04543971a29", - ".opencode/node_modules/zod/src/v4/core/tests/locales/es.test.ts": "08d570db1dd2cd1ddd16ecef4c6d9d7e3e293937e255ef6cb960fbabb5ba4a02", - ".opencode/node_modules/zod/src/v4/core/tests/locales/ru.test.ts": "1944b0643c6ef9e194e5256515363d9f475eacb7339ff1745a7629ca38109dd8", - ".opencode/node_modules/zod/src/v4/core/tests/locales/tr.test.ts": "ac6abcbff8137f499ab657115612c32ceb9b0c12903e27f988e8a97d208d2652", - ".opencode/node_modules/zod/src/v4/core/to-json-schema.ts": "207d5dd7d8d3f7a95ba12ce19b1a796e05fb1469e62e03e0163d84378a6d1829", - ".opencode/node_modules/zod/src/v4/core/util.ts": "964d81422e18b3e2645eb4377a24d68a648c938edfc55193efc70b36a35ad437", - ".opencode/node_modules/zod/src/v4/core/versions.ts": "10019f749ef18db03e5e02505ecf581180a183f9838d2571de6d051805906661", - ".opencode/node_modules/zod/src/v4/core/zsf.ts": "975e61b52c8de9fad0a0086952b9ee8a1abbacfa4489659529c53e80dd78094e", - ".opencode/node_modules/zod/src/v4/index.ts": "9f0dfd9a085d4ec23e8a073406300e4bffeb0fd3540b1f395c44b566f27d4d49", - ".opencode/node_modules/zod/src/v4/locales/ar.ts": "94cc038af03f0b0c8b264c1c55f107c93aa4b17c8c39759cac69c69ed5d9663a", - ".opencode/node_modules/zod/src/v4/locales/az.ts": "3d2f271c94a005a23cc831ed1a3a39167832f950048f6845bb5aab510e6b1188", - ".opencode/node_modules/zod/src/v4/locales/be.ts": "58396251205234d11850d154228b5cdbf96f7887f48b19d59ebc59a7c48adee7", - ".opencode/node_modules/zod/src/v4/locales/bg.ts": "480c87b4f81eacca8777c572611425944ebd705401ea922e0e3ee97640783514", - ".opencode/node_modules/zod/src/v4/locales/ca.ts": "d76dc77da7f7d89c3625ed821e333474f964fbfb69ab256007491d483a7fd11c", - ".opencode/node_modules/zod/src/v4/locales/cs.ts": "cef69a633387974e6703cc4eca16c242f889fa3754bc559d9277b705e627a1c9", - ".opencode/node_modules/zod/src/v4/locales/da.ts": "c76ab429619634c96263dbad70aca205e1cb9708f4e5bbe1159d69f7680c3026", - ".opencode/node_modules/zod/src/v4/locales/de.ts": "5abfa7d3751f211b66b833c8183c9614e371f9c987fa7baa60857135fec14d64", - ".opencode/node_modules/zod/src/v4/locales/en.ts": "f6e748ebd8de82a83281883a4180536070c96c2db4a159f23987b0e4a026c69d", - ".opencode/node_modules/zod/src/v4/locales/eo.ts": "3c0a3c8f8be4c6aa1ad0f792d6b420c29aa7c4ca92dd4832078d0b25538505f5", - ".opencode/node_modules/zod/src/v4/locales/es.ts": "e0e85ea85b55e3723a19337d98b33b9eb4d7ab5f1e803b27c1a0ff4b6c6a47fb", - ".opencode/node_modules/zod/src/v4/locales/fa.ts": "60b1d48339a83add0c5ab7b342072ee5112f868c7e0e66714aa7d2e6da005d13", - ".opencode/node_modules/zod/src/v4/locales/fi.ts": "2fe0d68b72a538a5bccffdf8ea27479e7321d26f0555e29cf827d33663c8e05f", - ".opencode/node_modules/zod/src/v4/locales/fr-CA.ts": "c5ad7b6f17961f82dd13cd046532d0c7b668f7e464132b2f1a9e7d5b531e37e9", - ".opencode/node_modules/zod/src/v4/locales/fr.ts": "da7a384cdf216c57feb3bbc3aef63a43b489a55353602888596b9f8b6e4027d7", - ".opencode/node_modules/zod/src/v4/locales/he.ts": "50e7e62b9079e19baeff8890eb60757eaef186527d464baaf93ebb6bb3c2a048", - ".opencode/node_modules/zod/src/v4/locales/hu.ts": "825c84d6fe0d5d1d410609df4efa20db893dd4c22eb93f2aa075f599fdec375b", - ".opencode/node_modules/zod/src/v4/locales/id.ts": "8f61c1779a45459f0606edc8e5e85eba0bc79de96b9ae6d3af9753d77fab5027", - ".opencode/node_modules/zod/src/v4/locales/index.ts": "26965e789e579cd10968fb6ca81caa2ae50c16608185b16dbe84eb00a652b0cf", - ".opencode/node_modules/zod/src/v4/locales/is.ts": "59d68dc8bb4c4655c301b0d875f27d0c9f9a8af72b8acb85463b6205de0b1cc1", - ".opencode/node_modules/zod/src/v4/locales/it.ts": "28f0f40702fd7d91461ccbb466d1d1ab3fdc3a702707e1ed356ff5e51453345d", - ".opencode/node_modules/zod/src/v4/locales/ja.ts": "87383f9f7101075eb087968f0d12084cdecf23a8e4af0b91ebf78ad8b788effb", - ".opencode/node_modules/zod/src/v4/locales/ka.ts": "cfe200ef72f4a56b05a6554d1af37d9bd00fe4c3413b0fd3a5cf97ed8a35a9c5", - ".opencode/node_modules/zod/src/v4/locales/kh.ts": "b55c1375cf0fbb40248ccc5d940074ef1448d38902ef0c8017d7ebda43649efb", - ".opencode/node_modules/zod/src/v4/locales/km.ts": "f68820276449ef124e35fa38a1b1d784dc5b4411bf9ee7127ffdaae3393882d8", - ".opencode/node_modules/zod/src/v4/locales/ko.ts": "ca90a3c670657d1f59253534bbc5ca56b73549d9a500229c605f269650802361", - ".opencode/node_modules/zod/src/v4/locales/lt.ts": "c8008b07f65ee7d3edfc61521dea423e1c4f746ef21e607cea0a3e8f4c6c40bd", - ".opencode/node_modules/zod/src/v4/locales/mk.ts": "536ccf5033c0153b424cafff555b7d4c6044b3bcc6ec73d65bca27c871d5be6b", - ".opencode/node_modules/zod/src/v4/locales/ms.ts": "689764fc38f037e660712af64ad285855c545762009cb7a8ad880ec6dfdf99df", - ".opencode/node_modules/zod/src/v4/locales/nl.ts": "cd4bf4207ff3a00fecb01b8d3cf56fa704d240b13f57527a5089f585aa657dc2", - ".opencode/node_modules/zod/src/v4/locales/no.ts": "09548cb18fceb85bb5d76cd618b8a822db9d170290bd51bc39806620a39ecf88", - ".opencode/node_modules/zod/src/v4/locales/ota.ts": "24bf7362b0b056f4fda7638b473401ecf218d3741f5face257735194a5328dee", - ".opencode/node_modules/zod/src/v4/locales/pl.ts": "09fe366cf11786b78a03c87c2224bac3358e629c944a232bbc32dc503ed19c47", - ".opencode/node_modules/zod/src/v4/locales/ps.ts": "a9c963e2339118f0c19b77e0743a8cf910c6edef62f189fb0a3643e336c2ffec", - ".opencode/node_modules/zod/src/v4/locales/pt.ts": "42f35b9c59c2bba49922ad3ada63b0408fe8de9b88807d6858af890ec255b622", - ".opencode/node_modules/zod/src/v4/locales/ru.ts": "99fff5403ea8c01263675e99ba7b80249fe3aa00dac829d1a20f8e26331268ac", - ".opencode/node_modules/zod/src/v4/locales/sl.ts": "a381cd500472dae38b617c0b32a4617b89ad9475218fd87bff0192f528ae787f", - ".opencode/node_modules/zod/src/v4/locales/sv.ts": "67aee549129f6f460bd500278347e13c96322a35f0c12c76aa3632cc3dbe724d", - ".opencode/node_modules/zod/src/v4/locales/ta.ts": "db63f8cc63fb88be90e822a0803e9c4b03bf96935182eb1c86229b4cc9300221", - ".opencode/node_modules/zod/src/v4/locales/th.ts": "b1c1e68c1b1d81fe26ba945b6cbec2dc2c4138c94b243b6827dd6a156ac7181e", - ".opencode/node_modules/zod/src/v4/locales/tr.ts": "57bfe52d1d8554cce8a2df4953cffea1fd69e59cd539fc0f8b54210abd1288ad", - ".opencode/node_modules/zod/src/v4/locales/ua.ts": "46a90664b22f2b079c7ee1d36c957df8b39442717c3df68c8bbb6a0df20eadce", - ".opencode/node_modules/zod/src/v4/locales/uk.ts": "db64683bb605b98c1a75e64dce0debf3717d1c44d8d196091975d7cf0c348e4a", - ".opencode/node_modules/zod/src/v4/locales/ur.ts": "1a1f050550cfcefb762ca6d9f2c0f46550613a5ec4ec835c5786f40c8ae8daf9", - ".opencode/node_modules/zod/src/v4/locales/vi.ts": "c417610d93565c91c8e730fb510d56baa8024d6757f21be03c54ad31d662f5e0", - ".opencode/node_modules/zod/src/v4/locales/yo.ts": "e8ce5e170c20aff2bb4e02a0708f68be021c1a3a4cbf4f41597e801c97b55f02", - ".opencode/node_modules/zod/src/v4/locales/zh-CN.ts": "a3d8b1bc3758099a6385f175f312f28505d15178d65557fd49db8bd686069cc0", - ".opencode/node_modules/zod/src/v4/locales/zh-TW.ts": "28b342ec5c1e2de8d2525893f4ee81407635fc500fa6560a74209513730eb8ae", - ".opencode/node_modules/zod/src/v4/mini/checks.ts": "c4f16c0e97aa6f0385c0bd147a0438f522343797d72f4cf0cb466213e0b74635", - ".opencode/node_modules/zod/src/v4/mini/coerce.ts": "e80b80452e5c3a248d3deb1044f14d9810d2e86642f943c10999f47116f226fc", - ".opencode/node_modules/zod/src/v4/mini/external.ts": "5ead78dfebae73aa37f3a9e4778f5558582e7caa442bbebe22587dcb00ff0da2", - ".opencode/node_modules/zod/src/v4/mini/index.ts": "455d93e660b61feec18cd4c31e2f394627546ae8ab31f9bacdab6318112ac31c", - ".opencode/node_modules/zod/src/v4/mini/iso.ts": "2d1a5e84a00aca5c3f1b2cebeb1b9a12ccfb7b38c15c96219571a20d72e39a03", - ".opencode/node_modules/zod/src/v4/mini/parse.ts": "f56f7674e03f0ff117dc804c6cb50361d9dc6ad33a12277009ec753ff99ccf0e", - ".opencode/node_modules/zod/src/v4/mini/schemas.ts": "3927bad5c4ac8c77fced35055da583975ad8e8dcdfe8baf75b4550f381bfe553", - ".opencode/node_modules/zod/src/v4/mini/tests/assignability.test.ts": "bf52ca9e89d34717a46dce06d03f2000027009ceaa0798dfa3bf8e47b5d537f6", - ".opencode/node_modules/zod/src/v4/mini/tests/brand.test.ts": "72b40e92c5ad4442024edd3681afbdf986476efff01929efb427b82c5b124710", - ".opencode/node_modules/zod/src/v4/mini/tests/checks.test.ts": "14c8c7c6fba2bc5a06a5f7fbfdf7774bf7a7135c233b47887cb306e5b7f71aa3", - ".opencode/node_modules/zod/src/v4/mini/tests/codec.test.ts": "e9d0836fc48f37a1e476b8a642140d57faecc0a885aefd4828c2ff6324750eec", - ".opencode/node_modules/zod/src/v4/mini/tests/computed.test.ts": "675fc1a036b656b796959fd8f90d6d3d5655c3f6788769e798f56447fba02d58", - ".opencode/node_modules/zod/src/v4/mini/tests/error.test.ts": "72d8e9e476dc934d09d5311fff1823b7f74f169ce30b696cf6870e6b514b1cf4", - ".opencode/node_modules/zod/src/v4/mini/tests/functions.test.ts": "438082fe06c6a89aa9f14ca53acd015be69205fb9aa26e8bb12d6494ee14005e", - ".opencode/node_modules/zod/src/v4/mini/tests/index.test.ts": "7ac070d07667b540db4ff4d87cd0c65574afa16df1e46ac459e720ae1cc08928", - ".opencode/node_modules/zod/src/v4/mini/tests/number.test.ts": "5e546f7b16d97b2a30fea914a900fa48d0de173ee58eb8a3bde04e1783e7c7e5", - ".opencode/node_modules/zod/src/v4/mini/tests/object.test.ts": "8806c1f526663a5a17d2c719dec7779b8070c9f7e8993d5787a303c862a61b21", - ".opencode/node_modules/zod/src/v4/mini/tests/prototypes.test.ts": "53d57aa1c4401cbe1958be7521b65c72ff55cf4993b1ae57543f4138f2c1e2e1", - ".opencode/node_modules/zod/src/v4/mini/tests/recursive-types.test.ts": "3797e364c9c68f42efca56dd1bb70209c70f1d16c3628d23ac302b2667d1e65c", - ".opencode/node_modules/zod/src/v4/mini/tests/string.test.ts": "38d0b96715d3d7472b8defb4e4e9ae2171b96a26f287f810eb30b52853c8db2b", - ".opencode/node_modules/zod/src/v4-mini/index.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/v3/ZodError.cjs": "88b9cdd780a91656965e07b94c86c0e4729f97923d45cc9d7944b0973a462503", - ".opencode/node_modules/zod/v3/ZodError.d.cts": "206e73f49f16633113787cc651dc03dc900379395dfa02ab1ef4c9cbbcd5adc2", - ".opencode/node_modules/zod/v3/ZodError.d.ts": "98ee86deadaf36f67986bae4987cfa4b7dfa135ab03706362187d5d00f6282a7", - ".opencode/node_modules/zod/v3/ZodError.js": "f5ac9e86b92e201d41e294d7aa35986a2aa28829e1bcc8ac25f7c8dce0674b99", - ".opencode/node_modules/zod/v3/errors.cjs": "bd32dbc86faeec2de1cc8e42ee3b29af2cbeb24fb5e3b57d7f056bb09cf8d4bd", - ".opencode/node_modules/zod/v3/errors.d.cts": "e3498cf5e428e6c6b9e97bd88736f26d6cf147dedbfa5a8ad3ed8e05e059af8a", - ".opencode/node_modules/zod/v3/errors.d.ts": "8e71e53b02c152a38af6aec45e288cc65bede077b92b9b43b3cb54a37978bb33", - ".opencode/node_modules/zod/v3/errors.js": "d40831f478288d76e82bbbcc3b7e95c8513b6e76471f2d49a37c979bc57d492d", - ".opencode/node_modules/zod/v3/external.cjs": "224ff76e204434ec469f71dbba41d475db1ea873d3f3a5c26d75ab3a9c9f5f11", - ".opencode/node_modules/zod/v3/external.d.cts": "a9ebb67d6bbead6044b43714b50dcb77b8f7541ffe803046fdec1714c1eba206", - ".opencode/node_modules/zod/v3/external.d.ts": "5d26d2e47e2352def36f89a3e8bf8581da22b7f857e07ef3114cd52cf4813445", - ".opencode/node_modules/zod/v3/external.js": "5d26d2e47e2352def36f89a3e8bf8581da22b7f857e07ef3114cd52cf4813445", - ".opencode/node_modules/zod/v3/helpers/enumUtil.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v3/helpers/enumUtil.d.cts": "f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c", - ".opencode/node_modules/zod/v3/helpers/enumUtil.d.ts": "f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c", - ".opencode/node_modules/zod/v3/helpers/enumUtil.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v3/helpers/errorUtil.cjs": "9c9e5dec7d1e9fce6a967735adf0333abd44d5e94a0225537c60ceed0c4f606a", - ".opencode/node_modules/zod/v3/helpers/errorUtil.d.cts": "e4b03ddcf8563b1c0aee782a185286ed85a255ce8a30df8453aade2188bbc904", - ".opencode/node_modules/zod/v3/helpers/errorUtil.d.ts": "e4b03ddcf8563b1c0aee782a185286ed85a255ce8a30df8453aade2188bbc904", - ".opencode/node_modules/zod/v3/helpers/errorUtil.js": "68589c0cf44c8b4ad19b77418f53caad2cd1dbc8d714f526e1876ebdee4828c5", - ".opencode/node_modules/zod/v3/helpers/parseUtil.cjs": "40aaa18765f13ae1a933c6f215ecceb6d3afee89eaa23bb080997e08c30a5b00", - ".opencode/node_modules/zod/v3/helpers/parseUtil.d.cts": "dba3f34531fd9b1b6e072928b6f885aa4d28dd6789cbd0e93563d43f4b62da53", - ".opencode/node_modules/zod/v3/helpers/parseUtil.d.ts": "754a9396b14ca3a4241591afb4edc644b293ccc8a3397f49be4dfd520c08acb3", - ".opencode/node_modules/zod/v3/helpers/parseUtil.js": "b57484013bb360bac2e334d58fcd5f6cbfa63ffcc7d8ff3dbbb3380ca659b956", - ".opencode/node_modules/zod/v3/helpers/partialUtil.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v3/helpers/partialUtil.d.cts": "2329d90062487e1eaca87b5e06abcbbeeecf80a82f65f949fd332cfcf824b87b", - ".opencode/node_modules/zod/v3/helpers/partialUtil.d.ts": "de2316e90fc6d379d83002f04ad9698bc1e5285b4d52779778f454dd12ce9f44", - ".opencode/node_modules/zod/v3/helpers/partialUtil.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v3/helpers/typeAliases.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v3/helpers/typeAliases.d.cts": "d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5", - ".opencode/node_modules/zod/v3/helpers/typeAliases.d.ts": "d3cfde44f8089768ebb08098c96d01ca260b88bccf238d55eee93f1c620ff5a5", - ".opencode/node_modules/zod/v3/helpers/typeAliases.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v3/helpers/util.cjs": "9b75d4955de684d11371054bbf8e4d747cb1ad7284ae1eda85d4c188b84c47fc", - ".opencode/node_modules/zod/v3/helpers/util.d.cts": "293eadad9dead44c6fd1db6de552663c33f215c55a1bfa2802a1bceed88ff0ec", - ".opencode/node_modules/zod/v3/helpers/util.d.ts": "293eadad9dead44c6fd1db6de552663c33f215c55a1bfa2802a1bceed88ff0ec", - ".opencode/node_modules/zod/v3/helpers/util.js": "906ef685e5853d496e293ee8f8a2bb8b1ed2d078a1bcdfedb2dfd61d75b6330c", - ".opencode/node_modules/zod/v3/index.cjs": "d58a1fea61e0f0bccdb2f4fd3b7b098adcfddb44f036302c4d2c4e173540a292", - ".opencode/node_modules/zod/v3/index.d.cts": "833e92c058d033cde3f29a6c7603f517001d1ddd8020bc94d2067a3bc69b2a8e", - ".opencode/node_modules/zod/v3/index.d.ts": "3db2efd285e7328d8014b54a7fce3f4861ebcdc655df40517092ed0050983617", - ".opencode/node_modules/zod/v3/index.js": "3db2efd285e7328d8014b54a7fce3f4861ebcdc655df40517092ed0050983617", - ".opencode/node_modules/zod/v3/locales/en.cjs": "5fe55414e39de7fff8ec19983adc1421c238b679f8aa6b9d3311c7f9f12a21f3", - ".opencode/node_modules/zod/v3/locales/en.d.cts": "fec412ded391a7239ef58f455278154b62939370309c1fed322293d98c8796a6", - ".opencode/node_modules/zod/v3/locales/en.d.ts": "c25ce98cca43a3bfa885862044be0d59557be4ecd06989b2001a83dcf69620fd", - ".opencode/node_modules/zod/v3/locales/en.js": "51a6dcc5d4bd52a64da127c012c6c963395c1d9a343ad75d4a8eca662fc8b205", - ".opencode/node_modules/zod/v3/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v3/standard-schema.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v3/standard-schema.d.cts": "25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262", - ".opencode/node_modules/zod/v3/standard-schema.d.ts": "25b3f581e12ede11e5739f57a86e8668fbc0124f6649506def306cad2c59d262", - ".opencode/node_modules/zod/v3/standard-schema.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v3/types.cjs": "9c7618e57545b5e44faecbcfc3bb00eed67ca46efbb11bd1b6ad3a1d69de95eb", - ".opencode/node_modules/zod/v3/types.d.cts": "93c3e73824ad57f98fd23b39335dbdae2db0bd98199b0dc0b9ccc60bf3c5134a", - ".opencode/node_modules/zod/v3/types.d.ts": "7941f67bd576a61bb48f46cf55c5eb0292c8ee3eb2db621815eb1d02b1aa09fb", - ".opencode/node_modules/zod/v3/types.js": "2faaeed1150d0d7126f6aa85b01594fabdd06771a0b7cb5df2d13f725e175ef1", - ".opencode/node_modules/zod/v4/classic/checks.cjs": "a3ec0b1f2ce36c04dff99eced43eef15bd378c50d0113c723c20ad89ac535fae", - ".opencode/node_modules/zod/v4/classic/checks.d.cts": "7b9496d2e1664155c3c293e1fbbe2aba288614163c88cb81ed6061905924b8f9", - ".opencode/node_modules/zod/v4/classic/checks.d.ts": "3a8449ac50228a454f709360f32277f3670ec62e7aabf1e6710538dd3353b2b4", - ".opencode/node_modules/zod/v4/classic/checks.js": "f20eb2e1153f7b80a52d30261dc0b90ad4a5adcfccc9b150d3998afdaea7f9d3", - ".opencode/node_modules/zod/v4/classic/coerce.cjs": "d25b07eead67ff339c3e2a6d5b7364647e11f57fff3e82055d49575304dce057", - ".opencode/node_modules/zod/v4/classic/coerce.d.cts": "e818471014c77c103330aee11f00a7a00b37b35500b53ea6f337aefacd6174c9", - ".opencode/node_modules/zod/v4/classic/coerce.d.ts": "1f4a8bb5e841d3a1510d12a84640ee327f5a4b55484e8f16e2b285d54eb19924", - ".opencode/node_modules/zod/v4/classic/coerce.js": "fb2efe3b6eacc475c77cbb571b5fb8650d92a10f316dc869e49b722973d83c3c", - ".opencode/node_modules/zod/v4/classic/compat.cjs": "dcd3c3b05fbc82ed0241ec7e015071a528a1e2e8025e96259be932e7340608e7", - ".opencode/node_modules/zod/v4/classic/compat.d.cts": "e27451b24234dfed45f6cf22112a04955183a99c42a2691fb4936d63cfe42761", - ".opencode/node_modules/zod/v4/classic/compat.d.ts": "2197ba08e54182521b3cf4fec0a875a9ff76235849875a42acd19f7e8a0c09b8", - ".opencode/node_modules/zod/v4/classic/compat.js": "0e948b101771cc0e7b753e54b3f5b486929d3db9bd4c09d98fe4ce86271bcf7b", - ".opencode/node_modules/zod/v4/classic/errors.cjs": "f704ed9292288d685a33123242399179eba77f1190299bc83042b434c1ae8cd6", - ".opencode/node_modules/zod/v4/classic/errors.d.cts": "6042774c61ece4ba77b3bf375f15942eb054675b7957882a00c22c0e4fe5865c", - ".opencode/node_modules/zod/v4/classic/errors.d.ts": "03be953961283699e72d526e1bff478be5af0840094b7e37d90a15ffd1426f64", - ".opencode/node_modules/zod/v4/classic/errors.js": "fd807f79ded4f88928ab8205de1922901a4d3a8c9b93d072e1ab8c0a44a95df5", - ".opencode/node_modules/zod/v4/classic/external.cjs": "9df0ca0fe9053f1fb7cda1a7c8cc952c6d3d49fbec811fb3bb63d2a845bef211", - ".opencode/node_modules/zod/v4/classic/external.d.cts": "2fbc91ba70096f93f57e22d1f0af22b707dbb3f9f5692cc4f1200861d3b75d88", - ".opencode/node_modules/zod/v4/classic/external.d.ts": "8bae377629b666ce6e171351d3bf9cb1e041dc69409e54f84c366921384ae1cd", - ".opencode/node_modules/zod/v4/classic/external.js": "f4c3233385332f4fba37de6c0e38ccd8c7ba6fe315a58e9777571a5cfd852815", - ".opencode/node_modules/zod/v4/classic/index.cjs": "d58a1fea61e0f0bccdb2f4fd3b7b098adcfddb44f036302c4d2c4e173540a292", - ".opencode/node_modules/zod/v4/classic/index.d.cts": "d8bc0c5487582c6d887c32c92d8b4ffb23310146fcb1d82adf4b15c77f57c4ac", - ".opencode/node_modules/zod/v4/classic/index.d.ts": "d49030b9a324bab9bcf9f663a70298391b0f5a25328409174d86617512bf3037", - ".opencode/node_modules/zod/v4/classic/index.js": "d49030b9a324bab9bcf9f663a70298391b0f5a25328409174d86617512bf3037", - ".opencode/node_modules/zod/v4/classic/iso.cjs": "f5d840d941b87a868da63fba9e4eb5917cadd87100af4940113f0ddd75dd9043", - ".opencode/node_modules/zod/v4/classic/iso.d.cts": "58d65a2803c3b6629b0e18c8bf1bc883a686fcf0333230dd0151ab6e85b74307", - ".opencode/node_modules/zod/v4/classic/iso.d.ts": "50f3d54845c748597afd9fe6a474862ab957f61f274d5fcba6509f5e4174c988", - ".opencode/node_modules/zod/v4/classic/iso.js": "3cc5e6b4da086b8ffc7ee3188c69e4d3c945b368092e78d2299b5321182752c5", - ".opencode/node_modules/zod/v4/classic/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4/classic/parse.cjs": "0fb97825ed1c15a1f6a238df9506e23f2e720edeae4774aa46a60ce7b2a75319", - ".opencode/node_modules/zod/v4/classic/parse.d.cts": "5a3bd57ed7a9d9afef74c75f77fce79ba3c786401af9810cdf45907c4e93f30e", - ".opencode/node_modules/zod/v4/classic/parse.d.ts": "07831f097149800006656666f2d1ea5d8e26caf8b40a2dfe951ed941eb2650ce", - ".opencode/node_modules/zod/v4/classic/parse.js": "14cbcc416ab9169abc2cde97af77da54ca769684eac1e03a3753c16ea4a7cd95", - ".opencode/node_modules/zod/v4/classic/schemas.cjs": "3b61b9192a59d6b28b3ea0c832a7e4a76315b92b71b432f497110ebc74193480", - ".opencode/node_modules/zod/v4/classic/schemas.d.cts": "8610f5dc475d74c4b095aafa0c191548bfd43f65802e6da54b5e526202b8cfe0", - ".opencode/node_modules/zod/v4/classic/schemas.d.ts": "0d6d068c3e5309ebef75a4d17eaf5af0b08debc3987f2f89e29f08e61ad26a4a", - ".opencode/node_modules/zod/v4/classic/schemas.js": "e185cecdafd77117e8081811ef006dd0bd478ebeef3a86b458a258d27b34eafe", - ".opencode/node_modules/zod/v4/core/api.cjs": "89921ccd93f2d360deb9c5ef52856cdf0d38fbca5d6b081079d45a40368d5aa0", - ".opencode/node_modules/zod/v4/core/api.d.cts": "56ccc6238510b913f5e6c21afdc447632873f76748d0b30a87cb313b42f1c196", - ".opencode/node_modules/zod/v4/core/api.d.ts": "2bfc055ab5b626d39e4b3e055fadf0622ad7e958316dccc9b1a52d3f40129f4e", - ".opencode/node_modules/zod/v4/core/api.js": "8722c715be582089faa0b96816db906a913697e8777ec247ed11bd2eed2e9fd7", - ".opencode/node_modules/zod/v4/core/checks.cjs": "1c60ee0c8eea5cdfd8439981ed87daa60a63cb5bbd25e2c3419f2ba1c7b65235", - ".opencode/node_modules/zod/v4/core/checks.d.cts": "8d67b13da77316a8a2fabc21d340866ddf8a4b99e76a6c951cc45189142df652", - ".opencode/node_modules/zod/v4/core/checks.d.ts": "3b22881e19fba860247cde1f60807cca7ce44ad78814fe5789316c3cc16208db", - ".opencode/node_modules/zod/v4/core/checks.js": "6d5342cbfc770541ef5f010c7ac534529012036c6482af1c3e57a1afb78c1828", - ".opencode/node_modules/zod/v4/core/core.cjs": "3238fe404d2e0e6e53d221ad0408567f31689c2e204db1007cfd2a4e0513587b", - ".opencode/node_modules/zod/v4/core/core.d.cts": "21360500b20e0ec570f26f1cbb388c155ede043698970f316969840da4f16465", - ".opencode/node_modules/zod/v4/core/core.d.ts": "f0ebd1c2e1708e7e7d105883430d3ee1d856560e26b346469ff584ec68859e9e", - ".opencode/node_modules/zod/v4/core/core.js": "a1f78ff62ae173c6a722b92b394075f3075c7f7d731bc443827aec4f30302d92", - ".opencode/node_modules/zod/v4/core/doc.cjs": "ae6c5bfe9570d30c119cd6bc9dff33f6956858b4fa6887b2b1f9680f11d1b65d", - ".opencode/node_modules/zod/v4/core/doc.d.cts": "add0ce7b77ba5b308492fa68f77f24d1ed1d9148534bdf05ac17c30763fc1a79", - ".opencode/node_modules/zod/v4/core/doc.d.ts": "add0ce7b77ba5b308492fa68f77f24d1ed1d9148534bdf05ac17c30763fc1a79", - ".opencode/node_modules/zod/v4/core/doc.js": "e084bbcc536746a8942fd33b08afe4db345554b3a0383114f1dca95261c958d9", - ".opencode/node_modules/zod/v4/core/errors.cjs": "f919e2356deaef1fd7555e3e15fb3cb8d7594dd8391ed9662b3a1ef6fcb5acda", - ".opencode/node_modules/zod/v4/core/errors.d.cts": "7952419455ca298776db0005b9b5b75571d484d526a29bfbdf041652213bce6f", - ".opencode/node_modules/zod/v4/core/errors.d.ts": "25dac9cd43c711463382917a7e8997d4a34871f864ba8ab0f53df85d03f90a38", - ".opencode/node_modules/zod/v4/core/errors.js": "66c310c429dd28309b17a5fe7310b99ae7e347fd502dc62ec628ebcc900c4e1b", - ".opencode/node_modules/zod/v4/core/index.cjs": "6f688ca5ed36ee7ca8c69f6169f0b06589b6698e743bb8427cf02150969159ea", - ".opencode/node_modules/zod/v4/core/index.d.cts": "d91805544905a40fbd639ba1b85f65dc13d6996a07034848d634aa9edb63479e", - ".opencode/node_modules/zod/v4/core/index.d.ts": "494aaa47240956231f7de5184f7f0828eaa15ba5c5f59fbda42da7b1348258ce", - ".opencode/node_modules/zod/v4/core/index.js": "494aaa47240956231f7de5184f7f0828eaa15ba5c5f59fbda42da7b1348258ce", - ".opencode/node_modules/zod/v4/core/json-schema.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v4/core/json-schema.d.cts": "c1a2e05eb6d7ca8d7e4a7f4c93ccf0c2857e842a64c98eaee4d85841ee9855e6", - ".opencode/node_modules/zod/v4/core/json-schema.d.ts": "c1a2e05eb6d7ca8d7e4a7f4c93ccf0c2857e842a64c98eaee4d85841ee9855e6", - ".opencode/node_modules/zod/v4/core/json-schema.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v4/core/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4/core/parse.cjs": "ea957a211bf66fd211842cfd2bcf4bea43aa27c643dc44da012ca63739672ef6", - ".opencode/node_modules/zod/v4/core/parse.d.cts": "3a819c2928ee06bbcc84e2797fd3558ae2ebb7e0ed8d87f71732fb2e2acc87b4", - ".opencode/node_modules/zod/v4/core/parse.d.ts": "7007626fc0d98e012e02caf70ae36647d7288b06c9121b51b20af592ebec3d53", - ".opencode/node_modules/zod/v4/core/parse.js": "a58b9c7be78e29d71f969f01807fe5f9be1f68d222a0f60c25e32e24f3b6639c", - ".opencode/node_modules/zod/v4/core/regexes.cjs": "06962f6496478a089281df50a84c288f703ac00ee8499fe62cb5f4d3e038b832", - ".opencode/node_modules/zod/v4/core/regexes.d.cts": "1765e61249cb44bf5064d42bfa06956455bbc74dc05f074d5727e8962592c920", - ".opencode/node_modules/zod/v4/core/regexes.d.ts": "1765e61249cb44bf5064d42bfa06956455bbc74dc05f074d5727e8962592c920", - ".opencode/node_modules/zod/v4/core/regexes.js": "f2aee057ccd1e081bbec2d1bf9d3ae57e3df72a13e056ff1cd48829158983fad", - ".opencode/node_modules/zod/v4/core/registries.cjs": "e58ac2fb52c7a0e48857787fc92b382afdd1adb6dd03736403cf38d7782677a2", - ".opencode/node_modules/zod/v4/core/registries.d.cts": "6e5857f38aa297a859cab4ec891408659218a5a2610cd317b6dcbef9979459cc", - ".opencode/node_modules/zod/v4/core/registries.d.ts": "7576d06c1b52d6e3dbd8ea3c53778b5a8d8065fbff3678db495c6166a698eda3", - ".opencode/node_modules/zod/v4/core/registries.js": "cec90d369f7206f701fa3f540bc92855a8c9145d44e4d8642053645e73f70002", - ".opencode/node_modules/zod/v4/core/schemas.cjs": "b9ea344ee137a4715c1c827ef133c5b2e344a5dbe1a19170a3de0bec296bafd7", - ".opencode/node_modules/zod/v4/core/schemas.d.cts": "d9faf4a343833207c6c5cd2322fb6771b56dc1c8ece975072e85227c2d326bc2", - ".opencode/node_modules/zod/v4/core/schemas.d.ts": "c74b22865b9e58e6756484d86cd06e58d25987fbd5c9b8fa95a658e4fce4f7cb", - ".opencode/node_modules/zod/v4/core/schemas.js": "73eb7d7474af05d191b86d654fc551fb7e0aca02ff87f6d56dc72cea9218b91f", - ".opencode/node_modules/zod/v4/core/standard-schema.cjs": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230", - ".opencode/node_modules/zod/v4/core/standard-schema.d.cts": "309ebd217636d68cf8784cbc3272c16fb94fb8e969e18b6fe88c35200340aef1", - ".opencode/node_modules/zod/v4/core/standard-schema.d.ts": "309ebd217636d68cf8784cbc3272c16fb94fb8e969e18b6fe88c35200340aef1", - ".opencode/node_modules/zod/v4/core/standard-schema.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", - ".opencode/node_modules/zod/v4/core/to-json-schema.cjs": "966cf5cd00087863083890715da106793c4773707924ef96469117f463371b0a", - ".opencode/node_modules/zod/v4/core/to-json-schema.d.cts": "85021a58f728318a9c83977a8a3a09196dcfc61345e0b8bbbb39422c1594f36b", - ".opencode/node_modules/zod/v4/core/to-json-schema.d.ts": "bdc0676fe9b2011992b6abccd1c15a565d2c201412f30c906697422f591d805f", - ".opencode/node_modules/zod/v4/core/to-json-schema.js": "c5974e08383d8de3daac020b49b5d62cda094231d6471bba1e1e1770ab635ce1", - ".opencode/node_modules/zod/v4/core/util.cjs": "6851a80c1cf0dd4859c7321d2d54cad3d3f658db1c8156a74a16807caa31ec1e", - ".opencode/node_modules/zod/v4/core/util.d.cts": "f987c74a4b4baf361afbf22a16d230ee490d662f9aa2066853bb7ebbb8611355", - ".opencode/node_modules/zod/v4/core/util.d.ts": "7062f2d8d0544f85f4c7398d2b08109e3a8c6fb1aae5b4b32b98567f5a9023fe", - ".opencode/node_modules/zod/v4/core/util.js": "d99bf396f177afe547703eff48b9746c360bfc2a2708adfd4a43e73768107b6c", - ".opencode/node_modules/zod/v4/core/versions.cjs": "1206ffbacc4e789fcd8f5d52566b1bfcc5baae49876c419f9bcd51a0e2d323b6", - ".opencode/node_modules/zod/v4/core/versions.d.cts": "1ff91526fcdd634148c655ef86e912a273ce6a0239e2505701561f086678262b", - ".opencode/node_modules/zod/v4/core/versions.d.ts": "1ff91526fcdd634148c655ef86e912a273ce6a0239e2505701561f086678262b", - ".opencode/node_modules/zod/v4/core/versions.js": "bfacecc9f831ae6cc9fc756424f94fae36d4ed196c4a4cd3a34507e38fbeec6c", - ".opencode/node_modules/zod/v4/index.cjs": "35934fbd99d4ca1d46932dfcddb6071c203b5c980862cf58e254998c49e1e5b8", - ".opencode/node_modules/zod/v4/index.d.cts": "8cb31102790372bebfd78dd56d6752913b0f3e2cefbeb08375acd9f5ba737155", - ".opencode/node_modules/zod/v4/index.d.ts": "a4b634bb8c97cc700dbf165f3bb0095ec669042da72eaf28a7c5e2ddd98169ce", - ".opencode/node_modules/zod/v4/index.js": "a4b634bb8c97cc700dbf165f3bb0095ec669042da72eaf28a7c5e2ddd98169ce", - ".opencode/node_modules/zod/v4/locales/ar.cjs": "25d2ec93b17667a2b8d02fdb170b275c853fefcecdcf7ac95b46877c7048a16e", - ".opencode/node_modules/zod/v4/locales/ar.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ar.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ar.js": "0c5613adab2e098069d88056df27f0bc361028d94d6af8a6dc46dda381d2d82e", - ".opencode/node_modules/zod/v4/locales/az.cjs": "06ba53956425e1718ce4c9d818a06c04bc999f8fd00f1981c7c74316d4e99f5b", - ".opencode/node_modules/zod/v4/locales/az.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/az.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/az.js": "cfb3ebe34580912dfb02fd3c22dcc80213f29d0ad99062f30c0cf56a9f511258", - ".opencode/node_modules/zod/v4/locales/be.cjs": "8269e9059c1575b909f559f39e49e2e226f1c6eb329c6f1c083c879cb234de09", - ".opencode/node_modules/zod/v4/locales/be.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/be.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/be.js": "b33d29e5b0f20b0520a8a05bbfcb1d96a43c205abac0f852d7e5e3eefc8450be", - ".opencode/node_modules/zod/v4/locales/bg.cjs": "3658e48af9e2858ad89158e6af06a188e57de14aa7cddc6a4903fbd08adcba40", - ".opencode/node_modules/zod/v4/locales/bg.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/bg.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/bg.js": "cdf9a0d1e081b22d62dc927a8ec1bcc850261bd7b56a8dc84d5e94a306ac0db9", - ".opencode/node_modules/zod/v4/locales/ca.cjs": "9adc2f9540839430681dcbf9b0bcc1b78b2d0a94dfc3eac170df43d9e5a577d1", - ".opencode/node_modules/zod/v4/locales/ca.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ca.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ca.js": "e3f8525c1ed6a8c1fa6ae9e2871e1a7bd2aec67c89764d51ef920553042337e3", - ".opencode/node_modules/zod/v4/locales/cs.cjs": "c42ea716c2809e37666fb31185718ffb62edb3e458996b30d2fa91df178a0643", - ".opencode/node_modules/zod/v4/locales/cs.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/cs.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/cs.js": "ca1527d68c849ff85e65b8697688719dca8df431724d8f3a64831439017fc5ef", - ".opencode/node_modules/zod/v4/locales/da.cjs": "6f090b1e6a8e61cafadfe9a8395e4fd0744c2d6a47129843afb40a0638064085", - ".opencode/node_modules/zod/v4/locales/da.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/da.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/da.js": "3187af9e566fdd251d09f3d73435a18025ae35ad3d46aa37272b7685a659ec3f", - ".opencode/node_modules/zod/v4/locales/de.cjs": "f08c911edf7e22b55c9bdef7235eaea4d4c36e6467744705652651cb140f7675", - ".opencode/node_modules/zod/v4/locales/de.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/de.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/de.js": "5b416bd06886b8eeedf0de4f162725636cf4b4af286a439fd4fca6bfae26b717", - ".opencode/node_modules/zod/v4/locales/en.cjs": "05a540ad5c222aab1cb15d99fc2c0984140c27f9b761501fc0641a2d582be63a", - ".opencode/node_modules/zod/v4/locales/en.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/en.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/en.js": "e9c8a8c547a75b2c7e87a22387c21e8ae0592f849c7ebd2c92df9d23c6fb9f44", - ".opencode/node_modules/zod/v4/locales/eo.cjs": "12df106b30a0f2e9d2c4256a5b39a29f7fd7d0f0e73ec573f06ddd8c1f3df898", - ".opencode/node_modules/zod/v4/locales/eo.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/eo.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/eo.js": "bbe19901c076716e9fa04400aa7020d4f15c7e574d31b5c351132f3ffc75ca55", - ".opencode/node_modules/zod/v4/locales/es.cjs": "95aee7c4ee66d1b4b8d36b714175ef58fbd1eb32ee0f28b3e88a066ef35d9248", - ".opencode/node_modules/zod/v4/locales/es.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/es.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/es.js": "d8c2c16a0bfa3159692d76bcba304f21197e3daffffbb1c5bd95ae0b347e6fc9", - ".opencode/node_modules/zod/v4/locales/fa.cjs": "5008aabcc546f9175ef10109475d32266e42d608c4dee9ca8d098f3a960d2560", - ".opencode/node_modules/zod/v4/locales/fa.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/fa.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/fa.js": "a7e02bbae31de8be066017ace75dc6651d22a7a9cb8fe98e0a6075d027a902db", - ".opencode/node_modules/zod/v4/locales/fi.cjs": "9dfc1de66f375928dc558cb705e93c0b9a834a3187de88e2b28dea536582e648", - ".opencode/node_modules/zod/v4/locales/fi.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/fi.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/fi.js": "6e4fe271feac955c71c8597a47c4467191e8146ad548d8d3dcd9564e6b5a86cd", - ".opencode/node_modules/zod/v4/locales/fr-CA.cjs": "510114ebc76a692a7bb944afacae441d577c8d0952444b1616c8575dfb63aa68", - ".opencode/node_modules/zod/v4/locales/fr-CA.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/fr-CA.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/fr-CA.js": "a2d5178451c0bd924d1e44cdbfa87109a3ababaf749eb1bc9db00039a2aa4cf4", - ".opencode/node_modules/zod/v4/locales/fr.cjs": "b251a10358ef0839ea36a945c23ab93905b91a55691821ba3e44ec69d0efe742", - ".opencode/node_modules/zod/v4/locales/fr.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/fr.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/fr.js": "e82a9f7655cd269e03fabfa04bb74096674dfec0ee61308c700eab6f2b7a9bb9", - ".opencode/node_modules/zod/v4/locales/he.cjs": "4214761eb524ab2024b7c3ed70b31dc02e7e3d4a9c32878423b61b11376cf5ea", - ".opencode/node_modules/zod/v4/locales/he.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/he.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/he.js": "015ddb02e660e6fd950f26432c6a9748c84f0ef2dbacd9ebf7b7563ab35abba0", - ".opencode/node_modules/zod/v4/locales/hu.cjs": "e1d62ddc5fb9c235248e6b1cf1c32917bafe53081304aeacd39f4ac555f255cb", - ".opencode/node_modules/zod/v4/locales/hu.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/hu.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/hu.js": "f5d7a779f7c4f2ea52bec6aca1c81f4c3a79924639727d2ca311a393ecfbb045", - ".opencode/node_modules/zod/v4/locales/id.cjs": "da98986abda8993196e484c5e5a33ba25c81cdf5b4ca16608dcfa65e6ef1249f", - ".opencode/node_modules/zod/v4/locales/id.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/id.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/id.js": "b67c815e4a8b91c178b610b5876083de40753f1811928e0a9682ff63223d88c6", - ".opencode/node_modules/zod/v4/locales/index.cjs": "203b86031d42ea980a2eebb40c26fd7778108a0f5de40d1caac5f15156d01885", - ".opencode/node_modules/zod/v4/locales/index.d.cts": "7b9e6b3c726d47935bdc9ebc78fe5398e28e751ba7d70e9e011f01fbd5b618be", - ".opencode/node_modules/zod/v4/locales/index.d.ts": "26965e789e579cd10968fb6ca81caa2ae50c16608185b16dbe84eb00a652b0cf", - ".opencode/node_modules/zod/v4/locales/index.js": "26965e789e579cd10968fb6ca81caa2ae50c16608185b16dbe84eb00a652b0cf", - ".opencode/node_modules/zod/v4/locales/is.cjs": "d37d44638830952defe01c248387522fc006645dccdd117d540fe2c1150c8313", - ".opencode/node_modules/zod/v4/locales/is.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/is.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/is.js": "703d8a1d77fb77192acf93fc4787f02d19d91d5bbd5a3aec21e7695b412cb9b2", - ".opencode/node_modules/zod/v4/locales/it.cjs": "fb2e0ab0d2eaf7d0ab5abd37bc00cbe24dd2564de984dc982ad0f33db4c0330a", - ".opencode/node_modules/zod/v4/locales/it.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/it.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/it.js": "5e444bce52d1505992a9803bf3abd30b91ccf5b022a2a3c21a9885daa2b687f1", - ".opencode/node_modules/zod/v4/locales/ja.cjs": "27ce4a1f40a7b777e905ac942d7a9c3f71f43cfbcf6366a1f416b6ee7467d5cf", - ".opencode/node_modules/zod/v4/locales/ja.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ja.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ja.js": "c7ecc379e0230066cc25a63597506521e68a76987b6c99f0b861d5240ce1e7d4", - ".opencode/node_modules/zod/v4/locales/ka.cjs": "12120b7997f17d4fad888d0907ebc34957a919df1053d18f7780512f2cc025fa", - ".opencode/node_modules/zod/v4/locales/ka.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/ka.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/ka.js": "90a891f22c612ad6eecf6975d58e7ce322b193e2fe3895cf8b7c800d9a2c8638", - ".opencode/node_modules/zod/v4/locales/kh.cjs": "5a5f3a63e4e97a25b44692b0910f73cf15e54fc5b03c61f9d87e2a9eb6e2a098", - ".opencode/node_modules/zod/v4/locales/kh.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/kh.d.ts": "e2a9a2ee24da53cf39abdc8ccb2c8b09e8b6b7c8f692448b90698d5208ed5e2d", - ".opencode/node_modules/zod/v4/locales/kh.js": "78c3d1991041bb8c8fc0f7c5d745aae422d4c2e4e8f30bae44f3fef4765900a8", - ".opencode/node_modules/zod/v4/locales/km.cjs": "d363ebe070cb240e3d3acc335c72bf21598329cd274f4f1e8738f45597c257d6", - ".opencode/node_modules/zod/v4/locales/km.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/km.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/km.js": "52059ef27113a2c5609014d8de482a6b7e644f1721691644f818ebc2aa50e6be", - ".opencode/node_modules/zod/v4/locales/ko.cjs": "6cbf291a630b59641d29d86cf01d7efa581675e838e6edb697b268c5ff024f2d", - ".opencode/node_modules/zod/v4/locales/ko.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ko.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ko.js": "f87cdcf6b3f89762d1331a670cbff687aa52d34beb7d0e0bcba9f9202f667308", - ".opencode/node_modules/zod/v4/locales/lt.cjs": "b900d411a793f16885c9a85d02eaf82fabd09f52621b4936b36ec3930ee61b44", - ".opencode/node_modules/zod/v4/locales/lt.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/lt.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/lt.js": "f3a346e1f39e0dead4d57ae412f6e40247efc5344de30a3326fbf0cbbd2b3647", - ".opencode/node_modules/zod/v4/locales/mk.cjs": "d0bff5ed169eeb21ce723024b291815a9ddbb6a7a415ab397f7d622a70419bb3", - ".opencode/node_modules/zod/v4/locales/mk.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/mk.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/mk.js": "dc64be99e35535aa7ffd648182dd7cfe3a51be08ad373d3bd12907b08acde24b", - ".opencode/node_modules/zod/v4/locales/ms.cjs": "fdf00d04224748328af8859affc695a1c0c4a110b763a75c6e028c0d924b5819", - ".opencode/node_modules/zod/v4/locales/ms.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ms.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ms.js": "5bd7da46aa238951a3f65dfd99d658582005dbb6b6b5586866fc4123fe9da582", - ".opencode/node_modules/zod/v4/locales/nl.cjs": "c53dfc2b6d22aadf40ab20100b6dffbbb6089446ab1c6b0560d0ed1d8f50b236", - ".opencode/node_modules/zod/v4/locales/nl.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/nl.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/nl.js": "b3bc897777d2ab5435c08dc65a75ec3e29f168b7e3fd70cbddd52a4960471712", - ".opencode/node_modules/zod/v4/locales/no.cjs": "4aeaa653cc2c45c2e9a860111bc6d58a9ebe8476b14e90dbbf7f1867128e0e48", - ".opencode/node_modules/zod/v4/locales/no.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/no.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/no.js": "c38a1e0b0cb4398fdabbffdbd1dd59a176535372f6af0226cfd6a600914f918a", - ".opencode/node_modules/zod/v4/locales/ota.cjs": "bfcf6b4ee261e84ac2a3083b6d5b9afb90b63a2d1218075f1edcabf8bad63332", - ".opencode/node_modules/zod/v4/locales/ota.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ota.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ota.js": "1a52c600e988011d30a150fac78176323a3ddf9d93db44cc13c665bca2ddc297", - ".opencode/node_modules/zod/v4/locales/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4/locales/pl.cjs": "bbe265c24c400f5b889a4e3cb2e0b67d9bb722c825e646ebbc2e3c7a9a892981", - ".opencode/node_modules/zod/v4/locales/pl.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/pl.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/pl.js": "482246e5da9d86498189e027bcc7e034b9c729dc07a875f78de3ec5ee47f0fc9", - ".opencode/node_modules/zod/v4/locales/ps.cjs": "86cc84fc9ce3b849207baada9ffe6b6426f21c02febd2e754c8f57e37e3103d6", - ".opencode/node_modules/zod/v4/locales/ps.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ps.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ps.js": "29f11ca76393fc30b3f7aa0af116bd29a3caf07b09e3113bb16bd0b443339552", - ".opencode/node_modules/zod/v4/locales/pt.cjs": "9e28743eea308d73f4671b3f780cb64bbb9a0f89682a82c55110dee5b43e56b4", - ".opencode/node_modules/zod/v4/locales/pt.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/pt.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/pt.js": "d9a033407c895b3cb239d7ae7c7946b7487a5c7c5988e4523fe338566190ac9d", - ".opencode/node_modules/zod/v4/locales/ru.cjs": "6f4e0e348f2c8eca9f8311bc829f8d3ff3c7a18f6b47139fb80b9b61ccfae31c", - ".opencode/node_modules/zod/v4/locales/ru.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ru.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ru.js": "12ca3536916e5b947e920e29cd48fba0b1daf0712eff575b9ff43b1f15a4ed47", - ".opencode/node_modules/zod/v4/locales/sl.cjs": "489fce4f844fe664c259836e31f9409dd601c908d373b99631fce26394107aa3", - ".opencode/node_modules/zod/v4/locales/sl.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/sl.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/sl.js": "a6513b3ae705f46deaf3adaf6e7d38642ccb2b8c9844e8d8086efb4d33876007", - ".opencode/node_modules/zod/v4/locales/sv.cjs": "5d918fda16df1c5e16a3b17c74fafb9b35e5f76f44adb93d51fe062f6319fb2a", - ".opencode/node_modules/zod/v4/locales/sv.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/sv.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/sv.js": "132cb1f90932fd7f687a1f7b6f050a589c9e25dab5f78078d1a60bc981d91a1d", - ".opencode/node_modules/zod/v4/locales/ta.cjs": "3c8c82fb7c9c4019a3e642f61e22cac12c77fed7167aa5878d3889e39725da58", - ".opencode/node_modules/zod/v4/locales/ta.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ta.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ta.js": "8ceaba6e203b9fc6ff2ef8062f5fe0677c38a06aee0e2eaee4f5c6b6d4c92bad", - ".opencode/node_modules/zod/v4/locales/th.cjs": "d12932c74180558fbc0a049fedf3c7f59a52bf539826dd1740da7dc568cdd2c9", - ".opencode/node_modules/zod/v4/locales/th.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/th.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/th.js": "0c219854cfd59b9c7fac532130c2d8a3c344a493dd3c361b60d2fadea4afa45b", - ".opencode/node_modules/zod/v4/locales/tr.cjs": "28ca52e08a6f4a9433d6302fd2e075b4173ba71fb3ad89c9c6772889d559dad8", - ".opencode/node_modules/zod/v4/locales/tr.d.cts": "26384fb401f582cae1234213c3dc75fdc80e3d728a0a1c55b405be8a0c6dddbe", - ".opencode/node_modules/zod/v4/locales/tr.d.ts": "3b25e966fd93475d8ca2834194ea78321d741a21ca9d1f606b25ec99c1bbc29a", - ".opencode/node_modules/zod/v4/locales/tr.js": "5899564068c44d6321135e441c8841a0c87fcee7f1c2ffb6e075c81b861a5963", - ".opencode/node_modules/zod/v4/locales/ua.cjs": "0565126e33d30fd26046fd5399fe6293e8827fa846c3a7ff414e4e63acbfbf0a", - ".opencode/node_modules/zod/v4/locales/ua.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ua.d.ts": "6484b25a0de66353ff65d498318289e1ee89515bd6105c73d64dddfff8835699", - ".opencode/node_modules/zod/v4/locales/ua.js": "9391169ffbc72f777a83230b6ffcc3f4cf46ab5945ec540de9ef4609dca1f4cc", - ".opencode/node_modules/zod/v4/locales/uk.cjs": "9d72ea5a461879fe16821cafffdb365d0b4a22ec7c08200d2b4e289bd2443426", - ".opencode/node_modules/zod/v4/locales/uk.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/uk.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/uk.js": "67a00c4c9a06d6a9c84c5d598230d28a9be97eeb023c455c4300bcbb67f85baf", - ".opencode/node_modules/zod/v4/locales/ur.cjs": "c5e01648e76fec946d1b6694ce400208171269a7de767d95a99d76299f51c30e", - ".opencode/node_modules/zod/v4/locales/ur.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/ur.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/ur.js": "673c79cb79ea713c7db86a20d69e8e7276bbc5aa9877c1b9f1230e309aa30ac8", - ".opencode/node_modules/zod/v4/locales/vi.cjs": "8bf4ad084e0cdb1b63dae321139a2704b52392f219c832cee61edf104a0a90e5", - ".opencode/node_modules/zod/v4/locales/vi.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/vi.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/vi.js": "669dc347036da69f481fd43aaf60c24badb4ff52906aa59ea86735028274ac5c", - ".opencode/node_modules/zod/v4/locales/yo.cjs": "5d6308e49a6797df8e4874d35e5368beb565a2d98dd1912837db200fbeb6d29d", - ".opencode/node_modules/zod/v4/locales/yo.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/yo.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/yo.js": "48269d5416f0a497c777ae28f1724263e2051ff69134fcc307c57faabbc3b470", - ".opencode/node_modules/zod/v4/locales/zh-CN.cjs": "e1d86c2f7f1cd5dfec5182b00b6a6db4d788f3898a32fe58e330b256840ed715", - ".opencode/node_modules/zod/v4/locales/zh-CN.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/zh-CN.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/zh-CN.js": "88dd19ebe066ad328ce315b9da17d8c6dbe76a44bda9b48128809d864392637a", - ".opencode/node_modules/zod/v4/locales/zh-TW.cjs": "7149854c4ea48287a43219f14f1cf0638ff6fc1257359d6726e7c9c062d1e449", - ".opencode/node_modules/zod/v4/locales/zh-TW.d.cts": "e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d", - ".opencode/node_modules/zod/v4/locales/zh-TW.d.ts": "7ceb8bc679a90951354f89379bc37228e7cf87b753069cd7b62310d5cbbe1f11", - ".opencode/node_modules/zod/v4/locales/zh-TW.js": "40d3639fd0b600c2eea303d359f36dcb34ccaceccbb27c00fabfd1d6d5761415", - ".opencode/node_modules/zod/v4/mini/checks.cjs": "751987cbe8e6346d30c25dc3ca04643e3ab7301a83cf31d0ee0ecf4dfb8b9149", - ".opencode/node_modules/zod/v4/mini/checks.d.cts": "ffcc1e3d8a6c4a3dcc6291d1f559f6853d2cbdb3df8c7e57eb99059c90d89925", - ".opencode/node_modules/zod/v4/mini/checks.d.ts": "3fbf0aa59f99b6e4d640cd2b062cff9537530af3f5bfed4f862a7f18a9caa839", - ".opencode/node_modules/zod/v4/mini/checks.js": "3fbf0aa59f99b6e4d640cd2b062cff9537530af3f5bfed4f862a7f18a9caa839", - ".opencode/node_modules/zod/v4/mini/coerce.cjs": "6dca70ae45d9a876d730898fada9316a066944d6dfd90d92c43fb10c6bb6b2b2", - ".opencode/node_modules/zod/v4/mini/coerce.d.cts": "d50434c282f9e0cfe9d728f6e7a12988bfc6de7be655f40ad44d6bee6f9fe9a3", - ".opencode/node_modules/zod/v4/mini/coerce.d.ts": "ddbf61f8fb60c82085677c1ea3393c534f3885f61ff7ebb4559575a03f82a4cf", - ".opencode/node_modules/zod/v4/mini/coerce.js": "ef3cb07ee9217997976afe9e2639d63eb4b926ef58a5fb9c24ac80855e529a93", - ".opencode/node_modules/zod/v4/mini/external.cjs": "05c2bb69db0d26b115d9e146b85831c52f2341d23a40f8872f873af089e40661", - ".opencode/node_modules/zod/v4/mini/external.d.cts": "48a578dc7819fe791ca85d2aa6eea01122c0f7c460a6def56ca37fbb6cab3378", - ".opencode/node_modules/zod/v4/mini/external.d.ts": "305cb5e50a3e4e49a2004e59c17fce5caa25c8d22ec340b0e762b23164c5a641", - ".opencode/node_modules/zod/v4/mini/external.js": "d56f0c9c15524cd7dc217b912f68c589863fa515401c315982f06cac917c8727", - ".opencode/node_modules/zod/v4/mini/index.cjs": "2a1c77f9c68bd47cd079ea601248dad70025528bc1062c2c657444000edabcac", - ".opencode/node_modules/zod/v4/mini/index.d.cts": "0f29e474ba3555dd52ad61783dc6277683dd19c36473589d6fe2d29e06d99f5a", - ".opencode/node_modules/zod/v4/mini/index.d.ts": "455d93e660b61feec18cd4c31e2f394627546ae8ab31f9bacdab6318112ac31c", - ".opencode/node_modules/zod/v4/mini/index.js": "455d93e660b61feec18cd4c31e2f394627546ae8ab31f9bacdab6318112ac31c", - ".opencode/node_modules/zod/v4/mini/iso.cjs": "b461dc1b91755b88cd22a50b54d1ab8b515357343bb20c7b7e68e1c2ea2b5ba8", - ".opencode/node_modules/zod/v4/mini/iso.d.cts": "17a9a3fe407d0e6bfc00f7e480e6090efd455cd1ce4204572543d0492d17a721", - ".opencode/node_modules/zod/v4/mini/iso.d.ts": "3f2f3b037cd2ba39395aa6628fb616f4642ae8cbab3b0f9ef786213745538658", - ".opencode/node_modules/zod/v4/mini/iso.js": "1829996a8bbebfce7e4e6ca384e3ef6aeb2d88cda5ae7f7eeec24904ba688cef", - ".opencode/node_modules/zod/v4/mini/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4/mini/parse.cjs": "3a1db8a15f2d19426e3a774543a1988e11d112c1e345a51b038d33eeaff573ac", - ".opencode/node_modules/zod/v4/mini/parse.d.cts": "18fe15c9645cea0541cb0664b82fdd03069cb33544989ad87e0091dc70cae250", - ".opencode/node_modules/zod/v4/mini/parse.d.ts": "db605a698f917c4006462596e6526fe6a9c6eff9b8409e8472fe545d4b70027c", - ".opencode/node_modules/zod/v4/mini/parse.js": "db605a698f917c4006462596e6526fe6a9c6eff9b8409e8472fe545d4b70027c", - ".opencode/node_modules/zod/v4/mini/schemas.cjs": "6ff53249f8d550e1559d99e37c6107b83a69b33302a57c299f078769db7d7952", - ".opencode/node_modules/zod/v4/mini/schemas.d.cts": "3a80a0f1075f9eac86dc6555ca4409535b74cfe1eb803c566c252014cff13912", - ".opencode/node_modules/zod/v4/mini/schemas.d.ts": "ec8e232365211878d47e3db95701346e8d3f1aea5c7049c6e84b3ca1be0d934b", - ".opencode/node_modules/zod/v4/mini/schemas.js": "d79e807c5eb58cfcb80597b7a38270862b1af876721baf8556210c9a27cb82cc", - ".opencode/node_modules/zod/v4/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/node_modules/zod/v4-mini/index.cjs": "c3ef916ed5e1bb397f04352f010f97961e98f610c13ecc05c25a384ef0e5a5a2", - ".opencode/node_modules/zod/v4-mini/index.d.cts": "d34c4532b0004150342d04ab1a6f61d19751c4fc7c465c72ec582b180b0904c0", - ".opencode/node_modules/zod/v4-mini/index.d.ts": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/v4-mini/index.js": "cf94ca2939d655e17fdfd1d0a3aceb4cbeb040d8946e9c9c1eaa5cc734dc69c8", - ".opencode/node_modules/zod/v4-mini/package.json": "5cb4c7dad36258f0c5b26d914adfebd66105dddc4af3c12d2b3173e7e5674191", - ".opencode/opencode.json": "dcc34cd8e53f5df784c7c50970c830f598b7637c1b824d9089cd70c59389a797", - ".opencode/opencode.json.old": "245a87dd300b32ba610c3415fbbbcf4f6d902aac340e24107dd6e5ebbc2dded8", - ".opencode/package.json": "6867538b2f6025286a70c943d15db4191d2412daa8bf7431a846b00c863f26b8", - ".opencode/plugin/inject-subagent-context.js": "6a1257d7a2e485d3de3b0e34c40ab285dfe48e976d442288423eab23d2a0e717", - ".opencode/plugin/session-start.js": "ce013e3b329b954cd1317fc69d804ad69f6524ff73531b29bac8d67e6a4d8d81", - ".agents/skills/ui-ux-pro-max/SKILL.md": "9bd26c52dfd10dfd613dd3cc85df217dc25a172e380bde41fdfa6fedda288b2a", - ".agents/skills/ui-ux-pro-max/data/_sync_all.py": "b540b3a4f87598ae29048b3cceae1fc17b39b629aee2cbe5c802ffc7b48abf6d", - ".agents/skills/ui-ux-pro-max/data/app-interface.csv": "2a17ef810dab715ce1f339861817a8fbe3ccc38142b70517301e874803e838ac", - ".agents/skills/ui-ux-pro-max/data/charts.csv": "ebb565308115f955791b0431797a89d9b3587c25d5babb9428d10712c4924817", - ".agents/skills/ui-ux-pro-max/data/colors.csv": "69ee8c1147b269599d20ca418bdd6f32563a9aa2d962fb9a9b8dec66bf7b1ba4", - ".agents/skills/ui-ux-pro-max/data/design.csv": "6f0ae42f16b3cbfa3f07050268387557859ff666e2651b1b82763f099d724b3f", - ".agents/skills/ui-ux-pro-max/data/draft.csv": "e190c796b707858a5436dc4c27f9ee9bb6618014ca73f533e423abe5ce9e4d06", - ".agents/skills/ui-ux-pro-max/data/google-fonts.csv": "2c03a3cd134d126bd9d6a7dc2a6360dc5272219ad6df3eb9315b031f806e1487", - ".agents/skills/ui-ux-pro-max/data/icons.csv": "f376c29fb4df37b4bdb366a5aa70cb211ba3dd8b435390aaa03152a64b07d2e8", - ".agents/skills/ui-ux-pro-max/data/landing.csv": "080cedbcd61ff8ec9520f33929baa76bee9589e783f83b2f8d824a466b6a46d7", - ".agents/skills/ui-ux-pro-max/data/products.csv": "9fd9e776ba847cf44c1ea78f95fe5e33b2c56bb7e186e3cfff9c49bc7fcb691b", - ".agents/skills/ui-ux-pro-max/data/react-performance.csv": "904c8afcda229629545912dde0e8ac37503757131f0169f80b016f1f58c4fd3f", - ".agents/skills/ui-ux-pro-max/data/stacks/react-native.csv": "a08ca77fcf6b6d9531982dce465366296013bfcf12d2938ac72ad57cf0c4f085", - ".agents/skills/ui-ux-pro-max/data/styles.csv": "9b5089dcde8999333b36878252a255cc3bacbb2fe7b836c76cc7f7aa2abb643d", - ".agents/skills/ui-ux-pro-max/data/typography.csv": "dbea262a54e3bfa2e6c3b15989a365d5ef4c43349316aff46635e82ca825adce", - ".agents/skills/ui-ux-pro-max/data/ui-reasoning.csv": "41976082ecae1100da937c949215dc6694393e03f3c2a7444dd92a9edb43cb11", - ".agents/skills/ui-ux-pro-max/data/ux-guidelines.csv": "1870ee048f2a2bdd60709f8f7adf7f3b6dcad560bc005c8b2915a8ac8639820d", - ".agents/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-313.pyc": "6fbe6d7129ee4b3055472e4f8d6417229fd160ed764e94935b9b9c1b0fe1a41f", - ".agents/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-313.pyc": "225b14b080c8fec56ee3ba08904d7fccae04607e2806a688b368d31f7c66a869", - ".agents/skills/ui-ux-pro-max/scripts/core.py": "18e00b1a2952fb919dcba0010ee71f75129a670ad565e8d0907958d6be8caeca", - ".agents/skills/ui-ux-pro-max/scripts/design_system.py": "4da1d341f3c7749df51b51db4a543a48a427c3c746eb0e9882a1ab86acf3bb54", - ".agents/skills/ui-ux-pro-max/scripts/search.py": "18b1efa4ee5a2fc1cf14d7b25429ab423ef6026d123878fb93c5884f33cd10db" + "__version": 2, + "hashes": { + ".trellis/scripts/common/active_task.py": "6c88ed40ef7289bca0f6d2ecba0f8b8aef46cd58788080fbeeea88de138a431f", + ".trellis/scripts/common/io.py": "6480b181f2bc505323b28ed7a66963d7b7edc96251e83b4c8e7a45907cc721c8", + ".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf", + ".trellis/scripts/common/git.py": "e14817be7de122d3a106f509c2825aeb9669d962ba73ba241642d2931cfdf1d6", + ".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9", + ".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe", + ".trellis/scripts/common/task_context.py": "1c16a7fa82d363010d0d0ebdc038296ae1552bf6e90214787d707f49567bc159", + ".trellis/scripts/common/task_store.py": "e3802a822dcd0a8005bac6ffe50af196944c2a42d56d70e68c622aaf9b1156e2", + ".trellis/scripts/common/session_context.py": "02e8d8209c2b88efeb83c9f1a3edf20d6344ceda5fcb9da53a3eb24fbfb47b17", + ".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7", + ".trellis/scripts/common/workflow_phase.py": "3ca97e634b53a428206b04f87eba1700d4b2063cf367ee276ab0b1849994b81d", + ".claude/commands/trellis/continue.md": "0683093a150d189af02c5de33afe01ebb740e940f9325f7ba7390906895661a1", + ".claude/commands/trellis/finish-work.md": "d6aa570ab684f57e4845de2d84a1ff6d9f0908e04c5a56e14fd70ae739c369fc", + ".claude/skills/trellis-before-dev/SKILL.md": "208ad3fd5131fa0da603d4bc354a29826967397f5aeef483fa0564113df13e9e", + ".claude/skills/trellis-brainstorm/SKILL.md": "a5fe1fbd16221c52c259b766044ab62dd43dfce2fa18d8c1f3fff4e27658cc34", + ".claude/skills/trellis-break-loop/SKILL.md": "35afb53fef42cd494e566f1ef170dbf442ec2be7e19931f28a14079b4dda753f", + ".claude/skills/trellis-check/SKILL.md": "a3f17aef687aa3b475d12ee64c3293e5491bb7474336be2c0f9ec22042f13b6e", + ".claude/skills/trellis-update-spec/SKILL.md": "d975db7af166578488958751ae2c56edb827a68bddb569aa27acc3453f64e610", + ".claude/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5", + ".claude/skills/trellis-meta/references/customize-local/change-agents.md": "7f2982162463f107f8b1a4fa1a41fee2bc7dbd0cc8e90c48559aba30c3ea403c", + ".claude/skills/trellis-meta/references/customize-local/change-context-loading.md": "c7be53e038eac99ff4d0abb3fafc67cfdfd90352744ce09ad11e7ef5085cf933", + ".claude/skills/trellis-meta/references/customize-local/change-hooks.md": "91892f2cff53ae003736007e95172945acabf48b2fc889bd627cd2406ce449c4", + ".claude/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "b3009ef20a4f24e5d8b196109dc9bab6bd30fc030dbc4fb796afdd2ca912e1ea", + ".claude/skills/trellis-meta/references/customize-local/change-spec-structure.md": "a3fc9da294448b4a3525ae1ab7c8c9b895ef8f3b53bcf37a13ce6afbdc040fe6", + ".claude/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d", + ".claude/skills/trellis-meta/references/customize-local/change-workflow.md": "6f1707a2cc032c50e41e5624cef46071dd53dc9810bc6b3cae66d86508dea1cb", + ".claude/skills/trellis-meta/references/customize-local/overview.md": "1a406c24b4c5737cf517ead5ecb0846c20b0648a117008d3f5a47614fc1793ca", + ".claude/skills/trellis-meta/references/local-architecture/context-injection.md": "31286b9c05e600db7d179100eca533f9b8a4aab3a9c255cb69e8dccacb4e8375", + ".claude/skills/trellis-meta/references/local-architecture/generated-files.md": "4356517517cef0ba7f3ba01965a4ba8953505702e4085f0797d3e36817c9669f", + ".claude/skills/trellis-meta/references/local-architecture/overview.md": "45ffd4ee95020f58201adc885f3dfc89b26483c2b350d96ca7f2f57f94d5ff5f", + ".claude/skills/trellis-meta/references/local-architecture/spec-system.md": "8996504201e2a5460516948fe29c4ce7ed6b960f15da8c514855d2d467046985", + ".claude/skills/trellis-meta/references/local-architecture/task-system.md": "a6f905e53fe5a7fa5be8a4bbe1d102bd90b69c6e63a1a3be51ac3f7f164f1d21", + ".claude/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6", + ".claude/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd", + ".claude/skills/trellis-meta/references/platform-files/agents.md": "ffee78fc3c29114c7409ca4805ebd51d44d8a6acb630e9f73d47013e10db5ac5", + ".claude/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "6e2d6d88719c2779fe34004f63d36cff203d8f64e7fb620f7cb1cde15c37c462", + ".claude/skills/trellis-meta/references/platform-files/overview.md": "6479cd2393166b4b369b511c44b78cbc64975c8b1df96ee1d4d1bd06b75cd48d", + ".claude/skills/trellis-meta/references/platform-files/platform-map.md": "ded6751c06f31d0a701d33c9dd69c482a583539ad3ed464aaad9e705f793b212", + ".claude/skills/trellis-meta/references/platform-files/skills-and-commands.md": "85435eb8bb6921283575bca51268fc534c22fd3ca33782e841ee5c76140ae48f", + ".claude/skills/trellis-meta/SKILL.md": "942e898a6fd769a93a3ca6f43f9fe0412d0adae011654fd384e9cacbd2af4f34", + ".claude/agents/trellis-check.md": "bfef8b996ae19a23fc8a71b63cd0e5d1cbc9f37fb6e879c2cbb3724448872e70", + ".claude/agents/trellis-implement.md": "f2e9f4cf54a3a2a554688b88627dcb946610c92ea9d9af6cf0bab64d132f3b8e", + ".claude/agents/trellis-research.md": "f82244b2a88a1f77b09813f58a7fdf506f8e9603a5c34b3abe6e170f73ab68a8", + ".claude/hooks/inject-subagent-context.py": "6b44a4043cf12a87b1cc34f682b33ea2f8e16a2bc3a9998a42d62e6e8672569e", + ".claude/hooks/inject-workflow-state.py": "0684fb17d0d42b36d1549e9bc0a905d4c06f714e2b2008d74d9ab0d2c1c2b626", + ".claude/hooks/session-start.py": "e8b84b8651df9039e8292cb9ce1a87ce07bb2405b46b80617b7b99b07856dfed", + ".claude/settings.json": "4defe9eae17b3101dae357179503683d868859e570931a7d3181e4ab10d82a9f", + ".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22", + ".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee", + ".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b", + ".trellis/scripts/common/git_context.py": "7533c08335791e50c3a6f9d551d5b1af0bdaa2a0a746721cb3e1a2140f4d9683", + ".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5", + ".trellis/scripts/common/task_utils.py": "f5ef4af87ba3e11d8b19630c0c96d009de1811fc9be56c2027a9c96e21ed103e", + ".trellis/scripts/common/cli_adapter.py": "cd844d1e84b1a09b373b3a7609e4d5606ee9d4825154c002cc9bb3f54c8e2fb9", + ".trellis/scripts/common/config.py": "671a3591f97b75ec19f25814d2ee3f7e9b38e048f6f67442519fe0715c454eeb", + ".trellis/scripts/task.py": "a07b33b519037aadabce00ea1a3d2bd309bcca881244d43c72fb50e51322e9ab", + ".trellis/scripts/add_session.py": "a97a6c88ff7def8045a5dffa5c698a823392d7f73c1641e8a0c08db0168bd913", + ".trellis/config.yaml": "c3c4af7d82c09a1638f63c1f560119507735b060a4780ef7e6d0cdef447c215d", + ".trellis/.gitignore": "3bc42434cbe61c937a1c26ea3c880b496bfad4e7d683750169e27ade509aa798", + ".trellis/workflow.md": "d407beb736c69673c91dae69197f8db45d410f1b80e93a9b48277d35289dc595" + } } \ No newline at end of file diff --git a/.trellis/.version b/.trellis/.version index 81de5c5..7bc8de8 100644 --- a/.trellis/.version +++ b/.trellis/.version @@ -1 +1 @@ -0.3.10 \ No newline at end of file +0.5.0-rc.6 \ No newline at end of file diff --git a/.trellis/config.yaml b/.trellis/config.yaml index 7d18551..8e7fcc7 100644 --- a/.trellis/config.yaml +++ b/.trellis/config.yaml @@ -31,3 +31,29 @@ max_journal_lines: 2000 # - "echo 'Task finished'" # after_archive: # - "echo 'Task archived'" + +#------------------------------------------------------------------------------- +# Monorepo / Packages +#------------------------------------------------------------------------------- + +# Declare packages for monorepo projects. +# Trellis auto-detects workspaces during `trellis init`, but you can also +# configure them manually here. +# +# packages: +# frontend: +# path: packages/frontend +# backend: +# path: packages/backend +# docs: +# path: docs-site +# type: submodule +# # For polyrepo / meta-repo layouts (independent .git in each subdir), +# # mark the package with `git: true`. The runtime treats it as an +# # independent repository for things like git-context display. +# webapp: +# path: ./webapp +# git: true + +# Default package used when --package is not specified. +# default_package: frontend diff --git a/.trellis/scripts/add_session.py b/.trellis/scripts/add_session.py index 71606e5..be2c005 100755 --- a/.trellis/scripts/add_session.py +++ b/.trellis/scripts/add_session.py @@ -4,8 +4,19 @@ Add a new session to journal file and update index.md. Usage: - python3 add_session.py --title "Title" --commit "hash" --summary "Summary" - echo "content" | python3 add_session.py --title "Title" --commit "hash" + python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli] + python3 add_session.py --title "Title" --branch "feat/my-branch" + + # Pipe detailed content via stdin (use --stdin to opt in): + cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary" + <session content here> + EOF + +Branch resolution order: + 1. --branch CLI arg (explicit) + 2. task.json branch field (from active task) + 3. git branch --show-current (auto-detect) + 4. None (omitted gracefully) """ from __future__ import annotations @@ -20,11 +31,21 @@ from pathlib import Path from common.paths import ( FILE_JOURNAL_PREFIX, get_repo_root, + get_current_task, get_developer, get_workspace_dir, ) from common.developer import ensure_developer -from common.config import get_session_commit_message, get_max_journal_lines +from common.git import run_git +from common.tasks import load_task +from common.config import ( + get_packages, + get_session_commit_message, + get_max_journal_lines, + is_monorepo, + resolve_package, + validate_package, +) # ============================================================================= @@ -123,7 +144,9 @@ def generate_session_content( commit: str, summary: str, extra_content: str, - today: str + today: str, + package: str | None = None, + branch: str | None = None, ) -> str: """Generate session content.""" if commit and commit != "-": @@ -135,12 +158,15 @@ def generate_session_content( else: commit_table = "(No commits - planning session)" + package_line = f"\n**Package**: {package}" if package else "" + branch_line = f"\n**Branch**: `{branch}`" if branch else "" + return f""" ## Session {session_num}: {title} **Date**: {today} -**Task**: {title} +**Task**: {title}{package_line}{branch_line} ### Summary @@ -175,7 +201,8 @@ def update_index( commit: str, new_session: int, active_file: str, - today: str + today: str, + branch: str | None = None, ) -> bool: """Update index.md with new session info.""" # Format commit for display @@ -254,10 +281,25 @@ def update_index( continue if in_session_history: - new_lines.append(line) - if re.match(r"^\|\s*-", line) and not header_written: - new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} |") + # Migrate old 4/6-column headers to 5-column Branch-only history. + if re.match( + r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$", + line, + ): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line): + new_lines.append("| # | Date | Title | Commits | Branch |") + continue + if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written: + new_lines.append("|---|------|-------|---------|--------|") + new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |") header_written = True + continue + new_lines.append(line) continue new_lines.append(line) @@ -274,11 +316,16 @@ def update_index( def _auto_commit_workspace(repo_root: Path) -> None: """Stage .trellis/workspace and .trellis/tasks, then commit with a configured message.""" commit_msg = get_session_commit_message(repo_root) - subprocess.run( + add_result = subprocess.run( ["git", "add", "-A", ".trellis/workspace", ".trellis/tasks"], cwd=repo_root, capture_output=True, + text=True, ) + if add_result.returncode != 0: + print(f"[WARN] git add failed (exit {add_result.returncode}): {add_result.stderr.strip()}", file=sys.stderr) + print("[WARN] Please commit .trellis/ changes manually: git add .trellis && git commit", file=sys.stderr) + return # Check if there are staged changes result = subprocess.run( ["git", "diff", "--cached", "--quiet", "--", ".trellis/workspace", ".trellis/tasks"], @@ -305,6 +352,8 @@ def add_session( summary: str = "(Add summary)", extra_content: str = "(Add details)", auto_commit: bool = True, + package: str | None = None, + branch: str | None = None, ) -> int: """Add a new session.""" repo_root = get_repo_root() @@ -330,7 +379,8 @@ def add_session( new_session = current_session + 1 session_content = generate_session_content( - new_session, title, commit, summary, extra_content, today + new_session, title, commit, summary, extra_content, today, package, + branch, ) content_lines = len(session_content.splitlines()) @@ -367,7 +417,16 @@ def add_session( # Update index.md active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md" - if not update_index(index_file, dev_dir, title, commit, new_session, active_file, today): + if not update_index( + index_file, + dev_dir, + title, + commit, + new_session, + active_file, + today, + branch, + ): return 1 print("", file=sys.stderr) @@ -400,8 +459,12 @@ def main() -> int: parser.add_argument("--commit", default="-", help="Comma-separated commit hashes") parser.add_argument("--summary", default="(Add summary)", help="Brief summary") parser.add_argument("--content-file", help="Path to file with detailed content") + parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)") + parser.add_argument("--branch", help="Branch name (auto-detected if omitted)") parser.add_argument("--no-commit", action="store_true", help="Skip auto-commit of workspace changes") + parser.add_argument("--stdin", action="store_true", + help="Read extra content from stdin (explicit opt-in)") args = parser.parse_args() @@ -410,12 +473,47 @@ def main() -> int: content_path = Path(args.content_file) if content_path.is_file(): extra_content = content_path.read_text(encoding="utf-8") - elif not sys.stdin.isatty(): + elif args.stdin: extra_content = sys.stdin.read() + # Load active task once — shared by package and branch resolution + repo_root = get_repo_root() + current = get_current_task(repo_root) + task_data = load_task(repo_root / current) if current else None + + package = args.package + if package: + # CLI source: fail-fast in monorepo, ignore in single-repo + if not is_monorepo(repo_root): + print("Warning: --package ignored in single-repo project", file=sys.stderr) + package = None + elif not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr) + return 1 + else: + # Inferred: active task's task.json.package → default_package → None + task_package = task_data.package if task_data else None + package = resolve_package(task_package, repo_root) + + # Resolve branch: CLI → task.json → git auto-detect → None + branch = args.branch + + if not branch: + if task_data and task_data.raw.get("branch"): + branch = task_data.raw["branch"] + else: + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + detected = branch_out.strip() + if detected: + branch = detected + return add_session( args.title, args.commit, args.summary, extra_content, auto_commit=not args.no_commit, + package=package, + branch=branch, ) diff --git a/.trellis/scripts/common/__init__.py b/.trellis/scripts/common/__init__.py index 1772978..6d72360 100755 --- a/.trellis/scripts/common/__init__.py +++ b/.trellis/scripts/common/__init__.py @@ -75,8 +75,18 @@ from .paths import ( count_lines, get_current_task, get_current_task_abs, + normalize_task_ref, + resolve_task_ref, set_current_task, clear_current_task, has_current_task, generate_task_date_prefix, ) + +from .active_task import ( + ActiveTask, + clear_active_task, + resolve_active_task, + resolve_context_key, + set_active_task, +) diff --git a/.trellis/scripts/common/active_task.py b/.trellis/scripts/common/active_task.py new file mode 100755 index 0000000..e6597e8 --- /dev/null +++ b/.trellis/scripts/common/active_task.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +"""Session-scoped active task resolution. + +The user-facing concept is a single "active task". Trellis stores that pointer +per AI session/window under `.trellis/.runtime/sessions/`; without a stable +session key there is no active task. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DIR_WORKFLOW = ".trellis" +DIR_TASKS = "tasks" +DIR_RUNTIME = ".runtime" +DIR_SESSIONS = "sessions" +DIR_CURSOR_SHELL = "cursor-shell" +CURSOR_SHELL_TICKET_TTL_SECONDS = 30 +TASK_SESSION_COMMANDS = {"start", "current", "finish"} + +_SESSION_KEYS = ("session_id", "sessionId", "sessionID") +_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID") +_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript") +_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput") +_KNOWN_PLATFORMS = { + "claude", + "codex", + "cursor", + "opencode", + "gemini", + "droid", + "qoder", + "codebuddy", + "kiro", + "copilot", + "pi", +} + +_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")), + ("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")), + ("cursor", ("CURSOR_SESSION_ID",)), + ("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")), + ("gemini", ("GEMINI_SESSION_ID",)), + ("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")), + ("qoder", ("QODER_SESSION_ID",)), + ("codebuddy", ("CODEBUDDY_SESSION_ID",)), + ("kiro", ("KIRO_SESSION_ID",)), + ("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")), + ("pi", ("PI_SESSION_ID", "PI_SESSIONID")), +) +_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")), +) +_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("claude", ("CLAUDE_TRANSCRIPT_PATH",)), + ("codex", ("CODEX_TRANSCRIPT_PATH",)), + ("cursor", ("CURSOR_TRANSCRIPT_PATH",)), + ("gemini", ("GEMINI_TRANSCRIPT_PATH",)), + ("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")), + ("qoder", ("QODER_TRANSCRIPT_PATH",)), + ("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)), +) +_ENV_PLATFORM_ALIASES = { + "claude-code": "claude", + "factory": "droid", + "factory-ai": "droid", + "github-copilot": "copilot", +} + + +@dataclass(frozen=True) +class ActiveTask: + """Resolved active task state.""" + + task_path: str | None + source_type: str + context_key: str | None = None + stale: bool = False + + @property + def source(self) -> str: + """Human-readable source label.""" + if self.source_type == "session" and self.context_key: + return f"session:{self.context_key}" + if self.source_type == "session-fallback" and self.context_key: + return f"session-fallback:{self.context_key}" + return self.source_type + + +def normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable storage and comparison.""" + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith(f"{DIR_TASKS}/"): + return f"{DIR_WORKFLOW}/{normalized}" + + return normalized + + +def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None: + """Resolve a task ref to an absolute task directory.""" + normalized = normalize_task_ref(task_ref) + if not normalized: + return None + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + + if normalized.startswith(f"{DIR_WORKFLOW}/"): + return repo_root / path_obj + + return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj + + +def _runtime_sessions_dir(repo_root: Path) -> Path: + return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS + + +def _sanitize_key(raw: str) -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip()) + safe = safe.strip("._-") + return safe[:160] if safe else "" + + +def _hash_value(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] + + +def _as_dict(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + +def _string_value(value: Any) -> str | None: + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return None + + +def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None: + for key in keys: + value = _string_value(data.get(key)) + if value: + return value + + for nested_key in _NESTED_KEYS: + nested = _as_dict(data.get(nested_key)) + if not nested: + continue + value = _lookup_string(nested, keys) + if value: + return value + + return None + + +def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str: + if platform: + return _sanitize_key(platform) or "session" + if platform_input: + for key in ("_trellis_platform", "trellis_platform", "platform", "source"): + value = _string_value(platform_input.get(key)) + if value: + return _sanitize_key(value) or "session" + if _string_value(platform_input.get("cursor_version")): + return "cursor" + return "session" + + +def _context_key(platform_name: str, kind: str, value: str) -> str: + if kind == "transcript": + return f"{platform_name}_transcript_{_hash_value(value)}" + safe_value = _sanitize_key(value) + if safe_value: + return f"{platform_name}_{safe_value}" + return f"{platform_name}_{_hash_value(value)}" + + +def _iter_env_keys( + env_keys: tuple[tuple[str, tuple[str, ...]], ...], + platform_name: str | None, +) -> tuple[tuple[str, tuple[str, ...]], ...]: + if not platform_name: + return env_keys + matched = tuple((name, keys) for name, keys in env_keys if name == platform_name) + return matched + + +def _env_platform_name(platform_name: str | None) -> str | None: + if not platform_name or platform_name == "session": + return None + return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name) + + +def _lookup_env_context_key(platform_name: str | None) -> str | None: + """Resolve a context key from platform-provided environment variables. + + Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run + shell command can only see session identity if the host platform exports it + in the command environment. These names are best-effort adapters; if none + are present, there is no session-scoped active task. + """ + env_platform_name = _env_platform_name(platform_name) + + for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "session", value) + + for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "conversation", value) + + for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name): + for key in keys: + value = _string_value(os.environ.get(key)) + if value: + return _context_key(name, "transcript", value) + + return None + + +def _find_repo_root_from_cwd() -> Path | None: + current = Path.cwd().resolve() + while True: + if (current / DIR_WORKFLOW).is_dir(): + return current + if current == current.parent: + return None + current = current.parent + + +def _cursor_shell_ticket_dir(repo_root: Path) -> Path: + return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL + + +def _remove_file(path: Path) -> bool: + try: + path.unlink() + return True + except OSError: + return False + + +def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool: + if not left or not right: + return False + left_path = resolve_task_ref(left, repo_root) + right_path = resolve_task_ref(right, repo_root) + if left_path is not None and right_path is not None: + return left_path == right_path + return normalize_task_ref(left) == normalize_task_ref(right) + + +def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool: + if Path(sys.argv[0]).name != "task.py": + return False + args = tuple(sys.argv[1:]) + if not args: + return False + + command_name = args[0] + if command_name not in TASK_SESSION_COMMANDS: + return False + + subcommands = ticket.get("subcommands") + if not isinstance(subcommands, list): + return False + + for subcommand in subcommands: + if not isinstance(subcommand, dict): + continue + if _string_value(subcommand.get("name")) != command_name: + continue + if command_name != "start": + return True + task_ref = args[1] if len(args) > 1 else None + if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root): + return True + + return False + + +def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool: + expires_at = ticket.get("expires_at_epoch") + if isinstance(expires_at, (int, float)) and expires_at < now: + _remove_file(ticket_path) + return False + + created_at = ticket.get("created_at_epoch") + if isinstance(created_at, (int, float)): + if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS: + return True + _remove_file(ticket_path) + return False + return True + + +def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool: + cwd = _string_value(ticket.get("cwd")) + if not cwd: + return True + try: + Path(cwd).resolve().relative_to(repo_root) + except ValueError: + return False + return True + + +def _matching_cursor_ticket_context_key( + ticket_path: Path, + repo_root: Path, + now: float, +) -> str | None: + ticket = _read_json(ticket_path) + if ticket is None or ticket.get("platform") != "cursor": + return None + if not _ticket_is_fresh(ticket, ticket_path, now): + return None + if not _ticket_cwd_matches_repo(ticket, repo_root): + return None + if not _pending_ticket_matches_args(ticket, repo_root): + return None + return _string_value(ticket.get("context_key")) + + +def _lookup_cursor_shell_ticket_context_key() -> str | None: + """Resolve Cursor conversation identity from a short-lived shell ticket. + + Cursor exposes `conversation_id` to `beforeShellExecution`, but does not + export it into the shell command environment. The Cursor hook writes a + short-lived ticket just before `task.py` runs. We accept a ticket only when + the current `task.py` subcommand matches and exactly one fresh context key + matches, which avoids cross-window pointer contamination. + """ + repo_root = _find_repo_root_from_cwd() + if repo_root is None: + return None + + ticket_dir = _cursor_shell_ticket_dir(repo_root) + if not ticket_dir.is_dir(): + return None + + now = time.time() + candidates: set[str] = set() + for ticket_path in ticket_dir.glob("*.json"): + context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now) + if context_key: + candidates.add(context_key) + + if len(candidates) == 1: + return next(iter(candidates)) + return None + + +def resolve_context_key( + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> str | None: + """Resolve a stable session/window context key, if one is available. + + `TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI + scripts and subprocesses. It does not store the task itself. + """ + override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID")) + if override: + return _sanitize_key(override) or _hash_value(override) + + data = _as_dict(platform_input) + platform_name = _detect_platform(data, platform) if data or platform else None + + if data: + session_id = _lookup_string(data, _SESSION_KEYS) + if session_id: + return _context_key(platform_name or "session", "session", session_id) + + conversation_id = _lookup_string(data, _CONVERSATION_KEYS) + if conversation_id: + return _context_key(platform_name or "session", "conversation", conversation_id) + + transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS) + if transcript_path: + return _context_key(platform_name or "session", "transcript", transcript_path) + + env_context_key = _lookup_env_context_key(platform_name) + if env_context_key: + return env_context_key + + if platform_name in (None, "session", "cursor"): + return _lookup_cursor_shell_ticket_context_key() + return None + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None + + +def _write_json(path: Path, data: dict[str, Any]) -> bool: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return True + except OSError: + return False + + +def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None: + normalized = normalize_task_ref(task_path) + if not normalized: + return None + full_path = resolve_task_ref(normalized, repo_root) + if full_path is None or not full_path.is_dir(): + return None + try: + return full_path.relative_to(repo_root).as_posix() + except ValueError: + return str(full_path) + + +def _active_from_ref( + task_ref: str | None, + repo_root: Path, + source_type: str, + context_key: str | None = None, +) -> ActiveTask | None: + if not task_ref: + return None + resolved = resolve_task_ref(task_ref, repo_root) + stale = resolved is None or not resolved.is_dir() + return ActiveTask(task_ref, source_type, context_key, stale) + + +def _context_path(repo_root: Path, context_key: str) -> Path: + return _runtime_sessions_dir(repo_root) / f"{context_key}.json" + + +def resolve_active_task( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask: + """Resolve the active task from session runtime state only. + + A stale session task is returned as stale. Missing context identity or a + missing/empty session context falls back to single-session inference: if + exactly one session file exists in the runtime, return its task with + source_type="session-fallback" — covers class-2 platform sub-agents (codex, + copilot, gemini, qoder) that don't inherit the parent's session id. ≥2 + files or 0 files yield ActiveTask(None) — refuses to guess across windows. + """ + context_key = resolve_context_key(platform_input, platform) + if context_key: + context = _read_json(_context_path(repo_root, context_key)) or {} + task_ref = _string_value(context.get("current_task")) + active = _active_from_ref(task_ref, repo_root, "session", context_key) + if active: + return active + + fallback = _resolve_single_session_fallback(repo_root) + if fallback is not None: + return fallback + + return ActiveTask(None, "none", context_key) + + +def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None: + """Return the task pointed at by the sole session file, if exactly one exists. + + Used when context-key resolution fails (typical for class-2 platform + sub-agents). Returns None if 0 or ≥2 session files are present — refuses + to pick across windows so 04-21's multi-session isolation contract holds. + """ + sessions_dir = _runtime_sessions_dir(repo_root) + if not sessions_dir.is_dir(): + return None + + session_files = sorted(sessions_dir.glob("*.json")) + if len(session_files) != 1: + return None + + session_file = session_files[0] + context = _read_json(session_file) or {} + task_ref = _string_value(context.get("current_task")) + if not task_ref: + return None + + fallback_key = session_file.stem + return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _context_metadata( + platform_input: dict[str, Any] | None, + platform: str | None, + context_key: str | None = None, +) -> dict[str, Any]: + data = _as_dict(platform_input) or {} + platform_name = _detect_platform(data, platform) + if platform_name == "session" and context_key: + prefix = context_key.split("_", 1)[0] + if prefix in _KNOWN_PLATFORMS: + platform_name = prefix + metadata: dict[str, Any] = { + "platform": platform_name, + "last_seen_at": _utc_now(), + } + for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS): + value = _lookup_string(data, (key,)) + if value: + metadata[key] = value + return metadata + + +def set_active_task( + task_path: str, + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask | None: + """Set the active task in session scope. + + Returns None when no context key is available; callers should surface a + user-facing error that explains how to provide session identity. + """ + canonical = _canonical_task_ref(task_path, repo_root) + if canonical is None: + return None + + context_key = resolve_context_key(platform_input, platform) + if not context_key: + return None + + context_path = _context_path(repo_root, context_key) + context = _read_json(context_path) or {} + context.update(_context_metadata(platform_input, platform, context_key)) + context["current_task"] = canonical + context.setdefault("current_run", None) + if not _write_json(context_path, context): + return None + return ActiveTask(canonical, "session", context_key) + + +def clear_active_task( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> ActiveTask: + """Clear the active task by deleting the current session context file.""" + context_key = resolve_context_key(platform_input, platform) + if not context_key: + return ActiveTask(None, "none") + + previous = resolve_active_task(repo_root, platform_input, platform) + context_path = _context_path(repo_root, context_key) + if context_path.is_file(): + _remove_file(context_path) + return previous + + +def clear_task_from_sessions(task_path: str, repo_root: Path) -> int: + """Delete all session runtime files that point at a task.""" + target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path) + if not target: + return 0 + + cleared = 0 + sessions_dir = _runtime_sessions_dir(repo_root) + if not sessions_dir.is_dir(): + return cleared + + for session_path in sessions_dir.glob("*.json"): + context = _read_json(session_path) or {} + current = _string_value(context.get("current_task")) + if not current: + continue + current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current) + if current_ref != target: + continue + if session_path.is_file() and _remove_file(session_path): + cleared += 1 + + return cleared + + +def get_current_task_source( + repo_root: Path, + platform_input: dict[str, Any] | None = None, + platform: str | None = None, +) -> tuple[str, str | None, str | None]: + """Return (`source_type`, `context_key`, `task_path`) for compatibility.""" + active = resolve_active_task(repo_root, platform_input, platform) + return active.source_type, active.context_key, active.task_path diff --git a/.trellis/scripts/common/cli_adapter.py b/.trellis/scripts/common/cli_adapter.py index ce3323b..b65f61a 100755 --- a/.trellis/scripts/common/cli_adapter.py +++ b/.trellis/scripts/common/cli_adapter.py @@ -1,7 +1,7 @@ """ CLI Adapter for Multi-Platform Support. -Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, and Qoder interfaces. +Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces. Supported platforms: - claude: Claude Code (default) @@ -13,7 +13,12 @@ Supported platforms: - kiro: Kiro Code (skills-based) - gemini: Gemini CLI - antigravity: Antigravity (workflow-based) +- windsurf: Windsurf (workflow-based) - qoder: Qoder +- codebuddy: CodeBuddy +- copilot: GitHub Copilot (VS Code) +- droid: Factory Droid (commands-based) +- pi: Pi Agent (extension-backed) Usage: from common.cli_adapter import CLIAdapter @@ -42,7 +47,12 @@ Platform = Literal[ "kiro", "gemini", "antigravity", + "windsurf", "qoder", + "codebuddy", + "copilot", + "droid", + "pi", ] @@ -87,7 +97,7 @@ class CLIAdapter: """Get platform-specific config directory name. Returns: - Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.agents', '.kilocode', '.kiro', '.gemini', '.agent', or '.qoder') + Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.windsurf', '.qoder', '.codebuddy', '.github/copilot', '.factory', or '.pi') """ if self.platform == "opencode": return ".opencode" @@ -96,7 +106,7 @@ class CLIAdapter: elif self.platform == "iflow": return ".iflow" elif self.platform == "codex": - return ".agents" + return ".codex" elif self.platform == "kilo": return ".kilocode" elif self.platform == "kiro": @@ -105,8 +115,18 @@ class CLIAdapter: return ".gemini" elif self.platform == "antigravity": return ".agent" + elif self.platform == "windsurf": + return ".windsurf" elif self.platform == "qoder": return ".qoder" + elif self.platform == "codebuddy": + return ".codebuddy" + elif self.platform == "copilot": + return ".github/copilot" + elif self.platform == "droid": + return ".factory" + elif self.platform == "pi": + return ".pi" else: return ".claude" @@ -117,7 +137,7 @@ class CLIAdapter: project_root: Project root directory Returns: - Path to config directory (.claude, .opencode, .cursor, .iflow, .agents, .kilocode, .kiro, .gemini, .agent, or .qoder) + Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .windsurf, .qoder, .codebuddy, .github/copilot, .factory, or .pi) """ return project_root / self.config_dir_name @@ -129,9 +149,11 @@ class CLIAdapter: project_root: Project root directory Returns: - Path to agent .md file + Path to agent definition file (.md for most platforms, .toml for Codex) """ mapped_name = self.get_agent_name(agent) + if self.platform == "codex": + return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml" return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md" def get_commands_path(self, project_root: Path, *parts: str) -> Path: @@ -147,8 +169,31 @@ class CLIAdapter: Note: Cursor uses prefix naming: .cursor/commands/trellis-<name>.md Antigravity uses workflow directory: .agent/workflows/<name>.md + Windsurf uses workflow directory: .windsurf/workflows/trellis-<name>.md + Copilot uses prompt files: .github/prompts/<name>.prompt.md + Pi uses prompt templates: .pi/prompts/trellis-<name>.md Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md """ + if self.platform == "pi": + prompts_dir = self.get_config_dir(project_root) / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"trellis-{filename}.md" + return prompts_dir / Path(*parts) + + if self.platform == "windsurf": + workflow_dir = self.get_config_dir(project_root) / "workflows" + if not parts: + return workflow_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + return workflow_dir / f"trellis-{filename}" + return workflow_dir / Path(*parts) + if self.platform in ("antigravity", "kilo"): workflow_dir = self.get_config_dir(project_root) / "workflows" if not parts: @@ -158,6 +203,17 @@ class CLIAdapter: return workflow_dir / filename return workflow_dir / Path(*parts) + if self.platform == "copilot": + prompts_dir = project_root / ".github" / "prompts" + if not parts: + return prompts_dir + if len(parts) >= 2 and parts[0] == "trellis": + filename = parts[-1] + if filename.endswith(".md"): + filename = filename[:-3] + return prompts_dir / f"{filename}.prompt.md" + return prompts_dir / Path(*parts) + if not parts: return self.get_config_dir(project_root) / "commands" @@ -175,31 +231,43 @@ class CLIAdapter: """Get relative path to a trellis command file. Args: - name: Command name without extension (e.g., 'finish-work', 'check-backend') + name: Command name without extension (e.g., 'finish-work', 'check') Returns: Relative path string for use in JSONL entries Note: Cursor: .cursor/commands/trellis-<name>.md - Codex: .agents/skills/<name>/SKILL.md - Kiro: .kiro/skills/<name>/SKILL.md + Codex: .agents/skills/trellis-<name>/SKILL.md + Kiro: .kiro/skills/trellis-<name>/SKILL.md Gemini: .gemini/commands/trellis/<name>.toml Antigravity: .agent/workflows/<name>.md + Windsurf: .windsurf/workflows/trellis-<name>.md + Pi: .pi/prompts/trellis-<name>.md Others: .{platform}/commands/trellis/<name>.md """ if self.platform == "cursor": return f".cursor/commands/trellis-{name}.md" elif self.platform == "codex": - return f".agents/skills/{name}/SKILL.md" + # 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix + # (see that release's manifest for the 60+ rename entries). + return f".agents/skills/trellis-{name}/SKILL.md" elif self.platform == "kiro": - return f".kiro/skills/{name}/SKILL.md" + return f".kiro/skills/trellis-{name}/SKILL.md" elif self.platform == "gemini": return f".gemini/commands/trellis/{name}.toml" elif self.platform == "antigravity": return f".agent/workflows/{name}.md" + elif self.platform == "windsurf": + return f".windsurf/workflows/trellis-{name}.md" elif self.platform == "kilo": return f".kilocode/workflows/{name}.md" + elif self.platform == "copilot": + return f".github/prompts/{name}.prompt.md" + elif self.platform == "droid": + return f".factory/commands/trellis/{name}.md" + elif self.platform == "pi": + return f".pi/prompts/trellis-{name}.md" else: return f"{self.config_dir_name}/commands/trellis/{name}.md" @@ -225,8 +293,18 @@ class CLIAdapter: return {} # Gemini CLI doesn't have a non-interactive env var elif self.platform == "antigravity": return {} + elif self.platform == "windsurf": + return {} elif self.platform == "qoder": return {} + elif self.platform == "codebuddy": + return {} + elif self.platform == "copilot": + return {} + elif self.platform == "droid": + return {} + elif self.platform == "pi": + return {} else: return {"CLAUDE_NON_INTERACTIVE": "1"} @@ -278,12 +356,8 @@ class CLIAdapter: cmd.append(prompt) elif self.platform == "iflow": - cmd = ["iflow", "-p"] - cmd.extend(["-y", "--agent", mapped_agent]) - # iFlow doesn't support --session-id on creation - if verbose: - cmd.append("--verbose") - cmd.append(prompt) + cmd = ["iflow", "-y", "-p"] + cmd.append(f"${mapped_agent} {prompt}") elif self.platform == "codex": cmd = ["codex", "exec"] cmd.append(prompt) @@ -296,8 +370,26 @@ class CLIAdapter: raise ValueError( "Antigravity workflows are UI slash commands; CLI agent run is not supported." ) + elif self.platform == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI agent run is not supported." + ) elif self.platform == "qoder": cmd = ["qodercli", "-p", prompt] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI agent run is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI agent run is not yet supported." + ) + elif self.platform == "pi": + cmd = ["pi", "-p", prompt] else: # claude cmd = ["claude", "-p"] @@ -344,8 +436,26 @@ class CLIAdapter: raise ValueError( "Antigravity workflows are UI slash commands; CLI resume is not supported." ) + elif self.platform == "windsurf": + raise ValueError( + "Windsurf workflows are UI slash commands; CLI resume is not supported." + ) elif self.platform == "qoder": return ["qodercli", "--resume", session_id] + elif self.platform == "codebuddy": + raise ValueError( + "CodeBuddy does not support non-interactive mode (no CLI agent)" + ) + elif self.platform == "copilot": + raise ValueError( + "GitHub Copilot is IDE-only; CLI resume is not supported." + ) + elif self.platform == "droid": + raise ValueError( + "Factory Droid CLI resume is not yet supported." + ) + elif self.platform == "pi": + return ["pi", "-c", session_id] else: return ["claude", "--resume", session_id] @@ -408,8 +518,18 @@ class CLIAdapter: return "gemini" elif self.platform == "antigravity": return "agy" + elif self.platform == "windsurf": + return "windsurf" elif self.platform == "qoder": return "qodercli" + elif self.platform == "codebuddy": + return "codebuddy" + elif self.platform == "copilot": + return "copilot" + elif self.platform == "droid": + return "droid" + elif self.platform == "pi": + return "pi" else: return "claude" @@ -417,9 +537,18 @@ class CLIAdapter: def supports_cli_agents(self) -> bool: """Check if platform supports running agents via CLI. - Claude Code, OpenCode, and iFlow support CLI agent execution. + Claude Code, OpenCode, iFlow, and Codex support CLI agent execution. Cursor is IDE-only and doesn't support CLI agents. """ + return self.platform in ("claude", "opencode", "iflow", "codex", "pi") + + @property + def requires_agent_definition_file(self) -> bool: + """Check if platform requires an agent definition file (.md/.toml) to run. + + Claude Code, OpenCode, iFlow: require agent .md files (--agent flag). + Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag. + """ return self.platform in ("claude", "opencode", "iflow") # ========================================================================= @@ -465,7 +594,7 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: """Get CLI adapter for the specified platform. Args: - platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder') + platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi') Returns: CLIAdapter instance @@ -483,15 +612,53 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter: "kiro", "gemini", "antigravity", + "windsurf", "qoder", + "codebuddy", + "copilot", + "droid", + "pi", ): raise ValueError( - f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder')" + f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')" ) return CLIAdapter(platform=platform) # type: ignore +_ALL_PLATFORM_CONFIG_DIRS = ( + ".claude", + ".cursor", + ".iflow", + ".opencode", + ".codex", + ".kilocode", + ".kiro", + ".gemini", + ".agent", + ".windsurf", + ".qoder", + ".codebuddy", + ".github/copilot", + ".factory", + ".pi", +) +"""Platform-specific config directory names used by detect_platform exclusion +checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform +layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the +agentskills.io standard), not a single-platform signal. Its presence must not +block detection of Kiro, Antigravity, Windsurf, or other platforms.""" + + +def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool: + """Check if any platform config dir exists besides those in *exclude*.""" + return any( + (project_root / d).is_dir() + for d in _ALL_PLATFORM_CONFIG_DIRS + if d not in exclude + ) + + def detect_platform(project_root: Path) -> Platform: """Auto-detect platform based on existing config directories. @@ -500,19 +667,22 @@ def detect_platform(project_root: Path) -> Platform: 2. .opencode directory exists → opencode 3. .iflow directory exists → iflow 4. .cursor directory exists (without .claude) → cursor - 5. .agents/skills exists and no other platform dirs → codex + 5. .codex exists and no other platform dirs → codex 6. .kilocode directory exists → kilo 7. .kiro/skills exists and no other platform dirs → kiro 8. .gemini directory exists → gemini 9. .agent/workflows exists and no other platform dirs → antigravity - 10. .qoder directory exists → qoder - 11. Default → claude + 10. .windsurf/workflows exists and no other platform dirs → windsurf + 11. .codebuddy directory exists → codebuddy + 12. .qoder directory exists → qoder + 13. .pi directory exists → pi + 14. Default → claude Args: project_root: Project root directory Returns: - Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', or 'qoder') + Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or default 'claude') """ import os @@ -528,17 +698,20 @@ def detect_platform(project_root: Path) -> Platform: "kiro", "gemini", "antigravity", + "windsurf", "qoder", + "codebuddy", + "copilot", + "droid", + "pi", ): return env_platform # type: ignore # Check for .opencode directory (OpenCode-specific) - # Note: .claude might exist in both platforms during migration if (project_root / ".opencode").is_dir(): return "opencode" # Check for .iflow directory (iFlow-specific) - # Note: .claude might exist in both platforms during migration if (project_root / ".iflow").is_dir(): return "iflow" @@ -551,21 +724,11 @@ def detect_platform(project_root: Path) -> Platform: if (project_root / ".gemini").is_dir(): return "gemini" - # Check for Codex skills directory only when no other platform config exists - other_platform_dirs_codex = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".kilocode", - ".kiro", - ".gemini", - ".agent", - ) - has_other_platform_config = any( - (project_root / directory).is_dir() for directory in other_platform_dirs_codex - ) - if (project_root / ".agents" / "skills").is_dir() and not has_other_platform_config: + # Check for .codex directory (Codex-specific) + # .agents/skills/ alone does NOT trigger codex detection (it's a shared standard) + if (project_root / ".codex").is_dir() and not _has_other_platform_dir( + project_root, {".codex", ".agents"} + ): return "codex" # Check for .kilocode directory (Kilo-specific) @@ -573,45 +736,65 @@ def detect_platform(project_root: Path) -> Platform: return "kilo" # Check for Kiro skills directory only when no other platform config exists - other_platform_dirs_kiro = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".agents", - ".kilocode", - ".gemini", - ".agent", - ) - has_other_platform_config = any( - (project_root / directory).is_dir() for directory in other_platform_dirs_kiro - ) - if (project_root / ".kiro" / "skills").is_dir() and not has_other_platform_config: + if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir( + project_root, {".kiro"} + ): return "kiro" # Check for Antigravity workflow directory only when no other platform config exists - other_platform_dirs_antigravity = ( - ".claude", - ".cursor", - ".iflow", - ".opencode", - ".agents", - ".kilocode", - ".kiro", - ) - has_other_platform_config = any( - (project_root / directory).is_dir() - for directory in other_platform_dirs_antigravity - ) if ( project_root / ".agent" / "workflows" - ).is_dir() and not has_other_platform_config: + ).is_dir() and not _has_other_platform_dir( + project_root, {".agent", ".gemini"} + ): return "antigravity" + # Check for Windsurf workflow directory only when no other platform config exists + if ( + project_root / ".windsurf" / "workflows" + ).is_dir() and not _has_other_platform_dir( + project_root, {".windsurf"} + ): + return "windsurf" + + # Check for .codebuddy directory (CodeBuddy-specific) + if (project_root / ".codebuddy").is_dir(): + return "codebuddy" + # Check for .qoder directory (Qoder-specific) if (project_root / ".qoder").is_dir(): return "qoder" + # Check for .github/copilot directory (GitHub Copilot-specific) + if (project_root / ".github" / "copilot").is_dir(): + return "copilot" + + # Check for .factory directory (Factory Droid-specific) + if (project_root / ".factory").is_dir(): + return "droid" + + # Check for .pi directory (Pi Agent-specific) + if (project_root / ".pi").is_dir(): + return "pi" + + # Fallback: checkout only has the Codex shared-skills layer + # (.agents/skills/trellis-* dirs) and no explicit platform config dir. + # Happens on fresh clones where .codex/ is gitignored/absent but the + # shared skills were committed to git. Must guard against the case + # where .claude/ or any other platform dir also exists — .agents/skills/ + # can legitimately coexist with any platform as a shared consumption + # layer for Amp/Cline/Warp/etc. + agents_skills = project_root / ".agents" / "skills" + if agents_skills.is_dir() and not _has_other_platform_dir( + project_root, set() + ): + try: + for entry in agents_skills.iterdir(): + if entry.is_dir() and entry.name.startswith("trellis-"): + return "codex" + except OSError: + pass + return "claude" diff --git a/.trellis/scripts/common/config.py b/.trellis/scripts/common/config.py index 601ab32..ecae1b3 100755 --- a/.trellis/scripts/common/config.py +++ b/.trellis/scripts/common/config.py @@ -7,10 +7,136 @@ Reads settings from .trellis/config.yaml with sensible defaults. from __future__ import annotations +import sys from pathlib import Path from .paths import DIR_WORKFLOW, get_repo_root -from .worktree import parse_simple_yaml + + +# ============================================================================= +# YAML Simple Parser (no dependencies) +# ============================================================================= + + +def _unquote(s: str) -> str: + """Remove exactly one layer of matching surrounding quotes. + + Unlike str.strip('"'), this only removes the outermost pair, + preserving any nested quotes inside the value. + + Examples: + _unquote('"hello"') -> 'hello' + _unquote("'hello'") -> 'hello' + _unquote('"echo \\'hi\\'"') -> "echo 'hi'" + _unquote('hello') -> 'hello' + _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged) + """ + if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): + return s[1:-1] + return s + + +def parse_simple_yaml(content: str) -> dict: + """Parse simple YAML with nested dict support (no dependencies). + + Supports: + - key: value (string) + - key: (followed by list items) + - item1 + - item2 + - key: (followed by nested dict) + nested_key: value + nested_key2: + - item + + Uses indentation to detect nesting (2+ spaces deeper = child). + + Args: + content: YAML content string. + + Returns: + Parsed dict (values can be str, list[str], or dict). + """ + lines = content.splitlines() + result: dict = {} + _parse_yaml_block(lines, 0, 0, result) + return result + + +def _parse_yaml_block( + lines: list[str], start: int, min_indent: int, target: dict +) -> int: + """Parse a YAML block into target dict, returning next line index.""" + i = start + current_list: list | None = None + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Skip empty lines and comments + if not stripped or stripped.startswith("#"): + i += 1 + continue + + # Calculate indentation + indent = len(line) - len(line.lstrip()) + + # If dedented past our block, we're done + if indent < min_indent: + break + + if stripped.startswith("- "): + if current_list is not None: + current_list.append(_unquote(stripped[2:].strip())) + i += 1 + elif ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = _unquote(value.strip()) + current_list = None + + if value: + # key: value + target[key] = value + i += 1 + else: + # key: (no value) — peek ahead to determine list vs nested dict + next_i, next_line = _next_content_line(lines, i + 1) + if next_i >= len(lines): + target[key] = {} + i = next_i + elif next_line.strip().startswith("- "): + # It's a list + current_list = [] + target[key] = current_list + i += 1 + else: + next_indent = len(next_line) - len(next_line.lstrip()) + if next_indent > indent: + # It's a nested dict + nested: dict = {} + target[key] = nested + i = _parse_yaml_block(lines, i + 1, next_indent, nested) + else: + # Empty value, same or less indent follows + target[key] = {} + i += 1 + else: + i += 1 + + return i + + +def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: + """Find the next non-empty, non-comment line.""" + i = start + while i < len(lines): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + return i, lines[i] + i += 1 + return i, "" # Defaults @@ -20,6 +146,15 @@ DEFAULT_MAX_JOURNAL_LINES = 2000 CONFIG_FILE = "config.yaml" +def _is_true_config_value(value: object) -> bool: + """Return True when a config value represents an enabled flag.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False + + def _get_config_path(repo_root: Path | None = None) -> Path: """Get path to config.yaml.""" root = repo_root or get_repo_root() @@ -70,3 +205,185 @@ def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: if isinstance(commands, list): return [str(c) for c in commands] return [] + + +# ============================================================================= +# Monorepo / Packages +# ============================================================================= + + +def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None: + """Get monorepo package declarations. + + Returns: + Dict mapping package name to its config (path, type, etc.), + or None if not configured (single-repo mode). + + Example return: + {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}} + """ + config = _load_config(repo_root) + packages = config.get("packages") + if not isinstance(packages, dict): + return None + # Ensure each value is a dict (filter out scalar entries) + filtered = {k: v for k, v in packages.items() if isinstance(v, dict)} + if not filtered: + return None + return filtered + + +def get_default_package(repo_root: Path | None = None) -> str | None: + """Get the default package name from config. + + Returns: + Package name string, or None if not configured. + """ + config = _load_config(repo_root) + value = config.get("default_package") + return str(value) if value else None + + +def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that are git submodules. + + Returns: + Dict mapping package name to its path for submodule-type packages. + Empty dict if none configured. + + Example return: + {"docs-site": "docs-site"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if cfg.get("type") == "submodule" + } + + +def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: + """Get packages that have their own independent git repository. + + These are sub-directories with their own .git (not submodules), + marked with ``git: true`` in config.yaml. + + Returns: + Dict mapping package name to its path for git-repo packages. + Empty dict if none configured. + + Example config:: + + packages: + backend: + path: iqs + git: true + + Example return:: + + {"backend": "iqs"} + """ + packages = get_packages(repo_root) + if packages is None: + return {} + return { + name: cfg.get("path", name) + for name, cfg in packages.items() + if _is_true_config_value(cfg.get("git")) + } + + +def is_monorepo(repo_root: Path | None = None) -> bool: + """Check if the project is configured as a monorepo (has packages in config).""" + return get_packages(repo_root) is not None + + +def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str: + """Get the spec directory base path relative to .trellis/. + + Single-repo: returns "spec" + Monorepo with package: returns "spec/<package>" + Monorepo without package: returns "spec" (caller should specify package) + """ + if package and is_monorepo(repo_root): + return f"spec/{package}" + return "spec" + + +def validate_package(package: str, repo_root: Path | None = None) -> bool: + """Check if a package name is valid in this project. + + Single-repo (no packages configured): always returns True. + Monorepo: returns True only if package exists in config.yaml packages. + """ + packages = get_packages(repo_root) + if packages is None: + return True # Single-repo, no validation needed + return package in packages + + +def resolve_package( + task_package: str | None = None, + repo_root: Path | None = None, +) -> str | None: + """Resolve package from inferred sources with validation. + + Checks in order: task_package → default_package. + Invalid inferred values print a warning to stderr and are skipped. + + Returns: + Resolved package name, or None if no valid package found. + + Note: + CLI --package should be validated separately by the caller + (fail-fast with available packages list on error). + """ + packages = get_packages(repo_root) + if packages is None: + return None # Single-repo, no package needed + + # Try task_package (guard against non-string values from malformed JSON) + if task_package and isinstance(task_package, str): + if task_package in packages: + return task_package + print( + f"Warning: task.json package '{task_package}' not found in config, skipping", + file=sys.stderr, + ) + + # Try default_package + default = get_default_package(repo_root) + if default: + if default in packages: + return default + print( + f"Warning: default_package '{default}' not found in config, skipping", + file=sys.stderr, + ) + + return None + + +def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None: + """Get session.spec_scope configuration. + + Returns: + list[str]: Package names to include in spec scanning. + str: "active_task" to use current task's package. + None: No scope configured (scan all packages). + """ + config = _load_config(repo_root) + session = config.get("session") + if not isinstance(session, dict): + return None + + scope = session.get("spec_scope") + if scope is None: + return None + if isinstance(scope, str): + return scope # e.g. "active_task" + if isinstance(scope, list): + return [str(s) for s in scope] + return None diff --git a/.trellis/scripts/common/developer.py b/.trellis/scripts/common/developer.py index 7f3cf0c..c203a31 100755 --- a/.trellis/scripts/common/developer.py +++ b/.trellis/scripts/common/developer.py @@ -123,8 +123,8 @@ def init_developer(name: str, repo_root: Path | None = None) -> bool: ## Session History <!-- @@@auto:session-history --> -| # | Date | Title | Commits | -|---|------|-------|---------| +| # | Date | Title | Commits | Branch | +|---|------|-------|---------|--------| <!-- @@@/auto:session-history --> --- diff --git a/.trellis/scripts/common/git.py b/.trellis/scripts/common/git.py new file mode 100755 index 0000000..c4bf29f --- /dev/null +++ b/.trellis/scripts/common/git.py @@ -0,0 +1,31 @@ +""" +Git command execution utility. + +Single source of truth for running git commands across all Trellis scripts. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: + """Run a git command and return (returncode, stdout, stderr). + + Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure + consistent output across all platforms (Windows, macOS, Linux). + """ + try: + git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args + result = subprocess.run( + git_args, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + return result.returncode, result.stdout, result.stderr + except Exception as e: + return 1, "", str(e) diff --git a/.trellis/scripts/common/git_context.py b/.trellis/scripts/common/git_context.py index 39b9ff5..31a0e77 100755 --- a/.trellis/scripts/common/git_context.py +++ b/.trellis/scripts/common/git_context.py @@ -3,6 +3,8 @@ """ Git and Session Context utilities. +Entry shim — delegates to session_context and packages_context. + Provides: output_json - Output context in JSON format output_text - Output context in text format @@ -11,599 +13,34 @@ Provides: from __future__ import annotations import json -import subprocess -from pathlib import Path -from .paths import ( - DIR_SCRIPTS, - DIR_SPEC, - DIR_TASKS, - DIR_WORKFLOW, - DIR_WORKSPACE, - FILE_TASK_JSON, - count_lines, - get_active_journal_file, - get_current_task, - get_developer, - get_repo_root, - get_tasks_dir, +from .git import run_git +from .session_context import ( + get_context_json, + get_context_text, + get_context_record_json, + get_context_text_record, + output_json, + output_text, +) +from .packages_context import ( + get_context_packages_text, + get_context_packages_json, +) +from .workflow_phase import ( + filter_platform, + get_phase_index, + get_step, ) -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]: - """Run a git command and return (returncode, stdout, stderr). - - Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure - consistent output across all platforms (Windows, macOS, Linux). - """ - try: - # Force git to output UTF-8 for consistent cross-platform behavior - git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args - result = subprocess.run( - git_args, - cwd=cwd, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - return result.returncode, result.stdout, result.stderr - except Exception as e: - return 1, "", str(e) - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -# ============================================================================= -# JSON Output -# ============================================================================= - - -def get_context_json(repo_root: Path | None = None) -> dict: - """Get context as a dictionary. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Context dictionary. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - tasks_dir = get_tasks_dir(repo_root) - journal_file = get_active_journal_file(repo_root) - - journal_lines = 0 - journal_relative = "" - if journal_file and developer: - journal_lines = count_lines(journal_file) - journal_relative = ( - f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" - ) - - # Git info - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - git_status_count = len([line for line in status_out.splitlines() if line.strip()]) - is_clean = git_status_count == 0 - - # Recent commits - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - commits = [] - for line in log_out.splitlines(): - if line.strip(): - parts = line.split(" ", 1) - if len(parts) >= 2: - commits.append({"hash": parts[0], "message": parts[1]}) - elif len(parts) == 1: - commits.append({"hash": parts[0], "message": ""}) - - # Tasks - tasks = [] - if tasks_dir.is_dir(): - for d in tasks_dir.iterdir(): - if d.is_dir() and d.name != "archive": - task_json_path = d / FILE_TASK_JSON - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - tasks.append( - { - "dir": d.name, - "name": data.get("name") or data.get("id") or "unknown", - "status": data.get("status", "unknown"), - "children": data.get("children", []), - "parent": data.get("parent"), - } - ) - - return { - "developer": developer or "", - "git": { - "branch": branch, - "isClean": is_clean, - "uncommittedChanges": git_status_count, - "recentCommits": commits, - }, - "tasks": { - "active": tasks, - "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", - }, - "journal": { - "file": journal_relative, - "lines": journal_lines, - "nearLimit": journal_lines > 1800, - }, - } - - -def output_json(repo_root: Path | None = None) -> None: - """Output context in JSON format. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - context = get_context_json(repo_root) - print(json.dumps(context, indent=2, ensure_ascii=False)) - - -# ============================================================================= -# Text Output -# ============================================================================= - - -def get_context_text(repo_root: Path | None = None) -> str: - """Get context as formatted text. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Formatted text output. - """ - if repo_root is None: - repo_root = get_repo_root() - - lines = [] - lines.append("========================================") - lines.append("SESSION CONTEXT") - lines.append("========================================") - lines.append("") - - developer = get_developer(repo_root) - - # Developer section - lines.append("## DEVELOPER") - if not developer: - lines.append( - f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" - ) - return "\n".join(lines) - - lines.append(f"Name: {developer}") - lines.append("") - - # Git status - lines.append("## GIT STATUS") - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - lines.append(f"Branch: {branch}") - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - status_lines = [line for line in status_out.splitlines() if line.strip()] - status_count = len(status_lines) - - if status_count == 0: - lines.append("Working directory: Clean") - else: - lines.append(f"Working directory: {status_count} uncommitted change(s)") - lines.append("") - lines.append("Changes:") - _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) - for line in short_out.splitlines()[:10]: - lines.append(line) - lines.append("") - - # Recent commits - lines.append("## RECENT COMMITS") - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - if log_out.strip(): - for line in log_out.splitlines(): - lines.append(line) - else: - lines.append("(no commits)") - lines.append("") - - # Current task - lines.append("## CURRENT TASK") - current_task = get_current_task(repo_root) - if current_task: - current_task_dir = repo_root / current_task - task_json_path = current_task_dir / FILE_TASK_JSON - lines.append(f"Path: {current_task}") - - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - t_name = data.get("name") or data.get("id") or "unknown" - t_status = data.get("status", "unknown") - t_created = data.get("createdAt", "unknown") - t_desc = data.get("description", "") - - lines.append(f"Name: {t_name}") - lines.append(f"Status: {t_status}") - lines.append(f"Created: {t_created}") - if t_desc: - lines.append(f"Description: {t_desc}") - - # Check for prd.md - prd_file = current_task_dir / "prd.md" - if prd_file.is_file(): - lines.append("") - lines.append("[!] This task has prd.md - read it for task details") - else: - lines.append("(none)") - lines.append("") - - # Active tasks - lines.append("## ACTIVE TASKS") - tasks_dir = get_tasks_dir(repo_root) - task_count = 0 - - # Collect all task data for hierarchy display - all_task_data: dict[str, dict] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - dir_name = d.name - t_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "-" - children: list[str] = [] - parent: str | None = None - - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "-") - children = data.get("children", []) - parent = data.get("parent") - - all_task_data[dir_name] = { - "status": status, - "assignee": assignee, - "children": children, - "parent": parent, - } - - def _children_progress(children_list: list[str]) -> str: - if not children_list: - return "" - done = 0 - for c in children_list: - if c in all_task_data and all_task_data[c]["status"] in ("completed", "done"): - done += 1 - return f" [{done}/{len(children_list)} done]" - - def _print_task_tree(name: str, indent: int = 0) -> None: - nonlocal task_count - info = all_task_data[name] - progress = _children_progress(info["children"]) if info["children"] else "" - prefix = " " * indent - lines.append(f"{prefix}- {name}/ ({info['status']}){progress} @{info['assignee']}") - task_count += 1 - for child in info["children"]: - if child in all_task_data: - _print_task_tree(child, indent + 1) - - for dir_name in sorted(all_task_data.keys()): - if not all_task_data[dir_name]["parent"]: - _print_task_tree(dir_name) - - if task_count == 0: - lines.append("(no active tasks)") - lines.append(f"Total: {task_count} active task(s)") - lines.append("") - - # My tasks - lines.append("## MY TASKS (Assigned to me)") - my_task_count = 0 - - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - assignee = data.get("assignee", "") - status = data.get("status", "planning") - - if assignee == developer and status != "done": - title = data.get("title") or data.get("name") or "unknown" - priority = data.get("priority", "P2") - children_list = data.get("children", []) - progress = _children_progress(children_list) if children_list else "" - lines.append(f"- [{priority}] {title} ({status}){progress}") - my_task_count += 1 - - if my_task_count == 0: - lines.append("(no tasks assigned to you)") - lines.append("") - - # Journal file - lines.append("## JOURNAL FILE") - journal_file = get_active_journal_file(repo_root) - if journal_file: - journal_lines = count_lines(journal_file) - relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" - lines.append(f"Active file: {relative}") - lines.append(f"Line count: {journal_lines} / 2000") - if journal_lines > 1800: - lines.append("[!] WARNING: Approaching 2000 line limit!") - else: - lines.append("No journal file found") - lines.append("") - - # Paths - lines.append("## PATHS") - lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") - lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") - lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") - lines.append("") - - lines.append("========================================") - - return "\n".join(lines) - - -def get_context_record_json(repo_root: Path | None = None) -> dict: - """Get record-mode context as a dictionary. - - Focused on: my active tasks, git status, current task. - """ - if repo_root is None: - repo_root = get_repo_root() - - developer = get_developer(repo_root) - tasks_dir = get_tasks_dir(repo_root) - - # Git info - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - git_status_count = len([line for line in status_out.splitlines() if line.strip()]) - - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - commits = [] - for line in log_out.splitlines(): - if line.strip(): - parts = line.split(" ", 1) - if len(parts) >= 2: - commits.append({"hash": parts[0], "message": parts[1]}) - - # My tasks - my_tasks = [] - all_task_statuses: dict[str, str] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - all_task_statuses[d.name] = data.get("status", "unknown") - - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data and data.get("assignee") == developer: - children_list = data.get("children", []) - done = sum(1 for c in children_list if all_task_statuses.get(c) in ("completed", "done")) - my_tasks.append({ - "dir": d.name, - "title": data.get("title") or data.get("name") or "unknown", - "status": data.get("status", "unknown"), - "priority": data.get("priority", "P2"), - "children": children_list, - "childrenDone": done, - "parent": data.get("parent"), - "meta": data.get("meta", {}), - }) - - # Current task - current_task_info = None - current_task = get_current_task(repo_root) - if current_task: - task_json_path = (repo_root / current_task) / FILE_TASK_JSON - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - current_task_info = { - "path": current_task, - "name": data.get("name") or data.get("id") or "unknown", - "status": data.get("status", "unknown"), - } - - return { - "developer": developer or "", - "git": { - "branch": branch, - "isClean": git_status_count == 0, - "uncommittedChanges": git_status_count, - "recentCommits": commits, - }, - "myTasks": my_tasks, - "currentTask": current_task_info, - } - - -def get_context_text_record(repo_root: Path | None = None) -> str: - """Get context as formatted text for record-session mode. - - Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), - then GIT STATUS, RECENT COMMITS, CURRENT TASK. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Formatted text output for record-session. - """ - if repo_root is None: - repo_root = get_repo_root() - - lines: list[str] = [] - lines.append("========================================") - lines.append("SESSION CONTEXT (RECORD MODE)") - lines.append("========================================") - lines.append("") - - developer = get_developer(repo_root) - if not developer: - lines.append( - f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" - ) - return "\n".join(lines) - - # MY ACTIVE TASKS — first and prominent - lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") - lines.append("[!] Review whether any should be archived before recording this session.") - lines.append("") - - tasks_dir = get_tasks_dir(repo_root) - my_task_count = 0 - - # Collect task data for children progress - all_task_statuses: dict[str, str] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - all_task_statuses[d.name] = data.get("status", "unknown") - - def _record_children_progress(children_list: list[str]) -> str: - if not children_list: - return "" - done = 0 - for c in children_list: - if all_task_statuses.get(c) in ("completed", "done"): - done += 1 - return f" [{done}/{len(children_list)} done]" - - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if d.is_dir() and d.name != "archive": - t_json = d / FILE_TASK_JSON - if t_json.is_file(): - data = _read_json_file(t_json) - if data: - assignee = data.get("assignee", "") - status = data.get("status", "planning") - - if assignee == developer: - title = data.get("title") or data.get("name") or "unknown" - priority = data.get("priority", "P2") - children_list = data.get("children", []) - progress = _record_children_progress(children_list) if children_list else "" - lines.append(f"- [{priority}] {title} ({status}){progress} — {d.name}") - my_task_count += 1 - - if my_task_count == 0: - lines.append("(no active tasks assigned to you)") - lines.append("") - - # GIT STATUS - lines.append("## GIT STATUS") - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - branch = branch_out.strip() or "unknown" - lines.append(f"Branch: {branch}") - - _, status_out, _ = _run_git_command(["status", "--porcelain"], cwd=repo_root) - status_lines = [line for line in status_out.splitlines() if line.strip()] - status_count = len(status_lines) - - if status_count == 0: - lines.append("Working directory: Clean") - else: - lines.append(f"Working directory: {status_count} uncommitted change(s)") - lines.append("") - lines.append("Changes:") - _, short_out, _ = _run_git_command(["status", "--short"], cwd=repo_root) - for line in short_out.splitlines()[:10]: - lines.append(line) - lines.append("") - - # RECENT COMMITS - lines.append("## RECENT COMMITS") - _, log_out, _ = _run_git_command(["log", "--oneline", "-5"], cwd=repo_root) - if log_out.strip(): - for line in log_out.splitlines(): - lines.append(line) - else: - lines.append("(no commits)") - lines.append("") - - # CURRENT TASK - lines.append("## CURRENT TASK") - current_task = get_current_task(repo_root) - if current_task: - current_task_dir = repo_root / current_task - task_json_path = current_task_dir / FILE_TASK_JSON - lines.append(f"Path: {current_task}") - - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - t_name = data.get("name") or data.get("id") or "unknown" - t_status = data.get("status", "unknown") - lines.append(f"Name: {t_name}") - lines.append(f"Status: {t_status}") - else: - lines.append("(none)") - lines.append("") - - lines.append("========================================") - - return "\n".join(lines) - - -def output_text(repo_root: Path | None = None) -> None: - """Output context in text format. - - Args: - repo_root: Repository root path. Defaults to auto-detected. - """ - print(get_context_text(repo_root)) +# Backward-compatible alias — external modules import this name +_run_git_command = run_git # ============================================================================= # Main Entry # ============================================================================= - def main() -> None: """CLI entry point.""" import argparse @@ -618,9 +55,17 @@ def main() -> None: parser.add_argument( "--mode", "-m", - choices=["default", "record"], + choices=["default", "record", "packages", "phase"], default="default", - help="Output mode: default (full context) or record (for record-session)", + help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)", + ) + parser.add_argument( + "--step", + help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.", + ) + parser.add_argument( + "--platform", + help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.", ) args = parser.parse_args() @@ -630,6 +75,21 @@ def main() -> None: print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False)) else: print(get_context_text_record()) + elif args.mode == "packages": + if args.json: + print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False)) + else: + print(get_context_packages_text()) + elif args.mode == "phase": + content = get_step(args.step) if args.step else get_phase_index() + if not content.strip(): + if args.step: + parser.exit(2, f"Step not found: {args.step}\n") + else: + parser.exit(2, "Phase Index section not found in workflow.md\n") + if args.platform: + content = filter_platform(content, args.platform) + print(content, end="") else: if args.json: output_json() diff --git a/.trellis/scripts/common/io.py b/.trellis/scripts/common/io.py new file mode 100755 index 0000000..44288f4 --- /dev/null +++ b/.trellis/scripts/common/io.py @@ -0,0 +1,37 @@ +""" +JSON file I/O utilities. + +Provides read_json and write_json as the single source of truth +for JSON file operations across all Trellis scripts. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def read_json(path: Path) -> dict | None: + """Read and parse a JSON file. + + Returns None if the file doesn't exist, is invalid JSON, or can't be read. + """ + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def write_json(path: Path, data: dict) -> bool: + """Write dict to JSON file with pretty formatting. + + Returns True on success, False on error. + """ + try: + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return True + except (OSError, IOError): + return False diff --git a/.trellis/scripts/common/log.py b/.trellis/scripts/common/log.py new file mode 100755 index 0000000..839c643 --- /dev/null +++ b/.trellis/scripts/common/log.py @@ -0,0 +1,45 @@ +""" +Terminal output utilities: colors and structured logging. + +Single source of truth for Colors and log_* functions +used across all Trellis scripts. +""" + +from __future__ import annotations + + +class Colors: + """ANSI color codes for terminal output.""" + + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + DIM = "\033[2m" + NC = "\033[0m" # No Color / Reset + + +def colored(text: str, color: str) -> str: + """Apply ANSI color to text.""" + return f"{color}{text}{Colors.NC}" + + +def log_info(msg: str) -> None: + """Print info-level message with [INFO] prefix.""" + print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") + + +def log_success(msg: str) -> None: + """Print success message with [SUCCESS] prefix.""" + print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") + + +def log_warn(msg: str) -> None: + """Print warning message with [WARN] prefix.""" + print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") + + +def log_error(msg: str) -> None: + """Print error message with [ERROR] prefix.""" + print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") diff --git a/.trellis/scripts/common/packages_context.py b/.trellis/scripts/common/packages_context.py new file mode 100755 index 0000000..e7d4e8c --- /dev/null +++ b/.trellis/scripts/common/packages_context.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +Package discovery and context output. + +Provides: + get_packages_info - Get structured package info + get_packages_section - Build PACKAGES text section + get_context_packages_text - Full packages text output (--mode packages) + get_context_packages_json - Full packages JSON output (--mode packages --json) +""" + +from __future__ import annotations + +from pathlib import Path + +from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope +from .paths import ( + DIR_SPEC, + DIR_WORKFLOW, + get_current_task, + get_repo_root, +) +from .tasks import load_task + + +# ============================================================================= +# Internal Helpers +# ============================================================================= + +def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]: + """Scan spec directory for available layers (subdirectories). + + For monorepo: scans spec/<package>/ + For single-repo: scans spec/ + """ + target = spec_dir / package if package else spec_dir + if not target.is_dir(): + return [] + return sorted( + d.name for d in target.iterdir() if d.is_dir() and d.name != "guides" + ) + + +def _get_active_task_package(repo_root: Path) -> str | None: + """Get the package field from the active task's task.json.""" + current = get_current_task(repo_root) + if not current: + return None + ct = load_task(repo_root / current) + return ct.package if ct and ct.package else None + + +def _resolve_scope_set( + packages: dict, + spec_scope, + task_pkg: str | None, + default_pkg: str | None, +) -> set | None: + """Resolve spec_scope to a set of allowed package names, or None for full scan.""" + if not packages: + return None + + if spec_scope is None: + return None + + if isinstance(spec_scope, str) and spec_scope == "active_task": + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + if isinstance(spec_scope, list): + valid = {e for e in spec_scope if e in packages} + if valid: + return valid + # All invalid: fallback + if task_pkg and task_pkg in packages: + return {task_pkg} + if default_pkg and default_pkg in packages: + return {default_pkg} + return None + + return None + + +# ============================================================================= +# Public Functions +# ============================================================================= + +def get_packages_info(repo_root: Path) -> list[dict]: + """Get structured package info for monorepo projects. + + Returns list of dicts with keys: name, path, type, default, specLayers, + isSubmodule, isGitRepo. + Returns empty list for single-repo projects. + """ + packages = get_packages(repo_root) + if not packages: + return [] + + default_pkg = get_default_package(repo_root) + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + result = [] + + for pkg_name, pkg_config in packages.items(): + pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config) + pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local" + pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False + layers = _scan_spec_layers(spec_dir, pkg_name) + + result.append({ + "name": pkg_name, + "path": pkg_path, + "type": pkg_type, + "default": pkg_name == default_pkg, + "specLayers": layers, + "isSubmodule": pkg_type == "submodule", + "isGitRepo": _is_true_config_value(pkg_git), + }) + + return result + + +def get_packages_section(repo_root: Path) -> str: + """Build the PACKAGES section for text output.""" + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + pkg_info = get_packages_info(repo_root) + + lines: list[str] = [] + lines.append("## PACKAGES") + + if not pkg_info: + lines.append("(single-repo mode)") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + default_pkg = get_default_package(repo_root) + + for pkg in pkg_info: + layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else "" + submodule_tag = " (submodule)" if pkg["isSubmodule"] else "" + git_repo_tag = " (git repo)" if pkg["isGitRepo"] else "" + default_tag = " *" if pkg["default"] else "" + lines.append( + f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}" + ) + + if default_pkg: + lines.append(f"Default package: {default_pkg}") + + return "\n".join(lines) + + +def get_context_packages_text(repo_root: Path | None = None) -> str: + """Get packages context as formatted text (for --mode packages).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + lines: list[str] = [] + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + lines.append("Single-repo project (no packages configured)") + lines.append("") + layers = _scan_spec_layers(spec_dir) + if layers: + lines.append(f"Spec layers: {', '.join(layers)}") + return "\n".join(lines) + + # Resolve scope for annotations + packages_dict = get_packages(repo_root) or {} + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg) + + lines.append("## PACKAGES") + lines.append("") + for pkg in pkg_info: + default_tag = " (default)" if pkg["default"] else "" + type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else "" + git_tag = " [git repo]" if pkg["isGitRepo"] else "" + + # Scope annotation + scope_tag = "" + if scope_set is not None and pkg["name"] not in scope_set: + scope_tag = " (out of scope)" + + lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}") + lines.append(f"Path: {pkg['path']}") + if pkg["specLayers"]: + lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}") + for layer in pkg["specLayers"]: + lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md") + else: + lines.append("Spec: not configured") + lines.append("") + + # Also show shared guides + guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides" + if guides_dir.is_dir(): + lines.append("### Shared Guides (always included)") + lines.append("Path: .trellis/spec/guides/index.md") + lines.append("") + + return "\n".join(lines) + + +def get_context_packages_json(repo_root: Path | None = None) -> dict: + """Get packages context as a dictionary (for --mode packages --json).""" + if repo_root is None: + repo_root = get_repo_root() + + pkg_info = get_packages_info(repo_root) + + if not pkg_info: + spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC + layers = _scan_spec_layers(spec_dir) + return { + "mode": "single-repo", + "specLayers": layers, + } + + default_pkg = get_default_package(repo_root) + spec_scope = get_spec_scope(repo_root) + task_pkg = _get_active_task_package(repo_root) + + return { + "mode": "monorepo", + "packages": pkg_info, + "defaultPackage": default_pkg, + "specScope": spec_scope, + "activeTaskPackage": task_pkg, + } diff --git a/.trellis/scripts/common/paths.py b/.trellis/scripts/common/paths.py index dcbb66b..1c5a58e 100755 --- a/.trellis/scripts/common/paths.py +++ b/.trellis/scripts/common/paths.py @@ -207,21 +207,55 @@ def count_lines(file_path: Path) -> int: # Current Task Management # ============================================================================= -def _get_current_task_file(repo_root: Path | None = None) -> Path: - """Get .current-task file path. +def normalize_task_ref(task_ref: str) -> str: + """Normalize a task ref for stable runtime storage. - Args: - repo_root: Repository root path. Defaults to auto-detected. - - Returns: - Path to .current-task file. + Stored refs should prefer repo-relative POSIX paths like + `.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved + unless they can later be converted back to repo-relative form by callers. """ + normalized = task_ref.strip() + if not normalized: + return "" + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return str(path_obj) + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + if normalized.startswith(f"{DIR_TASKS}/"): + return f"{DIR_WORKFLOW}/{normalized}" + + return normalized + + +def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None: + """Resolve a task ref to an absolute task directory path.""" if repo_root is None: repo_root = get_repo_root() - return repo_root / DIR_WORKFLOW / FILE_CURRENT_TASK + + normalized = normalize_task_ref(task_ref) + if not normalized: + return None + + path_obj = Path(normalized) + if path_obj.is_absolute(): + return path_obj + + if normalized.startswith(f"{DIR_WORKFLOW}/"): + return repo_root / path_obj + + return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj -def get_current_task(repo_root: Path | None = None) -> str | None: +def get_current_task( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> str | None: """Get current task directory path (relative to repo_root). Args: @@ -230,18 +264,19 @@ def get_current_task(repo_root: Path | None = None) -> str | None: Returns: Relative path to current task directory or None. """ - current_file = _get_current_task_file(repo_root) + if repo_root is None: + repo_root = get_repo_root() - if not current_file.is_file(): - return None + from .active_task import resolve_active_task - try: - return current_file.read_text(encoding="utf-8").strip() - except (OSError, IOError): - return None + return resolve_active_task(repo_root, platform_input, platform).task_path -def get_current_task_abs(repo_root: Path | None = None) -> Path | None: +def get_current_task_abs( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> Path | None: """Get current task directory absolute path. Args: @@ -253,14 +288,33 @@ def get_current_task_abs(repo_root: Path | None = None) -> Path | None: if repo_root is None: repo_root = get_repo_root() - relative = get_current_task(repo_root) + relative = get_current_task(repo_root, platform_input, platform) if relative: - return repo_root / relative + return resolve_task_ref(relative, repo_root) return None -def set_current_task(task_path: str, repo_root: Path | None = None) -> bool: - """Set current task. +def get_current_task_source( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> tuple[str, str | None, str | None]: + """Get active task source as (`source`, `context_key`, `task_path`).""" + if repo_root is None: + repo_root = get_repo_root() + + from .active_task import get_current_task_source as _get_source + + return _get_source(repo_root, platform_input, platform) + + +def set_current_task( + task_path: str, + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> bool: + """Set current task in session scope. Args: task_path: Task directory path (relative to repo_root). @@ -272,25 +326,22 @@ def set_current_task(task_path: str, repo_root: Path | None = None) -> bool: if repo_root is None: repo_root = get_repo_root() - if not task_path: - return False + from .active_task import set_active_task - # Verify task directory exists - full_path = repo_root / task_path - if not full_path.is_dir(): - return False - - current_file = _get_current_task_file(repo_root) - - try: - current_file.write_text(task_path, encoding="utf-8") - return True - except (OSError, IOError): - return False + return set_active_task( + task_path, + repo_root, + platform_input=platform_input, + platform=platform, + ) is not None -def clear_current_task(repo_root: Path | None = None) -> bool: - """Clear current task. +def clear_current_task( + repo_root: Path | None = None, + platform_input: dict | None = None, + platform: str | None = None, +) -> bool: + """Clear current task in session scope. Args: repo_root: Repository root path. Defaults to auto-detected. @@ -298,14 +349,17 @@ def clear_current_task(repo_root: Path | None = None) -> bool: Returns: True on success. """ - current_file = _get_current_task_file(repo_root) + if repo_root is None: + repo_root = get_repo_root() - try: - if current_file.is_file(): - current_file.unlink() - return True - except (OSError, IOError): - return False + from .active_task import clear_active_task + + clear_active_task( + repo_root, + platform_input=platform_input, + platform=platform, + ) + return True def has_current_task(repo_root: Path | None = None) -> bool: @@ -333,6 +387,52 @@ def generate_task_date_prefix() -> str: return datetime.now().strftime("%m-%d") +# ============================================================================= +# Monorepo / Package Paths +# ============================================================================= + + +def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path: + """Get the spec directory path. + + Single-repo: .trellis/spec + Monorepo with package: .trellis/spec/<package> + + Uses lazy import to avoid circular dependency with config.py. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_spec_base + + base = get_spec_base(package, repo_root) + return repo_root / DIR_WORKFLOW / base + + +def get_package_path(package: str, repo_root: Path | None = None) -> Path | None: + """Get a package's source directory absolute path from config. + + Returns: + Absolute path to the package directory, or None if not found. + """ + if repo_root is None: + repo_root = get_repo_root() + + from .config import get_packages + + packages = get_packages(repo_root) + if not packages or package not in packages: + return None + + info = packages[package] + if isinstance(info, dict): + rel_path = info.get("path", package) + else: + rel_path = str(info) + + return repo_root / rel_path + + # ============================================================================= # Main Entry (for testing) # ============================================================================= diff --git a/.trellis/scripts/common/session_context.py b/.trellis/scripts/common/session_context.py new file mode 100755 index 0000000..65cd0b8 --- /dev/null +++ b/.trellis/scripts/common/session_context.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 +""" +Session context generation (default + record modes). + +Provides: + get_context_json - JSON output for default mode + get_context_text - Text output for default mode + get_context_record_json - JSON for record mode + get_context_text_record - Text for record mode + output_json - Print JSON + output_text - Print text +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .config import get_git_packages +from .git import run_git +from .packages_context import get_packages_section +from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress +from .paths import ( + DIR_SCRIPTS, + DIR_SPEC, + DIR_TASKS, + DIR_WORKFLOW, + DIR_WORKSPACE, + count_lines, + get_active_journal_file, + get_current_task, + get_current_task_source, + get_developer, + get_repo_root, + get_tasks_dir, +) + + +# ============================================================================= +# Helpers +# ============================================================================= + +def _collect_package_git_info(repo_root: Path) -> list[dict]: + """Collect git status and recent commits for packages with independent git repos. + + Only packages marked with ``git: true`` in config.yaml are included. + + Returns: + List of dicts with keys: name, path, branch, isClean, + uncommittedChanges, recentCommits. + Empty list if no git-repo packages are configured. + """ + git_pkgs = get_git_packages(repo_root) + if not git_pkgs: + return [] + + result = [] + for pkg_name, pkg_path in git_pkgs.items(): + pkg_dir = repo_root / pkg_path + if not (pkg_dir / ".git").exists(): + continue + + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=pkg_dir) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=pkg_dir) + changes = len([l for l in status_out.splitlines() if l.strip()]) + + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=pkg_dir) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + elif len(parts) == 1: + commits.append({"hash": parts[0], "message": ""}) + + result.append({ + "name": pkg_name, + "path": pkg_path, + "branch": branch, + "isClean": changes == 0, + "uncommittedChanges": changes, + "recentCommits": commits, + }) + + return result + + +def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None: + """Append Git status and recent commits for package repositories.""" + for pkg in package_git_info: + lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})") + lines.append(f"Branch: {pkg['branch']}") + if pkg["isClean"]: + lines.append("Working directory: Clean") + else: + lines.append( + f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)" + ) + lines.append("") + lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})") + if pkg["recentCommits"]: + for commit in pkg["recentCommits"]: + lines.append(f"{commit['hash']} {commit['message']}") + else: + lines.append("(no commits)") + lines.append("") + + +# ============================================================================= +# JSON Output +# ============================================================================= + +def get_context_json(repo_root: Path | None = None) -> dict: + """Get context as a dictionary. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Context dictionary. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + journal_file = get_active_journal_file(repo_root) + + journal_lines = 0 + journal_relative = "" + if journal_file and developer: + journal_lines = count_lines(journal_file) + journal_relative = ( + f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + ) + + # Git info + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + git_status_count = len([line for line in status_out.splitlines() if line.strip()]) + is_clean = git_status_count == 0 + + # Recent commits + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + elif len(parts) == 1: + commits.append({"hash": parts[0], "message": ""}) + + # Tasks + tasks = [ + { + "dir": t.dir_name, + "name": t.name, + "status": t.status, + "children": list(t.children), + "parent": t.parent, + } + for t in iter_active_tasks(tasks_dir) + ] + + # Package git repos (independent sub-repositories) + pkg_git_info = _collect_package_git_info(repo_root) + + result = { + "developer": developer or "", + "git": { + "branch": branch, + "isClean": is_clean, + "uncommittedChanges": git_status_count, + "recentCommits": commits, + }, + "tasks": { + "active": tasks, + "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}", + }, + "journal": { + "file": journal_relative, + "lines": journal_lines, + "nearLimit": journal_lines > 1800, + }, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def output_json(repo_root: Path | None = None) -> None: + """Output context in JSON format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + context = get_context_json(repo_root) + print(json.dumps(context, indent=2, ensure_ascii=False)) + + +# ============================================================================= +# Text Output +# ============================================================================= + +def get_context_text(repo_root: Path | None = None) -> str: + """Get context as formatted text. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + + Returns: + Formatted text output. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines = [] + lines.append("========================================") + lines.append("SESSION CONTEXT") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + + # Developer section + lines.append("## DEVELOPER") + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + lines.append(f"Name: {developer}") + lines.append("") + + # Git status + lines.append("## GIT STATUS") + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + status_count = len(status_lines) + + if status_count == 0: + lines.append("Working directory: Clean") + else: + lines.append(f"Working directory: {status_count} uncommitted change(s)") + lines.append("") + lines.append("Changes:") + _, short_out, _ = run_git(["status", "--short"], cwd=repo_root) + for line in short_out.splitlines()[:10]: + lines.append(line) + lines.append("") + + # Recent commits + lines.append("## RECENT COMMITS") + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + if log_out.strip(): + for line in log_out.splitlines(): + lines.append(line) + else: + lines.append("(no commits)") + lines.append("") + + # Package git repos — independent sub-repositories + _append_package_git_context(lines, _collect_package_git_info(repo_root)) + + # Current task + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + current_task_dir = repo_root / current_task + source_type, context_key, _ = get_current_task_source(repo_root) + lines.append(f"Path: {current_task}") + lines.append( + f"Source: {source_type}" + (f":{context_key}" if context_key else "") + ) + + ct = load_task(current_task_dir) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}") + if ct.description: + lines.append(f"Description: {ct.description}") + + # Check for prd.md + prd_file = current_task_dir / "prd.md" + if prd_file.is_file(): + lines.append("") + lines.append("[!] This task has prd.md - read it for task details") + else: + lines.append("(none)") + lines.append("") + + # Active tasks + lines.append("## ACTIVE TASKS") + tasks_dir = get_tasks_dir(repo_root) + task_count = 0 + + # Collect all task data for hierarchy display + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} + + def _print_task_tree(name: str, indent: int = 0) -> None: + nonlocal task_count + t = all_tasks[name] + progress = children_progress(t.children, all_statuses) + prefix = " " * indent + lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}") + task_count += 1 + for child in t.children: + if child in all_tasks: + _print_task_tree(child, indent + 1) + + for dir_name in sorted(all_tasks.keys()): + if not all_tasks[dir_name].parent: + _print_task_tree(dir_name) + + if task_count == 0: + lines.append("(no active tasks)") + lines.append(f"Total: {task_count} active task(s)") + lines.append("") + + # My tasks + lines.append("## MY TASKS (Assigned to me)") + my_task_count = 0 + + for t in all_tasks.values(): + if t.assignee == developer and t.status != "done": + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no tasks assigned to you)") + lines.append("") + + # Journal file + lines.append("## JOURNAL FILE") + journal_file = get_active_journal_file(repo_root) + if journal_file: + journal_lines = count_lines(journal_file) + relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}" + lines.append(f"Active file: {relative}") + lines.append(f"Line count: {journal_lines} / 2000") + if journal_lines > 1800: + lines.append("[!] WARNING: Approaching 2000 line limit!") + else: + lines.append("No journal file found") + lines.append("") + + # Packages + packages_text = get_packages_section(repo_root) + if packages_text: + lines.append(packages_text) + lines.append("") + + # Paths + lines.append("## PATHS") + lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/") + lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/") + lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +# ============================================================================= +# Record Mode +# ============================================================================= + +def get_context_record_json(repo_root: Path | None = None) -> dict: + """Get record-mode context as a dictionary. + + Focused on: my active tasks, git status, current task. + """ + if repo_root is None: + repo_root = get_repo_root() + + developer = get_developer(repo_root) + tasks_dir = get_tasks_dir(repo_root) + + # Git info + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + git_status_count = len([line for line in status_out.splitlines() if line.strip()]) + + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + commits = [] + for line in log_out.splitlines(): + if line.strip(): + parts = line.split(" ", 1) + if len(parts) >= 2: + commits.append({"hash": parts[0], "message": parts[1]}) + + # My tasks (single pass — collect statuses and filter by assignee) + all_tasks_list = list(iter_active_tasks(tasks_dir)) + all_statuses = {t.dir_name: t.status for t in all_tasks_list} + + my_tasks = [] + for t in all_tasks_list: + if t.assignee == developer: + done = sum( + 1 for c in t.children + if all_statuses.get(c) in ("completed", "done") + ) + my_tasks.append({ + "dir": t.dir_name, + "title": t.title, + "status": t.status, + "priority": t.priority, + "children": list(t.children), + "childrenDone": done, + "parent": t.parent, + "meta": t.meta, + }) + + # Current task + current_task_info = None + current_task = get_current_task(repo_root) + if current_task: + source_type, context_key, _ = get_current_task_source(repo_root) + ct = load_task(repo_root / current_task) + if ct: + current_task_info = { + "path": current_task, + "name": ct.name, + "status": ct.status, + "source": source_type, + "contextKey": context_key, + } + + # Package git repos + pkg_git_info = _collect_package_git_info(repo_root) + + result = { + "developer": developer or "", + "git": { + "branch": branch, + "isClean": git_status_count == 0, + "uncommittedChanges": git_status_count, + "recentCommits": commits, + }, + "myTasks": my_tasks, + "currentTask": current_task_info, + } + + if pkg_git_info: + result["packageGit"] = pkg_git_info + + return result + + +def get_context_text_record(repo_root: Path | None = None) -> str: + """Get context as formatted text for record-session mode. + + Focused output: MY ACTIVE TASKS first (with [!!!] emphasis), + then GIT STATUS, RECENT COMMITS, CURRENT TASK. + """ + if repo_root is None: + repo_root = get_repo_root() + + lines: list[str] = [] + lines.append("========================================") + lines.append("SESSION CONTEXT (RECORD MODE)") + lines.append("========================================") + lines.append("") + + developer = get_developer(repo_root) + if not developer: + lines.append( + f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>" + ) + return "\n".join(lines) + + # MY ACTIVE TASKS — first and prominent + lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})") + lines.append("[!] Review whether any should be archived before recording this session.") + lines.append("") + + tasks_dir = get_tasks_dir(repo_root) + my_task_count = 0 + + # Single pass — collect all tasks and filter by assignee + all_statuses = get_all_statuses(tasks_dir) + + for t in iter_active_tasks(tasks_dir): + if t.assignee == developer: + progress = children_progress(t.children, all_statuses) + lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}") + my_task_count += 1 + + if my_task_count == 0: + lines.append("(no active tasks assigned to you)") + lines.append("") + + # GIT STATUS + lines.append("## GIT STATUS") + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + branch = branch_out.strip() or "unknown" + lines.append(f"Branch: {branch}") + + _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root) + status_lines = [line for line in status_out.splitlines() if line.strip()] + status_count = len(status_lines) + + if status_count == 0: + lines.append("Working directory: Clean") + else: + lines.append(f"Working directory: {status_count} uncommitted change(s)") + lines.append("") + lines.append("Changes:") + _, short_out, _ = run_git(["status", "--short"], cwd=repo_root) + for line in short_out.splitlines()[:10]: + lines.append(line) + lines.append("") + + # RECENT COMMITS + lines.append("## RECENT COMMITS") + _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root) + if log_out.strip(): + for line in log_out.splitlines(): + lines.append(line) + else: + lines.append("(no commits)") + lines.append("") + + # Package git repos — independent sub-repositories + _append_package_git_context(lines, _collect_package_git_info(repo_root)) + + # CURRENT TASK + lines.append("## CURRENT TASK") + current_task = get_current_task(repo_root) + if current_task: + source_type, context_key, _ = get_current_task_source(repo_root) + lines.append(f"Path: {current_task}") + lines.append( + f"Source: {source_type}" + (f":{context_key}" if context_key else "") + ) + ct = load_task(repo_root / current_task) + if ct: + lines.append(f"Name: {ct.name}") + lines.append(f"Status: {ct.status}") + else: + lines.append("(none)") + lines.append("") + + lines.append("========================================") + + return "\n".join(lines) + + +def output_text(repo_root: Path | None = None) -> None: + """Output context in text format. + + Args: + repo_root: Repository root path. Defaults to auto-detected. + """ + print(get_context_text(repo_root)) diff --git a/.trellis/scripts/common/task_context.py b/.trellis/scripts/common/task_context.py new file mode 100755 index 0000000..fa88412 --- /dev/null +++ b/.trellis/scripts/common/task_context.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Task JSONL context management. + +Provides: + cmd_add_context - Add entry to JSONL context file + cmd_validate - Validate JSONL context files + cmd_list_context - List JSONL context entries + +Note: + ``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files + are now seeded at ``task.py create`` time with a self-describing + ``_example`` line; the AI agent curates real entries during Phase 1.3 of + the workflow. See ``.trellis/workflow.md`` Phase 1.3 for the current + instructions. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .log import Colors, colored +from .paths import get_repo_root +from .task_utils import resolve_task_dir + + +# ============================================================================= +# Command: add-context +# ============================================================================= + +def cmd_add_context(args: argparse.Namespace) -> int: + """Add entry to JSONL context file.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + jsonl_name = args.file + path = args.path + reason = args.reason or "Added manually" + + if not target_dir.is_dir(): + print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) + return 1 + + # Support shorthand + if not jsonl_name.endswith(".jsonl"): + jsonl_name = f"{jsonl_name}.jsonl" + + jsonl_file = target_dir / jsonl_name + full_path = repo_root / path + + entry_type = "file" + if full_path.is_dir(): + entry_type = "directory" + if not path.endswith("/"): + path = f"{path}/" + elif not full_path.is_file(): + print(colored(f"Error: Path not found: {path}", Colors.RED)) + return 1 + + # Check if already exists + if jsonl_file.is_file(): + content = jsonl_file.read_text(encoding="utf-8") + if f'"{path}"' in content: + print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) + return 0 + + # Add entry + entry: dict + if entry_type == "directory": + entry = {"file": path, "type": "directory", "reason": reason} + else: + entry = {"file": path, "reason": reason} + + with jsonl_file.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: validate +# ============================================================================= + +def cmd_validate(args: argparse.Namespace) -> int: + """Validate JSONL context files.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Validating Context Files ===", Colors.BLUE)) + print(f"Target dir: {target_dir}") + print() + + total_errors = 0 + for jsonl_name in ["implement.jsonl", "check.jsonl"]: + jsonl_file = target_dir / jsonl_name + errors = _validate_jsonl(jsonl_file, repo_root) + total_errors += errors + + print() + if total_errors == 0: + print(colored("✓ All validations passed", Colors.GREEN)) + return 0 + else: + print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) + return 1 + + +def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: + """Validate a single JSONL file. + + Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are + skipped silently; they are self-describing comments, not real entries. + """ + file_name = jsonl_file.name + errors = 0 + + if not jsonl_file.is_file(): + print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") + return 0 + + line_num = 0 + real_entries = 0 + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + line_num += 1 + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") + errors += 1 + continue + + file_path = data.get("file") + entry_type = data.get("type", "file") + + if not file_path: + # Seed / comment row — skip silently + continue + + real_entries += 1 + full_path = repo_root / file_path + if entry_type == "directory": + if not full_path.is_dir(): + print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") + errors += 1 + else: + if not full_path.is_file(): + print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") + errors += 1 + + if errors == 0: + print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}") + else: + print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") + + return errors + + +# ============================================================================= +# Command: list-context +# ============================================================================= + +def cmd_list_context(args: argparse.Namespace) -> int: + """List JSONL context entries.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + + if not target_dir.is_dir(): + print(colored("Error: task directory required", Colors.RED)) + return 1 + + print(colored("=== Context Files ===", Colors.BLUE)) + print() + + for jsonl_name in ["implement.jsonl", "check.jsonl"]: + jsonl_file = target_dir / jsonl_name + if not jsonl_file.is_file(): + continue + + print(colored(f"[{jsonl_name}]", Colors.CYAN)) + + count = 0 + seed_only = True + for line in jsonl_file.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + + file_path = data.get("file") + if not file_path: + # Seed / comment row — don't count as a real entry + continue + seed_only = False + + count += 1 + entry_type = data.get("type", "file") + reason = data.get("reason", "-") + + if entry_type == "directory": + print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") + else: + print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") + print(f" {colored('→', Colors.YELLOW)} {reason}") + + if seed_only: + print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}") + + print() + + return 0 diff --git a/.trellis/scripts/common/task_queue.py b/.trellis/scripts/common/task_queue.py index 70378a1..f7485e2 100755 --- a/.trellis/scripts/common/task_queue.py +++ b/.trellis/scripts/common/task_queue.py @@ -12,23 +12,32 @@ Provides: from __future__ import annotations -import json from pathlib import Path from .paths import ( - FILE_TASK_JSON, get_repo_root, get_developer, get_tasks_dir, ) +from .tasks import iter_active_tasks -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None +# ============================================================================= +# Internal helper +# ============================================================================= + +def _task_to_dict(t) -> dict: + """Convert TaskInfo to the dict format callers expect.""" + return { + "priority": t.priority, + "id": t.raw.get("id", ""), + "title": t.title, + "status": t.status, + "assignee": t.assignee or "-", + "dir": t.dir_name, + "children": list(t.children), + "parent": t.parent, + } # ============================================================================= @@ -54,41 +63,10 @@ def list_tasks_by_status( tasks_dir = get_tasks_dir(repo_root) results = [] - if not tasks_dir.is_dir(): - return results - - for d in tasks_dir.iterdir(): - if not d.is_dir() or d.name == "archive": + for t in iter_active_tasks(tasks_dir): + if filter_status and t.status != filter_status: continue - - task_json = d / FILE_TASK_JSON - if not task_json.is_file(): - continue - - data = _read_json_file(task_json) - if not data: - continue - - task_id = data.get("id", "") - title = data.get("title") or data.get("name", "") - priority = data.get("priority", "P2") - status = data.get("status", "planning") - assignee = data.get("assignee", "-") - - # Apply filter - if filter_status and status != filter_status: - continue - - results.append({ - "priority": priority, - "id": task_id, - "title": title, - "status": status, - "assignee": assignee, - "dir": d.name, - "children": data.get("children", []), - "parent": data.get("parent"), - }) + results.append(_task_to_dict(t)) return results @@ -126,46 +104,12 @@ def list_tasks_by_assignee( tasks_dir = get_tasks_dir(repo_root) results = [] - if not tasks_dir.is_dir(): - return results - - for d in tasks_dir.iterdir(): - if not d.is_dir() or d.name == "archive": + for t in iter_active_tasks(tasks_dir): + if (t.assignee or "-") != assignee: continue - - task_json = d / FILE_TASK_JSON - if not task_json.is_file(): + if filter_status and t.status != filter_status: continue - - data = _read_json_file(task_json) - if not data: - continue - - task_assignee = data.get("assignee", "-") - - # Apply assignee filter - if task_assignee != assignee: - continue - - task_id = data.get("id", "") - title = data.get("title") or data.get("name", "") - priority = data.get("priority", "P2") - status = data.get("status", "planning") - - # Apply status filter - if filter_status and status != filter_status: - continue - - results.append({ - "priority": priority, - "id": task_id, - "title": title, - "status": status, - "assignee": task_assignee, - "dir": d.name, - "children": data.get("children", []), - "parent": data.get("parent"), - }) + results.append(_task_to_dict(t)) return results @@ -211,24 +155,9 @@ def get_task_stats(repo_root: Path | None = None) -> dict[str, int]: tasks_dir = get_tasks_dir(repo_root) stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0} - if not tasks_dir.is_dir(): - return stats - - for d in tasks_dir.iterdir(): - if not d.is_dir() or d.name == "archive": - continue - - task_json = d / FILE_TASK_JSON - if not task_json.is_file(): - continue - - data = _read_json_file(task_json) - if not data: - continue - - priority = data.get("priority", "P2") - if priority in stats: - stats[priority] += 1 + for t in iter_active_tasks(tasks_dir): + if t.priority in stats: + stats[t.priority] += 1 stats["Total"] += 1 return stats diff --git a/.trellis/scripts/common/task_store.py b/.trellis/scripts/common/task_store.py new file mode 100755 index 0000000..6a628b1 --- /dev/null +++ b/.trellis/scripts/common/task_store.py @@ -0,0 +1,600 @@ +#!/usr/bin/env python3 +""" +Task CRUD operations. + +Provides: + ensure_tasks_dir - Ensure tasks directory exists + cmd_create - Create a new task + cmd_archive - Archive completed task + cmd_set_branch - Set git branch for task + cmd_set_base_branch - Set PR target branch + cmd_set_scope - Set scope for PR title + cmd_add_subtask - Link child task to parent + cmd_remove_subtask - Unlink child task from parent +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime +from pathlib import Path + +from .config import ( + get_packages, + is_monorepo, + resolve_package, + validate_package, +) +from .git import run_git +from .io import read_json, write_json +from .log import Colors, colored +from .paths import ( + DIR_ARCHIVE, + DIR_TASKS, + DIR_WORKFLOW, + FILE_TASK_JSON, + generate_task_date_prefix, + get_developer, + get_repo_root, + get_tasks_dir, +) +from .task_utils import ( + archive_task_complete, + find_task_by_name, + resolve_task_dir, + run_task_hooks, +) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _slugify(title: str) -> str: + """Convert title to slug (only works with ASCII).""" + result = title.lower() + result = re.sub(r"[^a-z0-9]", "-", result) + result = re.sub(r"-+", "-", result) + result = result.strip("-") + return result + + +def ensure_tasks_dir(repo_root: Path) -> Path: + """Ensure tasks directory exists.""" + tasks_dir = get_tasks_dir(repo_root) + archive_dir = tasks_dir / "archive" + + if not tasks_dir.exists(): + tasks_dir.mkdir(parents=True) + print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) + + if not archive_dir.exists(): + archive_dir.mkdir(parents=True) + + return tasks_dir + + +# ============================================================================= +# Sub-agent platform detection + JSONL seeding +# ============================================================================= + +# Config directories of platforms that consume implement.jsonl / check.jsonl. +# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the +# platforms listed in workflow.md's "agent-capable" Skill Routing block +# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity / +# Windsurf are NOT in this list: they do not consume JSONL. +_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = ( + ".claude", + ".cursor", + ".codex", + ".kiro", + ".gemini", + ".opencode", + ".qoder", + ".codebuddy", + ".factory", # Factory Droid + ".github/copilot", + ".pi", # Pi Agent +) + +_SEED_EXAMPLE = ( + "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. " + "Put spec/research files only — no code paths. " + "Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. " + "Delete this line once real entries are added." +) + + +def _has_subagent_platform(repo_root: Path) -> bool: + """Return True if any sub-agent-capable platform is configured. + + Detected by probing well-known config directories at the repo root. Used + only to decide whether ``task.py create`` should seed empty + ``implement.jsonl`` / ``check.jsonl`` files. + """ + for config_dir in _SUBAGENT_CONFIG_DIRS: + if (repo_root / config_dir).is_dir(): + return True + return False + + +def _write_seed_jsonl(path: Path) -> None: + """Write a one-line seed JSONL file with a self-describing ``_example``. + + The seed row has no ``file`` field, so downstream consumers (hooks + + preludes) that iterate entries via ``item.get("file")`` naturally skip + it. The row exists purely as an in-file prompt for the AI curator. + """ + seed = {"_example": _SEED_EXAMPLE} + path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8") + + +# ============================================================================= +# Command: create +# ============================================================================= + +def cmd_create(args: argparse.Namespace) -> int: + """Create a new task.""" + repo_root = get_repo_root() + + if not args.title: + print(colored("Error: title is required", Colors.RED), file=sys.stderr) + return 1 + + # Validate --package (CLI source: fail-fast) + package: str | None = getattr(args, "package", None) + if not is_monorepo(repo_root): + # Single-repo: ignore --package, no package prefix + if package: + print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr) + package = None + elif package: + if not validate_package(package, repo_root): + packages = get_packages(repo_root) + available = ", ".join(sorted(packages.keys())) if packages else "(none)" + print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr) + return 1 + else: + # Inferred: default_package → None (no task.json yet for create) + package = resolve_package(repo_root=repo_root) + + # Default assignee to current developer + assignee = args.assignee + if not assignee: + assignee = get_developer(repo_root) + if not assignee: + print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) + return 1 + + ensure_tasks_dir(repo_root) + + # Get current developer as creator + creator = get_developer(repo_root) or assignee + + # Generate slug if not provided + slug = args.slug or _slugify(args.title) + if not slug: + print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) + return 1 + + # Create task directory with MM-DD-slug format + tasks_dir = get_tasks_dir(repo_root) + date_prefix = generate_task_date_prefix() + dir_name = f"{date_prefix}-{slug}" + task_dir = tasks_dir / dir_name + task_json_path = task_dir / FILE_TASK_JSON + + if task_dir.exists(): + print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) + else: + task_dir.mkdir(parents=True) + + today = datetime.now().strftime("%Y-%m-%d") + + # Record current branch as base_branch (PR target) + _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root) + current_branch = branch_out.strip() or "main" + + task_data = { + "id": slug, + "name": slug, + "title": args.title, + "description": args.description or "", + "status": "planning", + "dev_type": None, + "scope": None, + "package": package, + "priority": args.priority, + "creator": creator, + "assignee": assignee, + "createdAt": today, + "completedAt": None, + "branch": None, + "base_branch": current_branch, + "worktree_path": None, + "commit": None, + "pr_url": None, + "subtasks": [], + "children": [], + "parent": None, + "relatedFiles": [], + "notes": "", + "meta": {}, + } + + write_json(task_json_path, task_data) + + # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms. + # Agent curates real entries in Phase 1.3 (see .trellis/workflow.md). + # Agent-less platforms (Kilo / Antigravity / Windsurf) skip this — they + # load specs via the trellis-before-dev skill instead of JSONL. + seeded_jsonl = False + if _has_subagent_platform(repo_root): + for jsonl_name in ("implement.jsonl", "check.jsonl"): + jsonl_path = task_dir / jsonl_name + if not jsonl_path.exists(): + _write_seed_jsonl(jsonl_path) + seeded_jsonl = True + + # Handle --parent: establish bidirectional link + if args.parent: + parent_dir = resolve_task_dir(args.parent, repo_root) + parent_json_path = parent_dir / FILE_TASK_JSON + if not parent_json_path.is_file(): + print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) + else: + parent_data = read_json(parent_json_path) + if parent_data: + # Add child to parent's children list + parent_children = parent_data.get("children", []) + if dir_name not in parent_children: + parent_children.append(dir_name) + parent_data["children"] = parent_children + write_json(parent_json_path, parent_data) + + # Set parent in child's task.json + task_data["parent"] = parent_dir.name + write_json(task_json_path, task_data) + + print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) + + # Auto-activate the new task so the per-turn breadcrumb fires planning + # state. Best-effort: gracefully degrade if no session identity (CLI run + # outside an AI session) — the task is still created, the user can run + # task.py start later. Pointer is session-scoped so this never affects + # other AI sessions. + try: + from .active_task import resolve_context_key, set_active_task + if resolve_context_key(): + try: + rel_dir = task_dir.relative_to(repo_root).as_posix() + except ValueError: + rel_dir = str(task_dir) + set_active_task(rel_dir, repo_root) + except Exception: + pass + + print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) + print("", file=sys.stderr) + print(colored("Next steps:", Colors.BLUE), file=sys.stderr) + print(" 1. Create prd.md with requirements", file=sys.stderr) + if seeded_jsonl: + print( + " 2. Curate implement.jsonl / check.jsonl (spec + research files only — " + "see .trellis/workflow.md Phase 1.3)", + file=sys.stderr, + ) + print(" 3. Run: python3 task.py start <dir>", file=sys.stderr) + else: + print(" 2. Run: python3 task.py start <dir>", file=sys.stderr) + print("", file=sys.stderr) + + # Output relative path for script chaining + print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") + + run_task_hooks("after_create", task_json_path, repo_root) + return 0 + + +# ============================================================================= +# Command: archive +# ============================================================================= + +def cmd_archive(args: argparse.Namespace) -> int: + """Archive completed task.""" + repo_root = get_repo_root() + task_name = args.name + + if not task_name: + print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) + return 1 + + tasks_dir = get_tasks_dir(repo_root) + + # Resolve task directory (supports task name, relative path, or absolute path) + task_dir = resolve_task_dir(task_name, repo_root) + + if not task_dir or not task_dir.is_dir(): + print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) + print("Active tasks:", file=sys.stderr) + # Import lazily to avoid circular dependency + from .tasks import iter_active_tasks + for t in iter_active_tasks(tasks_dir): + print(f" - {t.dir_name}/", file=sys.stderr) + return 1 + + dir_name = task_dir.name + task_json_path = task_dir / FILE_TASK_JSON + + # Update status before archiving + today = datetime.now().strftime("%Y-%m-%d") + if task_json_path.is_file(): + data = read_json(task_json_path) + if data: + data["status"] = "completed" + data["completedAt"] = today + write_json(task_json_path, data) + + # Handle subtask relationships on archive. + # Keep this task in its parent's children list so progress + # counters (children_progress) stay consistent — children + # missing from the active set are treated as completed. + task_children = data.get("children", []) + + # If this is a parent, clear parent field in all children + if task_children: + for child_name in task_children: + child_dir_path = find_task_by_name(child_name, tasks_dir) + if child_dir_path: + child_json = child_dir_path / FILE_TASK_JSON + if child_json.is_file(): + child_data = read_json(child_json) + if child_data: + child_data["parent"] = None + write_json(child_json, child_data) + + # Clear any session that still points at this task before the path moves. + from .active_task import clear_task_from_sessions + clear_task_from_sessions(str(task_dir), repo_root) + + # Archive + result = archive_task_complete(task_dir, repo_root) + if "archived_to" in result: + archive_dest = Path(result["archived_to"]) + year_month = archive_dest.parent.name + print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) + + # Auto-commit unless --no-commit + if not getattr(args, "no_commit", False): + _auto_commit_archive(dir_name, repo_root) + + # Return the archive path + print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") + + # Run hooks with the archived path + archived_json = archive_dest / FILE_TASK_JSON + run_task_hooks("after_archive", archived_json, repo_root) + return 0 + + return 1 + + +def _auto_commit_archive(task_name: str, repo_root: Path) -> None: + """Stage .trellis/tasks/ changes and commit after archive.""" + tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}" + run_git(["add", "-A", tasks_rel], cwd=repo_root) + + # Check if there are staged changes + rc, _, _ = run_git( + ["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root + ) + if rc == 0: + print("[OK] No task changes to commit.", file=sys.stderr) + return + + commit_msg = f"chore(task): archive {task_name}" + rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root) + if rc == 0: + print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) + else: + print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) + + +# ============================================================================= +# Command: add-subtask +# ============================================================================= + +def cmd_add_subtask(args: argparse.Namespace) -> int: + """Link a child task to a parent task.""" + repo_root = get_repo_root() + + parent_dir = resolve_task_dir(args.parent_dir, repo_root) + child_dir = resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = read_json(parent_json_path) + child_data = read_json(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Check if child already has a parent + existing_parent = child_data.get("parent") + if existing_parent: + print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) + return 1 + + # Add child to parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name not in parent_children: + parent_children.append(child_dir_name) + parent_data["children"] = parent_children + + # Set parent in child's task.json + child_data["parent"] = parent_dir.name + + # Write both + write_json(parent_json_path, parent_data) + write_json(child_json_path, child_data) + + print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: remove-subtask +# ============================================================================= + +def cmd_remove_subtask(args: argparse.Namespace) -> int: + """Unlink a child task from a parent task.""" + repo_root = get_repo_root() + + parent_dir = resolve_task_dir(args.parent_dir, repo_root) + child_dir = resolve_task_dir(args.child_dir, repo_root) + + parent_json_path = parent_dir / FILE_TASK_JSON + child_json_path = child_dir / FILE_TASK_JSON + + if not parent_json_path.is_file(): + print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) + return 1 + + if not child_json_path.is_file(): + print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) + return 1 + + parent_data = read_json(parent_json_path) + child_data = read_json(child_json_path) + + if not parent_data or not child_data: + print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) + return 1 + + # Remove child from parent's children list + parent_children = parent_data.get("children", []) + child_dir_name = child_dir.name + if child_dir_name in parent_children: + parent_children.remove(child_dir_name) + parent_data["children"] = parent_children + + # Clear parent in child's task.json + child_data["parent"] = None + + # Write both + write_json(parent_json_path, parent_data) + write_json(child_json_path, child_data) + + print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) + return 0 + + +# ============================================================================= +# Command: set-branch +# ============================================================================= + +def cmd_set_branch(args: argparse.Namespace) -> int: + """Set git branch for task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + branch = args.branch + + if not branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-branch <task-dir> <branch-name>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["branch"] = branch + write_json(task_json, data) + + print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) + return 0 + + +# ============================================================================= +# Command: set-base-branch +# ============================================================================= + +def cmd_set_base_branch(args: argparse.Namespace) -> int: + """Set the base branch (PR target) for task.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + base_branch = args.base_branch + + if not base_branch: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>") + print("Example: python3 task.py set-base-branch <dir> develop") + print() + print("This sets the target branch for PR (the branch your feature will merge into).") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["base_branch"] = base_branch + write_json(task_json, data) + + print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) + print(f" PR will target: {base_branch}") + return 0 + + +# ============================================================================= +# Command: set-scope +# ============================================================================= + +def cmd_set_scope(args: argparse.Namespace) -> int: + """Set scope for PR title.""" + repo_root = get_repo_root() + target_dir = resolve_task_dir(args.dir, repo_root) + scope = args.scope + + if not scope: + print(colored("Error: Missing arguments", Colors.RED)) + print("Usage: python3 task.py set-scope <task-dir> <scope>") + return 1 + + task_json = target_dir / FILE_TASK_JSON + if not task_json.is_file(): + print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) + return 1 + + data = read_json(task_json) + if not data: + return 1 + + data["scope"] = scope + write_json(task_json, data) + + print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) + return 0 diff --git a/.trellis/scripts/common/task_utils.py b/.trellis/scripts/common/task_utils.py index 84df2fa..62c215e 100755 --- a/.trellis/scripts/common/task_utils.py +++ b/.trellis/scripts/common/task_utils.py @@ -3,9 +3,11 @@ Task utility functions. Provides: - is_safe_task_path - Validate task path is safe to operate on - find_task_by_name - Find task directory by name - archive_task_dir - Archive task to monthly directory + is_safe_task_path - Validate task path is safe to operate on + find_task_by_name - Find task directory by name + resolve_task_dir - Resolve task directory from name, relative, or absolute path + archive_task_dir - Archive task to monthly directory + run_task_hooks - Run lifecycle hooks for task events """ from __future__ import annotations @@ -15,7 +17,7 @@ import sys from datetime import datetime from pathlib import Path -from .paths import get_repo_root +from .paths import get_repo_root, get_tasks_dir # ============================================================================= @@ -35,23 +37,25 @@ def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: if repo_root is None: repo_root = get_repo_root() + normalized = task_path.replace("\\", "/") + # Check empty or null - if not task_path or task_path == "null": + if not normalized or normalized == "null": print("Error: empty or null task path", file=sys.stderr) return False # Reject absolute paths - if task_path.startswith("/"): + if Path(task_path).is_absolute(): print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) return False # Reject ".", "..", paths starting with "./" or "../", or containing ".." - if task_path in (".", "..") or task_path.startswith("./") or task_path.startswith("../") or ".." in task_path: + if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized: print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) return False # Final check: ensure resolved path is not the repo root - abs_path = repo_root / task_path + abs_path = repo_root / Path(normalized) if abs_path.exists(): try: resolved = abs_path.resolve() @@ -163,13 +167,105 @@ def archive_task_complete( return {} +# ============================================================================= +# Task Directory Resolution +# ============================================================================= + +def resolve_task_dir(target_dir: str, repo_root: Path) -> Path: + """Resolve task directory to absolute path. + + Supports: + - Absolute path: /path/to/task + - Relative path: .trellis/tasks/01-31-my-task + - Task name: my-task (uses find_task_by_name for lookup) + + Args: + target_dir: Task directory specification. + repo_root: Repository root path. + + Returns: + Resolved absolute path. + """ + if not target_dir: + return Path() + + normalized = target_dir.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + + # Absolute path + if Path(target_dir).is_absolute(): + return Path(target_dir) + + # Relative path (contains path separator or starts with .trellis) + if "/" in normalized or normalized.startswith(".trellis"): + return repo_root / Path(normalized) + + # Task name - try to find in tasks directory + tasks_dir = get_tasks_dir(repo_root) + found = find_task_by_name(target_dir, tasks_dir) + if found: + return found + + # Fallback to treating as relative path + return repo_root / Path(normalized) + + +# ============================================================================= +# Lifecycle Hooks +# ============================================================================= + +def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: + """Run lifecycle hooks for a task event. + + Args: + event: Event name (e.g. "after_create"). + task_json_path: Absolute path to the task's task.json. + repo_root: Repository root for cwd and config lookup. + """ + import os + import subprocess + + from .config import get_hooks + from .log import Colors, colored + + commands = get_hooks(event, repo_root) + if not commands: + return + + env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} + + for cmd in commands: + try: + result = subprocess.run( + cmd, + shell=True, + cwd=repo_root, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + print( + colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), + file=sys.stderr, + ) + if result.stderr.strip(): + print(f" {result.stderr.strip()}", file=sys.stderr) + except Exception as e: + print( + colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), + file=sys.stderr, + ) + + # ============================================================================= # Main Entry (for testing) # ============================================================================= if __name__ == "__main__": - from .paths import get_tasks_dir - repo = get_repo_root() tasks = get_tasks_dir(repo) diff --git a/.trellis/scripts/common/tasks.py b/.trellis/scripts/common/tasks.py new file mode 100755 index 0000000..7b44094 --- /dev/null +++ b/.trellis/scripts/common/tasks.py @@ -0,0 +1,112 @@ +""" +Task data access layer. + +Single source of truth for loading and iterating task directories. +Replaces scattered task.json parsing across 9+ files. + +Provides: + load_task — Load a single task by directory path + iter_active_tasks — Iterate all non-archived tasks (sorted) + get_all_statuses — Get {dir_name: status} map for children progress +""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +from .io import read_json +from .paths import FILE_TASK_JSON +from .types import TaskInfo + + +def load_task(task_dir: Path) -> TaskInfo | None: + """Load task from a directory containing task.json. + + Args: + task_dir: Absolute path to the task directory. + + Returns: + TaskInfo if task.json exists and is valid, None otherwise. + """ + task_json = task_dir / FILE_TASK_JSON + if not task_json.is_file(): + return None + + data = read_json(task_json) + if not data: + return None + + return TaskInfo( + dir_name=task_dir.name, + directory=task_dir, + title=data.get("title") or data.get("name") or "unknown", + status=data.get("status", "unknown"), + assignee=data.get("assignee", ""), + priority=data.get("priority", "P2"), + children=tuple(data.get("children", [])), + parent=data.get("parent"), + package=data.get("package"), + raw=data, + ) + + +def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]: + """Iterate all active (non-archived) tasks, sorted by directory name. + + Skips the "archive" directory and directories without valid task.json. + + Args: + tasks_dir: Path to the tasks directory. + + Yields: + TaskInfo for each valid task. + """ + if not tasks_dir.is_dir(): + return + + for d in sorted(tasks_dir.iterdir()): + if not d.is_dir() or d.name == "archive": + continue + info = load_task(d) + if info is not None: + yield info + + +def get_all_statuses(tasks_dir: Path) -> dict[str, str]: + """Get a {dir_name: status} mapping for all active tasks. + + Useful for computing children progress without loading full TaskInfo. + + Args: + tasks_dir: Path to the tasks directory. + + Returns: + Dict mapping directory names to status strings. + """ + return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)} + + +def children_progress( + children: tuple[str, ...] | list[str], + all_statuses: dict[str, str], +) -> str: + """Format children progress string like " [2/3 done]". + + Args: + children: List of child directory names. + all_statuses: Status map from get_all_statuses(). + + Returns: + Formatted string, or "" if no children. + """ + if not children: + return "" + # A child missing from active statuses has been archived (cmd_archive + # sets status=completed before moving the dir). Count it as done so + # parent progress doesn't regress when children are archived. + done = sum( + 1 for c in children + if c not in all_statuses or all_statuses.get(c) in ("completed", "done") + ) + return f" [{done}/{len(children)} done]" diff --git a/.trellis/scripts/common/types.py b/.trellis/scripts/common/types.py new file mode 100755 index 0000000..5802e10 --- /dev/null +++ b/.trellis/scripts/common/types.py @@ -0,0 +1,110 @@ +""" +Core type definitions for Trellis task data. + +Provides: + TaskData — TypedDict for task.json shape (read-path type hints only) + TaskInfo — Frozen dataclass for loaded task (the public API type) + AgentRecord — TypedDict for registry.json agent entries +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TypedDict + + +# ============================================================================= +# task.json shape (TypedDict — used only for read-path type hints) +# ============================================================================= + +class TaskData(TypedDict, total=False): + """Shape of task.json on disk. + + Used only for type annotations when reading task.json. + Writes must use the original dict to avoid losing unknown fields. + """ + + id: str + name: str + title: str + description: str + status: str + dev_type: str + scope: str | None + package: str | None + priority: str + creator: str + assignee: str + createdAt: str + completedAt: str | None + branch: str | None + base_branch: str | None + worktree_path: str | None + commit: str | None + pr_url: str | None + subtasks: list[str] + children: list[str] + parent: str | None + relatedFiles: list[str] + notes: str + meta: dict + + +# ============================================================================= +# Loaded task object (frozen dataclass — the public API type) +# ============================================================================= + +@dataclass(frozen=True) +class TaskInfo: + """Immutable view of a loaded task. + + Created by load_task() / iter_active_tasks(). + Contains the commonly accessed fields; the original dict + is preserved in `raw` for write-back and uncommon field access. + """ + + dir_name: str + directory: Path + title: str + status: str + assignee: str + priority: str + children: tuple[str, ...] + parent: str | None + package: str | None + raw: dict # original dict — use for writes and uncommon fields + + @property + def name(self) -> str: + """Task name (id or name field).""" + return self.raw.get("name") or self.raw.get("id") or self.dir_name + + @property + def description(self) -> str: + return self.raw.get("description", "") + + @property + def branch(self) -> str | None: + return self.raw.get("branch") + + @property + def meta(self) -> dict: + return self.raw.get("meta", {}) + + +# ============================================================================= +# registry.json agent entry +# ============================================================================= + +class AgentRecord(TypedDict, total=False): + """Shape of an agent entry in registry.json.""" + + id: str + pid: int + task_dir: str + worktree_path: str + branch: str + platform: str + started_at: str + status: str diff --git a/.trellis/scripts/common/workflow_phase.py b/.trellis/scripts/common/workflow_phase.py new file mode 100755 index 0000000..a9970f0 --- /dev/null +++ b/.trellis/scripts/common/workflow_phase.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Workflow Phase Extraction. + +Extracts step-level content from .trellis/workflow.md and optionally filters +platform-specific blocks. + +Platform marker syntax in workflow.md: + + [Claude Code, Cursor, ...] + agent-capable content + [/Claude Code, Cursor, ...] + +Provides: + get_phase_index - Extract the Phase Index section (no --step) + get_step - Extract a single step (#### X.X) section + filter_platform - Strip platform blocks that don't include the given name +""" + +from __future__ import annotations + +import re + +from .paths import DIR_WORKFLOW, get_repo_root + + +def _workflow_md_path(): + return get_repo_root() / DIR_WORKFLOW / "workflow.md" + +# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]" +_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$") + +# Step heading: "#### 1.0 Title" or "#### 1.0 ..." +_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$") + +# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs. +_PHASE_INDEX_HEADING = "## Phase Index" + + +def _read_workflow() -> str: + path = _workflow_md_path() + if not path.exists(): + raise FileNotFoundError(f"workflow.md not found: {path}") + return path.read_text(encoding="utf-8") + + +def _parse_marker(line: str) -> tuple[bool, list[str]] | None: + """Parse a platform marker line. + + Returns: + (is_closing, [platform_names]) if line is a marker, else None. + """ + m = _MARKER_RE.match(line) + if not m: + return None + is_closing = m.group(1) == "/" + names = [p.strip() for p in m.group(2).split(",") if p.strip()] + return is_closing, names + + +def get_phase_index() -> str: + """Return Phase Index + Phase 1/2/3 step bodies from workflow.md. + + Matches what the SessionStart hook injects into the `<workflow>` block: + starts at `## Phase Index`, continues through `## Phase 1: Plan`, + `## Phase 2: Execute`, `## Phase 3: Finish`, stops at + `## Customizing Trellis (for forks)` (the docs-for-forks footer). + `[workflow-state:STATUS]` tag blocks (now embedded in Phase Index since + v0.5.0-rc.0) are consumed by the UserPromptSubmit hook so they're + stripped from this output. + """ + text = _read_workflow() + lines = text.splitlines() + + start: int | None = None + end: int | None = None + for i, line in enumerate(lines): + stripped = line.strip() + if start is None and stripped == _PHASE_INDEX_HEADING: + start = i + continue + if start is not None and stripped == "## Customizing Trellis (for forks)": + end = i + break + + if start is None: + return "" + if end is None: + end = len(lines) + + section = "\n".join(lines[start:end]).rstrip() + # Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since + # they're injected separately by inject-workflow-state.py per-turn. + import re as _re + tag_re = _re.compile( + r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?", + _re.DOTALL, + ) + return tag_re.sub("", section).rstrip() + "\n" + + +def get_step(step_id: str) -> str: + """Return the `#### X.X` section matching step_id (header + body). + + Body ends at the next `####` or `---` or `##` heading (whichever comes first). + """ + text = _read_workflow() + lines = text.splitlines() + + start: int | None = None + for i, line in enumerate(lines): + m = _STEP_HEADING_RE.match(line) + if m and m.group(1) == step_id: + start = i + break + if start is None: + return "" + + end: int = len(lines) + for j in range(start + 1, len(lines)): + line = lines[j] + if line.startswith("#### "): + end = j + break + if line.startswith("## "): + end = j + break + # Horizontal rule at column 0 + if line.strip() == "---": + end = j + break + + return "\n".join(lines[start:end]).rstrip() + "\n" + + +def _platform_matches(platform: str, block_names: list[str]) -> bool: + """Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'.""" + needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "") + for name in block_names: + hay = name.lower().replace("-", "").replace("_", "").replace(" ", "") + if needle == hay: + return True + return False + + +def filter_platform(content: str, platform: str) -> str: + """Keep lines outside any `[...]` block + lines inside blocks that include platform. + + Marker lines themselves are dropped from the output. + """ + lines = content.splitlines() + out: list[str] = [] + + in_block = False + keep_block = False + + for line in lines: + marker = _parse_marker(line) + if marker is not None: + is_closing, names = marker + if not is_closing: + in_block = True + keep_block = _platform_matches(platform, names) + else: + in_block = False + keep_block = False + continue # drop the marker line itself + + if in_block: + if keep_block: + out.append(line) + continue + out.append(line) + + # Collapse runs of 3+ blank lines that may arise from dropped markers + collapsed: list[str] = [] + blank_run = 0 + for line in out: + if line.strip() == "": + blank_run += 1 + if blank_run <= 2: + collapsed.append(line) + else: + blank_run = 0 + collapsed.append(line) + + return "\n".join(collapsed).rstrip() + "\n" diff --git a/.trellis/scripts/create_bootstrap.py b/.trellis/scripts/create_bootstrap.py deleted file mode 100755 index 201146f..0000000 --- a/.trellis/scripts/create_bootstrap.py +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env python3 -""" -Create Bootstrap Task for First-Time Setup. - -Creates a guided task to help users fill in project guidelines -after initializing Trellis for the first time. - -Usage: - python3 create_bootstrap.py [project-type] - -Arguments: - project-type: frontend | backend | fullstack (default: fullstack) - -Prerequisites: - - .trellis/.developer must exist (run init_developer.py first) - -Creates: - .trellis/tasks/00-bootstrap-guidelines/ - - task.json # Task metadata - - prd.md # Task description and guidance -""" - -from __future__ import annotations - -import json -import sys -from datetime import datetime -from pathlib import Path - -from common.paths import ( - DIR_WORKFLOW, - DIR_SCRIPTS, - DIR_TASKS, - get_repo_root, - get_developer, - get_tasks_dir, - set_current_task, -) - - -# ============================================================================= -# Constants -# ============================================================================= - -TASK_NAME = "00-bootstrap-guidelines" - - -# ============================================================================= -# PRD Content -# ============================================================================= - -def write_prd_header() -> str: - """Write PRD header section.""" - return """# Bootstrap: Fill Project Development Guidelines - -## Purpose - -Welcome to Trellis! This is your first task. - -AI agents use `.trellis/spec/` to understand YOUR project's coding conventions. -**Empty templates = AI writes generic code that doesn't match your project style.** - -Filling these guidelines is a one-time setup that pays off for every future AI session. - ---- - -## Your Task - -Fill in the guideline files based on your **existing codebase**. -""" - - -def write_prd_backend_section() -> str: - """Write PRD backend section.""" - return """ - -### Backend Guidelines - -| File | What to Document | -|------|------------------| -| `.trellis/spec/backend/directory-structure.md` | Where different file types go (routes, services, utils) | -| `.trellis/spec/backend/database-guidelines.md` | ORM, migrations, query patterns, naming conventions | -| `.trellis/spec/backend/error-handling.md` | How errors are caught, logged, and returned | -| `.trellis/spec/backend/logging-guidelines.md` | Log levels, format, what to log | -| `.trellis/spec/backend/quality-guidelines.md` | Code review standards, testing requirements | -""" - - -def write_prd_frontend_section() -> str: - """Write PRD frontend section.""" - return """ - -### Frontend Guidelines - -| File | What to Document | -|------|------------------| -| `.trellis/spec/frontend/directory-structure.md` | Component/page/hook organization | -| `.trellis/spec/frontend/component-guidelines.md` | Component patterns, props conventions | -| `.trellis/spec/frontend/hook-guidelines.md` | Custom hook naming, patterns | -| `.trellis/spec/frontend/state-management.md` | State library, patterns, what goes where | -| `.trellis/spec/frontend/type-safety.md` | TypeScript conventions, type organization | -| `.trellis/spec/frontend/quality-guidelines.md` | Linting, testing, accessibility | -""" - - -def write_prd_footer() -> str: - """Write PRD footer section.""" - return """ - -### Thinking Guides (Optional) - -The `.trellis/spec/guides/` directory contains thinking guides that are already -filled with general best practices. You can customize them for your project if needed. - ---- - -## How to Fill Guidelines - -### Principle: Document Reality, Not Ideals - -Write what your codebase **actually does**, not what you wish it did. -AI needs to match existing patterns, not introduce new ones. - -### Steps - -1. **Look at existing code** - Find 2-3 examples of each pattern -2. **Document the pattern** - Describe what you see -3. **Include file paths** - Reference real files as examples -4. **List anti-patterns** - What does your team avoid? - ---- - -## Tips for Using AI - -Ask AI to help analyze your codebase: - -- "Look at my codebase and document the patterns you see" -- "Analyze my code structure and summarize the conventions" -- "Find error handling patterns and document them" - -The AI will read your code and help you document it. - ---- - -## Completion Checklist - -- [ ] Guidelines filled for your project type -- [ ] At least 2-3 real code examples in each guideline -- [ ] Anti-patterns documented - -When done: - -```bash -python3 ./.trellis/scripts/task.py finish -python3 ./.trellis/scripts/task.py archive 00-bootstrap-guidelines -``` - ---- - -## Why This Matters - -After completing this task: - -1. AI will write code that matches your project style -2. Relevant `/trellis:before-*-dev` commands will inject real context -3. `/trellis:check-*` commands will validate against your actual standards -4. Future developers (human or AI) will onboard faster -""" - - -def write_prd(task_dir: Path, project_type: str) -> None: - """Write prd.md file.""" - content = write_prd_header() - - if project_type == "frontend": - content += write_prd_frontend_section() - elif project_type == "backend": - content += write_prd_backend_section() - else: # fullstack - content += write_prd_backend_section() - content += write_prd_frontend_section() - - content += write_prd_footer() - - prd_file = task_dir / "prd.md" - prd_file.write_text(content, encoding="utf-8") - - -# ============================================================================= -# Task JSON -# ============================================================================= - -def write_task_json(task_dir: Path, developer: str, project_type: str) -> None: - """Write task.json file.""" - today = datetime.now().strftime("%Y-%m-%d") - - # Generate subtasks and related files based on project type - if project_type == "frontend": - subtasks = [ - {"name": "Fill frontend guidelines", "status": "pending"}, - {"name": "Add code examples", "status": "pending"}, - ] - related_files = [".trellis/spec/frontend/"] - elif project_type == "backend": - subtasks = [ - {"name": "Fill backend guidelines", "status": "pending"}, - {"name": "Add code examples", "status": "pending"}, - ] - related_files = [".trellis/spec/backend/"] - else: # fullstack - subtasks = [ - {"name": "Fill backend guidelines", "status": "pending"}, - {"name": "Fill frontend guidelines", "status": "pending"}, - {"name": "Add code examples", "status": "pending"}, - ] - related_files = [".trellis/spec/backend/", ".trellis/spec/frontend/"] - - task_data = { - "id": TASK_NAME, - "name": "Bootstrap Guidelines", - "description": "Fill in project development guidelines for AI agents", - "status": "in_progress", - "dev_type": "docs", - "priority": "P1", - "creator": developer, - "assignee": developer, - "createdAt": today, - "completedAt": None, - "commit": None, - "subtasks": subtasks, - "children": [], - "parent": None, - "relatedFiles": related_files, - "notes": f"First-time setup task created by trellis init ({project_type} project)", - "meta": {}, - } - - task_json = task_dir / "task.json" - task_json.write_text(json.dumps(task_data, indent=2, ensure_ascii=False), encoding="utf-8") - - -# ============================================================================= -# Main -# ============================================================================= - -def main() -> int: - """Main entry point.""" - # Parse project type argument - project_type = "fullstack" - if len(sys.argv) > 1: - project_type = sys.argv[1] - - # Validate project type - if project_type not in ("frontend", "backend", "fullstack"): - print(f"Unknown project type: {project_type}, defaulting to fullstack") - project_type = "fullstack" - - repo_root = get_repo_root() - developer = get_developer(repo_root) - - # Check developer initialized - if not developer: - print("Error: Developer not initialized") - print(f"Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <your-name>") - return 1 - - tasks_dir = get_tasks_dir(repo_root) - task_dir = tasks_dir / TASK_NAME - relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{TASK_NAME}" - - # Check if already exists - if task_dir.exists(): - print(f"Bootstrap task already exists: {relative_path}") - return 0 - - # Create task directory - task_dir.mkdir(parents=True, exist_ok=True) - - # Write files - write_task_json(task_dir, developer, project_type) - write_prd(task_dir, project_type) - - # Set as current task - set_current_task(relative_path, repo_root) - - # Silent output - init command handles user-facing messages - # Only output the task path for programmatic use - print(relative_path) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/__init__.py b/.trellis/scripts/multi_agent/__init__.py deleted file mode 100755 index c7c7e7d..0000000 --- a/.trellis/scripts/multi_agent/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Multi-Agent Pipeline Scripts. - -This module provides orchestration for multi-agent workflows. -""" diff --git a/.trellis/scripts/multi_agent/cleanup.py b/.trellis/scripts/multi_agent/cleanup.py deleted file mode 100755 index f81e370..0000000 --- a/.trellis/scripts/multi_agent/cleanup.py +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Cleanup Worktree. - -Usage: - python3 cleanup.py <branch-name> Remove specific worktree - python3 cleanup.py --list List all worktrees - python3 cleanup.py --merged Remove merged worktrees - python3 cleanup.py --all Remove all worktrees (with confirmation) - -Options: - -y, --yes Skip confirmation prompts - --keep-branch Don't delete the git branch - -This script: -1. Archives task directory to archive/{YYYY-MM}/ -2. Removes agent from registry -3. Removes git worktree -4. Optionally deletes git branch -""" - -from __future__ import annotations - -import argparse -import shutil -import subprocess -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.git_context import _run_git_command -from common.paths import get_repo_root -from common.registry import ( - registry_get_file, - registry_get_task_dir, - registry_remove_by_id, - registry_remove_by_worktree, - registry_search_agent, -) -from common.task_utils import ( - archive_task_complete, - is_safe_task_path, -) - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_warn(msg: str) -> None: - print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def confirm(prompt: str, skip_confirm: bool) -> bool: - """Ask for confirmation.""" - if skip_confirm: - return True - - if not sys.stdin.isatty(): - log_error("Non-interactive mode detected. Use -y to skip confirmation.") - return False - - response = input(f"{prompt} [y/N] ") - return response.lower() in ("y", "yes") - - -# ============================================================================= -# Commands -# ============================================================================= - - -def cmd_list(repo_root: Path) -> int: - """List worktrees.""" - print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") - print() - - subprocess.run(["git", "worktree", "list"], cwd=repo_root) - print() - - # Show registry info - registry_file = registry_get_file(repo_root) - if registry_file and registry_file.is_file(): - print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") - print() - - import json - - data = json.loads(registry_file.read_text(encoding="utf-8")) - agents = data.get("agents", []) - - if agents: - for agent in agents: - print( - f" {agent.get('id', '?')}: PID={agent.get('pid', '?')} [{agent.get('worktree_path', '?')}]" - ) - else: - print(" (none)") - print() - - return 0 - - -def archive_task(worktree_path: str, repo_root: Path) -> None: - """Archive task directory.""" - task_dir = registry_get_task_dir(worktree_path, repo_root) - - if not task_dir or not is_safe_task_path(task_dir, repo_root): - return - - task_dir_abs = repo_root / task_dir - if not task_dir_abs.is_dir(): - return - - result = archive_task_complete(task_dir_abs, repo_root) - if "archived_to" in result: - dest = Path(result["archived_to"]) - log_success(f"Archived task: {dest.name} -> archive/{dest.parent.name}/") - - -def cleanup_registry_only(search: str, repo_root: Path, skip_confirm: bool) -> int: - """Cleanup from registry only (no worktree).""" - agent_info = registry_search_agent(search, repo_root) - - if not agent_info: - log_error(f"No agent found in registry matching: {search}") - return 1 - - agent_id = agent_info.get("id", "?") - task_dir = agent_info.get("task_dir", "?") - - print() - print(f"{Colors.BLUE}=== Cleanup Agent (no worktree) ==={Colors.NC}") - print(f" Agent ID: {agent_id}") - print(f" Task Dir: {task_dir}") - print() - - if not confirm("Archive task and remove from registry?", skip_confirm): - log_info("Aborted") - return 0 - - # Archive task directory if exists - if task_dir and is_safe_task_path(task_dir, repo_root): - task_dir_abs = repo_root / task_dir - if task_dir_abs.is_dir(): - result = archive_task_complete(task_dir_abs, repo_root) - if "archived_to" in result: - dest = Path(result["archived_to"]) - log_success( - f"Archived task: {dest.name} -> archive/{dest.parent.name}/" - ) - else: - log_warn("Invalid task_dir in registry, skipping archive") - - # Remove from registry - registry_remove_by_id(agent_id, repo_root) - log_success(f"Removed from registry: {agent_id}") - - log_success("Cleanup complete") - return 0 - - -def cleanup_worktree( - branch: str, repo_root: Path, skip_confirm: bool, keep_branch: bool -) -> int: - """Cleanup single worktree.""" - # Find worktree path for branch - _, worktree_list, _ = _run_git_command( - ["worktree", "list", "--porcelain"], cwd=repo_root - ) - - worktree_path = None - current_worktree = None - - for line in worktree_list.splitlines(): - if line.startswith("worktree "): - current_worktree = line[9:] # Remove "worktree " prefix - elif line.startswith("branch refs/heads/"): - current_branch = line[18:] # Remove "branch refs/heads/" prefix - if current_branch == branch: - worktree_path = current_worktree - break - - if not worktree_path: - # No worktree found, try to cleanup from registry only - log_warn(f"No worktree found for: {branch}") - log_info("Trying to cleanup from registry...") - return cleanup_registry_only(branch, repo_root, skip_confirm) - - print() - print(f"{Colors.BLUE}=== Cleanup Worktree ==={Colors.NC}") - print(f" Branch: {branch}") - print(f" Worktree: {worktree_path}") - print() - - if not confirm("Remove this worktree?", skip_confirm): - log_info("Aborted") - return 0 - - # 1. Archive task - archive_task(worktree_path, repo_root) - - # 2. Remove from registry - registry_remove_by_worktree(worktree_path, repo_root) - log_info("Removed from registry") - - # 3. Remove worktree - log_info("Removing worktree...") - ret, _, _ = _run_git_command( - ["worktree", "remove", worktree_path, "--force"], cwd=repo_root - ) - if ret != 0: - # Try removing directory manually - try: - shutil.rmtree(worktree_path) - except Exception as e: - log_error(f"Failed to remove worktree: {e}") - - log_success("Worktree removed") - - # 4. Delete branch (optional) - if not keep_branch: - log_info("Deleting branch...") - ret, _, _ = _run_git_command(["branch", "-D", branch], cwd=repo_root) - if ret != 0: - log_warn("Could not delete branch (may be checked out elsewhere)") - - log_success(f"Cleanup complete for: {branch}") - return 0 - - -def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: - """Cleanup merged worktrees.""" - # Get main branch - _, head_out, _ = _run_git_command( - ["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root - ) - main_branch = head_out.strip().replace("refs/remotes/origin/", "") or "main" - - print(f"{Colors.BLUE}=== Finding Merged Worktrees ==={Colors.NC}") - print() - - # Get merged branches - _, merged_out, _ = _run_git_command( - ["branch", "--merged", main_branch], cwd=repo_root - ) - merged_branches = [] - for line in merged_out.splitlines(): - branch = line.strip().lstrip("* ") - if branch and branch != main_branch: - merged_branches.append(branch) - - if not merged_branches: - log_info("No merged branches found") - return 0 - - # Get worktree list - _, worktree_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) - - worktree_branches = [] - for branch in merged_branches: - if f"[{branch}]" in worktree_list: - worktree_branches.append(branch) - print(f" - {branch}") - - if not worktree_branches: - log_info("No merged worktrees found") - return 0 - - print() - if not confirm("Remove these merged worktrees?", skip_confirm): - log_info("Aborted") - return 0 - - for branch in worktree_branches: - cleanup_worktree(branch, repo_root, True, keep_branch) - - return 0 - - -def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int: - """Cleanup all worktrees.""" - print(f"{Colors.BLUE}=== All Worktrees ==={Colors.NC}") - print() - - # Get worktree list - _, worktree_list, _ = _run_git_command( - ["worktree", "list", "--porcelain"], cwd=repo_root - ) - - worktrees = [] - main_worktree = str(repo_root.resolve()) - - for line in worktree_list.splitlines(): - if line.startswith("worktree "): - wt = line[9:] - if wt != main_worktree: - worktrees.append(wt) - - if not worktrees: - log_info("No worktrees to remove") - return 0 - - for wt in worktrees: - print(f" - {wt}") - - print() - print(f"{Colors.RED}WARNING: This will remove ALL worktrees!{Colors.NC}") - - if not confirm("Are you sure?", skip_confirm): - log_info("Aborted") - return 0 - - # Get branch for each worktree - for wt in worktrees: - # Find branch name from worktree list - _, wt_list, _ = _run_git_command(["worktree", "list"], cwd=repo_root) - for line in wt_list.splitlines(): - if wt in line: - # Extract branch from [branch] format - import re - - match = re.search(r"\[([^\]]+)\]", line) - if match: - branch = match.group(1) - cleanup_worktree(branch, repo_root, True, keep_branch) - break - - return 0 - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser( - description="Multi-Agent Pipeline: Cleanup Worktree" - ) - parser.add_argument("branch", nargs="?", help="Branch name to cleanup") - parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") - parser.add_argument( - "--keep-branch", action="store_true", help="Don't delete git branch" - ) - parser.add_argument("--list", action="store_true", help="List all worktrees") - parser.add_argument("--merged", action="store_true", help="Remove merged worktrees") - parser.add_argument("--all", action="store_true", help="Remove all worktrees") - - args = parser.parse_args() - repo_root = get_repo_root() - - if args.list: - return cmd_list(repo_root) - elif args.merged: - return cmd_merged(repo_root, args.yes, args.keep_branch) - elif args.all: - return cmd_all(repo_root, args.yes, args.keep_branch) - elif args.branch: - return cleanup_worktree(args.branch, repo_root, args.yes, args.keep_branch) - else: - print("""Usage: - python3 cleanup.py <branch-name> Remove specific worktree - python3 cleanup.py --list List all worktrees - python3 cleanup.py --merged Remove merged worktrees - python3 cleanup.py --all Remove all worktrees - -Options: - -y, --yes Skip confirmation - --keep-branch Don't delete git branch -""") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/create_pr.py b/.trellis/scripts/multi_agent/create_pr.py deleted file mode 100755 index 54df3db..0000000 --- a/.trellis/scripts/multi_agent/create_pr.py +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Create PR. - -Usage: - python3 create_pr.py [task-dir] [--dry-run] - -This script: -1. Stages and commits all changes (excluding workspace/) -2. Pushes to origin -3. Creates a Draft PR using `gh pr create` -4. Updates task.json with status="completed", pr_url, and current_phase - -Note: This is the only action that performs git commit, as it's the final -step after all implementation and checks are complete. -""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.git_context import _run_git_command -from common.paths import ( - DIR_WORKFLOW, - FILE_TASK_JSON, - get_current_task, - get_repo_root, -) -from common.phase import get_phase_for_action - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" - ) - return True - except (OSError, IOError): - return False - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Create PR") - parser.add_argument("dir", nargs="?", help="Task directory") - parser.add_argument( - "--dry-run", action="store_true", help="Show what would be done" - ) - - args = parser.parse_args() - repo_root = get_repo_root() - - # ============================================================================= - # Get Task Directory - # ============================================================================= - target_dir = args.dir - if not target_dir: - # Try to get from .current-task - current_task = get_current_task(repo_root) - if current_task: - target_dir = current_task - - if not target_dir: - print( - f"{Colors.RED}Error: No task directory specified and no current task set{Colors.NC}" - ) - print("Usage: python3 create_pr.py [task-dir] [--dry-run]") - return 1 - - # Support relative paths - if not target_dir.startswith("/"): - target_dir_path = repo_root / target_dir - else: - target_dir_path = Path(target_dir) - - task_json = target_dir_path / FILE_TASK_JSON - if not task_json.is_file(): - print(f"{Colors.RED}Error: task.json not found at {target_dir_path}{Colors.NC}") - return 1 - - # ============================================================================= - # Main - # ============================================================================= - print(f"{Colors.BLUE}=== Create PR ==={Colors.NC}") - if args.dry_run: - print( - f"{Colors.YELLOW}[DRY-RUN MODE] No actual changes will be made{Colors.NC}" - ) - print() - - # Read task config - task_data = _read_json_file(task_json) - if not task_data: - print(f"{Colors.RED}Error: Failed to read task.json{Colors.NC}") - return 1 - - task_name = task_data.get("name", "") - base_branch = task_data.get("base_branch", "main") - scope = task_data.get("scope", "core") - dev_type = task_data.get("dev_type", "feature") - - # Map dev_type to commit prefix - prefix_map = { - "feature": "feat", - "frontend": "feat", - "backend": "feat", - "fullstack": "feat", - "bugfix": "fix", - "fix": "fix", - "refactor": "refactor", - "docs": "docs", - "test": "test", - } - commit_prefix = prefix_map.get(dev_type, "feat") - - print(f"Task: {task_name}") - print(f"Base branch: {base_branch}") - print(f"Scope: {scope}") - print(f"Commit prefix: {commit_prefix}") - print() - - # Get current branch - _, branch_out, _ = _run_git_command(["branch", "--show-current"]) - current_branch = branch_out.strip() - print(f"Current branch: {current_branch}") - - # Check for changes - print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}") - - # Stage changes - _run_git_command(["add", "-A"]) - - # Exclude workspace and temp files - _run_git_command(["reset", f"{DIR_WORKFLOW}/workspace/"]) - _run_git_command(["reset", ".agent-log", ".session-id"]) - - # Check if there are staged changes - ret, _, _ = _run_git_command(["diff", "--cached", "--quiet"]) - has_staged_changes = ret != 0 - - if not has_staged_changes: - print(f"{Colors.YELLOW}No staged changes to commit{Colors.NC}") - - # Check for unpushed commits - ret, log_out, _ = _run_git_command( - ["log", f"origin/{current_branch}..HEAD", "--oneline"] - ) - unpushed = len([line for line in log_out.splitlines() if line.strip()]) - - if unpushed == 0: - if args.dry_run: - _run_git_command(["reset", "HEAD"]) - print(f"{Colors.RED}No changes to create PR{Colors.NC}") - return 1 - - print(f"Found {unpushed} unpushed commit(s)") - else: - # Commit changes - print(f"{Colors.YELLOW}Committing changes...{Colors.NC}") - commit_msg = f"{commit_prefix}({scope}): {task_name}" - - if args.dry_run: - print(f"[DRY-RUN] Would commit with message: {commit_msg}") - print("[DRY-RUN] Staged files:") - _, staged_out, _ = _run_git_command(["diff", "--cached", "--name-only"]) - for line in staged_out.splitlines(): - print(f" - {line}") - else: - _run_git_command(["commit", "-m", commit_msg]) - print(f"{Colors.GREEN}Committed: {commit_msg}{Colors.NC}") - - # Push to remote - print(f"{Colors.YELLOW}Pushing to remote...{Colors.NC}") - if args.dry_run: - print(f"[DRY-RUN] Would push to: origin/{current_branch}") - else: - ret, _, err = _run_git_command(["push", "-u", "origin", current_branch]) - if ret != 0: - print(f"{Colors.RED}Failed to push: {err}{Colors.NC}") - return 1 - print(f"{Colors.GREEN}Pushed to origin/{current_branch}{Colors.NC}") - - # Create PR - print(f"{Colors.YELLOW}Creating PR...{Colors.NC}") - pr_title = f"{commit_prefix}({scope}): {task_name}" - pr_url = "" - - if args.dry_run: - print("[DRY-RUN] Would create PR:") - print(f" Title: {pr_title}") - print(f" Base: {base_branch}") - print(f" Head: {current_branch}") - prd_file = target_dir_path / "prd.md" - if prd_file.is_file(): - print(" Body: (from prd.md)") - pr_url = "https://github.com/example/repo/pull/DRY-RUN" - else: - # Check if PR already exists - result = subprocess.run( - [ - "gh", - "pr", - "list", - "--head", - current_branch, - "--base", - base_branch, - "--json", - "url", - "--jq", - ".[0].url", - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - existing_pr = result.stdout.strip() - - if existing_pr: - print(f"{Colors.YELLOW}PR already exists: {existing_pr}{Colors.NC}") - pr_url = existing_pr - else: - # Read PRD as PR body - pr_body = "" - prd_file = target_dir_path / "prd.md" - if prd_file.is_file(): - pr_body = prd_file.read_text(encoding="utf-8") - - # Create PR - result = subprocess.run( - [ - "gh", - "pr", - "create", - "--draft", - "--base", - base_branch, - "--title", - pr_title, - "--body", - pr_body, - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - - if result.returncode != 0: - print(f"{Colors.RED}Failed to create PR: {result.stderr}{Colors.NC}") - return 1 - - pr_url = result.stdout.strip() - print(f"{Colors.GREEN}PR created: {pr_url}{Colors.NC}") - - # Update task.json - print(f"{Colors.YELLOW}Updating task status...{Colors.NC}") - if args.dry_run: - print("[DRY-RUN] Would update task.json:") - print(" status: completed") - print(f" pr_url: {pr_url}") - print(" current_phase: (set to create-pr phase)") - else: - # Get the phase number for create-pr action - create_pr_phase = get_phase_for_action(task_json, "create-pr") - if not create_pr_phase: - create_pr_phase = 4 # Default fallback - - task_data["status"] = "completed" - task_data["pr_url"] = pr_url - task_data["current_phase"] = create_pr_phase - - _write_json_file(task_json, task_data) - print( - f"{Colors.GREEN}Task status updated to 'completed', phase {create_pr_phase}{Colors.NC}" - ) - - # In dry-run, reset the staging area - if args.dry_run: - _run_git_command(["reset", "HEAD"]) - - print() - print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}") - print(f"PR URL: {pr_url}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/plan.py b/.trellis/scripts/multi_agent/plan.py deleted file mode 100755 index 7ce5e6f..0000000 --- a/.trellis/scripts/multi_agent/plan.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Plan Agent Launcher. - -Usage: python3 plan.py --name <task-name> --type <dev-type> --requirement "<requirement>" - -This script: -1. Creates task directory -2. Starts Plan Agent in background -3. Plan Agent produces fully configured task directory - -After completion, use start.py to launch the Dispatch Agent. - -Prerequisites: - - agents/plan.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) - - Developer must be initialized -""" - -from __future__ import annotations - -import argparse -import os -import subprocess -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.cli_adapter import get_cli_adapter -from common.paths import get_repo_root -from common.developer import ensure_developer - - -# ============================================================================= -# Colors -# ============================================================================= - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") - - -# ============================================================================= -# Constants -# ============================================================================= - -DEFAULT_PLATFORM = "claude" - - -# ============================================================================= -# Main -# ============================================================================= - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser( - description="Multi-Agent Pipeline: Plan Agent Launcher" - ) - parser.add_argument("--name", "-n", required=True, help="Task name (e.g., user-auth)") - parser.add_argument("--type", "-t", required=True, help="Dev type: backend|frontend|fullstack") - parser.add_argument("--requirement", "-r", required=True, help="Requirement description") - parser.add_argument( - "--platform", "-p", - choices=["claude", "cursor", "iflow", "opencode", "qoder"], - default=DEFAULT_PLATFORM, - help="Platform to use (default: claude)" - ) - - args = parser.parse_args() - - task_name = args.name - dev_type = args.type - requirement = args.requirement - platform = args.platform - - # Initialize CLI adapter - adapter = get_cli_adapter(platform) - - # Validate dev type - if dev_type not in ("backend", "frontend", "fullstack"): - log_error(f"Invalid dev type: {dev_type} (must be: backend, frontend, fullstack)") - return 1 - - project_root = get_repo_root() - - # Check plan agent exists (path varies by platform) - plan_md = adapter.get_agent_path("plan", project_root) - if not plan_md.is_file(): - log_error(f"plan agent not found at {plan_md}") - log_info(f"Platform: {platform}") - return 1 - - ensure_developer(project_root) - - # ============================================================================= - # Step 1: Create Task Directory - # ============================================================================= - print() - print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Plan ==={Colors.NC}") - log_info(f"Task: {task_name}") - log_info(f"Type: {dev_type}") - log_info(f"Requirement: {requirement}") - print() - - log_info("Step 1: Creating task directory...") - - # Import task module to create task - from task import cmd_create - import argparse as ap - - # Create task using task.py's create command - create_args = ap.Namespace( - title=requirement, - slug=task_name, - assignee=None, - priority="P2", - description="" - ) - - # Capture stdout to get task dir - import io - from contextlib import redirect_stdout - - stdout_capture = io.StringIO() - with redirect_stdout(stdout_capture): - ret = cmd_create(create_args) - - if ret != 0: - log_error("Failed to create task directory") - return 1 - - task_dir = stdout_capture.getvalue().strip().split("\n")[-1] - task_dir_abs = project_root / task_dir - - log_success(f"Task directory: {task_dir}") - - # ============================================================================= - # Step 2: Prepare and Start Plan Agent - # ============================================================================= - log_info("Step 2: Starting Plan Agent in background...") - - log_file = task_dir_abs / ".plan-log" - log_file.touch() - - # Get proxy environment variables - https_proxy = os.environ.get("https_proxy", "") - http_proxy = os.environ.get("http_proxy", "") - all_proxy = os.environ.get("all_proxy", "") - - # Start agent in background (cross-platform, no shell script needed) - env = os.environ.copy() - env["PLAN_TASK_NAME"] = task_name - env["PLAN_DEV_TYPE"] = dev_type - env["PLAN_TASK_DIR"] = task_dir - env["PLAN_REQUIREMENT"] = requirement - env["https_proxy"] = https_proxy - env["http_proxy"] = http_proxy - env["all_proxy"] = all_proxy - - # Clear nested-session detection so the new CLI process can start - env.pop("CLAUDECODE", None) - - # Set non-interactive env var based on platform - env.update(adapter.get_non_interactive_env()) - - # Build CLI command using adapter - cli_cmd = adapter.build_run_command( - agent="plan", # Will be mapped to "trellis-plan" for OpenCode - prompt=f"Start planning for task: {task_name}", - skip_permissions=True, - verbose=True, - json_output=True, - ) - - with log_file.open("w") as log_f: - # Use shell=False for cross-platform compatibility - # creationflags for Windows, start_new_session for Unix - popen_kwargs = { - "stdout": log_f, - "stderr": subprocess.STDOUT, - "cwd": str(project_root), - "env": env, - } - if sys.platform == "win32": - popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - popen_kwargs["start_new_session"] = True - - process = subprocess.Popen(cli_cmd, **popen_kwargs) - agent_pid = process.pid - - log_success(f"Plan Agent started (PID: {agent_pid})") - - # ============================================================================= - # Summary - # ============================================================================= - print() - print(f"{Colors.GREEN}=== Plan Agent Running ==={Colors.NC}") - print() - print(f" Task: {task_name}") - print(f" Type: {dev_type}") - print(f" Dir: {task_dir}") - print(f" Log: {log_file}") - print(f" PID: {agent_pid}") - print() - print(f"{Colors.YELLOW}To monitor:{Colors.NC}") - print(f" tail -f {log_file}") - print() - print(f"{Colors.YELLOW}To check status:{Colors.NC}") - print(f" ps -p {agent_pid}") - print(f" ls -la {task_dir}") - print() - print(f"{Colors.YELLOW}After completion, run:{Colors.NC}") - print(f" python3 ./.trellis/scripts/multi_agent/start.py {task_dir}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/start.py b/.trellis/scripts/multi_agent/start.py deleted file mode 100755 index 40c2747..0000000 --- a/.trellis/scripts/multi_agent/start.py +++ /dev/null @@ -1,465 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Start Worktree Agent. - -Usage: python3 start.py <task-dir> -Example: python3 start.py .trellis/tasks/01-21-my-task - -This script: -1. Creates worktree (if not exists) with dependency install -2. Copies environment files (from worktree.yaml config) -3. Sets .current-task in worktree -4. Starts claude agent in background -5. Registers agent to registry.json - -Prerequisites: - - task.json must exist with 'branch' field - - agents/dispatch.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/) - -Configuration: .trellis/worktree.yaml -""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import sys -import uuid -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.cli_adapter import CLIAdapter, get_cli_adapter -from common.git_context import _run_git_command -from common.paths import ( - DIR_WORKFLOW, - FILE_CURRENT_TASK, - FILE_TASK_JSON, - get_repo_root, -) -from common.registry import ( - registry_add_agent, - registry_get_file, -) -from common.worktree import ( - get_worktree_base_dir, - get_worktree_config, - get_worktree_copy_files, - get_worktree_post_create_hooks, -) - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - NC = "\033[0m" - - -def log_info(msg: str) -> None: - print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}") - - -def log_success(msg: str) -> None: - print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}") - - -def log_warn(msg: str) -> None: - print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}") - - -def log_error(msg: str) -> None: - print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}") - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" - ) - return True - except (OSError, IOError): - return False - - -# ============================================================================= -# Constants -# ============================================================================= - -DEFAULT_PLATFORM = "claude" - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - import argparse - - parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Start Worktree Agent") - parser.add_argument("task_dir", help="Task directory path") - parser.add_argument( - "--platform", "-p", - choices=["claude", "cursor", "iflow", "opencode", "qoder"], - default=DEFAULT_PLATFORM, - help="Platform to use (default: claude)" - ) - - args = parser.parse_args() - task_dir_arg = args.task_dir - platform = args.platform - - # Initialize CLI adapter - adapter = get_cli_adapter(platform) - - project_root = get_repo_root() - - # Normalize paths - if task_dir_arg.startswith("/"): - task_dir_relative = task_dir_arg[len(str(project_root)) + 1 :] - task_dir_abs = Path(task_dir_arg) - else: - task_dir_relative = task_dir_arg - task_dir_abs = project_root / task_dir_arg - - task_json_path = task_dir_abs / FILE_TASK_JSON - - # ============================================================================= - # Validation - # ============================================================================= - if not task_json_path.is_file(): - log_error(f"task.json not found at {task_json_path}") - return 1 - - dispatch_md = adapter.get_agent_path("dispatch", project_root) - if not dispatch_md.is_file(): - log_error(f"dispatch.md not found at {dispatch_md}") - log_info(f"Platform: {platform}") - return 1 - - config_file = get_worktree_config(project_root) - if not config_file.is_file(): - log_error(f"worktree.yaml not found at {config_file}") - return 1 - - # ============================================================================= - # Read Task Config - # ============================================================================= - print() - print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Start ==={Colors.NC}") - log_info(f"Task: {task_dir_abs}") - - task_data = _read_json_file(task_json_path) - if not task_data: - log_error("Failed to read task.json") - return 1 - - branch = task_data.get("branch") - task_name = task_data.get("name") - task_status = task_data.get("status") - worktree_path = task_data.get("worktree_path") - - # Check if task was rejected - if task_status == "rejected": - log_error("Task was rejected by Plan Agent") - rejected_file = task_dir_abs / "REJECTED.md" - if rejected_file.is_file(): - print() - print(f"{Colors.YELLOW}Rejection reason:{Colors.NC}") - print(rejected_file.read_text(encoding="utf-8")) - print() - log_info( - "To retry, delete this directory and run plan.py again with revised requirements" - ) - return 1 - - # Check if prd.md exists (plan completed successfully) - prd_file = task_dir_abs / "prd.md" - if not prd_file.is_file(): - log_error("prd.md not found - Plan Agent may not have completed") - log_info(f"Check plan log: {task_dir_abs}/.plan-log") - return 1 - - if not branch: - log_error("branch field not set in task.json") - log_info("Please set branch field first, e.g.:") - log_info( - " jq '.branch = \"task/my-task\"' task.json > tmp && mv tmp task.json" - ) - return 1 - - log_info(f"Branch: {branch}") - log_info(f"Name: {task_name}") - - # ============================================================================= - # Step 1: Create Worktree (if not exists) - # ============================================================================= - if not worktree_path or not Path(worktree_path).is_dir(): - log_info("Step 1: Creating worktree...") - - # Record current branch as base_branch (PR target) - _, base_branch_out, _ = _run_git_command( - ["branch", "--show-current"], cwd=project_root - ) - base_branch = base_branch_out.strip() - log_info(f"Base branch (PR target): {base_branch}") - - # Calculate worktree path - worktree_base = get_worktree_base_dir(project_root) - worktree_base.mkdir(parents=True, exist_ok=True) - worktree_base = worktree_base.resolve() - worktree_path_obj = worktree_base / branch - worktree_path = str(worktree_path_obj) - - # Create parent directory - worktree_path_obj.parent.mkdir(parents=True, exist_ok=True) - - # Create branch if not exists - ret, _, _ = _run_git_command( - ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], - cwd=project_root, - ) - if ret == 0: - log_info("Branch exists, checking out...") - ret, _, err = _run_git_command( - ["worktree", "add", worktree_path, branch], cwd=project_root - ) - else: - log_info(f"Creating new branch: {branch}") - ret, _, err = _run_git_command( - ["worktree", "add", "-b", branch, worktree_path], cwd=project_root - ) - - if ret != 0: - log_error(f"Failed to create worktree: {err}") - return 1 - - log_success(f"Worktree created: {worktree_path}") - - # Update task.json with worktree_path and base_branch - task_data["worktree_path"] = worktree_path - task_data["base_branch"] = base_branch - _write_json_file(task_json_path, task_data) - - # ----- Copy environment files ----- - log_info("Copying environment files...") - copy_list = get_worktree_copy_files(project_root) - copy_count = 0 - - for item in copy_list: - if not item: - continue - - source = project_root / item - target = Path(worktree_path) / item - - if source.is_file(): - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(str(source), str(target)) - copy_count += 1 - - if copy_count > 0: - log_success(f"Copied {copy_count} file(s)") - - # ----- Copy task directory (may not be committed yet) ----- - log_info("Copying task directory...") - task_target_dir = Path(worktree_path) / task_dir_relative - task_target_dir.parent.mkdir(parents=True, exist_ok=True) - if task_target_dir.exists(): - shutil.rmtree(str(task_target_dir)) - shutil.copytree(str(task_dir_abs), str(task_target_dir)) - log_success("Task directory copied to worktree") - - # ----- Run post_create hooks ----- - log_info("Running post_create hooks...") - post_create = get_worktree_post_create_hooks(project_root) - hook_count = 0 - - for cmd in post_create: - if not cmd: - continue - - log_info(f" Running: {cmd}") - ret = subprocess.run(cmd, shell=True, cwd=worktree_path) - if ret.returncode != 0: - log_error(f"Hook failed: {cmd}") - return 1 - hook_count += 1 - - if hook_count > 0: - log_success(f"Ran {hook_count} hook(s)") - else: - log_info(f"Step 1: Using existing worktree: {worktree_path}") - - # ============================================================================= - # Step 2: Set .current-task in Worktree - # ============================================================================= - log_info("Step 2: Setting current task in worktree...") - - worktree_workflow_dir = Path(worktree_path) / DIR_WORKFLOW - worktree_workflow_dir.mkdir(parents=True, exist_ok=True) - - current_task_file = worktree_workflow_dir / FILE_CURRENT_TASK - current_task_file.write_text(task_dir_relative, encoding="utf-8") - log_success(f"Current task set: {task_dir_relative}") - - # ============================================================================= - # Step 3: Prepare and Start Claude Agent - # ============================================================================= - log_info(f"Step 3: Starting {adapter.cli_name} agent...") - - # Update task status - task_data["status"] = "in_progress" - _write_json_file(task_json_path, task_data) - - log_file = Path(worktree_path) / ".agent-log" - session_id_file = Path(worktree_path) / ".session-id" - - log_file.touch() - - # Generate session ID for resume support (Claude Code only) - # OpenCode generates its own session ID, we'll extract it from logs later - if adapter.supports_session_id_on_create: - session_id = str(uuid.uuid4()).lower() - session_id_file.write_text(session_id, encoding="utf-8") - log_info(f"Session ID: {session_id}") - else: - session_id = None # Will be extracted from logs after startup - log_info("Session ID will be extracted from logs after startup") - - # Get proxy environment variables - https_proxy = os.environ.get("https_proxy", "") - http_proxy = os.environ.get("http_proxy", "") - all_proxy = os.environ.get("all_proxy", "") - - # Start agent in background (cross-platform, no shell script needed) - env = os.environ.copy() - env["https_proxy"] = https_proxy - env["http_proxy"] = http_proxy - env["all_proxy"] = all_proxy - - # Clear nested-session detection so the new CLI process can start - # (when this script runs inside a Claude Code session, CLAUDECODE=1 is inherited) - env.pop("CLAUDECODE", None) - - # Set non-interactive env var based on platform - env.update(adapter.get_non_interactive_env()) - - # Build CLI command using adapter - # Note: Use explicit prompt to avoid confusion with CI/CD pipelines - # Also remind the model to follow its agent definition for better cross-model compatibility - cli_cmd = adapter.build_run_command( - agent="dispatch", - prompt="Follow your agent instructions to execute the task workflow. Start by reading .trellis/.current-task to get the task directory, then execute each action in task.json next_action array in order.", - session_id=session_id if adapter.supports_session_id_on_create else None, - skip_permissions=True, - verbose=True, - json_output=True, - ) - - with log_file.open("w") as log_f: - # Use shell=False for cross-platform compatibility - # creationflags for Windows, start_new_session for Unix - popen_kwargs = { - "stdout": log_f, - "stderr": subprocess.STDOUT, - "cwd": worktree_path, - "env": env, - } - if sys.platform == "win32": - popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - popen_kwargs["start_new_session"] = True - - process = subprocess.Popen(cli_cmd, **popen_kwargs) - agent_pid = process.pid - - log_success(f"Agent started with PID: {agent_pid}") - - # For OpenCode: extract session ID from logs after startup - if not adapter.supports_session_id_on_create: - import time - log_info("Waiting for session ID from logs...") - # Wait a bit for the log to have session ID - for _ in range(10): # Try for up to 5 seconds - time.sleep(0.5) - try: - log_content = log_file.read_text(encoding="utf-8", errors="replace") - session_id = adapter.extract_session_id_from_log(log_content) - if session_id: - session_id_file.write_text(session_id, encoding="utf-8") - log_success(f"Session ID extracted: {session_id}") - break - except Exception: - pass - else: - log_warn("Could not extract session ID from logs") - session_id = "unknown" - - # ============================================================================= - # Step 4: Register to Registry (in main repo, not worktree) - # ============================================================================= - log_info("Step 4: Registering agent to registry...") - - # Generate agent ID - task_id = task_data.get("id") - if not task_id: - task_id = branch.replace("/", "-") - - registry_add_agent( - task_id, worktree_path, agent_pid, task_dir_relative, project_root, platform - ) - - log_success(f"Agent registered: {task_id}") - - # ============================================================================= - # Summary - # ============================================================================= - print() - print(f"{Colors.GREEN}=== Agent Started ==={Colors.NC}") - print() - print(f" ID: {task_id}") - print(f" PID: {agent_pid}") - print(f" Session: {session_id}") - print(f" Worktree: {worktree_path}") - print(f" Task: {task_dir_relative}") - print(f" Log: {log_file}") - print(f" Registry: {registry_get_file(project_root)}") - print() - print(f"{Colors.YELLOW}To monitor:{Colors.NC} tail -f {log_file}") - print(f"{Colors.YELLOW}To stop:{Colors.NC} kill {agent_pid}") - if session_id and session_id != "unknown": - resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree_path) - print(f"{Colors.YELLOW}To resume:{Colors.NC} {resume_cmd}") - else: - print(f"{Colors.YELLOW}To resume:{Colors.NC} (session ID not available)") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/multi_agent/status.py b/.trellis/scripts/multi_agent/status.py deleted file mode 100755 index e83ac60..0000000 --- a/.trellis/scripts/multi_agent/status.py +++ /dev/null @@ -1,817 +0,0 @@ -#!/usr/bin/env python3 -""" -Multi-Agent Pipeline: Status Monitor. - -Usage: - python3 status.py Show summary of all tasks (default) - python3 status.py -a <assignee> Filter tasks by assignee - python3 status.py --list List all worktrees and agents - python3 status.py --detail <task> Detailed task status - python3 status.py --watch <task> Watch agent log in real-time - python3 status.py --log <task> Show recent log entries - python3 status.py --registry Show agent registry -""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import time -from datetime import datetime -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from common.cli_adapter import get_cli_adapter -from common.developer import ensure_developer -from common.paths import ( - FILE_TASK_JSON, - get_repo_root, - get_tasks_dir, -) -from common.phase import get_phase_info -from common.task_queue import format_task_stats, get_task_stats -from common.worktree import get_agents_dir - -# ============================================================================= -# Colors -# ============================================================================= - - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - CYAN = "\033[0;36m" - DIM = "\033[2m" - NC = "\033[0m" - - -# ============================================================================= -# Helper Functions -# ============================================================================= - - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def is_running(pid: int | str | None) -> bool: - """Check if PID is running.""" - if not pid: - return False - try: - pid_int = int(pid) - os.kill(pid_int, 0) - return True - except (ProcessLookupError, ValueError, PermissionError, TypeError): - return False - - -def status_color(status: str) -> str: - """Get status color.""" - colors = { - "completed": Colors.GREEN, - "in_progress": Colors.BLUE, - "planning": Colors.YELLOW, - } - return colors.get(status, Colors.DIM) - - -def get_registry_file(repo_root: Path) -> Path | None: - """Get registry file path.""" - agents_dir = get_agents_dir(repo_root) - if agents_dir: - return agents_dir / "registry.json" - return None - - -def find_agent(search: str, repo_root: Path) -> dict | None: - """Find agent by task name or ID.""" - registry_file = get_registry_file(repo_root) - if not registry_file or not registry_file.is_file(): - return None - - data = _read_json_file(registry_file) - if not data: - return None - - for agent in data.get("agents", []): - # Exact ID match - if agent.get("id") == search: - return agent - # Partial match on task_dir - task_dir = agent.get("task_dir", "") - if search in task_dir: - return agent - - return None - - -def calc_elapsed(started: str | None) -> str: - """Calculate elapsed time from ISO timestamp.""" - if not started: - return "N/A" - - try: - # Parse ISO format - if "+" in started: - started = started.split("+")[0] - if "T" in started: - start_dt = datetime.fromisoformat(started) - else: - return "N/A" - - now = datetime.now() - elapsed = (now - start_dt).total_seconds() - - if elapsed < 60: - return f"{int(elapsed)}s" - elif elapsed < 3600: - mins = int(elapsed // 60) - secs = int(elapsed % 60) - return f"{mins}m {secs}s" - else: - hours = int(elapsed // 3600) - mins = int((elapsed % 3600) // 60) - return f"{hours}h {mins}m" - except (ValueError, TypeError): - return "N/A" - - -def count_modified_files(worktree: str) -> int: - """Count modified files in worktree.""" - if not Path(worktree).is_dir(): - return 0 - - try: - result = subprocess.run( - ["git", "status", "--short"], - cwd=worktree, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - return len([line for line in result.stdout.splitlines() if line.strip()]) - except Exception: - return 0 - - -def tail_follow(file_path: Path) -> None: - """Follow a file like 'tail -f', cross-platform compatible.""" - with open(file_path, "r", encoding="utf-8", errors="replace") as f: - # Seek to end of file - f.seek(0, 2) - - while True: - line = f.readline() - if line: - print(line, end="", flush=True) - else: - time.sleep(0.1) - - -def get_last_tool(log_file: Path, platform: str = "claude") -> str | None: - """Get the last tool call from agent log. - - Supports both Claude Code and OpenCode log formats. - - Claude Code format: - {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}} - - OpenCode format: - {"type": "tool_use", "tool": "bash", "state": {"status": "completed"}} - """ - if not log_file.is_file(): - return None - - try: - lines = log_file.read_text(encoding="utf-8").splitlines() - for line in reversed(lines[-100:]): - try: - data = json.loads(line) - - if platform == "opencode": - # OpenCode format: {"type": "tool_use", "tool": "bash", ...} - if data.get("type") == "tool_use": - return data.get("tool") - else: - # Claude Code format: {"type": "assistant", "message": {"content": [...]}} - if data.get("type") == "assistant": - content = data.get("message", {}).get("content", []) - for item in content: - if item.get("type") == "tool_use": - return item.get("name") - except json.JSONDecodeError: - continue - except Exception: - pass - return None - - -def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None: - """Get the last assistant text from agent log. - - Supports both Claude Code and OpenCode log formats. - - Claude Code format: - {"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}} - - OpenCode format: - {"type": "text", "text": "..."} - """ - if not log_file.is_file(): - return None - - try: - lines = log_file.read_text(encoding="utf-8").splitlines() - for line in reversed(lines[-100:]): - try: - data = json.loads(line) - - if platform == "opencode": - # OpenCode format: {"type": "text", "text": "..."} - if data.get("type") == "text": - text = data.get("text", "") - if text: - return text[:max_len] - else: - # Claude Code format: {"type": "assistant", "message": {"content": [...]}} - if data.get("type") == "assistant": - content = data.get("message", {}).get("content", []) - for item in content: - if item.get("type") == "text": - text = item.get("text", "") - if text: - return text[:max_len] - except json.JSONDecodeError: - continue - except Exception: - pass - return None - - -# ============================================================================= -# Commands -# ============================================================================= - - -def cmd_help() -> int: - """Show help.""" - print("""Multi-Agent Pipeline: Status Monitor - -Usage: - python3 status.py Show summary of all tasks - python3 status.py -a <assignee> Filter tasks by assignee - python3 status.py --list List all worktrees and agents - python3 status.py --detail <task> Detailed task status - python3 status.py --progress <task> Quick progress view with recent activity - python3 status.py --watch <task> Watch agent log in real-time - python3 status.py --log <task> Show recent log entries - python3 status.py --registry Show agent registry - -Examples: - python3 status.py -a taosu - python3 status.py --detail my-task - python3 status.py --progress my-task - python3 status.py --watch 01-16-worktree-support - python3 status.py --log worktree-support -""") - return 0 - - -def cmd_list(repo_root: Path) -> int: - """List worktrees and agents.""" - print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}") - print() - - subprocess.run(["git", "worktree", "list"], cwd=repo_root) - print() - - print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}") - print() - - registry_file = get_registry_file(repo_root) - if not registry_file or not registry_file.is_file(): - print(" (no registry found)") - return 0 - - data = _read_json_file(registry_file) - if not data or not data.get("agents"): - print(" (no agents registered)") - return 0 - - for agent in data["agents"]: - agent_id = agent.get("id", "?") - pid = agent.get("pid") - wt = agent.get("worktree_path", "?") - started = agent.get("started_at", "?") - - if is_running(pid): - status_icon = f"{Colors.GREEN}●{Colors.NC}" - else: - status_icon = f"{Colors.RED}○{Colors.NC}" - - print(f" {status_icon} {agent_id} (PID: {pid})") - print(f" {Colors.DIM}Worktree: {wt}{Colors.NC}") - print(f" {Colors.DIM}Started: {started}{Colors.NC}") - print() - - return 0 - - -def cmd_summary(repo_root: Path, filter_assignee: str | None = None) -> int: - """Show summary of all tasks.""" - ensure_developer(repo_root) - - tasks_dir = get_tasks_dir(repo_root) - if not tasks_dir.is_dir(): - print("No tasks directory found") - return 0 - - registry_file = get_registry_file(repo_root) - - # Count running agents - running_count = 0 - total_agents = 0 - - if registry_file and registry_file.is_file(): - data = _read_json_file(registry_file) - if data: - agents = data.get("agents", []) - total_agents = len(agents) - for agent in agents: - if is_running(agent.get("pid")): - running_count += 1 - - # Task queue stats - task_stats = get_task_stats(repo_root) - - print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}") - print( - f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered" - ) - print(f" Tasks: {format_task_stats(task_stats)}") - print() - - # Process tasks - running_tasks = [] - stopped_tasks = [] - regular_tasks = [] - - registry_data = ( - _read_json_file(registry_file) - if registry_file and registry_file.is_file() - else None - ) - - for d in sorted(tasks_dir.iterdir()): - if not d.is_dir() or d.name == "archive": - continue - - name = d.name - task_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "unassigned" - priority = "P2" - - if task_json.is_file(): - data = _read_json_file(task_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "unassigned") - priority = data.get("priority", "P2") - - # Filter by assignee - if filter_assignee and assignee != filter_assignee: - continue - - # Check agent status - agent_info = None - if registry_data: - for agent in registry_data.get("agents", []): - if name in agent.get("task_dir", ""): - agent_info = agent - break - - if agent_info: - pid = agent_info.get("pid") - worktree = agent_info.get("worktree_path", "") - started = agent_info.get("started_at") - agent_platform = agent_info.get("platform", "claude") - - if is_running(pid): - # Running agent - task_dir_rel = agent_info.get("task_dir", "") - worktree_task_json = Path(worktree) / task_dir_rel / "task.json" - phase_source = task_json - if worktree_task_json.is_file(): - phase_source = worktree_task_json - - phase_info_str = get_phase_info(phase_source) - elapsed = calc_elapsed(started) - modified = count_modified_files(worktree) - - worktree_data = _read_json_file(phase_source) - branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A" - - log_file = Path(worktree) / ".agent-log" - last_tool = get_last_tool(log_file, platform=agent_platform) - - running_tasks.append( - { - "name": name, - "priority": priority, - "assignee": assignee, - "phase_info": phase_info_str, - "elapsed": elapsed, - "branch": branch, - "modified": modified, - "last_tool": last_tool, - "pid": pid, - } - ) - else: - # Stopped agent - task_dir_rel = agent_info.get("task_dir", "") - worktree_task_json = Path(worktree) / task_dir_rel / "task.json" - worktree_status = "unknown" - - if worktree_task_json.is_file(): - wt_data = _read_json_file(worktree_task_json) - if wt_data: - worktree_status = wt_data.get("status", "unknown") - - session_id_file = Path(worktree) / ".session-id" - log_file = Path(worktree) / ".agent-log" - - stopped_tasks.append( - { - "name": name, - "worktree": worktree, - "status": worktree_status, - "session_id_file": session_id_file, - "log_file": log_file, - "platform": agent_info.get("platform", "claude"), - } - ) - else: - # Regular task - regular_tasks.append( - { - "name": name, - "status": status, - "priority": priority, - "assignee": assignee, - } - ) - - # Output running agents - if running_tasks: - print(f"{Colors.CYAN}Running Agents:{Colors.NC}") - for t in running_tasks: - priority_color = ( - Colors.RED - if t["priority"] == "P0" - else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) - ) - print( - f"{Colors.GREEN}▶{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}" - ) - print(f" Phase: {t['phase_info']}") - print(f" Elapsed: {t['elapsed']}") - print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}") - print(f" Modified: {t['modified']} file(s)") - if t["last_tool"]: - print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}") - print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}") - print() - - # Output stopped agents - if stopped_tasks: - print(f"{Colors.RED}Stopped Agents:{Colors.NC}") - for t in stopped_tasks: - if t["status"] == "completed": - print( - f"{Colors.GREEN}✓{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}" - ) - else: - if t["session_id_file"].is_file(): - session_id = ( - t["session_id_file"].read_text(encoding="utf-8").strip() - ) - last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude")) - print( - f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}" - ) - if last_msg: - print(f'{Colors.DIM}"{last_msg}"{Colors.NC}') - # Use CLI adapter for platform-specific resume command - adapter = get_cli_adapter(t.get("platform", "claude")) - resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"]) - print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}") - else: - print( - f"{Colors.RED}○{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}" - ) - print() - - # Separator - if (running_tasks or stopped_tasks) and regular_tasks: - print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}") - print() - - # Output regular tasks grouped by assignee - if regular_tasks: - # Sort by assignee, priority, status - regular_tasks.sort( - key=lambda x: ( - x["assignee"], - {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2), - {"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1), - ) - ) - - current_assignee = None - for t in regular_tasks: - if t["assignee"] != current_assignee: - if current_assignee is not None: - print() - print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}") - current_assignee = t["assignee"] - - color = status_color(t["status"]) - priority_color = ( - Colors.RED - if t["priority"] == "P0" - else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE) - ) - print( - f" {color}●{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}" - ) - - if running_tasks: - print() - print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}") - print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}") - print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}") - - print() - return 0 - - -def cmd_detail(target: str, repo_root: Path) -> int: - """Show detailed task status.""" - agent = find_agent(target, repo_root) - if not agent: - print(f"Agent not found: {target}") - return 1 - - agent_id = agent.get("id", "?") - pid = agent.get("pid") - worktree = agent.get("worktree_path", "?") - task_dir = agent.get("task_dir", "?") - started = agent.get("started_at", "?") - platform = agent.get("platform", "claude") - - # Check for session-id - session_id = "" - session_id_file = Path(worktree) / ".session-id" - if session_id_file.is_file(): - session_id = session_id_file.read_text(encoding="utf-8").strip() - - print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}") - print() - print(f" ID: {agent_id}") - print(f" PID: {pid}") - print(f" Session: {session_id or 'N/A'}") - print(f" Worktree: {worktree}") - print(f" Task Dir: {task_dir}") - print(f" Started: {started}") - print() - - # Status - if is_running(pid): - print(f" Status: {Colors.GREEN}Running{Colors.NC}") - else: - print(f" Status: {Colors.RED}Stopped{Colors.NC}") - if session_id: - print() - # Use CLI adapter for platform-specific resume command - adapter = get_cli_adapter(platform) - resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree) - print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}") - - # Task info - task_json = repo_root / task_dir / "task.json" - if task_json.is_file(): - print() - print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}") - print() - data = _read_json_file(task_json) - if data: - print(f" Status: {data.get('status', 'unknown')}") - print(f" Branch: {data.get('branch', 'N/A')}") - print(f" Base Branch: {data.get('base_branch', 'N/A')}") - - # Git changes - if Path(worktree).is_dir(): - print() - print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}") - print() - - result = subprocess.run( - ["git", "status", "--short"], - cwd=worktree, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - changes = result.stdout.strip() - if changes: - for line in changes.splitlines()[:10]: - print(f" {line}") - total = len(changes.splitlines()) - if total > 10: - print(f" ... and {total - 10} more") - else: - print(" (no changes)") - - print() - return 0 - - -def cmd_watch(target: str, repo_root: Path) -> int: - """Watch agent log in real-time.""" - agent = find_agent(target, repo_root) - if not agent: - print(f"Agent not found: {target}") - return 1 - - worktree = agent.get("worktree_path", "") - log_file = Path(worktree) / ".agent-log" - - if not log_file.is_file(): - print(f"Log file not found: {log_file}") - return 1 - - print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}") - print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}") - print() - - try: - tail_follow(log_file) - except KeyboardInterrupt: - print() # Clean newline after Ctrl+C - return 0 - - -def cmd_log(target: str, repo_root: Path) -> int: - """Show recent log entries.""" - agent = find_agent(target, repo_root) - if not agent: - print(f"Agent not found: {target}") - return 1 - - worktree = agent.get("worktree_path", "") - platform = agent.get("platform", "claude") - log_file = Path(worktree) / ".agent-log" - - if not log_file.is_file(): - print(f"Log file not found: {log_file}") - return 1 - - print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}") - print(f"{Colors.DIM}Platform: {platform}{Colors.NC}") - print() - - lines = log_file.read_text(encoding="utf-8").splitlines() - for line in lines[-50:]: - try: - data = json.loads(line) - msg_type = data.get("type", "") - - if platform == "opencode": - # OpenCode format - if msg_type == "text": - text = data.get("text", "") - if text: - display = text[:300] - if len(text) > 300: - display += "..." - print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}") - elif msg_type == "tool_use": - tool_name = data.get("tool", "unknown") - status = data.get("state", {}).get("status", "") - print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})") - elif msg_type == "step_start": - print(f"{Colors.CYAN}[STEP]{Colors.NC} Start") - elif msg_type == "step_finish": - reason = data.get("reason", "") - print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})") - elif msg_type == "error": - error_msg = data.get("message", "") - print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}") - else: - # Claude Code format - if msg_type == "system": - subtype = data.get("subtype", "") - print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}") - elif msg_type == "user": - content = data.get("message", {}).get("content", "") - if content: - print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}") - elif msg_type == "assistant": - content = data.get("message", {}).get("content", []) - if content: - item = content[0] - text = item.get("text") - tool = item.get("name") - if text: - display = text[:300] - if len(text) > 300: - display += "..." - print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}") - elif tool: - print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}") - elif msg_type == "result": - tool_name = data.get("tool", "unknown") - print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed") - except json.JSONDecodeError: - continue - - return 0 - - -def cmd_registry(repo_root: Path) -> int: - """Show agent registry.""" - registry_file = get_registry_file(repo_root) - - print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}") - print() - print(f"File: {registry_file}") - print() - - if registry_file and registry_file.is_file(): - data = _read_json_file(registry_file) - if data: - print(json.dumps(data, indent=2)) - else: - print("(registry not found)") - - return 0 - - -# ============================================================================= -# Main -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Status Monitor") - parser.add_argument("-a", "--assignee", help="Filter by assignee") - parser.add_argument( - "--list", action="store_true", help="List all worktrees and agents" - ) - parser.add_argument("--detail", metavar="TASK", help="Detailed task status") - parser.add_argument("--progress", metavar="TASK", help="Quick progress view") - parser.add_argument("--watch", metavar="TASK", help="Watch agent log") - parser.add_argument("--log", metavar="TASK", help="Show recent log entries") - parser.add_argument("--registry", action="store_true", help="Show agent registry") - parser.add_argument("target", nargs="?", help="Target task") - - args = parser.parse_args() - repo_root = get_repo_root() - - if args.list: - return cmd_list(repo_root) - elif args.detail: - return cmd_detail(args.detail, repo_root) - elif args.progress: - return cmd_detail(args.progress, repo_root) # Similar to detail - elif args.watch: - return cmd_watch(args.watch, repo_root) - elif args.log: - return cmd_log(args.log, repo_root) - elif args.registry: - return cmd_registry(repo_root) - elif args.target: - return cmd_detail(args.target, repo_root) - else: - return cmd_summary(repo_root, args.assignee) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.trellis/scripts/task.py b/.trellis/scripts/task.py index 29f614c..6052ac9 100755 --- a/.trellis/scripts/task.py +++ b/.trellis/scripts/task.py @@ -1,21 +1,20 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Task Management Script for Multi-Agent Pipeline. +Task Management Script. Usage: - python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] - python3 task.py init-context <dir> <type> # Initialize jsonl files + python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry python3 task.py validate <dir> # Validate jsonl files python3 task.py list-context <dir> # List jsonl entries - python3 task.py start <dir> # Set as current task - python3 task.py finish # Clear current task + python3 task.py start <dir> # Set active task + python3 task.py current [--source] # Show active task + python3 task.py finish # Clear active task python3 task.py set-branch <dir> <branch> # Set git branch python3 task.py set-base-branch <dir> <branch> # Set PR target branch python3 task.py set-scope <dir> <scope> # Set scope for PR title - python3 task.py create-pr [dir] [--dry-run] # Create PR from task - python3 task.py archive <task-name> # Archive completed task + python3 task.py archive <task-dir> # Archive completed task python3 task.py list # List active tasks python3 task.py list-archive [month] # List archived tasks python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent @@ -24,617 +23,44 @@ Usage: from __future__ import annotations -import sys - -# IMPORTANT: Force stdout to use UTF-8 on Windows -# This fixes UnicodeEncodeError when outputting non-ASCII characters -if sys.platform == "win32": - import io as _io - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] - elif hasattr(sys.stdout, "detach"): - sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr] - import argparse -import json -import re import sys -from datetime import datetime -from pathlib import Path -from common.cli_adapter import get_cli_adapter_auto -from common.git_context import _run_git_command +from common.log import Colors, colored from common.paths import ( DIR_WORKFLOW, DIR_TASKS, - DIR_SPEC, - DIR_ARCHIVE, FILE_TASK_JSON, get_repo_root, get_developer, get_tasks_dir, get_current_task, - set_current_task, - clear_current_task, - generate_task_date_prefix, ) -from common.task_utils import ( - find_task_by_name, - archive_task_complete, +from common.active_task import ( + clear_active_task, + resolve_active_task, + resolve_context_key, + set_active_task, +) +from common.io import read_json, write_json +from common.task_utils import resolve_task_dir, run_task_hooks +from common.tasks import iter_active_tasks, children_progress + +# Import command handlers from split modules (also re-exports for plan.py compatibility) +from common.task_store import ( + cmd_create, + cmd_archive, + cmd_set_branch, + cmd_set_base_branch, + cmd_set_scope, + cmd_add_subtask, + cmd_remove_subtask, +) +from common.task_context import ( + cmd_add_context, + cmd_validate, + cmd_list_context, ) -from common.config import get_hooks - - -# ============================================================================= -# Colors -# ============================================================================= - -class Colors: - RED = "\033[0;31m" - GREEN = "\033[0;32m" - YELLOW = "\033[1;33m" - BLUE = "\033[0;34m" - CYAN = "\033[0;36m" - NC = "\033[0m" - - -def colored(text: str, color: str) -> str: - """Apply color to text.""" - return f"{color}{text}{Colors.NC}" - - -# ============================================================================= -# Lifecycle Hooks -# ============================================================================= - -def _run_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: - """Run lifecycle hooks for an event. - - Args: - event: Event name (e.g. "after_create"). - task_json_path: Absolute path to the task's task.json. - repo_root: Repository root for cwd and config lookup. - """ - import os - import subprocess - - commands = get_hooks(event, repo_root) - if not commands: - return - - env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} - - for cmd in commands: - try: - result = subprocess.run( - cmd, - shell=True, - cwd=repo_root, - env=env, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if result.returncode != 0: - print( - colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), - file=sys.stderr, - ) - if result.stderr.strip(): - print(f" {result.stderr.strip()}", file=sys.stderr) - except Exception as e: - print( - colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), - file=sys.stderr, - ) - - -# ============================================================================= -# Helper Functions -# ============================================================================= - -def _read_json_file(path: Path) -> dict | None: - """Read and parse a JSON file.""" - try: - return json.loads(path.read_text(encoding="utf-8")) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return None - - -def _write_json_file(path: Path, data: dict) -> bool: - """Write dict to JSON file.""" - try: - path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - return True - except (OSError, IOError): - return False - - -def _slugify(title: str) -> str: - """Convert title to slug (only works with ASCII).""" - result = title.lower() - result = re.sub(r"[^a-z0-9]", "-", result) - result = re.sub(r"-+", "-", result) - result = result.strip("-") - return result - - -def _resolve_task_dir(target_dir: str, repo_root: Path) -> Path: - """Resolve task directory to absolute path. - - Supports: - - Absolute path: /path/to/task - - Relative path: .trellis/tasks/01-31-my-task - - Task name: my-task (uses find_task_by_name for lookup) - """ - if not target_dir: - return Path() - - # Absolute path - if target_dir.startswith("/"): - return Path(target_dir) - - # Relative path (contains path separator or starts with .trellis) - if "/" in target_dir or target_dir.startswith(".trellis"): - return repo_root / target_dir - - # Task name - try to find in tasks directory - tasks_dir = get_tasks_dir(repo_root) - found = find_task_by_name(target_dir, tasks_dir) - if found: - return found - - # Fallback to treating as relative path - return repo_root / target_dir - - -# ============================================================================= -# JSONL Default Content Generators -# ============================================================================= - -def get_implement_base() -> list[dict]: - """Get base implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"}, - ] - - -def get_implement_backend() -> list[dict]: - """Get backend implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md", "reason": "Backend development guide"}, - ] - - -def get_implement_frontend() -> list[dict]: - """Get frontend implement context entries.""" - return [ - {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md", "reason": "Frontend development guide"}, - ] - - -def get_check_context(dev_type: str, repo_root: Path) -> list[dict]: - """Get check context entries.""" - adapter = get_cli_adapter_auto(repo_root) - - entries = [ - {"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"}, - ] - - if dev_type in ("backend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) - if dev_type in ("frontend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) - - return entries - - -def get_debug_context(dev_type: str, repo_root: Path) -> list[dict]: - """Get debug context entries.""" - adapter = get_cli_adapter_auto(repo_root) - - entries: list[dict] = [] - - if dev_type in ("backend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-backend"), "reason": "Backend check spec"}) - if dev_type in ("frontend", "fullstack"): - entries.append({"file": adapter.get_trellis_command_path("check-frontend"), "reason": "Frontend check spec"}) - - return entries - - -def _write_jsonl(path: Path, entries: list[dict]) -> None: - """Write entries to JSONL file.""" - lines = [json.dumps(entry, ensure_ascii=False) for entry in entries] - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -# ============================================================================= -# Task Operations -# ============================================================================= - -def ensure_tasks_dir(repo_root: Path) -> Path: - """Ensure tasks directory exists.""" - tasks_dir = get_tasks_dir(repo_root) - archive_dir = tasks_dir / "archive" - - if not tasks_dir.exists(): - tasks_dir.mkdir(parents=True) - print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr) - - if not archive_dir.exists(): - archive_dir.mkdir(parents=True) - - return tasks_dir - - -# ============================================================================= -# Command: create -# ============================================================================= - -def cmd_create(args: argparse.Namespace) -> int: - """Create a new task.""" - repo_root = get_repo_root() - - if not args.title: - print(colored("Error: title is required", Colors.RED), file=sys.stderr) - return 1 - - # Default assignee to current developer - assignee = args.assignee - if not assignee: - assignee = get_developer(repo_root) - if not assignee: - print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr) - return 1 - - ensure_tasks_dir(repo_root) - - # Get current developer as creator - creator = get_developer(repo_root) or assignee - - # Generate slug if not provided - slug = args.slug or _slugify(args.title) - if not slug: - print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr) - return 1 - - # Create task directory with MM-DD-slug format - tasks_dir = get_tasks_dir(repo_root) - date_prefix = generate_task_date_prefix() - dir_name = f"{date_prefix}-{slug}" - task_dir = tasks_dir / dir_name - task_json_path = task_dir / FILE_TASK_JSON - - if task_dir.exists(): - print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr) - else: - task_dir.mkdir(parents=True) - - today = datetime.now().strftime("%Y-%m-%d") - - # Record current branch as base_branch (PR target) - _, branch_out, _ = _run_git_command(["branch", "--show-current"], cwd=repo_root) - current_branch = branch_out.strip() or "main" - - task_data = { - "id": slug, - "name": slug, - "title": args.title, - "description": args.description or "", - "status": "planning", - "dev_type": None, - "scope": None, - "priority": args.priority, - "creator": creator, - "assignee": assignee, - "createdAt": today, - "completedAt": None, - "branch": None, - "base_branch": current_branch, - "worktree_path": None, - "current_phase": 0, - "next_action": [ - {"phase": 1, "action": "implement"}, - {"phase": 2, "action": "check"}, - {"phase": 3, "action": "finish"}, - {"phase": 4, "action": "create-pr"}, - ], - "commit": None, - "pr_url": None, - "subtasks": [], - "children": [], - "parent": None, - "relatedFiles": [], - "notes": "", - "meta": {}, - } - - _write_json_file(task_json_path, task_data) - - # Handle --parent: establish bidirectional link - if args.parent: - parent_dir = _resolve_task_dir(args.parent, repo_root) - parent_json_path = parent_dir / FILE_TASK_JSON - if not parent_json_path.is_file(): - print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr) - else: - parent_data = _read_json_file(parent_json_path) - if parent_data: - # Add child to parent's children list - parent_children = parent_data.get("children", []) - if dir_name not in parent_children: - parent_children.append(dir_name) - parent_data["children"] = parent_children - _write_json_file(parent_json_path, parent_data) - - # Set parent in child's task.json - task_data["parent"] = parent_dir.name - _write_json_file(task_json_path, task_data) - - print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr) - - print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr) - print("", file=sys.stderr) - print(colored("Next steps:", Colors.BLUE), file=sys.stderr) - print(" 1. Create prd.md with requirements", file=sys.stderr) - print(" 2. Run: python3 task.py init-context <dir> <dev_type>", file=sys.stderr) - print(" 3. Run: python3 task.py start <dir>", file=sys.stderr) - print("", file=sys.stderr) - - # Output relative path for script chaining - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") - - _run_hooks("after_create", task_json_path, repo_root) - return 0 - - -# ============================================================================= -# Command: init-context -# ============================================================================= - -def cmd_init_context(args: argparse.Namespace) -> int: - """Initialize JSONL context files for a task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - dev_type = args.type - - if not dev_type: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py init-context <task-dir> <dev_type>") - print(" dev_type: backend | frontend | fullstack | test | docs") - return 1 - - if not target_dir.is_dir(): - print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) - return 1 - - print(colored("=== Initializing Agent Context Files ===", Colors.BLUE)) - print(f"Target dir: {target_dir}") - print(f"Dev type: {dev_type}") - print() - - # implement.jsonl - print(colored("Creating implement.jsonl...", Colors.CYAN)) - implement_entries = get_implement_base() - if dev_type in ("backend", "test"): - implement_entries.extend(get_implement_backend()) - elif dev_type == "frontend": - implement_entries.extend(get_implement_frontend()) - elif dev_type == "fullstack": - implement_entries.extend(get_implement_backend()) - implement_entries.extend(get_implement_frontend()) - - implement_file = target_dir / "implement.jsonl" - _write_jsonl(implement_file, implement_entries) - print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries") - - # check.jsonl - print(colored("Creating check.jsonl...", Colors.CYAN)) - check_entries = get_check_context(dev_type, repo_root) - check_file = target_dir / "check.jsonl" - _write_jsonl(check_file, check_entries) - print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries") - - # debug.jsonl - print(colored("Creating debug.jsonl...", Colors.CYAN)) - debug_entries = get_debug_context(dev_type, repo_root) - debug_file = target_dir / "debug.jsonl" - _write_jsonl(debug_file, debug_entries) - print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries") - - print() - print(colored("✓ All context files created", Colors.GREEN)) - print() - print(colored("Next steps:", Colors.BLUE)) - print(" 1. Add task-specific specs: python3 task.py add-context <dir> <jsonl> <path>") - print(" 2. Set as current: python3 task.py start <dir>") - - return 0 - - -# ============================================================================= -# Command: add-context -# ============================================================================= - -def cmd_add_context(args: argparse.Namespace) -> int: - """Add entry to JSONL context file.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - jsonl_name = args.file - path = args.path - reason = args.reason or "Added manually" - - if not target_dir.is_dir(): - print(colored(f"Error: Directory not found: {target_dir}", Colors.RED)) - return 1 - - # Support shorthand - if not jsonl_name.endswith(".jsonl"): - jsonl_name = f"{jsonl_name}.jsonl" - - jsonl_file = target_dir / jsonl_name - full_path = repo_root / path - - entry_type = "file" - if full_path.is_dir(): - entry_type = "directory" - if not path.endswith("/"): - path = f"{path}/" - elif not full_path.is_file(): - print(colored(f"Error: Path not found: {path}", Colors.RED)) - return 1 - - # Check if already exists - if jsonl_file.is_file(): - content = jsonl_file.read_text(encoding="utf-8") - if f'"{path}"' in content: - print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW)) - return 0 - - # Add entry - entry: dict - if entry_type == "directory": - entry = {"file": path, "type": "directory", "reason": reason} - else: - entry = {"file": path, "reason": reason} - - with jsonl_file.open("a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") - - print(colored(f"Added {entry_type}: {path}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: validate -# ============================================================================= - -def cmd_validate(args: argparse.Namespace) -> int: - """Validate JSONL context files.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - if not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Validating Context Files ===", Colors.BLUE)) - print(f"Target dir: {target_dir}") - print() - - total_errors = 0 - for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: - jsonl_file = target_dir / jsonl_name - errors = _validate_jsonl(jsonl_file, repo_root) - total_errors += errors - - print() - if total_errors == 0: - print(colored("✓ All validations passed", Colors.GREEN)) - return 0 - else: - print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED)) - return 1 - - -def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int: - """Validate a single JSONL file.""" - file_name = jsonl_file.name - errors = 0 - - if not jsonl_file.is_file(): - print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}") - return 0 - - line_num = 0 - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - line_num += 1 - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}") - errors += 1 - continue - - file_path = data.get("file") - entry_type = data.get("type", "file") - - if not file_path: - print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}") - errors += 1 - continue - - full_path = repo_root / file_path - if entry_type == "directory": - if not full_path.is_dir(): - print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}") - errors += 1 - else: - if not full_path.is_file(): - print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}") - errors += 1 - - if errors == 0: - print(f" {colored(f'{file_name}: ✓ ({line_num} entries)', Colors.GREEN)}") - else: - print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}") - - return errors - - -# ============================================================================= -# Command: list-context -# ============================================================================= - -def cmd_list_context(args: argparse.Namespace) -> int: - """List JSONL context entries.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - - if not target_dir.is_dir(): - print(colored("Error: task directory required", Colors.RED)) - return 1 - - print(colored("=== Context Files ===", Colors.BLUE)) - print() - - for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]: - jsonl_file = target_dir / jsonl_name - if not jsonl_file.is_file(): - continue - - print(colored(f"[{jsonl_name}]", Colors.CYAN)) - - count = 0 - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - - try: - data = json.loads(line) - except json.JSONDecodeError: - continue - - count += 1 - file_path = data.get("file", "?") - entry_type = data.get("type", "file") - reason = data.get("reason", "-") - - if entry_type == "directory": - print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}") - else: - print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}") - print(f" {colored('→', Colors.YELLOW)} {reason}") - - print() - - return 0 # ============================================================================= @@ -642,7 +68,7 @@ def cmd_list_context(args: argparse.Namespace) -> int: # ============================================================================= def cmd_start(args: argparse.Namespace) -> int: - """Set current task.""" + """Set active task.""" repo_root = get_repo_root() task_input = args.dir @@ -651,7 +77,7 @@ def cmd_start(args: argparse.Namespace) -> int: return 1 # Resolve task directory (supports task name, relative path, or absolute path) - full_path = _resolve_task_dir(task_input, repo_root) + full_path = resolve_task_dir(task_input, repo_root) if not full_path.is_dir(): print(colored(f"Error: Task not found: {task_input}", Colors.RED)) @@ -660,17 +86,35 @@ def cmd_start(args: argparse.Namespace) -> int: # Convert to relative path for storage try: - task_dir = str(full_path.relative_to(repo_root)) + task_dir = full_path.relative_to(repo_root).as_posix() except ValueError: task_dir = str(full_path) - if set_current_task(task_dir, repo_root): + if not resolve_context_key(): + print(colored("Error: Cannot set active task without a session identity.", Colors.RED)) + print( + "Hint: run inside an AI IDE/session that exposes session identity, " + "or set TRELLIS_CONTEXT_ID before running task.py start." + ) + return 1 + + active = set_active_task(task_dir, repo_root) + if active: print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN)) + print(f"Source: {active.source}") + + task_json_path = full_path / FILE_TASK_JSON + if task_json_path.is_file(): + data = read_json(task_json_path) + if data and data.get("status") == "planning": + data["status"] = "in_progress" + if write_json(task_json_path, data): + print(colored("✓ Status: planning → in_progress", Colors.GREEN)) + print() print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE)) - task_json_path = full_path / FILE_TASK_JSON - _run_hooks("after_start", task_json_path, repo_root) + run_task_hooks("after_start", task_json_path, repo_root) return 0 else: print(colored("Error: Failed to set current task", Colors.RED)) @@ -678,9 +122,10 @@ def cmd_start(args: argparse.Namespace) -> int: def cmd_finish(args: argparse.Namespace) -> int: - """Clear current task.""" + """Clear active task.""" repo_root = get_repo_root() - current = get_current_task(repo_root) + active = clear_active_task(repo_root) + current = active.task_path if not current: print(colored("No current task set", Colors.YELLOW)) @@ -689,250 +134,37 @@ def cmd_finish(args: argparse.Namespace) -> int: # Resolve task.json path before clearing task_json_path = repo_root / current / FILE_TASK_JSON - clear_current_task(repo_root) print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) + print(f"Source: {active.source}") if task_json_path.is_file(): - _run_hooks("after_finish", task_json_path, repo_root) + run_task_hooks("after_finish", task_json_path, repo_root) return 0 -# ============================================================================= -# Command: archive -# ============================================================================= - -def cmd_archive(args: argparse.Namespace) -> int: - """Archive completed task.""" +def cmd_current(args: argparse.Namespace) -> int: + """Show active task.""" repo_root = get_repo_root() - task_name = args.name + active = resolve_active_task(repo_root) - if not task_name: - print(colored("Error: Task name is required", Colors.RED), file=sys.stderr) - return 1 + if args.source: + print(f"Current task: {active.task_path or '(none)'}") + print(f"Source: {active.source}") + if active.stale: + print("State: stale") + return 0 if active.task_path else 1 - tasks_dir = get_tasks_dir(repo_root) - - # Find task directory - task_dir = find_task_by_name(task_name, tasks_dir) - - if not task_dir or not task_dir.is_dir(): - print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr) - print("Active tasks:", file=sys.stderr) - cmd_list(argparse.Namespace(mine=False, status=None)) - return 1 - - dir_name = task_dir.name - task_json_path = task_dir / FILE_TASK_JSON - - # Update status before archiving - today = datetime.now().strftime("%Y-%m-%d") - if task_json_path.is_file(): - data = _read_json_file(task_json_path) - if data: - data["status"] = "completed" - data["completedAt"] = today - _write_json_file(task_json_path, data) - - # Handle subtask relationships on archive - task_parent = data.get("parent") - task_children = data.get("children", []) - - # If this is a child, remove from parent's children list - if task_parent: - parent_dir = find_task_by_name(task_parent, tasks_dir) - if parent_dir: - parent_json = parent_dir / FILE_TASK_JSON - if parent_json.is_file(): - parent_data = _read_json_file(parent_json) - if parent_data: - parent_children = parent_data.get("children", []) - if dir_name in parent_children: - parent_children.remove(dir_name) - parent_data["children"] = parent_children - _write_json_file(parent_json, parent_data) - - # If this is a parent, clear parent field in all children - if task_children: - for child_name in task_children: - child_dir_path = find_task_by_name(child_name, tasks_dir) - if child_dir_path: - child_json = child_dir_path / FILE_TASK_JSON - if child_json.is_file(): - child_data = _read_json_file(child_json) - if child_data: - child_data["parent"] = None - _write_json_file(child_json, child_data) - - # Clear if current task - current = get_current_task(repo_root) - if current and dir_name in current: - clear_current_task(repo_root) - - # Archive - result = archive_task_complete(task_dir, repo_root) - if "archived_to" in result: - archive_dest = Path(result["archived_to"]) - year_month = archive_dest.parent.name - print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) - - # Auto-commit unless --no-commit - if not getattr(args, "no_commit", False): - _auto_commit_archive(dir_name, repo_root) - - # Return the archive path - print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") - - # Run hooks with the archived path - archived_json = archive_dest / FILE_TASK_JSON - _run_hooks("after_archive", archived_json, repo_root) + if active.task_path: + print(active.task_path) return 0 return 1 -def _auto_commit_archive(task_name: str, repo_root: Path) -> None: - """Stage .trellis/tasks/ changes and commit after archive.""" - tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}" - _run_git_command(["add", "-A", tasks_rel], cwd=repo_root) - - # Check if there are staged changes - rc, _, _ = _run_git_command( - ["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root - ) - if rc == 0: - print("[OK] No task changes to commit.", file=sys.stderr) - return - - commit_msg = f"chore(task): archive {task_name}" - rc, _, err = _run_git_command(["commit", "-m", commit_msg], cwd=repo_root) - if rc == 0: - print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr) - else: - print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr) - - -# ============================================================================= -# Command: add-subtask -# ============================================================================= - -def cmd_add_subtask(args: argparse.Namespace) -> int: - """Link a child task to a parent task.""" - repo_root = get_repo_root() - - parent_dir = _resolve_task_dir(args.parent_dir, repo_root) - child_dir = _resolve_task_dir(args.child_dir, repo_root) - - parent_json_path = parent_dir / FILE_TASK_JSON - child_json_path = child_dir / FILE_TASK_JSON - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = _read_json_file(parent_json_path) - child_data = _read_json_file(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Check if child already has a parent - existing_parent = child_data.get("parent") - if existing_parent: - print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr) - return 1 - - # Add child to parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name not in parent_children: - parent_children.append(child_dir_name) - parent_data["children"] = parent_children - - # Set parent in child's task.json - child_data["parent"] = parent_dir.name - - # Write both - _write_json_file(parent_json_path, parent_data) - _write_json_file(child_json_path, child_data) - - print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr) - return 0 - - -# ============================================================================= -# Command: remove-subtask -# ============================================================================= - -def cmd_remove_subtask(args: argparse.Namespace) -> int: - """Unlink a child task from a parent task.""" - repo_root = get_repo_root() - - parent_dir = _resolve_task_dir(args.parent_dir, repo_root) - child_dir = _resolve_task_dir(args.child_dir, repo_root) - - parent_json_path = parent_dir / FILE_TASK_JSON - child_json_path = child_dir / FILE_TASK_JSON - - if not parent_json_path.is_file(): - print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr) - return 1 - - if not child_json_path.is_file(): - print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr) - return 1 - - parent_data = _read_json_file(parent_json_path) - child_data = _read_json_file(child_json_path) - - if not parent_data or not child_data: - print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr) - return 1 - - # Remove child from parent's children list - parent_children = parent_data.get("children", []) - child_dir_name = child_dir.name - if child_dir_name in parent_children: - parent_children.remove(child_dir_name) - parent_data["children"] = parent_children - - # Clear parent in child's task.json - child_data["parent"] = None - - # Write both - _write_json_file(parent_json_path, parent_data) - _write_json_file(child_json_path, child_data) - - print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr) - return 0 - - # ============================================================================= # Command: list # ============================================================================= -def _get_children_progress(children: list[str], tasks_dir: Path) -> str: - """Get children progress summary like '[2/3 done]'.""" - if not children: - return "" - done_count = 0 - total = len(children) - for child_name in children: - child_dir = tasks_dir / child_name - child_json = child_dir / FILE_TASK_JSON - if child_json.is_file(): - data = _read_json_file(child_json) - if data: - status = data.get("status", "") - if status in ("completed", "done"): - done_count += 1 - return f" [{done_count}/{total} done]" - - def cmd_list(args: argparse.Namespace) -> int: """List active tasks.""" repo_root = get_repo_root() @@ -951,51 +183,23 @@ def cmd_list(args: argparse.Namespace) -> int: print(colored("All active tasks:", Colors.BLUE)) print() - # First pass: collect all task data and identify parent/child relationships - all_tasks: dict[str, dict] = {} - if tasks_dir.is_dir(): - for d in sorted(tasks_dir.iterdir()): - if not d.is_dir() or d.name == "archive": - continue + # Single pass: collect all tasks via shared iterator + all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)} + all_statuses = {name: t.status for name, t in all_tasks.items()} - dir_name = d.name - task_json = d / FILE_TASK_JSON - status = "unknown" - assignee = "-" - children: list[str] = [] - parent: str | None = None - - if task_json.is_file(): - data = _read_json_file(task_json) - if data: - status = data.get("status", "unknown") - assignee = data.get("assignee", "-") - children = data.get("children", []) - parent = data.get("parent") - - all_tasks[dir_name] = { - "status": status, - "assignee": assignee, - "children": children, - "parent": parent, - } - - # Second pass: display tasks hierarchically + # Display tasks hierarchically count = 0 def _print_task(dir_name: str, indent: int = 0) -> None: nonlocal count - info = all_tasks[dir_name] - status = info["status"] - assignee = info["assignee"] - children = info["children"] + t = all_tasks[dir_name] # Apply --mine filter - if filter_mine and assignee != developer: + if filter_mine and (t.assignee or "-") != developer: return # Apply --status filter - if filter_status and status != filter_status: + if filter_status and t.status != filter_status: return relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}" @@ -1004,25 +208,27 @@ def cmd_list(args: argparse.Namespace) -> int: marker = f" {colored('<- current', Colors.GREEN)}" # Children progress - progress = _get_children_progress(children, tasks_dir) if children else "" + progress = children_progress(t.children, all_statuses) + + # Package tag + pkg_tag = f" @{t.package}" if t.package else "" prefix = " " * indent + " - " if filter_mine: - print(f"{prefix}{dir_name}/ ({status}){progress}{marker}") + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}") else: - print(f"{prefix}{dir_name}/ ({status}){progress} [{colored(assignee, Colors.CYAN)}]{marker}") + print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}") count += 1 # Print children indented - for child_name in children: + for child_name in t.children: if child_name in all_tasks: _print_task(child_name, indent + 1) # Display only top-level tasks (those without a parent) for dir_name in sorted(all_tasks.keys()): - info = all_tasks[dir_name] - if not info["parent"]: + if not all_tasks[dir_name].parent: _print_task(dir_name) if count == 0: @@ -1070,154 +276,35 @@ def cmd_list_archive(args: argparse.Namespace) -> int: return 0 -# ============================================================================= -# Command: set-branch -# ============================================================================= - -def cmd_set_branch(args: argparse.Namespace) -> int: - """Set git branch for task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - branch = args.branch - - if not branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-branch <task-dir> <branch-name>") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["branch"] = branch - _write_json_file(task_json, data) - - print(colored(f"✓ Branch set to: {branch}", Colors.GREEN)) - print() - print(colored("Now you can start the multi-agent pipeline:", Colors.BLUE)) - print(f" python3 ./.trellis/scripts/multi_agent/start.py {args.dir}") - return 0 - - -# ============================================================================= -# Command: set-base-branch -# ============================================================================= - -def cmd_set_base_branch(args: argparse.Namespace) -> int: - """Set the base branch (PR target) for task.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - base_branch = args.base_branch - - if not base_branch: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>") - print("Example: python3 task.py set-base-branch <dir> develop") - print() - print("This sets the target branch for PR (the branch your feature will merge into).") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["base_branch"] = base_branch - _write_json_file(task_json, data) - - print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN)) - print(f" PR will target: {base_branch}") - return 0 - - -# ============================================================================= -# Command: set-scope -# ============================================================================= - -def cmd_set_scope(args: argparse.Namespace) -> int: - """Set scope for PR title.""" - repo_root = get_repo_root() - target_dir = _resolve_task_dir(args.dir, repo_root) - scope = args.scope - - if not scope: - print(colored("Error: Missing arguments", Colors.RED)) - print("Usage: python3 task.py set-scope <task-dir> <scope>") - return 1 - - task_json = target_dir / FILE_TASK_JSON - if not task_json.is_file(): - print(colored(f"Error: task.json not found at {target_dir}", Colors.RED)) - return 1 - - data = _read_json_file(task_json) - if not data: - return 1 - - data["scope"] = scope - _write_json_file(task_json, data) - - print(colored(f"✓ Scope set to: {scope}", Colors.GREEN)) - return 0 - - -# ============================================================================= -# Command: create-pr (delegates to multi-agent script) -# ============================================================================= - -def cmd_create_pr(args: argparse.Namespace) -> int: - """Create PR from task - delegates to multi_agent/create_pr.py.""" - import subprocess - script_dir = Path(__file__).parent - create_pr_script = script_dir / "multi_agent" / "create_pr.py" - - cmd = [sys.executable, str(create_pr_script)] - if args.dir: - cmd.append(args.dir) - if args.dry_run: - cmd.append("--dry-run") - - result = subprocess.run(cmd) - return result.returncode - - # ============================================================================= # Help # ============================================================================= def show_usage() -> None: """Show usage help.""" - print("""Task Management Script for Multi-Agent Pipeline + print("""Task Management Script Usage: python3 task.py create <title> Create new task directory + python3 task.py create <title> --package <pkg> Create task for a specific package python3 task.py create <title> --parent <dir> Create task as child of parent - python3 task.py init-context <dir> <dev_type> Initialize jsonl files python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl python3 task.py validate <dir> Validate jsonl files python3 task.py list-context <dir> List jsonl entries - python3 task.py start <dir> Set as current task - python3 task.py finish Clear current task - python3 task.py set-branch <dir> <branch> Set git branch for multi-agent + python3 task.py start <dir> Set active task + python3 task.py current [--source] Show active task + python3 task.py finish Clear active task + python3 task.py set-branch <dir> <branch> Set git branch + python3 task.py set-base-branch <dir> <branch> Set PR target branch python3 task.py set-scope <dir> <scope> Set scope for PR title - python3 task.py create-pr [dir] [--dry-run] Create PR from task - python3 task.py archive <task-name> Archive completed task + python3 task.py archive <task-dir> Archive completed task python3 task.py add-subtask <parent> <child> Link child task to parent python3 task.py remove-subtask <parent> <child> Unlink child from parent python3 task.py list [--mine] [--status <status>] List tasks python3 task.py list-archive [YYYY-MM] List archived tasks -Arguments: - dev_type: backend | frontend | fullstack | test | docs +Monorepo options: + --package <pkg> Package name (validated against config.yaml packages) List options: --mine, -m Show only tasks assigned to current developer @@ -1225,13 +312,12 @@ List options: Examples: python3 task.py create "Add login feature" --slug add-login + python3 task.py create "Add login feature" --slug add-login --package cli python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent - python3 task.py init-context .trellis/tasks/01-21-add-login backend - python3 task.py add-context <dir> implement .trellis/spec/backend/auth.md "Auth guidelines" + python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines" python3 task.py set-branch <dir> task/add-login python3 task.py start .trellis/tasks/01-21-add-login - python3 task.py create-pr # Uses current task - python3 task.py create-pr <dir> --dry-run # Preview without changes + python3 task.py current --source python3 task.py finish python3 task.py archive add-login python3 task.py add-subtask parent-task child-task # Link existing tasks @@ -1248,8 +334,38 @@ Examples: def main() -> int: """CLI entry point.""" + # Deprecation guard: `init-context` was removed in v0.5.0-beta.12. + # Detect early so argparse doesn't mask the real reason with a generic + # "invalid choice" error. + if len(sys.argv) >= 2 and sys.argv[1] == "init-context": + print( + colored( + "Error: `task.py init-context` was removed in v0.5.0-beta.12.", + Colors.RED, + ), + file=sys.stderr, + ) + print( + "implement.jsonl / check.jsonl are now seeded on `task.py create` for", + file=sys.stderr, + ) + print( + "sub-agent-capable platforms and curated by the AI during Phase 1.3.", + file=sys.stderr, + ) + print("See .trellis/workflow.md Phase 1.3 or run:", file=sys.stderr) + print( + " python3 ./.trellis/scripts/get_context.py --mode phase --step 1.3", + file=sys.stderr, + ) + print( + "Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.", + file=sys.stderr, + ) + return 2 + parser = argparse.ArgumentParser( - description="Task Management Script for Multi-Agent Pipeline", + description="Task Management Script", formatter_class=argparse.RawDescriptionHelpFormatter, ) subparsers = parser.add_subparsers(dest="command", help="Commands") @@ -1262,16 +378,12 @@ def main() -> int: p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)") p_create.add_argument("--description", "-d", help="Task description") p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)") - - # init-context - p_init = subparsers.add_parser("init-context", help="Initialize context files") - p_init.add_argument("dir", help="Task directory") - p_init.add_argument("type", help="Dev type: backend|frontend|fullstack|test|docs") + p_create.add_argument("--package", help="Package name for monorepo projects") # add-context p_add = subparsers.add_parser("add-context", help="Add context entry") p_add.add_argument("dir", help="Task directory") - p_add.add_argument("file", help="JSONL file (implement|check|debug)") + p_add.add_argument("file", help="JSONL file (implement|check)") p_add.add_argument("path", help="File path to add") p_add.add_argument("reason", nargs="?", help="Reason for adding") @@ -1284,11 +396,16 @@ def main() -> int: p_listctx.add_argument("dir", help="Task directory") # start - p_start = subparsers.add_parser("start", help="Set current task") + p_start = subparsers.add_parser("start", help="Set active task") p_start.add_argument("dir", help="Task directory") + # current + p_current = subparsers.add_parser("current", help="Show active task") + p_current.add_argument("--source", action="store_true", + help="Show active task source") + # finish - subparsers.add_parser("finish", help="Clear current task") + subparsers.add_parser("finish", help="Clear active task") # set-branch p_branch = subparsers.add_parser("set-branch", help="Set git branch") @@ -1305,14 +422,9 @@ def main() -> int: p_scope.add_argument("dir", help="Task directory") p_scope.add_argument("scope", help="Scope name") - # create-pr - p_pr = subparsers.add_parser("create-pr", help="Create PR") - p_pr.add_argument("dir", nargs="?", help="Task directory") - p_pr.add_argument("--dry-run", action="store_true", help="Dry run mode") - # archive p_archive = subparsers.add_parser("archive", help="Archive task") - p_archive.add_argument("name", help="Task name") + p_archive.add_argument("name", help="Task directory or name") p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive") # list @@ -1342,16 +454,15 @@ def main() -> int: commands = { "create": cmd_create, - "init-context": cmd_init_context, "add-context": cmd_add_context, "validate": cmd_validate, "list-context": cmd_list_context, "start": cmd_start, + "current": cmd_current, "finish": cmd_finish, "set-branch": cmd_set_branch, "set-base-branch": cmd_set_base_branch, "set-scope": cmd_set_scope, - "create-pr": cmd_create_pr, "archive": cmd_archive, "add-subtask": cmd_add_subtask, "remove-subtask": cmd_remove_subtask, diff --git a/.trellis/spec/web/index.md b/.trellis/spec/web/index.md new file mode 100644 index 0000000..44f526b --- /dev/null +++ b/.trellis/spec/web/index.md @@ -0,0 +1,153 @@ +# Web Spec + +## Scope + +This spec applies to `web/**`. + +Read this file before changing the Astro site, React app islands, authenticated app routes, API clients, i18n, or responsive layout. + +## Current Stack + +- Astro 6 for static public pages and route files. +- Web production build uses Astro server output with the `@astrojs/node` adapter so client-owned dynamic shell routes such as `/{locale}/history/:id` can be refreshed directly. +- React 19 for interactive client UI. +- React Router DOM for the authenticated business app shell. +- Tailwind CSS 4 through `@tailwindcss/vite`. +- TypeScript strict mode. +- Local i18n from `web/src/i18n/utils.ts`. +- Backend API base for production: `https://api.meeyao.com`. +- Local development API access uses the Vite `/api` proxy in `web/astro.config.mjs`. + +Do not introduce a second frontend framework, a second router, or scattered API URL construction for web code. + +## Route Architecture + +Public pages are Astro pages under `web/src/pages/{locale}/` and use `Marketing.astro`. + +Authenticated pages are Astro route shells that all render `DashboardAppPage.astro`. The actual logged-in application is a single React Router app: + +- `DashboardApp.tsx` owns React Router routes for dashboard, store, history, notifications, profile, settings, and divination pages. +- `AppShell.tsx` owns the persistent sidebar, mobile drawer, route guard, authenticated session recovery, and authenticated layout. +- Business page components render only their page body. They must not wrap themselves in `AppShell`. +- Sidebar navigation must use React Router navigation so the shell remains mounted and only the right-side content changes. +- Direct browser refresh on each existing business route must still render the app shell through Astro. + +Login and public marketing/legal pages are not part of the authenticated app shell. + +## Auth Rules + +- Login and registration are the same email-code flow. The backend auto-registers new email accounts. +- Test credentials for local verification: `test@example.com` with code `123456`. +- Auth state is stored by `web/src/lib/auth.ts` under one local storage key. +- Every authenticated route must recover or refresh the session before showing business content. +- `AppShell.tsx` is the single owner of authenticated app session recovery. Do not add another client wrapper that also refreshes the session around every authenticated route. +- Missing, expired, invalid, or refresh-failed tokens must clear local auth and redirect to `/{locale}/login`. +- Do not add silent success paths for auth failures. + +## API Rules + +- All API paths live in `web/src/lib/api-routes.ts`. +- Shared request behavior lives in `web/src/lib/api-client.ts`. +- Auth/session behavior lives in `web/src/lib/auth.ts`. +- Business API functions live in `web/src/lib/api.ts`. +- Shared authenticated read caching lives in `web/src/lib/data-client.ts` and `web/src/lib/resources.ts`. +- Components must call typed API helper functions, not inline `fetch('/api/...')`. +- Components that need profile, points, packages, history, notifications, or unread-count data should use the resource hooks/functions from `web/src/lib/resources.ts` instead of starting their own duplicate GET lifecycle. +- Dashboard-visible user, points, notification, and history data must come from the backend. Do not hardcode those values. +- Production API host is `https://api.meeyao.com`; local dev should use same-origin `/api` and the Vite proxy. + +### Authenticated Data Resource Pattern + +Use this pattern for backend reads that are reused across authenticated pages: + +```typescript +// lib/api.ts: transport-only business API +export function getPointsBalance(): Promise<PointsBalance> { + return authFetch<PointsBalance>(API_ROUTES.points.balance); +} + +// lib/resources.ts: cache policy + hook/function surface +export function usePoints() { + return useResource({ + key: pointsBalanceKey, + ttlMs: 60_000, + fetcher: getPointsBalance, + staleWhileRevalidate: true, + }); +} +``` + +Resource contracts: + +- `lib/api.ts` remains transport-only: no per-endpoint ad hoc memory cache there. +- `lib/resources.ts` owns resource keys, TTLs, in-flight dedupe, stale-while-revalidate behavior, prefetch, and mutation invalidation. +- `clearAuth()` must clear the shared data cache so authenticated data cannot leak across users. +- Resource hooks must support disabled/optional keys for pages where an id may be absent; do not create a fetcher that intentionally rejects during normal render. +- Active hooks must refetch after invalidation when they still need the resource. + +Invalidation matrix: + +- Profile, avatar, or settings write -> set the profile resource with the returned backend profile. +- Divination run or follow-up completion -> invalidate points and the relevant history list/thread resources. +- Notification mark-read -> patch the notification list and decrement unread count when the item changes from unread to read. +- Mark-all-notifications-read -> patch the notification list and set unread count to zero. +- Logout, expired refresh, or invalid auth -> clear auth and clear all resource data. + +Wrong vs correct: + +```typescript +// Wrong: every page starts an independent duplicate GET. +useEffect(() => { + getUserProfile().then(setProfile); +}, []); + +// Correct: subscribe to the shared profile resource. +const profileState = useProfile(); +``` + +## Layout Rules + +- Build mobile-first, then add `sm:`, `md:`, `lg:`, and `xl:` refinements. +- Business pages must not require horizontal scrolling at common phone widths such as `390x844`. +- Use responsive stacks for fixed-width desktop columns: `flex-col lg:flex-row`, `w-full lg:w-[...]`. +- Keep the authenticated shell as `h-screen` with the main content scrollable. +- Mobile sidebar must be reachable through the menu button and must not hide the page content permanently. +- Public header mobile navigation must expose feature, pricing, about, login, and language switching. + +### Mobile Guided Overlays + +- Keep one dimming strategy per viewport. Do not combine a full-screen dark overlay with a spotlight element that also uses an oversized outer shadow on the same mobile viewport. +- Mobile spotlight targets should fit inside the phone viewport. If a desktop tutorial highlights a tall panel, use a smaller mobile-only target such as the rows or controls that the step actually explains. +- Tooltip placement and arrow direction must match: a tooltip above the target uses a bottom arrow pointing down; a tooltip below the target uses a top arrow pointing up. +- When the app shell owns scrolling, compute mobile overlay coordinates relative to the page component host and visible scroll container, not the document body. + +## i18n Rules + +- Supported locales: `zh`, `zh_Hant`, `en`. +- Routes are prefixed by locale, including the default locale. +- User-visible text should come from `web/src/i18n/utils.ts` or locale-specific content assets. +- Do not add user-facing strings in only one locale. + +## Design Source + +- Pencil design files under `web/design/` are the visual source for login and public page design. +- If UI implementation diverges from Pencil, inspect the design first and keep the code aligned unless the user explicitly asks to change the design. +- Assets in `web/public/images` and `web/public/legal` are symlinks to `web/design/assets`; do not duplicate them. + +## Verification + +Before finishing meaningful web changes: + +- Run `npm run build` in `web/`. +- Use Chrome DevTools on `http://localhost:4322/` for at least one desktop and one phone viewport when layout or routing changed. +- For authenticated changes, verify the test account can log in and lands on `/{locale}/dashboard`. +- Verify direct unauthenticated access to a business route redirects to login. +- Verify sidebar navigation changes content without a full document reload. + +## Forbidden + +- Do not hardcode API URLs or endpoint paths in components. +- Do not add `.env` as a required file for normal local development. +- Do not reintroduce page-refresh sidebar navigation inside the authenticated app. +- Do not wrap business page components in nested cards or duplicate `AppShell`. +- Do not add fallback/mock success behavior for failed API calls. diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/check.jsonl b/.trellis/tasks/05-10-audit-and-optimize-web-performance/check.jsonl new file mode 100644 index 0000000..e2a57e3 --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/check.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/spec/web/index.md", "reason": "Verify request behavior, authenticated routing, and responsive browser checks."} diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/implement.jsonl b/.trellis/tasks/05-10-audit-and-optimize-web-performance/implement.jsonl new file mode 100644 index 0000000..b9d509e --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/implement.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/spec/web/index.md", "reason": "Web authenticated app architecture, API, auth, and performance-sensitive layout rules."} diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md b/.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md new file mode 100644 index 0000000..7ee0326 --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md @@ -0,0 +1,127 @@ +# Audit and optimize web performance + +## Goal + +Comprehensively audit and refactor the web frontend data interaction layer while preserving the existing UI. The refactor should reduce repeated HTTP requests, hide US-backend latency where safe, centralize cache/invalidation behavior, and make authenticated page transitions feel faster in both perceived and actual request-count terms. + +## What I already know + +* Backend is hosted in the US, so users far away experience noticeable request latency. +* User has observed repeated HTTP requests and avoidable data-loading waits. +* Web stack is Astro 6 + React 19 + React Router DOM under `web/`. +* Auth state lives in `web/src/lib/auth.ts`; business APIs live in `web/src/lib/api.ts`. +* Existing `getPointsBalance()` has a 1-minute in-memory TTL cache, but many other stable reads do not. +* Early code scan found repeated `getUserProfile()` calls across `AppShell`, `LoginForm`, `SettingsPage`, `GeneralSettingsPage`, and `ProfileDetailPage`. +* `AppShell` already loads profile globally and exposes `UserSettingsContext`; some pages still refetch profile instead of using that context. +* Auth refresh now has single-flight behavior from the previous fix, which helps avoid concurrent refresh waste. + +## Assumptions + +* Performance improvements should preserve backend source of truth and auth safety. +* Cached authenticated data must be invalidated after writes that change it. +* We should prefer a local, typed data-client/resource layer over adding a large state/query dependency unless audit shows the custom approach is too risky. +* Optimizations should be observable through request count reduction and improved perceived loading behavior. + +## Requirements + +* Audit all authenticated web pages for duplicate or avoidable requests. +* Define and implement a frontend data layer with caching, in-flight dedupe, stale-while-revalidate, prefetch, and explicit invalidation. +* Reuse AppShell-loaded profile/settings where possible instead of refetching. +* Add cache invalidation for profile/settings/points changes. +* Keep UI presentation unchanged at rest; only data source and loading/refresh behavior should change. +* Avoid stale or unsafe auth behavior: 401 and refresh failures must still clear auth and redirect. +* Improve perceived performance with cached data, route-level reuse, and fewer full-page loading waits. +* Document verification with before/after request counts for key flows. + +## Acceptance Criteria + +* [x] Request audit lists endpoints, call sites, duplicate patterns, and priority. +* [x] Refactor plan defines target data architecture, resource cache policies, invalidation rules, and phased rollout. +* [x] Data client/resource layer is implemented without changing the visible UI design. +* [x] Dashboard → settings/profile/general/divination navigation avoids duplicate profile requests where safe. +* [x] Points balance requests are deduped in-flight and cached/invalidated intentionally. +* [x] Stable package/config-style reads are cached or intentionally left uncached with rationale. +* [x] Authenticated pages keep correct behavior after writes and after 401 responses. +* [ ] Browser verification captures representative flows under mobile and desktop viewports. +* [x] `git diff --check` passes; build/typecheck status documented. + +## Definition of Done + +* PRD updated with final implementation notes. +* Targeted code changes committed. +* Performance audit and refactor plan recorded in the task directory. +* Any reusable conventions captured in `.trellis/spec/web/index.md` or relevant spec. + +## Out of Scope + +* Backend endpoint redesign. +* CDN/edge deployment changes. +* Offline mode. +* Large dependency adoption unless explicitly approved after audit. +* Visual redesign of existing pages. + +## Technical Notes + +* Follow `.trellis/spec/web/index.md`. +* All API paths remain in `web/src/lib/api-routes.ts`. +* Components should call typed API helpers from `web/src/lib/api.ts`, not inline `fetch('/api/...')`. +* Target refactor plan: `.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md`. +* Request audit: `.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md`. +* Current build is blocked by existing Astro adapter configuration (`NoAdapterInstalled`) and should be documented unless fixed in a separate task. + +## Implementation Notes - 2026-05-10 + +* Added `web/src/lib/data-client.ts`, a typed in-memory query cache with TTL, in-flight request dedupe, `peek`, `set`, prefix `invalidate`, `prefetch`, `subscribe`, and `clearAll`. +* Added `web/src/lib/resources.ts` for profile, points, packages, history list/thread/summary, notifications list, and unread-count cache policy plus React resource hooks. +* Moved points TTL behavior out of `api.ts`; `getPointsBalance()` is now transport-only and points caching lives in the resource layer. +* AppShell now loads profile through the profile resource, subscribes to profile cache changes, clears data cache via `clearAuth()`, prefetches cheap dashboard data after auth, and prefetches route resources on nav hover/focus. +* Settings, general settings, profile detail, dashboard, store, history list, notifications, manual/auto divination, divination processing, result, and follow-up pages now reuse resource caches instead of owning duplicate GET lifecycles. +* Mutations patch or invalidate shared cache: profile/settings/avatar set profile; notification read/all-read patch list and unread count; divination/follow-up completion invalidates points and history. +* Quality check fixed two post-implementation issues: invalidated active resources now refetch instead of staying empty/stale, and `useHistoryThread(undefined)` no longer emits a doomed request when result pages rely on router/session state. +* Verification: `npm exec astro sync` passed; `git diff --check` passed; `npm run build` remains blocked by existing Astro `NoAdapterInstalled`; temporary `tsc --noEmit` check reports existing `DashboardApp.tsx` translation-map typing errors after refactor-specific type issues were fixed. +* Browser verification note: the in-app browser was switched to a visible 390x844 mobile viewport with the dev server on `127.0.0.1:4322`, but the automation surface only exposed the in-app browser toolbar during this run, so request-count capture still needs a follow-up pass with a usable page automation surface. + +## Implementation Notes - 2026-05-10 Second Pass + +* Fixed the Astro build blocker without breaking existing direct-refresh history URLs by adding the `@astrojs/node` adapter and switching the web build to server output. The six `prerender = false` dynamic history shell pages remain in place for `/{locale}/history/:id` and `/{locale}/history/:id/followup`. +* New in-app links still use the static shell URLs with `threadId` query parameters: `/{locale}/history/result?threadId=...` and `/{locale}/history/followup?threadId=...`, while the legacy direct URLs continue to resolve through Astro and React Router. +* Split authenticated page bodies in `DashboardApp.tsx` with `React.lazy` and `Suspense`, keeping `AppShell`, nav config, auth guard, and link interception eager. The initial `DashboardApp` chunk dropped from the prior ~142 KB shape to ~54 KB in the built output, with heavy pages emitted as separate chunks. +* Optimized static image assets in place under `web/design/assets/images` while preserving filenames and references. Largest qigua result images are now about 125-130 KB each, coin images about 26-27 KB, logo about 42 KB, and tutorial images reduced from roughly 800 KB / 2.5 MB / 3.0 MB to about 372 KB / 1.0 MB / 1.2 MB. +* Added `primeHistoryThreadFromSnapshot()` so dashboard/history-list clicks seed the thread resource from an already-loaded history snapshot before navigating to the result page. This avoids an immediate duplicate thread GET when list data already contains the target thread. +* Added real web typecheck dependencies (`@astrojs/check`, `typescript`, `@types/node`) and fixed the surfaced type gaps: `DashboardApp` now uses typed i18n sections, settings route shells reuse `DashboardAppPage.astro`, and the about-page ICP fields are present in all locales. + +## Verification - 2026-05-10 Second Pass + +* `npm run build` in `web/`: passed. Astro built server output with `@astrojs/node`, preserving on-demand dynamic history shells. +* `npm exec astro sync` in `web/`: passed. +* `git diff --check`: passed. +* `npm exec tsc -- --noEmit`: passed. +* `npm exec astro check`: passed with 0 errors; remaining diagnostics are hints for pre-existing cleanup candidates. +* Server preview smoke via `astro preview` on `127.0.0.1:4322`: `GET /zh/dashboard`, `/zh/history/smoke-thread`, `/zh/history/smoke-thread/followup`, `/zh/history/result?threadId=smoke-thread`, `/zh/history/followup?threadId=smoke-thread`, `/en/history/smoke-thread`, and `/zh_Hant/history/smoke-thread/followup` all returned `200 text/html`. +* Built client chunks: `DashboardApp` is ~54 KB, with lazy page chunks emitted separately (`ManualDivinationPage` ~19 KB, `AutoDivinationPage` ~19 KB, `DivinationResultPage` ~14 KB, `HistoryFollowUpPage` ~9 KB, `HistoryListPage` ~7 KB). Built asset folders: `dist/client/_astro` ~520 KB and `dist/client/images` ~4.2 MB. + +## Implementation Notes - 2026-05-10 Third Pass + +* Removed the authenticated layout-level `AuthProvider` wrapper and deleted the unused provider/context file. `AppShell` remains the only authenticated session recovery and route-guard owner for dashboard routes. +* Login and existing-session checks now use the shared profile resource instead of calling `getUserProfile()` directly, so login-to-dashboard can reuse the profile cache that was already fetched for language redirect decisions. +* Successful email login clears the shared data cache before storing the new session to avoid carrying authenticated resource data across account changes. + +## Verification - 2026-05-10 Third Pass + +* `npm exec astro sync` in `web/`: passed. +* `npm exec tsc -- --noEmit` in `web/`: passed. +* `npm exec astro check` in `web/`: passed with 0 errors; remaining diagnostics are hints. +* `npm run build` in `web/`: passed. Vite reported the existing `auth.ts` mixed static/dynamic import chunking warning. +* `git diff --check`: passed. + +## Implementation Notes - 2026-05-10 Fourth Pass + +* Fresh email-code login submit now redirects using the locale implied by the submitted backend language (`localeToBackendLanguage(locale)` -> `backendLanguageToLocale(language)`) instead of fetching profile before navigation. This removes the avoidable profile GET whose in-memory cache was discarded by the full-page dashboard load. +* Existing-auth login-page recovery still fetches profile after token refresh because the stored profile language preference is the authoritative source for redirecting an already-authenticated user from the login page. The current resource layer is intentionally in-memory only, so it cannot provide a reload-safe handoff for `window.location.href`; optimizing the fresh-login side avoids the waste while preserving stored-preference redirects for recovered sessions. + +## Verification - 2026-05-10 Fourth Pass + +* `npm exec tsc -- --noEmit` in `web/`: passed. +* `npm exec astro check` in `web/`: passed with 0 errors; remaining diagnostics are hints. +* `npm run build` in `web/`: passed. Vite reported the existing `auth.ts` mixed static/dynamic import chunking warning. +* `git diff --check`: passed. diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md b/.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md new file mode 100644 index 0000000..10c1e0a --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md @@ -0,0 +1,383 @@ +# Web data interaction refactor plan + +## Executive summary + +The current web app has solid typed API helpers, but it does not yet have an application data layer. Most pages call backend endpoints directly inside local `useEffect` blocks, so route changes repeatedly reload the same user/profile/points/history data. This is especially visible when the backend is far away because every avoidable request becomes a user-visible wait. + +The recommended refactor is to keep the UI unchanged and introduce a lightweight, project-owned data layer that centralizes: + +* request in-flight dedupe +* TTL cache and stale-while-revalidate behavior +* explicit invalidation after writes +* route-level prefetch +* shared resource state for pages +* optimistic local updates where safe + +Do not start by sprinkling `localStorage` or ad hoc memoization into components. That would reduce some requests but preserve the deeper problem: every component still owns its own data lifecycle. + +## Current architecture diagnosis + +### 1. API helpers are typed, but not stateful + +`web/src/lib/api.ts` provides typed functions such as `getUserProfile`, `getPointsBalance`, `getAgentHistory`, and `getNotifications`. Except for points, those functions always hit the backend. + +This is clean as a transport layer, but incomplete as a frontend data layer. + +### 2. Components own network lifecycle + +Examples: + +* `AppShell` fetches profile globally, but `SettingsPage`, `GeneralSettingsPage`, and `ProfileDetailPage` refetch profile. +* `Dashboard` fetches points/history/unread count on mount. +* `HistoryListPage` fetches history again even if dashboard just fetched it. +* `StorePage` fetches packages on mount even though packages are stable config-style data. +* `NotificationPage` owns its own list and mark-read updates without updating dashboard unread-count cache. + +Each page is locally correct, but globally wasteful. + +### 3. Loading states are page-local + +Because data state is local to each page, the UI often transitions from a populated page to a blank/loading page even if relevant data was just fetched elsewhere. This makes high-latency backend access feel worse than it needs to. + +### 4. Mutations do not have a shared invalidation model + +Examples: + +* `updateUserSettings` returns updated profile, but only the calling component updates local state. +* `updateUserProfile` / `uploadAvatar` can leave `AppShell` profile stale unless context is manually updated. +* `markNotificationRead` updates the notification page list, but dashboard unread count is not centrally invalidated. +* `enqueueDivinationRun` should invalidate points and history after success, but that is not represented in a central policy. + +## Target architecture + +### Layer 1: transport stays simple + +Keep `authFetch`, `authFetchRaw`, `apiUrl`, `api-routes`, and RFC7807 parsing as the low-level transport boundary. + +Responsibilities: + +* auth refresh and 401 handling +* JSON request/response parsing +* SSE raw response support +* no UI cache knowledge + +### Layer 2: API functions remain typed commands + +Keep `web/src/lib/api.ts` as the typed backend API command layer. + +Responsibilities: + +* endpoint-specific payload construction +* response types +* backend data mapping helpers +* no React hooks + +Change: + +* Remove ad hoc points-only cache from this file. +* Move caching into a dedicated data/cache layer. + +### Layer 3: new app data client + +Add `web/src/lib/data-client.ts` or `web/src/lib/query-client.ts`. + +Core capabilities: + +```ts +type CacheKey = readonly string[]; + +interface CacheEntry<T> { + data?: T; + error?: unknown; + updatedAt: number; + expiresAt: number; + promise?: Promise<T>; +} + +interface QueryOptions<T> { + key: CacheKey; + ttlMs: number; + fetcher: () => Promise<T>; + staleWhileRevalidate?: boolean; +} +``` + +Required operations: + +* `query<T>(options): Promise<T>`: returns fresh data, dedupes in-flight request. +* `peek<T>(key): T | undefined`: synchronous read for instant UI seeding. +* `set<T>(key, data, ttlMs)`: update cache after mutations. +* `invalidate(keyPrefix)`: remove or mark stale. +* `prefetch(options)`: warm cache without forcing UI loading. +* `clearAll()`: call on logout or terminal auth failure. + +Why local helper first: + +* Project currently has no query library. +* The data model is small enough to solve with a typed helper. +* Avoid adding dependency and architecture churn before measuring gains. + +Future option: + +* If data flows grow, migrate the same key model to TanStack Query later. The refactor should name cache keys and invalidation policies in a way that would map cleanly. + +### Layer 4: resource-specific services + +Add resource wrappers, for example: + +* `web/src/lib/resources/profile.ts` +* `web/src/lib/resources/points.ts` +* `web/src/lib/resources/store.ts` +* `web/src/lib/resources/history.ts` +* `web/src/lib/resources/notifications.ts` + +Each resource owns: + +* cache key +* TTL +* fetch function +* invalidation after writes +* local patch helpers + +Example: + +```ts +export const profileKey = ['profile'] as const; + +export function getProfileResource() { + return query({ + key: profileKey, + ttlMs: 5 * 60_000, + fetcher: getUserProfile, + staleWhileRevalidate: true, + }); +} + +export async function updateProfileResource(input: UpdateProfileRequest) { + const updated = await updateUserProfile(input); + set(profileKey, updated, PROFILE_TTL); + return updated; +} +``` + +### Layer 5: React provider and hooks + +Add `AppDataProvider` under the authenticated app shell. + +Hooks: + +* `useResource(key, loader, options)` +* `useProfile()` +* `usePoints()` +* `useHistorySummary()` +* `useHistoryThread(threadId)` +* `useNotifications(locale)` +* `useUnreadCount()` + +Hook behavior: + +* seed UI from `peek()` +* return cached data immediately when available +* refresh in background when stale +* expose `{ data, loading, refreshing, error, reload }` +* never bypass auth 401 behavior + +This keeps UI unchanged: pages still render the same cards/lists/forms, but data source shifts from local `useEffect` to shared hooks. + +## Cache policy by data domain + +| Domain | Key | TTL | Stale UI allowed | Invalidate on | +| --- | --- | ---: | --- | --- | +| Auth session | localStorage `meeyao_auth` | token expiry | no | logout, 401 refresh failure | +| Profile/settings | `['profile']` | 5 min | yes | profile update, settings update, avatar upload, logout | +| Points balance | `['points', 'balance']` | 30-60 sec | yes | divination run accepted/success, purchase, manual reload, logout | +| Packages | `['points', 'packages']` | 30 min | yes | app reload, explicit admin/package changes if added later | +| Dashboard history summary | `['history', 'summary']` | 60 sec | yes | divination run success, follow-up success, session delete | +| History full list | `['history', 'list']` | 60 sec | yes | divination run success, follow-up success, session delete | +| History thread | `['history', 'thread', threadId]` | 2-5 min | yes | follow-up success for same thread, session delete | +| Notifications list | `['notifications', locale]` | 60 sec | yes | mark read/all read, new notification polling if added | +| Unread count | `['notifications', 'unread-count']` | 30 sec | yes | mark read/all read, notification list refresh | + +## Display-state model + +Every page should distinguish: + +* `loading`: no cached data yet and initial request is pending +* `refreshing`: cached data is visible while background request updates it +* `error`: no usable data, or background refresh failed +* `stale`: data is visible but may be older than TTL + +UI stays visually the same, but behavior improves: + +* If cached data exists, do not show full-page loading. +* Use existing skeleton/spinner only for first load. +* On refresh failure, keep visible stale data and show a small non-blocking error only where useful. + +## Route prefetch strategy + +In `AppShell`, prefetch cheap likely-next data: + +* After auth/profile load: + * points balance + * unread notification count + * dashboard history summary +* On sidebar hover/focus/click intent: + * Store: packages + points + * History: history list + * Settings/Profile/General: profile + * Divination manual/auto: points + * Notifications: notification list + unread count + +Prefetch must be bounded: + +* only authenticated routes +* no SSE prefetch +* no mutation prefetch +* no repeated prefetch while in-flight or fresh + +## Mutation and invalidation policy + +### Profile/settings + +* `updateUserProfile` and `uploadAvatar` + * set `['profile']` to returned profile + * update `AppShell` user context immediately +* `updateUserSettings` + * set `['profile']` to returned profile + * redirect language only after cache/context update + +### Points/store + +* Purchase flow or future payment success: + * invalidate `['points', 'balance']` +* Divination run: + * optimistically mark points stale when run is accepted + * invalidate points and history after run finishes + +### History + +* Divination run success: + * set result in sessionStorage/router state as today + * invalidate history summary/list + * optionally seed `['history', 'thread', threadId]` if enough data exists +* Follow-up success: + * invalidate `['history', 'thread', threadId]` + * invalidate summary/list + +### Notifications + +* Mark one read: + * patch `['notifications', locale]` + * decrement `['notifications', 'unread-count']` locally +* Mark all read: + * patch list to all read + * set unread count to 0 + +## Implementation phases + +### Phase 1: measurement and guardrails + +Deliverables: + +* Add a dev-only request logger around `authFetch`/`apiRequest`. +* Capture before counts for: + * login -> dashboard + * dashboard -> settings -> profile -> general + * dashboard -> manual/auto divination + * dashboard -> history + * dashboard -> store +* Keep UI unchanged. + +Acceptance: + +* We can prove before/after request count improvements. + +### Phase 2: data client foundation + +Deliverables: + +* Add typed cache/data client. +* Move existing points TTL cache into the data client. +* Add clear-on-logout/auth-failure hook. +* Add unit-light self checks where possible, or targeted browser checks if test infra is absent. + +Acceptance: + +* In-flight duplicate calls collapse into one request. +* Cache invalidation is explicit and inspectable. + +### Phase 3: profile/settings context refactor + +Deliverables: + +* AppShell uses profile resource. +* SettingsPage/ProfileDetailPage/GeneralSettingsPage use profile resource/context instead of unconditional fetch. +* Mutations update profile cache and shell state. + +Acceptance: + +* Dashboard -> settings -> profile -> general should not issue repeated `GET /users/me/profile` while profile is fresh. + +### Phase 4: points, packages, dashboard + +Deliverables: + +* Points uses data client TTL + in-flight dedupe. +* Packages cached with long TTL. +* Dashboard data uses shared resources. +* Store/divination/settings reuse points cache. + +Acceptance: + +* Dashboard -> store -> divination does not repeatedly fetch points/packages while fresh. + +### Phase 5: history and notifications + +Deliverables: + +* History list/summary/thread resources. +* Dashboard and HistoryListPage share cached history. +* Notification list/unread resources with local patch after mark-read. + +Acceptance: + +* Dashboard -> history reuses fresh history data. +* Mark-read updates unread count without waiting for dashboard reload. + +### Phase 6: perceived-performance polish + +Deliverables: + +* Replace full-page loading on cached routes with stale data + subtle refreshing state. +* Add bounded route prefetch in AppShell. +* Add regression browser checks for mobile/desktop. + +Acceptance: + +* UI remains visually unchanged at rest. +* Route transitions feel instant when data is cached. + +## Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| Stale user-visible data | Short TTL for volatile data; explicit invalidation after writes. | +| Auth cache leaking between users | Clear all data cache on logout, missing auth, refresh failure, and user id change. | +| Hidden background refresh errors | Keep stale data visible but expose reload/error state for first-load failures. | +| Over-prefetch increasing traffic | Prefetch only small GET endpoints and rely on in-flight/fresh checks. | +| Refactor touching too many pages at once | Ship by phases with request-count verification after each phase. | +| UI drift | Do not alter markup/layout except replacing data source/loading conditions. | + +## Recommended final architecture + +``` +React Page + -> useProfile/usePoints/useHistory/useNotifications + -> Resource wrapper (keys, TTL, invalidation) + -> DataClient (cache, in-flight dedupe, stale-while-revalidate) + -> typed API functions in api.ts + -> authFetch/apiRequest transport + -> backend +``` + +The core principle: components describe what data they need; resource wrappers decide when the backend actually needs to be called. diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md b/.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md new file mode 100644 index 0000000..5567cf3 --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md @@ -0,0 +1,60 @@ +# Web request audit + +## Initial request topology + +### App shell and auth + +| Area | Request(s) | Current behavior | Waste / risk | +| --- | --- | --- | --- | +| `AppShell` | `refreshAccessToken()`, `getUserProfile()` | Runs on authenticated shell mount. Profile is stored in `UserSettingsContext`. | This should be the primary profile/settings source for child pages. | +| `LoginForm` existing auth check | `refreshAccessToken()`, `getUserProfile()` | Valid existing session fetches profile to choose locale before redirect. | Acceptable, but could reuse stored auth language if persisted in future. | +| `LoginForm` submit | `loginWithEmail()`, `getUserProfile()` | Login response lacks settings, so profile is fetched to choose locale. | Acceptable until backend returns language/settings in auth response. | + +### Duplicate profile/settings reads + +| Page | Current request | Better behavior | +| --- | --- | --- | +| `SettingsPage` | `getUserProfile()` + `getPointsBalance()` | Reuse `UserSettingsContext.userProfile`; fetch only points. | +| `GeneralSettingsPage` | `getUserProfile()` | Seed from `UserSettingsContext.userProfile`; fetch only if context missing. Update context after `updateUserSettings()`. | +| `ProfileDetailPage` | `getUserProfile()` | Seed from `UserSettingsContext.userProfile`; fetch only if context missing. Update context after `updateUserProfile()` / avatar upload. | +| `ManualDivinationPage` / `AutoDivinationPage` | import `getUserProfile` but rely on context for profile; `getUserProfile` import appears unused | Remove unused import and keep using context. | + +### Points and store reads + +| Page | Current request | Better behavior | +| --- | --- | --- | +| `Dashboard` | `getPointsBalance()`, `getUnreadNotificationCount()`, `getAgentHistory()` | Points has TTL cache, but should dedupe in-flight. History can be shared with history list via short TTL cache. | +| `SettingsPage` | `getPointsBalance()` | Keep cache, add in-flight dedupe and stale-while-revalidate option. | +| `StorePage` | `getPointsBalance()`, `getPackages()` | Cache packages for a longer TTL because packages are stable configuration. Keep explicit invalidation after purchase/payment flows. | +| `ManualDivinationPage` / `AutoDivinationPage` | `getPointsBalance()` | Reuse points cache/in-flight dedupe; consider prefetch when entering divination nav group. | + +### History and notifications + +| Area | Current request | Better behavior | +| --- | --- | --- | +| `Dashboard` | `getAgentHistory()` for latest four | Add short TTL/in-flight cache for history summary; history list can reuse if still fresh. | +| `HistoryListPage` | `getAgentHistory()` full list | Reuse cache populated by dashboard, then refresh in background. | +| `NotificationPage` | `getNotifications(locale)` | Cache list briefly per locale. Invalidate/update locally after mark-read actions. | +| Dashboard unread badge | `getUnreadNotificationCount()` | Cache briefly; invalidate/update after mark-read or mark-all-read. | + +## Priority plan + +1. Add a small typed cache helper in `web/src/lib/api.ts` or adjacent `web/src/lib/api-cache.ts` for: + - TTL cache + - in-flight promise dedupe + - explicit invalidation + - optional stale return with background refresh where UI can support it +2. Apply first to profile/settings, points, packages, history summary/list, notification list/count. +3. Refactor pages to use `UserSettingsContext` for profile reads. +4. Add prefetch hooks at AppShell/nav boundaries where it is cheap and safe. +5. Browser-verify request count reductions on: + - login -> dashboard + - dashboard -> settings -> profile -> general + - dashboard -> manual/auto divination + - dashboard -> history + - dashboard -> store + +## Open implementation questions + +* Whether to implement a minimal local cache helper or introduce a query library. Current repo has no query dependency, so default recommendation is a minimal helper first. +* Whether auth login response should eventually include profile language/settings to avoid immediate post-login profile fetch. This would be a backend contract change and is out of scope for the first frontend-only optimization pass. diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/research/remaining-web-performance-bottlenecks-after-1e4871e.md b/.trellis/tasks/05-10-audit-and-optimize-web-performance/research/remaining-web-performance-bottlenecks-after-1e4871e.md new file mode 100644 index 0000000..c02b53b --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/research/remaining-web-performance-bottlenecks-after-1e4871e.md @@ -0,0 +1,376 @@ +# Research: Remaining web performance bottlenecks after 1e4871e + +- **Query**: Deeply audit the web frontend for remaining performance bottlenecks after commit 1e4871e. Focus on repeated authenticated requests still possible, first-load route waterfall, oversized static assets, bundle/code-splitting opportunities, build-layer blocker (`NoAdapterInstalled`), dev/prod config, and measurable request/cache improvements. +- **Scope**: mixed +- **Date**: 2026-05-10 + +## Findings + +### Files Found + +| File Path | Description | +|---|---| +| `web/src/lib/data-client.ts:77` | In-memory query cache with in-flight dedupe, TTL, stale-while-revalidate, invalidation, subscriptions. | +| `web/src/lib/resources.ts:46` | Resource keys, TTLs, hooks, prefetch policies, and mutation invalidation wrappers. | +| `web/src/components/AppShell.tsx:74` | Authenticated shell boot sequence: refresh token, load profile resource, then prefetch dashboard basics. | +| `web/src/components/AuthProvider.tsx:37` | Separate auth recovery wrapper used by `AppLayout`, causing another refresh lifecycle before/alongside `AppShell`. | +| `web/src/components/DashboardApp.tsx:1` | Eagerly imports every authenticated route component into one React island. | +| `web/src/components/DashboardAppPage.astro:26` | Mounts the full dashboard app with `client:only="react"` for every authenticated route page. | +| `web/src/pages/*/history/[id].astro:2` | Dynamic history result routes opt out of prerendering with `export const prerender = false`. | +| `web/src/pages/*/history/[id]/followup.astro:2` | Dynamic follow-up routes opt out of prerendering with `export const prerender = false`. | +| `web/astro.config.mjs:6` | No Astro adapter configured; Vite dev proxy points `/api` at production API. | +| `web/src/layouts/App.astro:19` | Loads full Material Symbols variable font CSS from Google on authenticated app pages. | +| `web/src/layouts/Marketing.astro:20` | Loads same full Material Symbols font CSS on marketing pages. | +| `web/design/assets/images/**` | Source static images include multiple 0.6 MB to 3.0 MB PNG/JPG files. | +| `web/public/` | Only contains favicon files; current code references `/images/logo.png` and `/images/qigua/*.jpg`. | +| `web/dist/**` | Existing build output from an earlier run contains copied large image assets and one large dashboard JS chunk; current build cannot regenerate it. | + +### Code Patterns + +#### Priority 1: build remains blocked, which prevents reliable production measurement + +`pnpm run build` fails: + +```text +[NoAdapterInstalled] Cannot use server-rendered pages without an adapter. +``` + +The blocker is directly explained by six dynamic Astro route files that export `prerender = false`: + +```astro +// web/src/pages/en/history/[id].astro:2 +export const prerender = false; +``` + +The same pattern exists in: + +- `web/src/pages/en/history/[id].astro:2` +- `web/src/pages/zh/history/[id].astro:2` +- `web/src/pages/zh_Hant/history/[id].astro:2` +- `web/src/pages/en/history/[id]/followup.astro:2` +- `web/src/pages/zh/history/[id]/followup.astro:2` +- `web/src/pages/zh_Hant/history/[id]/followup.astro:2` + +These routes do not use server-only data. They only read `Astro.params.id` and render the same client-side `DashboardAppPage`: + +```astro +// web/src/pages/en/history/[id].astro:4-10 +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +const { id } = Astro.params; +--- + +<DashboardAppPage locale={locale} /> +``` + +Implementation steps: + +1. If deployment target is static hosting, remove `export const prerender = false` and add a static fallback strategy for client-owned history routes, e.g. Astro static dynamic route support with `getStaticPaths()` only if finite paths exist, or replace file-based `[id]` pages with a static catch-all/client app entry supported by the host rewrite rules. +2. If deployment target requires on-demand rendering, add the correct Astro server adapter in `web/astro.config.mjs` and the matching package in `web/package.json`. +3. Make build success the gate before bundle-size, image, and request-count acceptance data are considered final. + +#### Priority 2: first authenticated load can still spend requests on duplicate auth/session work + +`AppLayout` wraps every authenticated route with `AuthProvider client:load`: + +```astro +// web/src/layouts/App.astro:23-25 +<AuthProvider client:load> + <slot /> +</AuthProvider> +``` + +`AuthProvider` refreshes on mount regardless of whether the access token is still fresh: + +```tsx +// web/src/components/AuthProvider.tsx:37-45 +const auth = getAuth(); +if (!auth?.refresh_token) { + clearAuth(); + redirectToLogin(); + return; +} +refreshAccessToken() +``` + +`AppShell` then has its own auth boot sequence and also calls `refreshAccessToken()` before loading profile: + +```tsx +// web/src/components/AppShell.tsx:82-86 +refreshAccessToken() + .then((data) => { + if (alive) setAuthUser(data.user); + return getProfileResource(); + }) +``` + +The single-flight refresh promise in `web/src/lib/auth.ts:172` dedupes concurrent refreshes, but this is still a redundant lifecycle. Depending on hydration timing, it can be one shared refresh or two sequential refresh calls. It also delays the authenticated shell behind two components that both show full-screen loading spinners. + +Implementation steps: + +1. Collapse authenticated-route auth boot into one owner. Prefer `AppShell` because it already owns profile, locale redirect, nav prefetch, and user context. +2. Remove `AuthProvider` from `AppLayout` if no routed child consumes `useAuth()`, or turn it into a passive context seeded from `getAuth()` without forcing refresh. +3. In the remaining boot path, call `refreshAccessToken()` only when `isTokenExpired()` is true; otherwise seed `authUser` from `getAuth().user`. +4. Measure initial `/zh/dashboard` load request count before and after. Target: avoid a refresh request when the token is fresh, and keep one refresh maximum when expired. + +#### Priority 3: login to dashboard still repeats profile/session requests + +`LoginForm` checks existing sessions and submit success by calling `getUserProfile()` directly: + +```tsx +// web/src/components/LoginForm.tsx:59-63 +await refreshAccessToken(); +const profile = await getUserProfile(); +const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN'); +window.location.href = `/${userLocale}/dashboard`; +``` + +```tsx +// web/src/components/LoginForm.tsx:100-104 +await loginWithEmail(email, code, language, timezone); +const profile = await getUserProfile(); +const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || language); +window.location.href = `/${userLocale}/dashboard`; +``` + +Because the redirect reloads the app, the in-memory profile resource is empty on the dashboard. `AppShell` then fetches profile again through `getProfileResource()` at `web/src/components/AppShell.tsx:85`. + +Implementation steps: + +1. Short term: after login, redirect to `/${backendLanguageToLocale(language)}/dashboard` without the extra profile read when the user selected/submitted the language and backend stores it. +2. If backend user preference must remain authoritative, persist only the minimal post-login locale/profile seed into `sessionStorage` and hydrate `profileKey` before `AppShell` calls `getProfileResource()`. +3. Long term: have the auth/session response include profile language/settings, which eliminates the post-login profile GET entirely. +4. Target measurable improvement: login success path should drop from `login + profile + dashboard profile` to `login + dashboard profile`, or to `login` if profile seed is safe. + +#### Priority 4: invalidation can actively create duplicate refetches + +The resource layer intentionally uses one key for history summary and list: + +```ts +// web/src/lib/resources.ts:49-50 +export const historyListKey = ['history', 'list'] as const; +export const historySummaryKey = historyListKey; +``` + +But invalidation calls both keys: + +```ts +// web/src/lib/resources.ts:290-293 +export function invalidateHistory(threadId?: string): void { + invalidate(historySummaryKey); + invalidate(historyListKey); + if (threadId) invalidate(historyThreadKey(threadId)); +} +``` + +Since the two keys are the same object/value, the first `invalidate()` deletes the entry and notifies subscribers, which can immediately start a fetch and store an in-flight promise. The second `invalidate()` can delete that new in-flight entry and notify again, allowing a second network request for the same history list. + +Points have a similar repeated invalidation pattern during run flows: + +```tsx +// web/src/components/DivinationProcessingOverlay.tsx:157-159 +const { threadId, runId } = await enqueueDivinationRun(params, yaoStates); +invalidatePoints(); +``` + +```tsx +// web/src/components/DivinationProcessingOverlay.tsx:257-259 +setStep('done'); +invalidatePoints(); +invalidateHistory(threadId); +``` + +Follow-up does the same around SSE completion: + +```tsx +// web/src/components/HistoryFollowUpPage.tsx:166-190 +const { runId } = await enqueueFollowUpRun(threadId, text, resultData); +invalidatePoints(); +... +invalidateHistory(threadId); +invalidatePoints(); +const snapshot = await getHistoryThreadResource(threadId, true); +``` + +Implementation steps: + +1. Make `invalidateHistory()` dedupe equal prefixes before invalidating, or stop aliasing `historySummaryKey` and `historyListKey`. +2. Add an option to mark entries stale without deleting active in-flight promises, e.g. `invalidate(prefix, { refetchActive: true, preserveInFlight: true })`. +3. In divination/follow-up flows, prefer one points invalidation at the moment the backend has committed the charge, or use a stale mark after enqueue and one forced reload at finish. +4. Add instrumentation in `data-client.ts` around `startFetch()` in dev builds to count key-level fetch starts. Target: one `['points','balance']` refresh and one history refresh per completed run. + +#### Priority 5: first-load route waterfall and bundle shape are still dominated by one eager app island + +Every authenticated route page renders the same `DashboardAppPage`, and that mounts one client-only React app: + +```astro +// web/src/components/DashboardAppPage.astro:26-27 +<AppLayout locale={locale}> + <DashboardApp client:only="react" locale={locale} translations={translations} /> +</AppLayout> +``` + +`DashboardApp.tsx` eagerly imports all route screens: + +```tsx +// web/src/components/DashboardApp.tsx:3-15 +import AppShell from './AppShell'; +import Dashboard from './Dashboard'; +import StorePage from './StorePage'; +import HistoryListPage from './HistoryListPage'; +import DivinationResultPage from './DivinationResultPage'; +import HistoryFollowUpPage from './HistoryFollowUpPage'; +import NotificationPage from './NotificationPage'; +import ProfileDetailPage from './ProfileDetailPage'; +import SettingsPage from './SettingsPage'; +import GeneralSettingsPage from './GeneralSettingsPage'; +import FeedbackPage from './FeedbackPage'; +import ManualDivinationPage from './ManualDivinationPage'; +import AutoDivinationPage from './AutoDivinationPage'; +``` + +This means `/dashboard` pays parse/compile/download cost for store, notifications, profile edit, manual casting, auto casting, result rendering, follow-up chat, feedback, and settings code before any route transition. + +Existing stale `web/dist` output from 2026-05-10 01:07 showed: + +| Asset | Raw | Gzip | +|---|---:|---:| +| `web/dist/_astro/client.Bncfyed9.js` | 185,936 bytes | 58,384 bytes | +| `web/dist/_astro/DashboardApp.Df7kJHO-.js` | 142,394 bytes | 39,455 bytes | +| `web/dist/_astro/animations.BXuKIGyB.css` | ~89 KiB | 16,947 bytes | + +These numbers are not current-build authoritative because `pnpm run build` now fails, but they confirm the dashboard app chunk is large enough to justify code splitting once the build blocker is fixed. + +Implementation steps: + +1. Convert route components in `DashboardApp.tsx` to `React.lazy(() => import('./RoutePage'))` and wrap `<Routes>` in a small Suspense fallback that preserves shell layout. +2. Keep `AppShell`, `Dashboard`, and core nav in the initial chunk; lazy-load lower-probability paths such as store, result, follow-up, profile edit, feedback, notifications, manual/auto casting. +3. Split heavy local copy objects in `ManualDivinationPage.tsx`, `AutoDivinationPage.tsx`, and `DivinationProcessingOverlay.tsx` with the route component rather than the dashboard shell. +4. Add bundle budget reporting after the build blocker is fixed. + +#### Priority 6: static image assets are missing from `public/` but oversized in source/stale dist + +Current source references these public URLs: + +```tsx +// web/src/components/AppShell.tsx:156 +<img src="/images/logo.png" alt="MeiYao" className="w-9 h-9 rounded-lg" /> +``` + +```tsx +// web/src/components/ManualDivinationPage.tsx:61-63 +<img + src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'} +``` + +```tsx +// web/src/components/DivinationResultPage.tsx:24-27 +if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg'; +if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg'; +if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg'; +return '/images/qigua/zhongxia.jpg'; +``` + +But `web/public/` currently contains only: + +- `web/public/favicon.ico` (655 B) +- `web/public/favicon.svg` (749 B) + +The same assets exist under `web/design/assets/images/**` and stale `web/dist/images/**`. Largest source assets: + +| File | Size | +|---|---:| +| `web/design/assets/images/tutorial/tutorial_3.png` | 3.0 MB | +| `web/design/assets/images/tutorial/tutorial_2.png` | 2.5 MB | +| `web/design/assets/images/qigua/xiaxia.jpg` | 1.4 MB | +| `web/design/assets/images/tutorial/tutorial_1.png` | 799 KB | +| `web/design/assets/images/qigua/shangshang.jpg` | 768 KB | +| `web/design/assets/images/qigua/zhongxia.jpg` | 601 KB | +| `web/design/assets/images/qigua/zhongshang.jpg` | 596 KB | +| `web/design/assets/images/logo.png` | 142 KB | + +Implementation steps: + +1. Decide whether `web/public/images/**` should be source-controlled. If yes, copy optimized web assets there rather than relying on stale `dist/`. +2. Resize/compress qigua images to their displayed dimensions and convert to WebP/AVIF with JPG fallback only if needed. +3. Replace `logo.png` with optimized SVG or small WebP/PNG; 142 KB is too high for a small nav/login logo. +4. Do not ship tutorial PNGs unless a web tutorial page references them. If they are needed later, lazy-load and optimize them. +5. Add an asset budget check after build works, e.g. fail if any single public image exceeds 200 KB without explicit rationale. + +#### Priority 7: Material Symbols font load is broad and render-blocking + +Both app and marketing layouts include the full Material Symbols variable CSS: + +```astro +// web/src/layouts/App.astro:19 +<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" /> +``` + +```astro +// web/src/layouts/Marketing.astro:20 +<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" /> +``` + +The app also has a local `Icon.tsx` component for many icons, so the font is mostly used by settings/store/feedback subpages. The font CSS is loaded before route-specific need and from a third-party origin. + +Implementation steps: + +1. Replace remaining `material-symbols-rounded` spans with `Icon.tsx` where icons already exist. +2. If the font remains necessary, self-host a subset or load it only in authenticated route chunks that use it. +3. Add `preconnect` only if the remote font remains. + +#### Priority 8: dev/prod API config is not explicit enough for repeatable performance verification + +`apiBase()` depends on `PUBLIC_API_URL`: + +```ts +// web/src/lib/api-client.ts:1-4 +const apiBase = (): string => import.meta.env.PUBLIC_API_URL || ''; +export function apiUrl(path: string): string { + return path.startsWith('http') ? path : `${apiBase()}${path}`; +} +``` + +Dev server proxy is hardcoded to production: + +```mjs +// web/astro.config.mjs:17-24 +server: { + proxy: { + '/api': { + target: 'https://api.meeyao.com', + changeOrigin: true, + secure: true, + }, + }, +}, +``` + +This makes local request-count testing hit the production-latency path by default, and production behavior depends on `PUBLIC_API_URL` being set correctly in the hosting environment. + +Implementation steps: + +1. Add `.env.example` documenting `PUBLIC_API_URL` for production and local verification. +2. Make dev proxy target configurable, e.g. `DEV_API_PROXY_TARGET ?? 'https://api.meeyao.com'`. +3. During perf verification, record whether requests go same-origin proxy or direct API origin, because browser connection reuse and CORS/preflight behavior differ. + +### External References + +- [Astro routing reference](https://docs.astro.build/ja/reference/routing-reference/) — Astro routes prerender by default in static mode; exporting `prerender = false` opts a page into on-demand server rendering. +- [Astro NoAdapterInstalled error reference](https://docs.astro.build/zh-tw/reference/errors/no-adapter-installed/) — server-rendered pages require an appropriate deployment adapter. + +### Related Specs + +- `.trellis/spec/web/index.md` — Web-specific guidance referenced by the task PRD. +- `.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md` — Active task goals, acceptance criteria, prior implementation notes. +- `.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md` — Original request topology and duplicate request plan. +- `.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md` — Target data-client/resource architecture that commit `1e4871e` implemented. + +## Caveats / Not Found + +- `pnpm run build` currently fails with `NoAdapterInstalled`, so current production bundle sizes could not be regenerated. +- `web/dist/**` exists but is stale relative to the current failing build. Sizes from `web/dist` are useful directional evidence only. +- `pnpm exec astro sync` passed. +- `pnpm exec tsc --noEmit` could not run because `typescript`/`tsc` is not installed in `web/node_modules`. +- Browser request-count capture was not run in this research pass. The task PRD already notes a previous automation blocker; request-count verification should happen after the build/route blocker and auth boot duplication are addressed. diff --git a/.trellis/tasks/05-10-audit-and-optimize-web-performance/task.json b/.trellis/tasks/05-10-audit-and-optimize-web-performance/task.json new file mode 100644 index 0000000..3e6bf2e --- /dev/null +++ b/.trellis/tasks/05-10-audit-and-optimize-web-performance/task.json @@ -0,0 +1,26 @@ +{ + "id": "audit-and-optimize-web-performance", + "name": "audit-and-optimize-web-performance", + "title": "Audit and optimize web performance", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-10", + "completedAt": null, + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-04/04-30-product-website/prd.md b/.trellis/tasks/archive/2026-04/04-30-product-website/prd.md new file mode 100644 index 0000000..4389b2d --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-30-product-website/prd.md @@ -0,0 +1,150 @@ +# PRD: Product Website & Privacy Policy for App Store + +## 1. 需求概述 + +为 MeeYao Divination (觅爻签问) iOS 应用创建一个产品技术网站,满足 Apple App Store 审核对 Support URL 和 Privacy Policy URL 的要求。 + +**核心目标**: +- 提供 App Store 审核所需的公开网站(Support URL + Privacy Policy URL) +- 展示产品信息,建立可信度 +- 隐私政策页面完整、合规 + +## 2. 背景与合规要求 + +### App Store 审核要求 +- **Guideline 1.5**: app 和 Support URL 必须包含方便用户联系的方式 +- **Guideline 2.1**: 提交审核时所有 URL 必须完整可用,不接受占位内容或空网站 +- **Guideline 5.1**: 涉及个人数据收集的 app 必须提供隐私政策,且链接在 App Store Connect 中填写 +- **App Store Connect**: 自 2018 年 10 月起,所有新 app 和更新必须填写 Privacy Policy URL + +### 当前状态 +- 应用隐私政策文本已存在: `apps/assets/legal/{en,zh,zh_Hant}/privacy_policy.md` +- 应用服务条款已存在: `apps/assets/legal/{en,zh,zh_Hant}/terms_of_service.md` +- 关于我们已存在: `apps/assets/legal/{en,zh,zh_Hant}/about_us.md` +- 但尚无公开可访问的网站托管这些内容 + +## 3. 技术方案 + +### 3.1 技术栈 + +- **纯静态 HTML/CSS/JS** (无需构建工具或框架) +- **Tailwind CSS** via CDN(快速样式开发) +- **单页面应用**:两个路由通过 hash 路由或简单文件切换实现 +- 部署为静态文件,可托管在任何静态托管服务上 + +### 3.2 文件结构 + +``` +web/ +├── index.html # 产品主页 (Support URL 指向此页面) +├── privacy.html # 隐私政策页 (Privacy Policy URL 指向此页面) +├── assets/ +│ ├── logo.png # 应用 Logo (从 apps/assets/images/logo.png 复制) +│ ├── icon-1024.png # App Store 图标 (从 iOS AppIcon 复制) +│ ├── css/ +│ │ └── style.css # 自定义样式覆盖 +│ └── js/ +│ └── main.js # 交互逻辑(语言切换等) +└── README.md # 部署说明 +``` + +### 3.3 路由设计 + +| 路由 | 对应 App Store 字段 | 用途 | +|------|---------------------|------| +| `/` (index.html) | Support URL | 产品介绍、功能说明、联系方式 | +| `/privacy.html` | Privacy Policy URL | 完整隐私政策文档 | + +## 4. 页面设计要求 + +### 4.1 产品主页 (index.html) + +**定位**: Support URL 落地页,用户从 App Store 点击"支持"后到达此页。 + +**内容板块**: +1. **Hero 区域**: 应用名称、Logo、一句话描述 +2. **产品介绍**: MeeYao 是什么 - AI 辅助六爻文化参考工具 +3. **核心功能**: 手动起卦、自动起卦、AI 解读、历史记录 +4. **文化背景**: 六爻与易经文化简介 +5. **免责声明**: 娱乐和文化参考用途声明 +6. **联系方式**: 开发者邮箱 ann@xunmee.com +7. **页脚**: 版权信息、隐私政策链接、服务条款链接 + +**设计风格**: +- 与 App 视觉语言一致:紫色品牌色系 (#673AB7, #9C27B0) +- 安静、可信、温暖的视觉基调 +- 响应式设计,移动端优先 +- 无多余动画,保持简洁专业 + +### 4.2 隐私政策页 (privacy.html) + +**定位**: Privacy Policy URL 落地页,满足 App Store Connect 要求。 + +**内容**: +- 直接使用已有的英文隐私政策文本(面向全球用户) +- 结构化展示:信息收集、使用方式、数据存储、用户权利等 +- 包含最后更新日期 +- 顶部导航回到主页 + +**设计风格**: +- 干净的文档阅读体验 +- 清晰的标题层级 +- 与主页一致的配色和字体 + +## 5. 素材来源 + +| 素材 | 来源路径 | 目标路径 | +|------|----------|----------| +| 应用 Logo | `apps/assets/images/logo.png` | `web/assets/logo.png` | +| App Store 图标 | `apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png` | `web/assets/icon-1024.png` | +| 隐私政策文本 (EN) | `apps/assets/legal/en/privacy_policy.md` | 转为 HTML 内容 | +| 隐私政策文本 (ZH) | `apps/assets/legal/zh/privacy_policy.md` | 参考用 | +| 服务条款 (EN) | `apps/assets/legal/en/terms_of_service.md` | 参考用 | +| 关于我们 (EN) | `apps/assets/legal/en/about_us.md` | 参考用 | + +## 6. 品牌配色 (来自 visual_design_language.md) + +- Primary Purple: `#673AB7` +- Accent Purple: `#9C27B0` +- Light Purple Surface: `#F0E6FF` +- Background Gray: `#F8F8F8` +- Text Dark: `#333333` +- Text Medium: `#666666` +- Text Light: `#999999` +- Tag Gold: bg `#FFF8E1`, text `#FFB300` + +## 7. 实现步骤 + +### Phase 1: 项目初始化 +- [ ] 创建 `web/` 目录结构 +- [ ] 复制素材资源(logo、图标) +- [ ] 准备隐私政策 HTML 内容 + +### Phase 2: 设计与实现 +- [ ] 使用 UI skill 设计主页布局 +- [ ] 实现 index.html 产品主页 +- [ ] 实现 privacy.html 隐私政策页 +- [ ] 实现响应式适配 + +### Phase 3: 验证 +- [ ] 浏览器测试页面渲染 +- [ ] 移动端响应式验证 +- [ ] 验证所有链接可点击 +- [ ] 确认隐私政策内容完整 + +## 8. 验收标准 + +1. 两个 HTML 文件均可直接在浏览器中打开,无构建依赖 +2. 产品主页包含:应用名称、Logo、功能描述、免责声明、联系方式 +3. 隐私政策页包含:完整的英文隐私政策文本、更新日期、联系方式 +4. 移动端和桌面端均可正常浏览 +5. 配色与 App 视觉语言一致 +6. 所有图片资源本地可用(无外部依赖) + +## 9. 不做的事 + +- 不做多语言切换(英文为主,满足 App Store 全球审核) +- 不做后端服务、表单提交 +- 不做 SEO 优化(仅满足 App Store 审核) +- 不做域名配置/部署(仅产出静态文件) +- 不做服务条款独立页面(在主页底部简要链接即可) diff --git a/.trellis/tasks/archive/2026-04/04-30-product-website/task.json b/.trellis/tasks/archive/2026-04/04-30-product-website/task.json new file mode 100644 index 0000000..392ff35 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-30-product-website/task.json @@ -0,0 +1,44 @@ +{ + "id": "04-30-product-website", + "name": "04-30-product-website", + "title": "Product Website & Privacy Policy for App Store", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-04-30", + "completedAt": "2026-04-30", + "branch": "dev", + "base_branch": "dev", + "worktree_path": null, + "current_phase": 0, + "next_action": [ + { + "phase": 1, + "action": "implement" + }, + { + "phase": 2, + "action": "check" + }, + { + "phase": 3, + "action": "finish" + }, + { + "phase": 4, + "action": "create-pr" + } + ], + "commit": "9866f94", + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/00-join-zl-q/prd.md b/.trellis/tasks/archive/2026-05/00-join-zl-q/prd.md new file mode 100644 index 0000000..5997523 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/00-join-zl-q/prd.md @@ -0,0 +1,104 @@ +# Joiner Onboarding Task + +**You (the AI) are running this task. The developer does not read this file.** + +`zl-q` just ran `trellis init` on a fresh clone, saw "Developer +initialized", and will now start asking you questions in chat. This joiner task +exists under `.trellis/tasks/`; when they want to work on it, they should +start it from a session that provides Trellis session identity. + +Your job is to orient them to Trellis. Don't dump all of this at them — open +with a short greeting, ask where they want to start, and fill in the rest as +they engage. + +--- + +## Topics to cover (adapt order to their questions) + +### 1. What Trellis is + the workflow + +Trellis is a workflow layer over Claude Code / Cursor / etc. that keeps AI +agents consistent with project-specific conventions instead of writing generic +code every session. + +- **Three phases**: Plan (brainstorm → `prd.md`) → Execute (code + check) → + Finish (capture + wrap). Full reference: `.trellis/workflow.md`. +- **Task lifecycle**: planning → in_progress → done → archive, under + `.trellis/tasks/`. +- **Core slash commands**: + - `/trellis:continue` — resume the current session's active task + - `/trellis:finish-work` — wrap up a finished task + - `/trellis:start` — session boot from scratch (not needed here; the + SessionStart hook does its job automatically) + +### 2. Runtime mechanics (explain when they ask "how does it know what to do") + +- **SessionStart hook** runs `get_context.py` and injects identity, git + status, session active task, active tasks, and workflow phase into the AI + conversation at every session start. +- **`<workflow-state>` tag** is auto-injected with every user message, + carrying the current task + phase hint. +- **`/trellis:continue`** loads the Phase Index, reads `prd.md` + recent + activity, and routes to the right skill (`trellis-brainstorm` for planning, + `trellis-implement` for coding, `trellis-check` for verification). +- **`trellis-implement` sub-agent** is spawned when code needs to be written. + The platform hook reads `{TASK_DIR}/implement.jsonl` and auto-injects those + spec files + `prd.md` into the sub-agent's prompt so it codes per project + conventions. +- **`trellis-check` sub-agent** follows the same pattern with `check.jsonl` + — reviews changes against specs, auto-fixes issues, runs lint/typecheck. + +File layout (mention when they ask "where does what live"): +- `.trellis/.runtime/sessions/<session>.json` — session active-task state, gitignored +- `.trellis/tasks/<task>/{implement,check}.jsonl` — per-task context manifests +- `.trellis/spec/` — project-wide conventions (source of truth) +- `.trellis/workspace/zl-q/journal-*.md` — their session log, + rotated at ~2000 lines + +### 3. This project's actual conventions + +- Summarize `.trellis/spec/` for them — what coding conventions this + specific team enforces. +- Point at the last 5 entries in `.trellis/tasks/archive/` as a rhythm + example of how people actually work here. **If archive is empty** (the + project just started), skip this — don't invent examples. +- Not your job in this onboarding to teach them the business code itself — + the README and their teammates handle that. + +### 4. Their assigned work + +- Check if `.trellis/workspace/zl-q/` already exists — if yes, it's + their journal from another machine and worth mentioning. +- Run `python3 ./.trellis/scripts/task.py list --assignee zl-q` to + show tasks assigned to them. (Quote the name if it contains spaces.) +- Remind them that the "My Tasks" section appears in the SessionStart context + on every new session. + +--- + +## Optional: walk through a small task end-to-end + +If they want to practice before touching real work, offer to pick a tiny +P3 task or a typo fix and run the full cycle together: `/trellis:continue` +→ you implement via sub-agents → `/trellis:finish-work`. + +--- + +## Completion + +When they feel oriented (or after you've covered the four topics with +reasonable back-and-forth), guide them to run: + +```bash +python3 ./.trellis/scripts/task.py finish +python3 ./.trellis/scripts/task.py archive 00-join-zl-q +``` + +--- + +## Suggested opening line + +"Welcome! Your `trellis init` set me up to onboard you to this project. I +can walk you through the workflow, show you the runtime mechanics under the +hood, summarize the team's spec, or jump to what you're already curious about +— which would you prefer?" diff --git a/.trellis/tasks/archive/2026-05/00-join-zl-q/task.json b/.trellis/tasks/archive/2026-05/00-join-zl-q/task.json new file mode 100644 index 0000000..08ad552 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/00-join-zl-q/task.json @@ -0,0 +1,26 @@ +{ + "id": "00-join-zl-q", + "name": "00-join-zl-q", + "title": "Joining: Onboard to this Trellis project (zl-q)", + "description": "Onboard a new developer to an existing Trellis project: learn the workflow, conventions, and find assigned work", + "status": "completed", + "dev_type": "docs", + "scope": null, + "package": null, + "priority": "P1", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-06", + "completedAt": "2026-05-06", + "branch": null, + "base_branch": null, + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "Generated by trellis init for a new developer joining an existing Trellis project", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/prd.md b/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/prd.md new file mode 100644 index 0000000..8857826 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/prd.md @@ -0,0 +1,201 @@ +# Migration Task: Upgrade to v0.5.0-rc.6 + +**Created**: 2026-05-06 +**From Version**: 0.3.10 +**To Version**: 0.5.0-rc.6 +**Assignee**: zl-q + +## Status + +- [ ] Review migration guide +- [ ] Update custom files +- [ ] Run `trellis update --migrate` +- [ ] Test workflows + +--- + +## v0.4.0-beta.1 Migration Guide + +## Command Consolidation + +The following commands have been merged: + +| Old (removed) | New (replacement) | +|---------------|-------------------| +| `before-backend-dev` | `before-dev` | +| `before-frontend-dev` | `before-dev` | +| `check-backend` | `check` | +| `check-frontend` | `check` | + +### If you never customized these commands + +No action needed. `trellis update` will: +1. Auto-delete the old files (hash-verified, only if unmodified) +2. Write the new unified `before-dev` and `check` commands + +### If you customized the old commands + +The old files will NOT be deleted (hash mismatch protection). You need to: +1. Open the new `before-dev` / `check` command file +2. Merge your customizations from the old backend/frontend variants into the unified file +3. Delete the old files manually once merged + +### Affected paths per platform + +- **Claude**: `.claude/commands/trellis/` +- **Cursor**: `.cursor/commands/` +- **iFlow**: `.iflow/commands/trellis/` +- **OpenCode**: `.opencode/commands/trellis/` +- **Gemini**: `.gemini/commands/trellis/` +- **Kilo**: `.kilocode/workflows/` +- **Codex**: `.agents/skills/` +- **Kiro**: `.kiro/skills/` +- **Qoder**: `.qoder/skills/` + +## Python Scripts Refactoring + +All `.trellis/scripts/` files have been restructured internally. **No action needed** — all entry paths (`python3 .trellis/scripts/task.py`, etc.) remain unchanged. `trellis update` will sync the new scripts automatically. + +### AI Assistant Instructions + +When helping with this migration: + +When helping users migrate to v0.4.0-beta.1: + +1. **Check for customized old commands**: Run `git diff` or compare file hashes to see if the user modified before-backend-dev, before-frontend-dev, check-backend, or check-frontend files. +2. **If customized**: Help merge their customizations into the new unified `before-dev` and `check` files. The new files use `python3 ./.trellis/scripts/get_context.py --mode packages` to auto-detect which specs to load, replacing the hardcoded backend/frontend split. +3. **If not customized**: Just run `trellis update` — safe-file-delete will handle cleanup automatically. +4. **Python scripts**: No user action needed. The refactoring preserves all entry paths. If the user has custom scripts that import from `.trellis/scripts/common/`, they may need to update imports (e.g., `from common.io import read_json` instead of inline `_read_json_file`). + +--- + +## v0.5.0-beta.0 Migration Guide + +## 0.4.x → 0.5.x: What This Release Actually Changes + +0.5.0-beta.0 is a **breaking** release. Pre-existing 0.4.x projects need `trellis update --migrate` to sync. The update runs **206 migration entries** (renames + hash-verified safe-file-deletes); the patch is non-destructive but large, so expect a handful of confirm prompts. + +### 1. Skills got renamed: `skills/<name>/` → `skills/trellis-<name>/` + +All Trellis skill directories gained a `trellis-` prefix. 60+ rename migrations cover every platform (`.claude/`, `.cursor/`, `.agents/`, `.kiro/`, `.qoder/`, etc.). + +- **Unmodified skills**: renamed silently. +- **Skills you customized**: confirm prompt. Pressing Enter (default = backup-rename) is always safe — your edits land at the new `trellis-<name>/` path intact. + +### 2. Six commands + three sub-agents retired + +| Old (removed) | Replacement | +|---|---| +| `/record-session` | merged into `/trellis:finish-work` Step 3 | +| `/check-cross-layer` | merged into `/trellis:check` | +| `/parallel` | use your CLI's native worktree/parallel support | +| `/onboard` | superseded by auto-generated onboarding tasks | +| `/create-command` | low-usage, unshipped | +| `/integrate-skill` | low-usage, unshipped | +| `dispatch` / `debug` / `plan` sub-agents | replaced by skill routing (`trellis-brainstorm`, `trellis-check`, `trellis-break-loop`) | + +If any of these you relied on: replace the invocation with the right column. `/record-session` → `/trellis:finish-work` is the most common fix. + +### 3. Multi-Agent Pipeline gone + +`.trellis/scripts/multi_agent/`, `worktree.yaml`, and the Ralph Loop hook have been removed (138-entry safe-file-delete). Native worktree support in Claude / Cursor / etc. covers this space now. If you built automation around these, you'll need to port it to the platform's native primitives. + +### 4. iFlow dropped + +The `.iflow/` platform adapter was unmaintained upstream and has been removed. Existing `.iflow/` installs keep working on 0.4.x; switch to a supported platform (`--claude` / `--cursor` / `--codex` / etc.) to stay on 0.5.x. + +### 5. task.py schema cleanup + +`task.py create` stops writing legacy `current_phase` / `next_action` fields. Existing task.json files keep working (Python readers treat missing fields as null). New tasks from 0.5.x produce the canonical 24-field shape. + +### 6. New runtime: per-turn workflow breadcrumb + sub-agent context injection + +No user action needed — these are internal flow improvements. But if you hand-edit `.claude/settings.json` / `.codex/hooks.json` / etc., `trellis update` will want to overwrite the hooks block; your custom hooks land on the `Modified by you` prompt so you can review the merge. + +## How to upgrade + +```bash +trellis update --migrate +``` + +- `--migrate` is REQUIRED for breaking releases — without it, `trellis update` exits 1 telling you to add it. +- Hash-verified migrations mean your local customizations stay; unmodified Trellis templates get updated silently. +- The confirm prompts inline a `reason` explaining each prompt; Enter (default) is safe. +- Codex users: enable `features.codex_hooks = true` in `~/.codex/config.toml` to activate SessionStart + UserPromptSubmit hook injection (feature-flagged by Codex itself). + +### AI Assistant Instructions + +When helping with this migration: + +When helping a user migrate from 0.4.x to 0.5.x: + +1. **Check for retired commands first**. Grep the user's codebase / shell history / aliases for `/record-session`, `/check-cross-layer`, `/parallel`, `/onboard`, `/create-command`, `/integrate-skill`. For each match, help them replace per the table in the guide — most common case is `/record-session` → `/trellis:finish-work`. + +2. **Run `trellis update --migrate`**. Walk them through the prompts if any fire — 0.4 didn't hash-track certain SKILL.md files, so pristine copies may show as modified. The per-prompt `reason` explains this; pressing Enter (backup-rename) is always safe. + +3. **Don't try to preserve Multi-Agent Pipeline state**. If the user has `.trellis/scripts/multi_agent/`, `worktree.yaml`, or Ralph Loop hooks in local edits, explicitly tell them the feature has no replacement in 0.5 beyond the platform's native worktree support. Help them identify which of their automation still needs porting. + +4. **If they're on `.iflow/`**: the platform is gone. Help them pick a supported platform (`--claude` / `--cursor` / `--codex` / etc.) and run `trellis init --<platform>` on the project to add it. `.iflow/` can be manually deleted after. + +5. **After migrate**, run `trellis update` a second time and confirm it says "Already up to date!" — any remaining diff indicates an incomplete migration (usually a skill that the user customized and that needs a manual decision). + +6. **Skill paths in existing task jsonl files**: if the user has active tasks with `implement.jsonl` / `check.jsonl` referencing old paths (e.g. `.kiro/skills/check/SKILL.md` without `trellis-`), those paths now 404. Re-run `task.py init-context <task-dir> <type> --platform <platform>` to regenerate with correct paths. + +7. **Codex feature flag**: remind them to set `features.codex_hooks = true` in `~/.codex/config.toml`, otherwise SessionStart injection is silently skipped and they'll wonder why workflow breadcrumbs don't show up. + +--- + +## v0.5.0-beta.5 Migration Guide + +## Sub-Agent Rename: `implement` / `check` / `research` → `trellis-*` + +beta.5 renames the three core sub-agents to add a `trellis-` prefix across all 10 agent-capable platforms (Claude, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid). The generic names (`implement`, `check`, `research`) were colliding with user-defined agents in some setups. + +### What changes + +| Old name | New name | +|---|---| +| `implement` | `trellis-implement` | +| `check` | `trellis-check` | +| `research` | `trellis-research` | + +Paths per platform: + +- **Claude / OpenCode / Cursor / etc.**: `.<platform>/agents/implement.md` → `.<platform>/agents/trellis-implement.md` +- **Codex**: `.codex/agents/implement.toml` → `.codex/agents/trellis-implement.toml` +- **Kiro**: `.kiro/agents/implement.json` → `.kiro/agents/trellis-implement.json` + +### Side-effect: removed `model: opus` from agent templates + +Agent YAML frontmatter no longer hardcodes a model. Falls back to whatever the platform default is. If you pinned opus explicitly for a reason (budget, performance), re-add `model: opus` to your local agent files after migrate. + +### How to upgrade + +```bash +trellis update --migrate +``` + +30 rename entries run. Unmodified agent files are renamed silently; customized ones (e.g. you tweaked the agent prompt) land on the standard `Modified by you` confirm prompt — pressing Enter (backup-rename) is safe. + +### What to check after migrate + +1. **Any code that spawns agents by name** — shell scripts, CI jobs, or other automation calling `Task(subagent_type="implement")` must update to `Task(subagent_type="trellis-implement")`. +2. **Aliases / hot keys** — if you had a keyboard shortcut or prompt alias that referenced `implement` / `check` / `research`, update it. +3. **Model pin** — if you customized `model: opus` in agent frontmatter, re-add it. Trellis no longer pins a model by default. + +### AI Assistant Instructions + +When helping with this migration: + +When helping a user migrate to 0.5.0-beta.5: + +1. **Grep for bare agent names in user code**: look for `Task(subagent_type="implement"|"check"|"research")` in any .md / .ts / .py / shell files. Rename each to the `trellis-` prefixed version. + +2. **Check agent customizations**: diff the user's `.<platform>/agents/{implement,check,research}.*` against the new `trellis-*` templates. If they had custom content, help merge it into the new file. + +3. **Model pinning**: if their agent frontmatter had `model: opus` and they still want it pinned, re-add it after migrate. beta.5 removes the default pin in favor of platform-default. + +4. **Run migrate**: `trellis update --migrate`. Hash-verified renames — pristine files renamed silently, customized files land on the confirm prompt (Enter = backup-rename is safe). + +5. **Verify clean second run**: after migrate, running `trellis update` again should report "Already up to date!". Any diff indicates a rename that didn't complete (user chose skip on a modified file). + diff --git a/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/task.json b/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/task.json new file mode 100644 index 0000000..52eee3e --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-06-migrate-to-0.5.0-rc.6/task.json @@ -0,0 +1,26 @@ +{ + "id": "migrate-to-0.5.0-rc.6", + "name": "migrate-to-0.5.0-rc.6", + "title": "Migrate to v0.5.0-rc.6", + "description": "Breaking change migration from v0.3.10 to v0.5.0-rc.6", + "status": "completed", + "dev_type": null, + "scope": "migration", + "package": null, + "priority": "P1", + "creator": "trellis-update", + "assignee": "zl-q", + "createdAt": "2026-05-06", + "completedAt": "2026-05-06", + "branch": null, + "base_branch": null, + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/check.jsonl b/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/check.jsonl new file mode 100644 index 0000000..01d7095 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/check.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/tasks/05-09-fix-divination-cost-hardcode-and-coin-animation/prd.md", "reason": "验收标准:积分动态显示、硬币翻转动画、硬币视觉效果"} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/implement.jsonl b/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/implement.jsonl new file mode 100644 index 0000000..6099ce3 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/implement.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/tasks/05-09-fix-divination-cost-hardcode-and-coin-animation/prd.md", "reason": "完整需求文档,包含硬编码位置、API 字段、修改方案"} diff --git a/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/prd.md b/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/prd.md new file mode 100644 index 0000000..b24981d --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/prd.md @@ -0,0 +1,79 @@ +# 修复起卦积分硬编码与硬币动画 + +## Goal + +1. 修复手动起卦和自动起卦页面的积分硬编码问题,使用后端返回的 `runCost` 和实际积分余额 +2. 添加硬币翻转动画效果 +3. 优化硬币选中状态的视觉效果 + +## What I already know + +**硬编码位置:** + +1. `ManualDivinationPage.tsx` 第 77、103、129 行: + - `'可用 120 积分 · 本次 20 积分'` + - `'Available 120 credits · This reading 20 credits'` + +2. `AutoDivinationPage.tsx` 第 40 行: + - `'可用 120 积分 · 本次 20 积分'` + +3. `StorePage.tsx` 第 91 行: + - fallback `runCost: 20` + +**API 已有字段:** +- `PointsBalance` 接口(`lib/api.ts` 第 120-126 行): + - `balance`: 总积分 + - `availableBalance`: 可用积分 + - `runCost`: 单次解卦消耗 + - `canRun`: 是否可运行 + +**Flutter 参考实现:** +- `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart` 第 358、374 行 +- 使用 `points.runCost` 和 `points.availableBalance` + +**硬币当前样式:** +- `ManualDivinationPage.tsx` 第 44 行: + - `ring-2 ring-violet-600 ring-offset-2 ring-offset-slate-50` + - 选中有边框和背景 + +## Requirements + +### R1: 积分显示使用后端数据 +- 手动起卦和自动起卦页面需要调用 `getPointsBalance()` 获取实际积分 +- 显示格式:`可用 {availableBalance} 积分 · 本次 {runCost} 积分` +- 英文:`Available {availableBalance} credits · This time {runCost} credits` + +### R2: 硬币翻转动画 +- 点击硬币时有翻转动画效果 +- 使用 CSS transform: rotateY(180deg) 实现 3D 翻转 + +### R3: 优化硬币选中视觉效果 +- 移除选中硬币的边框(ring)和背景偏移 +- 只显示硬币图片本身 +- 可以保留轻微阴影以区分层次 + +## Acceptance Criteria + +- [ ] 手动起卦页面显示实际可用积分和解卦消耗 +- [ ] 自动起卦页面显示实际可用积分和解卦消耗 +- [ ] 点击硬币有翻转动画 +- [ ] 选中的硬币无边框,只显示硬币图片 + +## Definition of Done + +- 功能测试通过 +- 与 Flutter 实现一致 + +## Technical Notes + +**涉及文件:** +- `web/src/components/ManualDivinationPage.tsx` +- `web/src/components/AutoDivinationPage.tsx` +- `web/src/components/StorePage.tsx`(可选,fallback 值保留) + +**修改方案:** + +1. 在组件中添加 `useEffect` 调用 `getPointsBalance()` +2. 将硬编码文本替换为动态值 +3. 添加 CSS 翻转动画类 +4. 移除 `ring-2 ring-violet-600 ring-offset-2` 样式 diff --git a/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/task.json b/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/task.json new file mode 100644 index 0000000..5d1fc2f --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-fix-divination-cost-hardcode-and-coin-animation/task.json @@ -0,0 +1,26 @@ +{ + "id": "fix-divination-cost-hardcode-and-coin-animation", + "name": "fix-divination-cost-hardcode-and-coin-animation", + "title": "fix-divination-cost-hardcode-and-coin-animation", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-09", + "completedAt": "2026-05-09", + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/check.jsonl b/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/check.jsonl new file mode 100644 index 0000000..7b01c0d --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/check.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/tasks/05-09-manual-divination-page-design-alignment/prd.md", "reason": "验收标准:六爻符号显示、线颜色逻辑、硬币区域简化"} diff --git a/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/implement.jsonl b/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/implement.jsonl new file mode 100644 index 0000000..49702e3 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/implement.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/tasks/05-09-manual-divination-page-design-alignment/prd.md", "reason": "完整需求文档,包含六爻类型符号对照和修改方案"} diff --git a/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/prd.md b/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/prd.md new file mode 100644 index 0000000..5ff7226 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/prd.md @@ -0,0 +1,93 @@ +# 手动起卦页面设计稿对齐 + +## Goal + +将 Web 端手动起卦页面(ManualDivinationPage)与设计稿对齐,修复六爻铜钱区域的显示问题。 + +## What I already know + +**六爻类型与符号对照(来自 Flutter 代码):** + +| 类型 | 中文名 | 线条 | 符号 | +|------|--------|------|------| +| youngYang | 少阳 | 实线 `—` | 无 | +| youngYin | 少阴 | 断线 `--` | 无 | +| oldYang | 老阳 | 实线 `—` | `○` | +| oldYin | 老阴 | 断线 `--` | `×` | +| undetermined | 未确定 | 灰色线 | 无 | + +**Flutter 参考代码:** +- `apps/lib/shared/widgets/divination/yao_glyph.dart` - 爻图形组件 +- `apps/lib/shared/widgets/divination/yao_line_row.dart` - 爻行组件 +- `apps/lib/shared/widgets/divination/divination_terms.dart` - 符号定义 + +**Web 当前问题:** + +1. **状态显示问题** + - 当前:用文字"老阴/少阳"等 + - 应该:用符号 `×`、`○` 或空白 + +2. **线的颜色问题** + - 当前:当前选中的爻(active),即使未确认,线也变成紫色 + - 应该:只有确认后的爻才显示紫色线,未确认时保持灰色 + +3. **硬币选择区域** + - 当前:显示"老阴/老阳/少阴/少阳"结果 + - 应该:不显示结果,只显示硬币和"字/花"标签 + +4. **待录入显示** + - 当前:显示"待录入"文字 + - 应该:显示灰色线,右侧无符号 + +## Requirements + +### R1: 爻状态符号显示 +- oldYang 显示符号 `○` +- oldYin 显示符号 `×` +- youngYang 和 youngYin 无符号 +- undetermined 无符号 + +### R2: 爻线颜色逻辑 +- undetermined:灰色线 +- 已确认的爻(youngYang/youngYin/oldYang/oldYin):紫色线 +- 当前选中但未确认的爻:线保持灰色(与 undetermined 相同) + +### R3: 移除硬币区域的结果显示 +- 删除"老阴/老阳/少阴/少阳"结果显示区域 +- 保留:三个硬币图片 + "字/花"标签 + 确认按钮 + +### R4: 移除"待录入"文字 +- 待录入状态:灰色线 + 右侧空白(无符号无文字) + +## Acceptance Criteria + +- [ ] oldYang 爻右侧显示 `○` 符号 +- [ ] oldYin 爻右侧显示 `×` 符号 +- [ ] youngYang/youngYin 爻右侧无符号 +- [ ] 当前选中但未确认的爻,线颜色为灰色 +- [ ] 已确认的爻,线颜色为紫色 +- [ ] 硬币选择区域不显示结果文字 +- [ ] 待录入状态无"待录入"文字,只显示灰色线 + +## Definition of Done + +- 功能测试通过 +- 与设计稿视觉一致 + +## Technical Notes + +**涉及文件:** +- `web/src/components/ManualDivinationPage.tsx` + +**修改方案:** + +1. `YaoGlyph` 组件: + - 添加 `confirmed` 参数区分已确认/未确认状态 + - 未确认时使用灰色,已确认时使用紫色 + +2. 爻行显示: + - 右侧状态改为符号显示(`×`、`○` 或空白) + - 移除文字"待录入"和"老阴/少阳"等 + +3. 硬币区域: + - 删除 `text.yaoTypeNames[currentYaoType]` 显示区域 diff --git a/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/task.json b/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/task.json new file mode 100644 index 0000000..cd6bf02 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-manual-divination-page-design-alignment/task.json @@ -0,0 +1,26 @@ +{ + "id": "manual-divination-page-design-alignment", + "name": "manual-divination-page-design-alignment", + "title": "manual-divination-page-design-alignment", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-09", + "completedAt": "2026-05-09", + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/check.jsonl b/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/implement.jsonl b/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/prd.md b/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/prd.md new file mode 100644 index 0000000..3bf446b --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/prd.md @@ -0,0 +1,108 @@ +# 手动起卦处理蒙版与解卦结果页面 + +## 背景 + +用户在手动起卦页面点击"开始解卦"后,需要一个加载等待蒙版,等待后端 agent 传输完成后进入结果页面。 + +## 已完成 + +### 1. 处理蒙版层 (DivinationProcessingOverlay) ✅ + +**组件文件**: `web/src/components/DivinationProcessingOverlay.tsx` + +**实现内容**: +- 全屏蒙版覆盖,高斯模糊背景 +- 暗色调遮罩层阻止点击穿透 +- Y轴翻转动画(以中间为对称轴翻转180度) +- 8张八卦卡片循环展示 +- 三种状态:preparing → deriving → done +- 后端 API 对接(SSE 事件流) + +**i18n 文本** (使用 Flutter 已有文本): +- `transitionPreparing`: 天机推演中 / 天機推演中 / Deriving... +- `transitionDeriving`: 正在解卦 / 正在解卦 / Analyzing... +- `transitionDone`: 解卦完成\n点击查看 / 解卦完成\n點擊查看 / Complete\nTap to view +- `processingCard*`: 八卦卡片标题和爻辞(简中/繁中/英文三语) + +**动画参数**: +- 翻牌间隔: 2000ms +- 翻转动画时长: 600ms +- 3D 透视: perspective: 1000px + +### 2. ManualDivinationPage 集成 ✅ + +**修改内容**: +- 添加 `showProcessing` 状态 +- 点击"开始解卦"显示蒙版 +- 传递起卦参数和六爻状态 +- 完成后导航到解卦结果页面 + +### 3. 后端 API 层 ✅ + +**新增函数**: `web/src/lib/api.ts` +- `enqueueDivinationRun()` - 提交起卦请求 +- `streamDivinationEvents()` - SSE 事件流 + +**新增类型**: +- `YaoType` - 爻类型 +- `DivinationParams` - 起卦参数 +- `DivinationResultData` - 解卦结果数据 +- `GanzhiData` - 干支信息 +- `YaoLineData` - 爻线数据 + +**API 路由**: `web/src/lib/api-routes.ts` +- `agent.runs` - POST /api/v1/agent/runs +- `agent.runEvents` - GET /api/v1/agent/runs/{threadId}/events + +### 4. 解卦结果页面 ✅ + +**组件文件**: `web/src/components/DivinationResultPage.tsx` + +**子组件**: +- `SignCard` - 签文图片和标题 +- `KeywordCard` - 关键词卡片 +- `AnalysisCard` - 分析卡片(结论、建议、解析) +- `FocusPointsCard` - 关注要点卡片 +- `WarningCard` - AI 警告提示 +- `InfoCard` - 基础信息卡片 +- `GanzhiCard` - 干支信息卡片(月建、日辰、月破、日冲、五行旺衰表、空亡表) +- `HexagramDetailCard` - 卦象详情卡片(本卦/变卦六爻详情) +- `FollowUpPanel` - 追问入口面板 + +**路由文件**: +- `web/src/pages/zh/divination/result.astro` +- `web/src/pages/zh_Hant/divination/result.astro` +- `web/src/pages/en/divination/result.astro` + +### 5. i18n 扩展 ✅ + +**修改文件**: `web/src/i18n/utils.ts` +- 添加 `result` 翻译类型定义 +- 添加简中、繁中、英文的解卦结果页面翻译 + +**修改文件**: `web/src/components/DashboardApp.tsx` +- 导入 `DivinationResultPage` 组件 +- 添加 `result` 翻译类型 +- 添加 `/divination/result` 路由 + +**修改文件**: `web/src/components/DashboardAppPage.astro` +- 传递 `result` 翻译到 DashboardApp + +## 技术约束 + +### 前端 +- React 19 + Tailwind CSS +- SSE 使用原生 fetch + ReadableStream +- 遵循 `web/src/i18n/utils.ts` 的 i18n 模式 + +### 后端契约 +- 参考 Flutter `apps/lib/features/divination/data/apis/divination_api.dart` +- 参考 Flutter `apps/lib/features/divination/data/models/divination_result.dart` + +## 验收标准 + +1. ✅ 点击"开始解卦"显示处理蒙版 +2. ✅ 翻牌动画流畅,卡片文本正确显示 +3. ✅ 后端 API 对接成功,正确处理 SSE 事件 +4. ✅ 解卦结果页面正确渲染后端返回数据 +5. ✅ 简中/繁中/英文三种语言切换正常 diff --git a/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/task.json b/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/task.json new file mode 100644 index 0000000..45240de --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-manual-divination-shuffle-animation/task.json @@ -0,0 +1,26 @@ +{ + "id": "manual-divination-shuffle-animation", + "name": "manual-divination-shuffle-animation", + "title": "manual-divination-shuffle-animation", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "claude", + "assignee": "claude", + "createdAt": "2026-05-09", + "completedAt": null, + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/check.jsonl b/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/check.jsonl new file mode 100644 index 0000000..5e11d9d --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/check.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/tasks/05-09-settings-page-interaction-optimization/prd.md", "reason": "验收标准:返回逻辑、保存等待机制、toast 提示行为"} diff --git a/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/implement.jsonl b/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/implement.jsonl new file mode 100644 index 0000000..ee8ad99 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/implement.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/tasks/05-09-settings-page-interaction-optimization/prd.md", "reason": "完整需求文档,包含返回逻辑和保存等待机制的详细说明"} diff --git a/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/prd.md b/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/prd.md new file mode 100644 index 0000000..d9ccad0 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/prd.md @@ -0,0 +1,94 @@ +# 优化设置页面交互:返回逻辑与保存等待机制 + +## Goal + +优化设置相关二级页面的交互体验: +1. 二级页面返回按钮应返回其隶属的一级页面,而非路由栈上一页 +2. 通用设置页面的开关切换应等待后端成功响应后再更新 UI,不使用乐观更新 + +## What I already know + +**现有实现问题:** + +### 返回逻辑 +- `GeneralSettingsPage.tsx` 第 145-149 行:使用 `navigate(-1)` 返回路由栈上一页 +- `FeedbackPage.tsx` 第 113-117 行:同样使用 `navigate(-1)` +- 这意味着如果用户从设置页进入通用设置,再进入语言选择弹窗,返回时会回到语言弹窗而非设置页 + +### 保存逻辑 +- `GeneralSettingsPage.tsx` 第 103-128 行:`handleToggleChange` 函数先调用 `setSettings(newSettings)` 乐观更新 UI,再调用 `saveSettings` +- 用户看不到保存过程,失败时也没有明确的错误反馈 + +**页面层级关系:** +- 一级页面:SettingsPage (`/settings`) +- 二级页面: + - GeneralSettingsPage (`/settings/general`) + - FeedbackPage (`/settings/feedback`) + +## Requirements + +### R1: 二级页面返回逻辑 +- 点击返回按钮时,导航到隶属的一级页面(`/${locale}/settings`),而非路由栈上一页 +- 涉及页面:GeneralSettingsPage、FeedbackPage + +### R2: 通用设置保存等待机制 +- 开关切换时显示 loading 状态 +- 等待后端成功响应后再更新 UI 状态 +- 保存失败时显示 toast 错误提示,开关状态保持不变 +- 保存成功时不显示 toast +- 涉及设置项:canSell(隐私设置)、allowNotifications(通知设置) + +### R3: 删除硬编码默认值 +- SettingsPage: 删除 displayName 默认值 `'User'` 和 email 默认值 `'user@example.com'` +- ProfileDetailPage: 删除 email 默认值 `'user@example.com'` +- AppShell 侧边栏: 显示真实头像和用户名,而非邮箱前缀 +- 邮箱从 auth token 获取(后端 profile API 不返回 email) + +### R4: 语言 URL 同步 +- 页面加载时检查 URL 语言与用户偏好是否一致 +- 不一致则重定向到用户偏好的语言 URL +- 防止用户手动修改 URL 导致语言不一致 + +## Acceptance Criteria + +- [ ] GeneralSettingsPage 返回按钮点击后导航到 `/settings` +- [ ] FeedbackPage 返回按钮点击后导航到 `/settings` +- [ ] 切换 canSell 开关时,开关显示 loading 状态,后端成功后才切换 +- [ ] 切换 allowNotifications 开关时,开关显示 loading 状态,后端成功后才切换 +- [ ] 保存失败时显示 toast 错误提示,开关状态回滚 +- [ ] SettingsPage 无硬编码用户名/邮箱默认值 +- [ ] ProfileDetailPage 无硬编码邮箱默认值 +- [ ] AppShell 侧边栏显示真实头像和用户名 +- [ ] URL 语言与用户偏好不一致时自动重定向 + +## Definition of Done + +- 功能测试通过 +- 代码 lint 无错误 + +## Out of Scope + +- 语言选择的保存逻辑(已有页面刷新机制,不在本次修改范围) +- 其他页面的返回逻辑 +- 后端 API 修改 + +## Technical Notes + +**涉及文件:** +- `web/src/components/GeneralSettingsPage.tsx` +- `web/src/components/FeedbackPage.tsx` +- `web/src/components/SettingsPage.tsx` +- `web/src/components/ProfileDetailPage.tsx` +- `web/src/components/AppShell.tsx` + +**修改方案:** + +### 返回逻辑 +将 `navigate(-1)` 改为 `navigate('/${locale}/settings')` + +### 保存等待机制 +1. 移除乐观更新 `setSettings(newSettings)` +2. 保存开始时设置 `saving: true`(已有) +3. 保存成功后调用 `setSettings(newSettings)` +4. 保存失败时显示 toast 错误提示 +5. 需要 props 传入 toast 相关文案(saveSuccess、saveFailed) diff --git a/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/task.json b/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/task.json new file mode 100644 index 0000000..ade65e1 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-09-settings-page-interaction-optimization/task.json @@ -0,0 +1,26 @@ +{ + "id": "settings-page-interaction-optimization", + "name": "settings-page-interaction-optimization", + "title": "settings-page-interaction-optimization", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-09", + "completedAt": "2026-05-09", + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-10-05-10-auto-divination-page-rewrite/check.jsonl b/.trellis/tasks/archive/2026-05/05-10-05-10-auto-divination-page-rewrite/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-05-10-auto-divination-page-rewrite/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-10-05-10-auto-divination-page-rewrite/implement.jsonl b/.trellis/tasks/archive/2026-05/05-10-05-10-auto-divination-page-rewrite/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-05-10-auto-divination-page-rewrite/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-10-05-10-auto-divination-page-rewrite/task.json b/.trellis/tasks/archive/2026-05/05-10-05-10-auto-divination-page-rewrite/task.json new file mode 100644 index 0000000..2f9be17 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-05-10-auto-divination-page-rewrite/task.json @@ -0,0 +1,26 @@ +{ + "id": "05-10-auto-divination-page-rewrite", + "name": "05-10-auto-divination-page-rewrite", + "title": "05-10-auto-divination-page-rewrite", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-10", + "completedAt": null, + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/check.jsonl b/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/check.jsonl new file mode 100644 index 0000000..1c78216 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/check.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/spec/web/index.md", "reason": "Verify mobile overlay behavior and web layout constraints."} diff --git a/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/implement.jsonl b/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/implement.jsonl new file mode 100644 index 0000000..a524957 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/implement.jsonl @@ -0,0 +1 @@ +{"file": ".trellis/spec/web/index.md", "reason": "Web authenticated app layout, responsive behavior, and mobile guided overlay rules."} diff --git a/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/prd.md b/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/prd.md new file mode 100644 index 0000000..5041cb4 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/prd.md @@ -0,0 +1,53 @@ +# Fix mobile divination tutorial overlay + +## Goal + +Fix the mobile web tutorial overlay on manual and auto divination pages so spotlight targets, tooltip placement, arrow direction, and overlay shadow match the intended mobile UX. + +## What I already know + +* User reports mobile tutorial has an overly large dark/shadowed area. +* Step 3 should highlight the six yao area, not only the coin area. If the full coin + yao area is too large, highlight only the six yao rows and exclude the coins. +* Step 4 tooltip arrow should point down toward the spotlighted "start divination" button. +* Step 4 tooltip currently overlaps the spotlight region. +* Affected files are `web/src/components/ManualDivinationPage.tsx` and `web/src/components/AutoDivinationPage.tsx`. + +## Assumptions + +* Keep desktop behavior visually equivalent unless the existing code already uses a different desktop target. +* Mobile behavior is the priority for this task. +* No backend or protocol changes are needed. + +## Requirements + +* Mobile step 3 highlights the six yao rows/panel area and excludes the coin selector. +* Mobile step 4 places the tooltip above the submit/start button with enough gap to avoid overlap. +* Mobile step 4 arrow points downward toward the spotlighted button. +* Remove the excessive/double-shadow appearance caused by overlapping dimming strategies. +* Apply the same behavior to manual and auto divination tutorial overlays. + +## Acceptance Criteria + +* [x] In mobile viewport, manual step 3 spotlight excludes the coin selector. +* [x] In mobile viewport, auto step 3 spotlight excludes the coin selector. +* [x] In mobile viewport, manual step 4 tooltip does not overlap the highlighted submit button and arrow points down. +* [x] In mobile viewport, auto step 4 tooltip does not overlap the highlighted submit button and arrow points down. +* [x] Overlay dimming is visually consistent without large unintended dark blocks. + +## Definition of Done + +* Local browser verification at a phone-sized viewport. +* `git diff --check` passes. +* Build/typecheck status documented if blocked by existing project configuration. + +## Out of Scope + +* Redesigning the tutorial copy. +* Changing tutorial persistence/profile settings behavior. +* Backend auth/session changes. + +## Technical Notes + +* Web spec applies: `.trellis/spec/web/index.md`. +* Existing mobile threshold uses `window.innerWidth < 1280`. +* App shell scroll container is the main vertical scrolling region. diff --git a/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/task.json b/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/task.json new file mode 100644 index 0000000..dbddaa0 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-fix-mobile-divination-tutorial-overlay/task.json @@ -0,0 +1,26 @@ +{ + "id": "fix-mobile-divination-tutorial-overlay", + "name": "fix-mobile-divination-tutorial-overlay", + "title": "Fix mobile divination tutorial overlay", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-05-10", + "completedAt": "2026-05-10", + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/check.jsonl b/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/implement.jsonl b/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/prd.md b/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/prd.md new file mode 100644 index 0000000..dfc4bf5 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/prd.md @@ -0,0 +1,76 @@ +# 历史解卦列表页面实现 + +## 目标 +实现完整的历史解卦列表页面功能,包括: +1. Dashboard 卡片点击跳转到解卦结果页 +2. 重构 HistoryListPage 使用真实 API +3. 快捷筛选和搜索功能 + +## 设计稿参考 +- `HomeHistoryListPage` (EH0fv) - 历史列表页面 +- `HistoryResultDetailPage` - 历史详情页面 + +## 实现步骤 + +### Step 1: Dashboard 卡片点击跳转 +**文件**: `web/src/components/Dashboard.tsx` + +- [ ] 将历史卡片包装为可点击链接 +- [ ] 跳转到 `/{locale}/history/{threadId}` +- [ ] 确保 `HistoryItem` 有正确的 `threadId` 字段 + +### Step 2: 重构 HistoryListPage +**文件**: `web/src/components/HistoryListPage.tsx` + +- [ ] 删除 mock 数据 +- [ ] 使用 `getAgentHistory()` API 获取真实数据 +- [ ] 实现页面布局: + - Header: 返回按钮 + 标题 + 搜索框 + 筛选按钮 + - Stats: 总记录数 + 可追问数 + 最近解卦时间 + - Main: 左侧列表 + 右侧快捷筛选面板 +- [ ] 实现列表项点击跳转到详情页 + +### Step 3: 快捷筛选功能 +**文件**: `web/src/components/HistoryListPage.tsx` + +- [ ] 全部 / 事业·学业 / 情感·财富 等分类筛选 +- [ ] 前端过滤实现 +- [ ] 显示各分类数量 + +### Step 4: 搜索功能 +**文件**: `web/src/components/HistoryListPage.tsx` + +- [ ] 搜索问题或卦名 +- [ ] 前端过滤实现 +- [ ] 实时搜索响应 + +### Step 5: 历史详情页检查 +**文件**: `web/src/components/HistoryResultPage.tsx` + +- [ ] 确保 URL 参数 `threadId` 正确接收 +- [ ] 根据 `threadId` 显示对应的解卦结果 + +## 数据结构 + +### HistoryItem +```typescript +interface HistoryItem { + id: string; + threadId: string; + question: string; + category: string; + hexagram_name: string; + rating: string; + created_at: string; + can_follow_up: boolean; +} +``` + +## API 使用 +- `getAgentHistory()` - 获取历史记录列表 +- `mapHistoryMessagesToItems()` - 转换数据格式 + +## 注意事项 +- 响应式设计支持 +- 国际化支持 (zh/en) +- 参照设计稿调整样式 diff --git a/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/task.json b/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/task.json new file mode 100644 index 0000000..3c44271 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-10-web-history-list-page/task.json @@ -0,0 +1,26 @@ +{ + "id": "web-history-list-page", + "name": "web-history-list-page", + "title": "实现历史解卦列表页面", + "description": "实现历史解卦列表页面,包括:1. Dashboard卡片点击跳转 2. 重构HistoryListPage使用真实API 3. 快捷筛选和搜索功能", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "claude", + "assignee": "claude", + "createdAt": "2026-05-10", + "completedAt": "2026-05-10", + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workflow.md b/.trellis/workflow.md index fd01e35..f45c6b1 100644 --- a/.trellis/workflow.md +++ b/.trellis/workflow.md @@ -442,3 +442,33 @@ Following this workflow ensures: - [OK] Transparent team collaboration **Core Philosophy**: Read before write, follow standards, record promptly, capture learnings + +[workflow-state:no_task] +No active task. **A Direct answer** — pure Q&A / explanation / lookup / chat; no file writes + one-line answer + repo reads ≤ 2 files → AI judges, no override needed. +**B Create a task** — any implementation / code change / build / refactor work. Entry sequence: (1) `python3 ./.trellis/scripts/task.py create "<title>"` to create the task (status=planning, breadcrumb switches to [workflow-state:planning] for brainstorm + jsonl phase guidance) → (2) load `trellis-brainstorm` skill to discuss requirements with the user and iterate on prd.md → (3) once prd is done and jsonl is curated, run `task.py start <task-dir>` to enter [workflow-state:in_progress] for the implementation skeleton. For research-heavy work, dispatch `trellis-research` sub-agents — main agent must NOT do 3+ inline WebFetch / WebSearch / `gh api` calls. **"It looks small" is NOT grounds for downgrading B to A or C**. +**C Inline change** (per-turn only, escape hatch for B) — the user's CURRENT message MUST contain one of: "skip trellis" / "no task" / "just do it" / "don't create a task" / "跳过 trellis" / "别走流程" / "小修一下" / "直接改" / "先别建任务" → briefly acknowledge ("ok, skipping trellis flow this turn"), then inline. **Without seeing one of these phrases you must NOT inline on your own**; do not invent an override the user never said. +[/workflow-state:no_task] + +[workflow-state:planning] +Load the `trellis-brainstorm` skill and iterate on prd.md with the user. +Phase 1.3 (required, once): before `task.py start`, you MUST curate `implement.jsonl` and `check.jsonl` — list the spec / research files sub-agents need so they get the right context injected. You may skip only if the jsonl already has agent-curated entries (the seed `_example` row alone doesn't count). +Then run `task.py start <task-dir>` to flip status to in_progress. +Research output **must** land in `{task_dir}/research/*.md`, written by `trellis-research` sub-agents. The main agent should not inline WebFetch / WebSearch — the PRD only links to research files. +[/workflow-state:planning] + +[workflow-state:in_progress] +**Flow**: trellis-implement → trellis-check → trellis-update-spec → commit (Phase 3.4) → `/trellis:finish-work`. +**Default (no override)**: dispatch the `trellis-implement` / `trellis-check` sub-agents — the main agent does NOT edit code by default. Phase 3.4 commit (required, once): after trellis-update-spec, or whenever implementation is verifiably complete, the main agent **drives the commit** — state the commit plan in user-facing text, then run `git commit` — BEFORE suggesting `/trellis:finish-work`. `/finish-work` refuses to run on a dirty working tree (paths outside `.trellis/workspace/` and `.trellis/tasks/`). +**Sub-agent dispatch protocol (class-2 platforms: codex / copilot / gemini / qoder)**: When you spawn `trellis-implement` / `trellis-check` / `trellis-research`, your dispatch prompt **MUST** start with one line: `Active task: <task path from \`task.py current\`>`. No exceptions. These platforms have no hook to inject task context into sub-agents, so the sub-agent depends on this line; without it the sub-agent cannot find the task and will block to ask the user. +**Inline override** (per-turn only, escape hatch for sub-agent dispatch): the user's CURRENT message MUST explicitly contain one of: "do it inline" / "no sub-agent" / "你直接改" / "别派 sub-agent" / "main session 写就行" / "不用 sub-agent". **Without seeing one of these phrases you must NOT inline on your own**; do not invent an override the user never said. +[/workflow-state:in_progress] + +[workflow-state:completed] +Code committed via Phase 3.4; run `/trellis:finish-work` to wrap up (archive the task + record session). +If you reach this state with uncommitted code, return to Phase 3.4 first — `/finish-work` refuses to run on a dirty working tree. +`task.py archive` deletes any runtime session files that still point at the archived task. +[/workflow-state:completed] + +[workflow-state:my-status] +your per-turn prompt text +[/workflow-state:my-status] diff --git a/.trellis/workspace/opencode/index.md b/.trellis/workspace/opencode/index.md deleted file mode 100644 index c4a1ce0..0000000 --- a/.trellis/workspace/opencode/index.md +++ /dev/null @@ -1,41 +0,0 @@ -# Workspace Index - opencode - -> Journal tracking for AI development sessions. - ---- - -## Current Status - -<!-- @@@auto:current-status --> -- **Active File**: `journal-1.md` -- **Total Sessions**: 1 -- **Last Active**: 2026-04-28 -<!-- @@@/auto:current-status --> - ---- - -## Active Documents - -<!-- @@@auto:active-documents --> -| File | Lines | Status | -|------|-------|--------| -| `journal-1.md` | ~61 | Active | -<!-- @@@/auto:active-documents --> - ---- - -## Session History - -<!-- @@@auto:session-history --> -| # | Date | Title | Commits | -|---|------|-------|---------| -| 1 | 2026-04-28 | 任务归档与代码模块化提交 | `dd48d1d`, `85023a6`, `295dbc0`, `a940f2e`, `b9617ae`, `940c67e`, `a83001d`, `14752cd`, `c7a75a6`, `8de0331`, `9bc24fa` | -<!-- @@@/auto:session-history --> - ---- - -## Notes - -- Sessions are appended to journal files -- New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/opencode/journal-1.md b/.trellis/workspace/opencode/journal-1.md deleted file mode 100644 index 49881b5..0000000 --- a/.trellis/workspace/opencode/journal-1.md +++ /dev/null @@ -1,61 +0,0 @@ -# Journal - opencode (Part 1) - -> AI development session journal -> Started: 2026-04-27 - ---- - - - -## Session 1: 任务归档与代码模块化提交 - -**Date**: 2026-04-28 -**Task**: 任务归档与代码模块化提交 - -### Summary - -(Add summary) - -### Main Changes - -| 任务 | 说明 | -|------|------| -| 归档 | 04-27-feat-ios-apple-pay, 04-28-feat-locale-timezone-bootstrap, 04-28-feat-points-ledger, 04-28-refactor-unify-language | -| feat(locale) | App 启动时语言和时区自动设置 | -| feat(points) | 积分流水列表功能 | -| refactor(settings) | 统一语言设置,合并 interface_language 和 ai_language | -| feat(notification) | 通知标题和正文支持多语言 | -| feat(payment) | 优化套餐配置和支付服务 | -| refactor(divination) | 优化占卜界面和组件 | -| chore | 更新主题、反馈页面和测试用例 | - -**提交策略**: 按功能模块分类提交,每个任务/功能独立 commit - - -### Git Commits - -| Hash | Message | -|------|---------| -| `dd48d1d` | (see git log) | -| `85023a6` | (see git log) | -| `295dbc0` | (see git log) | -| `a940f2e` | (see git log) | -| `b9617ae` | (see git log) | -| `940c67e` | (see git log) | -| `a83001d` | (see git log) | -| `14752cd` | (see git log) | -| `c7a75a6` | (see git log) | -| `8de0331` | (see git log) | -| `9bc24fa` | (see git log) | - -### Testing - -- [OK] (Add test results) - -### Status - -[OK] **Completed** - -### Next Steps - -- None - task complete diff --git a/.trellis/worktree.yaml b/.trellis/worktree.yaml deleted file mode 100644 index 2648560..0000000 --- a/.trellis/worktree.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# Worktree Configuration for Multi-Agent Pipeline -# Used for worktree initialization in multi-agent workflows -# -# All paths are relative to project root - -#------------------------------------------------------------------------------- -# Paths -#------------------------------------------------------------------------------- - -# Worktree storage directory (relative to project root) -worktree_dir: ../trellis-worktrees - -#------------------------------------------------------------------------------- -# Files to Copy -#------------------------------------------------------------------------------- - -# Files to copy to each worktree (each worktree needs independent copy) -# These files contain sensitive info or need worktree-independent config -copy: - # Environment variables (uncomment and customize as needed) - # - .env - # - .env.local - # Workflow config - - .trellis/.developer - -#------------------------------------------------------------------------------- -# Post-Create Hooks -#------------------------------------------------------------------------------- - -# Commands to run after creating worktree -# Executed in worktree directory, in order, abort on failure -post_create: - # Install dependencies (uncomment based on your package manager) - # - npm install - # - pnpm install --frozen-lockfile - # - yarn install --frozen-lockfile - -#------------------------------------------------------------------------------- -# Check Agent Verification (Ralph Loop) -#------------------------------------------------------------------------------- - -# Commands to verify code quality before allowing check agent to finish -# If configured, Ralph Loop will run these commands - all must pass to allow completion -# If not configured or empty, trusts agent's completion markers -verify: - # - pnpm lint - # - pnpm typecheck diff --git a/AGENTS.md b/AGENTS.md index 6317047..2a879fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,25 +50,40 @@ Do not place backend/frontend implementation details here. When viewing data in the database, use `supabase mcp` tools (`supabase_execute_sql`, `supabase_list_tables`, etc.) instead of direct queries or other methods. -## Image Handling - -When reading images, check whether the model has native multimodal capability first. If it does, use `Read` tool to read images directly. If it does not, fall back to `understand_image` tool. Only use `Read` tool for non-image files. +## Test +- test_user_email: test@example.com +- test_code: 123456 +- login directly, do not send verfiy code <!-- TRELLIS:START --> # Trellis Instructions These instructions are for AI assistants working in this project. -Use the `/trellis:start` command when starting a new session to: -- Initialize your developer identity -- Understand current project context -- Read relevant guidelines +This project is managed by Trellis. The working knowledge you need lives under `.trellis/`: -Use `@/.trellis/` to learn: -- Development workflow (`workflow.md`) -- Project structure guidelines (`spec/`) -- Developer workspace (`workspace/`) +- `.trellis/workflow.md` — development phases, when to create tasks, skill routing +- `.trellis/spec/` — package- and layer-scoped coding guidelines (read before writing code in a given layer) +- `.trellis/workspace/` — per-developer journals and session traces +- `.trellis/tasks/` — active and archived tasks (PRDs, research, jsonl context) -Keep this managed block so 'trellis update' can refresh the instructions. +If a Trellis command is available on your platform (e.g. `/trellis:finish-work`, `/trellis:continue`), prefer it over manual steps. Not every platform exposes every command. + +If you're using Codex or another agent-capable tool, additional project-scoped helpers may live in: +- `.agents/skills/` — reusable Trellis skills +- `.codex/agents/` — optional custom subagents + +## Subagents + +- ALWAYS wait for every spawned subagent to reach a terminal status before yielding, acting on partial results, or spawning followups. + - On Codex, this means calling the `wait` tool with the subagent's thread id (requires `multi_agent_v2`). Do NOT infer completion from elapsed time. + - On Claude Code / OpenCode, this means awaiting the Task/agent tool result before continuing. +- NEVER cancel or re-spawn a subagent that hasn't finished. If a subagent appears stuck, raise the wait timeout (Codex default 30s, max 1h) before judging it broken. +- Spawn subagents automatically when: + - Parallelizable work (e.g., install + verify, npm test + typecheck, multiple tasks from plan) + - Long-running or blocking tasks where a worker can run independently + - Isolation for risky changes or checks + +Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `trellis update`. <!-- TRELLIS:END --> diff --git a/apps/ios/EryaoProducts.storekit b/apps/ios/EryaoProducts.storekit index 1ad1326..94dc4b8 100644 --- a/apps/ios/EryaoProducts.storekit +++ b/apps/ios/EryaoProducts.storekit @@ -70,8 +70,8 @@ } ], "settings" : { - "_applicationInternalID" : "6738123456", - "_developerTeamID" : "YOUR_TEAM_ID", + "_applicationInternalID" : "6764005221", + "_developerTeamID" : "T7H95RR33V", "_failTransactionsEnabled" : false, "_lastSynchronizedDate" : 756460800, "_locale" : "zh_CN", diff --git a/apps/ios/ExportOptions.plist b/apps/ios/ExportOptions.plist new file mode 100644 index 0000000..3caf2d3 --- /dev/null +++ b/apps/ios/ExportOptions.plist @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>method</key> + <string>app-store</string> + <key>teamID</key> + <string>T7H95RR33V</string> + <key>uploadSymbols</key> + <true/> + <key>signingStyle</key> + <string>automatic</string> +</dict> +</plist> diff --git a/apps/ios/Runner.xcodeproj/project.pbxproj b/apps/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..df9e3ca --- /dev/null +++ b/apps/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,738 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 0D924EE8E30858F5F8BF082B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1237CDFED87F1E97A868B7FD /* Pods_Runner.framework */; }; + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + BBFB026688CD1F9611027C45 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CCA8D3FFD5091DD8A01FFD1F /* Pods_RunnerTests.framework */; }; + AABB00020000000000000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AABB00010000000000000001 /* PrivacyInfo.xcprivacy */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0ECE86709FC3B6471EB853BE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; + 1237CDFED87F1E97A868B7FD /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; + 3BC91D5D8CD9B2AB67412284 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; + 3D5F7A8C3CBF8DCEB691FBC2 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; }; + 633273330F94AC4316172EAF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; + 6BE35C74B003349F81EBC873 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; + 86E9C2DB20CFC2CA4F1C6274 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; + AABB00010000000000000001 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; + CCA8D3FFD5091DD8A01FFD1F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33FCC457CCD9FAD0D4A23012 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BBFB026688CD1F9611027C45 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0D924EE8E30858F5F8BF082B /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 083024BDCE68881FE76ABDFA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1237CDFED87F1E97A868B7FD /* Pods_Runner.framework */, + CCA8D3FFD5091DD8A01FFD1F /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = "<group>"; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = "<group>"; + }; + 40FDC0D7693F7D8D053FDFE5 /* Pods */ = { + isa = PBXGroup; + children = ( + 6BE35C74B003349F81EBC873 /* Pods-Runner.debug.xcconfig */, + 633273330F94AC4316172EAF /* Pods-Runner.release.xcconfig */, + 0ECE86709FC3B6471EB853BE /* Pods-Runner.profile.xcconfig */, + 3D5F7A8C3CBF8DCEB691FBC2 /* Pods-RunnerTests.debug.xcconfig */, + 3BC91D5D8CD9B2AB67412284 /* Pods-RunnerTests.release.xcconfig */, + 86E9C2DB20CFC2CA4F1C6274 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = "<group>"; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = "<group>"; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 40FDC0D7693F7D8D053FDFE5 /* Pods */, + 083024BDCE68881FE76ABDFA /* Frameworks */, + ); + sourceTree = "<group>"; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = "<group>"; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + AABB00010000000000000001 /* PrivacyInfo.xcprivacy */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = "<group>"; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + B8F3E0819435557CF0903DC8 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 33FCC457CCD9FAD0D4A23012 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + DB04B3789658516D7D3C30AB /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 43D8D644089E4D1C631BA98B /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + AABB00020000000000000001 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 43D8D644089E4D1C631BA98B /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + B8F3E0819435557CF0903DC8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + DB04B3789658516D7D3C30AB /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = "<group>"; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = "<group>"; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = T7H95RR33V; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.meeyao.qianwen; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3D5F7A8C3CBF8DCEB691FBC2 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.meeyao.qianwen.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3BC91D5D8CD9B2AB67412284 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.meeyao.qianwen.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 86E9C2DB20CFC2CA4F1C6274 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.meeyao.qianwen.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = T7H95RR33V; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.meeyao.qianwen; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = T7H95RR33V; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.meeyao.qianwen; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/apps/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/apps/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1510" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "97C146ED1CF9000F007C117D" + BuildableName = "Runner.app" + BlueprintName = "Runner" + ReferencedContainer = "container:Runner.xcodeproj"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" + shouldUseLaunchSchemeArgsEnv = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "97C146ED1CF9000F007C117D" + BuildableName = "Runner.app" + BlueprintName = "Runner" + ReferencedContainer = "container:Runner.xcodeproj"> + </BuildableReference> + </MacroExpansion> + <Testables> + <TestableReference + skipped = "NO" + parallelizable = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "331C8080294A63A400263BE5" + BuildableName = "RunnerTests.xctest" + BlueprintName = "RunnerTests" + ReferencedContainer = "container:Runner.xcodeproj"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + enableGPUValidationMode = "1" + allowLocationSimulation = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "97C146ED1CF9000F007C117D" + BuildableName = "Runner.app" + BlueprintName = "Runner" + ReferencedContainer = "container:Runner.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </LaunchAction> + <ProfileAction + buildConfiguration = "Profile" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "97C146ED1CF9000F007C117D" + BuildableName = "Runner.app" + BlueprintName = "Runner" + ReferencedContainer = "container:Runner.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index 9da19ea..553595e 100644 Binary files a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index 9da19ea..71545d8 100644 Binary files a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index 9da19ea..0dc4005 100644 Binary files a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c..e49c404 100644 --- a/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -16,7 +16,7 @@ <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> - <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> + <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleAspectFit" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> </imageView> </subviews> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist index c79086c..5b9c9ac 100644 --- a/apps/ios/Runner/Info.plist +++ b/apps/ios/Runner/Info.plist @@ -4,6 +4,8 @@ <dict> <key>CADisableMinimumFrameDurationOnPhone</key> <true/> + <key>ITSAppUsesNonExemptEncryption</key> + <false/> <key>CFBundleDevelopmentRegion</key> <string>$(DEVELOPMENT_LANGUAGE)</string> <key>CFBundleDisplayName</key> @@ -32,12 +34,6 @@ <string>需要将头像处理结果保存到您的相册</string> <key>NSMicrophoneUsageDescription</key> <string>需要麦克风权限用于语音追问</string> - <key>NSLocalNetworkUsageDescription</key> - <string>用于开发调试时连接本地调试服务。</string> - <key>NSBonjourServices</key> - <array> - <string>_dartobservatory._tcp</string> - </array> <key>UIApplicationSceneManifest</key> <dict> <key>UIApplicationSupportsMultipleScenes</key> diff --git a/apps/ios/Runner/PrivacyInfo.xcprivacy b/apps/ios/Runner/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..9fcf42e --- /dev/null +++ b/apps/ios/Runner/PrivacyInfo.xcprivacy @@ -0,0 +1,84 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>NSPrivacyTracking</key> + <false/> + <key>NSPrivacyTrackingDomains</key> + <array/> + <key>NSPrivacyCollectedDataTypes</key> + <array> + <dict> + <key>NSPrivacyCollectedDataType</key> + <string>NSPrivacyCollectedDataTypePhotosAndVideos</string> + <key>NSPrivacyCollectedDataTypeLinked</key> + <false/> + <key>NSPrivacyCollectedDataTypeTracking</key> + <false/> + <key>NSPrivacyCollectedDataTypePurposes</key> + <array> + <string>NSPrivacyCollectedDataTypePurposeUserCustomization</string> + </array> + </dict> + <dict> + <key>NSPrivacyCollectedDataType</key> + <string>NSPrivacyCollectedDataTypeVoiceRecordings</string> + <key>NSPrivacyCollectedDataTypeLinked</key> + <false/> + <key>NSPrivacyCollectedDataTypeTracking</key> + <false/> + <key>NSPrivacyCollectedDataTypePurposes</key> + <array> + <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string> + </array> + </dict> + <dict> + <key>NSPrivacyCollectedDataType</key> + <string>NSPrivacyCollectedDataTypePurchases</string> + <key>NSPrivacyCollectedDataTypeLinked</key> + <false/> + <key>NSPrivacyCollectedDataTypeTracking</key> + <false/> + <key>NSPrivacyCollectedDataTypePurposes</key> + <array> + <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string> + </array> + </dict> + </array> + <key>NSPrivacyAccessedAPITypes</key> + <array> + <dict> + <key>NSPrivacyAccessedAPIType</key> + <string>NSPrivacyAccessedAPICategoryFileTimestamp</string> + <key>NSPrivacyAccessedAPITypeReasons</key> + <array> + <string>C617.1</string> + </array> + </dict> + <dict> + <key>NSPrivacyAccessedAPIType</key> + <string>NSPrivacyAccessedAPICategorySystemBootTime</string> + <key>NSPrivacyAccessedAPITypeReasons</key> + <array> + <string>35F9.1</string> + </array> + </dict> + <dict> + <key>NSPrivacyAccessedAPIType</key> + <string>NSPrivacyAccessedAPICategoryDiskSpace</string> + <key>NSPrivacyAccessedAPITypeReasons</key> + <array> + <string>E174.1</string> + </array> + </dict> + <dict> + <key>NSPrivacyAccessedAPIType</key> + <string>NSPrivacyAccessedAPICategoryUserDefaults</string> + <key>NSPrivacyAccessedAPITypeReasons</key> + <array> + <string>CA92.1</string> + </array> + </dict> + </array> +</dict> +</plist> diff --git a/apps/lib/features/auth/presentation/screens/login_screen.dart b/apps/lib/features/auth/presentation/screens/login_screen.dart index 629ad8b..1a7f3e5 100644 --- a/apps/lib/features/auth/presentation/screens/login_screen.dart +++ b/apps/lib/features/auth/presentation/screens/login_screen.dart @@ -11,8 +11,6 @@ import '../../../settings/presentation/screens/legal_document_screen.dart'; import '../../../settings/presentation/utils/legal_document_assets.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_modal_dialog.dart'; -import '../../../../shared/widgets/gua_icon.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; @@ -187,26 +185,6 @@ class _LoginScreenState extends State<LoginScreen> { ); } - void _showPolicyDialog(String title, String content) { - showDialog<void>( - context: context, - builder: (dialogContext) { - return AppModalDialog( - title: title, - message: content, - iconWidget: const GuaIcon(), - actions: [ - AppModalDialogAction( - label: AppLocalizations.of(dialogContext)!.dialogConfirm, - primary: true, - onPressed: () => Navigator.of(dialogContext).pop(), - ), - ], - ); - }, - ); - } - Future<void> _openLegalDocument(LegalDocumentType type) async { final l10n = AppLocalizations.of(context)!; await Navigator.of(context).push<void>( @@ -410,7 +388,7 @@ class _LoginScreenState extends State<LoginScreen> { ), const SizedBox(height: AppSpacing.md), Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Checkbox( value: _agreementChecked, @@ -421,11 +399,7 @@ class _LoginScreenState extends State<LoginScreen> { }, ), Expanded( - child: Padding( - padding: const EdgeInsets.only( - top: AppSpacing.sm, - ), - child: RichText( + child: RichText( text: TextSpan( style: Theme.of(context) .textTheme @@ -447,9 +421,7 @@ class _LoginScreenState extends State<LoginScreen> { .privacyPolicy, ), ), - TextSpan( - text: l10n.agreementSeparator, - ), + TextSpan(text: l10n.agreementAnd), TextSpan( text: l10n.termsOfService, style: TextStyle( @@ -464,27 +436,12 @@ class _LoginScreenState extends State<LoginScreen> { .termsOfService, ), ), - TextSpan(text: l10n.agreementAnd), - TextSpan( - text: l10n.disclaimer, - style: TextStyle( - color: colors.primary, - decoration: - TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => _showPolicyDialog( - l10n.disclaimer, - l10n.disclaimerContent, - ), - ), ], ), ), ), - ), - ], - ), + ], + ), ], ), ), diff --git a/backend/alembic/versions/20260511_0001_creem_transactions.py b/backend/alembic/versions/20260511_0001_creem_transactions.py new file mode 100644 index 0000000..cd08344 --- /dev/null +++ b/backend/alembic/versions/20260511_0001_creem_transactions.py @@ -0,0 +1,68 @@ +"""Create creem_transactions table for CREEM payment integration. + +Revision ID: 20260511_0001 +Revises: 20260428_0004 +Create Date: 2026-05-11 00:01:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260511_0001" +down_revision: Union[str, Sequence[str], None] = "20260428_0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "creem_transactions", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("product_code", sa.String(length=32), nullable=False), + sa.Column("creem_product_id", sa.String(length=128), nullable=False), + sa.Column("checkout_id", sa.String(length=128), nullable=False), + sa.Column("order_id", sa.String(length=128), nullable=True), + sa.Column("customer_id", sa.String(length=128), nullable=True), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("credits", sa.BigInteger(), nullable=False), + sa.Column("amount_cents", sa.BigInteger(), nullable=False), + sa.Column("currency", sa.String(length=8), nullable=False), + sa.Column("creem_payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("ledger_event_id", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("status in ('pending', 'completed', 'failed', 'refunded')", name="ck_creem_transactions_status"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("checkout_id", name="uq_creem_transactions_checkout_id"), + ) + op.create_index("ix_creem_transactions_user_created_at", "creem_transactions", ["user_id", sa.text("created_at DESC")]) + op.create_index("ix_creem_transactions_status_updated_at", "creem_transactions", ["status", sa.text("updated_at DESC")]) + _enable_service_only_rls("creem_transactions") + + +def downgrade() -> None: + _drop_service_only_rls("creem_transactions") + op.drop_table("creem_transactions") + + +def _enable_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") + op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + + +def _drop_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 0566acb..6d7c2c4 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -232,6 +232,13 @@ class AppleIapSettings(BaseModel): server_api_private_key: SecretStr | None = None +class CreemSettings(BaseModel): + api_key: SecretStr | None = None + webhook_secret: SecretStr | None = None + base_url: str = "https://test-api.creem.io" + success_url: str = "" + + def _resolve_env_files() -> list[str]: """Resolve env files in order: .env.local overrides .env""" current = Path(__file__).resolve() @@ -280,6 +287,7 @@ class Settings(BaseSettings): agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings) points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings) apple_iap: AppleIapSettings = Field(default_factory=AppleIapSettings) + creem: CreemSettings = Field(default_factory=CreemSettings) feedback_report: FeedbackReportSettings = Field( default_factory=FeedbackReportSettings ) diff --git a/backend/src/core/config/static/packages/mapping.yaml b/backend/src/core/config/static/packages/mapping.yaml index 8faa7f6..80dadfc 100644 --- a/backend/src/core/config/static/packages/mapping.yaml +++ b/backend/src/core/config/static/packages/mapping.yaml @@ -1,24 +1,28 @@ product_mappings: new_user_pack: app_store_product_id: com.meeyao.qianwen.new_user_pack + creem_product_id: prod_2x9LzVlR3ot1HLgbIZALPd credits: 60 type: starter sort_order: 0 enabled: true starter_pack: app_store_product_id: com.meeyao.qianwen.starter_pack + creem_product_id: prod_697ay0pXFXrBYEVC7HS0MR credits: 100 type: regular sort_order: 10 enabled: true popular_pack: app_store_product_id: com.meeyao.qianwen.popular_pack + creem_product_id: prod_5ivxlPnZWN6dIhnOxctThy credits: 210 type: regular sort_order: 20 enabled: true premium_pack: app_store_product_id: com.meeyao.qianwen.premium_pack + creem_product_id: prod_2L13k70jlpPYkdHhexHP2s credits: 415 type: regular sort_order: 30 diff --git a/backend/src/core/db/session.py b/backend/src/core/db/session.py index 0b4a9cf..b3ac918 100644 --- a/backend/src/core/db/session.py +++ b/backend/src/core/db/session.py @@ -14,6 +14,9 @@ engine: AsyncEngine = create_async_engine( config.database_url, echo=config.runtime.sql_log_queries, pool_pre_ping=True, + pool_size=3, + max_overflow=0, + pool_timeout=10, ) AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker( diff --git a/backend/src/models/creem_transaction.py b/backend/src/models/creem_transaction.py new file mode 100644 index 0000000..de551be --- /dev/null +++ b/backend/src/models/creem_transaction.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import uuid +from enum import Enum + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Index, + String, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class CreemTransactionStatus(str, Enum): + PENDING = "pending" + COMPLETED = "completed" + FAILED = "failed" + REFUNDED = "refunded" + + +class CreemTransaction(TimestampMixin, Base): + __tablename__ = "creem_transactions" + __table_args__ = ( + CheckConstraint( + "status in ('pending', 'completed', 'failed', 'refunded')", + name="ck_creem_transactions_status", + ), + UniqueConstraint( + "checkout_id", name="uq_creem_transactions_checkout_id" + ), + Index( + "ix_creem_transactions_user_created_at", + "user_id", + text("created_at DESC"), + ), + Index( + "ix_creem_transactions_status_updated_at", + "status", + text("updated_at DESC"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + product_code: Mapped[str] = mapped_column(String(32), nullable=False) + creem_product_id: Mapped[str] = mapped_column(String(128), nullable=False) + checkout_id: Mapped[str] = mapped_column(String(128), nullable=False) + order_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + customer_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + status: Mapped[str] = mapped_column(String(24), nullable=False) + credits: Mapped[int] = mapped_column(BigInteger, nullable=False) + amount_cents: Mapped[int] = mapped_column(BigInteger, nullable=False) + currency: Mapped[str] = mapped_column(String(8), nullable=False) + creem_payload: Mapped[dict[str, object]] = mapped_column( + "creem_payload", + JSONB(), + nullable=False, + server_default=text("'{}'::jsonb"), + default=dict, + ) + ledger_event_id: Mapped[str | None] = mapped_column(String(128), nullable=True) diff --git a/backend/src/v1/payments/creem_client.py b/backend/src/v1/payments/creem_client.py new file mode 100644 index 0000000..5cdf235 --- /dev/null +++ b/backend/src/v1/payments/creem_client.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging +from dataclasses import dataclass +from typing import Any + +import httpx + +from core.config.settings import config + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CreemProduct: + product_id: str + name: str + price_cents: int + currency: str + + +@dataclass(frozen=True) +class CreemCheckout: + checkout_id: str + checkout_url: str + + +class CreemClient: + def __init__(self) -> None: + settings = config.creem + self._api_key = settings.api_key.get_secret_value() if settings.api_key else None + self._base_url = settings.base_url.rstrip("/") + self._timeout = httpx.Timeout(30.0, connect=5.0) + + def _headers(self) -> dict[str, str]: + if not self._api_key: + raise RuntimeError("CREEM API key not configured") + return { + "x-api-key": self._api_key, + "Content-Type": "application/json", + } + + async def get_products(self) -> list[CreemProduct]: + """Fetch all products from CREEM.""" + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.get( + f"{self._base_url}/v1/products/search", + headers=self._headers(), + ) + resp.raise_for_status() + data: Any = resp.json() + + products: list[CreemProduct] = [] + for item in data.get("items", []): + product_id = item.get("id", "") + name = item.get("name", "") + price = item.get("price", 0) + currency = item.get("currency", "USD") + products.append( + CreemProduct( + product_id=product_id, + name=name, + price_cents=int(price), + currency=currency, + ) + ) + return products + + async def get_product(self, product_id: str) -> CreemProduct | None: + """Fetch a single product by ID.""" + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.get( + f"{self._base_url}/v1/products", + params={"product_id": product_id}, + headers=self._headers(), + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + data: Any = resp.json() + + return CreemProduct( + product_id=data.get("id", ""), + name=data.get("name", ""), + price_cents=int(data.get("price", 0)), + currency=data.get("currency", "USD"), + ) + + async def create_checkout( + self, + *, + product_id: str, + success_url: str, + customer_email: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> CreemCheckout: + """Create a checkout session.""" + payload: dict[str, Any] = { + "product_id": product_id, + "success_url": success_url, + } + if customer_email: + payload["customer"] = {"email": customer_email} + if metadata: + payload["metadata"] = metadata + + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.post( + f"{self._base_url}/v1/checkouts", + headers=self._headers(), + json=payload, + ) + resp.raise_for_status() + data: Any = resp.json() + + return CreemCheckout( + checkout_id=data.get("id", ""), + checkout_url=data.get("checkout_url", ""), + ) + + @staticmethod + def verify_webhook_signature( + payload: bytes, + signature: str, + secret: str, + ) -> bool: + """Verify webhook signature using HMAC-SHA256.""" + expected = hmac.new( + secret.encode("utf-8"), + payload, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, signature) diff --git a/backend/src/v1/payments/creem_service.py b/backend/src/v1/payments/creem_service.py new file mode 100644 index 0000000..ce9be9e --- /dev/null +++ b/backend/src/v1/payments/creem_service.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + +import yaml + +from core.config.settings import config +from core.http.errors import ApiProblemError, problem_payload +from models.creem_transaction import CreemTransaction +from schemas.domain.points import ( + ApplyPointsChangeCommand, + PurchaseLedgerMetadata, +) +from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType +from v1.payments.creem_client import CreemClient, CreemProduct +from v1.payments.repository import PaymentRepository +from v1.points.repository import PointsRepository + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CreemProductMapping: + creem_product_id: str + credits: int + type: str + sort_order: int = 0 + enabled: bool = True + + +_creem_product_mappings_cache: dict[str, CreemProductMapping] | None = None + + +def _load_creem_product_mappings() -> dict[str, CreemProductMapping]: + global _creem_product_mappings_cache + if _creem_product_mappings_cache is not None: + return _creem_product_mappings_cache + + mapping_path = ( + Path(__file__).parent.parent.parent + / "core/config/static/packages/mapping.yaml" + ) + with mapping_path.open("r", encoding="utf-8") as f: + raw: Any = yaml.safe_load(f) or {} + + mappings: dict[str, CreemProductMapping] = {} + product_mappings: Any = raw.get("product_mappings", {}) + for code, entry in product_mappings.items(): + if entry.get("creem_product_id"): + mappings[str(code)] = CreemProductMapping( + creem_product_id=str(entry["creem_product_id"]), + credits=int(entry["credits"]), + type=str(entry["type"]), + sort_order=int(entry.get("sort_order", 0)), + enabled=bool(entry.get("enabled", True)), + ) + + _creem_product_mappings_cache = mappings + return mappings + + +def clear_creem_product_mappings_cache() -> None: + global _creem_product_mappings_cache + _creem_product_mappings_cache = None + + +@dataclass(frozen=True) +class PackageWithPrice: + product_code: str + creem_product_id: str + credits: int + type: str + sort_order: int + price_cents: int + currency: str + + +@dataclass(frozen=True) +class CreateCheckoutResult: + checkout_id: str + checkout_url: str + + +class CreemService: + def __init__( + self, + *, + payment_repo: PaymentRepository, + points_repo: PointsRepository, + client: CreemClient, + ) -> None: + self._payment_repo: PaymentRepository = payment_repo + self._points_repo: PointsRepository = points_repo + self._client: CreemClient = client + + async def get_packages_with_prices(self) -> list[PackageWithPrice]: + """Get all packages with dynamic prices from CREEM API.""" + mappings = _load_creem_product_mappings() + products = await self._client.get_products() + + product_by_id: dict[str, CreemProduct] = {p.product_id: p for p in products} + + result: list[PackageWithPrice] = [] + for code, mapping in mappings.items(): + if not mapping.enabled: + continue + product = product_by_id.get(mapping.creem_product_id) + if product is None: + logger.warning( + "CREEM product not found: code=%s product_id=%s", + code, + mapping.creem_product_id, + ) + continue + result.append( + PackageWithPrice( + product_code=code, + creem_product_id=mapping.creem_product_id, + credits=mapping.credits, + type=mapping.type, + sort_order=mapping.sort_order, + price_cents=product.price_cents, + currency=product.currency, + ) + ) + + result.sort(key=lambda p: p.sort_order) + return result + + async def create_checkout( + self, + *, + user_id: UUID, + user_email: str, + product_code: str, + ) -> CreateCheckoutResult: + """Create a CREEM checkout session.""" + mappings = _load_creem_product_mappings() + mapping = mappings.get(product_code) + if mapping is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="PAYMENT_PRODUCT_NOT_FOUND", + detail=f"Product not found: {product_code}", + ), + ) + + is_starter = mapping.type == "starter" + normalized_email = user_email.strip().lower() + email_hash = ( + self._build_email_hash(normalized_email) if normalized_email else None + ) + + if is_starter: + if not email_hash: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PAYMENT_STARTER_PACK_INELIGIBLE", + detail="Email required for starter pack purchase", + ), + ) + claim = await self._payment_repo.get_register_bonus_claim( + email_hash=email_hash + ) + if claim is not None and claim.has_purchased_starter_pack: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="PAYMENT_STARTER_PACK_INELIGIBLE", + detail="Starter pack already purchased for this email", + ), + ) + + product = await self._client.get_product(mapping.creem_product_id) + if product is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="PAYMENT_PRODUCT_NOT_FOUND", + detail=f"CREEM product not found: {mapping.creem_product_id}", + ), + ) + + success_url = config.creem.success_url + checkout = await self._client.create_checkout( + product_id=mapping.creem_product_id, + success_url=success_url, + customer_email=normalized_email or None, + metadata={ + "user_id": str(user_id), + "product_code": product_code, + }, + ) + + transaction = CreemTransaction( + id=uuid4(), + user_id=user_id, + product_code=product_code, + creem_product_id=mapping.creem_product_id, + checkout_id=checkout.checkout_id, + status="pending", + credits=mapping.credits, + amount_cents=product.price_cents, + currency=product.currency, + creem_payload={"checkout_url": checkout.checkout_url}, + ) + await self._payment_repo.insert_creem_transaction(transaction=transaction) + await self._payment_repo.commit() + + logger.info( + "CREEM checkout created: user_id=%s product_code=%s checkout_id=%s", + user_id, + product_code, + checkout.checkout_id, + ) + + return CreateCheckoutResult( + checkout_id=checkout.checkout_id, + checkout_url=checkout.checkout_url, + ) + + async def handle_webhook( + self, + *, + payload: bytes, + signature: str, + ) -> None: + """Handle CREEM webhook notification.""" + settings = config.creem + secret = settings.webhook_secret + if secret is None: + logger.error("CREEM webhook_secret not configured") + return + + secret_value = secret.get_secret_value() + if not CreemClient.verify_webhook_signature(payload, signature, secret_value): + logger.warning("CREEM webhook signature verification failed") + return + + try: + event: Any = json.loads(payload) + except json.JSONDecodeError: + logger.warning("CREEM webhook payload is not valid JSON") + return + + event_type = event.get("eventType", "") + obj = event.get("object", {}) + + if event_type == "checkout.completed": + await self._handle_checkout_completed(obj) + + async def _handle_checkout_completed(self, obj: dict[str, Any]) -> None: + # CREEM webhook structure: checkout_id is in "id", order_id in "order.id", customer_id in "customer.id" + checkout_id = obj.get("id", "") + order_obj = obj.get("order", {}) + order_id = order_obj.get("id") if isinstance(order_obj, dict) else None + customer_obj = obj.get("customer", {}) + customer_id = customer_obj.get("id") if isinstance(customer_obj, dict) else None + metadata = obj.get("metadata", {}) + + txn = await self._payment_repo.get_creem_transaction_by_checkout_id( + checkout_id=checkout_id + ) + if txn is None: + logger.warning( + "CREEM checkout.completed for unknown checkout_id: %s", + checkout_id, + ) + return + + if txn.status == "completed": + logger.info( + "CREEM checkout already completed: checkout_id=%s", + checkout_id, + ) + return + + user_id = txn.user_id + credits = txn.credits + + account = await self._payment_repo.get_or_create_user_points_for_update( + user_id=user_id + ) + balance = int(account.balance) + new_balance = balance + credits + + account.balance = new_balance + account.lifetime_earned = int(account.lifetime_earned) + credits + account.version = int(account.version) + 1 + + event_id = f"payment.creem:{checkout_id}" + + metadata_obj = PurchaseLedgerMetadata( + operator_type=PointsOperatorType.SYSTEM, + run_id=event_id, + ext={ + "source": "creem", + "platform": "web", + "product_code": txn.product_code, + "transaction_id": checkout_id, + "creem_product_id": txn.creem_product_id, + "order_id": order_id or "", + "customer_id": customer_id or "", + "creem_transaction_id": str(txn.id), + }, + ) + + ledger_command = ApplyPointsChangeCommand( + user_id=user_id, + change_type=PointsChangeType.PURCHASE, + biz_type=PointsBizType.PAYMENT, + biz_id=txn.id, + event_id=event_id, + amount=credits, + direction=1, + operator_id=None, + metadata=metadata_obj, + ) + + await self._points_repo.append_ledger( + command=ledger_command, + balance_after=new_balance, + ) + + txn.order_id = order_id + txn.customer_id = customer_id + txn.status = "completed" + txn.ledger_event_id = event_id + txn.creem_payload = obj + + logger.info( + "CREEM payment completed: user_id=%s checkout_id=%s credits=%d new_balance=%d", + user_id, + checkout_id, + credits, + new_balance, + ) + + mappings = _load_creem_product_mappings() + mapping = mappings.get(txn.product_code) + if mapping and mapping.type == "starter": + user_email = obj.get("customer", {}).get("email", "") + normalized_email = user_email.strip().lower() + if normalized_email: + email_hash = self._build_email_hash(normalized_email) + _ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack( + email_hash=email_hash, + user_email_snapshot=normalized_email, + first_user_id_snapshot=user_id, + ) + + await self._payment_repo.commit() + + @staticmethod + def _build_email_hash(normalized_email: str) -> str: + key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip() + digest = hmac.new( + key=key.encode("utf-8"), + msg=normalized_email.encode("utf-8"), + digestmod=hashlib.sha256, + ) + return digest.hexdigest() diff --git a/backend/src/v1/payments/dependencies.py b/backend/src/v1/payments/dependencies.py index f7b6915..92ad4ac 100644 --- a/backend/src/v1/payments/dependencies.py +++ b/backend/src/v1/payments/dependencies.py @@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.db import get_db from v1.payments.apple_verifier import AppleJwsVerifier +from v1.payments.creem_client import CreemClient +from v1.payments.creem_service import CreemService from v1.payments.repository import PaymentRepository from v1.payments.service import PaymentService from v1.points.repository import PointsRepository @@ -19,3 +21,14 @@ def get_payment_service(session: AsyncSession = Depends(get_db)) -> PaymentServi points_repo=points_repo, verifier=verifier, ) + + +def get_creem_service(session: AsyncSession = Depends(get_db)) -> CreemService: + payment_repo = PaymentRepository(session) + points_repo = PointsRepository(session) + client = CreemClient() + return CreemService( + payment_repo=payment_repo, + points_repo=points_repo, + client=client, + ) diff --git a/backend/src/v1/payments/repository.py b/backend/src/v1/payments/repository.py index bac5a65..56d6e78 100644 --- a/backend/src/v1/payments/repository.py +++ b/backend/src/v1/payments/repository.py @@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from models.apple_iap_transaction import AppleIapTransaction +from models.creem_transaction import CreemTransaction from models.register_bonus_claims import RegisterBonusClaims from models.user_points import UserPoints @@ -84,5 +85,17 @@ class PaymentRepository: raise RuntimeError("Failed to upsert register bonus claim") return claim + async def get_creem_transaction_by_checkout_id( + self, *, checkout_id: str + ) -> CreemTransaction | None: + stmt = select(CreemTransaction).where( + CreemTransaction.checkout_id == checkout_id + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def insert_creem_transaction(self, *, transaction: CreemTransaction) -> None: + self._session.add(transaction) + await self._session.flush() + async def commit(self) -> None: await self._session.commit() diff --git a/backend/src/v1/payments/router.py b/backend/src/v1/payments/router.py index f84d9fe..86aa413 100644 --- a/backend/src/v1/payments/router.py +++ b/backend/src/v1/payments/router.py @@ -3,16 +3,19 @@ from __future__ import annotations import logging from typing import Annotated -from fastapi import APIRouter, Depends, Response +from fastapi import APIRouter, Depends, Request, Response from core.auth.models import CurrentUser -from v1.payments.dependencies import get_payment_service +from v1.payments.dependencies import get_creem_service, get_payment_service from v1.payments.schemas import ( AppleServerNotificationRequest, + CreateCheckoutRequest, + CreateCheckoutResponse, VerifyTransactionRequest, VerifyTransactionResponse, ) from v1.payments.service import PaymentService +from v1.payments.creem_service import CreemService from v1.users.dependencies import get_current_user logger = logging.getLogger(__name__) @@ -43,3 +46,34 @@ async def handle_apple_server_notification( ) -> Response: await service.handle_server_notification(signed_payload=request.signed_payload) return Response(status_code=200) + + +@router.post( + "/creem/checkouts", + response_model=CreateCheckoutResponse, +) +async def create_creem_checkout( + request: CreateCheckoutRequest, + service: Annotated[CreemService, Depends(get_creem_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> CreateCheckoutResponse: + result = await service.create_checkout( + user_id=current_user.id, + user_email=current_user.email or "", + product_code=request.product_code, + ) + return CreateCheckoutResponse( + checkoutId=result.checkout_id, + checkoutUrl=result.checkout_url, + ) + + +@router.post("/creem/webhook", status_code=200) +async def handle_creem_webhook( + http_request: Request, + service: Annotated[CreemService, Depends(get_creem_service)], +) -> Response: + signature = http_request.headers.get("creem-signature", "") + payload = await http_request.body() + await service.handle_webhook(payload=payload, signature=signature) + return Response(status_code=200) diff --git a/backend/src/v1/payments/schemas.py b/backend/src/v1/payments/schemas.py index 8411734..3db23ca 100644 --- a/backend/src/v1/payments/schemas.py +++ b/backend/src/v1/payments/schemas.py @@ -45,3 +45,16 @@ class AppleServerNotificationRequest(BaseModel): model_config = ConfigDict(extra="allow") signed_payload: str = Field(alias="signedPayload", default="") + + +class CreateCheckoutRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + product_code: str = Field(alias="productCode", min_length=1, max_length=32) + + +class CreateCheckoutResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + checkout_id: str = Field(alias="checkoutId") + checkout_url: str = Field(alias="checkoutUrl") diff --git a/backend/src/v1/payments/service.py b/backend/src/v1/payments/service.py index ec8a99e..856207a 100644 --- a/backend/src/v1/payments/service.py +++ b/backend/src/v1/payments/service.py @@ -33,6 +33,7 @@ logger = logging.getLogger(__name__) @dataclass(frozen=True) class ProductMapping: app_store_product_id: str + creem_product_id: str | None credits: int type: str sort_order: int = 0 @@ -58,7 +59,8 @@ def _load_product_mappings() -> dict[str, ProductMapping]: product_mappings: Any = raw.get("product_mappings", {}) for code, entry in product_mappings.items(): mappings[str(code)] = ProductMapping( - app_store_product_id=str(entry["app_store_product_id"]), + app_store_product_id=str(entry.get("app_store_product_id", "")), + creem_product_id=str(entry["creem_product_id"]) if entry.get("creem_product_id") else None, credits=int(entry["credits"]), type=str(entry["type"]), sort_order=int(entry.get("sort_order", 0)), diff --git a/backend/src/v1/points/dependencies.py b/backend/src/v1/points/dependencies.py index bb9cf7a..6fa74ac 100644 --- a/backend/src/v1/points/dependencies.py +++ b/backend/src/v1/points/dependencies.py @@ -3,10 +3,18 @@ from __future__ import annotations from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession +from core.config.settings import config from core.db import get_db +from v1.payments.creem_client import CreemClient from v1.points.repository import PointsRepository from v1.points.service import PointsService def get_points_service(session: AsyncSession = Depends(get_db)) -> PointsService: - return PointsService(repository=PointsRepository(session)) + creem_client: CreemClient | None = None + if config.creem.api_key: + creem_client = CreemClient() + return PointsService( + repository=PointsRepository(session), + creem_client=creem_client, + ) diff --git a/backend/src/v1/points/router.py b/backend/src/v1/points/router.py index 63ce42a..4e732f1 100644 --- a/backend/src/v1/points/router.py +++ b/backend/src/v1/points/router.py @@ -67,11 +67,14 @@ async def get_available_packages( PackageInfo( productCode=pkg.product_code, appStoreProductId=pkg.app_store_product_id, + creemProductId=pkg.creem_product_id, type=pkg.type, credits=pkg.credits, isStarter=pkg.is_starter, starterEligible=pkg.starter_eligible, sortOrder=pkg.sort_order, + priceCents=pkg.price_cents, + currency=pkg.currency, ) for pkg in result.packages ], diff --git a/backend/src/v1/points/schemas.py b/backend/src/v1/points/schemas.py index 3f774ae..3b988b5 100644 --- a/backend/src/v1/points/schemas.py +++ b/backend/src/v1/points/schemas.py @@ -19,14 +19,19 @@ class PackageInfo(BaseModel): model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) product_code: str = Field(alias="productCode", min_length=1, max_length=128) - app_store_product_id: str = Field( - alias="appStoreProductId", min_length=1, max_length=256 + app_store_product_id: str | None = Field( + alias="appStoreProductId", default=None, min_length=1, max_length=256 + ) + creem_product_id: str | None = Field( + alias="creemProductId", default=None, min_length=1, max_length=256 ) type: Literal["starter", "regular"] credits: int = Field(ge=1) is_starter: bool = Field(alias="isStarter") starter_eligible: bool = Field(alias="starterEligible") sort_order: int = Field(alias="sortOrder", ge=0) + price_cents: int | None = Field(alias="priceCents", default=None, ge=0) + currency: str | None = Field(alias="currency", default=None, min_length=3, max_length=8) class PackagesResponse(BaseModel): diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index c16d1bc..6e797bf 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -20,6 +20,8 @@ from schemas.domain.points import ( from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType from schemas.domain.points import ApplyPointsChangeCommand from v1.payments.service import _load_product_mappings +from v1.payments.creem_service import _load_creem_product_mappings +from v1.payments.creem_client import CreemClient from v1.points.repository import PointsRepository from v1.points.schemas import LedgerItem @@ -65,12 +67,15 @@ class RegisterBonusResult: @dataclass(frozen=True) class PackageInfoResult: product_code: str - app_store_product_id: str + app_store_product_id: str | None + creem_product_id: str | None type: Literal["starter", "regular"] credits: int sort_order: int is_starter: bool starter_eligible: bool + price_cents: int | None = None + currency: str | None = None @dataclass(frozen=True) @@ -79,8 +84,13 @@ class PackagesResult: class PointsService: - def __init__(self, repository: PointsRepository) -> None: + def __init__( + self, + repository: PointsRepository, + creem_client: CreemClient | None = None, + ) -> None: self._repository = repository + self._creem_client = creem_client async def grant_register_bonus_if_eligible( self, @@ -453,6 +463,17 @@ class PointsService: ) product_mappings = _load_product_mappings() + creem_mappings = _load_creem_product_mappings() + + creem_prices: dict[str, tuple[int, str]] = {} + if self._creem_client: + try: + products = await self._creem_client.get_products() + creem_prices = { + p.product_id: (p.price_cents, p.currency) for p in products + } + except Exception: + pass packages: list[PackageInfoResult] = [] for product_code, mapping in product_mappings.items(): @@ -464,15 +485,25 @@ class PointsService: if pkg_type == "starter" and has_starter: continue + creem_mapping = creem_mappings.get(product_code) + creem_product_id = creem_mapping.creem_product_id if creem_mapping else None + price_cents: int | None = None + currency: str | None = None + if creem_product_id and creem_product_id in creem_prices: + price_cents, currency = creem_prices[creem_product_id] + packages.append( PackageInfoResult( product_code=product_code, app_store_product_id=mapping.app_store_product_id, + creem_product_id=creem_product_id, type=pkg_type, credits=mapping.credits, sort_order=mapping.sort_order, is_starter=pkg_type == "starter", starter_eligible=(pkg_type == "starter" and not has_starter), + price_cents=price_cents, + currency=currency, ) ) diff --git a/deploy/README.md b/deploy/README.md index 84aa518..f590cf9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -14,6 +14,8 @@ - Docker - Docker Compose v2 - AWS CLI v2 +- Node.js 22+ (前端 SSR 服务) +- PM2 (前端进程管理) 确认命令: @@ -21,6 +23,31 @@ docker --version docker compose version aws --version +node --version # 需要 >= 22.12.0 +pm2 --version +``` + +## 服务架构 + +生产环境运行以下服务: + +| 服务 | 端口 | 说明 | +|------|------|------| +| Nginx | 80, 443 | 反向代理,SSL 终结 | +| 前端 SSR | 4322 | Node.js + Astro SSR | +| 后端 API | 5775 | Docker 容器 | +| Redis | 6379 | Docker 容器 | +| Worker Agent | - | Docker 容器 | + +### 端口映射 + +``` +Internet → Nginx (443) + ├── /_astro/* → 静态文件 (client 目录) + ├── /images/* → 静态文件 (client 目录) + └── /* → 反向代理到 localhost:4322 (前端 SSR) + +前端 SSR (4322) → /api/* → 反向代理到 localhost:5775 (后端 API) ``` ## 环境变量 @@ -199,3 +226,98 @@ docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers do ``` 谨慎使用 `down -v`,它会删除 Redis 持久化数据。 + +## 前端部署 + +### 构建前端 + +在本地开发机器上: + +```bash +cd web +PUBLIC_API_URL=https://api.meeyao.com npm run build +``` + +构建产物在 `web/dist/` 目录: +- `dist/client/` - 静态资源 (CSS, JS, 图片) +- `dist/server/` - SSR 服务端代码 + +### 上传到服务器 + +```bash +# 上传构建产物 +rsync -avz -e "ssh -i xunmee.pem" web/dist/ ubuntu@18.218.38.213:/home/ubuntu/deploy/web/ + +# 上传依赖配置 +rsync -avz -e "ssh -i xunmee.pem" web/package.json web/package-lock.json ubuntu@18.218.38.213:/home/ubuntu/deploy/web/ + +# 安装生产依赖 +ssh -i xunmee.pem ubuntu@18.218.38.213 "cd /home/ubuntu/deploy/web && npm install --omit=dev" +``` + +### PM2 管理 + +```bash +# 启动服务 +pm2 start server/entry.mjs --name meeyao-web + +# 设置端口 (默认 4321,需要改为 4322) +export HOST=0.0.0.0 +export PORT=4322 +pm2 restart meeyao-web + +# 保存进程列表 (开机自启) +pm2 save + +# 查看状态 +pm2 status + +# 查看日志 +pm2 logs meeyao-web +``` + +### Nginx 配置 + +前端 SSR 服务监听 `localhost:4322`,Nginx 配置要点: + +```nginx +server { + listen 443 ssl http2; + server_name meeyao.com; + + # 静态资源直接从 client 目录提供 + location /_astro { + root /home/ubuntu/deploy/web/client; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + location /images { + root /home/ubuntu/deploy/web/client; + expires 30d; + } + + # 其他请求代理到 Node.js SSR + location / { + proxy_pass http://127.0.0.1:4322; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### 前端更新流程 + +```bash +# 1. 本地构建 +cd web && PUBLIC_API_URL=https://api.meeyao.com npm run build + +# 2. 上传 +rsync -avz -e "ssh -i xunmee.pem" dist/ ubuntu@18.218.38.213:/home/ubuntu/deploy/web/ + +# 3. 重启服务 +ssh -i xunmee.pem ubuntu@18.218.38.213 "pm2 restart meeyao-web" +``` diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..87b813a --- /dev/null +++ b/web/README.md @@ -0,0 +1,43 @@ +# Astro Starter Kit: Minimal + +```sh +npm create astro@latest -- --template minimal +``` + +> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! + +## 🚀 Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +```text +/ +├── public/ +├── src/ +│ └── pages/ +│ └── index.astro +└── package.json +``` + +Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. + +There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. + +Any static assets, like images, can be placed in the `public/` directory. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | + +## 👀 Want to learn more? + +Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/web/astro.config.mjs b/web/astro.config.mjs new file mode 100644 index 0000000..fa60e98 --- /dev/null +++ b/web/astro.config.mjs @@ -0,0 +1,46 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; +import react from '@astrojs/react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + output: 'server', + adapter: node({ + mode: 'standalone', + }), + integrations: [react()], + i18n: { + locales: ['en', 'zh', 'zh_Hant'], + defaultLocale: 'en', + routing: { + prefixDefaultLocale: true, + }, + }, + vite: { + plugins: [tailwindcss()], + server: { + port: 4322, + proxy: { + '/api': { + target: 'http://localhost:5775', + changeOrigin: true, + secure: false, + timeout: 30000, + proxyTimeout: 60000, + }, + }, + }, + optimizeDeps: { + include: ['react', 'react-dom', 'react/jsx-dev-runtime'], + esbuildOptions: { + define: { + 'process.env.NODE_ENV': '"development"', + }, + }, + }, + resolve: { + dedupe: ['react', 'react-dom'], + }, + }, +}); diff --git a/web/design/assets/images/logo.png b/web/design/assets/images/logo.png new file mode 100644 index 0000000..5f8129e Binary files /dev/null and b/web/design/assets/images/logo.png differ diff --git a/web/design/assets/images/qigua/hua.jpg b/web/design/assets/images/qigua/hua.jpg new file mode 100644 index 0000000..e3c13dc Binary files /dev/null and b/web/design/assets/images/qigua/hua.jpg differ diff --git a/web/design/assets/images/qigua/shangshang.jpg b/web/design/assets/images/qigua/shangshang.jpg new file mode 100644 index 0000000..49c3253 Binary files /dev/null and b/web/design/assets/images/qigua/shangshang.jpg differ diff --git a/web/design/assets/images/qigua/xiaxia.jpg b/web/design/assets/images/qigua/xiaxia.jpg new file mode 100644 index 0000000..779bd68 Binary files /dev/null and b/web/design/assets/images/qigua/xiaxia.jpg differ diff --git a/web/design/assets/images/qigua/zhongshang.jpg b/web/design/assets/images/qigua/zhongshang.jpg new file mode 100644 index 0000000..6ca79ad Binary files /dev/null and b/web/design/assets/images/qigua/zhongshang.jpg differ diff --git a/web/design/assets/images/qigua/zhongxia.jpg b/web/design/assets/images/qigua/zhongxia.jpg new file mode 100644 index 0000000..44105fe Binary files /dev/null and b/web/design/assets/images/qigua/zhongxia.jpg differ diff --git a/web/design/assets/images/qigua/zi.jpg b/web/design/assets/images/qigua/zi.jpg new file mode 100644 index 0000000..e918985 Binary files /dev/null and b/web/design/assets/images/qigua/zi.jpg differ diff --git a/web/design/assets/images/tutorial/tutorial_1.png b/web/design/assets/images/tutorial/tutorial_1.png new file mode 100644 index 0000000..581661b Binary files /dev/null and b/web/design/assets/images/tutorial/tutorial_1.png differ diff --git a/web/design/assets/images/tutorial/tutorial_2.png b/web/design/assets/images/tutorial/tutorial_2.png new file mode 100644 index 0000000..f9f51f4 Binary files /dev/null and b/web/design/assets/images/tutorial/tutorial_2.png differ diff --git a/web/design/assets/images/tutorial/tutorial_3.png b/web/design/assets/images/tutorial/tutorial_3.png new file mode 100644 index 0000000..de643d6 Binary files /dev/null and b/web/design/assets/images/tutorial/tutorial_3.png differ diff --git a/web/design/assets/legal/en/about_us.md b/web/design/assets/legal/en/about_us.md new file mode 100644 index 0000000..7ecd245 --- /dev/null +++ b/web/design/assets/legal/en/about_us.md @@ -0,0 +1,23 @@ +# About Us + +Welcome to MeeYao Divination, an AI-assisted cultural reference app focused on traditional Six-Line culture and the traditional wisdom of the I Ching. + +Six-Line culture originates from the profound philosophical system of the I Ching. It embodies the traditional viewpoint of the connection between personal thoughts, timing and natural changes. By combining hexagram culture, traditional five-element theories and traditional GanZhi cultural concepts, users can explore traditional cultural connotations and life reference perspectives. + +MeeYao Divination is designed based on traditional oriental culture. Our core goal is to help users broaden their thinking horizons, view daily choices and life status from a diverse cultural perspective, and maintain a rational and peaceful mindset in daily life. We hope modern AI technology can serve as a convenient way for everyone to understand and experience traditional Chinese culture. + +--- + +## Company Info + +**Developer:** Ann Lee + +**Contact Email:** feedback@xunmee.com + +--- + +## Important Disclaimer + +All AI-generated content and cultural interpretation materials are for **entertainment, cultural appreciation and reference only**. This app does not provide professional advice of any kind, including but not limited to business, finance, investment, medical treatment, psychology, law, career or life decision-making. All generated content shall not be regarded as factual basis or decision-making guidance. The developer does not assume any responsibility for users' personal choices, behaviors and related consequences. Please treat traditional culture rationally and use this app with a rational attitude. + +© 2026 Ann Lee. All Rights Reserved. diff --git a/web/design/assets/legal/en/privacy_policy.md b/web/design/assets/legal/en/privacy_policy.md new file mode 100644 index 0000000..e0fd11e --- /dev/null +++ b/web/design/assets/legal/en/privacy_policy.md @@ -0,0 +1,163 @@ +# Privacy Policy + +**Last Updated**: April 27, 2026 + +**Effective Date**: April 27, 2026 + +--- + +## Introduction + +Dear User, Welcome to MeeYao Divination (the "App"), independently developed and operated by an **individual developer** ("I", "me", "my"). I am committed to protecting your personal privacy and complying with applicable U.S. federal and state privacy laws, including the California Consumer Privacy Act (CCPA/CPRA), the Children's Online Privacy Protection Act (COPPA), CalOPPA, and other U.S. state privacy regulations. + +This Privacy Policy clearly explains: + +- What personal information I collect +- How your data is used, stored and shared +- Your legal privacy rights under U.S. regulations +- How you can submit data requests + +This policy applies to all users of this App. California residents are granted additional rights specified in Section 5. + +--- + +## 1. Information We Collect + +I only collect necessary data to provide, maintain and optimize App cultural reference functions. All data is classified as Personal Information and Sensitive Personal Information (SPI) in accordance with CCPA/CPRA. + +### 1.1 Information You Provide Directly + +- **Account Information**: Email address, verification code (required for account registration and security verification) +- **Profile Information**: Optional nickname or display name voluntarily set by you +- **Personal Content**: Your input questions, cultural interpretation records and local session content +- **Support Information**: Feedback, consultation messages you send for user assistance + +### 1.2 Information Collected Automatically + +When you use the App, limited automatic data will be collected to ensure normal operation: + +- **Device Information**: Device model, operating system version, unique device identifier, device configuration +- **Technical Data**: IP address (for rough regional access recognition), access time, crash logs and operation performance data +- **Usage Data**: Function usage records, app stay duration and in-app interaction behavior + +--- + +## 2. How We Use Your Information + +Your information will only be used for the following legitimate and limited purposes: + +1. **Provide Core Functions**: Process your input content, generate AI cultural interpretation content, and record local usage records. +2. **Account Security**: Complete user verification, prevent abnormal login and protect your account security. +3. **Product Optimization**: Analyze anonymous usage data to fix bugs, optimize operation experience and improve product performance. +4. **User Assistance**: Reply to your feedback and solve your use problems. +5. **Service Reminders**: Push necessary system notices and policy update reminders. +6. **Legal Compliance**: Meet statutory compliance requirements and official platform review rules. + +I will **not** use your personal sensitive content for commercial advertising or unauthorized marketing without your explicit consent. + +--- + +## 3. Data Storage, Retention & Cross-Border Transfers + +### 3.1 Storage Location + +User data collected through this App may be stored on secure third-party cloud servers located in the United States. All cross-border data transmission adopts encrypted transmission protocols to ensure data security. + +### 3.2 Retention Period + +Data will only be retained within the necessary time limit: + +- **Account data**: Retained during your active use, and cleaned up reasonably after you cancel your account. +- **Personal content records**: Reserved within a reasonable cycle and regularly cleaned or anonymized. +- **Device and log data**: Automatically deleted after a limited period. + +--- + +## 4. Sharing & Disclosure of Information + +### 4.1 Sale of Personal Information + +**I do not sell, rent or trade your personal information** in any form, and will never sell your data for commercial benefits. + +### 4.2 Sharing with Third-Party Service Providers + +I only share data with trusted third-party service providers necessary for App operation, and sign strict data protection restrictions: + +- Cloud storage and server services +- App operation analysis, crash monitoring tools +- Apple official push and system service capabilities + +All third parties are prohibited from using your data for independent commercial purposes. + +### 4.3 Legal Disclosure + +Your data may be disclosed only in the following situations: + +- Required by laws, regulations, court orders or official government requests +- With your clear voluntary authorization and consent +- To protect personal legitimate rights and public safety + +--- + +## 5. Your U.S. Privacy Rights (California Residents Included) + +In accordance with CCPA/CPRA and U.S. local privacy laws, you enjoy the following rights: + +1. **Right to Know**: Inquire about the type and scope of personal data collected. +2. **Right to Access**: Obtain a copy of your personal usage data. +3. **Right to Deletion**: Apply to delete your account and related personal data. +4. **Right to Correction**: Modify incorrect personal information. +5. **Right to Data Portability**: Obtain your data in a readable format. +6. **Right to Opt-Out**: Reject non-essential data collection and irrelevant recommendation. +7. **Right to Limit Sensitive Data**: Restrict the use of your personal sensitive content. +8. **Right to Non-Discrimination**: No differential treatment for you to exercise privacy rights. + +### How to Exercise Your Rights + +You can submit data requests through the only dedicated contact method: + +- **Contact Email**: feedback@xunmee.com + +I will respond to your legitimate request within 45 days, and properly verify your identity to ensure data security before processing. + +--- + +## 6. Children's Privacy (COPPA Compliance) + +This App is not oriented to users under the age of 13. I do not intentionally collect any personal information of minors under 13 years old. + +If you find that minor information has been improperly collected, please contact me via email in a timely manner, and I will completely delete the relevant data in accordance with COPPA regulations. Users aged 13–17 need to use this App under the supervision and consent of their guardians. + +--- + +## 7. Data Security + +I adopt industry-standard technical protection measures to protect your data: + +- Encrypted storage and encrypted transmission to prevent data leakage +- Strict access restrictions and daily security management +- Regular abnormal monitoring and risk checking + +Please note that no network storage system can achieve absolute security, and I will always maintain the highest level of data protection measures. + +--- + +## 8. Policy Changes + +This Privacy Policy may be updated irregularly to adapt to platform rules and legal adjustments. Important content changes will be notified through in-app prompts or email reminders in advance. Your continued use of the App after the update takes effect means that you agree to the revised policy. + +--- + +## 9. Contact Us + +If you have any questions, suggestions or privacy-related complaints about this Privacy Policy, please contact me: + +**Developer Email**: feedback@xunmee.com + +If you are a California resident and dissatisfied with the processing result, you can consult the local privacy regulatory authority. + +--- + +**Independent Individual Developer** + +**Last Updated**: April 27, 2026 diff --git a/web/design/assets/legal/en/terms_of_service.md b/web/design/assets/legal/en/terms_of_service.md new file mode 100644 index 0000000..213713f --- /dev/null +++ b/web/design/assets/legal/en/terms_of_service.md @@ -0,0 +1,121 @@ +# Terms of Service + +**Last Updated:** April 27, 2026 + +--- + +## 1. Acceptance of Terms + +MeiYao Divination (the "App") is independently developed, owned and operated by an **individual developer** ("I", "me", "my"). + +By downloading, installing, registering, accessing, or using the App, you ("you" or "user") acknowledge that you have read, understood, and unconditionally agree to be bound by these Terms of Service ("Terms") and my Privacy Policy. If you do not agree to these Terms, you must not use this App. + +--- + +## 2. Age Requirement & COPPA Compliance + +You represent and warrant that you are at least 13 years of age to use this App. + +- This App is not intended for children under 13 years old. +- I do not knowingly collect personal information from users under the age of 13. If I become aware that a minor under 13 has submitted personal data, I will take immediate action to delete such information. + +--- + +## 3. Service Description + +This App provides AI-assisted cultural interpretation content related to traditional I Ching and Six-Line culture, for daily reference and cultural appreciation only. + +- All AI-generated content and cultural reference materials are for entertainment and personal reference purposes solely. +- Content shall not be regarded as professional advice, including without limitation finance, investment, law, medical treatment, career or business decision-making. +- I do not guarantee the accuracy, completeness or practicality of any AI-generated content within the App. +- Temporary service suspension caused by system maintenance, technical exceptions, network failure or force majeure shall not be deemed a breach of these Terms. + +--- + +## 4. User Accounts & Data Privacy + +- You shall provide true, accurate and complete registration information and keep your information updated. +- You are solely responsible for safeguarding your account login credentials and for all activities conducted under your account. +- I collect and process user personal data strictly in accordance with the published Privacy Policy and comply with applicable U.S. privacy laws, including CCPA/CPRA. +- California residents hold relevant data access, deletion and privacy rights as stated in the Privacy Policy. + +--- + +## 5. Intellectual Property + +All intellectual property rights within the App, including but not limited to program code, text copy, graphic design, interface content, logos and visual elements, are exclusively owned by the individual developer and protected by U.S. copyright law (DMCA), trademark regulations and international intellectual property conventions. + +You may not: + +- Copy, modify, edit, distribute, reproduce or create derivative works based on the App and its internal content. +- Reverse engineer, decompile, disassemble, crack or attempt to obtain the App's source code. +- Delete, cover or alter any copyright notice, proprietary mark and intellectual property statement in the App. + +--- + +## 6. Prohibited User Conduct + +You agree not to: + +- Use the App for illegal, malicious, fraudulent or infringing behaviors. +- Publish or spread illegal, defamatory, obscene, threatening, violent or third-party infringing content. +- Attack, interfere or disrupt the App's operating environment, server and network stability. +- Exploit system vulnerabilities for unauthorized access, commercial profit or improper use. +- Exaggerate or falsely promote the functional effect and reference value of in-app content. + +I reserve the right to issue warnings, restrict functions, suspend or terminate your account without prior notice if you violate the above provisions, and reserve the right to pursue legal liability when necessary. + +--- + +## 7. Disclaimer of Warranties (US Standard) + +THE APP AND ALL IN-APP FUNCTIONS ARE PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS, WITH NO EXPRESS OR IMPLIED WARRANTIES OF ANY KIND. THIS INCLUDES BUT IS NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT OF THIRD-PARTY RIGHTS. + +I DO NOT WARRANT THAT: + +- The App will operate continuously, securely, error-free or without interruption. +- All generated cultural reference content will fully meet your expectations. +- The App and its functions are completely stable, virus-free or defect-free. + +--- + +## 8. Limitation of Liability + +To the fullest extent permitted by applicable U.S. laws: + +- I shall not be liable for any indirect, incidental, special, consequential or compensatory damages arising from your use of the App. +- Under no circumstances shall I bear excessive liability for disputes, losses or risks caused by your independent judgment and personal decisions. +- As a free individual development application, no paid transaction relationship exists; all use risks shall be borne by the user. + +--- + +## 9. Indemnification + +You agree to indemnify and hold the individual developer harmless from all claims, damages, losses, costs and reasonable legal expenses arising from: + +- Your violation of these Terms of Service. +- Improper use, abuse or unauthorized operation of the App. +- Any infringement of third-party intellectual property and legal rights caused by your published content. + +--- + +## 10. Governing Law & Dispute Resolution + +These Terms shall be governed by and construed in accordance with the laws of the State of California, United States, excluding conflict of law rules. + +In case of any dispute arising from the use of this App, both parties shall first resolve the matter through friendly negotiation. If negotiation fails, disputes shall be submitted to the competent courts in Los Angeles County, California, for resolution. + +--- + +## 11. Modifications to Terms + +I reserve the right to revise and update these Terms of Service at any time. Material rule changes will be notified via in-app reminders or official contact email. Your continued use of the App after the update takes effect means you fully accept the revised Terms. + +--- + +## 12. Contact Information + +If you have questions, feedback or legal inquiries about these Terms, please contact: + +- **Developer**: Individual Independent Developer +- **Contact Email**: feedback@xunmee.com diff --git a/web/design/assets/legal/zh/about_us.md b/web/design/assets/legal/zh/about_us.md new file mode 100644 index 0000000..85d2226 --- /dev/null +++ b/web/design/assets/legal/zh/about_us.md @@ -0,0 +1,23 @@ +# 关于我们 + +欢迎使用 觅爻 MeeYao,一款依托 AI 技术、以传统六爻文化与易经智慧为核心的传统文化参考工具。 + +六爻文化源自博大精深的易经哲学体系,承载着古人对于心念、时序与天地变化相生相融的传统认知。结合卦象文化、五行理论及干支传统人文理念,帮助用户探索东方传统文化内涵,获得多元的生活参考视角。 + +觅爻 MeeYao 根植于东方传统文脉,核心初衷是帮助用户跳出固有思维局限,以更开阔的视角看待日常抉择与生活状态,保持理性平和的心态。我们希望借助现代 AI 技术,让大众更轻松地了解、感受与体验中华传统经典文化。 + +--- + +## 开发者信息 + +**开发者**:Ann Lee + +**联系邮箱**:feedback@xunmee.com + +--- + +## 重要免责声明 + +本 App 所有 AI 生成内容与文化解读资料,仅作娱乐、文化赏析与个人参考使用。本应用不提供任何专业指导建议,包括但不限于商业、金融、投资、医疗、心理、法律、职业及人生决策等领域。所有生成内容不得作为事实依据或行动决策的唯一标准。开发者不对用户的个人选择、行为及衍生后果承担任何法律责任。请理性看待传统文化,理性使用本应用。 + +© 2026 Ann Lee 保留所有权利 diff --git a/web/design/assets/legal/zh/privacy_policy.md b/web/design/assets/legal/zh/privacy_policy.md new file mode 100644 index 0000000..6bebd7b --- /dev/null +++ b/web/design/assets/legal/zh/privacy_policy.md @@ -0,0 +1,163 @@ +# 隐私政策 + +**最后更新日期**:2026年4月27日 + +**生效日期**:2026年4月27日 + +--- + +## 引言 + +尊敬的用户,欢迎使用 觅爻 MeeYao(以下简称"本应用"),本应用由**个人开发者**("我")独立开发和运营。我致力于保护您的个人隐私,并遵守适用的美国联邦和州隐私法律,包括《加州消费者隐私法》(CCPA/CPRA)、《儿童在线隐私保护法》(COPPA)、CalOPPA 以及其他美国州隐私法规。 + +本隐私政策清晰说明: + +- 我收集哪些个人信息 +- 您的数据如何被使用、存储和共享 +- 您在美国法规下的法定隐私权利 +- 如何提交数据请求 + +本政策适用于本应用的所有用户。加州居民享有第5节规定的额外权利。 + +--- + +## 1. 我收集的信息 + +我只收集为提供、维护和优化应用文化参考功能所需的数据。所有数据根据 CCPA/CPRA 分类为个人信息和敏感个人信息(SPI)。 + +### 1.1 您直接提供的信息 + +- **账户信息**:电子邮箱地址、验证码(账户注册和安全验证所需) +- **个人资料**:您自愿设置的昵称或显示名称 +- **个人内容**:您输入的问题、文化解读记录和本地会话内容 +- **支持信息**:您发送的反馈、咨询消息 + +### 1.2 自动收集的信息 + +当您使用本应用时,将收集有限的自动数据以确保正常运行: + +- **设备信息**:设备型号、操作系统版本、唯一设备标识符、设备配置 +- **技术数据**:IP 地址(用于粗略区域访问识别)、访问时间、崩溃日志和操作性能数据 +- **使用数据**:功能使用记录、应用停留时长和应用内交互行为 + +--- + +## 2. 我如何使用您的信息 + +您的信息仅用于以下合法且有限的目的: + +1. **提供核心功能**:处理您的输入内容,生成 AI 文化解读内容,记录本地使用记录。 +2. **账户安全**:完成用户验证,防止异常登录,保护您的账户安全。 +3. **产品优化**:分析匿名使用数据以修复错误、优化操作体验和提升产品性能。 +4. **用户协助**:回复您的反馈,解决您的使用问题。 +5. **服务提醒**:推送必要的系统通知和政策更新提醒。 +6. **法律合规**:满足法定合规要求和官方平台审核规则。 + +未经您的明确同意,我**不会**将您的个人敏感内容用于商业广告或未经授权的营销。 + +--- + +## 3. 数据存储、保留与跨境传输 + +### 3.1 存储位置 + +通过本应用收集的用户数据可能存储于位于美国的第三方安全云服务器上。所有跨境数据传输均采用加密传输协议以确保数据安全。 + +### 3.2 保留期限 + +数据仅在必要时限内保留: + +- **账户数据**:在您活跃使用期间保留,注销账户后合理清理。 +- **个人内容记录**:在合理周期内保留,定期清理或匿名化。 +- **设备和日志数据**:在有限期限后自动删除。 + +--- + +## 4. 信息共享与披露 + +### 4.1 个人信息的出售 + +我**不以任何形式出售、出租或交易您的个人信息**,绝不会为商业利益出售您的数据。 + +### 4.2 与第三方服务提供商共享 + +我只与应用运行所需的可信赖第三方服务提供商共享数据,并签署严格的数据保护限制: + +- 云存储和服务器服务 +- 应用运营分析、崩溃监控工具 +- 苹果官方推送和系统服务能力 + +所有第三方均被禁止将您的数据用于独立的商业目的。 + +### 4.3 法律披露 + +仅在以下情况下您的数据可能被披露: + +- 法律、法规、法院命令或官方政府要求所规定 +- 经您明确自愿授权和同意 +- 为保护个人合法权益和公共安全 + +--- + +## 5. 您的美国隐私权利(包括加州居民) + +根据 CCPA/CPRA 和美国地方隐私法律,您享有以下权利: + +1. **知情权**:查询收集的个人数据类型和范围。 +2. **访问权**:获取您的个人使用数据副本。 +3. **删除权**:申请删除您的账户及相关个人数据。 +4. **更正权**:修改不正确的个人信息。 +5. **数据携带权**:以可读格式获取您的数据。 +6. **选择退出权**:拒绝非必要数据收集和无关推荐。 +7. **限制敏感数据权**:限制使用您的个人敏感内容。 +8. **不受歧视权**:行使隐私权利不受差别待遇。 + +### 如何行使您的权利 + +您可以通过唯一指定联系方式提交数据请求: + +- **联系邮箱**:feedback@xunmee.com + +我将在 45 天内回复您的合法请求,并在处理前妥善验证您的身份以确保数据安全。 + +--- + +## 6. 儿童隐私(COPPA 合规) + +本应用不面向 13 岁以下用户。我不会故意收集 13 岁以下未成年人的任何个人信息。 + +如果您发现未成年人信息被不当收集,请及时通过邮箱联系我,我将按照 COPPA 法规完全删除相关数据。13-17 岁用户需在监护人监督和同意下使用本应用。 + +--- + +## 7. 数据安全 + +我采取行业标准的技术保护措施保护您的数据: + +- 加密存储和加密传输以防止数据泄露 +- 严格的访问限制和日常安全管理 +- 定期异常监控和风险检查 + +请注意,没有任何网络存储系统能实现绝对安全,我将始终保持最高级别的数据保护措施。 + +--- + +## 8. 政策变更 + +本隐私政策可能会不定期更新以适应平台规则和法律调整。重要内容变更将通过应用内提示或邮件提醒提前通知。更新生效后您继续使用本应用,即表示您同意修订后的政策。 + +--- + +## 9. 联系我们 + +如果您对本隐私政策有任何疑问、建议或隐私相关投诉,请联系我: + +**开发者邮箱**:feedback@xunmee.com + +如果您是加州居民且对处理结果不满意,可咨询当地隐私监管机构。 + +--- + +**独立个人开发者** + +**最后更新日期**:2026年4月27日 diff --git a/web/design/assets/legal/zh/terms_of_service.md b/web/design/assets/legal/zh/terms_of_service.md new file mode 100644 index 0000000..68e3aa6 --- /dev/null +++ b/web/design/assets/legal/zh/terms_of_service.md @@ -0,0 +1,121 @@ +# 用户服务条款 + +**最后更新日期**:2026年4月27日 + +--- + +## 1. 条款接受 + +觅爻 MeeYao(以下简称"本应用")由**个人开发者**("我")独立开发、拥有和运营。 + +下载、安装、注册、访问或使用本应用,即表示您("您"或"用户")确认已阅读、理解并无条件同意受本服务条款("条款")及我的隐私政策约束。如果您不同意本条款,请勿使用本应用。 + +--- + +## 2. 年龄要求与 COPPA 合规 + +您声明并保证您年满 13 岁方可使用本应用。 + +- 本应用不面向 13 岁以下儿童。 +- 我不会故意收集 13 岁以下用户的个人信息。如发现 13 岁以下未成年人提交了个人数据,我将立即采取行动删除该信息。 + +--- + +## 3. 服务说明 + +本应用提供与传统易经和六爻文化相关的 AI 辅助文化解读内容,仅供日常参考和文化赏析。 + +- 所有 AI 生成内容和文化参考资料仅供娱乐和个人参考目的。 +- 内容不得视为专业建议,包括但不限于金融、投资、法律、医疗、职业或商业决策。 +- 我不保证本应用内任何 AI 生成内容的准确性、完整性或实用性。 +- 因系统维护、技术异常、网络故障或不可抗力导致的临时服务中断不视为违反本条款。 + +--- + +## 4. 用户账户与数据隐私 + +- 您应提供真实、准确和完整的注册信息,并保持信息更新。 +- 您对保护账户登录凭据和账户下进行的所有活动负全责。 +- 我严格按照公布的隐私政策收集和处理用户个人数据,并遵守适用的美国隐私法律,包括 CCPA/CPRA。 +- 加州居民享有隐私政策中规定的数据访问、删除和隐私权利。 + +--- + +## 5. 知识产权 + +本应用内的所有知识产权,包括但不限于程序代码、文字内容、图形设计、界面内容、标识和视觉元素,均为个人开发者独有,受美国版权法(DMCA)、商标法规和国际知识产权公约保护。 + +您不得: + +- 复制、修改、编辑、分发、复制或基于本应用及其内部内容创作衍生作品。 +- 反向工程、反编译、反汇编、破解或试图获取本应用源代码。 +- 删除、覆盖或更改本应用中的任何版权声明、专有标记和知识产权声明。 + +--- + +## 6. 禁止的用户行为 + +您同意不会: + +- 将本应用用于非法、恶意、欺诈或侵权行为。 +- 发布或传播非法、诽谤、淫秽、威胁、暴力或侵犯第三方的内容。 +- 攻击、干扰或破坏本应用的运行环境、服务器和网络稳定性。 +- 利用系统漏洞进行未授权访问、商业牟利或不当使用。 +- 夸大或虚假宣传应用内内容的功能效果和参考价值。 + +如您违反上述规定,我保留不经事先通知发出警告、限制功能、暂停或终止您账户的权利,并保留必要时追究法律责任的权利。 + +--- + +## 7. 免责声明(美国标准) + +本应用及所有应用功能按"现状"和"可用"基础提供,不提供任何形式的明示或默示保证。包括但不限于适销性、特定用途适用性和不侵犯第三方权利的默示保证。 + +我不保证: + +- 本应用将连续、安全、无错误或不间断运行。 +- 所有生成的文化参考内容将完全符合您的期望。 +- 本应用及其功能完全稳定、无病毒或无缺陷。 + +--- + +## 8. 责任限制 + +在适用美国法律允许的最大范围内: + +- 我不对您使用本应用产生的任何间接、偶然、特殊、后果性或补偿性损害承担责任。 +- 在任何情况下,我都不对因您的独立判断和个人决定引起的争议、损失或风险承担过度责任。 +- 作为免费个人开发应用,不存在付费交易关系;所有使用风险由用户自行承担。 + +--- + +## 9. 赔偿 + +您同意赔偿并使个人开发者免受因以下原因产生的所有索赔、损害、损失、费用和合理法律费用: + +- 您违反本服务条款。 +- 不当使用、滥用或未经授权操作本应用。 +- 您发布的内容导致的第三方知识产权和法律权利侵犯。 + +--- + +## 10. 适用法律与争议解决 + +本条款受美国加利福尼亚州法律管辖并据其解释,排除法律冲突规则。 + +因使用本应用产生的任何争议,双方应首先通过友好协商解决。协商不成的,争议应提交加利福尼亚州洛杉矶县有管辖权的法院解决。 + +--- + +## 11. 条款修改 + +我保留随时修订和更新本服务条款的权利。重大规则变更将通过应用内提醒或官方联系邮箱通知。更新生效后您继续使用本应用,即表示您完全接受修订后的条款。 + +--- + +## 12. 联系方式 + +如果您对本条款有疑问、反馈或法律咨询,请联系: + +- **开发者**:独立个人开发者 +- **联系邮箱**:feedback@xunmee.com diff --git a/web/design/assets/legal/zh_Hant/about_us.md b/web/design/assets/legal/zh_Hant/about_us.md new file mode 100644 index 0000000..218fddd --- /dev/null +++ b/web/design/assets/legal/zh_Hant/about_us.md @@ -0,0 +1,23 @@ +# 關於我們 + +歡迎使用 覓爻 MeeYao,一款依託 AI 技術、以傳統六爻文化與易經智慧為核心的傳統文化參考工具。 + +六爻文化源自博大精深的易經哲學體系,承載著古人對於心念、時序與天地變化相生相融的傳統認知。結合卦象文化、五行理論及干支傳統人文理念,幫助用戶探索東方傳統文化內涵,獲得多元的生活參考視角。 + +覓爻 MeeYao 根植於東方傳統文脈,核心初衷是幫助用戶跳出固有思維局限,以更開闊的視角看待日常抉擇與生活狀態,保持理性平和的心態。我們希望借助現代 AI 技術,讓大眾更輕鬆地了解、感受與體驗中華傳統經典文化。 + +--- + +## 開發者信息 + +**開發者**:Ann Lee + +**聯繫郵箱**:feedback@xunmee.com + +--- + +## 重要免責聲明 + +本 App 所有 AI 生成內容與文化解讀資料,僅作娛樂、文化賞析與個人參考使用。本應用不提供任何專業指導建議,包括但不限於商業、金融、投資、醫療、心理、法律、職業及人生決策等領域。所有生成內容不得作為事實依據或行動決策的唯一標準。開發者不對用戶的個人選擇、行為及衍生後果承擔任何法律責任。請理性看待傳統文化,理性使用本應用。 + +© 2026 Ann Lee 保留所有權利 diff --git a/web/design/assets/legal/zh_Hant/privacy_policy.md b/web/design/assets/legal/zh_Hant/privacy_policy.md new file mode 100644 index 0000000..681ff4d --- /dev/null +++ b/web/design/assets/legal/zh_Hant/privacy_policy.md @@ -0,0 +1,163 @@ +# 隱私政策 + +**最後更新日期**:2026年4月27日 + +**生效日期**:2026年4月27日 + +--- + +## 引言 + +尊敬的用戶,歡迎使用 覓爻 MeeYao(以下簡稱「本應用」),本應用由**個人開發者**(「我」)獨立開發和運營。我致力於保護您的個人隱私,並遵守適用的美國聯邦和州隱私法律,包括《加州消費者隱私法》(CCPA/CPRA)、《兒童在線隱私保護法》(COPPA)、CalOPPA 以及其他美國州隱私法規。 + +本隱私政策清晰說明: + +- 我收集哪些個人信息 +- 您的數據如何被使用、存儲和共享 +- 您在美國法規下的法定隱私權利 +- 如何提交數據請求 + +本政策適用於本應用的所有用戶。加州居民享有第5節規定的額外權利。 + +--- + +## 1. 我收集的信息 + +我只收集為提供、維護和優化應用文化參考功能所需的數據。所有數據根據 CCPA/CPRA 分類為個人信息和敏感個人信息(SPI)。 + +### 1.1 您直接提供的信息 + +- **賬戶信息**:電子郵箱地址、驗證驗證碼(賬戶註冊和安全驗證所需) +- **個人資料**:您自願設置的暱稱或顯示名稱 +- **個人內容**:您輸入的問題、文化解讀記錄和本地會話內容 +- **支持信息**:您發送的反饋、諮詢消息 + +### 1.2 自動收集的信息 + +當您使用本應用時,將收集有限的自動數據以確保正常運行: + +- **設備信息**:設備型號、操作系統版本、唯一設備標識符、設備配置 +- **技術數據**:IP 地址(用於粗略區域訪問識別)、訪問時間、崩潰日誌和操作性能數據 +- **使用數據**:功能使用記錄、應用停留時長和應用內交互行為 + +--- + +## 2. 我如何使用您的信息 + +您的信息僅用於以下合法且有限的目的: + +1. **提供核心功能**:處理您的輸入內容,生成 AI 文化解讀內容,記錄本地使用記錄。 +2. **賬戶安全**:完成用戶驗證,防止異常登錄,保護您的賬戶安全。 +3. **產品優化**:分析匿名使用數據以修復錯誤、優化操作體驗和提升產品性能。 +4. **用戶協助**:回覆您的反饋,解決您的使用問題。 +5. **服務提醒**:推送必要的系統通知和政策更新提醒。 +6. **法律合規**:滿足法定合規要求和官方平台審核規則。 + +未經您的明確同意,我**不會**將您的個人敏感內容用於商業廣告或未經授權的營銷。 + +--- + +## 3. 數據存儲、保留與跨境傳輸 + +### 3.1 存儲位置 + +通過本應用收集的用戶數據可能存儲於位於美國的第三方安全雲服務器上。所有跨境數據傳輸均採用加密傳輸協議以確保數據安全。 + +### 3.2 保留期限 + +數據僅在必要時限內保留: + +- **賬戶數據**:在您活躍使用期間保留,註銷賬戶後合理清理。 +- **個人內容記錄**:在合理週期內保留,定期清理或匿名化。 +- **設備和日誌數據**:在有限期限後自動刪除。 + +--- + +## 4. 信息共享與披露 + +### 4.1 個人信息的出售 + +我**不以任何形式出售、出租或交易您的個人信息**,絕不會為商業利益出售您的數據。 + +### 4.2 與第三方服務提供商共享 + +我只與應用運行所需的可信賴第三方服務提供商共享數據,並簽署嚴格的數據保護限制: + +- 雲存儲和服務器服務 +- 應用運營分析、崩潰監控工具 +- 蘋果官方推送和系統服務能力 + +所有第三方均被禁止將您的數據用於獨立的商業目的。 + +### 4.3 法律披露 + +僅在以下情況下您的數據可能被披露: + +- 法律、法規、法院命令或官方政府要求所規定 +- 經您明確自願授權和同意 +- 為保護個人合法權益和公共安全 + +--- + +## 5. 您的美國隱私權利(包括加州居民) + +根據 CCPA/CPRA 和美國地方隱私法律,您享有以下權利: + +1. **知情權**:查詢收集的個人數據類型和範圍。 +2. **訪問權**:獲取您的個人使用數據副本。 +3. **刪除權**:申請刪除您的賬戶及相關個人數據。 +4. **更正權**:修改不正確的個人信息。 +5. **數據攜帶權**:以可讀格式獲取您的數據。 +6. **選擇退出權**:拒絕非必要數據收集和無關推薦。 +7. **限制敏感數據權**:限制使用您的個人敏感內容。 +8. **不受歧視權**:行使隱私權利不受差別待遇。 + +### 如何行使您的權利 + +您可以通過唯一指定聯繫方式提交數據請求: + +- **聯繫郵箱**:feedback@xunmee.com + +我將在 45 天內回覆您的合法請求,並在處理前妥善驗證您的身份以確保數據安全。 + +--- + +## 6. 兒童隱私(COPPA 合規) + +本應用不面向 13 歲以下用戶。我不會故意收集 13 歲以下未成年人的任何個人信息。 + +如果您發現未成年人信息被不當收集,請及時通過郵箱聯繫我,我將按照 COPPA 法規完全刪除相關數據。13-17 歲用戶需在監護人監督和同意下使用本應用。 + +--- + +## 7. 數據安全 + +我採取行業標準的技術保護措施保護您的數據: + +- 加密存儲和加密傳輸以防止數據洩露 +- 嚴格的訪問限制和日常安全管理 +- 定期異常監控和風險檢查 + +請注意,沒有任何網絡存儲系統能實現絕對安全,我將始終保持最高級別的數據保護措施。 + +--- + +## 8. 政策變更 + +本隱私政策可能會不定期更新以適應平台規則和法律調整。重要內容變更將通過應用內提示或郵件提醒提前通知。更新生效後您繼續使用本應用,即表示您同意修訂後的政策。 + +--- + +## 9. 聯繫我們 + +如果您對本隱私政策有任何疑問、建議或隱私相關投訴,請聯繫我: + +**開發者郵箱**:feedback@xunmee.com + +如果您是加州居民且對處理結果不滿意,可諮詢當地隱私監管機構。 + +--- + +**獨立個人開發者** + +**最後更新日期**:2026年4月27日 diff --git a/web/design/assets/legal/zh_Hant/terms_of_service.md b/web/design/assets/legal/zh_Hant/terms_of_service.md new file mode 100644 index 0000000..7e33a7f --- /dev/null +++ b/web/design/assets/legal/zh_Hant/terms_of_service.md @@ -0,0 +1,121 @@ +# 用戶服務條款 + +**最後更新日期**:2026年4月27日 + +--- + +## 1. 條款接受 + +覓爻 MeeYao(以下簡稱「本應用」)由**個人開發者**(「我」)獨立開發、擁有和運營。 + +下載、安裝、註冊、訪問或使用本應用,即表示您(「您」或「用戶」)確認已閱讀、理解並無條件同意受本服務條款(「條款」)及我的隱私政策約束。如果您不同意本條款,請勿使用本應用。 + +--- + +## 2. 年齡要求與 COPPA 合規 + +您聲明並保證您年滿 13 歲方可使用本應用。 + +- 本應用不面向 13 歲以下兒童。 +- 我不會故意收集 13 歲以下用戶的個人信息。如發現 13 歲以下未成年人提交了個人數據,我將立即採取行動刪除該信息。 + +--- + +## 3. 服務說明 + +本應用提供與傳統易經和六爻文化相關的 AI 輔助文化解讀內容,僅供日常參考和文化賞析。 + +- 所有 AI 生成內容和文化參考資料僅供娛樂和個人參考目的。 +- 內容不得視為專業建議,包括但不限於金融、投資、法律、醫療、職業或商業決策。 +- 我不保證本應用內任何 AI 生成內容的準確性、完整性或實用性。 +- 因系統維護、技術異常、網絡故障或不可抗力導致的臨時服務中斷不視為違反本條款。 + +--- + +## 4. 用戶賬戶與數據隱私 + +- 您應提供真實、準確和完整的註冊信息,並保持信息更新。 +- 您對保護賬戶登錄憑據和賬戶下進行的所有活動負全責。 +- 我嚴格按照公布的隱私政策收集和處理用戶個人數據,並遵守適用的美國隱私法律,包括 CCPA/CPRA。 +- 加州居民享有隱私政策中規定的數據訪問、刪除和隱私權利。 + +--- + +## 5. 知識產權 + +本應用內的所有知識產權,包括但不限於程序代碼、文字內容、圖形設計、界面內容、標識和視覺元素,均為個人開發者獨有,受美國版權法(DMCA)、商標法規和國際知識產權公約保護。 + +您不得: + +- 複製、修改、編輯、分發、複製或基於本應用及其內部內容創作衍生作品。 +- 反向工程、反編譯、反彙編、破解或試圖獲取本應用源代碼。 +- 刪除、覆蓋或更改本應用中的任何版權聲明、專有標記和知識產權聲明。 + +--- + +## 6. 禁止的用戶行為 + +您同意不會: + +- 將本應用用於非法、惡意、欺詐或侵權行為。 +- 發布或傳播非法、誹謗、淫穢、威脅、暴力或侵犯第三方的內容。 +- 攻擊、干擾或破壞本應用的運行環境、服務器和網絡穩定性。 +- 利用系統漏洞進行未授權訪問、商業牟利或不當使用。 +- 誇大或虛假宣傳應用內內容的功能效果和參考價值。 + +如您違反上述規定,我保留不經事先通知發出警告、限制功能、暫停或終止您賬戶的權利,並保留必要時追究法律責任的權利。 + +--- + +## 7. 免責聲明(美國標準) + +本應用及所有應用功能按「現狀」和「可用」基礎提供,不提供任何形式的明示或默示保證。包括但不限於適銷性、特定用途適用性和不侵犯第三方權利的默示保證。 + +我不保證: + +- 本應用將連續、安全、無錯誤或不間斷運行。 +- 所有生成的文化參考內容將完全符合您的期望。 +- 本應用及其功能完全穩定、無病毒或無缺陷。 + +--- + +## 8. 責任限制 + +在適用美國法律允許的最大範圍內: + +- 我不對您使用本應用產生的任何間接、偶然、特殊、後果性或補償性損害承擔責任。 +- 在任何情況下,我都不對因您的獨立判斷和個人決定引起的爭議、損失或風險承擔過度責任。 +- 作為免費個人開發應用,不存在付費交易關係;所有使用風險由用戶自行承擔。 + +--- + +## 9. 賠償 + +您同意賠償並使個人開發者免受因以下原因產生的所有索賠、損害、損失、費用和合理法律費用: + +- 您違反本服務條款。 +- 不當使用、濫用或未經授權操作本應用。 +- 您發布的內容導致的第三方知識產權和法律權利侵犯。 + +--- + +## 10. 適用法律與爭議解決 + +本條款受美國加利福尼亞州法律管轄並據其解釋,排除法律衝突規則。 + +因使用本應用產生的任何爭議,雙方應首先通過友好協商解決。協商不成的,爭議應提交加利福尼亞州洛杉磯縣有管轄權的法院解決。 + +--- + +## 11. 條款修改 + +我保留隨時修訂和更新本服務條款的權利。重大規則變更將通過應用內提醒或官方聯繫郵箱通知。更新生效後您繼續使用本應用,即表示您完全接受修訂後的條款。 + +--- + +## 12. 聯繫方式 + +如果您對本條款有疑問、反饋或法律諮詢,請聯繫: + +- **開發者**:獨立個人開發者 +- **聯繫郵箱**:feedback@xunmee.com diff --git a/web/design/eryao.pen b/web/design/eryao.pen new file mode 100644 index 0000000..0bed5ca --- /dev/null +++ b/web/design/eryao.pen @@ -0,0 +1,21365 @@ +{ + "version": "2.11", + "children": [ + { + "type": "frame", + "id": "heaGk", + "x": 0, + "y": 0, + "name": "Landing Page", + "width": 1440, + "height": 3200, + "fill": "#FFFFFF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "kIPTF", + "name": "Navbar", + "width": "fill_container", + "height": 80, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E2E8F0" + }, + "padding": [ + 0, + 80 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "K3RwsA", + "name": "navBrand", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "FN1Fw", + "name": "navLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "d8scf", + "name": "navTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "Q50Ng2", + "name": "navLinks", + "gap": 40, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "btT5c", + "name": "navLink1", + "fill": "#475569", + "content": "功能", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "x5TqU7", + "name": "navLink2", + "fill": "#475569", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Inb86", + "name": "navLink3", + "fill": "#475569", + "content": "关于", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "S8p6lF", + "name": "Lang Switcher", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 4, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "c5UeNA", + "name": "langBadge", + "width": 20, + "height": 20, + "fill": "#F1F5F9", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "C8vkc4", + "fill": "#64748B", + "content": "中", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "yFEEy", + "name": "langCurrent", + "fill": "#475569", + "content": "简体中文", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "i3RiH", + "name": "langArrow", + "fill": "#94A3B8", + "content": "▾", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cUEM0", + "name": "navCta", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LRmqY", + "name": "navCtaText", + "fill": "#FFFFFF", + "content": "开始使用", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "VWN9U", + "name": "Hero", + "width": "fill_container", + "height": 720, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#FFFFFF", + "position": 0 + }, + { + "color": "#F5F3FF", + "position": 1 + } + ] + }, + "layout": "vertical", + "gap": 32, + "padding": [ + 0, + 80 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BnavD", + "name": "heroBadge", + "fill": "#EDE9FE", + "cornerRadius": 20, + "gap": 8, + "padding": [ + 8, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "CKD7e", + "name": "heroBadgeText", + "fill": "#7C3AED", + "content": "传承千年的东方智慧", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "DXPhM", + "name": "heroHeadline", + "fill": "#0F172A", + "content": "以易经之名 寻心中所惑", + "lineHeight": 1.1, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 64, + "fontWeight": "800" + }, + { + "type": "text", + "id": "Y22qR", + "name": "heroSubtext", + "fill": "#64748B", + "content": "每一次签问,都是与自己的对话。觅爻将古老易经智慧与现代体验结合,让你在宁静中找到属于此刻的指引。", + "lineHeight": 1.6, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "dBTGW", + "name": "heroCtaGroup", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "txDY7", + "name": "heroPrimaryBtn", + "fill": "#7C3AED", + "cornerRadius": 12, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#7C3AED40", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 20 + }, + "padding": [ + 16, + 32 + ], + "children": [ + { + "type": "text", + "id": "W56gaI", + "name": "heroPrimaryBtnText", + "fill": "#FFFFFF", + "content": "免费开始签问", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "jdUop", + "name": "heroSecondaryBtn", + "fill": "#00000000", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "padding": [ + 16, + 32 + ], + "children": [ + { + "type": "text", + "id": "XhZJf", + "name": "heroSecondaryBtnText", + "fill": "#475569", + "content": "了解更多", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "J3Vlse", + "name": "heroTrust", + "fill": "#94A3B8", + "content": "已为 10,000+ 用户提供签问服务", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "vcjdT", + "name": "Showcase", + "width": "fill_container", + "height": 560, + "fill": "#FFFFFF", + "gap": 80, + "padding": 80, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BkawA", + "name": "showcaseLeft", + "width": 600, + "layout": "vertical", + "gap": 32, + "children": [ + { + "type": "text", + "id": "Q9QJDN", + "name": "showcaseTitle", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": 560, + "content": "仪式感的签问体验", + "fontFamily": "Inter", + "fontSize": 40, + "fontWeight": "700" + }, + { + "type": "text", + "id": "rDSE8", + "name": "showcaseDesc", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 560, + "content": "不同于简单的随机算法,觅爻在每一次签问中融入易经的哲学思考。静心、默念、抽取三步完成,却是一次内心的沉淀之旅。", + "lineHeight": 1.7, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "K9XqX", + "name": "showcaseFeatureList", + "width": "fill_container", + "layout": "vertical", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "il6Oh", + "name": "showcaseFeature1", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "nfV5T", + "name": "showcaseF1Icon", + "width": 40, + "height": 40, + "fill": "#F5F3FF", + "cornerRadius": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kNws3", + "name": "showcaseF1IconText", + "fill": "#7C3AED", + "content": "64", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "LW9fL", + "name": "showcaseF1Text", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "FcutK", + "name": "showcaseF1Title", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": 360, + "content": "64卦精解", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "c9M7L", + "name": "showcaseF1Desc", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 360, + "content": "每一卦配有详细爻辞与今译", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "GQ8YV", + "name": "showcaseRight", + "width": 480, + "height": 400, + "fill": "#F5F3FF", + "cornerRadius": 24, + "layout": "vertical", + "padding": 40, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qbaxb", + "name": "showcasePhone", + "width": 280, + "height": 360, + "fill": "#FFFFFF", + "cornerRadius": 32, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000012", + "offset": { + "x": 0, + "y": 20 + }, + "blur": 40 + }, + "layout": "vertical", + "gap": 20, + "padding": 24, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IDX3g", + "name": "phoneHeader", + "fill": "#7C3AED", + "content": "乾卦 · 元亨利贞", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "T1dNC", + "name": "phoneGua", + "opacity": 0.9, + "fill": "#7C3AED", + "content": "䷀", + "fontFamily": "Inter", + "fontSize": 100, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "mVIsb", + "name": "phoneText", + "fill": "#475569", + "content": "天行健,君子以自强不息。", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "OajLC", + "name": "Testimonials", + "width": "fill_container", + "height": 600, + "fill": "#0F172A", + "layout": "vertical", + "gap": 48, + "padding": 80, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K7Uxv", + "name": "testimonialsTitle", + "fill": "#FFFFFF", + "content": "用户心声", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 40, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "o8TREm", + "name": "testimonialsGrid", + "width": "fill_container", + "gap": 24, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "nx5e0", + "name": "testimonial1", + "width": 380, + "height": 200, + "fill": "#1E293B", + "cornerRadius": 16, + "layout": "vertical", + "gap": 16, + "padding": 32, + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "b1tlwd", + "name": "t1Quote", + "fill": "#E2E8F0", + "textGrowth": "fixed-width", + "width": 316, + "content": "在最迷茫的时候,觅爻给了我一个方向。不管结果如何,那种静下心来的过程本身就很有帮助。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "zeP4h", + "name": "t1Author", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "mU5OO", + "name": "t1Avatar", + "width": 40, + "height": 40, + "fill": "#6366F1", + "cornerRadius": 20 + }, + { + "type": "text", + "id": "xJGKu", + "name": "t1Name", + "fill": "#FFFFFF", + "content": "林小姐 · 产品经理", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "oyBKo", + "name": "testimonial2", + "width": 380, + "height": 200, + "fill": "#1E293B", + "cornerRadius": 16, + "layout": "vertical", + "gap": 16, + "padding": 32, + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "a0OfKl", + "name": "t2Quote", + "fill": "#E2E8F0", + "textGrowth": "fixed-width", + "width": 316, + "content": "界面很清爽,没有乱七八糟的广告。每次签问都像是一次心灵的短暂旅行。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "SKx3t", + "name": "t2Author", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Af0iw", + "name": "t2Avatar", + "width": 40, + "height": 40, + "fill": "#8B5CF6", + "cornerRadius": 20 + }, + { + "type": "text", + "id": "Gd0O6", + "name": "t2Name", + "fill": "#FFFFFF", + "content": "张先生 · 创业者", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "V3VnAf", + "name": "testimonial3", + "width": 380, + "height": 200, + "fill": "#1E293B", + "cornerRadius": 16, + "layout": "vertical", + "gap": 16, + "padding": 32, + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "qtZOF", + "name": "t3Quote", + "fill": "#E2E8F0", + "textGrowth": "fixed-width", + "width": 316, + "content": "我是一个程序员,原本不信这些。但试了几次后发现,这种随机性反而让我看到平时忽略的可能性。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "oCMej", + "name": "t3Author", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ViIIH", + "name": "t3Avatar", + "width": 40, + "height": 40, + "fill": "#06B6D4", + "cornerRadius": 20 + }, + { + "type": "text", + "id": "FoDmK", + "name": "t3Name", + "fill": "#FFFFFF", + "content": "王先生 · 软件工程师", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "GVwZW", + "name": "CTA", + "width": "fill_container", + "height": 440, + "fill": "#7C3AED", + "layout": "vertical", + "gap": 24, + "padding": 80, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "GMlwh", + "name": "ctaTitle", + "fill": "#FFFFFF", + "content": "开始你的第一次签问", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 48, + "fontWeight": "700" + }, + { + "type": "text", + "id": "TbvSI", + "name": "ctaSubtitle", + "fill": "#E9D5FF", + "content": "无需注册,立即体验。让古老的智慧,为现代的你指引方向。", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "oaxdQ", + "name": "ctaBtn", + "fill": "#FFFFFF", + "cornerRadius": 12, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 8 + }, + "blur": 24 + }, + "padding": [ + 20, + 48 + ], + "children": [ + { + "type": "text", + "id": "bfv8p", + "name": "ctaBtnText", + "fill": "#7C3AED", + "content": "免费开始 →", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Bi34P", + "name": "Footer", + "width": "fill_container", + "height": 300, + "fill": "#020617", + "gap": 64, + "padding": [ + 48, + 80 + ], + "children": [ + { + "type": "frame", + "id": "gpfte", + "name": "footerBrand", + "width": 280, + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "XXbcK", + "name": "footerLogoGroup", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "SRnjp", + "name": "footerLogo", + "width": 32, + "height": 32, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 6 + }, + { + "type": "text", + "id": "gFS7V", + "name": "footerBrandName", + "fill": "#FFFFFF", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "w3g6D", + "name": "footerDesc", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 260, + "content": "以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gmvdJ", + "name": "footerLinksGroup", + "width": "fill_container", + "gap": 120, + "children": [ + { + "type": "frame", + "id": "tEIvz", + "name": "footerCol1", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "Q1GMhX", + "name": "footerCol1Title", + "fill": "#FFFFFF", + "content": "产品", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "SKGbG", + "name": "footerCol1Link1", + "fill": "#64748B", + "content": "功能介绍", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "VMDk4", + "name": "footerCol1Link2", + "fill": "#64748B", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "N7cRLM", + "name": "footerCol2", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "IBqZy", + "name": "footerCol2Title", + "fill": "#FFFFFF", + "content": "支持", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "TJlje", + "name": "footerCol2Link1", + "fill": "#64748B", + "content": "帮助中心", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "x2spys", + "name": "footerCol2Link2", + "fill": "#64748B", + "content": "联系我们", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "j31Pl", + "name": "footerCol3", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "oygab", + "name": "footerCol3Title", + "fill": "#FFFFFF", + "content": "法律", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ao9GS", + "name": "footerCol3Link1", + "fill": "#64748B", + "content": "隐私政策", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "FphFn", + "name": "footerCol3Link2", + "fill": "#64748B", + "content": "服务条款", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "meDSx", + "fill": "#64748B", + "content": "免责声明", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "z8Qy0r", + "x": 0, + "y": 3400, + "name": "Login Page", + "width": 1440, + "height": 900, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#F5F0FF", + "position": 0 + }, + { + "color": "#FFFFFF", + "position": 1 + } + ] + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "zqKAM", + "layoutPosition": "absolute", + "x": -80, + "y": -60, + "opacity": 0.3, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 135, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#E8D5FF", + "position": 0 + }, + { + "color": "#D5E8FF", + "position": 1 + } + ] + }, + "width": 300, + "height": 300 + }, + { + "type": "ellipse", + "id": "QH1hK", + "layoutPosition": "absolute", + "x": 1240, + "y": 750, + "opacity": 0.2, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 45, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#C8E6FF", + "position": 0 + }, + { + "color": "#E8D5FF", + "position": 1 + } + ] + }, + "width": 200, + "height": 200 + }, + { + "type": "ellipse", + "id": "SYlat", + "layoutPosition": "absolute", + "x": 550, + "y": -40, + "opacity": 0.06, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#673AB7", + "position": 0 + }, + { + "color": "#9C27B0", + "position": 1 + } + ] + }, + "width": 120, + "height": 120 + }, + { + "type": "frame", + "id": "x6xQd", + "name": "card", + "width": 420, + "fill": "#FFFFFF", + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000D", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 24 + }, + "layout": "vertical", + "gap": 20, + "padding": 32, + "children": [ + { + "type": "frame", + "id": "y6IAve", + "name": "header", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "cQhs2", + "name": "logoFrame", + "width": 56, + "height": 56, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 14, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center" + }, + { + "type": "text", + "id": "F9MvP", + "name": "welcome", + "fill": "#1A1A2E", + "content": "欢迎", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "tmiC7", + "name": "subtitle", + "fill": "#666666", + "content": "使用邮箱验证码快速登录或注册", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "S6UnnJ", + "name": "emailSection", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "PF4Xx", + "fill": "#333333", + "content": "邮箱地址", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "CAo5r", + "name": "emailInput", + "width": "fill_container", + "height": 44, + "fill": "#F8F8F8", + "cornerRadius": 8, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "padding": [ + 0, + 14 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "D3HSbk", + "fill": "#999999", + "content": "请输入邮箱地址", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Asroc", + "name": "codeSection", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "PV071", + "fill": "#333333", + "content": "验证码", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "S6OgE", + "name": "codeRow", + "width": "fill_container", + "height": 44, + "gap": 8, + "children": [ + { + "type": "frame", + "id": "VPB4E", + "name": "codeInput", + "width": "fill_container", + "height": 44, + "fill": "#F8F8F8", + "cornerRadius": 8, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "padding": [ + 0, + 14 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "Q6wXRL", + "fill": "#999999", + "content": "请输入验证码", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Dt5bi", + "name": "sendBtn", + "width": 120, + "height": 44, + "fill": "#7C3AED", + "cornerRadius": 8, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kSFeT", + "fill": "#FFFFFF", + "content": "获取验证码", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wQpTS", + "name": "submitBtn", + "width": "fill_container", + "height": 44, + "fill": "#7C3AED", + "cornerRadius": 8, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "EkGBo", + "fill": "#FFFFFF", + "content": "登录 / 注册", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "XZsaU", + "name": "agreementRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "KHxGH", + "width": 16, + "height": 16, + "fill": "#FFFFFF", + "cornerRadius": 4, + "stroke": { + "thickness": 1.5, + "fill": "#7C3AED" + } + }, + { + "type": "text", + "id": "JSWdo", + "fill": "#666666", + "content": "我已阅读并同意", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "lNv2x", + "fill": "#7C3AED", + "content": "《隐私政策》", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "iszUM", + "fill": "#666666", + "content": "和", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "J2SxQ", + "fill": "#7C3AED", + "content": "《服务条款》", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "AOUHs", + "x": 1600, + "y": 3400, + "name": "Dashboard", + "width": 1440, + "height": 960, + "fill": "#F8FAFC", + "children": [ + { + "type": "frame", + "id": "p46yB", + "name": "sidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "NEaiW", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qrtTC", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "k4InO", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "SOoRd", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "r6KD8P", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "OqtVz", + "name": "navHome", + "width": "fill_container", + "fill": "#673AB7", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ShvMr", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "bEI6U", + "fill": "#FFFFFF", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "V44u2j", + "name": "navStore", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "lvOBP", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "eRXqu", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "CfXa2", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "EyIvL", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "WXZXJ", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "vLJPK", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "VvCuB", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "w9rISa", + "name": "navManual", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "n3obxK", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "G3Tz4", + "fill": "#64748B", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QW6Qa", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "tQbhF", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "QyUsR", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Di1mQ", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "v9gHF8", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "isdIM", + "name": "hist1Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "H2HK8R", + "name": "hist1Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "WzG1o", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "L5SMnz", + "name": "lang1Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "FOOAz", + "name": "lang1Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "g2CEf5", + "name": "navSettings", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "VpFyg", + "name": "set1Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "H9HPSa", + "name": "set1Text", + "fill": "#64748B", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gAGxc", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "ZIkzP", + "name": "userSection", + "width": "fill_container", + "cornerRadius": 10, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "T1wcdo", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UO7ZS", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "sgu1u", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "yYPSb", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "qrzFQ", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "mt7Ht", + "name": "mainArea", + "width": 1180, + "height": 960, + "fill": "#F8FAFC", + "layout": "vertical", + "gap": 24, + "padding": [ + 32, + 40 + ], + "children": [ + { + "type": "frame", + "id": "Is2s3", + "name": "headerBar", + "width": "fill_container", + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "WoDAL", + "name": "greeting", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "ixYTe", + "fill": "#0F172A", + "content": "下午好,林小姐", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "600" + }, + { + "type": "text", + "id": "nM3MJ", + "fill": "#64748B", + "content": "今天想要探寻什么方向?", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ARBBK", + "name": "headerRight", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "VG9iR", + "name": "notifyWrap", + "width": 42, + "height": 42, + "layout": "none", + "children": [ + { + "type": "frame", + "id": "qV4At", + "x": 0, + "y": 0, + "name": "notifyBell", + "width": 40, + "height": 40, + "fill": "#F1F5F9", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "scCAv", + "fill": "#64748B", + "content": "🔔", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hryoc", + "x": 28, + "y": -2, + "name": "notifyBadge", + "width": 18, + "height": 18, + "fill": "#EF4444", + "cornerRadius": 9, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "i2hnk", + "fill": "#FFFFFF", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wmfDv", + "name": "heroCard", + "width": "fill_container", + "height": 280, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 135, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#673AB7", + "position": 0 + }, + { + "color": "#512DA8", + "position": 1 + } + ] + }, + "cornerRadius": 20, + "gap": 48, + "padding": 48, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "SNiIA", + "name": "heroLeft", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "Q48ub", + "fill": "#FFFFFF", + "content": "开始您的卦象之旅", + "fontFamily": "Inter", + "fontSize": 32, + "fontWeight": "700" + }, + { + "type": "text", + "id": "CQ1Ir", + "fill": "#E9D5FF", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "借助AI智能,探索未来的可能。心中有问,起卦便知。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "hqLjR", + "name": "ctaBtn", + "width": 152, + "height": 48, + "fill": "#FFFFFF", + "cornerRadius": 12, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000030", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "F4jjb7", + "fill": "#7C3AED", + "content": "立即起卦", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "uk3X5", + "name": "heroRight", + "width": 220, + "height": 184, + "fill": "#FFFFFF15", + "cornerRadius": 16, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pT1QH", + "fill": "#FFFFFF66", + "content": "⚊ ⚋\n⚋ ⚊\n⚊ ⚊", + "lineHeight": 1.4, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "h0EvH", + "name": "historySection", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "scS6L", + "name": "historyHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pebs9", + "fill": "#0F172A", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "nX1Or", + "fill": "#7C3AED", + "content": "查看全部 →", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xCHtf", + "name": "historyList", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "AnKdR", + "name": "card1", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1F5F9" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "uDF4r", + "width": 40, + "height": 40, + "fill": "#E6F7FF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lH3HT", + "fill": "#1890FF", + "content": "◇", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cAoKV", + "name": "card1Content", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "Kdbz5", + "name": "card1Row1", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XN4yJ", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "今年转岗是否合适?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "500" + }, + { + "type": "text", + "id": "oj04t", + "fill": "#94A3B8", + "content": "2024-05-08", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "a2Ihm", + "name": "card1Tags", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Rs9qZ", + "fill": "#E6F7FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "DnXKe", + "fill": "#1890FF", + "content": "事业", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "i3OwUO", + "fill": "#F0E6FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "jBMmW", + "fill": "#7C3AED", + "content": "天雷无妄", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "xNqNp", + "fill": "#FFF8E1", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "SlfMl", + "fill": "#FFB300", + "content": "上上签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "CpSFt", + "name": "card2", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1F5F9" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RihZA", + "name": "card2Icon", + "width": 40, + "height": 40, + "fill": "#E6F7FF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vaT02", + "fill": "#1890FF", + "content": "◇", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "e3K6hl", + "name": "card2Content", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "fRztL", + "name": "card2Row1", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cxwFI", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "最近感情是否能推进?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "500" + }, + { + "type": "text", + "id": "FfvWG", + "fill": "#94A3B8", + "content": "2024-05-07", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mj0gH", + "name": "card2Tags", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "a8acC", + "name": "card2t1", + "fill": "#FCE4EC", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "U4Sl2K", + "fill": "#E91E63", + "content": "感情", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Fp7fd", + "name": "card2t2", + "fill": "#F0E6FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "esViU", + "fill": "#7C3AED", + "content": "泽火革", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "dfoVZ", + "name": "card2t3", + "fill": "#F5F0FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "YhNly", + "fill": "#7C3AED", + "content": "中上签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "z3Rfb7", + "name": "card3", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1F5F9" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "MtQ38", + "name": "card3Icon", + "width": 40, + "height": 40, + "fill": "#E6F7FF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JC6kx", + "fill": "#1890FF", + "content": "◇", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GtdIv", + "name": "card3Content", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "JXtbQ", + "name": "card3Row1", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ISlZB", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "本季度投资节奏如何?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "500" + }, + { + "type": "text", + "id": "DoVNW", + "fill": "#94A3B8", + "content": "2024-05-05", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "K6jm7A", + "name": "card3Tags", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "FQpUL", + "name": "card3t1", + "fill": "#E8F5E9", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "w8Mozo", + "fill": "#4CAF50", + "content": "财富", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "drpVF", + "name": "card3t2", + "fill": "#F0E6FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "Spf57", + "fill": "#7C3AED", + "content": "风地观", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "O2m1K8", + "name": "card3t3", + "fill": "#F5F5F5", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "hCniP", + "fill": "#9E9E9E", + "content": "中下签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "GO7Sr", + "name": "card4", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1F5F9" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "b97cJ", + "name": "card4Icon", + "width": 40, + "height": 40, + "fill": "#E6F7FF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K0FkXl", + "fill": "#1890FF", + "content": "◇", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "x5Fh5", + "name": "card4Content", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "NUfOV", + "name": "card4Row1", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YyJQK", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "近期身体不适,需要注意什么?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "500" + }, + { + "type": "text", + "id": "RuIpS", + "fill": "#94A3B8", + "content": "2024-05-03", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "grx5S", + "name": "card4Tags", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "MHov6", + "name": "card4t1", + "fill": "#FFF3E0", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "nDNZT", + "fill": "#F57C00", + "content": "健康", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "a6meu", + "name": "card4t2", + "fill": "#F0E6FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "HPB52", + "fill": "#7C3AED", + "content": "水雷屯", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "AkSkd", + "name": "card4t3", + "fill": "#FCE4EC", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "KoVfm", + "fill": "#E53935", + "content": "下下签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "t4elje", + "x": 1600, + "y": 0, + "name": "Features Page", + "width": 1440, + "height": 1500, + "fill": "#FFFFFF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "z0xoBx", + "name": "featNav", + "width": 1440, + "height": 80, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E2E8F0" + }, + "padding": [ + 0, + 80 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "p7dgub", + "name": "navBrand", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "cjeYZ", + "name": "navLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "Y2mHt", + "name": "navTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "BydW2", + "name": "navLinks", + "gap": 40, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rk3eg", + "name": "navLink1", + "fill": "#7C3AED", + "content": "功能", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "TsknJ", + "name": "navLink2", + "fill": "#475569", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Lh1pZ", + "name": "navLink3", + "fill": "#475569", + "content": "关于", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "B2eyi", + "name": "Lang Switcher", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 4, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "OMHqi", + "name": "langBadge", + "width": 20, + "height": 20, + "fill": "#F1F5F9", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kQFdO", + "fill": "#64748B", + "content": "中", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "w33tM", + "name": "langCurrent", + "fill": "#475569", + "content": "简体中文", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "zLt1M", + "name": "langArrow", + "fill": "#94A3B8", + "content": "▾", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ToDUC", + "name": "navCta", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "z3BHK", + "name": "navCtaText", + "fill": "#FFFFFF", + "content": "开始使用", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "r789b", + "name": "featHero", + "width": "fill_container", + "height": 320, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#FFFFFF", + "position": 0 + }, + { + "color": "#F5F3FF", + "position": 1 + } + ] + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 80 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ieydD", + "fill": "#0F172A", + "content": "功能特性", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 48, + "fontWeight": "800" + }, + { + "type": "text", + "id": "k74mh", + "fill": "#64748B", + "content": "以古老智慧解读今时困惑,觅爻签问提供完整的易学体验", + "lineHeight": 1.6, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Nu5RW", + "fill": "#94A3B8", + "content": "从起卦到解读,每一步都精心设计", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Sa6Ks", + "name": "featGrid", + "width": "fill_container", + "fill": "#FFFFFF", + "layout": "vertical", + "gap": 32, + "padding": 80, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zW2og", + "name": "row1", + "width": "fill_container", + "gap": 24, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "N0M408", + "name": "card1", + "width": 380, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "sJ1Jg", + "name": "icon1", + "width": 48, + "height": 48, + "fill": "#F5F3FF", + "cornerRadius": 12, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DZyrG", + "fill": "#7C3AED", + "textGrowth": "fixed-width", + "width": 24, + "content": "◆", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "cbgPJ", + "fill": "#0F172A", + "content": "两种起卦方式", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "text", + "id": "pqr1s", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 332, + "content": "手动起卦与自动起卦,灵活选择最适合你的方式。推荐使用手动起卦,卦象解读更准确。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TNIKm", + "name": "card2", + "width": 380, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "GoQen", + "name": "icon2", + "width": 48, + "height": 48, + "fill": "#EDE9FE", + "cornerRadius": 12, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "irLwe", + "fill": "#7C3AED", + "textGrowth": "fixed-width", + "width": 24, + "content": "✦", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "p7Yki", + "fill": "#0F172A", + "content": "AI 解卦分析", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "text", + "id": "tnDna", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 332, + "content": "基于传统六爻卦象与周易哲学体系,结合AI智能分析,提供深度卦象解读与建议。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Rbkd8", + "name": "card3", + "width": 380, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "xBbSU", + "name": "icon3", + "width": 48, + "height": 48, + "fill": "#F0FDF4", + "cornerRadius": 12, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wJrpb", + "fill": "#7C3AED", + "textGrowth": "fixed-width", + "width": 24, + "content": "■", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "Z0EJ9K", + "fill": "#0F172A", + "content": "九类问题覆盖", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "text", + "id": "V12ZvN", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 332, + "content": "事业、情感、财富、运势、解梦、健康、学业、寻物等九大领域,全面覆盖日常生活所问。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "B4AVb", + "name": "row2", + "width": "fill_container", + "gap": 24, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "J2eTf", + "name": "card4", + "width": 380, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "mHoXB", + "name": "icon4", + "width": 48, + "height": 48, + "fill": "#FFF7ED", + "cornerRadius": 12, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "hsyaL", + "fill": "#7C3AED", + "textGrowth": "fixed-width", + "width": 24, + "content": "○", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "UX2UZ", + "fill": "#0F172A", + "content": "追问互动", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "text", + "id": "sfUJJ", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 332, + "content": "每次解卦后可深入追问一次,针对卦象细节获取更多洞见,让解读更加全面深入。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "o2YYB", + "name": "card5", + "width": 380, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "eRwKa", + "name": "icon5", + "width": 48, + "height": 48, + "fill": "#FEF2F2", + "cornerRadius": 12, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "bGIfB", + "fill": "#7C3AED", + "textGrowth": "fixed-width", + "width": 24, + "content": "△", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "XKoEd", + "fill": "#0F172A", + "content": "历史记录", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "text", + "id": "eA7Q6", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 332, + "content": "自动保存所有解卦记录,包括卦象详情与AI解读。随时回顾历史,追踪问题变化趋势。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "wu2qs", + "name": "card6", + "width": 380, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "I81u7D", + "name": "icon6", + "width": 48, + "height": 48, + "fill": "#FFFBEB", + "cornerRadius": 12, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TwsUv", + "fill": "#7C3AED", + "textGrowth": "fixed-width", + "width": 24, + "content": "★", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "on9GB", + "fill": "#0F172A", + "content": "点数系统", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "600" + }, + { + "type": "text", + "id": "mBMEa", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 332, + "content": "提供灵活积分套餐:新人专享、入门补充、常用加量、高频进阶。按需购买,自由使用。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "OOEYh", + "name": "featFooter", + "width": 1440, + "height": 300, + "fill": "#020617", + "gap": 64, + "padding": [ + 48, + 80 + ], + "children": [ + { + "type": "frame", + "id": "tn6NX", + "name": "footerBrand", + "width": 280, + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "J6ZINY", + "name": "footerLogoGroup", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "P75ke", + "name": "footerLogo", + "width": 32, + "height": 32, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 6 + }, + { + "type": "text", + "id": "dGpvy", + "name": "footerBrandName", + "fill": "#FFFFFF", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "dbGCw", + "name": "footerDesc", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 260, + "content": "以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "t1dI1", + "name": "footerLinksGroup", + "width": "fill_container", + "gap": 120, + "children": [ + { + "type": "frame", + "id": "adeYP", + "name": "footerCol1", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "PDho1", + "name": "footerCol1Title", + "fill": "#FFFFFF", + "content": "产品", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ZaCt5", + "name": "footerCol1Link1", + "fill": "#64748B", + "content": "功能介绍", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "y666tw", + "name": "footerCol1Link2", + "fill": "#64748B", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lXOwT", + "name": "footerCol2", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "LcUlV", + "name": "footerCol2Title", + "fill": "#FFFFFF", + "content": "支持", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "A8eCdh", + "name": "footerCol2Link1", + "fill": "#64748B", + "content": "帮助中心", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "c7iE75", + "name": "footerCol2Link2", + "fill": "#64748B", + "content": "联系我们", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "jukRY", + "name": "footerCol3", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "D59Qkx", + "name": "footerCol3Title", + "fill": "#FFFFFF", + "content": "法律", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "eQTWc", + "name": "footerCol3Link1", + "fill": "#64748B", + "content": "隐私政策", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "AixQv", + "name": "footerCol3Link2", + "fill": "#64748B", + "content": "服务条款", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "TAKKu", + "x": 3200, + "y": 0, + "name": "Pricing Page", + "width": 1440, + "height": 1400, + "fill": "#FFFFFF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Ia6V2", + "name": "pNav", + "width": 1440, + "height": 80, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E2E8F0" + }, + "padding": [ + 0, + 80 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "KO9ar", + "name": "navBrand", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "AmtB9", + "name": "navLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "proJI", + "name": "navTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "h27fU5", + "name": "navLinks", + "gap": 40, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HbRCi", + "name": "navLink1", + "fill": "#475569", + "content": "功能", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "k2TeHW", + "name": "navLink2", + "fill": "#7C3AED", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "O6Xyz4", + "name": "navLink3", + "fill": "#475569", + "content": "关于", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "mm3qC", + "name": "Lang Switcher", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 4, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hiyDA", + "name": "langBadge", + "width": 20, + "height": 20, + "fill": "#F1F5F9", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Y0VafM", + "fill": "#64748B", + "content": "中", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "R94xQP", + "name": "langCurrent", + "fill": "#475569", + "content": "简体中文", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "E8XEg6", + "name": "langArrow", + "fill": "#94A3B8", + "content": "▾", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MxBOw", + "name": "navCta", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "l4znCv", + "name": "navCtaText", + "fill": "#FFFFFF", + "content": "开始使用", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "h28J8", + "name": "pHeader", + "width": "fill_container", + "height": 300, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#FFFFFF", + "position": 0 + }, + { + "color": "#F5F3FF", + "position": 1 + } + ] + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 80 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "z5Qizt", + "fill": "#0F172A", + "content": "选择适合你的套餐", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 48, + "fontWeight": "800" + }, + { + "type": "text", + "id": "G0AWDM", + "fill": "#64748B", + "content": "灵活积分套餐,按需选择,随时可用", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "YyMMZ", + "name": "pCards", + "width": "fill_container", + "fill": "#FFFFFF", + "layout": "vertical", + "gap": 32, + "padding": [ + 60, + 80 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "HC0ia", + "name": "pRow", + "width": "fill_container", + "gap": 24, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "n8DzVb", + "name": "pc1", + "width": 280, + "height": 310, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "padding": 32, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "QdOsk", + "fill": "#0F172A", + "content": "新人专享包", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "700" + }, + { + "type": "text", + "id": "d6qyFf", + "fill": "#F59E0B", + "content": "限购一次", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "BSKmi", + "fill": "#0F172A", + "content": "$0.99", + "fontFamily": "Inter", + "fontSize": 36, + "fontWeight": "800" + }, + { + "type": "text", + "id": "XKVMY", + "fill": "#7C3AED", + "content": "60 积分", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "e7jCGP", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "text", + "id": "izTxS", + "fill": "#64748B", + "content": "最适合初次体验", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "fX8GF", + "name": "sp1", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "e1ieb", + "width": "fill_container", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 12, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kFJlR", + "fill": "#FFFFFF", + "content": "立即支付", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Y79khe", + "name": "pc2", + "width": 280, + "height": 310, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "padding": 32, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "QXrTC", + "fill": "#0F172A", + "content": "入门补充包", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "700" + }, + { + "type": "text", + "id": "HGqfz", + "fill": "#0F172A", + "content": "$4.99", + "fontFamily": "Inter", + "fontSize": 36, + "fontWeight": "800" + }, + { + "type": "text", + "id": "E7Gmum", + "fill": "#7C3AED", + "content": "100 积分", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "Rlq9e", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "text", + "id": "vdMUs", + "fill": "#64748B", + "content": "日常解卦补充", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "J96Zm", + "fill": "#475569", + "content": "适量点数补充,经济实惠之选", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "iigcw", + "name": "sp2", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "DCV8f", + "width": "fill_container", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 12, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lsAJh", + "fill": "#FFFFFF", + "content": "立即支付", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "yLSVK", + "name": "pc3", + "width": 280, + "height": 310, + "fill": "#F5F3FF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#7C3AED" + }, + "layout": "vertical", + "padding": 32, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "SpVFi", + "fill": "#0F172A", + "content": "常用加量包", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "Y6IN3Z", + "fill": "#7C3AED", + "cornerRadius": 4, + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wbIkV", + "fill": "#FFFFFF", + "content": "推荐", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "e4hY5X", + "fill": "#0F172A", + "content": "$7.99", + "fontFamily": "Inter", + "fontSize": 36, + "fontWeight": "800" + }, + { + "type": "text", + "id": "v9qQbi", + "fill": "#7C3AED", + "content": "210 积分", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "KsrcH", + "width": "fill_container", + "height": 1, + "fill": "#DDD6FE" + }, + { + "type": "text", + "id": "gLjM7", + "fill": "#64748B", + "content": "最适合日常使用", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "h21Jp5", + "name": "sp3", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "Uw7vy", + "width": "fill_container", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 12, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "APai4", + "fill": "#FFFFFF", + "content": "立即支付", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "hm5bj", + "name": "pc4", + "width": 280, + "height": 310, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "padding": 32, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LmuVU", + "fill": "#0F172A", + "content": "高频进阶包", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "700" + }, + { + "type": "text", + "id": "J3nus", + "fill": "#0F172A", + "content": "$12.99", + "fontFamily": "Inter", + "fontSize": 36, + "fontWeight": "800" + }, + { + "type": "text", + "id": "TeLdd", + "fill": "#7C3AED", + "content": "415 积分", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "R552h", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "text", + "id": "a5UF8", + "fill": "#64748B", + "content": "重度使用优选", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "r6X1h", + "fill": "#475569", + "content": "大量点数储备,超值单价", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "kM5FQ", + "name": "sp4", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "Bx6uD", + "width": "fill_container", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 12, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "cX5u8", + "fill": "#FFFFFF", + "content": "立即支付", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "DJqcQ", + "name": "pFooter", + "width": 1440, + "height": 300, + "fill": "#020617", + "gap": 64, + "padding": [ + 48, + 80 + ], + "children": [ + { + "type": "frame", + "id": "pu0Ea", + "name": "footerBrand", + "width": 280, + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "K2OC6e", + "name": "footerLogoGroup", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "I1cbe", + "name": "footerLogo", + "width": 32, + "height": 32, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 6 + }, + { + "type": "text", + "id": "BugpG", + "name": "footerBrandName", + "fill": "#FFFFFF", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "sIW0E", + "name": "footerDesc", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 260, + "content": "以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lYYHV", + "name": "footerLinksGroup", + "width": "fill_container", + "gap": 120, + "children": [ + { + "type": "frame", + "id": "hPuwA", + "name": "footerCol1", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "fKmsu", + "name": "footerCol1Title", + "fill": "#FFFFFF", + "content": "产品", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "k7L49", + "name": "footerCol1Link1", + "fill": "#64748B", + "content": "功能介绍", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "myaYp", + "name": "footerCol1Link2", + "fill": "#64748B", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "F4O1A", + "name": "footerCol2", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "mfaLY", + "name": "footerCol2Title", + "fill": "#FFFFFF", + "content": "支持", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "jUx85", + "name": "footerCol2Link1", + "fill": "#64748B", + "content": "帮助中心", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KueyN", + "name": "footerCol2Link2", + "fill": "#64748B", + "content": "联系我们", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "pj0vf", + "name": "footerCol3", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "P3WqI8", + "name": "footerCol3Title", + "fill": "#FFFFFF", + "content": "法律", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "jwAWQ", + "name": "footerCol3Link1", + "fill": "#64748B", + "content": "隐私政策", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "JXr0A", + "name": "footerCol3Link2", + "fill": "#64748B", + "content": "服务条款", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "KkBap", + "x": 4800, + "y": 0, + "name": "About Page", + "width": 1440, + "height": 1750, + "fill": "#FFFFFF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "oFJxk", + "name": "aNav", + "width": 1440, + "height": 80, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E2E8F0" + }, + "padding": [ + 0, + 80 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "MXw3B", + "name": "navBrand", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RQ9Jg", + "name": "navLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "p8ezy", + "name": "navTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "z0PKE", + "name": "navLinks", + "gap": 40, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "E40WN", + "name": "navLink1", + "fill": "#475569", + "content": "功能", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "pISGI", + "name": "navLink2", + "fill": "#475569", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "JXm1F", + "name": "navLink3", + "fill": "#7C3AED", + "content": "关于", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "Hz1rg", + "name": "Lang Switcher", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 4, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "yA7l8", + "name": "langBadge", + "width": 20, + "height": 20, + "fill": "#F1F5F9", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UqMyU", + "fill": "#64748B", + "content": "中", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "ibhT9", + "name": "langCurrent", + "fill": "#475569", + "content": "简体中文", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Kofqg", + "name": "langArrow", + "fill": "#94A3B8", + "content": "▾", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "InTsp", + "name": "navCta", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K1sIfn", + "name": "navCtaText", + "fill": "#FFFFFF", + "content": "开始使用", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "vSeD1", + "name": "aHeader", + "width": "fill_container", + "height": 280, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#FFFFFF", + "position": 0 + }, + { + "color": "#F5F3FF", + "position": 1 + } + ] + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 80 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "A3tSAl", + "fill": "#0F172A", + "content": "关于觅爻签问", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 48, + "fontWeight": "800" + }, + { + "type": "text", + "id": "vPS9N", + "fill": "#64748B", + "content": "了解我们的理念与定位", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "BbKvo", + "name": "aContent", + "width": "fill_container", + "fill": "#FFFFFF", + "gap": 64, + "padding": 80, + "children": [ + { + "type": "frame", + "id": "Z2Ont9", + "name": "aLeft", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "children": [ + { + "type": "text", + "id": "yv7Et", + "fill": "#0F172A", + "content": "我们的故事", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "eGHZz", + "width": 48, + "height": 4, + "fill": "#7C3AED", + "cornerRadius": 2 + }, + { + "type": "text", + "id": "GwynU", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "觅爻签问是一个借助于 AI 解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Up4cW", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "XmnB6", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "用 AI 解锁古老智慧,让觅爻签问成为你探索趋势、明晰方向的现代助手。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "KcbSb", + "name": "aRight", + "width": 400, + "fill": "#FAFAFA", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "type": "text", + "id": "hWscV", + "fill": "#0F172A", + "content": "公司信息", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "XOFkw", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "text", + "id": "RpJc3", + "fill": "#475569", + "content": "洵觅科技(深圳)有限公司", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "lqGYp", + "fill": "#94A3B8", + "content": "联系邮箱", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "sHjJK", + "fill": "#475569", + "content": "xuyunlong@xunmee.com", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Wz6be", + "fill": "#94A3B8", + "content": "开发者", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "h4nl7s", + "fill": "#475569", + "content": "Ann Lee", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wIp80", + "fill": "#94A3B8", + "content": "备案号", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "WMkEV", + "fill": "#475569", + "content": "粤ICP备2025428416号-1A", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "g4HVlx", + "name": "aWarning", + "width": "fill_container", + "fill": "#FFFBEB", + "layout": "vertical", + "gap": 20, + "padding": [ + 80, + 60 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iqN2o", + "fill": "#92400E", + "content": "特别提醒", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "SBLBc", + "fill": "#A16207", + "textGrowth": "fixed-width", + "width": 800, + "content": "卦象解读结果均由 AI 生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。", + "lineHeight": 1.8, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "T0UUy", + "name": "aLegal", + "width": "fill_container", + "fill": "#F8FAFC", + "layout": "vertical", + "gap": 20, + "padding": [ + 48, + 80 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "EwdHW", + "fill": "#0F172A", + "content": "法律条款", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "KeVh2", + "name": "aLinksRow", + "gap": 32, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "KtSWX", + "fill": "#7C3AED", + "content": "隐私政策", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "sq1yh", + "fill": "#7C3AED", + "content": "服务条款", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "hvkCV", + "fill": "#7C3AED", + "content": "免责声明", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "I9Ipn1", + "name": "aFooter", + "width": 1440, + "height": 300, + "fill": "#020617", + "gap": 64, + "padding": [ + 48, + 80 + ], + "children": [ + { + "type": "frame", + "id": "qFd8k", + "name": "footerBrand", + "width": 280, + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Elnu2", + "name": "footerLogoGroup", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "cZIp3", + "name": "footerLogo", + "width": 32, + "height": 32, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 6 + }, + { + "type": "text", + "id": "PIXIV", + "name": "footerBrandName", + "fill": "#FFFFFF", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "rad04", + "name": "footerDesc", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 260, + "content": "以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "aHMrJ", + "name": "footerLinksGroup", + "width": "fill_container", + "gap": 120, + "children": [ + { + "type": "frame", + "id": "obc8w", + "name": "footerCol1", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "ngTUr", + "name": "footerCol1Title", + "fill": "#FFFFFF", + "content": "产品", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "tzqLe", + "name": "footerCol1Link1", + "fill": "#64748B", + "content": "功能介绍", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Ot6kw", + "name": "footerCol1Link2", + "fill": "#64748B", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LqBbG", + "name": "footerCol2", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "ILpKH", + "name": "footerCol2Title", + "fill": "#FFFFFF", + "content": "支持", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "xq2zV", + "name": "footerCol2Link1", + "fill": "#64748B", + "content": "帮助中心", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "zaKY1", + "name": "footerCol2Link2", + "fill": "#64748B", + "content": "联系我们", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "NSokt", + "name": "footerCol3", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "M8L9VL", + "name": "footerCol3Title", + "fill": "#FFFFFF", + "content": "法律", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "B18WP8", + "name": "footerCol3Link1", + "fill": "#64748B", + "content": "隐私政策", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ARRpy", + "name": "footerCol3Link2", + "fill": "#64748B", + "content": "服务条款", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "R6vbl", + "x": 6400, + "y": 0, + "name": "ppPage", + "width": 1440, + "height": 2200, + "fill": "#FFFFFF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "bwGOY", + "name": "aNav", + "width": 1440, + "height": 80, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E2E8F0" + }, + "padding": [ + 0, + 80 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "PaXdt", + "name": "navBrand", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "y0jTOf", + "name": "navLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "T5Y01", + "name": "navTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "WH3nq", + "name": "navLinks", + "gap": 40, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Jg6b4", + "name": "navLink1", + "fill": "#475569", + "content": "功能", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "SOAjf", + "name": "navLink2", + "fill": "#475569", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "XizET", + "name": "navLink3", + "fill": "#7C3AED", + "content": "关于", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "RkGLq", + "name": "Lang Switcher", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 4, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Y2lsB", + "name": "langBadge", + "width": 20, + "height": 20, + "fill": "#F1F5F9", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "tuWND", + "fill": "#64748B", + "content": "中", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "QZi15", + "name": "langCurrent", + "fill": "#475569", + "content": "简体中文", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "pTGnT", + "name": "langArrow", + "fill": "#94A3B8", + "content": "▾", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Kfpgl", + "name": "navCta", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "v2Qorl", + "name": "navCtaText", + "fill": "#FFFFFF", + "content": "开始使用", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pHd71", + "name": "aHeader", + "width": "fill_container", + "height": 280, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#FFFFFF", + "position": 0 + }, + { + "color": "#F5F3FF", + "position": 1 + } + ] + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 80 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ad7o3", + "fill": "#0F172A", + "content": "隐私政策", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 48, + "fontWeight": "800" + }, + { + "type": "text", + "id": "Ny5OU", + "fill": "#64748B", + "content": "最后更新日期:2026年4月27日", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ksDAe", + "name": "aContent", + "width": "fill_container", + "fill": "#FFFFFF", + "gap": 64, + "padding": 80, + "children": [ + { + "type": "frame", + "id": "K1ugIF", + "name": "aLeft", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "children": [ + { + "type": "text", + "id": "z2Rqwh", + "fill": "#0F172A", + "content": "引言", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "txugc", + "width": 48, + "height": 4, + "fill": "#7C3AED", + "cornerRadius": 2 + }, + { + "type": "text", + "id": "poAbR", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "我致力于保护您的个人隐私,并遵守适用的美国联邦和州隐私法律。本隐私政策清晰说明我收集哪些个人信息、您的数据如何被使用和存储、您在美国法规下的法定隐私权利,以及如何提交数据请求。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "iQXog", + "fill": "#0F172A", + "content": "1. 我收集的信息", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "W7OdJ", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "我只收集为提供、维护和优化应用文化参考功能所需的数据。您直接提供的信息包括:电子邮箱地址、验证码、您自愿设置的昵称、您输入的问题和文化解读记录。自动收集的信息包括:设备型号、操作系统版本、IP地址、访问时间、崩溃日志和使用数据。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "AdYkd", + "fill": "#0F172A", + "content": "2. 我如何使用您的信息", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "g1Zjkv", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "您的信息仅用于以下合法且有限的目的:提供核心功能、完成用户验证和账户安全、分析匿名使用数据以优化产品、回复您的反馈、推送必要的系统通知。未经您的明确同意,我绝不会将您的个人敏感内容用于商业广告或未经授权的营销。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "JpTeF", + "fill": "#0F172A", + "content": "4. 信息共享与披露", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "LnUIz", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "我不以任何形式出售、出租或交易您的个人信息。我只与应用运行所需的可信赖第三方服务提供商共享数据,并签署严格的数据保护限制。仅在法律要求或经您明确授权的情况下,您的数据可能被披露。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vVv9w", + "fill": "#0F172A", + "content": "5. 您的隐私权利", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "C4IZLt", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "根据CCPA/CPRA和相关隐私法律,您享有知情权、访问权、删除权、更正权、数据携带权、选择退出权、限制敏感数据权和不受歧视权。您可以通过联系邮箱提交数据请求,我将在45天内回复。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "w1b9RH", + "fill": "#0F172A", + "content": "9. 联系我们", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "AXEPM", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "如果您对本隐私政策有任何疑问或隐私相关投诉,请联系开发者邮箱:ann@xunmee.com", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "d8Sxe", + "name": "aRight", + "width": 400, + "fill": "#FAFAFA", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "type": "text", + "id": "MxNVh", + "fill": "#0F172A", + "content": "文档信息", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "N6SyUU", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "text", + "id": "i5g5Pl", + "fill": "#94A3B8", + "content": "文档类型", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "edtvQ", + "fill": "#475569", + "content": "隐私政策", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vMhKC", + "fill": "#94A3B8", + "content": "最后更新", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "BmdGP", + "fill": "#475569", + "content": "2026年4月27日", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "H5MM3", + "fill": "#94A3B8", + "content": "生效日期", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "dOZNc", + "fill": "#475569", + "content": "2026年4月27日", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "VoLFS", + "fill": "#94A3B8", + "content": "联系邮箱", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "bLr5D", + "fill": "#475569", + "content": "ann@xunmee.com", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ViFQX", + "name": "aWarning", + "width": "fill_container", + "fill": "#FFFBEB", + "layout": "vertical", + "gap": 20, + "padding": [ + 80, + 60 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "YWB2u", + "fill": "#F59E0B", + "content": "免责声明", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "Zhkwe", + "fill": "#92400E", + "textGrowth": "fixed-width", + "width": 800, + "content": "本应用提供的所有内容仅供娱乐和文化参考,不构成任何专业建议。用户应理性看待卦象解读结果,独立做出判断和决策。", + "lineHeight": 1.8, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qx3sf", + "name": "aLegal", + "width": "fill_container", + "fill": "#F8FAFC", + "layout": "vertical", + "gap": 20, + "padding": [ + 48, + 80 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Wclvn", + "fill": "#0F172A", + "content": "法律条款", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "ShOeC", + "name": "aLinksRow", + "gap": 32, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "wvp3u", + "fill": "#7C3AED", + "content": "隐私政策", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + }, + { + "type": "text", + "id": "Z1VC50", + "fill": "#64748B", + "content": "服务条款", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "MYtzy", + "fill": "#64748B", + "content": "免责声明", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ArRzC", + "name": "aFooter", + "width": 1440, + "height": 300, + "fill": "#020617", + "gap": 64, + "padding": [ + 48, + 80 + ], + "children": [ + { + "type": "frame", + "id": "BnEka", + "name": "footerBrand", + "width": 280, + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Byeq6", + "name": "footerLogoGroup", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "p4Ij0A", + "name": "footerLogo", + "width": 32, + "height": 32, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 6 + }, + { + "type": "text", + "id": "E5sX8c", + "name": "footerBrandName", + "fill": "#FFFFFF", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "D20lW3", + "name": "footerDesc", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 260, + "content": "以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "I2b5Ve", + "name": "footerLinksGroup", + "width": "fill_container", + "gap": 120, + "children": [ + { + "type": "frame", + "id": "wOM0H", + "name": "footerCol1", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "t62RSq", + "name": "footerCol1Title", + "fill": "#FFFFFF", + "content": "产品", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "qaQRR", + "name": "footerCol1Link1", + "fill": "#64748B", + "content": "功能介绍", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "R3lll", + "name": "footerCol1Link2", + "fill": "#64748B", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ohDrE", + "name": "footerCol2", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "EeEjG", + "name": "footerCol2Title", + "fill": "#FFFFFF", + "content": "支持", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "bUnxE", + "name": "footerCol2Link1", + "fill": "#64748B", + "content": "帮助中心", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "iLmVU", + "name": "footerCol2Link2", + "fill": "#64748B", + "content": "联系我们", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kwHpS", + "name": "footerCol3", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "eg8uv", + "name": "footerCol3Title", + "fill": "#FFFFFF", + "content": "法律", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "hkaaQ", + "name": "footerCol3Link1", + "fill": "#64748B", + "content": "隐私政策", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "V8dkFq", + "name": "footerCol3Link2", + "fill": "#64748B", + "content": "服务条款", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "bsODq", + "x": 8000, + "y": 0, + "name": "tosPage", + "width": 1440, + "height": 2200, + "fill": "#FFFFFF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "WjQWC", + "name": "aNav", + "width": 1440, + "height": 80, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#E2E8F0" + }, + "padding": [ + 0, + 80 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "X4G1f", + "name": "navBrand", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "tN2Yw", + "name": "navLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "p2LWf4", + "name": "navTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "VpArw", + "name": "navLinks", + "gap": 40, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MSL89", + "name": "navLink1", + "fill": "#475569", + "content": "功能", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Eobqg", + "name": "navLink2", + "fill": "#475569", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "fzr3p", + "name": "navLink3", + "fill": "#7C3AED", + "content": "关于", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "PfKnj", + "name": "Lang Switcher", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 4, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fEoDp", + "name": "langBadge", + "width": 20, + "height": 20, + "fill": "#F1F5F9", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "oGrIx", + "fill": "#64748B", + "content": "中", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "Z8S5pa", + "name": "langCurrent", + "fill": "#475569", + "content": "简体中文", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "t4h9fs", + "name": "langArrow", + "fill": "#94A3B8", + "content": "▾", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "YF7R4", + "name": "navCta", + "fill": "#7C3AED", + "cornerRadius": 8, + "padding": [ + 10, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "n1E15y", + "name": "navCtaText", + "fill": "#FFFFFF", + "content": "开始使用", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "mhWYa", + "name": "aHeader", + "width": "fill_container", + "height": 280, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#FFFFFF", + "position": 0 + }, + { + "color": "#F5F3FF", + "position": 1 + } + ] + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 80 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Y7nu1p", + "fill": "#0F172A", + "content": "服务条款", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 48, + "fontWeight": "800" + }, + { + "type": "text", + "id": "F9w5O", + "fill": "#64748B", + "content": "最后更新日期:2026年4月27日", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Hgs2r", + "name": "aContent", + "width": "fill_container", + "fill": "#FFFFFF", + "gap": 64, + "padding": 80, + "children": [ + { + "type": "frame", + "id": "RFS9M", + "name": "aLeft", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "children": [ + { + "type": "text", + "id": "oxby7", + "fill": "#0F172A", + "content": "1. 条款接受", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "HPFrq", + "width": 48, + "height": 4, + "fill": "#7C3AED", + "cornerRadius": 2 + }, + { + "type": "text", + "id": "i2TwqR", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "下载、安装、注册、访问或使用本应用,即表示您确认已阅读、理解并无条件同意受本服务条款及我的隐私政策约束。如果您不同意本条款,请勿使用本应用。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "qkg80", + "fill": "#0F172A", + "content": "2. 年龄要求", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "sUJQB", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "您声明并保证您年满13岁方可使用本应用。本应用不面向13岁以下儿童。我不会故意收集13岁以下用户的个人信息。如发现13岁以下未成年人提交了个人数据,我将立即采取行动删除该信息。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "XSqGp", + "fill": "#0F172A", + "content": "3. 服务说明", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "Ky7Tb", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "本应用提供与传统易经和六爻文化相关的AI辅助文化解读内容,仅供日常参考和文化赏析。所有AI生成内容和文化参考资料仅供娱乐和个人参考目的,不得视为专业建议。我不保证本应用内任何AI生成内容的准确性、完整性或实用性。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "q9M4HE", + "fill": "#0F172A", + "content": "5. 知识产权", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "R9US6k", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "本应用内的所有知识产权,包括但不限于程序代码、文字内容、图形设计、界面内容、标识和视觉元素,均为个人开发者独有。您不得复制、修改、分发、反向工程或试图获取本应用源代码。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "k0NoqH", + "fill": "#0F172A", + "content": "6. 禁止的行为", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "JfhUh", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "您同意不会将本应用用于非法、恶意、欺诈或侵权行为。不得攻击、干扰或破坏本应用的运行环境。如您违反上述规定,我保留不经事先通知发出警告、限制功能、暂停或终止您账户的权利。", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "S3D29", + "fill": "#0F172A", + "content": "12. 联系方式", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "MSDEw", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "如果您对本条款有疑问、反馈或法律咨询,请联系开发者邮箱:ann@xunmee.com", + "lineHeight": 1.8, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "BEz53", + "name": "aRight", + "width": 400, + "fill": "#FAFAFA", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "type": "text", + "id": "eDkMp", + "fill": "#0F172A", + "content": "文档信息", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "cqGXU", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "text", + "id": "wK4Cf", + "fill": "#94A3B8", + "content": "文档类型", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "g7S7y", + "fill": "#475569", + "content": "服务条款", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "JfoxK", + "fill": "#94A3B8", + "content": "最后更新", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ftL6u", + "fill": "#475569", + "content": "2026年4月27日", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "q01TA", + "fill": "#94A3B8", + "content": "适用法律", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "DIL5J", + "fill": "#475569", + "content": "美国加利福尼亚州法律", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "pKjxw", + "fill": "#94A3B8", + "content": "联系邮箱", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "C2VTd", + "fill": "#475569", + "content": "ann@xunmee.com", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "EVeOS", + "name": "aWarning", + "width": "fill_container", + "fill": "#FFFBEB", + "layout": "vertical", + "gap": 20, + "padding": [ + 80, + 60 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "eJijR", + "fill": "#F59E0B", + "content": "免责声明", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "iIXQ7", + "fill": "#92400E", + "textGrowth": "fixed-width", + "width": 800, + "content": "本应用提供的所有内容仅供娱乐和文化参考,不构成任何专业建议。用户应理性看待卦象解读结果,独立做出判断和决策。", + "lineHeight": 1.8, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LgKQe", + "name": "aLegal", + "width": "fill_container", + "fill": "#F8FAFC", + "layout": "vertical", + "gap": 20, + "padding": [ + 48, + 80 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "z1NYi", + "fill": "#0F172A", + "content": "法律条款", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "r5ad2", + "name": "aLinksRow", + "gap": 32, + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "JXLgd", + "fill": "#64748B", + "content": "隐私政策", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "lA5Hj", + "fill": "#7C3AED", + "content": "服务条款", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + }, + { + "type": "text", + "id": "iamsE", + "fill": "#64748B", + "content": "免责声明", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "L9EHZ", + "name": "aFooter", + "width": 1440, + "height": 300, + "fill": "#020617", + "gap": 64, + "padding": [ + 48, + 80 + ], + "children": [ + { + "type": "frame", + "id": "lr8Mf", + "name": "footerBrand", + "width": 280, + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "gkdY4", + "name": "footerLogoGroup", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "d5Aqr", + "name": "footerLogo", + "width": 32, + "height": 32, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 6 + }, + { + "type": "text", + "id": "bzFKA", + "name": "footerBrandName", + "fill": "#FFFFFF", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "r8IVUt", + "name": "footerDesc", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": 260, + "content": "以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "I8YDUt", + "name": "footerLinksGroup", + "width": "fill_container", + "gap": 120, + "children": [ + { + "type": "frame", + "id": "eUT6B", + "name": "footerCol1", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "G9CRg", + "name": "footerCol1Title", + "fill": "#FFFFFF", + "content": "产品", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "wSuma", + "name": "footerCol1Link1", + "fill": "#64748B", + "content": "功能介绍", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "QE6qO", + "name": "footerCol1Link2", + "fill": "#64748B", + "content": "定价", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FG9jN", + "name": "footerCol2", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "qZkqR", + "name": "footerCol2Title", + "fill": "#FFFFFF", + "content": "支持", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "sVvix", + "name": "footerCol2Link1", + "fill": "#64748B", + "content": "帮助中心", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "N4Idz", + "name": "footerCol2Link2", + "fill": "#64748B", + "content": "联系我们", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gdpz5", + "name": "footerCol3", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "e7lSq", + "name": "footerCol3Title", + "fill": "#FFFFFF", + "content": "法律", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "gI735", + "name": "footerCol3Link1", + "fill": "#64748B", + "content": "隐私政策", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "PlGvI", + "name": "footerCol3Link2", + "fill": "#64748B", + "content": "服务条款", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "yLJeI", + "x": 3200, + "y": 3400, + "name": "collapsed", + "width": 1440, + "height": 960, + "fill": "#F8FAFC", + "children": [ + { + "type": "frame", + "id": "Nz0By", + "name": "sidebar", + "width": 72, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 10, + "padding": [ + 16, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "beyFW", + "name": "expandBtn", + "width": 40, + "height": 40, + "fill": "#F8FAFC", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QJROH", + "name": "expandIcon", + "width": 22, + "height": 22, + "iconFontName": "keyboard_double_arrow_right", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + }, + { + "type": "frame", + "id": "ZOoC0", + "name": "collapsedNavStack", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ch5uQ", + "name": "navHomeCollapsed", + "width": 40, + "height": 40, + "fill": "#673AB7", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "i9Xm4", + "name": "homeIcon", + "width": 20, + "height": 20, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + } + ] + }, + { + "type": "frame", + "id": "RA3id", + "name": "navStoreCollapsed", + "width": 40, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "V6Lbft", + "name": "storeIcon", + "width": 20, + "height": 20, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + }, + { + "type": "frame", + "id": "zuRTq", + "name": "sepCollapsed1", + "width": 32, + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "YXcUI", + "name": "navDivinationCollapsed", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Z0Rmv", + "name": "navDivHeaderCollapsed", + "width": 40, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ROFtT", + "name": "divinationIcon", + "width": 20, + "height": 20, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + } + ] + }, + { + "type": "frame", + "id": "UT0tl", + "name": "navManualCollapsed", + "width": 34, + "height": 34, + "fill": "#F8FAFC", + "cornerRadius": 9, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "RdbQY", + "name": "manualIcon", + "width": 18, + "height": 18, + "iconFontName": "edit_note", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + }, + { + "type": "frame", + "id": "m1wcp", + "name": "navAutoCollapsed", + "width": 34, + "height": 34, + "fill": "#F8FAFC", + "cornerRadius": 9, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "OYJR6", + "name": "autoIcon", + "width": 18, + "height": 18, + "iconFontName": "sync", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Nc43W", + "name": "sepCollapsed2", + "width": 32, + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "v1OE3", + "name": "navHistoryCollapsed", + "width": 40, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "FqixW", + "name": "historyIcon", + "width": 20, + "height": 20, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + }, + { + "type": "frame", + "id": "qYTbC", + "name": "navLanguageCollapsed", + "width": 40, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "c38x8", + "name": "languageIcon", + "width": 20, + "height": 20, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + }, + { + "type": "frame", + "id": "OeRE8", + "name": "navSettingsCollapsed", + "width": 40, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "v5dL6", + "name": "settingsIcon", + "width": 20, + "height": 20, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ituCy", + "name": "collapsedSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "PwEiu", + "name": "profileCollapsed", + "width": 40, + "height": 40, + "fill": "#F0E6FF", + "cornerRadius": 20, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "j3rPj", + "name": "profileInitial", + "fill": "#673AB7", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "g44RS3", + "name": "mainArea", + "width": 1368, + "height": 960, + "fill": "#F8FAFC", + "layout": "vertical", + "gap": 24, + "padding": [ + 32, + 40 + ], + "children": [ + { + "type": "frame", + "id": "b7waRX", + "name": "headerBar", + "width": "fill_container", + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "EOMnB", + "name": "greeting", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "knQ9I", + "fill": "#0F172A", + "content": "下午好,林小姐", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "600" + }, + { + "type": "text", + "id": "xHjC2", + "fill": "#64748B", + "content": "今天想要探寻什么方向?", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "KcRw9", + "name": "headerRight", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Tf250", + "name": "notifyWrap", + "width": 42, + "height": 42, + "layout": "none", + "children": [ + { + "type": "frame", + "id": "seCFe", + "x": 0, + "y": 0, + "name": "notifyBell", + "width": 40, + "height": 40, + "fill": "#F1F5F9", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "W7h2x", + "fill": "#64748B", + "content": "🔔", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MtKcn", + "x": 28, + "y": -2, + "name": "notifyBadge", + "width": 18, + "height": 18, + "fill": "#EF4444", + "cornerRadius": 9, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rXaPK", + "fill": "#FFFFFF", + "content": "3", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "UEBCV", + "name": "heroCard", + "width": "fill_container", + "height": 280, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 135, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#673AB7", + "position": 0 + }, + { + "color": "#512DA8", + "position": 1 + } + ] + }, + "cornerRadius": 20, + "gap": 48, + "padding": 48, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fmExy", + "name": "heroLeft", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "SHMLV", + "fill": "#FFFFFF", + "content": "开始您的卦象之旅", + "fontFamily": "Inter", + "fontSize": 32, + "fontWeight": "700" + }, + { + "type": "text", + "id": "s1Hvz", + "fill": "#E9D5FF", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "借助AI智能,探索未来的可能。心中有问,起卦便知。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "PtJuf", + "name": "ctaBtn", + "width": 152, + "height": 48, + "fill": "#FFFFFF", + "cornerRadius": 12, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000030", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RfMzO", + "fill": "#7C3AED", + "content": "立即起卦", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zHqVr", + "name": "heroRight", + "width": 220, + "height": 184, + "fill": "#FFFFFF15", + "cornerRadius": 16, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "t3A8V", + "fill": "#FFFFFF66", + "content": "⚊ ⚋\n⚋ ⚊\n⚊ ⚊", + "lineHeight": 1.4, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "T4yLQp", + "name": "historySection", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "g1IiC7", + "name": "historyHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TrH8i", + "fill": "#0F172A", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Umsaa", + "fill": "#7C3AED", + "content": "查看全部 →", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "V5l7Y", + "name": "historyList", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "BAypS", + "name": "card1", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1F5F9" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "UID0O", + "width": 40, + "height": 40, + "fill": "#E6F7FF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sDkos", + "fill": "#1890FF", + "content": "◇", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bt0xp", + "name": "card1Content", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "kzzwj", + "name": "card1Row1", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "hf3Ln", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "今年转岗是否合适?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "500" + }, + { + "type": "text", + "id": "n3lD9G", + "fill": "#94A3B8", + "content": "2024-05-08", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qcaFF", + "name": "card1Tags", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "V87Efp", + "fill": "#E6F7FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "G38hPp", + "fill": "#1890FF", + "content": "事业", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "sUY5G", + "fill": "#F0E6FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "ncFW0", + "fill": "#7C3AED", + "content": "天雷无妄", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "T3pcw", + "fill": "#FFF8E1", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "K5APH", + "fill": "#FFB300", + "content": "上上签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "xKpM5", + "name": "card2", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1F5F9" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zBX4X", + "name": "card2Icon", + "width": 40, + "height": 40, + "fill": "#E6F7FF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "y45AD", + "fill": "#1890FF", + "content": "◇", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "g1Ol8", + "name": "card2Content", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "hLGTk", + "name": "card2Row1", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "INXQ9", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "最近感情是否能推进?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "500" + }, + { + "type": "text", + "id": "ehFqW", + "fill": "#94A3B8", + "content": "2024-05-07", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GZTYB", + "name": "card2Tags", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "GlOqu", + "name": "card2t1", + "fill": "#FCE4EC", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "J1fYDz", + "fill": "#E91E63", + "content": "感情", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "VCfcK", + "name": "card2t2", + "fill": "#F0E6FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "e41bl", + "fill": "#7C3AED", + "content": "泽火革", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "O9Mr7", + "name": "card2t3", + "fill": "#F5F0FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "DdPP0", + "fill": "#7C3AED", + "content": "中上签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wA5qE", + "name": "card3", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1F5F9" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "pkzbJ", + "name": "card3Icon", + "width": 40, + "height": 40, + "fill": "#E6F7FF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XaClf", + "fill": "#1890FF", + "content": "◇", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HuuS8", + "name": "card3Content", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "ho5sy", + "name": "card3Row1", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ekZ7N", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "本季度投资节奏如何?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "500" + }, + { + "type": "text", + "id": "y33Qy", + "fill": "#94A3B8", + "content": "2024-05-05", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "XESnf", + "name": "card3Tags", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "QY41Q", + "name": "card3t1", + "fill": "#E8F5E9", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "m4ZRR2", + "fill": "#4CAF50", + "content": "财富", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "XG0qB", + "name": "card3t2", + "fill": "#F0E6FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "h3Zql", + "fill": "#7C3AED", + "content": "风地观", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Q5UkL2", + "name": "card3t3", + "fill": "#F5F5F5", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "Cx56i", + "fill": "#9E9E9E", + "content": "中下签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "m7wiGF", + "name": "card4", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F1F5F9" + }, + "gap": 16, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "GC2s0", + "name": "card4Icon", + "width": 40, + "height": 40, + "fill": "#E6F7FF", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "s6BxII", + "fill": "#1890FF", + "content": "◇", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Y9rdls", + "name": "card4Content", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "IZQwJ", + "name": "card4Row1", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "CQ2Yt", + "fill": "#0F172A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "近期身体不适,需要注意什么?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "500" + }, + { + "type": "text", + "id": "V6Qbce", + "fill": "#94A3B8", + "content": "2024-05-03", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "F2bke", + "name": "card4Tags", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "kymrC", + "name": "card4t1", + "fill": "#FFF3E0", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "KhMW7", + "fill": "#F57C00", + "content": "健康", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "b5PLk", + "name": "card4t2", + "fill": "#F0E6FF", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "yUiUw", + "fill": "#7C3AED", + "content": "水雷屯", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "G8LvqP", + "name": "card4t3", + "fill": "#FCE4EC", + "cornerRadius": 6, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "Vll0r", + "fill": "#E53935", + "content": "下下签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "iiNGg", + "x": 4800, + "y": 3400, + "name": "NotificationPage", + "width": 1440, + "height": 960, + "fill": "#F8FAFC", + "children": [ + { + "type": "frame", + "id": "ZeBgg", + "name": "sidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "bd9GK", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hylJV", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "nV5go", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "qICTc", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hhKcp", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "CJRZ9", + "name": "navHome", + "width": "fill_container", + "fill": "#673AB7", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "P51BC", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "pxa4u", + "fill": "#FFFFFF", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "vlmqS", + "name": "navStore", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "RDkoe", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "l7kGu", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "E4Tzp3", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "HnbgK", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "bVk28", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "bJUpF", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "mVydX", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "NRQgC", + "name": "navManual", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wo8sT", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "uoQxy", + "fill": "#64748B", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ESmcZ", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "waiPn", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "G9EA5J", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "y5dapi", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "V9VZWq", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "U0cTVC", + "name": "hist2Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "YPA6P", + "name": "hist2Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kDtgL", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "eZeQb", + "name": "lang2Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "Pewun", + "name": "lang2Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "SU7i5", + "name": "navSettings", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "i7nb7", + "name": "set2Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "DnsD4", + "name": "set2Text", + "fill": "#64748B", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "XSxR6", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "g1VR1G", + "name": "userSection", + "width": "fill_container", + "cornerRadius": 10, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "w7YFMv", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TyhCD", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "Zv3Wb", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "aPjTW", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "y5Mtdi", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "q91BY", + "name": "mainArea", + "width": 1180, + "height": 960, + "fill": "#F8FAFC", + "layout": "vertical", + "gap": 24, + "padding": [ + 32, + 40 + ], + "children": [ + { + "type": "frame", + "id": "umyzn", + "name": "headerBar", + "width": "fill_container", + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "cKPkL", + "name": "greeting", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "JSIaK", + "fill": "#333333", + "content": "通知中心", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "OI4id", + "name": "headerRight", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "b3ybhJ", + "name": "allReadBtn", + "cornerRadius": 8, + "gap": 6, + "padding": [ + 12, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "K1epo", + "fill": "#673AB7", + "content": "全部已读", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "XGOaS", + "name": "notifList", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "dhAdQ", + "name": "notif1", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "ellipse", + "id": "vTIRB", + "layoutPosition": "absolute", + "x": 4, + "y": 22, + "name": "unreadDot", + "fill": "#ef4444", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "J0oIo", + "name": "notifBody1", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "as2Sr", + "fill": "#333333", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "欢迎使用觅爻签问", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "c6HBN", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "感谢您注册觅爻签问!您可以开始使用占卜服务进行卦象咨询了。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "tiQaf", + "fill": "#999999", + "content": "1天前", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HpYQo", + "name": "notif2", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "ellipse", + "id": "G2Jgm", + "layoutPosition": "absolute", + "x": 4, + "y": 22, + "name": "unreadDot2", + "fill": "#ef4444", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "fmRf2", + "name": "notifBody2", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "G6p1mN", + "fill": "#333333", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "卦象已生成 — 今年转岗是否合适?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "EOrth", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "您的卦象已完成,点击查看详细解读。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "ow6iB", + "fill": "#999999", + "content": "2天前", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GxFn4", + "name": "notif3", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "frame", + "id": "Lzs3E", + "name": "notifBody3", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "o5iZLN", + "fill": "#333333", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "积分到账通知", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "upxaP", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "您购买的「热门包」60信用点已到账,可在个人中心查看。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "dJ6az", + "fill": "#999999", + "content": "3天前", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "e9SkuH", + "name": "notif4", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "frame", + "id": "k5RvgD", + "name": "notifBody4", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "VHhrq", + "fill": "#333333", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "卦象已生成 — 最近感情能否推进?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "SFD7w", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "您的卦象已完成,点击查看详细解读。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "jZdvH", + "fill": "#999999", + "content": "5天前", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "k6Bgih", + "name": "notif5", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "frame", + "id": "k3quhk", + "name": "notifBody5", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "KcgMD", + "fill": "#333333", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "隐私政策已更新", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ICIcp", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "我们的隐私政策已更新,请查阅最新版本了解我们如何处理您的信息。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "ySNRK", + "fill": "#999999", + "content": "7天前", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "C8RaQ", + "x": 6400, + "y": 3400, + "name": "StorePage", + "width": 1440, + "height": 960, + "fill": "#F8FAFC", + "children": [ + { + "type": "frame", + "id": "YNlgG", + "name": "sidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "E7dQ9l", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hXNQw", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "b08VUC", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "C6PGaN", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JtMa9", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "CDIky", + "name": "navHome", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "thickness": 0, + "fill": "#FFFFFF" + }, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "pQMXt", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "u2mes", + "fill": "#64748B", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "e7loPQ", + "name": "navStore", + "width": "fill_container", + "fill": "#673AB7", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "dZ0Qa", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "BMR4V", + "fill": "#FFFFFF", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "T05KTc", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "m7S9Zg", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "x7bzc2", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "RKvwi", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "a29cb", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "w7X2N", + "name": "navManual", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mu4fv", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "uGogq", + "fill": "#64748B", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qvS3a", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lGRbi", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "xY7oN", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "FrANu", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "KhtmX", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "wRCpS", + "name": "hist3Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "FrvFA", + "name": "hist3Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "UedyP", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "U25BMw", + "name": "lang3Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "rjjWf", + "name": "lang3Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kx6K7", + "name": "navSettings", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "hdPiY", + "name": "set3Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "cGHla", + "name": "set3Text", + "fill": "#64748B", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "NxHqQ", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "oiRrL", + "name": "userSection", + "width": "fill_container", + "cornerRadius": 10, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "FSQs5", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Pskfo", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "fU7In", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "xiRlf", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "f5Ziu", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "vs0r1", + "name": "mainArea", + "width": 1180, + "height": 960, + "fill": "#F8F8F8", + "layout": "vertical", + "gap": 20, + "padding": [ + 28, + 32 + ], + "children": [ + { + "type": "frame", + "id": "PaL67", + "name": "storeHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "vVZFF", + "name": "storeHeaderText", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "NBokY", + "name": "headerTitle", + "fill": "#333333", + "content": "积分商店", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "text", + "id": "D2Crk", + "name": "headerSub", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "购买积分用于起卦与解读服务", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "fNUF1", + "name": "notifyButton", + "width": 42, + "height": 42, + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "bmGkg", + "name": "notifyIcon", + "width": 22, + "height": 22, + "iconFontName": "bell", + "iconFontFamily": "lucide", + "fill": "#64748B" + } + ] + } + ] + }, + { + "type": "frame", + "id": "bS931", + "name": "storeTopPanel", + "width": "fill_container", + "height": 190, + "gap": 20, + "children": [ + { + "type": "frame", + "id": "h9qLZn", + "name": "pointsHero", + "width": "fill_container", + "height": "fill_container", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 135, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#673AB7", + "position": 0 + }, + { + "color": "#9C27B0", + "position": 1 + } + ] + }, + "cornerRadius": 20, + "gap": 24, + "padding": [ + 28, + 30 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "L9ohC", + "name": "pointsHeroIcon", + "width": 68, + "height": 68, + "fill": "#FFFFFF24", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "F7Qbn", + "name": "heroIcon", + "width": 38, + "height": 38, + "iconFontName": "paid", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + } + ] + }, + { + "type": "frame", + "id": "Ltmip", + "name": "pointsHeroText", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "cJC82", + "name": "heroLabel", + "fill": "#F0E6FF", + "content": "当前积分", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "500" + }, + { + "type": "text", + "id": "fPAVX", + "name": "heroValue", + "fill": "#FFFFFF", + "content": "120 积分", + "fontFamily": "Inter", + "fontSize": 42, + "fontWeight": "700" + }, + { + "type": "text", + "id": "lyx7U", + "name": "heroDesc", + "fill": "#F0E6FF", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "积分可用于后续起卦与相关服务消费", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "tExBo", + "name": "pointsRulePanel", + "width": 320, + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 14, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "FKP2g", + "name": "ruleTitleRow", + "width": "fill_container", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ubZrk", + "name": "ruleIcon", + "width": 22, + "height": 22, + "iconFontName": "info", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "WZVCv", + "name": "ruleTitle", + "fill": "#333333", + "content": "积分说明", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "ev4I1", + "name": "rule1", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "1 次起卦会消耗固定积分", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "SIJUO", + "name": "rule2", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "充值完成后积分实时入账", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "pZuxd", + "name": "rule3", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "新人专享包每个账号限购一次", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qlxSi", + "name": "storeBody", + "width": "fill_container", + "height": "fill_container", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "cDmHB", + "name": "packagesPanel", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "G2vGx3", + "name": "packageSectionHead", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wIz5F", + "name": "sectionTitle", + "fill": "#333333", + "content": "充值套餐", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "text", + "id": "awstZ", + "name": "sectionMeta", + "fill": "#999999", + "content": "4 个可选套餐", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "l8xBhk", + "name": "packageRow1", + "width": "fill_container", + "height": 273, + "gap": 16, + "children": [ + { + "type": "frame", + "id": "sPr7x", + "name": "newUserPack", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#F0E6FF" + }, + "layout": "vertical", + "gap": 14, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "p9Ry2G", + "name": "cardHeader", + "width": "fill_container", + "height": 50, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fY8XL", + "name": "cardIconBox", + "width": 44, + "height": 44, + "fill": "#F0E6FF", + "cornerRadius": 12, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "UiOmv", + "name": "h1Icon", + "width": 26, + "height": 26, + "iconFontName": "redeem", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + }, + { + "type": "frame", + "id": "FeHDU", + "name": "cardTitleBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "oxuxN", + "name": "h1Title", + "fill": "#333333", + "content": "新人专享包", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "TKQWg", + "name": "h1Sub", + "fill": "#FFB300", + "content": "限购一次", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "text", + "id": "VMcjo", + "name": "h1Amount", + "fill": "#666666", + "content": "60 积分", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "kfWvT", + "name": "h1Desc", + "fill": "#999999", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "适合首次体验起卦与解读服务", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "HkQXP", + "name": "cardSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "gqRbE", + "name": "cardBottom", + "width": "fill_container", + "height": 44, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HodsE", + "name": "h1Price", + "fill": "#673AB7", + "content": "$0.99", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "u8Yxp", + "name": "buyButton", + "width": 120, + "height": 42, + "fill": "#673AB7", + "cornerRadius": 21, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LVTzB", + "name": "h1BtnText", + "fill": "#FFFFFF", + "content": "立即购买", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wOuBW", + "name": "starterPack", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 14, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "DWWAQ", + "name": "cardHeader", + "width": "fill_container", + "height": 50, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "eEalg", + "name": "cardIconBox", + "width": 44, + "height": 44, + "fill": "#F5F5F5", + "cornerRadius": 12, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "tWejM", + "name": "h2Icon", + "width": 26, + "height": 26, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + }, + { + "type": "frame", + "id": "ShLf8", + "name": "cardTitleBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "JjIQ4", + "name": "h2Title", + "fill": "#333333", + "content": "入门补充包", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "t9wAkC", + "name": "h2Sub", + "fill": "#999999", + "content": "日常补充", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "R0VPfY", + "name": "h2Amount", + "fill": "#666666", + "content": "100 积分", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ZAOeY", + "name": "h2Desc", + "fill": "#999999", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "适合轻量使用和临时补充积分", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "yS6Jk", + "name": "cardSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "chSJM", + "name": "cardBottom", + "width": "fill_container", + "height": 44, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "BUnGy", + "name": "h2Price", + "fill": "#673AB7", + "content": "$4.99", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "L1umvR", + "name": "buyButton", + "width": 120, + "height": 42, + "fill": "#673AB7", + "cornerRadius": 21, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "B2Xhf", + "name": "h2BtnText", + "fill": "#FFFFFF", + "content": "立即购买", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "e78llV", + "name": "packageRow2", + "width": "fill_container", + "height": 273, + "gap": 16, + "children": [ + { + "type": "frame", + "id": "uhhtC", + "name": "popularPack", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#F0E6FF" + }, + "layout": "vertical", + "gap": 14, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "G3PCAH", + "name": "cardHeader", + "width": "fill_container", + "height": 50, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DAAhI", + "name": "cardIconBox", + "width": 44, + "height": 44, + "fill": "#F0E6FF", + "cornerRadius": 12, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "HzJfg", + "name": "h3Icon", + "width": 26, + "height": 26, + "iconFontName": "local_fire_department", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#9C27B0" + } + ] + }, + { + "type": "frame", + "id": "Q6Sioh", + "name": "cardTitleBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "jkTa1", + "name": "h3Title", + "fill": "#333333", + "content": "常用加量包", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "JARGt", + "name": "h3Sub", + "fill": "#FFB300", + "content": "推荐", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "text", + "id": "t9VZ1", + "name": "h3Amount", + "fill": "#666666", + "content": "210 积分", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "LpEHl", + "name": "h3Desc", + "fill": "#999999", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "适合连续使用,积分数量更充足", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "VU5qF", + "name": "cardSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "eiPgi", + "name": "cardBottom", + "width": "fill_container", + "height": 44, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nSmS8", + "name": "h3Price", + "fill": "#673AB7", + "content": "$7.99", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "EWDL3", + "name": "buyButton", + "width": 120, + "height": 42, + "fill": "#673AB7", + "cornerRadius": 21, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ZwAWY", + "name": "h3BtnText", + "fill": "#FFFFFF", + "content": "立即购买", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "SGMnF", + "name": "premiumPack", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 14, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "L4sZE", + "name": "cardHeader", + "width": "fill_container", + "height": 50, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "iyzSt", + "name": "cardIconBox", + "width": 44, + "height": 44, + "fill": "#F5F5F5", + "cornerRadius": 12, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "c2wZU", + "name": "h4Icon", + "width": 26, + "height": 26, + "iconFontName": "workspace_premium", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + }, + { + "type": "frame", + "id": "dpsg9", + "name": "cardTitleBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "hTNLM", + "name": "h4Title", + "fill": "#333333", + "content": "高频进阶包", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "NVU0x", + "name": "h4Sub", + "fill": "#999999", + "content": "高频使用", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "hVgem", + "name": "h4Amount", + "fill": "#666666", + "content": "415 积分", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "text", + "id": "MfaB2", + "name": "h4Desc", + "fill": "#999999", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "适合长期使用和更高频的解读需求", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "N9gggu", + "name": "cardSpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "d4szRL", + "name": "cardBottom", + "width": "fill_container", + "height": 44, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XyqiE", + "name": "h4Price", + "fill": "#673AB7", + "content": "$12.99", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "c9gSL", + "name": "buyButton", + "width": 120, + "height": 42, + "fill": "#673AB7", + "cornerRadius": 21, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Uzveb", + "name": "h4BtnText", + "fill": "#FFFFFF", + "content": "立即购买", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "hrXbQ", + "name": "storeSidePanel", + "width": 320, + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "zZynA", + "name": "purchaseIconBox", + "width": 48, + "height": 48, + "fill": "#F0E6FF", + "cornerRadius": 14, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "qVufR", + "name": "sideIcon", + "width": 28, + "height": 28, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + }, + { + "type": "text", + "id": "esUp4", + "name": "sideTitle", + "fill": "#333333", + "content": "购买后自动到账", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "y1S2A", + "name": "sideDesc", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "选择套餐并完成支付后,积分会同步到当前账号。", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "kBrK1", + "name": "sideDivider", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "text", + "id": "I6q9s", + "name": "sideLabel", + "fill": "#999999", + "content": "推荐选择", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "N7s4sV", + "name": "popularCallout", + "width": "fill_container", + "fill": "#FFF8E1", + "cornerRadius": 12, + "gap": 10, + "padding": 14, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "DVDMS", + "name": "sideCalloutIcon", + "width": 22, + "height": 22, + "iconFontName": "star", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFB300" + }, + { + "type": "text", + "id": "Gl4ZP", + "name": "sideCalloutText", + "fill": "#333333", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "常用加量包 · 210 积分", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "vz7aP", + "name": "stepsTitle", + "fill": "#999999", + "content": "支付流程", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "Q13WK", + "name": "payStep1", + "width": "fill_container", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "S2mqmv", + "name": "stepDot", + "width": 8, + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "Sbtt3", + "name": "step1Text", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "选择适合的积分套餐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "jS5fD", + "name": "payStep2", + "width": "fill_container", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "N67mhA", + "name": "stepDot", + "width": 8, + "height": 8, + "fill": "#9C27B0", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "y1c3f", + "name": "step2Text", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "完成支付", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gyTKW", + "name": "payStep3", + "width": "fill_container", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "grS1R", + "name": "stepDot", + "width": 8, + "height": 8, + "fill": "#FFB300", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "k81OSe", + "name": "step3Text", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "积分同步更新到当前账号", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "vGur2", + "x": 8000, + "y": 3400, + "name": "SettingsPage", + "width": 1440, + "fill": "#F8FAFC", + "children": [ + { + "type": "frame", + "id": "z8zOzp", + "name": "sidebar", + "width": 260, + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "m3WKbQ", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "h8wS2", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "RQ8yk", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "iPrOa", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "t2fGvt", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "BvyTQ", + "name": "navHome", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "thickness": 0, + "fill": "#FFFFFF" + }, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ZIPUP", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "sKdKK", + "fill": "#64748B", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "e8iLJO", + "name": "navStore", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "EcRXy", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "i50Apz", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hOoKX", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "l2zPg", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "Ws9w0", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "VgkbA", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "qTMlI", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "p86l9t", + "name": "navManual", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Himmx", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "tQeNI", + "fill": "#64748B", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "BFgKD", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "s6OnNU", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "njv46", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "c9FRWp", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "nrCmO", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "c3QkA", + "name": "hist4Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "SpJWV", + "name": "hist4Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "v7j0pX", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "h5KILN", + "name": "lang4Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "dNXuU", + "name": "lang4Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "dB2vh", + "name": "navSettings", + "width": "fill_container", + "fill": "#673AB7", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "V4CdD", + "name": "set4Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "z1RNaO", + "name": "set4Text", + "fill": "#FFFFFF", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "B7mL08", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "ppDDL", + "name": "userSection", + "width": "fill_container", + "cornerRadius": 10, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "IUKwl", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "p5FT2", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "KvfKp", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "FRfmP", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "W77gPm", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "qs9UF", + "name": "mainArea", + "width": 1180, + "height": 960, + "fill": "#F8F8F8", + "layout": "vertical", + "gap": 24, + "padding": [ + 32, + 36 + ], + "children": [ + { + "type": "frame", + "id": "C22HW", + "name": "settingsWebHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "M4CZdJ", + "name": "settingsHeaderText", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "Jev9q", + "name": "title", + "fill": "#333333", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "text", + "id": "k9L7Y", + "name": "subtitle", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "管理账号资料、偏好、反馈与协议信息", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gJo4y", + "name": "accountStatus", + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Xy5oA", + "name": "noticeIcon", + "width": 18, + "height": 18, + "iconFontName": "verified_user", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "l21rWc", + "name": "noticeText", + "fill": "#333333", + "content": "账号正常", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "k0zGPo", + "name": "settingsWebContent", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "OZSwE", + "name": "settingsLeftColumn", + "width": 360, + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "QwjKA", + "name": "profileSummaryPanel", + "width": "fill_container", + "height": 176, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "oFtiE", + "name": "profileSummaryTop", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "vULvr", + "name": "avatar", + "width": 56, + "height": 56, + "fill": "#F0E6FF", + "cornerRadius": 28, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Dj6lK", + "name": "avatarText", + "fill": "#673AB7", + "content": "林", + "fontFamily": "Inter", + "fontSize": 23, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "x0nCEQ", + "name": "profileText", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "text", + "id": "fmAMB", + "name": "name", + "fill": "#333333", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "st5iY", + "name": "email", + "fill": "#64748B", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ap6ZL", + "name": "editProfileIconButton", + "width": 32, + "height": 32, + "fill": "#F8FAFC", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "mcVe0", + "name": "editTopIcon", + "width": 17, + "height": 17, + "iconFontName": "edit", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + } + ] + }, + { + "type": "text", + "id": "H9LVTc", + "name": "bio", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "探索命运奥秘,追寻内心答案。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "BKf9H", + "name": "pointsPanel", + "width": "fill_container", + "height": 96, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 14, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ihYJg", + "name": "pointsIconBox", + "width": 44, + "height": 44, + "fill": "#F0E6FF", + "cornerRadius": 12, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "K6yWJq", + "name": "pointsIcon", + "width": 26, + "height": 26, + "iconFontName": "paid", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + }, + { + "type": "frame", + "id": "zHmW6", + "name": "pointsText", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "B2vfE", + "name": "pointsLabel", + "fill": "#999999", + "content": "当前积分", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "eTzgG", + "name": "pointsValue", + "fill": "#333333", + "content": "120 积分", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "icon_font", + "id": "Qsneq", + "name": "pointsArrow", + "width": 22, + "height": 22, + "iconFontName": "chevron_right", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qkTBC", + "name": "settingsRightColumn", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "kpJHW", + "name": "accountSettingsPanel", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "text", + "id": "RbSMj", + "name": "accountTitle", + "fill": "#333333", + "content": "账号与偏好", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "zkjsh", + "name": "accountGrid", + "width": "fill_container", + "height": 104, + "gap": 14, + "children": [ + { + "type": "frame", + "id": "lpNaE", + "name": "generalSettingsTile", + "width": "fill_container", + "height": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "gap": 10, + "padding": 16, + "children": [ + { + "type": "icon_font", + "id": "i8EMpd", + "name": "generalIcon", + "width": 24, + "height": 24, + "iconFontName": "tune", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "P38X6", + "name": "generalTitle", + "fill": "#333333", + "content": "通用设置", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + }, + { + "type": "text", + "id": "oPIHV", + "name": "generalDesc", + "fill": "#999999", + "content": "语言、通知、振动", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "YtIdd", + "name": "feedbackTile", + "width": "fill_container", + "height": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "gap": 10, + "padding": 16, + "children": [ + { + "type": "icon_font", + "id": "UJiEw", + "name": "feedbackIcon", + "width": 24, + "height": 24, + "iconFontName": "feedback", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "rZe6H", + "name": "feedbackTitle", + "fill": "#333333", + "content": "意见反馈", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + }, + { + "type": "text", + "id": "t2C01P", + "name": "feedbackDesc", + "fill": "#999999", + "content": "提交问题与建议", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "r42XC", + "name": "accountDataTile", + "width": "fill_container", + "height": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "gap": 10, + "padding": 16, + "children": [ + { + "type": "icon_font", + "id": "RveAv", + "name": "dataIcon", + "width": 24, + "height": 24, + "iconFontName": "person", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "a1QSFs", + "name": "dataTitle", + "fill": "#333333", + "content": "账号数据", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + }, + { + "type": "text", + "id": "fv8Dr", + "name": "dataDesc", + "fill": "#999999", + "content": "账号信息与注销", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "y0XubZ", + "name": "legalPanel", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "text", + "id": "u8QAtt", + "name": "legalTitle", + "fill": "#333333", + "content": "关于与协议", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "cp9sD", + "name": "legalGrid", + "width": "fill_container", + "height": 104, + "gap": 14, + "children": [ + { + "type": "frame", + "id": "DRsu3", + "name": "aboutTile", + "width": "fill_container", + "height": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "gap": 10, + "padding": 16, + "children": [ + { + "type": "icon_font", + "id": "LzU8y", + "name": "aboutIcon", + "width": 24, + "height": 24, + "iconFontName": "info", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#9C27B0" + }, + { + "type": "text", + "id": "sRcpr", + "name": "aboutTitle", + "fill": "#333333", + "content": "关于我们", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + }, + { + "type": "text", + "id": "M7jab", + "name": "aboutDesc", + "fill": "#999999", + "content": "产品理念与定位", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HRDJH", + "name": "privacyTile", + "width": "fill_container", + "height": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "gap": 10, + "padding": 16, + "children": [ + { + "type": "icon_font", + "id": "S6DZI", + "name": "privacyIcon", + "width": 24, + "height": 24, + "iconFontName": "security", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#9C27B0" + }, + { + "type": "text", + "id": "wjoqU", + "name": "privacyTitle", + "fill": "#333333", + "content": "隐私政策", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + }, + { + "type": "text", + "id": "bI50s", + "name": "privacyDesc", + "fill": "#999999", + "content": "隐私保护说明", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "h71xl", + "name": "termsTile", + "width": "fill_container", + "height": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "gap": 10, + "padding": 16, + "children": [ + { + "type": "icon_font", + "id": "XgRgC", + "name": "termsIcon", + "width": 24, + "height": 24, + "iconFontName": "description", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#9C27B0" + }, + { + "type": "text", + "id": "EcMqM", + "name": "termsTitle", + "fill": "#333333", + "content": "服务条款", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + }, + { + "type": "text", + "id": "ngVsd", + "name": "termsDesc", + "fill": "#999999", + "content": "用户服务协议", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "P4qQj5", + "name": "settingsFooter", + "width": "fill_container", + "height": 56, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#FEE2E2" + }, + "padding": [ + 14, + 20 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kVFXf", + "name": "footerText", + "fill": "#EF4444", + "content": "退出当前账号", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "V0kwR", + "name": "logoutButton", + "width": 96, + "height": 36, + "fill": "#EF4444", + "cornerRadius": 18, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "X0f75", + "name": "footerBtnText", + "fill": "#FFFFFF", + "content": "退出登录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "avO5U", + "x": 9600, + "y": 3400, + "name": "ProfileDetailPage", + "width": 1440, + "height": 960, + "fill": "#F8F8F8", + "children": [ + { + "type": "frame", + "id": "SfAdb", + "name": "profileSidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "yV5TD", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Hz0sE", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "HyzV3", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "w7aSX", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eeezn", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "lJSte", + "name": "navHome", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "thickness": 0, + "fill": "#FFFFFF" + }, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "xrZEx", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "J60kQ", + "fill": "#64748B", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "W5PsY", + "name": "navStore", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "JDO02", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "recjC", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "sgyaf", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "LLkxC", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "E0XpA", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "jEm9s", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "J6KgQ", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "iGQjS", + "name": "navManual", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ikXGG", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "rsk1y", + "fill": "#64748B", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "CCrEo", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DLPEZ", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "n0MUS", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "QufMK", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "rZVlU", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "t1GeNG", + "name": "hist4Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "BEEYP", + "name": "hist4Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "WP9TK", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QjtTp", + "name": "lang5Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "U09mhS", + "name": "lang5Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FrrEa", + "name": "navSettings", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "thickness": 0, + "fill": "#FFFFFF" + }, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "u2ITL", + "name": "set4Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "JRyPR", + "name": "set4Text", + "fill": "#64748B", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "U6oVo", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "CLftx", + "name": "userSection", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "thickness": 0, + "fill": "#FFFFFF" + }, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Q079rO", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "BKTiu", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "rgiyf", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "JhT5M", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "Vph6X", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Y0E9F", + "name": "profileMainArea", + "width": 1180, + "height": 960, + "fill": "#F8F8F8", + "layout": "vertical", + "gap": 24, + "padding": [ + 32, + 36 + ], + "children": [ + { + "type": "frame", + "id": "CT1CD", + "name": "profileHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "AFois", + "name": "profileHeaderText", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "DI7u8", + "name": "pTitle", + "fill": "#333333", + "content": "个人档案", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "text", + "id": "BXNNs", + "name": "pSub", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "维护头像、昵称与个人简介,这些信息会展示在你的账号资料中。", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "M2GmS", + "name": "backToSettings", + "width": 118, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "dHfjP", + "name": "pBackIcon", + "width": 18, + "height": 18, + "iconFontName": "arrow_back", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "le0n4", + "name": "pBackText", + "fill": "#64748B", + "content": "返回设置", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "aj3XQ", + "name": "profileEditorContent", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "e95yc", + "name": "avatarEditPanel", + "width": 360, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 22, + "padding": 28, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "QL3ts", + "name": "largeAvatar", + "width": 128, + "height": 128, + "fill": "#F0E6FF", + "cornerRadius": 64, + "stroke": { + "thickness": 2, + "fill": "#D8B4FE" + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "jZ17l", + "name": "pAvatarText", + "fill": "#673AB7", + "content": "林", + "fontFamily": "Inter", + "fontSize": 48, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "fdzWh", + "name": "pAvatarTitle", + "fill": "#333333", + "content": "头像", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "oFUnI", + "name": "pAvatarHint", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "支持 PNG / JPG / WEBP,建议上传清晰正方形头像。", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "GYbOJ", + "name": "uploadAvatarButton", + "width": "fill_container", + "height": 42, + "fill": "#673AB7", + "cornerRadius": 21, + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "m6T2p", + "name": "pUploadIcon", + "width": 20, + "height": 20, + "iconFontName": "photo_library", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "XxIsA", + "name": "pUploadText", + "fill": "#FFFFFF", + "content": "从本地选择头像", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "F1MW3", + "name": "profileFormPanel", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 24, + "padding": 28, + "children": [ + { + "type": "text", + "id": "psGg1", + "name": "formTitle", + "fill": "#333333", + "content": "基础资料", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "iIWfm", + "name": "accountReadonlyRow", + "width": "fill_container", + "height": 76, + "fill": "#F8FAFC", + "cornerRadius": 12, + "gap": 16, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "awHUh", + "name": "accountIconBox", + "width": 44, + "height": 44, + "fill": "#F0E6FF", + "cornerRadius": 12, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "B7Mm18", + "name": "accountIcon", + "width": 24, + "height": 24, + "iconFontName": "alternate_email", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + }, + { + "type": "frame", + "id": "HT2Ed", + "name": "accountText", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "jVJeV", + "name": "accountLabel", + "fill": "#999999", + "content": "登录账号", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "lCH88", + "name": "accountValue", + "fill": "#333333", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ptoIl", + "name": "displayNameField", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "Q0NX5C", + "name": "nameLabel", + "fill": "#333333", + "content": "昵称", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "GU543", + "name": "displayNameInput", + "width": "fill_container", + "height": 46, + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "thickness": 1, + "fill": "#CBD5E1" + }, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iUGp1", + "name": "nameValue", + "fill": "#333333", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "xrsgZ", + "name": "nameLimit", + "fill": "#999999", + "content": "最多 20 个字符", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TJhuI", + "name": "bioField", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "FLTA0", + "name": "bioLabel", + "fill": "#333333", + "content": "个人简介", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "z8t6Gj", + "name": "bioInput", + "width": "fill_container", + "height": 124, + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "thickness": 1, + "fill": "#CBD5E1" + }, + "layout": "vertical", + "padding": 14, + "children": [ + { + "type": "text", + "id": "ocx8G", + "name": "bioValue", + "fill": "#333333", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "探索命运奥秘,追寻内心答案。", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "J7oU1W", + "name": "bioLimit", + "fill": "#999999", + "content": "最多 80 个字符", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "F8j2u", + "name": "profileFormActions", + "width": "fill_container", + "height": 44, + "gap": 12, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "UkOMp", + "name": "cancelButton", + "width": 96, + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 22, + "stroke": { + "thickness": 1, + "fill": "#CBD5E1" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HNLx2", + "name": "cancelText", + "fill": "#64748B", + "content": "取消", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "jQSVo", + "name": "saveProfileButton", + "width": 118, + "height": "fill_container", + "fill": "#673AB7", + "cornerRadius": 22, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DRRNM", + "name": "saveText", + "fill": "#FFFFFF", + "content": "保存资料", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Tjl0f", + "x": 0, + "y": 4560, + "name": "ManualDivinationPage", + "width": 1440, + "height": 960, + "fill": "#F8F8F8", + "children": [ + { + "type": "frame", + "id": "o63JxL", + "name": "manualSidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "tTJ7y", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BG2h2", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "fGIcU", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "a1gqFl", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "teL8z", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "DGyDQ", + "name": "navHome", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "thickness": 0, + "fill": "#FFFFFF" + }, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "yF3rj", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "OGVlt", + "fill": "#64748B", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "wNj2O", + "name": "navStore", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "H82lP8", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "EZzlG", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oedqU", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "MZ8dM", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "Sknnp", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "nTJ3m", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "i99T4", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "q7NgWg", + "name": "navManual", + "width": "fill_container", + "fill": "#F0E6FF", + "cornerRadius": 6, + "stroke": { + "thickness": 1, + "fill": "#673AB7" + }, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "LDiSM", + "width": 14, + "height": 14, + "iconFontName": "radio_button_checked", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "JKzzP", + "fill": "#673AB7", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "I02e4h", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "b8Sea", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "XzL9j", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "KH5yN", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "TudQ5", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "dKUxV", + "name": "hist4Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "lWYqz", + "name": "hist4Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kElTP", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "d3onhz", + "name": "lang4Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "qiJGY", + "name": "lang4Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "NYNes", + "name": "navSettings", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "thickness": 0, + "fill": "#FFFFFF" + }, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "VAxoQ", + "name": "set4Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "s3n7P", + "name": "set4Text", + "fill": "#64748B", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "b291y", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "J4Ln7", + "name": "userSection", + "width": "fill_container", + "cornerRadius": 10, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "u3Nwyv", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mbmAk", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "BCaZl", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "LSNgl", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "WX5kR", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "JoOgj", + "name": "manualMainArea", + "width": 1180, + "height": 960, + "fill": "#F8F8F8", + "layout": "vertical", + "gap": 22, + "padding": [ + 32, + 36 + ], + "children": [ + { + "type": "frame", + "id": "ANuH8", + "name": "manualHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "NmTYL", + "name": "manualHeaderText", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "F9sxn9", + "name": "manualTitle", + "fill": "#333333", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "text", + "id": "nXEsi", + "name": "manualSub", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "准备三枚相同的钱币,从初爻到上爻依次录入六次结果。", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "mwNaN", + "name": "balancePill", + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "bBSHM", + "name": "manualBalanceIcon", + "width": 18, + "height": 18, + "iconFontName": "paid", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "bEj4t", + "name": "manualBalanceText", + "fill": "#333333", + "content": "可用 120 积分 · 本次 20 积分", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "PErly", + "name": "manualBody", + "width": "fill_container", + "height": "fill_container", + "gap": 22, + "children": [ + { + "type": "frame", + "id": "DaLIp", + "name": "manualInputColumn", + "width": 360, + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "FScCE", + "name": "questionPanel", + "width": "fill_container", + "height": 300, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 22, + "children": [ + { + "type": "text", + "id": "WlTuw", + "name": "manualQuestionTitle", + "fill": "#333333", + "content": "所问之事", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "ZeL20", + "name": "questionTypeSelect", + "width": "fill_container", + "height": 42, + "fill": "#F8FAFC", + "cornerRadius": 10, + "stroke": { + "thickness": 1, + "fill": "#CBD5E1" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ML5VS", + "name": "manualTypeText", + "fill": "#333333", + "content": "事业", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "text", + "id": "r63HW", + "fill": "#64748B", + "content": "⌄", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "jBP21", + "name": "questionTextArea", + "width": "fill_container", + "height": 124, + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "thickness": 1, + "fill": "#CBD5E1" + }, + "layout": "vertical", + "padding": 14, + "children": [ + { + "type": "text", + "id": "V1vFkQ", + "name": "manualQuestionText", + "fill": "#333333", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "我接下来三个月的事业发展需要注意什么?", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "JXfX3", + "name": "timePanel", + "width": "fill_container", + "height": 132, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "i0D4Og", + "name": "manualTimeTitle", + "fill": "#333333", + "content": "选择起卦时间", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "G9wmD", + "name": "timeRow", + "width": "fill_container", + "height": 42, + "fill": "#F8FAFC", + "cornerRadius": 10, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FiKKT", + "name": "manualTimeText", + "fill": "#333333", + "content": "2026/05/08 14:30", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "skZqK", + "name": "manualTimeBtn", + "fill": "#673AB7", + "content": "修改", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zeOy4", + "name": "manualGuidePanel", + "width": "fill_container", + "height": 214, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "TjfC3", + "name": "manualGuideTitle", + "fill": "#333333", + "content": "录入规则", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "hRYyC", + "name": "manualGuide1", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "从初爻开始,按从下往上的顺序记录。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ITDFn", + "name": "manualGuide2", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "每一爻由三枚钱币的字面/花面组合决定。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "yxZWr", + "name": "manualGuide3", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "六爻完成后才可开始解卦。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "j2AF7h", + "name": "manualGuideButton", + "height": 32, + "fill": "#F0E6FF", + "cornerRadius": 17, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "fH23D", + "name": "guideIcon1", + "width": 18, + "height": 18, + "iconFontName": "help", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "l1Lam", + "name": "guideText1", + "fill": "#673AB7", + "content": "查看手动起卦教程", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "h5hgav", + "name": "manualYaoPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "jW67S", + "name": "yaoPanelHead", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lwsVh", + "name": "manualYaoTitle", + "fill": "#333333", + "content": "指定铜钱字花组合", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "cYOwx", + "name": "manualYaoHint", + "fill": "#673AB7", + "content": "3 / 6", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "O0siAd", + "name": "manualYaoRows", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "vZEY7", + "name": "yao6", + "width": "fill_container", + "height": 62, + "fill": "#F8FAFC", + "cornerRadius": 10, + "gap": 16, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iqymn", + "name": "y6Name", + "fill": "#999999", + "content": "上爻", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "g6LrX5", + "name": "lineEmpty", + "width": "fill_container", + "height": 10, + "fill": "#E2E8F0", + "cornerRadius": 5 + }, + { + "type": "text", + "id": "b0MT5", + "name": "y6Status", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QM3pR", + "name": "yao5", + "width": "fill_container", + "height": 62, + "fill": "#F8FAFC", + "cornerRadius": 10, + "gap": 16, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "l41Rj", + "name": "y5Name", + "fill": "#999999", + "content": "五爻", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "Eafry", + "name": "lineEmpty", + "width": "fill_container", + "height": 10, + "fill": "#E2E8F0", + "cornerRadius": 5 + }, + { + "type": "text", + "id": "gjxmx", + "name": "y5Status", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xpGP9", + "name": "yao4", + "width": "fill_container", + "height": 62, + "fill": "#F8FAFC", + "cornerRadius": 10, + "gap": 16, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "eCCrm", + "name": "y4Name", + "fill": "#999999", + "content": "四爻", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "WtRnt", + "name": "lineEmpty", + "width": "fill_container", + "height": 10, + "fill": "#E2E8F0", + "cornerRadius": 5 + }, + { + "type": "text", + "id": "Ecq8O", + "name": "y4Status", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "auEOy", + "name": "yao3", + "width": "fill_container", + "height": 62, + "fill": "#F0E6FF", + "cornerRadius": 10, + "stroke": { + "thickness": 1, + "fill": "#673AB7" + }, + "gap": 16, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "GWaMO", + "name": "y3Name", + "fill": "#673AB7", + "content": "三爻", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "QM7TK", + "name": "lineActive", + "width": "fill_container", + "height": 10, + "fill": "#673AB7", + "cornerRadius": 5 + }, + { + "type": "text", + "id": "vsAGo", + "name": "y3Status", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "P0015v", + "name": "yao2", + "width": "fill_container", + "height": 62, + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 16, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "el2kv", + "name": "y2Name", + "fill": "#673AB7", + "content": "二爻", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "EfVg4", + "name": "youngYinGlyph", + "width": "fill_container", + "height": 10, + "gap": 16, + "children": [ + { + "type": "frame", + "id": "yWKXT", + "name": "y2Left", + "width": "fill_container", + "height": 10, + "fill": "#673AB7", + "cornerRadius": 5 + }, + { + "type": "frame", + "id": "YQZAj", + "name": "y2Right", + "width": "fill_container", + "height": 10, + "fill": "#673AB7", + "cornerRadius": 5 + } + ] + }, + { + "type": "text", + "id": "V2eX0", + "name": "y2Status", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "X0OUt", + "name": "yao1", + "width": "fill_container", + "height": 62, + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 16, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "h1xkkO", + "name": "y1Name", + "fill": "#673AB7", + "content": "初爻", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "q9ofd", + "name": "oldYinGlyph", + "width": "fill_container", + "height": 10, + "gap": 16, + "children": [ + { + "type": "frame", + "id": "gOe1I", + "name": "y1Left", + "width": "fill_container", + "height": 10, + "fill": "#673AB7", + "cornerRadius": 5 + }, + { + "type": "frame", + "id": "rkUZH", + "name": "y1Right", + "width": "fill_container", + "height": 10, + "fill": "#673AB7", + "cornerRadius": 5 + } + ] + }, + { + "type": "text", + "id": "fT9Jg", + "name": "y1Status", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "content": "×", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "DDYiC", + "name": "coinSelectPanel", + "width": "fill_container", + "height": 142, + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "padding": 16, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "LFeeD", + "name": "manualCoinRow", + "width": "fill_container", + "height": 110, + "gap": 24, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zfTcF", + "name": "coinItemZi", + "width": 80, + "fill": "#00000000", + "layout": "vertical", + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "y7jsm2", + "name": "coinZiImage", + "width": 80, + "height": 80, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/qigua/zi.jpg", + "mode": "fill" + }, + "cornerRadius": 40, + "stroke": { + "thickness": 0, + "fill": "#00000000" + } + }, + { + "type": "text", + "id": "D8LIs3", + "name": "coinLabel", + "fill": "#475569", + "content": "字", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "C3p8s", + "name": "coinItemHua", + "width": 80, + "fill": "#00000000", + "layout": "vertical", + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Na4FT", + "name": "coinHuaImage", + "width": 80, + "height": 80, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/qigua/hua.jpg", + "mode": "fill" + }, + "cornerRadius": 40, + "stroke": { + "thickness": 0, + "fill": "#00000000" + } + }, + { + "type": "text", + "id": "xdozm", + "name": "coinLabel", + "fill": "#475569", + "content": "花", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "DT00s", + "name": "coinItemZi", + "width": 80, + "fill": "#00000000", + "layout": "vertical", + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "G5DxE", + "name": "coinZiImage", + "width": 80, + "height": 80, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/qigua/zi.jpg", + "mode": "fill" + }, + "cornerRadius": 40, + "stroke": { + "thickness": 0, + "fill": "#00000000" + } + }, + { + "type": "text", + "id": "Zo7VF", + "name": "coinLabel", + "fill": "#475569", + "content": "字", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "xJ6nX", + "name": "confirmYaoButton", + "width": "fill_container", + "height": 40, + "fill": "#673AB7", + "cornerRadius": 20, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dtrv4", + "name": "confirmCoinText", + "fill": "#FFFFFF", + "content": "确认此爻", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "t6cCY", + "name": "manualSummaryPanel", + "width": 300, + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 18, + "padding": 22, + "children": [ + { + "type": "text", + "id": "dmMiZ", + "name": "manualSummaryTitle", + "fill": "#333333", + "content": "提交前检查", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "Zx93E", + "name": "progressBox", + "width": "fill_container", + "height": 94, + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "text", + "id": "RW4Gy", + "name": "manualProgressLabel", + "fill": "#666666", + "content": "六爻进度", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wvzTC", + "name": "manualProgressValue", + "fill": "#673AB7", + "content": "3 / 6", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "QvqtN", + "name": "manualCheck1", + "fill": "#666666", + "content": "问题类型:事业", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "jmPMZ", + "name": "manualCheck2", + "fill": "#666666", + "content": "起卦方式:手动起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "PydmZ", + "name": "manualCheck3", + "fill": "#666666", + "content": "解卦消耗:20 积分", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "txk82", + "name": "summarySpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "j5i3E", + "name": "manualSubmitButton", + "width": "fill_container", + "height": 46, + "fill": "#CBD5E1", + "cornerRadius": 23, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "E6P7Z", + "name": "manualSubmitText", + "fill": "#FFFFFF", + "content": "开始解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "p3ev3H", + "x": 1600, + "y": 4560, + "name": "AutoDivinationPage", + "width": 1440, + "height": 960, + "fill": "#F8F8F8", + "children": [ + { + "type": "frame", + "id": "N5vjK", + "name": "autoSidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "cEahy", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "kng10", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "Kr9JI", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "nUi21", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Z7bKZ4", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "RXu6k", + "name": "navHome", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "thickness": 0, + "fill": "#FFFFFF" + }, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "d0btuc", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "obEcE", + "fill": "#64748B", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "i6YvF7", + "name": "navStore", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "In9Iy", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "PJ8Ts", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "f1TSlR", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "lfMJb", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "D3kRK", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "fgcOT", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "gEaco", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "fXhYJ", + "name": "navManual", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DwJB5", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "H9uTTq", + "fill": "#64748B", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "o2DYf", + "name": "navAuto", + "width": "fill_container", + "fill": "#F0E6FF", + "cornerRadius": 6, + "stroke": { + "thickness": 1, + "fill": "#673AB7" + }, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Q84LDE", + "width": 14, + "height": 14, + "iconFontName": "radio_button_checked", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "h7Ekc", + "fill": "#673AB7", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zPST0", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "wXjAw", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "G74UZE", + "name": "hist4Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "bvCUd", + "name": "hist4Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "OiMVy", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "iUV6Z", + "name": "lang4Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "XW2Ax", + "name": "lang4Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Y7LQ2U", + "name": "navSettings", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "thickness": 0, + "fill": "#FFFFFF" + }, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "FTgKZ", + "name": "set4Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "l56gcO", + "name": "set4Text", + "fill": "#64748B", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cwJ4r", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "d7wviK", + "name": "userSection", + "width": "fill_container", + "cornerRadius": 10, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "VSj1R", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sCqQs", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "sSYAU", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "c1wdb", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "v395OI", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "WyZdm", + "name": "autoMainArea", + "width": 1180, + "height": 960, + "fill": "#F8F8F8", + "layout": "vertical", + "gap": 22, + "padding": [ + 32, + 36 + ], + "children": [ + { + "type": "frame", + "id": "yvJWZ", + "name": "autoHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "R36kBu", + "name": "autoHeaderText", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "vrIiP", + "name": "autoTitle", + "fill": "#333333", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + }, + { + "type": "text", + "id": "r1fovG", + "name": "autoSub", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "点击按钮或摇动设备生成铜钱结果,连续 6 次形成完整卦象。", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LHGmx", + "name": "balancePill", + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "jcJph", + "name": "autoBalanceIcon", + "width": 18, + "height": 18, + "iconFontName": "paid", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "S5PSSY", + "name": "autoBalanceText", + "fill": "#333333", + "content": "可用 120 积分 · 本次 20 积分", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VWVzJ", + "name": "autoBody", + "width": "fill_container", + "height": "fill_container", + "gap": 22, + "children": [ + { + "type": "frame", + "id": "fuoUV", + "name": "autoInputColumn", + "width": 340, + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "q6Cdtt", + "name": "questionPanel", + "width": "fill_container", + "height": 280, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 22, + "children": [ + { + "type": "text", + "id": "wxG99", + "name": "autoQuestionTitle", + "fill": "#333333", + "content": "所问之事", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "tsmHE", + "name": "questionTypeSelect", + "width": "fill_container", + "height": 42, + "fill": "#F8FAFC", + "cornerRadius": 10, + "stroke": { + "thickness": 1, + "fill": "#CBD5E1" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Q96rRa", + "name": "autoTypeText", + "fill": "#333333", + "content": "事业", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "text", + "id": "EWL9A", + "name": "autoTypeIcon", + "fill": "#64748B", + "content": "⌄", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "A6VkC", + "name": "questionTextArea", + "width": "fill_container", + "height": 112, + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "thickness": 1, + "fill": "#CBD5E1" + }, + "layout": "vertical", + "padding": 14, + "children": [ + { + "type": "text", + "id": "Gu2iz", + "name": "autoQuestionText", + "fill": "#333333", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "我接下来三个月的事业发展需要注意什么?", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "OIwOu", + "name": "timePanel", + "width": "fill_container", + "height": 132, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "y6phfT", + "name": "autoTimeTitle", + "fill": "#333333", + "content": "选择时间", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "j3l6fe", + "name": "timeRow", + "width": "fill_container", + "height": 42, + "fill": "#F8FAFC", + "cornerRadius": 10, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "B693pR", + "name": "autoTimeText", + "fill": "#333333", + "content": "2026/05/08 14:30", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "AovJE", + "name": "autoTimeBtn", + "fill": "#673AB7", + "content": "修改", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "kofrE", + "name": "autoGuidePanel", + "width": "fill_container", + "height": 214, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "text", + "id": "Dl7cg", + "name": "autoGuideTitle", + "fill": "#333333", + "content": "自动起卦说明", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "AvX4n", + "name": "autoGuide1", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "点击开始摇卦,三枚铜钱会自动旋转并停止。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "nZCfR", + "name": "autoGuide2", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "每次结果自动记录到对应爻位。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IsuOt", + "name": "autoGuide3", + "fill": "#666666", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "完成 6 次后即可开始解卦。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "PwPMX", + "name": "autoGuideButton", + "height": 32, + "fill": "#F0E6FF", + "cornerRadius": 17, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "usEJR", + "name": "guideIcon2", + "width": 18, + "height": 18, + "iconFontName": "help", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "OAZux", + "name": "guideText2", + "fill": "#673AB7", + "content": "查看自动起卦教程", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "bScWh", + "name": "autoShakePanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 18, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "NoCjY", + "name": "shakePanelHead", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JjytY", + "name": "autoShakeTitle", + "fill": "#333333", + "content": "铜钱摇卦", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "P21p6L", + "name": "autoShakeStatus", + "fill": "#673AB7", + "content": "3 / 6", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "MrI68", + "name": "coinStage", + "width": "fill_container", + "height": 194, + "fill": "#F8FAFC", + "cornerRadius": 16, + "gap": 24, + "padding": 22, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "PisNJ", + "name": "coinItemZi", + "width": 86, + "fill": "#00000000", + "layout": "vertical", + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "N0f7Hu", + "name": "coinZiImage", + "width": 86, + "height": 86, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/qigua/zi.jpg", + "mode": "fill" + }, + "cornerRadius": 43, + "stroke": { + "thickness": 0, + "fill": "#00000000" + } + }, + { + "type": "text", + "id": "N7ZlDQ", + "name": "coinLabel", + "fill": "#475569", + "content": "字", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "p77QJB", + "name": "coinItemHua", + "width": 86, + "fill": "#00000000", + "layout": "vertical", + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "sXLdj", + "name": "coinHuaImage", + "width": 86, + "height": 86, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/qigua/hua.jpg", + "mode": "fill" + }, + "cornerRadius": 43, + "stroke": { + "thickness": 0, + "fill": "#00000000" + } + }, + { + "type": "text", + "id": "fakw0", + "name": "coinLabel", + "fill": "#475569", + "content": "花", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "Szv0X", + "name": "coinItemZi", + "width": 86, + "fill": "#00000000", + "layout": "vertical", + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "JojL0", + "name": "coinZiImage", + "width": 86, + "height": 86, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/qigua/zi.jpg", + "mode": "fill" + }, + "cornerRadius": 43, + "stroke": { + "thickness": 0, + "fill": "#00000000" + } + }, + { + "type": "text", + "id": "szD1b", + "name": "coinLabel", + "fill": "#475569", + "content": "字", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "I61KD", + "name": "shakeActionRow", + "width": "fill_container", + "height": 82, + "layout": "vertical", + "gap": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "C8xuwb", + "name": "shakeButton", + "width": 132, + "height": 42, + "fill": "#673AB7", + "cornerRadius": 22, + "gap": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ZxN1E", + "name": "autoShakeIcon", + "width": 20, + "height": 20, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "oUtSL", + "name": "autoShakeText", + "fill": "#FFFFFF", + "content": "摇卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "JehvA", + "name": "autoHexagramPreview", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 18, + "children": [ + { + "type": "text", + "id": "Q5q2Ei", + "name": "autoHexTitle", + "fill": "#333333", + "content": "卦象形成", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "WyFYU", + "name": "hexRows", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "aGE57", + "name": "hex6", + "width": "fill_container", + "height": 34, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "g7xVLe", + "name": "ah6Label", + "fill": "#999999", + "content": "上爻", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "BsEhW", + "name": "ah6Line", + "width": "fill_container", + "height": 8, + "fill": "#E2E8F0", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "D29eZ8", + "name": "ah6Status", + "fill": "#999999", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "yKszG", + "name": "hex5", + "width": "fill_container", + "height": 34, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "E9dQyd", + "name": "ah5Label", + "fill": "#999999", + "content": "五爻", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "H8HU5", + "name": "ah5Line", + "width": "fill_container", + "height": 8, + "fill": "#E2E8F0", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "k2cuXT", + "name": "ah5Status", + "fill": "#999999", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "AtfBK", + "name": "hex4", + "width": "fill_container", + "height": 34, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JkG3C", + "name": "ah4Label", + "fill": "#999999", + "content": "四爻", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "yGaYa", + "name": "ah4Line", + "width": "fill_container", + "height": 8, + "fill": "#E2E8F0", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "Q2hGqu", + "name": "ah4Status", + "fill": "#999999", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ii9mE", + "name": "hex3", + "width": "fill_container", + "height": 34, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "gFeHt", + "name": "ah3Label", + "fill": "#673AB7", + "content": "三爻", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "JDwB2", + "name": "ah3Line", + "width": "fill_container", + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "q3ZYc", + "name": "ah3Status", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "s6veu", + "name": "hex2", + "width": "fill_container", + "height": 34, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "r7XvhD", + "name": "ah2Label", + "fill": "#673AB7", + "content": "二爻", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Wu9bV", + "name": "oldYinGlyph", + "width": "fill_container", + "height": 8, + "gap": 14, + "children": [ + { + "type": "frame", + "id": "pL8bS", + "name": "ah2Left", + "width": "fill_container", + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + }, + { + "type": "frame", + "id": "HCNbK", + "name": "ah2Right", + "width": "fill_container", + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + } + ] + }, + { + "type": "text", + "id": "ve5zG", + "name": "ah2Status", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "content": "×", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "Rdofc", + "name": "hex1", + "width": "fill_container", + "height": 34, + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zI4rR", + "name": "ah1Label", + "fill": "#673AB7", + "content": "初爻", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Z5IcBv", + "name": "youngYinGlyph", + "width": "fill_container", + "height": 8, + "gap": 14, + "children": [ + { + "type": "frame", + "id": "NS2Qj", + "name": "ah1Left", + "width": "fill_container", + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + }, + { + "type": "frame", + "id": "N6JjI", + "name": "ah1Right", + "width": "fill_container", + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + } + ] + }, + { + "type": "text", + "id": "J0EIz", + "name": "ah1Status", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Di5sP", + "name": "autoSummaryPanel", + "width": 300, + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 18, + "padding": 22, + "children": [ + { + "type": "text", + "id": "Gxszg", + "name": "autoSummaryTitle", + "fill": "#333333", + "content": "提交前检查", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "O7lMjB", + "name": "progressBox", + "width": "fill_container", + "height": 94, + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "text", + "id": "EuaAu", + "name": "autoProgressLabel", + "fill": "#666666", + "content": "摇卦进度", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "g8RzaP", + "name": "autoProgressValue", + "fill": "#673AB7", + "content": "3 / 6", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "B5nyMx", + "name": "autoCheck1", + "fill": "#666666", + "content": "问题类型:事业", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "G3Lyur", + "name": "autoCheck2", + "fill": "#666666", + "content": "起卦方式:自动起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "NyKaC", + "name": "autoCheck3", + "fill": "#666666", + "content": "解卦消耗:20 积分", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Z3aM2L", + "name": "summarySpacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "uXc0B", + "name": "autoSubmitButton", + "width": "fill_container", + "height": 46, + "fill": "#CBD5E1", + "cornerRadius": 23, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "CLOIM", + "name": "autoSubmitText", + "fill": "#FFFFFF", + "content": "开始解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ka9oE", + "x": 3200, + "y": 4560, + "name": "DivinationProcessingPage", + "width": 1440, + "height": 960, + "fill": "#F8F8F8", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "Ejlup", + "x": 0, + "y": 0, + "name": "blurredAppBackdrop", + "width": 1440, + "height": 960, + "fill": "#F8F8F8", + "effect": { + "type": "blur", + "radius": 8 + }, + "children": [ + { + "type": "frame", + "id": "emkJG", + "name": "processingSidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "qTrsz", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "KicfN", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "k7fA7", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "F7dbcN", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "V0tc2", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "zjqQn", + "name": "navHome", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "eTPJH", + "name": "navHomeIcon", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "V5PjC", + "name": "navHomeText", + "fill": "#64748B", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oNKvv", + "name": "navStore", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "O4CDJ", + "name": "navStoreIcon", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "a4Vh8B", + "name": "navStoreText", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cyUPY", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "qZE0O", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "xINxq", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "hRDL7", + "name": "divIcon", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "d8AXa0", + "name": "divText", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "USDOC", + "name": "navManual", + "width": "fill_container", + "fill": "#F0E6FF", + "cornerRadius": 6, + "stroke": { + "thickness": 1, + "fill": "#673AB7" + }, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "fPPXi", + "name": "manualDot", + "width": 14, + "height": 14, + "iconFontName": "radio_button_checked", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "zp9Az", + "name": "manualText", + "fill": "#673AB7", + "content": "处理中", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "snTms", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "S3n1VP", + "name": "autoDot", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "nGF5l", + "name": "autoText", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "rNmnM", + "name": "processingMainArea", + "width": 1180, + "height": 960, + "fill": "#F8F8F8", + "layout": "vertical", + "gap": 22, + "padding": [ + 32, + 36 + ], + "children": [ + { + "type": "frame", + "id": "dDfS6", + "name": "processingHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qpKu6", + "name": "processingHeaderText", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "f9OlXK", + "name": "title", + "fill": "#111827", + "content": "正在生成解卦结果", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + }, + { + "type": "text", + "id": "HoW2K", + "name": "subtitle", + "fill": "#64748B", + "content": "系统已接收卦象,正在推演本次问题。", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GefxI", + "name": "pointsPill", + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "wDKwW", + "name": "pillIcon", + "width": 18, + "height": 18, + "iconFontName": "toll", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + }, + { + "type": "text", + "id": "CkgyE", + "name": "pillText", + "fill": "#333333", + "content": "积分 128", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "oElzA", + "name": "blurredContentSkeleton", + "width": "fill_container", + "height": "fill_container", + "gap": 22, + "children": [ + { + "type": "frame", + "id": "L3hdp8", + "name": "questionSkeleton", + "width": 340, + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 16, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "h7FYc", + "name": "leftLine1", + "width": "fill_container", + "height": 48, + "fill": "#F1F5F9", + "cornerRadius": 12 + }, + { + "type": "frame", + "id": "ljxDK", + "name": "leftLine2", + "width": "fill_container", + "height": 168, + "fill": "#F1F5F9", + "cornerRadius": 12 + }, + { + "type": "frame", + "id": "ijCrz", + "name": "leftLine3", + "width": "fill_container", + "height": 214, + "fill": "#F1F5F9", + "cornerRadius": 12 + } + ] + }, + { + "type": "frame", + "id": "BmMvp", + "name": "hexagramSkeleton", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 14, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "t06Tht", + "name": "sk1", + "width": "fill_container", + "height": 62, + "fill": "#F1F5F9", + "cornerRadius": 10 + }, + { + "type": "frame", + "id": "oAigO", + "name": "sk2", + "width": "fill_container", + "height": 62, + "fill": "#F1F5F9", + "cornerRadius": 10 + }, + { + "type": "frame", + "id": "jU87r", + "name": "sk3", + "width": "fill_container", + "height": 62, + "fill": "#F1F5F9", + "cornerRadius": 10 + }, + { + "type": "frame", + "id": "Ica6A", + "name": "sk4", + "width": "fill_container", + "height": 62, + "fill": "#F1F5F9", + "cornerRadius": 10 + }, + { + "type": "frame", + "id": "Ctngm", + "name": "sk5", + "width": "fill_container", + "height": 62, + "fill": "#F1F5F9", + "cornerRadius": 10 + }, + { + "type": "frame", + "id": "Y6yG0q", + "name": "sk6", + "width": "fill_container", + "height": 62, + "fill": "#F1F5F9", + "cornerRadius": 10 + } + ] + }, + { + "type": "frame", + "id": "u5gLaf", + "name": "summarySkeleton", + "width": 300, + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 18, + "padding": 22, + "children": [ + { + "type": "frame", + "id": "Stmn1", + "name": "r1", + "width": "fill_container", + "height": 94, + "fill": "#F1F5F9", + "cornerRadius": 12 + }, + { + "type": "frame", + "id": "jltNz", + "name": "r2", + "width": "fill_container", + "height": 18, + "fill": "#F1F5F9", + "cornerRadius": 9 + }, + { + "type": "frame", + "id": "lHWah", + "name": "r3", + "width": "fill_container", + "height": 18, + "fill": "#F1F5F9", + "cornerRadius": 9 + }, + { + "type": "frame", + "id": "TJZO3", + "name": "r4", + "width": "fill_container", + "height": 18, + "fill": "#F1F5F9", + "cornerRadius": 9 + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "p1lB3", + "x": 0, + "y": 0, + "name": "clickBlockerDimLayer", + "width": 1440, + "height": 960, + "fill": "#0F172A66" + }, + { + "type": "frame", + "id": "ADguW", + "x": 0, + "y": 0, + "name": "processingCenter", + "width": 1440, + "height": 960, + "fill": "#00000000", + "layout": "vertical", + "gap": 22, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "pXIXV", + "name": "flipCardStage", + "width": 420, + "height": 380, + "fill": "#00000000", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "E0RYnz", + "x": 126, + "y": 38, + "name": "flipGhostBack", + "rotation": -8, + "width": 168, + "height": 272, + "fill": "#EDE7F6AA", + "cornerRadius": 18 + }, + { + "type": "frame", + "id": "LJUQH", + "x": 126, + "y": 38, + "name": "flipGhostFront", + "rotation": 8, + "width": 168, + "height": 272, + "fill": "#D1C4E9AA", + "cornerRadius": 18 + }, + { + "type": "frame", + "id": "g30xC", + "x": 100, + "y": 30, + "name": "iChingFlipCard", + "width": 220, + "height": 320, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#F0E6FF", + "position": 0 + }, + { + "color": "#EDE7F6", + "position": 0.52 + }, + { + "color": "#FFFFFF", + "position": 1 + } + ] + }, + "cornerRadius": 18, + "stroke": { + "thickness": 1, + "fill": "#8B5CF64D" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000002E", + "offset": { + "x": 0, + "y": 14 + }, + "blur": 26 + }, + "layout": "vertical", + "gap": 18, + "padding": 24, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DmpX6", + "name": "iChingBadge", + "fill": "#FFFFFFBF", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 6, + 12 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "g3ixK", + "name": "badgeText", + "fill": "#673AB7", + "content": "周易", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "uwLSD", + "name": "guaMark", + "fill": "#673AB7", + "content": "爻", + "fontFamily": "Inter", + "fontSize": 44, + "fontWeight": "700" + }, + { + "type": "text", + "id": "shq4W", + "name": "processingCardTitle", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "乾 • 元亨利贞", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "oHNJ7", + "name": "processingCardQuote", + "fill": "#334155", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "天行健,君子以自强不息。", + "lineHeight": 1.5, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "w1xPTr", + "name": "processingStatus", + "width": 420, + "fill": "#00000000", + "layout": "vertical", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ShxsV", + "name": "statusTitle", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "天机推演中", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "text", + "id": "yPPIr", + "name": "statusDescription", + "fill": "#E2E8F0", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "正在解卦,请勿关闭页面或重复提交。", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "A6q9ZU", + "name": "processingProgress", + "width": 220, + "height": 6, + "fill": "#FFFFFF33", + "cornerRadius": 3, + "children": [ + { + "type": "frame", + "id": "mrVtE", + "name": "processingProgressFill", + "width": 138, + "height": 6, + "fill": "#FFFFFF", + "cornerRadius": 3 + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "EH0fv", + "x": 0, + "y": 5680, + "name": "HomeHistoryListPage", + "width": 1440, + "height": 960, + "fill": "#F8F8F8", + "children": [ + { + "type": "frame", + "id": "HpwIe", + "name": "sidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "e3eec9", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "m0alFP", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "jp9Xi", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "OjYiv", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Vgjz0", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "ocZjK", + "name": "navHome", + "width": "fill_container", + "fill": "#673AB7", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "cDiqX", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "zTXxA", + "fill": "#FFFFFF", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "ZPClh", + "name": "navStore", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "IIM5G", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "wseTb", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "D5MmST", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "oVCDz", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "nSo8J", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "DHJBD", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "zp5kG", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "u4b4x", + "name": "navManual", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nXJVw", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "N53g7", + "fill": "#64748B", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xRz02", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "JCIir", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vAqtC", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "sAkKg", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "q8U3b", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "OaIbr", + "name": "hist1Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "NDo2R", + "name": "hist1Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "CMFAs", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ZBwR0", + "name": "lang1Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "qGing", + "name": "lang1Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "grrTp", + "name": "navSettings", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "nRIgC", + "name": "set1Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "WOlC3", + "name": "set1Text", + "fill": "#64748B", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "sBbnh", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "Nf3QC", + "name": "userSection", + "width": "fill_container", + "cornerRadius": 10, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "wNdkY", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iq3jj", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "CpAvN", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "DoHSl", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "w84Fv8", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "itplx", + "name": "historyMainArea", + "width": 1180, + "height": 960, + "fill": "#F8F8F8", + "layout": "vertical", + "gap": 22, + "padding": [ + 32, + 36 + ], + "children": [ + { + "type": "frame", + "id": "QkUQv", + "name": "historyHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BssBE", + "name": "historyHeaderLeft", + "width": "fill_container", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Gu6En", + "name": "backHomeButton", + "width": 40, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "zisEo", + "name": "backIcon", + "width": 18, + "height": 18, + "iconFontName": "arrow_back_ios_new", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + } + ] + }, + { + "type": "frame", + "id": "rIGMA", + "name": "historyTitleWrap", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "o3vuVg", + "name": "historyTitle", + "fill": "#111827", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "xAXpR", + "name": "historyActions", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "z3zjY", + "name": "historySearch", + "width": 260, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ERtWn", + "name": "searchIcon", + "width": 18, + "height": 18, + "iconFontName": "search", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "NxQ2W", + "name": "searchText", + "fill": "#94A3B8", + "content": "搜索问题或卦名", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HYuEG", + "name": "filterButton", + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "J0XhyN", + "name": "filterIcon", + "width": 18, + "height": 18, + "iconFontName": "tune", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "g1uDst", + "name": "filterText", + "fill": "#475569", + "content": "筛选", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pyKST", + "name": "historyStats", + "width": "fill_container", + "height": 96, + "gap": 16, + "children": [ + { + "type": "frame", + "id": "wSQbp", + "name": "statTotal", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 18, + "children": [ + { + "type": "text", + "id": "akghs", + "name": "st1l", + "fill": "#64748B", + "content": "历史记录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "udC7d", + "name": "st1v", + "fill": "#111827", + "content": "28", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "barxm", + "name": "statFollow", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 18, + "children": [ + { + "type": "text", + "id": "Uhn5W", + "name": "st2l", + "fill": "#64748B", + "content": "可追问记录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "z9SR6", + "name": "st2v", + "fill": "#673AB7", + "content": "7", + "fontFamily": "Inter", + "fontSize": 28, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "SurkF", + "name": "statLatest", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 18, + "children": [ + { + "type": "text", + "id": "t94Yi", + "name": "st3l", + "fill": "#64748B", + "content": "最近解卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "sLZcH", + "name": "st3v", + "fill": "#111827", + "content": "今天 14:36", + "fontFamily": "Inter", + "fontSize": 22, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Gi05H", + "name": "historyContent", + "width": "fill_container", + "height": "fill_container", + "gap": 22, + "children": [ + { + "type": "frame", + "id": "kCvPs", + "name": "historyListPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "fr0G7", + "name": "listHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "eSRk7", + "name": "listTitle", + "fill": "#111827", + "content": "全部记录", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "F2SCmE", + "name": "listHint", + "fill": "#94A3B8", + "content": "左滑或点击删除图标可删除记录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "owOkK", + "name": "historyRowSelected", + "width": "fill_container", + "height": 106, + "fill": "#F0E6FF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#673AB7" + }, + "gap": 14, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "SDPTb", + "name": "rowIcon", + "width": 44, + "height": 44, + "fill": "#E0F2FE", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "GGSFX", + "name": "r1spark", + "width": 22, + "height": 22, + "iconFontName": "stars", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#0284C7" + } + ] + }, + { + "type": "frame", + "id": "qHQyB", + "name": "rowBody", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "ZYial", + "name": "r1q", + "fill": "#111827", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "这次项目合作能否顺利推进?", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "OaOVG", + "name": "rowTags", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "DMuB8", + "name": "r1tag1", + "fill": "#EDE9FE", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "gz0mf", + "name": "r1tag1t", + "fill": "#6D28D9", + "content": "事业", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "KxIhD", + "name": "r1tag2", + "fill": "#E0F2FE", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "FhDwH", + "name": "r1tag2t", + "fill": "#0369A1", + "content": "乾为天", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "VemIq", + "name": "r1tag3", + "fill": "#FEF3C7", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "FuW44", + "name": "r1tag3t", + "fill": "#B45309", + "content": "上上签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "IVSIV", + "name": "rowMeta", + "width": 120, + "layout": "vertical", + "gap": 12, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "ak5Lc", + "name": "r1time", + "fill": "#94A3B8", + "content": "2026-05-08", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "YnzgS", + "name": "r1arrow", + "width": 22, + "height": 22, + "iconFontName": "chevron_right", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#673AB7" + } + ] + } + ] + }, + { + "type": "frame", + "id": "dfrrv", + "name": "historyRow", + "width": "fill_container", + "height": 106, + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 14, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "SEsqo", + "name": "rowIcon", + "width": 44, + "height": 44, + "fill": "#E0F2FE", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "DiTBk", + "name": "r1spark", + "width": 22, + "height": 22, + "iconFontName": "stars", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#0284C7" + } + ] + }, + { + "type": "frame", + "id": "tKe9E", + "name": "rowBody", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "Eoiuz", + "name": "r1q", + "fill": "#111827", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "今年是否适合换一个新的方向?", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "dluog", + "name": "rowTags", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "Hy8xl", + "name": "r1tag1", + "fill": "#D1FAE5", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "xr2Bg", + "name": "r1tag1t", + "fill": "#047857", + "content": "运势", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "luVlP", + "name": "r1tag2", + "fill": "#E0F2FE", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "PxPtU", + "name": "r1tag2t", + "fill": "#0369A1", + "content": "泽风大过", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "An7G1", + "name": "r1tag3", + "fill": "#F3E8FF", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "Y8Z0zV", + "name": "r1tag3t", + "fill": "#673AB7", + "content": "中上签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "hXrGR", + "name": "rowMeta", + "width": 120, + "layout": "vertical", + "gap": 12, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "SpHsD", + "name": "r1time", + "fill": "#94A3B8", + "content": "2026-05-07", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "Z2p9k", + "name": "r1arrow", + "width": 22, + "height": 22, + "iconFontName": "chevron_right", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + } + ] + }, + { + "type": "frame", + "id": "K8y6aO", + "name": "historyRow", + "width": "fill_container", + "height": 106, + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 14, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "wRS1M", + "name": "rowIcon", + "width": 44, + "height": 44, + "fill": "#E0F2FE", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "sAz4T", + "name": "r1spark", + "width": 22, + "height": 22, + "iconFontName": "stars", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#0284C7" + } + ] + }, + { + "type": "frame", + "id": "kOvoi", + "name": "rowBody", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "JX2Ci", + "name": "r1q", + "fill": "#111827", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "感情关系下一步应该主动沟通吗?", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "FPU8A", + "name": "rowTags", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "xcpUg", + "name": "r1tag1", + "fill": "#FCE7F3", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "XBuc3", + "name": "r1tag1t", + "fill": "#BE185D", + "content": "情感", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cA3z2", + "name": "r1tag2", + "fill": "#E0F2FE", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "MkODA", + "name": "r1tag2t", + "fill": "#0369A1", + "content": "雷山小过", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "DFMgP", + "name": "r1tag3", + "fill": "#F1F5F9", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "rTmsY", + "name": "r1tag3t", + "fill": "#64748B", + "content": "中下签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "JTGuh", + "name": "rowMeta", + "width": 120, + "layout": "vertical", + "gap": 12, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "qJ7ej", + "name": "r1time", + "fill": "#94A3B8", + "content": "2026-05-06", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "zm56r", + "name": "r1arrow", + "width": 22, + "height": 22, + "iconFontName": "chevron_right", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + } + ] + }, + { + "type": "frame", + "id": "OZTLk", + "name": "historyRow", + "width": "fill_container", + "height": 106, + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 14, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "PRw9q", + "name": "rowIcon", + "width": 44, + "height": 44, + "fill": "#E0F2FE", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "s4tLIp", + "name": "r1spark", + "width": 22, + "height": 22, + "iconFontName": "stars", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#0284C7" + } + ] + }, + { + "type": "frame", + "id": "h37MN", + "name": "rowBody", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "DkZPW", + "name": "r1q", + "fill": "#111827", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "这笔长期投资是否应该继续持有?", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "JGOmr", + "name": "rowTags", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "eBgx2", + "name": "r1tag1", + "fill": "#FEF3C7", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "j93lZU", + "name": "r1tag1t", + "fill": "#B45309", + "content": "财富", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "A7CgQq", + "name": "r1tag2", + "fill": "#E0F2FE", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "Teygw", + "name": "r1tag2t", + "fill": "#0369A1", + "content": "水火既济", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "cbZTT", + "name": "r1tag3", + "fill": "#F3E8FF", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ZG7K1", + "name": "r1tag3t", + "fill": "#673AB7", + "content": "中上签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "YQOez", + "name": "rowMeta", + "width": 120, + "layout": "vertical", + "gap": 12, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "hq5qk", + "name": "r1time", + "fill": "#94A3B8", + "content": "2026-05-03", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "cZAm3", + "name": "r1arrow", + "width": 22, + "height": 22, + "iconFontName": "chevron_right", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + } + ] + }, + { + "type": "frame", + "id": "q71T6", + "name": "historyRow", + "width": "fill_container", + "height": 106, + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 14, + "padding": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "OEN8t", + "name": "rowIcon", + "width": 44, + "height": 44, + "fill": "#E0F2FE", + "cornerRadius": 10, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rfGAV", + "name": "r1spark", + "width": 22, + "height": 22, + "iconFontName": "stars", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#0284C7" + } + ] + }, + { + "type": "frame", + "id": "M3sWK", + "name": "rowBody", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "BoNaM", + "name": "r1q", + "fill": "#111827", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "梦见旧宅和雨水有什么提醒?", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "I8uGY", + "name": "rowTags", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "ROBaJ", + "name": "r1tag1", + "fill": "#EDE9FE", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "jNFX8", + "name": "r1tag1t", + "fill": "#6D28D9", + "content": "解梦", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "o4GCm8", + "name": "r1tag2", + "fill": "#E0F2FE", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "MS3ZY", + "name": "r1tag2t", + "fill": "#0369A1", + "content": "坎为水", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "wGcPD", + "name": "r1tag3", + "fill": "#FEE2E2", + "cornerRadius": 6, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "H6HuFN", + "name": "r1tag3t", + "fill": "#991B1B", + "content": "下下签", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zlaoT", + "name": "rowMeta", + "width": 120, + "layout": "vertical", + "gap": 12, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "zpkfl", + "name": "r1time", + "fill": "#94A3B8", + "content": "2026-04-29", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "KhTyy", + "name": "r1arrow", + "width": 22, + "height": 22, + "iconFontName": "chevron_right", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "kyf3Y", + "name": "historySidePanel", + "width": 300, + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Qe8jT", + "name": "quickFilters", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "text", + "id": "JQmhk", + "name": "quickTitle", + "fill": "#111827", + "content": "快速筛选", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "AAF5z", + "name": "q1", + "width": "fill_container", + "height": 40, + "fill": "#F8FAFC", + "cornerRadius": 10, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DTTuf", + "name": "q1t", + "fill": "#673AB7", + "content": "全部", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "text", + "id": "gBZ73", + "name": "q1n", + "fill": "#64748B", + "content": "28", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "pxqum", + "name": "q2", + "width": "fill_container", + "height": 40, + "cornerRadius": 10, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MhqEL", + "name": "q2t", + "fill": "#475569", + "content": "事业 / 学业", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IWuue", + "name": "q2n", + "fill": "#94A3B8", + "content": "11", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "NGd1R", + "name": "q3", + "width": "fill_container", + "height": 40, + "cornerRadius": 10, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pwR9A", + "name": "q3t", + "fill": "#475569", + "content": "情感 / 财富", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ifJQx", + "name": "q3n", + "fill": "#94A3B8", + "content": "9", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Vs2Gf", + "x": 1600, + "y": 5680, + "name": "HistoryResultDetailPage", + "width": 1440, + "height": 960, + "fill": "#F8F8F8", + "children": [ + { + "type": "frame", + "id": "Ff0sT", + "name": "sidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "WF3Vi", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Ha2eh", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "ERRJ0", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "CDyI1", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "m6Nrq", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "oZeiw", + "name": "navHome", + "width": "fill_container", + "fill": "#673AB7", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QoSDO", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "COBXw", + "fill": "#FFFFFF", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "XDms1", + "name": "navStore", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "VqfwT", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "brhtS", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "NEoSw", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "aMp32", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "pM4Uh", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "nWGXT", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "ELwzw", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "w6hntZ", + "name": "navManual", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OrVoZ", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "H0aLfJ", + "fill": "#64748B", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Lvbnp", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "sg6s5", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "A4hwk", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "vnzAH", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "JIwpp", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Ompnu", + "name": "hist1Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "O3QKW4", + "name": "hist1Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xyL9U", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ETCDI", + "name": "lang1Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "aXGQZ", + "name": "lang1Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "p4pAQ", + "name": "navSettings", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "p1ris", + "name": "set1Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "z4ZPnd", + "name": "set1Text", + "fill": "#64748B", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TRuaW", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "hbkEm", + "name": "userSection", + "width": "fill_container", + "cornerRadius": 10, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ujgFp", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "U1VqC", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "xZ3BL", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "WiNwJ", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "OzSTX", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "quPLY", + "name": "resultMainArea", + "width": 1180, + "height": 960, + "fill": "#F8F8F8", + "layout": "vertical", + "gap": 20, + "padding": [ + 28, + 36 + ], + "children": [ + { + "type": "frame", + "id": "M0Cn6", + "name": "resultHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "d1aVRg", + "name": "resultHeaderLeft", + "width": "fill_container", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "uSDLk", + "name": "backHistoryButton", + "width": 40, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Y2rzvP", + "name": "backIcon", + "width": 18, + "height": 18, + "iconFontName": "arrow_back_ios_new", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + } + ] + }, + { + "type": "frame", + "id": "VQk0T", + "name": "resultTitleWrap", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "lnjSz", + "name": "title", + "fill": "#111827", + "content": "解卦结果", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "HM5iu", + "name": "resultHeaderActions", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "S2Heaq", + "name": "copyResultButton", + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "YeYtA", + "name": "copyIcon", + "width": 18, + "height": 18, + "iconFontName": "content_copy", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "TvVKf", + "name": "copyText", + "fill": "#475569", + "content": "复制", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "L2ZTj", + "name": "shareButton", + "height": 40, + "fill": "#673AB7", + "cornerRadius": 20, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "aCFlB", + "name": "shareIcon", + "width": 18, + "height": 18, + "iconFontName": "ios_share", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "r0GbxW", + "name": "shareText", + "fill": "#FFFFFF", + "content": "分享", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Mr758", + "name": "resultBody", + "width": "fill_container", + "height": "fill_container", + "gap": 22, + "children": [ + { + "type": "frame", + "id": "l97u4", + "name": "analysisColumn", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 14, + "children": [ + { + "type": "frame", + "id": "qC5TV", + "name": "resultHero", + "width": "fill_container", + "height": 156, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "gap": 20, + "padding": 20, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "eJ3Xx", + "name": "signImage", + "width": 116, + "height": 116, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/qigua/shangshang.jpg", + "mode": "fill" + }, + "cornerRadius": 14, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000024", + "offset": { + "x": 0, + "y": 8 + }, + "blur": 18 + } + }, + { + "type": "frame", + "id": "g7kfMO", + "name": "heroText", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "Qn3o8", + "name": "heroTitle", + "fill": "#111827", + "content": "上上签 · 乾为天", + "fontFamily": "Inter", + "fontSize": 26, + "fontWeight": "700" + }, + { + "type": "text", + "id": "eRa89", + "name": "heroDesc", + "fill": "#334155", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "这次项目合作能否顺利推进?", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "d1Rso", + "name": "heroTags", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "hfXzM", + "name": "tag1", + "fill": "#EDE9FE", + "cornerRadius": 7, + "layout": "vertical", + "padding": [ + 5, + 10 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "w7sY1", + "name": "tag1t", + "fill": "#6D28D9", + "content": "事业", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "M1f93", + "name": "tag2", + "fill": "#E0F2FE", + "cornerRadius": 7, + "layout": "vertical", + "padding": [ + 5, + 10 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "bsYQW", + "name": "tag2t", + "fill": "#0369A1", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "CgxTz", + "name": "tag3", + "fill": "#FEF3C7", + "cornerRadius": 7, + "layout": "vertical", + "padding": [ + 5, + 10 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "xilvc", + "name": "tag3t", + "fill": "#B45309", + "content": "可追问 1 次", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Vik1c", + "name": "conclusionCard", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "B7qOb", + "name": "concHead", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XigfY", + "name": "concTitle", + "fill": "#673AB7", + "content": "解卦结论", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "text", + "id": "DIlDz", + "name": "concCopy", + "fill": "#673AB7", + "content": "复制", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "mg6PJ", + "name": "concText", + "fill": "#334155", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "当前合作整体趋势积极,关键在于明确分工与节奏。若能在本周内完成核心条件确认,后续推进阻力会明显降低。", + "lineHeight": 1.65, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bATxj", + "name": "suggestionCard", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "wb1D6", + "name": "sugHead", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "svUQV", + "name": "sugTitle", + "fill": "#673AB7", + "content": "卦象建议", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "text", + "id": "DR5Jg", + "name": "sugCopy", + "fill": "#673AB7", + "content": "复制", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "bxFWF", + "name": "sugText", + "fill": "#334155", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "宜主动推进,但不要一次性承诺过多。先确定边界、预算和交付时间,再逐步扩大合作范围。", + "lineHeight": 1.65, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "EUxXl", + "name": "analysisCard", + "width": "fill_container", + "height": 184, + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "I8VMrY", + "name": "anaHead", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "kDWqW", + "name": "anaTitle", + "fill": "#673AB7", + "content": "具体解析", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "text", + "id": "Y54M7", + "name": "anaCopy", + "fill": "#673AB7", + "content": "复制", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "LqoTl", + "name": "anaText", + "fill": "#334155", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "乾卦重在主动与秩序,代表资源、信心与方向感较强。当前问题的关键不是能不能推进,而是能否把强势推进转化为清晰规则。若忽略对方节奏,容易在细节处反复。", + "lineHeight": 1.65, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Mf0bj", + "name": "focusCard", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "frame", + "id": "zeEKy", + "name": "focusHead", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "tcaTo", + "name": "focusTitle", + "fill": "#673AB7", + "content": "关注要点", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "text", + "id": "T3jjpm", + "name": "focusCopy", + "fill": "#673AB7", + "content": "复制", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "PnHiU", + "name": "focusText", + "fill": "#334155", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "1. 本周确认合作边界\n2. 优先解决预算与排期\n3. 重要承诺保留书面记录", + "lineHeight": 1.65, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "y9FkAQ", + "name": "warningCard", + "width": "fill_container", + "fill": "#FFFBEB", + "cornerRadius": 12, + "gap": 10, + "padding": 14, + "children": [ + { + "type": "icon_font", + "id": "FVpp8", + "name": "warnIcon", + "width": 20, + "height": 20, + "iconFontName": "warning", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#B45309" + }, + { + "type": "text", + "id": "sS8TF", + "name": "warnText", + "fill": "#92400E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "LYvdj", + "name": "resultSideColumn", + "width": 340, + "height": "fill_container", + "layout": "vertical", + "gap": 14, + "children": [ + { + "type": "frame", + "id": "EQ4sz", + "name": "followUpPanel", + "width": "fill_container", + "fill": "#673AB7", + "cornerRadius": 16, + "layout": "vertical", + "gap": 12, + "padding": 18, + "children": [ + { + "type": "text", + "id": "xKk3e", + "name": "followTitle", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "可针对本次解卦继续追问 1 次", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "w5RDx5", + "name": "followBtn", + "width": "fill_container", + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vhKxY", + "name": "followBtnText", + "fill": "#673AB7", + "content": "追问", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "AKcET", + "name": "basicInfoCard", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 18, + "children": [ + { + "type": "text", + "id": "E78MEw", + "name": "infoTitle", + "fill": "#111827", + "content": "基础信息", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "text", + "id": "qdWo5", + "name": "kv1", + "fill": "#475569", + "content": "起卦时间:2026/5/8 14:36", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "bDMED", + "name": "kv2", + "fill": "#475569", + "content": "起卦方式:手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ziaeg", + "name": "kv3", + "fill": "#475569", + "content": "问题类型:事业", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "DvQrn", + "name": "kv4", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "占卜问题:这次项目合作能否顺利推进?", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "f8V3C", + "name": "ganzhiCard", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 18, + "children": [ + { + "type": "text", + "id": "a8TMj", + "name": "gzTitle", + "fill": "#111827", + "content": "干支信息", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "mouco", + "name": "ganzhiGrid", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "u36AGV", + "name": "gzrow1", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "k2yIlq", + "name": "gz11", + "width": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 8, + "layout": "vertical", + "gap": 4, + "padding": 10, + "children": [ + { + "type": "text", + "id": "EdIu5", + "name": "gz11k", + "fill": "#94A3B8", + "content": "月建", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "kaq0k", + "name": "gz11v", + "fill": "#111827", + "content": "巳", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "AQ97V", + "name": "gz12", + "width": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 8, + "layout": "vertical", + "gap": 4, + "padding": 10, + "children": [ + { + "type": "text", + "id": "JCApQ", + "name": "gz12k", + "fill": "#94A3B8", + "content": "日辰", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "r5ouN1", + "name": "gz12v", + "fill": "#111827", + "content": "申", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "tmf07", + "name": "gzrow2", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "d4xl7", + "name": "gz21", + "width": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 8, + "layout": "vertical", + "gap": 4, + "padding": 10, + "children": [ + { + "type": "text", + "id": "P0RO7", + "name": "gz21k", + "fill": "#94A3B8", + "content": "月破", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "W67JWV", + "name": "gz21v", + "fill": "#111827", + "content": "亥", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "KBqcq", + "name": "gz22", + "width": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 8, + "layout": "vertical", + "gap": 4, + "padding": 10, + "children": [ + { + "type": "text", + "id": "FJlSz", + "name": "gz22k", + "fill": "#94A3B8", + "content": "日冲", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "x99zG", + "name": "gz22v", + "fill": "#111827", + "content": "寅", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "q1eziE", + "name": "hexagramDetailCard", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 12, + "padding": 18, + "children": [ + { + "type": "text", + "id": "BPnJn", + "name": "hexTitle", + "fill": "#111827", + "content": "卦象详情", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "Q8TnKm", + "name": "guaNames", + "width": "fill_container", + "gap": 10, + "children": [ + { + "type": "text", + "id": "iGhbn", + "name": "guaA", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "本卦:乾为天", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "text", + "id": "tAeX0", + "name": "guaB", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "变卦:天风姤", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "xb8ER", + "name": "yaoRows", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "jsKgB", + "name": "y6", + "width": "fill_container", + "height": 24, + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HOmlm", + "name": "y6n", + "fill": "#64748B", + "content": "上爻", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ekV6S", + "name": "y6line", + "width": "fill_container", + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "zrolU", + "name": "y6mark", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "content": "○", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "S1mFU", + "name": "y5", + "width": "fill_container", + "height": 24, + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "U75mPE", + "name": "y6n", + "fill": "#64748B", + "content": "五爻", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Y5OkV", + "name": "y6line", + "width": "fill_container", + "height": 8, + "fill": "#94A3B8", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "qRMGk", + "name": "y6mark", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "qAZlL", + "name": "y4", + "width": "fill_container", + "height": 24, + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RegwI", + "name": "y6n", + "fill": "#64748B", + "content": "四爻", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "H1Glco", + "name": "y6line", + "width": "fill_container", + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "KPwjB", + "name": "y6mark", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "Zdtzv", + "name": "y3", + "width": "fill_container", + "height": 24, + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "R3eXf2", + "name": "y6n", + "fill": "#64748B", + "content": "三爻", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "AeQwa", + "name": "y6line", + "width": "fill_container", + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "kfd8k", + "name": "y6mark", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "P9vtQG", + "name": "y2", + "width": "fill_container", + "height": 24, + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nK4TR", + "name": "y6n", + "fill": "#64748B", + "content": "二爻", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "bWcBi", + "name": "y6line", + "width": "fill_container", + "height": 8, + "fill": "#94A3B8", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "E3JSJj", + "name": "y6mark", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "content": "×", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "GxNLf", + "name": "y1", + "width": "fill_container", + "height": 24, + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ePucm", + "name": "y6n", + "fill": "#64748B", + "content": "初爻", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "eLwGj", + "name": "y6line", + "width": "fill_container", + "height": 8, + "fill": "#673AB7", + "cornerRadius": 4 + }, + { + "type": "text", + "id": "po7Mi", + "name": "y6mark", + "fill": "#673AB7", + "textGrowth": "fixed-width", + "width": 20, + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "sIoaR", + "x": 3200, + "y": 5680, + "name": "HistoryFollowUpPage", + "width": 1440, + "height": 960, + "fill": "#F8F8F8", + "children": [ + { + "type": "frame", + "id": "Ne9mF", + "name": "sidebar", + "width": 260, + "height": 960, + "fill": "#FFFFFF", + "stroke": { + "align": "outside", + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "aUN2Q", + "name": "sidebarHeader", + "width": "fill_container", + "gap": 12, + "padding": [ + 8, + 0 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "REQFt", + "name": "sidebarLogo", + "width": 36, + "height": 36, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/logo.png", + "mode": "fit" + }, + "cornerRadius": 8 + }, + { + "type": "text", + "id": "JJ7MF", + "name": "sidebarTitle", + "fill": "#0F172A", + "content": "觅爻签问", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "SmjoS", + "name": "collapseBtn", + "fill": "#94A3B8", + "content": "◀", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "pzdtt", + "name": "sidebarDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "frame", + "id": "VK8NK", + "name": "navHome", + "width": "fill_container", + "fill": "#673AB7", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "JIn3o", + "width": 18, + "height": 18, + "iconFontName": "home", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "RWmP2", + "fill": "#FFFFFF", + "content": "首页", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "G70A7K", + "name": "navStore", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "lefcB", + "width": 18, + "height": 18, + "iconFontName": "shopping_bag", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "jrlne", + "fill": "#64748B", + "content": "商店", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "L8Pxi", + "name": "sep1", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "QgJxS", + "name": "navDivination", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "VCUdT", + "name": "navDivHeader", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "y9dqm", + "width": 18, + "height": 18, + "iconFontName": "casino", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "HOrbC", + "fill": "#0F172A", + "content": "起卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "CbPCE", + "name": "navManual", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "AOjfG", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "A1tPX", + "fill": "#64748B", + "content": "手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "TRF34", + "name": "navAuto", + "width": "fill_container", + "cornerRadius": 6, + "gap": 8, + "padding": [ + 10, + 10, + 10, + 32 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vO0BZ", + "fill": "#94A3B8", + "content": "○", + "fontFamily": "Inter", + "fontSize": 9, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "DVnr8", + "fill": "#64748B", + "content": "自动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "wEqyL", + "name": "sep2", + "width": "fill_container", + "height": 1, + "fill": "#F1F5F9" + }, + { + "type": "frame", + "id": "o6GgD", + "name": "navHistory", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "KknYf", + "name": "hist1Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "psoxf", + "name": "hist1Text", + "fill": "#64748B", + "content": "历史解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Wa9Dd", + "name": "navLanguage", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "yOJRf", + "name": "lang1Icon", + "width": 18, + "height": 18, + "iconFontName": "language", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "BU5UR", + "name": "lang1Text", + "fill": "#64748B", + "content": "语言", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Sjrkp", + "name": "navSettings", + "width": "fill_container", + "cornerRadius": 8, + "gap": 12, + "padding": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "CZMbj", + "name": "set1Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "q7TPu1", + "name": "set1Text", + "fill": "#64748B", + "content": "设置", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "i1o00", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "GOPup", + "name": "userSection", + "width": "fill_container", + "cornerRadius": 10, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "pat6g", + "name": "userAvatar", + "width": 36, + "height": 36, + "fill": "#673AB7", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "gOxfY", + "fill": "#FFFFFF", + "content": "林", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "B3Wth", + "name": "userInfo", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "U1LCO", + "fill": "#0F172A", + "content": "林小姐", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "GFtKt", + "fill": "#94A3B8", + "content": "lin@example.com", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "x7QcZ1", + "name": "followUpMainArea", + "width": 1180, + "height": 960, + "fill": "#F8F8F8", + "layout": "vertical", + "gap": 20, + "padding": [ + 28, + 36 + ], + "children": [ + { + "type": "frame", + "id": "SQ2wV", + "name": "followUpHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "NjNff", + "name": "followUpHeaderLeft", + "width": "fill_container", + "gap": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "x6ajI4", + "name": "backResultButton", + "width": 40, + "height": 40, + "fill": "#FFFFFF", + "cornerRadius": 20, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "B4psz", + "name": "backIcon", + "width": 18, + "height": 18, + "iconFontName": "arrow_back_ios_new", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + } + ] + }, + { + "type": "frame", + "id": "nO5kp", + "name": "followUpTitleWrap", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Q7rNM", + "name": "title", + "fill": "#111827", + "content": "追问", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "bzEjR", + "name": "followUpBody", + "width": "fill_container", + "height": "fill_container", + "gap": 22, + "children": [ + { + "type": "frame", + "id": "qMTdw", + "name": "chatPanel", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "TyFkf", + "name": "chatPanelHeader", + "width": "fill_container", + "height": 72, + "fill": "#FFFFFF", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#E2E8F0" + }, + "padding": [ + 0, + 20 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "UAvxW", + "name": "chatTitleWrap", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "FlJLq", + "name": "chatTitle", + "fill": "#111827", + "content": "本次追问对话", + "fontFamily": "Inter", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "EjzMd", + "name": "chatHint", + "fill": "#64748B", + "content": "AI 会结合原始卦象、问题和解读结果回答。", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "pRLPe", + "name": "viewFollowHistory", + "height": 36, + "fill": "#F8FAFC", + "cornerRadius": 18, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "dfB6s", + "name": "histIcon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "UwcOD", + "name": "histText", + "fill": "#475569", + "content": "追问记录", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "U8UIWt", + "name": "messageTimeline", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "layout": "vertical", + "gap": 18, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "W0pap4", + "name": "originalContextMessage", + "width": "fill_container", + "fill": "#F8FAFC", + "cornerRadius": 12, + "layout": "vertical", + "gap": 10, + "padding": 16, + "children": [ + { + "type": "text", + "id": "JK1Nl", + "name": "origTitle", + "fill": "#64748B", + "content": "原始问题", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "700" + }, + { + "type": "text", + "id": "E54dm", + "name": "origText", + "fill": "#111827", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "这次项目合作能否顺利推进?", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "eBCYH", + "name": "userMessageRow", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "t2nwDt", + "name": "userBubble", + "width": 520, + "fill": "#673AB7", + "cornerRadius": 14, + "layout": "vertical", + "gap": 8, + "padding": 16, + "children": [ + { + "type": "text", + "id": "sSCN4", + "name": "userText", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "如果我这周主动约对方开会,会不会显得太急?", + "lineHeight": 1.55, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vO8PE", + "name": "userTime", + "fill": "#EDE7F6", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "14:42", + "textAlign": "right", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "efJSH", + "name": "aiMessageRow", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "T74OuM", + "name": "aiAvatar", + "width": 36, + "height": 36, + "fill": "#F0E6FF", + "cornerRadius": 18, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nfuxv", + "name": "aiAvatarText", + "fill": "#673AB7", + "content": "爻", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "Fz3VN", + "name": "aiBubble", + "width": 620, + "fill": "#F8FAFC", + "cornerRadius": 14, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 10, + "padding": 16, + "children": [ + { + "type": "text", + "id": "OrwrB", + "name": "aiText", + "fill": "#334155", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "不算太急。乾卦强调主动,但要把主动表达为推进事项,而不是催促结果。建议用“同步边界和排期”的名义约会,并提前给出议题,让对方感受到秩序而不是压力。", + "lineHeight": 1.6, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "N333h", + "name": "aiMeta", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "d2u63m", + "name": "aiTime", + "fill": "#94A3B8", + "content": "14:43", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "MF22t", + "name": "messageComposer", + "width": "fill_container", + "height": 146, + "fill": "#FFFFFF", + "stroke": { + "thickness": { + "top": 1 + }, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "padding": [ + 18, + 22 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "yr5up", + "name": "aiComposerBox", + "width": "fill_container", + "height": 108, + "fill": "#F8FAFC", + "cornerRadius": 18, + "stroke": { + "thickness": 1, + "fill": "#D8DEE8" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 6 + }, + "blur": 16 + }, + "layout": "vertical", + "gap": 12, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "frame", + "id": "Te2fC", + "name": "composerInputLine", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "OLeIk", + "name": "composerPlaceholder", + "fill": "#64748B", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "继续追问这次解卦,例如:我应该什么时候推进?", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "E9d8o", + "name": "composerBottomBar", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "VZkUT", + "name": "composerHint", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qKfVB", + "name": "quotaMiniBadge", + "fill": "#F0E6FF", + "cornerRadius": 999, + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "AFCgN", + "name": "quotaText", + "fill": "#673AB7", + "content": "剩余 1 次", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "e2dYa", + "name": "enterText", + "fill": "#94A3B8", + "content": "Enter 发送,Shift + Enter 换行", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "N8PEUa", + "name": "composerSendButton", + "width": 38, + "height": 38, + "fill": "#673AB7", + "cornerRadius": 19, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rTBJD", + "name": "composerSendIcon", + "width": 20, + "height": 20, + "iconFontName": "arrow_upward", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "LR2UV", + "name": "followUpSideColumn", + "width": 340, + "height": "fill_container", + "layout": "vertical", + "gap": 14, + "children": [ + { + "type": "frame", + "id": "idMJ6", + "name": "resultSummaryPanel", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 14, + "padding": 18, + "children": [ + { + "type": "text", + "id": "W9Nsbv", + "name": "summaryTitle", + "fill": "#111827", + "content": "本次解卦摘要", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "mLwZ2", + "name": "sign", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "djuZ4", + "name": "signImg", + "width": 58, + "height": 58, + "fill": { + "type": "image", + "enabled": true, + "url": "assets/images/qigua/shangshang.jpg", + "mode": "fill" + }, + "cornerRadius": 10 + }, + { + "type": "frame", + "id": "pr8NU", + "name": "signTextWrap", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "UXFZJ", + "name": "signTitle", + "fill": "#111827", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "上上签 · 乾为天", + "fontFamily": "Inter", + "fontSize": 15, + "fontWeight": "700" + }, + { + "type": "text", + "id": "zr3bZ", + "name": "signSub", + "fill": "#64748B", + "content": "事业 · 手动起卦", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "gKHy2", + "name": "summaryQuestion", + "fill": "#334155", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "这次项目合作能否顺利推进?", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "QDrkN", + "name": "summaryDivider", + "width": "fill_container", + "height": 1, + "fill": "#E2E8F0" + }, + { + "type": "text", + "id": "hj5ju", + "name": "summaryConclusion", + "fill": "#475569", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "趋势积极,关键在于明确分工与节奏。", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xyICv", + "name": "followUpRules", + "width": "fill_container", + "fill": "#FFFBEB", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#FDE68A" + }, + "layout": "vertical", + "gap": 12, + "padding": 18, + "children": [ + { + "type": "icon_font", + "id": "HcaS7", + "name": "rulesIcon", + "width": 22, + "height": 22, + "iconFontName": "info", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#B45309" + }, + { + "type": "text", + "id": "lGfXU", + "name": "rulesTitle", + "fill": "#92400E", + "content": "追问说明", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "N2HdX", + "name": "rulesText", + "fill": "#92400E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "追问会基于同一次卦象继续分析,不会重新起卦。每条历史记录仅保留一次追问额度。", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "z1Ra7", + "name": "relatedActions", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "thickness": 1, + "fill": "#E2E8F0" + }, + "layout": "vertical", + "gap": 10, + "padding": 18, + "children": [ + { + "type": "text", + "id": "eNQg7", + "name": "relatedTitle", + "fill": "#111827", + "content": "相关操作", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "Aoua3", + "name": "rel1", + "width": "fill_container", + "height": 40, + "fill": "#F8FAFC", + "cornerRadius": 10, + "gap": 10, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "SFkql", + "name": "rel1Icon", + "width": 18, + "height": 18, + "iconFontName": "article", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#64748B" + }, + { + "type": "text", + "id": "a6pfW", + "name": "rel1Text", + "fill": "#475569", + "content": "查看完整解卦", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "Rn6M2", + "name": "rel2", + "width": "fill_container", + "height": 40, + "cornerRadius": 10, + "gap": 10, + "padding": [ + 0, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "EeX26", + "name": "rel2Icon", + "width": 18, + "height": 18, + "iconFontName": "history", + "iconFontFamily": "Material Symbols Rounded", + "fill": "#94A3B8" + }, + { + "type": "text", + "id": "wLtMI", + "name": "rel2Text", + "fill": "#64748B", + "content": "返回历史列表", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..fd9b489 --- /dev/null +++ b/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "eryao-web", + "type": "module", + "version": "0.0.1", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/node": "^10.1.0", + "@astrojs/react": "^5.0.4", + "@tailwindcss/vite": "^4.3.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "astro": "^6.3.1", + "marked": "^18.0.3", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.0", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@astrojs/check": "^0.9.9", + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..d97a7ee --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,4664 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@astrojs/node': + specifier: ^10.1.0 + version: 10.1.0(astro@6.3.1(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4)) + '@astrojs/react': + specifier: ^5.0.4 + version: 5.0.4(@types/node@25.6.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yaml@2.8.4) + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + astro: + specifier: ^6.3.1 + version: 6.3.1(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4) + marked: + specifier: ^18.0.3 + version: 18.0.3 + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) + react-router-dom: + specifier: ^7.15.0 + version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + devDependencies: + '@astrojs/check': + specifier: ^0.9.9 + version: 0.9.9(prettier@3.8.3)(typescript@6.0.3) + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + +packages: + + '@astrojs/check@0.9.9': + resolution: {integrity: sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler@2.13.1': + resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} + + '@astrojs/compiler@4.0.0': + resolution: {integrity: sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==} + + '@astrojs/internal-helpers@0.9.0': + resolution: {integrity: sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg==} + + '@astrojs/language-server@2.16.8': + resolution: {integrity: sha512-yg1pZF6hs9FaKr2fgXMOGbW7pDLgFexFjuhWilPAc8VybTU+WSnbfbhYaUL1exm6dAK4sM3aKXGcfVwss+HXbg==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-remark@7.1.1': + resolution: {integrity: sha512-C6e9BnLGlbdv6bV8MYGeHpHxsUHrCrB4OuRLqi5LI7oiBVcBcqfUN06zpwFQdHgV48QCCrMmLpyqBr7VqC+swA==} + + '@astrojs/node@10.1.0': + resolution: {integrity: sha512-4/2oqUTQ71UQ8+xX249T4l/d0/YkC5ssOVl4R2yQO7Wg4mOnvsq9Z9iaTkWAyElg3lqZq7XRNCEXCmDNiYcW1A==} + peerDependencies: + astro: ^6.3.0 + + '@astrojs/prism@4.0.1': + resolution: {integrity: sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==} + engines: {node: '>=22.12.0'} + + '@astrojs/react@5.0.4': + resolution: {integrity: sha512-yDNE4VnKOzCjH9dCBi7pT4F6kpI3M9TkS+uxnCB0sGIS6t5vKonOY+Hs/UUnSajJGT5jeBRfpI9IQp+r/n1fBA==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@types/react': ^17.0.50 || ^18.0.21 || ^19.0.0 + '@types/react-dom': ^17.0.17 || ^18.0.6 || ^19.0.0 + react: ^17.0.2 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0 + + '@astrojs/telemetry@3.3.2': + resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/yaml2ts@0.2.3': + resolution: {integrity: sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@capsizecss/unpack@4.0.0': + resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} + engines: {node: '>=18'} + + '@clack/core@1.3.0': + resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.3.0': + resolution: {integrity: sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==} + engines: {node: '>= 20.12.0'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@shikijs/core@4.0.2': + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.0.2': + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.0.2': + resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.0.2': + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.0.2': + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.0.2': + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + engines: {node: '>=20'} + + '@shikijs/types@4.0.2': + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + astro@6.3.1: + resolution: {integrity: sha512-atz6dmkE3Gu24bDgb7g2RE/BYnKqPYIHd6hTUM1UXvu/i7qNZOKLAqEHvgYpv9PQVcgWsXpk4/OOXZ0E/FzvSQ==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + engines: {node: '>=6.0.0'} + hasBin: true + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-tree@2.2.0: + resolution: {integrity: sha512-7y32czN0VBL8WkevhC/mrHnoHOmQaJ1Wvp8sjRuTz6/n9cjL83jQaUru2MvP7kzjpGVwrSy5CE4XyQObWGIHQQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.0: + resolution: {integrity: sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.2: + resolution: {integrity: sha512-pr8ToPIuwBonzUy42STpc5Cf0m69zsQ7gtCLLvKrTbhVRnRohT2pLiJmGp3PAh16nDVWpYpcRpdjuk1vFmnQUg==} + engines: {node: '>= 4'} + + domutils@3.0.1: + resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.352: + resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.21.2: + resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} + engines: {node: '>=10.13.0'} + + entities@4.2.0: + resolution: {integrity: sha512-wEJa03bJgqEwPnkUqYdgmcfUXfm6+4hePQhntIvRy/1/+C4dFuhYHsgKBRjbQ6OWBh42P+VhAoCDO77DUh0e/Q==} + engines: {node: '>=0.12'} + + entities@4.3.0: + resolution: {integrity: sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@2.3.0: + resolution: {integrity: sha512-b0EBt8SWFNnixVdvoR2ZtEGa9ZqLhbJnOjezn+WP+8kspFm+PFYDN8Z4Bc7pRlDjvuVcADSUkroIuTWWn/YiIA==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marked@18.0.3: + resolution: {integrity: sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==} + engines: {node: '>= 20'} + hasBin: true + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.0.0: + resolution: {integrity: sha512-Ov3aWCYpb31/SkHNRlREvSZsF9ETBW/rXw4PooawZO8qy2MviDq4TI6kxX6zFmGa/zUX36STKIC/IpASQk596w==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-core-commonmark@2.0.0: + resolution: {integrity: sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.0: + resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} + + micromark-factory-label@2.0.0: + resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + + micromark-factory-space@2.0.0: + resolution: {integrity: sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==} + + micromark-factory-title@2.0.0: + resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + + micromark-factory-whitespace@2.0.0: + resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + + micromark-util-character@2.0.0: + resolution: {integrity: sha512-DtHHqgTRz32ylwiGSZwoK+BTx4gaq//ig71g20pwBwRLxo3CfInyD+NyTrriLOhPz5WQOfAOSLLbKOaaGXWz9Q==} + + micromark-util-chunked@2.0.0: + resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + + micromark-util-classify-character@2.0.0: + resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} + + micromark-util-combine-extensions@2.0.0: + resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + + micromark-util-decode-numeric-character-reference@2.0.0: + resolution: {integrity: sha512-pIgcsGxpHEtTG/rPJRz/HOLSqp5VTuIIjXlPI+6JSDlK2oljApusG6KzpS8AF0ENUMCHlC/IBb5B9xdFiVlm5Q==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.0: + resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + + micromark-util-html-tag-name@2.0.0: + resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + + micromark-util-normalize-identifier@2.0.0: + resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + + micromark-util-resolve-all@2.0.0: + resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + + micromark-util-sanitize-uri@2.0.0: + resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + + micromark-util-subtokenize@2.0.0: + resolution: {integrity: sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==} + + micromark-util-symbol@2.0.0: + resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + + micromark-util-types@2.0.0: + resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + + micromark@4.0.0: + resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.0.1: + resolution: {integrity: sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + + p-queue@9.2.0: + resolution: {integrity: sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse5@7.0.0: + resolution: {integrity: sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.15.0: + resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.15.0: + resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + server-destroy@1.0.1: + resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} + + set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shiki@4.0.2: + resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + engines: {node: '>=20'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinyclip@0.1.12: + resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + unified@11.0.0: + resolution: {integrity: sha512-65zgPyv0vyXnJpw4fbGmoXjP0/6cBmnnesl4lSPGU2tYuzLWtuicRBFcaV6kgzitGrBHj6pgXYomMw1VQe/cQg==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.4: + resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + volar-service-css@0.0.70: + resolution: {integrity: sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.70: + resolution: {integrity: sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.70: + resolution: {integrity: sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.70: + resolution: {integrity: sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.70: + resolution: {integrity: sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.70: + resolution: {integrity: sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.70: + resolution: {integrity: sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.4: + resolution: {integrity: sha512-IpaHLPft+UBWf4dOIH15YEgydTbXGz52EMU2h16SfFpYu/yOQt3pY14049mtpJu+4CBHn+hq7S67e7O0AwpRqQ==} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.4: + resolution: {integrity: sha512-9YXi5pA3XF2V+NUQg6g+lulNS0ncRCKASYdK3Cs7kiH9sVFXWq27prjkC/B8M/xJLRPPRSPCHVMuBTgRNFh2sQ==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.0: + resolution: {integrity: sha512-npT72Iu28Tjsm94MsCbwJmIu5ycCF3UEPj3Eb3725T1Hqf4d+Vj2W4GC+F8l4n9yNItJuvE/AHYvomvAs9Dj8A==} + hasBin: true + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-language-server@1.20.0: + resolution: {integrity: sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==} + hasBin: true + + yaml@2.7.1: + resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} + engines: {node: '>= 14'} + hasBin: true + + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/check@0.9.9(prettier@3.8.3)(typescript@6.0.3)': + dependencies: + '@astrojs/language-server': 2.16.8(prettier@3.8.3)(typescript@6.0.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 6.0.3 + yargs: 17.7.2 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/compiler@4.0.0': {} + + '@astrojs/internal-helpers@0.9.0': + dependencies: + picomatch: 4.0.4 + + '@astrojs/language-server@2.16.8(prettier@3.8.3)(typescript@6.0.3)': + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/yaml2ts': 0.2.3 + '@jridgewell/sourcemap-codec': 1.5.5 + '@volar/kit': 2.4.28(typescript@6.0.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.16 + volar-service-css: 0.0.70(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.70(@volar/language-service@2.4.28) + volar-service-html: 0.0.70(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.70(@volar/language-service@2.4.28)(prettier@3.8.3) + volar-service-typescript: 0.0.70(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.70(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.70(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.1.0 + optionalDependencies: + prettier: 3.8.3 + transitivePeerDependencies: + - typescript + + '@astrojs/markdown-remark@7.1.1': + dependencies: + '@astrojs/internal-helpers': 0.9.0 + '@astrojs/prism': 4.0.1 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + js-yaml: 4.1.1 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.2 + retext-smartypants: 6.2.0 + shiki: 4.0.2 + smol-toml: 1.6.1 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/node@10.1.0(astro@6.3.1(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4))': + dependencies: + '@astrojs/internal-helpers': 0.9.0 + astro: 6.3.1(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4) + send: 1.2.1 + server-destroy: 1.0.1 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@4.0.1': + dependencies: + prismjs: 1.30.0 + + '@astrojs/react@5.0.4(@types/node@25.6.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.7.0)(lightningcss@1.32.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yaml@2.8.4)': + dependencies: + '@astrojs/internal-helpers': 0.9.0 + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': 5.2.0(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + devalue: 5.8.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + ultrahtml: 1.6.0 + vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + '@astrojs/telemetry@3.3.2': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + is-wsl: 3.1.1 + which-pm-runs: 1.1.0 + + '@astrojs/yaml2ts@0.2.3': + dependencies: + yaml: 2.8.4 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@capsizecss/unpack@4.0.0': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.3.0': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@clack/prompts@1.3.0': + dependencies: + '@clack/core': 1.3.0 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@oslojs/encoding@1.1.0': {} + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/pluginutils@5.3.0(rollup@4.60.3)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.3 + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@shikijs/core@4.0.2': + dependencies: + '@shikijs/primitive': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/primitive@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/types@4.0.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.2 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.1': {} + + '@vitejs/plugin-react@5.2.0(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + transitivePeerDependencies: + - supports-color + + '@volar/kit@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 6.0.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.0 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + '@vscode/l10n@0.0.18': {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-iterate@2.0.1: {} + + astro@6.3.1(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4): + dependencies: + '@astrojs/compiler': 4.0.0 + '@astrojs/internal-helpers': 0.9.0 + '@astrojs/markdown-remark': 7.1.1 + '@astrojs/telemetry': 3.3.2 + '@capsizecss/unpack': 4.0.0 + '@clack/prompts': 1.3.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.3) + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 1.1.1 + devalue: 5.8.0 + diff: 8.0.4 + dset: 3.1.4 + es-module-lexer: 2.1.0 + esbuild: 0.27.7 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.1.1 + jsonc-parser: 3.3.1 + magic-string: 0.30.21 + magicast: 0.5.2 + mrmime: 2.0.1 + neotraverse: 0.6.18 + obug: 2.1.1 + p-limit: 7.3.0 + p-queue: 9.2.0 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 4.0.2 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyclip: 0.1.12 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5 + vfile: 6.0.3 + vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vitefu: 1.1.3(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.4.3 + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + baseline-browser-mapping@2.10.27: {} + + boolbase@1.0.0: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.352 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-lite@1.0.30001792: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + ci-info@4.4.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@1.1.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.2 + domutils: 3.0.1 + nth-check: 2.0.1 + + css-tree@2.2.0: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.1.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.0 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + defu@6.1.7: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devalue@5.8.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.4: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.2 + entities: 4.2.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.2: + dependencies: + domelementtype: 2.3.0 + + domutils@3.0.1: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.2 + + dset@3.1.4: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.352: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.21.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.2.0: {} + + entities@4.3.0: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@5.0.0: {} + + estree-walker@2.0.2: {} + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + graceful-fs@4.2.11: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.0.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.1 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.0.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + inherits@2.0.4: {} + + iron-webcrypto@1.2.1: {} + + is-docker@3.0.0: {} + + is-docker@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-plain-obj@4.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + jsonc-parser@2.3.0: {} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + longest-streak@3.1.0: {} + + lru-cache@11.3.6: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + markdown-table@3.0.4: {} + + marked@18.0.3: {} + + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.0 + micromark-util-decode-numeric-character-reference: 2.0.0 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.0.0 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.0 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-core-commonmark@2.0.0: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.0 + micromark-factory-label: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-factory-title: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.0.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-html-tag-name: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-subtokenize: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-destination@2.0.0: + dependencies: + micromark-util-character: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-label@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-space@2.0.0: + dependencies: + micromark-util-character: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-title@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-whitespace@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-character@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-chunked@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-classify-character@2.0.0: + dependencies: + micromark-util-character: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-combine-extensions@2.0.0: + dependencies: + micromark-util-chunked: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-decode-numeric-character-reference@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.0 + micromark-util-symbol: 2.0.0 + + micromark-util-encode@2.0.0: {} + + micromark-util-html-tag-name@2.0.0: {} + + micromark-util-normalize-identifier@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-resolve-all@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-util-sanitize-uri@2.0.0: + dependencies: + micromark-util-character: 2.0.0 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + + micromark-util-subtokenize@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-symbol@2.0.0: {} + + micromark-util-types@2.0.0: {} + + micromark@4.0.0: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.0.0 + micromark-util-chunked: 2.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.0 + micromark-util-encode: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-subtokenize: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + transitivePeerDependencies: + - supports-color + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.12: {} + + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.4: {} + + node-releases@2.0.38: {} + + normalize-path@3.0.0: {} + + nth-check@2.0.1: + dependencies: + boolbase: 1.0.0 + + obug@2.1.1: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.2.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + package-manager-detector@1.6.0: {} + + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse5@7.0.0: + dependencies: + entities: 4.3.0 + + path-browserify@1.0.1: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.8.3: {} + + prismjs@1.30.0: {} + + property-information@7.1.0: {} + + radix3@1.1.2: {} + + range-parser@1.2.1: {} + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-refresh@0.18.0: {} + + react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + + react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + cookie: 1.1.1 + react: 19.2.6 + set-cookie-parser: 2.6.0 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + + react@19.2.6: {} + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.0 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + unified: 11.0.0 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.4 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.0 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.0.0 + unified: 11.0.5 + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + sax@1.6.0: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + server-destroy@1.0.1: {} + + set-cookie-parser@2.6.0: {} + + setprototypeof@1.2.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shiki@4.0.2: + dependencies: + '@shikijs/core': 4.0.2 + '@shikijs/engine-javascript': 4.0.2 + '@shikijs/engine-oniguruma': 4.0.2 + '@shikijs/langs': 4.0.2 + '@shikijs/themes': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + sisteransi@1.0.5: {} + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + statuses@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.1.0 + css-tree: 3.2.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tiny-inflate@1.0.3: {} + + tinyclip@0.1.12: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + toidentifier@1.0.1: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: + optional: true + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.7.4 + + typescript@6.0.3: {} + + ufo@1.6.4: {} + + ultrahtml@1.6.0: {} + + uncrypto@0.1.3: {} + + undici-types@7.19.2: {} + + unified@11.0.0: + dependencies: + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.1 + bail: 2.0.2 + devlop: 1.1.0 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.4: + dependencies: + css-tree: 3.2.1 + ofetch: 1.5.1 + ohash: 2.0.11 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.3.6 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.2 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + yaml: 2.8.4 + + vitefu@1.1.3(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): + optionalDependencies: + vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + + volar-service-css@0.0.70(@volar/language-service@2.4.28): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-emmet@0.0.70(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-html@0.0.70(@volar/language-service@2.4.28): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-prettier@0.0.70(@volar/language-service@2.4.28)(prettier@3.8.3): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + prettier: 3.8.3 + + volar-service-typescript-twoslash-queries@0.0.70(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-typescript@0.0.70(@volar/language-service@2.4.28): + dependencies: + path-browserify: 1.0.1 + semver: 7.7.4 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-yaml@0.0.70(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + yaml-language-server: 1.20.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.4: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.4 + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.4: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.0: + dependencies: + vscode-languageserver-protocol: 3.17.4 + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.1.0: {} + + web-namespaces@2.0.1: {} + + which-pm-runs@1.1.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml-language-server@1.20.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + prettier: 3.8.3 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.0 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + yaml: 2.7.1 + + yaml@2.7.1: {} + + yaml@2.8.4: {} + + yargs-parser@21.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@1.2.2: {} + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..dbb26c8 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: true + sharp: true diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..7f48a94 Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..f157bd1 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,9 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128"> + <path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" /> + <style> + path { fill: #000; } + @media (prefers-color-scheme: dark) { + path { fill: #FFF; } + } + </style> +</svg> diff --git a/web/public/images b/web/public/images new file mode 120000 index 0000000..7d32e0d --- /dev/null +++ b/web/public/images @@ -0,0 +1 @@ +../design/assets/images \ No newline at end of file diff --git a/web/public/legal b/web/public/legal new file mode 120000 index 0000000..9b4289c --- /dev/null +++ b/web/public/legal @@ -0,0 +1 @@ +../design/assets/legal \ No newline at end of file diff --git a/web/src/components/AboutPage.astro b/web/src/components/AboutPage.astro new file mode 100644 index 0000000..2dc992d --- /dev/null +++ b/web/src/components/AboutPage.astro @@ -0,0 +1,66 @@ +--- +import { t, localePath, type Locale } from '../i18n/utils'; +import fs from 'node:fs'; +import path from 'node:path'; +import { marked } from 'marked'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const footer = t(locale, 'footer'); + +const titleMap: Record<Locale, string> = { + zh: '关于我们', + zh_Hant: '關於我們', + en: 'About Us', +}; + +// Try multiple paths for dev and production environments +const possiblePaths = [ + path.resolve('public/legal', locale, 'about_us.md'), + path.resolve('client/legal', locale, 'about_us.md'), + path.resolve('../client/legal', locale, 'about_us.md'), +]; +let raw = ''; +for (const filePath of possiblePaths) { + try { + raw = fs.readFileSync(filePath, 'utf-8'); + raw = raw.replace(/^#\s+.+\n*/m, ''); + break; + } catch { + // Try next path + } +} +if (!raw) { + raw = `Content not available.`; +} +const content = await marked(raw); +--- + +<!-- Header --> +<section class="w-full bg-gradient-to-b from-white to-violet-50 py-16 md:py-20 px-6 md:px-20 text-center"> + <h1 class="reveal text-slate-900 text-4xl md:text-[48px] font-extrabold">{titleMap[locale]}</h1> +</section> + +<!-- Content --> +<section class="w-full py-16 md:py-20 px-6 md:px-20 bg-white"> + <div class="max-w-4xl mx-auto reveal prose prose-slate + [&_h2]:text-[28px] [&_h2]:font-bold [&_h2]:text-slate-900 [&_h2]:mt-8 [&_h2]:mb-4 + [&_p]:text-slate-600 [&_p]:text-base [&_p]:leading-loose + [&_strong]:text-slate-700 [&_hr]:border-slate-200 [&_hr]:my-8"> + <Fragment set:html={content} /> + </div> +</section> + +<!-- Legal Links --> +<section class="w-full bg-slate-50 py-12 px-6 md:px-20"> + <div class="reveal max-w-[600px] mx-auto text-center flex flex-col gap-5"> + <h3 class="text-slate-900 text-xl font-bold">{locale === 'en' ? 'Legal' : '法律条款'}</h3> + <div class="flex justify-center gap-8"> + <a href={localePath(locale, '/privacy')} class="text-violet-600 text-[15px] hover:underline">{footer.col3Link1}</a> + <a href={localePath(locale, '/terms')} class="text-violet-600 text-[15px] hover:underline">{footer.col3Link2}</a> + </div> + </div> +</section> diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..4fe0d91 --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -0,0 +1,253 @@ +import { useState, useEffect, createContext, useContext, type ReactNode } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import Icon from './Icon'; +import { getAuth, refreshAccessToken, redirectToLogin, backendLanguageToLocale, type AuthUser } from '../lib/auth'; +import type { UserProfile } from '../lib/api'; +import { getCachedProfile, getProfileResource, prefetchAppBasics, prefetchForPath, profileKey } from '../lib/resources'; +import { subscribe } from '../lib/data-client'; + +// User settings context +interface UserSettingsContextValue { + userProfile: UserProfile | null; + setUserProfile: (profile: UserProfile | null) => void; +} + +const UserSettingsContext = createContext<UserSettingsContextValue>({ + userProfile: null, + setUserProfile: () => {}, +}); + +export function useUserSettings() { + return useContext(UserSettingsContext); +} + +interface NavItem { + id: string; + icon: string; + label: string; + href: string; + sub?: { id: string; label: string; href: string }[]; +} + +interface AppShellProps { + locale: string; + brandName: string; + navItems: NavItem[]; + userName?: string; + userEmail?: string; + children: ReactNode; +} + +function cleanPath(path: string): string { + return path.replace(/\/+$/, '') || '/'; +} + +function getActiveNav(items: NavItem[], locale: string, pathname?: string): string { + const path = cleanPath(pathname ?? (typeof window === 'undefined' ? '' : window.location.pathname)); + // Profile page and settings sub-pages are part of settings + if (path === `/${locale}/profile`) return 'settings'; + if (path.startsWith(`/${locale}/settings`)) return 'settings'; + for (const item of items) { + if (item.sub) { + for (const sub of item.sub) { + if (sub.href && path === cleanPath(sub.href)) return sub.id; + } + } + if (item.href && path === cleanPath(item.href)) return item.id; + if (item.href && path.startsWith(cleanPath(item.href) + '/')) return item.id; + } + if (path === `/${locale}/dashboard` || path === `/${locale}`) return 'home'; + return 'home'; +} + +export default function AppShell({ locale, brandName, navItems, userName, userEmail, children }: AppShellProps) { + const location = useLocation(); + const routerNavigate = useNavigate(); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [expandedNav, setExpandedNav] = useState<string | null>('divination'); + const [authUser, setAuthUser] = useState<AuthUser | null>(null); + const [userProfile, setUserProfile] = useState<UserProfile | null>(null); + const [checkingAuth, setCheckingAuth] = useState(true); + const activeNav = getActiveNav(navItems, locale, location.pathname); + + useEffect(() => { + let alive = true; + const auth = getAuth(); + if (!auth?.refresh_token) { + redirectToLogin(); + return; + } + + refreshAccessToken() + .then((data) => { + if (alive) setAuthUser(data.user); + return getProfileResource(); + }) + .then((profile) => { + if (!alive) return; + setUserProfile(profile); + prefetchAppBasics(); + + // Check if URL locale matches user's language preference + const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN'); + if (locale !== userLocale) { + // Redirect to the correct locale + const currentPath = window.location.pathname; + const newPath = currentPath.replace(`/${locale}`, `/${userLocale}`); + window.location.replace(newPath); + } + }) + .catch(() => { + redirectToLogin(); + }) + .finally(() => { + if (alive) setCheckingAuth(false); + }); + + return () => { + alive = false; + }; + }, [locale]); + + useEffect(() => { + const cached = getCachedProfile(); + if (cached) setUserProfile(cached); + return subscribe(profileKey, () => { + setUserProfile(getCachedProfile() ?? null); + }); + }, []); + + useEffect(() => { + if (activeNav === 'manual' || activeNav === 'auto') setExpandedNav('divination'); + }, [activeNav]); + + const navigate = (href: string) => { + if (!href) return; + routerNavigate(href); + }; + + const prefetchNav = (href: string) => { + prefetchForPath(href, locale); + }; + + if (checkingAuth || authUser === null) { + return ( + <div className="min-h-screen bg-slate-50 flex items-center justify-center px-6"> + <div className="h-10 w-10 rounded-full border-2 border-violet-200 border-t-violet-600 animate-spin" /> + </div> + ); + } + + const shellUserName = userName || userProfile?.display_name || authUser?.email?.split('@')[0] || ''; + const shellUserEmail = userEmail || authUser?.email || ''; + const shellAvatarUrl = userProfile?.avatar_url; + + return ( + <UserSettingsContext.Provider value={{ userProfile, setUserProfile }}> + <div className="flex h-screen bg-slate-50 overflow-hidden"> + {sidebarOpen && ( + <div className="fixed inset-0 bg-black/40 z-40 md:hidden" onClick={() => setSidebarOpen(false)} /> + )} + + <aside className={`fixed md:static inset-y-0 left-0 z-50 w-[260px] bg-white border-r border-slate-200 flex flex-col gap-2 p-4 transition-[width,transform] duration-300 ${sidebarCollapsed ? 'md:w-[72px] md:px-3' : ''} ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}`}> + <div className={`flex items-center ${sidebarCollapsed ? 'md:justify-center' : 'justify-between'} px-2 py-2`}> + <a href={`/${locale}/dashboard`} onClick={(event) => { event.preventDefault(); navigate(`/${locale}/dashboard`); }} className="flex items-center gap-3 min-w-0"> + <img src="/images/logo.png" alt="MeiYao" className="w-9 h-9 rounded-lg" /> + <span className={`text-slate-900 text-lg font-bold whitespace-nowrap ${sidebarCollapsed ? 'md:hidden' : ''}`}>{brandName}</span> + </a> + <button onClick={() => setSidebarOpen(false)} className="md:hidden text-slate-400 hover:text-slate-600" aria-label="Close sidebar"> + <Icon name="close" className="w-5 h-5" /> + </button> + <button onClick={() => setSidebarCollapsed((value) => !value)} className={`hidden md:flex w-6 h-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-50 hover:text-slate-600 ${sidebarCollapsed ? 'md:absolute md:top-6 md:left-[50px] md:bg-white md:border md:border-slate-200' : ''}`} aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}> + <Icon name={sidebarCollapsed ? 'chevron_right' : 'chevron_left'} className="w-4 h-4" /> + </button> + </div> + + <div className="h-px bg-slate-200" /> + + <nav className="flex flex-col gap-1 flex-1 overflow-y-auto"> + {navItems.map((item) => { + if (item.sub) { + const isExpanded = expandedNav === item.id; + const isGroupActive = item.sub.some(s => activeNav === s.id); + return ( + <div key={item.id} className="flex flex-col gap-1"> + <button + onClick={() => setExpandedNav(isExpanded ? null : item.id)} + className={`flex items-center gap-3 px-2.5 py-2.5 rounded-lg transition-colors w-full text-left ${sidebarCollapsed ? 'md:justify-center' : ''} ${isGroupActive ? 'text-slate-900' : 'text-slate-500 hover:bg-slate-50'}`} + title={sidebarCollapsed ? item.label : undefined} + > + <Icon name={item.icon} className={`w-[18px] h-[18px] ${isGroupActive ? 'text-slate-600' : 'text-slate-500'}`} /> + <span className={`text-sm flex-1 font-medium ${sidebarCollapsed ? 'md:hidden' : ''} ${isGroupActive ? 'text-slate-900' : 'text-slate-700'}`}>{item.label}</span> + <Icon name="chevron_down" className={`w-4 h-4 text-slate-400 transition-transform ${sidebarCollapsed ? 'md:hidden' : ''} ${isExpanded ? 'rotate-180' : ''}`} /> + </button> + {!sidebarCollapsed && isExpanded && item.sub.map((sub) => ( + <a key={sub.id} href={sub.href} + onMouseEnter={() => prefetchNav(sub.href)} + onFocus={() => prefetchNav(sub.href)} + onClick={(e) => { e.preventDefault(); navigate(sub.href); setSidebarOpen(false); }} + className={`flex items-center gap-2 pl-8 pr-2.5 py-2.5 rounded-md text-sm transition-colors border ${activeNav === sub.id ? 'bg-[#F0E6FF] border-violet-600 text-violet-700 font-bold' : 'border-transparent text-slate-500 hover:bg-slate-50'}`}> + <span className="text-xs">{activeNav === sub.id ? '●' : '○'}</span>{sub.label} + </a> + ))} + <div className="h-px bg-slate-100 my-1" /> + </div> + ); + } + + const isActive = activeNav === item.id; + if (item.href) { + return ( + <a key={item.id} href={item.href} + onMouseEnter={() => prefetchNav(item.href)} + onFocus={() => prefetchNav(item.href)} + onClick={(e) => { e.preventDefault(); navigate(item.href); setSidebarOpen(false); }} + title={sidebarCollapsed ? item.label : undefined} + className={`flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-sm transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''} ${isActive ? 'bg-violet-700 text-white' : 'text-slate-500 hover:bg-slate-50'}`}> + <Icon name={item.icon} className="w-[18px] h-[18px]" /> + <span className={`${sidebarCollapsed ? 'md:hidden' : ''} ${isActive ? 'font-medium' : ''}`}>{item.label}</span> + </a> + ); + } + + return ( + <button key={item.id} + title={sidebarCollapsed ? item.label : undefined} + className={`flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-sm transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''} ${isActive ? 'bg-violet-700 text-white' : 'text-slate-500 hover:bg-slate-50'}`}> + <Icon name={item.icon} className="w-[18px] h-[18px]" /> + <span className={`${sidebarCollapsed ? 'md:hidden' : ''} ${isActive ? 'font-medium' : ''}`}>{item.label}</span> + </button> + ); + })} + </nav> + + <a href={`/${locale}/profile`} onClick={(event) => { event.preventDefault(); navigate(`/${locale}/profile`); }} className={`flex items-center gap-3 p-3 rounded-[10px] hover:bg-slate-50 transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''}`} title={sidebarCollapsed ? shellUserName : undefined}> + {shellAvatarUrl ? ( + <img src={shellAvatarUrl} alt={shellUserName} className="w-9 h-9 rounded-full object-cover" /> + ) : ( + <div className="w-9 h-9 rounded-full bg-violet-600 flex items-center justify-center text-white text-sm font-semibold"> + {shellUserName ? shellUserName[0].toUpperCase() : '?'} + </div> + )} + <div className={`flex flex-col gap-1 min-w-0 ${sidebarCollapsed ? 'md:hidden' : ''}`}> + <p className="text-slate-900 text-sm font-medium truncate">{shellUserName || '-'}</p> + <p className="text-slate-400 text-xs truncate">{shellUserEmail || '-'}</p> + </div> + </a> + </aside> + + <main className="flex-1 flex flex-col overflow-y-auto"> + <div className="flex items-center gap-3 px-6 md:px-10 pt-6 md:pt-8"> + <button onClick={() => setSidebarOpen(true)} className="md:hidden text-slate-500 hover:text-slate-700" aria-label="Open sidebar"> + <Icon name="menu" className="w-6 h-6" /> + </button> + </div> + <div className="flex-1 px-6 md:px-10 pb-10"> + {children} + </div> + </main> + </div> + </UserSettingsContext.Provider> + ); +} diff --git a/web/src/components/AutoDivinationPage.tsx b/web/src/components/AutoDivinationPage.tsx new file mode 100644 index 0000000..3fd8abb --- /dev/null +++ b/web/src/components/AutoDivinationPage.tsx @@ -0,0 +1,792 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Icon from './Icon'; +import DivinationProcessingOverlay from './DivinationProcessingOverlay'; +import { type DivinationResultData, type YaoType } from '../lib/api'; +import { updateSettingsResource, usePoints } from '../lib/resources'; +import { useUserSettings } from './AppShell'; + +interface Props { + locale: string; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string }; + divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideAuto: string; yaoTitle: string; coinLabel: string; confirmBtn: string; summaryTitle: string; checkCategory: string; checkMethod: string; checkCost: string; submitBtn: string; shakeTitle: string; shakeBtn: string; hexPreview: string; progressLabel: string }; +} + +type CoinFace = 'zi' | 'hua'; +const TOTAL_YAO_COUNT = 6; +const SHAKE_DURATION_PER_YAO = 3; // 3 seconds per yao + +function formatDateTimeInput(value: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`; +} + +function fromHuaCount(huaCount: number): YaoType { + switch (huaCount) { + case 0: return 'oldYin'; + case 1: return 'youngYang'; + case 2: return 'youngYin'; + case 3: return 'oldYang'; + default: return 'youngYang'; + } +} + +function randomYao(): YaoType { + return fromHuaCount(Math.floor(Math.random() * 4)); +} + +// Get coin combination for a YaoType +function coinsForYaoType(type: YaoType): [CoinFace, CoinFace, CoinFace] { + switch (type) { + case 'oldYin': return ['zi', 'zi', 'zi']; // 0 hua + case 'youngYang': return ['hua', 'zi', 'zi']; // 1 hua + case 'youngYin': return ['hua', 'hua', 'zi']; // 2 hua + case 'oldYang': return ['hua', 'hua', 'hua']; // 3 hua + } +} + +function CoinImage({ face, spinning }: { face: CoinFace; spinning?: boolean }) { + return ( + <img + src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'} + alt={face === 'zi' ? '字' : '花'} + className={`h-20 w-20 rounded-full object-cover shadow-md ${spinning ? 'coin-spin' : ''}`} + draggable={false} + /> + ); +} + +function YaoGlyph({ type, confirmed }: { type?: YaoType; confirmed?: boolean }) { + const color = confirmed ? 'bg-violet-700' : 'bg-slate-200'; + if (!type || type === 'youngYang' || type === 'oldYang') { + return <div className={`h-2.5 w-full rounded-full ${color}`} />; + } + return ( + <div className="flex h-2.5 w-full gap-4"> + <div className={`h-2.5 flex-1 rounded-full ${color}`} /> + <div className={`h-2.5 flex-1 rounded-full ${color}`} /> + </div> + ); +} + +function YaoChangeMark({ type }: { type?: YaoType }) { + if (type === 'oldYang') return <span className="text-violet-700 font-bold">○</span>; + if (type === 'oldYin') return <span className="text-violet-700 font-bold">×</span>; + return null; +} + +const copy = { + zh: { + title: '自动起卦', + subtitle: '点击摇卦按钮,系统自动生成六爻卦象。', + defaultQuestion: '我接下来三个月的事业发展需要注意什么?', + modify: '修改', + guideLines: ['系统自动为您生成六爻卦象。', '从初爻到上爻依次摇出。', '每爻摇卦需要等待3秒。'], + openGuide: '查看自动起卦教程', + guideSteps: [ + ['自动起卦', '系统会自动为您生成六爻卦象,从初爻到上爻依次摇出。'], + ['确认时间', '先确认起卦时间。如需调整,点击右侧「修改」。'], + ['依次摇卦', '点击「摇一摇」按钮,系统会依次摇出六爻。每爻需要等待3秒。'], + ['开始分析', '六爻都完成后,下方「开始解卦」按钮会激活,点击即可解卦。'], + ], + closeGuide: '结束教程', + nextGuide: '下一步', + prevGuide: '上一步', + lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'], + zi: '字', + hua: '花', + questionTypePrefix: '问题类型', + method: '起卦方式:自动起卦', + submit: '开始解卦', + shake: '摇一摇', + shaking: '摇卦中...', + shakingYao: '第 N 爻', + yaoComplete: '完成', + confirmTitle: '确认解卦', + confirmAvailable: '当前积分', + confirmCost: '本次消耗', + confirmRemaining: '解卦后剩余', + cancel: '取消', + confirm: '确认', + }, + zh_Hant: { + title: '自動起卦', + subtitle: '點擊搖卦按鈕,系統自動生成六爻卦象。', + defaultQuestion: '我接下來三個月的事業發展需要注意什麼?', + modify: '修改', + guideLines: ['系統自動為您生成六爻卦象。', '從初爻到上爻依次搖出。', '每爻搖卦需要等待3秒。'], + openGuide: '查看自動起卦教程', + guideSteps: [ + ['自動起卦', '系統會自動為您生成六爻卦象,從初爻到上爻依次搖出。'], + ['確認時間', '先確認起卦時間。如需調整,點擊右側「修改」。'], + ['依序搖卦', '點擊「搖一搖」按鈕,系統會依序搖出六爻。每爻需要等待3秒。'], + ['開始分析', '六爻都完成後,下方「開始解卦」按鈕會激活,點擊即可解卦。'], + ], + closeGuide: '結束教程', + nextGuide: '下一步', + prevGuide: '上一步', + lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'], + zi: '字', + hua: '花', + questionTypePrefix: '問題類型', + method: '起卦方式:自動起卦', + submit: '開始解卦', + shake: '搖一搖', + shaking: '搖卦中...', + shakingYao: '第 N 爻', + yaoComplete: '完成', + confirmTitle: '確認解卦', + confirmAvailable: '當前積分', + confirmCost: '本次消耗', + confirmRemaining: '解卦後剩餘', + cancel: '取消', + confirm: '確認', + }, + en: { + title: 'Auto Casting', + subtitle: 'Click the shake button to automatically generate a six-line hexagram.', + defaultQuestion: 'What should I pay attention to in my career development over the next three months?', + modify: 'Modify', + guideLines: ['The system will automatically generate a six-line hexagram for you.', 'Lines are cast from bottom to top.', 'Each line takes 3 seconds to cast.'], + openGuide: 'View Auto Casting Guide', + guideSteps: [ + ['Auto Casting', 'The system will automatically generate a six-line hexagram, casting from the first yao to the top yao.'], + ['Confirm Time', 'Check the casting time first. Tap "Modify" on the right if you need to adjust it.'], + ['Cast in Order', 'Click "Shake" button to cast all six lines. Each line takes 3 seconds.'], + ['Start Analysis', 'After all six yao are complete, the "Start Interpretation" button will activate.'], + ], + closeGuide: 'Finish', + nextGuide: 'Next', + prevGuide: 'Back', + lineNames: ['First Yao', 'Second Yao', 'Third Yao', 'Fourth Yao', 'Fifth Yao', 'Top Yao'], + zi: 'Inscription', + hua: 'Pattern', + questionTypePrefix: 'Category', + method: 'Method: Auto Casting', + submit: 'Start Interpretation', + shake: 'Shake', + shaking: 'Shaking...', + shakingYao: 'Yao N', + yaoComplete: 'Done', + confirmTitle: 'Confirm Interpretation', + confirmAvailable: 'Available credits', + confirmCost: 'This reading cost', + confirmRemaining: 'Remaining after', + cancel: 'Cancel', + confirm: 'Confirm', + }, +} as const; + +export default function AutoDivinationPage({ locale, divination: d }: Props) { + const text = copy[locale as keyof typeof copy] ?? copy.zh; + const cats = useMemo(() => d.categories.split(','), [d.categories]); + const navigate = useNavigate(); + const [category, setCategory] = useState<string>(cats[0]); + const [question, setQuestion] = useState<string>(''); + const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date())); + const [yaoResults, setYaoResults] = useState<YaoType[]>([]); + const [guideStep, setGuideStep] = useState<number | null>(null); + const pointsState = usePoints(); + const points = pointsState.data ?? null; + const [showProcessing, setShowProcessing] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + const { userProfile, setUserProfile } = useUserSettings(); + + // Shake state + const [isShaking, setIsShaking] = useState(false); + const [currentShakingYao, setCurrentShakingYao] = useState(0); + const [countdown, setCountdown] = useState(0); + const [currentCoins, setCurrentCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']); + + // Refs for guide spotlight + const coinsAreaRef = useRef<HTMLDivElement>(null); + const timePanelRef = useRef<HTMLElement>(null); + const yaoPanelRef = useRef<HTMLElement>(null); + const yaoRowsRef = useRef<HTMLDivElement>(null); + const submitBtnRef = useRef<HTMLButtonElement>(null); + const scrollContainerRef = useRef<HTMLDivElement>(null); + const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null); + const [tooltipPos, setTooltipPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); + const [tooltipSide, setTooltipSide] = useState<'right' | 'left' | 'bottom' | 'top'>('right'); + const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 1280); + + const [tutorialChecked, setTutorialChecked] = useState(false); + + // Auto-show tutorial on first visit + useEffect(() => { + if (tutorialChecked) return; + const tutorialSettings = userProfile?.settings?.divination_tutorial; + if (tutorialSettings && !tutorialSettings.auto_divination_shown) { + const timer = setTimeout(() => { + setTutorialChecked(true); + setGuideStep(0); + }, 400); + return () => clearTimeout(timer); + } else if (userProfile !== null) { + setTutorialChecked(true); + } + }, [userProfile, tutorialChecked]); + + // Mark tutorial as shown when guide ends + const closeGuide = async () => { + setGuideStep(null); + if (userProfile && !userProfile.settings.divination_tutorial.auto_divination_shown) { + const updatedSettings = { + ...userProfile.settings, + divination_tutorial: { + ...userProfile.settings.divination_tutorial, + auto_divination_shown: true, + }, + }; + try { + const updated = await updateSettingsResource(updatedSettings); + setUserProfile(updated); + } catch { + // Silently fail + } + } + }; + + const prevGuideStepRef = useRef<number | null>(null); + + useLayoutEffect(() => { + if (guideStep === null) { + setSpotlightRect(null); + prevGuideStepRef.current = null; + return; + } + + const mobileGuideTargets = [coinsAreaRef, timePanelRef, yaoRowsRef, submitBtnRef]; + const desktopGuideTargets = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef]; + const targetRef = (isMobile ? mobileGuideTargets : desktopGuideTargets)[guideStep]; + if (!targetRef?.current) return; + + const tooltipWidth = 320; + const tooltipHeight = isMobile ? 220 : 180; + const gap = isMobile ? 24 : 16; + + const overlayHost = scrollContainerRef.current; + if (!overlayHost) return; + + const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null; + if (!scrollContainer) return; + + const isInitialOpen = prevGuideStepRef.current === null; + + if (isMobile) { + const elementRect = targetRef.current.getBoundingClientRect(); + + const containerRect = scrollContainer.getBoundingClientRect(); + const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop; + const elementWidth = elementRect.width; + const elementHeight = elementRect.height; + + if (isInitialOpen) { + scrollContainer.scrollTop = 0; + } + + const scrollTopNeeded = Math.max( + 0, + guideStep === 3 ? elementTop - tooltipHeight - gap - 32 : elementTop - 20, + ); + scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'auto' }); + + requestAnimationFrame(() => { + if (!targetRef.current) return; + const newElementRect = targetRef.current.getBoundingClientRect(); + const hostRect = overlayHost.getBoundingClientRect(); + const visibleContainerRect = scrollContainer.getBoundingClientRect(); + const visibleTop = visibleContainerRect.top - hostRect.top; + const visibleBottom = visibleTop + visibleContainerRect.height; + + const spotlightLeft = newElementRect.left - hostRect.left; + const spotlightTop = newElementRect.top - hostRect.top; + + const tooltipLeft = Math.max(16, Math.min( + (newElementRect.left + newElementRect.right - tooltipWidth) / 2 - hostRect.left, + hostRect.width - tooltipWidth - 16 + )); + let tooltipTop = spotlightTop + elementHeight + gap; + let side: 'bottom' | 'top' = 'bottom'; + if (guideStep === 3) { + tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap); + side = 'top'; + } + if (tooltipTop + tooltipHeight > visibleBottom) { + tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap); + side = 'top'; + } + + setSpotlightRect({ + left: spotlightLeft, + top: spotlightTop, + width: elementWidth, + height: elementHeight + }); + setTooltipPos({ left: tooltipLeft, top: tooltipTop }); + setTooltipSide(side); + }); + + prevGuideStepRef.current = guideStep; + return; + } + + targetRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + const rect = targetRef.current.getBoundingClientRect(); + let tooltipLeft: number; + let tooltipTop: number; + let side: 'right' | 'left' | 'bottom' | 'top'; + + if (rect.right + gap + tooltipWidth <= window.innerWidth) { + tooltipLeft = rect.right + gap; + tooltipTop = rect.top; + side = 'right'; + } else if (rect.left >= tooltipWidth + gap) { + tooltipLeft = rect.left - tooltipWidth - gap; + tooltipTop = rect.top; + side = 'left'; + } else { + tooltipLeft = Math.max(16, Math.min(rect.left, window.innerWidth - tooltipWidth - 16)); + tooltipTop = rect.bottom + gap; + side = 'bottom'; + } + + if (tooltipTop + tooltipHeight > window.innerHeight) { + tooltipTop = Math.max(16, window.innerHeight - tooltipHeight - 16); + } + + setSpotlightRect({ left: rect.left, top: rect.top, width: rect.width, height: rect.height }); + setTooltipPos({ left: tooltipLeft, top: tooltipTop }); + setTooltipSide(side); + prevGuideStepRef.current = guideStep; + }, [guideStep, isMobile]); + + useEffect(() => { + setCategory(cats[0]); + }, [cats]); + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 1280); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const progress = yaoResults.length; + const done = progress >= TOTAL_YAO_COUNT; + const guideOpen = guideStep !== null; + const guide = guideOpen ? text.guideSteps[guideStep] : null; + + const showPreviousGuide = () => setGuideStep((step) => (step === null ? 0 : Math.max(step - 1, 0))); + const showNextGuide = () => setGuideStep((step) => (step === null ? 0 : Math.min(step + 1, text.guideSteps.length - 1))); + + // Handle shake - now shakes one yao at a time + const handleShake = () => { + if (isShaking || done) return; + + const yaoIndex = progress; // Current yao to shake + setCurrentShakingYao(yaoIndex); + setIsShaking(true); + setCountdown(SHAKE_DURATION_PER_YAO); + + // Generate random coins for spinning animation + const spinInterval = setInterval(() => { + const faces: CoinFace[] = ['zi', 'hua']; + setCurrentCoins([ + faces[Math.floor(Math.random() * 2)], + faces[Math.floor(Math.random() * 2)], + faces[Math.floor(Math.random() * 2)], + ]); + }, 100); + + const countdownTimer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(countdownTimer); + clearInterval(spinInterval); + + // Generate result for this yao + const newYao = randomYao(); + setYaoResults((current) => [...current, newYao]); + setCurrentCoins(coinsForYaoType(newYao)); + setIsShaking(false); + setCurrentShakingYao(0); + + return 0; + } + return prev - 1; + }); + }, 1000); + }; + + const handleSubmit = () => { + if (!done) return; + setShowConfirm(true); + }; + + const handleConfirm = () => { + setShowConfirm(false); + setShowProcessing(true); + }; + + const handleComplete = (result: DivinationResultData | null) => { + setShowProcessing(false); + if (result) { + navigate(`/${locale}/divination/result`, { state: { result } }); + } + }; + + const handleError = (error: Error) => { + setShowProcessing(false); + setErrorMessage(error.message || 'Unknown error'); + }; + + // Check if user has enough points + const hasEnoughPoints = points && points.availableBalance >= (points.runCost ?? 20); + + return ( + <div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]"> + <div className="flex items-center justify-between gap-5"> + <div className="min-w-0"> + <h1 className="text-[28px] font-bold leading-tight text-[#333333]">{text.title}</h1> + <p className="mt-1 text-sm text-[#666666]">{text.subtitle}</p> + </div> + <div className="hidden h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 text-[13px] font-semibold text-[#333333] md:flex"> + <Icon name="paid" className="h-[18px] w-[18px] text-violet-700" /> + {locale === 'en' + ? `Available ${points?.availableBalance ?? '...'} credits · This time ${points?.runCost ?? 20} credits` + : `可用 ${points?.availableBalance ?? '...'} 积分 · 本次 ${points?.runCost ?? 20} 积分`} + </div> + </div> + + <div className="flex min-h-0 flex-1 flex-col gap-[22px] xl:flex-row"> + <div className="flex w-full shrink-0 flex-col gap-4 xl:w-[360px]"> + {/* Guide panel */} + <section className="flex h-[214px] flex-col gap-3 rounded-2xl border bg-white p-5 border-slate-200"> + <h2 className="text-base font-bold text-slate-900">{d.guideTitle}</h2> + {text.guideLines.map((line, i) => <p key={i} className="text-[13px] leading-relaxed text-[#666666]">{line}</p>)} + <button + type="button" + onClick={() => setGuideStep(0)} + className="mt-auto flex h-8 w-fit items-center gap-2 rounded-[17px] bg-[#F0E6FF] px-3 text-[13px] font-bold text-[#673AB7] hover:bg-[#E6D6FF] transition-colors" + > + <Icon name="help" className="h-[18px] w-[18px]" /> + {text.openGuide} + </button> + </section> + + {/* Question panel */} + <section className="flex h-[300px] flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-[22px]"> + <h2 className="text-lg font-bold text-slate-900">{d.questionTitle}</h2> + <label className="sr-only" htmlFor="auto-category">{d.categoryLabel}</label> + <select + id="auto-category" + value={category} + onChange={(event) => setCategory(event.target.value)} + className="h-[42px] rounded-[10px] border border-slate-300 bg-slate-50 px-3 text-sm font-bold text-[#333333] outline-none focus:border-violet-500" + > + {cats.map((cat) => <option key={cat} value={cat}>{cat}</option>)} + </select> + <textarea + value={question} + onChange={(event) => setQuestion(event.target.value)} + placeholder={text.defaultQuestion} + className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500" + /> + </section> + + {/* Time panel */} + <section ref={timePanelRef} className={`flex h-[132px] flex-col gap-3 rounded-2xl border bg-white p-5 ${guideStep === 1 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}> + <h2 className="text-base font-bold text-slate-900">{d.timeTitle}</h2> + <div className="flex h-[42px] items-center justify-between gap-3 rounded-[10px] bg-slate-50 px-3"> + <input + type="datetime-local" + value={selectedTime} + onChange={(event) => setSelectedTime(event.target.value)} + className="w-full bg-transparent text-sm font-semibold text-[#333333] outline-none" + /> + <span className="shrink-0 cursor-pointer text-[13px] font-bold text-violet-700 hover:text-violet-800">{text.modify}</span> + </div> + </section> + </div> + + <section ref={yaoPanelRef} className={`flex min-w-0 flex-1 flex-col gap-4 rounded-2xl border bg-white p-6 ${guideStep === 2 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}> + <div className="flex items-center justify-between gap-4"> + <h2 className="text-lg font-bold text-slate-900">{d.yaoTitle}</h2> + <span className="text-[13px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</span> + </div> + + {/* Six yao rows */} + <div ref={yaoRowsRef} className="flex flex-col gap-2.5"> + {[5, 4, 3, 2, 1, 0].map((index) => { + const result = yaoResults[index]; + const isBeingShaken = isShaking && currentShakingYao === index; + const isNextToShake = !isShaking && progress === index; + const confirmed = !!result; + return ( + <div + key={index} + className={`flex h-[62px] items-center gap-4 rounded-[10px] px-3.5 ${ + isBeingShaken ? 'border border-violet-600 bg-violet-50' : + isNextToShake ? 'border border-violet-600 bg-violet-50' : + confirmed ? 'border border-slate-200 bg-white' : 'bg-slate-50' + }`} + > + <span className={`w-16 text-sm font-bold ${isBeingShaken || isNextToShake || confirmed ? 'text-violet-700' : 'text-slate-400'}`}> + {text.lineNames[index]} + </span> + <div className="min-w-0 flex-1"> + <YaoGlyph type={result} confirmed={confirmed} /> + </div> + <span className="w-6 text-center"> + {isBeingShaken ? ( + <span className="text-violet-600 text-xs font-bold">{countdown}s</span> + ) : ( + <YaoChangeMark type={result} /> + )} + </span> + </div> + ); + })} + </div> + + {/* Coins area */} + <div ref={coinsAreaRef} className="flex min-h-[142px] items-center justify-center rounded-xl bg-slate-50 p-4"> + <div className="flex items-center justify-center gap-6"> + {currentCoins.map((face, index) => ( + <div key={index} className="flex flex-col items-center gap-2"> + <CoinImage face={face} spinning={isShaking} /> + <span className="text-[13px] font-bold text-slate-600"> + {isShaking ? '?' : (face === 'zi' ? text.zi : text.hua)} + </span> + </div> + ))} + </div> + </div> + + {/* Shake button */} + <button + type="button" + onClick={handleShake} + disabled={done || isShaking} + className={`h-10 w-full rounded-full text-[13px] font-bold transition-colors flex items-center justify-center gap-2 ${ + done + ? 'cursor-not-allowed bg-slate-300 text-slate-400' + : isShaking + ? 'bg-violet-400 text-white cursor-wait' + : 'bg-violet-700 text-white hover:bg-violet-800' + }`} + > + {isShaking ? ( + <> + <div className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" /> + {text.shaking} ({text.shakingYao.replace('N', String(currentShakingYao + 1))}) + </> + ) : done ? ( + text.yaoComplete + ) : ( + <> + <Icon name="casino" className="w-5 h-5" /> + {text.shake} + </> + )} + </button> + </section> + + <aside className="flex w-full shrink-0 flex-col gap-[18px] rounded-2xl border border-slate-200 bg-white p-[22px] xl:w-[300px]"> + <h2 className="text-lg font-bold text-slate-900">{d.summaryTitle}</h2> + <div className="flex h-[94px] flex-col gap-2 rounded-xl bg-slate-50 p-4"> + <p className="text-[13px] text-[#666666]">{d.progressLabel}</p> + <p className="text-[28px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</p> + </div> + <p className="text-sm text-[#666666]">{text.questionTypePrefix}{locale === 'en' ? ': ' : ':'}{category}</p> + <p className="text-sm text-[#666666]">{text.method}</p> + <p className="text-sm text-[#666666]">{locale === 'en' ? `Cost: ${points?.runCost ?? 20} credits` : `解卦消耗:${points?.runCost ?? 20} 积分`}</p> + <div className="flex-1" /> + <button + ref={submitBtnRef} + type="button" + disabled={!done} + onClick={handleSubmit} + className={`h-[46px] w-full rounded-full text-sm font-bold transition-colors ${done ? 'bg-violet-700 text-white hover:bg-violet-800' : 'cursor-not-allowed bg-slate-300 text-slate-400'} ${guideStep === 3 ? 'ring-4 ring-violet-100' : ''}`} + > + {text.submit} + </button> + </aside> + </div> + + {/* Guide overlay */} + {guideOpen && guide && spotlightRect && ( + <> + <div + className="fixed inset-0 z-40 hidden bg-black/70 xl:block" + /> + <div + className="absolute inset-0 z-40 xl:hidden" + style={{ top: 0, height: '100vh' }} + /> + + <div + className={`z-50 rounded-2xl ring-4 ring-white shadow-[0_0_0_9999px_rgba(0,0,0,0.7)] transition-all duration-300 ${ + isMobile ? 'absolute' : 'fixed' + }`} + style={{ + left: spotlightRect.left, + top: spotlightRect.top, + width: spotlightRect.width, + height: spotlightRect.height + }} + /> + + <div + className={`z-50 w-[320px] rounded-2xl bg-slate-950 p-5 text-white shadow-2xl transition-all duration-300 ${ + isMobile ? 'absolute' : 'fixed' + }`} + style={{ + left: tooltipPos.left, + top: tooltipPos.top + }} + > + <div + className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${ + tooltipSide === 'right' ? '-left-1.5 top-6' : + tooltipSide === 'left' ? '-right-1.5 top-6' : + tooltipSide === 'top' ? '-bottom-1.5 left-1/2 -translate-x-1/2' : + '-top-1.5 left-6' + }`} + /> + + <div className="mb-3 flex items-center justify-between gap-4"> + <span className="text-xs font-bold text-violet-300">{guideStep + 1} / {text.guideSteps.length}</span> + <button type="button" onClick={() => closeGuide()} className="rounded-full p-1 text-white/50 hover:text-white"> + <Icon name="close" className="h-4 w-4" /> + </button> + </div> + <h3 className="text-base font-bold">{guide[0]}</h3> + <p className="mt-2 text-sm leading-relaxed text-white/70">{guide[1]}</p> + <div className="mt-4 flex items-center justify-between gap-3"> + <button + type="button" + onClick={showPreviousGuide} + disabled={guideStep === 0} + className="h-9 rounded-full px-4 text-sm font-medium text-white/50 disabled:opacity-30 hover:text-white disabled:hover:text-white/50" + > + {text.prevGuide} + </button> + {guideStep === text.guideSteps.length - 1 ? ( + <button + type="button" + onClick={() => closeGuide()} + className="h-9 rounded-full bg-violet-600 px-5 text-sm font-bold text-white hover:bg-violet-500" + > + {text.closeGuide} + </button> + ) : ( + <button + type="button" + onClick={showNextGuide} + className="h-9 rounded-full bg-violet-600 px-5 text-sm font-bold text-white hover:bg-violet-500" + > + {text.nextGuide} + </button> + )} + </div> + </div> + </> + )} + + {/* Confirmation dialog */} + {showConfirm && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl"> + <h3 className="text-slate-900 text-lg font-bold">{text.confirmTitle}</h3> + <div className="flex flex-col gap-3"> + <div className="flex justify-between text-sm"> + <span className="text-slate-500">{text.confirmAvailable}</span> + <span className="text-slate-900 font-semibold">{points?.availableBalance ?? '...'}</span> + </div> + <div className="flex justify-between text-sm"> + <span className="text-slate-500">{text.confirmCost}</span> + <span className="text-violet-600 font-semibold">{points?.runCost ?? 20}</span> + </div> + <div className="border-t border-slate-200 pt-3 flex justify-between text-sm"> + <span className="text-slate-500">{text.confirmRemaining}</span> + <span className={`font-bold ${hasEnoughPoints ? 'text-slate-900' : 'text-red-500'}`}> + {(points?.availableBalance ?? 0) - (points?.runCost ?? 20)} + </span> + </div> + {!hasEnoughPoints && ( + <p className="text-red-500 text-sm font-medium"> + {locale === 'en' ? 'Insufficient credits. Please purchase more.' : '积分不足,请先充值。'} + </p> + )} + </div> + <div className="flex gap-3"> + <button + onClick={() => setShowConfirm(false)} + className="flex-1 h-11 rounded-full border border-slate-300 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition-colors" + > + {text.cancel} + </button> + <button + onClick={handleConfirm} + disabled={!hasEnoughPoints} + className={`flex-1 h-11 rounded-full text-sm font-bold text-white transition-colors ${ + hasEnoughPoints + ? 'bg-violet-600 hover:bg-violet-700' + : 'bg-slate-300 cursor-not-allowed' + }`} + > + {text.confirm} + </button> + </div> + </div> + </div> + )} + + {/* Processing overlay */} + {showProcessing && ( + <DivinationProcessingOverlay + locale={locale} + params={{ + method: 'auto', + questionType: category, + question: question, + divinationTime: new Date(selectedTime), + }} + yaoStates={yaoResults} + onComplete={handleComplete} + onError={handleError} + /> + )} + + {/* Error dialog */} + {errorMessage && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl"> + <h3 className="text-red-600 text-lg font-bold">{locale === 'en' ? 'Error' : '出错了'}</h3> + <p className="text-sm text-slate-600">{errorMessage}</p> + <button + onClick={() => setErrorMessage(null)} + className="h-11 w-full rounded-full bg-slate-100 text-sm font-bold text-slate-700 hover:bg-slate-200 transition-colors" + > + {locale === 'en' ? 'Close' : '关闭'} + </button> + </div> + </div> + )} + + {/* Coin spin animation */} + <style>{` + @keyframes coin-spin { + 0% { transform: rotateY(0deg); } + 100% { transform: rotateY(360deg); } + } + .coin-spin { + animation: coin-spin 0.4s linear infinite; + } + `}</style> + </div> + ); +} diff --git a/web/src/components/CtaSection.astro b/web/src/components/CtaSection.astro new file mode 100644 index 0000000..1ce01f5 --- /dev/null +++ b/web/src/components/CtaSection.astro @@ -0,0 +1,26 @@ +--- +import { t, localePath, type Locale } from '../i18n/utils'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const cta = t(locale, 'cta'); +--- + +<section class="reveal-scale w-full py-24 md:py-32 relative overflow-hidden"> + <div class="glow-bg glow-bg-wide absolute inset-0 pointer-events-none"></div> + + <div class="relative z-10 max-w-3xl mx-auto px-6 flex flex-col items-center gap-6 text-center"> + <h2 class="text-slate-900 text-3xl md:text-[48px] font-bold"> + {cta.title} + </h2> + <p class="text-slate-500 text-lg max-w-xl"> + {cta.subtitle} + </p> + <a href={localePath(locale, '/login')} class="cyber-gradient cyber-glow text-white text-lg font-semibold px-12 py-5 rounded-xl hover:-translate-y-0.5 transition-all duration-300 mt-4"> + {cta.button} + </a> + </div> +</section> diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx new file mode 100644 index 0000000..590e631 --- /dev/null +++ b/web/src/components/Dashboard.tsx @@ -0,0 +1,158 @@ +import { mapHistoryMessagesToItems } from '../lib/api'; +import { primeHistoryThreadFromSnapshot, useHistorySummary, usePoints, useUnreadCount } from '../lib/resources'; +import Icon from './Icon'; + +interface DashboardProps { + locale: string; + translations: { + brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; greeting: string; greetingSub: string; heroTitle: string; heroDesc: string; heroCta: string; historyTitle: string; historyViewAll: string; logout: string; + }; +} + +const CATEGORY_COLORS: Record<string, string> = { + 'career': 'bg-blue-50 text-blue-500', + 'love': 'bg-pink-50 text-pink-500', + 'wealth': 'bg-amber-50 text-amber-600', + 'fortune': 'bg-purple-50 text-purple-500', + 'dream': 'bg-indigo-50 text-indigo-500', + 'health': 'bg-red-50 text-red-500', + 'study': 'bg-green-50 text-green-600', + 'search': 'bg-cyan-50 text-cyan-600', + 'other': 'bg-slate-100 text-slate-500', +}; + +// 问题类型显示标签映射 +const CATEGORY_LABELS_ZH: Record<string, string> = { + 'career': '事业', + 'love': '感情', + 'wealth': '财富', + 'fortune': '运势', + 'dream': '解梦', + 'health': '健康', + 'study': '学业', + 'search': '寻物', + 'other': '其他', +}; + +const CATEGORY_LABELS_EN: Record<string, string> = { + 'career': 'Career', + 'love': 'Love', + 'wealth': 'Wealth', + 'fortune': 'Fortune', + 'dream': 'Dream', + 'health': 'Health', + 'study': 'Study', + 'search': 'Search', + 'other': 'Other', +}; + +const RATING_COLORS: Record<string, string> = { + '上上签': 'bg-amber-50 text-amber-500', '上签': 'bg-amber-50 text-amber-500', '中上签': 'bg-violet-50 text-violet-600', '中签': 'bg-slate-100 text-slate-500', '下签': 'bg-red-50 text-red-500', +}; + +export default function Dashboard({ locale, translations: i18n }: DashboardProps) { + const pointsState = usePoints(); + const unreadState = useUnreadCount(); + const historyState = useHistorySummary(); + const points = pointsState.data; + const unreadCount = unreadState.data; + const history = historyState.data ? mapHistoryMessagesToItems(historyState.data.messages).slice(0, 4) : []; + const loadingData = historyState.loading; + const loadErrorSource = pointsState.error || unreadState.error || historyState.error; + const loadError = loadErrorSource instanceof Error ? loadErrorSource.message : loadErrorSource ? 'Failed to load dashboard data' : ''; + + const unreadNum = unreadCount?.count ?? 0; + const availablePoints = points?.availableBalance; + + return ( + <div className="flex flex-col gap-5 md:gap-6 min-h-full"> + {/* Header */} + <div className="flex items-start justify-between gap-4"> + <div className="min-w-0"> + <h1 className="text-slate-900 text-xl md:text-2xl font-semibold">{i18n.greeting}</h1> + <p className="text-slate-500 text-sm mt-1">{i18n.greetingSub}</p> + </div> + <a + href={`/${locale}/notifications`} + className="relative w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center hover:bg-slate-200 transition-colors" + aria-label={locale === 'en' ? 'Open notifications' : '打开通知'} + > + <Icon name="notifications" className="w-5 h-5 text-slate-500" /> + {unreadNum > 0 && ( + <span className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full text-white text-[10px] font-bold flex items-center justify-center">{unreadNum > 9 ? '9+' : unreadNum}</span> + )} + </a> + </div> + + {loadError && ( + <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600"> + {loadError} + </div> + )} + + {/* Hero card */} + <div className="relative rounded-2xl overflow-hidden p-5 md:p-12" style={{ background: 'linear-gradient(135deg, #673AB7, #512DA8)' }}> + <div className="flex flex-col md:flex-row md:items-center gap-6 md:gap-12"> + <div className="flex-1 flex flex-col gap-4"> + <h2 className="text-white text-2xl md:text-[32px] font-bold leading-tight">{i18n.heroTitle}</h2> + <p className="text-violet-200 text-sm md:text-base leading-relaxed">{i18n.heroDesc}</p> + <div className="flex flex-col sm:flex-row sm:items-center gap-3"> + <a href={`/${locale}/divination/manual`} + className="w-full sm:w-fit text-center px-6 py-3 rounded-xl bg-white text-violet-600 text-base font-semibold hover:bg-violet-50 transition-colors" + style={{ boxShadow: '0 4px 16px #00000030' }}> + {i18n.heroCta} + </a> + {availablePoints !== undefined && ( + <span className="text-violet-100 text-base"> + {locale === 'en' ? 'Available credits' : '可用积分'}: <strong className="text-white text-xl">{availablePoints}</strong> + </span> + )} + </div> + </div> + <div className="hidden md:flex w-[220px] h-[184px] rounded-2xl items-center justify-center" style={{ background: 'rgba(255,255,255,0.08)' }}> + <div className="text-white/40 text-2xl leading-relaxed text-center font-mono">⚊ ⚋<br />⚋ ⚊<br />⚊ ⚊</div> + </div> + </div> + </div> + + {/* History */} + <div className="flex flex-col gap-4"> + <div className="flex items-center justify-between"> + <h3 className="text-slate-900 text-lg font-semibold">{i18n.historyTitle}</h3> + <a href={`/${locale}/history`} className="text-violet-600 text-sm hover:underline">{i18n.historyViewAll}</a> + </div> + <div className="flex flex-col gap-3"> + {loadingData ? ( + <div className="text-slate-400 text-sm py-8 text-center">{locale === 'en' ? 'Loading...' : '加载中...'}</div> + ) : history.length === 0 ? ( + <div className="text-slate-400 text-sm py-8 text-center">{locale === 'en' ? 'No readings yet' : '暂无解卦记录'}</div> + ) : ( + history.map((item) => ( + <a + key={item.id} + href={`/${locale}/history/result?threadId=${encodeURIComponent(item.threadId)}`} + onClick={() => historyState.data && primeHistoryThreadFromSnapshot(item.threadId, historyState.data)} + className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 bg-white rounded-xl p-4 md:p-5 border border-slate-100 hover:shadow-sm hover:border-violet-200 transition-all cursor-pointer" + > + <div className="w-10 h-10 rounded-[10px] bg-blue-50 flex items-center justify-center shrink-0"> + <Icon name="hexagram" className="w-5 h-5 text-blue-400" /> + </div> + <div className="flex-1 min-w-0"> + <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-3"> + <p className="text-slate-900 text-[15px] font-medium truncate">{item.question}</p> + <span className="text-slate-400 text-xs shrink-0">{item.created_at?.slice(0, 10) || ''}</span> + </div> + <div className="flex flex-wrap items-center gap-2 mt-1.5"> + {item.category && <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-slate-100 text-slate-500'}`}>{locale === 'en' ? (CATEGORY_LABELS_EN[item.category] || item.category) : (CATEGORY_LABELS_ZH[item.category] || item.category)}</span>} + {item.hexagram_name && <span className="px-2 py-0.5 rounded-md bg-violet-50 text-violet-600 text-xs font-medium">{item.hexagram_name}</span>} + {item.rating && <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${RATING_COLORS[item.rating] || 'bg-slate-100 text-slate-500'}`}>{item.rating}</span>} + </div> + </div> + </a> + )) + )} + </div> + </div> + </div> + ); +} diff --git a/web/src/components/DashboardApp.tsx b/web/src/components/DashboardApp.tsx new file mode 100644 index 0000000..c607fae --- /dev/null +++ b/web/src/components/DashboardApp.tsx @@ -0,0 +1,113 @@ +import { lazy, Suspense, useEffect } from 'react'; +import { BrowserRouter, Navigate, Route, Routes, useNavigate } from 'react-router-dom'; +import type { Locale, Translations } from '../i18n/utils'; +import AppShell from './AppShell'; +import { getNavConfig } from './navConfig'; + +interface DashboardAppProps { + locale: Locale; + translations: Pick<Translations, 'dashboard' | 'store' | 'pricing' | 'history' | 'notifications' | 'profile' | 'settings' | 'divination' | 'general' | 'feedback' | 'result'>; +} + +const Dashboard = lazy(() => import('./Dashboard')); +const StorePage = lazy(() => import('./StorePage')); +const HistoryListPage = lazy(() => import('./HistoryListPage')); +const DivinationResultPage = lazy(() => import('./DivinationResultPage')); +const HistoryFollowUpPage = lazy(() => import('./HistoryFollowUpPage')); +const NotificationPage = lazy(() => import('./NotificationPage')); +const ProfileDetailPage = lazy(() => import('./ProfileDetailPage')); +const SettingsPage = lazy(() => import('./SettingsPage')); +const GeneralSettingsPage = lazy(() => import('./GeneralSettingsPage')); +const FeedbackPage = lazy(() => import('./FeedbackPage')); +const ManualDivinationPage = lazy(() => import('./ManualDivinationPage')); +const AutoDivinationPage = lazy(() => import('./AutoDivinationPage')); + +const APP_PATHS = [ + '/dashboard', + '/store', + '/history', + '/notifications', + '/profile', + '/settings', + '/settings/general', + '/settings/feedback', + '/divination/manual', + '/divination/auto', + '/divination/result', +]; + +function AppLinkInterceptor({ locale }: { locale: string }) { + const navigate = useNavigate(); + + useEffect(() => { + const handleClick = (event: MouseEvent) => { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const anchor = (event.target as Element | null)?.closest('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) return; + if (anchor.target || anchor.hasAttribute('download')) return; + + const url = new URL(anchor.href); + if (url.origin !== window.location.origin) return; + const localePrefix = `/${locale}`; + if (!url.pathname.startsWith(localePrefix)) return; + + const appPath = url.pathname.slice(localePrefix.length) || '/'; + if (!APP_PATHS.some((path) => appPath === path || appPath.startsWith(`${path}/`))) return; + + event.preventDefault(); + navigate(`${url.pathname}${url.search}${url.hash}`); + }; + + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, [locale, navigate]); + + return null; +} + +function RouteFallback() { + return ( + <div className="flex min-h-[320px] items-center justify-center"> + <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-violet-600" /> + </div> + ); +} + +function DashboardRoutes({ locale, translations }: DashboardAppProps) { + const dashboard = translations.dashboard; + const navItems = getNavConfig(locale, dashboard); + + return ( + <AppShell locale={locale} brandName={dashboard.brandName} navItems={navItems}> + <AppLinkInterceptor locale={locale} /> + <Suspense fallback={<RouteFallback />}> + <Routes> + <Route path={`/${locale}/dashboard`} element={<Dashboard locale={locale} translations={dashboard} />} /> + <Route path={`/${locale}/store`} element={<StorePage locale={locale} dashboard={dashboard} store={translations.store} pricing={translations.pricing} />} /> + <Route path={`/${locale}/history`} element={<HistoryListPage locale={locale} dashboard={dashboard} history={translations.history} />} /> + <Route path={`/${locale}/history/:id`} element={<DivinationResultPage locale={locale} translations={translations.result} />} /> + <Route path={`/${locale}/history/:id/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} /> + <Route path={`/${locale}/history/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} /> + <Route path={`/${locale}/history/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} /> + <Route path={`/${locale}/notifications`} element={<NotificationPage locale={locale} dashboard={dashboard} notifications={translations.notifications} />} /> + <Route path={`/${locale}/profile`} element={<ProfileDetailPage locale={locale} dashboard={dashboard} profile={translations.profile} />} /> + <Route path={`/${locale}/settings`} element={<SettingsPage locale={locale} dashboard={dashboard} settings={translations.settings} />} /> + <Route path={`/${locale}/settings/general`} element={<GeneralSettingsPage locale={locale} general={translations.general} />} /> + <Route path={`/${locale}/settings/feedback`} element={<FeedbackPage locale={locale} feedback={translations.feedback} />} /> + <Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} /> + <Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} /> + <Route path={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} /> + <Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} /> + </Routes> + </Suspense> + </AppShell> + ); +} + +export default function DashboardApp(props: DashboardAppProps) { + return ( + <BrowserRouter> + <DashboardRoutes {...props} /> + </BrowserRouter> + ); +} diff --git a/web/src/components/DashboardAppPage.astro b/web/src/components/DashboardAppPage.astro new file mode 100644 index 0000000..c36f951 --- /dev/null +++ b/web/src/components/DashboardAppPage.astro @@ -0,0 +1,28 @@ +--- +import AppLayout from '../layouts/App.astro'; +import DashboardApp from './DashboardApp'; +import { t, type Locale } from '../i18n/utils'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const translations = { + dashboard: t(locale, 'dashboard'), + store: t(locale, 'store'), + pricing: t(locale, 'pricing'), + history: t(locale, 'history'), + notifications: t(locale, 'notifications'), + profile: t(locale, 'profile'), + settings: t(locale, 'settings'), + divination: t(locale, 'divination'), + general: t(locale, 'general'), + feedback: t(locale, 'feedback'), + result: t(locale, 'result'), +}; +--- + +<AppLayout locale={locale}> + <DashboardApp client:only="react" locale={locale} translations={translations} /> +</AppLayout> diff --git a/web/src/components/DivinationProcessingOverlay.tsx b/web/src/components/DivinationProcessingOverlay.tsx new file mode 100644 index 0000000..bfa3cac --- /dev/null +++ b/web/src/components/DivinationProcessingOverlay.tsx @@ -0,0 +1,387 @@ +/** + * DivinationProcessingOverlay - 起卦处理中蒙版 + * 显示翻牌动画和状态提示,等待后端返回结果 + * + * i18n: 使用 Flutter app 已有文本 + * - 简中/繁中:有完整翻译 + * - 英文:有翻译 + */ + +import { useEffect, useState, useCallback, useRef } from 'react'; +import { + enqueueDivinationRun, + streamDivinationEvents, + type YaoType, + type DivinationResultData, +} from '../lib/api'; +import { invalidateHistory, invalidatePoints } from '../lib/resources'; + +// 八卦卡片数据 - 使用 Flutter 的文本 +const I_CHING_CARDS = { + zh: [ + { symbol: '☰', title: '乾 • 元亨利贞', quote: '天行健,君子以自强不息。' }, + { symbol: '☱', title: '兑 • 亨利贞', quote: '丽泽兑,君子以朋友讲习。' }, + { symbol: '☲', title: '离 • 明两作亨利贞', quote: '大人以继明照于四方。' }, + { symbol: '☳', title: '震 • 亨震来虩虩,笑言哑哑', quote: '震惊百里,惊远而惧迩也。' }, + { symbol: '☴', title: '巽 • 小亨利贞', quote: '随风,君子以申命行事。' }, + { symbol: '☵', title: '坎 • 习坎有孚维心亨', quote: '水流而不盈,行险而不失其信。' }, + { symbol: '☶', title: '艮 • 艮其背不获其身', quote: '时止则止,时行则行,动静不失其时。' }, + { symbol: '☷', title: '坤 • 元亨利牝马之贞', quote: '地势坤,君子以厚德载物。' }, + ], + zh_Hant: [ + { symbol: '☰', title: '乾 • 元亨利貞', quote: '天行健,君子以自強不息。' }, + { symbol: '☱', title: '兌 • 亨利貞', quote: '麗澤兌,君子以朋友講習。' }, + { symbol: '☲', title: '離 • 明兩作亨利貞', quote: '大人以繼明照於四方。' }, + { symbol: '☳', title: '震 • 亨震來虩虩,笑言啞啞', quote: '震驚百里,驚遠而懼邇也。' }, + { symbol: '☴', title: '巽 • 小亨利貞', quote: '隨風,君子以申命行事。' }, + { symbol: '☵', title: '坎 • 習坎有孚維心亨', quote: '水流而不盈,行險而不失其信。' }, + { symbol: '☶', title: '艮 • 艮其背不獲其身', quote: '時止則止,時行則行,動靜不失其時。' }, + { symbol: '☷', title: '坤 • 元亨利牝馬之貞', quote: '地勢坤,君子以厚德載物。' }, + ], + en: [ + { symbol: '☰', title: 'Qian • The Creative', quote: 'The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.' }, + { symbol: '☱', title: 'Dui • The Joyous', quote: 'Joy grounded in integrity brings openness, harmony, and right expression.' }, + { symbol: '☲', title: 'Li • The Clinging Fire', quote: 'With clear brilliance, the great one illumines all directions.' }, + { symbol: '☳', title: 'Zhen • The Arousing Thunder', quote: 'Shock awakens the heart; composure turns fear into growth.' }, + { symbol: '☴', title: 'Xun • The Gentle Wind', quote: 'Gentle penetration furthers progress and helps one meet the right people.' }, + { symbol: '☵', title: 'Kan • The Abysmal Water', quote: 'In danger, sincerity and disciplined action carry one through.' }, + { symbol: '☶', title: 'Gen • Keeping Still Mountain', quote: 'Stillness at the proper time keeps one centered and steady in place.' }, + { symbol: '☷', title: 'Kun • The Receptive Earth', quote: "The Earth's condition is devoted receptivity; the noble one carries all with broad virtue." }, + ], +} as const; + +// 处理步骤状态 +type ProcessingStep = 'preparing' | 'deriving' | 'done'; + +interface DivinationProcessingOverlayProps { + locale: string; + // 起卦参数 + params: { + method: 'manual' | 'auto'; + questionType: string; + question: string; + divinationTime: Date; + }; + // 六爻状态 + yaoStates: YaoType[]; + // 完成回调 + onComplete: (result: DivinationResultData | null) => void; + // 错误回调 + onError?: (error: Error) => void; +} + +const copy = { + zh: { + badge: '周易', + statusPreparing: '天机推演中', + statusDeriving: '正在解卦', + statusDone: '解卦完成\n点击查看', + }, + zh_Hant: { + badge: '周易', + statusPreparing: '天機推演中', + statusDeriving: '正在解卦', + statusDone: '解卦完成\n點擊查看', + }, + en: { + badge: 'I Ching', + statusPreparing: 'Deriving...', + statusDeriving: 'Analyzing...', + statusDone: 'Complete\nTap to view', + }, +} as const; + +// 动画时长 +const FLIP_INTERVAL = 2000; // 每2秒翻一次 +const FLIP_DURATION = 600; // 翻转动画600ms + +export default function DivinationProcessingOverlay({ + locale, + params, + yaoStates, + onComplete, + onError, +}: DivinationProcessingOverlayProps) { + const text = copy[locale as keyof typeof copy] ?? copy.zh; + const cards = I_CHING_CARDS[locale as keyof typeof I_CHING_CARDS] ?? I_CHING_CARDS.zh; + + const [step, setStep] = useState<ProcessingStep>('preparing'); + const [cardIndex, setCardIndex] = useState(0); + const [flipAngle, setFlipAngle] = useState(0); + const [result, setResult] = useState<DivinationResultData | null>(null); + const isFlippingRef = useRef(false); + const paramsRef = useRef(params); + const yaoStatesRef = useRef(yaoStates); + const onErrorRef = useRef(onError); + paramsRef.current = params; + yaoStatesRef.current = yaoStates; + onErrorRef.current = onError; + + // 翻牌动画 + useEffect(() => { + if (step === 'done') return; + + const interval = setInterval(() => { + if (isFlippingRef.current) return; + + isFlippingRef.current = true; + setFlipAngle((prev) => prev + 180); + + setTimeout(() => { + setCardIndex((prev) => (prev + 1) % cards.length); + }, FLIP_DURATION / 2); + + setTimeout(() => { + isFlippingRef.current = false; + }, FLIP_DURATION); + }, FLIP_INTERVAL); + + return () => clearInterval(interval); + }, [step, cards.length]); + + // 后端 SSE 请求 + useEffect(() => { + let aborted = false; + + // 辅助函数:转换 yaoInfoList 到 YaoLineData[] + function parseYaoInfoList(list: unknown): DivinationResultData['yaoLines'] { + if (!Array.isArray(list)) return []; + return list.map((item, idx) => ({ + index: idx, + spirit: (item.spiritName as string) || '', + relation: (item.relationName as string) || '', + branch: (item.tiganName as string) || '', + element: (item.elementName as string) || '', + type: item.isYang ? 'youngYang' : 'youngYin' as DivinationResultData['yaoLines'][0]['type'], + mark: (item.specialMark as string) || '', + })); + } + + async function runDivination() { + try { + // 1. 提交起卦请求 + const { threadId, runId } = await enqueueDivinationRun(paramsRef.current, yaoStatesRef.current); + invalidatePoints(); + + if (aborted) return; + + // 用于存储 derived 数据 + let ganzhi: DivinationResultData['ganzhi'] | null = null; + let guaName = ''; + let targetGuaName = ''; + let upperName = ''; + let lowerName = ''; + let binaryCode = ''; + let changedBinaryCode = ''; + let wuXingStatus: Record<string, string> = {}; + let yaoLines: DivinationResultData['yaoLines'] = []; + let targetYaoLines: DivinationResultData['yaoLines'] = []; + + let signLevel = ''; + let conclusion = ''; + let focusPoints: string[] = []; + let advice: string[] = []; + let keywords = ''; + let answer = ''; + let status: 'success' | 'failed' | 'refused' = 'success'; + + // 2. 监听 SSE 事件 + for await (const event of streamDivinationEvents(threadId, runId)) { + if (aborted) break; + + const { type, data } = event; + + if (type === 'DIVINATION_DERIVED') { + // 卦象推导完成 + const div = data.divination as Record<string, unknown> | undefined; + if (div) { + // ganzhi is a nested object in the backend response + const ganzhiSource = (div.ganzhi as Record<string, string> | undefined) || {}; + ganzhi = { + yearGanZhi: ganzhiSource.yearGanZhi || '', + monthGanZhi: ganzhiSource.monthGanZhi || '', + dayGanZhi: ganzhiSource.dayGanZhi || '', + timeGanZhi: ganzhiSource.timeGanZhi || '', + yearKongWang: ganzhiSource.yearKongWang || '', + monthKongWang: ganzhiSource.monthKongWang || '', + dayKongWang: ganzhiSource.dayKongWang || '', + timeKongWang: ganzhiSource.timeKongWang || '', + yueJian: ganzhiSource.yueJian || '', + riChen: ganzhiSource.riChen || '', + yuePo: ganzhiSource.yuePo || '', + riChong: ganzhiSource.riChong || '', + }; + guaName = (div.guaName as string) || ''; + targetGuaName = (div.targetGuaName as string) || ''; + upperName = (div.upperName as string) || ''; + lowerName = (div.lowerName as string) || ''; + binaryCode = (div.binaryCode as string) || ''; + changedBinaryCode = (div.changedBinaryCode as string) || ''; + wuXingStatus = (div.wuXingStatuses as Record<string, string>) || {}; + yaoLines = parseYaoInfoList(div.yaoInfoList); + targetYaoLines = parseYaoInfoList(div.targetYaoInfoList); + } + setStep('deriving'); + } else if (type === 'TEXT_MESSAGE_END') { + // 解卦结果 + signLevel = (data.sign_level as string) || ''; + conclusion = ((data.conclusion as string[]) || []).join('\n'); + focusPoints = (data.focus_points as string[]) || []; + advice = (data.advice as string[]) || []; + keywords = ((data.keywords as string[]) || []).join(' · '); + answer = (data.answer as string) || ''; + status = (data.status as 'success' | 'failed' | 'refused') || 'success'; + } else if (type === 'RUN_ERROR') { + const detail = (data.detail as string) || 'Unknown error'; + throw new Error(detail); + } else if (type === 'RUN_FINISHED') { + // 构建最终结果 + if (ganzhi) { + const result: DivinationResultData = { + threadId, + params, + binaryCode, + changedBinaryCode, + guaName, + targetGuaName, + upperName, + lowerName, + signType: signLevel, + keywords, + focusPoints, + conclusion, + analysis: answer, + suggestion: advice.join('\n'), + ganzhi, + wuXingStatus, + yaoLines, + targetYaoLines, + status, + }; + setResult(result); + } + setStep('done'); + invalidatePoints(); + invalidateHistory(threadId); + break; + } + } + } catch (error) { + if (!aborted && onErrorRef.current) { + onErrorRef.current(error instanceof Error ? error : new Error(String(error))); + } + } + } + + runDivination(); + + return () => { + aborted = true; + }; + }, []); + + const currentCard = cards[cardIndex]; + + const handleClick = useCallback(() => { + if (step === 'done') { + onComplete(result); + } + }, [step, onComplete, result]); + + return ( + <div + className="fixed inset-0 z-50 flex items-center justify-center" + onClick={handleClick} + > + {/* 高斯模糊背景层 */} + <div className="absolute inset-0 backdrop-blur-sm bg-slate-100/80" /> + + {/* 暗色遮罩层 */} + <div className="absolute inset-0 bg-[#0F172A]/40" /> + + {/* 中央内容区域 */} + <div className="relative flex flex-col items-center gap-6"> + {/* 翻牌动画区域 */} + <div className="relative h-[380px] w-[420px]" style={{ perspective: '1000px' }}> + {/* 背后的幻影牌 */} + <div + className="absolute" + style={{ + left: 126, + top: 38, + width: 168, + height: 272, + transform: 'rotate(-8deg)', + opacity: 0.5, + zIndex: 0, + }} + > + <div + className="h-full w-full rounded-[18px]" + style={{ background: 'rgba(237, 231, 246, 0.67)' }} + /> + </div> + + {/* 主卡片 - Y轴翻转 */} + <div + className="absolute" + style={{ + left: 100, + top: 30, + width: 220, + height: 320, + zIndex: 1, + transformStyle: 'preserve-3d', + transform: `rotateY(${flipAngle}deg)`, + transition: `transform ${FLIP_DURATION}ms ease-in-out`, + }} + > + {/* 正面 */} + <div + className="absolute inset-0 rounded-[18px] flex flex-col items-center justify-center gap-[18px] px-6 py-6" + style={{ + background: 'linear-gradient(180deg, #F0E6FF 0%, #EDE7F6 52%, #FFFFFF 100%)', + boxShadow: '0 14px 26px rgba(0, 0, 0, 0.18)', + border: '1px solid rgba(139, 92, 246, 0.3)', + backfaceVisibility: 'hidden', + }} + > + <div className="flex items-center justify-center rounded-full bg-white/75 px-3 py-1.5"> + <span className="text-xs font-bold text-[#673AB7]">{text.badge}</span> + </div> + <span className="text-[44px] font-bold text-[#673AB7]">{currentCard.symbol}</span> + <span className="w-full text-center text-base font-bold text-[#673AB7]">{currentCard.title}</span> + <span className="w-full text-center text-[13px] leading-relaxed text-[#334155]">{currentCard.quote}</span> + </div> + + {/* 背面 */} + <div + className="absolute inset-0 rounded-[18px] flex flex-col items-center justify-center gap-[18px] px-6 py-6" + style={{ + background: 'linear-gradient(180deg, #F0E6FF 0%, #EDE7F6 52%, #FFFFFF 100%)', + boxShadow: '0 14px 26px rgba(0, 0, 0, 0.18)', + border: '1px solid rgba(139, 92, 246, 0.3)', + backfaceVisibility: 'hidden', + transform: 'rotateY(180deg)', + }} + > + <div className="flex items-center justify-center rounded-full bg-white/75 px-3 py-1.5"> + <span className="text-xs font-bold text-[#673AB7]">{text.badge}</span> + </div> + <span className="text-[44px] font-bold text-[#673AB7]">{currentCard.symbol}</span> + <span className="w-full text-center text-base font-bold text-[#673AB7]">{currentCard.title}</span> + <span className="w-full text-center text-[13px] leading-relaxed text-[#334155]">{currentCard.quote}</span> + </div> + </div> + </div> + + {/* 状态区域 */} + <div className="flex w-[420px] flex-col items-center gap-2.5"> + <span className="w-full text-center text-xl font-bold text-white whitespace-pre-line"> + {step === 'preparing' ? text.statusPreparing : step === 'deriving' ? text.statusDeriving : text.statusDone} + </span> + </div> + </div> + </div> + ); +} diff --git a/web/src/components/DivinationResultPage.tsx b/web/src/components/DivinationResultPage.tsx new file mode 100644 index 0000000..8954a9c --- /dev/null +++ b/web/src/components/DivinationResultPage.tsx @@ -0,0 +1,546 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api'; +import { useHistoryThread } from '../lib/resources'; +import Icon from './Icon'; + +interface Props { + locale: string; + translations: Record<string, string>; +} + +const WU_XING = ['木', '火', '土', '金', '水']; + +function getSignTypeLabel(signType: string, t: Record<string, string>): string { + const normalized = signType.trim(); + if (normalized.includes('上上')) return t.signTypeShangShang; + if (normalized.includes('中上')) return t.signTypeZhongShang; + if (normalized.includes('下下')) return t.signTypeXiaXia; + return t.signTypeZhongXia; +} + +function getSignImageSrc(signType: string): string { + const normalized = signType.trim(); + if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg'; + if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg'; + if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg'; + return '/images/qigua/zhongxia.jpg'; +} + +function getQuestionTypeLabel(type: string, t: Record<string, string>): string { + const map: Record<string, string> = { + career: t.questionTypeCareer, + love: t.questionTypeLove, + wealth: t.questionTypeWealth, + fortune: t.questionTypeFortune, + dream: t.questionTypeDream, + health: t.questionTypeHealth, + study: t.questionTypeStudy, + search: t.questionTypeSearch, + other: t.questionTypeOther, + }; + return map[type] || type; +} + +function formatDivinationTime(date: Date, locale: string): string { + try { + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); + } catch { + return date.toLocaleString(); + } +} + +function abbreviateRelation(relation: string): string { + const map: Record<string, string> = { + '子孙': '孙', '妻财': '财', '官鬼': '官', '兄弟': '兄', '父母': '父', + }; + return map[relation] || relation; +} + +function getChangeMark(type: YaoType): string { + if (type === 'oldYang') return '○'; + if (type === 'oldYin') return '×'; + return ''; +} + +function YaoGlyph({ type }: { type: YaoType }) { + const isYin = type === 'youngYin' || type === 'oldYin'; + if (!isYin) { + return <div className="h-1.5 w-full rounded bg-violet-600" />; + } + return ( + <div className="flex gap-1"> + <div className="h-1.5 flex-1 rounded bg-violet-600" /> + <div className="h-1.5 flex-1 rounded bg-violet-600" /> + </div> + ); +} + +function CopyButton({ text, label }: { text: string; label: string }) { + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + }; + + return ( + <button onClick={handleCopy} className="text-violet-600 text-sm font-medium hover:text-violet-700 transition-colors"> + {label} + </button> + ); +} + +function AnalysisCard({ title, content, copyLabel }: { title: string; content: string; copyLabel: string }) { + return ( + <div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-2.5"> + <div className="flex items-center justify-between"> + <h4 className="text-violet-600 text-sm font-bold">{title}</h4> + <CopyButton text={content} label={copyLabel} /> + </div> + <p className="text-slate-600 text-sm leading-relaxed whitespace-pre-wrap">{content}</p> + </div> + ); +} + +function FocusPointsCard({ points, title, copyLabel }: { points: string[]; title: string; copyLabel: string }) { + if (points.length === 0) return null; + const content = points.join('\n'); + + return ( + <div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-2.5"> + <div className="flex items-center justify-between"> + <h4 className="text-violet-600 text-sm font-bold">{title}</h4> + <CopyButton text={content} label={copyLabel} /> + </div> + <div className="flex flex-col gap-1"> + {points.map((point, i) => ( + <p key={i} className="text-slate-600 text-sm leading-relaxed">{point}</p> + ))} + </div> + </div> + ); +} + +const SIGN_ANIM_SIZE = 380; +const SIGN_ANIM_DISPLAY_MS = 800; +const SIGN_ANIM_SHRINK_MS = 600; + +function SignAnimationOverlay({ + src, + targetRef, + onAnimationEnd, +}: { + src: string; + targetRef: React.RefObject<HTMLDivElement | null>; + onAnimationEnd: () => void; +}) { + const [shrinking, setShrinking] = useState(false); + + useEffect(() => { + const t = setTimeout(() => setShrinking(true), SIGN_ANIM_DISPLAY_MS); + return () => clearTimeout(t); + }, []); + + useEffect(() => { + if (shrinking) { + const t = setTimeout(onAnimationEnd, SIGN_ANIM_SHRINK_MS + 100); + return () => clearTimeout(t); + } + }, [shrinking, onAnimationEnd]); + + let imgStyle: React.CSSProperties = { + width: SIGN_ANIM_SIZE, + height: SIGN_ANIM_SIZE, + borderRadius: 14, + boxShadow: '0 20px 40px rgba(0,0,0,0.25)', + animation: 'signAppear 400ms ease-out', + transition: `transform ${SIGN_ANIM_SHRINK_MS}ms cubic-bezier(0.4,0,0.2,1), opacity ${SIGN_ANIM_SHRINK_MS - 100}ms ease 100ms`, + }; + + if (shrinking && targetRef.current) { + const rect = targetRef.current.getBoundingClientRect(); + const dx = rect.left + rect.width / 2 - window.innerWidth / 2; + const dy = rect.top + rect.height / 2 - window.innerHeight / 2; + const s = rect.width / SIGN_ANIM_SIZE; + imgStyle.transform = `translate(${dx}px, ${dy}px) scale(${s})`; + imgStyle.opacity = 0; + } + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center"> + <div + className="absolute inset-0 bg-slate-100/95" + style={{ + backdropFilter: 'blur(8px)', + transition: `opacity ${SIGN_ANIM_SHRINK_MS}ms ease-out`, + opacity: shrinking ? 0 : 1, + }} + /> + <div className="relative overflow-hidden" style={imgStyle}> + <img src={src} className="w-full h-full object-cover" /> + </div> + <style>{`@keyframes signAppear{from{transform:scale(.85);opacity:0}to{transform:scale(1);opacity:1}}`}</style> + </div> + ); +} + +function WarningCard({ message }: { message: string }) { + return ( + <div className="bg-amber-50 rounded-xl p-3.5 flex items-start gap-2.5"> + <Icon name="warning" className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" /> + <p className="text-amber-700 text-sm leading-relaxed">{message}</p> + </div> + ); +} + +function InfoCard({ data, t, locale }: { data: DivinationResultData; t: Record<string, string>; locale: string }) { + const methodLabel = data.params.method === 'auto' ? t.autoMethod : t.manualMethod; + + return ( + <div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-3"> + <h4 className="text-violet-600 text-sm font-bold">{t.divinationInfo}</h4> + <div className="flex flex-col gap-2 text-sm"> + <div className="flex justify-between"> + <span className="text-slate-400">{t.divinationTime}</span> + <span className="text-slate-600">{formatDivinationTime(data.params.divinationTime, locale)}</span> + </div> + <div className="flex justify-between"> + <span className="text-slate-400">{t.divinationMethod}</span> + <span className="text-slate-600">{methodLabel}</span> + </div> + <div className="flex justify-between"> + <span className="text-slate-400">{t.questionType}</span> + <span className="text-slate-600">{getQuestionTypeLabel(data.params.questionType, t)}</span> + </div> + <div className="flex justify-between"> + <span className="text-slate-400">{t.question}</span> + <span className="text-slate-600 max-w-[200px] text-right truncate">{data.params.question}</span> + </div> + </div> + </div> + ); +} + +function GanzhiCard({ ganzhi, wuXingStatus, t }: { ganzhi: DivinationResultData['ganzhi']; wuXingStatus: Record<string, string>; t: Record<string, string> }) { + return ( + <div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-3"> + <h4 className="text-violet-600 text-sm font-bold">{t.ganZhiInfo}</h4> + + <div className="grid grid-cols-2 gap-2 text-sm"> + <div className="flex gap-1"> + <span className="text-slate-400">{t.termYueJian}:</span> + <span className="text-slate-700 font-medium">{ganzhi.yueJian}</span> + </div> + <div className="flex gap-1"> + <span className="text-slate-400">{t.termRiChen}:</span> + <span className="text-slate-700 font-medium">{ganzhi.riChen}</span> + </div> + <div className="flex gap-1"> + <span className="text-slate-400">{t.termYuePo}:</span> + <span className="text-slate-700 font-medium">{ganzhi.yuePo}</span> + </div> + <div className="flex gap-1"> + <span className="text-slate-400">{t.termRiChong}:</span> + <span className="text-slate-700 font-medium">{ganzhi.riChong}</span> + </div> + </div> + + <div> + <h5 className="text-violet-600 text-xs font-bold mb-2">{t.wuXingWangShuai}</h5> + <div className="border border-slate-200 rounded overflow-hidden text-xs"> + <div className="flex bg-slate-100"> + {WU_XING.map((w, i) => ( + <div key={w} className={`flex-1 py-1.5 text-center ${i < WU_XING.length - 1 ? 'border-r border-slate-200' : ''}`}> + {w} + </div> + ))} + </div> + <div className="flex"> + {WU_XING.map((w, i) => ( + <div key={w} className={`flex-1 py-1.5 text-center ${i < WU_XING.length - 1 ? 'border-r border-slate-200' : ''}`}> + {wuXingStatus[w] || ''} + </div> + ))} + </div> + </div> + </div> + + <div> + <h5 className="text-violet-600 text-xs font-bold mb-2">{t.ganZhiKongWang}</h5> + <div className="border border-slate-200 rounded overflow-hidden text-xs"> + <div className="flex bg-slate-100"> + {[t.pillarColumn, t.yearPillar, t.monthPillar, t.dayPillar, t.timePillar].map((h, i, arr) => ( + <div key={h} className={`py-1.5 text-center ${i === 0 ? 'flex-[2]' : 'flex-[3]'} ${i < arr.length - 1 ? 'border-r border-slate-200' : ''}`}> + {h} + </div> + ))} + </div> + <div className="flex border-t border-slate-200"> + {[t.ganZhiLabel, ganzhi.yearGanZhi, ganzhi.monthGanZhi, ganzhi.dayGanZhi, ganzhi.timeGanZhi].map((v, i, arr) => ( + <div key={i} className={`py-1.5 text-center ${i === 0 ? 'flex-[2] font-bold' : 'flex-[3]'} ${i < arr.length - 1 ? 'border-r border-slate-200' : ''}`}> + {v} + </div> + ))} + </div> + <div className="flex border-t border-slate-200"> + {[t.kongWangLabel, ganzhi.yearKongWang, ganzhi.monthKongWang, ganzhi.dayKongWang, ganzhi.timeKongWang].map((v, i, arr) => ( + <div key={i} className={`py-1.5 text-center ${i === 0 ? 'flex-[2] font-bold' : 'flex-[3]'} ${i < arr.length - 1 ? 'border-r border-slate-200' : ''}`}> + {v} + </div> + ))} + </div> + </div> + </div> + </div> + ); +} + +function YaoDetailRow({ line, target, showTarget }: { line: DivinationResultData['yaoLines'][0]; target: DivinationResultData['yaoLines'][0]; showTarget: boolean }) { + return ( + <div className="flex gap-4 py-1.5"> + <div className="flex-1 flex items-center gap-1 text-xs"> + <span className="w-5 text-center">{line.spirit}</span> + <span className="w-7 text-center">{abbreviateRelation(line.relation)}</span> + <span className="w-5 text-center">{line.branch}</span> + <span className="w-5 text-center">{line.element}</span> + <div className="flex-1 px-1"><YaoGlyph type={line.type} /></div> + <span className="w-4 text-center">{getChangeMark(line.type)}</span> + <span className="w-4 text-center">{line.mark}</span> + </div> + {showTarget && ( + <div className="flex-1 flex items-center gap-1 text-xs"> + <span className="w-5 text-center">{target.spirit}</span> + <span className="w-7 text-center">{abbreviateRelation(target.relation)}</span> + <span className="w-5 text-center">{target.branch}</span> + <span className="w-5 text-center">{target.element}</span> + <div className="flex-1 px-1"><YaoGlyph type={target.type} /></div> + <span className="w-4 text-center">{getChangeMark(target.type)}</span> + <span className="w-4 text-center" /> + </div> + )} + </div> + ); +} + +function HexagramDetailCard({ data, t }: { data: DivinationResultData; t: Record<string, string> }) { + const hasChangingYao = data.binaryCode !== data.changedBinaryCode; + + return ( + <div className="bg-slate-50 rounded-xl p-4 border border-slate-200 flex flex-col gap-3"> + <div className="flex gap-4"> + <div className="flex-1 text-center font-bold text-sm">{data.guaName}</div> + {hasChangingYao && ( + <div className="flex-1 text-center font-bold text-sm">{data.targetGuaName}</div> + )} + </div> + + {[5, 4, 3, 2, 1, 0].map((idx) => ( + <YaoDetailRow + key={idx} + line={data.yaoLines[idx]} + target={idx < data.targetYaoLines.length ? data.targetYaoLines[idx] : data.yaoLines[idx]} + showTarget={hasChangingYao && idx < data.targetYaoLines.length} + /> + ))} + </div> + ); +} + +function FollowUpPanel({ canFollowUp, onFollowUp, t }: { canFollowUp: boolean; onFollowUp?: () => void; t: Record<string, string> }) { + return ( + <div className={`rounded-xl p-4 flex flex-col gap-3 ${canFollowUp ? 'bg-violet-600' : 'bg-slate-500'}`}> + <h4 className="text-white font-bold">{canFollowUp ? t.followUpEntryHint : t.followUpQuotaUsed}</h4> + <button + onClick={onFollowUp} + className="w-full py-2.5 rounded-lg bg-white text-sm font-semibold hover:bg-violet-50 transition-colors" + style={{ color: canFollowUp ? '#673AB7' : '#475569' }} + > + {canFollowUp ? t.followUpEntryAction : t.followUpViewHistory} + </button> + </div> + ); +} + +const RESULT_STORAGE_KEY = 'divination_result_data'; + +export default function DivinationResultPage({ locale, translations: t }: Props) { + const location = useLocation(); + const navigate = useNavigate(); + const { id: routeThreadId } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const threadId = routeThreadId ?? searchParams.get('threadId') ?? undefined; + const threadState = useHistoryThread(threadId); + const [data, setData] = useState<DivinationResultData | null>(null); + const [loading, setLoading] = useState(true); + const [canFollowUp, setCanFollowUp] = useState(true); + const [signAnimating, setSignAnimating] = useState(() => { + return !!(location.state as { result?: DivinationResultData } | null)?.result; + }); + const signImageRef = useRef<HTMLDivElement>(null); + const handleSignAnimationEnd = useCallback(() => setSignAnimating(false), []); + + useEffect(() => { + // 1. Try router state (from divination flow) + const state = location.state as { result?: DivinationResultData } | null; + if (state?.result) { + setData(state.result); + setLoading(false); + try { sessionStorage.setItem(RESULT_STORAGE_KEY, JSON.stringify(state.result)); } catch { /* ignore */ } + return; + } + + // 2. Try sessionStorage (backup for divination flow) + try { + const stored = sessionStorage.getItem(RESULT_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + setData(parsed); + setLoading(false); + return; + } + } catch { /* ignore */ } + + // 3. Fetch by threadId (from history flow) + if (threadId) { + setLoading(true); + if (threadState.data) { + const assistantMsg = threadState.data.messages.find((m) => m.role === 'assistant'); + if (assistantMsg) { + const resultData = historyMessageToResultData(assistantMsg); + if (resultData) setData(resultData); + } + const userCount = threadState.data.messages.filter((m) => m.role === 'user').length; + setCanFollowUp(userCount < 2); + setLoading(false); + } else if (!threadState.loading) { + setLoading(false); + } + } else { + setLoading(false); + } + }, [location.state, threadId, threadState.data, threadState.loading]); + + // Redirect if no data and not loading + useEffect(() => { + if (!loading && data === null) { + const timer = setTimeout(() => { + navigate(`/${locale}/dashboard`); + }, 100); + return () => clearTimeout(timer); + } + }, [data, loading, navigate, locale]); + + const handleBackHome = () => { + navigate(`/${locale}/dashboard`); + }; + + const handleFollowUp = () => { + const effectiveThreadId = data?.threadId || threadId; + if (effectiveThreadId) { + navigate(`/${locale}/history/followup?threadId=${encodeURIComponent(effectiveThreadId)}`, { state: { result: data } }); + } + }; + + if (loading || data === null) { + return ( + <div className="flex items-center justify-center min-h-[400px]"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-violet-600" /> + </div> + ); + } + + const isSuccess = data.status === 'success'; + + return ( + <div className="flex flex-col gap-5 min-h-full"> + {signAnimating && ( + <SignAnimationOverlay + src={getSignImageSrc(data.signType)} + targetRef={signImageRef} + onAnimationEnd={handleSignAnimationEnd} + /> + )} + {/* Header */} + <div className="flex items-center gap-3.5"> + <button + onClick={handleBackHome} + className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors" + aria-label={locale === 'en' ? 'Back to home' : '返回首页'} + > + <Icon name="arrow_back" className="w-[18px] h-[18px] text-slate-500" /> + </button> + <h1 className="text-slate-900 text-2xl font-bold">{t.screenTitle}</h1> + </div> + + {/* Body */} + <div className="flex flex-col xl:flex-row gap-5 flex-1 min-h-0 pb-8"> + {/* Left: Analysis */} + <div className="flex-1 flex flex-col gap-3.5 overflow-y-auto pr-1"> + {/* Hero: sign image + gua name + question + tags */} + <div className="bg-white rounded-2xl p-5 border border-slate-200 flex items-center gap-5"> + <div ref={signImageRef} className="w-[116px] h-[116px] rounded-[14px] overflow-hidden shrink-0 shadow-lg"> + <img src={getSignImageSrc(data.signType)} alt={getSignTypeLabel(data.signType, t)} className="w-full h-full object-cover" /> + </div> + <div className="flex-1 min-w-0 flex flex-col gap-2.5"> + <h3 className="text-slate-900 text-[26px] font-bold leading-tight"> + {getSignTypeLabel(data.signType, t)} · {data.guaName} + </h3> + <p className="text-slate-500 text-base font-semibold truncate">{data.params.question}</p> + <div className="flex items-center gap-2"> + <span className="px-2.5 py-1 rounded-lg bg-violet-50 text-violet-700 text-xs"> + {getQuestionTypeLabel(data.params.questionType, t)} + </span> + <span className="px-2.5 py-1 rounded-lg bg-sky-50 text-sky-700 text-xs"> + {data.params.method === 'auto' ? t.autoMethod : t.manualMethod} + </span> + </div> + </div> + </div> + {data.keywords && ( + <div className="bg-amber-50 rounded-xl p-4 text-center"> + <p className="text-violet-600 font-bold">{data.keywords}</p> + </div> + )} + <AnalysisCard title={t.conclusion} content={data.conclusion} copyLabel={t.copy} /> + <AnalysisCard title={t.suggestion} content={data.suggestion} copyLabel={t.copy} /> + <AnalysisCard title={t.analysis} content={data.analysis} copyLabel={t.copy} /> + <FocusPointsCard points={data.focusPoints} title={t.focusPoints} copyLabel={t.copy} /> + <WarningCard message={t.warning} /> + </div> + + {/* Right side column */} + <div className="w-full xl:w-[340px] flex flex-col gap-3.5 shrink-0"> + {isSuccess && data.threadId && ( + <FollowUpPanel canFollowUp={canFollowUp} onFollowUp={handleFollowUp} t={t} /> + )} + <InfoCard data={data} t={t} locale={locale} /> + {isSuccess && ( + <GanzhiCard ganzhi={data.ganzhi} wuXingStatus={data.wuXingStatus} t={t} /> + )} + {isSuccess ? ( + <HexagramDetailCard data={data} t={t} /> + ) : ( + <div className="bg-slate-100 rounded-xl p-6 text-center"> + <p className="text-slate-500 text-sm"> + {data.status === 'failed' ? t.hexagramDetailFailed : t.hexagramDetailRefused} + </p> + </div> + )} + </div> + </div> + </div> + ); +} diff --git a/web/src/components/FeaturesPage.astro b/web/src/components/FeaturesPage.astro new file mode 100644 index 0000000..0f760bc --- /dev/null +++ b/web/src/components/FeaturesPage.astro @@ -0,0 +1,90 @@ +--- +import { t, type Locale } from '../i18n/utils'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const f = t(locale, 'features'); + +const icons = [ + // 01 - 两种起卦方式: sparkle/sparkles + `<svg class="w-5 h-5 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>`, + // 02 - AI 解卦分析: brain + `<svg class="w-5 h-5 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/><path d="M3.477 10.896a4.5 4.5 0 0 1 .555-.396"/><path d="M20.523 10.896a4.5 4.5 0 0 0-.555-.396"/></svg>`, + // 03 - 九类问题覆盖: grid/layout + `<svg class="w-5 h-5 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg>`, + // 04 - 追问互动: message/chat + `<svg class="w-5 h-5 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>`, + // 05 - 历史记录: clock/history + `<svg class="w-5 h-5 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`, + // 06 - 点数系统: coins + `<svg class="w-5 h-5 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71V9"/></svg>`, +]; + +const cards = [ + { num: '01', title: f.c1Title, desc: f.c1Desc }, + { num: '02', title: f.c2Title, desc: f.c2Desc }, + { num: '03', title: f.c3Title, desc: f.c3Desc }, + { num: '04', title: f.c4Title, desc: f.c4Desc }, + { num: '05', title: f.c5Title, desc: f.c5Desc }, + { num: '06', title: f.c6Title, desc: f.c6Desc }, +]; +--- + +<!-- Page Header --> +<section class="w-full pt-32 md:pt-40 pb-16 md:pb-20 relative"> + <div class="glow-bg absolute inset-0 pointer-events-none"></div> + <div class="relative text-center px-6 max-w-3xl mx-auto"> + <h1 class="reveal text-slate-900 text-3xl md:text-4xl lg:text-5xl font-bold mb-4">{f.title}</h1> + <p class="reveal stagger-1 text-slate-500 text-lg">{f.subtitle}</p> + </div> +</section> + +<!-- Feature Details - alternating layout --> +<section class="w-full py-16 md:py-24" style="background-color: #F8F7FC;"> + <div class="max-w-6xl mx-auto px-6 space-y-24 md:space-y-32"> + {cards.map((card, index) => { + const isEven = index % 2 === 1; + const lines = [0,1,2,3,4,5].map(i => (index + i) % 2 === 0); + return ( + <div class={`reveal flex flex-col ${isEven ? 'md:flex-row-reverse' : 'md:flex-row'} gap-10 md:gap-16 items-center`}> + <!-- Text --> + <div class="flex-1"> + <span class="font-mono text-6xl md:text-7xl font-bold select-none block mb-2 text-violet-100">{card.num}</span> + <div class="flex items-center gap-3 mb-4 -mt-4"> + <div class="w-10 h-10 rounded-lg flex items-center justify-center bg-violet-50 shrink-0"> + <Fragment set:html={icons[index]} /> + </div> + <h2 class="text-2xl md:text-3xl font-bold text-slate-900">{card.title}</h2> + </div> + <p class="text-slate-500 leading-relaxed max-w-lg">{card.desc}</p> + </div> + + <!-- Hexagram Illustration --> + <div class="flex-1 w-full"> + <div class="relative w-full h-48 md:h-64 rounded-xl border border-violet-100 flex items-center justify-center bg-violet-50/30"> + <div class="flex flex-col gap-2.5 items-center"> + {lines.map((isYang) => isYang ? ( + <div class="hex-yang"></div> + ) : ( + <div class="hex-yin"><div></div><div></div></div> + ))} + </div> + </div> + </div> + </div> + ); + })} + </div> +</section> + +<!-- CTA --> +<section class="w-full py-24 md:py-32 relative"> + <div class="glow-bg absolute inset-0 pointer-events-none"></div> + <div class="reveal relative text-center px-6 max-w-3xl mx-auto"> + <h2 class="text-slate-900 text-3xl md:text-4xl font-bold mb-4">{f.tagline}</h2> + <p class="text-slate-500 text-lg mb-8">{f.subtitle}</p> + </div> +</section> diff --git a/web/src/components/FeedbackPage.tsx b/web/src/components/FeedbackPage.tsx new file mode 100644 index 0000000..eebf398 --- /dev/null +++ b/web/src/components/FeedbackPage.tsx @@ -0,0 +1,214 @@ +import { useState, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authFetch } from '../lib/auth'; +import { API_ROUTES } from '../lib/api-routes'; +import { apiUrl } from '../lib/api-client'; + +interface Props { + locale: string; + feedback: { title: string; typeLabel: string; typeBug: string; typeSuggestion: string; typeOther: string; contentLabel: string; contentPlaceholder: string; imagesLabel: string; anonymousLabel: string; anonymousHint: string; submitBtn: string; submitting: string; success: string; error: string; contentRequired: string; contentTooLong: string; tooManyImages: string; imageTooLarge: string }; +} + +type FeedbackType = 'bug' | 'suggestion' | 'other'; + +const MAX_IMAGES = 3; +const MAX_CONTENT_SIZE = 500; +const MAX_IMAGE_SIZE = 5 * 1024 * 1024; + +export default function FeedbackPage({ locale, feedback: f }: Props) { + const navigate = useNavigate(); + const [selectedType, setSelectedType] = useState<FeedbackType>('bug'); + const [content, setContent] = useState(''); + const [images, setImages] = useState<File[]>([]); + const [isAnonymous, setIsAnonymous] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const fileInputRef = useRef<HTMLInputElement>(null); + + const typeOptions: { value: FeedbackType; label: string }[] = [ + { value: 'bug', label: f.typeBug }, + { value: 'suggestion', label: f.typeSuggestion }, + { value: 'other', label: f.typeOther }, + ]; + + const handleImagePick = async (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (images.length >= MAX_IMAGES) { + setToast({ type: 'error', message: f.tooManyImages }); + return; + } + + if (file.size > MAX_IMAGE_SIZE) { + setToast({ type: 'error', message: f.imageTooLarge }); + return; + } + + setImages([...images, file]); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const handleRemoveImage = (index: number) => { + setImages(images.filter((_, i) => i !== index)); + }; + + const handleSubmit = async () => { + const trimmedContent = content.trim(); + if (!trimmedContent) { + setToast({ type: 'error', message: f.contentRequired }); + return; + } + if (trimmedContent.length > MAX_CONTENT_SIZE) { + setToast({ type: 'error', message: f.contentTooLong }); + return; + } + + setSubmitting(true); + setToast(null); + + try { + const formData = new FormData(); + formData.append('feedback_type', selectedType); + formData.append('content', trimmedContent); + formData.append('device_info', JSON.stringify({ platform: 'web', model: navigator.userAgent })); + formData.append('app_version', '1.0.0'); + formData.append('os_version', navigator.platform); + + for (const image of images) { + formData.append('images', image); + } + + if (isAnonymous) { + // Anonymous submission - no auth header + const res = await fetch(apiUrl(API_ROUTES.feedback.submit), { + method: 'POST', + body: formData, + }); + if (!res.ok) { + throw new Error('Submit failed'); + } + } else { + // Authenticated submission + await authFetch(API_ROUTES.feedback.submit, { + method: 'POST', + body: formData, + }); + } + + setToast({ type: 'success', message: f.success }); + setTimeout(() => navigate(`/${locale}/settings`), 1500); + } catch { + setToast({ type: 'error', message: f.error }); + } finally { + setSubmitting(false); + } + }; + + return ( + <div className="flex flex-col gap-6 min-h-full"> + {/* Page Header */} + <div className="flex items-center gap-3"> + <button + onClick={() => navigate(`/${locale}/settings`)} + className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors" + > + <span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span> + </button> + <h1 className="text-slate-900 text-xl font-bold">{f.title}</h1> + </div> + + {/* Toast */} + {toast && ( + <div className={`px-4 py-3 rounded-lg text-sm ${toast.type === 'success' ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-500'}`}> + {toast.message} + </div> + )} + + {/* Feedback Type */} + <div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4"> + <h3 className="text-slate-900 text-lg font-bold">{f.typeLabel}</h3> + <div className="flex gap-2"> + {typeOptions.map((opt) => ( + <button + key={opt.value} + onClick={() => setSelectedType(opt.value)} + className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${selectedType === opt.value ? 'bg-violet-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`} + > + {opt.label} + </button> + ))} + </div> + </div> + + {/* Content */} + <div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4"> + <h3 className="text-slate-900 text-lg font-bold">{f.contentLabel}</h3> + <textarea + value={content} + onChange={(e) => setContent(e.target.value)} + placeholder={f.contentPlaceholder} + rows={6} + maxLength={MAX_CONTENT_SIZE} + className="w-full px-4 py-3 rounded-xl bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 resize-none" + /> + <p className="text-slate-400 text-xs text-right">{content.length}/{MAX_CONTENT_SIZE}</p> + </div> + + {/* Images */} + <div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4"> + <h3 className="text-slate-900 text-lg font-bold">{f.imagesLabel}</h3> + <div className="flex flex-wrap gap-3"> + {images.map((img, idx) => ( + <div key={idx} className="relative w-20 h-20 rounded-xl border border-slate-200 overflow-hidden bg-slate-50"> + <img src={URL.createObjectURL(img)} alt="Preview" className="w-full h-full object-cover" /> + <button + onClick={() => handleRemoveImage(idx)} + className="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-xs hover:bg-red-600" + > + <span className="material-symbols-rounded text-sm">close</span> + </button> + </div> + ))} + {images.length < MAX_IMAGES && ( + <label className="w-20 h-20 rounded-xl border border-dashed border-slate-300 bg-slate-50 flex items-center justify-center cursor-pointer hover:bg-slate-100 transition-colors"> + <input + ref={fileInputRef} + type="file" + accept="image/png,image/jpeg,image/webp" + onChange={handleImagePick} + className="hidden" + /> + <span className="material-symbols-rounded text-slate-400 text-2xl">add_photo_alternate</span> + </label> + )} + </div> + </div> + + {/* Anonymous Option */} + <div className="bg-white rounded-2xl p-6 border border-slate-200"> + <label className="flex items-start gap-3 cursor-pointer"> + <input + type="checkbox" + checked={isAnonymous} + onChange={(e) => setIsAnonymous(e.target.checked)} + className="mt-1 w-4 h-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500" + /> + <div> + <p className="text-slate-900 text-sm font-medium">{f.anonymousLabel}</p> + <p className="text-slate-400 text-xs mt-0.5">{f.anonymousHint}</p> + </div> + </label> + </div> + + {/* Submit Button */} + <button + onClick={handleSubmit} + disabled={submitting} + className="w-full py-3.5 rounded-xl bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors" + > + {submitting ? f.submitting : f.submitBtn} + </button> + </div> + ); +} diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro new file mode 100644 index 0000000..400f2bd --- /dev/null +++ b/web/src/components/Footer.astro @@ -0,0 +1,40 @@ +--- +import { t, localePath, type Locale } from '../i18n/utils'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const footer = t(locale, 'footer'); +--- + +<footer class="w-full bg-slate-950 px-6 md:px-20 py-16 md:py-12"> + <div class="max-w-7xl mx-auto flex flex-col md:flex-row gap-10 md:gap-16"> + <div class="w-full md:w-64 flex flex-col gap-4 shrink-0"> + <a href={localePath(locale, '/')} class="flex items-center gap-3"> + <img src="/images/logo.png" alt="MeiYao" class="w-8 h-8" /> + <span class="text-white text-lg font-bold">{footer.brandName}</span> + </a> + <p class="text-slate-400 text-sm leading-relaxed">{footer.desc}</p> + </div> + + <div class="flex-1 flex flex-wrap gap-10 md:gap-16"> + <div class="flex flex-col gap-3 min-w-[120px]"> + <span class="text-white text-sm font-semibold">{footer.col1Title}</span> + <a href={localePath(locale, '/features')} class="text-slate-400 text-sm hover:text-white">{footer.col1Link1}</a> + <a href={localePath(locale, '/pricing')} class="text-slate-400 text-sm hover:text-white">{footer.col1Link2}</a> + </div> + <div class="flex flex-col gap-3 min-w-[120px]"> + <span class="text-white text-sm font-semibold">{footer.col2Title}</span> + <a href="#" class="text-slate-400 text-sm hover:text-white">{footer.col2Link1}</a> + <a href="#" class="text-slate-400 text-sm hover:text-white">{footer.col2Link2}</a> + </div> + <div class="flex flex-col gap-3 min-w-[120px]"> + <span class="text-white text-sm font-semibold">{footer.col3Title}</span> + <a href={localePath(locale, '/privacy')} class="text-slate-400 text-sm hover:text-white">{footer.col3Link1}</a> + <a href={localePath(locale, '/terms')} class="text-slate-400 text-sm hover:text-white">{footer.col3Link2}</a> + </div> + </div> + </div> +</footer> diff --git a/web/src/components/GeneralSettingsPage.tsx b/web/src/components/GeneralSettingsPage.tsx new file mode 100644 index 0000000..5d212ae --- /dev/null +++ b/web/src/components/GeneralSettingsPage.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { ProfileSettings } from '../lib/api'; +import { backendLanguageToLocale } from '../lib/auth'; +import { updateSettingsResource, useProfile } from '../lib/resources'; + +interface Props { + locale: string; + general: { title: string; languageLabel: string; languageValue: string; privacyTitle: string; doNotSell: string; doNotSellHint: string; notificationTitle: string; allowNotification: string; saveSuccess: string; saveFailed: string }; +} + +const languageOptions = [ + { tag: 'zh-CN', label: '简体中文' }, + { tag: 'zh-TW', label: '繁體中文' }, + { tag: 'en-US', label: 'English' }, +]; + +// Default settings structure +function getDefaultSettings(): ProfileSettings { + return { + version: 1, + preferences: { + language: 'zh-CN', + timezone: 'Asia/Shanghai', + }, + privacy: { + can_sell: false, + profile_visibility: 'public', + }, + notification: { + allow_notifications: true, + allow_vibration: true, + }, + divination_tutorial: { + divination_entry_shown: false, + auto_divination_shown: false, + manual_divination_shown: false, + }, + }; +} + +export default function GeneralSettingsPage({ locale, general: g }: Props) { + const navigate = useNavigate(); + const profileState = useProfile(); + const [settings, setSettings] = useState<ProfileSettings>(getDefaultSettings()); + const [saving, setSaving] = useState(false); + const [showLanguageModal, setShowLanguageModal] = useState(false); + const [toast, setToast] = useState<{ type: 'error'; message: string } | null>(null); + + const selectedLanguage = settings.preferences.language; + const canSell = settings.privacy.can_sell; + const allowNotifications = settings.notification.allow_notifications; + + useEffect(() => { + if (profileState.data?.settings) setSettings(profileState.data.settings); + }, [profileState.data]); + + const saveSettings = async (newSettings: ProfileSettings): Promise<boolean> => { + setSaving(true); + try { + const updated = await updateSettingsResource(newSettings); + setSettings(updated.settings); + return true; + } catch { + return false; + } finally { + setSaving(false); + } + }; + + const handleLanguageSelect = async (lang: string) => { + setShowLanguageModal(false); + if (lang === selectedLanguage) return; + + const newSettings: ProfileSettings = { + ...settings, + preferences: { + ...settings.preferences, + language: lang, + }, + }; + + const success = await saveSettings(newSettings); + if (success) { + const newLocale = backendLanguageToLocale(lang); + const currentPath = window.location.pathname; + const newPath = currentPath.replace(`/${locale}`, `/${newLocale}`); + window.location.href = newPath; + } + }; + + const handleToggleChange = async (field: 'canSell' | 'allowNotifications', value: boolean) => { + let newSettings: ProfileSettings; + if (field === 'canSell') { + newSettings = { + ...settings, + privacy: { + ...settings.privacy, + can_sell: value, + }, + }; + } else { + newSettings = { + ...settings, + notification: { + ...settings.notification, + allow_notifications: value, + }, + }; + } + + setSaving(true); + setToast(null); + + const success = await saveSettings(newSettings); + if (success) { + setSettings(newSettings); + } else { + setToast({ type: 'error', message: g.saveFailed }); + } + setSaving(false); + }; + + const currentLangLabel = languageOptions.find(l => l.tag === selectedLanguage)?.label || selectedLanguage; + + if (profileState.loading) { + return ( + <div className="flex flex-col gap-6 min-h-full"> + <div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div> + </div> + ); + } + + return ( + <div className="flex flex-col gap-6 min-h-full"> + {/* Page Header */} + <div className="flex items-center gap-3"> + <button + onClick={() => navigate(`/${locale}/settings`)} + className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors" + > + <span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span> + </button> + <h1 className="text-slate-900 text-xl font-bold">{g.title}</h1> + </div> + + {/* Toast */} + {toast && ( + <div className="px-4 py-3 rounded-lg text-sm bg-red-50 text-red-500"> + {toast.message} + </div> + )} + + {/* Language Section */} + <div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4"> + <h3 className="text-slate-900 text-lg font-bold">{g.languageLabel}</h3> + <button + onClick={() => setShowLanguageModal(true)} + disabled={saving} + className="flex items-center justify-between px-4 py-3.5 rounded-xl bg-slate-50 hover:bg-slate-100 transition-colors disabled:opacity-50" + > + <div className="flex items-center gap-3"> + <span className="material-symbols-rounded text-violet-600 text-xl">language</span> + <div className="text-left"> + <p className="text-slate-900 text-sm font-medium">{currentLangLabel}</p> + <p className="text-slate-400 text-xs">{selectedLanguage}</p> + </div> + </div> + <span className="material-symbols-rounded text-slate-400 text-lg">chevron_right</span> + </button> + </div> + + {/* Privacy Section */} + <div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4"> + <h3 className="text-slate-900 text-lg font-bold">{g.privacyTitle}</h3> + <div className="flex flex-col gap-1"> + <div className="flex items-center justify-between px-4 py-3.5 rounded-xl bg-slate-50"> + <div className="flex-1"> + <p className="text-slate-900 text-sm font-medium">{g.doNotSell}</p> + <p className="text-slate-400 text-xs mt-0.5">{g.doNotSellHint}</p> + </div> + <button + onClick={() => handleToggleChange('canSell', !canSell)} + disabled={saving} + className={`w-11 h-6 rounded-full transition-colors relative shrink-0 ml-3 disabled:opacity-50 ${canSell ? 'bg-violet-600' : 'bg-slate-200'}`} + > + <div className={`w-5 h-5 rounded-full bg-white shadow-sm absolute top-0.5 transition-transform ${canSell ? 'left-5.5' : 'left-0.5'}`} /> + </button> + </div> + </div> + </div> + + {/* Notification Section */} + <div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4"> + <h3 className="text-slate-900 text-lg font-bold">{g.notificationTitle}</h3> + <div className="flex items-center justify-between px-4 py-3.5 rounded-xl bg-slate-50"> + <div className="flex items-center gap-3"> + <span className="material-symbols-rounded text-violet-600 text-xl">notifications</span> + <p className="text-slate-900 text-sm font-medium">{g.allowNotification}</p> + </div> + <button + onClick={() => handleToggleChange('allowNotifications', !allowNotifications)} + disabled={saving} + className={`w-11 h-6 rounded-full transition-colors relative disabled:opacity-50 ${allowNotifications ? 'bg-violet-600' : 'bg-slate-200'}`} + > + <div className={`w-5 h-5 rounded-full bg-white shadow-sm absolute top-0.5 transition-transform ${allowNotifications ? 'left-5.5' : 'left-0.5'}`} /> + </button> + </div> + </div> + + {/* Language Selection Modal */} + {showLanguageModal && ( + <div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-6"> + <div className="bg-white rounded-2xl w-full max-w-md p-6 flex flex-col gap-4"> + <div className="flex items-center justify-between"> + <h3 className="text-slate-900 text-lg font-bold">{locale === 'en' ? 'Select Language' : '选择语言'}</h3> + <button onClick={() => setShowLanguageModal(false)} className="text-slate-400 hover:text-slate-600"> + <span className="material-symbols-rounded text-xl">close</span> + </button> + </div> + <div className="flex flex-col gap-2"> + {languageOptions.map((lang) => ( + <button + key={lang.tag} + onClick={() => handleLanguageSelect(lang.tag)} + disabled={saving} + className={`flex items-center justify-between px-4 py-3 rounded-xl transition-colors disabled:opacity-50 ${selectedLanguage === lang.tag ? 'bg-violet-50 border border-violet-200' : 'hover:bg-slate-50'}`} + > + <div className="flex items-center gap-3"> + <span className="material-symbols-rounded text-violet-600 text-lg">language</span> + <div className="text-left"> + <p className="text-slate-900 text-sm font-medium">{lang.label}</p> + <p className="text-slate-400 text-xs">{lang.tag}</p> + </div> + </div> + {selectedLanguage === lang.tag && ( + <span className="material-symbols-rounded text-violet-600 text-lg">check</span> + )} + </button> + ))} + </div> + </div> + </div> + )} + </div> + ); +} diff --git a/web/src/components/Hero.astro b/web/src/components/Hero.astro new file mode 100644 index 0000000..5b6d32d --- /dev/null +++ b/web/src/components/Hero.astro @@ -0,0 +1,46 @@ +--- +import { t, localePath, type Locale } from '../i18n/utils'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const hero = t(locale, 'hero'); +--- + +<section class="relative w-full min-h-screen flex items-center justify-center overflow-hidden"> + <!-- Particles --> + <div class="particle-lines"></div> + <div class="particle"></div><div class="particle"></div><div class="particle"></div> + <div class="particle"></div><div class="particle"></div><div class="particle"></div> + <div class="particle"></div><div class="particle"></div> + + <!-- Purple glow --> + <div class="glow-bg absolute inset-0 pointer-events-none"></div> + + <div class="relative z-10 text-center px-6 max-w-3xl mx-auto"> + <span class="reveal stagger-1 inline-block px-4 py-1.5 rounded-full text-xs font-medium tracking-widest text-violet-600 border border-violet-200 bg-violet-50 mb-8"> + {hero.badge} + </span> + + <h1 class="reveal stagger-2 text-slate-900 text-4xl sm:text-5xl md:text-6xl font-bold leading-tight mb-6"> + {hero.headline} + </h1> + + <p class="reveal stagger-3 text-slate-500 text-lg md:text-xl leading-relaxed max-w-xl mx-auto mb-10"> + {hero.subtext} + </p> + + <div class="reveal stagger-4 flex flex-col sm:flex-row items-center justify-center gap-4"> + <a href={localePath(locale, '/login')} class="w-full sm:w-auto text-center cyber-gradient cyber-glow text-white text-sm font-medium px-8 py-3.5 rounded-lg hover:-translate-y-0.5 transition-all duration-300"> + {hero.primaryCta} + </a> + <a href={localePath(locale, '/features')} class="w-full sm:w-auto text-center text-violet-600 border border-violet-200 hover:bg-violet-50 text-sm font-medium px-8 py-3.5 rounded-lg transition-all duration-300"> + {hero.secondaryCta} + </a> + </div> + + <p class="reveal stagger-5 text-slate-400 text-sm mt-12">{hero.trust}</p> + </div> +</section> diff --git a/web/src/components/HistoryFollowUpPage.tsx b/web/src/components/HistoryFollowUpPage.tsx new file mode 100644 index 0000000..6fd04e4 --- /dev/null +++ b/web/src/components/HistoryFollowUpPage.tsx @@ -0,0 +1,395 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { + historyMessageToResultData, + enqueueFollowUpRun, + streamDivinationEvents, + type DivinationResultData, +} from '../lib/api'; +import { getHistoryThreadResource, invalidateHistory, invalidatePoints } from '../lib/resources'; +import Icon from './Icon'; + +interface Props { + locale: string; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string }; + history: { chatTitle: string; chatPlaceholder: string; sendBtn: string; followUpRules: string; followUpRule1: string; followUpRule2: string; relatedActions: string; newDivination: string; viewHistory: string; resultTitle: string }; +} + +interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +function getSignImageSrc(signType: string): string { + const normalized = signType.trim(); + if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg'; + if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg'; + if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg'; + return '/images/qigua/zhongxia.jpg'; +} + +function getQuestionTypeLabel(type: string): string { + const QUESTION_TYPE_ZH: Record<string, string> = { + career: '事业', love: '情感', wealth: '财富', fortune: '运势', + dream: '解梦', health: '健康', study: '学业', search: '寻物', other: '其他', + '事业': '事业', '情感': '情感', '财富': '财富', '运势': '运势', + '解梦': '解梦', '健康': '健康', '学业': '学业', '寻物': '寻物', '其他': '其他', + }; + return QUESTION_TYPE_ZH[type] || type; +} + +const CATEGORY_COLORS: Record<string, string> = { + 'career': 'bg-blue-50 text-blue-500', + 'love': 'bg-pink-50 text-pink-500', + 'wealth': 'bg-amber-50 text-amber-600', + 'fortune': 'bg-purple-50 text-purple-500', + 'dream': 'bg-indigo-50 text-indigo-500', + 'health': 'bg-red-50 text-red-500', + 'study': 'bg-green-50 text-green-600', + 'search': 'bg-cyan-50 text-cyan-600', + 'other': 'bg-slate-100 text-slate-500', + '事业': 'bg-blue-50 text-blue-500', + '情感': 'bg-pink-50 text-pink-500', + '财富': 'bg-amber-50 text-amber-600', + '运势': 'bg-purple-50 text-purple-500', + '解梦': 'bg-indigo-50 text-indigo-500', + '健康': 'bg-red-50 text-red-500', + '学业': 'bg-green-50 text-green-600', + '寻物': 'bg-cyan-50 text-cyan-600', + '其他': 'bg-slate-100 text-slate-500', +}; + +export default function HistoryFollowUpPage({ locale, history: h }: Props) { + const location = useLocation(); + const navigate = useNavigate(); + const { id: routeThreadId } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const threadId = routeThreadId ?? searchParams.get('threadId') ?? undefined; + + const [resultData, setResultData] = useState<DivinationResultData | null>(null); + const [messages, setMessages] = useState<ChatMessage[]>([]); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(''); + const [inputText, setInputText] = useState(''); + + const messagesEndRef = useRef<HTMLDivElement>(null); + + const scrollToBottom = useCallback(() => { + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 50); + }, []); + + // Load history messages and result data + useEffect(() => { + let alive = true; + + async function load() { + if (!threadId) { + setLoading(false); + return; + } + + try { + const snapshot = await getHistoryThreadResource(threadId); + if (!alive) return; + + // Extract result data from first assistant message + const assistantMsg = snapshot.messages.find((m) => m.role === 'assistant'); + if (assistantMsg) { + const result = historyMessageToResultData(assistantMsg); + if (result) setResultData(result); + } + + // Convert all messages to chat format (user + assistant) + const chatMessages: ChatMessage[] = snapshot.messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ + id: m.id, + role: m.role as 'user' | 'assistant', + content: m.content || '', + })); + + setMessages(chatMessages); + } catch (err) { + if (!alive) return; + setError(err instanceof Error ? err.message : 'Failed to load history'); + } finally { + if (alive) { + setLoading(false); + scrollToBottom(); + } + } + } + + // Also try to get result from router state (passed from DivinationResultPage) + const state = location.state as { result?: DivinationResultData } | null; + if (state?.result) { + setResultData(state.result); + } + + load(); + return () => { alive = false; }; + }, [threadId, location.state, scrollToBottom]); + + // Follow-up quota: original user message + max 1 follow-up + const hasFollowUpQuota = (() => { + const userCount = messages.filter((m) => m.role === 'user').length; + return userCount < 2; + })(); + + const handleSend = useCallback(async () => { + const text = inputText.trim(); + if (!text || sending || !hasFollowUpQuota || !threadId || !resultData) return; + + setInputText(''); + setSending(true); + setError(''); + + const localUserMsg: ChatMessage = { + id: `local_user_${Date.now()}`, + role: 'user', + content: text, + }; + const localAssistantMsg: ChatMessage = { + id: `local_assistant_${Date.now()}`, + role: 'assistant', + content: '', + }; + + setMessages((prev) => [...prev, localUserMsg, localAssistantMsg]); + scrollToBottom(); + + try { + const { runId } = await enqueueFollowUpRun(threadId, text, resultData); + invalidatePoints(); + + let answer = ''; + for await (const event of streamDivinationEvents(threadId, runId)) { + if (event.type === 'TEXT_MESSAGE_END') { + answer = (event.data.answer as string) || ''; + } else if (event.type === 'RUN_ERROR') { + throw new Error((event.data.detail as string) || 'Follow-up failed'); + } else if (event.type === 'RUN_FINISHED') { + break; + } + } + + // Update assistant message with answer + setMessages((prev) => + prev.map((m) => + m.id === localAssistantMsg.id ? { ...m, content: answer } : m + ) + ); + + // Reload history to get server-side message IDs + invalidateHistory(threadId); + invalidatePoints(); + const snapshot = await getHistoryThreadResource(threadId, true); + const chatMessages: ChatMessage[] = snapshot.messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ + id: m.id, + role: m.role as 'user' | 'assistant', + content: m.content || '', + })); + setMessages(chatMessages); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send follow-up'); + // Reload history to restore correct state + try { + const snapshot = await getHistoryThreadResource(threadId, true); + const chatMessages: ChatMessage[] = snapshot.messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ + id: m.id, + role: m.role as 'user' | 'assistant', + content: m.content || '', + })); + setMessages(chatMessages); + } catch { /* ignore */ } + } finally { + setSending(false); + scrollToBottom(); + } + }, [inputText, sending, hasFollowUpQuota, threadId, resultData, scrollToBottom]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleBack = () => { + navigate(-1); + }; + + const signImgSrc = resultData ? getSignImageSrc(resultData.signType) : ''; + const questionTypeLabel = resultData ? getQuestionTypeLabel(resultData.params.questionType) : ''; + const categoryColor = resultData ? (CATEGORY_COLORS[resultData.params.questionType] || 'bg-slate-100 text-slate-500') : ''; + + return ( + <div className="flex flex-col xl:flex-row gap-5 min-h-full"> + {/* Chat panel */} + <div className="flex-1 bg-white rounded-2xl border border-slate-200 flex flex-col overflow-hidden"> + {/* Header */} + <div className="flex items-center justify-between px-5 h-[56px] border-b border-slate-200 shrink-0"> + <div className="flex items-center gap-3"> + <button + onClick={handleBack} + className="w-9 h-9 rounded-full bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors" + aria-label={locale === 'en' ? 'Back to home' : '返回首页'} + > + <Icon name="arrow_back" className="w-[18px] h-[18px] text-slate-500" /> + </button> + <h3 className="text-slate-900 text-base font-bold">{h.chatTitle}</h3> + </div> + {resultData && ( + <span className="text-slate-400 text-sm">{resultData.guaName}</span> + )} + </div> + + {/* Messages */} + <div className="flex-1 flex flex-col gap-3 p-5 overflow-y-auto"> + {loading ? ( + <div className="flex-1 flex items-center justify-center"> + <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-violet-600" /> + </div> + ) : messages.length === 0 ? ( + <div className="flex-1 flex items-center justify-center"> + <p className="text-slate-400 text-sm"> + {locale === 'en' ? 'No messages yet' : '暂无对话消息'} + </p> + </div> + ) : ( + messages.map((msg) => ( + <div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}> + <div className={`max-w-[80%] px-4 py-3 rounded-2xl text-sm leading-relaxed ${ + msg.role === 'user' + ? 'bg-violet-600 text-white' + : 'bg-slate-100 text-slate-700 border border-slate-200' + }`}> + {msg.content || ( + <span className="inline-flex gap-1"> + <span className="animate-bounce" style={{ animationDelay: '0ms' }}>.</span> + <span className="animate-bounce" style={{ animationDelay: '150ms' }}>.</span> + <span className="animate-bounce" style={{ animationDelay: '300ms' }}>.</span> + </span> + )} + </div> + </div> + )) + )} + <div ref={messagesEndRef} /> + </div> + + {/* Error */} + {error && ( + <div className="px-5 py-2"> + <p className="text-red-500 text-xs">{error}</p> + </div> + )} + + {/* Composer */} + <div className="px-[22px] py-[18px] border-t border-slate-200 shrink-0"> + <div className="rounded-[18px] bg-slate-50 border border-slate-300 shadow-[0_6px_16px_rgba(0,0,0,0.04)] flex flex-col gap-3 p-[14px_16px]"> + <textarea + value={inputText} + onChange={(e) => setInputText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={hasFollowUpQuota + ? (locale === 'en' ? 'Ask a follow-up, e.g.: When should I proceed?' : '继续追问这次解卦,例如:我应该什么时候推进?') + : (locale === 'en' ? 'Follow-up quota used' : '追问次数已用完')} + rows={2} + disabled={!hasFollowUpQuota || sending} + className="w-full bg-transparent text-sm leading-[1.45] focus:outline-none resize-none disabled:cursor-not-allowed placeholder:text-slate-500" + /> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <span className={`rounded-full px-2 py-1 text-xs font-bold ${ + hasFollowUpQuota ? 'bg-violet-50 text-violet-600' : 'bg-red-50 text-red-500' + }`}> + {hasFollowUpQuota + ? (locale === 'en' ? '1 left' : '剩余 1 次') + : (locale === 'en' ? '0 left' : '剩余 0 次')} + </span> + <span className="text-slate-400 text-xs"> + {locale === 'en' ? 'Enter to send, Shift+Enter for new line' : 'Enter 发送,Shift + Enter 换行'} + </span> + </div> + <button + onClick={handleSend} + disabled={!inputText.trim() || sending || !hasFollowUpQuota} + className="w-[38px] h-[38px] rounded-full bg-violet-600 flex items-center justify-center hover:bg-violet-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0" + > + {sending ? ( + <div className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" /> + ) : ( + <Icon name="arrow_upward" className="w-5 h-5 text-white" /> + )} + </button> + </div> + </div> + </div> + </div> + + {/* Right side column */} + <div className="w-full xl:w-[340px] flex flex-col gap-3.5 shrink-0"> + {/* Result summary */} + {resultData && ( + <div className="bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-3"> + <h4 className="text-slate-900 text-sm font-bold">{h.resultTitle}</h4> + <div className="flex items-center gap-3"> + <div className="w-14 h-14 rounded-lg overflow-hidden shrink-0"> + <img src={signImgSrc} alt="" className="w-full h-full object-cover" /> + </div> + <div className="flex-1 min-w-0 flex flex-col gap-1"> + <p className="text-slate-700 text-sm font-semibold truncate">{resultData.params.question}</p> + <div className="flex items-center gap-1.5"> + <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${categoryColor}`}> + {questionTypeLabel} + </span> + <span className="px-2 py-0.5 rounded-md bg-violet-50 text-violet-600 text-xs font-medium"> + {resultData.guaName} + </span> + </div> + </div> + </div> + {resultData.conclusion && ( + <p className="text-slate-500 text-xs leading-relaxed line-clamp-3">{resultData.conclusion}</p> + )} + </div> + )} + + {/* Follow-up rules */} + <div className="bg-amber-50 rounded-2xl p-4 border border-amber-200 flex flex-col gap-2"> + <h4 className="text-slate-900 text-sm font-bold">{h.followUpRules}</h4> + <p className="text-amber-700 text-sm">{h.followUpRule1}</p> + <p className="text-amber-700 text-sm">{h.followUpRule2}</p> + </div> + + {/* Related actions */} + <div className="bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-2.5"> + <h4 className="text-slate-900 text-sm font-bold">{h.relatedActions}</h4> + <button + onClick={() => threadId && navigate(`/${locale}/history/result?threadId=${encodeURIComponent(threadId)}`)} + className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1" + > + <Icon name="auto_awesome" className="w-4 h-4" /> + {locale === 'en' ? 'View Full Reading' : '查看完整解卦'} + </button> + <button + onClick={() => navigate(`/${locale}/history`)} + className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1" + > + <Icon name="history" className="w-4 h-4" /> + {h.viewHistory} + </button> + </div> + </div> + </div> + ); +} diff --git a/web/src/components/HistoryListPage.tsx b/web/src/components/HistoryListPage.tsx new file mode 100644 index 0000000..e865cd1 --- /dev/null +++ b/web/src/components/HistoryListPage.tsx @@ -0,0 +1,276 @@ +import { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { mapHistoryMessagesToItems, type HistoryItem } from '../lib/api'; +import { primeHistoryThreadFromSnapshot, useHistoryList } from '../lib/resources'; +import Icon from './Icon'; + +interface Props { + locale: string; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string }; + history: { title: string; statTotal: string; statLatest: string; filters: string; filterAll: string; filterCareer: string; filterLove: string; filterWealth: string; noResults: string }; +} + +const ALL_CATEGORIES = ['career', 'love', 'wealth', 'fortune', 'dream', 'health', 'study', 'search', 'other'] as const; +type CategoryKey = typeof ALL_CATEGORIES[number]; + +const CATEGORY_COLORS: Record<string, string> = { + 'career': 'bg-blue-50 text-blue-500', + 'love': 'bg-pink-50 text-pink-500', + 'wealth': 'bg-amber-50 text-amber-600', + 'fortune': 'bg-purple-50 text-purple-500', + 'dream': 'bg-indigo-50 text-indigo-500', + 'health': 'bg-red-50 text-red-500', + 'study': 'bg-green-50 text-green-600', + 'search': 'bg-cyan-50 text-cyan-600', + 'other': 'bg-slate-100 text-slate-500', +}; + +const CATEGORY_LABELS_ZH: Record<string, string> = { + 'career': '事业', + 'love': '感情', + 'wealth': '财富', + 'fortune': '运势', + 'dream': '解梦', + 'health': '健康', + 'study': '学业', + 'search': '寻物', + 'other': '其他', +}; + +const CATEGORY_LABELS_EN: Record<string, string> = { + 'career': 'Career', + 'love': 'Love', + 'wealth': 'Wealth', + 'fortune': 'Fortune', + 'dream': 'Dream', + 'health': 'Health', + 'study': 'Study', + 'search': 'Search', + 'other': 'Other', +}; + +const RATING_COLORS: Record<string, string> = { + '上上签': 'bg-amber-50 text-amber-500', + '上签': 'bg-amber-50 text-amber-500', + '中上签': 'bg-violet-50 text-violet-600', + '中签': 'bg-slate-100 text-slate-500', + '下签': 'bg-red-50 text-red-500', +}; + +export default function HistoryListPage({ locale, history: i18n }: Props) { + const navigate = useNavigate(); + const historyState = useHistoryList(); + const [selectedId, setSelectedId] = useState<string | null>(null); + const [activeFilter, setActiveFilter] = useState<CategoryKey | 'all'>('all'); + const [searchQuery, setSearchQuery] = useState(''); + const allItems = useMemo(() => historyState.data ? mapHistoryMessagesToItems(historyState.data.messages) : [], [historyState.data]); + const loading = historyState.loading; + const error = historyState.error instanceof Error ? historyState.error.message : historyState.error ? 'Failed to load history' : ''; + + const stats = useMemo(() => { + const total = allItems.length; + const latest = allItems.length > 0 ? allItems[0].created_at : null; + return { total, latest }; + }, [allItems]); + + // 过滤后的列表 + const filteredItems = useMemo(() => { + let items = allItems; + + // 分类筛选 + if (activeFilter !== 'all') { + items = items.filter((item) => item.category === activeFilter); + } + + // 搜索筛选 + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + items = items.filter( + (item) => + item.question.toLowerCase().includes(query) || + item.hexagram_name.toLowerCase().includes(query) + ); + } + + return items; + }, [allItems, activeFilter, searchQuery]); + + // 各分类数量 + const filterCounts = useMemo(() => { + const counts: Record<string, number> = { all: allItems.length }; + for (const cat of ALL_CATEGORIES) { + counts[cat] = allItems.filter((item) => item.category === cat).length; + } + return counts; + }, [allItems]); + + // 格式化最近时间 + const formatLatest = (dateStr: string | null) => { + if (!dateStr) return '--'; + const date = new Date(dateStr); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const timeStr = date.toLocaleTimeString(locale === 'en' ? 'en-US' : 'zh-CN', { + hour: '2-digit', + minute: '2-digit', + }); + if (isToday) { + return locale === 'en' ? `Today ${timeStr}` : `今天 ${timeStr}`; + } + return date.toLocaleDateString(locale === 'en' ? 'en-US' : 'zh-CN', { + month: 'short', + day: 'numeric', + }); + }; + + // 点击卡片跳转 + const handleItemClick = (item: HistoryItem) => { + setSelectedId(item.id); + if (historyState.data) primeHistoryThreadFromSnapshot(item.threadId, historyState.data); + navigate(`/${locale}/history/result?threadId=${encodeURIComponent(item.threadId)}`); + }; + + // 返回首页 + const handleBackHome = () => { + navigate(`/${locale}/dashboard`); + }; + + return ( + <div className="flex flex-col gap-5 min-h-full"> + {/* Header */} + <div className="flex items-center justify-between gap-4"> + <div className="flex items-center gap-3"> + <button + onClick={handleBackHome} + className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors" + aria-label={locale === 'en' ? 'Back to home' : '返回首页'} + > + <Icon name="arrow_back" className="w-5 h-5 text-slate-600" /> + </button> + <div> + <h1 className="text-slate-900 text-xl font-semibold">{i18n.title}</h1> + </div> + </div> + <div className="flex items-center gap-2"> + {/* Search */} + <div className="relative"> + <Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> + <input + type="text" + placeholder={locale === 'en' ? 'Search question or hexagram' : '搜索问题或卦名'} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="w-60 pl-9 pr-4 py-2 rounded-full bg-white border border-slate-200 text-sm focus:outline-none focus:border-violet-400 transition-colors" + /> + </div> + </div> + </div> + + {/* Error */} + {error && ( + <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600"> + {error} + </div> + )} + + {/* Stats */} + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> + <div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-1"> + <p className="text-slate-500 text-xs">{i18n.statTotal}</p> + <p className="text-slate-900 text-2xl font-bold">{stats.total}</p> + </div> + <div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-1"> + <p className="text-slate-500 text-xs">{i18n.statLatest}</p> + <p className="text-slate-900 text-lg font-bold">{formatLatest(stats.latest)}</p> + </div> + </div> + + {/* Main Content: List + Filters */} + <div className="flex flex-col lg:flex-row gap-5 flex-1 min-h-0"> + {/* List */} + <div className="flex-1 bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-3 overflow-y-auto"> + <div className="flex items-center justify-between"> + <h3 className="text-slate-900 text-sm font-bold">{i18n.title}</h3> + <span className="text-slate-400 text-xs">{filteredItems.length} {locale === 'en' ? 'items' : '条'}</span> + </div> + + {loading ? ( + <div className="text-slate-400 text-sm py-8 text-center">{locale === 'en' ? 'Loading...' : '加载中...'}</div> + ) : filteredItems.length === 0 ? ( + <div className="text-slate-400 text-sm py-8 text-center">{i18n.noResults}</div> + ) : ( + <div className="flex flex-col gap-2"> + {filteredItems.map((item) => ( + <button + key={item.id} + onClick={() => handleItemClick(item)} + className={`flex items-center gap-3 rounded-xl p-4 text-left transition-all cursor-pointer border ${ + selectedId === item.id + ? 'bg-violet-50 border-violet-400' + : 'bg-white border-slate-200 hover:bg-slate-50' + }`} + > + <div className="w-11 h-11 rounded-[10px] bg-blue-50 flex items-center justify-center shrink-0"> + <Icon name="hexagram" className="w-5 h-5 text-blue-400" /> + </div> + <div className="flex-1 min-w-0"> + <p className="text-slate-900 text-sm font-semibold truncate">{item.question}</p> + <div className="flex items-center gap-2 mt-1"> + {item.category && ( + <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-slate-100 text-slate-500'}`}> + {locale === 'en' ? (CATEGORY_LABELS_EN[item.category] || item.category) : (CATEGORY_LABELS_ZH[item.category] || item.category)} + </span> + )} + {item.hexagram_name && ( + <span className="px-2 py-0.5 rounded-md bg-violet-50 text-violet-600 text-xs font-medium">{item.hexagram_name}</span> + )} + {item.rating && ( + <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${RATING_COLORS[item.rating] || 'bg-slate-100 text-slate-500'}`}> + {item.rating} + </span> + )} + </div> + </div> + <div className="flex flex-col items-end gap-1 shrink-0 w-24"> + <span className="text-slate-400 text-xs">{item.created_at?.slice(0, 10) || ''}</span> + <span className="text-violet-500"> + <Icon name="chevron_right" className="w-5 h-5" /> + </span> + </div> + </button> + ))} + </div> + )} + </div> + + {/* Side: Quick Filters */} + <div className="w-full lg:w-[280px] flex flex-col gap-4 shrink-0"> + <div className="bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-2"> + <h3 className="text-slate-900 text-sm font-bold">{i18n.filters}</h3> + <button + onClick={() => setActiveFilter('all')} + className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${ + activeFilter === 'all' ? 'bg-slate-100 text-violet-600 font-semibold' : 'text-slate-500 hover:bg-slate-50' + }`} + > + <span>{i18n.filterAll}</span> + <span className={activeFilter === 'all' ? 'text-violet-500' : 'text-slate-400'}>{filterCounts.all}</span> + </button> + {ALL_CATEGORIES.map((cat) => ( + <button + key={cat} + onClick={() => setActiveFilter(cat)} + className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${ + activeFilter === cat ? 'bg-slate-100 text-violet-600 font-semibold' : 'text-slate-500 hover:bg-slate-50' + }`} + > + <span>{locale === 'en' ? (CATEGORY_LABELS_EN[cat] || cat) : (CATEGORY_LABELS_ZH[cat] || cat)}</span> + <span className={activeFilter === cat ? 'text-violet-500' : 'text-slate-400'}>{filterCounts[cat] || 0}</span> + </button> + ))} + </div> + </div> + </div> + </div> + ); +} diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx new file mode 100644 index 0000000..cb39166 --- /dev/null +++ b/web/src/components/Icon.tsx @@ -0,0 +1,81 @@ +interface IconProps { + name: string; + className?: string; +} + +const PATHS: Record<string, string[]> = { + home: [ + 'M3 10.5 12 3l9 7.5', + 'M5 9.5V21h14V9.5', + 'M9 21v-6h6v6', + ], + shopping_bag: [ + 'M6 7h12l1 14H5L6 7Z', + 'M9 7a3 3 0 0 1 6 0', + ], + casino: [ + 'M7 4h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3Z', + 'M8.5 8.5h.01M15.5 8.5h.01M12 12h.01M8.5 15.5h.01M15.5 15.5h.01', + ], + history: [ + 'M3 12a9 9 0 1 0 3-6.7', + 'M3 4v5h5', + 'M12 7v5l3 2', + ], + language: [ + 'M4 5h9', + 'M9 3v2', + 'M5 9c1.2 3.2 3.6 5.5 7 7', + 'M12 5c-.8 5-3.3 8.6-8 11', + 'M14 21l4-10 4 10', + 'M15.5 17h5', + ], + settings: [ + 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z', + 'M12 2v3M12 19v3M4.93 4.93l2.12 2.12M16.95 16.95l2.12 2.12M2 12h3M19 12h3M4.93 19.07l2.12-2.12M16.95 7.05l2.12-2.12', + ], + notifications: [ + 'M18 8a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9', + 'M10 21h4', + ], + chevron_right: ['M9 18l6-6-6-6'], + chevron_left: ['M15 18l-6-6 6-6'], + chevron_down: ['M6 9l6 6 6-6'], + warning: ['M12 2L1 21h22L12 2Z', 'M12 9v4', 'M12 17h.01'], + menu: ['M4 6h16M4 12h16M4 18h16'], + close: ['M18 6L6 18M6 6l12 12'], + calendar_today: ['M7 3v4M17 3v4M4 9h16M5 5h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z'], + paid: [ + 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z', + 'M9.5 14.5c.6.8 1.5 1.2 2.7 1.2 1.5 0 2.4-.7 2.4-1.8 0-1.2-1-1.6-2.7-2.1-1.5-.4-2.7-.9-2.7-2.4 0-1.2 1-2.1 2.8-2.1 1.1 0 2 .3 2.6 1', + 'M12 6v12', + ], + arrow_back: ['M19 12H5M12 19l-7-7 7-7'], + content_copy: ['M7 9.66V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-4.66'], + ios_share: ['M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9', 'M14 13l-4-4-4 4'], + auto_awesome: ['M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9Z', 'M11.5 15l-1.5-3-1.5 3L6 16.5 8.5 18 10 21l1.5-3 3-1.5-3-1.5Z'], + search: ['M21 21l-5.2-5.2', 'M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z'], + arrow_upward: ['M12 4v16M5 11l7-7 7 7'], + // 六爻卦象图标 - 三条线表示(上爻、中爻、初爻的抽象) + hexagram: [ + 'M4 4h16', + 'M4 10h16', + 'M4 16h7', + 'M13 16h7', + ], + help: [ + 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z', + 'M9 9a3 3 0 1 1 4 2.83V13', + 'M12 17h.01', + ], +}; + +export default function Icon({ name, className = 'w-5 h-5' }: IconProps) { + const paths = PATHS[name] ?? PATHS.home; + + return ( + <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> + {paths.map((d) => <path key={d} d={d} />)} + </svg> + ); +} diff --git a/web/src/components/LegalPage.astro b/web/src/components/LegalPage.astro new file mode 100644 index 0000000..9a6ecd7 --- /dev/null +++ b/web/src/components/LegalPage.astro @@ -0,0 +1,154 @@ +--- +import { t, localePath, type Locale } from '../i18n/utils'; +import fs from 'node:fs'; +import path from 'node:path'; +import { marked } from 'marked'; + +interface Props { + locale: Locale; + docType: 'privacy_policy' | 'terms_of_service' | 'about_us'; +} + +const { locale, docType } = Astro.props; + +const footer = t(locale, 'footer'); + +const titleMap: Record<string, Record<Locale, string>> = { + privacy_policy: { zh: '隐私政策', zh_Hant: '隱私政策', en: 'Privacy Policy' }, + terms_of_service: { zh: '服务条款', zh_Hant: '服務條款', en: 'Terms of Service' }, +}; +const subtitleMap: Record<string, Record<Locale, string>> = { + privacy_policy: { zh: '最后更新日期:2026年4月27日', zh_Hant: '最後更新日期:2026年4月27日', en: 'Last updated: April 27, 2026' }, + terms_of_service: { zh: '最后更新日期:2026年4月27日', zh_Hant: '最後更新日期:2026年4月27日', en: 'Last updated: April 27, 2026' }, +}; +const warningMap: Record<Locale, { title: string; body: string }> = { + zh: { + title: '特别提醒', + body: '本 App 所有 AI 生成内容与文化解读资料,仅作娱乐、文化赏析与个人参考使用。本应用不提供任何专业指导建议,包括但不限于商业、金融、投资、医疗、心理、法律、职业及人生决策等领域。所有生成内容不得作为事实依据或行动决策的唯一标准。开发者不对用户的个人选择、行为及衍生后果承担任何法律责任。请理性看待传统文化,理性使用本应用。', + }, + zh_Hant: { + title: '特別提醒', + body: '本 App 所有 AI 生成內容與文化解讀資料,僅作娛樂、文化賞析與個人參考使用。本應用不提供任何專業指導建議,包括但不限於商業、金融、投資、醫療、心理、法律、職業及人生決策等領域。所有生成內容不得作為事實依據或行動決策的唯一標準。開發者不對用戶的個人選擇、行為及衍生後果承擔任何法律責任。請理性看待傳統文化,理性使用本應用。', + }, + en: { + title: 'Important Disclaimer', + body: 'All AI-generated content and cultural interpretation materials in this App are for entertainment, cultural appreciation, and personal reference only. This app does not provide any professional guidance or advice, including but not limited to business, finance, investment, medical, psychological, legal, career, and life decisions. All generated content should not be used as factual evidence or the sole basis for decision-making. The developer assumes no legal responsibility for users\' personal choices, actions, and consequences. Please approach traditional culture rationally and use this app wisely.', + }, +}; +const legalTitleMap: Record<Locale, string> = { + zh: '法律条款', + zh_Hant: '法律條款', + en: 'Legal', +}; + +const title = titleMap[docType]?.[locale] ?? ''; +const subtitle = subtitleMap[docType]?.[locale] ?? ''; +const warning = warningMap[locale]; +const legalTitle = legalTitleMap[locale]; + +// Try multiple paths for dev and production environments +const possiblePaths = [ + path.resolve('public/legal', locale, `${docType}.md`), + path.resolve('client/legal', locale, `${docType}.md`), + path.resolve('../client/legal', locale, `${docType}.md`), +]; +let raw = ''; +for (const filePath of possiblePaths) { + try { + raw = fs.readFileSync(filePath, 'utf-8'); + raw = raw.replace(/^#\s+.+\n*/m, ''); + break; + } catch { + // Try next path + } +} +if (!raw) { + raw = `Content not available.`; +} +const content = await marked(raw); + +const sideInfo: Record<string, Record<Locale, { type: string; date: string; law?: string; email: string }>> = { + privacy_policy: { + zh: { type: '隐私政策', date: '2026年4月27日', email: 'feedback@xunmee.com' }, + zh_Hant: { type: '隱私政策', date: '2026年4月27日', email: 'feedback@xunmee.com' }, + en: { type: 'Privacy Policy', date: 'April 27, 2026', email: 'feedback@xunmee.com' }, + }, + terms_of_service: { + zh: { type: '服务条款', date: '2026年4月27日', law: '美国加利福尼亚州法律', email: 'feedback@xunmee.com' }, + zh_Hant: { type: '服務條款', date: '2026年4月27日', law: '美國加利福尼亞州法律', email: 'feedback@xunmee.com' }, + en: { type: 'Terms of Service', date: 'April 27, 2026', law: 'California, USA', email: 'feedback@xunmee.com' }, + }, +}; +const info = sideInfo[docType]?.[locale]; + +const isActive = (linkType: string) => linkType === docType; +--- + +<!-- Header --> +<section class="w-full bg-gradient-to-b from-white to-violet-50 py-16 md:py-20 px-6 md:px-20 text-center"> + <h1 class="reveal text-slate-900 text-4xl md:text-[48px] font-extrabold">{title}</h1> + <p class="reveal stagger-1 text-slate-500 text-lg mt-4">{subtitle}</p> +</section> + +<!-- Content: Left article + Right info card --> +<section class="w-full py-16 md:py-20 px-6 md:px-20 bg-white"> + <div class="max-w-6xl mx-auto flex flex-col md:flex-row gap-16"> + <!-- Article --> + <div class="reveal flex-1 prose prose-slate max-w-none + [&_h2]:text-[28px] [&_h2]:font-bold [&_h2]:text-slate-900 [&_h2]:mt-8 [&_h2]:mb-2 + [&_h3]:text-2xl [&_h3]:font-bold [&_h3]:text-slate-900 [&_h3]:mt-6 [&_h3]:mb-2 + [&_p]:text-slate-500 [&_p]:text-base [&_p]:leading-loose + [&_strong]:text-slate-700 [&_ul]:list-disc [&_ul]:pl-6 [&_li]:text-slate-500 [&_li]:leading-loose + [&_a]:text-violet-600 [&_hr]:border-slate-200 [&_hr]:my-6"> + <Fragment set:html={content} /> + </div> + + <!-- Side card --> + {info && ( + <div class="reveal-right w-full md:w-[320px] bg-slate-50 border border-slate-200 rounded-2xl p-8 flex flex-col gap-6 shrink-0 self-start sticky top-28"> + <h3 class="text-slate-900 text-xl font-bold"> + {locale === 'en' ? 'Document Info' : '文档信息'} + </h3> + <div class="h-px bg-slate-200"></div> + + <div> + <p class="text-slate-400 text-sm font-semibold">{locale === 'en' ? 'Type' : '文档类型'}</p> + <p class="text-slate-600 text-[15px]">{info.type}</p> + </div> + <div> + <p class="text-slate-400 text-sm font-semibold">{locale === 'en' ? 'Last Updated' : '最后更新'}</p> + <p class="text-slate-600 text-[15px]">{info.date}</p> + </div> + {info.law && ( + <div> + <p class="text-slate-400 text-sm font-semibold">{locale === 'en' ? 'Governing Law' : '适用法律'}</p> + <p class="text-slate-600 text-[15px]">{info.law}</p> + </div> + )} + <div> + <p class="text-slate-400 text-sm font-semibold">{locale === 'en' ? 'Contact' : '联系邮箱'}</p> + <p class="text-slate-600 text-[15px]">{info.email}</p> + </div> + </div> + )} + </div> +</section> + +<!-- Warning --> +<section class="w-full bg-amber-50 py-20 px-6 md:px-20"> + <div class="reveal max-w-[800px] mx-auto text-center flex flex-col gap-5"> + <h3 class="text-amber-600 text-2xl font-bold">{warning.title}</h3> + <p class="text-amber-700 text-[15px] leading-loose">{warning.body}</p> + </div> +</section> + +<!-- Legal Links --> +<section class="w-full bg-slate-50 py-12 px-6 md:px-20"> + <div class="reveal max-w-[600px] mx-auto text-center flex flex-col gap-5"> + <h3 class="text-slate-900 text-xl font-bold">{legalTitle}</h3> + <div class="flex justify-center gap-8"> + <a href={localePath(locale, '/privacy')} class={`text-[15px] hover:underline ${isActive('privacy_policy') ? 'text-violet-600 font-semibold' : 'text-slate-500'}`}>{footer.col3Link1}</a> + <a href={localePath(locale, '/terms')} class={`text-[15px] hover:underline ${isActive('terms_of_service') ? 'text-violet-600 font-semibold' : 'text-slate-500'}`}>{footer.col3Link2}</a> + </div> + </div> +</section> diff --git a/web/src/components/LoginForm.tsx b/web/src/components/LoginForm.tsx new file mode 100644 index 0000000..c668184 --- /dev/null +++ b/web/src/components/LoginForm.tsx @@ -0,0 +1,220 @@ +import { useState, useCallback, useEffect } from 'react'; +import { sendOtp, loginWithEmail, getAuth, refreshAccessToken, ApiError, localeToBackendLanguage, backendLanguageToLocale } from '../lib/auth'; +import { getProfileResource } from '../lib/resources'; + +interface LoginFormProps { + locale: string; + translations: { + welcome: string; + subtitle: string; + emailLabel: string; + emailPlaceholder: string; + codeLabel: string; + codePlaceholder: string; + sendCode: string; + submit: string; + agreePrefix: string; + privacy: string; + agreeAnd: string; + terms: string; + }; + privacyUrl: string; + termsUrl: string; +} + +const ERROR_MESSAGES: Record<string, Record<string, string>> = { + AUTH_TOO_MANY_REQUESTS: { zh: '请求过于频繁,请稍后再试', zh_Hant: '請求過於頻繁,請稍後再試', en: 'Too many requests, please try again later' }, + AUTH_VERIFICATION_CODE_INVALID: { zh: '验证码错误', zh_Hant: '驗證碼錯誤', en: 'Invalid verification code' }, + AUTH_USER_NOT_FOUND: { zh: '用户不存在', zh_Hant: '用戶不存在', en: 'User not found' }, + AUTH_SERVICE_UNAVAILABLE: { zh: '服务暂时不可用', zh_Hant: '服務暫時不可用', en: 'Service temporarily unavailable' }, +}; + +function getErrorMessage(err: unknown, locale: string): string { + if (err instanceof ApiError && err.code) { + const msgs = ERROR_MESSAGES[err.code]; + if (msgs && msgs[locale]) return msgs[locale]; + } + return locale === 'en' ? 'An error occurred, please try again' : '操作失败,请重试'; +} + +export default function LoginForm({ locale, translations: i18n, privacyUrl, termsUrl }: LoginFormProps) { + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [agreed, setAgreed] = useState(false); + const [sending, setSending] = useState(false); + const [countdown, setCountdown] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [checking, setChecking] = useState(true); + + // Check existing auth on mount + useEffect(() => { + const checkAuth = async () => { + const auth = getAuth(); + if (!auth) { + setChecking(false); + return; + } + try { + await refreshAccessToken(); + // Token valid, get profile language and redirect + const profile = await getProfileResource(); + const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN'); + window.location.href = `/${userLocale}/dashboard`; + } catch { + // Token invalid, clear auth and stay on login page + setChecking(false); + } + }; + checkAuth(); + }, [locale]); + + const handleSendCode = useCallback(async () => { + if (!email || countdown > 0) return; + setError(''); + setSending(true); + try { + await sendOtp(email); + setCountdown(60); + const timer = setInterval(() => { + setCountdown((c) => { + if (c <= 1) { clearInterval(timer); return 0; } + return c - 1; + }); + }, 1000); + } catch (err) { + setError(getErrorMessage(err, locale)); + } finally { + setSending(false); + } + }, [email, countdown, locale]); + + const handleSubmit = useCallback(async () => { + if (!email || !code || !agreed) return; + setError(''); + setLoading(true); + try { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const language = localeToBackendLanguage(locale); + await loginWithEmail(email, code, language, timezone); + // Fresh login just sent this language to the backend. Avoid fetching profile + // before a full-page navigation that would lose the in-memory resource cache. + const userLocale = backendLanguageToLocale(language); + window.location.href = `/${userLocale}/dashboard`; + } catch (err) { + setError(getErrorMessage(err, locale)); + } finally { + setLoading(false); + } + }, [email, code, agreed, locale]); + + // Show loading while checking existing auth + if (checking) { + return ( + <div className="min-h-screen flex items-center justify-center" style={{ background: 'linear-gradient(180deg, #F5F0FF 0%, #FFFFFF 100%)' }}> + <div className="h-10 w-10 rounded-full border-2 border-violet-200 border-t-violet-600 animate-spin" /> + </div> + ); + } + + return ( + <div className="relative min-h-screen flex items-center justify-center px-4 py-8 overflow-hidden" + style={{ background: 'linear-gradient(180deg, #F5F0FF 0%, #FFFFFF 100%)' }}> + + {/* Decorative blobs */} + <div className="absolute -top-16 -left-20 w-[300px] h-[300px] rounded-full opacity-30 pointer-events-none" + style={{ background: 'linear-gradient(135deg, #E8D5FF, #D5E8FF)' }} /> + <div className="absolute bottom-0 right-0 w-[200px] h-[200px] rounded-full opacity-20 pointer-events-none" + style={{ background: 'linear-gradient(45deg, #C8E6FF, #E8D5FF)' }} /> + <div className="absolute top-0 left-1/2 -translate-x-1/2 w-[120px] h-[120px] rounded-full opacity-[0.06] pointer-events-none" + style={{ background: 'linear-gradient(0deg, #673AB7, #9C27B0)' }} /> + + <div className="relative w-full max-w-[420px] bg-white rounded-2xl shadow-lg p-5 sm:p-8 flex flex-col gap-5" + style={{ boxShadow: '0 4px 24px #0000000D' }}> + + {/* Header */} + <div className="flex flex-col items-center gap-2"> + <div className="w-14 h-14 rounded-[14px] overflow-hidden"> + <img src="/images/logo.png" alt="MeiYao" className="w-full h-full object-contain" /> + </div> + <h1 className="text-slate-900 text-2xl font-bold">{i18n.welcome}</h1> + <p className="text-slate-500 text-sm">{i18n.subtitle}</p> + </div> + + {error && ( + <div className="text-red-500 text-sm text-center">{error}</div> + )} + + {/* Email */} + <div className="flex flex-col gap-1.5"> + <label htmlFor="login-email" className="text-slate-700 text-[13px] font-medium">{i18n.emailLabel}</label> + <input + id="login-email" + name="email" + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder={i18n.emailPlaceholder} + className="w-full h-11 px-3.5 rounded-lg bg-[#F8F8F8] border border-slate-200 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 transition-colors" + /> + </div> + + {/* Code */} + <div className="flex flex-col gap-1.5"> + <label htmlFor="login-code" className="text-slate-700 text-[13px] font-medium">{i18n.codeLabel}</label> + <div className="flex gap-2"> + <input + id="login-code" + name="code" + type="text" + value={code} + onChange={(e) => setCode(e.target.value)} + placeholder={i18n.codePlaceholder} + maxLength={6} + className="min-w-0 flex-1 h-11 px-3.5 rounded-lg bg-[#F8F8F8] border border-slate-200 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 transition-colors" + /> + <button + type="button" + onClick={handleSendCode} + disabled={!email || countdown > 0 || sending} + className="h-11 w-[120px] rounded-lg bg-violet-600 text-white text-[13px] font-medium whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed hover:bg-violet-700 transition-colors shrink-0" + > + {countdown > 0 ? `${countdown}s` : sending ? '...' : i18n.sendCode} + </button> + </div> + </div> + + {/* Submit */} + <button + type="button" + onClick={handleSubmit} + disabled={!email || !code || !agreed || loading} + className="w-full h-11 rounded-lg bg-violet-600 text-white text-[15px] font-semibold disabled:opacity-50 disabled:cursor-not-allowed hover:bg-violet-700 transition-colors" + > + {loading ? '...' : i18n.submit} + </button> + + {/* Agreement */} + <div className="flex items-center gap-2"> + <button + type="button" + onClick={() => setAgreed(!agreed)} + className={`w-4 h-4 rounded border-[1.5px] flex items-center justify-center shrink-0 transition-colors ${agreed ? 'bg-violet-600 border-violet-600' : 'bg-white border-violet-400'}`} + > + {agreed && ( + <svg className="w-3 h-3 text-white" viewBox="0 0 12 12" fill="none"> + <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + )} + </button> + <p className="text-slate-500 text-xs"> + {i18n.agreePrefix} + <a href={privacyUrl} className="text-violet-600 hover:underline">{i18n.privacy}</a> + {i18n.agreeAnd} + <a href={termsUrl} className="text-violet-600 hover:underline">{i18n.terms}</a> + </p> + </div> + </div> + </div> + ); +} diff --git a/web/src/components/ManualDivinationPage.tsx b/web/src/components/ManualDivinationPage.tsx new file mode 100644 index 0000000..b54e571 --- /dev/null +++ b/web/src/components/ManualDivinationPage.tsx @@ -0,0 +1,786 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Icon from './Icon'; +import DivinationProcessingOverlay from './DivinationProcessingOverlay'; +import type { DivinationResultData } from '../lib/api'; +import { updateSettingsResource, usePoints } from '../lib/resources'; +import { useUserSettings } from './AppShell'; + +interface Props { + locale: string; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string }; + divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideManual: string; yaoTitle: string; coinLabel: string; confirmBtn: string; summaryTitle: string; checkCategory: string; checkMethod: string; checkCost: string; submitBtn: string; progressLabel: string }; +} + +type CoinFace = 'zi' | 'hua'; +type YaoType = 'youngYang' | 'youngYin' | 'oldYang' | 'oldYin'; + +const TOTAL_YAO_COUNT = 6; + +function formatDateTimeInput(value: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`; +} + +function fromHuaCount(huaCount: number): YaoType { + switch (huaCount) { + case 0: + return 'oldYin'; + case 1: + return 'youngYang'; + case 2: + return 'youngYin'; + case 3: + return 'oldYang'; + default: + throw new RangeError('huaCount must be 0..3'); + } +} + +function fromCoins(coins: CoinFace[]): YaoType { + return fromHuaCount(coins.filter((coin) => coin === 'hua').length); +} + +// Get a default coin combination for a given YaoType +// (multiple combinations can map to the same YaoType, we pick one) +function coinsForYaoType(type: YaoType): [CoinFace, CoinFace, CoinFace] { + switch (type) { + case 'oldYin': // 0 hua + return ['zi', 'zi', 'zi']; + case 'youngYang': // 1 hua + return ['hua', 'zi', 'zi']; + case 'youngYin': // 2 hua + return ['hua', 'hua', 'zi']; + case 'oldYang': // 3 hua + return ['hua', 'hua', 'hua']; + } +} + +function CoinImage({ face }: { face: CoinFace }) { + return ( + <img + src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'} + alt={face === 'zi' ? '字' : '花'} + className={`h-20 w-20 rounded-full object-cover shadow-md coin-flip ${face === 'hua' ? 'flipped' : ''}`} + draggable={false} + /> + ); +} + +function YaoGlyph({ type, confirmed }: { type?: YaoType; confirmed?: boolean }) { + // Gray for unconfirmed, violet for confirmed + const color = confirmed ? 'bg-violet-700' : 'bg-slate-200'; + + // Yang: solid line, Yin: split line + if (!type || type === 'youngYang' || type === 'oldYang') { + return <div className={`h-2.5 w-full rounded-full ${color}`} />; + } + + return ( + <div className="flex h-2.5 w-full gap-4"> + <div className={`h-2.5 flex-1 rounded-full ${color}`} /> + <div className={`h-2.5 flex-1 rounded-full ${color}`} /> + </div> + ); +} + +function YaoChangeMark({ type }: { type?: YaoType }) { + if (type === 'oldYang') return <span className="text-violet-700 font-bold">○</span>; + if (type === 'oldYin') return <span className="text-violet-700 font-bold">×</span>; + return null; +} + +const copy = { + zh: { + title: '手动起卦', + subtitle: '准备三枚相同的钱币,从初爻到上爻依次录入六次结果。', + balance: '可用 120 积分 · 本次 20 积分', + defaultQuestion: '我接下来三个月的事业发展需要注意什么?', + modify: '修改', + guideLines: ['从初爻开始,按从下往上的顺序记录。', '每一爻由三枚钱币的字面/花面组合决定。', '六爻完成后才可开始解卦。'], + openGuide: '查看手动起卦教程', + guideSteps: [ + ['手动起卦', '准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。'], + ['确认时间', '先确认起卦时间。如需调整,点击右侧「修改」。'], + ['依次录入六爻', '从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。'], + ['开始分析', '六爻都填完后,下方「分析卦象」按钮会闪烁提示,点击即可解卦。'], + ], + closeGuide: '结束教程', + nextGuide: '下一步', + prevGuide: '上一步', + lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'], + pending: '待录入', + zi: '字', + hua: '花', + yaoTypeNames: { youngYang: '少阳', youngYin: '少阴', oldYang: '老阳', oldYin: '老阴' }, + questionTypePrefix: '问题类型', + method: '起卦方式:手动起卦', + submit: '开始解卦', + confirmTitle: '确认解卦', + confirmAvailable: '当前积分', + confirmCost: '本次消耗', + confirmRemaining: '解卦后剩余', + cancel: '取消', + confirm: '确认', + }, + zh_Hant: { + title: '手動起卦', + subtitle: '準備三枚相同的錢幣,從初爻到上爻依序錄入六次結果。', + balance: '可用 120 積分 · 本次 20 積分', + defaultQuestion: '我接下來三個月的事業發展需要注意什麼?', + modify: '修改', + guideLines: ['從初爻開始,按從下往上的順序記錄。', '每一爻由三枚錢幣的字面/花面組合決定。', '六爻完成後才可開始解卦。'], + openGuide: '查看手動起卦教程', + guideSteps: [ + ['手動起卦', '準備三枚相同的錢幣。每次記錄一爻,按從下往上的順序共記錄六爻。'], + ['確認時間', '先確認起卦時間。如需調整,點擊右側「修改」。'], + ['依序錄入六爻', '從初爻開始逐條選擇,未完成前下一爻不可點擊。每條會彈出三枚錢幣選擇面板。'], + ['開始分析', '六爻都填完後,下方「分析卦象」按鈕會閃爍提示,點擊即可解卦。'], + ], + closeGuide: '結束教程', + nextGuide: '下一步', + prevGuide: '上一步', + lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'], + pending: '待錄入', + zi: '字', + hua: '花', + yaoTypeNames: { youngYang: '少陽', youngYin: '少陰', oldYang: '老陽', oldYin: '老陰' }, + questionTypePrefix: '問題類型', + method: '起卦方式:手動起卦', + submit: '開始解卦', + confirmTitle: '確認解卦', + confirmAvailable: '當前積分', + confirmCost: '本次消耗', + confirmRemaining: '解卦後剩餘', + cancel: '取消', + confirm: '確認', + }, + en: { + title: 'Manual Casting', + subtitle: 'Prepare three identical coins and record six results from the first yao at the bottom to the top yao.', + balance: 'Available 120 credits · This reading 20 credits', + defaultQuestion: 'What should I pay attention to in my career development over the next three months?', + modify: 'Modify', + guideLines: ['Record from the first yao upward.', 'Each yao is determined by the inscription-side and pattern-side combination of three coins.', 'Start interpretation after all six yao are complete.'], + openGuide: 'View Manual Casting Guide', + guideSteps: [ + ['Manual Casting', 'Prepare three identical coins. Record one yao at a time, from bottom to top, until all six yao are complete.'], + ['Confirm Time', 'Check the casting time first. Tap "Modify" on the right if you need to adjust it.'], + ['Fill Six Yao in Order', 'Start from the first yao and select one row at a time. The next row stays locked until the current row is completed.'], + ['Start Analysis', 'After all six yao are filled, the "Analyze Hexagram" button will blink. Tap it to start interpretation.'], + ], + closeGuide: 'Finish', + nextGuide: 'Next', + prevGuide: 'Back', + lineNames: ['First Yao', 'Second Yao', 'Third Yao', 'Fourth Yao', 'Fifth Yao', 'Top Yao'], + pending: 'Pending', + zi: 'Inscription', + hua: 'Pattern', + yaoTypeNames: { youngYang: 'Young Yang', youngYin: 'Young Yin', oldYang: 'Old Yang', oldYin: 'Old Yin' }, + questionTypePrefix: 'Category', + method: 'Method: Manual Casting', + submit: 'Start Interpretation', + confirmTitle: 'Confirm Interpretation', + confirmAvailable: 'Available credits', + confirmCost: 'This reading cost', + confirmRemaining: 'Remaining after', + cancel: 'Cancel', + confirm: 'Confirm', + }, +} as const; + +export default function ManualDivinationPage({ locale, divination: d }: Props) { + const text = copy[locale as keyof typeof copy] ?? copy.zh; + const cats = useMemo(() => d.categories.split(','), [d.categories]); + const navigate = useNavigate(); + const [category, setCategory] = useState<string>(cats[0]); + const [question, setQuestion] = useState<string>(''); + const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date())); + const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']); + const [yaoResults, setYaoResults] = useState<YaoType[]>([]); + const [guideStep, setGuideStep] = useState<number | null>(null); + const pointsState = usePoints(); + const points = pointsState.data ?? null; + const [editingIndex, setEditingIndex] = useState<number | null>(null); + const [showProcessing, setShowProcessing] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + const { userProfile, setUserProfile } = useUserSettings(); + + // Refs for guide spotlight positioning + // Guide steps: 0=coins area, 1=time, 2=yao panel (full), 3=submit + const coinsAreaRef = useRef<HTMLDivElement>(null); + const timePanelRef = useRef<HTMLElement>(null); + const yaoPanelRef = useRef<HTMLElement>(null); + const yaoRowsRef = useRef<HTMLDivElement>(null); + const submitBtnRef = useRef<HTMLButtonElement>(null); + const scrollContainerRef = useRef<HTMLDivElement>(null); + const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null); + const [tooltipPos, setTooltipPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); + const [tooltipSide, setTooltipSide] = useState<'right' | 'left' | 'bottom' | 'top'>('right'); + const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 1280); + + // Track if first-visit auto-show has been attempted + const [tutorialChecked, setTutorialChecked] = useState(false); + + // Auto-show tutorial on first visit + useEffect(() => { + if (tutorialChecked) return; + const tutorialSettings = userProfile?.settings?.divination_tutorial; + if (tutorialSettings && !tutorialSettings.manual_divination_shown) { + // Delay to let the page render first + const timer = setTimeout(() => { + setTutorialChecked(true); + setGuideStep(0); + }, 400); + return () => clearTimeout(timer); + } else if (userProfile !== null) { + // Profile loaded but tutorial already shown + setTutorialChecked(true); + } + }, [userProfile, tutorialChecked]); + + // Mark tutorial as shown when guide ends + const closeGuide = async () => { + setGuideStep(null); + if (userProfile && !userProfile.settings.divination_tutorial.manual_divination_shown) { + const updatedSettings = { + ...userProfile.settings, + divination_tutorial: { + ...userProfile.settings.divination_tutorial, + manual_divination_shown: true, + }, + }; + try { + const updated = await updateSettingsResource(updatedSettings); + setUserProfile(updated); + } catch { + // Silently fail - tutorial shown state is non-critical + } + } + }; + + // Track previous guide step to detect initial open + const prevGuideStepRef = useRef<number | null>(null); + + // Update spotlight position when guide step changes + // Mobile: use absolute positioning relative to scroll container for stability + // Desktop: use fixed positioning relative to viewport + useLayoutEffect(() => { + if (guideStep === null) { + setSpotlightRect(null); + prevGuideStepRef.current = null; + return; + } + + const mobileGuideTargets = [coinsAreaRef, timePanelRef, yaoRowsRef, submitBtnRef]; + const desktopGuideTargets = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef]; + const targetRef = (isMobile ? mobileGuideTargets : desktopGuideTargets)[guideStep]; + if (!targetRef?.current) return; + + const tooltipWidth = 320; + const tooltipHeight = isMobile ? 220 : 180; + const gap = isMobile ? 24 : 16; + + const overlayHost = scrollContainerRef.current; + if (!overlayHost) return; + + // Get scroll container - it's the main element inside AppShell + const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null; + if (!scrollContainer) return; + + const isInitialOpen = prevGuideStepRef.current === null; + + // ===== MOBILE: Absolute positioning relative to scroll container ===== + if (isMobile) { + const elementRect = targetRef.current.getBoundingClientRect(); + + // Element position relative to scroll container (accounts for current scroll) + const containerRect = scrollContainer.getBoundingClientRect(); + const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop; + const elementWidth = elementRect.width; + const elementHeight = elementRect.height; + + // On initial open, scroll to top first + if (isInitialOpen) { + scrollContainer.scrollTop = 0; + } + + // Calculate where we need to scroll to make the spotlight visible. + const scrollTopNeeded = Math.max( + 0, + guideStep === 3 ? elementTop - tooltipHeight - gap - 32 : elementTop - 20, + ); + + scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'auto' }); + + // Use requestAnimationFrame to calculate after the scroll position has updated. + requestAnimationFrame(() => { + if (!targetRef.current) return; + // Recalculate element position after scroll setup + const newElementRect = targetRef.current.getBoundingClientRect(); + const hostRect = overlayHost.getBoundingClientRect(); + const visibleContainerRect = scrollContainer.getBoundingClientRect(); + const visibleTop = visibleContainerRect.top - hostRect.top; + const visibleBottom = visibleTop + visibleContainerRect.height; + + // Position relative to this component because the mobile overlay is absolute. + const spotlightLeft = newElementRect.left - hostRect.left; + const spotlightTop = newElementRect.top - hostRect.top; + + const tooltipLeft = Math.max(16, Math.min( + (newElementRect.left + newElementRect.right - tooltipWidth) / 2 - hostRect.left, + hostRect.width - tooltipWidth - 16 + )); + let tooltipTop = spotlightTop + elementHeight + gap; + let side: 'bottom' | 'top' = 'bottom'; + if (guideStep === 3) { + tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap); + side = 'top'; + } + if (tooltipTop + tooltipHeight > visibleBottom) { + tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap); + side = 'top'; + } + + setSpotlightRect({ + left: spotlightLeft, + top: spotlightTop, + width: elementWidth, + height: elementHeight + }); + setTooltipPos({ left: tooltipLeft, top: tooltipTop }); + setTooltipSide(side); + }); + + prevGuideStepRef.current = guideStep; + return; + } + + // ===== DESKTOP: Fixed positioning relative to viewport ===== + targetRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + const rect = targetRef.current.getBoundingClientRect(); + let tooltipLeft: number; + let tooltipTop: number; + let side: 'right' | 'left' | 'bottom' | 'top'; + + if (rect.right + gap + tooltipWidth <= window.innerWidth) { + tooltipLeft = rect.right + gap; + tooltipTop = rect.top; + side = 'right'; + } else if (rect.left >= tooltipWidth + gap) { + tooltipLeft = rect.left - tooltipWidth - gap; + tooltipTop = rect.top; + side = 'left'; + } else { + tooltipLeft = Math.max(16, Math.min(rect.left, window.innerWidth - tooltipWidth - 16)); + tooltipTop = rect.bottom + gap; + side = 'bottom'; + } + + if (tooltipTop + tooltipHeight > window.innerHeight) { + tooltipTop = Math.max(16, window.innerHeight - tooltipHeight - 16); + } + + setSpotlightRect({ left: rect.left, top: rect.top, width: rect.width, height: rect.height }); + setTooltipPos({ left: tooltipLeft, top: tooltipTop }); + setTooltipSide(side); + prevGuideStepRef.current = guideStep; + }, [guideStep, isMobile]); + + useEffect(() => { + setCategory(cats[0]); + }, [cats]); + + // Track mobile state on resize + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 1280); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const progress = yaoResults.length; + const currentYaoType = fromCoins(coins); + const guideOpen = guideStep !== null; + const guide = guideOpen ? text.guideSteps[guideStep] : null; + + const flipCoin = (idx: number) => { + setCoins((current) => { + const next = [...current] as [CoinFace, CoinFace, CoinFace]; + next[idx] = next[idx] === 'zi' ? 'hua' : 'zi'; + return next; + }); + }; + + const confirmYao = () => { + if (editingIndex !== null) { + // Editing an existing yao + setYaoResults((current) => { + const next = [...current]; + next[editingIndex] = currentYaoType; + return next; + }); + setEditingIndex(null); + } else { + if (progress >= TOTAL_YAO_COUNT) return; + setYaoResults((current) => [...current, currentYaoType]); + } + }; + + const selectYaoRow = (index: number) => { + if (guideOpen) return; // Don't allow interaction during guide + if (!yaoResults[index]) return; // Not filled yet + setEditingIndex(index); + // Load the coins state for this yao + setCoins(coinsForYaoType(yaoResults[index])); + }; + + const showPreviousGuide = () => setGuideStep((step) => (step === null ? 0 : Math.max(step - 1, 0))); + const showNextGuide = () => setGuideStep((step) => (step === null ? 0 : Math.min(step + 1, text.guideSteps.length - 1))); + + const handleSubmit = () => { + setShowConfirm(true); + }; + + const handleConfirm = () => { + setShowConfirm(false); + setShowProcessing(true); + }; + + const handleComplete = (result: DivinationResultData | null) => { + setShowProcessing(false); + if (result) { + // Navigate to result page with state + navigate(`/${locale}/divination/result`, { state: { result } }); + } + }; + + const handleError = (error: Error) => { + setShowProcessing(false); + setErrorMessage(error.message || 'Unknown error'); + }; + + // Check if user has enough points + const hasEnoughPoints = points && points.availableBalance >= (points.runCost ?? 20); + + return ( + <div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]"> + <div className="flex items-center justify-between gap-5"> + <div className="min-w-0"> + <h1 className="text-[28px] font-bold leading-tight text-[#333333]">{text.title}</h1> + <p className="mt-1 text-sm text-[#666666]">{text.subtitle}</p> + </div> + <div className="hidden h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 text-[13px] font-semibold text-[#333333] md:flex"> + <Icon name="paid" className="h-[18px] w-[18px] text-violet-700" /> + {locale === 'en' + ? `Available ${points?.availableBalance ?? '...'} credits · This time ${points?.runCost ?? 20} credits` + : `可用 ${points?.availableBalance ?? '...'} 积分 · 本次 ${points?.runCost ?? 20} 积分`} + </div> + </div> + + <div className="flex min-h-0 flex-1 flex-col gap-[22px] xl:flex-row"> + <div className="flex w-full shrink-0 flex-col gap-4 xl:w-[360px]"> + {/* 教程面板 - 手机端显示在最上方 */} + <section className="flex h-[214px] flex-col gap-3 rounded-2xl border bg-white p-5 border-slate-200"> + <h2 className="text-base font-bold text-slate-900">{d.guideTitle}</h2> + {text.guideLines.map((line) => <p key={line} className="text-[13px] leading-relaxed text-[#666666]">{line}</p>)} + <button + type="button" + onClick={() => setGuideStep(0)} + className="mt-auto flex h-8 w-fit items-center gap-2 rounded-[17px] bg-[#F0E6FF] px-3 text-[13px] font-bold text-[#673AB7] hover:bg-[#E6D6FF] transition-colors" + > + <Icon name="help" className="h-[18px] w-[18px]" /> + {text.openGuide} + </button> + </section> + + {/* 问题面板 */} + <section className="flex h-[300px] flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-[22px]"> + <h2 className="text-lg font-bold text-slate-900">{d.questionTitle}</h2> + <label className="sr-only" htmlFor="manual-category">{d.categoryLabel}</label> + <select + id="manual-category" + value={category} + onChange={(event) => setCategory(event.target.value)} + className="h-[42px] rounded-[10px] border border-slate-300 bg-slate-50 px-3 text-sm font-bold text-[#333333] outline-none focus:border-violet-500" + > + {cats.map((cat) => <option key={cat} value={cat}>{cat}</option>)} + </select> + <textarea + value={question} + onChange={(event) => setQuestion(event.target.value)} + placeholder={text.defaultQuestion} + className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500" + /> + </section> + + {/* 时间面板 */} + <section ref={timePanelRef} className={`flex h-[132px] flex-col gap-3 rounded-2xl border bg-white p-5 ${guideStep === 1 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}> + <h2 className="text-base font-bold text-slate-900">{d.timeTitle}</h2> + <div className="flex h-[42px] items-center justify-between gap-3 rounded-[10px] bg-slate-50 px-3"> + <input + type="datetime-local" + value={selectedTime} + onChange={(event) => setSelectedTime(event.target.value)} + className="w-full bg-transparent text-sm font-semibold text-[#333333] outline-none" + /> + <span className="shrink-0 cursor-pointer text-[13px] font-bold text-violet-700 hover:text-violet-800">{text.modify}</span> + </div> + </section> + </div> + + <section ref={yaoPanelRef} className={`flex min-w-0 flex-1 flex-col gap-4 rounded-2xl border bg-white p-6 ${guideStep === 2 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}> + <div className="flex items-center justify-between gap-4"> + <h2 className="text-lg font-bold text-slate-900">{d.yaoTitle}</h2> + <span className="text-[13px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</span> + </div> + + <div ref={yaoRowsRef} className="flex flex-col gap-2.5"> + {[5, 4, 3, 2, 1, 0].map((index) => { + const result = yaoResults[index]; + // Only show "active" highlight when not in editing mode + const active = editingIndex === null && index === progress && progress < TOTAL_YAO_COUNT; + const confirmed = !!result; + const editing = editingIndex === index; + return ( + <div + key={index} + onClick={() => selectYaoRow(index)} + className={`flex h-[62px] items-center gap-4 rounded-[10px] px-3.5 ${editing ? 'border border-violet-600 bg-violet-50' : active ? 'border border-violet-600 bg-violet-50' : confirmed ? 'border border-slate-200 bg-white cursor-pointer hover:border-violet-300' : 'bg-slate-50'}`} + > + <span className={`w-16 text-sm font-bold ${active || confirmed || editing ? 'text-violet-700' : 'text-slate-400'}`}>{text.lineNames[index]}</span> + <div className="min-w-0 flex-1"> + <YaoGlyph type={result} confirmed={confirmed} /> + </div> + <span className="w-6 text-center"> + <YaoChangeMark type={result} /> + </span> + </div> + ); + })} + </div> + + {/* Coins area - always visible; shows editing state or next-yao state */} + <div ref={coinsAreaRef} className="flex min-h-[142px] items-center justify-center rounded-xl bg-slate-50 p-4"> + <div className="flex items-center justify-center gap-6"> + {coins.map((face, index) => ( + <button + key={index} + type="button" + onClick={() => flipCoin(index)} + className="flex w-20 flex-col items-center gap-2 text-[13px] font-bold text-slate-600" + > + <CoinImage face={face} /> + <span>{face === 'zi' ? text.zi : text.hua}</span> + </button> + ))} + </div> + </div> + + <button + type="button" + onClick={confirmYao} + disabled={progress >= TOTAL_YAO_COUNT && editingIndex === null} + className={`h-10 w-full rounded-full text-[13px] font-bold transition-colors ${ + editingIndex !== null + ? 'bg-violet-700 text-white hover:bg-violet-800' + : progress >= TOTAL_YAO_COUNT + ? 'cursor-not-allowed bg-slate-300 text-slate-400' + : 'bg-violet-700 text-white hover:bg-violet-800' + }`} + > + {editingIndex !== null ? d.confirmBtn : progress >= TOTAL_YAO_COUNT ? d.confirmBtn : d.confirmBtn} + </button> + </section> + + <aside className="flex w-full shrink-0 flex-col gap-[18px] rounded-2xl border border-slate-200 bg-white p-[22px] xl:w-[300px]"> + <h2 className="text-lg font-bold text-slate-900">{d.summaryTitle}</h2> + <div className="flex h-[94px] flex-col gap-2 rounded-xl bg-slate-50 p-4"> + <p className="text-[13px] text-[#666666]">{d.progressLabel}</p> + <p className="text-[28px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</p> + </div> + <p className="text-sm text-[#666666]">{text.questionTypePrefix}{locale === 'en' ? ': ' : ':'}{category}</p> + <p className="text-sm text-[#666666]">{text.method}</p> + <p className="text-sm text-[#666666]">{locale === 'en' ? `Cost: ${points?.runCost ?? 20} credits` : `解卦消耗:${points?.runCost ?? 20} 积分`}</p> + <div className="flex-1" /> + <button + ref={submitBtnRef} + type="button" + disabled={progress < TOTAL_YAO_COUNT} + onClick={handleSubmit} + className={`h-[46px] w-full rounded-full text-sm font-bold transition-colors ${progress >= TOTAL_YAO_COUNT ? 'bg-violet-700 text-white hover:bg-violet-800' : 'cursor-not-allowed bg-slate-300 text-slate-400'} ${guideStep === 3 ? 'ring-4 ring-violet-100' : ''}`} + > + {text.submit} + </button> + </aside> + </div> + + {/* Guide overlay with spotlight */} + {guideOpen && guide && spotlightRect && ( + <> + {/* Dark overlay - fixed for desktop, covers viewport */} + <div + className="fixed inset-0 z-40 hidden bg-black/70 xl:block" + /> + {/* Mobile dark overlay - positioned within scroll container */} + <div + className="absolute inset-0 z-40 xl:hidden" + style={{ top: 0, height: '100vh' }} + /> + + {/* Spotlight on target element - fixed for desktop, absolute for mobile */} + <div + className={`z-50 rounded-2xl ring-4 ring-white shadow-[0_0_0_9999px_rgba(0,0,0,0.7)] transition-all duration-300 ${ + isMobile ? 'absolute' : 'fixed' + }`} + style={{ + left: spotlightRect.left, + top: spotlightRect.top, + width: spotlightRect.width, + height: spotlightRect.height + }} + /> + + {/* Guide tooltip - fixed for desktop, absolute for mobile */} + <div + className={`z-50 w-[320px] rounded-2xl bg-slate-950 p-5 text-white shadow-2xl transition-all duration-300 ${ + isMobile ? 'absolute' : 'fixed' + }`} + style={{ + left: tooltipPos.left, + top: tooltipPos.top + }} + > + {/* Arrow pointing to spotlight */} + <div + className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${ + tooltipSide === 'right' ? '-left-1.5 top-6' : + tooltipSide === 'left' ? '-right-1.5 top-6' : + tooltipSide === 'top' ? '-bottom-1.5 left-1/2 -translate-x-1/2' : + '-top-1.5 left-6' + }`} + /> + + <div className="mb-3 flex items-center justify-between gap-4"> + <span className="text-xs font-bold text-violet-300">{guideStep + 1} / {text.guideSteps.length}</span> + <button type="button" onClick={() => closeGuide()} className="rounded-full p-1 text-white/50 hover:text-white"> + <Icon name="close" className="h-4 w-4" /> + </button> + </div> + <h3 className="text-base font-bold">{guide[0]}</h3> + <p className="mt-2 text-sm leading-relaxed text-white/70">{guide[1]}</p> + <div className="mt-4 flex items-center justify-between gap-3"> + <button + type="button" + onClick={showPreviousGuide} + disabled={guideStep === 0} + className="h-9 rounded-full px-4 text-sm font-medium text-white/50 disabled:opacity-30 hover:text-white disabled:hover:text-white/50" + > + {text.prevGuide} + </button> + {guideStep === text.guideSteps.length - 1 ? ( + <button + type="button" + onClick={() => closeGuide()} + className="h-9 rounded-full bg-violet-600 px-5 text-sm font-bold text-white hover:bg-violet-500" + > + {text.closeGuide} + </button> + ) : ( + <button + type="button" + onClick={showNextGuide} + className="h-9 rounded-full bg-violet-600 px-5 text-sm font-bold text-white hover:bg-violet-500" + > + {text.nextGuide} + </button> + )} + </div> + </div> + </> + )} + + {/* Confirmation dialog */} + {showConfirm && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl"> + <h3 className="text-slate-900 text-lg font-bold">{text.confirmTitle}</h3> + <div className="flex flex-col gap-3"> + <div className="flex justify-between text-sm"> + <span className="text-slate-500">{text.confirmAvailable}</span> + <span className="text-slate-900 font-semibold">{points?.availableBalance ?? '...'}</span> + </div> + <div className="flex justify-between text-sm"> + <span className="text-slate-500">{text.confirmCost}</span> + <span className="text-violet-600 font-semibold">{points?.runCost ?? 20}</span> + </div> + <div className="border-t border-slate-200 pt-3 flex justify-between text-sm"> + <span className="text-slate-500">{text.confirmRemaining}</span> + <span className={`font-bold ${hasEnoughPoints ? 'text-slate-900' : 'text-red-500'}`}> + {(points?.availableBalance ?? 0) - (points?.runCost ?? 20)} + </span> + </div> + {!hasEnoughPoints && ( + <p className="text-red-500 text-sm font-medium"> + {locale === 'en' ? 'Insufficient credits. Please purchase more.' : '积分不足,请先充值。'} + </p> + )} + </div> + <div className="flex gap-3"> + <button + onClick={() => setShowConfirm(false)} + className="flex-1 h-11 rounded-full border border-slate-300 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition-colors" + > + {text.cancel} + </button> + <button + onClick={handleConfirm} + disabled={!hasEnoughPoints} + className={`flex-1 h-11 rounded-full text-sm font-bold text-white transition-colors ${ + hasEnoughPoints + ? 'bg-violet-600 hover:bg-violet-700' + : 'bg-slate-300 cursor-not-allowed' + }`} + > + {text.confirm} + </button> + </div> + </div> + </div> + )} + + {/* 处理中蒙版 */} + {showProcessing && ( + <DivinationProcessingOverlay + locale={locale} + params={{ + method: 'manual', + questionType: category, + question: question, + divinationTime: new Date(selectedTime), + }} + yaoStates={yaoResults} + onComplete={handleComplete} + onError={handleError} + /> + )} + + {/* Error dialog */} + {errorMessage && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> + <div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl"> + <h3 className="text-red-600 text-lg font-bold">{locale === 'en' ? 'Error' : '出错了'}</h3> + <p className="text-sm text-slate-600">{errorMessage}</p> + <button + onClick={() => setErrorMessage(null)} + className="h-11 w-full rounded-full bg-slate-100 text-sm font-bold text-slate-700 hover:bg-slate-200 transition-colors" + > + {locale === 'en' ? 'Close' : '关闭'} + </button> + </div> + </div> + )} + </div> + ); +} diff --git a/web/src/components/Navbar.astro b/web/src/components/Navbar.astro new file mode 100644 index 0000000..53cbe45 --- /dev/null +++ b/web/src/components/Navbar.astro @@ -0,0 +1,54 @@ +--- +import { t, localePath, getLocaleLabel, type Locale } from '../i18n/utils'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const currentPath = Astro.url.pathname.replace(new RegExp(`^/(zh|zh_Hant|en)`), ''); +const nav = t(locale, 'nav'); +const footer = t(locale, 'footer'); +const otherLocales: Locale[] = (['zh', 'zh_Hant', 'en'] as Locale[]).filter((l) => l !== locale); +--- + +<header class="w-full border-b border-slate-200 bg-white sticky top-0 z-50"> + <div class="flex h-16 md:h-20 items-center justify-between gap-3 px-5 md:px-20"> + <a href={localePath(locale, '/')} class="flex min-w-0 items-center gap-2 md:gap-3 shrink"> + <img src="/images/logo.png" alt="MeiYao" class="w-8 h-8 md:w-9 md:h-9 shrink-0" /> + <span class="truncate text-slate-900 text-lg md:text-xl font-bold whitespace-nowrap">{footer.brandName}</span> + </a> + + <nav class="hidden md:flex items-center gap-8"> + <a href={localePath(locale, '/features')} class="text-slate-600 text-sm hover:text-slate-900 whitespace-nowrap">{nav.features}</a> + <a href={localePath(locale, '/pricing')} class="text-slate-600 text-sm hover:text-slate-900 whitespace-nowrap">{nav.pricing}</a> + <a href={localePath(locale, '/about')} class="text-slate-600 text-sm hover:text-slate-900 whitespace-nowrap">{nav.about}</a> + </nav> + + <div class="flex items-center gap-3 md:gap-4 shrink-0"> + <details class="relative"> + <summary class="flex list-none items-center gap-1 px-2 py-1.5 text-xs text-slate-600 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 whitespace-nowrap cursor-pointer"> + {getLocaleLabel(locale)} + <svg class="w-3 h-3 text-slate-400" viewBox="0 0 12 12" fill="none"><path d="M3 5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> + </summary> + <div class="absolute right-0 top-full mt-1 min-w-full bg-white border border-slate-200 rounded-lg shadow-lg py-1 z-50"> + {otherLocales.map((l) => ( + <a href={localePath(l, currentPath)} class="block px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-900 whitespace-nowrap"> + {getLocaleLabel(l)} + </a> + ))} + </div> + </details> + + <a href={localePath(locale, '/login')} class="bg-violet-600 text-white text-xs md:text-sm font-semibold px-4 md:px-5 py-2 md:py-2.5 rounded-lg hover:bg-violet-700 transition-colors whitespace-nowrap"> + {nav.getStarted} + </a> + </div> + </div> + + <nav class="grid grid-cols-3 border-t border-slate-100 px-5 py-2 text-center md:hidden"> + <a href={localePath(locale, '/features')} class="text-slate-600 text-sm font-medium hover:text-slate-900">{nav.features}</a> + <a href={localePath(locale, '/pricing')} class="text-slate-600 text-sm font-medium hover:text-slate-900">{nav.pricing}</a> + <a href={localePath(locale, '/about')} class="text-slate-600 text-sm font-medium hover:text-slate-900">{nav.about}</a> + </nav> +</header> diff --git a/web/src/components/NotificationPage.tsx b/web/src/components/NotificationPage.tsx new file mode 100644 index 0000000..ca8131b --- /dev/null +++ b/web/src/components/NotificationPage.tsx @@ -0,0 +1,226 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { NotificationItem } from '../lib/api'; +import { markAllNotificationsReadResource, markNotificationReadResource, useNotifications } from '../lib/resources'; + +interface Props { + locale: string; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string }; + notifications: { title: string; loading: string; error: string; empty: string; markAllRead: string; markAllReadDone: string }; +} + +function formatRelativeTime(dateStr: string, locale: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (locale === 'en') { + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins} min ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return 'Yesterday'; + return `${diffDays} days ago`; + } else { + if (diffMins < 1) return '刚刚'; + if (diffMins < 60) return `${diffMins}分钟前`; + if (diffHours < 24) return `${diffHours}小时前`; + if (diffDays === 1) return '昨天'; + return `${diffDays}天前`; + } +} + +function formatFullTime(dateStr: string, locale: string): string { + const date = new Date(dateStr); + if (locale === 'en') { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default function NotificationPage({ locale, notifications: n }: Props) { + const notificationsState = useNotifications(locale); + const [selectedItem, setSelectedItem] = useState<NotificationItem | null>(null); + const [markingAll, setMarkingAll] = useState(false); + const [toast, setToast] = useState<string | null>(null); + const items = useMemo(() => notificationsState.data?.items ?? [], [notificationsState.data]); + const loading = notificationsState.loading; + const error = notificationsState.error instanceof Error ? notificationsState.error.message : notificationsState.error ? n.error : null; + + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 2000); + return () => clearTimeout(timer); + } + }, [toast]); + + const handleItemClick = async (item: NotificationItem) => { + setSelectedItem(item); + if (!item.isRead) { + try { + await markNotificationReadResource(item.id, locale); + } catch { + // ignore mark read error + } + } + }; + + const handleMarkAllRead = async () => { + const unreadItems = items.filter((i) => !i.isRead); + if (unreadItems.length === 0) return; + + setMarkingAll(true); + try { + await markAllNotificationsReadResource(locale); + setToast(n.markAllReadDone); + } catch { + // ignore error + } finally { + setMarkingAll(false); + } + }; + + const closeModal = () => setSelectedItem(null); + + const unreadCount = items.filter((i) => !i.isRead).length; + + if (loading) { + return ( + <div className="flex flex-col gap-6 min-h-full"> + <h1 className="text-slate-900 text-2xl font-bold">{n.title}</h1> + <div className="text-slate-500">{n.loading}</div> + </div> + ); + } + + if (error) { + return ( + <div className="flex flex-col gap-6 min-h-full"> + <h1 className="text-slate-900 text-2xl font-bold">{n.title}</h1> + <div className="text-red-500">{error}</div> + </div> + ); + } + + return ( + <div className="flex flex-col gap-6 min-h-full"> + {/* Header */} + <div className="flex items-center justify-between"> + <h1 className="text-slate-900 text-2xl font-bold">{n.title}</h1> + {unreadCount > 0 && ( + <button + onClick={handleMarkAllRead} + disabled={markingAll} + className="text-sm text-amber-600 hover:text-amber-700 disabled:text-slate-400" + > + {markingAll ? '...' : n.markAllRead} + </button> + )} + </div> + + {/* List */} + {items.length === 0 ? ( + <div className="text-slate-500 py-8 text-center">{n.empty}</div> + ) : ( + <div className="flex flex-col gap-3"> + {items.map((notif) => ( + <button + key={notif.id} + onClick={() => handleItemClick(notif)} + className="relative bg-white rounded-xl px-5 py-4 flex items-start gap-4 hover:shadow-sm transition-shadow text-left w-full" + > + {!notif.isRead && <div className="absolute left-4 top-5 w-2 h-2 rounded-full bg-red-500" />} + <div className="flex-1 min-w-0 ml-2"> + <p className={`text-[15px] ${!notif.isRead ? 'text-slate-900 font-semibold' : 'text-slate-600'}`}> + {notif.title} + </p> + <p className="text-slate-400 text-[13px] mt-1">{notif.body}</p> + </div> + <span className="text-slate-400 text-xs shrink-0 mt-0.5">{formatRelativeTime(notif.createdAt, locale)}</span> + </button> + ))} + </div> + )} + + {/* Modal Overlay */} + {selectedItem && ( + <div + className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4" + onClick={closeModal} + > + {/* Modal Content */} + <div + className="bg-white rounded-2xl shadow-xl w-full max-w-md max-h-[80vh] overflow-hidden flex flex-col" + onClick={(e) => e.stopPropagation()} + > + {/* Modal Header */} + <div className="flex items-start justify-between gap-3 p-5 border-b border-slate-100"> + <div className="flex-1 min-w-0"> + <h2 className="text-lg font-semibold text-slate-900">{selectedItem.title}</h2> + <p className="text-slate-400 text-sm mt-1">{formatFullTime(selectedItem.createdAt, locale)}</p> + </div> + <button + onClick={closeModal} + className="text-slate-400 hover:text-slate-600 p-1 shrink-0 -mt-1 -mr-1" + aria-label="Close" + > + <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> + </svg> + </button> + </div> + + {/* Modal Body */} + <div className="p-5 overflow-y-auto"> + {/* Status Badge */} + <div className="mb-4"> + <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ + selectedItem.isRead ? 'bg-slate-100 text-slate-600' : 'bg-amber-100 text-amber-700' + }`}> + {selectedItem.isRead + ? (locale === 'en' ? 'Read' : '已读') + : (locale === 'en' ? 'Unread' : '未读')} + </span> + </div> + + {/* Body */} + <div className="text-slate-700 text-[15px] leading-relaxed whitespace-pre-wrap"> + {selectedItem.body} + </div> + </div> + + {/* Modal Footer */} + <div className="p-5 border-t border-slate-100"> + <button + onClick={closeModal} + className="w-full py-2.5 bg-violet-600 text-white rounded-lg font-medium hover:bg-violet-700 transition-colors" + > + {locale === 'en' ? 'Close' : '关闭'} + </button> + </div> + </div> + </div> + )} + + {/* Toast */} + {toast && ( + <div className="fixed bottom-8 left-1/2 -translate-x-1/2 bg-slate-800 text-white px-4 py-2 rounded-lg text-sm shadow-lg z-50"> + {toast} + </div> + )} + </div> + ); +} diff --git a/web/src/components/PricingPage.astro b/web/src/components/PricingPage.astro new file mode 100644 index 0000000..ba7d760 --- /dev/null +++ b/web/src/components/PricingPage.astro @@ -0,0 +1,47 @@ +--- +import { t, type Locale } from '../i18n/utils'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const p = t(locale, 'pricing'); + +const plans = [ + { name: p.p1Name, badge: p.p1Badge, price: p.p1Price, credits: p.p1Credits, desc: p.p1Desc, detail: '', featured: false }, + { name: p.p2Name, badge: '', price: p.p2Price, credits: p.p2Credits, desc: p.p2Desc, detail: p.p2Detail, featured: false }, + { name: p.p3Name, badge: p.p3Badge, price: p.p3Price, credits: p.p3Credits, desc: p.p3Desc, detail: '', featured: true }, + { name: p.p4Name, badge: '', price: p.p4Price, credits: p.p4Credits, desc: p.p4Desc, detail: p.p4Detail, featured: false }, +]; +--- + +<!-- Header --> +<section class="w-full py-24 md:py-32 relative overflow-hidden"> + <div class="glow-bg absolute inset-0 pointer-events-none"></div> + <div class="relative text-center px-6 max-w-3xl mx-auto"> + <h1 class="reveal stagger-1 text-slate-900 text-3xl md:text-4xl lg:text-5xl font-bold mb-4">{p.title}</h1> + <p class="reveal stagger-2 text-slate-500 text-lg max-w-xl mx-auto">{p.subtitle}</p> + </div> +</section> + +<!-- Pricing Cards --> +<section class="w-full py-16 md:py-24 px-6 md:px-20" style="background-color: #F8F7FC;"> + <div class="max-w-7xl mx-auto"> + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"> + {plans.map((plan, i) => ( + <div class={`reveal stagger-${(i % 4) + 1} rounded-2xl p-8 flex flex-col items-center text-center ${plan.featured ? 'bg-violet-50 border-2 border-violet-600 shadow-lg shadow-violet-100' : 'bg-white border border-slate-200'}`}> + <h3 class="text-slate-900 text-[22px] font-bold whitespace-nowrap">{plan.name}</h3> + {plan.badge && <span class={`text-xs font-medium mt-2 px-3 py-1 rounded-full ${plan.featured ? 'bg-violet-600 text-white' : 'bg-amber-100 text-amber-700'}`}>{plan.badge}</span>} + <p class="text-slate-900 text-4xl font-extrabold mt-4">{plan.price}</p> + <p class="text-violet-600 text-sm font-medium">{plan.credits}</p> + <div class={`w-full h-px my-5 ${plan.featured ? 'bg-violet-200' : 'bg-slate-100'}`}></div> + <p class="text-slate-500 text-sm">{plan.desc}</p> + {plan.detail && <p class="text-slate-600 text-sm leading-relaxed mt-2">{plan.detail}</p>} + <div class="flex-1 min-h-4"></div> + <a href="#" class={`w-full text-center py-3 rounded-lg font-semibold transition-all duration-300 mt-6 ${plan.featured ? 'cyber-gradient cyber-glow text-white hover:-translate-y-0.5' : 'bg-white text-violet-600 border border-violet-200 hover:bg-violet-50'}`}>{p.buyNow}</a> + </div> + ))} + </div> + </div> +</section> diff --git a/web/src/components/ProfileDetailPage.tsx b/web/src/components/ProfileDetailPage.tsx new file mode 100644 index 0000000..7b60086 --- /dev/null +++ b/web/src/components/ProfileDetailPage.tsx @@ -0,0 +1,252 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getAuth } from '../lib/auth'; +import { updateProfileResource, uploadAvatarResource, useProfile } from '../lib/resources'; + +interface Props { + locale: string; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string }; + profile: { avatarTitle: string; avatarHint: string; uploadBtn: string; formTitle: string; emailLabel: string; displayNameLabel: string; displayNamePlaceholder: string; bioLabel: string; bioPlaceholder: string; saveBtn: string; cancelBtn: string }; +} + +// Compress image before upload +async function compressImage(file: File, maxWidth = 512, maxHeight = 512, quality = 0.8): Promise<File> { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + // Calculate new dimensions + let width = img.width; + let height = img.height; + if (width > maxWidth || height > maxHeight) { + const ratio = Math.min(maxWidth / width, maxHeight / height); + width = Math.round(width * ratio); + height = Math.round(height * ratio); + } + + // Create canvas and draw + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Canvas context not available')); + return; + } + ctx.drawImage(img, 0, 0, width, height); + + // Convert to blob + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Failed to compress image')); + return; + } + const compressedFile = new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { + type: 'image/jpeg', + }); + resolve(compressedFile); + }, + 'image/jpeg', + quality + ); + }; + img.onerror = () => reject(new Error('Failed to load image')); + img.src = URL.createObjectURL(file); + }); +} + +export default function ProfileDetailPage({ locale, profile: p }: Props) { + const navigate = useNavigate(); + const profileState = useProfile(); + const profile = profileState.data ?? null; + const [displayName, setDisplayName] = useState(''); + const [bio, setBio] = useState(''); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [success, setSuccess] = useState<string | null>(null); + const fileInputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + if (!profileState.data) return; + setDisplayName(profileState.data.display_name || ''); + setBio(profileState.data.bio || ''); + }, [profileState.data]); + + useEffect(() => { + if (profileState.error instanceof Error) setError(profileState.error.message || 'Failed to load profile'); + }, [profileState.error]); + + // Clear messages after 3 seconds + useEffect(() => { + if (success) { + const timer = setTimeout(() => setSuccess(null), 3000); + return () => clearTimeout(timer); + } + }, [success]); + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + await updateProfileResource({ + display_name: displayName || undefined, + bio: bio || undefined, + }); + // Navigate back to settings on success + navigate(-1); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); + } + }; + + const handleCancel = () => { + navigate(-1); + }; + + const handleAvatarClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Validate file type + if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) { + setError(locale === 'en' ? 'Only PNG, JPG, WEBP allowed' : '仅支持 PNG、JPG、WEBP'); + return; + } + + setUploading(true); + setError(null); + try { + // Compress image before upload + const compressedFile = await compressImage(file, 512, 512, 0.8); + + // Check compressed size (max 2MB after compression) + if (compressedFile.size > 2 * 1024 * 1024) { + throw new Error(locale === 'en' ? 'Image too large, please choose a smaller one' : '图片太大,请选择更小的图片'); + } + + await uploadAvatarResource(compressedFile); + setSuccess(locale === 'en' ? 'Avatar updated' : '头像已更新'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to upload'); + } finally { + setUploading(false); + // Reset file input + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + if (profileState.loading) { + return ( + <div className="flex flex-col gap-6 min-h-full"> + <div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div> + </div> + ); + } + + const email = getAuth()?.user?.email || ''; + + return ( + <div className="flex flex-col lg:flex-row gap-6 min-h-full"> + {/* Avatar edit */} + <div className="w-full lg:w-[360px] bg-white rounded-2xl p-7 border border-slate-200 flex flex-col items-center gap-5 shrink-0 self-start"> + {/* Avatar preview */} + {profile?.avatar_url ? ( + <img src={profile.avatar_url} alt={displayName} className="w-32 h-32 rounded-full object-cover border-2 border-violet-200" /> + ) : ( + <div className="w-32 h-32 rounded-full bg-violet-50 border-2 border-violet-200 flex items-center justify-center"> + <span className="text-violet-600 text-4xl font-bold">{displayName ? displayName[0].toUpperCase() : '?'}</span> + </div> + )} + <h3 className="text-slate-900 text-lg font-bold">{p.avatarTitle}</h3> + <p className="text-slate-500 text-[13px] text-center">{p.avatarHint}</p> + <input + ref={fileInputRef} + type="file" + accept="image/png,image/jpeg,image/webp" + onChange={handleFileChange} + className="hidden" + /> + <button + onClick={handleAvatarClick} + disabled={uploading} + className="w-full h-[42px] rounded-full bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors" + > + {uploading ? (locale === 'en' ? 'Uploading...' : '上传中...') : p.uploadBtn} + </button> + </div> + + {/* Form */} + <div className="flex-1 bg-white rounded-2xl p-7 border border-slate-200 flex flex-col gap-6"> + <h3 className="text-slate-900 text-xl font-bold">{p.formTitle}</h3> + + {/* Error message */} + {error && ( + <div className="text-red-500 text-sm bg-red-50 px-4 py-2 rounded-lg">{error}</div> + )} + + {/* Success message */} + {success && ( + <div className="text-green-600 text-sm bg-green-50 px-4 py-2 rounded-lg">{success}</div> + )} + + {/* Email readonly */} + <div className="bg-slate-50 rounded-xl px-4 py-4 flex items-center gap-4"> + <span className="material-symbols-rounded text-slate-400 text-lg">email</span> + <div> + <p className="text-slate-400 text-xs">{p.emailLabel}</p> + <p className="text-slate-600 text-sm">{email || '-'}</p> + </div> + </div> + + {/* Display name */} + <div className="flex flex-col gap-2"> + <label className="text-slate-700 text-sm font-medium">{p.displayNameLabel}</label> + <input + value={displayName} + onChange={(e) => setDisplayName(e.target.value)} + placeholder={p.displayNamePlaceholder} + maxLength={30} + className="w-full h-11 px-4 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400" + /> + </div> + + {/* Bio */} + <div className="flex flex-col gap-2"> + <label className="text-slate-700 text-sm font-medium">{p.bioLabel}</label> + <textarea + value={bio} + onChange={(e) => setBio(e.target.value)} + placeholder={p.bioPlaceholder} + rows={4} + maxLength={200} + className="w-full px-4 py-3 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 resize-none" + /> + <p className="text-slate-400 text-xs text-right">{bio.length}/200</p> + </div> + + <div className="flex gap-3 justify-end mt-auto"> + <button + onClick={handleCancel} + className="px-5 py-2.5 rounded-lg text-sm text-slate-500 hover:bg-slate-50 transition-colors" + > + {p.cancelBtn} + </button> + <button + onClick={handleSave} + disabled={saving} + className="px-5 py-2.5 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors" + > + {saving ? (locale === 'en' ? 'Saving...' : '保存中...') : p.saveBtn} + </button> + </div> + </div> + </div> + ); +} diff --git a/web/src/components/SettingsPage.tsx b/web/src/components/SettingsPage.tsx new file mode 100644 index 0000000..07c0c54 --- /dev/null +++ b/web/src/components/SettingsPage.tsx @@ -0,0 +1,220 @@ +import { useState } from 'react'; +import { logout, getAuth, clearAuth, redirectToLogin } from '../lib/auth'; +import { usePoints, useProfile } from '../lib/resources'; + +interface Props { + locale: string; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string }; + settings: { title: string; profileTitle: string; emailLabel: string; nameLabel: string; joinedLabel: string; pointsTitle: string; pointsBalance: string; accountTitle: string; changeName: string; changeAvatar: string; changeLanguage: string; legalTitle: string; privacy: string; terms: string; logout: string; logoutConfirm: string }; +} + +export default function SettingsPage({ locale, settings: s }: Props) { + const profileState = useProfile(); + const pointsState = usePoints(); + const profile = profileState.data ?? null; + const points = pointsState.data ?? null; + const loading = profileState.loading || pointsState.loading; + const [logoutLoading, setLogoutLoading] = useState(false); + + const handleLogout = async () => { + if (logoutLoading) return; + if (!confirm(s.logoutConfirm)) return; + + setLogoutLoading(true); + // Clear local auth immediately and redirect + clearAuth(); + // Fire backend logout in background (don't wait) + logout().catch(() => {}); + // Redirect to login + redirectToLogin(); + }; + + const authEmail = getAuth()?.user?.email; + const displayName = loading ? '' : (profile?.display_name || authEmail?.split('@')[0] || ''); + const email = loading ? '' : (authEmail || ''); + const bio = profile?.bio || ''; + + return ( + <div className="flex flex-col gap-6 min-h-full"> + {/* Page Header */} + <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> + <div className="flex flex-col gap-1.5"> + <h1 className="text-slate-900 text-2xl font-bold">{s.title}</h1> + <p className="text-slate-500 text-sm"> + {locale === 'en' ? 'Manage account, preferences, and policies' : '管理账号资料、偏好与协议信息'} + </p> + </div> + <div className="flex items-center gap-2 px-3.5 py-2.5 bg-white rounded-full border border-slate-200"> + <span className="material-symbols-rounded text-violet-600 text-lg">verified_user</span> + <span className="text-slate-700 text-sm font-medium"> + {locale === 'en' ? 'Account OK' : '账号正常'} + </span> + </div> + </div> + + {/* Main Content */} + <div className="flex flex-col xl:flex-row gap-6 flex-1 min-h-0"> + {/* Left Column */} + <div className="w-full xl:w-[360px] flex flex-col gap-4 shrink-0"> + {/* Profile Summary Card */} + <div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3"> + <div className="flex items-start gap-3"> + {/* Avatar */} + {profile?.avatar_url ? ( + <img src={profile.avatar_url} alt={displayName} className="w-14 h-14 rounded-[28px] object-cover" /> + ) : ( + <div className="w-14 h-14 rounded-[28px] bg-violet-50 flex items-center justify-center"> + <span className="text-violet-600 text-xl font-bold">{displayName ? displayName[0].toUpperCase() : '?'}</span> + </div> + )} + {/* Name & Email */} + <div className="flex-1 min-w-0"> + <p className="text-slate-900 text-lg font-bold truncate">{loading ? '...' : (displayName || '-')}</p> + <p className="text-slate-500 text-xs truncate">{loading ? '...' : (email || '-')}</p> + </div> + {/* Edit Profile Button */} + <a + href={`/${locale}/profile`} + className="w-8 h-8 rounded-2xl bg-slate-50 border border-slate-200 flex items-center justify-center hover:bg-slate-100 transition-colors shrink-0" + title={s.changeName} + > + <span className="material-symbols-rounded text-violet-600 text-[17px]">edit</span> + </a> + </div> + {/* Bio */} + {bio && ( + <p className="text-slate-500 text-[13px] leading-relaxed">{bio}</p> + )} + </div> + + {/* Points Card */} + <a + href={`/${locale}/store`} + className="bg-white rounded-2xl p-5 border border-slate-200 flex items-center gap-3.5 hover:shadow-sm transition-shadow" + > + {/* Points Icon */} + <div className="w-11 h-11 rounded-xl bg-violet-50 flex items-center justify-center shrink-0"> + <span className="material-symbols-rounded text-violet-600 text-[26px]">paid</span> + </div> + {/* Points Info */} + <div className="flex-1 min-w-0"> + <p className="text-slate-400 text-[13px]">{s.pointsTitle}</p> + <p className="text-slate-900 text-xl font-bold"> + {loading ? '...' : points?.balance ?? 0} + <span className="text-sm font-normal text-slate-400 ml-1">{s.pointsBalance}</span> + </p> + </div> + {/* Arrow */} + <span className="material-symbols-rounded text-violet-600 text-[22px] shrink-0">chevron_right</span> + </a> + </div> + + {/* Right Column */} + <div className="flex-1 flex flex-col gap-4"> + {/* Account Settings Panel */} + <div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4"> + <h3 className="text-slate-900 text-lg font-bold">{s.accountTitle}</h3> + <div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5"> + {/* General Settings */} + <a + href={`/${locale}/settings/general`} + className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors cursor-pointer" + > + <span className="material-symbols-rounded text-violet-600 text-xl">tune</span> + <div> + <p className="text-slate-900 text-[15px] font-bold"> + {locale === 'en' ? 'General' : '通用设置'} + </p> + <p className="text-slate-400 text-xs"> + {locale === 'en' ? 'Language, notifications' : '语言、通知'} + </p> + </div> + </a> + {/* Feedback */} + <a + href={`/${locale}/settings/feedback`} + className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors cursor-pointer" + > + <span className="material-symbols-rounded text-violet-600 text-xl">feedback</span> + <div> + <p className="text-slate-900 text-[15px] font-bold"> + {locale === 'en' ? 'Feedback' : '意见反馈'} + </p> + <p className="text-slate-400 text-xs"> + {locale === 'en' ? 'Submit suggestions' : '提交问题与建议'} + </p> + </div> + </a> + {/* Account Data */} + <a + href={`/${locale}/profile`} + className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors cursor-pointer" + > + <span className="material-symbols-rounded text-violet-600 text-xl">person</span> + <div> + <p className="text-slate-900 text-[15px] font-bold"> + {locale === 'en' ? 'Account Data' : '账号数据'} + </p> + <p className="text-slate-400 text-xs"> + {locale === 'en' ? 'Profile information' : '账号信息'} + </p> + </div> + </a> + </div> + </div> + + {/* Legal Panel */} + <div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4"> + <h3 className="text-slate-900 text-lg font-bold">{s.legalTitle}</h3> + <div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5"> + {/* About */} + <a href={`/${locale}/about`} className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors"> + <span className="material-symbols-rounded text-violet-600 text-xl">info</span> + <div> + <p className="text-slate-900 text-[15px] font-bold"> + {locale === 'en' ? 'About' : '关于我们'} + </p> + <p className="text-slate-400 text-xs"> + {locale === 'en' ? 'Product vision' : '产品理念与定位'} + </p> + </div> + </a> + {/* Privacy */} + <a href={`/${locale}/privacy`} className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors"> + <span className="material-symbols-rounded text-violet-600 text-xl">security</span> + <div> + <p className="text-slate-900 text-[15px] font-bold">{s.privacy}</p> + <p className="text-slate-400 text-xs"> + {locale === 'en' ? 'Privacy policy' : '隐私保护说明'} + </p> + </div> + </a> + {/* Terms */} + <a href={`/${locale}/terms`} className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2.5 hover:bg-slate-100 transition-colors"> + <span className="material-symbols-rounded text-violet-600 text-xl">description</span> + <div> + <p className="text-slate-900 text-[15px] font-bold">{s.terms}</p> + <p className="text-slate-400 text-xs"> + {locale === 'en' ? 'User agreement' : '用户服务协议'} + </p> + </div> + </a> + </div> + </div> + + {/* Logout Button */} + <button + onClick={handleLogout} + disabled={logoutLoading} + className={`bg-white rounded-2xl px-5 py-3.5 border border-red-200 flex items-center justify-between hover:bg-red-50 transition-colors ${logoutLoading ? 'opacity-50 cursor-not-allowed' : ''}`} + > + <span className="text-red-500 text-sm font-medium"> + {logoutLoading ? (locale === 'en' ? 'Logging out...' : '退出中...') : s.logout} + </span> + <span className="material-symbols-rounded text-red-400 text-lg">logout</span> + </button> + </div> + </div> + </div> + ); +} diff --git a/web/src/components/Showcase.astro b/web/src/components/Showcase.astro new file mode 100644 index 0000000..a4db134 --- /dev/null +++ b/web/src/components/Showcase.astro @@ -0,0 +1,76 @@ +--- +import { t, type Locale } from '../i18n/utils'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const showcase = t(locale, 'showcase'); +const features = t(locale, 'features'); +--- + +<!-- Features Grid Section --> +<section class="w-full py-24 md:py-32" style="background-color: #F8F7FC;"> + <div class="max-w-7xl mx-auto px-6"> + <div class="reveal text-center mb-16"> + <p class="text-xs font-medium tracking-[0.15em] text-violet-600 uppercase mb-4"> + {showcase.title} + </p> + <h2 class="text-slate-900 text-3xl md:text-4xl font-bold mb-4"> + {features.c1Title} + </h2> + <p class="text-slate-500 text-lg max-w-xl mx-auto">{showcase.desc}</p> + </div> + + <div class="reveal stagger-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> + <div class="feature-card bg-white rounded-xl p-8 border border-violet-100"> + <div class="w-12 h-12 rounded-lg flex items-center justify-center mb-5 bg-violet-50"> + <svg class="w-6 h-6 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 10 10 0 000-20z"/><path d="M2 12h20"/></svg> + </div> + <h3 class="font-semibold text-lg mb-3 text-slate-900">{features.c1Title}</h3> + <p class="text-sm leading-relaxed text-slate-500">{features.c1Desc}</p> + </div> + + <div class="feature-card bg-white rounded-xl p-8 border border-violet-100"> + <div class="w-12 h-12 rounded-lg flex items-center justify-center mb-5 bg-violet-50"> + <svg class="w-6 h-6 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg> + </div> + <h3 class="font-semibold text-lg mb-3 text-slate-900">{features.c2Title}</h3> + <p class="text-sm leading-relaxed text-slate-500">{features.c2Desc}</p> + </div> + + <div class="feature-card bg-white rounded-xl p-8 border border-violet-100"> + <div class="w-12 h-12 rounded-lg flex items-center justify-center mb-5 bg-violet-50"> + <svg class="w-6 h-6 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> + </div> + <h3 class="font-semibold text-lg mb-3 text-slate-900">{features.c3Title}</h3> + <p class="text-sm leading-relaxed text-slate-500">{features.c3Desc}</p> + </div> + + <div class="feature-card bg-white rounded-xl p-8 border border-violet-100"> + <div class="w-12 h-12 rounded-lg flex items-center justify-center mb-5 bg-violet-50"> + <svg class="w-6 h-6 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg> + </div> + <h3 class="font-semibold text-lg mb-3 text-slate-900">{features.c4Title}</h3> + <p class="text-sm leading-relaxed text-slate-500">{features.c4Desc}</p> + </div> + + <div class="feature-card bg-white rounded-xl p-8 border border-violet-100"> + <div class="w-12 h-12 rounded-lg flex items-center justify-center mb-5 bg-violet-50"> + <svg class="w-6 h-6 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> + </div> + <h3 class="font-semibold text-lg mb-3 text-slate-900">{features.c5Title}</h3> + <p class="text-sm leading-relaxed text-slate-500">{features.c5Desc}</p> + </div> + + <div class="feature-card bg-white rounded-xl p-8 border border-violet-100"> + <div class="w-12 h-12 rounded-lg flex items-center justify-center mb-5 bg-violet-50"> + <svg class="w-6 h-6 text-violet-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> + </div> + <h3 class="font-semibold text-lg mb-3 text-slate-900">{features.c6Title}</h3> + <p class="text-sm leading-relaxed text-slate-500">{features.c6Desc}</p> + </div> + </div> + </div> +</section> diff --git a/web/src/components/StorePage.tsx b/web/src/components/StorePage.tsx new file mode 100644 index 0000000..37db51c --- /dev/null +++ b/web/src/components/StorePage.tsx @@ -0,0 +1,181 @@ +import { useMemo, useState } from 'react'; +import { usePackages, usePoints } from '../lib/resources'; +import { createCheckout } from '../lib/api'; + +interface Props { + locale: string; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string }; + store: { title: string; currentPoints: string; pointsLabel: string; rulesTitle: string; rule1: string; rule2: string; rule3: string; popularLabel: string; popularText: string; stepsTitle: string; step1: string; step2: string; step3: string; sideTitle: string; sideDesc: string }; + pricing: { p1Name: string; p1Badge: string; p1Price: string; p1Credits: string; p1Desc: string; p2Name: string; p2Price: string; p2Credits: string; p2Desc: string; p3Name: string; p3Badge: string; p3Price: string; p3Credits: string; p3Desc: string; p4Name: string; p4Price: string; p4Credits: string; p4Desc: string; buyNow: string }; +} + +interface PackageDisplay { + name: string; + badge: string; + price: string; + credits: string; + desc: string; + featured: boolean; + productCode: string; + creemProductId: string | null; + starterEligible: boolean; + isStarter: boolean; +} + +// Map product codes to display names from pricing translations +const PRODUCT_CODE_MAP: Record<string, string> = { + 'new_user_pack': 'p1', // 新人专享包 60积分 + 'starter_pack': 'p2', // 入门补充包 100积分 + 'popular_pack': 'p3', // 常用加量包 210积分 + 'premium_pack': 'p4', // 高频进阶包 415积分 +}; + +// Format price from cents to display string +function formatPrice(cents: number | null, currency: string | null): string { + if (cents === null || currency === null) return ''; + const dollars = cents / 100; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(dollars); +} + +function SidePanel({ s }: { s: Props['store'] }) { + return ( + <div className="w-full xl:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-4 shrink-0 overflow-y-auto"> + <div className="w-12 h-12 rounded-xl bg-violet-50 flex items-center justify-center"> + <span className="material-symbols-rounded text-violet-600 text-2xl">shopping_cart</span> + </div> + <p className="text-slate-900 text-lg font-bold">{s.sideTitle}</p> + <p className="text-slate-500 text-sm">{s.sideDesc}</p> + <div className="h-px bg-slate-100" /> + <p className="text-slate-400 text-xs font-semibold">{s.popularLabel}</p> + <div className="bg-amber-50 rounded-xl p-3.5 text-amber-700 text-sm">{s.popularText}</div> + <p className="text-slate-400 text-xs font-semibold mt-2">{s.stepsTitle}</p> + <div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">1</span>{s.step1}</div> + <div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">2</span>{s.step2}</div> + <div className="flex items-center gap-2.5 text-sm text-slate-600"><span className="w-5 h-5 rounded-full bg-violet-100 text-violet-600 text-xs font-bold flex items-center justify-center">3</span>{s.step3}</div> + </div> + ); +} + +export default function StorePage({ store: s, pricing: p }: Props) { + const pointsState = usePoints(); + const packagesState = usePackages(); + const points = pointsState.data ?? null; + const [purchasing, setPurchasing] = useState<string | null>(null); + + const packages = useMemo<PackageDisplay[]>(() => { + const packagesData = packagesState.data; + if (!packagesData) return []; + const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => { + const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1'; + const dynamicPrice = formatPrice(pkg.priceCents, pkg.currency); + return { + name: p[`${key}Name` as keyof typeof p] || pkg.productCode, + badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '', + price: dynamicPrice, + credits: `${pkg.credits} ${s.pointsLabel}`, + desc: p[`${key}Desc` as keyof typeof p] || '', + featured: pkg.productCode === 'popular_pack', + productCode: pkg.productCode, + creemProductId: pkg.creemProductId, + starterEligible: pkg.starterEligible, + isStarter: pkg.isStarter, + }; + }); + displayPkgs.sort((a, b) => { + const pkgA = packagesData.packages.find(pkg => pkg.productCode === a.productCode); + const pkgB = packagesData.packages.find(pkg => pkg.productCode === b.productCode); + return (pkgA?.sortOrder || 0) - (pkgB?.sortOrder || 0); + }); + return displayPkgs; + }, [packagesState.data, p, s.pointsLabel]); + + const loading = pointsState.loading || packagesState.loading; + + const handleBuy = async (pkg: PackageDisplay) => { + if (!pkg.creemProductId) return; + setPurchasing(pkg.productCode); + try { + const result = await createCheckout(pkg.productCode); + window.location.href = result.checkoutUrl; + } catch (error) { + console.error('Failed to create checkout:', error); + setPurchasing(null); + } + }; + + return ( + <div className="flex flex-col gap-5 min-h-full"> + <h1 className="text-slate-900 text-xl font-bold">{s.title}</h1> + + {/* Top: Points hero + rules */} + <div className="flex flex-col lg:flex-row gap-5"> + <div className="flex-1 rounded-2xl p-7 flex items-center gap-6" style={{ background: 'linear-gradient(135deg, #673AB7, #9C27B0)' }}> + <div className="w-[68px] h-[68px] rounded-full flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.14)' }}> + <span className="material-symbols-rounded text-white text-3xl">account_balance_wallet</span> + </div> + <div> + <p className="text-violet-200 text-sm">{s.currentPoints}</p> + <p className="text-white text-3xl font-bold"> + {loading ? '...' : points?.balance ?? 0} + <span className="text-base font-normal text-violet-200"> {s.pointsLabel}</span> + </p> + </div> + </div> + <div className="w-full lg:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3.5 shrink-0"> + <div className="flex items-center gap-2.5"> + <span className="material-symbols-rounded text-violet-600 text-lg">info</span> + <span className="text-slate-900 text-sm font-bold">{s.rulesTitle}</span> + </div> + <p className="text-slate-500 text-sm">{s.rule1}</p> + <p className="text-slate-500 text-sm">{s.rule2}</p> + <p className="text-slate-500 text-sm">{s.rule3}</p> + </div> + </div> + + {/* Mobile: Side panel below rules (visible only on mobile) */} + <div className="xl:hidden"> + <SidePanel s={s} /> + </div> + + {/* Body: Packages + side panel (desktop) */} + <div className="flex flex-col xl:flex-row gap-5 flex-1 min-h-0"> + <div className="flex-1 flex flex-col gap-4 overflow-y-auto"> + {loading ? ( + <div className="text-slate-500 text-center py-8">{s.sideDesc}</div> + ) : ( + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> + {packages.map((pkg) => ( + <div key={pkg.productCode} className={`bg-white rounded-2xl p-6 flex flex-col gap-3 border ${pkg.featured ? 'border-violet-400 ring-1 ring-violet-100' : 'border-slate-200'}`}> + <div className="flex items-center justify-between"> + <span className="text-slate-900 font-bold text-base">{pkg.name}</span> + {pkg.badge && <span className={`text-xs px-2.5 py-0.5 rounded-full ${pkg.featured ? 'bg-violet-600 text-white' : 'bg-amber-100 text-amber-700'}`}>{pkg.badge}</span>} + </div> + <p className="text-slate-900 text-2xl font-extrabold">{pkg.price}</p> + <p className="text-violet-600 text-sm font-medium">{pkg.credits}</p> + <p className="text-slate-500 text-sm">{pkg.desc}</p> + <button + className={`w-full py-2.5 rounded-lg font-semibold text-sm mt-auto ${pkg.featured ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-white text-violet-600 border border-violet-200 hover:bg-violet-50'} transition-colors disabled:opacity-50 disabled:cursor-not-allowed`} + disabled={pkg.isStarter && !pkg.starterEligible || purchasing === pkg.productCode || !pkg.creemProductId} + onClick={() => handleBuy(pkg)} + > + {pkg.isStarter && !pkg.starterEligible ? (s.rulesTitle.includes('已购') ? '已购买' : 'Purchased') : purchasing === pkg.productCode ? '...' : p.buyNow} + </button> + </div> + ))} + </div> + )} + </div> + + {/* Desktop: Side panel (visible only on xl+) */} + <div className="hidden xl:block"> + <SidePanel s={s} /> + </div> + </div> + </div> + ); +} diff --git a/web/src/components/Testimonials.astro b/web/src/components/Testimonials.astro new file mode 100644 index 0000000..1a91a9e --- /dev/null +++ b/web/src/components/Testimonials.astro @@ -0,0 +1,44 @@ +--- +import { t, type Locale } from '../i18n/utils'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const test = t(locale, 'testimonials'); +--- + +<section class="w-full py-24 md:py-32 px-6 md:px-20 bg-slate-900"> + <div class="max-w-7xl mx-auto"> + <h2 class="reveal text-white text-3xl md:text-[40px] font-bold text-center mb-12"> + {test.title} + </h2> + + <div class="grid grid-cols-1 md:grid-cols-3 gap-6"> + <div class="reveal stagger-1 bg-slate-800 rounded-2xl p-8 flex flex-col justify-between min-h-[200px]"> + <p class="text-slate-300 text-base leading-relaxed">"{test.t1Quote}"</p> + <div class="flex items-center gap-3 mt-6"> + <div class="w-10 h-10 bg-indigo-500 rounded-full shrink-0"></div> + <span class="text-white text-sm font-medium">{test.t1Name}</span> + </div> + </div> + + <div class="reveal stagger-2 bg-slate-800 rounded-2xl p-8 flex flex-col justify-between min-h-[200px]"> + <p class="text-slate-300 text-base leading-relaxed">"{test.t2Quote}"</p> + <div class="flex items-center gap-3 mt-6"> + <div class="w-10 h-10 bg-violet-500 rounded-full shrink-0"></div> + <span class="text-white text-sm font-medium">{test.t2Name}</span> + </div> + </div> + + <div class="reveal stagger-3 bg-slate-800 rounded-2xl p-8 flex flex-col justify-between min-h-[200px]"> + <p class="text-slate-300 text-base leading-relaxed">"{test.t3Quote}"</p> + <div class="flex items-center gap-3 mt-6"> + <div class="w-10 h-10 bg-cyan-500 rounded-full shrink-0"></div> + <span class="text-white text-sm font-medium">{test.t3Name}</span> + </div> + </div> + </div> + </div> +</section> diff --git a/web/src/components/navConfig.ts b/web/src/components/navConfig.ts new file mode 100644 index 0000000..304c051 --- /dev/null +++ b/web/src/components/navConfig.ts @@ -0,0 +1,23 @@ +export interface NavItem { + id: string; + icon: string; + label: string; + href: string; + sub?: { id: string; label: string; href: string }[]; +} + +export function getNavConfig(locale: string, d: { + navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; +}): NavItem[] { + return [ + { id: 'home', icon: 'home', label: d.navHome, href: `/${locale}/dashboard` }, + { id: 'store', icon: 'shopping_bag', label: d.navStore, href: `/${locale}/store` }, + { id: 'divination', icon: 'casino', label: d.navDivination, href: '', sub: [ + { id: 'manual', label: d.navManual, href: `/${locale}/divination/manual` }, + { id: 'auto', label: d.navAuto, href: `/${locale}/divination/auto` }, + ]}, + { id: 'history', icon: 'history', label: d.navHistory, href: `/${locale}/history` }, + { id: 'language', icon: 'language', label: d.navLanguage, href: `/${locale}/settings/general` }, + { id: 'settings', icon: 'settings', label: d.navSettings, href: `/${locale}/settings` }, + ]; +} diff --git a/web/src/i18n/utils.ts b/web/src/i18n/utils.ts new file mode 100644 index 0000000..0ee76f0 --- /dev/null +++ b/web/src/i18n/utils.ts @@ -0,0 +1,114 @@ +export type Locale = 'en' | 'zh' | 'zh_Hant'; +export const defaultLocale: Locale = 'en'; +export const locales: Locale[] = ['en', 'zh', 'zh_Hant']; + +export function isValidLocale(locale: string): locale is Locale { + return (locales as readonly string[]).includes(locale); +} + +export function getLocaleFromUrl(url: URL): Locale { + const [, locale] = url.pathname.split('/'); + if (isValidLocale(locale)) return locale; + return defaultLocale; +} + +export function getLocaleLabel(locale: Locale): string { + const labels: Record<Locale, string> = { zh: '简体中文', zh_Hant: '繁體中文', en: 'English' }; + return labels[locale]; +} + +export interface Translations { + nav: { features: string; pricing: string; about: string; getStarted: string }; + hero: { badge: string; headline: string; subtext: string; primaryCta: string; secondaryCta: string; trust: string }; + showcase: { title: string; desc: string; feature1Title: string; feature1Desc: string }; + testimonials: { title: string; t1Quote: string; t1Name: string; t2Quote: string; t2Name: string; t3Quote: string; t3Name: string }; + cta: { title: string; subtitle: string; button: string }; + footer: { brandName: string; desc: string; col1Title: string; col1Link1: string; col1Link2: string; col2Title: string; col2Link1: string; col2Link2: string; col3Title: string; col3Link1: string; col3Link2: string }; + features: { title: string; subtitle: string; tagline: string; c1Title: string; c1Desc: string; c2Title: string; c2Desc: string; c3Title: string; c3Desc: string; c4Title: string; c4Desc: string; c5Title: string; c5Desc: string; c6Title: string; c6Desc: string }; + pricing: { title: string; subtitle: string; p1Name: string; p1Badge: string; p1Price: string; p1Credits: string; p1Desc: string; p2Name: string; p2Price: string; p2Credits: string; p2Desc: string; p2Detail: string; p3Name: string; p3Badge: string; p3Price: string; p3Credits: string; p3Desc: string; p4Name: string; p4Price: string; p4Credits: string; p4Desc: string; p4Detail: string; buyNow: string }; + login: { welcome: string; subtitle: string; emailLabel: string; emailPlaceholder: string; codeLabel: string; codePlaceholder: string; sendCode: string; submit: string; agreePrefix: string; privacy: string; agreeAnd: string; terms: string }; + dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; greeting: string; greetingSub: string; heroTitle: string; heroDesc: string; heroCta: string; historyTitle: string; historyViewAll: string; logout: string }; + notifications: { title: string; loading: string; error: string; empty: string; markAllRead: string; markAllReadDone: string }; + store: { title: string; currentPoints: string; pointsLabel: string; rulesTitle: string; rule1: string; rule2: string; rule3: string; popularLabel: string; popularText: string; stepsTitle: string; step1: string; step2: string; step3: string; autoCredit: string; sideTitle: string; sideDesc: string }; + settings: { title: string; profileTitle: string; emailLabel: string; nameLabel: string; joinedLabel: string; pointsTitle: string; pointsBalance: string; accountTitle: string; changeName: string; changeAvatar: string; changeLanguage: string; legalTitle: string; privacy: string; terms: string; logout: string; logoutConfirm: string }; + profile: { avatarTitle: string; avatarHint: string; uploadBtn: string; formTitle: string; emailLabel: string; displayNameLabel: string; displayNamePlaceholder: string; bioLabel: string; bioPlaceholder: string; saveBtn: string; cancelBtn: string }; + divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideManual: string; guideAuto: string; yaoTitle: string; coinLabel: string; confirmBtn: string; summaryTitle: string; checkCategory: string; checkMethod: string; checkCost: string; submitBtn: string; shakeTitle: string; shakeBtn: string; hexPreview: string; progressLabel: string }; + history: { title: string; statTotal: string; statFollow: string; statLatest: string; filters: string; filterAll: string; filterCareer: string; filterLove: string; filterWealth: string; noResults: string; resultTitle: string; conclusion: string; suggestion: string; analysis: string; focus: string; warning: string; followUpTitle: string; followUpDesc: string; followUpBtn: string; chatTitle: string; chatPlaceholder: string; sendBtn: string; relatedActions: string; newDivination: string; viewHistory: string; followUpRules: string; followUpRule1: string; followUpRule2: string; basicInfo: string; ganzhi: string; hexagramDetail: string }; + result: { screenTitle: string; conclusion: string; suggestion: string; analysis: string; focusPoints: string; warning: string; basicInfo: string; divinationInfo: string; divinationTime: string; divinationMethod: string; questionType: string; question: string; autoMethod: string; manualMethod: string; hexagramDetail: string; hexagramDetailFailed: string; hexagramDetailRefused: string; copy: string; ganZhiInfo: string; wuXingWangShuai: string; ganZhiKongWang: string; termYueJian: string; termRiChen: string; termYuePo: string; termRiChong: string; pillarColumn: string; yearPillar: string; monthPillar: string; dayPillar: string; timePillar: string; ganZhiLabel: string; kongWangLabel: string; questionTypeCareer: string; questionTypeLove: string; questionTypeWealth: string; questionTypeFortune: string; questionTypeDream: string; questionTypeHealth: string; questionTypeStudy: string; questionTypeSearch: string; questionTypeOther: string; signTypeShangShang: string; signTypeZhongShang: string; signTypeZhongXia: string; signTypeXiaXia: string; yaoColSpirit: string; yaoColRelation: string; yaoColBranch: string; yaoColElement: string; yaoColChange: string; yaoColMark: string; followUpEntryHint: string; followUpEntryAction: string; followUpQuotaUsed: string; followUpViewHistory: string }; + general: { title: string; languageLabel: string; languageValue: string; privacyTitle: string; doNotSell: string; doNotSellHint: string; notificationTitle: string; allowNotification: string; saveSuccess: string; saveFailed: string }; + feedback: { title: string; typeLabel: string; typeBug: string; typeSuggestion: string; typeOther: string; contentLabel: string; contentPlaceholder: string; imagesLabel: string; anonymousLabel: string; anonymousHint: string; submitBtn: string; submitting: string; success: string; error: string; contentRequired: string; contentTooLong: string; tooManyImages: string; imageTooLarge: string }; +} + +const translations: Record<Locale, Translations> = { + zh: { + nav: { features: '功能', pricing: '定价', about: '关于', getStarted: '开始使用' }, + hero: { badge: '传承千年的东方智慧', headline: '以易经之名 寻心中所惑', subtext: '每一次签问,都是与自己的对话。觅爻将古老易经智慧与现代体验结合,让你在宁静中找到属于此刻的指引。', primaryCta: '免费开始签问', secondaryCta: '了解更多', trust: '已为 10,000+ 用户提供签问服务' }, + showcase: { title: '仪式感的签问体验', desc: '不同于简单的随机算法,觅爻在每一次签问中融入易经的哲学思考。静心、默念、抽取三步完成,却是一次内心的沉淀之旅。', feature1Title: '64卦精解', feature1Desc: '每一卦配有详细爻辞与今译' }, + testimonials: { title: '用户心声', t1Quote: '在最迷茫的时候,觅爻给了我一个方向。不管结果如何,那种静下心来的过程本身就很有帮助。', t1Name: '林小姐 · 产品经理', t2Quote: '界面很清爽,没有乱七八糟的广告。每次签问都像是一次心灵的短暂旅行。', t2Name: '张先生 · 创业者', t3Quote: '我是一个程序员,原本不信这些。但试了几次后发现,这种随机性反而让我看到平时忽略的可能性。', t3Name: '王先生 · 软件工程师' }, + cta: { title: '开始你的第一次签问', subtitle: '无需注册,立即体验。让古老的智慧,为现代的你指引方向。', button: '免费开始 →' }, + footer: { brandName: '觅爻签问', desc: '以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。', col1Title: '产品', col1Link1: '功能介绍', col1Link2: '定价', col2Title: '支持', col2Link1: '帮助中心', col2Link2: '联系我们', col3Title: '法律', col3Link1: '隐私政策', col3Link2: '服务条款' }, + features: { title: '功能特性', subtitle: '以古老智慧解读今时困惑,觅爻签问提供完整的易学体验', tagline: '从起卦到解读,每一步都精心设计', c1Title: '两种起卦方式', c1Desc: '手动起卦与自动起卦,灵活选择最适合你的方式。推荐使用手动起卦,卦象解读更准确。', c2Title: 'AI 解卦分析', c2Desc: '基于传统六爻卦象与周易哲学体系,结合AI智能分析,提供深度卦象解读与建议。', c3Title: '九类问题覆盖', c3Desc: '事业、情感、财富、运势、解梦、健康、学业、寻物等九大领域,全面覆盖日常生活所问。', c4Title: '追问互动', c4Desc: '每次解卦后可深入追问一次,针对卦象细节获取更多洞见,让解读更加全面深入。', c5Title: '历史记录', c5Desc: '自动保存所有解卦记录,包括卦象详情与AI解读。随时回顾历史,追踪问题变化趋势。', c6Title: '点数系统', c6Desc: '提供灵活积分套餐:新人专享、入门补充、常用加量、高频进阶。按需购买,自由使用。' }, + pricing: { title: '选择适合你的套餐', subtitle: '灵活积分套餐,按需选择,随时可用', p1Name: '新人专享包', p1Badge: '限购一次', p1Price: '$0.99', p1Credits: '60 积分', p1Desc: '最适合初次体验', p2Name: '入门补充包', p2Price: '$4.99', p2Credits: '100 积分', p2Desc: '日常解卦补充', p2Detail: '适量点数补充,经济实惠之选', p3Name: '常用加量包', p3Badge: '推荐', p3Price: '$7.99', p3Credits: '210 积分', p3Desc: '最适合日常使用', p4Name: '高频进阶包', p4Price: '$12.99', p4Credits: '415 积分', p4Desc: '重度使用优选', p4Detail: '大量点数储备,超值单价', buyNow: '立即购买' }, + login: { welcome: '欢迎', subtitle: '使用邮箱验证码快速登录或注册', emailLabel: '邮箱地址', emailPlaceholder: '请输入邮箱地址', codeLabel: '验证码', codePlaceholder: '请输入验证码', sendCode: '获取验证码', submit: '登录 / 注册', agreePrefix: '我已阅读并同意', privacy: '《隐私政策》', agreeAnd: '和', terms: '《服务条款》' }, + dashboard: { brandName: '觅爻签问', navHome: '首页', navStore: '商店', navDivination: '起卦', navManual: '手动起卦', navAuto: '自动起卦', navHistory: '历史解卦', navLanguage: '语言', navSettings: '设置', greeting: '下午好', greetingSub: '今天想要探寻什么方向?', heroTitle: '开始您的卦象之旅', heroDesc: '借助AI智能,探索未来的可能。心中有问,起卦便知。', heroCta: '立即起卦', historyTitle: '历史解卦', historyViewAll: '查看全部 →', logout: '退出登录' }, + notifications: { title: '通知中心', loading: '加载中...', error: '加载失败', empty: '暂无通知', markAllRead: '全部已读', markAllReadDone: '已全部标记为已读' }, + store: { title: '积分商店', currentPoints: '当前积分', pointsLabel: '积分', rulesTitle: '积分规则', rule1: '1 次起卦会消耗固定积分', rule2: '充值完成后积分实时入账', rule3: '新人专享包每个账号限购一次', popularLabel: '推荐选择', popularText: '常用加量包性价比最高,适合大多数用户日常使用。', stepsTitle: '支付流程', step1: '选择套餐并确认', step2: '完成支付', step3: '积分自动到账', autoCredit: '购买后自动到账', sideTitle: '购买后自动到账', sideDesc: '选择套餐并完成支付后,积分会同步到当前账号。' }, + settings: { title: '设置', profileTitle: '个人资料', emailLabel: '邮箱', nameLabel: '昵称', joinedLabel: '注册时间', pointsTitle: '积分余额', pointsBalance: '积分', accountTitle: '账号设置', changeName: '修改昵称', changeAvatar: '修改头像', changeLanguage: '切换语言', legalTitle: '法律条款', privacy: '隐私政策', terms: '服务条款', logout: '退出登录', logoutConfirm: '确定要退出登录吗?' }, + profile: { avatarTitle: '头像', avatarHint: '支持 PNG / JPG / WEBP,建议上传清晰正方形头像。', uploadBtn: '上传头像', formTitle: '基础资料', emailLabel: '邮箱', displayNameLabel: '昵称', displayNamePlaceholder: '请输入昵称', bioLabel: '个人简介', bioPlaceholder: '请输入个人简介', saveBtn: '保存', cancelBtn: '取消' }, + divination: { questionTitle: '提出你的问题', questionPlaceholder: '请输入你想问的问题...', categoryLabel: '问题类型', categories: '事业,感情,财富,运势,解梦,健康,学业,寻物,其他', timeTitle: '起卦时间', timeHint: '默认使用当前时间,也可手动选择', guideTitle: '起卦指引', guideManual: '手动起卦需要您亲自抛掷三枚铜钱六次,系统会根据结果生成卦象。请按照以下步骤操作:\n\n1. 心中默念问题\n2. 点击下方铜钱选择正反面\n3. 每爻抛掷三枚铜钱\n4. 重复六次完成起卦', guideAuto: '自动起卦由系统随机生成卦象,适合快速获取结果。请按照以下步骤操作:\n\n1. 心中默念问题\n2. 点击"摇卦"按钮\n3. 系统自动生成卦象', yaoTitle: '六爻铜钱', coinLabel: '点击铜钱选择正反面', confirmBtn: '确认此爻', summaryTitle: '提交前检查', checkCategory: '问题类型:事业', checkMethod: '起卦方式:手动起卦', checkCost: '解卦消耗:20 积分', submitBtn: '确认提交', shakeTitle: '摇卦', shakeBtn: '摇一摇', hexPreview: '卦象预览', progressLabel: '完成进度' }, + history: { title: '历史解卦', statTotal: '总卦数', statFollow: '可追问', statLatest: '最近一卦', filters: '快速筛选', filterAll: '全部', filterCareer: '事业', filterLove: '感情', filterWealth: '财富', noResults: '暂无解卦记录', resultTitle: '卦象解读', conclusion: '结论', suggestion: '建议', analysis: '详细分析', focus: '重点关注', warning: '以上解读由 AI 生成,仅供参考娱乐,不作为决策依据。', followUpTitle: '追问', followUpDesc: '对卦象有疑问?可以追问一次获取更深入的解读。', followUpBtn: '开始追问', chatTitle: '追问对话', chatPlaceholder: '输入你的追问...', sendBtn: '发送', relatedActions: '相关操作', newDivination: '重新起卦', viewHistory: '查看历史', followUpRules: '追问规则', followUpRule1: '每次解卦可追问一次', followUpRule2: '追问不消耗积分,完全免费', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象详情' }, + result: { screenTitle: '解卦结果', conclusion: '解卦结论', suggestion: '卦象建议', analysis: '具体解析', focusPoints: '断卦要点', warning: '卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。', basicInfo: '基础信息', divinationInfo: '起卦信息', divinationTime: '起卦时间', divinationMethod: '起卦方式', questionType: '问题类型', question: '占卜问题', autoMethod: '自动起卦', manualMethod: '手动起卦', hexagramDetail: '卦象详情', hexagramDetailFailed: '解卦失败,卦象详情暂不可用', hexagramDetailRefused: '暂不支持解卦,请调整问题后重试', copy: '复制', ganZhiInfo: '干支信息', wuXingWangShuai: '五行旺衰', ganZhiKongWang: '空亡信息', termYueJian: '月建', termRiChen: '日辰', termYuePo: '月破', termRiChong: '日冲', pillarColumn: '四柱', yearPillar: '年柱', monthPillar: '月柱', dayPillar: '日柱', timePillar: '时柱', ganZhiLabel: '干支', kongWangLabel: '空亡', questionTypeCareer: '事业', questionTypeLove: '情感', questionTypeWealth: '财富', questionTypeFortune: '运势', questionTypeDream: '解梦', questionTypeHealth: '健康', questionTypeStudy: '学业', questionTypeSearch: '寻物', questionTypeOther: '其他', signTypeShangShang: '上上签', signTypeZhongShang: '中上签', signTypeZhongXia: '中下签', signTypeXiaXia: '下下签', yaoColSpirit: '六神', yaoColRelation: '六亲', yaoColBranch: '地支', yaoColElement: '五行', yaoColChange: '动', yaoColMark: '标', followUpEntryHint: '可针对本次解卦继续追问 1 次', followUpEntryAction: '追问', followUpQuotaUsed: '本次会话追问次数已用完', followUpViewHistory: '查看历史记录' }, + general: { title: '通用设置', languageLabel: '语言设置', languageValue: '界面语言', privacyTitle: '隐私设置', doNotSell: '个性化广告推荐', doNotSellHint: '关闭后,我们不会将您的个人信息用于广告推荐', notificationTitle: '通知设置', allowNotification: '允许接收通知', saveSuccess: '保存成功', saveFailed: '保存失败' }, + feedback: { title: '意见反馈', typeLabel: '反馈类型', typeBug: '问题反馈', typeSuggestion: '功能建议', typeOther: '其他', contentLabel: '反馈内容', contentPlaceholder: '请详细描述您的问题或建议...', imagesLabel: '添加截图(最多3张)', anonymousLabel: '不上传我的个人信息', anonymousHint: '勾选后将不采集您的用户ID,仅采集设备信息用于问题排查', submitBtn: '提交反馈', submitting: '提交中...', success: '感谢您的反馈,我们会尽快处理', error: '提交失败,请稍后重试', contentRequired: '请输入反馈内容', contentTooLong: '反馈内容不能超过500字', tooManyImages: '最多只能上传3张图片', imageTooLarge: '图片大小不能超过5MB' }, + }, + zh_Hant: { + nav: { features: '功能', pricing: '定價', about: '關於', getStarted: '開始使用' }, + hero: { badge: '傳承千年的東方智慧', headline: '以易經之名 尋心中所惑', subtext: '每一次簽問,都是與自己的對話。覓爻將古老易經智慧與現代體驗結合,讓你在寧靜中找到屬於此刻的指引。', primaryCta: '免費開始簽問', secondaryCta: '瞭解更多', trust: '已為 10,000+ 用戶提供簽問服務' }, + showcase: { title: '儀式感的簽問體驗', desc: '不同於簡單的隨機算法,覓爻在每一次簽問中融入易經的哲學思考。靜心、默念、抽取三步完成,卻是一次內心的沉澱之旅。', feature1Title: '64卦精解', feature1Desc: '每一卦配有詳細爻辭與今譯' }, + testimonials: { title: '用戶心聲', t1Quote: '在最迷茫的時候,覓爻給了我一個方向。不管結果如何,那種靜下心來的過程本身就很有幫助。', t1Name: '林小姐 · 產品經理', t2Quote: '界面很清爽,沒有亂七八糟的廣告。每次簽問都像是一次心靈的短暫旅行。', t2Name: '張先生 · 創業者', t3Quote: '我是一個程序員,原本不信這些。但試了幾次後發現,這種隨機性反而讓我看到平時忽略的可能性。', t3Name: '王先生 · 軟件工程師' }, + cta: { title: '開始你的第一次簽問', subtitle: '無需註冊,立即體驗。讓古老的智慧,為現代的你指引方向。', button: '免費開始 →' }, + footer: { brandName: '覓爻簽問', desc: '以古老智慧,解讀今時困惑。讓每一次簽問,都成為與自己對話的機會。', col1Title: '產品', col1Link1: '功能介紹', col1Link2: '定價', col2Title: '支持', col2Link1: '幫助中心', col2Link2: '聯繫我們', col3Title: '法律', col3Link1: '隱私政策', col3Link2: '服務條款' }, + features: { title: '功能特性', subtitle: '以古老智慧解讀今時困惑,覓爻簽問提供完整的易學體驗', tagline: '從起卦到解讀,每一步都精心設計', c1Title: '兩種起卦方式', c1Desc: '手動起卦與自動起卦,靈活選擇最適合你的方式。推薦使用手動起卦,卦象解讀更準確。', c2Title: 'AI 解卦分析', c2Desc: '基於傳統六爻卦象與周易哲學體系,結合AI智能分析,提供深度卦象解讀與建議。', c3Title: '九類問題覆蓋', c3Desc: '事業、情感、財富、運勢、解夢、健康、學業、尋物等九大領域,全面覆蓋日常生活所問。', c4Title: '追問互動', c4Desc: '每次解卦後可深入追問一次,針對卦象細節獲取更多洞見,讓解讀更加全面深入。', c5Title: '歷史記錄', c5Desc: '自動保存所有解卦記錄,包括卦象詳情與AI解讀。隨時回顧歷史,追蹤問題變化趨勢。', c6Title: '點數系統', c6Desc: '提供靈活積分套餐:新人專享、入門補充、常用加量、高頻進階。按需購買,自由使用。' }, + pricing: { title: '選擇適合你的套餐', subtitle: '靈活積分套餐,按需選擇,隨時可用', p1Name: '新人專享包', p1Badge: '限購一次', p1Price: '$0.99', p1Credits: '60 積分', p1Desc: '最適合初次體驗', p2Name: '入門補充包', p2Price: '$4.99', p2Credits: '100 積分', p2Desc: '日常解卦補充', p2Detail: '適量點數補充,經濟實惠之選', p3Name: '常用加量包', p3Badge: '推薦', p3Price: '$7.99', p3Credits: '210 積分', p3Desc: '最適合日常使用', p4Name: '高頻進階包', p4Price: '$12.99', p4Credits: '415 積分', p4Desc: '重度使用優選', p4Detail: '大量點數儲備,超值單價', buyNow: '立即購買' }, + login: { welcome: '歡迎', subtitle: '使用郵箱驗證碼快速登錄或註冊', emailLabel: '郵箱地址', emailPlaceholder: '請輸入郵箱地址', codeLabel: '驗證碼', codePlaceholder: '請輸入驗證碼', sendCode: '獲取驗證碼', submit: '登錄 / 註冊', agreePrefix: '我已閱讀並同意', privacy: '《隱私政策》', agreeAnd: '和', terms: '《服務條款》' }, + dashboard: { brandName: '覓爻簽問', navHome: '首頁', navStore: '商店', navDivination: '起卦', navManual: '手動起卦', navAuto: '自動起卦', navHistory: '歷史解卦', navLanguage: '語言', navSettings: '設置', greeting: '下午好', greetingSub: '今天想要探尋什麼方向?', heroTitle: '開始您的卦象之旅', heroDesc: '借助AI智能,探索未來的可能。心中有問,起卦便知。', heroCta: '立即起卦', historyTitle: '歷史解卦', historyViewAll: '查看全部 →', logout: '退出登錄' }, + notifications: { title: '通知中心', loading: '加載中...', error: '加載失敗', empty: '暫無通知', markAllRead: '全部已讀', markAllReadDone: '已全部標記為已讀' }, + store: { title: '積分商店', currentPoints: '當前積分', pointsLabel: '積分', rulesTitle: '積分規則', rule1: '1 次起卦會消耗固定積分', rule2: '充值完成後積分實時入賬', rule3: '新人專享包每個賬號限購一次', popularLabel: '推薦選擇', popularText: '常用加量包性價比最高,適合大多數用戶日常使用。', stepsTitle: '支付流程', step1: '選擇套餐並確認', step2: '完成支付', step3: '積分自動到賬', autoCredit: '購買後自動到賬', sideTitle: '購買後自動到賬', sideDesc: '選擇套餐並完成支付後,積分會同步到當前賬號。' }, + settings: { title: '設置', profileTitle: '個人資料', emailLabel: '郵箱', nameLabel: '暱稱', joinedLabel: '註冊時間', pointsTitle: '積分餘額', pointsBalance: '積分', accountTitle: '賬號設置', changeName: '修改暱稱', changeAvatar: '修改頭像', changeLanguage: '切換語言', legalTitle: '法律條款', privacy: '隱私政策', terms: '服務條款', logout: '退出登錄', logoutConfirm: '確定要退出登錄嗎?' }, + profile: { avatarTitle: '頭像', avatarHint: '支持 PNG / JPG / WEBP,建議上傳清晰正方形頭像。', uploadBtn: '上傳頭像', formTitle: '基礎資料', emailLabel: '郵箱', displayNameLabel: '暱稱', displayNamePlaceholder: '請輸入暱稱', bioLabel: '個人簡介', bioPlaceholder: '請輸入個人簡介', saveBtn: '保存', cancelBtn: '取消' }, + divination: { questionTitle: '提出你的問題', questionPlaceholder: '請輸入你想問的問題...', categoryLabel: '問題類型', categories: '事業,感情,財富,運勢,解夢,健康,學業,尋物,其他', timeTitle: '起卦時間', timeHint: '默認使用當前時間,也可手動選擇', guideTitle: '起卦指引', guideManual: '手動起卦需要您親自拋擲三枚銅錢六次,系統會根據結果生成卦象。請按照以下步驟操作:\n\n1. 心中默念問題\n2. 點擊下方銅錢選擇正反面\n3. 每爻拋擲三枚銅錢\n4. 重複六次完成起卦', guideAuto: '自動起卦由系統隨機生成卦象,適合快速獲取結果。請按照以下步驟操作:\n\n1. 心中默念問題\n2. 點擊"搖卦"按鈕\n3. 系統自動生成卦象', yaoTitle: '六爻銅錢', coinLabel: '點擊銅錢選擇正反面', confirmBtn: '確認此爻', summaryTitle: '提交前檢查', checkCategory: '問題類型:事業', checkMethod: '起卦方式:手動起卦', checkCost: '解卦消耗:20 積分', submitBtn: '確認提交', shakeTitle: '搖卦', shakeBtn: '搖一搖', hexPreview: '卦象預覽', progressLabel: '完成進度' }, + history: { title: '歷史解卦', statTotal: '總卦數', statFollow: '可追問', statLatest: '最近一卦', filters: '快速篩選', filterAll: '全部', filterCareer: '事業', filterLove: '感情', filterWealth: '財富', noResults: '暫無解卦記錄', resultTitle: '卦象解讀', conclusion: '結論', suggestion: '建議', analysis: '詳細分析', focus: '重點關注', warning: '以上解讀由 AI 生成,僅供參考娛樂,不作為決策依據。', followUpTitle: '追問', followUpDesc: '對卦象有疑問?可以追問一次獲取更深入的解讀。', followUpBtn: '開始追問', chatTitle: '追問對話', chatPlaceholder: '輸入你的追問...', sendBtn: '發送', relatedActions: '相關操作', newDivination: '重新起卦', viewHistory: '查看歷史', followUpRules: '追問規則', followUpRule1: '每次解卦可追問一次', followUpRule2: '追問不消耗積分,完全免費', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象詳情' }, + result: { screenTitle: '解卦結果', conclusion: '解卦結論', suggestion: '卦象建議', analysis: '具體解析', focusPoints: '斷卦要點', warning: '卦象解讀結果均由AI生成,僅供娛樂參考,切不可作為商業、醫療等專業領域的決策依據。理性看待卦象,自由掌握人生。', basicInfo: '基礎信息', divinationInfo: '起卦信息', divinationTime: '起卦時間', divinationMethod: '起卦方式', questionType: '問題類型', question: '占卜問題', autoMethod: '自動起卦', manualMethod: '手動起卦', hexagramDetail: '卦象詳情', hexagramDetailFailed: '解卦失敗,卦象詳情暫不可用', hexagramDetailRefused: '暫不支持解卦,請調整問題後重試', copy: '複製', ganZhiInfo: '干支信息', wuXingWangShuai: '五行旺衰', ganZhiKongWang: '空亡信息', termYueJian: '月建', termRiChen: '日辰', termYuePo: '月破', termRiChong: '日沖', pillarColumn: '四柱', yearPillar: '年柱', monthPillar: '月柱', dayPillar: '日柱', timePillar: '時柱', ganZhiLabel: '干支', kongWangLabel: '空亡', questionTypeCareer: '事業', questionTypeLove: '情感', questionTypeWealth: '財富', questionTypeFortune: '運勢', questionTypeDream: '解夢', questionTypeHealth: '健康', questionTypeStudy: '學業', questionTypeSearch: '尋物', questionTypeOther: '其他', signTypeShangShang: '上上簽', signTypeZhongShang: '中上簽', signTypeZhongXia: '中下簽', signTypeXiaXia: '下下簽', yaoColSpirit: '六神', yaoColRelation: '六親', yaoColBranch: '地支', yaoColElement: '五行', yaoColChange: '動', yaoColMark: '標', followUpEntryHint: '可針對本次解卦繼續追問 1 次', followUpEntryAction: '追問', followUpQuotaUsed: '本次會話追問次數已用完', followUpViewHistory: '查看歷史記錄' }, + general: { title: '通用設定', languageLabel: '語言設置', languageValue: '介面語言', privacyTitle: '隱私設置', doNotSell: '個人化廣告推薦', doNotSellHint: '關閉後,我們不會將您的個人資訊用於廣告推薦', notificationTitle: '通知設置', allowNotification: '允許接收通知', saveSuccess: '保存成功', saveFailed: '保存失敗' }, + feedback: { title: '意見回饋', typeLabel: '回饋類型', typeBug: '問題回饋', typeSuggestion: '功能建議', typeOther: '其他', contentLabel: '回饋內容', contentPlaceholder: '請詳細描述您的問題或建議...', imagesLabel: '添加截圖(最多3張)', anonymousLabel: '不上傳我的個人信息', anonymousHint: '勾選後將不採集您的用戶ID,僅採集設備信息用於問題排查', submitBtn: '提交回饋', submitting: '提交中...', success: '感謝您的回饋,我們會盡快處理', error: '提交失敗,請稍後重試', contentRequired: '請輸入回饋內容', contentTooLong: '回饋內容不能超過500字', tooManyImages: '最多只能上傳3張圖片', imageTooLarge: '圖片大小不能超過5MB' }, + }, + en: { + nav: { features: 'Features', pricing: 'Pricing', about: 'About', getStarted: 'Get Started' }, + hero: { badge: 'Ancient Eastern Wisdom', headline: 'Seek Answers Through the Wisdom of I Ching', subtext: 'Every divination is a dialogue with yourself. MeiYao combines ancient I Ching wisdom with modern experience, guiding you to find clarity in tranquility.', primaryCta: 'Start Free', secondaryCta: 'Learn More', trust: 'Trusted by 10,000+ users' }, + showcase: { title: 'A Ritualistic Divination Experience', desc: 'Unlike simple random algorithms, MeiYao infuses every divination with the philosophical depth of I Ching. Three steps — calm your mind, focus your intention, draw your hexagram — yet it becomes a journey of inner reflection.', feature1Title: '64 Hexagram Interpretations', feature1Desc: 'Each hexagram comes with detailed line texts and modern commentary' }, + testimonials: { title: 'What Users Say', t1Quote: 'When I was most lost, MeiYao gave me direction. Regardless of the result, the process of calming down was itself very helpful.', t1Name: 'Ms. Lin · Product Manager', t2Quote: 'The interface is clean, no annoying ads. Each divination feels like a brief journey for the soul.', t2Name: 'Mr. Zhang · Entrepreneur', t3Quote: "I'm a programmer and didn't believe in this stuff. But after trying it a few times, the randomness actually helped me see possibilities I'd been overlooking.", t3Name: 'Mr. Wang · Software Engineer' }, + cta: { title: 'Begin Your First Divination', subtitle: 'No registration needed. Let ancient wisdom guide your modern life.', button: 'Start Free →' }, + footer: { brandName: 'MeiYao Divination', desc: 'Using ancient wisdom to interpret modern confusion. Let every divination become a chance to dialogue with yourself.', col1Title: 'Product', col1Link1: 'Features', col1Link2: 'Pricing', col2Title: 'Support', col2Link1: 'Help Center', col2Link2: 'Contact Us', col3Title: 'Legal', col3Link1: 'Privacy Policy', col3Link2: 'Terms of Service' }, + features: { title: 'Features', subtitle: 'Ancient wisdom meets modern困惑, MeiYao provides a complete I Ching experience', tagline: 'From casting to interpretation, every step is carefully designed', c1Title: 'Two Casting Methods', c1Desc: 'Manual and auto casting — choose what suits you best. Manual casting is recommended for more accurate readings.', c2Title: 'AI Analysis', c2Desc: 'Combining traditional Six-Line hexagrams with AI intelligence for in-depth interpretation and suggestions.', c3Title: '9 Question Categories', c3Desc: 'Career, love, wealth, fortune, dreams, health, study, lost items, and more — covering all aspects of daily life.', c4Title: 'Follow-up Questions', c4Desc: 'Ask one follow-up question after each reading for deeper insights into specific hexagram details.', c5Title: 'History', c5Desc: 'All readings are automatically saved with full hexagram details and AI interpretations. Review anytime.', c6Title: 'Credits System', c6Desc: 'Flexible credit packages: starter, basic, popular, and premium. Purchase as needed, use freely.' }, + pricing: { title: 'Choose Your Plan', subtitle: 'Flexible credit packages, pay as you go', p1Name: 'Starter Pack', p1Badge: 'Once Only', p1Price: '$0.99', p1Credits: '60 credits', p1Desc: 'Best for first-timers', p2Name: 'Basic Pack', p2Price: '$4.99', p2Credits: '100 credits', p2Desc: 'Daily supplement', p2Detail: 'Affordable credit refill', p3Name: 'Popular Pack', p3Badge: 'Popular', p3Price: '$7.99', p3Credits: '210 credits', p3Desc: 'Best for daily use', p4Name: 'Premium Pack', p4Price: '$12.99', p4Credits: '415 credits', p4Desc: 'Best value per credit', p4Detail: 'Bulk credits at best unit price', buyNow: 'Buy Now' }, + login: { welcome: 'Welcome', subtitle: 'Sign in or sign up with email verification code', emailLabel: 'Email', emailPlaceholder: 'Enter your email', codeLabel: 'Verification Code', codePlaceholder: 'Enter code', sendCode: 'Send Code', submit: 'Sign In / Sign Up', agreePrefix: 'I have read and agree to the ', privacy: 'Privacy Policy', agreeAnd: ' and ', terms: 'Terms of Service' }, + dashboard: { brandName: 'MeiYao Divination', navHome: 'Home', navStore: 'Store', navDivination: 'Divination', navManual: 'Manual Cast', navAuto: 'Auto Cast', navHistory: 'History', navLanguage: 'Language', navSettings: 'Settings', greeting: 'Good afternoon', greetingSub: 'What would you like to explore today?', heroTitle: 'Begin Your Hexagram Journey', heroDesc: 'Explore future possibilities with AI. Ask your question, cast your hexagram.', heroCta: 'Start Divination', historyTitle: 'Recent Readings', historyViewAll: 'View All →', logout: 'Sign Out' }, + notifications: { title: 'Notifications', loading: 'Loading...', error: 'Failed to load', empty: 'No notifications', markAllRead: 'Mark All Read', markAllReadDone: 'All marked as read' }, + store: { title: 'Credits Store', currentPoints: 'Current Credits', pointsLabel: 'credits', rulesTitle: 'Credits Rules', rule1: '1 divination costs a fixed number of credits', rule2: 'Credits are added instantly after purchase', rule3: 'Starter Pack is limited to one per account', popularLabel: 'Recommended', popularText: 'Popular Pack offers the best value for most users.', stepsTitle: 'Payment Steps', step1: 'Select a package', step2: 'Complete payment', step3: 'Credits added automatically', autoCredit: 'Auto-delivery after purchase', sideTitle: 'Auto-delivery after purchase', sideDesc: 'Credits are synced to your account immediately after payment.' }, + settings: { title: 'Settings', profileTitle: 'Profile', emailLabel: 'Email', nameLabel: 'Name', joinedLabel: 'Joined', pointsTitle: 'Credits Balance', pointsBalance: 'credits', accountTitle: 'Account Settings', changeName: 'Change Name', changeAvatar: 'Change Avatar', changeLanguage: 'Change Language', legalTitle: 'Legal', privacy: 'Privacy Policy', terms: 'Terms of Service', logout: 'Sign Out', logoutConfirm: 'Are you sure you want to sign out?' }, + profile: { avatarTitle: 'Avatar', avatarHint: 'Supports PNG / JPG / WEBP. Square images recommended.', uploadBtn: 'Upload Avatar', formTitle: 'Basic Info', emailLabel: 'Email', displayNameLabel: 'Display Name', displayNamePlaceholder: 'Enter display name', bioLabel: 'Bio', bioPlaceholder: 'Enter your bio', saveBtn: 'Save', cancelBtn: 'Cancel' }, + divination: { questionTitle: 'Ask Your Question', questionPlaceholder: 'Enter your question...', categoryLabel: 'Category', categories: 'Career,Love,Wealth,Fortune,Dreams,Health,Study,Lost Items,Other', timeTitle: 'Casting Time', timeHint: 'Uses current time by default, or pick manually', guideTitle: 'Guide', guideManual: 'Manual casting requires you to toss three coins six times. Follow these steps:\n\n1. Focus on your question\n2. Click coins below to set inscription/pattern\n3. Toss three coins per line\n4. Repeat six times to complete', guideAuto: 'Auto casting generates a hexagram randomly. Follow these steps:\n\n1. Focus on your question\n2. Click "Shake" button\n3. System generates the hexagram', yaoTitle: 'Six Lines', coinLabel: 'Click coins to set inscription/pattern', confirmBtn: 'Confirm Line', summaryTitle: 'Review Before Submit', checkCategory: 'Category: Career', checkMethod: 'Method: Manual Cast', checkCost: 'Cost: 20 credits', submitBtn: 'Confirm & Submit', shakeTitle: 'Shake', shakeBtn: 'Shake', hexPreview: 'Hexagram Preview', progressLabel: 'Progress' }, + history: { title: 'Reading History', statTotal: 'Total', statFollow: 'Follow-up', statLatest: 'Latest', filters: 'Quick Filters', filterAll: 'All', filterCareer: 'Career', filterLove: 'Love', filterWealth: 'Wealth', noResults: 'No readings yet', resultTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focus: 'Key Focus', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', followUpTitle: 'Follow-up', followUpDesc: 'Have questions about the reading? Ask one follow-up for deeper insights.', followUpBtn: 'Start Follow-up', chatTitle: 'Follow-up Chat', chatPlaceholder: 'Enter your follow-up question...', sendBtn: 'Send', relatedActions: 'Related Actions', newDivination: 'New Reading', viewHistory: 'View History', followUpRules: 'Follow-up Rules', followUpRule1: 'One follow-up per reading', followUpRule2: 'Follow-up is completely free', basicInfo: 'Basic Info', ganzhi: 'Stem-Branch Info', hexagramDetail: 'Hexagram Details' }, + result: { screenTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focusPoints: 'Key Points', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', basicInfo: 'Basic Info', divinationInfo: 'Casting Info', divinationTime: 'Casting Time', divinationMethod: 'Method', questionType: 'Category', question: 'Question', autoMethod: 'Auto Cast', manualMethod: 'Manual Cast', hexagramDetail: 'Hexagram Details', hexagramDetailFailed: 'Reading failed, details unavailable', hexagramDetailRefused: 'Reading not supported, please adjust your question', copy: 'Copy', ganZhiInfo: 'Stem-Branch', wuXingWangShuai: 'Five Elements', ganZhiKongWang: 'Void', termYueJian: '月建', termRiChen: '日辰', termYuePo: '月破', termRiChong: '日冲', pillarColumn: '四柱', yearPillar: '年柱', monthPillar: '月柱', dayPillar: '日柱', timePillar: '时柱', ganZhiLabel: '干支', kongWangLabel: '空亡', questionTypeCareer: 'Career', questionTypeLove: 'Love', questionTypeWealth: 'Wealth', questionTypeFortune: 'Fortune', questionTypeDream: 'Dreams', questionTypeHealth: 'Health', questionTypeStudy: 'Study', questionTypeSearch: 'Lost Items', questionTypeOther: 'Other', signTypeShangShang: '上上签', signTypeZhongShang: '中上签', signTypeZhongXia: '中下签', signTypeXiaXia: '下下签', yaoColSpirit: '六神', yaoColRelation: '六亲', yaoColBranch: '地支', yaoColElement: '五行', yaoColChange: '动', yaoColMark: '标', followUpEntryHint: 'You can ask one follow-up question', followUpEntryAction: 'Follow-up', followUpQuotaUsed: 'Follow-up quota used for this session', followUpViewHistory: 'View History' }, + general: { title: 'General Settings', languageLabel: 'Language', languageValue: 'Interface Language', privacyTitle: 'Privacy', doNotSell: 'Personalized Ads', doNotSellHint: 'When off, your personal info won\'t be used for ad recommendations', notificationTitle: 'Notifications', allowNotification: 'Allow notifications', saveSuccess: 'Saved successfully', saveFailed: 'Failed to save' }, + feedback: { title: 'Feedback', typeLabel: 'Feedback Type', typeBug: 'Bug', typeSuggestion: 'Suggestion', typeOther: 'Other', contentLabel: 'Content', contentPlaceholder: 'Please describe your issue or suggestion in detail...', imagesLabel: 'Add Screenshots (max 3)', anonymousLabel: 'Do not upload my personal information', anonymousHint: 'If checked, your user ID will not be collected. Device info will still be collected for troubleshooting.', submitBtn: 'Submit Feedback', submitting: 'Submitting...', success: 'Thank you for your feedback. We will process it soon.', error: 'Failed to submit. Please try again.', contentRequired: 'Please enter feedback content', contentTooLong: 'Feedback content cannot exceed 500 characters', tooManyImages: 'Maximum 3 images allowed', imageTooLarge: 'Image size cannot exceed 5MB' }, + }, +}; + +export function t<K extends keyof Translations>(locale: Locale, section: K): Translations[K] { + return translations[locale][section]; +} + +export function localePath(locale: Locale, path: string): string { + return `/${locale}${path}`; +} diff --git a/web/src/layouts/App.astro b/web/src/layouts/App.astro new file mode 100644 index 0000000..27d277f --- /dev/null +++ b/web/src/layouts/App.astro @@ -0,0 +1,25 @@ +--- +import '../styles/global.css'; +import '../styles/animations.css'; + +interface Props { + locale: import('../i18n/utils').Locale; +} + +const { locale } = Astro.props; +--- + +<!doctype html> +<html lang={locale}> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" /> + </head> + <body class="bg-slate-50 text-slate-900 antialiased"> + <main> + <slot /> + </main> + </body> +</html> diff --git a/web/src/layouts/Auth.astro b/web/src/layouts/Auth.astro new file mode 100644 index 0000000..d5d7f23 --- /dev/null +++ b/web/src/layouts/Auth.astro @@ -0,0 +1,24 @@ +--- +import '../styles/global.css'; +import '../styles/animations.css'; + +interface Props { + locale: import('../i18n/utils').Locale; +} + +const { locale } = Astro.props; +--- + +<!doctype html> +<html lang={locale}> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + </head> + <body class="bg-white text-slate-900 antialiased"> + <main> + <slot /> + </main> + </body> +</html> diff --git a/web/src/layouts/Marketing.astro b/web/src/layouts/Marketing.astro new file mode 100644 index 0000000..4c17300 --- /dev/null +++ b/web/src/layouts/Marketing.astro @@ -0,0 +1,45 @@ +--- +import Navbar from '../components/Navbar.astro'; +import Footer from '../components/Footer.astro'; +import '../styles/global.css'; +import '../styles/animations.css'; + +interface Props { + locale: import('../i18n/utils').Locale; +} + +const { locale } = Astro.props; +--- + +<!doctype html> +<html lang={locale}> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" /> + </head> + <body class="bg-white text-slate-900 antialiased"> + <Navbar locale={locale} /> + <main> + <slot /> + </main> + <Footer locale={locale} /> + <script> + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + observer.unobserve(entry.target); + } + }); + }, + { rootMargin: '0px 0px -60px 0px', threshold: 0.1 } + ); + document.querySelectorAll('.reveal, .reveal-left, .reveal-right, .reveal-scale').forEach((el) => { + observer.observe(el); + }); + </script> + </body> +</html> diff --git a/web/src/lib/api-client.ts b/web/src/lib/api-client.ts new file mode 100644 index 0000000..bbf1c3a --- /dev/null +++ b/web/src/lib/api-client.ts @@ -0,0 +1,51 @@ +const apiBase = (): string => import.meta.env.PUBLIC_API_URL || ''; + +export function apiUrl(path: string): string { + return path.startsWith('http') ? path : `${apiBase()}${path}`; +} + +export class ApiError extends Error { + status: number; + code?: string; + detail?: string; + + constructor(status: number, title: string, code?: string, detail?: string) { + super(title); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.detail = detail; + } +} + +export async function toApiError(res: Response): Promise<ApiError> { + try { + const body = await res.json(); + return new ApiError( + res.status, + body.title || body.detail || `Request failed (${res.status})`, + body.code, + body.detail, + ); + } catch { + return new ApiError(res.status, `Request failed (${res.status})`); + } +} + +export function jsonHeaders(options?: RequestInit): Headers { + const headers = new Headers(options?.headers); + if (!headers.has('Content-Type') && !(options?.body instanceof FormData)) { + headers.set('Content-Type', 'application/json'); + } + return headers; +} + +export async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> { + const res = await fetch(apiUrl(path), { + ...options, + headers: jsonHeaders(options), + }); + if (!res.ok) throw await toApiError(res); + if (res.status === 204) return undefined as T; + return res.json() as Promise<T>; +} diff --git a/web/src/lib/api-routes.ts b/web/src/lib/api-routes.ts new file mode 100644 index 0000000..044761a --- /dev/null +++ b/web/src/lib/api-routes.ts @@ -0,0 +1,37 @@ +export const API_ROUTES = { + auth: { + sendOtp: '/api/v1/auth/otp/send', + emailSession: '/api/v1/auth/email-session', + refreshSession: '/api/v1/auth/sessions/refresh', + deleteSession: '/api/v1/auth/sessions', + }, + users: { + profile: '/api/v1/users/me/profile', + updateProfile: '/api/v1/users/me/profile', + updateSettings: '/api/v1/users/me/settings', + avatarUploadUrl: '/api/v1/users/me/avatar/upload-url', + uploadAvatar: '/api/v1/users/me/avatar', + }, + points: { + balance: '/api/v1/points/balance', + packages: '/api/v1/points/packages', + }, + payments: { + creemCheckout: '/api/v1/payments/creem/checkouts', + }, + notifications: { + list: '/api/v1/notifications', + unreadCount: '/api/v1/notifications/unread-count', + markRead: (id: string) => `/api/v1/notifications/${id}/read`, + markAllRead: '/api/v1/notifications/mark-all-read', + }, + agent: { + history: '/api/v1/agent/history', + historyByThread: (threadId: string) => `/api/v1/agent/history?threadId=${threadId}`, + runs: '/api/v1/agent/runs', + runEvents: (threadId: string) => `/api/v1/agent/runs/${threadId}/events`, + }, + feedback: { + submit: '/api/v1/feedback', + }, +} as const; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..0a06c29 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,784 @@ +/** + * Typed API client for backend endpoints. + * Wraps authFetch for authenticated requests. + */ + +import { authFetch } from './auth'; +import { API_ROUTES } from './api-routes'; + +// --- User Profile --- + +export interface UserProfile { + user_id: string; + display_name: string; + bio: string | null; + avatar_path: string | null; + avatar_url: string | null; + settings: { + version: number; + preferences: { + language: string; + timezone: string; + }; + privacy: { + can_sell: boolean; + profile_visibility: string; + }; + notification: { + allow_notifications: boolean; + allow_vibration: boolean; + }; + divination_tutorial: { + divination_entry_shown: boolean; + auto_divination_shown: boolean; + manual_divination_shown: boolean; + }; + }; + updated_at: string; +} + +export interface UpdateProfileRequest { + display_name?: string; + bio?: string; + avatar_path?: string; +} + +export interface ProfileSettings { + version: number; + preferences: { + language: string; + timezone: string; + }; + privacy: { + can_sell: boolean; + profile_visibility: string; + }; + notification: { + allow_notifications: boolean; + allow_vibration: boolean; + }; + divination_tutorial: { + divination_entry_shown: boolean; + auto_divination_shown: boolean; + manual_divination_shown: boolean; + }; +} + +export interface UpdateSettingsRequest { + settings: ProfileSettings; +} + +export interface AvatarUploadUrlRequest { + mime_type: string; + file_size: number; + ext: string; +} + +export interface AvatarUploadUrlResponse { + bucket: string; + path: string; + upload_url: string; + expires_in: number; +} + +export function getUserProfile(): Promise<UserProfile> { + return authFetch<UserProfile>(API_ROUTES.users.profile); +} + +export function updateUserProfile(data: UpdateProfileRequest): Promise<UserProfile> { + return authFetch<UserProfile>(API_ROUTES.users.updateProfile, { + method: 'PATCH', + body: JSON.stringify(data), + }); +} + +export function getAvatarUploadUrl(data: AvatarUploadUrlRequest): Promise<AvatarUploadUrlResponse> { + return authFetch<AvatarUploadUrlResponse>(API_ROUTES.users.avatarUploadUrl, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export function uploadAvatar(file: File): Promise<UserProfile> { + const formData = new FormData(); + formData.append('file', file); + return authFetch<UserProfile>(API_ROUTES.users.uploadAvatar, { + method: 'POST', + body: formData, + }); +} + +export function updateUserSettings(data: UpdateSettingsRequest): Promise<UserProfile> { + return authFetch<UserProfile>(API_ROUTES.users.updateSettings, { + method: 'PATCH', + body: JSON.stringify(data), + }); +} + +// --- Points --- + +export interface PointsBalance { + balance: number; + frozenBalance: number; + availableBalance: number; + runCost: number; + canRun: boolean; +} + +export interface PackageInfo { + productCode: string; + appStoreProductId: string | null; + creemProductId: string | null; + type: 'starter' | 'regular'; + credits: number; + isStarter: boolean; + starterEligible: boolean; + sortOrder: number; + priceCents: number | null; + currency: string | null; +} + +export interface PackagesResponse { + packages: PackageInfo[]; +} + +export interface CreateCheckoutRequest { + productCode: string; +} + +export interface CreateCheckoutResponse { + checkoutId: string; + checkoutUrl: string; +} + +export function getPointsBalance(): Promise<PointsBalance> { + return authFetch<PointsBalance>(API_ROUTES.points.balance); +} + +export function invalidatePointsCache(): void { + // Points caching lives in resources.ts. Kept for older imports during rollout. +} + +export function getPackages(): Promise<PackagesResponse> { + return authFetch<PackagesResponse>(API_ROUTES.points.packages); +} + +export function createCheckout(productCode: string): Promise<CreateCheckoutResponse> { + return authFetch<CreateCheckoutResponse>(API_ROUTES.payments.creemCheckout, { + method: 'POST', + body: JSON.stringify({ productCode }), + }); +} + +// --- Notifications --- + +export interface NotificationPayloadNone { + action: 'none'; +} + +export interface NotificationPayloadRoute { + action: 'open_route'; + route: string; + entity_id?: string | null; + tab?: string | null; +} + +export interface NotificationPayloadUrl { + action: 'open_url'; + url: string; +} + +export type NotificationPayload = NotificationPayloadNone | NotificationPayloadRoute | NotificationPayloadUrl; + +export interface NotificationItem { + id: string; + notificationId: string; + type: string; + title: string; + body: string; + payload: NotificationPayload; + isRead: boolean; + readAt: string | null; + createdAt: string; +} + +export interface NotificationListResponse { + items: NotificationItem[]; + nextCursor: string | null; + hasMore: boolean; +} + +export interface UnreadCount { + count: number; +} + +export function getNotifications(locale?: string, limit = 20, cursor?: string): Promise<NotificationListResponse> { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + if (locale) params.set('locale', locale); + if (cursor) params.set('cursor', cursor); + const query = params.toString(); + return authFetch<NotificationListResponse>(`${API_ROUTES.notifications.list}?${query}`); +} + +export function getUnreadNotificationCount(): Promise<UnreadCount> { + return authFetch<UnreadCount>(API_ROUTES.notifications.unreadCount); +} + +export function markNotificationRead(id: string, locale?: string): Promise<NotificationItem> { + const params = locale ? `?locale=${locale}` : ''; + return authFetch<NotificationItem>(API_ROUTES.notifications.markRead(id) + params, { + method: 'PATCH', + }); +} + +export function markAllNotificationsRead(): Promise<{ updatedCount: number }> { + return authFetch<{ updatedCount: number }>(API_ROUTES.notifications.markAllRead, { + method: 'PATCH', + }); +} + +// --- Agent History --- + +export interface HistoryAgentOutput { + status?: string | null; + sign_level?: string | null; + conclusion?: string[]; + focus_points?: string[]; + advice?: string[]; + keywords?: string[]; + answer?: string | null; + divination_derived?: { + question?: string; + questionType?: string; + guaName?: string; + gua_name?: string; + binaryCode?: string; + changedBinaryCode?: string; + targetGuaName?: string; + upperName?: string; + lowerName?: string; + divinationMethod?: string; + divinationTime?: string; + ganzhi?: { + yearGanZhi?: string; + monthGanZhi?: string; + dayGanZhi?: string; + timeGanZhi?: string; + yearKongWang?: string; + monthKongWang?: string; + dayKongWang?: string; + timeKongWang?: string; + yueJian?: string; + riChen?: string; + yuePo?: string; + riChong?: string; + }; + wuXingStatuses?: Record<string, string>; + yaoInfoList?: Array<{ + position?: number; + spiritName?: string; + relationName?: string; + tiganName?: string; + elementName?: string; + isYang?: boolean; + isChanging?: boolean; + specialMark?: string; + }>; + targetYaoInfoList?: Array<{ + position?: number; + spiritName?: string; + relationName?: string; + tiganName?: string; + elementName?: string; + isYang?: boolean; + isChanging?: boolean; + specialMark?: string; + }>; + } | null; +} + +export interface HistoryMessage { + id: string; + threadId: string; + seq: number; + role: 'user' | 'assistant'; + content: string; + timestamp: string; + agent_output?: HistoryAgentOutput | null; +} + +export interface HistoryItem { + id: string; + threadId: string; + question: string; + category: string; + hexagram_name: string; + rating: string; + created_at: string; +} + +export interface HistorySnapshot { + scope: string; + threadId: string | null; + day: string | null; + hasMore: boolean; + messages: HistoryMessage[]; +} + +export async function getAgentHistory(): Promise<HistorySnapshot> { + return authFetch<HistorySnapshot>(API_ROUTES.agent.history); +} + +export async function getAgentHistoryByThread(threadId: string): Promise<HistorySnapshot> { + return authFetch<HistorySnapshot>(API_ROUTES.agent.historyByThread(threadId)); +} + +// 问题类型中文到英文的映射 +const QUESTION_TYPE_MAP: Record<string, string> = { + '事业': 'career', + '情感': 'love', + '感情': 'love', + '财富': 'wealth', + '运势': 'fortune', + '解梦': 'dream', + '健康': 'health', + '学业': 'study', + '寻物': 'search', + '其他': 'other', +}; + +export function mapHistoryMessagesToItems(messages: HistoryMessage[]): HistoryItem[] { + return messages + .filter((m) => m.role === 'assistant' && m.agent_output) + .map((message) => { + const output = message.agent_output; + const derived = output?.divination_derived; + + const question = derived?.question || message.content; + const questionTypeRaw = derived?.questionType || ''; + const category = QUESTION_TYPE_MAP[questionTypeRaw] || questionTypeRaw.toLowerCase(); + const hexagramName = derived?.guaName || derived?.gua_name || ''; + const rating = output?.sign_level || ''; + + return { + id: message.id, + threadId: message.threadId, + question, + category, + hexagram_name: hexagramName, + rating, + created_at: message.timestamp, + }; + }); +} + +export function parseChineseDate(dateStr: string): Date { + const match = dateStr.match(/(\d{4})年(\d{2})月(\d{2})日\s+(\d{2}):(\d{2})/); + if (match) { + const [, year, month, day, hour, minute] = match; + return new Date(`${year}-${month}-${day}T${hour}:${minute}:00`); + } + try { + const d = new Date(dateStr); + if (!isNaN(d.getTime())) return d; + } catch { /* ignore */ } + return new Date(); +} + +export function historyMessageToResultData(message: HistoryMessage): DivinationResultData | null { + const output = message.agent_output; + const derived = output?.divination_derived; + if (!output || !derived) return null; + + // Parse yao lines + const yaoLines: DivinationResultData['yaoLines'] = []; + if (Array.isArray(derived.yaoInfoList)) { + derived.yaoInfoList.forEach((item, idx) => { + // Determine YaoType based on isYang and isChanging + let type: YaoType = 'youngYang'; + if (item.isYang === true && item.isChanging === true) { + type = 'oldYang'; + } else if (item.isYang === false && item.isChanging === true) { + type = 'oldYin'; + } else if (item.isYang === false && item.isChanging === false) { + type = 'youngYin'; + } + yaoLines.push({ + index: idx, + spirit: item.spiritName || '', + relation: item.relationName || '', + branch: item.tiganName || '', + element: item.elementName || '', + type, + mark: item.specialMark || '', + }); + }); + } + + // Parse target yao lines + const targetYaoLines: DivinationResultData['yaoLines'] = []; + if (Array.isArray(derived.targetYaoInfoList)) { + derived.targetYaoInfoList.forEach((item, idx) => { + let type: YaoType = 'youngYang'; + if (item.isYang === true && item.isChanging === true) { + type = 'oldYang'; + } else if (item.isYang === false && item.isChanging === true) { + type = 'oldYin'; + } else if (item.isYang === false && item.isChanging === false) { + type = 'youngYin'; + } + targetYaoLines.push({ + index: idx, + spirit: item.spiritName || '', + relation: item.relationName || '', + branch: item.tiganName || '', + element: item.elementName || '', + type, + mark: item.specialMark || '', + }); + }); + } + + // Parse ganzhi from nested object + const ganzhiSource = derived.ganzhi || {}; + const ganzhi: DivinationResultData['ganzhi'] = { + yearGanZhi: ganzhiSource.yearGanZhi || '', + monthGanZhi: ganzhiSource.monthGanZhi || '', + dayGanZhi: ganzhiSource.dayGanZhi || '', + timeGanZhi: ganzhiSource.timeGanZhi || '', + yearKongWang: ganzhiSource.yearKongWang || '', + monthKongWang: ganzhiSource.monthKongWang || '', + dayKongWang: ganzhiSource.dayKongWang || '', + timeKongWang: ganzhiSource.timeKongWang || '', + yueJian: ganzhiSource.yueJian || '', + riChen: ganzhiSource.riChen || '', + yuePo: ganzhiSource.yuePo || '', + riChong: ganzhiSource.riChong || '', + }; + + const divinationTimeStr = derived.divinationTime || ''; + const divinationTime = parseChineseDate(divinationTimeStr); + + return { + threadId: message.threadId, + params: { + method: derived.divinationMethod?.includes('手动') ? 'manual' : 'auto', + questionType: derived.questionType || '', + question: derived.question || '', + divinationTime, + }, + binaryCode: derived.binaryCode || '', + changedBinaryCode: derived.changedBinaryCode || '', + guaName: derived.guaName || derived.gua_name || '', + targetGuaName: derived.targetGuaName || '', + upperName: derived.upperName || '', + lowerName: derived.lowerName || '', + signType: output.sign_level || '', + keywords: (output.keywords || []).join(' · '), + focusPoints: output.focus_points || [], + conclusion: (output.conclusion || []).join('\n'), + analysis: output.answer || '', + suggestion: (output.advice || []).join('\n'), + ganzhi, + wuXingStatus: derived.wuXingStatuses || {}, + yaoLines, + targetYaoLines, + status: (output.status as 'success' | 'failed' | 'refused') || 'success', + }; +} + +// --- Divination Run --- + +export type YaoType = 'youngYang' | 'youngYin' | 'oldYang' | 'oldYin'; + +export interface DivinationParams { + method: 'manual' | 'auto'; + questionType: string; + question: string; + divinationTime: Date; +} + +export interface RunAcceptedData { + threadId: string; + runId: string; +} + +interface GanzhiData { + yearGanZhi: string; + monthGanZhi: string; + dayGanZhi: string; + timeGanZhi: string; + yearKongWang: string; + monthKongWang: string; + dayKongWang: string; + timeKongWang: string; + yueJian: string; + riChen: string; + yuePo: string; + riChong: string; +} + +interface YaoLineData { + index: number; + spirit: string; + relation: string; + branch: string; + element: string; + type: YaoType; + mark: string; +} + +export interface DivinationResultData { + threadId?: string; + params: DivinationParams; + binaryCode: string; + changedBinaryCode: string; + guaName: string; + targetGuaName: string; + upperName: string; + lowerName: string; + signType: string; + keywords: string; + focusPoints: string[]; + conclusion: string; + analysis: string; + suggestion: string; + ganzhi: GanzhiData; + wuXingStatus: Record<string, string>; + yaoLines: YaoLineData[]; + targetYaoLines: YaoLineData[]; + status: 'success' | 'failed' | 'refused'; +} + +export type DivinationEventType = + | 'DIVINATION_DERIVED' + | 'TEXT_MESSAGE_END' + | 'RUN_ERROR' + | 'RUN_FINISHED'; + +export interface DivinationEvent { + type: DivinationEventType; + data: Record<string, unknown>; +} + +function yaoTypeToText(type: YaoType): string { + return type === 'youngYang' ? '少阳' + : type === 'youngYin' ? '少阴' + : type === 'oldYang' ? '老阳' + : '老阴'; +} + +function questionTypeToText(type: string): string { + const map: Record<string, string> = { + career: '事业', + love: '情感', + wealth: '财富', + fortune: '运势', + dream: '解梦', + health: '健康', + study: '学业', + search: '寻物', + other: '其他', + }; + return map[type] || type; +} + +function toRfc3339Utc(date: Date): string { + return date.toISOString(); +} + +// Polyfill for crypto.randomUUID in unsupported environments +function generateUUID(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback: RFC 4122 v4 UUID + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export async function enqueueDivinationRun( + params: DivinationParams, + yaoStates: YaoType[] +): Promise<RunAcceptedData> { + const threadId = generateUUID(); + const runId = generateUUID(); + + const payload = { + threadId, + runId, + state: {}, + messages: [ + { id: `msg_${runId}_user_0`, role: 'user', content: params.question }, + ], + tools: [], + context: [], + forwardedProps: { + runtime_mode: 'chat', + client_time: { + device_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + client_now_iso: toRfc3339Utc(new Date()), + client_epoch_ms: Date.now(), + }, + divinationPayload: { + divinationMethod: params.method === 'manual' ? '手动起卦' : '自动起卦', + questionType: questionTypeToText(params.questionType), + question: params.question, + divinationTimeIso: toRfc3339Utc(params.divinationTime), + yaoLines: yaoStates.map(yaoTypeToText), + }, + }, + }; + + return authFetch<RunAcceptedData>(API_ROUTES.agent.runs, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function enqueueFollowUpRun( + threadId: string, + question: string, + result: DivinationResultData +): Promise<RunAcceptedData> { + const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const yaoStates = result.yaoLines.map((line) => line.type); + const divinationTime = result.params.divinationTime instanceof Date + ? result.params.divinationTime + : new Date(result.params.divinationTime); + + const payload = { + threadId, + runId, + state: {}, + messages: [ + { id: `msg_${runId}_user_0`, role: 'user', content: question }, + ], + tools: [], + context: [], + forwardedProps: { + runtime_mode: 'follow_up', + client_time: { + device_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + client_now_iso: toRfc3339Utc(new Date()), + client_epoch_ms: Date.now(), + }, + divinationPayload: { + divinationMethod: result.params.method === 'manual' ? '手动起卦' : '自动起卦', + questionType: questionTypeToText(result.params.questionType), + question: result.params.question, + divinationTimeIso: toRfc3339Utc(divinationTime), + yaoLines: yaoStates.map(yaoTypeToText), + }, + }, + }; + + return authFetch<RunAcceptedData>(API_ROUTES.agent.runs, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function* streamDivinationEvents( + threadId: string, + runId: string +): AsyncGenerator<DivinationEvent> { + const { authFetchRaw } = await import('./auth'); + + const response = await authFetchRaw( + `${API_ROUTES.agent.runEvents(threadId)}?runId=${runId}`, + { + headers: { + Accept: 'text/event-stream', + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to connect to event stream: ${response.status}`); + } + + yield* readSseStream(response); +} + +async function* readSseStream(response: Response): AsyncGenerator<DivinationEvent> { + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE frames (separated by \n\n) + while (true) { + const splitAt = buffer.indexOf('\n\n'); + if (splitAt < 0) break; + + const frame = buffer.substring(0, splitAt); + buffer = buffer.substring(splitAt + 2); + + const event = parseSseFrame(frame); + if (event) { + yield event; + } + } + } + + // Process any remaining buffer + if (buffer.trim()) { + const event = parseSseFrame(buffer); + if (event) { + yield event; + } + } + } finally { + reader.releaseLock(); + } +} + +function parseSseFrame(frame: string): DivinationEvent | null { + if (frame.startsWith(':')) return null; + + const lines = frame.split('\n'); + let eventType = ''; + const dataLines: string[] = []; + + for (const raw of lines) { + const line = raw.trimEnd(); + if (line.startsWith('event:')) { + eventType = line.substring(6).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.substring(5).trimStart()); + } + } + + if (dataLines.length === 0) return null; + + const dataText = dataLines.join('\n'); + if (!dataText.trim()) return null; + + let data: Record<string, unknown>; + try { + data = JSON.parse(dataText); + } catch { + data = { raw: dataText }; + } + + // Use event type from SSE event line, fallback to data.type + const typeFromData = data.type as string | undefined; + const type: DivinationEventType = (eventType || typeFromData || 'UNKNOWN') as DivinationEventType; + + return { type, data }; +} diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 0000000..9af0ea7 --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,291 @@ +/** + * Auth storage + API calls + authFetch wrapper. + * Mirrors Flutter's SessionStore + AuthApi + AuthRepositoryImpl. + */ + +import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client'; +import { API_ROUTES } from './api-routes'; +import { clearAll as clearDataCache } from './data-client'; + +const STORAGE_KEY = 'meeyao_auth'; + +export { ApiError }; + +export interface AuthUser { + id: string; + email: string; +} + +export interface AuthData { + access_token: string; + refresh_token: string; + expires_at: number; // Unix ms + user: AuthUser; +} + +interface SessionResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + user: { id: string; email: string }; +} + +// --- Language mapping --- + +/** + * Map frontend locale to backend BCP-47 language tag + */ +export function localeToBackendLanguage(locale: string): string { + const mapping: Record<string, string> = { + 'zh': 'zh-CN', + 'zh_Hant': 'zh-TW', + 'en': 'en-US', + }; + return mapping[locale] || 'zh-CN'; +} + +/** + * Map backend BCP-47 language tag to frontend locale + */ +export function backendLanguageToLocale(lang: string): string { + const mapping: Record<string, string> = { + 'zh-CN': 'zh', + 'zh-TW': 'zh_Hant', + 'zh-Hant': 'zh_Hant', + 'en-US': 'en', + 'en': 'en', + }; + return mapping[lang] || 'zh'; +} + +// --- Storage --- + +export function getAuth(): AuthData | null { + if (typeof window === 'undefined') return null; + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as AuthData; + } catch { + clearAuth(); + return null; + } +} + +export function setAuth(data: AuthData): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} + +export function clearAuth(): void { + localStorage.removeItem(STORAGE_KEY); + clearDataCache(); +} + +// --- Token status --- + +export function isTokenExpired(): boolean { + const auth = getAuth(); + if (!auth) return true; + // Refresh 60 seconds before actual expiry + return auth.expires_at - 60_000 < Date.now(); +} + +let refreshPromise: Promise<AuthData> | null = null; + +// --- Helpers --- + +function toAuthData(response: SessionResponse): AuthData { + return { + access_token: response.access_token, + refresh_token: response.refresh_token, + expires_at: Date.now() + response.expires_in * 1000, + user: { id: response.user.id, email: response.user.email }, + }; +} + +function getLocaleFromPath(): string { + if (typeof window === 'undefined') return 'zh'; + const match = window.location.pathname.match(/^\/(zh|zh_Hant|en)(?:\/|$)/); + return match ? match[1] : 'zh'; +} + +export function loginPath(): string { + const locale = getLocaleFromPath(); + return `/${locale}/login`; +} + +export function redirectToLogin(): void { + if (typeof window === 'undefined') return; + window.location.replace(loginPath()); +} + +// --- API calls --- + +export async function sendOtp(email: string): Promise<void> { + await apiRequest<void>(API_ROUTES.auth.sendOtp, { + method: 'POST', + body: JSON.stringify({ email }), + }); +} + +export async function loginWithEmail( + email: string, + token: string, + language?: string, + timezone?: string, +): Promise<AuthData> { + const body: Record<string, string> = { email, token }; + if (language) body.language = language; + if (timezone) body.timezone = timezone; + + const json = await apiRequest<SessionResponse>(API_ROUTES.auth.emailSession, { + method: 'POST', + body: JSON.stringify(body), + }); + const data = toAuthData(json); + clearDataCache(); + setAuth(data); + return data; +} + +async function doRefreshAccessToken(): Promise<AuthData> { + const auth = getAuth(); + if (!auth?.refresh_token) { + clearAuth(); + throw new Error('No refresh token'); + } + const res = await fetch(apiUrl(API_ROUTES.auth.refreshSession), { + method: 'POST', + headers: jsonHeaders(), + body: JSON.stringify({ refresh_token: auth.refresh_token }), + }); + if (!res.ok) { + clearAuth(); + throw await toApiError(res); + } + const json: SessionResponse = await res.json(); + const data = toAuthData(json); + setAuth(data); + return data; +} + +export async function refreshAccessToken(): Promise<AuthData> { + if (refreshPromise) return refreshPromise; + + refreshPromise = doRefreshAccessToken(); + try { + return await refreshPromise; + } finally { + refreshPromise = null; + } +} + +export async function logout(): Promise<void> { + const auth = getAuth(); + try { + if (auth?.refresh_token) { + await fetch(apiUrl(API_ROUTES.auth.deleteSession), { + method: 'DELETE', + headers: jsonHeaders(), + body: JSON.stringify({ refresh_token: auth.refresh_token }), + }); + } + } finally { + clearAuth(); + } +} + +// --- authFetch --- + +export async function authFetch<T>(path: string, options?: RequestInit): Promise<T> { + // 1. Ensure token is fresh + if (isTokenExpired()) { + try { + await refreshAccessToken(); + } catch { + // refresh failed, redirect to login + clearAuth(); + redirectToLogin(); + throw new Error('Session expired'); + } + } + + const auth = getAuth(); + if (!auth) { + redirectToLogin(); + throw new Error('Not authenticated'); + } + + const headers = jsonHeaders(options); + headers.set('Authorization', `Bearer ${auth.access_token}`); + + // 2. Make request + const url = apiUrl(path); + let res = await fetch(url, { ...options, headers }); + + // 3. On 401, refresh once and retry + if (res.status === 401) { + try { + await refreshAccessToken(); + } catch { + clearAuth(); + redirectToLogin(); + throw new Error('Session expired'); + } + const refreshed = getAuth(); + if (!refreshed) { + redirectToLogin(); + throw new Error('Not authenticated'); + } + const retryHeaders = jsonHeaders(options); + retryHeaders.set('Authorization', `Bearer ${refreshed.access_token}`); + res = await fetch(url, { ...options, headers: retryHeaders }); + } + + if (res.status === 401) { + clearAuth(); + redirectToLogin(); + throw new Error('Not authenticated'); + } + + if (!res.ok) throw await toApiError(res); + if (res.status === 204) return undefined as T; + return res.json() as Promise<T>; +} + +/** + * Like authFetch but returns raw Response for streaming (SSE, etc.) + * Does NOT throw on non-OK responses - caller must handle response.status + */ +export async function authFetchRaw(path: string, options?: RequestInit): Promise<Response> { + if (isTokenExpired()) { + await refreshAccessToken(); + } + + const auth = getAuth(); + if (!auth) { + redirectToLogin(); + throw new Error('Not authenticated'); + } + + const headers = jsonHeaders(options); + headers.set('Authorization', `Bearer ${auth.access_token}`); + + const url = apiUrl(path); + let res = await fetch(url, { ...options, headers }); + + if (res.status === 401) { + await refreshAccessToken(); + const refreshed = getAuth(); + if (!refreshed) { + redirectToLogin(); + throw new Error('Not authenticated'); + } + const retryHeaders = jsonHeaders(options); + retryHeaders.set('Authorization', `Bearer ${refreshed.access_token}`); + res = await fetch(url, { ...options, headers: retryHeaders }); + } + + return res; +} diff --git a/web/src/lib/data-client.ts b/web/src/lib/data-client.ts new file mode 100644 index 0000000..660f924 --- /dev/null +++ b/web/src/lib/data-client.ts @@ -0,0 +1,149 @@ +export type CacheKey = readonly string[]; + +interface CacheEntry<T> { + data?: T; + error?: unknown; + updatedAt: number; + expiresAt: number; + promise?: Promise<T>; +} + +export interface QueryOptions<T> { + key: CacheKey; + ttlMs: number; + fetcher: () => Promise<T>; + staleWhileRevalidate?: boolean; + force?: boolean; +} + +type Listener = () => void; + +const cache = new Map<string, CacheEntry<unknown>>(); +const listeners = new Map<string, Set<Listener>>(); + +function keyToString(key: CacheKey): string { + return JSON.stringify(key); +} + +function isPrefix(key: CacheKey, prefix: CacheKey): boolean { + return prefix.every((part, index) => key[index] === part); +} + +function notify(serializedKey: string): void { + listeners.get(serializedKey)?.forEach((listener) => listener()); +} + +function notifyPrefix(prefix: CacheKey): void { + for (const serializedKey of listeners.keys()) { + const parsedKey = JSON.parse(serializedKey) as string[]; + if (isPrefix(parsedKey, prefix)) notify(serializedKey); + } +} + +function startFetch<T>(serializedKey: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> { + const now = Date.now(); + const existing = cache.get(serializedKey) as CacheEntry<T> | undefined; + const promise = fetcher() + .then((data) => { + cache.set(serializedKey, { + data, + updatedAt: Date.now(), + expiresAt: Date.now() + ttlMs, + }); + notify(serializedKey); + return data; + }) + .catch((error) => { + cache.set(serializedKey, { + data: existing?.data, + error, + updatedAt: existing?.updatedAt ?? now, + expiresAt: existing?.data === undefined ? now : existing.expiresAt, + }); + notify(serializedKey); + throw error; + }); + + cache.set(serializedKey, { + ...existing, + updatedAt: existing?.updatedAt ?? now, + expiresAt: existing?.expiresAt ?? now, + promise, + }); + notify(serializedKey); + return promise; +} + +export function query<T>({ + key, + ttlMs, + fetcher, + staleWhileRevalidate = true, + force = false, +}: QueryOptions<T>): Promise<T> { + const serializedKey = keyToString(key); + const entry = cache.get(serializedKey) as CacheEntry<T> | undefined; + const now = Date.now(); + + if (!force && entry?.promise) return entry.promise; + if (!force && entry?.data !== undefined && entry.expiresAt > now) return Promise.resolve(entry.data); + + if (!force && staleWhileRevalidate && entry?.data !== undefined) { + void startFetch(serializedKey, ttlMs, fetcher).catch((error) => { + console.debug('[data-client] Background refresh failed', error); + }); + return Promise.resolve(entry.data); + } + + return startFetch(serializedKey, ttlMs, fetcher); +} + +export function prefetch<T>(options: QueryOptions<T>): void { + void query(options).catch((error) => { + console.debug('[data-client] Prefetch failed', error); + }); +} + +export function peek<T>(key: CacheKey): T | undefined { + return (cache.get(keyToString(key)) as CacheEntry<T> | undefined)?.data; +} + +export function getEntry<T>(key: CacheKey): CacheEntry<T> | undefined { + return cache.get(keyToString(key)) as CacheEntry<T> | undefined; +} + +export function set<T>(key: CacheKey, data: T, ttlMs: number): void { + cache.set(keyToString(key), { + data, + updatedAt: Date.now(), + expiresAt: Date.now() + ttlMs, + }); + notify(keyToString(key)); +} + +export function invalidate(prefix: CacheKey): void { + for (const serializedKey of Array.from(cache.keys())) { + const parsedKey = JSON.parse(serializedKey) as string[]; + if (isPrefix(parsedKey, prefix)) { + cache.delete(serializedKey); + notify(serializedKey); + } + } + notifyPrefix(prefix); +} + +export function clearAll(): void { + cache.clear(); + for (const serializedKey of listeners.keys()) notify(serializedKey); +} + +export function subscribe(key: CacheKey, listener: Listener): () => void { + const serializedKey = keyToString(key); + const keyListeners = listeners.get(serializedKey) ?? new Set<Listener>(); + keyListeners.add(listener); + listeners.set(serializedKey, keyListeners); + return () => { + keyListeners.delete(listener); + if (keyListeners.size === 0) listeners.delete(serializedKey); + }; +} diff --git a/web/src/lib/resources.ts b/web/src/lib/resources.ts new file mode 100644 index 0000000..8b75177 --- /dev/null +++ b/web/src/lib/resources.ts @@ -0,0 +1,408 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + getAgentHistory, + getAgentHistoryByThread, + getNotifications, + getPackages, + getPointsBalance, + getUnreadNotificationCount, + getUserProfile, + markAllNotificationsRead, + markNotificationRead, + updateUserProfile, + updateUserSettings, + uploadAvatar, + type HistorySnapshot, + type NotificationItem, + type NotificationListResponse, + type PackageInfo, + type PackagesResponse, + type PointsBalance, + type ProfileSettings, + type UnreadCount, + type UpdateProfileRequest, + type UserProfile, +} from './api'; +import { + getEntry, + invalidate, + peek, + prefetch, + query, + set, + subscribe, + type QueryOptions, +} from './data-client'; + +const PROFILE_TTL = 5 * 60_000; +const POINTS_TTL = 60_000; +const PACKAGES_TTL = 30 * 60_000; +const HISTORY_TTL = 60_000; +const HISTORY_THREAD_TTL = 5 * 60_000; +const NOTIFICATIONS_TTL = 60_000; +const UNREAD_TTL = 30_000; + +export const profileKey = ['profile'] as const; +export const pointsBalanceKey = ['points', 'balance'] as const; +export const packagesKey = ['points', 'packages'] as const; +export const historyListKey = ['history', 'list'] as const; +export const historySummaryKey = historyListKey; +export const historyThreadKey = (threadId: string) => ['history', 'thread', threadId] as const; +export const notificationsKey = (locale: string) => ['notifications', 'list', locale] as const; +export const unreadCountKey = ['notifications', 'unread-count'] as const; + +interface ResourceState<T> { + data: T | undefined; + loading: boolean; + refreshing: boolean; + error: unknown; + reload: () => Promise<T>; +} + +type ResourceOptions<T> = QueryOptions<T> & { + enabled?: boolean; +}; + +export function useResource<T>(options: ResourceOptions<T>): ResourceState<T> { + const optionsRef = useRef(options); + optionsRef.current = options; + const keyId = useMemo(() => JSON.stringify(options.key), [options.key]); + const enabled = options.enabled ?? true; + const [data, setDataState] = useState<T | undefined>(() => enabled ? peek<T>(options.key) : undefined); + const [loading, setLoading] = useState(() => enabled && peek<T>(options.key) === undefined); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState<unknown>(() => enabled ? getEntry<T>(options.key)?.error : undefined); + + const load = useCallback((force = false) => { + const currentOptions = optionsRef.current; + if (currentOptions.enabled === false) { + return Promise.reject(new Error('Resource is disabled')); + } + const hasCachedData = peek<T>(currentOptions.key) !== undefined; + setLoading(!hasCachedData); + setRefreshing(hasCachedData); + return query({ ...currentOptions, force }) + .then((next) => { + setDataState(next); + setError(undefined); + return next; + }) + .catch((err) => { + setError(err); + throw err; + }) + .finally(() => { + setLoading(false); + setRefreshing(false); + }); + }, []); + + const syncFromCache = useCallback(() => { + const entry = getEntry<T>(optionsRef.current.key); + if (!entry) { + setDataState(undefined); + setError(undefined); + setRefreshing(false); + void load(false).catch(() => undefined); + return; + } + setDataState(entry.data); + setError(entry.error); + setRefreshing(Boolean(entry.promise && entry.data !== undefined)); + }, [load]); + + useEffect(() => { + if (!enabled) return undefined; + return subscribe(optionsRef.current.key, syncFromCache); + }, [enabled, keyId, syncFromCache]); + + useEffect(() => { + if (!enabled) { + setDataState(undefined); + setLoading(false); + setRefreshing(false); + setError(undefined); + return; + } + setDataState(peek<T>(optionsRef.current.key)); + setError(getEntry<T>(optionsRef.current.key)?.error); + void load(false).catch(() => undefined); + }, [enabled, keyId, load]); + + return { + data, + loading, + refreshing, + error, + reload: () => load(true), + }; +} + +function fetchProfileResource(force = false): Promise<UserProfile> { + return query({ + key: profileKey, + ttlMs: PROFILE_TTL, + fetcher: getUserProfile, + staleWhileRevalidate: true, + force, + }); +} + +export function getProfileResource(): Promise<UserProfile> { + return fetchProfileResource(false); +} + +export function useProfile(): ResourceState<UserProfile> { + return useResource({ + key: profileKey, + ttlMs: PROFILE_TTL, + fetcher: getUserProfile, + staleWhileRevalidate: true, + }); +} + +export function setProfileResource(profile: UserProfile): void { + set(profileKey, profile, PROFILE_TTL); +} + +export async function updateProfileResource(input: UpdateProfileRequest): Promise<UserProfile> { + const updated = await updateUserProfile(input); + setProfileResource(updated); + return updated; +} + +export async function uploadAvatarResource(file: File): Promise<UserProfile> { + const updated = await uploadAvatar(file); + setProfileResource(updated); + return updated; +} + +export async function updateSettingsResource(settings: ProfileSettings): Promise<UserProfile> { + const updated = await updateUserSettings({ settings }); + setProfileResource(updated); + return updated; +} + +export function getPointsResource(force = false): Promise<PointsBalance> { + return query({ + key: pointsBalanceKey, + ttlMs: POINTS_TTL, + fetcher: getPointsBalance, + staleWhileRevalidate: true, + force, + }); +} + +export function usePoints(): ResourceState<PointsBalance> { + return useResource({ + key: pointsBalanceKey, + ttlMs: POINTS_TTL, + fetcher: getPointsBalance, + staleWhileRevalidate: true, + }); +} + +export function invalidatePoints(): void { + invalidate(pointsBalanceKey); +} + +export function getPackagesResource(force = false): Promise<PackagesResponse> { + return query({ + key: packagesKey, + ttlMs: PACKAGES_TTL, + fetcher: getPackages, + staleWhileRevalidate: true, + force, + }); +} + +export function usePackages(): ResourceState<PackagesResponse> { + return useResource({ + key: packagesKey, + ttlMs: PACKAGES_TTL, + fetcher: getPackages, + staleWhileRevalidate: true, + }); +} + +export function getHistoryListResource(force = false): Promise<HistorySnapshot> { + return query({ + key: historyListKey, + ttlMs: HISTORY_TTL, + fetcher: getAgentHistory, + staleWhileRevalidate: true, + force, + }); +} + +export function useHistoryList(): ResourceState<HistorySnapshot> { + return useResource({ + key: historyListKey, + ttlMs: HISTORY_TTL, + fetcher: getAgentHistory, + staleWhileRevalidate: true, + }); +} + +export function getHistorySummaryResource(force = false): Promise<HistorySnapshot> { + return query({ + key: historySummaryKey, + ttlMs: HISTORY_TTL, + fetcher: getAgentHistory, + staleWhileRevalidate: true, + force, + }); +} + +export function useHistorySummary(): ResourceState<HistorySnapshot> { + return useResource({ + key: historySummaryKey, + ttlMs: HISTORY_TTL, + fetcher: getAgentHistory, + staleWhileRevalidate: true, + }); +} + +export function getHistoryThreadResource(threadId: string, force = false): Promise<HistorySnapshot> { + return query({ + key: historyThreadKey(threadId), + ttlMs: HISTORY_THREAD_TTL, + fetcher: () => getAgentHistoryByThread(threadId), + staleWhileRevalidate: true, + force, + }); +} + +export function primeHistoryThreadFromSnapshot(threadId: string, snapshot: HistorySnapshot): void { + const messages = snapshot.messages.filter((message) => message.threadId === threadId); + if (messages.length === 0) return; + set(historyThreadKey(threadId), { + scope: 'thread', + threadId, + day: snapshot.day, + hasMore: false, + messages, + }, HISTORY_THREAD_TTL); +} + +export function useHistoryThread(threadId?: string): ResourceState<HistorySnapshot> { + return useResource({ + key: threadId ? historyThreadKey(threadId) : ['history', 'thread', 'missing'], + ttlMs: HISTORY_THREAD_TTL, + fetcher: () => { + if (!threadId) return Promise.reject(new Error('Missing history thread id')); + return getAgentHistoryByThread(threadId); + }, + staleWhileRevalidate: true, + enabled: Boolean(threadId), + }); +} + +export function invalidateHistory(threadId?: string): void { + invalidate(historySummaryKey); + invalidate(historyListKey); + if (threadId) invalidate(historyThreadKey(threadId)); +} + +export function getNotificationsResource(locale: string, force = false): Promise<NotificationListResponse> { + return query({ + key: notificationsKey(locale), + ttlMs: NOTIFICATIONS_TTL, + fetcher: () => getNotifications(locale), + staleWhileRevalidate: true, + force, + }); +} + +export function useNotifications(locale: string): ResourceState<NotificationListResponse> { + return useResource({ + key: notificationsKey(locale), + ttlMs: NOTIFICATIONS_TTL, + fetcher: () => getNotifications(locale), + staleWhileRevalidate: true, + }); +} + +export function getUnreadCountResource(force = false): Promise<UnreadCount> { + return query({ + key: unreadCountKey, + ttlMs: UNREAD_TTL, + fetcher: getUnreadNotificationCount, + staleWhileRevalidate: true, + force, + }); +} + +export function useUnreadCount(): ResourceState<UnreadCount> { + return useResource({ + key: unreadCountKey, + ttlMs: UNREAD_TTL, + fetcher: getUnreadNotificationCount, + staleWhileRevalidate: true, + }); +} + +export async function markNotificationReadResource(id: string, locale: string): Promise<NotificationItem> { + const updated = await markNotificationRead(id, locale); + const listKey = notificationsKey(locale); + const list = peek<NotificationListResponse>(listKey); + if (list) { + const previous = list.items.find((item) => item.id === id); + set(listKey, { + ...list, + items: list.items.map((item) => (item.id === id ? updated : item)), + }, NOTIFICATIONS_TTL); + if (previous && !previous.isRead && updated.isRead) { + const unread = peek<UnreadCount>(unreadCountKey); + if (unread) set(unreadCountKey, { count: Math.max(0, unread.count - 1) }, UNREAD_TTL); + } + } else { + invalidate(unreadCountKey); + } + return updated; +} + +export async function markAllNotificationsReadResource(locale: string): Promise<{ updatedCount: number }> { + const result = await markAllNotificationsRead(); + const listKey = notificationsKey(locale); + const list = peek<NotificationListResponse>(listKey); + if (list) { + const readAt = new Date().toISOString(); + set(listKey, { + ...list, + items: list.items.map((item) => ({ ...item, isRead: true, readAt: item.readAt ?? readAt })), + }, NOTIFICATIONS_TTL); + } + set(unreadCountKey, { count: 0 }, UNREAD_TTL); + return result; +} + +export function prefetchAppBasics(): void { + prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true }); + prefetch({ key: unreadCountKey, ttlMs: UNREAD_TTL, fetcher: getUnreadNotificationCount, staleWhileRevalidate: true }); + prefetch({ key: historySummaryKey, ttlMs: HISTORY_TTL, fetcher: getAgentHistory, staleWhileRevalidate: true }); +} + +export function prefetchForPath(pathname: string, locale: string): void { + if (pathname.includes('/store')) { + prefetch({ key: packagesKey, ttlMs: PACKAGES_TTL, fetcher: getPackages, staleWhileRevalidate: true }); + prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true }); + } else if (pathname.includes('/history')) { + prefetch({ key: historyListKey, ttlMs: HISTORY_TTL, fetcher: getAgentHistory, staleWhileRevalidate: true }); + } else if (pathname.includes('/notifications')) { + prefetch({ key: notificationsKey(locale), ttlMs: NOTIFICATIONS_TTL, fetcher: () => getNotifications(locale), staleWhileRevalidate: true }); + prefetch({ key: unreadCountKey, ttlMs: UNREAD_TTL, fetcher: getUnreadNotificationCount, staleWhileRevalidate: true }); + } else if (pathname.includes('/settings') || pathname.includes('/profile')) { + prefetch({ key: profileKey, ttlMs: PROFILE_TTL, fetcher: getUserProfile, staleWhileRevalidate: true }); + } else if (pathname.includes('/divination')) { + prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true }); + } +} + +export function getCachedProfile(): UserProfile | undefined { + return peek<UserProfile>(profileKey); +} + +export function getCachedPackages(): PackageInfo[] | undefined { + return peek<PackagesResponse>(packagesKey)?.packages; +} diff --git a/web/src/pages/en/about.astro b/web/src/pages/en/about.astro new file mode 100644 index 0000000..d7496a1 --- /dev/null +++ b/web/src/pages/en/about.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import AboutPage from '../../components/AboutPage.astro'; +const locale = 'en' as const; +--- +<MarketingLayout locale={locale}> + <AboutPage locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/en/dashboard.astro b/web/src/pages/en/dashboard.astro new file mode 100644 index 0000000..dd60c86 --- /dev/null +++ b/web/src/pages/en/dashboard.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/divination/auto.astro b/web/src/pages/en/divination/auto.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/divination/auto.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/divination/manual.astro b/web/src/pages/en/divination/manual.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/divination/manual.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/divination/result.astro b/web/src/pages/en/divination/result.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/divination/result.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/features.astro b/web/src/pages/en/features.astro new file mode 100644 index 0000000..6b9e47e --- /dev/null +++ b/web/src/pages/en/features.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import FeaturesPage from '../../components/FeaturesPage.astro'; +const locale = 'en' as const; +--- +<MarketingLayout locale={locale}> + <FeaturesPage locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/en/history/[id].astro b/web/src/pages/en/history/[id].astro new file mode 100644 index 0000000..87a6824 --- /dev/null +++ b/web/src/pages/en/history/[id].astro @@ -0,0 +1,9 @@ +--- +export const prerender = false; + +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/history/[id]/followup.astro b/web/src/pages/en/history/[id]/followup.astro new file mode 100644 index 0000000..fe40150 --- /dev/null +++ b/web/src/pages/en/history/[id]/followup.astro @@ -0,0 +1,9 @@ +--- +export const prerender = false; + +import DashboardAppPage from '../../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/history/followup.astro b/web/src/pages/en/history/followup.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/history/followup.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/history/index.astro b/web/src/pages/en/history/index.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/history/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/history/result.astro b/web/src/pages/en/history/result.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/history/result.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/index.astro b/web/src/pages/en/index.astro new file mode 100644 index 0000000..14e7743 --- /dev/null +++ b/web/src/pages/en/index.astro @@ -0,0 +1,13 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import Hero from '../../components/Hero.astro'; +import Showcase from '../../components/Showcase.astro'; +import CtaSection from '../../components/CtaSection.astro'; + +const locale = 'en' as const; +--- +<MarketingLayout locale={locale}> + <Hero locale={locale} /> + <Showcase locale={locale} /> + <CtaSection locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/en/login.astro b/web/src/pages/en/login.astro new file mode 100644 index 0000000..5e2fc2d --- /dev/null +++ b/web/src/pages/en/login.astro @@ -0,0 +1,18 @@ +--- +import AuthLayout from '../../layouts/Auth.astro'; +import LoginForm from '../../components/LoginForm'; +import { t, localePath } from '../../i18n/utils'; + +const locale = 'en' as const; +const login = t(locale, 'login'); +--- + +<AuthLayout locale={locale}> + <LoginForm + client:load + locale={locale} + translations={login} + privacyUrl={localePath(locale, '/privacy')} + termsUrl={localePath(locale, '/terms')} + /> +</AuthLayout> diff --git a/web/src/pages/en/notifications/index.astro b/web/src/pages/en/notifications/index.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/notifications/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/pricing.astro b/web/src/pages/en/pricing.astro new file mode 100644 index 0000000..7a4ca58 --- /dev/null +++ b/web/src/pages/en/pricing.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import PricingPage from '../../components/PricingPage.astro'; +const locale = 'en' as const; +--- +<MarketingLayout locale={locale}> + <PricingPage locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/en/privacy.astro b/web/src/pages/en/privacy.astro new file mode 100644 index 0000000..f8aa9b0 --- /dev/null +++ b/web/src/pages/en/privacy.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import LegalPage from '../../components/LegalPage.astro'; +const locale = 'en' as const; +--- +<MarketingLayout locale={locale}> + <LegalPage locale={locale} docType="privacy_policy" /> +</MarketingLayout> diff --git a/web/src/pages/en/profile/index.astro b/web/src/pages/en/profile/index.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/profile/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/settings/feedback.astro b/web/src/pages/en/settings/feedback.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/settings/feedback.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/settings/general.astro b/web/src/pages/en/settings/general.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/settings/general.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/settings/index.astro b/web/src/pages/en/settings/index.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/settings/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/store/index.astro b/web/src/pages/en/store/index.astro new file mode 100644 index 0000000..60a5a5d --- /dev/null +++ b/web/src/pages/en/store/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'en' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/en/terms.astro b/web/src/pages/en/terms.astro new file mode 100644 index 0000000..b055640 --- /dev/null +++ b/web/src/pages/en/terms.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import LegalPage from '../../components/LegalPage.astro'; +const locale = 'en' as const; +--- +<MarketingLayout locale={locale}> + <LegalPage locale={locale} docType="terms_of_service" /> +</MarketingLayout> diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro new file mode 100644 index 0000000..e2a558a --- /dev/null +++ b/web/src/pages/index.astro @@ -0,0 +1,3 @@ +--- +return Astro.redirect('/en/'); +--- diff --git a/web/src/pages/zh/about.astro b/web/src/pages/zh/about.astro new file mode 100644 index 0000000..193f4f7 --- /dev/null +++ b/web/src/pages/zh/about.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import AboutPage from '../../components/AboutPage.astro'; +const locale = 'zh' as const; +--- +<MarketingLayout locale={locale}> + <AboutPage locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/zh/dashboard.astro b/web/src/pages/zh/dashboard.astro new file mode 100644 index 0000000..211e74b --- /dev/null +++ b/web/src/pages/zh/dashboard.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/divination/auto.astro b/web/src/pages/zh/divination/auto.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/divination/auto.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/divination/manual.astro b/web/src/pages/zh/divination/manual.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/divination/manual.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/divination/result.astro b/web/src/pages/zh/divination/result.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/divination/result.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/features.astro b/web/src/pages/zh/features.astro new file mode 100644 index 0000000..703e9ee --- /dev/null +++ b/web/src/pages/zh/features.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import FeaturesPage from '../../components/FeaturesPage.astro'; +const locale = 'zh' as const; +--- +<MarketingLayout locale={locale}> + <FeaturesPage locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/zh/history/[id].astro b/web/src/pages/zh/history/[id].astro new file mode 100644 index 0000000..32ce436 --- /dev/null +++ b/web/src/pages/zh/history/[id].astro @@ -0,0 +1,9 @@ +--- +export const prerender = false; + +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/history/[id]/followup.astro b/web/src/pages/zh/history/[id]/followup.astro new file mode 100644 index 0000000..814251d --- /dev/null +++ b/web/src/pages/zh/history/[id]/followup.astro @@ -0,0 +1,9 @@ +--- +export const prerender = false; + +import DashboardAppPage from '../../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/history/followup.astro b/web/src/pages/zh/history/followup.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/history/followup.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/history/index.astro b/web/src/pages/zh/history/index.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/history/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/history/result.astro b/web/src/pages/zh/history/result.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/history/result.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/index.astro b/web/src/pages/zh/index.astro new file mode 100644 index 0000000..ce4d03e --- /dev/null +++ b/web/src/pages/zh/index.astro @@ -0,0 +1,13 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import Hero from '../../components/Hero.astro'; +import Showcase from '../../components/Showcase.astro'; +import CtaSection from '../../components/CtaSection.astro'; + +const locale = 'zh' as const; +--- +<MarketingLayout locale={locale}> + <Hero locale={locale} /> + <Showcase locale={locale} /> + <CtaSection locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/zh/login.astro b/web/src/pages/zh/login.astro new file mode 100644 index 0000000..5f8f2f8 --- /dev/null +++ b/web/src/pages/zh/login.astro @@ -0,0 +1,18 @@ +--- +import AuthLayout from '../../layouts/Auth.astro'; +import LoginForm from '../../components/LoginForm'; +import { t, localePath } from '../../i18n/utils'; + +const locale = 'zh' as const; +const login = t(locale, 'login'); +--- + +<AuthLayout locale={locale}> + <LoginForm + client:load + locale={locale} + translations={login} + privacyUrl={localePath(locale, '/privacy')} + termsUrl={localePath(locale, '/terms')} + /> +</AuthLayout> diff --git a/web/src/pages/zh/notifications/index.astro b/web/src/pages/zh/notifications/index.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/notifications/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/pricing.astro b/web/src/pages/zh/pricing.astro new file mode 100644 index 0000000..ee08f62 --- /dev/null +++ b/web/src/pages/zh/pricing.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import PricingPage from '../../components/PricingPage.astro'; +const locale = 'zh' as const; +--- +<MarketingLayout locale={locale}> + <PricingPage locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/zh/privacy.astro b/web/src/pages/zh/privacy.astro new file mode 100644 index 0000000..e405c41 --- /dev/null +++ b/web/src/pages/zh/privacy.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import LegalPage from '../../components/LegalPage.astro'; +const locale = 'zh' as const; +--- +<MarketingLayout locale={locale}> + <LegalPage locale={locale} docType="privacy_policy" /> +</MarketingLayout> diff --git a/web/src/pages/zh/profile/index.astro b/web/src/pages/zh/profile/index.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/profile/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/settings/feedback.astro b/web/src/pages/zh/settings/feedback.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/settings/feedback.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/settings/general.astro b/web/src/pages/zh/settings/general.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/settings/general.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/settings/index.astro b/web/src/pages/zh/settings/index.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/settings/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/store/index.astro b/web/src/pages/zh/store/index.astro new file mode 100644 index 0000000..6fe1daa --- /dev/null +++ b/web/src/pages/zh/store/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh/terms.astro b/web/src/pages/zh/terms.astro new file mode 100644 index 0000000..7636fc3 --- /dev/null +++ b/web/src/pages/zh/terms.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import LegalPage from '../../components/LegalPage.astro'; +const locale = 'zh' as const; +--- +<MarketingLayout locale={locale}> + <LegalPage locale={locale} docType="terms_of_service" /> +</MarketingLayout> diff --git a/web/src/pages/zh_Hant/about.astro b/web/src/pages/zh_Hant/about.astro new file mode 100644 index 0000000..ca63e24 --- /dev/null +++ b/web/src/pages/zh_Hant/about.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import AboutPage from '../../components/AboutPage.astro'; +const locale = 'zh_Hant' as const; +--- +<MarketingLayout locale={locale}> + <AboutPage locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/zh_Hant/dashboard.astro b/web/src/pages/zh_Hant/dashboard.astro new file mode 100644 index 0000000..4058228 --- /dev/null +++ b/web/src/pages/zh_Hant/dashboard.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/divination/auto.astro b/web/src/pages/zh_Hant/divination/auto.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/divination/auto.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/divination/manual.astro b/web/src/pages/zh_Hant/divination/manual.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/divination/manual.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/divination/result.astro b/web/src/pages/zh_Hant/divination/result.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/divination/result.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/features.astro b/web/src/pages/zh_Hant/features.astro new file mode 100644 index 0000000..fc858a6 --- /dev/null +++ b/web/src/pages/zh_Hant/features.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import FeaturesPage from '../../components/FeaturesPage.astro'; +const locale = 'zh_Hant' as const; +--- +<MarketingLayout locale={locale}> + <FeaturesPage locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/zh_Hant/history/[id].astro b/web/src/pages/zh_Hant/history/[id].astro new file mode 100644 index 0000000..9fe25b0 --- /dev/null +++ b/web/src/pages/zh_Hant/history/[id].astro @@ -0,0 +1,9 @@ +--- +export const prerender = false; + +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/history/[id]/followup.astro b/web/src/pages/zh_Hant/history/[id]/followup.astro new file mode 100644 index 0000000..3347007 --- /dev/null +++ b/web/src/pages/zh_Hant/history/[id]/followup.astro @@ -0,0 +1,9 @@ +--- +export const prerender = false; + +import DashboardAppPage from '../../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/history/followup.astro b/web/src/pages/zh_Hant/history/followup.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/history/followup.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/history/index.astro b/web/src/pages/zh_Hant/history/index.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/history/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/history/result.astro b/web/src/pages/zh_Hant/history/result.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/history/result.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/index.astro b/web/src/pages/zh_Hant/index.astro new file mode 100644 index 0000000..bf1d25f --- /dev/null +++ b/web/src/pages/zh_Hant/index.astro @@ -0,0 +1,13 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import Hero from '../../components/Hero.astro'; +import Showcase from '../../components/Showcase.astro'; +import CtaSection from '../../components/CtaSection.astro'; + +const locale = 'zh_Hant' as const; +--- +<MarketingLayout locale={locale}> + <Hero locale={locale} /> + <Showcase locale={locale} /> + <CtaSection locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/zh_Hant/login.astro b/web/src/pages/zh_Hant/login.astro new file mode 100644 index 0000000..2aa3f4e --- /dev/null +++ b/web/src/pages/zh_Hant/login.astro @@ -0,0 +1,18 @@ +--- +import AuthLayout from '../../layouts/Auth.astro'; +import LoginForm from '../../components/LoginForm'; +import { t, localePath } from '../../i18n/utils'; + +const locale = 'zh_Hant' as const; +const login = t(locale, 'login'); +--- + +<AuthLayout locale={locale}> + <LoginForm + client:load + locale={locale} + translations={login} + privacyUrl={localePath(locale, '/privacy')} + termsUrl={localePath(locale, '/terms')} + /> +</AuthLayout> diff --git a/web/src/pages/zh_Hant/notifications/index.astro b/web/src/pages/zh_Hant/notifications/index.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/notifications/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/pricing.astro b/web/src/pages/zh_Hant/pricing.astro new file mode 100644 index 0000000..a9b1468 --- /dev/null +++ b/web/src/pages/zh_Hant/pricing.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import PricingPage from '../../components/PricingPage.astro'; +const locale = 'zh_Hant' as const; +--- +<MarketingLayout locale={locale}> + <PricingPage locale={locale} /> +</MarketingLayout> diff --git a/web/src/pages/zh_Hant/privacy.astro b/web/src/pages/zh_Hant/privacy.astro new file mode 100644 index 0000000..63f6662 --- /dev/null +++ b/web/src/pages/zh_Hant/privacy.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import LegalPage from '../../components/LegalPage.astro'; +const locale = 'zh_Hant' as const; +--- +<MarketingLayout locale={locale}> + <LegalPage locale={locale} docType="privacy_policy" /> +</MarketingLayout> diff --git a/web/src/pages/zh_Hant/profile/index.astro b/web/src/pages/zh_Hant/profile/index.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/profile/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/settings/feedback.astro b/web/src/pages/zh_Hant/settings/feedback.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/settings/feedback.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/settings/general.astro b/web/src/pages/zh_Hant/settings/general.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/settings/general.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/settings/index.astro b/web/src/pages/zh_Hant/settings/index.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/settings/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/store/index.astro b/web/src/pages/zh_Hant/store/index.astro new file mode 100644 index 0000000..3c63749 --- /dev/null +++ b/web/src/pages/zh_Hant/store/index.astro @@ -0,0 +1,7 @@ +--- +import DashboardAppPage from '../../../components/DashboardAppPage.astro'; + +const locale = 'zh_Hant' as const; +--- + +<DashboardAppPage locale={locale} /> diff --git a/web/src/pages/zh_Hant/terms.astro b/web/src/pages/zh_Hant/terms.astro new file mode 100644 index 0000000..70b10ea --- /dev/null +++ b/web/src/pages/zh_Hant/terms.astro @@ -0,0 +1,8 @@ +--- +import MarketingLayout from '../../layouts/Marketing.astro'; +import LegalPage from '../../components/LegalPage.astro'; +const locale = 'zh_Hant' as const; +--- +<MarketingLayout locale={locale}> + <LegalPage locale={locale} docType="terms_of_service" /> +</MarketingLayout> diff --git a/web/src/styles/animations.css b/web/src/styles/animations.css new file mode 100644 index 0000000..9cbfcfd --- /dev/null +++ b/web/src/styles/animations.css @@ -0,0 +1,186 @@ +@import "tailwindcss"; + +@keyframes fade-in-up { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-in-left { + from { opacity: 0; transform: translateX(-40px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes slide-in-right { + from { opacity: 0; transform: translateX(40px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes scale-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-10px); } +} + +@keyframes glow-pulse { + 0%, 100% { opacity: 0.12; } + 50% { opacity: 0.2; } +} + +@keyframes particle-drift { + 0% { transform: translate(0, 0); } + 25% { transform: translate(10px, -20px); } + 50% { transform: translate(-5px, -40px); } + 75% { transform: translate(15px, -20px); } + 100% { transform: translate(0, 0); } +} + +/* Reveal animations */ +.reveal { opacity: 0; will-change: transform, opacity; } +.reveal.visible { animation: fade-in-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; } + +.reveal-left { opacity: 0; will-change: transform, opacity; } +.reveal-left.visible { animation: slide-in-left 0.7s cubic-bezier(0.22, 1, 0.36, 1) forwards; } + +.reveal-right { opacity: 0; will-change: transform, opacity; } +.reveal-right.visible { animation: slide-in-right 0.7s cubic-bezier(0.22, 1, 0.36, 1) forwards; } + +.reveal-scale { opacity: 0; will-change: transform, opacity; } +.reveal-scale.visible { animation: scale-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; } + +.reveal.visible.stagger-1 { animation-delay: 0.1s; } +.reveal.visible.stagger-2 { animation-delay: 0.2s; } +.reveal.visible.stagger-3 { animation-delay: 0.3s; } +.reveal.visible.stagger-4 { animation-delay: 0.4s; } +.reveal.visible.stagger-5 { animation-delay: 0.5s; } +.reveal.visible.stagger-6 { animation-delay: 0.6s; } + +@media (max-width: 767px) { + .reveal, .reveal-left, .reveal-right, .reveal-scale { + opacity: 1; + transform: none; + animation: none; + } +} + +/* Cyber gradient */ +.cyber-gradient { + background: linear-gradient(135deg, #8B5CF6 0%, #A78BFA 50%, #C084FC 100%); +} + +.cyber-glow { + box-shadow: 0 0 20px rgba(139, 92, 246, 0.35), 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/* Purple glow backgrounds */ +.glow-bg { + position: relative; + overflow: hidden; +} +.glow-bg::before { + content: ''; + position: absolute; + top: -10%; + left: 50%; + transform: translateX(-50%); + width: 800px; + height: 600px; + border-radius: 50%; + background: radial-gradient(ellipse at center, rgba(139, 92, 246, 0.15) 0%, transparent 70%); + pointer-events: none; + animation: glow-pulse 6s ease-in-out infinite; +} + +.glow-bg-wide::before { + width: 1200px; + height: 800px; +} + +/* Floating particles (pure CSS) */ +.particle { + position: absolute; + width: 4px; + height: 4px; + border-radius: 50%; + background: rgba(139, 92, 246, 0.4); + pointer-events: none; +} + +.particle:nth-child(1) { top: 20%; left: 15%; animation: particle-drift 8s ease-in-out infinite; } +.particle:nth-child(2) { top: 40%; left: 75%; animation: particle-drift 12s ease-in-out infinite 1s; width: 3px; height: 3px; } +.particle:nth-child(3) { top: 60%; left: 30%; animation: particle-drift 10s ease-in-out infinite 2s; width: 5px; height: 5px; } +.particle:nth-child(4) { top: 30%; left: 55%; animation: particle-drift 9s ease-in-out infinite 3s; } +.particle:nth-child(5) { top: 70%; left: 85%; animation: particle-drift 11s ease-in-out infinite 4s; width: 3px; height: 3px; } +.particle:nth-child(6) { top: 50%; left: 10%; animation: particle-drift 7s ease-in-out infinite 5s; width: 2px; height: 2px; } +.particle:nth-child(7) { top: 15%; left: 65%; animation: particle-drift 13s ease-in-out infinite 2s; } +.particle:nth-child(8) { top: 80%; left: 45%; animation: particle-drift 8s ease-in-out infinite 6s; width: 3px; height: 3px; } + +/* Connection lines between particles */ +.particle-lines { + position: absolute; + inset: 0; + pointer-events: none; + background-image: + radial-gradient(circle at 15% 20%, rgba(139, 92, 246, 0.06) 0%, transparent 40%), + radial-gradient(circle at 75% 40%, rgba(139, 92, 246, 0.04) 0%, transparent 35%), + radial-gradient(circle at 30% 60%, rgba(139, 92, 246, 0.05) 0%, transparent 38%); +} + +/* Hexagram lines */ +.hex-yang { + width: 9rem; + height: 6px; + border-radius: 9999px; + background: linear-gradient(135deg, #8B5CF6, #C084FC); +} + +.hex-yin { + display: flex; + align-items: center; + gap: 4px; +} +.hex-yin > div { + width: 3.5rem; + height: 6px; + border-radius: 9999px; + background: rgba(139, 92, 246, 0.4); +} + +/* Coin flip animation */ +.coin-flip { + transition: transform 0.3s ease-out; + transform-style: preserve-3d; +} + +.coin-flip.flipped { + transform: rotateY(180deg); +} + +/* Feature card hover */ +.feature-card { + transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1); +} +.feature-card:hover { + transform: translateY(-4px); + border-color: rgba(139, 92, 246, 0.25); + box-shadow: 0 8px 30px rgba(139, 92, 246, 0.1); +} + +/* Smooth scroll */ +html { scroll-behavior: smooth; } + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .reveal, .reveal-left, .reveal-right, .reveal-scale { opacity: 1; transform: none; animation: none; } + .particle { animation: none; } + .glow-bg::before { animation: none; } + html { scroll-behavior: auto; } +} diff --git a/web/src/styles/global.css b/web/src/styles/global.css new file mode 100644 index 0000000..d788ad4 --- /dev/null +++ b/web/src/styles/global.css @@ -0,0 +1,16 @@ +@import "tailwindcss"; + +.material-symbols-rounded { + font-family: 'Material Symbols Rounded'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-smoothing: antialiased; +} \ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..377d900 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [ + ".astro/types.d.ts", + "**/*" + ], + "exclude": [ + "dist" + ], + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "resolveJsonModule": true, + "esModuleInterop": true + } +} \ No newline at end of file