opencode-skills-collection 4.1.1 → 4.1.3

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.

Potentially problematic release.


This version of opencode-skills-collection might be problematic. Click here for more details.

Files changed (274) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +133 -1
  2. package/bundled-skills/360-feedback-system/SKILL.md +481 -0
  3. package/bundled-skills/access-matrix/SKILL.md +338 -0
  4. package/bundled-skills/accounting-audit-system-builder/SKILL.md +302 -0
  5. package/bundled-skills/accounting-audit-system-builder/references/catalog.md +114 -0
  6. package/bundled-skills/accounting-software-selection/SKILL.md +427 -0
  7. package/bundled-skills/accounting-software-selection/references/build-artifacts.md +362 -0
  8. package/bundled-skills/accounting-software-selection/references/common-pitfalls.md +44 -0
  9. package/bundled-skills/accounting-software-selection/references/field-reference.md +93 -0
  10. package/bundled-skills/accounting-software-selection/references/select-options.md +202 -0
  11. package/bundled-skills/admin-access-register/SKILL.md +381 -0
  12. package/bundled-skills/advanced-analytics-dashboard/SKILL.md +318 -0
  13. package/bundled-skills/ai-image-generation-studio/SKILL.md +259 -0
  14. package/bundled-skills/ai-logo-maker/SKILL.md +259 -0
  15. package/bundled-skills/ai-multilingual-dubbing/SKILL.md +259 -0
  16. package/bundled-skills/ai-photo-restyler/SKILL.md +259 -0
  17. package/bundled-skills/ai-podcast-voiceover/SKILL.md +259 -0
  18. package/bundled-skills/alumni-re-hire-tracker/SKILL.md +314 -0
  19. package/bundled-skills/announcement-board/SKILL.md +336 -0
  20. package/bundled-skills/antigravity-maintainer-batch-release/SKILL.md +63 -4
  21. package/bundled-skills/asset-it-management/SKILL.md +407 -0
  22. package/bundled-skills/attendance/SKILL.md +331 -0
  23. package/bundled-skills/audit-log/SKILL.md +314 -0
  24. package/bundled-skills/audit-preparation/SKILL.md +416 -0
  25. package/bundled-skills/beatra/SKILL.md +260 -0
  26. package/bundled-skills/board-governance/SKILL.md +360 -0
  27. package/bundled-skills/brand-growth-system-builder/SKILL.md +360 -0
  28. package/bundled-skills/brand-kit-print-collateral/SKILL.md +498 -0
  29. package/bundled-skills/buddy-program-manager/SKILL.md +329 -0
  30. package/bundled-skills/budget-cash-flow/SKILL.md +352 -0
  31. package/bundled-skills/busabase/SKILL.md +118 -0
  32. package/bundled-skills/business-email-template/SKILL.md +480 -0
  33. package/bundled-skills/business-email-template/references/common-pitfalls.md +30 -0
  34. package/bundled-skills/business-website-setup/SKILL.md +489 -0
  35. package/bundled-skills/business-website-setup/references/common-pitfalls.md +32 -0
  36. package/bundled-skills/candidate-talent-pool/SKILL.md +359 -0
  37. package/bundled-skills/capacity-workload-planner/SKILL.md +354 -0
  38. package/bundled-skills/changelog-entry/SKILL.md +141 -0
  39. package/bundled-skills/clients-accounts/SKILL.md +398 -0
  40. package/bundled-skills/code-of-conduct/SKILL.md +488 -0
  41. package/bundled-skills/code-of-conduct/references/common-pitfalls.md +31 -0
  42. package/bundled-skills/company-email-accounts/SKILL.md +404 -0
  43. package/bundled-skills/competency-matrix/SKILL.md +335 -0
  44. package/bundled-skills/contract-document-renewal/SKILL.md +396 -0
  45. package/bundled-skills/course-upskilling-requests/SKILL.md +354 -0
  46. package/bundled-skills/credit-cycle-analysis/SKILL.md +303 -0
  47. package/bundled-skills/credit-cycle-analysis/references/best-practices.md +35 -0
  48. package/bundled-skills/credit-cycle-analysis/references/common-pitfalls.md +5 -0
  49. package/bundled-skills/credit-cycle-analysis/references/examples.md +34 -0
  50. package/bundled-skills/credit-cycle-analysis/references/field-reference.md +47 -0
  51. package/bundled-skills/credit-cycle-analysis/references/how-it-works.md +494 -0
  52. package/bundled-skills/credit-cycle-analysis/references/security-notes.md +16 -0
  53. package/bundled-skills/credit-cycle-analysis/references/select-options.md +32 -0
  54. package/bundled-skills/csv-manual-export/SKILL.md +499 -0
  55. package/bundled-skills/csv-manual-export/references/common-pitfalls.md +19 -0
  56. package/bundled-skills/culture-retention/SKILL.md +346 -0
  57. package/bundled-skills/data-export-engine/SKILL.md +308 -0
  58. package/bundled-skills/data-privacy-controls/SKILL.md +317 -0
  59. package/bundled-skills/day-book/SKILL.md +499 -0
  60. package/bundled-skills/day-book/references/best-practices.md +32 -0
  61. package/bundled-skills/day-book/references/common-pitfalls.md +58 -0
  62. package/bundled-skills/day-book/references/field-reference.md +45 -0
  63. package/bundled-skills/day-book/references/how-it-works.md +7 -0
  64. package/bundled-skills/day-book/references/related-skills.md +18 -0
  65. package/bundled-skills/day-book/references/relations.md +14 -0
  66. package/bundled-skills/day-book/references/reusable-prompt.md +50 -0
  67. package/bundled-skills/day-book/references/security-safety-notes.md +19 -0
  68. package/bundled-skills/day-book/references/select-options.md +38 -0
  69. package/bundled-skills/deep-research-framework/SKILL.md +81 -0
  70. package/bundled-skills/dei-dashboard/SKILL.md +307 -0
  71. package/bundled-skills/design-theme-guide/SKILL.md +472 -0
  72. package/bundled-skills/disciplinary-pip-tracker/SKILL.md +345 -0
  73. package/bundled-skills/document-management-system/SKILL.md +328 -0
  74. package/bundled-skills/ecommerce-listing-image-set/SKILL.md +259 -0
  75. package/bundled-skills/employee-suggestion-hub/SKILL.md +322 -0
  76. package/bundled-skills/esop-equity-tracker/SKILL.md +331 -0
  77. package/bundled-skills/events-activities/SKILL.md +355 -0
  78. package/bundled-skills/expense-accounting/SKILL.md +496 -0
  79. package/bundled-skills/expense-accounting/references/common-pitfalls.md +41 -0
  80. package/bundled-skills/expense-accounting/references/related-skills.md +11 -0
  81. package/bundled-skills/expense-accounting/references/reusable-prompt.md +36 -0
  82. package/bundled-skills/expense-management/SKILL.md +351 -0
  83. package/bundled-skills/five-axis-code-review/SKILL.md +78 -0
  84. package/bundled-skills/free-design-resources/SKILL.md +480 -0
  85. package/bundled-skills/free-design-resources/references/common-pitfalls.md +44 -0
  86. package/bundled-skills/free-design-resources/references/related-skills.md +24 -0
  87. package/bundled-skills/free-design-resources/references/reusable-prompt.md +12 -0
  88. package/bundled-skills/gamification-engine/SKILL.md +307 -0
  89. package/bundled-skills/gbp-local-seo-intent/SKILL.md +466 -0
  90. package/bundled-skills/gbp-local-seo-intent/references/best-practices.md +41 -0
  91. package/bundled-skills/gbp-local-seo-intent/references/common-pitfalls.md +40 -0
  92. package/bundled-skills/gbp-local-seo-intent/references/related-skills.md +17 -0
  93. package/bundled-skills/gbp-local-seo-intent/references/reusable-prompt.md +11 -0
  94. package/bundled-skills/gbp-local-seo-intent/references/security-safety-notes.md +27 -0
  95. package/bundled-skills/git-commit-message/SKILL.md +76 -0
  96. package/bundled-skills/health-wellness/SKILL.md +334 -0
  97. package/bundled-skills/idea-to-blueprint/LICENSE +21 -0
  98. package/bundled-skills/idea-to-blueprint/SKILL.md +260 -0
  99. package/bundled-skills/idea-to-blueprint/assets/AGENTS.md.template +38 -0
  100. package/bundled-skills/idea-to-blueprint/assets/CLAUDE.md.template +38 -0
  101. package/bundled-skills/idea-to-blueprint/assets/DECISIONS.md.template +14 -0
  102. package/bundled-skills/idea-to-blueprint/assets/PROGRESS.md.template +47 -0
  103. package/bundled-skills/idea-to-blueprint/assets/session-prompt.md +23 -0
  104. package/bundled-skills/idea-to-blueprint/references/architecture-decisions.md +202 -0
  105. package/bundled-skills/idea-to-blueprint/references/blueprint-template.md +259 -0
  106. package/bundled-skills/idea-to-blueprint/references/code-quality-and-review.md +216 -0
  107. package/bundled-skills/idea-to-blueprint/references/design-minimal.md +96 -0
  108. package/bundled-skills/idea-to-blueprint/references/edge-case-catalog.md +239 -0
  109. package/bundled-skills/idea-to-blueprint/references/intake-questions.md +105 -0
  110. package/bundled-skills/idea-to-blueprint/references/research-protocol.md +196 -0
  111. package/bundled-skills/idea-to-blueprint/references/session-protocol.md +133 -0
  112. package/bundled-skills/idea-to-blueprint/references/stories-ac-tests.md +194 -0
  113. package/bundled-skills/idea-to-blueprint/references/ux-writing-guide.md +128 -0
  114. package/bundled-skills/idea-to-blueprint/scripts/lint_blueprint.py +460 -0
  115. package/bundled-skills/intern-program/SKILL.md +354 -0
  116. package/bundled-skills/internal-communication/SKILL.md +318 -0
  117. package/bundled-skills/inventory-stock-reconciliation/SKILL.md +470 -0
  118. package/bundled-skills/inventory-stock-reconciliation/references/best-practices.md +18 -0
  119. package/bundled-skills/inventory-stock-reconciliation/references/common-pitfalls.md +42 -0
  120. package/bundled-skills/inventory-stock-reconciliation/references/related-skills.md +13 -0
  121. package/bundled-skills/inventory-stock-reconciliation/references/relations.md +8 -0
  122. package/bundled-skills/inventory-stock-reconciliation/references/reusable-prompt.md +31 -0
  123. package/bundled-skills/inventory-stock-reconciliation/references/security-safety-notes.md +17 -0
  124. package/bundled-skills/inventory-stock-reconciliation/references/select-options.md +45 -0
  125. package/bundled-skills/invoices-billing/SKILL.md +385 -0
  126. package/bundled-skills/issue-grievance-tracker/SKILL.md +400 -0
  127. package/bundled-skills/json-schema-manual/SKILL.md +309 -0
  128. package/bundled-skills/json-schema-manual/references/best-practices.md +9 -0
  129. package/bundled-skills/json-schema-manual/references/common-pitfalls.md +25 -0
  130. package/bundled-skills/json-schema-manual/references/input-modules.md +203 -0
  131. package/bundled-skills/json-schema-manual/references/related-skills.md +8 -0
  132. package/bundled-skills/json-schema-manual/references/reusable-prompt.md +10 -0
  133. package/bundled-skills/json-schema-manual/references/security-safety-notes.md +13 -0
  134. package/bundled-skills/knowledge-base/SKILL.md +318 -0
  135. package/bundled-skills/kpi-tracker/SKILL.md +345 -0
  136. package/bundled-skills/learning-career-development/SKILL.md +349 -0
  137. package/bundled-skills/leave-management/SKILL.md +341 -0
  138. package/bundled-skills/legal-compliance-vault/SKILL.md +324 -0
  139. package/bundled-skills/linktree-link-hub/SKILL.md +453 -0
  140. package/bundled-skills/liuguang-banlan-ui/SKILL.md +22 -18
  141. package/bundled-skills/liuguang-banlan-ui/assets/starter/launcher.css +2 -2
  142. package/bundled-skills/liuguang-banlan-ui/assets/starter/obsidian/index.html +5 -4
  143. package/bundled-skills/liuguang-banlan-ui/assets/starter/obsidian/theme-config.js +3 -10
  144. package/bundled-skills/liuguang-banlan-ui/assets/starter/obsidian/theme.css +48 -62
  145. package/bundled-skills/liuguang-banlan-ui/assets/starter/opal/index.html +5 -4
  146. package/bundled-skills/liuguang-banlan-ui/assets/starter/opal/theme-config.js +27 -35
  147. package/bundled-skills/liuguang-banlan-ui/assets/starter/opal/theme.css +48 -62
  148. package/bundled-skills/liuguang-banlan-ui/assets/starter/shared/spectral-field.js +135 -148
  149. package/bundled-skills/liuguang-banlan-ui/references/style-contract.md +26 -7
  150. package/bundled-skills/liuguang-banlan-ui/references/verification.md +19 -7
  151. package/bundled-skills/liuguang-banlan-ui/scripts/measure_preview.py +78 -47
  152. package/bundled-skills/liuguang-banlan-ui/scripts/serve_preview.py +35 -0
  153. package/bundled-skills/liuguang-banlan-ui/scripts/validate_manifest.py +34 -3
  154. package/bundled-skills/liuguang-banlan-ui/tests/test_measure_preview.py +119 -0
  155. package/bundled-skills/liuguang-banlan-ui/tests/test_serve_preview.py +38 -0
  156. package/bundled-skills/liuguang-banlan-ui/tests/test_validate_manifest.py +73 -0
  157. package/bundled-skills/logo-image-design/SKILL.md +459 -0
  158. package/bundled-skills/loki-mode/examples/todo-app-generated/frontend/package-lock.json +12 -12
  159. package/bundled-skills/loki-mode/examples/todo-app-generated/frontend/package.json +1 -1
  160. package/bundled-skills/meeting-notes/SKILL.md +80 -0
  161. package/bundled-skills/mentorship-program/SKILL.md +332 -0
  162. package/bundled-skills/mirrord/SKILL.md +90 -0
  163. package/bundled-skills/monthly-closing-statements/SKILL.md +488 -0
  164. package/bundled-skills/monthly-closing-statements/references/related-skills.md +17 -0
  165. package/bundled-skills/monthly-closing-statements/references/reusable-prompt.md +8 -0
  166. package/bundled-skills/music-generation-studio/SKILL.md +259 -0
  167. package/bundled-skills/notification-reminder-hub/SKILL.md +324 -0
  168. package/bundled-skills/notion-manual-import/SKILL.md +376 -0
  169. package/bundled-skills/notion-manual-import/references/best-practices.md +10 -0
  170. package/bundled-skills/notion-manual-import/references/common-pitfalls.md +26 -0
  171. package/bundled-skills/notion-manual-import/references/input-modules.md +203 -0
  172. package/bundled-skills/notion-manual-import/references/related-skills.md +7 -0
  173. package/bundled-skills/notion-manual-import/references/reusable-prompt.md +8 -0
  174. package/bundled-skills/notion-manual-import/references/security-safety-notes.md +15 -0
  175. package/bundled-skills/observability-cloud-planning/SKILL.md +487 -0
  176. package/bundled-skills/observability-cloud-planning/references/common-pitfalls.md +39 -0
  177. package/bundled-skills/observability-cloud-planning/references/related-skills.md +20 -0
  178. package/bundled-skills/observability-cloud-planning/references/reusable-prompt.md +12 -0
  179. package/bundled-skills/observability-cloud-planning/references/security-safety-notes.md +30 -0
  180. package/bundled-skills/offboarding-exit/SKILL.md +343 -0
  181. package/bundled-skills/offer-appointment/SKILL.md +351 -0
  182. package/bundled-skills/okr-system/SKILL.md +355 -0
  183. package/bundled-skills/onboarding-playbook/SKILL.md +292 -0
  184. package/bundled-skills/organization-design/SKILL.md +324 -0
  185. package/bundled-skills/party-ledger-reconciliation/SKILL.md +411 -0
  186. package/bundled-skills/payment-accounting/SKILL.md +421 -0
  187. package/bundled-skills/payments-received/SKILL.md +315 -0
  188. package/bundled-skills/payroll-finance/SKILL.md +351 -0
  189. package/bundled-skills/people-directory/SKILL.md +459 -0
  190. package/bundled-skills/performance-management/SKILL.md +367 -0
  191. package/bundled-skills/petty-cash-management/SKILL.md +448 -0
  192. package/bundled-skills/policy-acknowledgement/SKILL.md +314 -0
  193. package/bundled-skills/policy-library/SKILL.md +327 -0
  194. package/bundled-skills/poster-design-studio/SKILL.md +259 -0
  195. package/bundled-skills/pre-boarding/SKILL.md +326 -0
  196. package/bundled-skills/presentation-deck/SKILL.md +491 -0
  197. package/bundled-skills/presentation-deck/references/related-skills.md +19 -0
  198. package/bundled-skills/presentation-deck/references/reusable-prompt.md +11 -0
  199. package/bundled-skills/probation-tracker/SKILL.md +347 -0
  200. package/bundled-skills/product-photo-studio/SKILL.md +259 -0
  201. package/bundled-skills/project-based-performance/SKILL.md +323 -0
  202. package/bundled-skills/projects-work-management/SKILL.md +368 -0
  203. package/bundled-skills/promotion-upgrade-requests/SKILL.md +377 -0
  204. package/bundled-skills/purchase-accounting/SKILL.md +414 -0
  205. package/bundled-skills/receipt-accounting/SKILL.md +424 -0
  206. package/bundled-skills/recognition-rewards/SKILL.md +324 -0
  207. package/bundled-skills/recruitment-pipeline/SKILL.md +399 -0
  208. package/bundled-skills/remote-work-tracker/SKILL.md +306 -0
  209. package/bundled-skills/repo-foundation/SKILL.md +166 -0
  210. package/bundled-skills/repo-foundation/references/LICENSE.md +21 -0
  211. package/bundled-skills/repo-foundation/references/bootstrap.md +63 -0
  212. package/bundled-skills/repo-foundation/references/companion.md +9 -0
  213. package/bundled-skills/repo-foundation/references/continuity.md +50 -0
  214. package/bundled-skills/repo-foundation/references/evolution.md +60 -0
  215. package/bundled-skills/repo-foundation/references/migration-examples.md +53 -0
  216. package/bundled-skills/repo-foundation/references/shared-contracts.md +48 -0
  217. package/bundled-skills/repo-foundation/references/verification.md +50 -0
  218. package/bundled-skills/repo-native-refactor/SKILL.md +225 -0
  219. package/bundled-skills/repo-native-refactor/references/LICENSE.md +21 -0
  220. package/bundled-skills/repo-native-refactor/references/deterministic-tooling.md +101 -0
  221. package/bundled-skills/repo-native-refactor/references/error-reliability.md +117 -0
  222. package/bundled-skills/repo-native-refactor/references/finding-taxonomy.md +181 -0
  223. package/bundled-skills/repo-native-refactor/references/refactor-examples.md +77 -0
  224. package/bundled-skills/repo-native-refactor/references/repository-prose.md +157 -0
  225. package/bundled-skills/repo-native-refactor/references/repository-rehabilitation.md +94 -0
  226. package/bundled-skills/repo-native-refactor/references/semantic-risk.md +187 -0
  227. package/bundled-skills/repo-native-refactor/references/shared-contracts.md +48 -0
  228. package/bundled-skills/repo-native-refactor/references/testing-integrity.md +103 -0
  229. package/bundled-skills/reports-analytics/SKILL.md +320 -0
  230. package/bundled-skills/salary-benchmarking/SKILL.md +318 -0
  231. package/bundled-skills/salary-wage-accounting/SKILL.md +412 -0
  232. package/bundled-skills/sales-accounting/SKILL.md +494 -0
  233. package/bundled-skills/sales-accounting/references/common-pitfalls.md +28 -0
  234. package/bundled-skills/sales-accounting/references/related-skills.md +13 -0
  235. package/bundled-skills/sales-accounting/references/reusable-prompt.md +8 -0
  236. package/bundled-skills/seo-directory-backlinks/SKILL.md +484 -0
  237. package/bundled-skills/seo-directory-backlinks/references/common-pitfalls.md +36 -0
  238. package/bundled-skills/seo-directory-backlinks/references/related-skills.md +15 -0
  239. package/bundled-skills/seo-directory-backlinks/references/reusable-prompt.md +11 -0
  240. package/bundled-skills/skill-gap-analysis/SKILL.md +338 -0
  241. package/bundled-skills/social-media-setup/SKILL.md +477 -0
  242. package/bundled-skills/social-media-setup/references/common-pitfalls.md +34 -0
  243. package/bundled-skills/social-media-setup/references/related-skills.md +19 -0
  244. package/bundled-skills/social-media-setup/references/reusable-prompt.md +11 -0
  245. package/bundled-skills/social-media-setup/references/security-safety-notes.md +27 -0
  246. package/bundled-skills/sop-company-wiki/SKILL.md +324 -0
  247. package/bundled-skills/source-document-filing/SKILL.md +368 -0
  248. package/bundled-skills/spreadsheet-manual-build/SKILL.md +493 -0
  249. package/bundled-skills/spreadsheet-manual-build/references/best-practices.md +9 -0
  250. package/bundled-skills/spreadsheet-manual-build/references/common-pitfalls.md +22 -0
  251. package/bundled-skills/spreadsheet-manual-build/references/related-skills.md +8 -0
  252. package/bundled-skills/spreadsheet-manual-build/references/reusable-prompt.md +9 -0
  253. package/bundled-skills/spreadsheet-manual-build/references/security-safety-notes.md +16 -0
  254. package/bundled-skills/stakeholder-investor-reports/SKILL.md +303 -0
  255. package/bundled-skills/suno-lyrics-to-song/SKILL.md +259 -0
  256. package/bundled-skills/talking-avatar-video/SKILL.md +260 -0
  257. package/bundled-skills/tax-register/SKILL.md +363 -0
  258. package/bundled-skills/tds-booking-payment/SKILL.md +405 -0
  259. package/bundled-skills/team-calendar/SKILL.md +312 -0
  260. package/bundled-skills/tech-writing-proofread/SKILL.md +78 -0
  261. package/bundled-skills/telegram/assets/boilerplate/nodejs/package-lock.json +3 -3
  262. package/bundled-skills/telegram/assets/boilerplate/nodejs/package.json +1 -0
  263. package/bundled-skills/template-library/SKILL.md +304 -0
  264. package/bundled-skills/time-tracking/SKILL.md +353 -0
  265. package/bundled-skills/typescript-expert/scripts/ts_diagnostic.py +40 -19
  266. package/bundled-skills/vendor-contractor-management/SKILL.md +327 -0
  267. package/bundled-skills/viral-video-teardown-remake/SKILL.md +260 -0
  268. package/bundled-skills/voice-cloning-studio/SKILL.md +260 -0
  269. package/bundled-skills/voiceover-narration-studio/SKILL.md +259 -0
  270. package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/nodejs/package-lock.json +3 -3
  271. package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/nodejs/package.json +1 -0
  272. package/bundled-skills/youtube-summarizer/SKILL.md +3 -3
  273. package/package.json +1 -1
  274. package/skills_index.json +254 -0
