@huicai/cli 2026.814.0 → 2026.1021.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (493) hide show
  1. package/LICENSE +391 -32
  2. package/README.md +139 -8
  3. package/bin/huicai.js +155 -74
  4. package/bin/update.js +97 -0
  5. package/package.json +27 -8
  6. package/skills/ab-testing/trigger-cases.json +30 -0
  7. package/skills/academic-writing/trigger-cases.json +30 -0
  8. package/skills/accounting-ops/trigger-cases.json +30 -0
  9. package/skills/ai-agent/trigger-cases.json +30 -0
  10. package/skills/ai-aiops/trigger-cases.json +30 -0
  11. package/skills/ai-coding/SKILL.md +2 -0
  12. package/skills/ai-coding/trigger-cases.json +30 -0
  13. package/skills/ai-compliance/trigger-cases.json +30 -0
  14. package/skills/ai-eval/trigger-cases.json +30 -0
  15. package/skills/ai-llm-app/trigger-cases.json +30 -0
  16. package/skills/ai-mlops/trigger-cases.json +30 -0
  17. package/skills/ai-multimodal/trigger-cases.json +30 -0
  18. package/skills/ai-prompt/trigger-cases.json +30 -0
  19. package/skills/ai-rag/trigger-cases.json +30 -0
  20. package/skills/anomaly-detection/trigger-cases.json +30 -0
  21. package/skills/api-design/trigger-cases.json +31 -0
  22. package/skills/api-integration/trigger-cases.json +31 -0
  23. package/skills/arch-design/trigger-cases.json +12 -0
  24. package/skills/arch-diagram/SKILL.md +118 -0
  25. package/skills/arch-diagram/trigger-cases.json +30 -0
  26. package/skills/arch-distributed/trigger-cases.json +30 -0
  27. package/skills/arch-multitenant/trigger-cases.json +30 -0
  28. package/skills/audio-brief/SKILL.md +65 -0
  29. package/skills/audio-brief/trigger-cases.json +30 -0
  30. package/skills/audit-assist/trigger-cases.json +30 -0
  31. package/skills/audit-chain/trigger-cases.json +31 -0
  32. package/skills/banking-credit/trigger-cases.json +30 -0
  33. package/skills/brand-design/trigger-cases.json +30 -0
  34. package/skills/business-plan/trigger-cases.json +30 -0
  35. package/skills/capital-ops/trigger-cases.json +30 -0
  36. package/skills/cicd-pipeline/trigger-cases.json +31 -0
  37. package/skills/code-archaeology/SKILL.md +55 -0
  38. package/skills/code-archaeology/trigger-cases.json +9 -0
  39. package/skills/code-quality/SKILL.md +138 -0
  40. package/skills/code-quality/references/languages.md +107 -0
  41. package/skills/code-quality/scripts/__pycache__/code_battery.cpython-312.pyc +0 -0
  42. package/skills/code-quality/scripts/__pycache__/code_check.cpython-312.pyc +0 -0
  43. package/skills/code-quality/scripts/__pycache__/code_check.cpython-313.pyc +0 -0
  44. package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-312.pyc +0 -0
  45. package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-313.pyc +0 -0
  46. package/skills/code-quality/scripts/__pycache__/code_fix.cpython-312.pyc +0 -0
  47. package/skills/code-quality/scripts/__pycache__/code_fix.cpython-313.pyc +0 -0
  48. package/skills/code-quality/scripts/__pycache__/code_java.cpython-312.pyc +0 -0
  49. package/skills/code-quality/scripts/__pycache__/code_java.cpython-313.pyc +0 -0
  50. package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-312.pyc +0 -0
  51. package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-313.pyc +0 -0
  52. package/skills/code-quality/scripts/__pycache__/code_lens.cpython-312.pyc +0 -0
  53. package/skills/code-quality/scripts/__pycache__/code_lens.cpython-313.pyc +0 -0
  54. package/skills/code-quality/scripts/__pycache__/code_python.cpython-312.pyc +0 -0
  55. package/skills/code-quality/scripts/__pycache__/code_python.cpython-313.pyc +0 -0
  56. package/skills/code-quality/scripts/__pycache__/code_testplan.cpython-312.pyc +0 -0
  57. package/skills/code-quality/scripts/code_battery.py +1072 -0
  58. package/skills/code-quality/scripts/code_check.py +1269 -0
  59. package/skills/code-quality/scripts/code_csharp.py +371 -0
  60. package/skills/code-quality/scripts/code_fix.py +201 -0
  61. package/skills/code-quality/scripts/code_java.py +695 -0
  62. package/skills/code-quality/scripts/code_jsts.py +313 -0
  63. package/skills/code-quality/scripts/code_lens.py +1079 -0
  64. package/skills/code-quality/scripts/code_python.py +536 -0
  65. package/skills/code-quality/scripts/code_testplan.py +239 -0
  66. package/skills/code-quality/trigger-cases.json +45 -0
  67. package/skills/code-review/SKILL.md +24 -0
  68. package/skills/code-review/trigger-cases.json +35 -0
  69. package/skills/commit-and-pr/trigger-cases.json +31 -0
  70. package/skills/content-creation/trigger-cases.json +30 -0
  71. package/skills/corporate-governance/trigger-cases.json +30 -0
  72. package/skills/crawl-chain/trigger-cases.json +31 -0
  73. package/skills/customer-service/trigger-cases.json +30 -0
  74. package/skills/data-analysis/trigger-cases.json +30 -0
  75. package/skills/data-chain/SKILL.md +1 -1
  76. package/skills/data-chain/trigger-cases.json +31 -0
  77. package/skills/data-governance/trigger-cases.json +30 -0
  78. package/skills/data-report-pro/trigger-cases.json +30 -0
  79. package/skills/data-streaming/trigger-cases.json +30 -0
  80. package/skills/data-wrangling/SKILL.md +1 -1
  81. package/skills/data-wrangling/trigger-cases.json +30 -0
  82. package/skills/db-es/trigger-cases.json +30 -0
  83. package/skills/db-migration/trigger-cases.json +30 -0
  84. package/skills/db-mysql/trigger-cases.json +30 -0
  85. package/skills/db-postgres/trigger-cases.json +30 -0
  86. package/skills/db-redis/trigger-cases.json +30 -0
  87. package/skills/debug-root-cause/trigger-cases.json +30 -0
  88. package/skills/dependency-audit/SKILL.md +56 -0
  89. package/skills/dependency-audit/trigger-cases.json +7 -0
  90. package/skills/dependency-upgrade/trigger-cases.json +31 -0
  91. package/skills/dev-cpp/trigger-cases.json +30 -0
  92. package/skills/dev-cross-platform/trigger-cases.json +30 -0
  93. package/skills/dev-csharp/SKILL.md +23 -0
  94. package/skills/dev-csharp/trigger-cases.json +30 -0
  95. package/skills/dev-frontend/SKILL.md +19 -0
  96. package/skills/dev-frontend/trigger-cases.json +30 -0
  97. package/skills/dev-go/trigger-cases.json +30 -0
  98. package/skills/dev-harmony/trigger-cases.json +30 -0
  99. package/skills/dev-java/SKILL.md +20 -0
  100. package/skills/dev-java/trigger-cases.json +30 -0
  101. package/skills/dev-mobile/trigger-cases.json +30 -0
  102. package/skills/dev-node/trigger-cases.json +30 -0
  103. package/skills/dev-php/trigger-cases.json +30 -0
  104. package/skills/dev-python/SKILL.md +21 -0
  105. package/skills/dev-python/trigger-cases.json +30 -0
  106. package/skills/dev-rust/trigger-cases.json +30 -0
  107. package/skills/dev-toolchain/trigger-cases.json +31 -0
  108. package/skills/doc-chain/trigger-cases.json +31 -0
  109. package/skills/doc-compare/trigger-cases.json +30 -0
  110. package/skills/docx-author/SKILL.md +127 -25
  111. package/skills/docx-author/scripts/__pycache__/docx_battery.cpython-312.pyc +0 -0
  112. package/skills/docx-author/scripts/__pycache__/docx_build.cpython-312.pyc +0 -0
  113. package/skills/docx-author/scripts/__pycache__/docx_check.cpython-312.pyc +0 -0
  114. package/skills/docx-author/scripts/__pycache__/docx_layout.cpython-312.pyc +0 -0
  115. package/skills/docx-author/scripts/__pycache__/zh_prose.cpython-312.pyc +0 -0
  116. package/skills/docx-author/scripts/docx_battery.py +586 -0
  117. package/skills/docx-author/scripts/docx_build.py +995 -0
  118. package/skills/docx-author/scripts/docx_check.py +1306 -0
  119. package/skills/docx-author/scripts/docx_doc.py +14 -154
  120. package/skills/docx-author/scripts/docx_layout.py +490 -0
  121. package/skills/docx-author/scripts/zh_prose.py +639 -0
  122. package/skills/docx-author/trigger-cases.json +58 -0
  123. package/skills/echo-brief/SKILL.md +84 -0
  124. package/skills/echo-brief/references/echo-format.md +68 -0
  125. package/skills/echo-brief/trigger-cases.json +40 -0
  126. package/skills/ecommerce-ops/trigger-cases.json +30 -0
  127. package/skills/education-training/trigger-cases.json +30 -0
  128. package/skills/ehs-safety/trigger-cases.json +30 -0
  129. package/skills/email-writing/trigger-cases.json +30 -0
  130. package/skills/eng-ai/trigger-cases.json +30 -0
  131. package/skills/eng-architecture/trigger-cases.json +31 -0
  132. package/skills/eng-automation/trigger-cases.json +31 -0
  133. package/skills/eng-collaboration/trigger-cases.json +30 -0
  134. package/skills/eng-complex-env/trigger-cases.json +30 -0
  135. package/skills/eng-data-platform/trigger-cases.json +31 -0
  136. package/skills/eng-database/trigger-cases.json +31 -0
  137. package/skills/eng-develop/SKILL.md +9 -1
  138. package/skills/eng-develop/references/dod-checklist.md +3 -0
  139. package/skills/eng-develop/references/engineering-workflow.md +37 -0
  140. package/skills/eng-develop/trigger-cases.json +30 -0
  141. package/skills/eng-docs/SKILL.md +24 -0
  142. package/skills/eng-docs/trigger-cases.json +30 -0
  143. package/skills/eng-efficiency/trigger-cases.json +30 -0
  144. package/skills/eng-infra/trigger-cases.json +30 -0
  145. package/skills/eng-integration/trigger-cases.json +31 -0
  146. package/skills/eng-legacy/trigger-cases.json +31 -0
  147. package/skills/eng-observability/trigger-cases.json +31 -0
  148. package/skills/eng-ops/trigger-cases.json +31 -0
  149. package/skills/eng-performance/trigger-cases.json +31 -0
  150. package/skills/eng-planning/trigger-cases.json +31 -0
  151. package/skills/eng-quality/SKILL.md +8 -0
  152. package/skills/eng-quality/trigger-cases.json +31 -0
  153. package/skills/eng-release/trigger-cases.json +31 -0
  154. package/skills/eng-reliability/trigger-cases.json +31 -0
  155. package/skills/eng-requirements/trigger-cases.json +12 -0
  156. package/skills/eng-research/trigger-cases.json +30 -0
  157. package/skills/eng-security/trigger-cases.json +31 -0
  158. package/skills/eng-ux/trigger-cases.json +30 -0
  159. package/skills/engineering-chain/trigger-cases.json +30 -0
  160. package/skills/env-offline/trigger-cases.json +31 -0
  161. package/skills/env-private-deploy/trigger-cases.json +31 -0
  162. package/skills/env-xinchuang/trigger-cases.json +31 -0
  163. package/skills/equipment-tpm/trigger-cases.json +30 -0
  164. package/skills/esg-report/trigger-cases.json +30 -0
  165. package/skills/event-management/trigger-cases.json +30 -0
  166. package/skills/excel-analysis-chain/SKILL.md +17 -4
  167. package/skills/excel-analysis-chain/trigger-cases.json +31 -0
  168. package/skills/explain-codebase/trigger-cases.json +11 -0
  169. package/skills/file-ingest/trigger-cases.json +30 -0
  170. package/skills/finance-analysis/trigger-cases.json +31 -0
  171. package/skills/finance-chain/trigger-cases.json +31 -0
  172. package/skills/financial-modeling/trigger-cases.json +31 -0
  173. package/skills/find-simplifications/SKILL.md +43 -0
  174. package/skills/find-simplifications/trigger-cases.json +23 -0
  175. package/skills/foreign-trade/trigger-cases.json +30 -0
  176. package/skills/funnel-cohort/trigger-cases.json +30 -0
  177. package/skills/gov-document/trigger-cases.json +30 -0
  178. package/skills/gui-change-evidence/SKILL.md +47 -0
  179. package/skills/gui-change-evidence/trigger-cases.json +23 -0
  180. package/skills/hr-chain/trigger-cases.json +31 -0
  181. package/skills/hr-operations/trigger-cases.json +30 -0
  182. package/skills/info-disclosure/trigger-cases.json +31 -0
  183. package/skills/infra-gateway/trigger-cases.json +31 -0
  184. package/skills/infra-k8s/trigger-cases.json +31 -0
  185. package/skills/infra-mq/trigger-cases.json +30 -0
  186. package/skills/insurance-advisory/trigger-cases.json +30 -0
  187. package/skills/internal-audit/trigger-cases.json +30 -0
  188. package/skills/ip-management/trigger-cases.json +31 -0
  189. package/skills/ipo-listing/trigger-cases.json +31 -0
  190. package/skills/it-audit/trigger-cases.json +31 -0
  191. package/skills/itsm-ops/trigger-cases.json +31 -0
  192. package/skills/knowledge-sop/trigger-cases.json +31 -0
  193. package/skills/legal-chain/trigger-cases.json +31 -0
  194. package/skills/legal-contract/trigger-cases.json +30 -0
  195. package/skills/litigation-support/trigger-cases.json +31 -0
  196. package/skills/logistics-warehouse/trigger-cases.json +30 -0
  197. package/skills/long-doc-analysis/trigger-cases.json +31 -0
  198. package/skills/management-accounting/trigger-cases.json +30 -0
  199. package/skills/manufacturing-chain/trigger-cases.json +31 -0
  200. package/skills/manufacturing-quality/trigger-cases.json +30 -0
  201. package/skills/marketing-analytics/trigger-cases.json +31 -0
  202. package/skills/marketing-chain/trigger-cases.json +31 -0
  203. package/skills/meeting-minutes/trigger-cases.json +30 -0
  204. package/skills/metrics-system/trigger-cases.json +31 -0
  205. package/skills/negotiation-prep/trigger-cases.json +30 -0
  206. package/skills/obs-prometheus/trigger-cases.json +31 -0
  207. package/skills/office-automation/trigger-cases.json +31 -0
  208. package/skills/okr-review/trigger-cases.json +30 -0
  209. package/skills/ops-fleet/trigger-cases.json +31 -0
  210. package/skills/ops-linux/trigger-cases.json +31 -0
  211. package/skills/optimization-solver/trigger-cases.json +30 -0
  212. package/skills/patent-disclosure/SKILL.md +105 -0
  213. package/skills/patent-disclosure/references/figures.md +110 -0
  214. package/skills/patent-disclosure/references/outline.md +162 -0
  215. package/skills/patent-disclosure/scripts/patent_docx.py +230 -0
  216. package/skills/patent-disclosure/trigger-cases.json +31 -0
  217. package/skills/patent-drafting/SKILL.md +108 -0
  218. package/skills/patent-drafting/trigger-cases.json +31 -0
  219. package/skills/patent-portfolio/SKILL.md +81 -0
  220. package/skills/patent-portfolio/trigger-cases.json +31 -0
  221. package/skills/pdf-toolkit/trigger-cases.json +31 -0
  222. package/skills/perf-optimize/trigger-cases.json +31 -0
  223. package/skills/pivot-crosstab/trigger-cases.json +30 -0
  224. package/skills/policy-analysis/trigger-cases.json +31 -0
  225. package/skills/portfolio-analysis/trigger-cases.json +31 -0
  226. package/skills/ppt-chain/SKILL.md +14 -6
  227. package/skills/ppt-chain/trigger-cases.json +31 -0
  228. package/skills/pptx-author/SKILL.md +86 -33
  229. package/skills/pptx-author/scripts/__pycache__/pptx_check.cpython-312.pyc +0 -0
  230. package/skills/pptx-author/scripts/__pycache__/pptx_deck.cpython-312.pyc +0 -0
  231. package/skills/pptx-author/scripts/__pycache__/pptx_layout.cpython-312.pyc +0 -0
  232. package/skills/pptx-author/scripts/pptx_battery.py +178 -0
  233. package/skills/pptx-author/scripts/pptx_build.py +119 -0
  234. package/skills/pptx-author/scripts/pptx_check.py +1182 -0
  235. package/skills/pptx-author/scripts/pptx_deck.py +736 -145
  236. package/skills/pptx-author/scripts/pptx_layout.py +384 -0
  237. package/skills/pptx-author/trigger-cases.json +15 -0
  238. package/skills/pr-comms/trigger-cases.json +30 -0
  239. package/skills/procurement-supply/trigger-cases.json +31 -0
  240. package/skills/product-design/trigger-cases.json +31 -0
  241. package/skills/production-planning/trigger-cases.json +31 -0
  242. package/skills/project-management/trigger-cases.json +31 -0
  243. package/skills/project-proposal/trigger-cases.json +31 -0
  244. package/skills/prose-standard/SKILL.md +53 -0
  245. package/skills/prose-standard/trigger-cases.json +23 -0
  246. package/skills/qa-api-automation/trigger-cases.json +31 -0
  247. package/skills/qa-mobile-automation/trigger-cases.json +31 -0
  248. package/skills/qa-ui-automation/trigger-cases.json +31 -0
  249. package/skills/quant-analysis/trigger-cases.json +31 -0
  250. package/skills/rag-triage/SKILL.md +64 -0
  251. package/skills/rag-triage/evals.json +57 -0
  252. package/skills/rag-triage/references/triage-playbook.md +75 -0
  253. package/skills/rag-triage/trigger-cases.json +31 -0
  254. package/skills/real-estate-property/trigger-cases.json +30 -0
  255. package/skills/refactor-safely/SKILL.md +1 -1
  256. package/skills/refactor-safely/trigger-cases.json +30 -0
  257. package/skills/regression-forecast/trigger-cases.json +31 -0
  258. package/skills/release-checklist/trigger-cases.json +31 -0
  259. package/skills/report-craft/SKILL.md +76 -0
  260. package/skills/report-craft/references/feasibility.md +118 -0
  261. package/skills/report-craft/references/review-checklist.md +51 -0
  262. package/skills/report-craft/references/visuals.md +114 -0
  263. package/skills/report-craft/references/writing.md +80 -0
  264. package/skills/report-craft/scripts/__pycache__/chart_render.cpython-312.pyc +0 -0
  265. package/skills/report-craft/scripts/__pycache__/fin_model.cpython-312.pyc +0 -0
  266. package/skills/report-craft/scripts/__pycache__/project_facts.cpython-312.pyc +0 -0
  267. package/skills/report-craft/scripts/chart_render.py +1222 -0
  268. package/skills/report-craft/scripts/fin_model.py +602 -0
  269. package/skills/report-craft/scripts/project_facts.py +580 -0
  270. package/skills/report-craft/scripts/report_battery.py +349 -0
  271. package/skills/report-craft/trigger-cases.json +15 -0
  272. package/skills/retail-store-ops/trigger-cases.json +30 -0
  273. package/skills/risk-compliance/trigger-cases.json +31 -0
  274. package/skills/scope-creep/SKILL.md +45 -0
  275. package/skills/scope-creep/trigger-cases.json +7 -0
  276. package/skills/sec-auth/trigger-cases.json +31 -0
  277. package/skills/sec-compliance/trigger-cases.json +31 -0
  278. package/skills/securities-chain/trigger-cases.json +31 -0
  279. package/skills/securities-research/trigger-cases.json +31 -0
  280. package/skills/security-audit/trigger-cases.json +31 -0
  281. package/skills/slides-html/SKILL.md +25 -10
  282. package/skills/slides-html/trigger-cases.json +11 -0
  283. package/skills/sql-analysis/SKILL.md +110 -0
  284. package/skills/sql-analysis/trigger-cases.json +34 -0
  285. package/skills/sql-tuning/trigger-cases.json +31 -0
  286. package/skills/statistical-testing/trigger-cases.json +31 -0
  287. package/skills/strategy-planning/trigger-cases.json +30 -0
  288. package/skills/supply-chain/trigger-cases.json +31 -0
  289. package/skills/tax-compliance/trigger-cases.json +30 -0
  290. package/skills/tech-doc/SKILL.md +262 -0
  291. package/skills/tech-doc/scripts/__pycache__/archive_battery.cpython-312.pyc +0 -0
  292. package/skills/tech-doc/scripts/__pycache__/archive_build.cpython-312.pyc +0 -0
  293. package/skills/tech-doc/scripts/__pycache__/archive_check.cpython-312.pyc +0 -0
  294. package/skills/tech-doc/scripts/__pycache__/archive_spec.cpython-312.pyc +0 -0
  295. package/skills/tech-doc/scripts/__pycache__/doc_templates.cpython-312.pyc +0 -0
  296. package/skills/tech-doc/scripts/archive_battery.py +860 -0
  297. package/skills/tech-doc/scripts/archive_build.py +599 -0
  298. package/skills/tech-doc/scripts/archive_check.py +1307 -0
  299. package/skills/tech-doc/scripts/archive_spec.py +499 -0
  300. package/skills/tech-doc/scripts/doc_templates.py +624 -0
  301. package/skills/tech-doc/trigger-cases.json +57 -0
  302. package/skills/tech-research/trigger-cases.json +31 -0
  303. package/skills/tender-bidding/trigger-cases.json +31 -0
  304. package/skills/translation-pro/trigger-cases.json +30 -0
  305. package/skills/treasury-mgmt/trigger-cases.json +31 -0
  306. package/skills/trim-cot-leakage/SKILL.md +58 -0
  307. package/skills/trim-cot-leakage/trigger-cases.json +23 -0
  308. package/skills/web-craft/SKILL.md +103 -0
  309. package/skills/web-craft/references/craft-checklist.md +75 -0
  310. package/skills/web-craft/references/design-direction.md +122 -0
  311. package/skills/web-craft/references/h5-mobile.md +100 -0
  312. package/skills/web-craft/references/motion.md +136 -0
  313. package/skills/web-craft/references/recipes.md +74 -0
  314. package/skills/web-craft/templates/wedding-invitation.html +1180 -0
  315. package/skills/web-craft/trigger-cases.json +14 -0
  316. package/skills/web-scraping/trigger-cases.json +34 -0
  317. package/skills/web-search/SKILL.md +11 -10
  318. package/skills/web-search/trigger-cases.json +31 -0
  319. package/skills/weekly-report/SKILL.md +134 -0
  320. package/skills/weekly-report/trigger-cases.json +11 -0
  321. package/skills/write-tests/trigger-cases.json +31 -0
  322. package/skills/xlsx-analysis/SKILL.md +143 -77
  323. package/skills/xlsx-analysis/scripts/__pycache__/data_profile.cpython-312.pyc +0 -0
  324. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_build.cpython-312.pyc +0 -0
  325. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_check.cpython-312.pyc +0 -0
  326. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_layout.cpython-312.pyc +0 -0
  327. package/skills/xlsx-analysis/scripts/__pycache__/xlsx_toolkit.cpython-312.pyc +0 -0
  328. package/skills/xlsx-analysis/scripts/data_profile.py +391 -0
  329. package/skills/xlsx-analysis/scripts/xlsx_battery.py +389 -0
  330. package/skills/xlsx-analysis/scripts/xlsx_build.py +522 -0
  331. package/skills/xlsx-analysis/scripts/xlsx_check.py +924 -0
  332. package/skills/xlsx-analysis/scripts/xlsx_layout.py +235 -0
  333. package/skills/xlsx-analysis/scripts/xlsx_style.py +28 -66
  334. package/skills/xlsx-analysis/scripts/xlsx_toolkit.py +61 -5
  335. package/skills/xlsx-analysis/trigger-cases.json +59 -0
  336. package/squads/hc-defense.md +47 -0
  337. package/squads/hc-rdarchive.md +110 -0
  338. package/squads/hc-redteam.md +50 -0
  339. package/squads/hc-weekly.md +125 -0
  340. package/src/account.js +1 -1
  341. package/src/acp.js +1 -0
  342. package/src/advisor.js +1 -1
  343. package/src/agent.js +1 -1
  344. package/src/api.js +1 -1
  345. package/src/archaeology.js +1 -0
  346. package/src/ask-ui.js +1 -0
  347. package/src/assets-import.js +1 -1
  348. package/src/autofix.js +1 -1
  349. package/src/automation-tools.js +1 -1
  350. package/src/automation.js +1 -1
  351. package/src/autorun.js +1 -1
  352. package/src/banner.js +1 -1
  353. package/src/capability-probe.js +1 -0
  354. package/src/checkpoints.js +1 -1
  355. package/src/code-tools.js +1 -0
  356. package/src/codeindex.js +1 -1
  357. package/src/codemode.js +1 -0
  358. package/src/command-bridge.js +1 -0
  359. package/src/command-ui.js +1 -0
  360. package/src/commands.js +1 -1
  361. package/src/compaction.js +1 -1
  362. package/src/computer.js +1 -1
  363. package/src/config.js +1 -1
  364. package/src/consult.js +1 -1
  365. package/src/context-report.js +1 -1
  366. package/src/context-window.js +1 -0
  367. package/src/context.js +1 -1
  368. package/src/crew.js +1 -0
  369. package/src/crush.js +1 -0
  370. package/src/daemon.js +1 -1
  371. package/src/decisions.js +1 -1
  372. package/src/depaudit.js +1 -0
  373. package/src/device.js +1 -1
  374. package/src/diagram-draw.js +1 -0
  375. package/src/diagram-layouts.js +1 -0
  376. package/src/diagram-raster.js +1 -0
  377. package/src/diagram.js +1 -0
  378. package/src/dictation.js +1 -0
  379. package/src/diffview.js +1 -1
  380. package/src/doctor.js +1 -1
  381. package/src/docx-tools.js +1 -0
  382. package/src/engineering.js +1 -0
  383. package/src/errkind.js +1 -1
  384. package/src/evidence.js +1 -0
  385. package/src/executors.js +1 -1
  386. package/src/expert-catalog.js +1 -1
  387. package/src/experts-store.js +1 -1
  388. package/src/experts.js +1 -1
  389. package/src/external-agents.js +1 -0
  390. package/src/forks.js +1 -1
  391. package/src/fs-observe.js +1 -0
  392. package/src/gitflow.js +1 -1
  393. package/src/goalround.js +1 -0
  394. package/src/hooks.js +1 -1
  395. package/src/ide.js +1 -1
  396. package/src/input.js +1 -1
  397. package/src/insights.js +1 -1
  398. package/src/jobs.js +1 -0
  399. package/src/kbsync.js +1 -1
  400. package/src/lsp.js +1 -0
  401. package/src/mcp-apps.js +1 -0
  402. package/src/mcp.js +1 -1
  403. package/src/memory.js +1 -1
  404. package/src/notify.js +1 -1
  405. package/src/permissions.js +1 -1
  406. package/src/plan-limits.js +1 -0
  407. package/src/policy.js +1 -0
  408. package/src/pptx-tools.js +1 -0
  409. package/src/presence.js +1 -1
  410. package/src/privatedir.js +1 -0
  411. package/src/pyruntime.js +1 -1
  412. package/src/radar.js +1 -0
  413. package/src/render.js +1 -1
  414. package/src/repl.js +1 -1
  415. package/src/report-tools.js +1 -0
  416. package/src/routing.js +1 -0
  417. package/src/sandbox-probe.js +1 -0
  418. package/src/sandbox-win-launcher.mjs +156 -0
  419. package/src/sandbox.js +1 -0
  420. package/src/scan.js +1 -1
  421. package/src/schedule.js +1 -1
  422. package/src/scopecreep.js +1 -0
  423. package/src/sdk.js +1 -0
  424. package/src/selection.js +1 -0
  425. package/src/session-events.js +1 -0
  426. package/src/session.js +1 -1
  427. package/src/sessionindex.js +1 -0
  428. package/src/sessionlog.js +1 -0
  429. package/src/skill-catalog.js +1 -0
  430. package/src/skill-lint.js +1 -0
  431. package/src/skill-optimize.js +1 -0
  432. package/src/skill-scan.js +1 -0
  433. package/src/skills.js +1 -1
  434. package/src/spill.js +1 -0
  435. package/src/squad.js +1 -1
  436. package/src/subagent.js +1 -1
  437. package/src/surface-state.js +1 -0
  438. package/src/tapd.js +1 -0
  439. package/src/terminal-select.js +1 -0
  440. package/src/terminal.js +1 -0
  441. package/src/test-runner.js +1 -0
  442. package/src/todo.js +1 -1
  443. package/src/toolpipe.js +1 -0
  444. package/src/tools.js +1 -1
  445. package/src/toon.js +1 -0
  446. package/src/transcript.js +1 -1
  447. package/src/tui/app.js +1 -1
  448. package/src/tui/command-menu.js +1 -0
  449. package/src/tui/progress.js +1 -0
  450. package/src/tui/scroll.js +1 -0
  451. package/src/tui/welcome.js +1 -0
  452. package/src/tui/workbench.js +1 -0
  453. package/src/ui-spec.js +1 -0
  454. package/src/uitree.js +1 -0
  455. package/src/uninstall.js +1 -1
  456. package/src/vision.js +1 -1
  457. package/src/visual.js +1 -1
  458. package/src/voice-form.js +1 -0
  459. package/src/voice.js +1 -0
  460. package/src/watch.js +1 -1
  461. package/src/web-audit.js +1 -0
  462. package/src/web-cdp.js +1 -0
  463. package/src/web-inspect.js +1 -0
  464. package/src/websearch.js +1 -0
  465. package/src/weekly.js +1 -0
  466. package/src/workflow.js +1 -0
  467. package/src/worklog.js +1 -0
  468. package/src/xlsx-tools.js +1 -0
  469. package/ui/command-picker.js +79 -0
  470. package/skills/ab-testing/scripts/__pycache__/ab_test.cpython-312.pyc +0 -0
  471. package/skills/accounting-ops/scripts/__pycache__/acct_tools.cpython-312.pyc +0 -0
  472. package/skills/anomaly-detection/scripts/__pycache__/anomaly.cpython-312.pyc +0 -0
  473. package/skills/audit-assist/scripts/__pycache__/audit_sampling.cpython-312.pyc +0 -0
  474. package/skills/banking-credit/scripts/__pycache__/credit_metrics.cpython-312.pyc +0 -0
  475. package/skills/data-report-pro/scripts/__pycache__/bigdata.cpython-312.pyc +0 -0
  476. package/skills/data-wrangling/scripts/__pycache__/join_tools.cpython-312.pyc +0 -0
  477. package/skills/ecommerce-ops/scripts/__pycache__/gmv_decompose.cpython-312.pyc +0 -0
  478. package/skills/funnel-cohort/scripts/__pycache__/funnel.cpython-312.pyc +0 -0
  479. package/skills/management-accounting/scripts/__pycache__/costing.cpython-312.pyc +0 -0
  480. package/skills/manufacturing-quality/scripts/__pycache__/spc.cpython-312.pyc +0 -0
  481. package/skills/marketing-analytics/scripts/__pycache__/rfm.cpython-312.pyc +0 -0
  482. package/skills/optimization-solver/scripts/__pycache__/solver.cpython-312.pyc +0 -0
  483. package/skills/pivot-crosstab/scripts/__pycache__/pivot_tools.cpython-312.pyc +0 -0
  484. package/skills/portfolio-analysis/scripts/__pycache__/portfolio.cpython-312.pyc +0 -0
  485. package/skills/procurement-supply/scripts/__pycache__/supply.cpython-312.pyc +0 -0
  486. package/skills/production-planning/scripts/__pycache__/mrp_capacity.cpython-312.pyc +0 -0
  487. package/skills/project-management/scripts/__pycache__/cpm.cpython-312.pyc +0 -0
  488. package/skills/quant-analysis/scripts/__pycache__/quant.cpython-312.pyc +0 -0
  489. package/skills/real-estate-property/scripts/__pycache__/property_calc.cpython-312.pyc +0 -0
  490. package/skills/regression-forecast/scripts/__pycache__/regress.cpython-312.pyc +0 -0
  491. package/skills/securities-research/scripts/__pycache__/valuation.cpython-312.pyc +0 -0
  492. package/skills/statistical-testing/scripts/__pycache__/stat_tests.cpython-312.pyc +0 -0
  493. package/skills/treasury-mgmt/scripts/__pycache__/cashflow_forecast.cpython-312.pyc +0 -0
