From 0793222930f083b8f130b076c79a2dd5bb6b1583 Mon Sep 17 00:00:00 2001 From: anx4758 Date: Wed, 25 Feb 2026 16:33:50 +0800 Subject: [PATCH] feat: harden skill pipeline gates and rename npm package to stylekit-skill --- .github/workflows/regression-gate.yml | 35 + .gitignore | 3 + GO_LIVE_CHECKLIST.md | 50 + README.md | 44 +- RELEASE.md | 1 + SKILL.md | 64 +- agents/claude.yaml | 78 + ...yle-prompts-skill.js => stylekit-skill.js} | 68 +- package.json | 28 +- prompts/expand-animation-profiles.md | 120 + prompts/expand-interaction-patterns.md | 120 + pyproject.toml | 14 + references/github-actions-regression-gate.yml | 35 +- references/output-contract.md | 84 +- .../taxonomy/animation-profiles.v2.json | 576 +++++ .../taxonomy/interaction-patterns.v2.json | 488 ++++ references/taxonomy/site-type-routing.json | 433 ++++ references/taxonomy/style-tag-map.v2.json | 2101 +++++++++++++++++ references/taxonomy/style-tag-registry.json | 41 + references/taxonomy/tag-aliases.json | 28 + references/taxonomy/tag-schema.json | 90 + scripts/_brief_constants.py | 366 +++ scripts/_common.py | 82 + scripts/audit_style_rule_conflicts.py | 5 + scripts/blend_engine.py | 127 + scripts/brief_builder.py | 210 ++ scripts/generate_brief.py | 1668 +++---------- scripts/merge_taxonomy_expansion.py | 359 +++ scripts/prompt_generator.py | 263 +++ scripts/propose_upgrade.py | 118 + scripts/qa_prompt.py | 138 +- scripts/reference_handler.py | 336 +++ scripts/review_upgrade_candidate.py | 158 ++ scripts/run_pipeline.py | 781 +++++- scripts/search_stylekit.py | 157 +- scripts/smoke_test.py | 272 +++ scripts/v2_taxonomy.py | 806 +++++++ scripts/validate_output_contract_sync.py | 328 +++ scripts/validate_taxonomy.py | 296 +++ tests/__init__.py | 0 tests/conftest.py | 126 + tests/schemas/benchmark_pipeline_output.json | 55 + tests/schemas/generate_brief_output.json | 112 + tests/schemas/qa_prompt_output.json | 49 + .../schemas/run_pipeline_codegen_output.json | 60 + tests/schemas/run_pipeline_manual_output.json | 53 + tests/schemas/search_stylekit_output.json | 51 + tests/test_blend_engine.py | 323 +++ tests/test_brief_builder.py | 324 +++ tests/test_common.py | 206 ++ tests/test_output_contracts.py | 261 ++ tests/test_qa_prompt.py | 437 ++++ tests/test_v2_taxonomy.py | 608 +++++ tests/test_validate_output_contract_sync.py | 141 ++ tests/test_validate_taxonomy.py | 55 + 55 files changed, 12167 insertions(+), 1665 deletions(-) create mode 100644 GO_LIVE_CHECKLIST.md create mode 100644 agents/claude.yaml rename bin/{stylekit-style-prompts-skill.js => stylekit-skill.js} (73%) create mode 100644 prompts/expand-animation-profiles.md create mode 100644 prompts/expand-interaction-patterns.md create mode 100644 pyproject.toml create mode 100644 references/taxonomy/animation-profiles.v2.json create mode 100644 references/taxonomy/interaction-patterns.v2.json create mode 100644 references/taxonomy/site-type-routing.json create mode 100644 references/taxonomy/style-tag-map.v2.json create mode 100644 references/taxonomy/style-tag-registry.json create mode 100644 references/taxonomy/tag-aliases.json create mode 100644 references/taxonomy/tag-schema.json create mode 100644 scripts/_brief_constants.py create mode 100644 scripts/_common.py create mode 100644 scripts/blend_engine.py create mode 100644 scripts/brief_builder.py create mode 100644 scripts/merge_taxonomy_expansion.py create mode 100644 scripts/prompt_generator.py create mode 100644 scripts/propose_upgrade.py create mode 100644 scripts/reference_handler.py create mode 100644 scripts/review_upgrade_candidate.py create mode 100644 scripts/v2_taxonomy.py create mode 100644 scripts/validate_output_contract_sync.py create mode 100644 scripts/validate_taxonomy.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/schemas/benchmark_pipeline_output.json create mode 100644 tests/schemas/generate_brief_output.json create mode 100644 tests/schemas/qa_prompt_output.json create mode 100644 tests/schemas/run_pipeline_codegen_output.json create mode 100644 tests/schemas/run_pipeline_manual_output.json create mode 100644 tests/schemas/search_stylekit_output.json create mode 100644 tests/test_blend_engine.py create mode 100644 tests/test_brief_builder.py create mode 100644 tests/test_common.py create mode 100644 tests/test_output_contracts.py create mode 100644 tests/test_qa_prompt.py create mode 100644 tests/test_v2_taxonomy.py create mode 100644 tests/test_validate_output_contract_sync.py create mode 100644 tests/test_validate_taxonomy.py diff --git a/.github/workflows/regression-gate.yml b/.github/workflows/regression-gate.yml index 39c9714..0b009f8 100644 --- a/.github/workflows/regression-gate.yml +++ b/.github/workflows/regression-gate.yml @@ -4,6 +4,10 @@ on: pull_request: workflow_dispatch: +defaults: + run: + working-directory: . + jobs: regression-gate: runs-on: ubuntu-latest @@ -11,15 +15,46 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "18" + - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.11" + - name: Environment info + run: | + python3 --version + node --version + node bin/stylekit-skill.js doctor + + - name: Install test dependencies + run: | + pip install pytest jsonschema + + - name: Run unit tests + run: | + pytest tests/ -m "not slow" -v + + - name: Run contract tests + run: | + pytest tests/ -m slow -v + - name: Run smoke test run: | python3 scripts/smoke_test.py + - name: Run taxonomy guard + run: | + python3 scripts/validate_taxonomy.py --max-unused-style-tags 0 --fail-on-warning + + - name: Run output-contract sync guard + run: | + python3 scripts/validate_output_contract_sync.py --format text --fail-on-warning + - name: Run benchmark regression gate run: | bash scripts/ci_regression_gate.sh \ diff --git a/.gitignore b/.gitignore index 3afcf0d..82d657f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ tmp/ *.log .DS_Store +node_modules/ +.pytest_cache/ +*.tgz diff --git a/GO_LIVE_CHECKLIST.md b/GO_LIVE_CHECKLIST.md new file mode 100644 index 0000000..154c432 --- /dev/null +++ b/GO_LIVE_CHECKLIST.md @@ -0,0 +1,50 @@ +# StyleKit Skill Go-Live Checklist + +## 1) Gate Stability + +- [ ] `main` branch passes CI continuously for at least 7 days (or 20 consecutive runs). +- [ ] No flaky tests in `pytest -m slow`. +- [ ] `regression_gate.passed=true` on the latest benchmark run. + +## 2) Quality Baseline + +- [ ] 30 real-world prompts (blog/saas/dashboard/docs/ecommerce/landing-page/portfolio/general) are evaluated. +- [ ] Overall pass rate >= 90%. +- [ ] No P0/P1 issues in design recommendation outputs. +- [ ] Re-run consistency: 5 repeated runs per case, key fields (`style_choice`, `site_profile`, `tag_bundle`) >= 95% identical. + +## 3) Required Local Checks + +Run all checks before release: + +```bash +node bin/stylekit-skill.js doctor +python3 scripts/validate_taxonomy.py --format text --max-unused-style-tags 0 --fail-on-warning +python3 scripts/validate_output_contract_sync.py --format text --fail-on-warning +python3 scripts/smoke_test.py +pytest tests/ -m "not slow" -q +pytest tests/ -m slow -q +python3 scripts/benchmark_pipeline.py --format json --baseline-snapshot references/benchmark-baseline.json --fail-on-regression +``` + +## 4) Release Preparation + +- [ ] Update `package.json` version (SemVer). +- [ ] Update `RELEASE.md` with notable changes and rollback notes. +- [ ] Confirm GitHub workflow `.github/workflows/regression-gate.yml` is green on the release commit. +- [ ] Verify `npm pack --dry-run` contains expected payload only. + +## 5) Publish + +```bash +npm login +npm whoami +npm publish --access public +``` + +## 6) Post-Release + +- [ ] Tag release: `git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z` +- [ ] Install smoke check via npx: + `npx @anxforever/stylekit-skill doctor` +- [ ] Archive benchmark snapshot under `tmp/` for traceability. diff --git a/README.md b/README.md index 29e0016..c51b7fe 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# @anxforever/stylekit-style-prompts-skill +# @anxforever/stylekit-skill StyleKit 风格提示词 Skill(独立仓库版)。 目标:把 StyleKit 的风格能力直接安装到 Codex / Claude 的 skills 目录中。 @@ -8,70 +8,90 @@ StyleKit 风格提示词 Skill(独立仓库版)。 ### 安装到 Codex ```bash -npx @anxforever/stylekit-style-prompts-skill install --tool codex +npx @anxforever/stylekit-skill install --tool codex ``` ### 安装到 Claude ```bash -npx @anxforever/stylekit-style-prompts-skill install --tool claude +npx @anxforever/stylekit-skill install --tool claude ``` ### 自动检测本机工具并安装 ```bash -npx @anxforever/stylekit-style-prompts-skill install --tool auto +npx @anxforever/stylekit-skill install --tool auto ``` ### 覆盖安装(已存在时) ```bash -npx @anxforever/stylekit-style-prompts-skill install --tool codex --force +npx @anxforever/stylekit-skill install --tool codex --force ``` ### 卸载 ```bash -npx @anxforever/stylekit-style-prompts-skill uninstall --tool codex -npx @anxforever/stylekit-style-prompts-skill uninstall --tool claude +npx @anxforever/stylekit-skill uninstall --tool codex +npx @anxforever/stylekit-skill uninstall --tool claude ``` ### 环境自检 ```bash -npx @anxforever/stylekit-style-prompts-skill doctor +npx @anxforever/stylekit-skill doctor ``` ## 2) 本地开发验证(维护者) ```bash -node bin/stylekit-style-prompts-skill.js doctor -node bin/stylekit-style-prompts-skill.js install --tool codex --target /tmp/stylekit-skill-test --force -node bin/stylekit-style-prompts-skill.js uninstall --target /tmp/stylekit-skill-test +node bin/stylekit-skill.js doctor +node bin/stylekit-skill.js install --tool codex --target /tmp/stylekit-skill-test --force +node bin/stylekit-skill.js uninstall --target /tmp/stylekit-skill-test python3 scripts/audit_style_rule_conflicts.py --format text +python3 scripts/validate_taxonomy.py --format json --max-unused-style-tags 0 --fail-on-warning +python3 scripts/validate_output_contract_sync.py --format json --fail-on-warning python3 scripts/smoke_test.py python3 scripts/run_pipeline.py --query "高端科技SaaS财务后台,玻璃质感,强调可读性" --stack nextjs --format json python3 scripts/run_pipeline.py --workflow codegen --query "高端科技SaaS财务后台,玻璃质感,强调可读性" --stack nextjs --format json +python3 scripts/run_pipeline.py --query "高端科技SaaS财务后台,玻璃质感,强调可读性" --stack nextjs --site-type dashboard --recommendation-mode hybrid --content-depth skeleton --decision-speed fast --format json +python3 scripts/merge_taxonomy_expansion.py --type animation --input tmp/gemini-output.json --dry-run +python3 scripts/propose_upgrade.py --pipeline-output tmp/pipeline-output.json --out-dir tmp/upgrade-proposals --format json +python3 scripts/review_upgrade_candidate.py --candidate tmp/upgrade-proposals/.json --format json ``` 说明: - 默认是 `--workflow manual`(手册/知识库模式):输出设计简报 + 手册化建议,不强制走 prompt QA。 - 若要生成并严格审查 prompt,请显式加 `--workflow codegen`。 +- v2 支持站点类型路由:`--site-type`(blog/saas/dashboard/docs/ecommerce/landing-page/portfolio/general)。 +- v2 支持组合决策参数:`--recommendation-mode`、`--content-depth`、`--decision-speed`。 +- taxonomy 门禁可用 `--max-unused-style-tags 0 --fail-on-warning` 强制 style tag registry 无闲置条目且 warning 视为失败。 +- 契约防漂移门禁可用 `validate_output_contract_sync.py`:校验 `references/output-contract.md` 与 `tests/schemas` 一致(按每个必需章节的第一个 JSON 示例做门禁校验,可用 `--fail-on-warning` 将 warning 提升为失败)。 +- taxonomy 扩展脚本支持 `new_style_tags` 字段,并会在 apply 时更新 `references/taxonomy/style-tag-registry.json`。 - 在 manual 模式下,会额外输出 `manual_assistant.decision_assistant`,包含:候选风格卡片、给新手的引导问题、以及用户选定风格后的下一步命令模板。 - 可直接复用对话模板:`references/cc-decision-conversation-template.md`。 +- 若要做“人工审核升级”闭环:先运行 `propose_upgrade.py` 生成候选,再用 `review_upgrade_candidate.py` 校验后发 PR。 ## 3) 回归门禁 ```bash +python3 scripts/validate_taxonomy.py --format json --max-unused-style-tags 0 --fail-on-warning +python3 scripts/validate_output_contract_sync.py --format text --fail-on-warning bash scripts/ci_regression_gate.sh --baseline references/benchmark-baseline.json --snapshot-out tmp/benchmark-ci-latest.json ``` ## 4) 发布到 npm(让所有人可用) ```bash +node bin/stylekit-skill.js doctor +npm pack --dry-run npm login npm whoami npm publish --access public ``` -发布后,任何人都可以通过 `npx @anxforever/stylekit-style-prompts-skill ...` 直接安装。 +发布后,任何人都可以通过 `npx @anxforever/stylekit-skill ...` 直接安装。 + +## 5) 正式上线清单 + +上线前请完整执行:`GO_LIVE_CHECKLIST.md`。 diff --git a/RELEASE.md b/RELEASE.md index dd642ba..6d880a9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,6 +1,7 @@ # Release Process This repository uses benchmark regression gates to keep skill quality stable. +For full release readiness gates, also run `GO_LIVE_CHECKLIST.md`. ## 1) Pre-release Checks diff --git a/SKILL.md b/SKILL.md index 43f6147..e1484cf 100644 --- a/SKILL.md +++ b/SKILL.md @@ -9,12 +9,28 @@ description: Use when users ask to generate beautiful frontend prompts from Styl Generate better-looking frontend output by combining StyleKit style identity, actionable constraints, and quality checks. +## When to Use + +Activate this skill when the user: +- Asks to generate a frontend design prompt or style prompt +- Wants to select, compare, or blend StyleKit visual styles +- Needs a design brief for a new page, dashboard, or landing page +- Asks to audit or fix an existing frontend prompt's quality +- Mentions StyleKit, style prompts, or design system prompt generation +- Wants to convert a screenshot or Figma frame into a style-constrained prompt + +Do NOT use this skill for general CSS questions, backend logic, or non-visual tasks. + ## Quick One-shot Command Run handbook mode in one command (default): `python scripts/run_pipeline.py --query "" --stack nextjs --format json` +Run site-type routed composition (style + layout + motion + interaction): + +`python scripts/run_pipeline.py --query "" --stack nextjs --site-type dashboard --recommendation-mode hybrid --content-depth skeleton --decision-speed fast --format json` + Run prompt-generation mode with QA gate: `python scripts/run_pipeline.py --workflow codegen --query "" --stack nextjs --format json` @@ -59,14 +75,26 @@ CI one-command gate: `bash scripts/ci_regression_gate.sh --baseline references/benchmark-baseline.json --snapshot-out tmp/benchmark-ci-latest.json` +Run taxonomy guard with strict style-tag registry usage: + +`python scripts/validate_taxonomy.py --format json --max-unused-style-tags 0 --fail-on-warning` + +Run output-contract sync guard (docs example JSON vs tests schema): + +`python scripts/validate_output_contract_sync.py --format text --fail-on-warning` + +Dry-run taxonomy expansion (including optional `new_style_tags` in input JSON): + +`python scripts/merge_taxonomy_expansion.py --type animation --input tmp/gemini-output.json --dry-run` + ## Workflow 1: Requirement -> Style Candidates -> Design Brief -> Prompt 1. Refresh dataset when needed: `bash scripts/refresh-style-prompts.sh /mnt/d/stylekit` 2. Retrieve top style candidates: - `python scripts/search_stylekit.py --query "" --top 5` + `python scripts/search_stylekit.py --query "" --top 5 --site-type ` 3. Generate design brief and prompts: - `python scripts/generate_brief.py --query "" --stack nextjs --mode brief+prompt` + `python scripts/generate_brief.py --query "" --stack nextjs --site-type dashboard --recommendation-mode hybrid --content-depth skeleton --decision-speed fast --mode brief+prompt` 4. If needed, force multi-style blend ownership: `python scripts/generate_brief.py --query "" --stack nextjs --mode brief+prompt --blend-mode on` 5. For iterative work, set refine mode: @@ -87,7 +115,7 @@ CI one-command gate: 3. Read `manual_assistant.decision_assistant.recommended_style_options` and explain 3-4 options with trade-offs. 4. Ask `manual_assistant.decision_assistant.decision_questions` to help user pick direction. 5. After user selects one option, run codegen mode with forced style: - `python scripts/run_pipeline.py --workflow codegen --query "" --stack nextjs --style --blend-mode off --format json` + `python scripts/run_pipeline.py --workflow codegen --query "" --stack nextjs --style --site-type --content-depth skeleton --blend-mode off --format json` 6. Follow `references/cc-decision-conversation-template.md` for a turn-by-turn assistant script. ## Workflow 2: Existing Prompt -> Quality Audit -> Fix Suggestions @@ -125,6 +153,12 @@ Primary output object fields: - `soft_prompt` - `ai_rules` - `style_choice` +- `site_profile` +- `tag_bundle` +- `composition_plan` +- `decision_flow` +- `content_plan` +- `upgrade_candidates` - `quality_gate` (for audits) - `design_brief.refine_mode` - `design_brief.input_context.reference_type` @@ -141,7 +175,22 @@ Primary output object fields: - Include pre-delivery validation tests (swap/squint/signature/token). - Include an anti-pattern blacklist (absolute layout misuse, nested scroll, missing focus states, etc.). - Preserve user language (Chinese in -> Chinese out; English in -> English out). -- If intent is ambiguous, return top 3 candidates with reasons before final prompt. +- If intent is ambiguous, return top 5 candidates with reasons before final prompt. + +## Error Handling + +- If `quality_gate.status` is `"fail"`, read `violations` and `autofix_suggestions`, apply fixes, then re-run the QA audit. Repeat up to 3 rounds. +- If the pipeline exits with a non-zero code, check stderr for `ModuleNotFoundError` (missing Python dependency or wrong cwd) or `FileNotFoundError` (missing reference data — run `refresh-style-prompts.sh` first). +- If `search_candidates` returns 0 results, broaden the query or remove `--site-type` constraint. + +## Parameter Interactions + +- `--blend-mode on` + `--style `: forces blend OFF (explicit style selection overrides blend). +- `--refine-mode` requires `--workflow codegen`; ignored in handbook mode. +- `--reference-type` + `--strict-reference-schema`: strict mode validates the reference JSON payload against the expected schema and fails fast on mismatch. +- `--recommendation-mode hybrid` uses both BM25 search and taxonomy routing; `rules` skips BM25 and relies solely on site-type routing rules. +- `--content-depth skeleton` produces minimal structure; `storyboard` adds section copy; `near-prod` generates production-ready content blocks. +- `validate_taxonomy.py --max-unused-style-tags 0 --fail-on-warning` enforces zero unused tags in `style-tag-registry` and treats warnings as failures. ## Stack Adapters @@ -173,5 +222,12 @@ If stack is unknown, fallback to framework-agnostic Tailwind semantics. - `scripts/benchmark_pipeline.py`: benchmark pass-rate, hard-check pass rate, bucket pass-rate (`strict-domain`/`balanced`/`expressive`), snapshot export, and baseline regression gate. - `scripts/ci_regression_gate.sh`: CI wrapper for benchmark regression gate (supports baseline bootstrap). - `scripts/smoke_test.py`: validate end-to-end script integrity. +- `scripts/validate_taxonomy.py`: taxonomy consistency + style-tag-registry coverage guard (`--fail-on-warning` promotes warnings to failures). +- `scripts/validate_output_contract_sync.py`: output-contract markdown JSON examples vs tests schema sync guard (uses the first JSON block in each required section as canonical; `--fail-on-warning` promotes warnings to failures). +- `scripts/merge_taxonomy_expansion.py`: merge Gemini taxonomy expansion payloads (animation/interaction + optional `new_style_tags`). +- `scripts/propose_upgrade.py`: generate manual-review upgrade candidates from pipeline output. +- `scripts/review_upgrade_candidate.py`: validate upgrade candidate schema and gate requirements. - `references/benchmark-baseline.json`: default baseline snapshot for CI gate. - `references/github-actions-regression-gate.yml`: GitHub Actions template for regression automation. +- `references/taxonomy/style-tag-registry.json`: controlled style tag dictionary used by routing validation. +- `references/taxonomy/*`: site-type routing, controlled tags, alias mapping, and style-tag overrides. diff --git a/agents/claude.yaml b/agents/claude.yaml new file mode 100644 index 0000000..1ef1011 --- /dev/null +++ b/agents/claude.yaml @@ -0,0 +1,78 @@ +name: stylekit-style-prompts +display_name: "StyleKit Style Prompts" +description: > + Generate beautiful frontend design prompts from 120+ StyleKit styles. + Includes style search, design brief generation, multi-style blending, + prompt quality audit, and iterative refinement workflows. + +triggers: + - user asks to generate a frontend design prompt or style prompt + - user wants to select, compare, or blend StyleKit visual styles + - user needs a design brief for a page, dashboard, or landing page + - user asks to audit or fix an existing frontend prompt + - user mentions StyleKit, style prompts, or design system prompt generation + - user wants to convert a screenshot or Figma frame into a style-constrained prompt + +exclude: + - general CSS questions without StyleKit context + - backend logic or non-visual tasks + - generic code generation without design intent + +tools: + - bash + +commands: + one_shot: + description: "Full pipeline: search → brief → prompt → QA in one command" + template: > + python scripts/run_pipeline.py + --query "{query}" + --stack {stack|nextjs} + --format json + codegen: + description: "Prompt generation mode with QA gate" + template: > + python scripts/run_pipeline.py + --workflow codegen + --query "{query}" + --stack {stack|nextjs} + --format json + search: + description: "Find top matching StyleKit styles" + template: > + python scripts/search_stylekit.py + --query "{query}" + --top {top|5} + --format json + brief: + description: "Generate design brief and prompts" + template: > + python scripts/generate_brief.py + --query "{query}" + --stack {stack|nextjs} + --mode {mode|brief+prompt} + qa: + description: "Audit prompt quality" + template: > + python scripts/qa_prompt.py + --input {input_file} + --style {style_slug} + +working_directory: scripts +requires: + - python >= 3.10 + +error_handling: + qa_fail: > + When QA returns status "fail", read violations and autofix_suggestions. + Rewrite the prompt addressing each violation, then re-run QA. + Repeat up to 3 rounds. If still failing, report remaining violations to user. + no_style_match: > + If search returns 0 candidates, broaden the query or remove style-type filter. + reference_schema_error: > + If --strict-reference-schema fails, show schema errors to user and suggest fixes. + +parameter_interactions: + - "--blend-mode on + --style forces blend off (single style override)" + - "--refine-mode != new implies iterative context; pair with reference inputs when available" + - "--strict-reference-schema blocks unknown fields; omit for exploratory payloads" diff --git a/bin/stylekit-style-prompts-skill.js b/bin/stylekit-skill.js similarity index 73% rename from bin/stylekit-style-prompts-skill.js rename to bin/stylekit-skill.js index f8e8301..37fb8dd 100644 --- a/bin/stylekit-style-prompts-skill.js +++ b/bin/stylekit-skill.js @@ -5,8 +5,9 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); +const { execFileSync, execSync } = require("child_process"); -const PACKAGE_NAME = "@anxforever/stylekit-style-prompts-skill"; +const PACKAGE_NAME = "@anxforever/stylekit-skill"; const SKILL_SLUG = "stylekit-style-prompts"; const ROOT_DIR = path.resolve(__dirname, ".."); const PAYLOAD_ITEMS = [ @@ -29,7 +30,7 @@ function printHelp() { ${PACKAGE_NAME} Usage: - stylekit-style-prompts-skill [options] + stylekit-skill [options] Commands: install Install the skill payload @@ -180,6 +181,12 @@ function copyPayload(targetPath, dryRun) { function install(options) { ensurePayloadAvailable(); + const py = checkPython(); + if (!py.found) { + process.stdout.write("Warning: python3 not found. Pipeline scripts require Python >= 3.10.\n"); + } else if (!py.ok) { + process.stdout.write(`Warning: ${py.version} detected but >= 3.10 is required for pipeline scripts.\n`); + } const targets = resolveTargets(options.tool, options.target); for (const entry of targets) { const { tool, targetPath } = entry; @@ -220,6 +227,20 @@ function uninstall(options) { } } +function checkPython() { + try { + const raw = execSync("python3 --version", { encoding: "utf-8" }).trim(); + const match = raw.match(/Python\s+(\d+)\.(\d+)/); + if (!match) return { found: false, version: raw, ok: false }; + const major = Number.parseInt(match[1], 10); + const minor = Number.parseInt(match[2], 10); + const ok = major > 3 || (major === 3 && minor >= 10); + return { found: true, version: raw, ok }; + } catch { + return { found: false, version: null, ok: false }; + } +} + function doctor() { const nodeMajor = Number.parseInt(process.versions.node.split(".")[0], 10); const nodeOk = Number.isFinite(nodeMajor) && nodeMajor >= 18; @@ -228,6 +249,13 @@ function doctor() { process.stdout.write("Requirement: Node >= 18\n"); } + const py = checkPython(); + if (py.found) { + process.stdout.write(`Python version: ${py.version} (${py.ok ? "ok" : "fail — need >= 3.10"})\n`); + } else { + process.stdout.write("Python version: not found (fail — python3 is required)\n"); + } + const missingPayload = PAYLOAD_ITEMS.filter( (item) => !fs.existsSync(path.join(ROOT_DIR, item)) ); @@ -238,9 +266,36 @@ function doctor() { for (const [tool, targetPath] of Object.entries(DEFAULT_TARGETS)) { const installed = fs.existsSync(path.join(targetPath, "SKILL.md")); process.stdout.write(`${tool} target: ${targetPath} (${installed ? "installed" : "not installed"})\n`); + if (installed) { + const catalogPath = path.join(targetPath, "references", "style-prompts.json"); + const taxonomyDir = path.join(targetPath, "references", "taxonomy"); + const catalogOk = fs.existsSync(catalogPath); + const taxonomyOk = fs.existsSync(taxonomyDir); + process.stdout.write(` style-prompts.json: ${catalogOk ? "ok" : "missing"}\n`); + process.stdout.write(` taxonomy/: ${taxonomyOk ? "ok" : "missing"}\n`); + if (catalogOk) { + try { + JSON.parse(fs.readFileSync(catalogPath, "utf-8")); + process.stdout.write(" catalog parse: ok\n"); + } catch { + process.stdout.write(" catalog parse: fail (invalid JSON)\n"); + } + } + } } - return nodeOk && missingPayload.length === 0 ? 0 : 1; + if (py.ok) { + try { + const scriptsDirLiteral = JSON.stringify(path.join(ROOT_DIR, "scripts")); + const pyCode = `import sys; sys.path.insert(0, ${scriptsDirLiteral}); from _common import __version__; print(__version__)`; + execFileSync("python3", ["-c", pyCode], { encoding: "utf-8", timeout: 10000 }); + process.stdout.write("Python import chain: ok\n"); + } catch { + process.stdout.write("Python import chain: fail (cannot import _common)\n"); + } + } + + return nodeOk && py.ok && missingPayload.length === 0 ? 0 : 1; } function main() { @@ -254,6 +309,13 @@ function main() { return 0; case "doctor": return doctor(); + case "version": + case "--version": + case "-v": { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, "package.json"), "utf-8")); + process.stdout.write(`${pkg.name} ${pkg.version}\n`); + return 0; + } case "help": printHelp(); return 0; diff --git a/package.json b/package.json index 33d8ae5..ac474c3 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,26 @@ { - "name": "@anxforever/stylekit-style-prompts-skill", - "version": "0.1.1", - "description": "Standalone StyleKit style-prompts skill installer for Codex and Claude.", + "name": "@anxforever/stylekit-skill", + "version": "0.2.0", + "description": "Standalone StyleKit skill installer for Codex and Claude.", "license": "MIT", "type": "commonjs", "bin": { - "stylekit-style-prompts-skill": "bin/stylekit-style-prompts-skill.js" + "stylekit-skill": "bin/stylekit-skill.js" }, "files": [ "SKILL.md", "README.md", "RELEASE.md", + "GO_LIVE_CHECKLIST.md", "LICENSE", "agents/*.yaml", "references/*.md", "references/*.json", "references/*.yml", + "references/taxonomy/*.json", "scripts/*.py", "scripts/*.sh", - "bin/stylekit-style-prompts-skill.js" + "bin/stylekit-skill.js" ], "keywords": [ "skill", @@ -30,12 +32,22 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/AnxForever/stylekit-style-prompts-skill.git" + "url": "git+https://github.com/AnxForever/stylekit-skill.git" }, "bugs": { - "url": "https://github.com/AnxForever/stylekit-style-prompts-skill/issues" + "url": "https://github.com/AnxForever/stylekit-skill/issues" + }, + "homepage": "https://github.com/AnxForever/stylekit-skill#readme", + "scripts": { + "doctor": "node bin/stylekit-skill.js doctor", + "smoke": "python3 scripts/smoke_test.py", + "test:unit": "pytest tests/ -m \"not slow\" -q", + "test:slow": "pytest tests/ -m slow -q", + "check:taxonomy": "python3 scripts/validate_taxonomy.py --format text --max-unused-style-tags 0 --fail-on-warning", + "check:contract": "python3 scripts/validate_output_contract_sync.py --format text --fail-on-warning", + "check:gate": "bash scripts/ci_regression_gate.sh --baseline references/benchmark-baseline.json --snapshot-out tmp/benchmark-ci-latest.json", + "prepublishOnly": "node bin/stylekit-skill.js doctor && python3 scripts/validate_taxonomy.py --format text --max-unused-style-tags 0 --fail-on-warning && python3 scripts/validate_output_contract_sync.py --format text --fail-on-warning && python3 scripts/smoke_test.py" }, - "homepage": "https://github.com/AnxForever/stylekit-style-prompts-skill#readme", "engines": { "node": ">=18" }, diff --git a/prompts/expand-animation-profiles.md b/prompts/expand-animation-profiles.md new file mode 100644 index 0000000..71aefc0 --- /dev/null +++ b/prompts/expand-animation-profiles.md @@ -0,0 +1,120 @@ +# Expand Animation Profiles — Gemini Prompt + +## Role + +You are a senior motion design engineer specializing in web UI animation systems. Your task is to expand an existing animation profile taxonomy with new categories and variants. + +## Context + +We have a StyleKit taxonomy that maps `motion_profile` enum values to concrete animation profiles. Each profile defines intent, timing, easing, states, and anti-patterns for a specific animation behavior. + +### Current Enum Values (5) + +``` +minimal, subtle, smooth, energetic, cinematic +``` + +### Current Profiles (12) + +```json +{ + "minimal-static": { "motion_profile": "minimal", ... }, + "minimal-functional": { "motion_profile": "minimal", ... }, + "subtle-fade": { "motion_profile": "subtle", ... }, + "subtle-slide": { "motion_profile": "subtle", ... }, + "smooth-morph": { "motion_profile": "smooth", ... }, + "smooth-flow": { "motion_profile": "smooth", ... }, + "smooth-reveal": { "motion_profile": "smooth", ... }, + "energetic-bounce": { "motion_profile": "energetic", ... }, + "energetic-stagger": { "motion_profile": "energetic", ... }, + "energetic-pulse": { "motion_profile": "energetic", ... }, + "cinematic-parallax": { "motion_profile": "cinematic", ... }, + "cinematic-sequence": { "motion_profile": "cinematic", ... } +} +``` + +### One Complete Example (follow this structure exactly) + +```json +{ + "smooth-morph": { + "motion_profile": "smooth", + "intent": "Facilitates fluid transitions between geometric states to maintain visual continuity during layout changes.", + "trigger": "user-interaction", + "states": ["default", "expanded", "collapsed", "hover"], + "duration_range_ms": [250, 450], + "easing": "cubic-bezier(0.4, 0, 0.2, 1)", + "reduced_motion_fallback": "instant-state-swap", + "suitable_site_types": ["saas", "ecommerce", "landing-page"], + "anti_patterns": ["Unexpected layout shifts during morphing", "Morphing without will-change optimization"] + } +} +``` + +### Available site_types + +``` +blog, saas, dashboard, docs, ecommerce, landing-page, portfolio, general +``` + +## Your Task + +### Part 1: New Enum Values + +Propose 2-3 new `motion_profile` enum values that fill gaps in the current taxonomy. Consider: +- **playful**: bouncy, whimsical, toy-like motion for kids/casual apps +- **functional**: purely utilitarian micro-interactions (loading spinners, progress bars, skeleton screens) +- **ambient**: slow, atmospheric, background-only motion (floating particles, gradient shifts) + +For each new enum value, provide: +1. A short definition (1 sentence) +2. 2-3 concrete profiles following the exact schema below + +### Part 2: New Variants for Existing Enums + +Add 1-2 new profiles for each existing enum value that cover gaps: +- `minimal`: consider a "minimal-skeleton" for loading states +- `subtle`: consider a "subtle-scale" for hover micro-feedback +- `smooth`: consider a "smooth-spring" using spring physics +- `energetic`: consider an "energetic-flip" for card interactions +- `cinematic`: consider a "cinematic-morph" for page transitions + +## Output Format + +Return a single JSON object. **No markdown fences, no commentary — pure JSON only.** + +``` +{ + "new_enum_values": [ + { + "value": "playful", + "definition": "..." + } + ], + "new_profiles": { + "playful-wobble": { + "motion_profile": "playful", + "intent": "...", + "trigger": "user-interaction | viewport-enter | scroll-progress | page-load | attention-needed | state-change-only", + "states": ["..."], + "duration_range_ms": [min, max], + "easing": "css easing string or cubic-bezier(...)", + "reduced_motion_fallback": "none | instant-state-swap | instant-visible | fade-only | static-position | static-layers | scale-only | color-highlight-only", + "suitable_site_types": ["..."], + "anti_patterns": ["...", "..."] + } + } +} +``` + +## Quality Constraints + +1. **intent** must be a single sentence starting with a verb, describing the UX purpose (not the CSS implementation) +2. **duration_range_ms** must be realistic: [min, max] where min >= 0 and max <= 2000 +3. **easing** must be a valid CSS easing value +4. **anti_patterns** must be 2-3 specific, actionable warnings (not vague "don't overuse") +5. **reduced_motion_fallback** must be one of the allowed values listed above +6. **suitable_site_types** must only use values from the available list +7. Each profile name must follow the pattern `{enum-value}-{variant}` (kebab-case) +8. No two profiles should have identical intent — each must serve a distinct UX purpose +9. Total new profiles: aim for 10-15 across all categories diff --git a/prompts/expand-interaction-patterns.md b/prompts/expand-interaction-patterns.md new file mode 100644 index 0000000..9913708 --- /dev/null +++ b/prompts/expand-interaction-patterns.md @@ -0,0 +1,120 @@ +# Expand Interaction Patterns — Gemini Prompt + +## Role + +You are a senior UX engineer specializing in interaction design systems and accessibility. Your task is to expand an existing interaction pattern taxonomy with new categories and richer state coverage. + +## Context + +We have a StyleKit taxonomy that maps `interaction_pattern` enum values to structured pattern definitions. Each pattern defines a primary goal, required components, state coverage requirements, accessibility constraints, and anti-patterns. + +### Current Enum Values (6) + +``` +content-reading, conversion-focused, data-dense-feedback, showcase-narrative, docs-navigation, assistant-guided +``` + +### Current Patterns (6, 1:1 with enum) + +Each pattern key matches its enum value exactly. + +### One Complete Example (follow this structure exactly) + +```json +{ + "data-dense-feedback": { + "primary_goal": "Supports efficient data parsing and manipulation through immediate, granular interaction feedback.", + "suitable_site_types": ["dashboard", "saas"], + "required_components": ["data-table", "kpi-card", "filter-bar", "chart", "alert-strip"], + "state_coverage_requirements": { + "table-row": ["default", "hover", "selected", "loading", "error", "empty"], + "kpi-card": ["loading", "loaded", "error", "trend-up", "trend-down"], + "filter": ["default", "active", "applied-count", "clearing"] + }, + "accessibility_constraints": [ + "Tables must use semantic headers with appropriate scope attributes", + "Provide accessible data table fallbacks for complex charts", + "Announce critical status updates via aria-live regions", + "Ensure full keyboard operability for all filtering and sorting controls" + ], + "anti_patterns": [ + "Auto-refreshing data without user consent or notification", + "Hiding essential data behind tooltips without persistent alternatives", + "Relying solely on color to communicate system status" + ] + } +} +``` + +### Available site_types + +``` +blog, saas, dashboard, docs, ecommerce, landing-page, portfolio, general +``` + +## Your Task + +### Part 1: New Enum Values + +Propose 3-4 new `interaction_pattern` enum values that fill real gaps. Consider these candidates: + +- **form-wizard**: Multi-step form flows with validation, progress tracking, and error recovery +- **social-feed**: Infinite scroll, reactions, comments, real-time updates +- **media-player**: Video/audio playback controls, playlists, progress scrubbing +- **collaborative-editing**: Real-time cursors, presence indicators, conflict resolution +- **search-explore**: Faceted search, filters, sort, saved searches, result previews +- **notification-center**: Toast stacks, notification lists, read/unread, action buttons +- **onboarding-tour**: Step-by-step product tours, feature highlights, skip/resume + +Pick the 3-4 most universally useful ones. + +### Part 2: Enrich Existing Patterns + +For each of the 6 existing patterns, suggest 1-2 additional `state_coverage_requirements` entries (new component + states) that are currently missing but would improve real-world coverage. + +## Output Format + +Return a single JSON object. **No markdown fences, no commentary — pure JSON only.** + +``` +{ + "new_enum_values": [ + { + "value": "form-wizard", + "definition": "..." + } + ], + "new_patterns": { + "form-wizard": { + "primary_goal": "...", + "suitable_site_types": ["..."], + "required_components": ["..."], + "state_coverage_requirements": { + "component-name": ["state1", "state2", "..."] + }, + "accessibility_constraints": ["...", "...", "...", "..."], + "anti_patterns": ["...", "...", "..."] + } + }, + "existing_pattern_additions": { + "content-reading": { + "new_state_coverage": { + "component-name": ["state1", "state2"] + } + } + } +} +``` + +## Quality Constraints + +1. **primary_goal** must be a single sentence starting with a verb, describing the UX purpose +2. **required_components** should be 3-5 concrete UI components (kebab-case) +3. **state_coverage_requirements** must have 2-4 component entries, each with 3-6 realistic states +4. **accessibility_constraints** must be exactly 4 items, each a specific WCAG-aligned requirement (not vague) +5. **anti_patterns** must be exactly 3 items, each a specific, actionable warning +6. **suitable_site_types** must only use values from the available list +7. Pattern keys must be kebab-case and match their enum value exactly +8. No two patterns should overlap significantly in primary_goal +9. States should follow real UI lifecycle: include loading, error, empty where applicable +10. Each new `state_coverage_requirements` entry in Part 2 must target a component NOT already covered in the existing pattern diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6934f3a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "stylekit-skill-scripts" +version = "0.2.0" +description = "Python pipeline scripts for StyleKit skill" +requires-python = ">=3.10" +dependencies = [] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = ["slow: integration tests that invoke full pipeline"] diff --git a/references/github-actions-regression-gate.yml b/references/github-actions-regression-gate.yml index 734bc90..846e1f8 100644 --- a/references/github-actions-regression-gate.yml +++ b/references/github-actions-regression-gate.yml @@ -4,6 +4,10 @@ on: pull_request: workflow_dispatch: +defaults: + run: + working-directory: . + jobs: regression-gate: runs-on: ubuntu-latest @@ -11,17 +15,40 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "18" + - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.11" + - name: Install contract-check dependency + run: | + pip install jsonschema + + - name: Environment info + run: | + python3 --version + node --version + node bin/stylekit-skill.js doctor + - name: Run smoke test run: | - python3 path/to/stylekit-style-prompts/scripts/smoke_test.py + python3 scripts/smoke_test.py + + - name: Run taxonomy guard + run: | + python3 scripts/validate_taxonomy.py --max-unused-style-tags 0 --fail-on-warning + + - name: Run output-contract sync guard + run: | + python3 scripts/validate_output_contract_sync.py --format text --fail-on-warning - name: Run benchmark regression gate run: | - bash path/to/stylekit-style-prompts/scripts/ci_regression_gate.sh \ - --baseline path/to/stylekit-style-prompts/references/benchmark-baseline.json \ - --snapshot-out path/to/stylekit-style-prompts/tmp/benchmark-ci-latest.json + bash scripts/ci_regression_gate.sh \ + --baseline references/benchmark-baseline.json \ + --snapshot-out tmp/benchmark-ci-latest.json diff --git a/references/output-contract.md b/references/output-contract.md index 041e879..f47e43f 100644 --- a/references/output-contract.md +++ b/references/output-contract.md @@ -1,5 +1,10 @@ # StyleKit Style Prompts Output Contract +Note: +- Contract sync guard (`scripts/validate_output_contract_sync.py`) validates the **first** `json` code block under each required section. +- Additional `json` blocks in the same section are treated as supplemental examples and are not schema-gated. +- Use `--fail-on-warning` in CI to fail when duplicate/supplemental blocks trigger warnings. + ## 1) Candidate Search Output Source: `scripts/search_stylekit.py` @@ -12,7 +17,15 @@ Source: `scripts/search_stylekit.py` "top": 5, "returned": 5, "style_type_filter": "visual", - "schemaVersion": "1.1.0", + "site_type_filter": "dashboard", + "site_profile": { + "site_type": "dashboard", + "source": "alias-match", + "confidence": 0.9, + "matched_signals": ["dashboard", "admin"] + }, + "schemaVersion": "2.0.0", + "catalog_schema_version": "1.1.0", "generatedAt": "2026-02-24T00:00:00.000Z", "candidates": [ { @@ -27,7 +40,13 @@ Source: `scripts/search_stylekit.py` "style_type_match": true, "matched_keywords": ["dashboard"], "matched_tags": ["high-contrast"], - "concept_overlap": 4 + "concept_overlap": 4, + "site_type_adjustment": 2.4, + "site_route_details": { + "site_type": "dashboard", + "favored_hits": ["dashboard", "data"], + "penalized_hits": [] + } }, "reason_summary": "keyword overlap; tag overlap", "preview": { @@ -77,6 +96,7 @@ Source: `scripts/generate_brief.py` "reference_notes": "...", "reference_file": "...", "reference_payload_present": true, + "reference_has_signals": true, "reference_schema_validation": { "valid": true, "strict_mode": false, @@ -108,6 +128,46 @@ Source: `scripts/generate_brief.py` "token_hierarchy": ["..."], "component_architecture": ["..."] }, + "site_profile": { + "site_type": "dashboard", + "source": "alias-match", + "confidence": 0.9, + "matched_signals": ["dashboard", "admin"] + }, + "tag_bundle": { + "site_type": "dashboard", + "visual_style": "corporate", + "layout_archetype": "kpi-console", + "motion_profile": "minimal", + "interaction_pattern": "data-dense-feedback", + "modifiers": ["dense-information", "readability-first"] + }, + "composition_plan": { + "site_type": "dashboard", + "recommendation_mode": "hybrid", + "style_recommendation": {}, + "layout_recommendation": {}, + "motion_recommendation": {}, + "interaction_recommendation": {}, + "owner_matrix": {}, + "ai_interaction_script": ["..."], + "checks": ["..."], + "rationale": ["..."] + }, + "decision_flow": { + "decision_speed": "fast", + "style_options": [], + "steps": ["..."], + "lock_command_template": "python scripts/run_pipeline.py ..." + }, + "content_plan": { + "content_depth": "skeleton", + "core_pages": ["overview", "detail"], + "core_modules": ["kpi-cards", "table"], + "optional_modules": ["activity-feed"], + "state_coverage": ["default", "hover", "active", "focus-visible", "disabled", "loading", "error", "empty"], + "goal": "..." + }, "color_strategy": { "primary": "#xxxxxx", "secondary": "#xxxxxx", @@ -131,7 +191,7 @@ Source: `scripts/generate_brief.py` "notes": "..." } }, - "ai_rules": ["..."], + "ai_rules": ["rule-1", "rule-2", "rule-3"], "hard_prompt": "...", "soft_prompt": "...", "candidate_rank": [] @@ -146,6 +206,10 @@ Rules: - `blend_mode=on` forces blend plan if at least one alternative style exists. - `refine_mode` supports: `new`, `polish`, `debug`, `contrast-fix`, `layout-fix`, `component-fill`. - `reference_type` supports: `none`, `screenshot`, `figma`, `mixed`. +- `site_type` supports: `auto`, `blog`, `saas`, `dashboard`, `docs`, `ecommerce`, `landing-page`, `portfolio`, `general`. +- `content_depth` supports: `skeleton`, `storyboard`, `near-prod`. +- `recommendation_mode` supports: `hybrid`, `rules`. +- `decision_speed` supports: `fast`, `guided`. - Optional reference payload can be passed via `--reference-file` or `--reference-json`. - Optional `--strict-reference-schema` fails on schema errors/unknown top-level fields. @@ -169,6 +233,7 @@ Source: `scripts/qa_prompt.py` "autofix_suggestions": [], "meta": { "style": "vaporwave", + "expected_lang": "en", "min_ai_rules": 3, "prompt_length": 1200 } @@ -211,15 +276,26 @@ Source: `scripts/run_pipeline.py` ```json { + "schemaVersion": "2.0.0", "status": "pass", + "site_type": "dashboard", "query": "...", "stack": "nextjs", "style_type_filter": "visual", + "recommendation_mode": "hybrid", + "content_depth": "skeleton", + "decision_speed": "fast", "blend_mode": "auto", "refine_mode": "new", "reference_type": "none", "strict_reference_schema": false, "selected_style": "glassmorphism", + "site_profile": {}, + "tag_bundle": {}, + "composition_plan": {}, + "decision_flow": {}, + "content_plan": {}, + "upgrade_candidates": [], "candidates": [], "result": {}, "quality_gate": {} @@ -232,6 +308,8 @@ Rules: - `result` equals output from `generate_brief.py`. - `quality_gate` equals output from `qa_prompt.py`. - `blend_mode` controls blend behavior: `off`, `auto`, `on`. +- `--style ` always forces effective `blend_mode=off` in `run_pipeline.py`. +- `upgrade_candidates` is advisory only; it does not mutate repository files. ## 5) Benchmark Output diff --git a/references/taxonomy/animation-profiles.v2.json b/references/taxonomy/animation-profiles.v2.json new file mode 100644 index 0000000..27bb45d --- /dev/null +++ b/references/taxonomy/animation-profiles.v2.json @@ -0,0 +1,576 @@ +{ + "schemaVersion": "2.0.0", + "profiles": { + "minimal-static": { + "motion_profile": "minimal", + "intent": "Eliminates decorative motion to focus exclusively on essential, instantaneous state changes.", + "trigger": "state-change-only", + "states": [ + "default", + "hover", + "active", + "focus-visible", + "disabled" + ], + "duration_range_ms": [ + 0, + 100 + ], + "easing": "linear", + "reduced_motion_fallback": "none", + "suitable_site_types": [ + "docs", + "dashboard" + ], + "anti_patterns": [ + "Scroll-triggered animations", + "Auto-playing carousels" + ] + }, + "minimal-functional": { + "motion_profile": "minimal", + "intent": "Provides subtle micro-feedback to confirm user interactions with UI controls.", + "trigger": "user-interaction", + "states": [ + "hover", + "active", + "focus-visible", + "loading" + ], + "duration_range_ms": [ + 50, + 150 + ], + "easing": "ease-out", + "reduced_motion_fallback": "instant-state-swap", + "suitable_site_types": [ + "docs", + "dashboard", + "saas" + ], + "anti_patterns": [ + "Elaborate entrance animations", + "Parallax effects" + ] + }, + "subtle-fade": { + "motion_profile": "subtle", + "intent": "Utilizes soft opacity transitions for smooth content entry and visibility shifts.", + "trigger": "viewport-enter", + "states": [ + "hidden", + "visible", + "hover" + ], + "duration_range_ms": [ + 150, + 300 + ], + "easing": "ease-in-out", + "reduced_motion_fallback": "instant-visible", + "suitable_site_types": [ + "blog", + "saas", + "general" + ], + "anti_patterns": [ + "Stagger delays exceeding 500ms", + "Jarring bounce easing" + ] + }, + "subtle-slide": { + "motion_profile": "subtle", + "intent": "Employs brief directional translations to guide visual flow during list or card entry.", + "trigger": "viewport-enter", + "states": [ + "off-screen", + "in-place", + "hover" + ], + "duration_range_ms": [ + 200, + 350 + ], + "easing": "ease-out", + "reduced_motion_fallback": "fade-only", + "suitable_site_types": [ + "blog", + "saas", + "ecommerce", + "general" + ], + "anti_patterns": [ + "Slide distances exceeding 40px", + "Horizontal sliding on mobile viewports" + ] + }, + "smooth-morph": { + "motion_profile": "smooth", + "intent": "Facilitates fluid transitions between geometric states to maintain visual continuity during layout changes.", + "trigger": "user-interaction", + "states": [ + "default", + "expanded", + "collapsed", + "hover" + ], + "duration_range_ms": [ + 250, + 450 + ], + "easing": "cubic-bezier(0.4, 0, 0.2, 1)", + "reduced_motion_fallback": "instant-state-swap", + "suitable_site_types": [ + "saas", + "ecommerce", + "landing-page" + ], + "anti_patterns": [ + "Unexpected layout shifts during morphing", + "Morphing without will-change optimization" + ] + }, + "smooth-flow": { + "motion_profile": "smooth", + "intent": "Delivers seamless, continuous motion synchronized with scroll progress for immersive navigation.", + "trigger": "scroll-progress", + "states": [ + "start", + "mid", + "end" + ], + "duration_range_ms": [ + 300, + 600 + ], + "easing": "cubic-bezier(0.25, 0.1, 0.25, 1)", + "reduced_motion_fallback": "static-position", + "suitable_site_types": [ + "landing-page", + "portfolio", + "general" + ], + "anti_patterns": [ + "Jank caused by non-composited properties", + "Aggressive scroll hijacking" + ] + }, + "smooth-reveal": { + "motion_profile": "smooth", + "intent": "Implements sophisticated section reveals using clip-paths or masks for high-end visual impact.", + "trigger": "viewport-enter", + "states": [ + "masked", + "revealed" + ], + "duration_range_ms": [ + 400, + 700 + ], + "easing": "cubic-bezier(0.16, 1, 0.3, 1)", + "reduced_motion_fallback": "fade-only", + "suitable_site_types": [ + "landing-page", + "portfolio" + ], + "anti_patterns": [ + "Reveals that block content readability", + "Overlapping simultaneous reveals" + ] + }, + "energetic-bounce": { + "motion_profile": "energetic", + "intent": "Applies dynamic spring physics to provide tactile, high-energy feedback for interactive elements.", + "trigger": "user-interaction", + "states": [ + "rest", + "pressed", + "released", + "hover" + ], + "duration_range_ms": [ + 200, + 500 + ], + "easing": "cubic-bezier(0.34, 1.56, 0.64, 1)", + "reduced_motion_fallback": "scale-only", + "suitable_site_types": [ + "portfolio", + "landing-page", + "ecommerce" + ], + "anti_patterns": [ + "Bounce effects on long-form text", + "Input-to-bounce delay exceeding 100ms" + ] + }, + "energetic-stagger": { + "motion_profile": "energetic", + "intent": "Choreographs sequential element entry to establish visual rhythm and hierarchy in grids or lists.", + "trigger": "viewport-enter", + "states": [ + "queued", + "entering", + "settled" + ], + "duration_range_ms": [ + 150, + 400 + ], + "easing": "cubic-bezier(0.22, 1, 0.36, 1)", + "reduced_motion_fallback": "instant-visible", + "suitable_site_types": [ + "portfolio", + "ecommerce", + "landing-page" + ], + "anti_patterns": [ + "Staggering more than 12 items simultaneously", + "Total sequence duration exceeding 1500ms" + ] + }, + "energetic-pulse": { + "motion_profile": "energetic", + "intent": "Directs focus toward critical calls to action or notifications using rhythmic scaling or highlights.", + "trigger": "attention-needed", + "states": [ + "idle", + "pulsing", + "acknowledged" + ], + "duration_range_ms": [ + 300, + 800 + ], + "easing": "ease-in-out", + "reduced_motion_fallback": "color-highlight-only", + "suitable_site_types": [ + "ecommerce", + "saas", + "landing-page" + ], + "anti_patterns": [ + "Infinite pulse loops", + "Pulsing non-interactive static elements" + ] + }, + "cinematic-parallax": { + "motion_profile": "cinematic", + "intent": "Creates spatial depth through multi-layered parallax effects for immersive brand storytelling.", + "trigger": "scroll-progress", + "states": [ + "background", + "midground", + "foreground" + ], + "duration_range_ms": [ + 500, + 1200 + ], + "easing": "linear", + "reduced_motion_fallback": "static-layers", + "suitable_site_types": [ + "portfolio", + "landing-page" + ], + "anti_patterns": [ + "Parallax on mobile devices", + "Exceeding 3 depth layers", + "Parallax applied to body text" + ] + }, + "cinematic-sequence": { + "motion_profile": "cinematic", + "intent": "Executes a highly orchestrated sequence of element arrivals for dramatic, high-fidelity landing experiences.", + "trigger": "page-load", + "states": [ + "waiting", + "entering", + "settled" + ], + "duration_range_ms": [ + 600, + 1500 + ], + "easing": "cubic-bezier(0.16, 1, 0.3, 1)", + "reduced_motion_fallback": "instant-visible", + "suitable_site_types": [ + "portfolio", + "landing-page" + ], + "anti_patterns": [ + "Sequences blocking interaction for over 2s", + "Re-triggering sequences on every route change" + ] + }, + "playful-wobble": { + "motion_profile": "playful", + "intent": "Provides a tactile, toy-like physical reaction to user input to increase engagement on primary actions.", + "trigger": "user-interaction", + "states": [ + "default", + "pressed", + "released" + ], + "duration_range_ms": [ + 300, + 600 + ], + "easing": "cubic-bezier(0.68, -0.55, 0.265, 1.55)", + "reduced_motion_fallback": "scale-only", + "suitable_site_types": [ + "landing-page", + "portfolio", + "ecommerce" + ], + "anti_patterns": [ + "Applying to destructive or serious system actions", + "Stacking with other transform effects causing clipping" + ] + }, + "ambient-drift": { + "motion_profile": "ambient", + "intent": "Adds depth and continuous vitality to empty background spaces without competing with foreground content.", + "trigger": "page-load", + "states": [ + "idle" + ], + "duration_range_ms": [ + 1500, + 2000 + ], + "easing": "ease-in-out", + "reduced_motion_fallback": "static-position", + "suitable_site_types": [ + "landing-page", + "portfolio" + ], + "anti_patterns": [ + "Moving elements too quickly across the viewport", + "Overlapping interactive areas causing pointer-event issues" + ] + }, + "minimal-skeleton": { + "motion_profile": "minimal", + "intent": "Preserves layout structure during data fetching using low-contrast shimmering to reduce perceived load time.", + "trigger": "state-change-only", + "states": [ + "loading", + "loaded" + ], + "duration_range_ms": [ + 1000, + 1500 + ], + "easing": "ease-in-out", + "reduced_motion_fallback": "instant-visible", + "suitable_site_types": [ + "dashboard", + "saas", + "ecommerce", + "blog" + ], + "anti_patterns": [ + "Mismatching skeleton dimensions with final content", + "Animating shimmer at high speeds causing visual stress" + ] + }, + "subtle-scale": { + "motion_profile": "subtle", + "intent": "Confirms cursor hover status on interactive cards or buttons through a minor size adjustment.", + "trigger": "user-interaction", + "states": [ + "default", + "hovered" + ], + "duration_range_ms": [ + 100, + 200 + ], + "easing": "cubic-bezier(0.2, 0, 0, 1)", + "reduced_motion_fallback": "none", + "suitable_site_types": [ + "ecommerce", + "blog", + "portfolio", + "docs" + ], + "anti_patterns": [ + "Scaling up enough to overlap adjacent sibling elements", + "Omitting drop-shadow adjustments when element scales up" + ] + }, + "smooth-spring": { + "motion_profile": "smooth", + "intent": "Reveals off-canvas menus or modal dialogs with a natural, friction-based deceleration to mimic physical slide-in.", + "trigger": "user-interaction", + "states": [ + "closed", + "open" + ], + "duration_range_ms": [ + 300, + 500 + ], + "easing": "cubic-bezier(0.175, 0.885, 0.32, 1)", + "reduced_motion_fallback": "instant-state-swap", + "suitable_site_types": [ + "saas", + "dashboard", + "ecommerce", + "general" + ], + "anti_patterns": [ + "Using overly bouncy parameters that prolong the interaction time", + "Triggering spring animations on scroll instead of direct click" + ] + }, + "energetic-flip": { + "motion_profile": "energetic", + "intent": "Reveals secondary contextual information on the reverse side of a component via a rapid 3D rotation.", + "trigger": "user-interaction", + "states": [ + "front", + "back" + ], + "duration_range_ms": [ + 400, + 600 + ], + "easing": "cubic-bezier(0.4, 0, 0.2, 1)", + "reduced_motion_fallback": "instant-state-swap", + "suitable_site_types": [ + "portfolio", + "landing-page", + "ecommerce" + ], + "anti_patterns": [ + "Flipping text heavy content that becomes hard to track", + "Ignoring backface-visibility resulting in mirrored content" + ] + }, + "cinematic-morph": { + "motion_profile": "cinematic", + "intent": "Bridges context between a thumbnail and a fullscreen detail view by seamlessly animating shared elements.", + "trigger": "user-interaction", + "states": [ + "thumbnail", + "fullscreen" + ], + "duration_range_ms": [ + 600, + 900 + ], + "easing": "cubic-bezier(0.65, 0, 0.35, 1)", + "reduced_motion_fallback": "fade-only", + "suitable_site_types": [ + "portfolio", + "ecommerce", + "blog" + ], + "anti_patterns": [ + "Cross-fading mismatched aspect ratios abruptly", + "Animating too many non-shared elements concurrently" + ] + }, + "playful-stretch": { + "motion_profile": "playful", + "intent": "Exaggerates drag or pull-to-refresh interactions using simulated elasticity to make waiting periods feel shorter.", + "trigger": "user-interaction", + "states": [ + "default", + "stretching", + "snapped" + ], + "duration_range_ms": [ + 200, + 450 + ], + "easing": "cubic-bezier(0.175, 0.885, 0.32, 1.275)", + "reduced_motion_fallback": "instant-state-swap", + "suitable_site_types": [ + "portfolio", + "general", + "landing-page" + ], + "anti_patterns": [ + "Stretching typography leading to illegibility", + "Exceeding realistic screen boundaries during stretch" + ] + }, + "functional-spinner": { + "motion_profile": "functional", + "intent": "Communicates ongoing indeterminate background processes clearly to prevent user abandonment during wait times.", + "trigger": "state-change-only", + "states": [ + "hidden", + "spinning" + ], + "duration_range_ms": [ + 600, + 1000 + ], + "easing": "linear", + "reduced_motion_fallback": "fade-only", + "suitable_site_types": [ + "saas", + "dashboard", + "ecommerce", + "docs" + ], + "anti_patterns": [ + "Using non-linear easing that makes rotation look jerky", + "Leaving spinners active indefinitely without timeouts" + ] + }, + "functional-progress": { + "motion_profile": "functional", + "intent": "Illustrates determinate completion status accurately to set expectations during file uploads or multi-step forms.", + "trigger": "state-change-only", + "states": [ + "empty", + "filling", + "complete" + ], + "duration_range_ms": [ + 150, + 300 + ], + "easing": "cubic-bezier(0.4, 0, 0.2, 1)", + "reduced_motion_fallback": "instant-state-swap", + "suitable_site_types": [ + "saas", + "dashboard", + "ecommerce" + ], + "anti_patterns": [ + "Animating backwards when progress decreases", + "Slowing down artificially to simulate heavy processing" + ] + }, + "ambient-glow": { + "motion_profile": "ambient", + "intent": "Draws subtle atmospheric attention to premium features or hero sections through slowly shifting gradient opacity.", + "trigger": "viewport-enter", + "states": [ + "dim", + "glowing" + ], + "duration_range_ms": [ + 1200, + 2000 + ], + "easing": "linear", + "reduced_motion_fallback": "color-highlight-only", + "suitable_site_types": [ + "saas", + "landing-page", + "portfolio" + ], + "anti_patterns": [ + "Causing heavy GPU repaints with unoptimized drop-shadows", + "Creating strobing effects that violate accessibility guidelines" + ] + } + } +} diff --git a/references/taxonomy/interaction-patterns.v2.json b/references/taxonomy/interaction-patterns.v2.json new file mode 100644 index 0000000..5ae611a --- /dev/null +++ b/references/taxonomy/interaction-patterns.v2.json @@ -0,0 +1,488 @@ +{ + "schemaVersion": "2.0.0", + "patterns": { + "content-reading": { + "primary_goal": "Optimizes for distraction-free, long-form reading by prioritizing legibility and flow.", + "suitable_site_types": [ + "blog", + "docs", + "general" + ], + "required_components": [ + "article-body", + "table-of-contents", + "reading-progress" + ], + "state_coverage_requirements": { + "links": [ + "default", + "hover", + "visited", + "focus-visible" + ], + "code-blocks": [ + "default", + "hover-copy", + "copied-feedback" + ], + "images": [ + "loading", + "loaded", + "error", + "lightbox" + ], + "reading-progress-bar": [ + "hidden", + "visible", + "complete" + ], + "inline-footnote": [ + "default", + "hover", + "focused", + "active-popover" + ] + }, + "accessibility_constraints": [ + "Maintain line length between 45-75 characters for optimal readability", + "Ensure paragraph spacing is at least 1.5em", + "Implement skip-to-content links as the first focusable element", + "Maintain a logical heading hierarchy without skipping levels" + ], + "anti_patterns": [ + "Sticky overlays that reduce the effective reading area", + "Auto-playing media within the primary article flow", + "Infinite scroll without accessible pagination fallbacks" + ] + }, + "conversion-focused": { + "primary_goal": "Guides users toward a primary conversion event via clear visual hierarchy and persuasive signals.", + "suitable_site_types": [ + "landing-page", + "ecommerce", + "saas" + ], + "required_components": [ + "hero-cta", + "pricing-card", + "social-proof", + "trust-badges" + ], + "state_coverage_requirements": { + "cta-button": [ + "default", + "hover", + "active", + "loading", + "success", + "disabled" + ], + "pricing-toggle": [ + "monthly-active", + "annual-active", + "focused", + "loading" + ], + "form-fields": [ + "empty", + "focused", + "filled", + "error", + "success" + ], + "testimonial-carousel": [ + "auto-playing", + "paused", + "drag-active", + "focused" + ] + }, + "accessibility_constraints": [ + "Primary CTA must meet a WCAG AA contrast ratio of at least 4.5:1", + "Associate form error messages with inputs using aria-describedby", + "Announce dynamic price changes via ARIA live regions", + "Provide manual controls for testimonial carousels" + ], + "anti_patterns": [ + "Competing CTAs with identical visual weight above the fold", + "Dark patterns in pricing or subscription disclosure", + "Form submissions lacking immediate 'loading' state feedback" + ] + }, + "data-dense-feedback": { + "primary_goal": "Supports efficient data parsing and manipulation through immediate, granular interaction feedback.", + "suitable_site_types": [ + "dashboard", + "saas" + ], + "required_components": [ + "data-table", + "kpi-card", + "filter-bar", + "chart", + "alert-strip" + ], + "state_coverage_requirements": { + "table-row": [ + "default", + "hover", + "selected", + "loading", + "error", + "empty" + ], + "kpi-card": [ + "loading", + "loaded", + "error", + "trend-up", + "trend-down" + ], + "filter": [ + "default", + "active", + "applied-count", + "clearing" + ], + "export-menu": [ + "idle", + "open", + "generating", + "ready", + "error" + ], + "bulk-action-bar": [ + "hidden", + "entering", + "visible", + "processing", + "success" + ] + }, + "accessibility_constraints": [ + "Tables must use semantic headers with appropriate scope attributes", + "Provide accessible data table fallbacks for complex charts", + "Announce critical status updates via aria-live regions", + "Ensure full keyboard operability for all filtering and sorting controls" + ], + "anti_patterns": [ + "Auto-refreshing data without user consent or notification", + "Hiding essential data behind tooltips without persistent alternatives", + "Relying solely on color to communicate system status" + ] + }, + "showcase-narrative": { + "primary_goal": "Delivers an immersive visual journey that highlights creative work and brand identity.", + "suitable_site_types": [ + "portfolio", + "landing-page" + ], + "required_components": [ + "project-card", + "lightbox", + "case-study-hero", + "image-gallery" + ], + "state_coverage_requirements": { + "project-card": [ + "default", + "hover-preview", + "expanded", + "loading" + ], + "gallery": [ + "browsing", + "focused-item", + "lightbox-open", + "lightbox-close" + ], + "case-study": [ + "summary", + "expanded", + "scrolling" + ], + "parallax-section": [ + "loading", + "entering", + "active", + "exiting" + ], + "video-modal": [ + "loading", + "playing", + "paused", + "buffering", + "error" + ] + }, + "accessibility_constraints": [ + "Provide descriptive alt text for all meaningful visual assets", + "Manage focus within lightboxes and enable Escape key dismissal", + "Include captions or full transcripts for all video content", + "Ensure all motion respects the 'prefers-reduced-motion' media query" + ], + "anti_patterns": [ + "Auto-advancing galleries lacking pause/play controls", + "Scroll hijacking that disrupts standard page navigation", + "Heavy assets without low-resolution loading placeholders" + ] + }, + "docs-navigation": { + "primary_goal": "Facilitates rapid information retrieval through structured, predictable navigation and search.", + "suitable_site_types": [ + "docs" + ], + "required_components": [ + "sidebar-nav", + "search", + "breadcrumb", + "code-block", + "version-switcher" + ], + "state_coverage_requirements": { + "sidebar-item": [ + "default", + "hover", + "active", + "expanded", + "collapsed" + ], + "search": [ + "empty", + "typing", + "results", + "no-results", + "loading" + ], + "code-block": [ + "default", + "hover", + "copied", + "wrapping-toggled", + "error" + ], + "table-of-contents": [ + "idle", + "tracking-scroll", + "stuck", + "expanded" + ] + }, + "accessibility_constraints": [ + "Apply aria-current to the active page link in the sidebar", + "Announce dynamic search results via aria-live regions", + "Ensure code snippets have keyboard-accessible copy buttons", + "Standardize the Cmd/Ctrl+K keyboard shortcut for search access" + ], + "anti_patterns": [ + "Collapsible sidebars that lack a visible toggle on mobile", + "Search functionality that requires precise string matching", + "Version switchers that reset the user's scroll position" + ] + }, + "assistant-guided": { + "primary_goal": "Reduces cognitive load via progressive disclosure and contextual guidance throughout complex tasks.", + "suitable_site_types": [ + "saas", + "general", + "ecommerce" + ], + "required_components": [ + "onboarding-flow", + "tooltip", + "command-palette", + "empty-state" + ], + "state_coverage_requirements": { + "onboarding": [ + "welcome", + "step-active", + "step-complete", + "skipped", + "finished" + ], + "tooltip": [ + "hidden", + "appearing", + "visible", + "dismissing" + ], + "empty-state": [ + "no-data", + "first-use", + "error", + "action-prompt" + ], + "chat-message": [ + "typing-indicator", + "streaming", + "complete", + "error-retry" + ], + "prompt-suggestion": [ + "default", + "hover", + "focused", + "loading", + "applied" + ] + }, + "accessibility_constraints": [ + "Ensure tooltips are discoverable and triggerable via keyboard focus", + "Allow onboarding flows to be skipped, paused, and resumed", + "Implement full screen-reader support for command palettes", + "Ensure empty states provide clear, actionable next steps" + ], + "anti_patterns": [ + "Mandatory onboarding sequences without an exit option", + "Persistent tooltips that obscure underlying interactive elements", + "Command palettes that lack fuzzy-search logic" + ] + }, + "form-wizard": { + "primary_goal": "Reduces cognitive load during complex data collection by dividing inputs into sequential, validated steps.", + "suitable_site_types": [ + "saas", + "ecommerce", + "general" + ], + "required_components": [ + "step-indicator", + "form-field", + "progress-bar", + "navigation-buttons" + ], + "state_coverage_requirements": { + "step-indicator": [ + "upcoming", + "active", + "completed", + "error", + "disabled" + ], + "form-field": [ + "default", + "focused", + "filled", + "valid", + "invalid", + "disabled" + ], + "navigation-buttons": [ + "default", + "disabled", + "loading", + "success" + ] + }, + "accessibility_constraints": [ + "Ensure step indicators communicate current step and total steps to screen readers using aria-current and visually hidden text.", + "Connect inline error messages programmatically to their respective input fields using aria-describedby.", + "Manage focus appropriately when transitioning between wizard steps, typically moving to the first heading or input of the new step.", + "Provide clear, persistent error summaries at the top of the form when a step submission fails validation." + ], + "anti_patterns": [ + "Hiding the 'Back' button or aggressively breaking browser back navigation functionality.", + "Clearing user-entered data unconditionally when they navigate to previous steps to review their answers.", + "Failing to visually and programmatically indicate which fields are optional versus required upfront." + ] + }, + "search-explore": { + "primary_goal": "Facilitates rapid content discovery and evaluation through immediate feedback on search queries, filtering, and sorting mechanisms.", + "suitable_site_types": [ + "ecommerce", + "blog", + "docs", + "saas" + ], + "required_components": [ + "search-input", + "facet-group", + "result-card", + "empty-state" + ], + "state_coverage_requirements": { + "search-input": [ + "idle", + "focused", + "typing", + "loading-suggestions", + "populated" + ], + "facet-group": [ + "collapsed", + "expanded", + "partially-selected", + "loading-counts", + "disabled" + ], + "result-card": [ + "loading-skeleton", + "loaded", + "focused", + "hover", + "saved" + ] + }, + "accessibility_constraints": [ + "Announce dynamic updates to search results counts and filtering status using an aria-live region.", + "Ensure all interactive facet checkboxes, sliders, and toggle buttons are fully keyboard operable.", + "Maintain logical focus flow either by keeping it on the search input or moving it to the first result appropriately after a query executes.", + "Provide sufficient color contrast for highlighting search query keyword matches within the result text snippets." + ], + "anti_patterns": [ + "Returning a dead-end 'no results' page without suggesting alternative queries, spelling corrections, or relaxing applied filters.", + "Triggering expensive network requests on every single keystroke without debouncing, causing UI stuttering and layout shifts.", + "Resetting all user-selected category filters unexpectedly when a new keyword search is initiated in the main input." + ] + }, + "notification-center": { + "primary_goal": "Aggregates asynchronous system events and user activity updates into a centralized, easily dismissible feed.", + "suitable_site_types": [ + "saas", + "dashboard", + "ecommerce" + ], + "required_components": [ + "notification-trigger", + "toast-message", + "notification-list", + "badge-counter" + ], + "state_coverage_requirements": { + "notification-trigger": [ + "idle", + "has-unread", + "active-popover", + "disabled" + ], + "toast-message": [ + "entering", + "visible", + "action-hover", + "exiting", + "paused" + ], + "notification-item": [ + "unread", + "read", + "hover", + "selected", + "archived", + "error" + ] + }, + "accessibility_constraints": [ + "Implement toast notifications using role='status' or role='alert' appropriately depending on the urgency of the message.", + "Ensure the main notification trigger button exposes its expanded or collapsed state via the aria-expanded attribute.", + "Provide a defined keyboard shortcut or a clear focus management path to reach the notification center popover quickly.", + "Allow users to pause, dismiss, or extend the duration of auto-hiding toasts before they disappear from the screen." + ], + "anti_patterns": [ + "Stacking too many transient toasts on screen simultaneously, obscuring primary application content and navigation.", + "Failing to provide a persistent history log or inbox for transient notifications that users might have missed while away.", + "Using generic titles like 'System Update' without specifying the context, impact, or required user action." + ] + } + } +} diff --git a/references/taxonomy/site-type-routing.json b/references/taxonomy/site-type-routing.json new file mode 100644 index 0000000..8ac57c2 --- /dev/null +++ b/references/taxonomy/site-type-routing.json @@ -0,0 +1,433 @@ +{ + "schemaVersion": "2.0.0", + "site_types": { + "blog": { + "preferred_layout_archetypes": [ + "article-first", + "timeline-flow", + "balanced-sections" + ], + "preferred_motion_profiles": [ + "subtle", + "minimal", + "smooth", + "ambient" + ], + "preferred_interaction_patterns": [ + "content-reading", + "assistant-guided", + "search-explore" + ], + "favored_style_tags": [ + "editorial", + "minimal", + "readable", + "typography" + ], + "penalized_style_tags": [ + "high-contrast", + "glitch", + "neon" + ], + "default_modules": [ + "hero", + "article-list", + "featured-post", + "author-card", + "newsletter" + ], + "optional_modules": [ + "related-posts", + "comments", + "reading-progress" + ], + "recommended_animation_profiles": [ + "subtle-fade", + "subtle-slide", + "subtle-scale", + "ambient-drift" + ], + "recommended_interaction_patterns": [ + "content-reading", + "search-explore" + ] + }, + "saas": { + "preferred_layout_archetypes": [ + "feature-grid", + "balanced-sections", + "form-centric" + ], + "preferred_motion_profiles": [ + "subtle", + "smooth", + "functional" + ], + "preferred_interaction_patterns": [ + "assistant-guided", + "conversion-focused", + "data-dense-feedback", + "form-wizard", + "search-explore", + "notification-center" + ], + "favored_style_tags": [ + "modern", + "minimal", + "professional", + "trust", + "dashboard" + ], + "penalized_style_tags": [ + "chaotic", + "glitch" + ], + "default_modules": [ + "hero", + "feature-grid", + "pricing", + "social-proof", + "faq", + "cta-band" + ], + "optional_modules": [ + "integration-logos", + "case-study", + "onboarding-flow" + ], + "recommended_animation_profiles": [ + "subtle-fade", + "smooth-morph", + "smooth-spring", + "energetic-pulse", + "functional-spinner", + "functional-progress" + ], + "recommended_interaction_patterns": [ + "conversion-focused", + "assistant-guided", + "data-dense-feedback", + "form-wizard", + "search-explore", + "notification-center" + ] + }, + "dashboard": { + "preferred_layout_archetypes": [ + "kpi-console", + "doc-sidebar", + "balanced-sections" + ], + "preferred_motion_profiles": [ + "minimal", + "subtle", + "functional" + ], + "preferred_interaction_patterns": [ + "data-dense-feedback", + "assistant-guided", + "notification-center" + ], + "favored_style_tags": [ + "dashboard", + "data", + "readable", + "enterprise", + "clean" + ], + "penalized_style_tags": [ + "playful", + "ornamental", + "glitch" + ], + "default_modules": [ + "sidebar-nav", + "kpi-cards", + "chart-grid", + "table", + "filters", + "alert-strip" + ], + "optional_modules": [ + "activity-feed", + "empty-state", + "drill-down-panel" + ], + "recommended_animation_profiles": [ + "minimal-static", + "minimal-functional", + "minimal-skeleton", + "functional-spinner", + "functional-progress" + ], + "recommended_interaction_patterns": [ + "data-dense-feedback", + "notification-center" + ] + }, + "docs": { + "preferred_layout_archetypes": [ + "doc-sidebar", + "article-first", + "balanced-sections" + ], + "preferred_motion_profiles": [ + "minimal", + "subtle", + "functional" + ], + "preferred_interaction_patterns": [ + "docs-navigation", + "content-reading", + "search-explore" + ], + "favored_style_tags": [ + "minimal", + "readable", + "documentation", + "neutral" + ], + "penalized_style_tags": [ + "high-contrast", + "excessive-motion", + "visual-noise" + ], + "default_modules": [ + "sidebar-toc", + "search", + "article-body", + "code-block", + "pagination" + ], + "optional_modules": [ + "version-switcher", + "feedback-widget", + "copy-link" + ], + "recommended_animation_profiles": [ + "minimal-static", + "minimal-functional", + "functional-progress" + ], + "recommended_interaction_patterns": [ + "docs-navigation", + "content-reading", + "search-explore" + ] + }, + "ecommerce": { + "preferred_layout_archetypes": [ + "catalog-conversion", + "feature-grid", + "form-centric" + ], + "preferred_motion_profiles": [ + "smooth", + "subtle", + "playful", + "functional" + ], + "preferred_interaction_patterns": [ + "conversion-focused", + "assistant-guided", + "form-wizard", + "search-explore", + "notification-center" + ], + "favored_style_tags": [ + "product", + "conversion", + "clean", + "premium" + ], + "penalized_style_tags": [ + "dense-information", + "low-contrast" + ], + "default_modules": [ + "product-gallery", + "price-card", + "variant-picker", + "reviews", + "upsell", + "sticky-cta" + ], + "optional_modules": [ + "shipping-info", + "trust-badges", + "faq" + ], + "recommended_animation_profiles": [ + "smooth-morph", + "energetic-stagger", + "energetic-pulse", + "energetic-flip", + "playful-stretch", + "subtle-scale", + "functional-spinner" + ], + "recommended_interaction_patterns": [ + "conversion-focused", + "assistant-guided", + "form-wizard", + "search-explore", + "notification-center" + ] + }, + "landing-page": { + "preferred_layout_archetypes": [ + "split-hero", + "feature-grid", + "balanced-sections" + ], + "preferred_motion_profiles": [ + "smooth", + "cinematic", + "subtle", + "ambient", + "playful" + ], + "preferred_interaction_patterns": [ + "conversion-focused", + "showcase-narrative" + ], + "favored_style_tags": [ + "brand", + "hero", + "modern", + "premium" + ], + "penalized_style_tags": [ + "dense-information", + "unstyled" + ], + "default_modules": [ + "hero", + "benefits", + "social-proof", + "feature-grid", + "cta-band", + "faq" + ], + "optional_modules": [ + "comparison-table", + "founder-note", + "video-block" + ], + "recommended_animation_profiles": [ + "smooth-reveal", + "smooth-spring", + "energetic-stagger", + "cinematic-parallax", + "ambient-drift", + "ambient-glow", + "playful-wobble", + "playful-stretch" + ], + "recommended_interaction_patterns": [ + "conversion-focused", + "showcase-narrative" + ] + }, + "portfolio": { + "preferred_layout_archetypes": [ + "showcase-masonry", + "timeline-flow", + "split-hero" + ], + "preferred_motion_profiles": [ + "cinematic", + "energetic", + "smooth", + "ambient", + "playful" + ], + "preferred_interaction_patterns": [ + "showcase-narrative", + "assistant-guided" + ], + "favored_style_tags": [ + "expressive", + "editorial", + "visual", + "creative" + ], + "penalized_style_tags": [ + "generic", + "corporate-only" + ], + "default_modules": [ + "hero", + "project-grid", + "case-study", + "about", + "contact" + ], + "optional_modules": [ + "awards", + "testimonials", + "behind-the-scenes" + ], + "recommended_animation_profiles": [ + "cinematic-parallax", + "cinematic-sequence", + "cinematic-morph", + "energetic-stagger", + "energetic-flip", + "smooth-spring", + "ambient-drift", + "playful-wobble" + ], + "recommended_interaction_patterns": [ + "showcase-narrative" + ] + }, + "general": { + "preferred_layout_archetypes": [ + "balanced-sections", + "feature-grid", + "article-first" + ], + "preferred_motion_profiles": [ + "subtle", + "smooth", + "playful" + ], + "preferred_interaction_patterns": [ + "assistant-guided", + "content-reading", + "form-wizard" + ], + "favored_style_tags": [ + "modern", + "balanced", + "clean" + ], + "penalized_style_tags": [ + "chaotic" + ], + "default_modules": [ + "hero", + "section-grid", + "cta-band" + ], + "optional_modules": [ + "faq", + "testimonials", + "contact" + ], + "recommended_animation_profiles": [ + "subtle-fade", + "subtle-slide", + "subtle-scale", + "smooth-morph", + "playful-stretch" + ], + "recommended_interaction_patterns": [ + "assistant-guided", + "content-reading", + "form-wizard" + ] + } + } +} diff --git a/references/taxonomy/style-tag-map.v2.json b/references/taxonomy/style-tag-map.v2.json new file mode 100644 index 0000000..486ad5f --- /dev/null +++ b/references/taxonomy/style-tag-map.v2.json @@ -0,0 +1,2101 @@ +{ + "schemaVersion": "2.0.0", + "description": "Style-specific v2 tag mapping overrides. When a style is not listed, scripts fall back to heuristic mapping from styleType/category/tags.", + "style_mappings": { + "neo-brutalist": { + "visual_style": "expressive", + "motion_profile_hints": [ + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "editorial": { + "visual_style": "editorial", + "layout_archetype_hints": [ + "article-first", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "readability-first", + "editorial-tone" + ] + }, + "neumorphism": { + "visual_style": "modern-tech", + "motion_profile_hints": [ + "smooth" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "minimal-chrome" + ] + }, + "glassmorphism": { + "visual_style": "modern-tech", + "motion_profile_hints": [ + "smooth", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative", + "conversion-focused" + ], + "modifiers": [ + "premium-tone", + "brand-heavy" + ] + }, + "bento-grid": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "feature-grid", + "showcase-masonry" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "dense-information" + ] + }, + "dashboard-layout": { + "visual_style": "corporate", + "layout_archetype_hints": [ + "kpi-console", + "doc-sidebar" + ], + "motion_profile_hints": [ + "minimal", + "subtle" + ], + "interaction_pattern_hints": [ + "data-dense-feedback" + ], + "modifiers": [ + "readability-first", + "dense-information", + "trust-first" + ] + }, + "apple-style": { + "visual_style": "minimal", + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "minimal-chrome", + "premium-tone" + ] + }, + "material-design": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "assistant-guided", + "conversion-focused" + ], + "modifiers": [ + "trust-first" + ] + }, + "vaporwave": { + "visual_style": "retro", + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy", + "playful-tone" + ] + }, + "corporate-clean": { + "visual_style": "corporate", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "minimal", + "subtle" + ], + "interaction_pattern_hints": [ + "conversion-focused", + "assistant-guided" + ], + "modifiers": [ + "trust-first", + "readability-first", + "minimal-chrome" + ] + }, + "minimalist-flat": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "balanced-sections" + ], + "motion_profile_hints": [ + "minimal" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "minimal-chrome", + "readability-first" + ] + }, + "natural-organic": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "editorial-tone" + ] + }, + "notion-style": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "doc-sidebar", + "article-first" + ], + "motion_profile_hints": [ + "minimal" + ], + "interaction_pattern_hints": [ + "docs-navigation", + "content-reading" + ], + "modifiers": [ + "readability-first", + "minimal-chrome", + "dense-information" + ] + }, + "japanese-fresh": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading", + "showcase-narrative" + ], + "modifiers": [ + "minimal-chrome", + "premium-tone" + ] + }, + "scandinavian": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "minimal-chrome", + "readability-first" + ] + }, + "wabi-sabi": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle", + "minimal" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "editorial-tone", + "minimal-chrome" + ] + }, + "zen-garden": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "minimal-chrome", + "premium-tone" + ] + }, + "kawaii-minimal": { + "visual_style": "playful", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "playful-tone", + "minimal-chrome" + ] + }, + "terracotta": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading", + "showcase-narrative" + ], + "modifiers": [ + "editorial-tone", + "premium-tone" + ] + }, + "monochrome": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "balanced-sections", + "article-first" + ], + "motion_profile_hints": [ + "minimal" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "high-contrast", + "minimal-chrome" + ] + }, + "ink-wash": { + "visual_style": "editorial", + "layout_archetype_hints": [ + "article-first", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "content-reading", + "showcase-narrative" + ], + "modifiers": [ + "editorial-tone", + "premium-tone" + ] + }, + "korean-minimal": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading", + "assistant-guided" + ], + "modifiers": [ + "minimal-chrome", + "readability-first" + ] + }, + "blueprint": { + "visual_style": "corporate", + "layout_archetype_hints": [ + "kpi-console", + "doc-sidebar" + ], + "motion_profile_hints": [ + "minimal" + ], + "interaction_pattern_hints": [ + "data-dense-feedback", + "docs-navigation" + ], + "modifiers": [ + "dense-information", + "readability-first" + ] + }, + "timeline-vertical": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "timeline-flow" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "readability-first" + ] + }, + "soft-ui": { + "visual_style": "modern-tech", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "minimal-chrome", + "premium-tone" + ] + }, + "dark-mode": { + "visual_style": "modern-tech", + "layout_archetype_hints": [ + "balanced-sections", + "kpi-console" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "assistant-guided", + "data-dense-feedback" + ], + "modifiers": [ + "readability-first" + ] + }, + "claymorphism": { + "visual_style": "playful", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "smooth", + "energetic" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "playful-tone" + ] + }, + "stripe-style": { + "visual_style": "modern-tech", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "conversion-focused", + "assistant-guided" + ], + "modifiers": [ + "trust-first", + "premium-tone", + "minimal-chrome" + ] + }, + "bauhaus": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "high-contrast" + ] + }, + "swiss-style": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "balanced-sections", + "article-first" + ], + "motion_profile_hints": [ + "minimal", + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "readability-first", + "editorial-tone" + ] + }, + "fluent-design": { + "visual_style": "modern-tech", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "smooth" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "minimal-chrome" + ] + }, + "visual-novel": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "cinematic", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "hero-driven" + ] + }, + "warm-dashboard": { + "visual_style": "corporate", + "layout_archetype_hints": [ + "kpi-console", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle", + "minimal" + ], + "interaction_pattern_hints": [ + "data-dense-feedback" + ], + "modifiers": [ + "readability-first", + "dense-information" + ] + }, + "liquid-glass": { + "visual_style": "modern-tech", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "smooth", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative", + "conversion-focused" + ], + "modifiers": [ + "premium-tone" + ] + }, + "sci-fi-hud": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "kpi-console", + "feature-grid" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "data-dense-feedback" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "particle": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "cinematic", + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "hero-driven", + "brand-heavy" + ] + }, + "marble-luxury": { + "visual_style": "corporate", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "conversion-focused" + ], + "modifiers": [ + "premium-tone", + "brand-heavy" + ] + }, + "tropical-paradise": { + "visual_style": "playful", + "layout_archetype_hints": [ + "split-hero", + "feature-grid" + ], + "motion_profile_hints": [ + "smooth", + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "hero-driven" + ] + }, + "github-style": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "doc-sidebar", + "article-first" + ], + "motion_profile_hints": [ + "minimal" + ], + "interaction_pattern_hints": [ + "docs-navigation", + "content-reading" + ], + "modifiers": [ + "readability-first", + "dense-information", + "minimal-chrome" + ] + }, + "neon-tokyo": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "showcase-masonry" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "retro-vintage": { + "visual_style": "retro", + "layout_archetype_hints": [ + "balanced-sections", + "article-first" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "editorial-tone", + "brand-heavy" + ] + }, + "pixel-art": { + "visual_style": "retro", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "minimal", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "brand-heavy" + ] + }, + "y2k": { + "visual_style": "retro", + "layout_archetype_hints": [ + "feature-grid", + "showcase-masonry" + ], + "motion_profile_hints": [ + "energetic", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "brand-heavy", + "high-contrast" + ] + }, + "memphis": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "showcase-masonry" + ], + "motion_profile_hints": [ + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "high-contrast", + "brand-heavy" + ] + }, + "art-deco": { + "visual_style": "retro", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative", + "conversion-focused" + ], + "modifiers": [ + "premium-tone", + "brand-heavy" + ] + }, + "skeuomorphism": { + "visual_style": "retro", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "smooth" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "brand-heavy" + ] + }, + "synthwave": { + "visual_style": "retro", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "ukiyo-e-digital": { + "visual_style": "retro", + "layout_archetype_hints": [ + "balanced-sections", + "showcase-masonry" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "editorial-tone", + "brand-heavy", + "premium-tone" + ] + }, + "gothic": { + "visual_style": "retro", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "high-contrast" + ] + }, + "outrun": { + "visual_style": "retro", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "dark-academia": { + "visual_style": "retro", + "layout_archetype_hints": [ + "article-first", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "editorial-tone", + "premium-tone" + ] + }, + "cottagecore": { + "visual_style": "retro", + "layout_archetype_hints": [ + "balanced-sections", + "article-first" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "editorial-tone", + "playful-tone" + ] + }, + "pixel-anime": { + "visual_style": "retro", + "layout_archetype_hints": [ + "feature-grid", + "showcase-masonry" + ], + "motion_profile_hints": [ + "energetic", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "brand-heavy" + ] + }, + "film-noir": { + "visual_style": "retro", + "layout_archetype_hints": [ + "split-hero", + "article-first" + ], + "motion_profile_hints": [ + "smooth", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "premium-tone" + ] + }, + "arcade-crt": { + "visual_style": "retro", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "high-contrast" + ] + }, + "frutiger-aero": { + "visual_style": "retro", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "playful-tone" + ] + }, + "brutalist-web": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections" + ], + "motion_profile_hints": [ + "minimal" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "vhs-aesthetic": { + "visual_style": "retro", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "energetic", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy" + ] + }, + "mid-century-modern": { + "visual_style": "retro", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading", + "showcase-narrative" + ], + "modifiers": [ + "editorial-tone", + "premium-tone" + ] + }, + "constructivism": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "minimal", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "medieval-manuscript": { + "visual_style": "retro", + "layout_archetype_hints": [ + "article-first", + "balanced-sections" + ], + "motion_profile_hints": [ + "minimal" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "editorial-tone", + "brand-heavy" + ] + }, + "victorian-botanical": { + "visual_style": "retro", + "layout_archetype_hints": [ + "article-first", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "editorial-tone", + "premium-tone" + ] + }, + "modern-gradient": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "smooth", + "cinematic" + ], + "interaction_pattern_hints": [ + "conversion-focused", + "showcase-narrative" + ], + "modifiers": [ + "hero-driven", + "brand-heavy" + ] + }, + "geometric-bold": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "ghibli-style": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "smooth", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "editorial-tone" + ] + }, + "comic-style": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "showcase-masonry" + ], + "motion_profile_hints": [ + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "brand-heavy" + ] + }, + "sketch-style": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "article-first" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading", + "showcase-narrative" + ], + "modifiers": [ + "editorial-tone", + "playful-tone" + ] + }, + "watercolor-style": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "editorial-tone", + "premium-tone" + ] + }, + "cyberpunk-neon": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "kpi-console" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative", + "data-dense-feedback" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "neo-brutalist-soft": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "brand-heavy" + ] + }, + "neo-brutalist-playful": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "showcase-masonry" + ], + "motion_profile_hints": [ + "energetic", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "high-contrast", + "brand-heavy" + ] + }, + "surrealism": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "showcase-masonry" + ], + "motion_profile_hints": [ + "cinematic", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "hero-driven" + ] + }, + "risograph": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "editorial-tone" + ] + }, + "mecha": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "feature-grid" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "gothic-lolita": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "showcase-masonry" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "premium-tone" + ] + }, + "cyber-chinese": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "acid-graphics": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "showcase-masonry", + "feature-grid" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "hand-drawn-doodle": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "article-first" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "playful-tone", + "editorial-tone" + ] + }, + "watercolor-art": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "showcase-masonry" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "editorial-tone", + "premium-tone" + ] + }, + "impressionist-oil": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "editorial-tone", + "premium-tone" + ] + }, + "collage-art": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "showcase-masonry", + "feature-grid" + ], + "motion_profile_hints": [ + "subtle", + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "playful-tone" + ] + }, + "glitch-art": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "shoujo-manga": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "smooth", + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "brand-heavy" + ] + }, + "cyber-anime": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "feature-grid" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "neon-samurai": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "magic-circle": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "cinematic", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "premium-tone" + ] + }, + "cyber-wafuu": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy", + "premium-tone" + ] + }, + "steampunk": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "premium-tone" + ] + }, + "pop-art": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "showcase-masonry" + ], + "motion_profile_hints": [ + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "playful-tone", + "brand-heavy" + ] + }, + "solarpunk": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading", + "showcase-narrative" + ], + "modifiers": [ + "editorial-tone" + ] + }, + "jrpg": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "feature-grid" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "playful-tone" + ] + }, + "neon-gradient": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero", + "balanced-sections" + ], + "motion_profile_hints": [ + "smooth", + "cinematic" + ], + "interaction_pattern_hints": [ + "conversion-focused", + "showcase-narrative" + ], + "modifiers": [ + "hero-driven", + "brand-heavy" + ] + }, + "cel-shading": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "smooth", + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "playful-tone" + ] + }, + "anti-design": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections" + ], + "motion_profile_hints": [ + "minimal" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "holographic": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "smooth", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "premium-tone", + "brand-heavy" + ] + }, + "generative-art": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "showcase-masonry", + "balanced-sections" + ], + "motion_profile_hints": [ + "cinematic", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy" + ] + }, + "op-art": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "islamic-geometric": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "premium-tone" + ] + }, + "indian-festive": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "energetic", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "playful-tone" + ] + }, + "african-textile": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "editorial-tone" + ] + }, + "pastel-goth": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "showcase-masonry" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy" + ] + }, + "maximalism": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "showcase-masonry", + "feature-grid" + ], + "motion_profile_hints": [ + "energetic", + "cinematic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "high-contrast" + ] + }, + "graffiti-street": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "showcase-masonry", + "split-hero" + ], + "motion_profile_hints": [ + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "high-contrast", + "brand-heavy" + ] + }, + "cubism": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "feature-grid", + "balanced-sections" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "high-contrast" + ] + }, + "witchcore": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "premium-tone" + ] + }, + "paper-craft": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "playful-tone", + "editorial-tone" + ] + }, + "masonry-flow": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "showcase-masonry" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ] + }, + "split-screen": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "split-hero" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative", + "conversion-focused" + ], + "modifiers": [ + "hero-driven" + ] + }, + "sidebar-fixed": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "doc-sidebar" + ], + "motion_profile_hints": [ + "minimal" + ], + "interaction_pattern_hints": [ + "docs-navigation", + "data-dense-feedback" + ], + "modifiers": [ + "dense-information" + ] + }, + "magazine-grid": { + "visual_style": "editorial", + "layout_archetype_hints": [ + "showcase-masonry", + "article-first" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "editorial-tone" + ] + }, + "f-pattern-layout": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading", + "conversion-focused" + ], + "modifiers": [ + "readability-first" + ] + }, + "z-pattern-layout": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "subtle", + "smooth" + ], + "interaction_pattern_hints": [ + "conversion-focused" + ], + "modifiers": [ + "hero-driven" + ] + }, + "holy-grail-layout": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "balanced-sections", + "doc-sidebar" + ], + "motion_profile_hints": [ + "minimal", + "subtle" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ], + "modifiers": [ + "readability-first" + ] + }, + "asymmetric-grid": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "showcase-masonry", + "feature-grid" + ], + "motion_profile_hints": [ + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy" + ] + }, + "parallax-sections": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "cinematic", + "smooth" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "hero-driven" + ] + }, + "full-page-scroll": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections" + ], + "motion_profile_hints": [ + "cinematic", + "energetic" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "hero-driven" + ] + }, + "card-stack": { + "visual_style": "balanced", + "layout_archetype_hints": [ + "feature-grid", + "showcase-masonry" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "assistant-guided" + ] + }, + "hero-fullscreen": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "split-hero" + ], + "motion_profile_hints": [ + "cinematic", + "smooth" + ], + "interaction_pattern_hints": [ + "conversion-focused", + "showcase-narrative" + ], + "modifiers": [ + "hero-driven", + "brand-heavy" + ] + }, + "art-nouveau": { + "visual_style": "expressive", + "layout_archetype_hints": [ + "balanced-sections", + "split-hero" + ], + "motion_profile_hints": [ + "smooth", + "subtle" + ], + "interaction_pattern_hints": [ + "showcase-narrative" + ], + "modifiers": [ + "brand-heavy", + "premium-tone", + "editorial-tone" + ] + }, + "swiss-poster": { + "visual_style": "minimal", + "layout_archetype_hints": [ + "balanced-sections", + "feature-grid" + ], + "motion_profile_hints": [ + "minimal", + "subtle" + ], + "interaction_pattern_hints": [ + "content-reading" + ], + "modifiers": [ + "readability-first", + "high-contrast" + ] + } + } +} diff --git a/references/taxonomy/style-tag-registry.json b/references/taxonomy/style-tag-registry.json new file mode 100644 index 0000000..831c8e0 --- /dev/null +++ b/references/taxonomy/style-tag-registry.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": "2.0.0", + "description": "Controlled style tags allowed in site-type routing favored/penalized lists.", + "allowed_style_tags": [ + "balanced", + "brand", + "chaotic", + "clean", + "conversion", + "corporate-only", + "creative", + "dashboard", + "data", + "dense-information", + "documentation", + "editorial", + "enterprise", + "excessive-motion", + "expressive", + "generic", + "glitch", + "hero", + "high-contrast", + "low-contrast", + "minimal", + "modern", + "neon", + "neutral", + "ornamental", + "playful", + "premium", + "product", + "professional", + "readable", + "trust", + "typography", + "unstyled", + "visual", + "visual-noise" + ] +} diff --git a/references/taxonomy/tag-aliases.json b/references/taxonomy/tag-aliases.json new file mode 100644 index 0000000..3b3a0b0 --- /dev/null +++ b/references/taxonomy/tag-aliases.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": "2.0.0", + "site_type_aliases": { + "blog": ["blog", "article", "editorial", "博客", "文章", "内容站"], + "saas": ["saas", "b2b", "workspace", "product app", "软件服务", "企业应用"], + "dashboard": ["dashboard", "admin", "panel", "console", "后台", "仪表盘", "控制台", "看板"], + "docs": ["docs", "documentation", "guide", "manual", "文档", "帮助中心", "说明"], + "ecommerce": ["ecommerce", "store", "shop", "checkout", "电商", "商城", "购物", "商品"], + "landing-page": ["landing", "hero", "marketing", "homepage", "落地页", "官网首页", "营销页"], + "portfolio": ["portfolio", "case study", "showreel", "作品集", "案例展示", "个人展示"], + "general": ["web", "website", "app", "站点", "网站", "页面"] + }, + "motion_aliases": { + "minimal": ["静态", "少动效", "minimal motion", "almost no motion"], + "subtle": ["subtle", "gentle", "克制", "轻微动效"], + "smooth": ["smooth", "丝滑", "连贯", "流畅"], + "energetic": ["energetic", "bold motion", "强动效", "明显动效"], + "cinematic": ["cinematic", "叙事感", "镜头感"] + }, + "interaction_aliases": { + "content-reading": ["阅读", "reading", "long-form", "内容优先"], + "conversion-focused": ["转化", "conversion", "cta", "购买", "注册"], + "data-dense-feedback": ["数据密度", "kpi", "table", "chart", "反馈明确"], + "showcase-narrative": ["展示", "narrative", "作品展示", "故事化"], + "docs-navigation": ["目录", "search", "toc", "文档导航"], + "assistant-guided": ["引导", "guided", "wizard", "step-by-step"] + } +} diff --git a/references/taxonomy/tag-schema.json b/references/taxonomy/tag-schema.json new file mode 100644 index 0000000..6804814 --- /dev/null +++ b/references/taxonomy/tag-schema.json @@ -0,0 +1,90 @@ +{ + "schemaVersion": "2.0.0", + "description": "Controlled taxonomy for composing StyleKit recommendations across site type, style, layout, motion, and interaction.", + "dimensions": { + "site_type": { + "type": "enum", + "values": [ + "blog", + "saas", + "dashboard", + "docs", + "ecommerce", + "landing-page", + "portfolio", + "general" + ] + }, + "visual_style": { + "type": "enum", + "values": [ + "minimal", + "modern-tech", + "expressive", + "editorial", + "retro", + "corporate", + "playful", + "balanced" + ] + }, + "layout_archetype": { + "type": "enum", + "values": [ + "article-first", + "feature-grid", + "kpi-console", + "split-hero", + "showcase-masonry", + "catalog-conversion", + "doc-sidebar", + "timeline-flow", + "form-centric", + "balanced-sections" + ] + }, + "motion_profile": { + "type": "enum", + "values": [ + "minimal", + "subtle", + "smooth", + "energetic", + "cinematic", + "playful", + "ambient", + "functional" + ] + }, + "interaction_pattern": { + "type": "enum", + "values": [ + "content-reading", + "conversion-focused", + "data-dense-feedback", + "showcase-narrative", + "docs-navigation", + "assistant-guided", + "form-wizard", + "search-explore", + "notification-center" + ] + }, + "modifiers": { + "type": "set", + "values": [ + "high-contrast", + "readability-first", + "conversion-first", + "brand-heavy", + "trust-first", + "dense-information", + "minimal-chrome", + "hero-driven", + "editorial-tone", + "playful-tone", + "premium-tone" + ] + } + } +} diff --git a/scripts/_brief_constants.py b/scripts/_brief_constants.py new file mode 100644 index 0000000..6de7515 --- /dev/null +++ b/scripts/_brief_constants.py @@ -0,0 +1,366 @@ +"""Shared constants and tiny utilities for the generate_brief module family.""" + +from __future__ import annotations + +import re +from typing import Any + +# --------------------------------------------------------------------------- +# Stack hints +# --------------------------------------------------------------------------- + +STACK_HINTS = { + "html-tailwind": { + "en": "Use semantic HTML and Tailwind utility classes. Keep components reusable and avoid inline style except dynamic variables.", + "zh": "使用语义化 HTML 与 Tailwind 工具类,组件可复用,除动态变量外避免内联样式。", + }, + "react": { + "en": "Build reusable React components, stable keys, and accessible interaction states. Keep state minimal and localized.", + "zh": "构建可复用 React 组件,保证稳定 key 与可访问交互状态,状态最小化并局部化。", + }, + "nextjs": { + "en": "Prefer Server Components by default, add Client Components only for interactivity, and keep bundle weight low.", + "zh": "默认优先 Server Components,仅在交互需要时使用 Client Components,并控制包体积。", + }, + "vue": { + "en": "Use composables for shared logic, keep templates readable, and map style constraints into scoped utility patterns.", + "zh": "复用逻辑放入 composables,模板保持可读,把风格约束映射为稳定的样式模式。", + }, + "svelte": { + "en": "Keep component boundaries clear, use transitions intentionally, and avoid over-animating layout-critical areas.", + "zh": "组件边界保持清晰,过渡动画有目的地使用,避免关键布局区域过度动画。", + }, + "tailwind-v4": { + "en": "Use Tailwind v4 CSS-first setup with @theme/@utility/@custom-variant, prefer semantic tokens and OKLCH palette where possible.", + "zh": "使用 Tailwind v4 的 CSS-first 方案(@theme/@utility/@custom-variant),优先语义 token 与 OKLCH 色彩体系。", + }, +} + +REFERENCE_TYPES = ("none", "screenshot", "figma", "mixed") + +REFINE_MODE_HINTS = { + "new": { + "en": { + "objective": "Create a new screen or flow with a complete style-aligned structure.", + "constraints": [ + "Prioritize coherent information architecture before decorative details.", + "Ensure full interaction coverage (hover/active/focus-visible/disabled).", + "Deliver complete responsive behavior for core breakpoints.", + ], + }, + "zh": { + "objective": "从零创建新页面/新流程,输出完整且风格一致的结构。", + "constraints": [ + "先保证信息架构完整,再补充装饰性细节。", + "交互状态需覆盖 hover/active/focus-visible/disabled。", + "核心断点下都要保证完整响应式表现。", + ], + }, + }, + "polish": { + "en": { + "objective": "Polish visual quality while preserving existing structure and functionality.", + "constraints": [ + "Do not rewrite the page architecture unless required by clear defects.", + "Keep content hierarchy and user flow stable.", + "Improve typography, spacing rhythm, and visual consistency first.", + ], + }, + "zh": { + "objective": "在保留现有结构与功能的前提下进行视觉提质。", + "constraints": [ + "除明显缺陷外,不重写页面架构。", + "保持内容层级与用户流程稳定。", + "优先优化排版、间距节奏和视觉一致性。", + ], + }, + }, + "debug": { + "en": { + "objective": "Fix rendering and interaction defects without regressing style identity.", + "constraints": [ + "Focus on overflow, clipping, z-index overlap, and state regressions.", + "Keep style DNA intact while fixing bugs.", + "Provide minimal-change remediation over full rewrites.", + ], + }, + "zh": { + "objective": "修复渲染与交互缺陷,同时保持原有风格识别度。", + "constraints": [ + "重点处理溢出、裁切、z-index 覆盖和状态回归问题。", + "修 bug 时保持风格 DNA 不被破坏。", + "优先最小改动修复,避免整体重写。", + ], + }, + }, + "contrast-fix": { + "en": { + "objective": "Repair contrast and readability issues to meet accessibility baseline.", + "constraints": [ + "Enforce WCAG AA contrast targets for text and key UI states.", + "Preserve brand palette intent while adjusting tonal steps.", + "Avoid introducing visual noise during contrast correction.", + ], + }, + "zh": { + "objective": "修复对比度与可读性问题,使其满足无障碍基线。", + "constraints": [ + "正文与关键交互态满足 WCAG AA 对比度目标。", + "在不破坏品牌色语义的前提下调整明度层级。", + "修正对比度时避免引入额外视觉噪声。", + ], + }, + }, + "layout-fix": { + "en": { + "objective": "Repair layout structure and responsive behavior without changing style direction.", + "constraints": [ + "Fix grid/flex alignment, spacing collisions, and viewport overflow.", + "Preserve component semantics while rebalancing layout rhythm.", + "Validate desktop/tablet/mobile structure after fixes.", + ], + }, + "zh": { + "objective": "修复布局结构与响应式问题,不改变既有风格方向。", + "constraints": [ + "修复 grid/flex 对齐、间距冲突和视口溢出问题。", + "在重整布局节奏时保持组件语义稳定。", + "修复后验证桌面/平板/移动端结构一致性。", + ], + }, + }, + "component-fill": { + "en": { + "objective": "Complete missing components and states to reach production readiness.", + "constraints": [ + "Fill missing core components before adding new visual flourishes.", + "Ensure every new component has interaction and accessibility states.", + "Match token scale and naming with existing design system conventions.", + ], + }, + "zh": { + "objective": "补齐缺失组件与状态,提升到可交付质量。", + "constraints": [ + "先补齐核心组件,再考虑额外视觉特效。", + "新增组件必须包含交互态与可访问状态。", + "严格对齐现有 design token 的尺度与命名约定。", + ], + }, + }, +} + +REFERENCE_GUIDELINES = { + "screenshot": { + "en": [ + "Treat screenshot as visual reference for layout, spacing, and hierarchy.", + "Replicate structure first, then adapt to semantic HTML/component architecture.", + "Infer missing behavior explicitly (hover/focus/loading/error) instead of guessing silently.", + ], + "zh": [ + "将截图作为布局、间距和层级的视觉参考来源。", + "先对齐结构,再映射到语义化 HTML/组件架构。", + "对缺失交互(hover/focus/loading/error)需显式补全,不可隐式猜测。", + ], + }, + "figma": { + "en": [ + "Use Figma frame structure and token cues (color/spacing/type) as implementation baseline.", + "Break complex frames into reusable components before assembling full page.", + "Keep naming and token semantics consistent between design and code.", + ], + "zh": [ + "以 Figma 的 Frame 结构与 token 线索(色彩/间距/字体)作为实现基线。", + "复杂 Frame 先拆成可复用组件,再组装整页。", + "保持设计稿与代码中的命名和 token 语义一致。", + ], + }, +} + +REFERENCE_FIELD_ALIASES = { + "layout_issues": ["layout_issues", "layoutIssues", "layoutProblems", "layout_issues_list", "issues"], + "missing_components": ["missing_components", "missingComponents", "component_gaps", "components_missing"], + "preserve_elements": ["preserve_elements", "preserveElements", "preserve", "keep", "must_keep"], + "interaction_gaps": ["interaction_gaps", "interactionGaps", "state_gaps", "states_missing"], + "a11y_gaps": ["a11y_gaps", "accessibility_gaps", "accessibilityGaps", "wcag_gaps"], + "token_clues": ["token_clues", "tokenClues", "design_tokens", "tokens", "style_tokens"], + "notes": ["notes", "note", "summary", "description", "context"], +} + +REFERENCE_SECTION_KEYS = ("layout", "components", "interaction", "accessibility", "tokens") +REFERENCE_META_KEYS = ("source", "type", "notes", "metadata", "frame", "frames", "screen", "screens", "page") +REFERENCE_KNOWN_TOP_LEVEL = set(REFERENCE_SECTION_KEYS) | set(REFERENCE_META_KEYS) +for _alias_items in REFERENCE_FIELD_ALIASES.values(): + REFERENCE_KNOWN_TOP_LEVEL.update(_alias_items) + +A11Y_BASELINE = { + "en": [ + "Text contrast meets WCAG 2.1 AA (4.5:1 for normal text).", + "Keyboard focus is visible on all interactive controls.", + "Interactive targets are at least 44x44px on touch devices.", + "Respect prefers-reduced-motion for non-essential animation.", + ], + "zh": [ + "文本对比度满足 WCAG 2.1 AA(正文至少 4.5:1)。", + "所有可交互控件具备可见的键盘焦点状态。", + "触屏场景下交互目标至少 44x44px。", + "非必要动画需遵循 prefers-reduced-motion。", + ], +} + +NEGATOR_WORDS = ["avoid", "don't", "do not", "禁止", "不要", "避免", "严禁"] +NEG_SECTION_MARKERS = [ + "绝对禁止", "禁止使用", "禁止", + "must avoid", "must not", "forbidden", "absolutely forbidden", "do not", +] +POS_SECTION_MARKERS = [ + "必须遵守", "必须使用", "必须", + "must follow", "must use", "required", +] +RADIUS_TOKEN_RE = re.compile(r"\brounded(?:-[a-z0-9]+)?\b", re.IGNORECASE) +SHADOW_TOKEN_RE = re.compile(r"\bshadow(?:-[a-z0-9\[\]_/.-]+)?\b", re.IGNORECASE) +BG_WHITE_TOKEN_RE = re.compile(r"\bbg-white(?:/[0-9]{1,3})?\b", re.IGNORECASE) +BG_BLACK_TOKEN_RE = re.compile(r"\bbg-black(?:/[0-9]{1,3})?\b", re.IGNORECASE) + +GENERIC_FONTS = ["inter", "arial", "roboto", "system-ui", "sans-serif"] +CJK_RE = re.compile(r"[\u4e00-\u9fff]") + +DISTINCTIVE_FONT_HINTS = { + "en": [ + "Pair one display font with one readable body font.", + "Avoid overusing generic defaults (Inter/Roboto/Arial/system-ui).", + "Use typography contrast (size, weight, spacing) to lead hierarchy.", + ], + "zh": [ + "标题字体与正文字体形成明显对比并保持统一。", + "避免过度依赖通用默认字体(Inter/Roboto/Arial/system-ui)。", + "通过字号、字重、字距建立清晰层级。", + ], +} + +VALIDATION_TESTS = { + "en": [ + "Swap test: replace key visual choices with common defaults; if identity stays unchanged, redesign.", + "Squint test: hierarchy should remain clear when blurred or zoomed out.", + "Signature test: point to at least 3 concrete UI elements carrying the style signature.", + "Token test: token names and values should reflect product intent, not generic templates.", + ], + "zh": [ + "替换测试(Swap test):把关键视觉选择替换为常见默认值,若辨识度不变则需要重做。", + "眯眼测试(Squint test):弱化细节后,信息层级仍然清晰可辨。", + "签名测试(Signature test):至少指出 3 个承载风格签名的具体界面元素。", + "Token 测试(Token test):设计 token 命名与取值要体现产品语义,避免模板化。", + ], +} + +ANTI_PATTERN_BLACKLIST = { + "en": [ + "Do not build full-page layout with absolute positioning; use flex/grid structure.", + "Do not create nested scroll containers or uncontrolled z-index wars.", + "Do not remove focus outlines without an explicit focus-visible replacement.", + "Do not submit forms without loading/disabled states and recovery-friendly errors.", + "Avoid god components (>300 lines) and deep prop drilling beyond two levels.", + ], + "zh": [ + "禁止用 absolute 定位搭整页布局,优先 flex/grid 结构。", + "禁止嵌套滚动容器与失控的 z-index 叠层竞争。", + "禁止移除焦点样式且不提供 focus-visible 替代方案。", + "禁止表单提交缺少 loading/disabled 状态与可恢复错误提示。", + "避免 God 组件(超过 300 行)和超过两层 prop drilling。", + ], +} + +DEFAULT_AI_RULES = { + "en": [ + "Keep clear hierarchy across heading, body, and metadata layers.", + "Implement explicit hover, active, focus-visible, and disabled states.", + "Maintain WCAG AA contrast and 44x44px touch-target baseline.", + "Use semantic design tokens and consistent spacing/radius scales.", + ], + "zh": [ + "保持标题、正文、元信息的清晰层级。", + "交互态必须覆盖 hover、active、focus-visible 与 disabled。", + "满足 WCAG AA 对比度并保证 44x44px 触控尺寸基线。", + "使用语义化 design token,并保持统一间距与圆角尺度。", + ], +} + +DEFAULT_DO_LIST = { + "en": [ + "Use semantic layout and preserve strong information hierarchy.", + "Provide visible interaction states and keyboard focus.", + "Keep typography and spacing rhythm consistent across breakpoints.", + ], + "zh": [ + "使用语义化布局并保持明确的信息层级。", + "提供可见交互状态与键盘焦点反馈。", + "在各断点保持一致的排版与间距节奏。", + ], +} + +DEFAULT_DONT_LIST = { + "en": [ + "Do not sacrifice readability for decorative effects.", + "Do not remove focus styles without a visible replacement.", + "Do not break responsive structure with rigid fixed-width layout.", + ], + "zh": [ + "禁止为了装饰效果牺牲可读性。", + "禁止移除焦点样式且不提供可见替代。", + "禁止用僵硬固定宽度破坏响应式结构。", + ], +} + + +# --------------------------------------------------------------------------- +# Shared tiny utilities (used by multiple sub-modules) +# --------------------------------------------------------------------------- + +def detect_lang(text: str) -> str: + return "zh" if re.search(r"[\u4e00-\u9fff]", text) else "en" + + +def has_cjk(text: str) -> bool: + return bool(CJK_RE.search(text or "")) + + +def dedupe_ordered(items: list[str]) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for item in items: + key = item.strip().lower() + if not key or key in seen: + continue + seen.add(key) + out.append(item.strip()) + return out + + +def language_filter_rules(rules: list[str], lang: str) -> list[str]: + cleaned = [str(rule).strip() for rule in rules if str(rule).strip()] + if lang == "en": + cleaned = [rule for rule in cleaned if not has_cjk(rule)] + return dedupe_ordered(cleaned) + + +def to_text_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + text = value.strip() + return [text] if text else [] + if isinstance(value, (int, float, bool)): + return [str(value)] + if isinstance(value, list): + out: list[str] = [] + for item in value: + out.extend(to_text_list(item)) + return out + if isinstance(value, dict): + out: list[str] = [] + for key, val in value.items(): + vals = to_text_list(val) + if vals: + for item in vals: + out.append(f"{key}: {item}") + return out + return [] diff --git a/scripts/_common.py b/scripts/_common.py new file mode 100644 index 0000000..b8be4c4 --- /dev/null +++ b/scripts/_common.py @@ -0,0 +1,82 @@ +"""Shared utilities for stylekit-style-prompts scripts.""" + +from __future__ import annotations + +import datetime as dt +import json +import re +from pathlib import Path +from typing import Any + +__version__ = "0.1.1" + +# --------------------------------------------------------------------------- +# Stopwords +# --------------------------------------------------------------------------- + +STOPWORDS: set[str] = { + "the", "and", "for", "with", "from", "that", "this", "into", + "your", "you", "want", "need", "make", "build", + "page", "site", "style", "design", "frontend", "ui", "ux", + "页面", "风格", "设计", "前端", "需要", "希望", "一个", "这个", +} + +RULE_STOPWORDS: set[str] = { + "use", "using", "must", "should", "ensure", "keep", "add", "set", + "avoid", "do", "not", + "the", "and", "for", "with", "from", "that", "this", "your", "you", + "to", "in", "on", "of", "at", "by", "as", "be", "is", "are", + "使用", "添加", "加入", "保持", "确保", "避免", "禁止", "不要", + "需要", "并", "和", "与", "在", "到", "及", "或", +} + + +# --------------------------------------------------------------------------- +# Text normalization & tokenization +# --------------------------------------------------------------------------- + +def normalize_text(value: Any) -> str: + """Normalize text: lowercase, strip punctuation (keep CJK/alphanumeric/hyphens).""" + text = str(value or "").lower() + text = re.sub(r"[^\w\u4e00-\u9fff\s-]", " ", text) + text = re.sub(r"\s+", " ", text).strip() + return text + + +def tokenize(text: str) -> list[str]: + """Tokenize text into searchable terms (Latin + CJK bi-gram).""" + text_norm = normalize_text(text) + tokens: list[str] = [] + for part in re.findall(r"[\u4e00-\u9fff]+|[a-z0-9-]+", text_norm): + if re.fullmatch(r"[\u4e00-\u9fff]+", part): + if len(part) >= 2 and part not in STOPWORDS: + tokens.append(part) + if len(part) >= 2: + for i in range(len(part) - 1): + gram = part[i : i + 2] + if gram not in STOPWORDS: + tokens.append(gram) + continue + for unit in part.split("-"): + if len(unit) > 1 and unit not in STOPWORDS: + tokens.append(unit) + return tokens + + +# --------------------------------------------------------------------------- +# JSON helpers +# --------------------------------------------------------------------------- + +def load_json(path: Path) -> dict[str, Any]: + """Load a JSON file and return the parsed dict.""" + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +# --------------------------------------------------------------------------- +# Timestamp +# --------------------------------------------------------------------------- + +def now_iso() -> str: + """Return current UTC time in ISO 8601 format.""" + return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/scripts/audit_style_rule_conflicts.py b/scripts/audit_style_rule_conflicts.py index 00e51ce..e173e05 100644 --- a/scripts/audit_style_rule_conflicts.py +++ b/scripts/audit_style_rule_conflicts.py @@ -9,6 +9,11 @@ from pathlib import Path from typing import Any +import sys +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + from generate_brief import ( detect_lang, ensure_min_rules, diff --git a/scripts/blend_engine.py b/scripts/blend_engine.py new file mode 100644 index 0000000..e61117f --- /dev/null +++ b/scripts/blend_engine.py @@ -0,0 +1,127 @@ +"""Multi-style blend planning: scoring, ownership, and directive generation.""" + +from __future__ import annotations + +from typing import Any + +from search_stylekit import expand_query_tokens, tokenize + + +def motion_score(style: dict[str, Any]) -> float: + text = (style.get("aiRules", "") + "\n" + " ".join(style.get("keywords", []))).lower() + keywords = ["hover", "active", "transition", "animation", "motion", "glow", "悬停", "点击", "动画", "发光", "动效"] + return sum(1.0 for kw in keywords if kw in text) + + +def typography_score(style: dict[str, Any], qtokens: list[str]) -> float: + text = "\n".join( + [ + str(style.get("name", "")), + str(style.get("nameEn", "")), + str(style.get("philosophy", "")), + " ".join(style.get("keywords", [])), + ] + ).lower() + base = 0.0 + for kw in ["typography", "serif", "editorial", "readability", "字体", "排版", "可读"]: + if kw in text: + base += 1.0 + base += sum(0.3 for token in qtokens if token in text) + return base + + +def spacing_score(style: dict[str, Any]) -> float: + stype = style.get("styleType") + base = 2.0 if stype == "layout" else 0.0 + text = "\n".join([str(style.get("nameEn", "")), str(style.get("name", "")), " ".join(style.get("keywords", []))]).lower() + for kw in ["layout", "grid", "dashboard", "timeline", "sidebar", "布局", "网格", "间距"]: + if kw in text: + base += 0.8 + return base + + +def color_score(style: dict[str, Any], qtokens: list[str]) -> float: + text = "\n".join([str(style.get("nameEn", "")), str(style.get("name", "")), " ".join(style.get("keywords", []))]).lower() + base = 0.0 + for kw in ["color", "neon", "glass", "gradient", "luxury", "palette", "色彩", "霓虹", "玻璃", "渐变", "高端"]: + if kw in text: + base += 0.7 + base += sum(0.25 for token in qtokens if token in text) + return base + + +def pick_owner(styles: list[dict[str, Any]], scorer) -> str: + if not styles: + return "" + scored = sorted(styles, key=scorer, reverse=True) + return scored[0].get("slug", "") + + +def build_blend_plan(primary: dict[str, Any], alternatives: list[dict[str, Any]], query: str, lang: str) -> dict[str, Any]: + all_styles = [primary] + [item["style"] if "style" in item else item for item in alternatives] + all_styles = [style for style in all_styles if style] + qtokens = expand_query_tokens(tokenize(query)) + + if len(all_styles) <= 1: + return { + "enabled": False, + "base_style": primary.get("slug"), + "blend_styles": [], + "conflict_resolution": {}, + "priority_order": [primary.get("slug")], + "notes": "No secondary style available for blending." if lang == "en" else "当前没有可用于融合的次级风格。", + } + + secondary = [style for style in all_styles if style.get("slug") != primary.get("slug")][:2] + blend_weights = [] + weight_values = [0.25, 0.15] + for idx, style in enumerate(secondary): + blend_weights.append({"slug": style.get("slug"), "weight": weight_values[idx] if idx < len(weight_values) else 0.1}) + + color_owner = pick_owner(all_styles, lambda s: color_score(s, qtokens)) + typography_owner = pick_owner(all_styles, lambda s: typography_score(s, qtokens)) + spacing_owner = pick_owner(all_styles, spacing_score) + motion_owner = pick_owner(all_styles, motion_score) + + conflict_resolution = { + "color_owner": color_owner or primary.get("slug"), + "typography_owner": typography_owner or primary.get("slug"), + "spacing_owner": spacing_owner or primary.get("slug"), + "motion_owner": motion_owner or primary.get("slug"), + } + + priority_order = [primary.get("slug")] + [item.get("slug") for item in secondary] + + return { + "enabled": True, + "base_style": primary.get("slug"), + "blend_styles": blend_weights, + "conflict_resolution": conflict_resolution, + "priority_order": priority_order, + "notes": ( + "Base style controls global identity; owners control each token domain." + if lang == "en" + else "主风格控制整体气质,冲突归属控制具体 token 维度。" + ), + } + + +def blend_directive(blend_plan: dict[str, Any], lang: str) -> str: + if not blend_plan.get("enabled"): + return "" + c = blend_plan.get("conflict_resolution", {}) + if lang == "zh": + return ( + "融合规则:\n" + f"- 色彩由 `{c.get('color_owner')}` 主导\n" + f"- 字体与排版由 `{c.get('typography_owner')}` 主导\n" + f"- 间距与布局节奏由 `{c.get('spacing_owner')}` 主导\n" + f"- 动效与交互反馈由 `{c.get('motion_owner')}` 主导" + ) + return ( + "Blend rules:\n" + f"- Color is owned by `{c.get('color_owner')}`\n" + f"- Typography is owned by `{c.get('typography_owner')}`\n" + f"- Spacing/layout rhythm is owned by `{c.get('spacing_owner')}`\n" + f"- Motion/interaction is owned by `{c.get('motion_owner')}`" + ) diff --git a/scripts/brief_builder.py b/scripts/brief_builder.py new file mode 100644 index 0000000..75da7b8 --- /dev/null +++ b/scripts/brief_builder.py @@ -0,0 +1,210 @@ +"""Design brief building: visual direction, component guidelines, and intent.""" + +from __future__ import annotations + +from typing import Any + +from _brief_constants import has_cjk + + +def localized_visual_direction(style: dict[str, Any], lang: str) -> str: + raw = str(style.get("philosophy", "")).split("\n\n")[0].strip() + if raw: + if lang == "en" and not has_cjk(raw): + return raw + if lang == "zh" and has_cjk(raw): + return raw + + slug = style.get("slug", "style") + name = style.get("name", slug) + name_en = style.get("nameEn", slug) + if lang == "zh": + return f"{name} 强调风格识别度、信息层级和组件状态一致性。" + return f"{name_en} direction with strong visual identity, clear hierarchy, and consistent component-state behavior." + + +def infer_design_intent(query: str, lang: str) -> dict[str, str]: + q = query.lower() + if lang == "zh": + purpose = "构建高质量可落地前端界面,并确保视觉辨识度。" + if any(k in q for k in ["saas", "后台", "dashboard", "管理"]): + audience = "B 端专业用户,重视效率、可读性与稳定性。" + elif any(k in q for k in ["landing", "营销", "转化", "品牌"]): + audience = "潜在客户与决策者,重视品牌感与可信度。" + else: + audience = "通用互联网用户,重视信息清晰和操作顺畅。" + + if any(k in q for k in ["玻璃", "glassy", "frosted", "glass"]): + tone = "现代科技感、通透层叠、精致高端。" + elif any(k in q for k in ["复古", "vintage", "retro", "y2k"]): + tone = "复古表达、强记忆点、个性视觉。" + elif any(k in q for k in ["极简", "minimal", "clean"]): + tone = "克制极简、结构优先、内容导向。" + else: + tone = "鲜明风格取向,避免通用模板化审美。" + + memorable_hook = "至少设置一个可记忆视觉锚点(独特排版/背景层次/动效节奏)。" + return { + "purpose": purpose, + "audience": audience, + "tone": tone, + "memorable_hook": memorable_hook, + } + + purpose = "Deliver production-ready frontend UI with strong aesthetic identity." + if any(k in q for k in ["saas", "dashboard", "admin", "finance"]): + audience = "Professional users who prioritize readability, efficiency, and trust." + elif any(k in q for k in ["landing", "marketing", "conversion", "brand"]): + audience = "Prospects and decision-makers who respond to credibility and brand clarity." + else: + audience = "General users who need clear structure and smooth interactions." + + if any(k in q for k in ["glass", "frosted", "transparent"]): + tone = "Polished modern tech aesthetic with layered translucency." + elif any(k in q for k in ["retro", "vintage", "y2k"]): + tone = "Expressive nostalgic aesthetic with memorable visual contrast." + elif any(k in q for k in ["minimal", "clean"]): + tone = "Refined minimal aesthetic with strict hierarchy and restraint." + else: + tone = "Distinctive intentional style direction, not generic defaults." + + memorable_hook = "Introduce one memorable visual anchor (type treatment, background depth, or motion rhythm)." + return { + "purpose": purpose, + "audience": audience, + "tone": tone, + "memorable_hook": memorable_hook, + } + + +def anti_generic_constraints(lang: str) -> list[str]: + if lang == "zh": + return [ + "避免无差别模板化布局;保留明确风格立场。", + "避免默认紫色渐变白底套路,色彩需与风格语义一致。", + "避免过度依赖通用默认字体,至少给出清晰字体策略。", + "背景需有层次与氛围(渐变、纹理、形状或叠层),不要单一平涂。", + ] + return [ + "Avoid generic interchangeable layout patterns; keep a clear style point-of-view.", + "Avoid default purple-on-white gradient clichés unless explicitly required by style.", + "Avoid over-reliance on generic default fonts; provide explicit typography strategy.", + "Build atmospheric background depth (gradients/textures/shapes/layers), not flat filler.", + ] + + +def design_system_structure(stack: str, lang: str) -> dict[str, Any]: + if lang == "zh": + return { + "token_hierarchy": [ + "品牌色 Token -> 语义 Token(primary/surface/text)-> 组件 Token(button/card/input)", + "间距与圆角采用全局尺度,避免组件各自为政。", + "状态 Token 明确区分 hover/active/focus/disabled。", + ], + "component_architecture": [ + "Base -> Variant -> Size -> State -> Override 进行组件分层。", + "优先复用组件 API,不在页面内重复拼接样式逻辑。", + f"栈适配:{stack} 下保持设计 Token 与组件 API 同步。", + ], + } + return { + "token_hierarchy": [ + "Brand tokens -> semantic tokens (primary/surface/text) -> component tokens (button/card/input).", + "Use a unified spacing/radius scale instead of per-component ad hoc values.", + "Define explicit state tokens for hover/active/focus/disabled.", + ], + "component_architecture": [ + "Structure components as Base -> Variant -> Size -> State -> Override.", + "Prefer reusable component APIs over per-page style assembly.", + f"Stack alignment: keep token and component API mapping consistent in {stack}.", + ], + } + + +def build_component_guidelines( + style: dict[str, Any], + lang: str, + interaction_pattern_data: dict[str, Any] | None = None, +) -> list[str]: + guidelines = [] + components = style.get("components", {}) + + if components.get("button"): + guidelines.append( + "按钮要有明确层级、可见 hover/active/focus 状态。" if lang == "zh" else "Buttons must expose clear hierarchy and visible hover/active/focus states." + ) + if components.get("card"): + guidelines.append( + "卡片需体现信息层级:标题、摘要、次级信息和操作区。" if lang == "zh" else "Cards should express hierarchy: title, summary, metadata, and actions." + ) + if components.get("input"): + guidelines.append( + "表单输入需包含标签、错误状态和辅助说明。" if lang == "zh" else "Inputs must include labels, error states, and helper copy." + ) + if components.get("nav"): + guidelines.append( + "导航需包含当前态与可预测的信息结构。" if lang == "zh" else "Navigation should include active state and predictable information structure." + ) + if components.get("hero"): + guidelines.append( + "首屏需在 3 秒内传达价值主张与主行动按钮。" if lang == "zh" else "Hero must communicate value proposition and primary CTA within 3 seconds." + ) + if components.get("footer"): + guidelines.append( + "页脚承载次级链接、版权与信任信息。" if lang == "zh" else "Footer should host secondary links, trust signals, and legal metadata." + ) + + if not guidelines: + guidelines.append("Use component constraints from aiRules and doList." if lang == "en" else "优先按 aiRules 与 doList 约束组件实现。") + + if interaction_pattern_data: + required = interaction_pattern_data.get("required_components", []) + existing_lower = " ".join(guidelines).lower() + missing = [c for c in required if c.lower() not in existing_lower] + if missing: + if lang == "zh": + guidelines.append(f"交互模式要求组件:{', '.join(missing[:4])}——确保已纳入页面结构。") + else: + guidelines.append(f"Interaction pattern requires: {', '.join(missing[:4])} — ensure these are included in the page structure.") + + return guidelines[:6] + + +def build_interaction_rules( + ai_rules: list[str], + lang: str, + interaction_pattern_data: dict[str, Any] | None = None, +) -> list[str]: + keywords = ["hover", "active", "focus", "transition", "animation", "motion", "交互", "悬停", "点击", "焦点", "动画"] + selected = [rule for rule in ai_rules if any(word in rule.lower() for word in keywords)] + + if interaction_pattern_data and len(selected) < 3: + a11y = interaction_pattern_data.get("accessibility_constraints", []) + if a11y: + selected.extend(a11y[:3]) + + if len(selected) < 3: + fallback = ( + [ + "States must include hover, active, focus-visible, and disabled.", + "Motion timing should stay within 150-300ms unless explicitly theatrical.", + "Interactive feedback should be immediate and visually unambiguous.", + ] + if lang == "en" + else [ + "组件状态至少覆盖 hover、active、focus-visible 与 disabled。", + "动画时长通常控制在 150-300ms,除非是刻意戏剧化表现。", + "交互反馈必须即时且视觉上明确。", + ] + ) + selected.extend(fallback) + + deduped = [] + seen = set() + for rule in selected: + key = rule.lower().strip() + if key in seen: + continue + seen.add(key) + deduped.append(rule) + return deduped[:6] \ No newline at end of file diff --git a/scripts/generate_brief.py b/scripts/generate_brief.py index 0851524..7f732b0 100644 --- a/scripts/generate_brief.py +++ b/scripts/generate_brief.py @@ -9,379 +9,77 @@ from pathlib import Path from typing import Any +import sys +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from _brief_constants import ( + A11Y_BASELINE, + ANTI_PATTERN_BLACKLIST, + BG_BLACK_TOKEN_RE, + BG_WHITE_TOKEN_RE, + DEFAULT_AI_RULES, + DISTINCTIVE_FONT_HINTS, + GENERIC_FONTS, + NEGATOR_WORDS, + NEG_SECTION_MARKERS, + POS_SECTION_MARKERS, + RADIUS_TOKEN_RE, + REFERENCE_TYPES, + REFINE_MODE_HINTS, + SHADOW_TOKEN_RE, + STACK_HINTS, + VALIDATION_TESTS, + dedupe_ordered, + detect_lang, + has_cjk, + language_filter_rules, +) +from _common import RULE_STOPWORDS, __version__ +from blend_engine import build_blend_plan +from brief_builder import ( + anti_generic_constraints, + build_component_guidelines, + build_interaction_rules, + design_system_structure, + infer_design_intent, + localized_visual_direction, +) +from prompt_generator import make_prompts +from reference_handler import ( + build_reference_guidelines, + load_reference_payload, + normalize_reference_signals, + refine_mode_strategy, + validate_reference_payload_schema, +) from search_stylekit import BM25, build_text, expand_query_tokens, heuristic_score, load_json, tokenize +from v2_taxonomy import ( + CONTENT_DEPTH_CHOICES, + DECISION_SPEED_CHOICES, + RECOMMENDATION_MODE_CHOICES, + SITE_TYPES, + build_composition_plan, + build_content_plan, + build_decision_flow, + build_tag_bundle, + load_v2_references, + resolve_interaction_pattern_data, + resolve_site_type, + routing_adjustment_for_style, + routing_for_site_type, +) SCRIPT_DIR = Path(__file__).resolve().parent SKILL_ROOT = SCRIPT_DIR.parent REF_DIR = SKILL_ROOT / "references" CATALOG_DEFAULT = REF_DIR / "style-prompts.json" -STACK_HINTS = { - "html-tailwind": { - "en": "Use semantic HTML and Tailwind utility classes. Keep components reusable and avoid inline style except dynamic variables.", - "zh": "使用语义化 HTML 与 Tailwind 工具类,组件可复用,除动态变量外避免内联样式。", - }, - "react": { - "en": "Build reusable React components, stable keys, and accessible interaction states. Keep state minimal and localized.", - "zh": "构建可复用 React 组件,保证稳定 key 与可访问交互状态,状态最小化并局部化。", - }, - "nextjs": { - "en": "Prefer Server Components by default, add Client Components only for interactivity, and keep bundle weight low.", - "zh": "默认优先 Server Components,仅在交互需要时使用 Client Components,并控制包体积。", - }, - "vue": { - "en": "Use composables for shared logic, keep templates readable, and map style constraints into scoped utility patterns.", - "zh": "复用逻辑放入 composables,模板保持可读,把风格约束映射为稳定的样式模式。", - }, - "svelte": { - "en": "Keep component boundaries clear, use transitions intentionally, and avoid over-animating layout-critical areas.", - "zh": "组件边界保持清晰,过渡动画有目的地使用,避免关键布局区域过度动画。", - }, - "tailwind-v4": { - "en": "Use Tailwind v4 CSS-first setup with @theme/@utility/@custom-variant, prefer semantic tokens and OKLCH palette where possible.", - "zh": "使用 Tailwind v4 的 CSS-first 方案(@theme/@utility/@custom-variant),优先语义 token 与 OKLCH 色彩体系。", - }, -} - -REFERENCE_TYPES = ("none", "screenshot", "figma", "mixed") - -REFINE_MODE_HINTS = { - "new": { - "en": { - "objective": "Create a new screen or flow with a complete style-aligned structure.", - "constraints": [ - "Prioritize coherent information architecture before decorative details.", - "Ensure full interaction coverage (hover/active/focus-visible/disabled).", - "Deliver complete responsive behavior for core breakpoints.", - ], - }, - "zh": { - "objective": "从零创建新页面/新流程,输出完整且风格一致的结构。", - "constraints": [ - "先保证信息架构完整,再补充装饰性细节。", - "交互状态需覆盖 hover/active/focus-visible/disabled。", - "核心断点下都要保证完整响应式表现。", - ], - }, - }, - "polish": { - "en": { - "objective": "Polish visual quality while preserving existing structure and functionality.", - "constraints": [ - "Do not rewrite the page architecture unless required by clear defects.", - "Keep content hierarchy and user flow stable.", - "Improve typography, spacing rhythm, and visual consistency first.", - ], - }, - "zh": { - "objective": "在保留现有结构与功能的前提下进行视觉提质。", - "constraints": [ - "除明显缺陷外,不重写页面架构。", - "保持内容层级与用户流程稳定。", - "优先优化排版、间距节奏和视觉一致性。", - ], - }, - }, - "debug": { - "en": { - "objective": "Fix rendering and interaction defects without regressing style identity.", - "constraints": [ - "Focus on overflow, clipping, z-index overlap, and state regressions.", - "Keep style DNA intact while fixing bugs.", - "Provide minimal-change remediation over full rewrites.", - ], - }, - "zh": { - "objective": "修复渲染与交互缺陷,同时保持原有风格识别度。", - "constraints": [ - "重点处理溢出、裁切、z-index 覆盖和状态回归问题。", - "修 bug 时保持风格 DNA 不被破坏。", - "优先最小改动修复,避免整体重写。", - ], - }, - }, - "contrast-fix": { - "en": { - "objective": "Repair contrast and readability issues to meet accessibility baseline.", - "constraints": [ - "Enforce WCAG AA contrast targets for text and key UI states.", - "Preserve brand palette intent while adjusting tonal steps.", - "Avoid introducing visual noise during contrast correction.", - ], - }, - "zh": { - "objective": "修复对比度与可读性问题,使其满足无障碍基线。", - "constraints": [ - "正文与关键交互态满足 WCAG AA 对比度目标。", - "在不破坏品牌色语义的前提下调整明度层级。", - "修正对比度时避免引入额外视觉噪声。", - ], - }, - }, - "layout-fix": { - "en": { - "objective": "Repair layout structure and responsive behavior without changing style direction.", - "constraints": [ - "Fix grid/flex alignment, spacing collisions, and viewport overflow.", - "Preserve component semantics while rebalancing layout rhythm.", - "Validate desktop/tablet/mobile structure after fixes.", - ], - }, - "zh": { - "objective": "修复布局结构与响应式问题,不改变既有风格方向。", - "constraints": [ - "修复 grid/flex 对齐、间距冲突和视口溢出问题。", - "在重整布局节奏时保持组件语义稳定。", - "修复后验证桌面/平板/移动端结构一致性。", - ], - }, - }, - "component-fill": { - "en": { - "objective": "Complete missing components and states to reach production readiness.", - "constraints": [ - "Fill missing core components before adding new visual flourishes.", - "Ensure every new component has interaction and accessibility states.", - "Match token scale and naming with existing design system conventions.", - ], - }, - "zh": { - "objective": "补齐缺失组件与状态,提升到可交付质量。", - "constraints": [ - "先补齐核心组件,再考虑额外视觉特效。", - "新增组件必须包含交互态与可访问状态。", - "严格对齐现有 design token 的尺度与命名约定。", - ], - }, - }, -} - -REFERENCE_GUIDELINES = { - "screenshot": { - "en": [ - "Treat screenshot as visual reference for layout, spacing, and hierarchy.", - "Replicate structure first, then adapt to semantic HTML/component architecture.", - "Infer missing behavior explicitly (hover/focus/loading/error) instead of guessing silently.", - ], - "zh": [ - "将截图作为布局、间距和层级的视觉参考来源。", - "先对齐结构,再映射到语义化 HTML/组件架构。", - "对缺失交互(hover/focus/loading/error)需显式补全,不可隐式猜测。", - ], - }, - "figma": { - "en": [ - "Use Figma frame structure and token cues (color/spacing/type) as implementation baseline.", - "Break complex frames into reusable components before assembling full page.", - "Keep naming and token semantics consistent between design and code.", - ], - "zh": [ - "以 Figma 的 Frame 结构与 token 线索(色彩/间距/字体)作为实现基线。", - "复杂 Frame 先拆成可复用组件,再组装整页。", - "保持设计稿与代码中的命名和 token 语义一致。", - ], - }, -} - -REFERENCE_FIELD_ALIASES = { - "layout_issues": ["layout_issues", "layoutIssues", "layoutProblems", "layout_issues_list", "issues"], - "missing_components": ["missing_components", "missingComponents", "component_gaps", "components_missing"], - "preserve_elements": ["preserve_elements", "preserveElements", "preserve", "keep", "must_keep"], - "interaction_gaps": ["interaction_gaps", "interactionGaps", "state_gaps", "states_missing"], - "a11y_gaps": ["a11y_gaps", "accessibility_gaps", "accessibilityGaps", "wcag_gaps"], - "token_clues": ["token_clues", "tokenClues", "design_tokens", "tokens", "style_tokens"], - "notes": ["notes", "note", "summary", "description", "context"], -} - -REFERENCE_SECTION_KEYS = ("layout", "components", "interaction", "accessibility", "tokens") -REFERENCE_META_KEYS = ("source", "type", "notes", "metadata", "frame", "frames", "screen", "screens", "page") -REFERENCE_KNOWN_TOP_LEVEL = set(REFERENCE_SECTION_KEYS) | set(REFERENCE_META_KEYS) -for alias_items in REFERENCE_FIELD_ALIASES.values(): - REFERENCE_KNOWN_TOP_LEVEL.update(alias_items) - -A11Y_BASELINE = { - "en": [ - "Text contrast meets WCAG 2.1 AA (4.5:1 for normal text).", - "Keyboard focus is visible on all interactive controls.", - "Interactive targets are at least 44x44px on touch devices.", - "Respect prefers-reduced-motion for non-essential animation.", - ], - "zh": [ - "文本对比度满足 WCAG 2.1 AA(正文至少 4.5:1)。", - "所有可交互控件具备可见的键盘焦点状态。", - "触屏场景下交互目标至少 44x44px。", - "非必要动画需遵循 prefers-reduced-motion。", - ], -} - -NEGATOR_WORDS = ["avoid", "don't", "do not", "禁止", "不要", "避免", "严禁"] -NEG_SECTION_MARKERS = [ - "绝对禁止", - "禁止使用", - "禁止", - "must avoid", - "must not", - "forbidden", - "absolutely forbidden", - "do not", -] -POS_SECTION_MARKERS = [ - "必须遵守", - "必须使用", - "必须", - "must follow", - "must use", - "required", -] -RADIUS_TOKEN_RE = re.compile(r"\brounded(?:-[a-z0-9]+)?\b", re.IGNORECASE) -SHADOW_TOKEN_RE = re.compile(r"\bshadow(?:-[a-z0-9\[\]_/.-]+)?\b", re.IGNORECASE) -BG_WHITE_TOKEN_RE = re.compile(r"\bbg-white(?:/[0-9]{1,3})?\b", re.IGNORECASE) -BG_BLACK_TOKEN_RE = re.compile(r"\bbg-black(?:/[0-9]{1,3})?\b", re.IGNORECASE) -RULE_STOPWORDS = { - "use", - "using", - "must", - "should", - "ensure", - "keep", - "add", - "set", - "avoid", - "the", - "and", - "for", - "with", - "from", - "that", - "this", - "your", - "you", - "to", - "in", - "on", - "of", - "at", - "by", - "as", - "be", - "is", - "are", - "use", - "使用", - "添加", - "加入", - "保持", - "确保", - "避免", - "禁止", - "不要", - "需要", - "并", - "和", - "与", - "在", - "到", - "及", - "或", -} - -GENERIC_FONTS = ["inter", "arial", "roboto", "system-ui", "sans-serif"] -CJK_RE = re.compile(r"[\u4e00-\u9fff]") - -DISTINCTIVE_FONT_HINTS = { - "en": [ - "Pair one display font with one readable body font.", - "Avoid overusing generic defaults (Inter/Roboto/Arial/system-ui).", - "Use typography contrast (size, weight, spacing) to lead hierarchy.", - ], - "zh": [ - "标题字体与正文字体形成明显对比并保持统一。", - "避免过度依赖通用默认字体(Inter/Roboto/Arial/system-ui)。", - "通过字号、字重、字距建立清晰层级。", - ], -} - -VALIDATION_TESTS = { - "en": [ - "Swap test: replace key visual choices with common defaults; if identity stays unchanged, redesign.", - "Squint test: hierarchy should remain clear when blurred or zoomed out.", - "Signature test: point to at least 3 concrete UI elements carrying the style signature.", - "Token test: token names and values should reflect product intent, not generic templates.", - ], - "zh": [ - "替换测试(Swap test):把关键视觉选择替换为常见默认值,若辨识度不变则需要重做。", - "眯眼测试(Squint test):弱化细节后,信息层级仍然清晰可辨。", - "签名测试(Signature test):至少指出 3 个承载风格签名的具体界面元素。", - "Token 测试(Token test):设计 token 命名与取值要体现产品语义,避免模板化。", - ], -} - -ANTI_PATTERN_BLACKLIST = { - "en": [ - "Do not build full-page layout with absolute positioning; use flex/grid structure.", - "Do not create nested scroll containers or uncontrolled z-index wars.", - "Do not remove focus outlines without an explicit focus-visible replacement.", - "Do not submit forms without loading/disabled states and recovery-friendly errors.", - "Avoid god components (>300 lines) and deep prop drilling beyond two levels.", - ], - "zh": [ - "禁止用 absolute 定位搭整页布局,优先 flex/grid 结构。", - "禁止嵌套滚动容器与失控的 z-index 叠层竞争。", - "禁止移除焦点样式且不提供 focus-visible 替代方案。", - "禁止表单提交缺少 loading/disabled 状态与可恢复错误提示。", - "避免 God 组件(超过 300 行)和超过两层 prop drilling。", - ], -} - -DEFAULT_AI_RULES = { - "en": [ - "Keep clear hierarchy across heading, body, and metadata layers.", - "Implement explicit hover, active, focus-visible, and disabled states.", - "Maintain WCAG AA contrast and 44x44px touch-target baseline.", - "Use semantic design tokens and consistent spacing/radius scales.", - ], - "zh": [ - "保持标题、正文、元信息的清晰层级。", - "交互态必须覆盖 hover、active、focus-visible 与 disabled。", - "满足 WCAG AA 对比度并保证 44x44px 触控尺寸基线。", - "使用语义化 design token,并保持统一间距与圆角尺度。", - ], -} - -DEFAULT_DO_LIST = { - "en": [ - "Use semantic layout and preserve strong information hierarchy.", - "Provide visible interaction states and keyboard focus.", - "Keep typography and spacing rhythm consistent across breakpoints.", - ], - "zh": [ - "使用语义化布局并保持明确的信息层级。", - "提供可见交互状态与键盘焦点反馈。", - "在各断点保持一致的排版与间距节奏。", - ], -} - -DEFAULT_DONT_LIST = { - "en": [ - "Do not sacrifice readability for decorative effects.", - "Do not remove focus styles without a visible replacement.", - "Do not break responsive structure with rigid fixed-width layout.", - ], - "zh": [ - "禁止为了装饰效果牺牲可读性。", - "禁止移除焦点样式且不提供可见替代。", - "禁止用僵硬固定宽度破坏响应式结构。", - ], -} - - -def detect_lang(text: str) -> str: - return "zh" if re.search(r"[\u4e00-\u9fff]", text) else "en" - - -def has_cjk(text: str) -> bool: - return bool(CJK_RE.search(text or "")) +# --------------------------------------------------------------------------- +# Rule processing helpers +# --------------------------------------------------------------------------- def section_polarity_from_heading(line: str) -> str | None: text = str(line or "").strip() @@ -389,7 +87,6 @@ def section_polarity_from_heading(line: str) -> str | None: return None if not text.startswith("#"): return None - normalized = re.sub(r"^[#\s]+", "", text).lower() if any(marker in normalized for marker in NEG_SECTION_MARKERS): return "neg" @@ -414,29 +111,22 @@ def to_negative_rule(rule: str, lang: str) -> str: def extract_utility_signatures(rule: str) -> dict[str, set[str]]: low = str(rule or "").lower() signatures: dict[str, set[str]] = {} - for token in RADIUS_TOKEN_RE.findall(low): value = token.split("-", 1)[1] if "-" in token else "base" signatures.setdefault("radius", set()).add(value) - for token in SHADOW_TOKEN_RE.findall(low): value = token.split("-", 1)[1] if "-" in token else "base" signatures.setdefault("shadow", set()).add(value) - for token in BG_WHITE_TOKEN_RE.findall(low): value = "translucent" if "/" in token else "opaque" signatures.setdefault("bg-white", set()).add(value) - for token in BG_BLACK_TOKEN_RE.findall(low): value = "translucent" if "/" in token else "opaque" signatures.setdefault("bg-black", set()).add(value) - return signatures def utility_family_conflicts(values_a: set[str], values_b: set[str], family: str) -> bool: - # Opposite polarity is required by caller; treat only same-value collisions as conflicts. - # This allows valid pairs like "禁止 rounded-none" + "使用 rounded-xl". return bool(values_a & values_b) @@ -497,25 +187,6 @@ def rewrite_ambiguous_positive_rule(rule: str, lang: str) -> str: return rule -def dedupe_ordered(items: list[str]) -> list[str]: - out: list[str] = [] - seen: set[str] = set() - for item in items: - key = item.strip().lower() - if not key or key in seen: - continue - seen.add(key) - out.append(item.strip()) - return out - - -def language_filter_rules(rules: list[str], lang: str) -> list[str]: - cleaned = [str(rule).strip() for rule in rules if str(rule).strip()] - if lang == "en": - cleaned = [rule for rule in cleaned if not has_cjk(rule)] - return dedupe_ordered(cleaned) - - def rule_polarity(rule: str) -> str: low = rule.lower().strip() return "neg" if any(word in low for word in NEGATOR_WORDS) else "pos" @@ -561,13 +232,11 @@ def resolve_rule_conflicts(rules: list[str], lang: str) -> list[str]: filtered = language_filter_rules(rules, lang) indexed = list(enumerate(filtered)) ranked = sorted(indexed, key=lambda pair: (-rule_priority_score(pair[1]), pair[0])) - selected: list[tuple[int, str]] = [] for idx, rule in ranked: if any(rule_conflicts(rule, kept_rule) for _, kept_rule in selected): continue selected.append((idx, rule)) - selected.sort(key=lambda pair: pair[0]) return [rule for _, rule in selected] @@ -584,493 +253,6 @@ def ensure_rule_floor(rules: list[str], lang: str, min_count: int = 3) -> list[s return out -def build_localized_rule_list(items: list[str], lang: str, kind: str) -> list[str]: - cleaned = language_filter_rules([str(item).strip() for item in items if str(item).strip()], lang) - if cleaned: - return cleaned[:6] - if kind == "do": - return DEFAULT_DO_LIST[lang][:6] - return DEFAULT_DONT_LIST[lang][:6] - - -def localized_visual_direction(style: dict[str, Any], lang: str) -> str: - raw = str(style.get("philosophy", "")).split("\n\n")[0].strip() - if raw: - if lang == "en" and not has_cjk(raw): - return raw - if lang == "zh" and has_cjk(raw): - return raw - - slug = style.get("slug", "style") - name = style.get("name", slug) - name_en = style.get("nameEn", slug) - if lang == "zh": - return f"{name} 强调风格识别度、信息层级和组件状态一致性。" - return f"{name_en} direction with strong visual identity, clear hierarchy, and consistent component-state behavior." - - -def style_anchor_terms(style: dict[str, Any], lang: str) -> list[str]: - keywords = [str(x).strip() for x in style.get("keywords", []) if str(x).strip()] - tags = [str(x).strip() for x in style.get("tags", []) if str(x).strip()] - - if lang == "zh": - zh_terms = [term for term in keywords + tags if has_cjk(term)] - if zh_terms: - return dedupe_ordered(zh_terms)[:5] - return dedupe_ordered(keywords + tags)[:5] - - en_terms: list[str] = [] - for term in keywords + tags: - if not has_cjk(term): - en_terms.append(term) - name_en_tokens = re.findall(r"[a-zA-Z]{3,}", str(style.get("nameEn", ""))) - slug_tokens = [tok for tok in str(style.get("slug", "")).replace("-", " ").split() if len(tok) >= 3] - en_terms.extend(name_en_tokens) - en_terms.extend(slug_tokens) - return dedupe_ordered(en_terms)[:6] - - -def build_reference_guidelines(reference_type: str, lang: str) -> list[str]: - if reference_type == "none": - return [] - if reference_type == "mixed": - combined = REFERENCE_GUIDELINES["screenshot"][lang] + REFERENCE_GUIDELINES["figma"][lang] - return dedupe_ordered(combined)[:6] - if reference_type in REFERENCE_GUIDELINES: - return REFERENCE_GUIDELINES[reference_type][lang][:6] - return [] - - -def refine_mode_strategy(refine_mode: str, lang: str) -> dict[str, Any]: - mode = refine_mode if refine_mode in REFINE_MODE_HINTS else "new" - payload = REFINE_MODE_HINTS[mode][lang] - return { - "mode": mode, - "objective": payload["objective"], - "constraints": payload["constraints"][:6], - } - - -def to_text_list(value: Any) -> list[str]: - if value is None: - return [] - if isinstance(value, str): - text = value.strip() - return [text] if text else [] - if isinstance(value, (int, float, bool)): - return [str(value)] - if isinstance(value, list): - out: list[str] = [] - for item in value: - out.extend(to_text_list(item)) - return out - if isinstance(value, dict): - out: list[str] = [] - for key, val in value.items(): - vals = to_text_list(val) - if vals: - for item in vals: - out.append(f"{key}: {item}") - return out - return [] - - -def merge_reference_payload(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]: - merged = dict(base) - for key, value in incoming.items(): - if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): - merged[key] = merge_reference_payload(merged[key], value) - elif key in merged and isinstance(merged[key], list) and isinstance(value, list): - merged[key] = merged[key] + value - else: - merged[key] = value - return merged - - -def load_reference_payload(reference_json: str, reference_file: str) -> dict[str, Any]: - payload: dict[str, Any] = {} - - if reference_file.strip(): - path = Path(reference_file.strip()) - if not path.exists(): - raise SystemExit(f"Reference file not found: {path}") - text = path.read_text(encoding="utf-8").strip() - if text: - try: - loaded = json.loads(text) - if isinstance(loaded, dict): - payload = merge_reference_payload(payload, loaded) - else: - payload = merge_reference_payload(payload, {"notes": str(loaded)}) - except json.JSONDecodeError: - payload = merge_reference_payload(payload, {"notes": text}) - - if reference_json.strip(): - text = reference_json.strip() - try: - loaded = json.loads(text) - if isinstance(loaded, dict): - payload = merge_reference_payload(payload, loaded) - else: - payload = merge_reference_payload(payload, {"notes": str(loaded)}) - except json.JSONDecodeError: - payload = merge_reference_payload(payload, {"notes": text}) - - return payload - - -def validate_reference_payload_schema( - payload: dict[str, Any], - reference_type: str, - lang: str, - strict_mode: bool, -) -> dict[str, Any]: - warnings: list[str] = [] - errors: list[str] = [] - coercions: list[str] = [] - unknown_fields: list[str] = [] - - if payload and not isinstance(payload, dict): - errors.append("reference payload must be a JSON object") - payload = {} - - sanitized = dict(payload or {}) - - for section in REFERENCE_SECTION_KEYS: - if section not in sanitized: - continue - value = sanitized.get(section) - if isinstance(value, dict): - continue - if section == "tokens": - coerced_values = to_text_list(value) - if coerced_values: - sanitized[section] = {"values": coerced_values} - coercions.append(section) - warnings.append(f"coerced `{section}` to object with `values` list") - continue - errors.append(f"`{section}` must be an object or list-like value") - continue - - coerced_values = to_text_list(value) - if coerced_values: - sanitized[section] = {"issues": coerced_values} - coercions.append(section) - warnings.append(f"coerced `{section}` to object with `issues` list") - else: - errors.append(f"`{section}` must be an object or list-like value") - - for meta_key in ("source", "type"): - if meta_key in sanitized and not isinstance(sanitized.get(meta_key), str): - coerced = " ".join(to_text_list(sanitized.get(meta_key))).strip() - if coerced: - sanitized[meta_key] = coerced - coercions.append(meta_key) - warnings.append(f"coerced `{meta_key}` to string") - else: - errors.append(f"`{meta_key}` must be a string") - - for key in sanitized.keys(): - if key not in REFERENCE_KNOWN_TOP_LEVEL: - unknown_fields.append(key) - - if unknown_fields: - sample = ", ".join(sorted(unknown_fields)[:6]) - warnings.append(f"unknown top-level fields detected: {sample}") - - source_hint = str(sanitized.get("source", "") or sanitized.get("type", "")).lower() - if reference_type in {"screenshot", "figma"} and source_hint: - if reference_type == "screenshot" and "figma" in source_hint: - warnings.append("reference_type is screenshot but source/type suggests figma") - if reference_type == "figma" and any(token in source_hint for token in ["screen", "shot", "截图"]): - warnings.append("reference_type is figma but source/type suggests screenshot") - - if strict_mode and (errors or unknown_fields): - if errors: - errors.append("strict schema mode blocks invalid reference payload") - if unknown_fields: - errors.append("strict schema mode blocks unknown top-level fields") - - valid = len(errors) == 0 - return { - "valid": valid, - "strict_mode": strict_mode, - "errors": dedupe_ordered(errors), - "warnings": dedupe_ordered(warnings), - "coercions": dedupe_ordered(coercions), - "unknown_fields": sorted(set(unknown_fields)), - "sanitized_payload": sanitized, - } - - -def get_alias_values(payload: dict[str, Any], aliases: list[str]) -> list[str]: - out: list[str] = [] - for key in aliases: - if key in payload: - out.extend(to_text_list(payload.get(key))) - return dedupe_ordered(out) - - -def normalize_reference_signals( - payload: dict[str, Any], - reference_type: str, - reference_notes: str, - lang: str, -) -> dict[str, Any]: - if not payload and not reference_notes.strip(): - return { - "has_signals": False, - "source": reference_type, - "summary": "", - "signals": { - "layout_issues": [], - "missing_components": [], - "preserve_elements": [], - "interaction_gaps": [], - "a11y_gaps": [], - "token_clues": [], - "notes": [], - }, - "derived_rules": [], - } - - layout_block = payload.get("layout") if isinstance(payload.get("layout"), dict) else {} - component_block = payload.get("components") if isinstance(payload.get("components"), dict) else {} - interaction_block = payload.get("interaction") if isinstance(payload.get("interaction"), dict) else {} - a11y_block = payload.get("accessibility") if isinstance(payload.get("accessibility"), dict) else {} - token_block = payload.get("tokens") if isinstance(payload.get("tokens"), dict) else {} - - layout_issues = get_alias_values(payload, REFERENCE_FIELD_ALIASES["layout_issues"]) - layout_issues.extend(get_alias_values(layout_block, ["issues", "problem", "problems", "gaps"])) - - missing_components = get_alias_values(payload, REFERENCE_FIELD_ALIASES["missing_components"]) - missing_components.extend(get_alias_values(component_block, ["missing", "gaps"])) - - preserve_elements = get_alias_values(payload, REFERENCE_FIELD_ALIASES["preserve_elements"]) - preserve_elements.extend(get_alias_values(layout_block, ["preserve", "keep"])) - preserve_elements.extend(get_alias_values(component_block, ["preserve", "keep"])) - - interaction_gaps = get_alias_values(payload, REFERENCE_FIELD_ALIASES["interaction_gaps"]) - interaction_gaps.extend(get_alias_values(interaction_block, ["missing_states", "gaps", "issues"])) - - a11y_gaps = get_alias_values(payload, REFERENCE_FIELD_ALIASES["a11y_gaps"]) - a11y_gaps.extend(get_alias_values(a11y_block, ["issues", "gaps", "missing"])) - - token_clues = get_alias_values(payload, REFERENCE_FIELD_ALIASES["token_clues"]) - token_clues.extend(get_alias_values(token_block, ["colors", "spacing", "typography", "radius", "shadows"])) - - notes = get_alias_values(payload, REFERENCE_FIELD_ALIASES["notes"]) - if reference_notes.strip(): - notes.append(reference_notes.strip()) - - layout_issues = dedupe_ordered(layout_issues)[:5] - missing_components = dedupe_ordered(missing_components)[:5] - preserve_elements = dedupe_ordered(preserve_elements)[:5] - interaction_gaps = dedupe_ordered(interaction_gaps)[:5] - a11y_gaps = dedupe_ordered(a11y_gaps)[:5] - token_clues = dedupe_ordered(token_clues)[:6] - notes = dedupe_ordered(notes)[:3] - - derived_rules: list[str] = [] - if lang == "zh": - derived_rules.extend([f"修复参考输入中的布局问题:{item}。" for item in layout_issues[:3]]) - derived_rules.extend([f"补齐缺失组件/状态:{item}。" for item in missing_components[:2]]) - derived_rules.extend([f"保留既有结构要素:{item}。" for item in preserve_elements[:2]]) - derived_rules.extend([f"补全交互缺口:{item}。" for item in interaction_gaps[:2]]) - derived_rules.extend([f"修复可访问性缺口:{item}。" for item in a11y_gaps[:2]]) - if token_clues: - derived_rules.append(f"参考 token 线索并映射到语义 token:{';'.join(token_clues[:4])}。") - else: - derived_rules.extend([f"Fix layout issue from reference input: {item}." for item in layout_issues[:3]]) - derived_rules.extend([f"Fill missing component/state: {item}." for item in missing_components[:2]]) - derived_rules.extend([f"Preserve existing structural element: {item}." for item in preserve_elements[:2]]) - derived_rules.extend([f"Close interaction gap: {item}." for item in interaction_gaps[:2]]) - derived_rules.extend([f"Fix accessibility gap: {item}." for item in a11y_gaps[:2]]) - if token_clues: - derived_rules.append(f"Map reference token clues to semantic tokens: {'; '.join(token_clues[:4])}.") - - summary_parts = [] - if layout_issues: - summary_parts.append(f"layout:{len(layout_issues)}") - if missing_components: - summary_parts.append(f"components:{len(missing_components)}") - if preserve_elements: - summary_parts.append(f"preserve:{len(preserve_elements)}") - if interaction_gaps: - summary_parts.append(f"interaction:{len(interaction_gaps)}") - if a11y_gaps: - summary_parts.append(f"a11y:{len(a11y_gaps)}") - if token_clues: - summary_parts.append(f"tokens:{len(token_clues)}") - summary = ", ".join(summary_parts) - - signals = { - "layout_issues": layout_issues, - "missing_components": missing_components, - "preserve_elements": preserve_elements, - "interaction_gaps": interaction_gaps, - "a11y_gaps": a11y_gaps, - "token_clues": token_clues, - "notes": notes, - } - has_signals = any(bool(value) for value in signals.values()) - - return { - "has_signals": has_signals, - "source": reference_type, - "summary": summary, - "signals": signals, - "derived_rules": dedupe_ordered(derived_rules)[:8], - } - - -def reference_signal_prompt_block(reference_signals: dict[str, Any], lang: str) -> str: - if not reference_signals.get("has_signals"): - return "" - - sig = reference_signals.get("signals", {}) - layout_issues = sig.get("layout_issues", []) - missing_components = sig.get("missing_components", []) - preserve_elements = sig.get("preserve_elements", []) - interaction_gaps = sig.get("interaction_gaps", []) - a11y_gaps = sig.get("a11y_gaps", []) - token_clues = sig.get("token_clues", []) - notes = sig.get("notes", []) - - if lang == "zh": - lines = ["参考信号提取:"] - if layout_issues: - lines.append(f"- 布局问题:{';'.join(layout_issues[:3])}") - if missing_components: - lines.append(f"- 缺失组件:{';'.join(missing_components[:3])}") - if preserve_elements: - lines.append(f"- 保留要素:{';'.join(preserve_elements[:3])}") - if interaction_gaps: - lines.append(f"- 交互缺口:{';'.join(interaction_gaps[:3])}") - if a11y_gaps: - lines.append(f"- 可访问性缺口:{';'.join(a11y_gaps[:3])}") - if token_clues: - lines.append(f"- Token 线索:{';'.join(token_clues[:4])}") - if notes: - lines.append(f"- 备注:{';'.join(notes[:2])}") - return "\n".join(lines) + "\n\n" - - lines = ["Reference signal extraction:"] - if layout_issues: - lines.append(f"- Layout issues: {'; '.join(layout_issues[:3])}") - if missing_components: - lines.append(f"- Missing components: {'; '.join(missing_components[:3])}") - if preserve_elements: - lines.append(f"- Preserve elements: {'; '.join(preserve_elements[:3])}") - if interaction_gaps: - lines.append(f"- Interaction gaps: {'; '.join(interaction_gaps[:3])}") - if a11y_gaps: - lines.append(f"- Accessibility gaps: {'; '.join(a11y_gaps[:3])}") - if token_clues: - lines.append(f"- Token clues: {'; '.join(token_clues[:4])}") - if notes: - lines.append(f"- Notes: {'; '.join(notes[:2])}") - return "\n".join(lines) + "\n\n" - - -def infer_design_intent(query: str, lang: str) -> dict[str, str]: - q = query.lower() - if lang == "zh": - purpose = "构建高质量可落地前端界面,并确保视觉辨识度。" - if any(k in q for k in ["saas", "后台", "dashboard", "管理"]): - audience = "B 端专业用户,重视效率、可读性与稳定性。" - elif any(k in q for k in ["landing", "营销", "转化", "品牌"]): - audience = "潜在客户与决策者,重视品牌感与可信度。" - else: - audience = "通用互联网用户,重视信息清晰和操作顺畅。" - - if any(k in q for k in ["玻璃", "glassy", "frosted", "glass"]): - tone = "现代科技感、通透层叠、精致高端。" - elif any(k in q for k in ["复古", "vintage", "retro", "y2k"]): - tone = "复古表达、强记忆点、个性视觉。" - elif any(k in q for k in ["极简", "minimal", "clean"]): - tone = "克制极简、结构优先、内容导向。" - else: - tone = "鲜明风格取向,避免通用模板化审美。" - - memorable_hook = "至少设置一个可记忆视觉锚点(独特排版/背景层次/动效节奏)。" - return { - "purpose": purpose, - "audience": audience, - "tone": tone, - "memorable_hook": memorable_hook, - } - - purpose = "Deliver production-ready frontend UI with strong aesthetic identity." - if any(k in q for k in ["saas", "dashboard", "admin", "finance"]): - audience = "Professional users who prioritize readability, efficiency, and trust." - elif any(k in q for k in ["landing", "marketing", "conversion", "brand"]): - audience = "Prospects and decision-makers who respond to credibility and brand clarity." - else: - audience = "General users who need clear structure and smooth interactions." - - if any(k in q for k in ["glass", "frosted", "transparent"]): - tone = "Polished modern tech aesthetic with layered translucency." - elif any(k in q for k in ["retro", "vintage", "y2k"]): - tone = "Expressive nostalgic aesthetic with memorable visual contrast." - elif any(k in q for k in ["minimal", "clean"]): - tone = "Refined minimal aesthetic with strict hierarchy and restraint." - else: - tone = "Distinctive intentional style direction, not generic defaults." - - memorable_hook = "Introduce one memorable visual anchor (type treatment, background depth, or motion rhythm)." - return { - "purpose": purpose, - "audience": audience, - "tone": tone, - "memorable_hook": memorable_hook, - } - - -def anti_generic_constraints(lang: str) -> list[str]: - if lang == "zh": - return [ - "避免无差别模板化布局;保留明确风格立场。", - "避免默认紫色渐变白底套路,色彩需与风格语义一致。", - "避免过度依赖通用默认字体,至少给出清晰字体策略。", - "背景需有层次与氛围(渐变、纹理、形状或叠层),不要单一平涂。", - ] - return [ - "Avoid generic interchangeable layout patterns; keep a clear style point-of-view.", - "Avoid default purple-on-white gradient clichés unless explicitly required by style.", - "Avoid over-reliance on generic default fonts; provide explicit typography strategy.", - "Build atmospheric background depth (gradients/textures/shapes/layers), not flat filler.", - ] - - -def design_system_structure(stack: str, lang: str) -> dict[str, Any]: - if lang == "zh": - return { - "token_hierarchy": [ - "品牌色 Token -> 语义 Token(primary/surface/text)-> 组件 Token(button/card/input)", - "间距与圆角采用全局尺度,避免组件各自为政。", - "状态 Token 明确区分 hover/active/focus/disabled。", - ], - "component_architecture": [ - "Base -> Variant -> Size -> State -> Override 进行组件分层。", - "优先复用组件 API,不在页面内重复拼接样式逻辑。", - f"栈适配:{stack} 下保持设计 Token 与组件 API 同步。", - ], - } - return { - "token_hierarchy": [ - "Brand tokens -> semantic tokens (primary/surface/text) -> component tokens (button/card/input).", - "Use a unified spacing/radius scale instead of per-component ad hoc values.", - "Define explicit state tokens for hover/active/focus/disabled.", - ], - "component_architecture": [ - "Structure components as Base -> Variant -> Size -> State -> Override.", - "Prefer reusable component APIs over per-page style assembly.", - f"Stack alignment: keep token and component API mapping consistent in {stack}.", - ], - } - - def rule_token_set(text: str) -> set[str]: return {tok for tok in tokenize(text) if tok not in RULE_STOPWORDS and len(tok) > 1} @@ -1081,14 +263,11 @@ def is_negative_rule(rule: str) -> bool: def conflicts_with_dont(rule: str, dont_list: list[str]) -> bool: - # Negative rules are usually safe because they describe what to avoid. if is_negative_rule(rule): return False - r_tokens = rule_token_set(rule) if not r_tokens: return False - for dont in dont_list: d_tokens = rule_token_set(str(dont)) if len(d_tokens) < 2: @@ -1103,22 +282,18 @@ def conflicts_with_dont(rule: str, dont_list: list[str]) -> bool: def extract_rules(ai_rules_text: str, lang: str) -> list[str]: lines = [] section_polarity: str | None = None - for raw in str(ai_rules_text or "").splitlines(): raw_line = raw.strip() if not raw_line: continue - heading_polarity = section_polarity_from_heading(raw_line) if heading_polarity: section_polarity = heading_polarity continue - line = re.sub(r"^[-*]\s+", "", raw_line) line = re.sub(r"^\d+\.\s+", "", line) if len(line) < 8: continue - low = line.lower() if line.startswith("#"): continue @@ -1126,13 +301,9 @@ def extract_rules(ai_rules_text: str, lang: str) -> list[str]: continue if "生成的所有代码必须" in line or "all code must" in low: continue - - # Treat bullets inside forbidden sections as negative constraints. if section_polarity == "neg": line = to_negative_rule(line, lang) - lines.append(line) - deduped = [] seen = set() for item in lines: @@ -1150,20 +321,12 @@ def rank_styles(styles: list[dict[str, Any]], query: str) -> list[dict[str, Any] bm25.fit(docs) qtokens = expand_query_tokens(tokenize(query)) bm25_scores = {styles[idx].get("slug"): score for idx, score in bm25.score(query_tokens=qtokens)} - ranked = [] for style in styles: h_score, reasons = heuristic_score(style, query, qtokens) b_score = bm25_scores.get(style.get("slug"), 0.0) final_score = b_score * 3.0 + h_score - ranked.append( - { - "style": style, - "score": final_score, - "reason": reasons, - } - ) - + ranked.append({"style": style, "score": final_score, "reason": reasons}) ranked.sort(key=lambda x: x["score"], reverse=True) return ranked @@ -1174,90 +337,21 @@ def resolve_primary_style(styles: list[dict[str, Any]], query: str, forced_slug: if style.get("slug") == forced_slug: return style, rank_styles(styles, query) raise SystemExit(f"Style slug not found: {forced_slug}") - ranked = rank_styles(styles, query) if not ranked: raise SystemExit("No style available") return ranked[0]["style"], ranked -def build_component_guidelines(style: dict[str, Any], lang: str) -> list[str]: - guidelines = [] - components = style.get("components", {}) - - if components.get("button"): - guidelines.append( - "按钮要有明确层级、可见 hover/active/focus 状态。" if lang == "zh" else "Buttons must expose clear hierarchy and visible hover/active/focus states." - ) - if components.get("card"): - guidelines.append( - "卡片需体现信息层级:标题、摘要、次级信息和操作区。" if lang == "zh" else "Cards should express hierarchy: title, summary, metadata, and actions." - ) - if components.get("input"): - guidelines.append( - "表单输入需包含标签、错误状态和辅助说明。" if lang == "zh" else "Inputs must include labels, error states, and helper copy." - ) - if components.get("nav"): - guidelines.append( - "导航需包含当前态与可预测的信息结构。" if lang == "zh" else "Navigation should include active state and predictable information structure." - ) - if components.get("hero"): - guidelines.append( - "首屏需在 3 秒内传达价值主张与主行动按钮。" if lang == "zh" else "Hero must communicate value proposition and primary CTA within 3 seconds." - ) - if components.get("footer"): - guidelines.append( - "页脚承载次级链接、版权与信任信息。" if lang == "zh" else "Footer should host secondary links, trust signals, and legal metadata." - ) - - if not guidelines: - guidelines.append("Use component constraints from aiRules and doList." if lang == "en" else "优先按 aiRules 与 doList 约束组件实现。") - - return guidelines[:6] - - -def build_interaction_rules(ai_rules: list[str], lang: str) -> list[str]: - keywords = ["hover", "active", "focus", "transition", "animation", "motion", "交互", "悬停", "点击", "焦点", "动画"] - selected = [rule for rule in ai_rules if any(word in rule.lower() for word in keywords)] - - if len(selected) < 3: - fallback = ( - [ - "States must include hover, active, focus-visible, and disabled.", - "Motion timing should stay within 150-300ms unless explicitly theatrical.", - "Interactive feedback should be immediate and visually unambiguous.", - ] - if lang == "en" - else [ - "组件状态至少覆盖 hover、active、focus-visible 与 disabled。", - "动画时长通常控制在 150-300ms,除非是刻意戏剧化表现。", - "交互反馈必须即时且视觉上明确。", - ] - ) - selected.extend(fallback) - - deduped = [] - seen = set() - for rule in selected: - key = rule.lower().strip() - if key in seen: - continue - seen.add(key) - deduped.append(rule) - return deduped[:6] - - def normalize_rule(rule: str, dont_list: list[str], lang: str) -> str: low = rule.lower().strip() if any(word in low for word in NEGATOR_WORDS): return rule - risky_starts = ["省略", "删除", "移除", "去掉", "禁用", "omit", "remove", "disable"] if any(low.startswith(item) for item in risky_starts): if lang == "zh": return f"避免{rule}" if not rule.startswith(("避免", "不要", "禁止")) else rule return f"Avoid {rule[0].lower() + rule[1:]}" if rule else rule - for dont in dont_list: d = str(dont).strip() if len(d) < 4: @@ -1279,7 +373,6 @@ def ensure_min_rules(base_rules: list[str], do_list: list[str], dont_list: list[ if conflicts_with_dont(normalized, dont_list): continue out.append(normalized) - for item in do_list: if len(out) >= 6: break @@ -1308,7 +401,6 @@ def ensure_min_rules(base_rules: list[str], do_list: list[str], dont_list: list[ ] ) out.extend(defaults) - deduped = [] seen = set() for rule in out: @@ -1320,362 +412,127 @@ def ensure_min_rules(base_rules: list[str], do_list: list[str], dont_list: list[ return deduped[:8] -def motion_score(style: dict[str, Any]) -> float: - text = (style.get("aiRules", "") + "\n" + " ".join(style.get("keywords", []))).lower() - keywords = ["hover", "active", "transition", "animation", "motion", "glow", "悬停", "点击", "动画", "发光", "动效"] - return sum(1.0 for kw in keywords if kw in text) - - -def typography_score(style: dict[str, Any], qtokens: list[str]) -> float: - text = "\n".join( - [ - str(style.get("name", "")), - str(style.get("nameEn", "")), - str(style.get("philosophy", "")), - " ".join(style.get("keywords", [])), - ] - ).lower() - base = 0.0 - for kw in ["typography", "serif", "editorial", "readability", "字体", "排版", "可读"]: - if kw in text: - base += 1.0 - base += sum(0.3 for token in qtokens if token in text) - return base - - -def spacing_score(style: dict[str, Any]) -> float: - stype = style.get("styleType") - base = 2.0 if stype == "layout" else 0.0 - text = "\n".join([str(style.get("nameEn", "")), str(style.get("name", "")), " ".join(style.get("keywords", []))]).lower() - for kw in ["layout", "grid", "dashboard", "timeline", "sidebar", "布局", "网格", "间距"]: - if kw in text: - base += 0.8 - return base - - -def color_score(style: dict[str, Any], qtokens: list[str]) -> float: - text = "\n".join([str(style.get("nameEn", "")), str(style.get("name", "")), " ".join(style.get("keywords", []))]).lower() - base = 0.0 - for kw in ["color", "neon", "glass", "gradient", "luxury", "palette", "色彩", "霓虹", "玻璃", "渐变", "高端"]: - if kw in text: - base += 0.7 - base += sum(0.25 for token in qtokens if token in text) - return base - - -def pick_owner(styles: list[dict[str, Any]], scorer) -> str: - if not styles: - return "" - scored = sorted(styles, key=scorer, reverse=True) - return scored[0].get("slug", "") - - -def build_blend_plan(primary: dict[str, Any], alternatives: list[dict[str, Any]], query: str, lang: str) -> dict[str, Any]: - all_styles = [primary] + [item["style"] if "style" in item else item for item in alternatives] - all_styles = [style for style in all_styles if style] - qtokens = expand_query_tokens(tokenize(query)) - - if len(all_styles) <= 1: - return { - "enabled": False, - "base_style": primary.get("slug"), - "blend_styles": [], - "conflict_resolution": {}, - "priority_order": [primary.get("slug")], - "notes": "No secondary style available for blending." if lang == "en" else "当前没有可用于融合的次级风格。", - } - - secondary = [style for style in all_styles if style.get("slug") != primary.get("slug")][:2] - blend_weights = [] - weight_values = [0.25, 0.15] - for idx, style in enumerate(secondary): - blend_weights.append({"slug": style.get("slug"), "weight": weight_values[idx] if idx < len(weight_values) else 0.1}) - - color_owner = pick_owner(all_styles, lambda s: color_score(s, qtokens)) - typography_owner = pick_owner(all_styles, lambda s: typography_score(s, qtokens)) - spacing_owner = pick_owner(all_styles, spacing_score) - motion_owner = pick_owner(all_styles, motion_score) - - conflict_resolution = { - "color_owner": color_owner or primary.get("slug"), - "typography_owner": typography_owner or primary.get("slug"), - "spacing_owner": spacing_owner or primary.get("slug"), - "motion_owner": motion_owner or primary.get("slug"), - } - - priority_order = [primary.get("slug")] + [item.get("slug") for item in secondary] - - return { - "enabled": True, - "base_style": primary.get("slug"), - "blend_styles": blend_weights, - "conflict_resolution": conflict_resolution, - "priority_order": priority_order, - "notes": ( - "Base style controls global identity; owners control each token domain." - if lang == "en" - else "主风格控制整体气质,冲突归属控制具体 token 维度。" - ), - } - - -def blend_directive(blend_plan: dict[str, Any], lang: str) -> str: - if not blend_plan.get("enabled"): - return "" - c = blend_plan.get("conflict_resolution", {}) - if lang == "zh": - return ( - "融合规则:\n" - f"- 色彩由 `{c.get('color_owner')}` 主导\n" - f"- 字体与排版由 `{c.get('typography_owner')}` 主导\n" - f"- 间距与布局节奏由 `{c.get('spacing_owner')}` 主导\n" - f"- 动效与交互反馈由 `{c.get('motion_owner')}` 主导" - ) - return ( - "Blend rules:\n" - f"- Color is owned by `{c.get('color_owner')}`\n" - f"- Typography is owned by `{c.get('typography_owner')}`\n" - f"- Spacing/layout rhythm is owned by `{c.get('spacing_owner')}`\n" - f"- Motion/interaction is owned by `{c.get('motion_owner')}`" - ) - - -def make_prompts( - query: str, - style: dict[str, Any], - ai_rules: list[str], - stack: str, - lang: str, - blend_plan: dict[str, Any], - intent: dict[str, str], - anti_generic: list[str], - refine_mode: str, - reference_type: str, - reference_notes: str, - reference_signals: dict[str, Any], -) -> tuple[str, str]: - stack_hint = STACK_HINTS.get(stack, STACK_HINTS["html-tailwind"])[lang] - validation_tests = VALIDATION_TESTS[lang] - anti_patterns = ANTI_PATTERN_BLACKLIST[lang] - do_list = build_localized_rule_list(style.get("doList", []), lang, kind="do") - dont_list = build_localized_rule_list(style.get("dontList", []), lang, kind="dont") - anchor_terms = style_anchor_terms(style, lang) - anchor_line_zh = "、".join(anchor_terms) if anchor_terms else style.get("slug", "") - anchor_line_en = ", ".join(anchor_terms) if anchor_terms else style.get("slug", "") - refine_strategy = refine_mode_strategy(refine_mode, lang) - reference_guidelines = build_reference_guidelines(reference_type, lang) - reference_signal_block = reference_signal_prompt_block(reference_signals, lang) - - if lang == "zh": - refine_block = ( - f"迭代模式:{refine_strategy.get('mode')}\n" - + f"- 本轮目标:{refine_strategy.get('objective')}\n" - + "模式约束:\n" - + "\n".join([f"- {item}" for item in refine_strategy.get("constraints", [])]) - + "\n\n" - ) - reference_block = "" - if reference_guidelines: - reference_block = ( - f"参考输入类型:{reference_type}\n" - + "参考输入约束:\n" - + "\n".join([f"- {item}" for item in reference_guidelines]) - + ("\n" + f"- 参考备注:{reference_notes.strip()}" if reference_notes.strip() else "") - + "\n\n" - ) - reference_block = reference_block + reference_signal_block - else: - refine_block = ( - f"Refinement mode: {refine_strategy.get('mode')}\n" - + f"- Objective: {refine_strategy.get('objective')}\n" - + "Mode constraints:\n" - + "\n".join([f"- {item}" for item in refine_strategy.get("constraints", [])]) - + "\n\n" - ) - reference_block = "" - if reference_guidelines: - reference_block = ( - f"Reference input type: {reference_type}\n" - + "Reference handling constraints:\n" - + "\n".join([f"- {item}" for item in reference_guidelines]) - + ("\n" + f"- Reference notes: {reference_notes.strip()}" if reference_notes.strip() else "") - + "\n\n" - ) - reference_block = reference_block + reference_signal_block - - if lang == "zh": - hard = ( - f"你是高级前端设计工程师。请严格按照 StyleKit 风格 `{style.get('slug')}` 生成界面。\n" - f"需求:{query}\n\n" - + "设计意图:\n" - + f"- 目标:{intent.get('purpose')}\n" - + f"- 受众:{intent.get('audience')}\n" - + f"- 调性:{intent.get('tone')}\n" - + f"- 记忆点:{intent.get('memorable_hook')}\n\n" - + refine_block - + reference_block - + "硬性约束:\n" - + "\n".join([f"- {rule}" for rule in ai_rules]) - + "\n\n" - + "必须遵守 Do:\n" - + "\n".join([f"- {item}" for item in do_list]) - + "\n\n" - + "必须避免 Don't:\n" - + "\n".join([f"- {item}" for item in dont_list]) - + "\n\n" - + f"技术栈约束:{stack_hint}\n" - + "组件覆盖:至少提供 button、card、input,并补充 nav、hero、footer 中至少两个。\n" - + "可访问性基线:保持 WCAG 2.1 AA(4.5:1)对比度、44x44px 触控目标,并确保键盘可达性。\n" - + "反模板化约束:\n" - + "\n".join([f"- {item}" for item in anti_generic]) - + "\n" - + "设计系统约束:使用 primary/surface/text 语义 token,统一 spacing scale 与 radius,并明确 variant/state 层级。\n" - + f"风格锚点词:{anchor_line_zh}(需在视觉语言、排版和组件语义中体现)。\n" - + "提案前校验(必须自检):\n" - + "\n".join([f"- {item}" for item in validation_tests]) - + "\n" - + "反模式禁令:\n" - + "\n".join([f"- {item}" for item in anti_patterns]) - + "\n" - + "输出要求:提供语义化结构、响应式布局、可访问状态(hover/active/focus-visible/disabled),并保持视觉一致。" - ) - blend_hint = blend_directive(blend_plan, lang) - if blend_hint: - hard = hard + "\n\n" + blend_hint - - soft = ( - f"请基于 StyleKit 风格 `{style.get('slug')}` 生成一个美观且可实现的前端方案。\n" - f"需求:{query}\n" - "保持风格核心(配色、层级、节奏、交互反馈),允许在版式和细节上做创造性调整。\n" - + "设计意图:\n" - + f"- 目标:{intent.get('purpose')}\n" - + f"- 受众:{intent.get('audience')}\n" - + f"- 调性:{intent.get('tone')}\n" - + refine_block - + reference_block - + "优先规则:\n" - + "\n".join([f"- {rule}" for rule in ai_rules[:4]]) - + f"\n技术栈建议:{stack_hint}\n" - + "建议组件:button、card、input,并补充 nav/hero/footer 中至少两个。\n" - + "最低可访问性:WCAG 对比度目标(建议 4.5:1)与 44x44px 触控尺寸基线。\n" - + "建议采用设计 token:primary/secondary/text、spacing scale、radius、variant/state。\n" - + f"风格锚点词:{anchor_line_zh}。\n" - + "请在提交前执行替换测试、眯眼测试、签名测试、Token 测试,并避开绝对定位整页、嵌套滚动和焦点样式缺失等反模式。" - ) - blend_hint = blend_directive(blend_plan, lang) - if blend_hint: - soft = soft + "\n" + blend_hint - return hard, soft - - hard = ( - f"You are a senior frontend design engineer. Strictly implement StyleKit style `{style.get('slug')}`.\n" - f"Requirement: {query}\n\n" - + "Design intent:\n" - + f"- Purpose: {intent.get('purpose')}\n" - + f"- Audience: {intent.get('audience')}\n" - + f"- Tone: {intent.get('tone')}\n" - + f"- Memorable hook: {intent.get('memorable_hook')}\n\n" - + refine_block - + reference_block - + "Hard constraints:\n" - + "\n".join([f"- {rule}" for rule in ai_rules]) - + "\n\n" - + "Must-do constraints:\n" - + "\n".join([f"- {item}" for item in do_list]) - + "\n\n" - + "Must-avoid constraints:\n" - + "\n".join([f"- {item}" for item in dont_list]) - + "\n\n" - + f"Stack hint: {stack_hint}\n" - + "Component coverage: include button, card, input, and at least two of nav/hero/footer.\n" - + "Accessibility baseline: maintain WCAG 2.1 AA (4.5:1) contrast, 44x44px touch targets, and keyboard-ready focus states.\n" - + "Anti-generic constraints:\n" - + "\n".join([f"- {item}" for item in anti_generic]) - + "\n" - + "Design system constraints: use semantic design tokens (primary/surface/text), a unified spacing scale + radius scale, and explicit variant/state hierarchy.\n" - + f"Style anchor terms: {anchor_line_en} (must appear in visual language, typography, and component semantics).\n" - + "Pre-delivery validation tests:\n" - + "\n".join([f"- {item}" for item in validation_tests]) - + "\n" - + "Anti-pattern blacklist:\n" - + "\n".join([f"- {item}" for item in anti_patterns]) - + "\n" - + "Output semantic structure, responsive layout, and full interaction states (hover/active/focus-visible/disabled)." - ) - blend_hint = blend_directive(blend_plan, lang) - if blend_hint: - hard = hard + "\n\n" + blend_hint - - soft = ( - f"Generate a beautiful and production-feasible frontend concept in StyleKit style `{style.get('slug')}`.\n" - f"Requirement: {query}\n" - "Preserve the style DNA (color, hierarchy, rhythm, interaction feedback) while allowing creative layout variation.\n" - + "Design intent:\n" - + f"- Purpose: {intent.get('purpose')}\n" - + f"- Audience: {intent.get('audience')}\n" - + f"- Tone: {intent.get('tone')}\n" - + refine_block - + reference_block - + "Priority rules:\n" - + "\n".join([f"- {rule}" for rule in ai_rules[:4]]) - + f"\nStack hint: {stack_hint}\n" - + "Suggested components: button, card, input, plus at least two of nav/hero/footer.\n" - + "Accessibility minimum: WCAG contrast target (e.g. 4.5:1) and 44x44px touch-target baseline.\n" - + "Prefer tokenized implementation: semantic tokens, spacing/radius scales, and explicit variants/states.\n" - + f"Style anchor terms: {anchor_line_en}.\n" - + "Run swap/squint/signature/token tests before final output and avoid anti-patterns (absolute layout, nested scroll, missing focus styles)." - ) - blend_hint = blend_directive(blend_plan, lang) - if blend_hint: - soft = soft + "\n" + blend_hint - - return hard, soft - +# --------------------------------------------------------------------------- +# CLI entry +# --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser(description="Generate StyleKit design brief and prompts") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument("--query", required=True, help="User requirement") parser.add_argument("--style", help="Force style slug") + parser.add_argument("--site-type", default="auto", choices=["auto", *SITE_TYPES], help="Site-type routing hint") parser.add_argument("--stack", default="html-tailwind", choices=list(STACK_HINTS.keys())) parser.add_argument("--mode", default="brief+prompt", choices=["brief-only", "brief+prompt"]) + parser.add_argument( + "--recommendation-mode", default="hybrid", choices=RECOMMENDATION_MODE_CHOICES, + help="hybrid = rules first then LLM polish; rules = deterministic routing only", + ) + parser.add_argument("--content-depth", default="skeleton", choices=CONTENT_DEPTH_CHOICES) + parser.add_argument("--decision-speed", default="fast", choices=DECISION_SPEED_CHOICES) parser.add_argument("--blend-mode", default="auto", choices=["off", "auto", "on"], help="Style blend planning mode") parser.add_argument( - "--refine-mode", - default="new", - choices=sorted(REFINE_MODE_HINTS.keys()), + "--refine-mode", default="new", choices=sorted(REFINE_MODE_HINTS.keys()), help="Iteration mode: new/polish/debug/contrast-fix/layout-fix/component-fill", ) + parser.add_argument( - "--reference-type", - default="none", - choices=REFERENCE_TYPES, + "--reference-type", default="none", choices=REFERENCE_TYPES, help="Optional input reference type: none/screenshot/figma/mixed", ) parser.add_argument("--reference-notes", default="", help="Optional notes describing screenshot/Figma context") parser.add_argument("--reference-file", default="", help="Optional path to reference JSON/text (screenshot/Figma analysis)") parser.add_argument("--reference-json", default="", help="Optional inline reference JSON/text") parser.add_argument( - "--strict-reference-schema", - action="store_true", + "--strict-reference-schema", action="store_true", help="Fail when reference payload has schema errors or unknown top-level fields", ) parser.add_argument("--style-type", choices=["visual", "layout", "animation"]) parser.add_argument("--catalog", default=str(CATALOG_DEFAULT)) args = parser.parse_args() + output = run( + query=args.query, + style=args.style, + site_type=args.site_type, + stack=args.stack, + mode=args.mode, + recommendation_mode=args.recommendation_mode, + content_depth=args.content_depth, + decision_speed=args.decision_speed, + blend_mode=args.blend_mode, + refine_mode=args.refine_mode, + reference_type=args.reference_type, + reference_notes=args.reference_notes, + reference_file=args.reference_file, + reference_json=args.reference_json, + strict_reference_schema=args.strict_reference_schema, + style_type=args.style_type, + catalog=args.catalog, + ) + print(json.dumps(output, ensure_ascii=False, indent=2)) + + +def run( + *, + query: str, + style: str | None = None, + site_type: str = "auto", + stack: str = "html-tailwind", + mode: str = "brief+prompt", + recommendation_mode: str = "hybrid", + content_depth: str = "skeleton", + decision_speed: str = "fast", + blend_mode: str = "auto", + refine_mode: str = "new", + reference_type: str = "none", + reference_notes: str = "", + reference_file: str = "", + reference_json: str = "", + strict_reference_schema: bool = False, + style_type: str | None = None, + catalog: str = str(CATALOG_DEFAULT), +) -> dict[str, Any]: - catalog_path = Path(args.catalog) + catalog_path = Path(catalog) if not catalog_path.exists(): raise SystemExit(f"Catalog not found: {catalog_path}") - catalog = load_json(catalog_path) - styles: list[dict[str, Any]] = catalog.get("styles", []) - if args.style_type: - styles = [s for s in styles if s.get("styleType") == args.style_type] - + catalog_data = load_json(catalog_path) + styles: list[dict[str, Any]] = catalog_data.get("styles", []) + if style_type: + styles = [s for s in styles if s.get("styleType") == style_type] if not styles: raise SystemExit("No styles available after filtering") - primary, ranked = resolve_primary_style(styles, args.query, args.style) + lang = detect_lang(query) + v2_refs = load_v2_references(REF_DIR) + site_profile = resolve_site_type(query, site_type, v2_refs["aliases"]) + route = routing_for_site_type(site_profile["site_type"], v2_refs["routing"]) + + ranked = rank_styles(styles, query) + for item in ranked: + route_adjustment, route_details = routing_adjustment_for_style( + style=item["style"], + site_type=site_profile["site_type"], + route=route, + style_map_payload=v2_refs["style_map"], + query=query, + ) + item["score"] += route_adjustment + item["route_adjustment"] = route_adjustment + item["route_details"] = route_details + ranked.sort(key=lambda x: x["score"], reverse=True) + + if style: + forced = [s for s in styles if s.get("slug") == style] + if not forced: + raise SystemExit(f"Style slug not found: {style}") + primary = forced[0] + else: + if not ranked: + raise SystemExit("No style available") + primary = ranked[0]["style"] + alternatives = [ { "slug": item["style"].get("slug"), @@ -1683,24 +540,24 @@ def main() -> None: "nameEn": item["style"].get("nameEn"), "score": round(item["score"], 4), "styleType": item["style"].get("styleType"), + "route_adjustment": round(item.get("route_adjustment", 0.0), 4), } for item in ranked if item["style"].get("slug") != primary.get("slug") ][:2] - lang = detect_lang(args.query) - raw_reference_payload = load_reference_payload(args.reference_json, args.reference_file) + raw_reference_payload = load_reference_payload(reference_json, reference_file) schema_validation = validate_reference_payload_schema( payload=raw_reference_payload, - reference_type=args.reference_type, + reference_type=reference_type, lang=lang, - strict_mode=args.strict_reference_schema, + strict_mode=strict_reference_schema, ) if not schema_validation.get("valid"): joined = "; ".join(schema_validation.get("errors", [])) raise SystemExit(f"Reference schema validation failed: {joined}") reference_payload = schema_validation.get("sanitized_payload", {}) - effective_reference_type = args.reference_type + effective_reference_type = reference_type if effective_reference_type == "none" and reference_payload: source_hint = str(reference_payload.get("source", "") or reference_payload.get("type", "")).lower() if "figma" in source_hint and any(k in source_hint for k in ["screen", "shot", "截图"]): @@ -1712,111 +569,174 @@ def main() -> None: else: effective_reference_type = "mixed" - reference_signals = normalize_reference_signals( + reference_signals_data = normalize_reference_signals( payload=reference_payload, reference_type=effective_reference_type, - reference_notes=args.reference_notes, + reference_notes=reference_notes, lang=lang, ) base_rules = extract_rules(primary.get("aiRules", ""), lang) - base_rules.extend(reference_signals.get("derived_rules", [])) + base_rules.extend(reference_signals_data.get("derived_rules", [])) ai_rules = ensure_min_rules(base_rules, primary.get("doList", []), primary.get("dontList", []), lang) ai_rules = resolve_rule_conflicts(ai_rules, lang) ai_rules = ensure_rule_floor(ai_rules, lang, min_count=3) ai_rules = resolve_rule_conflicts(ai_rules, lang) ai_rules = ensure_rule_floor(ai_rules, lang, min_count=3)[:8] - component_guidelines = build_component_guidelines(primary, lang) - interaction_rules = build_interaction_rules(ai_rules, lang) - stack_hint = STACK_HINTS.get(args.stack, STACK_HINTS["html-tailwind"])[lang] + tag_bundle = build_tag_bundle( + style=primary, + site_type=site_profile["site_type"], + query=query, + route=route, + style_map_payload=v2_refs["style_map"], + ) + + ipt_data = resolve_interaction_pattern_data( + tag_bundle, v2_refs.get("interaction_patterns"), + ) + + component_guidelines = build_component_guidelines(primary, lang, interaction_pattern_data=ipt_data) + interaction_rules = build_interaction_rules(ai_rules, lang, interaction_pattern_data=ipt_data) + stack_hint = STACK_HINTS.get(stack, STACK_HINTS["html-tailwind"])[lang] + alt_full = [item for item in ranked if item["style"].get("slug") != primary.get("slug")][:2] - auto_blend = args.blend_mode == "on" or (args.blend_mode == "auto" and len(alt_full) > 0) - blend_plan = build_blend_plan(primary, alt_full if auto_blend else [], args.query, lang) - intent = infer_design_intent(args.query, lang) + auto_blend = blend_mode == "on" or (blend_mode == "auto" and len(alt_full) > 0) + blend_plan = build_blend_plan( + primary, [item["style"] for item in alt_full] if auto_blend else [], query, lang, + ) + + style_options = [ + {"option_id": f"opt-{idx}", "slug": item["style"].get("slug"), "reason": item.get("reason", "")} + for idx, item in enumerate(ranked[:4]) + ] + + composition_plan = build_composition_plan( + site_type=site_profile["site_type"], + route=route, + tag_bundle=tag_bundle, + primary_style=primary, + alternatives=[item["style"] for item in alt_full], + blend_plan=blend_plan, + recommendation_mode=recommendation_mode, + lang=lang, + animation_profiles=v2_refs.get("animation_profiles"), + interaction_patterns=v2_refs.get("interaction_patterns"), + ) + content_plan = build_content_plan( + site_type=site_profile["site_type"], + route=route, + content_depth=content_depth, + lang=lang, + ) + decision_flow = build_decision_flow( + site_type=site_profile["site_type"], + lang=lang, + speed=decision_speed, + style_options=style_options, + stack=stack, + ) + + intent = infer_design_intent(query, lang) anti_generic = anti_generic_constraints(lang) - system_structure = design_system_structure(args.stack, lang) - refine_strategy = refine_mode_strategy(args.refine_mode, lang) + visual_direction = localized_visual_direction(primary, lang) + ds_structure = design_system_structure(stack, lang) + refine_strategy = refine_mode_strategy(refine_mode, lang) reference_guidelines = build_reference_guidelines(effective_reference_type, lang) + # --- typography strategy --- + philosophy = str(primary.get("philosophy", "")).lower() + font_hints = DISTINCTIVE_FONT_HINTS[lang] + has_distinctive = any(hint.lower() in philosophy for hint in GENERIC_FONTS) + if lang == "zh": + typography_strategy = "使用风格指定的字体策略,避免通用默认字体。" if not has_distinctive else "风格已指定字体方向,保持一致。" + else: + typography_strategy = "Follow the style's typography direction; avoid generic defaults." if not has_distinctive else "Style specifies font direction; maintain consistency." + + # --- color strategy --- + colors = primary.get("colors", {}) + color_strategy = { + "primary": colors.get("primary", ""), + "secondary": colors.get("secondary", ""), + "accent": colors.get("accent", []) if isinstance(colors.get("accent"), list) else [], + } + design_brief = { "style_choice": { - "primary": { - "slug": primary.get("slug"), - "name": primary.get("name"), - "nameEn": primary.get("nameEn"), - "styleType": primary.get("styleType"), - }, - "alternatives": alternatives, - "why": ( - "主要匹配关键词、标签和规则语义重合度。" if lang == "zh" else "Selected from strongest overlap in keywords, tags, and rule semantics." - ), + "slug": primary.get("slug"), + "name": primary.get("name"), + "nameEn": primary.get("nameEn"), + "category": primary.get("category"), + "styleType": primary.get("styleType"), }, - "visual_direction": localized_visual_direction(primary, lang), - "typography_strategy": ( - "通过标题与正文层级拉开信息节奏,保持风格统一。" if lang == "zh" else "Use clear heading/body hierarchy to maintain consistent visual rhythm." - ), - "color_strategy": { - "primary": primary.get("colors", {}).get("primary"), - "secondary": primary.get("colors", {}).get("secondary"), - "accent": primary.get("colors", {}).get("accent", []), - }, - "component_guidelines": component_guidelines, - "interaction_rules": interaction_rules, - "a11y_baseline": A11Y_BASELINE[lang], - "font_strategy_hints": DISTINCTIVE_FONT_HINTS[lang], "design_intent": intent, - "anti_generic_constraints": anti_generic, - "validation_tests": VALIDATION_TESTS[lang], - "anti_pattern_blacklist": ANTI_PATTERN_BLACKLIST[lang], - "design_system_structure": system_structure, - "refine_mode": args.refine_mode, + "refine_mode": refine_mode, "iteration_strategy": refine_strategy, "input_context": { "reference_type": effective_reference_type, - "reference_notes": args.reference_notes.strip(), - "reference_file": args.reference_file.strip(), + "reference_notes": reference_notes, + "reference_file": reference_file, "reference_payload_present": bool(reference_payload), - "reference_schema_validation": { - "valid": schema_validation.get("valid"), - "strict_mode": schema_validation.get("strict_mode"), - "errors": schema_validation.get("errors", []), - "warnings": schema_validation.get("warnings", []), - "coercions": schema_validation.get("coercions", []), - "unknown_fields": schema_validation.get("unknown_fields", []), - }, + "reference_has_signals": bool(reference_signals_data.get("has_signals")), + "reference_schema_validation": schema_validation, "reference_guidelines": reference_guidelines, - "reference_signal_summary": reference_signals.get("summary", ""), - "reference_signals": reference_signals.get("signals", {}), - "reference_derived_rules": reference_signals.get("derived_rules", []), + "reference_signal_summary": reference_signals_data.get("summary", ""), + "reference_signals": reference_signals_data.get("signals", {}), + "reference_derived_rules": reference_signals_data.get("derived_rules", []), }, + "visual_direction": visual_direction, + "typography_strategy": typography_strategy, + "font_strategy_hints": font_hints, + "anti_generic_constraints": anti_generic, + "validation_tests": VALIDATION_TESTS[lang], + "anti_pattern_blacklist": ANTI_PATTERN_BLACKLIST[lang], + "design_system_structure": ds_structure, + "site_profile": site_profile, + "tag_bundle": tag_bundle, + "composition_plan": composition_plan, + "decision_flow": decision_flow, + "content_plan": content_plan, + "color_strategy": color_strategy, + "component_guidelines": component_guidelines, + "interaction_rules": interaction_rules, + "a11y_baseline": A11Y_BASELINE[lang], "stack_hint": stack_hint, "blend_plan": blend_plan, } hard_prompt = "" soft_prompt = "" - if args.mode == "brief+prompt": + if mode == "brief+prompt": hard_prompt, soft_prompt = make_prompts( - args.query, - primary, - ai_rules, - args.stack, - lang, - blend_plan, - intent, - anti_generic, - args.refine_mode, - effective_reference_type, - args.reference_notes, - reference_signals, + query=query, + style=primary, + ai_rules=ai_rules, + stack=stack, + lang=lang, + blend_plan=blend_plan, + intent=intent, + anti_generic=anti_generic, + refine_mode=refine_mode, + reference_type=effective_reference_type, + reference_notes=reference_notes, + reference_signals=reference_signals_data, + interaction_script=composition_plan.get("ai_interaction_script", []), ) - output = { - "query": args.query, - "mode": args.mode, + return { + "query": query, + "mode": mode, "language": lang, - "style_choice": design_brief["style_choice"], + "style_choice": { + "primary": { + "slug": primary.get("slug"), + "name": primary.get("name"), + "nameEn": primary.get("nameEn"), + "styleType": primary.get("styleType"), + }, + "alternatives": alternatives, + "why": ranked[0].get("reason", "") if ranked else "", + }, "design_brief": design_brief, "ai_rules": ai_rules, "hard_prompt": hard_prompt, @@ -1824,15 +744,13 @@ def main() -> None: "candidate_rank": [ { "slug": item["style"].get("slug"), - "nameEn": item["style"].get("nameEn"), "score": round(item["score"], 4), + "reason": item.get("reason", ""), } for item in ranked[:5] ], } - print(json.dumps(output, ensure_ascii=False, indent=2)) - if __name__ == "__main__": main() diff --git a/scripts/merge_taxonomy_expansion.py b/scripts/merge_taxonomy_expansion.py new file mode 100644 index 0000000..528df83 --- /dev/null +++ b/scripts/merge_taxonomy_expansion.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Merge Gemini-generated taxonomy expansions into existing JSON files. + +Usage: + # Dry-run (validate only, no writes): + python3 scripts/merge_taxonomy_expansion.py --type animation --input gemini-output.json --dry-run + + # Apply merge: + python3 scripts/merge_taxonomy_expansion.py --type animation --input gemini-output.json + + # For interaction patterns: + python3 scripts/merge_taxonomy_expansion.py --type interaction --input gemini-output.json + +Input JSON supports optional style tag registry updates: + "new_style_tags": ["professional", {"tag": "brand-future"}] +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +SKILL_ROOT = Path(__file__).resolve().parent.parent +TAX_DIR = SKILL_ROOT / "references" / "taxonomy" +STYLE_TAG_REGISTRY = TAX_DIR / "style-tag-registry.json" +TAG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def load_json(path: Path) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def save_json(path: Path, data: dict) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f" Written: {path}") + + +def is_string_list(value: object) -> bool: + return isinstance(value, list) and all(isinstance(item, str) for item in value) + + +def is_state_coverage_map(value: object) -> bool: + if not isinstance(value, dict): + return False + for states in value.values(): + if not is_string_list(states): + return False + return True + + +def normalize_tag(value: str) -> str: + return str(value or "").strip().lower() + + +def extract_new_style_tags(input_data: dict, existing_tags: set[str], errors: list[str]) -> list[str]: + raw = input_data.get("new_style_tags", []) + if raw in (None, ""): + return [] + if not isinstance(raw, list): + errors.append("new_style_tags must be a list") + return [] + + added: list[str] = [] + for idx, item in enumerate(raw): + if isinstance(item, dict): + candidate = item.get("tag") or item.get("value") + else: + candidate = item + if not isinstance(candidate, str) or not candidate.strip(): + errors.append(f"new_style_tags[{idx}] must be a non-empty string") + continue + tag = normalize_tag(candidate) + if TAG_PATTERN.fullmatch(tag) is None: + errors.append(f"new_style_tags[{idx}] '{candidate}' must be kebab-case") + continue + if tag in existing_tags or tag in added: + continue + added.append(tag) + return added + + +def load_style_tag_registry(errors: list[str]) -> tuple[dict, set[str]]: + if not STYLE_TAG_REGISTRY.exists(): + errors.append(f"Missing style tag registry: {STYLE_TAG_REGISTRY}") + return {"schemaVersion": "2.0.0", "allowed_style_tags": []}, set() + + registry = load_json(STYLE_TAG_REGISTRY) + raw = registry.get("allowed_style_tags", []) + if not isinstance(raw, list): + errors.append("style-tag-registry: allowed_style_tags must be a list") + return registry, set() + + tags: set[str] = set() + for idx, item in enumerate(raw): + if not isinstance(item, str) or not item.strip(): + errors.append(f"style-tag-registry: allowed_style_tags[{idx}] must be a non-empty string") + continue + normalized = normalize_tag(item) + if TAG_PATTERN.fullmatch(normalized) is None: + errors.append(f"style-tag-registry: invalid tag '{item}'") + continue + tags.add(normalized) + return registry, tags + + +def apply_style_tag_registry_update(registry: dict, existing_tags: set[str], added_tags: list[str], dry_run: bool) -> None: + if not added_tags: + return + print(f" New style tags to add: {added_tags}") + if dry_run: + return + + merged = sorted(existing_tags.union(added_tags)) + registry["allowed_style_tags"] = merged + save_json(STYLE_TAG_REGISTRY, registry) + + +def merge_animation(input_data: dict, dry_run: bool) -> list[str]: + errors: list[str] = [] + schema = load_json(TAX_DIR / "tag-schema.json") + anim = load_json(TAX_DIR / "animation-profiles.v2.json") + valid_motion = set(schema["dimensions"]["motion_profile"]["values"]) + valid_sites = set(schema["dimensions"]["site_type"]["values"]) + style_tag_registry, existing_style_tags = load_style_tag_registry(errors) + + new_enums = input_data.get("new_enum_values", []) + new_profiles = input_data.get("new_profiles", {}) + new_style_tags = extract_new_style_tags(input_data, existing_style_tags, errors) + + required_fields = {"motion_profile", "intent", "trigger", "states", + "duration_range_ms", "easing", "reduced_motion_fallback", + "suitable_site_types", "anti_patterns"} + + # Register new enum values + added_enums = [] + for item in new_enums: + val = item.get("value", "") + if val and val not in valid_motion: + valid_motion.add(val) + added_enums.append(val) + + # Validate profiles + added_profiles = [] + for name, prof in new_profiles.items(): + missing = required_fields - set(prof.keys()) + if missing: + errors.append(f"Profile '{name}' missing fields: {', '.join(sorted(missing))}") + continue + mp = prof.get("motion_profile") + if mp not in valid_motion: + errors.append(f"Profile '{name}': motion_profile '{mp}' not in enum (including new values)") + continue + suitable_site_types = prof.get("suitable_site_types", []) + if not is_string_list(suitable_site_types): + errors.append(f"Profile '{name}': suitable_site_types must be a list of strings") + continue + for st in suitable_site_types: + if st not in valid_sites: + errors.append(f"Profile '{name}': site_type '{st}' not in enum") + states = prof.get("states", []) + if not is_string_list(states): + errors.append(f"Profile '{name}': states must be a list of strings") + continue + dur = prof.get("duration_range_ms", []) + if not isinstance(dur, list) or len(dur) != 2 or not all(isinstance(v, (int, float)) for v in dur): + errors.append(f"Profile '{name}': duration_range_ms must be [min, max]") + continue + if dur[0] > dur[1]: + errors.append(f"Profile '{name}': duration_range_ms min must be <= max") + if not isinstance(prof.get("anti_patterns", []), list): + errors.append(f"Profile '{name}': anti_patterns must be a list") + if name in anim.get("profiles", {}): + errors.append(f"Profile '{name}' already exists — skipping (use a different name)") + continue + added_profiles.append(name) + + print(f"\n New enum values: {added_enums or '(none)'}") + print(f" New style tags to add: {new_style_tags or '(none)'}") + print(f" New profiles to add: {len(added_profiles)}") + print(f" Validation errors: {len(errors)}") + for e in errors: + print(f" ✗ {e}") + + if errors: + print("\n ⚠ Fix errors above before merging.") + return errors + + if dry_run: + print("\n Dry-run complete. Use without --dry-run to apply.") + return errors + + # Apply + if added_enums: + schema["dimensions"]["motion_profile"]["values"].extend(added_enums) + save_json(TAX_DIR / "tag-schema.json", schema) + apply_style_tag_registry_update(style_tag_registry, existing_style_tags, new_style_tags, dry_run) + + for name in added_profiles: + anim["profiles"][name] = new_profiles[name] + save_json(TAX_DIR / "animation-profiles.v2.json", anim) + + print( + f"\n ✓ Merged {len(added_profiles)} profiles + {len(added_enums)} enum values" + f" + {len(new_style_tags)} style tags." + ) + return errors + + +def merge_interaction(input_data: dict, dry_run: bool) -> list[str]: + errors: list[str] = [] + schema = load_json(TAX_DIR / "tag-schema.json") + ipt = load_json(TAX_DIR / "interaction-patterns.v2.json") + valid_interaction = set(schema["dimensions"]["interaction_pattern"]["values"]) + valid_sites = set(schema["dimensions"]["site_type"]["values"]) + style_tag_registry, existing_style_tags = load_style_tag_registry(errors) + + new_enums = input_data.get("new_enum_values", []) + new_patterns = input_data.get("new_patterns", {}) + additions = input_data.get("existing_pattern_additions", {}) + new_style_tags = extract_new_style_tags(input_data, existing_style_tags, errors) + + required_fields = {"primary_goal", "suitable_site_types", "required_components", + "state_coverage_requirements", "accessibility_constraints", "anti_patterns"} + + added_enums = [] + for item in new_enums: + val = item.get("value", "") + if val and val not in valid_interaction: + valid_interaction.add(val) + added_enums.append(val) + + added_patterns = [] + for name, pat in new_patterns.items(): + missing = required_fields - set(pat.keys()) + if missing: + errors.append(f"Pattern '{name}' missing fields: {', '.join(sorted(missing))}") + continue + if name not in valid_interaction: + errors.append(f"Pattern '{name}' not in enum (add it to new_enum_values first)") + continue + suitable_site_types = pat.get("suitable_site_types", []) + if not is_string_list(suitable_site_types): + errors.append(f"Pattern '{name}': suitable_site_types must be a list of strings") + continue + for st in suitable_site_types: + if st not in valid_sites: + errors.append(f"Pattern '{name}': site_type '{st}' not in enum") + if not is_string_list(pat.get("required_components", [])): + errors.append(f"Pattern '{name}': required_components must be a list of strings") + continue + if not is_state_coverage_map(pat.get("state_coverage_requirements", {})): + errors.append(f"Pattern '{name}': state_coverage_requirements must be an object of string[] values") + continue + if not is_string_list(pat.get("accessibility_constraints", [])): + errors.append(f"Pattern '{name}': accessibility_constraints must be a list of strings") + continue + if not is_string_list(pat.get("anti_patterns", [])): + errors.append(f"Pattern '{name}': anti_patterns must be a list of strings") + continue + if name in ipt.get("patterns", {}): + errors.append(f"Pattern '{name}' already exists — skipping") + continue + added_patterns.append(name) + + # Validate additions to existing patterns + enriched = [] + for name, add_data in additions.items(): + if name not in ipt.get("patterns", {}): + errors.append(f"Addition target '{name}' not found in existing patterns") + continue + new_states = add_data.get("new_state_coverage", {}) + if new_states and not is_state_coverage_map(new_states): + errors.append( + f"Addition target '{name}': new_state_coverage must be an object of string[] values" + ) + continue + if not new_states: + continue + enriched.append((name, new_states)) + + print(f"\n New enum values: {added_enums or '(none)'}") + print(f" New style tags to add: {new_style_tags or '(none)'}") + print(f" New patterns to add: {len(added_patterns)}") + print(f" Existing patterns to enrich: {len(enriched)}") + print(f" Validation errors: {len(errors)}") + for e in errors: + print(f" ✗ {e}") + + if errors: + print("\n ⚠ Fix errors above before merging.") + return errors + + if dry_run: + print("\n Dry-run complete. Use without --dry-run to apply.") + return errors + + if added_enums: + schema["dimensions"]["interaction_pattern"]["values"].extend(added_enums) + save_json(TAX_DIR / "tag-schema.json", schema) + apply_style_tag_registry_update(style_tag_registry, existing_style_tags, new_style_tags, dry_run) + + for name in added_patterns: + ipt["patterns"][name] = new_patterns[name] + + for name, new_states in enriched: + existing_states = ipt["patterns"][name].get("state_coverage_requirements", {}) + existing_states.update(new_states) + ipt["patterns"][name]["state_coverage_requirements"] = existing_states + + save_json(TAX_DIR / "interaction-patterns.v2.json", ipt) + + print( + f"\n ✓ Merged {len(added_patterns)} patterns + {len(enriched)} enrichments" + f" + {len(added_enums)} enum values + {len(new_style_tags)} style tags." + ) + return errors + + +def main() -> None: + parser = argparse.ArgumentParser(description="Merge Gemini taxonomy expansions") + parser.add_argument("--type", required=True, choices=["animation", "interaction"]) + parser.add_argument("--input", required=True, help="Path to Gemini output JSON file") + parser.add_argument("--dry-run", action="store_true", help="Validate only, don't write") + args = parser.parse_args() + + input_path = Path(args.input) + if not input_path.exists(): + print(f"Input file not found: {input_path}") + sys.exit(1) + + input_data = load_json(input_path) + print(f"Loaded {input_path} ({len(json.dumps(input_data))} bytes)") + + if args.type == "animation": + errors = merge_animation(input_data, args.dry_run) + else: + errors = merge_interaction(input_data, args.dry_run) + + if errors: + sys.exit(1) + + if not args.dry_run: + print("\nRunning validation...") + from validate_taxonomy import validate + result = validate() + print(f"Taxonomy validation: {result['status']}") + if result["errors"]: + for e in result["errors"]: + print(f" ✗ {e}") + sys.exit(1) + print("✓ All clear.") + + +if __name__ == "__main__": + main() diff --git a/scripts/prompt_generator.py b/scripts/prompt_generator.py new file mode 100644 index 0000000..4147e8a --- /dev/null +++ b/scripts/prompt_generator.py @@ -0,0 +1,263 @@ +"""Hard/soft prompt generation from style data and design brief.""" + +from __future__ import annotations + +import re +from typing import Any + +from _brief_constants import ( + ANTI_PATTERN_BLACKLIST, + DEFAULT_DONT_LIST, + DEFAULT_DO_LIST, + STACK_HINTS, + VALIDATION_TESTS, + dedupe_ordered, + has_cjk, + language_filter_rules, +) +from blend_engine import blend_directive +from reference_handler import ( + build_reference_guidelines, + reference_signal_prompt_block, + refine_mode_strategy, +) + + +def build_localized_rule_list(items: list[str], lang: str, kind: str) -> list[str]: + cleaned = language_filter_rules([str(item).strip() for item in items if str(item).strip()], lang) + if cleaned: + return cleaned[:6] + if kind == "do": + return DEFAULT_DO_LIST[lang][:6] + return DEFAULT_DONT_LIST[lang][:6] + + +def style_anchor_terms(style: dict[str, Any], lang: str) -> list[str]: + keywords = [str(x).strip() for x in style.get("keywords", []) if str(x).strip()] + tags = [str(x).strip() for x in style.get("tags", []) if str(x).strip()] + + if lang == "zh": + zh_terms = [term for term in keywords + tags if has_cjk(term)] + if zh_terms: + return dedupe_ordered(zh_terms)[:5] + return dedupe_ordered(keywords + tags)[:5] + + en_terms: list[str] = [] + for term in keywords + tags: + if not has_cjk(term): + en_terms.append(term) + name_en_tokens = re.findall(r"[a-zA-Z]{3,}", str(style.get("nameEn", ""))) + slug_tokens = [tok for tok in str(style.get("slug", "")).replace("-", " ").split() if len(tok) >= 3] + en_terms.extend(name_en_tokens) + en_terms.extend(slug_tokens) + return dedupe_ordered(en_terms)[:6] + + +def make_prompts( + query: str, + style: dict[str, Any], + ai_rules: list[str], + stack: str, + lang: str, + blend_plan: dict[str, Any], + intent: dict[str, str], + anti_generic: list[str], + refine_mode: str, + reference_type: str, + reference_notes: str, + reference_signals: dict[str, Any], + interaction_script: list[str], +) -> tuple[str, str]: + stack_hint = STACK_HINTS.get(stack, STACK_HINTS["html-tailwind"])[lang] + validation_tests = VALIDATION_TESTS[lang] + anti_patterns = ANTI_PATTERN_BLACKLIST[lang] + do_list = build_localized_rule_list(style.get("doList", []), lang, kind="do") + dont_list = build_localized_rule_list(style.get("dontList", []), lang, kind="dont") + anchor_terms = style_anchor_terms(style, lang) + anchor_line_zh = "、".join(anchor_terms) if anchor_terms else style.get("slug", "") + anchor_line_en = ", ".join(anchor_terms) if anchor_terms else style.get("slug", "") + refine_strategy = refine_mode_strategy(refine_mode, lang) + reference_guidelines = build_reference_guidelines(reference_type, lang) + reference_signal_block = reference_signal_prompt_block(reference_signals, lang) + interaction_script = [str(item).strip() for item in interaction_script if str(item).strip()] + if lang == "zh": + interaction_script_block = ( + "AI 交互设计脚本:\n" + "\n".join([f"- {line}" for line in interaction_script]) + "\n\n" + if interaction_script + else "" + ) + else: + interaction_script_block = ( + "AI interaction design script:\n" + "\n".join([f"- {line}" for line in interaction_script]) + "\n\n" + if interaction_script + else "" + ) + + if lang == "zh": + refine_block = ( + f"迭代模式:{refine_strategy.get('mode')}\n" + + f"- 本轮目标:{refine_strategy.get('objective')}\n" + + "模式约束:\n" + + "\n".join([f"- {item}" for item in refine_strategy.get("constraints", [])]) + + "\n\n" + ) + reference_block = "" + if reference_guidelines: + reference_block = ( + f"参考输入类型:{reference_type}\n" + + "参考输入约束:\n" + + "\n".join([f"- {item}" for item in reference_guidelines]) + + ("\n" + f"- 参考备注:{reference_notes.strip()}" if reference_notes.strip() else "") + + "\n\n" + ) + reference_block = reference_block + reference_signal_block + else: + refine_block = ( + f"Refinement mode: {refine_strategy.get('mode')}\n" + + f"- Objective: {refine_strategy.get('objective')}\n" + + "Mode constraints:\n" + + "\n".join([f"- {item}" for item in refine_strategy.get("constraints", [])]) + + "\n\n" + ) + reference_block = "" + if reference_guidelines: + reference_block = ( + f"Reference input type: {reference_type}\n" + + "Reference handling constraints:\n" + + "\n".join([f"- {item}" for item in reference_guidelines]) + + ("\n" + f"- Reference notes: {reference_notes.strip()}" if reference_notes.strip() else "") + + "\n\n" + ) + reference_block = reference_block + reference_signal_block + + if lang == "zh": + hard = ( + f"你是高级前端设计工程师。请严格按照 StyleKit 风格 `{style.get('slug')}` 生成界面。\n" + f"需求:{query}\n\n" + + "设计意图:\n" + + f"- 目标:{intent.get('purpose')}\n" + + f"- 受众:{intent.get('audience')}\n" + + f"- 调性:{intent.get('tone')}\n" + + f"- 记忆点:{intent.get('memorable_hook')}\n\n" + + refine_block + + reference_block + + interaction_script_block + + "硬性约束:\n" + + "\n".join([f"- {rule}" for rule in ai_rules]) + + "\n\n" + + "必须遵守 Do:\n" + + "\n".join([f"- {item}" for item in do_list]) + + "\n\n" + + "必须避免 Don't:\n" + + "\n".join([f"- {item}" for item in dont_list]) + + "\n\n" + + f"技术栈约束:{stack_hint}\n" + + "组件覆盖:至少提供 button、card、input,并补充 nav、hero、footer 中至少两个。\n" + + "可访问性基线:保持 WCAG 2.1 AA(4.5:1)对比度、44x44px 触控目标,并确保键盘可达性。\n" + + "反模板化约束:\n" + + "\n".join([f"- {item}" for item in anti_generic]) + + "\n" + + "设计系统约束:使用 primary/surface/text 语义 token,统一 spacing scale 与 radius,并明确 variant/state 层级。\n" + + f"风格锚点词:{anchor_line_zh}(需在视觉语言、排版和组件语义中体现)。\n" + + "提案前校验(必须自检):\n" + + "\n".join([f"- {item}" for item in validation_tests]) + + "\n" + + "反模式禁令:\n" + + "\n".join([f"- {item}" for item in anti_patterns]) + + "\n" + + "输出要求:提供语义化结构、响应式布局、可访问状态(hover/active/focus-visible/disabled),并保持视觉一致。" + ) + blend_hint = blend_directive(blend_plan, lang) + if blend_hint: + hard = hard + "\n\n" + blend_hint + + soft = ( + f"请基于 StyleKit 风格 `{style.get('slug')}` 生成一个美观且可实现的前端方案。\n" + f"需求:{query}\n" + "保持风格核心(配色、层级、节奏、交互反馈),允许在版式和细节上做创造性调整。\n" + + "设计意图:\n" + + f"- 目标:{intent.get('purpose')}\n" + + f"- 受众:{intent.get('audience')}\n" + + f"- 调性:{intent.get('tone')}\n" + + refine_block + + reference_block + + interaction_script_block + + "优先规则:\n" + + "\n".join([f"- {rule}" for rule in ai_rules[:4]]) + + f"\n技术栈建议:{stack_hint}\n" + + "建议组件:button、card、input,并补充 nav/hero/footer 中至少两个。\n" + + "最低可访问性:WCAG 对比度目标(建议 4.5:1)与 44x44px 触控尺寸基线。\n" + + "建议采用设计 token:primary/secondary/text、spacing scale、radius、variant/state。\n" + + f"风格锚点词:{anchor_line_zh}。\n" + + "请在提交前执行替换测试、眯眼测试、签名测试、Token 测试,并避开绝对定位整页、嵌套滚动和焦点样式缺失等反模式。" + ) + blend_hint = blend_directive(blend_plan, lang) + if blend_hint: + soft = soft + "\n" + blend_hint + return hard, soft + + hard = ( + f"You are a senior frontend design engineer. Strictly implement StyleKit style `{style.get('slug')}`.\n" + f"Requirement: {query}\n\n" + + "Design intent:\n" + + f"- Purpose: {intent.get('purpose')}\n" + + f"- Audience: {intent.get('audience')}\n" + + f"- Tone: {intent.get('tone')}\n" + + f"- Memorable hook: {intent.get('memorable_hook')}\n\n" + + refine_block + + reference_block + + interaction_script_block + + "Hard constraints:\n" + + "\n".join([f"- {rule}" for rule in ai_rules]) + + "\n\n" + + "Must-do constraints:\n" + + "\n".join([f"- {item}" for item in do_list]) + + "\n\n" + + "Must-avoid constraints:\n" + + "\n".join([f"- {item}" for item in dont_list]) + + "\n\n" + + f"Stack hint: {stack_hint}\n" + + "Component coverage: include button, card, input, and at least two of nav/hero/footer.\n" + + "Accessibility baseline: maintain WCAG 2.1 AA (4.5:1) contrast, 44x44px touch targets, and keyboard-ready focus states.\n" + + "Anti-generic constraints:\n" + + "\n".join([f"- {item}" for item in anti_generic]) + + "\n" + + "Design system constraints: use semantic design tokens (primary/surface/text), a unified spacing scale + radius scale, and explicit variant/state hierarchy.\n" + + f"Style anchor terms: {anchor_line_en} (must appear in visual language, typography, and component semantics).\n" + + "Pre-delivery validation tests:\n" + + "\n".join([f"- {item}" for item in validation_tests]) + + "\n" + + "Anti-pattern blacklist:\n" + + "\n".join([f"- {item}" for item in anti_patterns]) + + "\n" + + "Output semantic structure, responsive layout, and full interaction states (hover/active/focus-visible/disabled)." + ) + blend_hint = blend_directive(blend_plan, lang) + if blend_hint: + hard = hard + "\n\n" + blend_hint + + soft = ( + f"Generate a beautiful and production-feasible frontend concept in StyleKit style `{style.get('slug')}`.\n" + f"Requirement: {query}\n" + "Preserve the style DNA (color, hierarchy, rhythm, interaction feedback) while allowing creative layout variation.\n" + + "Design intent:\n" + + f"- Purpose: {intent.get('purpose')}\n" + + f"- Audience: {intent.get('audience')}\n" + + f"- Tone: {intent.get('tone')}\n" + + refine_block + + reference_block + + interaction_script_block + + "Priority rules:\n" + + "\n".join([f"- {rule}" for rule in ai_rules[:4]]) + + f"\nStack hint: {stack_hint}\n" + + "Suggested components: button, card, input, plus at least two of nav/hero/footer.\n" + + "Accessibility minimum: WCAG contrast target (e.g. 4.5:1) and 44x44px touch-target baseline.\n" + + "Prefer tokenized implementation: semantic tokens, spacing/radius scales, and explicit variants/states.\n" + + f"Style anchor terms: {anchor_line_en}.\n" + + "Run swap/squint/signature/token tests before final output and avoid anti-patterns (absolute layout, nested scroll, missing focus styles)." + ) + blend_hint = blend_directive(blend_plan, lang) + if blend_hint: + soft = soft + "\n" + blend_hint + + return hard, soft diff --git a/scripts/propose_upgrade.py b/scripts/propose_upgrade.py new file mode 100644 index 0000000..97f222a --- /dev/null +++ b/scripts/propose_upgrade.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Generate manual-review upgrade proposal from pipeline output.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import sys +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from _common import load_json, now_iso +from v2_taxonomy import build_upgrade_candidates + +SCRIPT_DIR = Path(__file__).resolve().parent +SKILL_ROOT = SCRIPT_DIR.parent +DEFAULT_OUT_DIR = SKILL_ROOT / "tmp" / "upgrade-proposals" + + + +def candidate_slug(value: str) -> str: + raw = "".join(ch for ch in str(value or "") if ch.isalnum() or ch in ("-", "_")) + return raw[:40] or "candidate" + + +def to_markdown(payload: dict[str, Any]) -> str: + lines = [ + "# Upgrade Proposal", + f"- Status: {payload.get('status')}", + f"- Source: {payload.get('source_file')}", + f"- Candidate count: {len(payload.get('candidates', []))}", + "", + ] + for idx, item in enumerate(payload.get("candidates", []), start=1): + lines.extend( + [ + f"## {idx}. {item.get('candidate_id')}", + f"- Summary: {item.get('summary')}", + f"- Site type: {(item.get('evidence', {}) or {}).get('site_type')}", + f"- Selected style: {(item.get('evidence', {}) or {}).get('selected_style')}", + f"- Violations: {', '.join((item.get('evidence', {}) or {}).get('violation_ids', [])) or '(none)'}", + "- Proposed changes:", + ] + ) + for change in item.get("proposed_changes", []): + lines.append(f" - {change.get('action')} -> {change.get('target')}") + lines.append("- Required gates:") + for gate in item.get("required_gates", []): + lines.append(f" - {gate}") + lines.append("") + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Propose v2 taxonomy upgrades from pipeline output") + parser.add_argument("--pipeline-output", required=True, help="Path to run_pipeline output JSON") + parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR), help="Directory for candidate JSON output") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--format", choices=["json", "markdown"], default="json") + args = parser.parse_args() + + source_path = Path(args.pipeline_output) + if not source_path.exists(): + raise SystemExit(f"Pipeline output file not found: {source_path}") + + payload = load_json(source_path) + if not isinstance(payload, dict): + raise SystemExit(f"Expected JSON object in {source_path}") + query = str(payload.get("query", "")).strip() + selected_style = str(payload.get("selected_style", "")).strip() + + site_profile = payload.get("site_profile", {}) or payload.get("result", {}).get("site_profile", {}) or {} + tag_bundle = payload.get("tag_bundle", {}) or payload.get("result", {}).get("tag_bundle", {}) or {} + quality_gate = payload.get("quality_gate", {}) or {} + + candidates = payload.get("upgrade_candidates") + if not isinstance(candidates, list) or not candidates: + candidates = build_upgrade_candidates( + query=query, + site_type=site_profile.get("site_type", "general"), + selected_style=selected_style, + tag_bundle=tag_bundle, + quality_gate=quality_gate, + ) + + out_payload: dict[str, Any] = { + "schemaVersion": "2.0.0", + "generatedAt": now_iso(), + "source_file": str(source_path), + "status": "no-op" if not candidates else "proposed", + "candidates": candidates, + } + + output_path = "" + if candidates and not args.dry_run: + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + slug = candidate_slug(selected_style or site_profile.get("site_type", "general")) + filename = f"upgrade-{now_iso().replace(':', '').replace('-', '')}-{slug}.json" + output_file = out_dir / filename + output_file.write_text(json.dumps(out_payload, ensure_ascii=False, indent=2), encoding="utf-8") + output_path = str(output_file) + + if output_path: + out_payload["output_file"] = output_path + + if args.format == "markdown": + print(to_markdown(out_payload)) + else: + print(json.dumps(out_payload, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/qa_prompt.py b/scripts/qa_prompt.py index f331c1b..1b64a1b 100644 --- a/scripts/qa_prompt.py +++ b/scripts/qa_prompt.py @@ -9,7 +9,12 @@ from pathlib import Path from typing import Any -from search_stylekit import load_json, normalize_text, tokenize +import sys +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from _common import RULE_STOPWORDS, __version__, load_json, normalize_text, tokenize SCRIPT_DIR = Path(__file__).resolve().parent SKILL_ROOT = SCRIPT_DIR.parent @@ -89,54 +94,7 @@ "mixed": ["screenshot", "截图", "figma", "frame", "设计稿", "视觉参考"], } -RULE_STOPWORDS = { - "use", - "using", - "must", - "should", - "ensure", - "keep", - "add", - "set", - "avoid", - "do", - "not", - "the", - "and", - "for", - "with", - "from", - "that", - "this", - "your", - "you", - "to", - "in", - "on", - "of", - "at", - "by", - "as", - "be", - "is", - "are", - "使用", - "添加", - "加入", - "保持", - "确保", - "避免", - "禁止", - "不要", - "需要", - "并", - "和", - "与", - "在", - "到", - "及", - "或", -} + CJK_RE = re.compile(r"[\u4e00-\u9fff]") RADIUS_TOKEN_RE = re.compile(r"\brounded(?:-[a-z0-9]+)?\b", re.IGNORECASE) SHADOW_TOKEN_RE = re.compile(r"\bshadow(?:-[a-z0-9\[\]_/.-]+)?\b", re.IGNORECASE) @@ -351,6 +309,7 @@ def find_style(catalog: dict[str, Any], slug: str | None) -> dict[str, Any] | No def main() -> None: parser = argparse.ArgumentParser(description="Audit and gate prompt quality") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument("--input", help="Prompt file path") parser.add_argument("--text", help="Inline prompt text") parser.add_argument("--prompt-field", default="hard_prompt", help="Preferred JSON field when input is a JSON object") @@ -374,9 +333,36 @@ def main() -> None: parser.add_argument("--catalog", default=str(CATALOG_DEFAULT)) parser.add_argument("--min-ai-rules", type=int, default=3) args = parser.parse_args() + payload = run( + input_path=args.input, + text=args.text, + prompt_field=args.prompt_field, + lang=args.lang, + require_refine_mode=args.require_refine_mode, + require_reference_type=args.require_reference_type, + require_reference_signals=args.require_reference_signals, + style=args.style, + catalog=args.catalog, + min_ai_rules=args.min_ai_rules, + ) + print(json.dumps(payload, ensure_ascii=False, indent=2)) - text, source_meta = read_prompt_text(args.input, args.text, args.prompt_field) - normalized = normalize_text(text) + +def run( + *, + input_path: str | None = None, + text: str | None = None, + prompt_field: str = "hard_prompt", + lang: str | None = None, + require_refine_mode: str | None = None, + require_reference_type: str | None = None, + require_reference_signals: bool = False, + style: str | None = None, + catalog: str = str(CATALOG_DEFAULT), + min_ai_rules: int = 3, +) -> dict[str, Any]: + text_content, source_meta = read_prompt_text(input_path, text, prompt_field) + normalized = normalize_text(text_content) checks = [] # 1) Non-empty prompt @@ -391,13 +377,13 @@ def main() -> None: ) # 2) Minimum actionable rules - bullet_rules = extract_bullet_rules(text) + bullet_rules = extract_bullet_rules(text_content) checks.append( { "id": "min_actionable_rules", "severity": "high", - "passed": len(bullet_rules) >= args.min_ai_rules, - "message": f"Prompt should contain at least {args.min_ai_rules} actionable bullet rules.", + "passed": len(bullet_rules) >= min_ai_rules, + "message": f"Prompt should contain at least {min_ai_rules} actionable bullet rules.", "details": {"found": len(bullet_rules)}, } ) @@ -427,9 +413,9 @@ def main() -> None: ) # 2.2) Language consistency (hard check) - expected_lang = infer_expected_lang(text, args.lang) - cjk_count = len(CJK_RE.findall(text)) - ascii_word_count = len(re.findall(r"[a-zA-Z]{2,}", text)) + expected_lang = infer_expected_lang(text_content, lang) + cjk_count = len(CJK_RE.findall(text_content)) + ascii_word_count = len(re.findall(r"[a-zA-Z]{2,}", text_content)) if expected_lang == "en": lang_passed = cjk_count == 0 else: @@ -450,7 +436,7 @@ def main() -> None: ) # 3) Component coverage - lower = text.lower() + lower = text_content.lower() core_components = ["button", "card", "input"] secondary_components = ["nav", "hero", "footer", "导航", "首屏", "页脚"] core_hits = [c for c in core_components if c in lower] @@ -591,7 +577,7 @@ def main() -> None: ) # 4.6) Refinement mode alignment (optional requirement) - refine_mode = args.require_refine_mode + refine_mode = require_refine_mode if refine_mode: refine_keywords = REFINE_MODE_KEYWORDS.get(refine_mode, []) refine_hits = contains_any(lower, refine_keywords) @@ -613,7 +599,7 @@ def main() -> None: ) # 4.7) Reference input handling (optional requirement) - reference_type = args.require_reference_type + reference_type = require_reference_type if reference_type: if reference_type == "none": ref_passed = True @@ -659,7 +645,7 @@ def main() -> None: ) # 4.8) Reference signal extraction block (optional requirement) - if args.require_reference_signals: + if require_reference_signals: signal_block_hits = contains_any( lower, [ @@ -688,14 +674,14 @@ def main() -> None: ) # 5) Style identity and conflict checks (optional) - catalog_path = Path(args.catalog) + catalog_path = Path(catalog) style_data = None - if catalog_path.exists() and args.style: - catalog = load_json(catalog_path) - style_data = find_style(catalog, args.style) + if catalog_path.exists() and style: + catalog_data = load_json(catalog_path) + style_data = find_style(catalog_data, style) if style_data: - prompt_tokens = set(tokenize(text)) + prompt_tokens = set(tokenize(text_content)) style_tokens = set(tokenize(" ".join(style_data.get("keywords", []) + style_data.get("tags", [])))) overlap = sorted(prompt_tokens & style_tokens) @@ -710,7 +696,7 @@ def main() -> None: ) possible_conflicts = [] - norm_text = text.lower() + norm_text = text_content.lower() for rule in style_data.get("dontList", [])[:20]: target = rule.lower().strip() if len(target) < 6: @@ -777,20 +763,20 @@ def main() -> None: "violations": failed, "autofix_suggestions": suggestions, "meta": { - "style": args.style, - "expected_lang": infer_expected_lang(text, args.lang), - "min_ai_rules": args.min_ai_rules, - "prompt_length": len(text), + "style": style, + "expected_lang": infer_expected_lang(text_content, lang), + "min_ai_rules": min_ai_rules, + "prompt_length": len(text_content), "source_kind": source_meta.get("source_kind"), "source_field": source_meta.get("source_field"), - "prompt_field_preferred": args.prompt_field, - "required_refine_mode": args.require_refine_mode, - "required_reference_type": args.require_reference_type, - "require_reference_signals": args.require_reference_signals, + "prompt_field_preferred": prompt_field, + "required_refine_mode": require_refine_mode, + "required_reference_type": require_reference_type, + "require_reference_signals": require_reference_signals, }, } - print(json.dumps(payload, ensure_ascii=False, indent=2)) + return payload if __name__ == "__main__": diff --git a/scripts/reference_handler.py b/scripts/reference_handler.py new file mode 100644 index 0000000..c50962e --- /dev/null +++ b/scripts/reference_handler.py @@ -0,0 +1,336 @@ +"""Reference input handling: payload loading, validation, and signal extraction.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from _brief_constants import ( + REFERENCE_FIELD_ALIASES, + REFERENCE_GUIDELINES, + REFERENCE_KNOWN_TOP_LEVEL, + REFERENCE_SECTION_KEYS, + REFINE_MODE_HINTS, + dedupe_ordered, + to_text_list, +) + + +def build_reference_guidelines(reference_type: str, lang: str) -> list[str]: + if reference_type == "none": + return [] + if reference_type == "mixed": + combined = REFERENCE_GUIDELINES["screenshot"][lang] + REFERENCE_GUIDELINES["figma"][lang] + return dedupe_ordered(combined)[:6] + if reference_type in REFERENCE_GUIDELINES: + return REFERENCE_GUIDELINES[reference_type][lang][:6] + return [] + + +def refine_mode_strategy(refine_mode: str, lang: str) -> dict[str, Any]: + mode = refine_mode if refine_mode in REFINE_MODE_HINTS else "new" + payload = REFINE_MODE_HINTS[mode][lang] + return { + "mode": mode, + "objective": payload["objective"], + "constraints": payload["constraints"][:6], + } + + +def merge_reference_payload(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]: + merged = dict(base) + for key, value in incoming.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = merge_reference_payload(merged[key], value) + elif key in merged and isinstance(merged[key], list) and isinstance(value, list): + merged[key] = merged[key] + value + else: + merged[key] = value + return merged + + +def load_reference_payload(reference_json: str, reference_file: str) -> dict[str, Any]: + payload: dict[str, Any] = {} + + if reference_file.strip(): + path = Path(reference_file.strip()) + if not path.exists(): + raise SystemExit(f"Reference file not found: {path}") + text = path.read_text(encoding="utf-8").strip() + if text: + try: + loaded = json.loads(text) + if isinstance(loaded, dict): + payload = merge_reference_payload(payload, loaded) + else: + payload = merge_reference_payload(payload, {"notes": str(loaded)}) + except json.JSONDecodeError: + payload = merge_reference_payload(payload, {"notes": text}) + + if reference_json.strip(): + text = reference_json.strip() + try: + loaded = json.loads(text) + if isinstance(loaded, dict): + payload = merge_reference_payload(payload, loaded) + else: + payload = merge_reference_payload(payload, {"notes": str(loaded)}) + except json.JSONDecodeError: + payload = merge_reference_payload(payload, {"notes": text}) + + return payload + + +def validate_reference_payload_schema( + payload: dict[str, Any], + reference_type: str, + lang: str, + strict_mode: bool, +) -> dict[str, Any]: + warnings: list[str] = [] + errors: list[str] = [] + coercions: list[str] = [] + unknown_fields: list[str] = [] + + if payload and not isinstance(payload, dict): + errors.append("reference payload must be a JSON object") + payload = {} + + sanitized = dict(payload or {}) + + for section in REFERENCE_SECTION_KEYS: + if section not in sanitized: + continue + value = sanitized.get(section) + if isinstance(value, dict): + continue + if section == "tokens": + coerced_values = to_text_list(value) + if coerced_values: + sanitized[section] = {"values": coerced_values} + coercions.append(section) + warnings.append(f"coerced `{section}` to object with `values` list") + continue + errors.append(f"`{section}` must be an object or list-like value") + continue + + coerced_values = to_text_list(value) + if coerced_values: + sanitized[section] = {"issues": coerced_values} + coercions.append(section) + warnings.append(f"coerced `{section}` to object with `issues` list") + else: + errors.append(f"`{section}` must be an object or list-like value") + + for meta_key in ("source", "type"): + if meta_key in sanitized and not isinstance(sanitized.get(meta_key), str): + coerced = " ".join(to_text_list(sanitized.get(meta_key))).strip() + if coerced: + sanitized[meta_key] = coerced + coercions.append(meta_key) + warnings.append(f"coerced `{meta_key}` to string") + else: + errors.append(f"`{meta_key}` must be a string") + + for key in sanitized.keys(): + if key not in REFERENCE_KNOWN_TOP_LEVEL: + unknown_fields.append(key) + + if unknown_fields: + sample = ", ".join(sorted(unknown_fields)[:6]) + warnings.append(f"unknown top-level fields detected: {sample}") + + source_hint = str(sanitized.get("source", "") or sanitized.get("type", "")).lower() + if reference_type in {"screenshot", "figma"} and source_hint: + if reference_type == "screenshot" and "figma" in source_hint: + warnings.append("reference_type is screenshot but source/type suggests figma") + if reference_type == "figma" and any(token in source_hint for token in ["screen", "shot", "截图"]): + warnings.append("reference_type is figma but source/type suggests screenshot") + + if strict_mode and (errors or unknown_fields): + if errors: + errors.append("strict schema mode blocks invalid reference payload") + if unknown_fields: + errors.append("strict schema mode blocks unknown top-level fields") + + valid = len(errors) == 0 + return { + "valid": valid, + "strict_mode": strict_mode, + "errors": dedupe_ordered(errors), + "warnings": dedupe_ordered(warnings), + "coercions": dedupe_ordered(coercions), + "unknown_fields": sorted(set(unknown_fields)), + "sanitized_payload": sanitized, + } + + +def get_alias_values(payload: dict[str, Any], aliases: list[str]) -> list[str]: + out: list[str] = [] + for key in aliases: + if key in payload: + out.extend(to_text_list(payload.get(key))) + return dedupe_ordered(out) + + +def normalize_reference_signals( + payload: dict[str, Any], + reference_type: str, + reference_notes: str, + lang: str, +) -> dict[str, Any]: + if not payload and not reference_notes.strip(): + return { + "has_signals": False, + "source": reference_type, + "summary": "", + "signals": { + "layout_issues": [], + "missing_components": [], + "preserve_elements": [], + "interaction_gaps": [], + "a11y_gaps": [], + "token_clues": [], + "notes": [], + }, + "derived_rules": [], + } + + layout_block = payload.get("layout") if isinstance(payload.get("layout"), dict) else {} + component_block = payload.get("components") if isinstance(payload.get("components"), dict) else {} + interaction_block = payload.get("interaction") if isinstance(payload.get("interaction"), dict) else {} + a11y_block = payload.get("accessibility") if isinstance(payload.get("accessibility"), dict) else {} + token_block = payload.get("tokens") if isinstance(payload.get("tokens"), dict) else {} + + layout_issues = get_alias_values(payload, REFERENCE_FIELD_ALIASES["layout_issues"]) + layout_issues.extend(get_alias_values(layout_block, ["issues", "problem", "problems", "gaps"])) + + missing_components = get_alias_values(payload, REFERENCE_FIELD_ALIASES["missing_components"]) + missing_components.extend(get_alias_values(component_block, ["missing", "gaps"])) + + preserve_elements = get_alias_values(payload, REFERENCE_FIELD_ALIASES["preserve_elements"]) + preserve_elements.extend(get_alias_values(layout_block, ["preserve", "keep"])) + preserve_elements.extend(get_alias_values(component_block, ["preserve", "keep"])) + + interaction_gaps = get_alias_values(payload, REFERENCE_FIELD_ALIASES["interaction_gaps"]) + interaction_gaps.extend(get_alias_values(interaction_block, ["missing_states", "gaps", "issues"])) + + a11y_gaps = get_alias_values(payload, REFERENCE_FIELD_ALIASES["a11y_gaps"]) + a11y_gaps.extend(get_alias_values(a11y_block, ["issues", "gaps", "missing"])) + + token_clues = get_alias_values(payload, REFERENCE_FIELD_ALIASES["token_clues"]) + token_clues.extend(get_alias_values(token_block, ["colors", "spacing", "typography", "radius", "shadows"])) + + notes = get_alias_values(payload, REFERENCE_FIELD_ALIASES["notes"]) + if reference_notes.strip(): + notes.append(reference_notes.strip()) + + layout_issues = dedupe_ordered(layout_issues)[:5] + missing_components = dedupe_ordered(missing_components)[:5] + preserve_elements = dedupe_ordered(preserve_elements)[:5] + interaction_gaps = dedupe_ordered(interaction_gaps)[:5] + a11y_gaps = dedupe_ordered(a11y_gaps)[:5] + token_clues = dedupe_ordered(token_clues)[:6] + notes = dedupe_ordered(notes)[:3] + + derived_rules: list[str] = [] + if lang == "zh": + derived_rules.extend([f"修复参考输入中的布局问题:{item}。" for item in layout_issues[:3]]) + derived_rules.extend([f"补齐缺失组件/状态:{item}。" for item in missing_components[:2]]) + derived_rules.extend([f"保留既有结构要素:{item}。" for item in preserve_elements[:2]]) + derived_rules.extend([f"补全交互缺口:{item}。" for item in interaction_gaps[:2]]) + derived_rules.extend([f"修复可访问性缺口:{item}。" for item in a11y_gaps[:2]]) + if token_clues: + derived_rules.append(f"参考 token 线索并映射到语义 token:{';'.join(token_clues[:4])}。") + else: + derived_rules.extend([f"Fix layout issue from reference input: {item}." for item in layout_issues[:3]]) + derived_rules.extend([f"Fill missing component/state: {item}." for item in missing_components[:2]]) + derived_rules.extend([f"Preserve existing structural element: {item}." for item in preserve_elements[:2]]) + derived_rules.extend([f"Close interaction gap: {item}." for item in interaction_gaps[:2]]) + derived_rules.extend([f"Fix accessibility gap: {item}." for item in a11y_gaps[:2]]) + if token_clues: + derived_rules.append(f"Map reference token clues to semantic tokens: {'; '.join(token_clues[:4])}.") + + summary_parts = [] + if layout_issues: + summary_parts.append(f"layout:{len(layout_issues)}") + if missing_components: + summary_parts.append(f"components:{len(missing_components)}") + if preserve_elements: + summary_parts.append(f"preserve:{len(preserve_elements)}") + if interaction_gaps: + summary_parts.append(f"interaction:{len(interaction_gaps)}") + if a11y_gaps: + summary_parts.append(f"a11y:{len(a11y_gaps)}") + if token_clues: + summary_parts.append(f"tokens:{len(token_clues)}") + summary = ", ".join(summary_parts) + + signals = { + "layout_issues": layout_issues, + "missing_components": missing_components, + "preserve_elements": preserve_elements, + "interaction_gaps": interaction_gaps, + "a11y_gaps": a11y_gaps, + "token_clues": token_clues, + "notes": notes, + } + has_signals = any(bool(value) for value in signals.values()) + + return { + "has_signals": has_signals, + "source": reference_type, + "summary": summary, + "signals": signals, + "derived_rules": dedupe_ordered(derived_rules)[:8], + } + + +def reference_signal_prompt_block(reference_signals: dict[str, Any], lang: str) -> str: + if not reference_signals.get("has_signals"): + return "" + + sig = reference_signals.get("signals", {}) + layout_issues = sig.get("layout_issues", []) + missing_components = sig.get("missing_components", []) + preserve_elements = sig.get("preserve_elements", []) + interaction_gaps = sig.get("interaction_gaps", []) + a11y_gaps = sig.get("a11y_gaps", []) + token_clues = sig.get("token_clues", []) + notes = sig.get("notes", []) + + if lang == "zh": + lines = ["参考信号提取:"] + if layout_issues: + lines.append(f"- 布局问题:{';'.join(layout_issues[:3])}") + if missing_components: + lines.append(f"- 缺失组件:{';'.join(missing_components[:3])}") + if preserve_elements: + lines.append(f"- 保留要素:{';'.join(preserve_elements[:3])}") + if interaction_gaps: + lines.append(f"- 交互缺口:{';'.join(interaction_gaps[:3])}") + if a11y_gaps: + lines.append(f"- 可访问性缺口:{';'.join(a11y_gaps[:3])}") + if token_clues: + lines.append(f"- Token 线索:{';'.join(token_clues[:4])}") + if notes: + lines.append(f"- 备注:{';'.join(notes[:2])}") + return "\n".join(lines) + "\n\n" + + lines = ["Reference signal extraction:"] + if layout_issues: + lines.append(f"- Layout issues: {'; '.join(layout_issues[:3])}") + if missing_components: + lines.append(f"- Missing components: {'; '.join(missing_components[:3])}") + if preserve_elements: + lines.append(f"- Preserve elements: {'; '.join(preserve_elements[:3])}") + if interaction_gaps: + lines.append(f"- Interaction gaps: {'; '.join(interaction_gaps[:3])}") + if a11y_gaps: + lines.append(f"- Accessibility gaps: {'; '.join(a11y_gaps[:3])}") + if token_clues: + lines.append(f"- Token clues: {'; '.join(token_clues[:4])}") + if notes: + lines.append(f"- Notes: {'; '.join(notes[:2])}") + return "\n".join(lines) + "\n\n" diff --git a/scripts/review_upgrade_candidate.py b/scripts/review_upgrade_candidate.py new file mode 100644 index 0000000..1cf084b --- /dev/null +++ b/scripts/review_upgrade_candidate.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Validate upgrade proposal files before creating a PR.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import sys +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from _common import load_json as _load_json + +ALLOWED_TARGETS = { + "references/taxonomy/style-tag-map.v2.json", + "references/taxonomy/site-type-routing.json", +} + +REQUIRED_GATE_SNIPPETS = ( + "python3 scripts/smoke_test.py", + "bash scripts/ci_regression_gate.sh", +) + + +def load_json(path: Path) -> dict[str, Any]: + payload = _load_json(path) + if not isinstance(payload, dict): + raise SystemExit("Candidate file must contain a JSON object") + return payload + + +def extract_candidates(payload: dict[str, Any]) -> list[dict[str, Any]]: + if isinstance(payload.get("candidates"), list): + return [item for item in payload["candidates"] if isinstance(item, dict)] + if payload.get("candidate_id"): + return [payload] + return [] + + +def validate_candidate(item: dict[str, Any]) -> tuple[list[str], list[str]]: + issues: list[str] = [] + warnings: list[str] = [] + + candidate_id = str(item.get("candidate_id", "")).strip() + if not candidate_id: + issues.append("missing candidate_id") + + changes = item.get("proposed_changes", []) + if not isinstance(changes, list) or not changes: + issues.append("missing proposed_changes") + else: + for idx, change in enumerate(changes, start=1): + if not isinstance(change, dict): + issues.append(f"proposed_changes[{idx}] is not an object") + continue + target = str(change.get("target", "")).strip() + action = str(change.get("action", "")).strip() + if not target: + issues.append(f"proposed_changes[{idx}] missing target") + elif target not in ALLOWED_TARGETS: + issues.append(f"proposed_changes[{idx}] target not allowed: {target}") + if not action: + issues.append(f"proposed_changes[{idx}] missing action") + + gates = item.get("required_gates", []) + if not isinstance(gates, list) or not gates: + issues.append("missing required_gates") + else: + gate_text = "\n".join(str(g) for g in gates) + for snippet in REQUIRED_GATE_SNIPPETS: + if snippet not in gate_text: + issues.append(f"required_gates missing expected gate: {snippet}") + + evidence = item.get("evidence", {}) + if not isinstance(evidence, dict): + issues.append("evidence must be an object") + else: + if not evidence.get("site_type"): + warnings.append("evidence.site_type is empty") + if not evidence.get("selected_style"): + warnings.append("evidence.selected_style is empty") + + return issues, warnings + + +def to_markdown(report: dict[str, Any]) -> str: + lines = [ + "# Upgrade Candidate Review", + f"- Status: {report.get('status')}", + f"- Candidate count: {report.get('candidate_count')}", + f"- Issues: {len(report.get('issues', []))}", + f"- Warnings: {len(report.get('warnings', []))}", + "", + ] + if report.get("issues"): + lines.append("## Issues") + for item in report["issues"]: + lines.append(f"- {item}") + lines.append("") + if report.get("warnings"): + lines.append("## Warnings") + for item in report["warnings"]: + lines.append(f"- {item}") + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Review upgrade proposal schema and gate requirements") + parser.add_argument("--candidate", required=True, help="Path to upgrade proposal JSON") + parser.add_argument("--strict", action="store_true", help="Treat warnings as failures") + parser.add_argument("--format", choices=["json", "markdown"], default="json") + args = parser.parse_args() + + candidate_path = Path(args.candidate) + if not candidate_path.exists(): + raise SystemExit(f"Candidate file not found: {candidate_path}") + + payload = load_json(candidate_path) + candidates = extract_candidates(payload) + if not candidates: + raise SystemExit("No candidate entries found") + + issues: list[str] = [] + warnings: list[str] = [] + + for idx, item in enumerate(candidates, start=1): + item_issues, item_warnings = validate_candidate(item) + issues.extend([f"candidate[{idx}]: {msg}" for msg in item_issues]) + warnings.extend([f"candidate[{idx}]: {msg}" for msg in item_warnings]) + + status = "pass" + if issues: + status = "fail" + elif args.strict and warnings: + status = "fail" + + report = { + "status": status, + "candidate_count": len(candidates), + "issues": issues, + "warnings": warnings, + } + + if args.format == "markdown": + print(to_markdown(report)) + else: + print(json.dumps(report, ensure_ascii=False, indent=2)) + + if status != "pass": + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_pipeline.py b/scripts/run_pipeline.py index ec81239..d658dd5 100644 --- a/scripts/run_pipeline.py +++ b/scripts/run_pipeline.py @@ -5,17 +5,26 @@ import argparse import json -import os -import subprocess import sys -import tempfile from pathlib import Path from typing import Any -SCRIPT_DIR = Path(__file__).resolve().parent -SEARCH_SCRIPT = SCRIPT_DIR / "search_stylekit.py" -BRIEF_SCRIPT = SCRIPT_DIR / "generate_brief.py" -QA_SCRIPT = SCRIPT_DIR / "qa_prompt.py" +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from _common import __version__, normalize_text +from v2_taxonomy import ( + CONTENT_DEPTH_CHOICES, + DECISION_SPEED_CHOICES, + RECOMMENDATION_MODE_CHOICES, + SITE_TYPES, + build_upgrade_candidates, +) +import search_stylekit +import generate_brief +import qa_prompt as qa_prompt_mod + PRODUCT_HINTS = { "dashboard": ["dashboard", "admin", "panel", "console", "后台", "仪表盘", "控制台"], @@ -104,6 +113,507 @@ def build_style_options(search_payload: dict[str, Any], product_type: str, zh: b return options +def clamp(value: float, low: float = 0.0, high: float = 1.0) -> float: + return max(low, min(high, value)) + + +def normalize_sum(weights: dict[str, float]) -> dict[str, float]: + total = sum(max(v, 0.0) for v in weights.values()) or 1.0 + return {k: round(max(v, 0.0) / total, 4) for k, v in weights.items()} + + +def infer_decision_priorities(product_type: str, query: str, tag_bundle: dict[str, Any], zh: bool) -> dict[str, Any]: + base_by_type: dict[str, dict[str, float]] = { + "dashboard": { + "readability": 0.32, + "conversion": 0.08, + "brand_expression": 0.1, + "motion_richness": 0.08, + "layout_fitness": 0.24, + "implementation_safety": 0.18, + }, + "docs": { + "readability": 0.35, + "conversion": 0.05, + "brand_expression": 0.07, + "motion_richness": 0.06, + "layout_fitness": 0.24, + "implementation_safety": 0.23, + }, + "saas": { + "readability": 0.24, + "conversion": 0.2, + "brand_expression": 0.14, + "motion_richness": 0.1, + "layout_fitness": 0.2, + "implementation_safety": 0.12, + }, + "landing-page": { + "readability": 0.14, + "conversion": 0.31, + "brand_expression": 0.24, + "motion_richness": 0.14, + "layout_fitness": 0.1, + "implementation_safety": 0.07, + }, + "ecommerce": { + "readability": 0.16, + "conversion": 0.32, + "brand_expression": 0.18, + "motion_richness": 0.12, + "layout_fitness": 0.12, + "implementation_safety": 0.1, + }, + "portfolio": { + "readability": 0.1, + "conversion": 0.08, + "brand_expression": 0.35, + "motion_richness": 0.23, + "layout_fitness": 0.14, + "implementation_safety": 0.1, + }, + "blog": { + "readability": 0.31, + "conversion": 0.08, + "brand_expression": 0.2, + "motion_richness": 0.1, + "layout_fitness": 0.18, + "implementation_safety": 0.13, + }, + } + weights = dict( + base_by_type.get( + product_type, + { + "readability": 0.22, + "conversion": 0.16, + "brand_expression": 0.2, + "motion_richness": 0.12, + "layout_fitness": 0.18, + "implementation_safety": 0.12, + }, + ) + ) + q = normalize_text(query) + modifiers = [normalize_text(x) for x in (tag_bundle.get("modifiers", []) or [])] + + if any(k in q for k in ("readability", "readable", "可读", "信息密度", "文档", "docs")) or "readability-first" in modifiers: + weights["readability"] += 0.08 + weights["implementation_safety"] += 0.04 + if any(k in q for k in ("conversion", "cta", "注册", "购买", "转化", "checkout")) or "conversion-first" in modifiers: + weights["conversion"] += 0.1 + if any(k in q for k in ("brand", "premium", "高端", "品牌", "记忆点")) or "brand-heavy" in modifiers: + weights["brand_expression"] += 0.09 + if any(k in q for k in ("motion", "animation", "动效", "交互", "transition")): + weights["motion_richness"] += 0.08 + if any(k in q for k in ("layout", "grid", "sidebar", "布局", "网格", "结构")): + weights["layout_fitness"] += 0.08 + if product_type in {"dashboard", "docs"} and "high-contrast" in modifiers: + weights["implementation_safety"] += 0.05 + weights["readability"] += 0.05 + + normalized = normalize_sum(weights) + top_key = sorted(normalized.items(), key=lambda kv: kv[1], reverse=True)[0][0] + label_map = { + "readability": "可读性优先" if zh else "readability-first", + "conversion": "转化优先" if zh else "conversion-first", + "brand_expression": "品牌表达优先" if zh else "brand-expression-first", + "motion_richness": "动效表现优先" if zh else "motion-richness-first", + "layout_fitness": "布局结构优先" if zh else "layout-fitness-first", + "implementation_safety": "实现稳健优先" if zh else "implementation-safety-first", + } + return { + "weights": normalized, + "top_priority": top_key, + "top_priority_label": label_map.get(top_key, top_key), + } + + +def axis_score( + tags: set[str], + positives: set[str], + negatives: set[str], + base: float = 0.45, + hit_gain: float = 0.12, + miss_penalty: float = 0.1, +) -> float: + pos_hits = len(tags & positives) + neg_hits = len(tags & negatives) + raw = base + pos_hits * hit_gain - neg_hits * miss_penalty + return round(clamp(raw), 4) + + +def candidate_tag_set(candidate: dict[str, Any]) -> set[str]: + tags: set[str] = set() + preview = candidate.get("preview", {}) or {} + reason = candidate.get("reason", {}) or {} + for item in preview.get("tags", []): + key = normalize_text(item) + if key: + tags.add(key) + for item in preview.get("keywords", []): + key = normalize_text(item) + if key: + tags.add(key) + style_type = normalize_text(candidate.get("styleType")) + if style_type: + tags.add(style_type) + reason_text = normalize_text(candidate.get("reason_summary", "")) + for part in reason_text.replace(";", " ").replace(",", " ").split(): + if part: + tags.add(part) + route_details = reason.get("site_route_details", {}) or {} + for item in route_details.get("favored_hits", []): + key = normalize_text(item) + if key: + tags.add(key) + return tags + + +def build_candidate_scorecard( + candidate: dict[str, Any], + priorities: dict[str, float], + product_type: str, + zh: bool, +) -> dict[str, Any]: + tags = candidate_tag_set(candidate) + stype = normalize_text(candidate.get("styleType")) + reason = candidate.get("reason", {}) or {} + route_adj = float(reason.get("site_type_adjustment", 0.0) or 0.0) + route_fit = clamp((route_adj + 5.0) / 10.0) + + readability_fit = axis_score( + tags, + positives={"minimal", "clean", "readable", "dashboard", "docs", "corporate", "editorial", "neutral", "professional"}, + negatives={"expressive", "high-contrast", "neon", "glitch", "chaotic"}, + base=0.46, + ) + conversion_fit = axis_score( + tags, + positives={"ecommerce", "conversion", "marketing", "landing", "hero", "cta", "product", "pricing", "brand"}, + negatives={"docs", "documentation", "article", "longform"}, + base=0.44, + ) + brand_fit = axis_score( + tags, + positives={"expressive", "premium", "editorial", "retro", "modern", "glass", "luxury", "visual", "creative"}, + negatives={"generic", "plain"}, + base=0.45, + ) + motion_fit = axis_score( + tags, + positives={"expressive", "playful", "motion", "animation", "dynamic", "neon", "glass"}, + negatives={"minimal", "docs", "enterprise"}, + base=0.42, + ) + layout_fit = axis_score( + tags, + positives={"layout", "grid", "dashboard", "sidebar", "timeline", "table", "kpi", "doc"}, + negatives={"unstyled"}, + base=0.43, + ) + implementation_safety = axis_score( + tags, + positives={"clean", "minimal", "corporate", "dashboard", "docs", "readable", "responsive"}, + negatives={"glitch", "neon", "high-contrast", "anti-design"}, + base=0.48, + ) + + if stype == "layout": + layout_fit = round(clamp(layout_fit + 0.2), 4) + if product_type in {"dashboard", "docs", "saas"}: + readability_fit = round(clamp(readability_fit + 0.07), 4) + if stype == "visual" and product_type in {"landing-page", "portfolio"}: + brand_fit = round(clamp(brand_fit + 0.08), 4) + motion_fit = round(clamp(motion_fit + 0.05), 4) + + risk_penalty = 0.06 + complexity = "standard" + if "expressive" in tags or "high-contrast" in tags: + complexity = "advanced" + risk_penalty = 0.16 + elif "minimal" in tags or stype == "layout": + complexity = "beginner-friendly" + risk_penalty = 0.03 + if priorities.get("readability", 0.0) >= 0.25 and ("expressive" in tags or "high-contrast" in tags): + risk_penalty += 0.05 + + weighted = ( + readability_fit * priorities.get("readability", 0.0) + + conversion_fit * priorities.get("conversion", 0.0) + + brand_fit * priorities.get("brand_expression", 0.0) + + motion_fit * priorities.get("motion_richness", 0.0) + + layout_fit * priorities.get("layout_fitness", 0.0) + + implementation_safety * priorities.get("implementation_safety", 0.0) + ) + decision_score = round(clamp(weighted + route_fit * 0.08 - risk_penalty * 0.5), 4) + + highlights: list[str] = [] + if readability_fit >= 0.65: + highlights.append("可读性强" if zh else "strong readability") + if conversion_fit >= 0.65: + highlights.append("转化导向明确" if zh else "clear conversion orientation") + if brand_fit >= 0.65: + highlights.append("品牌辨识度高" if zh else "high brand distinctiveness") + if layout_fit >= 0.65: + highlights.append("结构组织能力强" if zh else "strong layout structure") + if motion_fit >= 0.65: + highlights.append("动效表达能力强" if zh else "strong motion expression") + if not highlights: + highlights.append("整体均衡" if zh else "balanced profile") + + tradeoffs: list[str] = [] + if risk_penalty >= 0.14: + tradeoffs.append("实现复杂度偏高" if zh else "higher implementation complexity") + if readability_fit < 0.5 and product_type in {"dashboard", "docs", "blog"}: + tradeoffs.append("信息密度场景可读性风险" if zh else "readability risk in dense-information scenarios") + if conversion_fit < 0.45 and product_type in {"landing-page", "ecommerce", "saas"}: + tradeoffs.append("转化导向偏弱" if zh else "weaker conversion orientation") + if not tradeoffs: + tradeoffs.append("无明显短板" if zh else "no obvious downside") + + return { + "slug": candidate.get("slug"), + "name": candidate.get("name"), + "nameEn": candidate.get("nameEn"), + "styleType": candidate.get("styleType"), + "decision_score": decision_score, + "route_fit": round(route_fit, 4), + "complexity": complexity, + "risk_penalty": round(risk_penalty, 4), + "score_breakdown": { + "readability_fit": readability_fit, + "conversion_fit": conversion_fit, + "brand_expression_fit": brand_fit, + "motion_fit": motion_fit, + "layout_fit": layout_fit, + "implementation_safety": implementation_safety, + }, + "highlights": highlights[:3], + "tradeoffs": tradeoffs[:3], + "reason_summary": candidate.get("reason_summary"), + } + + +def build_ai_iteration_prompts( + *, + query: str, + product_type: str, + top_slugs: list[str], + top_priority_label: str, + stack: str, + zh: bool, +) -> dict[str, str]: + s1 = top_slugs[0] if top_slugs else "" + s2 = top_slugs[1] if len(top_slugs) > 1 else s1 + if zh: + return { + "analyze_options_prompt": ( + f"请以 `{product_type}` 场景评估 `{s1}` 与 `{s2}`,重点围绕 `{top_priority_label}`。" + "输出:优点、风险、适用边界、建议取舍。" + ), + "stress_test_prompt": ( + f"请对 `{s1}` 做压力测试:信息密度、可访问性、移动端和性能四个维度逐项挑错,并给最小修复方案。" + ), + "merge_prompt": ( + f"请基于 `{s1}` 为主、`{s2}` 为辅,给出可执行的融合规则(色彩/排版/布局/动效所有权)并避免冲突。" + ), + "v1_enrichment_prompt": ( + f"请在 `{stack}` 技术栈下,把首版从骨架补全为可演示版本,必须包含 empty/loading/error/focus-visible 状态。" + ), + } + return { + "analyze_options_prompt": ( + f"Compare `{s1}` vs `{s2}` for a `{product_type}` use case with `{top_priority_label}` as the main objective. " + "Output pros, risks, boundaries, and recommendation." + ), + "stress_test_prompt": ( + f"Stress-test `{s1}` across information density, accessibility, mobile behavior, and performance; give minimal fixes." + ), + "merge_prompt": ( + f"Use `{s1}` as base and `{s2}` as support, then define conflict-free ownership for color/typography/layout/motion." + ), + "v1_enrichment_prompt": ( + f"For `{stack}`, expand the first version from skeleton to demo-ready with empty/loading/error/focus-visible states." + ), + } + + +def build_decision_matrix( + *, + search_payload: dict[str, Any], + style_options: list[dict[str, Any]], + product_type: str, + query: str, + tag_bundle: dict[str, Any], + selected_style: str | None, + stack: str, + zh: bool, +) -> dict[str, Any]: + priorities_meta = infer_decision_priorities(product_type, query, tag_bundle, zh) + priorities = priorities_meta["weights"] + option_id_by_slug = {item.get("slug"): item.get("option_id") for item in style_options} + + cards = [] + for candidate in search_payload.get("candidates", [])[:5]: + card = build_candidate_scorecard(candidate, priorities, product_type, zh) + card["option_id"] = option_id_by_slug.get(card["slug"]) + cards.append(card) + + cards.sort(key=lambda item: item.get("decision_score", 0.0), reverse=True) + if cards: + cards[0]["recommended"] = True + if len(cards) > 1: + gap = round(cards[0]["decision_score"] - cards[1]["decision_score"], 4) + else: + gap = 0.2 + + confidence = "high" if gap >= 0.12 else ("medium" if gap >= 0.06 else "low") + if selected_style: + for card in cards: + card["is_selected_style"] = card.get("slug") == selected_style + + top_slugs = [card.get("slug", "") for card in cards[:2]] + ai_prompts = build_ai_iteration_prompts( + query=query, + product_type=product_type, + top_slugs=top_slugs, + top_priority_label=priorities_meta.get("top_priority_label", ""), + stack=stack, + zh=zh, + ) + + primary = cards[0] if cards else {} + backup = cards[1] if len(cards) > 1 else {} + return { + "decision_priorities": priorities_meta, + "candidate_scorecards": cards, + "primary_recommendation": { + "slug": primary.get("slug"), + "option_id": primary.get("option_id"), + "decision_score": primary.get("decision_score"), + "confidence": confidence, + "highlights": primary.get("highlights", []), + "tradeoffs": primary.get("tradeoffs", []), + }, + "backup_recommendation": { + "slug": backup.get("slug"), + "option_id": backup.get("option_id"), + "decision_score": backup.get("decision_score"), + "highlights": backup.get("highlights", []), + "tradeoffs": backup.get("tradeoffs", []), + } + if backup + else {}, + "recommendation_gap": gap, + "quick_lock_rule": ( + "直接选择主推荐;仅当你更看重备选的单项优势时再切换。" + if zh + else "Pick primary by default; switch only when backup better matches your single top concern." + ), + "ai_iteration_prompts": ai_prompts, + } + + +def build_adaptive_decision_questions( + *, + product_type: str, + decision_matrix: dict[str, Any], + zh: bool, +) -> list[dict[str, Any]]: + priorities = (decision_matrix.get("decision_priorities", {}) or {}).get("weights", {}) or {} + sorted_axes = sorted(priorities.items(), key=lambda kv: kv[1], reverse=True) + top_axis = sorted_axes[0][0] if sorted_axes else "readability" + second_axis = sorted_axes[1][0] if len(sorted_axes) > 1 else "brand_expression" + axis_name_zh = { + "readability": "可读性", + "conversion": "转化", + "brand_expression": "品牌表达", + "motion_richness": "动效表现", + "layout_fitness": "布局结构", + "implementation_safety": "实现稳健性", + } + axis_name_en = { + "readability": "readability", + "conversion": "conversion", + "brand_expression": "brand expression", + "motion_richness": "motion richness", + "layout_fitness": "layout fitness", + "implementation_safety": "implementation safety", + } + + if zh: + questions = [ + { + "id": "primary_goal", + "question": f"这版优先保证哪一项?", + "choices": [ + f"{axis_name_zh.get(top_axis, top_axis)}优先", + f"{axis_name_zh.get(second_axis, second_axis)}优先", + "两者平衡", + ], + "recommended": f"{axis_name_zh.get(top_axis, top_axis)}优先", + }, + { + "id": "risk_tolerance", + "question": "你对实现复杂度的容忍度?", + "choices": ["低(先稳)", "中(可接受)", "高(追求表现)"], + "recommended": "中(可接受)" if product_type in {"landing-page", "portfolio"} else "低(先稳)", + }, + { + "id": "motion_intensity", + "question": "动效强度希望如何?", + "choices": ["克制", "中等", "明显"], + "recommended": "克制" if product_type in {"dashboard", "docs", "blog"} else "中等", + }, + ] + if product_type in {"landing-page", "ecommerce", "saas"}: + questions.append( + { + "id": "conversion_strategy", + "question": "转化策略更偏向哪种?", + "choices": ["强 CTA 直接驱动", "信息说服为主", "品牌先行再转化"], + "recommended": "信息说服为主" if product_type == "saas" else "强 CTA 直接驱动", + } + ) + return questions + + questions = [ + { + "id": "primary_goal", + "question": "What should be the top priority for this iteration?", + "choices": [ + f"{axis_name_en.get(top_axis, top_axis)} first", + f"{axis_name_en.get(second_axis, second_axis)} first", + "balanced", + ], + "recommended": f"{axis_name_en.get(top_axis, top_axis)} first", + }, + { + "id": "risk_tolerance", + "question": "How much implementation complexity can we tolerate?", + "choices": ["low", "medium", "high"], + "recommended": "medium" if product_type in {"landing-page", "portfolio"} else "low", + }, + { + "id": "motion_intensity", + "question": "Preferred motion intensity?", + "choices": ["minimal", "moderate", "noticeable"], + "recommended": "minimal" if product_type in {"dashboard", "docs", "blog"} else "moderate", + }, + ] + if product_type in {"landing-page", "ecommerce", "saas"}: + questions.append( + { + "id": "conversion_strategy", + "question": "Which conversion strategy should lead?", + "choices": ["CTA-led", "information-led", "brand-led then convert"], + "recommended": "information-led" if product_type == "saas" else "CTA-led", + } + ) + return questions + + def build_decision_questions(product_type: str, zh: bool) -> list[dict[str, Any]]: if zh: questions = [ @@ -161,15 +671,27 @@ def build_decision_questions(product_type: str, zh: bool) -> list[dict[str, Any] return questions -def build_next_step_templates(style_options: list[dict[str, Any]], query: str, stack: str, zh: bool) -> dict[str, Any]: - selected = style_options[0]["slug"] if style_options else "" +def build_next_step_templates( + style_options: list[dict[str, Any]], + query: str, + stack: str, + zh: bool, + site_type: str, + content_depth: str, + forced_style: str | None = None, +) -> dict[str, Any]: + selected = forced_style or (style_options[0]["slug"] if style_options else "") + command = ( + f'python scripts/run_pipeline.py --workflow codegen --query "{query}" --stack {stack} --style {selected} ' + f"--site-type {site_type} --content-depth {content_depth} --recommendation-mode hybrid --decision-speed fast --blend-mode off --format json" + ) if zh: return { - "after_user_selects_style": f'python scripts/run_pipeline.py --workflow codegen --query "{query}" --stack {stack} --style {selected} --blend-mode off --format json', + "after_user_selects_style": command, "assistant_script": "先展示 3-4 个风格选项并解释差异,用户选择后再进入代码生成。", } return { - "after_user_selects_style": f'python scripts/run_pipeline.py --workflow codegen --query "{query}" --stack {stack} --style {selected} --blend-mode off --format json', + "after_user_selects_style": command, "assistant_script": "Present 3-4 style options with trade-offs, then generate code after user selection.", } @@ -223,12 +745,42 @@ def build_manual_assistant( ) -> dict[str, Any]: design_brief = brief_payload.get("design_brief", {}) or {} design_intent = design_brief.get("design_intent", {}) or {} - style_choice = design_brief.get("style_choice", {}) or {} + style_choice = brief_payload.get("style_choice", {}) or design_brief.get("style_choice", {}) or {} + site_profile = design_brief.get("site_profile", {}) or {} + decision_flow = design_brief.get("decision_flow", {}) or brief_payload.get("decision_flow", {}) or {} + content_plan = design_brief.get("content_plan", {}) or brief_payload.get("content_plan", {}) or {} + tag_bundle = design_brief.get("tag_bundle", {}) or brief_payload.get("tag_bundle", {}) or {} + composition_plan = design_brief.get("composition_plan", {}) or brief_payload.get("composition_plan", {}) or {} zh = is_zh(query) - product_type = infer_product_type(query) + product_type = site_profile.get("site_type") or infer_product_type(query) + content_depth = content_plan.get("content_depth", "skeleton") style_options = build_style_options(search_payload, product_type=product_type, zh=zh) - decision_questions = build_decision_questions(product_type=product_type, zh=zh) - next_step_templates = build_next_step_templates(style_options=style_options, query=query, stack=stack, zh=zh) + decision_matrix = build_decision_matrix( + search_payload=search_payload, + style_options=style_options, + product_type=product_type, + query=query, + tag_bundle=tag_bundle, + selected_style=selected_style, + stack=stack, + zh=zh, + ) + decision_questions = build_adaptive_decision_questions( + product_type=product_type, + decision_matrix=decision_matrix, + zh=zh, + ) + legacy_questions = build_decision_questions(product_type=product_type, zh=zh) + primary_slug = (decision_matrix.get("primary_recommendation", {}) or {}).get("slug") + next_step_templates = build_next_step_templates( + style_options=style_options, + query=query, + stack=stack, + zh=zh, + site_type=product_type, + content_depth=content_depth, + forced_style=selected_style or primary_slug, + ) conversation_flow = build_cc_conversation_flow(zh=zh) return { @@ -236,6 +788,7 @@ def build_manual_assistant( "product_profile": { "query": query, "inferred_product_type": product_type, + "site_profile": site_profile, "user_confidence": infer_user_confidence(query), "stack": stack, "purpose": design_intent.get("purpose"), @@ -245,14 +798,21 @@ def build_manual_assistant( "decision_assistant": { "recommended_style_options": style_options, "decision_questions": decision_questions, + "legacy_decision_questions": legacy_questions, "next_step_templates": next_step_templates, "cc_conversation_flow": conversation_flow, + "decision_flow_v2": decision_flow, + "decision_matrix": decision_matrix, + "primary_recommendation": decision_matrix.get("primary_recommendation", {}), + "backup_recommendation": decision_matrix.get("backup_recommendation", {}), + "ai_iteration_prompts": decision_matrix.get("ai_iteration_prompts", {}), }, "style_recommendation": { "selected_style": selected_style, "primary": style_choice.get("primary", {}), "alternatives": style_choice.get("alternatives", []), "top_candidates": search_payload.get("candidates", [])[:5], + "tag_bundle": tag_bundle, }, "implementation_handbook": { "component_guidelines": design_brief.get("component_guidelines", []), @@ -260,37 +820,20 @@ def build_manual_assistant( "a11y_baseline": design_brief.get("a11y_baseline", []), "anti_pattern_blacklist": design_brief.get("anti_pattern_blacklist", [])[:8], "validation_tests": design_brief.get("validation_tests", []), + "content_plan": content_plan, + "composition_plan": composition_plan, + "recommended_first_style": primary_slug or selected_style, }, } -def run_json_command(cmd: list[str]) -> dict[str, Any]: - proc = subprocess.run(cmd, capture_output=True, text=True) - if proc.returncode != 0: - raise SystemExit( - "Command failed:\n" - + " ".join(cmd) - + "\n\nSTDOUT:\n" - + (proc.stdout or "") - + "\nSTDERR:\n" - + (proc.stderr or "") - ) - - out = (proc.stdout or "").strip() - if not out: - raise SystemExit(f"Empty output from command: {' '.join(cmd)}") - - try: - return json.loads(out) - except json.JSONDecodeError as exc: - preview = out[:500] - raise SystemExit( - f"Invalid JSON from command: {' '.join(cmd)}\nError: {exc}\nOutput preview:\n{preview}" - ) def to_markdown(payload: dict[str, Any]) -> str: blend = payload.get("result", {}).get("design_brief", {}).get("blend_plan", {}) + site_profile = payload.get("site_profile", {}) or payload.get("result", {}).get("site_profile", {}) + tag_bundle = payload.get("tag_bundle", {}) or payload.get("result", {}).get("tag_bundle", {}) + content_plan = payload.get("content_plan", {}) or payload.get("result", {}).get("content_plan", {}) lines = [ "# StyleKit Pipeline Result", f"- Query: {payload['query']}", @@ -302,6 +845,10 @@ def to_markdown(payload: dict[str, Any]) -> str: f"- Blend enabled: {bool(blend.get('enabled'))}", f"- Refine mode: {payload.get('refine_mode')}", f"- Reference type: {payload.get('reference_type')}", + f"- Site type: {site_profile.get('site_type', '')}", + f"- Layout archetype: {tag_bundle.get('layout_archetype', '')}", + f"- Motion profile: {tag_bundle.get('motion_profile', '')}", + f"- Content depth: {content_plan.get('content_depth', '')}", f"- Strict reference schema: {bool(payload.get('strict_reference_schema'))}", "", "## Top Candidates", @@ -366,6 +913,7 @@ def to_markdown(payload: dict[str, Any]) -> str: def main() -> None: parser = argparse.ArgumentParser(description="Run StyleKit full pipeline") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument("--query", required=True, help="User requirement") parser.add_argument( "--workflow", @@ -373,11 +921,15 @@ def main() -> None: choices=["manual", "codegen"], help="manual = handbook/knowledge mode, codegen = prompt-generation mode", ) + parser.add_argument("--site-type", default="auto", choices=["auto", *SITE_TYPES]) parser.add_argument("--stack", default="html-tailwind", choices=["html-tailwind", "react", "nextjs", "vue", "svelte", "tailwind-v4"]) parser.add_argument("--style", help="Force style slug") parser.add_argument("--style-type", choices=["visual", "layout", "animation"]) parser.add_argument("--top", type=int, default=5) parser.add_argument("--mode", default=None, choices=["brief-only", "brief+prompt"]) + parser.add_argument("--recommendation-mode", default="hybrid", choices=RECOMMENDATION_MODE_CHOICES) + parser.add_argument("--content-depth", default="skeleton", choices=CONTENT_DEPTH_CHOICES) + parser.add_argument("--decision-speed", default="fast", choices=DECISION_SPEED_CHOICES) parser.add_argument("--blend-mode", default=None, choices=["off", "auto", "on"]) parser.add_argument( "--refine-mode", @@ -393,47 +945,37 @@ def main() -> None: parser.add_argument("--format", choices=["json", "markdown"], default="json") args = parser.parse_args() - py = sys.executable resolved_mode = args.mode or ("brief-only" if args.workflow == "manual" else "brief+prompt") resolved_blend_mode = args.blend_mode or ("off" if args.workflow == "manual" else "auto") - if args.style and args.blend_mode is None: + if args.style: + # Explicit style selection always disables blending to avoid mixed ownership. resolved_blend_mode = "off" - search_cmd = [py, str(SEARCH_SCRIPT), "--query", args.query, "--top", str(args.top), "--format", "json"] - if args.style_type: - search_cmd.extend(["--style-type", args.style_type]) - search_payload = run_json_command(search_cmd) - - brief_cmd = [ - py, - str(BRIEF_SCRIPT), - "--query", - args.query, - "--stack", - args.stack, - "--mode", - resolved_mode, - "--blend-mode", - resolved_blend_mode, - "--refine-mode", - args.refine_mode, - "--reference-type", - args.reference_type, - ] - if args.reference_notes.strip(): - brief_cmd.extend(["--reference-notes", args.reference_notes.strip()]) - if args.reference_file.strip(): - brief_cmd.extend(["--reference-file", args.reference_file.strip()]) - if args.reference_json.strip(): - brief_cmd.extend(["--reference-json", args.reference_json.strip()]) - if args.strict_reference_schema: - brief_cmd.append("--strict-reference-schema") - if args.style: - brief_cmd.extend(["--style", args.style]) - if args.style_type: - brief_cmd.extend(["--style-type", args.style_type]) + search_payload = search_stylekit.run( + query=args.query, + top=args.top, + style_type=args.style_type, + site_type=args.site_type, + ) - brief_payload = run_json_command(brief_cmd) + brief_payload = generate_brief.run( + query=args.query, + style=args.style or None, + site_type=args.site_type, + stack=args.stack, + mode=resolved_mode, + recommendation_mode=args.recommendation_mode, + content_depth=args.content_depth, + decision_speed=args.decision_speed, + blend_mode=resolved_blend_mode, + refine_mode=args.refine_mode, + reference_type=args.reference_type, + reference_notes=args.reference_notes.strip(), + reference_file=args.reference_file.strip(), + reference_json=args.reference_json.strip(), + strict_reference_schema=args.strict_reference_schema, + style_type=args.style_type, + ) selected_style = args.style or (brief_payload.get("style_choice", {}).get("primary", {}) or {}).get("slug") resolved_reference_type = ( brief_payload.get("design_brief", {}).get("input_context", {}).get("reference_type") or args.reference_type @@ -441,6 +983,9 @@ def main() -> None: reference_payload_present = bool( brief_payload.get("design_brief", {}).get("input_context", {}).get("reference_payload_present") ) + reference_has_signals = bool( + brief_payload.get("design_brief", {}).get("input_context", {}).get("reference_has_signals") + ) qa_payload: dict[str, Any] if resolved_mode == "brief+prompt": @@ -448,31 +993,21 @@ def main() -> None: if not qa_input_text: qa_input_text = json.dumps(brief_payload, ensure_ascii=False) - with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".txt", delete=False) as tmp: - tmp.write(qa_input_text) - tmp_path = tmp.name - - try: - qa_cmd = [py, str(QA_SCRIPT), "--input", tmp_path, "--min-ai-rules", str(args.min_ai_rules)] - expected_lang = brief_payload.get("language") - if expected_lang in {"en", "zh"}: - qa_cmd.extend(["--lang", expected_lang]) - qa_cmd.extend(["--require-refine-mode", args.refine_mode]) - qa_cmd.extend(["--require-reference-type", resolved_reference_type]) - if reference_payload_present: - qa_cmd.append("--require-reference-signals") - if selected_style: - qa_cmd.extend(["--style", selected_style]) - qa_payload = run_json_command(qa_cmd) - finally: - try: - os.remove(tmp_path) - except OSError: - pass + expected_lang = brief_payload.get("language") + qa_payload = qa_prompt_mod.run( + text=qa_input_text, + min_ai_rules=args.min_ai_rules, + lang=expected_lang if expected_lang in {"en", "zh"} else None, + require_refine_mode=args.refine_mode, + require_reference_type=resolved_reference_type, + require_reference_signals=reference_payload_present and reference_has_signals, + style=selected_style or None, + ) else: qa_payload = { "status": "pass", "violations": [], + "autofix_suggestions": [], "warnings": [ { "code": "QA_SKIPPED_BRIEF_ONLY", @@ -480,6 +1015,18 @@ def main() -> None: } ], "checks": [], + "meta": { + "style": selected_style, + "expected_lang": brief_payload.get("language"), + "min_ai_rules": args.min_ai_rules, + "prompt_length": 0, + "source_kind": "brief-only", + "source_field": None, + "prompt_field_preferred": "hard_prompt", + "required_refine_mode": None, + "required_reference_type": resolved_reference_type, + "require_reference_signals": False, + }, } manual_assistant = build_manual_assistant( @@ -489,23 +1036,67 @@ def main() -> None: search_payload=search_payload, brief_payload=brief_payload, ) + site_profile = ( + brief_payload.get("site_profile") + or brief_payload.get("design_brief", {}).get("site_profile") + or search_payload.get("site_profile") + or {} + ) + tag_bundle = ( + brief_payload.get("tag_bundle") + or brief_payload.get("design_brief", {}).get("tag_bundle") + or {} + ) + composition_plan = ( + brief_payload.get("composition_plan") + or brief_payload.get("design_brief", {}).get("composition_plan") + or {} + ) + decision_flow = ( + brief_payload.get("decision_flow") + or brief_payload.get("design_brief", {}).get("decision_flow") + or {} + ) + content_plan = ( + brief_payload.get("content_plan") + or brief_payload.get("design_brief", {}).get("content_plan") + or {} + ) + upgrade_candidates = build_upgrade_candidates( + query=args.query, + site_type=site_profile.get("site_type", "general"), + selected_style=selected_style or "", + tag_bundle=tag_bundle, + quality_gate=qa_payload, + ) output = { + "schemaVersion": "2.0.0", "status": qa_payload.get("status"), "workflow": args.workflow, "mode": resolved_mode, "query": args.query, + "site_type": args.site_type, "stack": args.stack, "style_type_filter": args.style_type, + "recommendation_mode": args.recommendation_mode, + "content_depth": args.content_depth, + "decision_speed": args.decision_speed, "blend_mode": resolved_blend_mode, "refine_mode": args.refine_mode, "reference_type": resolved_reference_type, "strict_reference_schema": args.strict_reference_schema, "selected_style": selected_style, + "site_profile": site_profile, + "tag_bundle": tag_bundle, + "composition_plan": composition_plan, + "decision_flow": decision_flow, + "content_plan": content_plan, "candidates": search_payload.get("candidates", []), "result": brief_payload, "manual_assistant": manual_assistant, "quality_gate": qa_payload, + "upgrade_candidates": upgrade_candidates, } if args.format == "markdown": diff --git a/scripts/search_stylekit.py b/scripts/search_stylekit.py index b624727..bfdee11 100644 --- a/scripts/search_stylekit.py +++ b/scripts/search_stylekit.py @@ -11,37 +11,20 @@ from pathlib import Path from typing import Any -STOPWORDS = { - "the", - "and", - "for", - "with", - "from", - "that", - "this", - "into", - "your", - "you", - "want", - "need", - "make", - "build", - "page", - "site", - "style", - "design", - "frontend", - "ui", - "ux", - "页面", - "风格", - "设计", - "前端", - "需要", - "希望", - "一个", - "这个", -} +import sys +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from _common import STOPWORDS, __version__, load_json, normalize_text, tokenize + +from v2_taxonomy import ( + SITE_TYPES, + load_v2_references, + resolve_site_type, + routing_adjustment_for_style, + routing_for_site_type, +) QUERY_SYNONYMS = { "glass": ["glassmorphism", "frosted", "blur", "透明", "模糊", "玻璃"], @@ -323,36 +306,6 @@ INDEX_DEFAULT = REF_DIR / "style-search-index.json" -def normalize_text(value: Any) -> str: - text = str(value or "").lower() - text = re.sub(r"[^\w\u4e00-\u9fff\s-]", " ", text) - text = re.sub(r"\s+", " ", text).strip() - return text - - -def tokenize(text: str) -> list[str]: - text_norm = normalize_text(text) - tokens: list[str] = [] - - for part in re.findall(r"[\u4e00-\u9fff]+|[a-z0-9-]+", text_norm): - # Chinese token handling: keep whole phrase + bi-grams - if re.fullmatch(r"[\u4e00-\u9fff]+", part): - if len(part) >= 2 and part not in STOPWORDS: - tokens.append(part) - if len(part) >= 2: - for i in range(len(part) - 1): - gram = part[i : i + 2] - if gram not in STOPWORDS: - tokens.append(gram) - continue - - # Latin token handling - for unit in part.split("-"): - if len(unit) > 1 and unit not in STOPWORDS: - tokens.append(unit) - - return tokens - def expand_query_tokens(tokens: list[str]) -> list[str]: expanded = list(tokens) @@ -418,10 +371,6 @@ def score(self, query: str | None = None, query_tokens: list[str] | None = None) return out -def load_json(path: Path) -> dict[str, Any]: - with path.open("r", encoding="utf-8") as f: - return json.load(f) - def build_text(style: dict[str, Any]) -> str: parts: list[str] = [ @@ -653,6 +602,7 @@ def format_markdown(payload: dict[str, Any]) -> str: lines = [ "# Style Search Result", f"- Query: {payload['query']}", + f"- Site type: {payload.get('site_profile', {}).get('site_type', 'general')}", f"- Returned: {len(payload['candidates'])}", "", ] @@ -671,23 +621,47 @@ def format_markdown(payload: dict[str, Any]) -> str: def main() -> None: parser = argparse.ArgumentParser(description="Search StyleKit styles and rank candidates") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument("--query", required=True, help="User requirement or desired visual direction") parser.add_argument("--top", type=int, default=5, help="Top candidates to return") parser.add_argument("--style-type", choices=["visual", "layout", "animation"], help="Filter by style type") + parser.add_argument("--site-type", default="auto", choices=["auto", *SITE_TYPES], help="Site type routing hint") parser.add_argument("--catalog", default=str(CATALOG_DEFAULT), help="Path to style-prompts.json") parser.add_argument("--index", default=str(INDEX_DEFAULT), help="Path to style-search-index.json") parser.add_argument("--format", choices=["json", "markdown"], default="json") args = parser.parse_args() + payload = run( + query=args.query, + top=args.top, + style_type=args.style_type, + site_type=args.site_type, + catalog=args.catalog, + index=args.index, + ) + if args.format == "markdown": + print(format_markdown(payload)) + else: + print(json.dumps(payload, ensure_ascii=False, indent=2)) - catalog_path = Path(args.catalog) + +def run( + *, + query: str, + top: int = 5, + style_type: str | None = None, + site_type: str = "auto", + catalog: str = str(CATALOG_DEFAULT), + index: str = str(INDEX_DEFAULT), +) -> dict[str, Any]: + catalog_path = Path(catalog) if not catalog_path.exists(): raise SystemExit(f"Catalog not found: {catalog_path}") - catalog = load_json(catalog_path) - styles: list[dict[str, Any]] = catalog.get("styles", []) + catalog_data = load_json(catalog_path) + styles: list[dict[str, Any]] = catalog_data.get("styles", []) - if args.style_type: - styles = [s for s in styles if s.get("styleType") == args.style_type] + if style_type: + styles = [s for s in styles if s.get("styleType") == style_type] if not styles: raise SystemExit("No styles available after filtering") @@ -696,7 +670,7 @@ def main() -> None: slug_for_doc: list[str] bm25_map: dict[str, float] = {} - index_path = Path(args.index) + index_path = Path(index) if index_path.exists(): index_data = load_json(index_path) docs_data = index_data.get("documents", []) @@ -706,8 +680,11 @@ def main() -> None: docs = [build_text(s) for s in styles] slug_for_doc = [s.get("slug", "") for s in styles] - qtokens_base = tokenize(args.query) + qtokens_base = tokenize(query) qtokens = expand_query_tokens(qtokens_base) + v2_refs = load_v2_references(REF_DIR) + site_profile = resolve_site_type(query, site_type, v2_refs["aliases"]) + route = routing_for_site_type(site_profile["site_type"], v2_refs["routing"]) bm25 = BM25() bm25.fit(docs) @@ -718,9 +695,16 @@ def main() -> None: ranked = [] for style in styles: - h_score, reasons = heuristic_score(style, args.query, qtokens) + h_score, reasons = heuristic_score(style, query, qtokens) b_score = bm25_map.get(style.get("slug", ""), 0.0) - final_score = b_score * 3.0 + h_score + routing_adjustment, routing_details = routing_adjustment_for_style( + style=style, + site_type=site_profile["site_type"], + route=route, + style_map_payload=v2_refs["style_map"], + query=query, + ) + final_score = b_score * 3.0 + h_score + routing_adjustment reason_parts = [] if reasons["exact_slug"]: @@ -731,9 +715,14 @@ def main() -> None: reason_parts.append("keyword overlap") if reasons["matched_tags"]: reason_parts.append("tag overlap") + if routing_adjustment != 0: + reason_parts.append("site-type route bias") if not reason_parts: reason_parts.append("semantic overlap from style description and rules") + reasons["site_type_adjustment"] = routing_adjustment + reasons["site_route_details"] = routing_details + ranked.append( { "slug": style.get("slug"), @@ -751,25 +740,23 @@ def main() -> None: ) ranked.sort(key=lambda item: item["score"], reverse=True) - top_n = max(args.top, 1) + top_n = max(top, 1) - payload = { - "query": args.query, + return { + "query": query, "query_tokens": qtokens_base[:20], "expanded_query_tokens": qtokens[:40], "top": top_n, "returned": min(top_n, len(ranked)), - "style_type_filter": args.style_type, - "schemaVersion": catalog.get("schemaVersion", "unknown"), - "generatedAt": catalog.get("generatedAt"), + "style_type_filter": style_type, + "site_type_filter": site_type, + "site_profile": site_profile, + "schemaVersion": "2.0.0", + "catalog_schema_version": catalog_data.get("schemaVersion", "unknown"), + "generatedAt": catalog_data.get("generatedAt"), "candidates": ranked[:top_n], } - if args.format == "markdown": - print(format_markdown(payload)) - else: - print(json.dumps(payload, ensure_ascii=False, indent=2)) - if __name__ == "__main__": main() diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index 68aece7..22b7bd5 100644 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -19,6 +19,11 @@ BRIEF = SCRIPT_DIR / "generate_brief.py" QA = SCRIPT_DIR / "qa_prompt.py" PIPELINE = SCRIPT_DIR / "run_pipeline.py" +PROPOSE_UPGRADE = SCRIPT_DIR / "propose_upgrade.py" +REVIEW_UPGRADE = SCRIPT_DIR / "review_upgrade_candidate.py" +VALIDATE_TAXONOMY = SCRIPT_DIR / "validate_taxonomy.py" +MERGE_TAXONOMY = SCRIPT_DIR / "merge_taxonomy_expansion.py" +VALIDATE_CONTRACT_SYNC = SCRIPT_DIR / "validate_output_contract_sync.py" def run_json(cmd: list[str]) -> dict: @@ -43,6 +48,65 @@ def ensure(condition: bool, message: str) -> None: raise AssertionError(message) +def run_expect_fail(cmd: list[str], label: str) -> None: + """Run a command and assert it exits non-zero.""" + proc = subprocess.run(cmd, capture_output=True, text=True) + ensure(proc.returncode != 0, f"[{label}] expected failure but got exit 0") + + +def validate_brief_schema(payload: dict) -> None: + """Validate generate_brief.py output matches the output contract schema.""" + # Top-level required keys + for key in ("query", "mode", "language", "style_choice", "design_brief", "ai_rules"): + ensure(key in payload, f"Missing top-level key: {key}") + + ensure(isinstance(payload["ai_rules"], list), "ai_rules must be a list") + ensure(len(payload["ai_rules"]) >= 3, "ai_rules must have >= 3 items") + + sc = payload["style_choice"] + ensure("primary" in sc, "style_choice.primary missing") + for k in ("slug", "name", "nameEn", "styleType"): + ensure(k in sc["primary"], f"style_choice.primary.{k} missing") + + db = payload["design_brief"] + brief_required = [ + "style_choice", "design_intent", "refine_mode", "iteration_strategy", + "input_context", "visual_direction", "anti_generic_constraints", + "validation_tests", "anti_pattern_blacklist", "design_system_structure", + "site_profile", "tag_bundle", "composition_plan", "decision_flow", + "content_plan", "component_guidelines", "interaction_rules", + "a11y_baseline", "stack_hint", "blend_plan", + ] + for key in brief_required: + ensure(key in db, f"design_brief.{key} missing") + + # design_intent sub-keys + for k in ("purpose", "audience", "tone", "memorable_hook"): + ensure(k in db["design_intent"], f"design_brief.design_intent.{k} missing") + + # iteration_strategy sub-keys + for k in ("mode", "objective", "constraints"): + ensure(k in db["iteration_strategy"], f"design_brief.iteration_strategy.{k} missing") + + # input_context sub-keys + ic = db["input_context"] + for k in ("reference_type", "reference_payload_present", "reference_has_signals", "reference_schema_validation"): + ensure(k in ic, f"design_brief.input_context.{k} missing") + + # blend_plan sub-keys + bp = db["blend_plan"] + for k in ("enabled", "base_style", "blend_styles", "priority_order"): + ensure(k in bp, f"design_brief.blend_plan.{k} missing") + + # prompts when mode is brief+prompt + if payload["mode"] == "brief+prompt": + ensure(bool(payload.get("hard_prompt")), "hard_prompt empty in brief+prompt mode") + ensure(bool(payload.get("soft_prompt")), "soft_prompt empty in brief+prompt mode") + elif payload["mode"] == "brief-only": + ensure(payload.get("hard_prompt", "") == "", "hard_prompt should be empty in brief-only mode") + ensure(payload.get("soft_prompt", "") == "", "soft_prompt should be empty in brief-only mode") + + def main() -> None: parser = argparse.ArgumentParser(description="Run smoke tests for stylekit-style-prompts") parser.add_argument("--query", default="高端科技SaaS财务后台,玻璃质感,强调可读性") @@ -125,6 +189,7 @@ def main() -> None: len(brief_payload.get("design_brief", {}).get("iteration_strategy", {}).get("constraints", [])) >= 2, "design_brief.iteration_strategy.constraints missing", ) + validate_brief_schema(brief_payload) with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json", delete=False) as tmp: json.dump(brief_payload, tmp, ensure_ascii=False, indent=2) @@ -171,6 +236,14 @@ def main() -> None: args.query, "--stack", args.stack, + "--site-type", + "dashboard", + "--content-depth", + "skeleton", + "--recommendation-mode", + "hybrid", + "--decision-speed", + "fast", "--blend-mode", "on", "--refine-mode", @@ -194,6 +267,194 @@ def main() -> None: ensure(pipeline_payload.get("status") == "pass", "Pipeline quality gate failed") ensure("quality_gate" in pipeline_payload, "Pipeline missing quality_gate") ensure(pipeline_payload.get("quality_gate", {}).get("status") == "pass", "Quality gate status is not pass") + ensure(bool(pipeline_payload.get("site_profile")), "Pipeline missing site_profile") + ensure(bool(pipeline_payload.get("tag_bundle")), "Pipeline missing tag_bundle") + ensure(bool(pipeline_payload.get("composition_plan")), "Pipeline missing composition_plan") + ensure(bool(pipeline_payload.get("decision_flow")), "Pipeline missing decision_flow") + ensure(bool(pipeline_payload.get("content_plan")), "Pipeline missing content_plan") + + # Force one synthetic warning payload so upgrade proposal flow is exercised. + upgrade_seed = dict(pipeline_payload) + # Force regeneration path from synthetic QA signals. + upgrade_seed.pop("upgrade_candidates", None) + upgrade_seed["quality_gate"] = { + "status": "fail", + "violations": [{"id": "interaction_accessibility", "message": "synthetic smoke signal"}], + "warnings": [{"id": "synthetic_warning", "message": "synthetic smoke warning"}], + "checks": [], + } + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json", delete=False) as upf: + json.dump(upgrade_seed, upf, ensure_ascii=False, indent=2) + upgrade_seed_path = upf.name + + try: + proposal_payload = run_json( + [ + py, + str(PROPOSE_UPGRADE), + "--pipeline-output", + upgrade_seed_path, + "--out-dir", + str(SKILL_ROOT / "tmp" / "upgrade-proposals"), + "--format", + "json", + ] + ) + finally: + try: + os.remove(upgrade_seed_path) + except OSError: + pass + + ensure(proposal_payload.get("status") == "proposed", "Upgrade proposal should be generated") + proposal_file = proposal_payload.get("output_file") + ensure(bool(proposal_file), "Upgrade proposal output_file is missing") + review_payload = run_json([py, str(REVIEW_UPGRADE), "--candidate", str(proposal_file), "--format", "json"]) + ensure(review_payload.get("status") == "pass", "Upgrade proposal review failed") + + # --- Negative tests --- + + # Invalid style slug should fail + run_expect_fail( + [py, str(BRIEF), "--query", "test", "--style", "nonexistent-slug-xyz-999"], + "invalid style slug", + ) + + # Strict reference schema with unknown top-level fields should fail + run_expect_fail( + [ + py, str(BRIEF), "--query", "test", "--strict-reference-schema", + "--reference-json", json.dumps({"bogus_field": "value", "another_unknown": 123}), + ], + "strict schema with unknown fields", + ) + + # merge_taxonomy: invalid new_style_tags should fail fast in dry-run + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json", delete=False) as merge_bad: + json.dump( + { + "new_enum_values": [], + "new_profiles": {}, + "new_style_tags": ["Bad Tag With Spaces"], + }, + merge_bad, + ensure_ascii=False, + indent=2, + ) + merge_bad_path = merge_bad.name + try: + run_expect_fail( + [py, str(MERGE_TAXONOMY), "--type", "animation", "--input", merge_bad_path, "--dry-run"], + "invalid new_style_tags in merge_taxonomy", + ) + finally: + try: + os.remove(merge_bad_path) + except OSError: + pass + + # --- brief-only mode test --- + brief_only_payload = run_json( + [py, str(BRIEF), "--query", args.query, "--stack", args.stack, "--mode", "brief-only"] + ) + validate_brief_schema(brief_only_payload) + ensure(brief_only_payload.get("hard_prompt", "") == "", "brief-only should have empty hard_prompt") + ensure(brief_only_payload.get("soft_prompt", "") == "", "brief-only should have empty soft_prompt") + + # --- English query test --- + en_payload = run_json( + [py, str(BRIEF), "--query", "modern SaaS dashboard with glassmorphism", "--stack", "nextjs", "--mode", "brief+prompt"] + ) + validate_brief_schema(en_payload) + ensure(en_payload.get("language") == "en", "English query should detect lang=en") + ensure(bool(en_payload.get("hard_prompt")), "English brief+prompt should have hard_prompt") + + # --- Taxonomy validation --- + taxonomy_payload = run_json( + [ + py, + str(VALIDATE_TAXONOMY), + "--format", + "json", + "--max-unused-style-tags", + "0", + "--fail-on-warning", + ] + ) + ensure(taxonomy_payload.get("status") == "pass", "Taxonomy validation failed") + ensure(taxonomy_payload.get("coverage", 0) >= 0.70, "Taxonomy coverage below 70%") + stats = taxonomy_payload.get("style_tag_registry_stats", {}) or {} + ensure(stats.get("unused_count", 0) == 0, "Style tag registry should have no unused tags in smoke baseline") + + # --- Output contract sync guard --- + contract_sync_payload = run_json( + [py, str(VALIDATE_CONTRACT_SYNC), "--format", "json", "--fail-on-warning"] + ) + ensure(contract_sync_payload.get("status") == "pass", "Output contract sync validation failed") + + # --- Taxonomy style-tag registry guard (negative path) --- + routing_src = REF_DIR / "taxonomy" / "site-type-routing.json" + routing_data = json.loads(routing_src.read_text(encoding="utf-8")) + routing_data["site_types"]["blog"]["favored_style_tags"][0] = "smoke-invalid-style-tag" + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json", delete=False) as tf: + json.dump(routing_data, tf, ensure_ascii=False, indent=2) + bad_routing_path = tf.name + try: + proc = subprocess.run( + [py, str(VALIDATE_TAXONOMY), "--format", "json", "--routing-file", bad_routing_path], + capture_output=True, + text=True, + ) + finally: + try: + os.remove(bad_routing_path) + except OSError: + pass + ensure(proc.returncode != 0, "Taxonomy validator should fail when routing includes unregistered style tag") + bad_routing_payload = json.loads((proc.stdout or "").strip()) + ensure(bad_routing_payload.get("status") == "fail", "Bad routing validation should report fail") + ensure( + any("not in style-tag-registry" in str(e) for e in bad_routing_payload.get("errors", [])), + "Bad routing validation should report style-tag-registry mismatch", + ) + + # --- Taxonomy registry usage coverage guard (negative path) --- + registry_src = REF_DIR / "taxonomy" / "style-tag-registry.json" + registry_data = json.loads(registry_src.read_text(encoding="utf-8")) + registry_tags = registry_data.get("allowed_style_tags", []) + ensure(isinstance(registry_tags, list), "style-tag-registry allowed_style_tags should be a list") + registry_tags.append("smoke-unused-style-tag") + registry_data["allowed_style_tags"] = registry_tags + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json", delete=False) as tf_reg: + json.dump(registry_data, tf_reg, ensure_ascii=False, indent=2) + bad_registry_path = tf_reg.name + try: + proc = subprocess.run( + [ + py, + str(VALIDATE_TAXONOMY), + "--format", + "json", + "--style-tag-registry-file", + bad_registry_path, + "--max-unused-style-tags", + "0", + ], + capture_output=True, + text=True, + ) + finally: + try: + os.remove(bad_registry_path) + except OSError: + pass + ensure(proc.returncode != 0, "Taxonomy validator should fail when unused style tags exceed threshold") + bad_registry_payload = json.loads((proc.stdout or "").strip()) + ensure(bad_registry_payload.get("status") == "fail", "Bad registry validation should report fail") + ensure( + any("unused tag count" in str(e) for e in bad_registry_payload.get("errors", [])), + "Bad registry validation should report unused tag threshold violation", + ) print( json.dumps( @@ -203,6 +464,17 @@ def main() -> None: "blend_enabled": blend_plan.get("enabled"), "pipeline_status": pipeline_payload.get("status"), "pipeline_refine_mode": pipeline_payload.get("refine_mode"), + "pipeline_site_type": (pipeline_payload.get("site_profile", {}) or {}).get("site_type"), + "upgrade_review": review_payload.get("status"), + "negative_tests": "pass", + "brief_only_test": "pass", + "english_query_test": "pass", + "schema_validation": "pass", + "taxonomy_validation": taxonomy_payload.get("status"), + "taxonomy_registry_guard": "pass", + "taxonomy_registry_usage_guard": "pass", + "contract_sync_validation": contract_sync_payload.get("status"), + "taxonomy_coverage": taxonomy_payload.get("coverage"), }, ensure_ascii=False, indent=2, diff --git a/scripts/v2_taxonomy.py b/scripts/v2_taxonomy.py new file mode 100644 index 0000000..18428d6 --- /dev/null +++ b/scripts/v2_taxonomy.py @@ -0,0 +1,806 @@ +#!/usr/bin/env python3 +"""V2 taxonomy helpers for site-type routing and composition planning.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Any + +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from _common import STOPWORDS, load_json, normalize_text, now_iso, tokenize + +SITE_TYPES = ( + "blog", + "saas", + "dashboard", + "docs", + "ecommerce", + "landing-page", + "portfolio", + "general", +) + +CONTENT_DEPTH_CHOICES = ("skeleton", "storyboard", "near-prod") +RECOMMENDATION_MODE_CHOICES = ("hybrid", "rules") +DECISION_SPEED_CHOICES = ("fast", "guided") + +DEFAULT_ROUTE = { + "preferred_layout_archetypes": ["balanced-sections", "feature-grid"], + "preferred_motion_profiles": ["subtle", "smooth"], + "preferred_interaction_patterns": ["assistant-guided", "content-reading"], + "favored_style_tags": ["modern", "clean", "balanced"], + "penalized_style_tags": ["chaotic"], + "default_modules": ["hero", "section-grid", "cta-band"], + "optional_modules": ["faq", "contact"], +} + +DEFAULT_ROUTES = {site: dict(DEFAULT_ROUTE) for site in SITE_TYPES} + +DEFAULT_SITE_ALIASES = { + "blog": ["blog", "article", "editorial", "博客", "文章", "内容站"], + "saas": ["saas", "b2b", "workspace", "企业应用", "软件服务"], + "dashboard": ["dashboard", "admin", "panel", "console", "后台", "仪表盘", "控制台", "看板"], + "docs": ["docs", "documentation", "guide", "manual", "文档", "说明", "帮助中心"], + "ecommerce": ["ecommerce", "store", "shop", "checkout", "电商", "商城", "购物", "商品"], + "landing-page": ["landing", "hero", "marketing", "homepage", "落地页", "首页", "营销"], + "portfolio": ["portfolio", "case study", "showreel", "作品集", "案例", "展示"], + "general": ["web", "website", "app", "站点", "网站", "页面"], +} + +STYLE_TO_VISUAL_STYLE = { + "minimal": "minimal", + "editorial": "editorial", + "retro": "retro", + "vintage": "retro", + "y2k": "retro", + "cyberpunk": "expressive", + "neo-brutalist": "expressive", + "neon": "expressive", + "glass": "modern-tech", + "glassmorphism": "modern-tech", + "dashboard": "corporate", + "enterprise": "corporate", + "playful": "playful", +} + + + + +def load_v2_references(ref_dir: Path) -> dict[str, Any]: + tax_dir = ref_dir / "taxonomy" + schema_path = tax_dir / "tag-schema.json" + aliases_path = tax_dir / "tag-aliases.json" + routing_path = tax_dir / "site-type-routing.json" + style_map_path = tax_dir / "style-tag-map.v2.json" + + schema = {"schemaVersion": "2.0.0", "dimensions": {}} + aliases = {"schemaVersion": "2.0.0", "site_type_aliases": dict(DEFAULT_SITE_ALIASES)} + routing = {"schemaVersion": "2.0.0", "site_types": dict(DEFAULT_ROUTES)} + style_map = {"schemaVersion": "2.0.0", "style_mappings": {}} + + if schema_path.exists(): + schema = load_json(schema_path) + if aliases_path.exists(): + aliases = load_json(aliases_path) + if routing_path.exists(): + routing = load_json(routing_path) + if style_map_path.exists(): + style_map = load_json(style_map_path) + + anim_path = tax_dir / "animation-profiles.v2.json" + ipt_path = tax_dir / "interaction-patterns.v2.json" + animation_profiles: dict[str, Any] = {"schemaVersion": "2.0.0", "profiles": {}} + interaction_patterns: dict[str, Any] = {"schemaVersion": "2.0.0", "patterns": {}} + if anim_path.exists(): + animation_profiles = load_json(anim_path) + if ipt_path.exists(): + interaction_patterns = load_json(ipt_path) + + aliases.setdefault("site_type_aliases", dict(DEFAULT_SITE_ALIASES)) + routing.setdefault("site_types", dict(DEFAULT_ROUTES)) + for site in SITE_TYPES: + routing["site_types"].setdefault(site, dict(DEFAULT_ROUTE)) + + return { + "schema": schema, + "aliases": aliases, + "routing": routing, + "style_map": style_map, + "animation_profiles": animation_profiles, + "interaction_patterns": interaction_patterns, + } + + +def resolve_site_type(query: str, explicit_site_type: str, aliases_payload: dict[str, Any]) -> dict[str, Any]: + if explicit_site_type and explicit_site_type != "auto": + site_type = explicit_site_type if explicit_site_type in SITE_TYPES else "general" + return { + "site_type": site_type, + "source": "explicit", + "confidence": 1.0, + "matched_signals": [site_type], + } + + aliases = aliases_payload.get("site_type_aliases", {}) or {} + tokens = tokenize(query) + qset = set(tokens) + + scored: list[tuple[str, int, list[str]]] = [] + for site, terms in aliases.items(): + matched = [term for term in terms if normalize_text(term) in qset or normalize_text(term) in normalize_text(query)] + if matched: + scored.append((site, len(matched), matched[:6])) + + if not scored: + return { + "site_type": "general", + "source": "heuristic-default", + "confidence": 0.35, + "matched_signals": [], + } + + scored.sort(key=lambda item: item[1], reverse=True) + winner = scored[0] + confidence = min(1.0, 0.45 + winner[1] * 0.15) + site_type = winner[0] if winner[0] in SITE_TYPES else "general" + return { + "site_type": site_type, + "source": "alias-match", + "confidence": round(confidence, 3), + "matched_signals": winner[2], + } + + +def routing_for_site_type(site_type: str, routing_payload: dict[str, Any]) -> dict[str, Any]: + routes = (routing_payload.get("site_types", {}) if isinstance(routing_payload, dict) else {}) or {} + route = routes.get(site_type) or DEFAULT_ROUTES.get(site_type) or DEFAULT_ROUTE + out = dict(DEFAULT_ROUTE) + out.update(route) + return out + + +def style_mapping_for_slug(slug: str, style_map_payload: dict[str, Any]) -> dict[str, Any]: + mappings = (style_map_payload.get("style_mappings", {}) if isinstance(style_map_payload, dict) else {}) or {} + if slug in mappings and isinstance(mappings[slug], dict): + return mappings[slug] + return {} + + +def infer_visual_style(style: dict[str, Any], mapping: dict[str, Any]) -> str: + explicit = mapping.get("visual_style") + if explicit: + return str(explicit) + + text = " ".join( + [ + str(style.get("slug", "")), + str(style.get("name", "")), + str(style.get("nameEn", "")), + str(style.get("category", "")), + " ".join(style.get("keywords", [])), + " ".join(style.get("tags", [])), + ] + ).lower() + + for hint, label in STYLE_TO_VISUAL_STYLE.items(): + if hint in text: + return label + if style.get("styleType") == "layout": + return "balanced" + return "modern-tech" + + +def infer_layout_archetype( + style: dict[str, Any], + mapping: dict[str, Any], + route: dict[str, Any], + site_type: str, + query: str, +) -> str: + hints = mapping.get("layout_archetype_hints", []) + if isinstance(hints, list) and hints: + return str(hints[0]) + + query_low = normalize_text(query) + if site_type == "dashboard": + return "kpi-console" + if site_type == "docs": + return "doc-sidebar" + if site_type == "blog": + return "article-first" + if site_type == "portfolio": + return "showcase-masonry" + if site_type == "ecommerce": + return "catalog-conversion" + if site_type == "landing-page": + return "split-hero" + if "sidebar" in query_low or "侧边栏" in query_low: + return "doc-sidebar" + if "table" in query_low or "数据" in query_low: + return "kpi-console" + preferred = route.get("preferred_layout_archetypes", []) + if isinstance(preferred, list) and preferred: + return str(preferred[0]) + return "balanced-sections" + + +def infer_motion_profile(style: dict[str, Any], mapping: dict[str, Any], route: dict[str, Any], query: str) -> str: + hints = mapping.get("motion_profile_hints", []) + if isinstance(hints, list) and hints: + return str(hints[0]) + + text = " ".join( + [ + normalize_text(query), + normalize_text(style.get("slug", "")), + normalize_text(style.get("nameEn", "")), + " ".join([normalize_text(x) for x in style.get("tags", [])]), + " ".join([normalize_text(x) for x in style.get("keywords", [])]), + normalize_text(style.get("aiRules", "")), + ] + ) + if any(k in text for k in ("minimal", "readable", "docs", "文档", "可读", "克制")): + return "minimal" + if any(k in text for k in ("smooth", "glass", "丝滑", "玻璃", "fluid")): + return "smooth" + if any(k in text for k in ("dramatic", "bold", "neon", "cyber", "视觉冲击", "强烈")): + return "energetic" + if any(k in text for k in ("playful", "whimsical", "fun", "bouncy", "趣味", "活泼", "可爱")): + return "playful" + if any(k in text for k in ("ambient", "atmospheric", "floating", "氛围", "漂浮", "背景动效")): + return "ambient" + if any(k in text for k in ("loading", "skeleton", "progress", "spinner", "加载", "骨架屏")): + return "functional" + preferred = route.get("preferred_motion_profiles", []) + if isinstance(preferred, list) and preferred: + return str(preferred[0]) + return "subtle" + + +def infer_interaction_pattern(style: dict[str, Any], mapping: dict[str, Any], route: dict[str, Any], site_type: str, query: str) -> str: + hints = mapping.get("interaction_pattern_hints", []) + if isinstance(hints, list) and hints: + return str(hints[0]) + + query_low = normalize_text(query) + if any(k in query_low for k in ("wizard", "multi-step", "form", "表单", "向导", "分步", "注册流程")): + return "form-wizard" + if any(k in query_low for k in ("search", "filter", "facet", "搜索", "筛选", "过滤", "检索")): + return "search-explore" + if any(k in query_low for k in ("notification", "toast", "alert", "inbox", "通知", "消息中心", "提醒")): + return "notification-center" + if site_type == "dashboard": + return "data-dense-feedback" + if site_type == "docs": + return "docs-navigation" + if site_type in {"landing-page", "ecommerce"}: + return "conversion-focused" + if site_type in {"portfolio"}: + return "showcase-narrative" + if "read" in query_low or "阅读" in query_low: + return "content-reading" + preferred = route.get("preferred_interaction_patterns", []) + if isinstance(preferred, list) and preferred: + return str(preferred[0]) + return "assistant-guided" + + +def infer_modifiers(style: dict[str, Any], mapping: dict[str, Any], site_type: str, query: str) -> list[str]: + modifiers: list[str] = [] + mapped = mapping.get("modifiers", []) + if isinstance(mapped, list): + modifiers.extend(str(x) for x in mapped if str(x).strip()) + + text = " ".join( + [ + normalize_text(query), + normalize_text(style.get("category", "")), + " ".join([normalize_text(x) for x in style.get("tags", [])]), + " ".join([normalize_text(x) for x in style.get("keywords", [])]), + ] + ) + if any(k in text for k in ("readability", "readable", "可读", "文档", "docs")): + modifiers.append("readability-first") + if any(k in text for k in ("conversion", "cta", "checkout", "转化", "购买", "注册")): + modifiers.append("conversion-first") + if any(k in text for k in ("high-contrast", "霓虹", "强对比", "brutalist", "neo-brutalist")): + modifiers.append("high-contrast") + if site_type in {"dashboard", "docs"}: + modifiers.append("dense-information") + if site_type in {"landing-page", "portfolio"}: + modifiers.append("hero-driven") + + out: list[str] = [] + seen = set() + for item in modifiers: + key = str(item).strip().lower() + if not key or key in seen: + continue + seen.add(key) + out.append(key) + return out[:3] + + +def build_tag_bundle( + *, + style: dict[str, Any], + site_type: str, + query: str, + route: dict[str, Any], + style_map_payload: dict[str, Any], +) -> dict[str, Any]: + slug = str(style.get("slug", "")) + mapping = style_mapping_for_slug(slug, style_map_payload) + visual_style = infer_visual_style(style, mapping) + layout_archetype = infer_layout_archetype(style, mapping, route, site_type, query) + motion_profile = infer_motion_profile(style, mapping, route, query) + interaction_pattern = infer_interaction_pattern(style, mapping, route, site_type, query) + modifiers = infer_modifiers(style, mapping, site_type, query) + + return { + "site_type": site_type, + "visual_style": visual_style, + "layout_archetype": layout_archetype, + "motion_profile": motion_profile, + "interaction_pattern": interaction_pattern, + "modifiers": modifiers, + } + + +def routing_adjustment_for_style( + *, + style: dict[str, Any], + site_type: str, + route: dict[str, Any], + style_map_payload: dict[str, Any], + query: str, +) -> tuple[float, dict[str, Any]]: + mapping = style_mapping_for_slug(str(style.get("slug", "")), style_map_payload) + base_tags = { + normalize_text(style.get("styleType", "")), + normalize_text(style.get("category", "")), + normalize_text(mapping.get("visual_style", "")), + } + for item in style.get("tags", []): + base_tags.add(normalize_text(item)) + for item in style.get("keywords", []): + base_tags.add(normalize_text(item)) + for item in mapping.get("modifiers", []): + base_tags.add(normalize_text(item)) + + favored = {normalize_text(x) for x in route.get("favored_style_tags", [])} + penalized = {normalize_text(x) for x in route.get("penalized_style_tags", [])} + favored_hits = sorted(tag for tag in base_tags if tag and tag in favored) + penalized_hits = sorted(tag for tag in base_tags if tag and tag in penalized) + + adjustment = 0.0 + adjustment += min(len(favored_hits), 3) * 1.6 + adjustment -= min(len(penalized_hits), 3) * 1.4 + + stype = normalize_text(style.get("styleType", "")) + query_low = normalize_text(query) + prefer_layout = any(k in query_low for k in ("dashboard", "admin", "panel", "console", "布局", "grid", "table", "chart", "侧边栏")) + if site_type in {"dashboard", "docs"} and stype == "layout": + adjustment += 1.8 + if site_type in {"landing-page", "portfolio"} and stype == "visual": + adjustment += 1.2 + if prefer_layout and stype == "layout": + adjustment += 1.2 + if prefer_layout and stype != "layout" and site_type in {"dashboard", "docs", "saas"}: + adjustment -= 0.8 + + return round(adjustment, 4), { + "site_type": site_type, + "favored_hits": favored_hits[:6], + "penalized_hits": penalized_hits[:6], + } + + +def resolve_animation_profile( + tag_bundle: dict[str, Any], + route: dict[str, Any], + animation_profiles_payload: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Resolve the best-matching animation profile from taxonomy data.""" + if not animation_profiles_payload: + return None + profiles = animation_profiles_payload.get("profiles", {}) + if not profiles: + return None + + motion = tag_bundle.get("motion_profile", "") + recommended = route.get("recommended_animation_profiles", []) + for name in recommended: + if name in profiles and profiles[name].get("motion_profile") == motion: + return profiles[name] + + for name in recommended: + if name in profiles: + return profiles[name] + + for _name, profile in profiles.items(): + if profile.get("motion_profile") == motion: + return profile + + return None + + +def resolve_interaction_pattern_data( + tag_bundle: dict[str, Any], + interaction_patterns_payload: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Resolve the matching interaction pattern data from taxonomy.""" + if not interaction_patterns_payload: + return None + patterns = interaction_patterns_payload.get("patterns", {}) + if not patterns: + return None + + pattern_key = tag_bundle.get("interaction_pattern", "") + if pattern_key in patterns: + return patterns[pattern_key] + + return None + + +def build_ai_interaction_script( + tag_bundle: dict[str, Any], + lang: str, + resolved_anim_profile: dict[str, Any] | None = None, + resolved_interaction_pattern: dict[str, Any] | None = None, +) -> list[str]: + motion = tag_bundle.get("motion_profile") + pattern = tag_bundle.get("interaction_pattern") + layout = tag_bundle.get("layout_archetype") + + has_anim = resolved_anim_profile is not None + has_ipt = resolved_interaction_pattern is not None + + if not has_anim and not has_ipt: + if lang == "zh": + return [ + "动效目标:先明确每段动效服务的信息目标(导览、反馈、转化),禁止无目的炫技。", + f"触发机制:围绕 `{pattern}` 设计 hover/active/focus/loading/error 触发条件。", + "状态机:每个核心组件至少定义 default/hover/active/focus-visible/disabled 五态。", + f"节奏参数:主动画采用 `{motion}` 档位,常规反馈 150-300ms,结构转场 250-500ms。", + "可访问性:非必要动画遵循 prefers-reduced-motion,焦点可见且键盘路径连续。", + f"布局协同:`{layout}` 场景下优先保持信息层级稳定,再叠加动效。", + ] + return [ + "Motion objective: every animation must serve guidance, feedback, or conversion intent.", + f"Trigger logic: design hover/active/focus/loading/error around `{pattern}` behavior.", + "State machine: define default/hover/active/focus-visible/disabled for core components.", + f"Rhythm: use `{motion}` profile; micro-feedback 150-300ms, structural transitions 250-500ms.", + "Accessibility: respect prefers-reduced-motion and preserve visible keyboard focus path.", + f"Layout sync: prioritize hierarchy stability in `{layout}` before adding expressive motion.", + ] + + lines: list[str] = [] + + if has_anim: + ap = resolved_anim_profile + dur = ap.get("duration_range_ms", [150, 300]) + dur_str = f"{dur[0]}-{dur[1]}ms" if len(dur) >= 2 else str(dur) + easing = ap.get("easing", "ease") + intent = ap.get("intent", "") + fallback = ap.get("reduced_motion_fallback", "instant-state-swap") + anti = ap.get("anti_patterns", []) + + if lang == "zh": + lines.append(f"动效意图:{intent}") + lines.append(f"节奏参数:时长 {dur_str},缓动 `{easing}`,reduced-motion 回退 `{fallback}`。") + if anti: + lines.append(f"动效禁区:{'; '.join(anti[:3])}。") + else: + lines.append(f"Motion intent: {intent}") + lines.append(f"Timing: duration {dur_str}, easing `{easing}`, reduced-motion fallback `{fallback}`.") + if anti: + lines.append(f"Motion anti-patterns: {'; '.join(anti[:3])}.") + + if has_ipt: + ip = resolved_interaction_pattern + goal = ip.get("primary_goal", "") + states = ip.get("state_coverage_requirements", {}) + a11y = ip.get("accessibility_constraints", []) + ipt_anti = ip.get("anti_patterns", []) + + if lang == "zh": + lines.append(f"交互目标:{goal}") + if states: + state_items = [f"`{comp}`: {', '.join(st)}" for comp, st in list(states.items())[:3]] + lines.append(f"状态覆盖要求:{'; '.join(state_items)}。") + if a11y: + lines.append(f"可访问性约束:{'; '.join(a11y[:3])}。") + if ipt_anti: + lines.append(f"交互禁区:{'; '.join(ipt_anti[:2])}。") + else: + lines.append(f"Interaction goal: {goal}") + if states: + state_items = [f"`{comp}`: {', '.join(st)}" for comp, st in list(states.items())[:3]] + lines.append(f"State coverage: {'; '.join(state_items)}.") + if a11y: + lines.append(f"Accessibility: {'; '.join(a11y[:3])}.") + if ipt_anti: + lines.append(f"Interaction anti-patterns: {'; '.join(ipt_anti[:2])}.") + + if lang == "zh": + lines.append(f"布局协同:`{layout}` 场景下优先保持信息层级稳定,再叠加动效。") + else: + lines.append(f"Layout sync: prioritize hierarchy stability in `{layout}` before adding expressive motion.") + + return lines[:10] + + +def build_composition_plan( + *, + site_type: str, + route: dict[str, Any], + tag_bundle: dict[str, Any], + primary_style: dict[str, Any], + alternatives: list[dict[str, Any]], + blend_plan: dict[str, Any], + recommendation_mode: str, + lang: str, + animation_profiles: dict[str, Any] | None = None, + interaction_patterns: dict[str, Any] | None = None, +) -> dict[str, Any]: + owners = dict(blend_plan.get("conflict_resolution", {}) if isinstance(blend_plan, dict) else {}) + fallback_owner = primary_style.get("slug") + owners.setdefault("color_owner", fallback_owner) + owners.setdefault("typography_owner", fallback_owner) + owners.setdefault("spacing_owner", fallback_owner) + owners.setdefault("motion_owner", fallback_owner) + + alt_slugs = [item.get("slug") for item in alternatives if item.get("slug")] + rationale = [ + f"Site-type route `{site_type}` prefers `{tag_bundle.get('layout_archetype')}` + `{tag_bundle.get('interaction_pattern')}`.", + f"Primary style `{primary_style.get('slug')}` anchors the visual identity.", + "Owners from blend conflict-resolution control color/typography/spacing/motion decisions.", + ] + if alt_slugs: + rationale.append(f"Secondary style context considered: {', '.join(alt_slugs[:2])}.") + if recommendation_mode == "rules": + rationale.append("Recommendation mode is deterministic rules-only.") + else: + rationale.append("Recommendation mode is rules-first with LLM polishing.") + + if lang == "zh": + rationale = [ + f"站点类型 `{site_type}` 路由优先 `{tag_bundle.get('layout_archetype')}` 与 `{tag_bundle.get('interaction_pattern')}`。", + f"主风格 `{primary_style.get('slug')}` 负责视觉识别度。", + "融合所有权(色彩/排版/间距/动效)来自冲突消解矩阵。", + ] + ([f"次级风格上下文:{', '.join(alt_slugs[:2])}。"] if alt_slugs else []) + ( + ["推荐模式:纯规则确定性输出。"] if recommendation_mode == "rules" else ["推荐模式:规则优先 + LLM 润色。"] + ) + + resolved_anim = resolve_animation_profile(tag_bundle, route, animation_profiles) + resolved_ipt = resolve_interaction_pattern_data(tag_bundle, interaction_patterns) + + motion_rec: dict[str, Any] = { + "motion_profile": tag_bundle.get("motion_profile"), + "reason": route.get("preferred_motion_profiles", [])[:3], + } + if resolved_anim: + motion_rec["intent"] = resolved_anim.get("intent") + motion_rec["duration_range_ms"] = resolved_anim.get("duration_range_ms") + motion_rec["easing"] = resolved_anim.get("easing") + + interaction_rec: dict[str, Any] = { + "interaction_pattern": tag_bundle.get("interaction_pattern"), + "reason": route.get("preferred_interaction_patterns", [])[:3], + } + if resolved_ipt: + interaction_rec["primary_goal"] = resolved_ipt.get("primary_goal") + interaction_rec["required_components"] = resolved_ipt.get("required_components") + + checks = [ + "state-machine coverage for hover/active/focus-visible/loading/error", + "layout hierarchy stability before decorative animation", + "token consistency across style/layout/motion ownership", + ] + if resolved_ipt: + state_reqs = resolved_ipt.get("state_coverage_requirements", {}) + for comp, states in list(state_reqs.items())[:3]: + checks.append(f"{comp} states: {', '.join(states)}") + + return { + "site_type": site_type, + "recommendation_mode": recommendation_mode, + "style_recommendation": { + "primary_style": primary_style.get("slug"), + "visual_style": tag_bundle.get("visual_style"), + "alternatives": alt_slugs[:3], + }, + "layout_recommendation": { + "layout_archetype": tag_bundle.get("layout_archetype"), + "reason": route.get("preferred_layout_archetypes", [])[:3], + }, + "motion_recommendation": motion_rec, + "interaction_recommendation": interaction_rec, + "owner_matrix": { + "style_identity_owner": fallback_owner, + "color_owner": owners.get("color_owner"), + "typography_owner": owners.get("typography_owner"), + "spacing_owner": owners.get("spacing_owner"), + "motion_owner": owners.get("motion_owner"), + "interaction_owner": owners.get("interaction_owner", owners.get("motion_owner")), + }, + "ai_interaction_script": build_ai_interaction_script( + tag_bundle, lang, + resolved_anim_profile=resolved_anim, + resolved_interaction_pattern=resolved_ipt, + ), + "checks": checks, + "rationale": rationale, + } + + +def build_decision_flow( + *, + site_type: str, + lang: str, + speed: str, + style_options: list[dict[str, Any]], + stack: str, +) -> dict[str, Any]: + options = [ + {"option_id": item.get("option_id"), "slug": item.get("slug"), "reason": item.get("reason")} + for item in style_options[:4] + ] + selected_slug = options[0]["slug"] if options else "" + + if lang == "zh": + fast_steps = [ + "第1步(目标):确认页面优先级(可读性 / 转化 / 品牌表达)。", + "第2步(强度):在克制、平衡、表达性之间选择视觉强度。", + "第3步(组合):锁定布局 archetype 与动效档位,进入代码生成。", + ] + guided_steps = fast_steps + [ + "第4步(交互):确认关键组件状态覆盖(empty/loading/error/focus)。", + "第5步(验收):先跑 swap/squint/signature/token 四项检查。", + ] + steps = fast_steps if speed == "fast" else guided_steps + command = ( + f'python scripts/run_pipeline.py --workflow codegen --query "" --stack {stack} ' + f"--site-type {site_type} --style {selected_slug} --content-depth skeleton " + "--recommendation-mode hybrid --decision-speed fast --format json" + ) + else: + fast_steps = [ + "Step 1 (goal): choose readability vs conversion vs brand-expression priority.", + "Step 2 (intensity): pick calm, balanced, or expressive visual intensity.", + "Step 3 (composition): lock layout archetype + motion profile, then generate.", + ] + guided_steps = fast_steps + [ + "Step 4 (interaction): confirm empty/loading/error/focus state coverage.", + "Step 5 (validation): run swap/squint/signature/token checks before handoff.", + ] + steps = fast_steps if speed == "fast" else guided_steps + command = ( + f'python scripts/run_pipeline.py --workflow codegen --query "" --stack {stack} ' + f"--site-type {site_type} --style {selected_slug} --content-depth skeleton " + "--recommendation-mode hybrid --decision-speed fast --format json" + ) + + return { + "decision_speed": speed, + "style_options": options, + "steps": steps, + "lock_command_template": command, + } + + +def build_content_plan(*, site_type: str, route: dict[str, Any], content_depth: str, lang: str) -> dict[str, Any]: + core_modules = [str(x) for x in route.get("default_modules", [])][:8] + optional_modules = [str(x) for x in route.get("optional_modules", [])][:6] + + if site_type in {"dashboard", "docs"}: + pages = ["overview", "detail", "list", "settings"] + elif site_type in {"blog", "portfolio"}: + pages = ["home", "list", "detail", "about"] + elif site_type in {"ecommerce"}: + pages = ["home", "catalog", "detail", "checkout"] + else: + pages = ["home", "features", "detail", "contact"] + + states = ["default", "hover", "active", "focus-visible", "disabled", "loading", "error", "empty"] + out = { + "content_depth": content_depth, + "core_pages": pages, + "core_modules": core_modules, + "optional_modules": optional_modules, + "state_coverage": states, + } + + if content_depth in {"storyboard", "near-prod"}: + out["motion_storyboard"] = [ + "entry: section-level reveal follows reading order", + "micro-feedback: control interactions stay below 300ms", + "transition: route change keeps hierarchy continuity", + ] + if content_depth == "near-prod": + out["implementation_checklist"] = [ + "component token mapping completed", + "a11y checks (contrast/focus/touch target) documented", + "fallback states and empty data copy filled", + ] + + if lang == "zh": + out["goal"] = "首版先保证信息完整与状态完整,再优化视觉戏剧性。" + else: + out["goal"] = "Prioritize complete information and state coverage before visual flourish." + return out + + +def build_upgrade_candidates( + *, + query: str, + site_type: str, + selected_style: str, + tag_bundle: dict[str, Any], + quality_gate: dict[str, Any], +) -> list[dict[str, Any]]: + violations = quality_gate.get("violations", []) if isinstance(quality_gate, dict) else [] + warnings = quality_gate.get("warnings", []) if isinstance(quality_gate, dict) else [] + ignored_warning_ids = {"QA_SKIPPED_BRIEF_ONLY"} + actionable_warnings = [] + for item in warnings: + if not isinstance(item, dict): + continue + warning_id = str(item.get("id", item.get("code", "unknown"))) + if warning_id not in ignored_warning_ids: + actionable_warnings.append(item) + + if not violations and not actionable_warnings: + return [] + + violation_ids = [str(item.get("id", item.get("code", "unknown"))) for item in violations if isinstance(item, dict)] + warning_ids = [str(item.get("id", item.get("code", "unknown"))) for item in actionable_warnings] + short_query = normalize_text(query)[:60] + candidate_id = f"{now_iso().replace(':', '').replace('-', '')}-{selected_style or 'style'}" + + return [ + { + "candidate_id": candidate_id, + "source": "runtime-analysis", + "summary": f"Candidate update for {site_type}/{selected_style} from latest QA signals.", + "evidence": { + "query_excerpt": short_query, + "site_type": site_type, + "selected_style": selected_style, + "violation_ids": violation_ids[:8], + "warning_ids": warning_ids[:8], + "tag_bundle": tag_bundle, + }, + "proposed_changes": [ + { + "target": "references/taxonomy/style-tag-map.v2.json", + "action": "upsert_style_mapping", + "payload": { + "slug": selected_style, + "modifiers": tag_bundle.get("modifiers", []), + "interaction_pattern_hints": [tag_bundle.get("interaction_pattern")], + "motion_profile_hints": [tag_bundle.get("motion_profile")], + }, + }, + { + "target": "references/taxonomy/site-type-routing.json", + "action": "adjust_routing_weights", + "payload": { + "site_type": site_type, + "boost_tags": tag_bundle.get("modifiers", []), + "watch_checks": violation_ids[:5], + }, + }, + ], + "required_gates": [ + "python3 scripts/smoke_test.py", + "bash scripts/ci_regression_gate.sh --baseline references/benchmark-baseline.json --snapshot-out tmp/benchmark-ci-latest.json", + ], + } + ] diff --git a/scripts/validate_output_contract_sync.py b/scripts/validate_output_contract_sync.py new file mode 100644 index 0000000..9db78bc --- /dev/null +++ b/scripts/validate_output_contract_sync.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Validate output-contract markdown examples against JSON schemas. + +This guard prevents drift between: +1) human-facing contract examples in references/output-contract.md +2) machine-enforced schemas under tests/schemas/ +""" + +from __future__ import annotations + +import argparse +import copy +import json +import sys +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator + + +SCRIPT_DIR = Path(__file__).resolve().parent +SKILL_ROOT = SCRIPT_DIR.parent +DEFAULT_CONTRACT_FILE = SKILL_ROOT / "references" / "output-contract.md" +DEFAULT_SCHEMAS_DIR = SKILL_ROOT / "tests" / "schemas" +REQUIRED_HEADINGS = ( + "1) Candidate Search Output", + "2) Design Brief + Prompt Output", + "3) Prompt QA Output", + "4) One-shot Pipeline Output", + "5) Benchmark Output", +) + + +def load_json(path: Path) -> dict[str, Any]: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def extract_section_headings(markdown: str) -> list[str]: + return [line[3:].strip() for line in markdown.splitlines() if line.startswith("## ")] + + +def extract_json_blocks(markdown: str) -> list[dict[str, Any]]: + blocks: list[dict[str, Any]] = [] + heading = "" + in_block = False + buf: list[str] = [] + block_start_line = 0 + + for line_no, raw in enumerate(markdown.splitlines(), start=1): + line = raw.rstrip("\n") + if line.startswith("## "): + heading = line[3:].strip() + continue + + if line.strip() == "```json": + in_block = True + buf = [] + block_start_line = line_no + continue + + if line.strip() == "```" and in_block: + in_block = False + blocks.append( + { + "heading": heading, + "json": "\n".join(buf).strip(), + "line": block_start_line, + } + ) + buf = [] + continue + + if in_block: + buf.append(line) + + return blocks + + +def group_blocks_by_heading(blocks: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + grouped: dict[str, list[dict[str, Any]]] = {} + for item in blocks: + grouped.setdefault(str(item.get("heading", "")), []).append(item) + return grouped + + +def parse_json_payload(raw_json: str, *, heading: str, line: int) -> tuple[dict[str, Any] | None, str | None]: + try: + payload = json.loads(raw_json) + except json.JSONDecodeError as exc: + return None, f"Invalid JSON under section '{heading}' at line {line}: {exc.msg}" + if not isinstance(payload, dict): + return None, ( + f"JSON under section '{heading}' at line {line} must be an object; " + f"got {type(payload).__name__}" + ) + return payload, None + + +def ensure_pipeline_minimal(payload: dict[str, Any], *, workflow: str) -> dict[str, Any]: + out = copy.deepcopy(payload) + out["workflow"] = workflow + out["mode"] = "brief+prompt" if workflow == "codegen" else "brief-only" + out.setdefault("manual_assistant", {}) + quality_gate = out.get("quality_gate", {}) + if not isinstance(quality_gate, dict): + quality_gate = {} + quality_gate.setdefault("status", "pass") + quality_gate.setdefault("checks", []) + out["quality_gate"] = quality_gate + result = out.get("result", {}) + if not isinstance(result, dict): + result = {} + if workflow == "codegen": + result.setdefault("hard_prompt", "...") + result.setdefault("soft_prompt", "...") + out["result"] = result + return out + + +def validate_against_schema( + payload: dict[str, Any], + schema: dict[str, Any], +) -> list[str]: + validator = Draft202012Validator(schema) + errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.path)) + msgs: list[str] = [] + for err in errors: + path = ".".join(str(p) for p in err.absolute_path) or "" + msgs.append(f"[{path}] {err.message}") + return msgs + + +def run( + *, + contract_file: str = str(DEFAULT_CONTRACT_FILE), + schemas_dir: str = str(DEFAULT_SCHEMAS_DIR), + fail_on_warning: bool = False, +) -> dict[str, Any]: + contract_path = Path(contract_file) + schema_path = Path(schemas_dir) + + if not contract_path.exists(): + raise SystemExit(f"Contract file not found: {contract_path}") + if not schema_path.exists(): + raise SystemExit(f"Schemas dir not found: {schema_path}") + + markdown = contract_path.read_text(encoding="utf-8") + headings = extract_section_headings(markdown) + blocks = extract_json_blocks(markdown) + if not blocks: + return { + "status": "fail", + "checks": [], + "errors": ["No JSON code blocks found in output contract markdown."], + } + + missing = [heading for heading in REQUIRED_HEADINGS if heading not in headings] + if missing: + return { + "status": "fail", + "checks": [], + "errors": [f"Missing required JSON section(s): {', '.join(missing)}"], + } + + required_positions = [headings.index(heading) for heading in REQUIRED_HEADINGS] + if required_positions != sorted(required_positions): + ordered = ", ".join(REQUIRED_HEADINGS) + return { + "status": "fail", + "checks": [], + "errors": [f"Required section order changed; expected order: {ordered}"], + } + + blocks_by_heading = group_blocks_by_heading(blocks) + missing_json = [heading for heading in REQUIRED_HEADINGS if not blocks_by_heading.get(heading)] + if missing_json: + return { + "status": "fail", + "checks": [], + "errors": [f"Required section has no JSON block(s): {', '.join(missing_json)}"], + } + + schemas = { + "search_stylekit": load_json(schema_path / "search_stylekit_output.json"), + "generate_brief": load_json(schema_path / "generate_brief_output.json"), + "qa_prompt": load_json(schema_path / "qa_prompt_output.json"), + "pipeline_codegen": load_json(schema_path / "run_pipeline_codegen_output.json"), + "pipeline_manual": load_json(schema_path / "run_pipeline_manual_output.json"), + "benchmark_pipeline": load_json(schema_path / "benchmark_pipeline_output.json"), + } + + checks: list[dict[str, Any]] = [] + warnings: list[str] = [] + + def add_check(name: str, heading: str, payload: dict[str, Any], schema_key: str) -> None: + errors = validate_against_schema(payload, schemas[schema_key]) + checks.append( + { + "name": name, + "heading": heading, + "schema": schema_key, + "passed": len(errors) == 0, + "errors": errors[:20], + } + ) + + def get_primary_payload(heading: str) -> tuple[dict[str, Any] | None, str | None]: + section_blocks = blocks_by_heading.get(heading, []) + if not section_blocks: + return None, f"Missing JSON block for required section: {heading}" + if len(section_blocks) > 1: + warnings.append( + f"Section '{heading}' has {len(section_blocks)} JSON blocks; using the first one as canonical." + ) + primary = section_blocks[0] + return parse_json_payload( + str(primary.get("json", "")), + heading=heading, + line=int(primary.get("line", 0)), + ) + + search_heading = REQUIRED_HEADINGS[0] + brief_heading = REQUIRED_HEADINGS[1] + qa_heading = REQUIRED_HEADINGS[2] + pipe_heading = REQUIRED_HEADINGS[3] + bench_heading = REQUIRED_HEADINGS[4] + + search_payload, search_error = get_primary_payload(search_heading) + brief_payload, brief_error = get_primary_payload(brief_heading) + qa_payload, qa_error = get_primary_payload(qa_heading) + pipeline_payload, pipeline_error = get_primary_payload(pipe_heading) + benchmark_payload, benchmark_error = get_primary_payload(bench_heading) + + parse_errors = [err for err in [search_error, brief_error, qa_error, pipeline_error, benchmark_error] if err] + if parse_errors: + return { + "status": "fail", + "checks": [], + "errors": parse_errors, + "warnings": warnings, + } + + assert search_payload is not None + assert brief_payload is not None + assert qa_payload is not None + assert pipeline_payload is not None + assert benchmark_payload is not None + + add_check("search_contract_example", search_heading, search_payload, "search_stylekit") + add_check("brief_contract_example", brief_heading, brief_payload, "generate_brief") + add_check("qa_contract_example", qa_heading, qa_payload, "qa_prompt") + add_check( + "pipeline_contract_example_codegen", + pipe_heading, + ensure_pipeline_minimal(pipeline_payload, workflow="codegen"), + "pipeline_codegen", + ) + add_check( + "pipeline_contract_example_manual", + pipe_heading, + ensure_pipeline_minimal(pipeline_payload, workflow="manual"), + "pipeline_manual", + ) + add_check( + "benchmark_contract_example", + bench_heading, + benchmark_payload, + "benchmark_pipeline", + ) + + failed = [item for item in checks if not item["passed"]] + errors = [f"{item['name']}: {len(item['errors'])} error(s)" for item in failed] + if fail_on_warning and warnings: + errors.append(f"fail-on-warning enabled: {len(warnings)} warning(s)") + status = "pass" if not errors else "fail" + return { + "status": status, + "checks": checks, + "errors": errors, + "warnings": warnings, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Validate references/output-contract.md examples against tests/schemas JSON schemas", + ) + parser.add_argument("--contract-file", default=str(DEFAULT_CONTRACT_FILE)) + parser.add_argument("--schemas-dir", default=str(DEFAULT_SCHEMAS_DIR)) + parser.add_argument("--format", choices=["json", "text"], default="text") + parser.add_argument( + "--fail-on-warning", + action="store_true", + help="Treat warnings as failures (non-zero exit when warnings are present).", + ) + args = parser.parse_args() + + result = run( + contract_file=args.contract_file, + schemas_dir=args.schemas_dir, + fail_on_warning=args.fail_on_warning, + ) + + if args.format == "json": + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + print(f"Status: {result['status']}") + for check in result.get("checks", []): + label = "pass" if check.get("passed") else "fail" + print(f"- {check.get('name')}: {label}") + if not check.get("passed"): + for item in check.get("errors", [])[:5]: + print(f" * {item}") + if result.get("warnings"): + print("Warnings:") + for item in result["warnings"]: + print(f" - {item}") + if result.get("errors"): + print("Errors:") + for item in result["errors"]: + print(f" - {item}") + + sys.exit(0 if result.get("status") == "pass" else 1) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_taxonomy.py b/scripts/validate_taxonomy.py new file mode 100644 index 0000000..cd0ef9e --- /dev/null +++ b/scripts/validate_taxonomy.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Validate taxonomy data files for consistency and coverage.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +SKILL_ROOT = Path(__file__).resolve().parent.parent +REF_DIR = SKILL_ROOT / "references" +TAX_DIR = REF_DIR / "taxonomy" +TAG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def normalize_tag(value: str) -> str: + return str(value or "").strip().lower() + + +def load_json(path: Path) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def validate( + min_coverage: float = 0.70, + *, + routing_file: str | None = None, + style_tag_registry_file: str | None = None, + max_unused_style_tags: int | None = None, + fail_on_warning: bool = False, +) -> dict: + errors: list[str] = [] + warnings: list[str] = [] + + # --- Load sources of truth --- + catalog = load_json(REF_DIR / "style-prompts.json") + catalog_slugs = {s["slug"] for s in catalog["styles"]} + + schema = load_json(TAX_DIR / "tag-schema.json") + dims = schema["dimensions"] + valid_visual = set(dims["visual_style"]["values"]) + valid_layout = set(dims["layout_archetype"]["values"]) + valid_motion = set(dims["motion_profile"]["values"]) + valid_interaction = set(dims["interaction_pattern"]["values"]) + valid_modifiers = set(dims["modifiers"]["values"]) + valid_site_types = set(dims["site_type"]["values"]) + + # --- Validate style-tag-map.v2.json --- + tag_map = load_json(TAX_DIR / "style-tag-map.v2.json") + mappings = tag_map.get("style_mappings", {}) + + mapped_valid = 0 + for slug, entry in mappings.items(): + if slug not in catalog_slugs: + errors.append(f"style-tag-map: slug '{slug}' not in catalog") + else: + mapped_valid += 1 + + vs = entry.get("visual_style") + if vs and vs not in valid_visual: + errors.append(f"style-tag-map[{slug}]: visual_style '{vs}' not in enum") + + for lh in entry.get("layout_archetype_hints", []): + if lh not in valid_layout: + errors.append(f"style-tag-map[{slug}]: layout hint '{lh}' not in enum") + + for mh in entry.get("motion_profile_hints", []): + if mh not in valid_motion: + errors.append(f"style-tag-map[{slug}]: motion hint '{mh}' not in enum") + + for ih in entry.get("interaction_pattern_hints", []): + if ih not in valid_interaction: + errors.append(f"style-tag-map[{slug}]: interaction hint '{ih}' not in enum") + + for mod in entry.get("modifiers", []): + if mod not in valid_modifiers: + errors.append(f"style-tag-map[{slug}]: modifier '{mod}' not in enum") + + coverage = mapped_valid / len(catalog_slugs) if catalog_slugs else 0.0 + if coverage < min_coverage: + errors.append(f"style-tag-map: coverage {coverage:.2%} < {min_coverage:.0%}") + + # --- Validate animation-profiles.v2.json --- + anim_path = TAX_DIR / "animation-profiles.v2.json" + if anim_path.exists(): + anim = load_json(anim_path) + profiles = anim.get("profiles", {}) + for name, prof in profiles.items(): + mp = prof.get("motion_profile") + if mp and mp not in valid_motion: + errors.append(f"animation-profiles[{name}]: motion_profile '{mp}' not in enum") + for st in prof.get("suitable_site_types", []): + if st not in valid_site_types: + errors.append(f"animation-profiles[{name}]: site_type '{st}' not in enum") + for field in ("intent", "trigger", "states", "duration_range_ms", "easing", + "reduced_motion_fallback", "suitable_site_types", "anti_patterns"): + if field not in prof: + errors.append(f"animation-profiles[{name}]: missing required field '{field}'") + else: + errors.append("animation-profiles.v2.json not found") + + # --- Validate interaction-patterns.v2.json --- + ipt_path = TAX_DIR / "interaction-patterns.v2.json" + if ipt_path.exists(): + ipt = load_json(ipt_path) + patterns = ipt.get("patterns", {}) + for name, pat in patterns.items(): + if name not in valid_interaction: + errors.append(f"interaction-patterns: pattern key '{name}' not in enum") + for st in pat.get("suitable_site_types", []): + if st not in valid_site_types: + errors.append(f"interaction-patterns[{name}]: site_type '{st}' not in enum") + for field in ("primary_goal", "suitable_site_types", "required_components", + "state_coverage_requirements", "accessibility_constraints", "anti_patterns"): + if field not in pat: + errors.append(f"interaction-patterns[{name}]: missing required field '{field}'") + else: + errors.append("interaction-patterns.v2.json not found") + + # --- Load style tag registry --- + registry_path = Path(style_tag_registry_file) if style_tag_registry_file else (TAX_DIR / "style-tag-registry.json") + if not registry_path.exists(): + errors.append(f"style-tag-registry file not found: {registry_path}") + allowed_style_tags: set[str] = set() + else: + registry = load_json(registry_path) + allowed_raw = registry.get("allowed_style_tags", []) + allowed_style_tags = set() + if not isinstance(allowed_raw, list): + errors.append(f"style-tag-registry[{registry_path}]: allowed_style_tags must be a list") + else: + for tag in allowed_raw: + if not isinstance(tag, str) or not tag.strip(): + errors.append(f"style-tag-registry[{registry_path}]: contains non-string tag") + continue + normalized = normalize_tag(tag) + if TAG_PATTERN.fullmatch(normalized) is None: + errors.append( + f"style-tag-registry[{registry_path}]: tag '{tag}' must use kebab-case tokens" + ) + continue + allowed_style_tags.add(normalized) + + # --- Validate site-type-routing.json cross-references --- + routing_path = Path(routing_file) if routing_file else (TAX_DIR / "site-type-routing.json") + routing = load_json(routing_path) + anim_profile_keys = set(profiles.keys()) if anim_path.exists() else set() + ipt_pattern_keys = set(patterns.keys()) if ipt_path.exists() else set() + + for st_name, st_data in routing.get("site_types", {}).items(): + if st_name not in valid_site_types: + errors.append(f"site-type-routing: key '{st_name}' not in site_type enum") + for layout in st_data.get("preferred_layout_archetypes", []): + if layout not in valid_layout: + errors.append(f"site-type-routing[{st_name}]: layout archetype '{layout}' not in layout_archetype enum") + for ap in st_data.get("recommended_animation_profiles", []): + if ap not in anim_profile_keys: + errors.append(f"site-type-routing[{st_name}]: animation profile '{ap}' not in animation-profiles.v2.json") + for ip in st_data.get("recommended_interaction_patterns", []): + if ip not in ipt_pattern_keys: + errors.append(f"site-type-routing[{st_name}]: interaction pattern '{ip}' not in interaction-patterns.v2.json") + for mp in st_data.get("preferred_motion_profiles", []): + if mp not in valid_motion: + errors.append(f"site-type-routing[{st_name}]: motion profile '{mp}' not in motion_profile enum") + for ip in st_data.get("preferred_interaction_patterns", []): + if ip not in valid_interaction: + errors.append(f"site-type-routing[{st_name}]: interaction pattern '{ip}' not in interaction_pattern enum") + for field in ("favored_style_tags", "penalized_style_tags"): + tags = st_data.get(field, []) + if not isinstance(tags, list): + errors.append(f"site-type-routing[{st_name}]: '{field}' must be a list") + continue + for tag in tags: + if not isinstance(tag, str) or not tag.strip(): + errors.append(f"site-type-routing[{st_name}]: '{field}' contains non-string tag") + continue + normalized = normalize_tag(tag) + if TAG_PATTERN.fullmatch(normalized) is None: + errors.append( + f"site-type-routing[{st_name}]: '{field}' tag '{tag}' must use kebab-case tokens" + ) + continue + if normalized not in allowed_style_tags: + errors.append( + f"site-type-routing[{st_name}]: '{field}' tag '{tag}' not in style-tag-registry" + ) + used_style_tags: set[str] = set() + for st_data in routing.get("site_types", {}).values(): + if not isinstance(st_data, dict): + continue + for field in ("favored_style_tags", "penalized_style_tags"): + tags = st_data.get(field, []) + if not isinstance(tags, list): + continue + for tag in tags: + if isinstance(tag, str) and tag.strip(): + used_style_tags.add(normalize_tag(tag)) + + allowed_count = len(allowed_style_tags) + used_count = len(used_style_tags.intersection(allowed_style_tags)) + unused_style_tags = sorted(allowed_style_tags - used_style_tags) + unused_count = len(unused_style_tags) + usage_ratio = (used_count / allowed_count) if allowed_count else 0.0 + + if max_unused_style_tags is not None and unused_count > max_unused_style_tags: + errors.append( + f"style-tag-registry: unused tag count {unused_count} exceeds limit {max_unused_style_tags}" + ) + elif max_unused_style_tags is None and unused_count > 0: + warnings.append( + "style-tag-registry: unused style tags detected; " + "use --max-unused-style-tags to enforce a hard limit" + ) + + if fail_on_warning and warnings: + errors.append(f"fail-on-warning enabled: {len(warnings)} warning(s)") + + status = "pass" if not errors else "fail" + return { + "status": status, + "coverage": round(coverage, 4), + "errors": errors, + "warnings": warnings, + "style_tag_registry_stats": { + "allowed_count": allowed_count, + "used_count": used_count, + "unused_count": unused_count, + "usage_ratio": round(usage_ratio, 4), + "unused_tags": unused_style_tags[:50], + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate taxonomy data files") + parser.add_argument("--format", choices=["json", "text"], default="text") + parser.add_argument("--min-coverage", type=float, default=0.70) + parser.add_argument("--routing-file", default="", help="Optional path override for site-type-routing.json") + parser.add_argument( + "--style-tag-registry-file", + default="", + help="Optional path override for style-tag-registry.json", + ) + parser.add_argument( + "--max-unused-style-tags", + type=int, + default=-1, + help="Fail when unused style tags exceed this count. Use -1 to disable.", + ) + parser.add_argument( + "--fail-on-warning", + action="store_true", + help="Treat warnings as failures (non-zero exit when warnings are present).", + ) + args = parser.parse_args() + + result = validate( + min_coverage=args.min_coverage, + routing_file=args.routing_file or None, + style_tag_registry_file=args.style_tag_registry_file or None, + max_unused_style_tags=None if args.max_unused_style_tags < 0 else args.max_unused_style_tags, + fail_on_warning=args.fail_on_warning, + ) + + if args.format == "json": + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + print(f"Status: {result['status']}") + print(f"Coverage: {result['coverage']:.2%}") + stats = result.get("style_tag_registry_stats", {}) or {} + print( + "Style-tag registry usage:" + f" used={stats.get('used_count', 0)}/{stats.get('allowed_count', 0)}" + f" ({stats.get('usage_ratio', 0.0):.2%}), unused={stats.get('unused_count', 0)}" + ) + if stats.get("unused_tags"): + print(f"Unused tags sample: {', '.join(stats.get('unused_tags', [])[:10])}") + if result.get("warnings"): + print(f"Warnings ({len(result['warnings'])}):") + for item in result["warnings"]: + print(f" - {item}") + if result["errors"]: + print(f"Errors ({len(result['errors'])}):") + for e in result["errors"]: + print(f" - {e}") + else: + print("No errors found.") + + sys.exit(0 if result["status"] == "pass" else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d7bfd6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,126 @@ +"""Shared fixtures for stylekit-style-prompts test suite.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +SKILL_ROOT = Path(__file__).resolve().parent.parent +SCRIPTS_DIR = SKILL_ROOT / "scripts" +REF_DIR = SKILL_ROOT / "references" + +# Ensure scripts/ is importable +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + + +# --------------------------------------------------------------------------- +# Real-data fixtures (loaded from reference JSON files) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def style_catalog() -> dict[str, Any]: + path = REF_DIR / "style-prompts.json" + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture(scope="session") +def tag_schema() -> dict[str, Any]: + path = REF_DIR / "taxonomy" / "tag-schema.json" + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture(scope="session") +def site_type_routing() -> dict[str, Any]: + path = REF_DIR / "taxonomy" / "site-type-routing.json" + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture(scope="session") +def style_tag_map() -> dict[str, Any]: + path = REF_DIR / "taxonomy" / "style-tag-map.v2.json" + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture(scope="session") +def animation_profiles() -> dict[str, Any]: + path = REF_DIR / "taxonomy" / "animation-profiles.v2.json" + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture(scope="session") +def interaction_patterns() -> dict[str, Any]: + path = REF_DIR / "taxonomy" / "interaction-patterns.v2.json" + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +# --------------------------------------------------------------------------- +# Lightweight factory fixtures (inline construction) +# --------------------------------------------------------------------------- + +@pytest.fixture() +def minimal_style() -> dict[str, Any]: + """Minimal style dict with only required fields.""" + return { + "slug": "test-style", + "name": "测试风格", + "nameEn": "Test Style", + "styleType": "visual", + "category": "expressive", + "tags": ["modern", "clean"], + "keywords": ["test", "sample"], + "colors": { + "primary": "#3B82F6", + "secondary": "#1E293B", + "accent": ["#F59E0B"], + }, + "philosophy": "A test style for unit testing purposes.", + "aiRules": "- Use consistent spacing.\n- Maintain visual hierarchy.\n- Avoid clutter.", + "doList": ["Keep spacing consistent", "Use semantic tokens"], + "dontList": ["Avoid nested scroll", "Do not use absolute positioning"], + "components": { + "button": True, + "card": True, + "input": True, + "nav": True, + "hero": True, + "footer": True, + }, + } + + +@pytest.fixture() +def sample_tag_bundle() -> dict[str, Any]: + """Complete 6-dimension tag bundle.""" + return { + "site_type": "dashboard", + "visual_style": "modern-tech", + "layout_archetype": "kpi-console", + "motion_profile": "subtle", + "interaction_pattern": "data-dense-feedback", + "modifiers": ["dense-information", "readability-first"], + } + + +@pytest.fixture() +def default_route() -> dict[str, Any]: + """Dashboard routing entry.""" + return { + "preferred_layout_archetypes": ["kpi-console", "feature-grid"], + "preferred_motion_profiles": ["subtle", "functional"], + "preferred_interaction_patterns": ["data-dense-feedback"], + "favored_style_tags": ["modern", "clean", "dashboard"], + "penalized_style_tags": ["chaotic", "retro"], + "default_modules": ["kpi-card", "data-table", "chart-panel"], + "optional_modules": ["settings", "notification"], + } diff --git a/tests/schemas/benchmark_pipeline_output.json b/tests/schemas/benchmark_pipeline_output.json new file mode 100644 index 0000000..2985534 --- /dev/null +++ b/tests/schemas/benchmark_pipeline_output.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "benchmark_pipeline_output.json", + "title": "benchmark_pipeline.py output", + "type": "object", + "required": ["summary", "regression_gate", "baseline_update", "meta"], + "properties": { + "summary": { + "type": "object", + "required": ["pass_rate"], + "properties": { + "pass_rate": { "type": "number" }, + "check_pass_rate": { "type": "object" }, + "bucket_pass_rate": { "type": "object" }, + "cases": { "type": "integer" }, + "ok_runs": { "type": "integer" }, + "pass_runs": { "type": "integer" }, + "avg_time_sec": { "type": "number" }, + "avg_hard_prompt_len": { "type": "number" }, + "avg_ai_rules_count": { "type": "number" }, + "style_distribution": { "type": "object" }, + "failed_cases": { "type": "array" } + } + }, + "regression_gate": { + "type": "object", + "required": ["enabled", "passed", "thresholds", "findings"], + "properties": { + "enabled": { "type": "boolean" }, + "passed": { "type": "boolean" }, + "thresholds": { "type": "object" }, + "findings": { "type": "array" } + } + }, + "baseline_update": { + "type": "object", + "required": ["mode", "target", "enabled", "applied", "reason"], + "properties": { + "mode": { "type": "string" }, + "target": { "type": ["string", "null"] }, + "enabled": { "type": "boolean" }, + "applied": { "type": "boolean" }, + "reason": { "type": "string" } + } + }, + "meta": { + "type": "object", + "required": ["baseline_snapshot"], + "properties": { + "baseline_snapshot": { "type": ["string", "null"] } + } + }, + "sample_results": { "type": "array" } + } +} diff --git a/tests/schemas/generate_brief_output.json b/tests/schemas/generate_brief_output.json new file mode 100644 index 0000000..344df60 --- /dev/null +++ b/tests/schemas/generate_brief_output.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "generate_brief_output.json", + "title": "generate_brief.py --mode brief+prompt output", + "type": "object", + "required": [ + "query", + "mode", + "language", + "style_choice", + "design_brief", + "ai_rules", + "hard_prompt", + "soft_prompt", + "candidate_rank" + ], + "properties": { + "query": { "type": "string" }, + "mode": { "type": "string" }, + "language": { "type": "string" }, + "style_choice": { + "type": "object", + "required": ["primary", "alternatives", "why"], + "properties": { + "primary": { + "type": "object", + "required": ["slug", "name", "nameEn", "styleType"], + "properties": { + "slug": { "type": "string" }, + "name": { "type": "string" }, + "nameEn": { "type": "string" }, + "styleType": { "type": "string" } + } + }, + "alternatives": { "type": "array" }, + "why": { "type": ["string", "object"] } + } + }, + "design_brief": { + "type": "object", + "required": [ + "style_choice", + "design_intent", + "refine_mode", + "iteration_strategy", + "input_context", + "visual_direction", + "typography_strategy", + "anti_generic_constraints", + "validation_tests", + "anti_pattern_blacklist", + "design_system_structure", + "site_profile", + "tag_bundle", + "composition_plan", + "decision_flow", + "content_plan", + "component_guidelines", + "interaction_rules", + "a11y_baseline", + "stack_hint", + "blend_plan", + "color_strategy", + "font_strategy_hints" + ], + "properties": { + "style_choice": { "type": "object" }, + "design_intent": { "type": ["string", "object"] }, + "refine_mode": { "type": "string" }, + "iteration_strategy": { "type": ["object", "string"] }, + "input_context": { "type": "object" }, + "visual_direction": { "type": ["object", "string"] }, + "typography_strategy": { "type": ["object", "string"] }, + "anti_generic_constraints": { "type": ["object", "array"] }, + "validation_tests": { "type": "array" }, + "anti_pattern_blacklist": { "type": "array" }, + "design_system_structure": { "type": "object" }, + "site_profile": { "type": "object" }, + "tag_bundle": { "type": "object" }, + "composition_plan": { "type": "object" }, + "decision_flow": { "type": "object" }, + "content_plan": { "type": "object" }, + "component_guidelines": { "type": "array" }, + "interaction_rules": { "type": "array" }, + "a11y_baseline": { "type": "array" }, + "stack_hint": { "type": "string" }, + "blend_plan": { "type": "object" }, + "color_strategy": { "type": "object" }, + "font_strategy_hints": { "type": ["object", "array"] } + } + }, + "ai_rules": { + "type": "array", + "items": { "type": "string" }, + "minItems": 3 + }, + "hard_prompt": { "type": "string" }, + "soft_prompt": { "type": "string" }, + "candidate_rank": { + "type": "array", + "items": { + "type": "object", + "required": ["slug", "score", "reason"], + "properties": { + "slug": { "type": "string" }, + "score": { "type": "number" }, + "reason": { "type": ["string", "object"] } + } + } + } + } +} diff --git a/tests/schemas/qa_prompt_output.json b/tests/schemas/qa_prompt_output.json new file mode 100644 index 0000000..717a832 --- /dev/null +++ b/tests/schemas/qa_prompt_output.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "qa_prompt_output.json", + "title": "qa_prompt.py output", + "type": "object", + "required": [ + "status", + "checks", + "violations", + "autofix_suggestions", + "meta" + ], + "properties": { + "status": { + "type": "string", + "enum": ["pass", "fail"] + }, + "checks": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "severity", "passed", "message"], + "properties": { + "id": { "type": "string" }, + "severity": { "type": "string" }, + "passed": { "type": "boolean" }, + "message": { "type": "string" } + } + } + }, + "violations": { "type": "array" }, + "autofix_suggestions": { "type": "array" }, + "meta": { + "type": "object", + "required": [ + "style", + "expected_lang", + "min_ai_rules", + "prompt_length" + ], + "properties": { + "style": { "type": ["string", "null"] }, + "expected_lang": { "type": "string" }, + "min_ai_rules": { "type": "integer" }, + "prompt_length": { "type": "integer" } + } + } + } +} diff --git a/tests/schemas/run_pipeline_codegen_output.json b/tests/schemas/run_pipeline_codegen_output.json new file mode 100644 index 0000000..b18f284 --- /dev/null +++ b/tests/schemas/run_pipeline_codegen_output.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "run_pipeline_codegen_output.json", + "title": "run_pipeline.py --workflow codegen output", + "type": "object", + "required": [ + "schemaVersion", + "status", + "workflow", + "mode", + "query", + "site_type", + "stack", + "selected_style", + "site_profile", + "tag_bundle", + "composition_plan", + "decision_flow", + "content_plan", + "candidates", + "result", + "manual_assistant", + "quality_gate", + "upgrade_candidates" + ], + "properties": { + "schemaVersion": { "type": "string" }, + "status": { "type": "string" }, + "workflow": { "type": "string", "const": "codegen" }, + "mode": { "type": "string" }, + "query": { "type": "string" }, + "site_type": { "type": "string" }, + "stack": { "type": "string" }, + "selected_style": { "type": "string" }, + "site_profile": { "type": "object" }, + "tag_bundle": { "type": "object" }, + "composition_plan": { "type": "object" }, + "decision_flow": { "type": "object" }, + "content_plan": { "type": "object" }, + "candidates": { "type": "array" }, + "result": { + "type": "object", + "required": ["hard_prompt", "soft_prompt"], + "properties": { + "hard_prompt": { "type": "string" }, + "soft_prompt": { "type": "string" } + } + }, + "manual_assistant": { "type": "object" }, + "quality_gate": { + "type": "object", + "required": ["status", "checks"], + "properties": { + "status": { "type": "string" }, + "checks": { "type": "array" } + } + }, + "upgrade_candidates": { "type": "array" } + } +} diff --git a/tests/schemas/run_pipeline_manual_output.json b/tests/schemas/run_pipeline_manual_output.json new file mode 100644 index 0000000..23cb2b4 --- /dev/null +++ b/tests/schemas/run_pipeline_manual_output.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "run_pipeline_manual_output.json", + "title": "run_pipeline.py --workflow manual output", + "type": "object", + "required": [ + "schemaVersion", + "status", + "workflow", + "mode", + "query", + "site_type", + "stack", + "selected_style", + "site_profile", + "tag_bundle", + "composition_plan", + "decision_flow", + "content_plan", + "candidates", + "result", + "manual_assistant", + "quality_gate", + "upgrade_candidates" + ], + "properties": { + "schemaVersion": { "type": "string" }, + "status": { "type": "string" }, + "workflow": { "type": "string", "const": "manual" }, + "mode": { "type": "string" }, + "query": { "type": "string" }, + "site_type": { "type": "string" }, + "stack": { "type": "string" }, + "selected_style": { "type": "string" }, + "site_profile": { "type": "object" }, + "tag_bundle": { "type": "object" }, + "composition_plan": { "type": "object" }, + "decision_flow": { "type": "object" }, + "content_plan": { "type": "object" }, + "candidates": { "type": "array" }, + "result": { "type": "object" }, + "manual_assistant": { "type": "object" }, + "quality_gate": { + "type": "object", + "required": ["status", "checks"], + "properties": { + "status": { "type": "string" }, + "checks": { "type": "array" } + } + }, + "upgrade_candidates": { "type": "array" } + } +} diff --git a/tests/schemas/search_stylekit_output.json b/tests/schemas/search_stylekit_output.json new file mode 100644 index 0000000..cdb53ed --- /dev/null +++ b/tests/schemas/search_stylekit_output.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "search_stylekit_output.json", + "title": "search_stylekit.py output", + "type": "object", + "required": [ + "query", + "query_tokens", + "expanded_query_tokens", + "top", + "returned", + "schemaVersion", + "candidates" + ], + "properties": { + "query": { "type": "string" }, + "query_tokens": { + "type": "array", + "items": { "type": "string" } + }, + "expanded_query_tokens": { + "type": "array", + "items": { "type": "string" } + }, + "top": { "type": "integer" }, + "returned": { "type": "integer" }, + "style_type_filter": { "type": ["string", "null"] }, + "site_type_filter": { "type": ["string", "null"] }, + "site_profile": { "type": "object" }, + "schemaVersion": { "type": "string" }, + "catalog_schema_version": { "type": ["string", "null"] }, + "generatedAt": { "type": ["string", "null"] }, + "candidates": { + "type": "array", + "items": { + "type": "object", + "required": ["slug", "name", "nameEn", "styleType", "score", "reason"], + "properties": { + "slug": { "type": "string" }, + "name": { "type": "string" }, + "nameEn": { "type": "string" }, + "styleType": { "type": "string" }, + "score": { "type": "number" }, + "reason": { "type": ["string", "object"] }, + "reason_summary": { "type": ["string", "null"] }, + "preview": { "type": "object" } + } + } + } + } +} diff --git a/tests/test_blend_engine.py b/tests/test_blend_engine.py new file mode 100644 index 0000000..1bc3adf --- /dev/null +++ b/tests/test_blend_engine.py @@ -0,0 +1,323 @@ +"""Unit tests for blend_engine module.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from blend_engine import ( + blend_directive, + build_blend_plan, + color_score, + motion_score, + pick_owner, + spacing_score, + typography_score, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _style(slug: str, **overrides: Any) -> dict[str, Any]: + """Build a minimal style dict with given overrides.""" + base: dict[str, Any] = { + "slug": slug, + "name": "", + "nameEn": "", + "styleType": "visual", + "keywords": [], + "aiRules": "", + "philosophy": "", + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# motion_score +# --------------------------------------------------------------------------- + +class TestMotionScore: + """motion_score scores based on motion keywords in aiRules + keywords.""" + + def test_style_with_motion_keywords_scores_higher(self): + motion_style = _style("motion", aiRules="hover transition animation", keywords=["glow"]) + plain_style = _style("plain", aiRules="use clean layout", keywords=["minimal"]) + + assert motion_score(motion_style) > motion_score(plain_style) + + def test_style_without_motion_keywords_scores_zero(self): + plain = _style("plain", aiRules="keep it simple", keywords=["clean"]) + assert motion_score(plain) == 0.0 + + def test_each_keyword_adds_one_point(self): + style = _style("full", aiRules="hover active transition animation motion glow", keywords=[]) + assert motion_score(style) == 6.0 + + def test_chinese_motion_keywords(self): + style = _style("zh", aiRules="悬停 动画 发光", keywords=["动效"]) + assert motion_score(style) >= 4.0 + + def test_minimal_fixture_has_low_motion(self, minimal_style: dict[str, Any]): + score = motion_score(minimal_style) + assert score == 0.0 + + +# --------------------------------------------------------------------------- +# typography_score +# --------------------------------------------------------------------------- + +class TestTypographyScore: + """typography_score scores typography keywords + query token overlap.""" + + def test_typography_keywords_increase_score(self): + style = _style("typo", nameEn="Editorial Serif", keywords=["typography", "readability"]) + score = typography_score(style, []) + assert score >= 3.0 + + def test_query_token_overlap_adds_to_score(self): + style = _style("typo", nameEn="Modern Type", keywords=["modern"]) + score_with = typography_score(style, ["modern"]) + score_without = typography_score(style, ["unrelated"]) + assert score_with > score_without + + def test_no_keywords_no_overlap_scores_zero(self): + style = _style("blank") + assert typography_score(style, []) == 0.0 + + def test_chinese_typography_keywords(self): + style = _style("zh", name="排版风格", keywords=["字体", "可读"]) + assert typography_score(style, []) >= 3.0 + + def test_query_token_each_adds_03(self): + style = _style("s", keywords=["alpha", "beta"]) + score = typography_score(style, ["alpha", "beta"]) + assert score == pytest.approx(0.6, abs=1e-9) + + +# --------------------------------------------------------------------------- +# spacing_score +# --------------------------------------------------------------------------- + +class TestSpacingScore: + """spacing_score gives bonus for layout styleType and layout keywords.""" + + def test_layout_style_type_gets_bonus(self): + layout = _style("layout", styleType="layout") + visual = _style("visual", styleType="visual") + assert spacing_score(layout) >= 2.0 + assert spacing_score(visual) == 0.0 + + def test_layout_keywords_detected(self): + style = _style("grid", keywords=["grid", "dashboard"]) + assert spacing_score(style) >= 1.6 # 2 keywords * 0.8 + + def test_combined_type_and_keywords(self): + style = _style("full-layout", styleType="layout", keywords=["grid", "sidebar"]) + score = spacing_score(style) + assert score >= 3.5 # base 2 + 2 keywords * 0.8 + + def test_no_layout_signals_scores_zero(self): + style = _style("none", styleType="visual", keywords=["clean", "modern"]) + assert spacing_score(style) == 0.0 + + def test_chinese_layout_keywords(self): + style = _style("zh", keywords=["布局", "网格", "间距"]) + assert spacing_score(style) >= 2.4 # 3 * 0.8 + + +# --------------------------------------------------------------------------- +# color_score +# --------------------------------------------------------------------------- + +class TestColorScore: + """color_score detects color keywords and query token overlap.""" + + def test_color_keywords_detected(self): + style = _style("neon", nameEn="Neon Glass", keywords=["gradient", "palette"]) + score = color_score(style, []) + assert score >= 2.8 # 4 keywords * 0.7 + + def test_query_token_overlap(self): + style = _style("s", keywords=["neon", "dark"]) + score_with = color_score(style, ["neon"]) + score_without = color_score(style, ["unrelated"]) + assert score_with > score_without + + def test_no_color_signals_scores_zero(self): + style = _style("blank", keywords=["clean", "modern"]) + assert color_score(style, []) == 0.0 + + def test_chinese_color_keywords(self): + style = _style("zh", keywords=["色彩", "霓虹", "渐变", "高端"]) + assert color_score(style, []) >= 2.8 # 4 * 0.7 + + def test_each_query_token_adds_025(self): + style = _style("s", keywords=["alpha", "beta"]) + score = color_score(style, ["alpha", "beta"]) + assert score == pytest.approx(0.5, abs=1e-9) + + +# --------------------------------------------------------------------------- +# pick_owner +# --------------------------------------------------------------------------- + +class TestPickOwner: + """pick_owner returns slug of the highest scoring style.""" + + def test_returns_highest_scoring_slug(self): + styles = [ + _style("low", aiRules="simple"), + _style("high", aiRules="hover transition animation glow"), + ] + result = pick_owner(styles, motion_score) + assert result == "high" + + def test_empty_list_returns_empty_string(self): + assert pick_owner([], motion_score) == "" + + def test_single_style_returns_its_slug(self): + styles = [_style("only")] + assert pick_owner(styles, motion_score) == "only" + + def test_works_with_lambda_scorer(self): + styles = [_style("a", keywords=["neon"]), _style("b", keywords=["clean"])] + result = pick_owner(styles, lambda s: color_score(s, [])) + assert result == "a" + + +# --------------------------------------------------------------------------- +# build_blend_plan +# --------------------------------------------------------------------------- + +class TestBuildBlendPlan: + """build_blend_plan returns enabled:False for single style, full plan for multiple.""" + + def test_single_style_returns_disabled_plan(self): + primary = _style("solo", keywords=["modern"]) + plan = build_blend_plan(primary, [], "modern website", "en") + + assert plan["enabled"] is False + assert plan["base_style"] == "solo" + assert plan["blend_styles"] == [] + assert plan["conflict_resolution"] == {} + + def test_single_style_zh_note(self): + primary = _style("solo") + plan = build_blend_plan(primary, [], "现代网站", "zh") + assert "没有可用于融合" in plan["notes"] + + def test_multiple_styles_returns_enabled_plan(self): + primary = _style("primary", keywords=["neon", "gradient"]) + alt1 = _style("alt1", keywords=["typography", "serif"]) + alt2 = _style("alt2", keywords=["grid", "layout"], styleType="layout") + + plan = build_blend_plan(primary, [alt1, alt2], "neon typography layout", "en") + + assert plan["enabled"] is True + assert plan["base_style"] == "primary" + + def test_conflict_resolution_keys_present(self): + primary = _style("p", keywords=["neon"]) + alt = _style("a", keywords=["serif"]) + plan = build_blend_plan(primary, [alt], "design", "en") + + cr = plan["conflict_resolution"] + assert "color_owner" in cr + assert "typography_owner" in cr + assert "spacing_owner" in cr + assert "motion_owner" in cr + + def test_blend_styles_have_weights(self): + primary = _style("p") + alt = _style("a") + plan = build_blend_plan(primary, [alt], "test", "en") + + assert len(plan["blend_styles"]) >= 1 + for entry in plan["blend_styles"]: + assert "slug" in entry + assert "weight" in entry + + def test_priority_order_starts_with_primary(self): + primary = _style("main") + alt = _style("sec") + plan = build_blend_plan(primary, [alt], "test", "en") + assert plan["priority_order"][0] == "main" + + def test_alternatives_with_style_key_unwrapped(self): + primary = _style("p") + alt_wrapped = {"style": _style("wrapped-alt", keywords=["color"])} + plan = build_blend_plan(primary, [alt_wrapped], "test", "en") + + assert plan["enabled"] is True + slugs = [entry["slug"] for entry in plan["blend_styles"]] + assert "wrapped-alt" in slugs + + +# --------------------------------------------------------------------------- +# blend_directive +# --------------------------------------------------------------------------- + +class TestBlendDirective: + """blend_directive returns non-empty string for enabled plans, empty for disabled.""" + + def test_disabled_plan_returns_empty(self): + plan = {"enabled": False, "conflict_resolution": {}} + assert blend_directive(plan, "en") == "" + + def test_enabled_plan_returns_nonempty_en(self): + plan = { + "enabled": True, + "conflict_resolution": { + "color_owner": "style-a", + "typography_owner": "style-b", + "spacing_owner": "style-a", + "motion_owner": "style-b", + }, + } + result = blend_directive(plan, "en") + assert result != "" + assert "style-a" in result + assert "style-b" in result + assert "Blend rules" in result + + def test_enabled_plan_returns_nonempty_zh(self): + plan = { + "enabled": True, + "conflict_resolution": { + "color_owner": "neon", + "typography_owner": "editorial", + "spacing_owner": "grid-master", + "motion_owner": "neon", + }, + } + result = blend_directive(plan, "zh") + assert result != "" + assert "neon" in result + assert "融合规则" in result + + def test_directive_contains_all_owner_names(self): + plan = { + "enabled": True, + "conflict_resolution": { + "color_owner": "alpha", + "typography_owner": "beta", + "spacing_owner": "gamma", + "motion_owner": "delta", + }, + } + result = blend_directive(plan, "en") + for owner in ("alpha", "beta", "gamma", "delta"): + assert owner in result + + def test_integration_with_build_blend_plan(self): + primary = _style("main", keywords=["neon", "gradient"]) + alt = _style("sub", keywords=["typography", "serif"]) + plan = build_blend_plan(primary, [alt], "neon serif design", "en") + result = blend_directive(plan, "en") + + assert result != "" + assert "main" in result or "sub" in result diff --git a/tests/test_brief_builder.py b/tests/test_brief_builder.py new file mode 100644 index 0000000..48d074a --- /dev/null +++ b/tests/test_brief_builder.py @@ -0,0 +1,324 @@ +"""Unit tests for brief_builder module.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from brief_builder import ( + anti_generic_constraints, + build_component_guidelines, + build_interaction_rules, + design_system_structure, + infer_design_intent, + localized_visual_direction, +) + + +# --------------------------------------------------------------------------- +# build_component_guidelines +# --------------------------------------------------------------------------- + +class TestBuildComponentGuidelines: + """Tests for build_component_guidelines.""" + + def test_all_components_produce_guidelines(self, minimal_style: dict[str, Any]) -> None: + result = build_component_guidelines(minimal_style, "en") + assert isinstance(result, list) + # minimal_style has 6 components, output is capped at 6 + assert len(result) == 6 + assert any("Button" in g or "button" in g.lower() for g in result) + assert any("Card" in g or "card" in g.lower() for g in result) + assert any("Input" in g or "input" in g.lower() for g in result) + assert any("Nav" in g or "nav" in g.lower() for g in result) + assert any("Hero" in g or "hero" in g.lower() for g in result) + assert any("Footer" in g or "footer" in g.lower() for g in result) + + def test_empty_components_fallback(self) -> None: + style: dict[str, Any] = {"slug": "empty", "components": {}} + result = build_component_guidelines(style, "en") + assert len(result) >= 1 + assert "aiRules" in result[0] or "doList" in result[0] + + def test_empty_components_fallback_zh(self) -> None: + style: dict[str, Any] = {"slug": "empty", "components": {}} + result = build_component_guidelines(style, "zh") + assert len(result) >= 1 + assert "aiRules" in result[0] or "doList" in result[0] + + def test_interaction_pattern_adds_missing_components(self) -> None: + style: dict[str, Any] = { + "slug": "slim", + "components": {"button": True, "card": True}, + } + pattern_data: dict[str, Any] = { + "required_components": ["Accordion", "Modal"], + } + result = build_component_guidelines(style, "en", interaction_pattern_data=pattern_data) + joined = " ".join(result) + assert "Interaction pattern requires" in joined + assert "Accordion" in joined or "Modal" in joined + + def test_interaction_pattern_no_duplicate_for_existing(self) -> None: + # "button" already covered by component guidelines, should not appear as missing + style: dict[str, Any] = { + "slug": "slim", + "components": {"button": True}, + } + pattern_data: dict[str, Any] = { + "required_components": ["button"], + } + result = build_component_guidelines(style, "en", interaction_pattern_data=pattern_data) + assert not any("Interaction pattern requires" in g for g in result) + + def test_zh_language_output(self, minimal_style: dict[str, Any]) -> None: + result = build_component_guidelines(minimal_style, "zh") + assert isinstance(result, list) + assert len(result) >= 1 + # Chinese output should contain CJK characters + assert any("\u4e00" <= ch <= "\u9fff" for g in result for ch in g) + + +# --------------------------------------------------------------------------- +# build_interaction_rules +# --------------------------------------------------------------------------- + +class TestBuildInteractionRules: + """Tests for build_interaction_rules.""" + + def test_matching_keywords_selected(self) -> None: + ai_rules = [ + "Use smooth hover transitions on all interactive elements.", + "Keep font sizes consistent.", + "Apply active state feedback on buttons.", + "Use focus-visible ring for keyboard navigation.", + "Maintain color contrast ratios.", + ] + result = build_interaction_rules(ai_rules, "en") + assert any("hover" in r.lower() for r in result) + assert any("active" in r.lower() for r in result) + assert any("focus" in r.lower() for r in result) + # Non-matching rules should not appear + assert not any("font sizes" in r for r in result) + + def test_few_matching_adds_fallback(self) -> None: + ai_rules = ["Keep spacing consistent.", "Use semantic colors."] + result = build_interaction_rules(ai_rules, "en") + assert len(result) >= 3 + assert any("hover" in r.lower() for r in result) + assert any("150-300ms" in r for r in result) + + def test_few_matching_adds_fallback_zh(self) -> None: + ai_rules = ["保持间距一致。"] + result = build_interaction_rules(ai_rules, "zh") + assert len(result) >= 3 + assert any("hover" in r for r in result) + assert any("150-300ms" in r for r in result) + + def test_accessibility_constraints_from_pattern_data(self) -> None: + ai_rules = ["Use hover effect on cards."] + pattern_data: dict[str, Any] = { + "accessibility_constraints": [ + "Ensure minimum touch target of 44px.", + "Provide aria-labels on all icon buttons.", + ], + } + result = build_interaction_rules(ai_rules, "en", interaction_pattern_data=pattern_data) + assert any("44px" in r or "aria-label" in r for r in result) + + def test_deduplication(self) -> None: + ai_rules = [ + "Use hover transitions.", + "Use hover transitions.", + "Apply focus ring.", + "Apply focus ring.", + ] + result = build_interaction_rules(ai_rules, "en") + lowered = [r.lower().strip() for r in result] + assert len(lowered) == len(set(lowered)) + + def test_max_six_rules(self) -> None: + ai_rules = [ + f"Rule about hover effect variant {i}" for i in range(10) + ] + result = build_interaction_rules(ai_rules, "en") + assert len(result) <= 6 + + +# --------------------------------------------------------------------------- +# design_system_structure +# --------------------------------------------------------------------------- + +class TestDesignSystemStructure: + """Tests for design_system_structure.""" + + def test_returns_required_keys_en(self) -> None: + result = design_system_structure("React + Tailwind", "en") + assert "token_hierarchy" in result + assert "component_architecture" in result + assert isinstance(result["token_hierarchy"], list) + assert isinstance(result["component_architecture"], list) + + def test_stack_name_in_output_en(self) -> None: + stack = "Vue + UnoCSS" + result = design_system_structure(stack, "en") + combined = " ".join(result["component_architecture"]) + assert stack in combined + + def test_returns_required_keys_zh(self) -> None: + result = design_system_structure("React + Tailwind", "zh") + assert "token_hierarchy" in result + assert "component_architecture" in result + assert isinstance(result["token_hierarchy"], list) + assert isinstance(result["component_architecture"], list) + + def test_stack_name_in_output_zh(self) -> None: + stack = "Next.js" + result = design_system_structure(stack, "zh") + combined = " ".join(result["component_architecture"]) + assert stack in combined + + def test_en_content_is_english(self) -> None: + result = design_system_structure("React", "en") + first_token = result["token_hierarchy"][0] + assert "Brand" in first_token or "brand" in first_token.lower() + + def test_zh_content_is_chinese(self) -> None: + result = design_system_structure("React", "zh") + first_token = result["token_hierarchy"][0] + assert any("\u4e00" <= ch <= "\u9fff" for ch in first_token) + + +# --------------------------------------------------------------------------- +# infer_design_intent +# --------------------------------------------------------------------------- + +class TestInferDesignIntent: + """Tests for infer_design_intent.""" + + def test_returns_all_four_keys(self) -> None: + result = infer_design_intent("build a portfolio site", "en") + assert set(result.keys()) == {"purpose", "audience", "tone", "memorable_hook"} + + def test_saas_query_b_end_audience_en(self) -> None: + result = infer_design_intent("saas dashboard for analytics", "en") + assert "Professional" in result["audience"] or "professional" in result["audience"].lower() + + def test_saas_query_b_end_audience_zh(self) -> None: + result = infer_design_intent("SaaS 后台管理系统", "zh") + assert "B 端" in result["audience"] + + def test_landing_query_prospects_en(self) -> None: + result = infer_design_intent("landing page for marketing", "en") + assert "Prospect" in result["audience"] or "prospect" in result["audience"].lower() + + def test_landing_query_prospects_zh(self) -> None: + result = infer_design_intent("品牌营销着陆页", "zh") + assert "潜在客户" in result["audience"] + + def test_generic_query_general_audience_en(self) -> None: + result = infer_design_intent("a personal blog", "en") + assert "General" in result["audience"] or "general" in result["audience"].lower() + + def test_glass_tone_en(self) -> None: + result = infer_design_intent("frosted glass ui", "en") + assert "translucen" in result["tone"].lower() or "modern" in result["tone"].lower() + + def test_retro_tone_en(self) -> None: + result = infer_design_intent("retro y2k website", "en") + assert "nostalgic" in result["tone"].lower() or "retro" in result["tone"].lower() + + def test_minimal_tone_en(self) -> None: + result = infer_design_intent("clean minimal portfolio", "en") + assert "minimal" in result["tone"].lower() + + def test_en_vs_zh_different_language(self) -> None: + en = infer_design_intent("saas dashboard", "en") + zh = infer_design_intent("saas dashboard", "zh") + assert en["purpose"] != zh["purpose"] + + +# --------------------------------------------------------------------------- +# localized_visual_direction +# --------------------------------------------------------------------------- + +class TestLocalizedVisualDirection: + """Tests for localized_visual_direction.""" + + def test_has_philosophy_returns_excerpt_en(self, minimal_style: dict[str, Any]) -> None: + # minimal_style philosophy is English text + result = localized_visual_direction(minimal_style, "en") + assert "test style" in result.lower() + + def test_no_philosophy_fallback_en(self) -> None: + style: dict[str, Any] = {"slug": "neo", "name": "新风格", "nameEn": "Neo Style"} + result = localized_visual_direction(style, "en") + assert "Neo Style" in result + assert "visual identity" in result.lower() + + def test_no_philosophy_fallback_zh(self) -> None: + style: dict[str, Any] = {"slug": "neo", "name": "新风格", "nameEn": "Neo Style"} + result = localized_visual_direction(style, "zh") + assert "新风格" in result + assert "风格识别度" in result + + def test_chinese_philosophy_returned_for_zh(self) -> None: + style: dict[str, Any] = { + "slug": "cn", + "name": "中式", + "philosophy": "中国传统美学,融合现代设计。\n\n更多内容。", + } + result = localized_visual_direction(style, "zh") + assert "中国传统美学" in result + # Only first paragraph + assert "更多内容" not in result + + def test_english_philosophy_not_used_for_zh(self) -> None: + style: dict[str, Any] = { + "slug": "test", + "name": "测试", + "nameEn": "Test", + "philosophy": "English only philosophy without CJK.", + } + result = localized_visual_direction(style, "zh") + # Should fall back since philosophy has no CJK + assert "测试" in result + assert "风格识别度" in result + + def test_fallback_uses_slug_when_name_missing(self) -> None: + style: dict[str, Any] = {"slug": "fallback-slug"} + result = localized_visual_direction(style, "en") + assert "fallback-slug" in result + + +# --------------------------------------------------------------------------- +# anti_generic_constraints +# --------------------------------------------------------------------------- + +class TestAntiGenericConstraints: + """Tests for anti_generic_constraints.""" + + def test_en_returns_english_list(self) -> None: + result = anti_generic_constraints("en") + assert isinstance(result, list) + assert len(result) >= 3 + assert all(isinstance(r, str) for r in result) + assert any("generic" in r.lower() or "avoid" in r.lower() for r in result) + + def test_zh_returns_chinese_list(self) -> None: + result = anti_generic_constraints("zh") + assert isinstance(result, list) + assert len(result) >= 3 + # Should contain CJK characters + assert any("\u4e00" <= ch <= "\u9fff" for r in result for ch in r) + + def test_en_and_zh_different(self) -> None: + en = anti_generic_constraints("en") + zh = anti_generic_constraints("zh") + assert en != zh + + def test_each_constraint_is_nonempty(self) -> None: + for lang in ("en", "zh"): + result = anti_generic_constraints(lang) + assert all(len(r.strip()) > 0 for r in result) diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 0000000..dd0f37d --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,206 @@ +"""Unit tests for scripts/_common.py utilities.""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from _common import load_json, normalize_text, now_iso, tokenize + + +# --------------------------------------------------------------------------- +# load_json +# --------------------------------------------------------------------------- + +class TestLoadJson: + """Tests for load_json().""" + + def test_loads_valid_json(self, tmp_path: Path) -> None: + data = {"key": "value", "nested": {"a": 1}} + fp = tmp_path / "valid.json" + fp.write_text(json.dumps(data), encoding="utf-8") + + result = load_json(fp) + + assert result == data + + def test_raises_on_missing_file(self, tmp_path: Path) -> None: + missing = tmp_path / "does_not_exist.json" + + with pytest.raises(FileNotFoundError): + load_json(missing) + + def test_raises_on_invalid_json(self, tmp_path: Path) -> None: + fp = tmp_path / "bad.json" + fp.write_text("{not valid json!!", encoding="utf-8") + + with pytest.raises(json.JSONDecodeError): + load_json(fp) + + def test_loads_utf8_content(self, tmp_path: Path) -> None: + data = {"name": "测试风格", "desc": "日本語テスト"} + fp = tmp_path / "utf8.json" + fp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + result = load_json(fp) + + assert result == data + + +# --------------------------------------------------------------------------- +# normalize_text +# --------------------------------------------------------------------------- + +class TestNormalizeText: + """Tests for normalize_text().""" + + def test_lowercases_text(self) -> None: + assert normalize_text("Hello WORLD") == "hello world" + + def test_strips_punctuation(self) -> None: + # Punctuation replaced by space, then whitespace collapsed + assert normalize_text("hello, world!") == "hello world" + result = normalize_text("one...two///three") + assert " " not in result # no double spaces after collapse + + def test_preserves_cjk_characters(self) -> None: + result = normalize_text("现代简约风格") + assert result == "现代简约风格" + + def test_preserves_hyphens(self) -> None: + result = normalize_text("modern-tech") + assert result == "modern-tech" + + def test_preserves_alphanumeric(self) -> None: + result = normalize_text("grid2x layout3") + assert result == "grid2x layout3" + + def test_handles_none(self) -> None: + assert normalize_text(None) == "" + + def test_handles_empty_string(self) -> None: + assert normalize_text("") == "" + + def test_collapses_whitespace(self) -> None: + assert normalize_text(" too many spaces ") == "too many spaces" + + def test_mixed_cjk_and_latin(self) -> None: + result = normalize_text("Modern 现代 Style") + assert "modern" in result + assert "现代" in result + assert "style" in result + + +# --------------------------------------------------------------------------- +# tokenize +# --------------------------------------------------------------------------- + +class TestTokenize: + """Tests for tokenize().""" + + def test_english_terms(self) -> None: + tokens = tokenize("dark gradient cards") + assert "dark" in tokens + assert "gradient" in tokens + assert "cards" in tokens + + def test_filters_stopwords(self) -> None: + tokens = tokenize("the design for your page") + # "the", "for", "your", "page", "design" are all stopwords + assert "the" not in tokens + assert "for" not in tokens + assert "your" not in tokens + assert "page" not in tokens + assert "design" not in tokens + + def test_filters_single_char_latin(self) -> None: + tokens = tokenize("a b c real") + assert "a" not in tokens + assert "b" not in tokens + assert "c" not in tokens + assert "real" in tokens + + def test_cjk_bigrams(self) -> None: + # "现代简约" -> bigrams: "现代", "代简", "简约" + tokens = tokenize("现代简约") + assert "现代" in tokens + assert "代简" in tokens + assert "简约" in tokens + + def test_cjk_full_term_included(self) -> None: + # Full CJK run (>=2 chars, not a stopword) is also included + tokens = tokenize("现代简约") + assert "现代简约" in tokens + + def test_cjk_stopword_bigrams_filtered(self) -> None: + # "风格" and "设计" are in STOPWORDS + tokens = tokenize("风格") + assert "风格" not in tokens + + def test_mixed_cjk_and_english(self) -> None: + tokens = tokenize("modern 现代简约 cards") + assert "modern" in tokens + assert "cards" in tokens + assert "现代" in tokens + + def test_hyphenated_terms_split(self) -> None: + tokens = tokenize("modern-tech") + assert "modern" in tokens + assert "tech" in tokens + + def test_hyphen_single_char_parts_filtered(self) -> None: + tokens = tokenize("a-real-b") + assert "a" not in tokens + assert "b" not in tokens + assert "real" in tokens + + def test_empty_input(self) -> None: + assert tokenize("") == [] + + def test_only_stopwords_returns_empty(self) -> None: + tokens = tokenize("the and for with") + assert tokens == [] + + def test_punctuation_stripped_before_tokenizing(self) -> None: + tokens = tokenize("hello! world?") + assert "hello" in tokens + assert "world" in tokens + + +# --------------------------------------------------------------------------- +# now_iso +# --------------------------------------------------------------------------- + +class TestNowIso: + """Tests for now_iso().""" + + def test_returns_string(self) -> None: + result = now_iso() + assert isinstance(result, str) + + def test_ends_with_z(self) -> None: + result = now_iso() + assert result.endswith("Z") + + def test_valid_iso_format(self) -> None: + result = now_iso() + # Should parse without error; strip trailing Z for fromisoformat + parsed = datetime.fromisoformat(result.replace("Z", "+00:00")) + assert parsed.tzinfo is not None + + def test_no_microseconds(self) -> None: + result = now_iso() + # ISO with microseconds would contain a dot before timezone + assert "." not in result + + def test_reasonable_timestamp(self) -> None: + before = datetime.now(timezone.utc).replace(microsecond=0) + result = now_iso() + after = datetime.now(timezone.utc).replace(microsecond=0) + + parsed = datetime.fromisoformat(result.replace("Z", "+00:00")) + assert before <= parsed <= after diff --git a/tests/test_output_contracts.py b/tests/test_output_contracts.py new file mode 100644 index 0000000..e1b8e63 --- /dev/null +++ b/tests/test_output_contracts.py @@ -0,0 +1,261 @@ +"""Contract tests: validate real pipeline outputs against JSON Schema (Draft 2020-12). + +Every test runs the actual CLI script via subprocess, parses the JSON output, +and validates it against the corresponding schema in tests/schemas/. + +All tests are marked ``@pytest.mark.slow`` so they can be excluded from fast +unit-test runs (``pytest -m 'not slow'``). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft202012Validator + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +SKILL_ROOT = Path(__file__).resolve().parent.parent +SCRIPTS_DIR = SKILL_ROOT / "scripts" +SCHEMAS_DIR = Path(__file__).resolve().parent / "schemas" +PYTHON = sys.executable + + +# --------------------------------------------------------------------------- +# Schema loaders +# --------------------------------------------------------------------------- + +def _load_schema(name: str) -> dict[str, Any]: + path = SCHEMAS_DIR / name + with path.open("r", encoding="utf-8") as fh: + schema = json.load(fh) + Draft202012Validator.check_schema(schema) + return schema + + +@pytest.fixture(scope="module") +def brief_schema() -> dict[str, Any]: + return _load_schema("generate_brief_output.json") + + +@pytest.fixture(scope="module") +def pipeline_manual_schema() -> dict[str, Any]: + return _load_schema("run_pipeline_manual_output.json") + + +@pytest.fixture(scope="module") +def pipeline_codegen_schema() -> dict[str, Any]: + return _load_schema("run_pipeline_codegen_output.json") + + +@pytest.fixture(scope="module") +def qa_schema() -> dict[str, Any]: + return _load_schema("qa_prompt_output.json") + + +@pytest.fixture(scope="module") +def search_schema() -> dict[str, Any]: + return _load_schema("search_stylekit_output.json") + + +@pytest.fixture(scope="module") +def benchmark_schema() -> dict[str, Any]: + return _load_schema("benchmark_pipeline_output.json") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _run_script(script: str, args: list[str], *, timeout: int = 120) -> dict[str, Any]: + """Run a script under ``scripts/`` and return parsed JSON output.""" + cmd = [PYTHON, str(SCRIPTS_DIR / script), *args] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + cwd=str(SKILL_ROOT), + ) + assert result.returncode == 0, ( + f"{script} exited with code {result.returncode}\n" + f"--- stderr ---\n{result.stderr[-2000:]}" + ) + payload = json.loads(result.stdout) + assert isinstance(payload, dict), f"Expected dict, got {type(payload).__name__}" + return payload + + +def _validate(payload: dict[str, Any], schema: dict[str, Any]) -> None: + """Validate *payload* against *schema* using Draft 2020-12.""" + validator = Draft202012Validator(schema) + errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.path)) + if errors: + msgs = [] + for err in errors[:10]: + path = ".".join(str(p) for p in err.absolute_path) or "" + msgs.append(f" [{path}] {err.message}") + raise AssertionError( + f"Schema validation failed ({len(errors)} error(s)):\n" + "\n".join(msgs) + ) + + +# --------------------------------------------------------------------------- +# Parametrized query x stack combos +# --------------------------------------------------------------------------- + +QUERY_STACK_COMBOS = [ + pytest.param( + "做一个SaaS数据分析仪表盘,带实时图表和KPI卡片", + "nextjs", + id="zh-saas-nextjs", + ), + pytest.param( + "Build a modern landing page for a design agency with bold typography", + "html-tailwind", + id="en-landing-html", + ), + pytest.param( + "创建一个极简主义技术博客,带代码高亮和暗色模式", + "react", + id="zh-blog-react", + ), +] + + +# --------------------------------------------------------------------------- +# Contract tests +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +class TestGenerateBriefContract: + """generate_brief.py --mode brief+prompt output conforms to schema.""" + + @pytest.mark.parametrize("query,stack", QUERY_STACK_COMBOS) + def test_generate_brief_contract( + self, + query: str, + stack: str, + brief_schema: dict[str, Any], + ) -> None: + payload = _run_script( + "generate_brief.py", + ["--query", query, "--stack", stack, "--mode", "brief+prompt"], + ) + _validate(payload, brief_schema) + + +@pytest.mark.slow +class TestRunPipelineManualContract: + """run_pipeline.py --workflow manual output conforms to schema.""" + + @pytest.mark.parametrize("query,stack", QUERY_STACK_COMBOS) + def test_run_pipeline_manual_contract( + self, + query: str, + stack: str, + pipeline_manual_schema: dict[str, Any], + ) -> None: + payload = _run_script( + "run_pipeline.py", + [ + "--query", query, + "--stack", stack, + "--workflow", "manual", + "--format", "json", + ], + ) + _validate(payload, pipeline_manual_schema) + + +@pytest.mark.slow +class TestRunPipelineCodegenContract: + """run_pipeline.py --workflow codegen output conforms to schema.""" + + @pytest.mark.parametrize("query,stack", QUERY_STACK_COMBOS) + def test_run_pipeline_codegen_contract( + self, + query: str, + stack: str, + pipeline_codegen_schema: dict[str, Any], + ) -> None: + payload = _run_script( + "run_pipeline.py", + [ + "--query", query, + "--stack", stack, + "--workflow", "codegen", + "--format", "json", + ], + ) + _validate(payload, pipeline_codegen_schema) + + +@pytest.mark.slow +class TestQaPromptContract: + """qa_prompt.py output conforms to schema.""" + + @pytest.mark.parametrize("query,stack", QUERY_STACK_COMBOS) + def test_qa_prompt_contract( + self, + query: str, + stack: str, + qa_schema: dict[str, Any], + ) -> None: + # First generate a prompt to feed into QA + brief_payload = _run_script( + "generate_brief.py", + ["--query", query, "--stack", stack, "--mode", "brief+prompt"], + ) + hard_prompt = brief_payload.get("hard_prompt", "") + assert hard_prompt, "generate_brief produced empty hard_prompt" + + # Run QA audit on the generated prompt + payload = _run_script( + "qa_prompt.py", + ["--text", hard_prompt, "--lang", brief_payload.get("language", "en")], + ) + _validate(payload, qa_schema) + + +@pytest.mark.slow +class TestSearchStylekitContract: + """search_stylekit.py output conforms to schema.""" + + @pytest.mark.parametrize("query,stack", QUERY_STACK_COMBOS) + def test_search_stylekit_contract( + self, + query: str, + stack: str, + search_schema: dict[str, Any], + ) -> None: + del stack # stack is not used by search_stylekit; keep param matrix consistent. + payload = _run_script( + "search_stylekit.py", + ["--query", query, "--top", "5", "--format", "json"], + ) + _validate(payload, search_schema) + + +@pytest.mark.slow +class TestBenchmarkPipelineContract: + """benchmark_pipeline.py output conforms to schema.""" + + def test_benchmark_pipeline_contract( + self, + benchmark_schema: dict[str, Any], + ) -> None: + payload = _run_script( + "benchmark_pipeline.py", + ["--format", "json", "--show-cases", "1"], + timeout=240, + ) + _validate(payload, benchmark_schema) diff --git a/tests/test_qa_prompt.py b/tests/test_qa_prompt.py new file mode 100644 index 0000000..2d7738a --- /dev/null +++ b/tests/test_qa_prompt.py @@ -0,0 +1,437 @@ +"""Unit tests for qa_prompt.py quality-gate functions.""" + +from __future__ import annotations + +import json +import pytest + +from qa_prompt import ( + contains_any, + contains_any_positive, + extract_bullet_rules, + has_cjk, + infer_expected_lang, + read_prompt_text, + rules_conflict, + run, +) + + +# --------------------------------------------------------------------------- +# 1. rules_conflict +# --------------------------------------------------------------------------- + + +class TestRulesConflict: + """rules_conflict: polarity, token overlap, and utility conflicts.""" + + def test_same_polarity_returns_false(self): + """Two positive (no negator) rules never conflict regardless of overlap.""" + a = "Use rounded corners with shadow-lg on all cards" + b = "Use rounded corners with shadow-lg on all buttons" + assert rules_conflict(a, b) is False + + def test_same_polarity_both_negative_returns_false(self): + """Two negative rules also share polarity -> False.""" + a = "Avoid rounded corners on cards" + b = "Never use rounded corners on buttons" + assert rules_conflict(a, b) is False + + def test_opposite_polarity_overlapping_tokens_returns_true(self): + """Opposite polarity with sufficient token overlap -> True.""" + a = "Use large rounded corners with prominent shadow on every card component" + b = "Never use large rounded corners with prominent shadow on every card component" + assert rules_conflict(a, b) is True + + def test_opposite_polarity_no_overlap_returns_false(self): + """Opposite polarity but completely unrelated topics -> False.""" + a = "Use bold colorful gradients across hero sections" + b = "Avoid nested scroll containers inside modals" + assert rules_conflict(a, b) is False + + def test_utility_conflict_rounded(self): + """Utility conflict: same radius family with overlapping values.""" + a = "Use rounded-lg for buttons" + b = "Never use rounded-lg for containers" + assert rules_conflict(a, b) is True + + def test_utility_conflict_shadow(self): + """Utility conflict: shadow family.""" + a = "Apply shadow-md to cards" + b = "Avoid shadow-md on interactive elements" + assert rules_conflict(a, b) is True + + def test_no_utility_conflict_different_values(self): + """Different radius values with opposite polarity but no token overlap.""" + a = "Use rounded-full on avatars" + b = "Avoid rounded-none on profile pictures" + assert rules_conflict(a, b) is False + + +# --------------------------------------------------------------------------- +# 2. has_cjk / infer_expected_lang +# --------------------------------------------------------------------------- + + +class TestCjkDetection: + """has_cjk and infer_expected_lang behavior.""" + + def test_has_cjk_true(self): + assert has_cjk("hello 你好") is True + + def test_has_cjk_false(self): + assert has_cjk("only english text") is False + + def test_has_cjk_empty(self): + assert has_cjk("") is False + + def test_has_cjk_none(self): + assert has_cjk(None) is False + + def test_infer_lang_override_en(self): + """Explicit lang='en' overrides CJK presence.""" + assert infer_expected_lang("包含中文字符", "en") == "en" + + def test_infer_lang_override_zh(self): + """Explicit lang='zh' overrides English-only text.""" + assert infer_expected_lang("no cjk here", "zh") == "zh" + + def test_infer_lang_auto_en(self): + assert infer_expected_lang("all english words", None) == "en" + + def test_infer_lang_auto_zh(self): + assert infer_expected_lang("这是一段中文文本", None) == "zh" + + +# --------------------------------------------------------------------------- +# 3. extract_bullet_rules +# --------------------------------------------------------------------------- + + +class TestExtractBulletRules: + """extract_bullet_rules: markdown bullet extraction.""" + + def test_dash_bullets(self): + text = "# Title\n- Use rounded corners on all cards\n- Avoid nested scroll containers" + rules = extract_bullet_rules(text) + assert len(rules) == 2 + assert "Use rounded corners on all cards" in rules + + def test_asterisk_bullets(self): + text = "* Use consistent spacing across sections\n* Maintain visual hierarchy" + rules = extract_bullet_rules(text) + assert len(rules) == 2 + + def test_numbered_bullets(self): + text = "1. Use semantic token naming conventions\n2. Ensure WCAG 4.5:1 contrast ratio" + rules = extract_bullet_rules(text) + assert len(rules) == 2 + + def test_short_rules_filtered(self): + """Rules shorter than 8 characters are dropped.""" + text = "- Use it\n- Use consistent spacing across sections" + rules = extract_bullet_rules(text) + assert len(rules) == 1 + + def test_empty_text(self): + assert extract_bullet_rules("") == [] + + def test_no_bullets(self): + text = "This is a paragraph without any bullet points." + assert extract_bullet_rules(text) == [] + + +# --------------------------------------------------------------------------- +# 4. contains_any / contains_any_positive +# --------------------------------------------------------------------------- + + +class TestContainsAny: + """contains_any: simple keyword hit detection.""" + + def test_single_hit(self): + assert contains_any("use hover states", ["hover"]) == ["hover"] + + def test_multiple_hits(self): + hits = contains_any("hover and focus transition", ["hover", "focus", "animation"]) + assert "hover" in hits + assert "focus" in hits + assert "animation" not in hits + + def test_no_hits(self): + assert contains_any("nothing relevant here", ["hover", "focus"]) == [] + + +class TestContainsAnyPositive: + """contains_any_positive: negation-aware hit detection.""" + + def test_positive_mention(self): + hits = contains_any_positive("Use hover states for feedback", ["hover"]) + assert hits == ["hover"] + + def test_negated_mention_excluded(self): + hits = contains_any_positive("don't use hover states on mobile", ["hover"]) + assert hits == [] + + def test_negated_with_avoid(self): + hits = contains_any_positive("avoid using arial as primary font", ["arial"]) + assert hits == [] + + def test_mixed_positive_and_negated(self): + text = "Use inter for body text. don't use arial for headings." + hits = contains_any_positive(text, ["inter", "arial"]) + assert "inter" in hits + assert "arial" not in hits + + def test_far_negator_not_blocking(self): + """Negator outside window (>40 chars away) should not block.""" + # Build text where "don't" is more than 40 chars before the keyword + spacer = "x" * 50 + text = f"don't do something unrelated {spacer} use rounded corners" + hits = contains_any_positive(text, ["rounded"]) + assert "rounded" in hits + + +# --------------------------------------------------------------------------- +# 5. run() — well-formed prompt vs minimal/empty +# --------------------------------------------------------------------------- + +WELL_FORMED_PROMPT = """ +# Modern Dashboard Style + +- Use Manrope font family for headings and DM Sans for body text +- Apply rounded-xl corners on all card components with shadow-md elevation +- Use semantic design tokens for primary, secondary, and accent colors +- Maintain consistent spacing scale using 4px base unit with radius variants +- Ensure button states include hover, active, and focus-visible transitions +- Apply WCAG 4.5:1 contrast ratio for all text-on-background combinations +- Set touch target minimum 44x44px for mobile interactive elements +- Use component variants for each interactive state (default, hover, active, disabled) +- Apply swap test and squint test before final delivery to prevent generic output +- Don't use absolute positioning for page-level layout; prefer flex/grid +- Avoid nested scroll containers inside modal overlays +- Never rely on z-index stacking for visual hierarchy +- Don't remove focus-visible outlines on interactive elements + +## Components +- button: primary, secondary, ghost variants with hover/active/focus +- card: content card with shadow elevation and rounded corners +- input: text field with clear label, placeholder, error, and disabled state +- nav: top navigation bar with responsive breakpoints +- hero: full-width hero section with headline and CTA +- footer: site footer with link groups and copyright +""" + + +class TestRunWellFormed: + """run() with a prompt designed to pass all core checks.""" + + def test_passes_overall(self): + result = run(text=WELL_FORMED_PROMPT) + check_ids = {c["id"] for c in result["checks"]} + assert "non_empty" in check_ids + assert "min_actionable_rules" in check_ids + assert "rule_conflict" in check_ids + assert "language_consistency" in check_ids + assert "component_coverage" in check_ids + assert result["status"] == "pass" + + def test_non_empty_passes(self): + result = run(text=WELL_FORMED_PROMPT) + check = next(c for c in result["checks"] if c["id"] == "non_empty") + assert check["passed"] is True + + def test_min_actionable_rules_passes(self): + result = run(text=WELL_FORMED_PROMPT) + check = next(c for c in result["checks"] if c["id"] == "min_actionable_rules") + assert check["passed"] is True + + def test_rule_conflict_passes(self): + result = run(text=WELL_FORMED_PROMPT) + check = next(c for c in result["checks"] if c["id"] == "rule_conflict") + assert check["passed"] is True + + def test_language_consistency_passes(self): + result = run(text=WELL_FORMED_PROMPT) + check = next(c for c in result["checks"] if c["id"] == "language_consistency") + assert check["passed"] is True + + def test_component_coverage_passes(self): + result = run(text=WELL_FORMED_PROMPT) + check = next(c for c in result["checks"] if c["id"] == "component_coverage") + assert check["passed"] is True + + +class TestRunMinimalEmpty: + """run() with empty or minimal text that should fail.""" + + def test_empty_text_fails(self): + result = run(text=" ") + assert result["status"] == "fail" + non_empty = next(c for c in result["checks"] if c["id"] == "non_empty") + assert non_empty["passed"] is False + + def test_minimal_text_fails(self): + result = run(text="Just a short sentence.") + assert result["status"] == "fail" + + +# --------------------------------------------------------------------------- +# 6. run() with require_refine_mode and require_reference_type +# --------------------------------------------------------------------------- + + +class TestRunOptionalChecks: + """Verify optional checks appear when flags are set.""" + + def test_refine_mode_check_present(self): + result = run(text=WELL_FORMED_PROMPT, require_refine_mode="new") + check_ids = {c["id"] for c in result["checks"]} + assert "refinement_mode_alignment" in check_ids + + def test_refine_mode_check_absent_without_flag(self): + result = run(text=WELL_FORMED_PROMPT) + check_ids = {c["id"] for c in result["checks"]} + assert "refinement_mode_alignment" not in check_ids + + def test_reference_type_check_present(self): + result = run(text=WELL_FORMED_PROMPT, require_reference_type="screenshot") + check_ids = {c["id"] for c in result["checks"]} + assert "reference_context_guard" in check_ids + + def test_reference_type_none_passes(self): + result = run(text=WELL_FORMED_PROMPT, require_reference_type="none") + check = next(c for c in result["checks"] if c["id"] == "reference_context_guard") + assert check["passed"] is True + + def test_reference_type_check_absent_without_flag(self): + result = run(text=WELL_FORMED_PROMPT) + check_ids = {c["id"] for c in result["checks"]} + assert "reference_context_guard" not in check_ids + + def test_reference_signals_check_present(self): + result = run(text=WELL_FORMED_PROMPT, require_reference_signals=True) + check_ids = {c["id"] for c in result["checks"]} + assert "reference_signal_alignment" in check_ids + + def test_reference_signals_check_absent_without_flag(self): + result = run(text=WELL_FORMED_PROMPT, require_reference_signals=False) + check_ids = {c["id"] for c in result["checks"]} + assert "reference_signal_alignment" not in check_ids + + +# --------------------------------------------------------------------------- +# 7. Status logic +# --------------------------------------------------------------------------- + + +class TestStatusLogic: + """Status: high failure -> fail, 2+ medium -> fail, otherwise pass.""" + + def test_high_failure_means_fail(self): + """Empty text triggers high-severity non_empty failure.""" + result = run(text=" ") + assert result["status"] == "fail" + high_fails = [v for v in result["violations"] if v["severity"] == "high"] + assert len(high_fails) >= 1 + + def test_single_medium_failure_still_pass(self): + """A prompt that fails exactly one medium check should still pass. + + We craft a prompt that passes all high checks but fails only + one medium check (component_coverage) by omitting secondary + components. + """ + prompt = """ +- Use Manrope font family for headings and DM Sans for body text +- Apply rounded-xl corners on all card components with shadow-md elevation +- Use semantic design tokens for primary, secondary, and accent colors +- Maintain consistent spacing scale using 4px base unit with radius variants +- Ensure button states include hover, active, and focus-visible transitions +- Apply WCAG 4.5:1 contrast ratio for all text-on-background combinations +- Set touch target minimum 44x44px for mobile interactive elements +- Use component variants for each interactive state (default, hover, active, disabled) +- Apply swap test and squint test before final delivery to prevent generic output +- Don't use absolute positioning for page-level layout; prefer flex/grid +- Avoid nested scroll containers inside modal overlays +- Never rely on z-index stacking for visual hierarchy + +button, card, input components are styled. nav, hero, footer are covered. +""" + result = run(text=prompt) + medium_fails = [v for v in result["violations"] if v["severity"] == "medium"] + # If we happen to have <=1 medium fail, status should be pass + if len(medium_fails) <= 1: + assert result["status"] == "pass" + else: + assert result["status"] == "fail" + + def test_two_medium_failures_means_fail(self): + """A prompt with only bullet rules but lacking multiple medium criteria.""" + prompt = "\n".join( + [ + "- Use consistent spacing across the entire layout grid system", + "- Maintain clear visual hierarchy with proper heading scale", + "- Apply smooth transitions on interactive elements always", + ] + ) + result = run(text=prompt) + medium_fails = [v for v in result["violations"] if v["severity"] == "medium"] + assert len(medium_fails) >= 2 + assert result["status"] == "fail" + + +# --------------------------------------------------------------------------- +# 8. read_prompt_text — plain text and JSON input +# --------------------------------------------------------------------------- + + +class TestReadPromptText: + """read_prompt_text with plain text and JSON inline input.""" + + def test_plain_text(self): + text, meta = read_prompt_text(None, "Hello world", "hard_prompt") + assert text == "Hello world" + assert meta["source_kind"] == "text" + assert meta["source_field"] is None + + def test_json_with_hard_prompt(self): + obj = {"hard_prompt": "Use rounded corners", "name": "test"} + inline = json.dumps(obj) + text, meta = read_prompt_text(None, inline, "hard_prompt") + assert text == "Use rounded corners" + assert meta["source_kind"] == "json" + assert meta["source_field"] == "hard_prompt" + + def test_json_fallback_field(self): + """When preferred field is missing, falls back to common fields.""" + obj = {"prompt": "Fallback content here"} + inline = json.dumps(obj) + text, meta = read_prompt_text(None, inline, "hard_prompt") + assert text == "Fallback content here" + assert meta["source_field"] == "prompt" + + def test_json_nested(self): + obj = {"config": {"hard_prompt": "Nested prompt text"}} + inline = json.dumps(obj) + text, meta = read_prompt_text(None, inline, "hard_prompt") + assert text == "Nested prompt text" + assert meta["source_kind"] == "json" + assert "config" in meta["source_field"] + + def test_no_input_raises(self): + with pytest.raises(SystemExit): + read_prompt_text(None, None, "hard_prompt") + + def test_json_array_input(self): + obj = [{"hard_prompt": "Array item prompt"}] + inline = json.dumps(obj) + text, meta = read_prompt_text(None, inline, "hard_prompt") + assert text == "Array item prompt" + assert meta["source_kind"] == "json" + + def test_invalid_json_falls_back_to_text(self): + inline = "{not valid json" + text, meta = read_prompt_text(None, inline, "hard_prompt") + assert text == inline + assert meta["source_kind"] == "text" diff --git a/tests/test_v2_taxonomy.py b/tests/test_v2_taxonomy.py new file mode 100644 index 0000000..b556c5a --- /dev/null +++ b/tests/test_v2_taxonomy.py @@ -0,0 +1,608 @@ +"""Unit tests for v2_taxonomy module.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +import v2_taxonomy as mod + + +# --------------------------------------------------------------------------- +# 1. resolve_site_type +# --------------------------------------------------------------------------- + +class TestResolveSiteType: + """Tests for resolve_site_type().""" + + def _aliases_payload(self) -> dict[str, Any]: + return {"site_type_aliases": dict(mod.DEFAULT_SITE_ALIASES)} + + def test_explicit_site_type_returned_directly(self): + result = mod.resolve_site_type("anything", "dashboard", self._aliases_payload()) + assert result["site_type"] == "dashboard" + assert result["source"] == "explicit" + assert result["confidence"] == 1.0 + + def test_explicit_invalid_falls_to_general(self): + result = mod.resolve_site_type("anything", "nonexistent-type", self._aliases_payload()) + assert result["site_type"] == "general" + assert result["source"] == "explicit" + + def test_explicit_auto_triggers_alias_matching(self): + result = mod.resolve_site_type("blog article", "auto", self._aliases_payload()) + assert result["source"] != "explicit" + + @pytest.mark.parametrize( + "query, expected_type", + [ + ("build a blog article page", "blog"), + ("enterprise saas workspace", "saas"), + ("admin dashboard panel", "dashboard"), + ("documentation guide manual", "docs"), + ("ecommerce store checkout", "ecommerce"), + ("landing marketing homepage", "landing-page"), + ("portfolio case study showreel", "portfolio"), + ("website app page", "general"), + ], + ) + def test_alias_match_all_8_types(self, query: str, expected_type: str): + result = mod.resolve_site_type(query, "", self._aliases_payload()) + assert result["site_type"] == expected_type + assert result["source"] == "alias-match" + assert result["confidence"] > 0 + + def test_no_match_defaults_to_general(self): + result = mod.resolve_site_type("xyzzy foobar", "", self._aliases_payload()) + assert result["site_type"] == "general" + assert result["source"] == "heuristic-default" + assert result["confidence"] == 0.35 + assert result["matched_signals"] == [] + + +# --------------------------------------------------------------------------- +# 2. infer_visual_style +# --------------------------------------------------------------------------- + +class TestInferVisualStyle: + """Tests for infer_visual_style().""" + + def test_explicit_mapping_wins(self, minimal_style): + mapping = {"visual_style": "editorial"} + assert mod.infer_visual_style(minimal_style, mapping) == "editorial" + + @pytest.mark.parametrize( + "keyword, expected", + [ + ("minimal", "minimal"), + ("editorial", "editorial"), + ("retro", "retro"), + ("cyberpunk", "expressive"), + ("glass", "modern-tech"), + ("dashboard", "corporate"), + ("playful", "playful"), + ], + ) + def test_keyword_detection(self, keyword: str, expected: str): + style = {"slug": keyword, "name": "", "nameEn": "", "category": "", "keywords": [], "tags": []} + assert mod.infer_visual_style(style, {}) == expected + + def test_layout_style_type_returns_balanced(self): + style = {"slug": "unknown-xyz", "name": "", "nameEn": "", "category": "", "keywords": [], "tags": [], "styleType": "layout"} + assert mod.infer_visual_style(style, {}) == "balanced" + + def test_default_returns_modern_tech(self): + style = {"slug": "unknown-xyz", "name": "", "nameEn": "", "category": "", "keywords": [], "tags": []} + assert mod.infer_visual_style(style, {}) == "modern-tech" + + +# --------------------------------------------------------------------------- +# 3. infer_layout_archetype +# --------------------------------------------------------------------------- + +class TestInferLayoutArchetype: + """Tests for infer_layout_archetype().""" + + def test_mapping_hints_take_priority(self, minimal_style, default_route): + mapping = {"layout_archetype_hints": ["custom-layout"]} + assert mod.infer_layout_archetype(minimal_style, mapping, default_route, "general", "") == "custom-layout" + + @pytest.mark.parametrize( + "site_type, expected", + [ + ("dashboard", "kpi-console"), + ("docs", "doc-sidebar"), + ("blog", "article-first"), + ("portfolio", "showcase-masonry"), + ("ecommerce", "catalog-conversion"), + ("landing-page", "split-hero"), + ], + ) + def test_site_type_specific(self, minimal_style, default_route, site_type: str, expected: str): + assert mod.infer_layout_archetype(minimal_style, {}, default_route, site_type, "") == expected + + def test_query_keyword_sidebar(self, minimal_style, default_route): + result = mod.infer_layout_archetype(minimal_style, {}, default_route, "general", "page with sidebar") + assert result == "doc-sidebar" + + def test_query_keyword_table(self, minimal_style, default_route): + result = mod.infer_layout_archetype(minimal_style, {}, default_route, "general", "data table view") + assert result == "kpi-console" + + def test_route_fallback(self, minimal_style): + route = {"preferred_layout_archetypes": ["feature-grid"]} + result = mod.infer_layout_archetype(minimal_style, {}, route, "general", "generic query") + assert result == "feature-grid" + + def test_ultimate_default(self, minimal_style): + result = mod.infer_layout_archetype(minimal_style, {}, {}, "general", "generic query") + assert result == "balanced-sections" + + +# --------------------------------------------------------------------------- +# 4. infer_motion_profile +# --------------------------------------------------------------------------- + +class TestInferMotionProfile: + """Tests for infer_motion_profile().""" + + def test_mapping_hints(self, minimal_style, default_route): + mapping = {"motion_profile_hints": ["energetic"]} + assert mod.infer_motion_profile(minimal_style, mapping, default_route, "") == "energetic" + + @pytest.mark.parametrize( + "keyword, expected", + [ + ("minimal readable", "minimal"), + ("smooth glass", "smooth"), + ("dramatic bold neon", "energetic"), + ("playful bouncy fun", "playful"), + ("ambient atmospheric", "ambient"), + ("loading skeleton", "functional"), + ], + ) + def test_keyword_detection(self, default_route, keyword: str, expected: str): + style = {"slug": "", "nameEn": "", "tags": [], "keywords": [], "aiRules": ""} + assert mod.infer_motion_profile(style, {}, default_route, keyword) == expected + + def test_route_fallback(self): + style = {"slug": "", "nameEn": "", "tags": [], "keywords": [], "aiRules": ""} + route = {"preferred_motion_profiles": ["functional"]} + assert mod.infer_motion_profile(style, {}, route, "unknown query xyz") == "functional" + + def test_default_subtle(self): + style = {"slug": "", "nameEn": "", "tags": [], "keywords": [], "aiRules": ""} + assert mod.infer_motion_profile(style, {}, {}, "unknown query xyz") == "subtle" + + +# --------------------------------------------------------------------------- +# 5. infer_interaction_pattern +# --------------------------------------------------------------------------- + +class TestInferInteractionPattern: + """Tests for infer_interaction_pattern().""" + + def test_mapping_hints(self, minimal_style, default_route): + mapping = {"interaction_pattern_hints": ["form-wizard"]} + assert mod.infer_interaction_pattern(minimal_style, mapping, default_route, "general", "") == "form-wizard" + + @pytest.mark.parametrize( + "keyword, expected", + [ + ("wizard multi-step form", "form-wizard"), + ("search filter facet", "search-explore"), + ("notification toast alert", "notification-center"), + ], + ) + def test_query_keywords(self, minimal_style, default_route, keyword: str, expected: str): + assert mod.infer_interaction_pattern(minimal_style, {}, default_route, "general", keyword) == expected + + @pytest.mark.parametrize( + "site_type, expected", + [ + ("dashboard", "data-dense-feedback"), + ("docs", "docs-navigation"), + ("landing-page", "conversion-focused"), + ("ecommerce", "conversion-focused"), + ("portfolio", "showcase-narrative"), + ], + ) + def test_site_type_fallback(self, minimal_style, default_route, site_type: str, expected: str): + assert mod.infer_interaction_pattern(minimal_style, {}, default_route, site_type, "generic") == expected + + def test_route_fallback(self, minimal_style): + route = {"preferred_interaction_patterns": ["content-reading"]} + assert mod.infer_interaction_pattern(minimal_style, {}, route, "general", "generic xyz") == "content-reading" + + def test_default_assistant_guided(self, minimal_style): + assert mod.infer_interaction_pattern(minimal_style, {}, {}, "general", "generic xyz") == "assistant-guided" + + +# --------------------------------------------------------------------------- +# 6. infer_modifiers +# --------------------------------------------------------------------------- + +class TestInferModifiers: + """Tests for infer_modifiers().""" + + def test_mapped_modifiers_included(self, minimal_style): + mapping = {"modifiers": ["custom-mod"]} + result = mod.infer_modifiers(minimal_style, mapping, "general", "") + assert "custom-mod" in result + + def test_keyword_readability(self): + style = {"category": "", "tags": ["readability"], "keywords": []} + result = mod.infer_modifiers(style, {}, "general", "") + assert "readability-first" in result + + def test_keyword_conversion(self): + style = {"category": "", "tags": [], "keywords": []} + result = mod.infer_modifiers(style, {}, "general", "conversion cta checkout") + assert "conversion-first" in result + + def test_keyword_high_contrast(self): + style = {"category": "", "tags": ["neo-brutalist"], "keywords": []} + result = mod.infer_modifiers(style, {}, "general", "") + assert "high-contrast" in result + + @pytest.mark.parametrize( + "site_type, expected_mod", + [ + ("dashboard", "dense-information"), + ("docs", "dense-information"), + ("landing-page", "hero-driven"), + ("portfolio", "hero-driven"), + ], + ) + def test_site_type_additions(self, site_type: str, expected_mod: str): + style = {"category": "", "tags": [], "keywords": []} + result = mod.infer_modifiers(style, {}, site_type, "") + assert expected_mod in result + + def test_dedup_and_cap_at_3(self): + style = {"category": "readability", "tags": ["readability", "conversion", "neo-brutalist", "high-contrast"], "keywords": ["cta"]} + mapping = {"modifiers": ["readability-first"]} + result = mod.infer_modifiers(style, mapping, "dashboard", "conversion cta") + assert len(result) <= 3 + assert len(result) == len(set(result)) + + +# --------------------------------------------------------------------------- +# 7. build_tag_bundle +# --------------------------------------------------------------------------- + +class TestBuildTagBundle: + """Tests for build_tag_bundle().""" + + def test_all_six_dimensions_present(self, minimal_style, default_route, style_tag_map): + bundle = mod.build_tag_bundle( + style=minimal_style, + site_type="dashboard", + query="admin dashboard", + route=default_route, + style_map_payload=style_tag_map, + ) + expected_keys = {"site_type", "visual_style", "layout_archetype", "motion_profile", "interaction_pattern", "modifiers"} + assert expected_keys == set(bundle.keys()) + + def test_site_type_propagated(self, minimal_style, default_route): + bundle = mod.build_tag_bundle( + style=minimal_style, + site_type="blog", + query="article page", + route=default_route, + style_map_payload={"style_mappings": {}}, + ) + assert bundle["site_type"] == "blog" + + def test_modifiers_is_list(self, minimal_style, default_route): + bundle = mod.build_tag_bundle( + style=minimal_style, + site_type="general", + query="some query", + route=default_route, + style_map_payload={"style_mappings": {}}, + ) + assert isinstance(bundle["modifiers"], list) + + +# --------------------------------------------------------------------------- +# 8. routing_adjustment_for_style +# --------------------------------------------------------------------------- + +class TestRoutingAdjustmentForStyle: + """Tests for routing_adjustment_for_style().""" + + def test_returns_tuple(self, minimal_style, default_route): + result = mod.routing_adjustment_for_style( + style=minimal_style, + site_type="dashboard", + route=default_route, + style_map_payload={"style_mappings": {}}, + query="dashboard admin", + ) + assert isinstance(result, tuple) + assert len(result) == 2 + assert isinstance(result[0], float) + assert isinstance(result[1], dict) + + def test_favored_hits_boost(self, default_route): + style = {"slug": "s", "styleType": "visual", "category": "modern", "tags": ["modern", "clean"], "keywords": []} + adj, info = mod.routing_adjustment_for_style( + style=style, + site_type="general", + route=default_route, + style_map_payload={"style_mappings": {}}, + query="", + ) + assert adj > 0 + assert len(info["favored_hits"]) > 0 + + def test_penalized_hits_reduce(self, default_route): + style = {"slug": "s", "styleType": "visual", "category": "chaotic", "tags": ["chaotic"], "keywords": []} + adj, info = mod.routing_adjustment_for_style( + style=style, + site_type="general", + route=default_route, + style_map_payload={"style_mappings": {}}, + query="", + ) + assert adj < 0 + assert len(info["penalized_hits"]) > 0 + + def test_info_contains_site_type(self, minimal_style, default_route): + _, info = mod.routing_adjustment_for_style( + style=minimal_style, + site_type="saas", + route=default_route, + style_map_payload={"style_mappings": {}}, + query="", + ) + assert info["site_type"] == "saas" + + +# --------------------------------------------------------------------------- +# 9. resolve_animation_profile +# --------------------------------------------------------------------------- + +class TestResolveAnimationProfile: + """Tests for resolve_animation_profile().""" + + def test_none_when_no_profiles_payload(self, sample_tag_bundle, default_route): + assert mod.resolve_animation_profile(sample_tag_bundle, default_route, None) is None + + def test_none_when_empty_profiles(self, sample_tag_bundle, default_route): + assert mod.resolve_animation_profile(sample_tag_bundle, default_route, {"profiles": {}}) is None + + def test_recommended_with_matching_motion(self, sample_tag_bundle, default_route): + profiles_payload = { + "profiles": { + "prof-a": {"motion_profile": "subtle", "intent": "calm"}, + "prof-b": {"motion_profile": "energetic", "intent": "bold"}, + } + } + route = {**default_route, "recommended_animation_profiles": ["prof-a"]} + result = mod.resolve_animation_profile(sample_tag_bundle, route, profiles_payload) + assert result is not None + assert result["intent"] == "calm" + + def test_recommended_fallback_no_motion_match(self): + bundle = {"motion_profile": "nonexistent"} + profiles_payload = { + "profiles": { + "prof-a": {"motion_profile": "subtle", "intent": "calm"}, + } + } + route = {"recommended_animation_profiles": ["prof-a"]} + result = mod.resolve_animation_profile(bundle, route, profiles_payload) + assert result is not None + assert result["intent"] == "calm" + + def test_fallback_by_motion_profile(self, sample_tag_bundle): + profiles_payload = { + "profiles": { + "prof-x": {"motion_profile": "subtle", "intent": "understated"}, + } + } + route = {} # no recommended + result = mod.resolve_animation_profile(sample_tag_bundle, route, profiles_payload) + assert result is not None + assert result["intent"] == "understated" + + def test_real_data(self, sample_tag_bundle, default_route, animation_profiles): + """Smoke test with real animation-profiles.v2.json data.""" + result = mod.resolve_animation_profile(sample_tag_bundle, default_route, animation_profiles) + # May or may not find a match -- just verify no crash and correct type + assert result is None or isinstance(result, dict) + + +# --------------------------------------------------------------------------- +# 10. resolve_interaction_pattern_data +# --------------------------------------------------------------------------- + +class TestResolveInteractionPatternData: + """Tests for resolve_interaction_pattern_data().""" + + def test_none_when_no_payload(self, sample_tag_bundle): + assert mod.resolve_interaction_pattern_data(sample_tag_bundle, None) is None + + def test_none_when_empty_patterns(self, sample_tag_bundle): + assert mod.resolve_interaction_pattern_data(sample_tag_bundle, {"patterns": {}}) is None + + def test_key_lookup_success(self, sample_tag_bundle): + payload = { + "patterns": { + "data-dense-feedback": {"primary_goal": "density"}, + } + } + result = mod.resolve_interaction_pattern_data(sample_tag_bundle, payload) + assert result is not None + assert result["primary_goal"] == "density" + + def test_key_lookup_miss(self): + bundle = {"interaction_pattern": "nonexistent-pattern"} + payload = {"patterns": {"other": {"primary_goal": "x"}}} + assert mod.resolve_interaction_pattern_data(bundle, payload) is None + + def test_real_data(self, sample_tag_bundle, interaction_patterns): + """Smoke test with real interaction-patterns.v2.json data.""" + result = mod.resolve_interaction_pattern_data(sample_tag_bundle, interaction_patterns) + assert result is None or isinstance(result, dict) + + +# --------------------------------------------------------------------------- +# 11. build_ai_interaction_script +# --------------------------------------------------------------------------- + +class TestBuildAiInteractionScript: + """Tests for build_ai_interaction_script().""" + + def test_without_resolved_data_en(self, sample_tag_bundle): + lines = mod.build_ai_interaction_script(sample_tag_bundle, "en") + assert isinstance(lines, list) + assert len(lines) == 6 + assert any("Motion objective" in ln for ln in lines) + + def test_without_resolved_data_zh(self, sample_tag_bundle): + lines = mod.build_ai_interaction_script(sample_tag_bundle, "zh") + assert isinstance(lines, list) + assert len(lines) == 6 + assert any("动效目标" in ln for ln in lines) + + def test_with_anim_profile_en(self, sample_tag_bundle): + anim = { + "duration_range_ms": [100, 250], + "easing": "ease-out", + "intent": "guide the eye", + "reduced_motion_fallback": "instant-state-swap", + "anti_patterns": ["excessive bounce"], + } + lines = mod.build_ai_interaction_script(sample_tag_bundle, "en", resolved_anim_profile=anim) + assert any("Motion intent" in ln for ln in lines) + assert any("Timing" in ln for ln in lines) + assert any("anti-patterns" in ln for ln in lines) + + def test_with_interaction_pattern_en(self, sample_tag_bundle): + ipt = { + "primary_goal": "density", + "state_coverage_requirements": {"button": ["default", "hover"]}, + "accessibility_constraints": ["focus ring required"], + "anti_patterns": ["invisible focus"], + } + lines = mod.build_ai_interaction_script(sample_tag_bundle, "en", resolved_interaction_pattern=ipt) + assert any("Interaction goal" in ln for ln in lines) + assert any("State coverage" in ln for ln in lines) + + def test_with_both_resolved_zh(self, sample_tag_bundle): + anim = { + "duration_range_ms": [100, 250], + "easing": "ease-out", + "intent": "guide", + "reduced_motion_fallback": "instant-state-swap", + "anti_patterns": [], + } + ipt = { + "primary_goal": "density", + "state_coverage_requirements": {}, + "accessibility_constraints": [], + "anti_patterns": [], + } + lines = mod.build_ai_interaction_script(sample_tag_bundle, "zh", resolved_anim_profile=anim, resolved_interaction_pattern=ipt) + assert any("动效意图" in ln for ln in lines) + assert any("交互目标" in ln for ln in lines) + assert any("布局协同" in ln for ln in lines) + + def test_max_10_lines(self, sample_tag_bundle): + anim = { + "duration_range_ms": [100, 250], + "easing": "ease-out", + "intent": "guide", + "reduced_motion_fallback": "swap", + "anti_patterns": ["a", "b", "c"], + } + ipt = { + "primary_goal": "density", + "state_coverage_requirements": {"a": ["s1"], "b": ["s2"], "c": ["s3"]}, + "accessibility_constraints": ["c1", "c2", "c3"], + "anti_patterns": ["x", "y"], + } + lines = mod.build_ai_interaction_script(sample_tag_bundle, "en", resolved_anim_profile=anim, resolved_interaction_pattern=ipt) + assert len(lines) <= 10 + + +# --------------------------------------------------------------------------- +# 12. build_composition_plan +# --------------------------------------------------------------------------- + +class TestBuildCompositionPlan: + """Tests for build_composition_plan().""" + + def _make_plan( + self, + sample_tag_bundle: dict[str, Any], + default_route: dict[str, Any], + lang: str = "en", + recommendation_mode: str = "hybrid", + animation_profiles: dict[str, Any] | None = None, + interaction_patterns: dict[str, Any] | None = None, + ) -> dict[str, Any]: + primary = {"slug": "test-style"} + alternatives = [{"slug": "alt-1"}, {"slug": "alt-2"}] + blend_plan = {"conflict_resolution": {"color_owner": "test-style"}} + return mod.build_composition_plan( + site_type="dashboard", + route=default_route, + tag_bundle=sample_tag_bundle, + primary_style=primary, + alternatives=alternatives, + blend_plan=blend_plan, + recommendation_mode=recommendation_mode, + lang=lang, + animation_profiles=animation_profiles, + interaction_patterns=interaction_patterns, + ) + + def test_required_keys(self, sample_tag_bundle, default_route): + plan = self._make_plan(sample_tag_bundle, default_route) + required = { + "site_type", + "recommendation_mode", + "style_recommendation", + "layout_recommendation", + "motion_recommendation", + "interaction_recommendation", + "owner_matrix", + "ai_interaction_script", + "checks", + "rationale", + } + assert required.issubset(set(plan.keys())) + + def test_site_type_propagated(self, sample_tag_bundle, default_route): + plan = self._make_plan(sample_tag_bundle, default_route) + assert plan["site_type"] == "dashboard" + + def test_owner_matrix_has_fields(self, sample_tag_bundle, default_route): + plan = self._make_plan(sample_tag_bundle, default_route) + om = plan["owner_matrix"] + for key in ("style_identity_owner", "color_owner", "typography_owner", "spacing_owner", "motion_owner"): + assert key in om + + def test_rationale_zh(self, sample_tag_bundle, default_route): + plan = self._make_plan(sample_tag_bundle, default_route, lang="zh") + assert any("站点类型" in r for r in plan["rationale"]) + + def test_rules_mode_rationale(self, sample_tag_bundle, default_route): + plan = self._make_plan(sample_tag_bundle, default_route, recommendation_mode="rules") + assert any("deterministic" in r or "纯规则" in r for r in plan["rationale"]) + + def test_with_real_data(self, sample_tag_bundle, default_route, animation_profiles, interaction_patterns): + plan = self._make_plan( + sample_tag_bundle, + default_route, + animation_profiles=animation_profiles, + interaction_patterns=interaction_patterns, + ) + assert isinstance(plan["ai_interaction_script"], list) + assert isinstance(plan["checks"], list) diff --git a/tests/test_validate_output_contract_sync.py b/tests/test_validate_output_contract_sync.py new file mode 100644 index 0000000..c189db9 --- /dev/null +++ b/tests/test_validate_output_contract_sync.py @@ -0,0 +1,141 @@ +"""Unit tests for scripts/validate_output_contract_sync.py.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from validate_output_contract_sync import ( + DEFAULT_CONTRACT_FILE, + DEFAULT_SCHEMAS_DIR, + REQUIRED_HEADINGS, + extract_json_blocks, + group_blocks_by_heading, + run, +) + + +def _load_canonical_payloads() -> dict[str, dict[str, Any]]: + markdown = DEFAULT_CONTRACT_FILE.read_text(encoding="utf-8") + grouped = group_blocks_by_heading(extract_json_blocks(markdown)) + payloads: dict[str, dict[str, Any]] = {} + for heading in REQUIRED_HEADINGS: + payloads[heading] = json.loads(grouped[heading][0]["json"]) + return payloads + + +def _write_contract( + path: Path, + payloads: dict[str, dict[str, Any]], + *, + heading_order: list[str] | None = None, + extra_blocks: dict[str, list[dict[str, Any]]] | None = None, +) -> None: + order = heading_order or list(REQUIRED_HEADINGS) + extras = extra_blocks or {} + parts: list[str] = ["# Temp Contract", ""] + for heading in order: + parts.append(f"## {heading}") + parts.append("") + parts.append("```json") + parts.append(json.dumps(payloads[heading], ensure_ascii=False, indent=2)) + parts.append("```") + parts.append("") + for block in extras.get(heading, []): + parts.append("```json") + parts.append(json.dumps(block, ensure_ascii=False, indent=2)) + parts.append("```") + parts.append("") + path.write_text("\n".join(parts), encoding="utf-8") + + +class TestValidateOutputContractSync: + """Focused tests for contract example sync guard behavior.""" + + def test_uses_first_json_block_when_section_has_multiple_blocks(self, tmp_path: Path) -> None: + payloads = _load_canonical_payloads() + contract = tmp_path / "contract.md" + _write_contract( + contract, + payloads, + extra_blocks={ + REQUIRED_HEADINGS[0]: [ + { + "query": "this is intentionally incomplete and should be ignored", + } + ] + }, + ) + + result = run(contract_file=str(contract), schemas_dir=str(DEFAULT_SCHEMAS_DIR)) + + assert result["status"] == "pass" + assert result.get("warnings"), "Expected warning for duplicate JSON blocks" + assert "using the first one as canonical" in result["warnings"][0] + + def test_fail_on_warning_turns_duplicate_blocks_into_failure(self, tmp_path: Path) -> None: + payloads = _load_canonical_payloads() + contract = tmp_path / "contract.md" + _write_contract( + contract, + payloads, + extra_blocks={REQUIRED_HEADINGS[2]: [payloads[REQUIRED_HEADINGS[2]]]}, + ) + + result = run( + contract_file=str(contract), + schemas_dir=str(DEFAULT_SCHEMAS_DIR), + fail_on_warning=True, + ) + + assert result["status"] == "fail" + assert any("fail-on-warning enabled" in msg for msg in result.get("errors", [])) + assert result.get("warnings"), "Expected warnings to be preserved in output" + + def test_fails_when_required_heading_order_changes(self, tmp_path: Path) -> None: + payloads = _load_canonical_payloads() + contract = tmp_path / "contract.md" + swapped = [ + REQUIRED_HEADINGS[1], + REQUIRED_HEADINGS[0], + REQUIRED_HEADINGS[2], + REQUIRED_HEADINGS[3], + REQUIRED_HEADINGS[4], + ] + _write_contract(contract, payloads, heading_order=swapped) + + result = run(contract_file=str(contract), schemas_dir=str(DEFAULT_SCHEMAS_DIR)) + + assert result["status"] == "fail" + assert any("Required section order changed" in msg for msg in result.get("errors", [])) + + def test_fails_when_primary_json_block_is_invalid(self, tmp_path: Path) -> None: + payloads = _load_canonical_payloads() + contract = tmp_path / "contract.md" + parts: list[str] = ["# Temp Contract", ""] + for idx, heading in enumerate(REQUIRED_HEADINGS): + parts.append(f"## {heading}") + parts.append("") + if idx == 0: + # First block is intentionally invalid JSON. + parts.append("```json") + parts.append('{"query":"broken",}') + parts.append("```") + parts.append("") + # Second block is valid but should be ignored because first is canonical. + parts.append("```json") + parts.append(json.dumps(payloads[heading], ensure_ascii=False, indent=2)) + parts.append("```") + parts.append("") + else: + parts.append("```json") + parts.append(json.dumps(payloads[heading], ensure_ascii=False, indent=2)) + parts.append("```") + parts.append("") + contract.write_text("\n".join(parts), encoding="utf-8") + + result = run(contract_file=str(contract), schemas_dir=str(DEFAULT_SCHEMAS_DIR)) + + assert result["status"] == "fail" + assert any("Invalid JSON under section" in msg for msg in result.get("errors", [])) diff --git a/tests/test_validate_taxonomy.py b/tests/test_validate_taxonomy.py new file mode 100644 index 0000000..bedf782 --- /dev/null +++ b/tests/test_validate_taxonomy.py @@ -0,0 +1,55 @@ +"""Unit tests for scripts/validate_taxonomy.py.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import validate_taxonomy as mod + + +def _write_registry_with_extra_unused_tag(tmp_path: Path, *, tag: str = "unit-unused-style-tag") -> Path: + src = mod.TAX_DIR / "style-tag-registry.json" + data = json.loads(src.read_text(encoding="utf-8")) + tags = data.get("allowed_style_tags", []) + if tag not in tags: + tags.append(tag) + data["allowed_style_tags"] = tags + out = tmp_path / "style-tag-registry.json" + out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return out + + +class TestValidateTaxonomy: + """Behavior tests for taxonomy validator warning/error policy.""" + + def test_baseline_strict_mode_passes(self) -> None: + result = mod.validate(max_unused_style_tags=0, fail_on_warning=True) + assert result["status"] == "pass" + assert result.get("warnings", []) == [] + assert result.get("errors", []) == [] + + def test_warns_when_unused_style_tags_exist_without_threshold(self, tmp_path: Path) -> None: + registry = _write_registry_with_extra_unused_tag(tmp_path) + result = mod.validate(style_tag_registry_file=str(registry)) + + assert result["status"] == "pass" + assert result.get("warnings"), "Expected warnings when unused tags exist without threshold" + assert "unused style tags detected" in result["warnings"][0] + stats = result.get("style_tag_registry_stats", {}) or {} + assert stats.get("unused_count", 0) >= 1 + + def test_fail_on_warning_promotes_warning_to_failure(self, tmp_path: Path) -> None: + registry = _write_registry_with_extra_unused_tag(tmp_path) + result = mod.validate(style_tag_registry_file=str(registry), fail_on_warning=True) + + assert result["status"] == "fail" + assert result.get("warnings"), "Expected warnings to be preserved in failure result" + assert any("fail-on-warning enabled" in msg for msg in result.get("errors", [])) + + def test_max_unused_style_tags_still_fails_hard(self, tmp_path: Path) -> None: + registry = _write_registry_with_extra_unused_tag(tmp_path) + result = mod.validate(style_tag_registry_file=str(registry), max_unused_style_tags=0) + + assert result["status"] == "fail" + assert any("unused tag count" in msg for msg in result.get("errors", []))