@@ -30,17 +30,26 @@ except ModuleNotFoundError as exc:
30
30
  raise SystemExit(2)
31
31
 
32
32
 
33
- def srgb_to_oklab(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
34
- linear = np.where(
35
- rgb <= 0.04045,
36
- rgb / 12.92,
37
- ((rgb + 0.055) / 1.055) ** 2.4,
38
- )
33
+ # A color counts as visible when it deviates this far from the base (OKLab).
34
+ TINT_THRESHOLD = {"opal": 0.003, "obsidian": 0.004}
35
+ # A block is gray when its lightness moves this far with little hue change.
36
+ GRAY_SHIFT = 0.003
37
+
38
+
39
+ def load_config(path: pathlib.Path) -> dict:
40
+ return load_manifest(path)
41
+
42
+
43
+ def srgb_to_linear(rgb: np.ndarray) -> np.ndarray:
44
+ return np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
45
+
46
+
47
+ def linear_to_oklab(linear: np.ndarray) -> np.ndarray:
39
48
  r, g, b = np.moveaxis(linear, -1, 0)
40
49
  l = np.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
41
50
  m = np.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
42
51
  s = np.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
43
- lab = np.stack(
52
+ return np.stack(
44
53
  (
45
54
  0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
46
55
  1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
@@ -48,38 +57,50 @@ def srgb_to_oklab(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
48
57
  ),
49
58
  axis=-1,
50
59
  )
51
- luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
52
- return lab, luminance
53
60
 
54
61
 
55
- def circular_distance(a: np.ndarray, b: float) -> np.ndarray:
56
- delta = np.abs(a - b)
57
- return np.minimum(delta, 360.0 - delta)
62
+ def oklch_to_ab(color: dict) -> np.ndarray:
63
+ hue = np.radians(float(color["h"]))
64
+ return float(color["c"]) * np.array([np.cos(hue), np.sin(hue)])
58
65
 
59
66
 
60
- def measure(image_path: pathlib.Path, config: dict) -> dict:
61
- image = Image.open(image_path).convert("RGB")
62
- pixels = np.asarray(image, dtype=np.float32) / 255.0
63
- lab, luminance = srgb_to_oklab(pixels)
64
- chroma = np.hypot(lab[..., 1], lab[..., 2])
65
- hue = (np.degrees(np.arctan2(lab[..., 2], lab[..., 1])) + 360.0) % 360.0
67
+ def block_average(linear: np.ndarray, block: int) -> np.ndarray:
68
+ block = max(1, min(block, linear.shape[0], linear.shape[1]))
69
+ h = linear.shape[0] // block * block
70
+ w = linear.shape[1] // block * block
71
+ trimmed = linear[:h, :w].reshape(h // block, block, w // block, block, 3)
72
+ return trimmed.mean(axis=(1, 3))
66
73
 
67
- base_chroma = float(config["base"]["oklch"]["c"])
68
- threshold = base_chroma + (0.003 if config["mode"] == "opal" else 0.004)
69
- chroma_energy = np.maximum(chroma - threshold, 0.0)
70
- visible = chroma_energy > 0
71
74
 
72
- distances = np.stack(
73
- [circular_distance(hue, float(color["oklch"]["h"])) for color in config["colors"]],
74
- axis=-1,
75
- )
76
- assignments = np.argmin(distances, axis=-1)
77
- total_pixels = pixels.shape[0] * pixels.shape[1]
78
- total_energy = float(chroma_energy.sum())
75
+ def measure(image_path: pathlib.Path, config: dict, block: int = 8) -> dict:
76
+ with Image.open(image_path) as source:
77
+ image = source.convert("RGB")
78
+ linear = srgb_to_linear(np.asarray(image, dtype=np.float64) / 255.0)
79
+ luminance = 0.2126 * linear[..., 0] + 0.7152 * linear[..., 1] + 0.0722 * linear[..., 2]
80
+ lab = linear_to_oklab(block_average(linear, block))
81
+
82
+ base = config["base"]["oklch"]
83
+ base_ab = oklch_to_ab(base)
84
+ shift = lab[..., 0] - float(base["l"])
85
+ delta = lab[..., 1:] - base_ab
86
+ tint = np.hypot(delta[..., 0], delta[..., 1])
87
+
88
+ threshold = TINT_THRESHOLD.get(config["mode"], 0.003)
89
+ energy = np.maximum(tint - threshold, 0.0)
90
+ visible = energy > 0
91
+ gray = (np.abs(shift) > GRAY_SHIFT) & (tint < np.abs(shift) / 3)
92
+
93
+ directions = np.stack([oklch_to_ab(color["oklch"]) - base_ab for color in config["colors"]])
94
+ angles = np.arctan2(directions[:, 1], directions[:, 0])
95
+ deviation_angle = np.arctan2(delta[..., 1], delta[..., 0])
96
+ distance = np.abs((deviation_angle[..., None] - angles + np.pi) % (2 * np.pi) - np.pi)
97
+ assignments = np.argmin(distance, axis=-1)
98
+
99
+ total_blocks = tint.size
100
+ total_energy = float(energy.sum())
79
101
  colors = []
80
102
  for index, color in enumerate(config["colors"]):
81
103
  assigned = visible & (assignments == index)
82
- energy = float(chroma_energy[assigned].sum())
83
104
  colors.append(
84
105
  {
85
106
  "id": color["id"],
@@ -87,24 +108,40 @@ def measure(image_path: pathlib.Path, config: dict) -> dict:
87
108
  "configuredIntensity": color["intensity"],
88
109
  "oklch": color["oklch"],
89
110
  "peakOpacity": color["peakOpacity"],
90
- "measuredCoverage": round(float(assigned.sum()) / total_pixels, 4),
91
- "effectiveShare": round(energy / total_energy, 4) if total_energy else 0.0,
111
+ "measuredCoverage": round(float(assigned.sum()) / total_blocks, 4),
112
+ "effectiveShare": round(float(energy[assigned].sum()) / total_energy, 4) if total_energy else 0.0,
92
113
  }
93
114
  )
94
115
 
116
+ mean_tint = float(tint.mean())
95
117
  return {
96
118
  "image": image_path.name,
97
119
  "dimensions": {"width": image.width, "height": image.height},
98
- "method": "nearest configured OKLCH hue over pixels above the base-chroma visibility threshold",
99
- "visibilityThresholdChroma": round(threshold, 4),
120
+ "method": (
121
+ f"OKLab deviation from the configured base over {block}x{block} pixel blocks; "
122
+ "hue is attributed by the direction of the deviation"
123
+ ),
124
+ "blockSize": block,
125
+ "visibilityThresholdTint": threshold,
100
126
  "chromaticPixelRatio": round(float(visible.mean()), 4),
101
- "meanOklabChroma": round(float(chroma.mean()), 5),
127
+ "tint": {
128
+ "mean": round(mean_tint, 5),
129
+ "p50": round(float(np.percentile(tint, 50)), 5),
130
+ "p95": round(float(np.percentile(tint, 95)), 5),
131
+ },
132
+ "lightnessShift": {
133
+ "mean": round(float(shift.mean()), 5),
134
+ "p05": round(float(np.percentile(shift, 5)), 5),
135
+ "p95": round(float(np.percentile(shift, 95)), 5),
136
+ },
137
+ "shiftToTint": round(abs(float(shift.mean())) / mean_tint, 3) if mean_tint else None,
138
+ "grayRatio": round(float(gray.mean()), 4) if config["mode"] == "opal" else None,
102
139
  "luminance": {
103
140
  "mean": round(float(luminance.mean()), 5),
104
141
  "p05": round(float(np.percentile(luminance, 5)), 5),
105
142
  "p95": round(float(np.percentile(luminance, 95)), 5),
106
143
  "max": round(float(luminance.max()), 5),
107
- "configuredCap": config["field"]["luminanceCap"],
144
+ "configuredCap": config["field"].get("luminanceCap"),
108
145
  },
109
146
  "colors": colors,
110
147
  }
@@ -114,25 +151,19 @@ def main() -> None:
114
151
  parser = argparse.ArgumentParser()
115
152
  parser.add_argument("--image", required=True, type=pathlib.Path)
116
153
  parser.add_argument("--config", required=True, type=pathlib.Path)
154
+ parser.add_argument("--block", type=int, default=8, help="pixel block size used to average out dither")
117
155
  args = parser.parse_args()
118
156
  try:
119
- config = load_manifest(args.config.resolve())
157
+ config = load_config(args.config.resolve())
120
158
  except (ManifestSyntaxError, OSError) as error:
121
- print(
122
- json.dumps(
123
- {"status": "invalid", "errors": [str(error)]},
124
- ensure_ascii=False,
125
- indent=2,
126
- ),
127
- file=sys.stderr,
128
- )
159
+ print(json.dumps({"status": "invalid", "errors": [str(error)]}, ensure_ascii=False, indent=2), file=sys.stderr)
129
160
  raise SystemExit(1) from error
130
161
  payload = {
131
- "schemaVersion": "1.0",
162
+ "schemaVersion": "1.1",
132
163
  "theme": config["label"],
133
164
  "preset": config["preset"],
134
165
  "overallColorIntensity": config["overallColorIntensity"],
135
- "measurement": measure(args.image.resolve(), config),
166
+ "measurement": measure(args.image.resolve(), config, args.block),
136
167
  }
137
168
  print(json.dumps(payload, ensure_ascii=False, indent=2))
138
169
 
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env python3
2
+ """Serve a directory over local HTTP with caching disabled, for previews and screenshots."""
3
+
4
+ import argparse
5
+ import functools
6
+ import http.server
7
+ import pathlib
8
+
9
+
10
+ # python -m http.server sends Last-Modified without Cache-Control, so browsers keep
11
+ # serving a stale theme-config.js after an edit and screenshots measure the old field.
12
+ class NoStoreHandler(http.server.SimpleHTTPRequestHandler):
13
+ def end_headers(self):
14
+ self.send_header("Cache-Control", "no-store")
15
+ super().end_headers()
16
+
17
+
18
+ def main() -> None:
19
+ parser = argparse.ArgumentParser(description=__doc__)
20
+ parser.add_argument("directory", nargs="?", type=pathlib.Path, default=pathlib.Path("."))
21
+ parser.add_argument("--port", type=int, default=8000)
22
+ parser.add_argument("--bind", default="127.0.0.1")
23
+ args = parser.parse_args()
24
+ root = args.directory.resolve()
25
+ handler = functools.partial(NoStoreHandler, directory=str(root))
26
+ with http.server.ThreadingHTTPServer((args.bind, args.port), handler) as server:
27
+ print(f"serving {root} at http://{args.bind}:{args.port}/ with Cache-Control: no-store")
28
+ try:
29
+ server.serve_forever()
30
+ except KeyboardInterrupt:
31
+ pass
32
+
33
+
34
+ if __name__ == "__main__":
35
+ main()
@@ -126,7 +126,7 @@ def validate(config: dict) -> list[str]:
126
126
  if not isinstance(field, dict):
127
127
  errors.append("field must be an object")
128
128
  field = {}
129
- for key in ("scale", "octaves", "warpStrength", "motionSpeed", "staticTime", "ditherStrength", "luminanceCap"):
129
+ for key in ("scale", "octaves", "warpStrength", "motionSpeed", "staticTime", "ditherStrength"):
130
130
  require(key in field, f"field missing {key}", errors)
131
131
  check_range(field.get("scale"), 0, float("inf"), "field.scale must be positive", errors, lower_inclusive=False)
132
132
  octaves = field.get("octaves")
@@ -139,10 +139,37 @@ def validate(config: dict) -> list[str]:
139
139
  check_range(field.get("motionSpeed"), 0, float("inf"), "field.motionSpeed must be finite and nonnegative", errors)
140
140
  check_finite(field.get("staticTime"), "field.staticTime must be a finite number", errors)
141
141
  check_range(field.get("ditherStrength"), 0, 1, "field.ditherStrength must be in [0, 1]", errors)
142
- check_range(field.get("luminanceCap"), 0, 1, "field.luminanceCap must be in (0, 1]", errors, lower_inclusive=False)
142
+ if config["mode"] == "obsidian" or "luminanceCap" in field:
143
+ check_range(field.get("luminanceCap"), 0, 1, "field.luminanceCap must be in (0, 1]", errors, lower_inclusive=False)
143
144
  return errors
144
145
 
145
146
 
147
+ def hue_travel(hues: list[float]) -> float:
148
+ steps = (abs(a - b) % 360 for a, b in zip(hues, hues[1:] + hues[:1]))
149
+ return sum(min(step, 360 - step) for step in steps)
150
+
151
+
152
+ def best_hue_travel(hues: list[float]) -> float:
153
+ ordered = sorted(h % 360 for h in hues)
154
+ widest = max([b - a for a, b in zip(ordered, ordered[1:])] + [ordered[0] + 360 - ordered[-1]])
155
+ return 360.0 if widest <= 180 else 2 * (360 - widest)
156
+
157
+
158
+ def collect_warnings(config: dict) -> list[str]:
159
+ notes: list[str] = []
160
+ colors = config.get("colors", [])
161
+ hues = [float(color.get("oklch", {}).get("h", 0)) for color in colors]
162
+ if len(hues) >= 3:
163
+ travel, best = hue_travel(hues), best_hue_travel(hues)
164
+ if travel > best * 1.1:
165
+ notes.append(
166
+ f"colors are not in hue order: neighbor hue steps add up to {travel:.0f} degrees, "
167
+ f"the best order gives {best:.0f}. The renderer mixes each color with its array "
168
+ "neighbors, and near-complementary neighbors mix toward gray."
169
+ )
170
+ return notes
171
+
172
+
146
173
  def main() -> None:
147
174
  parser = argparse.ArgumentParser()
148
175
  parser.add_argument("config", type=pathlib.Path)
@@ -162,7 +189,11 @@ def main() -> None:
162
189
  if errors:
163
190
  print(json.dumps({"status": "invalid", "errors": errors}, ensure_ascii=False, indent=2))
164
191
  sys.exit(1)
165
- print(json.dumps({"status": "valid", "mode": config["mode"], "preset": config["preset"], "colors": len(config["colors"])}, ensure_ascii=False, indent=2))
192
+ report = {"status": "valid", "mode": config["mode"], "preset": config["preset"], "colors": len(config["colors"])}
193
+ notes = collect_warnings(config)
194
+ if notes:
195
+ report["warnings"] = notes
196
+ print(json.dumps(report, ensure_ascii=False, indent=2))
166
197
 
167
198
 
168
199
  if __name__ == "__main__":
@@ -0,0 +1,119 @@
1
+ import pathlib
2
+ import sys
3
+ import tempfile
4
+ import unittest
5
+
6
+ import numpy as np
7
+ from PIL import Image
8
+
9
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "scripts"))
10
+ import measure_preview as mp # noqa: E402
11
+
12
+ LAB_TO_LMS = np.array([
13
+ [1.0, 0.3963377774, 0.2158037573],
14
+ [1.0, -0.1055613458, -0.0638541728],
15
+ [1.0, -0.0894841775, -1.2914855480],
16
+ ])
17
+ LMS_TO_RGB = np.array([
18
+ [4.0767416621, -3.3077115913, 0.2309699292],
19
+ [-1.2684380046, 2.6097574011, -0.3413193965],
20
+ [-0.0041960863, -0.7034186147, 1.7076147010],
21
+ ])
22
+ HUES = (("rose", 18), ("peach", 62), ("mint", 159), ("cyan", 201), ("blue", 248), ("lilac", 304))
23
+
24
+
25
+ def lab_to_srgb(lab):
26
+ linear = np.clip(LMS_TO_RGB @ ((LAB_TO_LMS @ np.asarray(lab, dtype=np.float64)) ** 3), 0.0, 1.0)
27
+ return np.where(linear <= 0.0031308, linear * 12.92, 1.055 * linear ** (1 / 2.4) - 0.055)
28
+
29
+
30
+ def config(base=(0.982, 0.004, 94)):
31
+ return {
32
+ "mode": "opal",
33
+ "base": {"oklch": {"l": base[0], "c": base[1], "h": base[2]}},
34
+ "colors": [
35
+ {"id": cid, "label": cid, "oklch": {"l": 0.93, "c": 0.045, "h": h}, "intensity": 0.5, "peakOpacity": 0.08}
36
+ for cid, h in HUES
37
+ ],
38
+ "field": {},
39
+ }
40
+
41
+
42
+ def base_lab(cfg):
43
+ o = cfg["base"]["oklch"]
44
+ h = np.radians(o["h"])
45
+ return np.array([o["l"], o["c"] * np.cos(h), o["c"] * np.sin(h)])
46
+
47
+
48
+ def flat(lab, size=(64, 64)):
49
+ return np.broadcast_to(lab_to_srgb(lab), (*size, 3)).copy()
50
+
51
+
52
+ class MeasurePreviewTest(unittest.TestCase):
53
+ def setUp(self):
54
+ self.tmp = tempfile.TemporaryDirectory()
55
+ self.addCleanup(self.tmp.cleanup)
56
+
57
+ def measure_codes(self, cfg, codes):
58
+ path = pathlib.Path(self.tmp.name) / "field.png"
59
+ Image.fromarray(np.clip(codes, 0, 255).astype(np.uint8)).save(path)
60
+ return mp.measure(path, cfg)
61
+
62
+ def measure(self, cfg, pixels):
63
+ return self.measure_codes(cfg, np.round(pixels * 255))
64
+
65
+ def test_flat_base_reads_as_base(self):
66
+ for chroma in (0.0, 0.004):
67
+ with self.subTest(chroma=chroma):
68
+ cfg = config(base=(0.982, chroma, 94))
69
+ m = self.measure(cfg, flat(base_lab(cfg)))
70
+ self.assertEqual(m["chromaticPixelRatio"], 0)
71
+ self.assertEqual(m["grayRatio"], 0)
72
+ self.assertLess(abs(m["lightnessShift"]["mean"]), 0.0025)
73
+ self.assertIsNone(m["luminance"]["configuredCap"])
74
+
75
+ def test_visibility_does_not_depend_on_base_chroma(self):
76
+ tint = np.array([0.0, -0.0045, -0.0025])
77
+ ratios = []
78
+ for chroma in (0.0, 0.004, 0.008):
79
+ cfg = config(base=(0.982, chroma, 94))
80
+ ratios.append(self.measure(cfg, flat(base_lab(cfg) + tint))["chromaticPixelRatio"])
81
+ self.assertEqual(ratios, [1.0, 1.0, 1.0])
82
+
83
+ def test_hue_follows_deviation_direction(self):
84
+ cfg = config()
85
+ blue = np.radians(248)
86
+ m = self.measure(cfg, flat(base_lab(cfg) + 0.006 * np.array([0.0, np.cos(blue), np.sin(blue)])))
87
+ coverage = {c["id"]: c["measuredCoverage"] for c in m["colors"]}
88
+ self.assertEqual(max(coverage, key=coverage.get), "blue")
89
+
90
+ def test_lightness_only_shift_is_gray(self):
91
+ cfg = config(base=(0.982, 0.0, 94))
92
+ m = self.measure(cfg, flat(base_lab(cfg) + np.array([-0.01, 0.0, 0.0])))
93
+ self.assertEqual(m["grayRatio"], 1.0)
94
+ self.assertAlmostEqual(m["lightnessShift"]["mean"], -0.01, delta=0.002)
95
+
96
+ def test_tinted_shift_is_not_gray(self):
97
+ cfg = config()
98
+ rose = np.radians(18)
99
+ m = self.measure(cfg, flat(base_lab(cfg) + np.array([-0.006, 0.008 * np.cos(rose), 0.008 * np.sin(rose)])))
100
+ self.assertEqual(m["grayRatio"], 0)
101
+ self.assertEqual(m["chromaticPixelRatio"], 1.0)
102
+
103
+ def test_gray_ratio_is_only_defined_for_opal(self):
104
+ cfg = config(base=(0.098, 0.012, 236))
105
+ cfg["mode"] = "obsidian"
106
+ m = self.measure(cfg, flat(base_lab(cfg) + np.array([0.15, 0.0, 0.0])))
107
+ self.assertIsNone(m["grayRatio"])
108
+
109
+ def test_block_average_absorbs_dither(self):
110
+ cfg = config()
111
+ rng = np.random.default_rng(7)
112
+ codes = np.round(flat(base_lab(cfg)) * 255) + rng.integers(-1, 2, size=(64, 64, 3))
113
+ m = self.measure_codes(cfg, codes)
114
+ self.assertEqual(m["chromaticPixelRatio"], 0)
115
+ self.assertEqual(m["grayRatio"], 0)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
@@ -0,0 +1,38 @@
1
+ import functools
2
+ import http.server
3
+ import pathlib
4
+ import sys
5
+ import tempfile
6
+ import threading
7
+ import unittest
8
+ import urllib.request
9
+
10
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "scripts"))
11
+ import serve_preview # noqa: E402
12
+
13
+
14
+ class QuietHandler(serve_preview.NoStoreHandler):
15
+ def log_message(self, *args):
16
+ pass
17
+
18
+
19
+ class ServePreviewTest(unittest.TestCase):
20
+ def test_responses_are_not_cached(self):
21
+ body = b"window.SPECTRAL_THEME = {};"
22
+ with tempfile.TemporaryDirectory() as root:
23
+ pathlib.Path(root, "theme-config.js").write_bytes(body)
24
+ handler = functools.partial(QuietHandler, directory=root)
25
+ with http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) as server:
26
+ threading.Thread(target=server.serve_forever, daemon=True).start()
27
+ try:
28
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
29
+ url = f"http://127.0.0.1:{server.server_address[1]}/theme-config.js"
30
+ with opener.open(url) as response:
31
+ self.assertEqual(response.headers["Cache-Control"], "no-store")
32
+ self.assertEqual(response.read(), body)
33
+ finally:
34
+ server.shutdown()
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()
@@ -0,0 +1,73 @@
1
+ import pathlib
2
+ import shutil
3
+ import sys
4
+ import unittest
5
+
6
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(ROOT / "scripts"))
8
+ import validate_manifest as vm # noqa: E402
9
+
10
+ HUES = {"rose": 18, "peach": 62, "mint": 159, "cyan": 201, "blue": 248, "lilac": 304}
11
+ INTERLEAVED = ["rose", "cyan", "lilac", "peach", "mint", "blue"]
12
+
13
+
14
+ def manifest(mode="opal", order=None, cap=None):
15
+ field = {"scale": 1, "octaves": 4, "warpStrength": 0.3, "motionSpeed": 0.01, "staticTime": 1, "ditherStrength": 1}
16
+ if cap is not None:
17
+ field["luminanceCap"] = cap
18
+ return {
19
+ "schemaVersion": "1.1",
20
+ "mode": mode,
21
+ "label": "test",
22
+ "preset": "test",
23
+ "seed": 1,
24
+ "overallColorIntensity": 0.5,
25
+ "base": {"oklch": {"l": 0.98, "c": 0.004, "h": 94}},
26
+ "colors": [
27
+ {
28
+ "id": cid,
29
+ "label": cid,
30
+ "oklch": {"l": 0.93, "c": 0.04, "h": HUES[cid]},
31
+ "srgbFallback": "#ffffff",
32
+ "intensity": 0.5,
33
+ "peakOpacity": 0.08,
34
+ "fieldScale": 1,
35
+ "phase": [0, 0],
36
+ }
37
+ for cid in (order or list(HUES))
38
+ ],
39
+ "field": field,
40
+ "output": {"colorSpace": "srgb", "reducedMotion": "frozen-calibrated-frame"},
41
+ }
42
+
43
+
44
+ class ValidateManifestTest(unittest.TestCase):
45
+ def test_hue_ordered_palette_passes_without_warnings(self):
46
+ self.assertEqual(vm.validate(manifest()), [])
47
+ self.assertEqual(vm.collect_warnings(manifest()), [])
48
+
49
+ def test_interleaved_palette_warns(self):
50
+ notes = vm.collect_warnings(manifest(order=INTERLEAVED))
51
+ self.assertEqual(len(notes), 1)
52
+ self.assertIn("hue order", notes[0])
53
+
54
+ def test_luminance_cap_is_required_only_for_obsidian(self):
55
+ self.assertEqual(vm.validate(manifest(mode="opal")), [])
56
+ errors = vm.validate(manifest(mode="obsidian"))
57
+ self.assertTrue(
58
+ any(msg.startswith("field.luminanceCap must be in (0, 1]") for msg in errors),
59
+ errors,
60
+ )
61
+ self.assertEqual(vm.validate(manifest(mode="obsidian", cap=0.165)), [])
62
+
63
+ @unittest.skipUnless(shutil.which("node"), "node is needed to load the starter manifests")
64
+ def test_starter_manifests(self):
65
+ for mode in ("opal", "obsidian"):
66
+ with self.subTest(mode=mode):
67
+ config = vm.load_manifest(ROOT / "assets" / "starter" / mode / "theme-config.js")
68
+ self.assertEqual(vm.validate(config), [])
69
+ self.assertEqual(vm.collect_warnings(config), [])
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()