@@ -0,0 +1,1180 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" data-theme="cinnabar">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="format-detection" content="telephone=no">
7
+ <meta name="theme-color" content="#B3302A">
8
+ <title>顾北辰 &amp; 沈知意 · 婚礼请柬</title>
9
+ <meta name="description" content="谨定于 2026 年 6 月 6 日(星期六)中午 11:18,在苏州云栖酒店揽月厅举行婚礼,恭候光临。">
10
+ <meta property="og:title" content="顾北辰 &amp; 沈知意 · 婚礼请柬">
11
+ <meta property="og:description" content="2026.06.06 星期六 · 苏州云栖酒店 揽月厅">
12
+ <script>
13
+ /* ════════
14
+ * 请柬内容 —— 只改这一段即可(页面上的文字全部由它生成)。
15
+ * 同步改一下上面 <title> 与 description:微信/QQ 抓分享卡片时脚本还没跑。
16
+ * ════════ */
17
+ window.INVITE = {
18
+ // 示例数据标记:换成新人的真实信息后改为 false。为 true 时页面顶部会挂一条 TODO 提醒,
19
+ // web_inspect 会按「开发说明露给访客」报必修——专防复制了模板却忘了换内容
20
+ sample: true,
21
+ groom: '顾北辰',
22
+ bride: '沈知意',
23
+ // 婚礼开始时间:必须带时区(+08:00),外地亲友手机时区不同也不会算错
24
+ date: '2026-06-06T11:18:00+08:00',
25
+ durationHours: 3, // 加入日历时的时长
26
+ lunar: '', // 留空 = 按中国农历自动计算(如「丙午年四月廿一」)
27
+ tagline: '六月六日 · 六六大顺', // 首屏一句话:日子有讲究就点出来(用代码算过的);没有就写「我们结婚啦」
28
+ theme: 'cinnabar', // cinnabar 朱砂宣纸 | champagne 法式香槟 | sage 森系 | ink 水墨
29
+ venue: {
30
+ name: '云栖酒店',
31
+ hall: '揽月厅',
32
+ city: '苏州',
33
+ address: '苏州市姑苏区示例路 88 号',
34
+ lnglat: '', // 可选:高德坐标「经度,纬度」;留空则按名称检索导航(不会因坐标写错把人导错地方)
35
+ tips: '园区内可停车,婚宴当日请听从现场指引。'
36
+ },
37
+ // 流程时间请与新人核对后再发出
38
+ schedule: [
39
+ { time: '10:30', title: '迎宾签到', desc: '宴会厅前庭 · 茶点与合影' },
40
+ { time: '11:18', title: '婚礼仪式', desc: '见证我们交换誓言', key: true },
41
+ { time: '12:00', title: '喜宴开席', desc: '薄酒佳肴 · 与君同乐' },
42
+ { time: '13:00', title: '新人敬酒', desc: '逐桌致谢 · 感念相伴' }
43
+ ],
44
+ rsvp: {
45
+ deadline: '', // 留空 = 婚礼前 10 天;也可写「10月8日」
46
+ endpoint: '', // 可选:收集回执的接口(POST JSON);留空则保存在宾客本机并生成回执文本
47
+ maxGuests: 6
48
+ },
49
+ photos: [], // 可选:[{ src: 'photos/1.jpg', alt: '湖边的合照' }];为空则不显示相册
50
+ contacts: [], // 可选:[{ role: '新郎', name: '顾北辰', phone: '13800000000' }]
51
+ music: { src: '' } // 可选:背景音乐 mp3 路径;留空用内置八音盒《卡农》(无需任何文件)
52
+ };
53
+ document.documentElement.classList.add('js');
54
+ document.documentElement.setAttribute('data-theme', window.INVITE.theme || 'cinnabar');
55
+ </script>
56
+ <style>
57
+ /* ── 设计令牌:换风格只动这里 */
58
+ :root {
59
+ --paper: #F7F1E6; --paper-2: #EFE5D3; --card: #FBF7EF;
60
+ --ink: #2B2420; --ink-2: #554840; --ink-3: #6F6053;
61
+ --accent: #B3302A; --accent-deep: #8C1F18; --accent-text: #B3302A; --on-accent: #FBEFD9;
62
+ --gold: #9A7438; --gold-text: #7D5E2B; --gold-soft: #D8BF8C; --line: rgba(154, 116, 56, .38);
63
+ --hill: #E6D8BF; --water: #D9C7A6; --petal: 196, 72, 64;
64
+ --font-display: "STKaiti", "Kaiti SC", "KaiTi", "BiauKai", "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", "Noto Serif CJK SC", serif;
65
+ --font-serif: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", "Noto Serif CJK SC", "SimSun", serif;
66
+ --font-sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "HarmonyOS Sans SC", "MiSans", "Microsoft YaHei", "Noto Sans SC", sans-serif;
67
+ --font-num: "Didot", "Bodoni 72", "Bodoni MT", "Times New Roman", var(--font-serif);
68
+ --step--1: 13px; --step-0: 16px; --step-1: clamp(18px, 4.8vw, 20px);
69
+ --step-2: clamp(22px, 6vw, 26px); --step-3: clamp(30px, 8.6vw, 36px); --step-4: clamp(40px, 12vw, 54px);
70
+ --radius: 14px; --shadow: 0 14px 36px -18px rgba(70, 36, 16, .35);
71
+ --ease: cubic-bezier(.2, .7, .2, 1); --maxw: 440px;
72
+ --meander: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44'%3E%3Cpath d='M6 6h14v14H10V10h6v6M24 24h14v14H28V28h6v6' fill='none' stroke='%23fff' stroke-opacity='.09' stroke-width='1.2'/%3E%3C/svg%3E"); /* 回纹底纹:白色低透明,叠在任何主色上都成立 */
73
+ }
74
+ [data-theme="champagne"] {
75
+ --paper: #FBF7F2; --paper-2: #F3EBE1; --card: #FFFDFA;
76
+ --ink: #2E2A28; --ink-2: #57504B; --ink-3: #6E6560;
77
+ --accent: #9A5A60; --accent-deep: #7E4349; --accent-text: #7E4349; --on-accent: #FFF8F2;
78
+ --gold: #8F7348; --gold-text: #77603B; --gold-soft: #D9C6A3; --line: rgba(143, 115, 72, .36);
79
+ --hill: #EFE3D6; --water: #E2D2BF; --petal: 214, 150, 150;
80
+ }
81
+ [data-theme="sage"] {
82
+ --paper: #F5F4EE; --paper-2: #E9EBE0; --card: #FBFBF7;
83
+ --ink: #232923; --ink-2: #4A5248; --ink-3: #606A5E;
84
+ --accent: #5E7461; --accent-deep: #3F5243; --accent-text: #3F5243; --on-accent: #F6F5EE;
85
+ --gold: #8A7A4E; --gold-text: #6E6139; --gold-soft: #CFC6A2; --line: rgba(94, 116, 97, .34);
86
+ --hill: #DDE3D2; --water: #CBD5C0; --petal: 236, 214, 196;
87
+ }
88
+ [data-theme="ink"] {
89
+ --paper: #F6F4EF; --paper-2: #ECE9E2; --card: #FBFAF7;
90
+ --ink: #1F1E1C; --ink-2: #46443F; --ink-3: #5F5C56;
91
+ --accent: #A3312B; --accent-deep: #7D211C; --accent-text: #A3312B; --on-accent: #FAF6EE;
92
+ --gold: #7F6B45; --gold-text: #6E5C3A; --gold-soft: #CDBF9F; --line: rgba(31, 30, 28, .22);
93
+ --hill: #E3E0D8; --water: #D6D2C8; --petal: 163, 49, 43;
94
+ }
95
+
96
+ /* ── 基础 */
97
+ *, *::before, *::after { box-sizing: border-box; }
98
+ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
99
+ body {
100
+ margin: 0; background: var(--paper); color: var(--ink);
101
+ font: 400 var(--step-0)/1.8 var(--font-sans); letter-spacing: .02em;
102
+ -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent;
103
+ }
104
+ [hidden] { display: none !important; }
105
+ html.js body.is-locked { overflow: hidden; }
106
+ img, svg { display: block; max-width: 100%; }
107
+ button, input, textarea, select { font: inherit; color: inherit; }
108
+ button { cursor: pointer; }
109
+ h1, h2, h3, p, figure, ol, ul { margin: 0; }
110
+ ol, ul { padding: 0; list-style: none; }
111
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
112
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
113
+
114
+ .app { position: relative; max-width: var(--maxw); margin: 0 auto; background: var(--paper); overflow: clip; }
115
+ /* 宣纸纤维:极淡的噪点,只为让大面积纯色「有质感」 */
116
+ .app::before {
117
+ content: ""; position: absolute; inset: 0; pointer-events: none; opacity: .5; mix-blend-mode: multiply;
118
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .5 0 0 0 0 .42 0 0 0 0 .3 0 0 0 .07 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
119
+ }
120
+
121
+ /* ── 按钮 */
122
+ .btn {
123
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
124
+ min-height: 46px; padding: 0 20px; border-radius: 999px;
125
+ border: 1px solid var(--accent); background: transparent; color: var(--accent-text);
126
+ font-size: 15px; letter-spacing: .08em; text-decoration: none;
127
+ transition: transform .15s var(--ease), background-color .2s, color .2s;
128
+ }
129
+ .btn:active { transform: scale(.97); }
130
+ .btn--solid { background: var(--accent); color: var(--on-accent); }
131
+ .btn--block { width: 100%; min-height: 52px; font-size: 16px; }
132
+ .btn[disabled] { opacity: .55; cursor: default; }
133
+ .btn svg { width: 18px; height: 18px; flex: none; }
134
+
135
+ /* ── 封面(点开请柬) */
136
+ .gate { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; perspective: 1400px; touch-action: none; }
137
+ html:not(.js) .gate { display: none; }
138
+ .gate[hidden] { display: none; }
139
+ .gate__door {
140
+ position: absolute; top: 0; bottom: 0; width: 50.2%;
141
+ background:
142
+ radial-gradient(140% 70% at 50% 50%, rgba(255, 255, 255, .07), transparent 60%),
143
+ var(--meander),
144
+ var(--accent);
145
+ transition: transform 1.25s var(--ease) .3s;
146
+ backface-visibility: hidden;
147
+ }
148
+ .gate__door::before { content: ""; position: absolute; top: 16px; bottom: 16px; border: 1px solid rgba(255, 255, 255, .26); }
149
+ .gate__door--l { left: 0; transform-origin: left center; box-shadow: inset -1px 0 0 rgba(0, 0, 0, .18); }
150
+ .gate__door--l::before { left: 16px; right: 0; border-right: 0; }
151
+ .gate__door--r { right: 0; transform-origin: right center; box-shadow: inset 1px 0 0 rgba(255, 255, 255, .12); }
152
+ .gate__door--r::before { right: 16px; left: 0; border-left: 0; }
153
+ .gate__content {
154
+ position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center;
155
+ padding: 24px 28px calc(24px + env(safe-area-inset-bottom)); color: var(--on-accent); text-align: center;
156
+ transition: opacity .45s var(--ease), transform .45s var(--ease);
157
+ }
158
+ .gate__kicker { font-size: 12px; letter-spacing: .42em; margin-right: -.42em; }
159
+ .gate__medal {
160
+ display: grid; place-items: center; width: 104px; height: 104px; margin: 30px 0 26px; border-radius: 50%;
161
+ background: var(--gold-soft); color: var(--accent-deep);
162
+ box-shadow: 0 0 0 6px rgba(255, 255, 255, .1), 0 0 0 7px rgba(216, 191, 140, .55), 0 18px 40px -12px rgba(0, 0, 0, .45);
163
+ font: 56px/1 var(--font-display);
164
+ }
165
+ .gate__names { font: 400 34px/1.3 var(--font-display); color: var(--gold-soft); letter-spacing: .12em; }
166
+ .gate__names i { font-style: normal; margin: 0 .35em; opacity: .8; }
167
+ .gate__to { margin-top: 12px; font-size: 15px; letter-spacing: .12em; }
168
+ .gate__date { margin-top: 6px; font: 18px/1.6 var(--font-num); letter-spacing: .24em; }
169
+ .gate__btn {
170
+ margin-top: 40px; min-height: 50px; padding: 0 34px; border-radius: 999px;
171
+ border: 1px solid var(--gold-soft); background: rgba(0, 0, 0, .12); color: var(--on-accent);
172
+ font-size: 16px; letter-spacing: .3em; text-indent: .3em;
173
+ animation: breathe 2.6s ease-in-out infinite;
174
+ }
175
+ @keyframes breathe { 50% { box-shadow: 0 0 0 8px rgba(216, 191, 140, .14); } }
176
+ .gate.is-opening .gate__content { opacity: 0; transform: scale(.96); }
177
+ .gate.is-opening .gate__door--l { transform: rotateY(-108deg); }
178
+ .gate.is-opening .gate__door--r { transform: rotateY(108deg); }
179
+ .gate.is-gone { opacity: 0; transition: opacity .4s ease; pointer-events: none; }
180
+
181
+ /* ── 音乐按钮 */
182
+ .music {
183
+ position: fixed; z-index: 40; top: calc(12px + env(safe-area-inset-top)); right: max(12px, calc(50vw - var(--maxw) / 2 + 12px));
184
+ width: 44px; height: 44px; padding: 0; border-radius: 50%; display: grid; place-items: center;
185
+ border: 1px solid var(--line); background: rgba(255, 255, 255, .72); color: var(--accent-text);
186
+ -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); box-shadow: var(--shadow);
187
+ }
188
+ .music svg { width: 22px; height: 22px; }
189
+ .music.is-playing svg { animation: spin 5s linear infinite; }
190
+ .music::after { content: ""; position: absolute; width: 24px; height: 1.5px; background: currentColor; transform: rotate(-45deg); transition: opacity .2s; }
191
+ .music.is-playing::after { opacity: 0; }
192
+ @keyframes spin { to { transform: rotate(360deg); } }
193
+ .petals { position: fixed; inset: 0; z-index: 30; pointer-events: none; }
194
+
195
+ /* ── 首屏 */
196
+ .hero {
197
+ position: relative; min-height: 100vh; min-height: 100dvh;
198
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
199
+ padding: calc(72px + env(safe-area-inset-top)) 28px calc(96px + env(safe-area-inset-bottom)); text-align: center;
200
+ background: radial-gradient(120% 70% at 50% 38%, var(--card), var(--paper) 70%);
201
+ }
202
+ .corner { position: absolute; width: 58px; height: 58px; color: var(--gold); opacity: .8; }
203
+ .corner--tl { top: calc(16px + env(safe-area-inset-top)); left: 16px; }
204
+ .corner--tr { top: calc(16px + env(safe-area-inset-top)); right: 16px; transform: scaleX(-1); }
205
+ .corner--bl { bottom: 16px; left: 16px; transform: scaleY(-1); }
206
+ .corner--br { bottom: 16px; right: 16px; transform: scale(-1); }
207
+ .branch { position: absolute; top: calc(30px + env(safe-area-inset-top)); right: -8px; width: 58%; max-width: 250px; pointer-events: none; }
208
+ .hero__kicker { font-size: 12px; letter-spacing: .42em; margin-right: -.42em; color: var(--ink-3); }
209
+ .hero__seal {
210
+ width: 62px; height: 62px; margin: 26px auto 22px; display: grid; place-items: center; border-radius: 8px;
211
+ background: var(--accent); color: var(--on-accent); font: 38px/1 var(--font-display);
212
+ box-shadow: inset 0 0 0 3px var(--accent), inset 0 0 0 4.5px rgba(255, 255, 255, .55); transform: rotate(-4deg);
213
+ }
214
+ html.js body:not(.is-open) .hero__seal { opacity: 0; }
215
+ body.is-open .hero__seal { animation: stamp .75s var(--ease) .25s both; }
216
+ @keyframes stamp { from { opacity: 0; transform: scale(1.9) rotate(-14deg); } to { opacity: 1; transform: scale(1) rotate(-4deg); } }
217
+ .hero__names { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0 .22em; font: 400 var(--step-4)/1.2 var(--font-display); letter-spacing: .08em; }
218
+ .hero__names.is-long { font-size: var(--step-3); }
219
+ .hero__amp { font: italic 400 .52em/1 var(--font-num); color: var(--gold-text); padding-top: .2em; }
220
+ .hero__tagline { margin-top: 14px; font: 400 var(--step-1)/1.6 var(--font-serif); color: var(--ink-2); letter-spacing: .24em; margin-right: -.24em; }
221
+ .hero__date { display: flex; align-items: center; gap: 14px; margin-top: 34px; font: 400 var(--step-3)/1 var(--font-num); letter-spacing: .06em; }
222
+ .hero__date::before, .hero__date::after { content: ""; width: 34px; height: 1px; background: var(--line); }
223
+ .hero__meta { margin-top: 14px; font-size: 15px; color: var(--ink-2); letter-spacing: .06em; text-wrap: balance; }
224
+ .hero__place { margin-top: 6px; font: 400 16px/1.7 var(--font-serif); color: var(--accent-text); letter-spacing: .16em; }
225
+ .scroll-hint {
226
+ position: absolute; left: 50%; bottom: calc(22px + env(safe-area-inset-bottom)); transform: translateX(-50%);
227
+ display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 44px; min-height: 44px; padding: 4px 8px;
228
+ font-size: 12px; letter-spacing: .3em; color: var(--ink-3); text-decoration: none;
229
+ }
230
+ .scroll-hint svg { width: 18px; height: 18px; animation: nudge 1.8s ease-in-out infinite; }
231
+ @keyframes nudge { 50% { transform: translateY(5px); } }
232
+
233
+ /* ── 通用区块 */
234
+ .section { position: relative; padding: 84px 26px; }
235
+ .section--tint { background: var(--paper-2); }
236
+ .head { text-align: center; margin-bottom: 38px; }
237
+ .head__en { display: block; font-size: 12px; letter-spacing: .4em; margin-right: -.4em; color: var(--ink-3); }
238
+ .head__cn { margin-top: 8px; font: 400 var(--step-2)/1.4 var(--font-display); letter-spacing: .3em; margin-right: -.3em; }
239
+ .head__rule { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 14px; color: var(--gold); }
240
+ .head__rule::before, .head__rule::after { content: ""; width: 42px; height: 1px; background: var(--line); }
241
+ .head__rule i { width: 7px; height: 7px; background: currentColor; transform: rotate(45deg); }
242
+
243
+ /* 入场:默认可见;脚本启用后才先藏起来(脚本出错时内容不会永远看不见) */
244
+ html.js .reveal { opacity: 0; transform: translateY(18px); transition: opacity .9s var(--ease), transform .9s var(--ease); transition-delay: calc(var(--d, 0) * 110ms); }
245
+ html.js .reveal.is-in { opacity: 1; transform: none; }
246
+
247
+ /* ── 请柬正文 */
248
+ .letter {
249
+ position: relative; padding: 40px 24px 34px; background: var(--card); text-align: center;
250
+ border: 1px solid var(--line); border-radius: 4px; box-shadow: var(--shadow);
251
+ }
252
+ .letter::before { content: ""; position: absolute; inset: 7px; border: 1px solid var(--line); border-radius: 2px; pointer-events: none; }
253
+ .letter__to { font: 400 var(--step-1)/1.6 var(--font-display); color: var(--accent-text); margin-bottom: 18px; letter-spacing: .12em; }
254
+ .letter__body { font: 400 16px/2.15 var(--font-serif); color: var(--ink); letter-spacing: .08em; }
255
+ .letter__body b { font-weight: 600; color: var(--accent-text); }
256
+ .letter__body .big { display: block; margin: 6px 0; font: 400 var(--step-2)/1.7 var(--font-display); letter-spacing: .2em; color: var(--ink); }
257
+ .letter__sign { margin-top: 26px; display: flex; justify-content: center; align-items: baseline; gap: 10px; font: 400 var(--step-1)/1.4 var(--font-display); }
258
+ .letter__sign small { font: 13px/1 var(--font-sans); color: var(--ink-3); letter-spacing: .2em; }
259
+
260
+ /* ── 倒计时 */
261
+ .count { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
262
+ .count__cell { padding: 16px 0 12px; text-align: center; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); }
263
+ .count__num { display: block; font: 400 30px/1.1 var(--font-num); font-variant-numeric: tabular-nums; color: var(--ink); }
264
+ .count__num.tick { animation: tick .45s var(--ease); }
265
+ @keyframes tick { from { opacity: .2; transform: translateY(-6px); } }
266
+ .count__unit { display: block; margin-top: 6px; font-size: 13px; color: var(--ink-3); letter-spacing: .2em; margin-right: -.2em; }
267
+ .count__note { margin-top: 20px; text-align: center; font: 400 16px/1.8 var(--font-serif); color: var(--ink-2); letter-spacing: .1em; }
268
+ .count__actions { display: flex; justify-content: center; margin-top: 22px; }
269
+
270
+ /* ── 流程 */
271
+ .flow { position: relative; margin-left: 6px; }
272
+ .flow::before { content: ""; position: absolute; left: 76px; top: 10px; bottom: 10px; width: 1px; background: var(--line); }
273
+ .flow__item { position: relative; display: grid; grid-template-columns: 62px minmax(0, 1fr); gap: 30px; padding: 12px 0; }
274
+ .flow__time { font: 400 19px/1.5 var(--font-num); text-align: right; color: var(--ink-2); font-variant-numeric: tabular-nums; }
275
+ .flow__item::after { content: ""; position: absolute; left: 72px; top: 21px; width: 9px; height: 9px; border-radius: 50%; background: var(--paper-2); border: 1px solid var(--gold); }
276
+ .flow__item.is-key::after { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px rgba(var(--petal), .16); }
277
+ .flow__item.is-key .flow__time { color: var(--accent-text); }
278
+ .flow__title { font: 400 18px/1.5 var(--font-serif); letter-spacing: .1em; }
279
+ .flow__desc { font-size: 14px; color: var(--ink-2); line-height: 1.7; }
280
+
281
+ /* ── 地点 */
282
+ .venue { overflow: hidden; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
283
+ .venue__art { aspect-ratio: 32 / 15; width: 100%; height: auto; }
284
+ .venue__body { padding: 22px 22px 24px; }
285
+ .venue__name { font: 400 var(--step-2)/1.4 var(--font-display); letter-spacing: .12em; }
286
+ .venue__hall { margin-left: .4em; font: 400 16px var(--font-serif); color: var(--accent-text); letter-spacing: .1em; }
287
+ .venue__addr { margin-top: 6px; font-size: 15px; color: var(--ink-2); }
288
+ .venue__tips { margin-top: 10px; font-size: 14px; line-height: 1.7; color: var(--ink-3); }
289
+ .venue__nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 20px; }
290
+ .venue__nav .btn { padding: 0 10px; font-size: 14px; letter-spacing: .04em; }
291
+ .pin-ring { transform-box: fill-box; transform-origin: center; animation: ring 2.4s ease-out infinite; }
292
+ @keyframes ring { from { transform: scale(.4); opacity: .7; } to { transform: scale(1.6); opacity: 0; } }
293
+
294
+ /* ── 诗 */
295
+ .poem { position: relative; padding: 76px 26px 64px; background: var(--accent); color: var(--on-accent); text-align: center; overflow: hidden; }
296
+ .poem::before {
297
+ content: ""; position: absolute; inset: 0; opacity: .9;
298
+ background: var(--meander);
299
+ }
300
+ .poem__cols {
301
+ position: relative; display: inline-block; height: 5.6em; text-align: start;
302
+ writing-mode: vertical-rl; font: 400 24px/1.5 var(--font-display); letter-spacing: .34em;
303
+ }
304
+ .poem__cols p { margin-block-end: .9em; }
305
+ .poem__cols p:last-child { margin-block-end: 0; }
306
+ .poem__src { position: relative; margin-top: 26px; font-size: 13px; letter-spacing: .3em; margin-right: -.3em; }
307
+ .poem__seal {
308
+ position: relative; display: inline-block; height: 3.1em; margin-top: 18px; padding: 6px 5px;
309
+ writing-mode: vertical-rl; font: 17px/1.15 var(--font-display); letter-spacing: 0;
310
+ color: var(--accent-deep); background: var(--gold-soft); border-radius: 3px;
311
+ }
312
+
313
+ /* ── 相册(有照片才出现) */
314
+ .album { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
315
+ .album__item { position: relative; overflow: hidden; border-radius: 10px; background: var(--paper-2); aspect-ratio: 3 / 4; padding: 0; border: 0; }
316
+ .album__item:first-child { grid-column: span 2; aspect-ratio: 4 / 3; }
317
+ .album__item img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s var(--ease); }
318
+ .album__item:active img { transform: scale(1.03); }
319
+ .lightbox { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; background: rgba(20, 14, 10, .92); padding: 24px; }
320
+ .lightbox[hidden] { display: none; }
321
+ .lightbox img { max-height: 82vh; border-radius: 6px; object-fit: contain; }
322
+ .lightbox__close { position: absolute; top: calc(12px + env(safe-area-inset-top)); right: 12px; width: 44px; height: 44px; border-radius: 50%; border: 0; background: rgba(255, 255, 255, .14); color: #fff; font-size: 22px; }
323
+
324
+ /* ── 回执 */
325
+ .rsvp__lead { margin: -14px 0 26px; text-align: center; font-size: 15px; color: var(--ink-2); }
326
+ .form { display: grid; gap: 18px; padding: 26px 22px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
327
+ .field { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; min-width: 0; }
328
+ .field > label, .field > legend { padding: 0; font-size: 15px; color: var(--ink-2); letter-spacing: .06em; }
329
+ .field em { font-style: normal; color: var(--accent-text); }
330
+ .input {
331
+ width: 100%; min-height: 48px; padding: 10px 14px; border-radius: 12px;
332
+ border: 1px solid rgba(43, 36, 32, .2); background: #fff; font-size: 16px; line-height: 1.5;
333
+ transition: border-color .2s, box-shadow .2s; -webkit-appearance: none; appearance: none;
334
+ }
335
+ .input::placeholder { color: #776A5F; }
336
+ .input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--petal), .16); }
337
+ .input[aria-invalid="true"] { border-color: var(--accent-deep); }
338
+ textarea.input { min-height: 96px; resize: vertical; }
339
+ .seg { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
340
+ .seg__item { position: relative; }
341
+ .seg__item input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
342
+ .seg__item span {
343
+ display: grid; place-items: center; min-height: 48px; border-radius: 12px; border: 1px solid rgba(43, 36, 32, .2);
344
+ background: #fff; font-size: 15px; letter-spacing: .12em; transition: all .2s;
345
+ }
346
+ .seg__item input:checked + span { border-color: var(--accent); background: var(--accent); color: var(--on-accent); }
347
+ .seg__item input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
348
+ .stepper { display: grid; grid-template-columns: 48px minmax(0, 1fr) 48px; gap: 8px; }
349
+ .stepper .input { text-align: center; font: 400 18px var(--font-num); -moz-appearance: textfield; }
350
+ .stepper .input::-webkit-outer-spin-button, .stepper .input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
351
+ .stepper__btn { min-height: 48px; border-radius: 12px; border: 1px solid rgba(43, 36, 32, .2); background: #fff; font-size: 22px; line-height: 1; }
352
+ .field[aria-disabled="true"] { opacity: .45; }
353
+ .field__err { min-height: 0; font-size: 13px; color: var(--accent-deep); }
354
+ .field__err:empty { display: none; }
355
+ .field__count { justify-self: end; font-size: 13px; color: var(--ink-3); }
356
+ .done { padding: 30px 24px; text-align: center; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
357
+ .done[hidden] { display: none; }
358
+ .done__mark { width: 60px; height: 60px; margin: 0 auto 14px; display: grid; place-items: center; border-radius: 50%; background: var(--accent); color: var(--on-accent); font: 30px/1 var(--font-display); }
359
+ .done__title { font: 400 var(--step-2)/1.5 var(--font-display); letter-spacing: .12em; }
360
+ .done__name { margin-top: 2px; font: 400 var(--step-1)/1.5 var(--font-display); color: var(--accent-text); letter-spacing: .1em; overflow-wrap: anywhere; }
361
+ .done__text { margin-top: 10px; font-size: 15px; color: var(--ink-2); white-space: pre-line; }
362
+ .done__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 22px; }
363
+
364
+ /* ── 联系与结尾 */
365
+ .contacts { display: grid; gap: 10px; margin-top: 26px; }
366
+ .footer { padding: 76px 26px calc(56px + env(safe-area-inset-bottom)); text-align: center; background: var(--paper-2); }
367
+ .footer__thanks { font: 400 var(--step-1)/1.8 var(--font-serif); color: var(--ink-2); letter-spacing: .2em; margin-right: -.2em; }
368
+ .footer__names { margin-top: 10px; font: 400 var(--step-3)/1.3 var(--font-display); letter-spacing: .12em; }
369
+ .footer__date { margin-top: 6px; font: 400 16px/1.6 var(--font-num); letter-spacing: .3em; color: var(--ink-3); }
370
+ .footer__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 30px; }
371
+
372
+ /* ── 轻提示 / 分享引导 */
373
+ .toast {
374
+ position: fixed; left: 50%; bottom: calc(28px + env(safe-area-inset-bottom)); z-index: 70; max-width: min(86vw, 360px);
375
+ padding: 11px 20px; border-radius: 999px; background: rgba(43, 36, 32, .92); color: var(--paper);
376
+ font-size: 14px; text-align: center; transform: translate(-50%, 16px); opacity: 0; visibility: hidden;
377
+ transition: opacity .25s, transform .25s var(--ease), visibility .25s;
378
+ }
379
+ .toast.is-on { opacity: 1; visibility: visible; transform: translate(-50%, 0); }
380
+ .guide { position: fixed; inset: 0; z-index: 80; background: rgba(20, 14, 10, .82); color: #fff; }
381
+ .guide[hidden] { display: none; }
382
+ .guide svg { position: absolute; top: calc(8px + env(safe-area-inset-top)); right: 26px; width: 92px; height: 120px; }
383
+ .guide p { position: absolute; top: calc(140px + env(safe-area-inset-top)); left: 0; right: 0; text-align: center; font: 400 20px/1.9 var(--font-serif); letter-spacing: .12em; }
384
+
385
+ /* ── 桌面:请柬居中成一张「手机卡片」 */
386
+ @media (min-width: 720px) {
387
+ body {
388
+ background:
389
+ var(--meander),
390
+ var(--accent-deep);
391
+ }
392
+ .app { margin: 48px auto; border-radius: 28px; box-shadow: 0 40px 90px -30px rgba(0, 0, 0, .55); }
393
+ .music { top: 60px; }
394
+ .gate__kicker { font-size: 14px; }
395
+ .gate__medal { width: 132px; height: 132px; font-size: 72px; }
396
+ .gate__names { font-size: 46px; }
397
+ .gate__to { font-size: 18px; }
398
+ .gate__date { font-size: 22px; }
399
+ .hero { min-height: 780px; }
400
+ }
401
+
402
+ /* ── 减弱动效:晕动症用户、系统开了「减少动态效果」 */
403
+ @media (prefers-reduced-motion: reduce) {
404
+ *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; transition-delay: 0s !important; scroll-behavior: auto !important; }
405
+ html.js .reveal { opacity: 1; transform: none; }
406
+ }
407
+ </style>
408
+ </head>
409
+ <body class="is-locked">
410
+ <svg width="0" height="0" style="position:absolute" aria-hidden="true">
411
+ <symbol id="corner" viewBox="0 0 58 58"><path d="M2 56V14Q2 2 14 2h42M8 56V20Q8 8 20 8h36M14 14h8v8" fill="none" stroke="currentColor" stroke-width="1.1"/></symbol>
412
+ <defs><g id="bloom" fill="var(--accent)"><circle r="6" cx="0" cy="-6"/><circle r="6" cx="5.7" cy="-1.9"/><circle r="6" cx="3.5" cy="4.9"/><circle r="6" cx="-3.5" cy="4.9"/><circle r="6" cx="-5.7" cy="-1.9"/><circle r="2.6" fill="var(--gold-soft)"/></g></defs>
413
+ </svg>
414
+
415
+ <!-- 封面:点开请柬(首次点击同时作为播放音乐的用户手势) -->
416
+ <div class="gate" id="gate" role="dialog" aria-modal="true" aria-labelledby="gateNames">
417
+ <div class="gate__door gate__door--l" aria-hidden="true"></div>
418
+ <div class="gate__door gate__door--r" aria-hidden="true"></div>
419
+ <div class="gate__content">
420
+ <p class="gate__kicker">WEDDING INVITATION</p>
421
+ <div class="gate__medal" aria-hidden="true">囍</div>
422
+ <p class="gate__names" id="gateNames"><span data-bind="groom"></span><i>·</i><span data-bind="bride"></span></p>
423
+ <p class="gate__to" data-bind="gateTo"></p>
424
+ <p class="gate__date" data-bind="dateDots"></p>
425
+ <button class="gate__btn" id="gateOpen" type="button">轻触开启</button>
426
+ </div>
427
+ </div>
428
+
429
+ <button class="music" id="music" type="button" aria-label="播放背景音乐" aria-pressed="false" hidden>
430
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="9.2"/><circle cx="12" cy="12" r="2.6"/><path d="M12 2.8a9.2 9.2 0 0 1 8.4 5.4" stroke-linecap="round"/></svg>
431
+ </button>
432
+ <canvas class="petals" id="petals" aria-hidden="true"></canvas>
433
+
434
+ <main class="app" id="app">
435
+ <!-- 首屏 -->
436
+ <header class="hero" id="top">
437
+ <svg class="corner corner--tl" viewBox="0 0 58 58" aria-hidden="true"><use href="#corner"/></svg>
438
+ <svg class="corner corner--tr" viewBox="0 0 58 58" aria-hidden="true"><use href="#corner"/></svg>
439
+ <svg class="corner corner--bl" viewBox="0 0 58 58" aria-hidden="true"><use href="#corner"/></svg>
440
+ <svg class="corner corner--br" viewBox="0 0 58 58" aria-hidden="true"><use href="#corner"/></svg>
441
+ <!-- 梅枝:线条用墨色、花用主色、花蕊用金,换主题自动跟着变 -->
442
+ <svg class="branch" viewBox="0 0 240 170" aria-hidden="true">
443
+ <g fill="none" stroke="var(--ink-2)" stroke-linecap="round" opacity=".78">
444
+ <path d="M242 8C196 18 162 34 138 60 118 82 100 94 72 102 52 108 34 120 18 142" stroke-width="3"/>
445
+ <path d="M166 38c-4 16 0 30 12 42" stroke-width="2"/>
446
+ <path d="M118 80c-12-10-26-14-42-11" stroke-width="1.7"/>
447
+ <path d="M92 97c2 14-2 26-12 36" stroke-width="1.5"/>
448
+ </g>
449
+ <g fill="var(--accent)">
450
+ <use href="#bloom" transform="translate(178 80)"/>
451
+ <use href="#bloom" transform="translate(76 69) scale(.8)"/>
452
+ <use href="#bloom" transform="translate(138 60) scale(.62)" opacity=".9"/>
453
+ <circle cx="80" cy="133" r="3.6"/><circle cx="208" cy="22" r="3.2"/><circle cx="36" cy="124" r="2.8" opacity=".8"/><circle cx="160" cy="44" r="2.6" opacity=".8"/>
454
+ </g>
455
+ </svg>
456
+
457
+ <p class="hero__kicker reveal">WEDDING INVITATION</p>
458
+ <div class="hero__seal" aria-hidden="true">囍</div>
459
+ <h1 class="hero__names reveal" style="--d:1">
460
+ <span data-bind="groom"></span><span class="hero__amp" aria-label="与">&amp;</span><span data-bind="bride"></span>
461
+ </h1>
462
+ <p class="hero__tagline reveal" style="--d:2" data-bind="tagline"></p>
463
+ <p class="hero__date reveal" style="--d:3" data-bind="dateDots"></p>
464
+ <p class="hero__meta reveal" style="--d:4" data-bind="dateMeta"></p>
465
+ <p class="hero__place reveal" style="--d:5" data-bind="placeLine"></p>
466
+ <a class="scroll-hint" href="#letter" aria-label="向下浏览请柬">向下
467
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round"/></svg>
468
+ </a>
469
+ </header>
470
+
471
+ <!-- 请柬正文 -->
472
+ <section class="section" id="letter" aria-labelledby="letterTitle">
473
+ <div class="head reveal">
474
+ <span class="head__en">INVITATION</span>
475
+ <h2 class="head__cn" id="letterTitle">敬 请 光 临</h2>
476
+ <div class="head__rule" aria-hidden="true"><i></i></div>
477
+ </div>
478
+ <article class="letter reveal" style="--d:1">
479
+ <p class="letter__to" data-bind="letterTo" hidden></p>
480
+ <p class="letter__body" id="letterBody"></p>
481
+ <p class="letter__sign"><span data-bind="groom"></span><span data-bind="bride"></span><small>敬邀</small></p>
482
+ </article>
483
+ </section>
484
+
485
+ <!-- 倒计时 -->
486
+ <section class="section section--tint" id="countdown" aria-labelledby="countTitle">
487
+ <div class="head reveal">
488
+ <span class="head__en">COUNTDOWN</span>
489
+ <h2 class="head__cn" id="countTitle" data-bind="countTitle">婚 礼 倒 计 时</h2>
490
+ <div class="head__rule" aria-hidden="true"><i></i></div>
491
+ </div>
492
+ <div class="count reveal" style="--d:1" id="count" role="timer" aria-live="off">
493
+ <div class="count__cell"><b class="count__num" id="cDay">--</b><span class="count__unit">天</span></div>
494
+ <div class="count__cell"><b class="count__num" id="cHour">--</b><span class="count__unit">时</span></div>
495
+ <div class="count__cell"><b class="count__num" id="cMin">--</b><span class="count__unit">分</span></div>
496
+ <div class="count__cell"><b class="count__num" id="cSec">--</b><span class="count__unit">秒</span></div>
497
+ </div>
498
+ <p class="count__note reveal" style="--d:2" id="countNote"></p>
499
+ <div class="count__actions reveal" style="--d:3">
500
+ <button class="btn" id="calBtn" type="button">
501
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15.5" rx="2.5"/><path d="M3.5 10h17M8 3v4M16 3v4M12 13v5M9.5 15.5h5" stroke-linecap="round"/></svg>
502
+ 添加到日历提醒
503
+ </button>
504
+ </div>
505
+ </section>
506
+
507
+ <!-- 流程 -->
508
+ <section class="section" id="schedule" aria-labelledby="flowTitle">
509
+ <div class="head reveal">
510
+ <span class="head__en">SCHEDULE</span>
511
+ <h2 class="head__cn" id="flowTitle">婚 礼 流 程</h2>
512
+ <div class="head__rule" aria-hidden="true"><i></i></div>
513
+ </div>
514
+ <ol class="flow" id="flow"></ol>
515
+ </section>
516
+
517
+ <!-- 地点 -->
518
+ <section class="section section--tint" id="venue" aria-labelledby="venueTitle">
519
+ <div class="head reveal">
520
+ <span class="head__en">LOCATION</span>
521
+ <h2 class="head__cn" id="venueTitle">婚 宴 地 点</h2>
522
+ <div class="head__rule" aria-hidden="true"><i></i></div>
523
+ </div>
524
+ <div class="venue reveal" style="--d:1">
525
+ <!-- 示意插画而不是假地图:画错的地图比没有地图更糟 -->
526
+ <svg class="venue__art" viewBox="0 0 320 150" role="img" aria-label="婚宴地点示意插画">
527
+ <rect width="320" height="150" fill="var(--paper-2)"/>
528
+ <path d="M0 92C34 66 66 64 100 82c34-30 82-30 118-6 28-16 64-14 102 2v72H0z" fill="var(--hill)"/>
529
+ <path d="M0 112c40-8 86-6 124 0s86 8 124 0 58-8 72-6v44H0z" fill="var(--water)" opacity=".7"/>
530
+ <g stroke="var(--card)" stroke-width="2" stroke-linecap="round" opacity=".9"><path d="M24 126h46M104 134h60M200 124h52M60 142h44M232 140h56"/></g>
531
+ <g fill="none" stroke="var(--ink-2)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" opacity=".7">
532
+ <path d="M44 96q16-12 32 0M48 96v12M72 96v12M40 108h40"/>
533
+ <path d="M226 82q22-16 44 0M232 82v16M264 82v16M222 98h52M236 72q12-10 24 0"/>
534
+ </g>
535
+ <circle class="pin-ring" cx="160" cy="70" r="16" fill="none" stroke="var(--accent)" stroke-width="1.5"/>
536
+ <path d="M160 88s-15-14-15-25a15 15 0 0 1 30 0c0 11-15 25-15 25z" fill="var(--accent)"/>
537
+ <circle cx="160" cy="63" r="5.4" fill="var(--card)"/>
538
+ </svg>
539
+ <div class="venue__body">
540
+ <h3><span class="venue__name" data-bind="venueName"></span><span class="venue__hall" data-bind="venueHall"></span></h3>
541
+ <p class="venue__addr" data-bind="venueAddr"></p>
542
+ <p class="venue__tips" data-bind="venueTips"></p>
543
+ <div class="venue__nav">
544
+ <a class="btn btn--solid" id="navAmap" href="#" target="_blank" rel="noopener">高德导航</a>
545
+ <a class="btn" id="navBaidu" href="#" target="_blank" rel="noopener">百度导航</a>
546
+ <a class="btn" id="navApple" href="#" target="_blank" rel="noopener">Apple 地图</a>
547
+ <button class="btn" id="copyAddr" type="button">复制地址</button>
548
+ </div>
549
+ </div>
550
+ </div>
551
+ </section>
552
+
553
+ <!-- 诗:用经典代替编造的「恋爱故事」(私人经历不该由我们杜撰) -->
554
+ <section class="poem" aria-labelledby="poemSrc">
555
+ <div class="poem__cols reveal" lang="zh-CN"><p>桃之夭夭</p><p>灼灼其华</p><p>之子于归</p><p>宜其室家</p></div>
556
+ <p class="poem__src reveal" style="--d:2" id="poemSrc">《诗经 · 周南 · 桃夭》</p>
557
+ <div><p class="poem__seal reveal" style="--d:3" aria-hidden="true">宜室宜家</p></div>
558
+ </section>
559
+
560
+ <!-- 相册:INVITE.photos 为空时整块不出现(不给宾客看空框) -->
561
+ <section class="section" id="album" aria-labelledby="albumTitle" hidden>
562
+ <div class="head reveal">
563
+ <span class="head__en">MOMENTS</span>
564
+ <h2 class="head__cn" id="albumTitle">我 们 的 时 光</h2>
565
+ <div class="head__rule" aria-hidden="true"><i></i></div>
566
+ </div>
567
+ <div class="album" id="albumGrid"></div>
568
+ </section>
569
+
570
+ <!-- 回执 -->
571
+ <section class="section" id="rsvp" aria-labelledby="rsvpTitle">
572
+ <div class="head reveal">
573
+ <span class="head__en">R.S.V.P.</span>
574
+ <h2 class="head__cn" id="rsvpTitle">出 席 回 执</h2>
575
+ <div class="head__rule" aria-hidden="true"><i></i></div>
576
+ </div>
577
+ <p class="rsvp__lead reveal" data-bind="rsvpLead"></p>
578
+ <form class="form reveal" style="--d:1" id="rsvpForm" novalidate>
579
+ <div class="field">
580
+ <label for="fName">您的称呼 <em>*</em></label>
581
+ <input class="input" id="fName" name="name" type="text" autocomplete="name" maxlength="20" placeholder="如:张三 / 张三一家" aria-describedby="eName" required>
582
+ <p class="field__err" id="eName" role="alert"></p>
583
+ </div>
584
+ <fieldset class="field">
585
+ <legend>能否出席 <em>*</em></legend>
586
+ <div class="seg">
587
+ <label class="seg__item"><input type="radio" name="attend" value="yes" aria-describedby="eAttend"><span>欣然赴约</span></label>
588
+ <label class="seg__item"><input type="radio" name="attend" value="no" aria-describedby="eAttend"><span>遗憾缺席</span></label>
589
+ </div>
590
+ <p class="field__err" id="eAttend" role="alert"></p>
591
+ </fieldset>
592
+ <div class="field" id="guestsField">
593
+ <label for="fGuests">出席人数(含本人)</label>
594
+ <div class="stepper">
595
+ <button class="stepper__btn" type="button" data-step="-1" aria-label="减少一位">−</button>
596
+ <input class="input" id="fGuests" name="guests" type="number" inputmode="numeric" min="1" max="6" value="1">
597
+ <button class="stepper__btn" type="button" data-step="1" aria-label="增加一位">+</button>
598
+ </div>
599
+ </div>
600
+ <div class="field">
601
+ <label for="fPhone">联系电话(选填)</label>
602
+ <input class="input" id="fPhone" name="phone" type="tel" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="方便新人与您联系" aria-describedby="ePhone">
603
+ <p class="field__err" id="ePhone" role="alert"></p>
604
+ </div>
605
+ <div class="field">
606
+ <label for="fWish">祝福留言(选填)</label>
607
+ <textarea class="input" id="fWish" name="wish" rows="3" maxlength="200" placeholder="写下对新人的祝福"></textarea>
608
+ <p class="field__count" aria-live="off"><span id="wishCount">0</span>/200</p>
609
+ </div>
610
+ <button class="btn btn--solid btn--block" id="rsvpSubmit" type="submit">提交回执</button>
611
+ </form>
612
+ <div class="done" id="rsvpDone" hidden>
613
+ <div class="done__mark" aria-hidden="true">囍</div>
614
+ <p class="done__title" id="doneTitle"></p>
615
+ <p class="done__name" id="doneName"></p>
616
+ <p class="done__text" id="doneText"></p>
617
+ <div class="done__actions">
618
+ <button class="btn btn--solid" id="copyReceipt" type="button">复制回执</button>
619
+ <button class="btn" id="editReceipt" type="button">修改回复</button>
620
+ </div>
621
+ </div>
622
+ <div class="contacts" id="contacts" hidden></div>
623
+ </section>
624
+
625
+ <footer class="footer">
626
+ <p class="footer__thanks reveal">感谢您的祝福与陪伴</p>
627
+ <p class="footer__names reveal" style="--d:1" data-bind="namesLine"></p>
628
+ <p class="footer__date reveal" style="--d:2" data-bind="dateDots"></p>
629
+ <div class="footer__actions reveal" style="--d:3">
630
+ <button class="btn btn--solid" id="shareBtn" type="button">分享请柬</button>
631
+ <a class="btn" href="#top">回到顶部</a>
632
+ </div>
633
+ </footer>
634
+ </main>
635
+
636
+ <div class="lightbox" id="lightbox" hidden>
637
+ <img id="lightboxImg" alt="">
638
+ <button class="lightbox__close" id="lightboxClose" type="button" aria-label="关闭大图">×</button>
639
+ </div>
640
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
641
+ <div class="guide" id="guide" hidden>
642
+ <svg viewBox="0 0 92 120" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 112C24 70 44 38 78 14M78 14l-22 4M78 14l-4 22"/></svg>
643
+ <p>点击右上角 ···<br>选择「发送给朋友」</p>
644
+ </div>
645
+ <noscript><p style="padding:24px;text-align:center">请在微信或浏览器中打开,以查看完整的电子请柬。</p></noscript>
646
+
647
+ <script>
648
+ (() => {
649
+ 'use strict';
650
+ const C = window.INVITE;
651
+ const $ = (s, r = document) => r.querySelector(s);
652
+ const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
653
+ const UA = navigator.userAgent;
654
+ const isWeChat = /MicroMessenger/i.test(UA);
655
+ const isIOS = /iP(hone|ad|od)/.test(UA) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
656
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
657
+ const store = { // 隐私模式下 localStorage 会抛错:读写都要兜住
658
+ get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch (e) { return null; } },
659
+ set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { return false; } },
660
+ };
661
+
662
+ // ── 日期:一律按北京时间算,星期与农历都由代码推出来,不靠手填
663
+ const when = new Date(C.date);
664
+ const TZ = 'Asia/Shanghai';
665
+ const partsOf = (d) => {
666
+ const o = {};
667
+ new Intl.DateTimeFormat('zh-CN', { timeZone: TZ, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit', hourCycle: 'h23', weekday: 'long' })
668
+ .formatToParts(d).forEach((p) => { o[p.type] = p.value; });
669
+ return { y: +o.year, m: +o.month, d: +o.day, h: +o.hour, min: o.minute, week: o.weekday };
670
+ };
671
+ const W = partsOf(when);
672
+ const pad = (n) => String(n).padStart(2, '0');
673
+ const DIGIT = '〇一二三四五六七八九';
674
+ const cnYear = (y) => String(y).split('').map((c) => DIGIT[+c]).join('');
675
+ const cnNum = (n) => (n <= 10 ? (n === 10 ? '十' : DIGIT[n]) : n < 20 ? '十' + (n % 10 ? DIGIT[n % 10] : '') : DIGIT[Math.floor(n / 10)] + '十' + (n % 10 ? DIGIT[n % 10] : ''));
676
+ const lunarDay = (n) => (n <= 10 ? '初' + cnNum(n) : n < 20 ? '十' + DIGIT[n - 10] : n === 20 ? '二十' : n < 30 ? '廿' + DIGIT[n - 20] : '三十');
677
+ function lunarText(d) {
678
+ if (C.lunar) return C.lunar;
679
+ try {
680
+ const o = {};
681
+ new Intl.DateTimeFormat('zh-CN-u-ca-chinese', { timeZone: TZ, year: 'numeric', month: 'long', day: 'numeric' })
682
+ .formatToParts(d).forEach((p) => { o[p.type] = p.value; });
683
+ if (!o.month || !o.day) return '';
684
+ const month = o.month === '十一月' ? '冬月' : o.month;
685
+ return (o.yearName ? o.yearName + '年' : '') + month + lunarDay(+o.day);
686
+ } catch (e) { return ''; } // 老内核没有中国历:不显示,也不瞎猜
687
+ }
688
+ const lunar = lunarText(when);
689
+ const period = W.h < 11 ? '上午' : W.h < 13 ? '中午' : W.h < 18 ? '下午' : '晚上';
690
+ const clock = `${period} ${W.h > 12 ? W.h - 12 : W.h}:${W.min}`;
691
+ const guest = (new URLSearchParams(location.search).get('to') || '').replace(/[<>]/g, '').trim().slice(0, 20);
692
+
693
+ // ── 文案绑定:全部用 textContent(宾客名来自链接参数,绝不能拼进 innerHTML)──
694
+ const deadline = C.rsvp.deadline || (() => { const d = partsOf(new Date(when.getTime() - 10 * 864e5)); return `${d.m}月${d.d}日`; })();
695
+ const TEXT = {
696
+ groom: C.groom, bride: C.bride, tagline: C.tagline || '我们结婚啦',
697
+ dateDots: `${W.y} · ${pad(W.m)} · ${pad(W.d)}`,
698
+ dateMeta: [W.week, lunar && '农历' + lunar.replace(/^.{2}年/, ''), clock].filter(Boolean).join(' · '),
699
+ placeLine: `${C.venue.city} · ${C.venue.name}${C.venue.hall ? ' ' + C.venue.hall : ''}`,
700
+ namesLine: `${C.groom} & ${C.bride}`,
701
+ gateTo: guest ? `诚邀 ${guest} 见证我们的婚礼` : '诚邀您见证我们的婚礼',
702
+ letterTo: guest ? `敬呈 ${guest}` : '',
703
+ venueName: C.venue.name, venueHall: C.venue.hall, venueAddr: C.venue.address, venueTips: C.venue.tips || '',
704
+ rsvpLead: `为便于安排席位,请于 ${deadline} 前回复`,
705
+ countTitle: '婚 礼 倒 计 时',
706
+ };
707
+ $$('[data-bind]').forEach((el) => {
708
+ const v = TEXT[el.dataset.bind];
709
+ if (v == null) return;
710
+ el.textContent = v;
711
+ if (el.hasAttribute('hidden') && v) el.hidden = false;
712
+ });
713
+ document.title = `${C.groom} & ${C.bride} · 婚礼请柬`;
714
+ if (C.sample) { // 示例数据绊线:见 INVITE.sample 的注释
715
+ const bar = document.createElement('p');
716
+ bar.textContent = 'TODO:这是模板示例数据——把 window.INVITE 改成新人的真实信息,并把 sample 设为 false';
717
+ bar.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99;margin:0;padding:10px 14px;background:#fff3cd;color:#5c4400;font:14px/1.5 sans-serif;text-align:center';
718
+ document.body.appendChild(bar);
719
+ }
720
+ if (Math.max(C.groom.length, C.bride.length) >= 3) $('.hero__names').classList.add('is-long'); // 复姓/三字名:首屏名字降一档,免得挤出屏
721
+
722
+ // 请柬正文:旧式请柬的写法,日期用中文数字
723
+ (function letter() {
724
+ const box = $('#letterBody');
725
+ const line = (parts) => {
726
+ const p = document.createElement('span');
727
+ p.style.display = 'block';
728
+ parts.forEach((x) => {
729
+ if (typeof x === 'string') p.appendChild(document.createTextNode(x));
730
+ else { const el = document.createElement(x.tag || 'b'); el.textContent = x.text; if (x.cls) el.className = x.cls; p.appendChild(el); }
731
+ });
732
+ box.appendChild(p);
733
+ };
734
+ line(['谨定于公历', { text: `${cnYear(W.y)}年${cnNum(W.m)}月${cnNum(W.d)}日` }]);
735
+ line([lunar ? `农历${lunar} ${W.week}` : W.week]);
736
+ line([`${period}${cnNum(W.h > 12 ? W.h - 12 : W.h)}时${+W.min ? cnNum(+W.min) + '分' : '整'}`]);
737
+ line(['在 ', { text: `${C.venue.name}${C.venue.hall ? ' · ' + C.venue.hall : ''}` }]);
738
+ line(['举行结婚典礼']);
739
+ line([{ tag: 'span', cls: 'big', text: '敬备喜宴 恭候光临' }]);
740
+ })();
741
+
742
+ // ── 流程
743
+ (function flow() {
744
+ const list = $('#flow');
745
+ (C.schedule || []).forEach((it, i) => {
746
+ const li = document.createElement('li');
747
+ li.className = 'flow__item reveal' + (it.key ? ' is-key' : '');
748
+ li.style.setProperty('--d', i);
749
+ const t = document.createElement('span'); t.className = 'flow__time'; t.textContent = it.time;
750
+ const body = document.createElement('div');
751
+ const h = document.createElement('h3'); h.className = 'flow__title'; h.textContent = it.title;
752
+ body.appendChild(h);
753
+ if (it.desc) { const p = document.createElement('p'); p.className = 'flow__desc'; p.textContent = it.desc; body.appendChild(p); }
754
+ li.append(t, body);
755
+ list.appendChild(li);
756
+ });
757
+ if (!list.children.length) $('#schedule').hidden = true;
758
+ })();
759
+
760
+ // ── 导航:默认按名称检索(不需要地图 key,也不会因坐标写错把人导错地方)──
761
+ (function nav() {
762
+ const v = C.venue;
763
+ const kw = encodeURIComponent(v.name);
764
+ const city = encodeURIComponent(v.city || '');
765
+ const ll = (v.lnglat || '').split(',').map(Number);
766
+ const hasLL = ll.length === 2 && ll.every((n) => Number.isFinite(n));
767
+ $('#navAmap').href = hasLL
768
+ ? `https://uri.amap.com/marker?position=${ll[0]},${ll[1]}&name=${kw}&coordinate=gaode&callnative=1`
769
+ : `https://uri.amap.com/search?keyword=${kw}&city=${city}&callnative=1`;
770
+ $('#navBaidu').href = hasLL
771
+ ? `https://api.map.baidu.com/marker?location=${ll[1]},${ll[0]}&title=${kw}&content=${encodeURIComponent(v.address || '')}&coord_type=gcj02&output=html&src=webapp.invite.h5`
772
+ : `https://api.map.baidu.com/place/search?query=${kw}&region=${city}&output=html&src=webapp.invite.h5`;
773
+ $('#navApple').href = `https://maps.apple.com/?q=${encodeURIComponent(v.city + v.name)}${hasLL ? `&ll=${ll[1]},${ll[0]}` : ''}`;
774
+ $('#copyAddr').addEventListener('click', async () => {
775
+ toast(await copyText(`${v.name}${v.hall ? ' ' + v.hall : ''}\n${v.address}`) ? '地址已复制' : '复制失败,请长按地址手动复制');
776
+ });
777
+ })();
778
+
779
+ // ── 轻提示 / 复制
780
+ let toastTimer = 0;
781
+ function toast(msg) {
782
+ const el = $('#toast');
783
+ el.textContent = msg;
784
+ el.classList.add('is-on');
785
+ clearTimeout(toastTimer);
786
+ toastTimer = setTimeout(() => el.classList.remove('is-on'), 2200);
787
+ }
788
+ async function copyText(text) {
789
+ try {
790
+ if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; }
791
+ } catch (e) { /* 老版微信:走下面的兜底 */ }
792
+ const ta = document.createElement('textarea');
793
+ ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:-200px;opacity:0';
794
+ document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, text.length);
795
+ let ok = false;
796
+ try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
797
+ ta.remove();
798
+ return ok;
799
+ }
800
+
801
+ // ── 倒计时:按时间戳差值算,跨时区也准;过了婚期自动换成「已相守 N 天」──
802
+ (function countdown() {
803
+ const el = { d: $('#cDay'), h: $('#cHour'), m: $('#cMin'), s: $('#cSec') };
804
+ const note = $('#countNote');
805
+ const set = (node, val) => {
806
+ if (node.textContent === val) return;
807
+ node.textContent = val;
808
+ if (!reduceMotion) { node.classList.remove('tick'); void node.offsetWidth; node.classList.add('tick'); }
809
+ };
810
+ function render() {
811
+ const diff = when.getTime() - Date.now();
812
+ if (diff > 0) {
813
+ set(el.d, String(Math.floor(diff / 864e5)));
814
+ set(el.h, pad(Math.floor(diff % 864e5 / 36e5)));
815
+ set(el.m, pad(Math.floor(diff % 36e5 / 6e4)));
816
+ set(el.s, pad(Math.floor(diff % 6e4 / 1e3)));
817
+ note.textContent = diff < 864e5 ? '就是今天,期待与您相见' : `静候 ${W.m} 月 ${W.d} 日,与您共赴良辰`;
818
+ return true;
819
+ }
820
+ const days = Math.floor(-diff / 864e5);
821
+ $('#count').hidden = true;
822
+ $('[data-bind="countTitle"]').textContent = days < 1 ? '礼 成' : '执 手 同 行';
823
+ note.textContent = days < 1 ? '我们今天结婚了,谢谢您的到来' : `我们已携手走过 ${days} 天,感谢一路有您`;
824
+ return false;
825
+ }
826
+ if (render()) { const t = setInterval(() => { if (!render()) clearInterval(t); }, 1000); }
827
+ })();
828
+
829
+ // ── 加入日历(.ics)──
830
+ $('#calBtn').addEventListener('click', async () => {
831
+ const v = C.venue;
832
+ const end = new Date(when.getTime() + (C.durationHours || 3) * 36e5);
833
+ const fmt = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
834
+ const esc = (s) => String(s).replace(/[\\;,]/g, (m) => '\\' + m).replace(/\n/g, '\\n');
835
+ const title = `${C.groom} & ${C.bride} 的婚礼`;
836
+ const where = `${v.name}${v.hall ? ' ' + v.hall : ''}(${v.address})`;
837
+ if (isWeChat) { // 微信内置浏览器不支持下载文件
838
+ await copyText(`${title}\n${W.y}年${W.m}月${W.d}日 ${W.week} ${clock}\n${where}`);
839
+ toast('微信内无法直接添加日历,已复制时间地点;可点右上角「在浏览器打开」后添加');
840
+ return;
841
+ }
842
+ const ics = [
843
+ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//invite//wedding//CN', 'CALSCALE:GREGORIAN', 'BEGIN:VEVENT',
844
+ `UID:${fmt(when)}-${encodeURIComponent(C.groom + C.bride)}@invite`, `DTSTAMP:${fmt(new Date())}`,
845
+ `DTSTART:${fmt(when)}`, `DTEND:${fmt(end)}`, `SUMMARY:${esc(title)}`, `LOCATION:${esc(where)}`,
846
+ `DESCRIPTION:${esc('期待与您相见。' + (lunar ? '农历' + lunar : ''))}`,
847
+ 'BEGIN:VALARM', 'TRIGGER:-P1D', 'ACTION:DISPLAY', `DESCRIPTION:${esc('明天是' + title)}`, 'END:VALARM',
848
+ 'END:VEVENT', 'END:VCALENDAR',
849
+ ].join('\r\n');
850
+ if (isIOS) { location.href = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(ics); return; }
851
+ const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' }));
852
+ const a = Object.assign(document.createElement('a'), { href: url, download: 'wedding.ics' });
853
+ document.body.appendChild(a); a.click(); a.remove();
854
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
855
+ toast('已生成日历文件,打开即可添加提醒');
856
+ });
857
+
858
+ // ── 相册(可选)──
859
+ (function album() {
860
+ const photos = (C.photos || []).filter((p) => p && p.src);
861
+ if (!photos.length) return;
862
+ const grid = $('#albumGrid');
863
+ photos.forEach((p, i) => {
864
+ const btn = document.createElement('button');
865
+ btn.type = 'button'; btn.className = 'album__item reveal'; btn.style.setProperty('--d', i % 3);
866
+ btn.setAttribute('aria-label', '查看大图:' + (p.alt || '照片 ' + (i + 1)));
867
+ const img = new Image();
868
+ img.src = p.src; img.alt = p.alt || ''; img.loading = i ? 'lazy' : 'eager'; img.decoding = 'async';
869
+ img.addEventListener('error', () => btn.remove()); // 某张图丢了:这一格直接不显示,别留破图
870
+ btn.appendChild(img);
871
+ btn.addEventListener('click', () => { $('#lightboxImg').src = p.src; $('#lightboxImg').alt = p.alt || ''; $('#lightbox').hidden = false; });
872
+ grid.appendChild(btn);
873
+ });
874
+ $('#album').hidden = false;
875
+ const close = () => { $('#lightbox').hidden = true; };
876
+ $('#lightboxClose').addEventListener('click', close);
877
+ $('#lightbox').addEventListener('click', (e) => { if (e.target.id === 'lightbox') close(); });
878
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
879
+ })();
880
+
881
+ // ── 联系新人(可选)──
882
+ (function contacts() {
883
+ const list = (C.contacts || []).filter((c) => c && c.phone);
884
+ if (!list.length) return;
885
+ const box = $('#contacts');
886
+ list.forEach((c) => {
887
+ const a = document.createElement('a');
888
+ a.className = 'btn'; a.href = 'tel:' + String(c.phone).replace(/[^\d+]/g, '');
889
+ a.textContent = `致电${c.role || ''} ${c.name || ''}`.trim();
890
+ box.appendChild(a);
891
+ });
892
+ box.hidden = false;
893
+ })();
894
+
895
+ // ── 回执
896
+ (function rsvp() {
897
+ const form = $('#rsvpForm');
898
+ const done = $('#rsvpDone');
899
+ const key = 'invite-rsvp:' + C.groom + C.bride + C.date;
900
+ const max = Math.max(1, C.rsvp.maxGuests || 6);
901
+ const guests = $('#fGuests');
902
+ guests.max = String(max);
903
+ const setErr = (input, id, msg) => {
904
+ $('#' + id).textContent = msg;
905
+ if (input) input.setAttribute('aria-invalid', msg ? 'true' : 'false');
906
+ };
907
+ const attendValue = () => (form.querySelector('input[name="attend"]:checked') || {}).value || '';
908
+ const syncGuests = () => {
909
+ const no = attendValue() === 'no';
910
+ $('#guestsField').setAttribute('aria-disabled', no ? 'true' : 'false');
911
+ guests.disabled = no;
912
+ $$('.stepper__btn', form).forEach((b) => { b.disabled = no; });
913
+ };
914
+ $$('input[name="attend"]', form).forEach((r) => r.addEventListener('change', () => { setErr(null, 'eAttend', ''); syncGuests(); }));
915
+ $$('.stepper__btn', form).forEach((b) => b.addEventListener('click', () => {
916
+ const n = Math.min(max, Math.max(1, (parseInt(guests.value, 10) || 1) + Number(b.dataset.step)));
917
+ guests.value = String(n);
918
+ }));
919
+ guests.addEventListener('change', () => { guests.value = String(Math.min(max, Math.max(1, parseInt(guests.value, 10) || 1))); });
920
+ $('#fWish').addEventListener('input', (e) => { $('#wishCount').textContent = String(e.target.value.length); });
921
+ $('#fName').addEventListener('input', () => setErr($('#fName'), 'eName', ''));
922
+ $('#fPhone').addEventListener('input', (e) => { e.target.value = e.target.value.replace(/\D/g, '').slice(0, 11); setErr(e.target, 'ePhone', ''); });
923
+
924
+ const receipt = (r) => [
925
+ `【婚礼回执】${C.groom} & ${C.bride} · ${W.y}.${pad(W.m)}.${pad(W.d)}`,
926
+ `称呼:${r.name}`,
927
+ r.attend === 'yes' ? `出席:欣然赴约(${r.guests} 位)` : '出席:遗憾缺席',
928
+ r.phone ? `电话:${r.phone}` : '',
929
+ r.wish ? `祝福:${r.wish}` : '',
930
+ ].filter(Boolean).join('\n');
931
+ const showDone = (r, note) => {
932
+ $('#doneTitle').textContent = r.attend === 'yes' ? '期待与您相见' : '感谢您的祝福';
933
+ $('#doneName').textContent = r.name;
934
+ $('#doneText').textContent = (r.attend === 'yes' ? `已为您预留 ${r.guests} 个座位` : '虽不能同席,心意已收到') + (note ? '\n' + note : '');
935
+ form.hidden = true;
936
+ done.hidden = false;
937
+ };
938
+ const saved = store.get(key);
939
+ if (saved && saved.name) showDone(saved, '');
940
+
941
+ form.addEventListener('submit', async (e) => {
942
+ e.preventDefault();
943
+ const r = {
944
+ name: $('#fName').value.trim(), attend: attendValue(),
945
+ guests: attendValue() === 'yes' ? Math.min(max, Math.max(1, parseInt(guests.value, 10) || 1)) : 0,
946
+ phone: $('#fPhone').value.trim(), wish: $('#fWish').value.trim(), at: new Date().toISOString(),
947
+ };
948
+ let first = null;
949
+ if (!r.name) { setErr($('#fName'), 'eName', '请留下您的称呼'); first = first || $('#fName'); }
950
+ if (!r.attend) { setErr(null, 'eAttend', '请选择能否出席'); first = first || form.querySelector('input[name="attend"]'); }
951
+ if (r.phone && !/^1[3-9]\d{9}$/.test(r.phone)) { setErr($('#fPhone'), 'ePhone', '手机号格式不对(11 位,以 1 开头)'); first = first || $('#fPhone'); }
952
+ if (first) { first.focus(); return; }
953
+ const btn = $('#rsvpSubmit');
954
+ btn.disabled = true; btn.textContent = '提交中…';
955
+ let note = '';
956
+ if (C.rsvp.endpoint) {
957
+ try {
958
+ const ctl = new AbortController();
959
+ const timer = setTimeout(() => ctl.abort(), 8000);
960
+ const res = await fetch(C.rsvp.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...r, couple: `${C.groom}&${C.bride}` }), signal: ctl.signal });
961
+ clearTimeout(timer);
962
+ if (!res.ok) throw new Error('HTTP ' + res.status);
963
+ } catch (err) {
964
+ note = '网络不太顺畅,回执已保存在本机;可复制发给新人';
965
+ }
966
+ } else {
967
+ note = '可点「复制回执」发给新人';
968
+ }
969
+ store.set(key, r);
970
+ btn.disabled = false; btn.textContent = '提交回执';
971
+ showDone(r, note);
972
+ toast('回执已提交');
973
+ });
974
+ $('#copyReceipt').addEventListener('click', async () => {
975
+ const r = store.get(key);
976
+ if (!r) return;
977
+ toast(await copyText(receipt(r)) ? '回执已复制,可粘贴发给新人' : '复制失败,请截图发给新人');
978
+ });
979
+ $('#editReceipt').addEventListener('click', () => {
980
+ const r = store.get(key) || {};
981
+ $('#fName').value = r.name || '';
982
+ $('#fPhone').value = r.phone || '';
983
+ $('#fWish').value = r.wish || '';
984
+ $('#wishCount').textContent = String(($('#fWish').value || '').length);
985
+ guests.value = String(r.guests || 1);
986
+ $$('input[name="attend"]', form).forEach((x) => { x.checked = x.value === r.attend; });
987
+ syncGuests();
988
+ done.hidden = true;
989
+ form.hidden = false;
990
+ $('#fName').focus();
991
+ });
992
+ })();
993
+
994
+ // ── 分享:有系统分享用系统分享;微信里给「点右上角」引导;其余复制链接
995
+ $('#shareBtn').addEventListener('click', async () => {
996
+ const data = { title: document.title, text: `${C.groom} & ${C.bride} 诚邀您见证我们的婚礼`, url: location.href };
997
+ if (isWeChat) { $('#guide').hidden = false; return; }
998
+ if (navigator.share) { try { await navigator.share(data); return; } catch (e) { if (e && e.name === 'AbortError') return; } }
999
+ toast(await copyText(location.href) ? '链接已复制,发给亲友即可' : '请复制浏览器地址栏的链接分享');
1000
+ });
1001
+ $('#guide').addEventListener('click', () => { $('#guide').hidden = true; });
1002
+
1003
+ // ── 音乐:给了 mp3 就放 mp3;没给就用 Web Audio 合成八音盒《卡农》——不依赖任何文件
1004
+ const btnEl = $('#music');
1005
+ const music = (function createMusic() {
1006
+ let playing = false;
1007
+ if (C.music && C.music.src) {
1008
+ const a = new Audio(C.music.src);
1009
+ a.loop = true; a.preload = 'auto';
1010
+ a.addEventListener('error', () => { btnEl.hidden = true; });
1011
+ document.addEventListener('WeixinJSBridgeReady', () => { if (playing) a.play().catch(() => {}); });
1012
+ return {
1013
+ start() { playing = true; return a.play().then(() => true, () => { playing = false; return false; }); },
1014
+ stop() { playing = false; a.pause(); },
1015
+ get on() { return playing; },
1016
+ };
1017
+ }
1018
+ let ac = null;
1019
+ let out = null;
1020
+ let timer = 0;
1021
+ let next = 0;
1022
+ let beat = 0;
1023
+ const SPB = 60 / 66; // 每拍秒数(66 BPM)
1024
+ const hz = (n) => 440 * Math.pow(2, (n - 69) / 12);
1025
+ // 帕赫贝尔《卡农》(公有领域)C 大调:每个和弦两拍,八个和弦一轮;两段旋律交替
1026
+ const CHORDS = [[60, 64, 67, 72], [55, 59, 62, 67], [57, 60, 64, 69], [52, 55, 59, 64], [53, 57, 60, 65], [48, 52, 55, 60], [53, 57, 60, 65], [55, 59, 62, 67]];
1027
+ const MELODY = [[76, 74, 72, 71, 69, 67, 69, 71], [72, 71, 69, 67, 65, 64, 65, 67]];
1028
+ const bell = (f, t, dur, vol) => {
1029
+ const g = ac.createGain();
1030
+ const o1 = ac.createOscillator();
1031
+ const o2 = ac.createOscillator();
1032
+ const g2 = ac.createGain();
1033
+ o1.frequency.value = f; o2.frequency.value = f * 2.01; g2.gain.value = 0.25; // 略失谐的二次泛音 = 八音盒的金属感
1034
+ o1.connect(g); o2.connect(g2); g2.connect(g); g.connect(out);
1035
+ g.gain.setValueAtTime(0.0001, t);
1036
+ g.gain.exponentialRampToValueAtTime(vol, t + 0.012);
1037
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
1038
+ o1.start(t); o2.start(t); o1.stop(t + dur + 0.05); o2.stop(t + dur + 0.05);
1039
+ };
1040
+ const tick = () => {
1041
+ while (next < ac.currentTime + 0.3) {
1042
+ const bar = Math.floor(beat / 2) % 8;
1043
+ const round = Math.floor(beat / 16) % 2;
1044
+ const half = beat % 2;
1045
+ const chord = CHORDS[bar];
1046
+ bell(hz(chord[(half * 2) % 4]), next, 1.5, 0.045);
1047
+ bell(hz(chord[(half * 2 + 1) % 4]), next + SPB / 2, 1.5, 0.04);
1048
+ if (!half) bell(hz(MELODY[round][bar]), next, 3.4, 0.1);
1049
+ next += SPB;
1050
+ beat++;
1051
+ }
1052
+ };
1053
+ return {
1054
+ start() {
1055
+ const AC = window.AudioContext || window.webkitAudioContext;
1056
+ if (!AC) return Promise.resolve(false);
1057
+ if (!ac) {
1058
+ ac = new AC();
1059
+ out = ac.createGain();
1060
+ out.gain.value = 0.9;
1061
+ const lp = ac.createBiquadFilter();
1062
+ lp.type = 'lowpass'; lp.frequency.value = 3400;
1063
+ out.connect(lp); lp.connect(ac.destination);
1064
+ next = ac.currentTime + 0.12;
1065
+ }
1066
+ playing = true;
1067
+ clearInterval(timer);
1068
+ timer = setInterval(tick, 80);
1069
+ tick();
1070
+ return ac.resume().then(() => true, () => false);
1071
+ },
1072
+ stop() { playing = false; clearInterval(timer); timer = 0; if (ac) ac.suspend().catch(() => {}); },
1073
+ get on() { return playing; },
1074
+ };
1075
+ })();
1076
+ const paintMusic = () => {
1077
+ btnEl.classList.toggle('is-playing', music.on);
1078
+ btnEl.setAttribute('aria-pressed', String(music.on));
1079
+ btnEl.setAttribute('aria-label', music.on ? '暂停背景音乐' : '播放背景音乐');
1080
+ };
1081
+ btnEl.addEventListener('click', () => { if (music.on) music.stop(); else music.start().then(paintMusic); paintMusic(); });
1082
+ let resumeOnShow = false;
1083
+ document.addEventListener('visibilitychange', () => { // 切到后台就停,回来再续:别在别人不看时吵
1084
+ if (document.hidden) { resumeOnShow = music.on; if (music.on) { music.stop(); paintMusic(); } }
1085
+ else if (resumeOnShow) { music.start().then(paintMusic); resumeOnShow = false; }
1086
+ });
1087
+
1088
+ // ── 花瓣:轻量画布,页面不可见/减弱动效时不跑
1089
+ const petals = (function createPetals() {
1090
+ const cv = $('#petals');
1091
+ const ctx = cv.getContext && cv.getContext('2d');
1092
+ if (!ctx || reduceMotion) return { start() {} };
1093
+ const rgb = getComputedStyle(document.documentElement).getPropertyValue('--petal').trim() || '196,72,64';
1094
+ let W0 = 0; let H0 = 0; let raf = 0; let last = 0; let list = [];
1095
+ const resize = () => {
1096
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
1097
+ W0 = window.innerWidth; H0 = window.innerHeight;
1098
+ cv.width = Math.round(W0 * dpr); cv.height = Math.round(H0 * dpr);
1099
+ cv.style.width = W0 + 'px'; cv.style.height = H0 + 'px';
1100
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1101
+ };
1102
+ const spawn = (anywhere) => ({
1103
+ x: Math.random() * W0, y: anywhere ? Math.random() * H0 : -20, r: 5 + Math.random() * 6,
1104
+ vy: 18 + Math.random() * 26, sway: 14 + Math.random() * 24, ph: Math.random() * 6.28,
1105
+ rot: Math.random() * 6.28, vr: (Math.random() - 0.5) * 1.6, a: 0.35 + Math.random() * 0.4,
1106
+ });
1107
+ const frame = (t) => {
1108
+ const dt = Math.min(0.05, (t - (last || t)) / 1000);
1109
+ last = t;
1110
+ ctx.clearRect(0, 0, W0, H0);
1111
+ for (const p of list) {
1112
+ p.y += p.vy * dt; p.ph += dt * 1.2; p.rot += p.vr * dt;
1113
+ const x = p.x + Math.sin(p.ph) * p.sway;
1114
+ if (p.y > H0 + 20) Object.assign(p, spawn(false));
1115
+ ctx.save(); ctx.translate(x, p.y); ctx.rotate(p.rot); ctx.scale(1, 0.55 + 0.45 * Math.abs(Math.cos(p.ph)));
1116
+ ctx.beginPath(); ctx.moveTo(0, -p.r);
1117
+ ctx.bezierCurveTo(p.r * 0.95, -p.r * 0.55, p.r * 0.75, p.r * 0.7, 0, p.r);
1118
+ ctx.bezierCurveTo(-p.r * 0.75, p.r * 0.7, -p.r * 0.95, -p.r * 0.55, 0, -p.r);
1119
+ ctx.fillStyle = `rgba(${rgb},${p.a})`; ctx.fill(); ctx.restore();
1120
+ }
1121
+ raf = requestAnimationFrame(frame);
1122
+ };
1123
+ return {
1124
+ start() {
1125
+ resize();
1126
+ list = Array.from({ length: W0 > 600 ? 22 : 14 }, () => spawn(true));
1127
+ window.addEventListener('resize', resize);
1128
+ document.addEventListener('visibilitychange', () => {
1129
+ cancelAnimationFrame(raf);
1130
+ if (!document.hidden) { last = 0; raf = requestAnimationFrame(frame); }
1131
+ });
1132
+ raf = requestAnimationFrame(frame);
1133
+ },
1134
+ };
1135
+ })();
1136
+
1137
+ // ── 入场动画:等封面打开后再开始观察,宾客才看得到首屏的入场
1138
+ function startReveal() {
1139
+ const els = $$('.reveal');
1140
+ if (reduceMotion || !('IntersectionObserver' in window)) { els.forEach((e) => e.classList.add('is-in')); return; }
1141
+ // threshold 取 0:比视口还高的元素永远到不了 0.5,用 0 + 底部内缩才不会「滚过去了还是看不见」
1142
+ const io = new IntersectionObserver((entries) => {
1143
+ entries.forEach((en) => { if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } });
1144
+ }, { threshold: 0, rootMargin: '0px 0px -8% 0px' });
1145
+ els.forEach((e) => io.observe(e));
1146
+ }
1147
+
1148
+ // ── 封面
1149
+ const gate = $('#gate');
1150
+ const app = $('#app');
1151
+ let opened = false;
1152
+ function afterOpen() {
1153
+ gate.hidden = true;
1154
+ document.body.classList.remove('is-locked');
1155
+ document.body.classList.add('is-open');
1156
+ app.removeAttribute('inert');
1157
+ btnEl.hidden = false;
1158
+ paintMusic();
1159
+ startReveal();
1160
+ petals.start();
1161
+ }
1162
+ function openGate(withMusic) {
1163
+ if (opened) return;
1164
+ opened = true;
1165
+ try { sessionStorage.setItem('invite-opened', '1'); } catch (e) { /* 隐私模式 */ }
1166
+ if (withMusic) music.start().then(paintMusic); // 必须在这次点击的同步调用栈里发起:它就是播放要的用户手势
1167
+ if (reduceMotion) { afterOpen(); return; }
1168
+ gate.classList.add('is-opening');
1169
+ setTimeout(() => gate.classList.add('is-gone'), 1250);
1170
+ setTimeout(afterOpen, 1650);
1171
+ }
1172
+ app.setAttribute('inert', '');
1173
+ $('#gateOpen').addEventListener('click', () => openGate(true));
1174
+ let skip = false;
1175
+ try { skip = sessionStorage.getItem('invite-opened') === '1'; } catch (e) { skip = false; }
1176
+ if (skip || (location.hash && location.hash.length > 1)) { opened = true; afterOpen(); } // 同一会话里再打开 / 从锚点链接进来:不重复拦封面
1177
+ })();
1178
+ </script>
1179
+ </body>
1180
+ </html>