@rubytech/create-maxy-code 0.1.51 → 0.1.57

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 (304) hide show
  1. package/dist/__tests__/platform-port-stamp.test.js +24 -0
  2. package/dist/index.js +5 -2
  3. package/dist/port-resolution.js +2 -0
  4. package/package.json +1 -1
  5. package/payload/platform/lib/require-port-env/dist/index.d.ts +31 -0
  6. package/payload/platform/lib/require-port-env/dist/index.d.ts.map +1 -0
  7. package/payload/platform/lib/require-port-env/dist/index.js +52 -0
  8. package/payload/platform/lib/require-port-env/dist/index.js.map +1 -0
  9. package/payload/platform/lib/require-port-env/src/index.ts +56 -0
  10. package/payload/platform/lib/require-port-env/tsconfig.json +8 -0
  11. package/payload/platform/package-lock.json +0 -1
  12. package/payload/platform/package.json +2 -3
  13. package/payload/platform/plugins/admin/PLUGIN.md +16 -3
  14. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +183 -144
  15. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +180 -99
  16. package/payload/platform/plugins/admin/mcp/dist/index.js +20 -12
  17. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  18. package/payload/platform/plugins/brochures/PLUGIN.md +2 -0
  19. package/payload/platform/plugins/brochures/skills/property-market-report/SKILL.md +100 -0
  20. package/payload/platform/plugins/brochures/skills/property-market-report/references/render.py +267 -0
  21. package/payload/platform/plugins/brochures/skills/property-market-report/references/template.html +266 -0
  22. package/payload/platform/plugins/brochures/skills/property-socials/SKILL.md +141 -0
  23. package/payload/platform/plugins/cloudflare/mcp/dist/lib/setup-orchestrator.d.ts.map +1 -1
  24. package/payload/platform/plugins/cloudflare/mcp/dist/lib/setup-orchestrator.js +2 -1
  25. package/payload/platform/plugins/cloudflare/mcp/dist/lib/setup-orchestrator.js.map +1 -1
  26. package/payload/platform/plugins/docs/references/admin-session.md +33 -3
  27. package/payload/platform/plugins/docs/references/deployment.md +25 -0
  28. package/payload/platform/plugins/docs/references/internals.md +26 -0
  29. package/payload/platform/plugins/docs/references/platform.md +3 -1
  30. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  31. package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
  32. package/payload/platform/plugins/email/mcp/dist/lib/claude-bridge.d.ts.map +1 -1
  33. package/payload/platform/plugins/email/mcp/dist/lib/claude-bridge.js +2 -1
  34. package/payload/platform/plugins/email/mcp/dist/lib/claude-bridge.js.map +1 -1
  35. package/payload/platform/plugins/memory/.claude-plugin/plugin.json +1 -1
  36. package/payload/platform/plugins/memory/PLUGIN.md +1 -1
  37. package/payload/platform/plugins/memory/mcp/dist/index.js +6 -1
  38. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  39. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.d.ts +2 -0
  40. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.d.ts.map +1 -0
  41. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js +107 -0
  42. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js.map +1 -0
  43. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts +4 -2
  44. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts.map +1 -1
  45. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js +22 -13
  46. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js.map +1 -1
  47. package/payload/platform/plugins/memory/references/graph-primitives.md +42 -0
  48. package/payload/platform/plugins/memory/skills/concept-synthesis/SKILL.md +1 -1
  49. package/payload/platform/plugins/memory/skills/conversation-archive-enrich/SKILL.md +2 -2
  50. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +1 -1
  51. package/payload/platform/plugins/property-data/.claude-plugin/plugin.json +17 -0
  52. package/payload/platform/plugins/property-data/PLUGIN.md +100 -0
  53. package/payload/platform/plugins/property-data/mcp/dist/index.d.ts +2 -0
  54. package/payload/platform/plugins/property-data/mcp/dist/index.d.ts.map +1 -0
  55. package/payload/platform/plugins/property-data/mcp/dist/index.js +153 -0
  56. package/payload/platform/plugins/property-data/mcp/dist/index.js.map +1 -0
  57. package/payload/platform/plugins/property-data/mcp/dist/lib/crypto.d.ts +3 -0
  58. package/payload/platform/plugins/property-data/mcp/dist/lib/crypto.d.ts.map +1 -0
  59. package/payload/platform/plugins/property-data/mcp/dist/lib/crypto.js +72 -0
  60. package/payload/platform/plugins/property-data/mcp/dist/lib/crypto.js.map +1 -0
  61. package/payload/platform/plugins/property-data/mcp/dist/lib/neo4j.d.ts +5 -0
  62. package/payload/platform/plugins/property-data/mcp/dist/lib/neo4j.d.ts.map +1 -0
  63. package/payload/platform/plugins/property-data/mcp/dist/lib/neo4j.js +38 -0
  64. package/payload/platform/plugins/property-data/mcp/dist/lib/neo4j.js.map +1 -0
  65. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +29 -0
  66. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -0
  67. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js +112 -0
  68. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -0
  69. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts +14 -0
  70. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -0
  71. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js +20 -0
  72. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -0
  73. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.d.ts +4 -0
  74. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  75. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js +19 -0
  76. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -0
  77. package/payload/platform/plugins/property-data/mcp/dist/tools/key-list.d.ts +4 -0
  78. package/payload/platform/plugins/property-data/mcp/dist/tools/key-list.d.ts.map +1 -0
  79. package/payload/platform/plugins/property-data/mcp/dist/tools/key-list.js +10 -0
  80. package/payload/platform/plugins/property-data/mcp/dist/tools/key-list.js.map +1 -0
  81. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.d.ts +5 -0
  82. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -0
  83. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js +43 -0
  84. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -0
  85. package/payload/platform/plugins/property-data/mcp/package-lock.json +2566 -0
  86. package/payload/platform/plugins/property-data/mcp/package.json +21 -0
  87. package/payload/platform/plugins/property-data/mcp/src/__tests__/key-roundtrip.test.ts +107 -0
  88. package/payload/platform/plugins/property-data/mcp/src/__tests__/propertydata-api.test.ts +184 -0
  89. package/payload/platform/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +48 -0
  90. package/payload/platform/plugins/property-data/mcp/src/index.ts +249 -0
  91. package/payload/platform/plugins/property-data/mcp/src/lib/crypto.ts +79 -0
  92. package/payload/platform/plugins/property-data/mcp/src/lib/neo4j.ts +47 -0
  93. package/payload/platform/plugins/property-data/mcp/src/lib/propertydata-api.ts +135 -0
  94. package/payload/platform/plugins/property-data/mcp/src/tools/endpoints.ts +27 -0
  95. package/payload/platform/plugins/property-data/mcp/src/tools/key-deregister.ts +21 -0
  96. package/payload/platform/plugins/property-data/mcp/src/tools/key-list.ts +10 -0
  97. package/payload/platform/plugins/property-data/mcp/src/tools/key-register.ts +58 -0
  98. package/payload/platform/plugins/property-data/mcp/tsconfig.json +20 -0
  99. package/payload/platform/plugins/property-data/mcp/vitest.config.ts +8 -0
  100. package/payload/platform/plugins/property-data/skills/property-data/SKILL.md +58 -0
  101. package/payload/platform/plugins/waitlist/PLUGIN.md +6 -8
  102. package/payload/platform/plugins/waitlist/mcp/dist/index.js +33 -0
  103. package/payload/platform/plugins/waitlist/mcp/dist/index.js.map +1 -1
  104. package/payload/platform/plugins/waitlist/mcp/dist/lib/admin-model.d.ts +16 -0
  105. package/payload/platform/plugins/waitlist/mcp/dist/lib/admin-model.d.ts.map +1 -0
  106. package/payload/platform/plugins/waitlist/mcp/dist/lib/admin-model.js +58 -0
  107. package/payload/platform/plugins/waitlist/mcp/dist/lib/admin-model.js.map +1 -0
  108. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-extract.d.ts +34 -0
  109. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-extract.d.ts.map +1 -0
  110. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-extract.js +129 -0
  111. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-extract.js.map +1 -0
  112. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-heal.d.ts +12 -18
  113. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-heal.d.ts.map +1 -1
  114. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-heal.js +89 -86
  115. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-heal.js.map +1 -1
  116. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-setup.d.ts.map +1 -1
  117. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-setup.js +15 -53
  118. package/payload/platform/plugins/waitlist/mcp/dist/tools/waitlist-setup.js.map +1 -1
  119. package/payload/platform/plugins/whatsapp/PLUGIN.md +1 -0
  120. package/payload/platform/plugins/whatsapp/mcp/dist/index.js +5 -5
  121. package/payload/platform/plugins/whatsapp/mcp/dist/index.js.map +1 -1
  122. package/payload/platform/plugins/workflows/PLUGIN.md +10 -176
  123. package/payload/platform/plugins/workflows/mcp/dist/index.js +14 -142
  124. package/payload/platform/plugins/workflows/mcp/dist/index.js.map +1 -1
  125. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.d.ts +1 -88
  126. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.d.ts.map +1 -1
  127. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.js +0 -86
  128. package/payload/platform/plugins/workflows/mcp/dist/lib/step-resolver.js.map +1 -1
  129. package/payload/platform/plugins/workflows/mcp/dist/lib/validate-capabilities.d.ts +5 -12
  130. package/payload/platform/plugins/workflows/mcp/dist/lib/validate-capabilities.d.ts.map +1 -1
  131. package/payload/platform/plugins/workflows/mcp/dist/lib/validate-capabilities.js +5 -46
  132. package/payload/platform/plugins/workflows/mcp/dist/lib/validate-capabilities.js.map +1 -1
  133. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.d.ts +1 -16
  134. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.d.ts.map +1 -1
  135. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.js +39 -75
  136. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-create.js.map +1 -1
  137. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.d.ts +8 -21
  138. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.d.ts.map +1 -1
  139. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js +39 -266
  140. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js.map +1 -1
  141. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.d.ts +3 -8
  142. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.d.ts.map +1 -1
  143. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.js +3 -10
  144. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-get.js.map +1 -1
  145. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.d.ts.map +1 -1
  146. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.js +38 -86
  147. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-update.js.map +1 -1
  148. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-validate.d.ts.map +1 -1
  149. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-validate.js +8 -26
  150. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-validate.js.map +1 -1
  151. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  152. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +1 -2
  153. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  154. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  155. package/payload/platform/services/claude-session-manager/dist/http-server.js +204 -23
  156. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  157. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +39 -1
  158. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  159. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +109 -10
  160. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  161. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  162. package/payload/platform/services/claude-session-manager/dist/reaper.js +11 -0
  163. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  164. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +21 -0
  165. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  166. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  167. package/payload/platform/services/claude-session-manager/vitest.config.ts +14 -0
  168. package/payload/platform/templates/agents/admin/IDENTITY.md +2 -0
  169. package/payload/platform/templates/agents/admin/SOUL.md +3 -1
  170. package/payload/premium-plugins/real-agent/BUNDLE.md +5 -3
  171. package/payload/premium-plugins/real-agent/agents/negotiator.md +1 -1
  172. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  173. package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
  174. package/payload/premium-plugins/real-agent/plugins/brochures/PLUGIN.md +2 -0
  175. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-market-report/SKILL.md +100 -0
  176. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-market-report/references/render.py +267 -0
  177. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-market-report/references/template.html +266 -0
  178. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-socials/SKILL.md +141 -0
  179. package/payload/premium-plugins/real-agent/plugins/property-data/.claude-plugin/plugin.json +17 -0
  180. package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +100 -0
  181. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.d.ts +2 -0
  182. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.d.ts.map +1 -0
  183. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js +153 -0
  184. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js.map +1 -0
  185. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/crypto.d.ts +3 -0
  186. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/crypto.d.ts.map +1 -0
  187. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/crypto.js +72 -0
  188. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/crypto.js.map +1 -0
  189. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/neo4j.d.ts +5 -0
  190. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/neo4j.d.ts.map +1 -0
  191. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/neo4j.js +38 -0
  192. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/neo4j.js.map +1 -0
  193. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +29 -0
  194. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -0
  195. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js +112 -0
  196. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -0
  197. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts +14 -0
  198. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -0
  199. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js +20 -0
  200. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -0
  201. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.d.ts +4 -0
  202. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  203. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js +19 -0
  204. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -0
  205. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-list.d.ts +4 -0
  206. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-list.d.ts.map +1 -0
  207. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-list.js +10 -0
  208. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-list.js.map +1 -0
  209. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.d.ts +5 -0
  210. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -0
  211. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js +43 -0
  212. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -0
  213. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/package-lock.json +2566 -0
  214. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/package.json +21 -0
  215. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/key-roundtrip.test.ts +107 -0
  216. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/propertydata-api.test.ts +184 -0
  217. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/__tests__/schema-parity.test.ts +48 -0
  218. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/index.ts +249 -0
  219. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/crypto.ts +79 -0
  220. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/neo4j.ts +47 -0
  221. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/propertydata-api.ts +135 -0
  222. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/endpoints.ts +27 -0
  223. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-deregister.ts +21 -0
  224. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-list.ts +10 -0
  225. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-register.ts +58 -0
  226. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/tsconfig.json +20 -0
  227. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/vitest.config.ts +8 -0
  228. package/payload/premium-plugins/real-agent/plugins/property-data/skills/property-data/SKILL.md +58 -0
  229. package/payload/server/{chunk-CWI7NOWK.js → chunk-2IKQERHN.js} +32 -4
  230. package/payload/server/maxy-edge.js +53 -25
  231. package/payload/server/public/assets/admin-2ZJmzJ_g.js +216 -0
  232. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-DGHlapUl.js → architectureDiagram-Q4EWVU46-DgfgldJT.js} +1 -1
  233. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-Dgnf9J14.js → blockDiagram-DXYQGD6D-agu8GDzA.js} +1 -1
  234. package/payload/server/public/assets/{brand-CtilcX3W.css → brand-BDASZqMG.css} +1 -1
  235. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-GBVmghhJ.js → c4Diagram-AHTNJAMY-CmNsUmAe.js} +1 -1
  236. package/payload/server/public/assets/channel-DXGOX55q.js +1 -0
  237. package/payload/server/public/assets/{chunk-336JU56O-CSVC_J3A.js → chunk-336JU56O-D9Uts7k9.js} +2 -2
  238. package/payload/server/public/assets/{chunk-426QAEUC-CK1Z0942.js → chunk-426QAEUC-D4ABqXMn.js} +1 -1
  239. package/payload/server/public/assets/{chunk-4TB4RGXK-CaqJYLxL.js → chunk-4TB4RGXK-D1KE4OAX.js} +1 -1
  240. package/payload/server/public/assets/{chunk-5FUZZQ4R-57BmPy_B.js → chunk-5FUZZQ4R-CAh0GXe2.js} +1 -1
  241. package/payload/server/public/assets/{chunk-5PVQY5BW-Ud03uYx_.js → chunk-5PVQY5BW-DBHa6HOv.js} +1 -1
  242. package/payload/server/public/assets/{chunk-EDXVE4YY-p5KO0PoG.js → chunk-EDXVE4YY-BDC-6mL3.js} +1 -1
  243. package/payload/server/public/assets/{chunk-ENJZ2VHE-CkxgLujt.js → chunk-ENJZ2VHE-CH3uROkW.js} +1 -1
  244. package/payload/server/public/assets/{chunk-ICPOFSXX-DwMkY8p7.js → chunk-ICPOFSXX-BzZf0nAw.js} +1 -1
  245. package/payload/server/public/assets/{chunk-OYMX7WX6-CyYOUKBw.js → chunk-OYMX7WX6-BrLHrI6O.js} +1 -1
  246. package/payload/server/public/assets/{chunk-U2HBQHQK-Dk5rfJM6.js → chunk-U2HBQHQK-Bw4UkLe_.js} +1 -1
  247. package/payload/server/public/assets/{chunk-X2U36JSP-D0qCg9Nq.js → chunk-X2U36JSP-23xgdk05.js} +1 -1
  248. package/payload/server/public/assets/{chunk-YZCP3GAM-C0uFHrdT.js → chunk-YZCP3GAM-BA8iGgRa.js} +1 -1
  249. package/payload/server/public/assets/{chunk-ZZ45TVLE-C7lSyGRs.js → chunk-ZZ45TVLE-B-ol4EgR.js} +1 -1
  250. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DTuuDxpK.js +1 -0
  251. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CIw8RU0q.js +1 -0
  252. package/payload/server/public/assets/clone-O4ZyuvYl.js +1 -0
  253. package/payload/server/public/assets/{dagre-BWZ4wr8y.js → dagre--kXszEIb.js} +1 -1
  254. package/payload/server/public/assets/{dagre-KV5264BT-C7mpqRpw.js → dagre-KV5264BT-amN1uU2d.js} +1 -1
  255. package/payload/server/public/assets/data-DlmoKxmN.js +1 -0
  256. package/payload/server/public/assets/{device-url-actions-347cMDY4.js → device-url-actions-CPoz5fqz.js} +1 -1
  257. package/payload/server/public/assets/{diagram-5BDNPKRD-BcQRB7_3.js → diagram-5BDNPKRD-Cwizs9tP.js} +1 -1
  258. package/payload/server/public/assets/{diagram-G4DWMVQ6-BLSGUKbc.js → diagram-G4DWMVQ6-BpSu5Uao.js} +1 -1
  259. package/payload/server/public/assets/{diagram-MMDJMWI5-Ra1VvGJh.js → diagram-MMDJMWI5-DiArtkto.js} +1 -1
  260. package/payload/server/public/assets/{diagram-TYMM5635-Bx_r1USn.js → diagram-TYMM5635-_tygK58Y.js} +1 -1
  261. package/payload/server/public/assets/{erDiagram-SMLLAGMA-CJhoN8dG.js → erDiagram-SMLLAGMA-CBV6LWYA.js} +1 -1
  262. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-Bd5CZIoq.js → flowDiagram-DWJPFMVM-CHZFrtOU.js} +1 -1
  263. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-BNQJqJFh.js → ganttDiagram-T4ZO3ILL-B3vKq5ov.js} +1 -1
  264. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-DoE1AFmA.js → gitGraphDiagram-UUTBAWPF-dnJPjbtj.js} +1 -1
  265. package/payload/server/public/assets/graph-BVvcFSEQ.js +1 -0
  266. package/payload/server/public/assets/graph-labels-cCvgu38Y.js +1 -0
  267. package/payload/server/public/assets/{graphlib-D7JFfLSw.js → graphlib-CNXtnega.js} +1 -1
  268. package/payload/server/public/assets/{infoDiagram-42DDH7IO-BtsKRO-J.js → infoDiagram-42DDH7IO-D8DYwVAF.js} +1 -1
  269. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-DapnDR0V.js → ishikawaDiagram-UXIWVN3A-CfEVY2Ud.js} +1 -1
  270. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BLqfwMMu.js → journeyDiagram-VCZTEJTY-FmoBq7Kh.js} +1 -1
  271. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DqiSbvUW.js → kanban-definition-6JOO6SKY-B-Q9qk99.js} +1 -1
  272. package/payload/server/public/assets/{line-DcwBbbhp.js → line-DZwbJENP.js} +1 -1
  273. package/payload/server/public/assets/{mermaid-parser.core-BZwNRInp.js → mermaid-parser.core-C3ahKykw.js} +1 -1
  274. package/payload/server/public/assets/{mermaid.core-DnsyVInc.js → mermaid.core-B8OFZIE8.js} +3 -3
  275. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-MPPO8aDx.js → mindmap-definition-QFDTVHPH-CqhkfbWa.js} +1 -1
  276. package/payload/server/public/assets/{page-Cz_pVOv_.js → page-BnAxPkmg.js} +1 -1
  277. package/payload/server/public/assets/{page-pXt2BdOS.js → page-BnaN3u86.js} +1 -1
  278. package/payload/server/public/assets/{pieDiagram-DEJITSTG-BKzbjD_5.js → pieDiagram-DEJITSTG-CJqQ6u3-.js} +1 -1
  279. package/payload/server/public/assets/{public-C-0V5aC8.js → public-rWe9ADCS.js} +3 -3
  280. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-CvvvRvae.js → quadrantDiagram-34T5L4WZ-BtEqOXQM.js} +1 -1
  281. package/payload/server/public/assets/{requirementDiagram-MS252O5E-BxXZA7z2.js → requirementDiagram-MS252O5E-DPHWUtE6.js} +1 -1
  282. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BRRFjjao.js → sankeyDiagram-XADWPNL6-FBmc3wHT.js} +1 -1
  283. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DOy_E8xe.js → sequenceDiagram-FGHM5R23-BMSd2-1M.js} +1 -1
  284. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-CDqat01p.js → stateDiagram-FHFEXIEX-DqFbU-LT.js} +1 -1
  285. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CXTnsHGq.js +1 -0
  286. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-AK1yuR8Z.js → timeline-definition-GMOUNBTQ-DsfvHVK8.js} +1 -1
  287. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-CCKJ7H16.js → vennDiagram-DHZGUBPP-kHU05mqC.js} +1 -1
  288. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-CKHKJnG_.js → wardleyDiagram-NUSXRM2D-C696Mez_.js} +1 -1
  289. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-BBD35k9e.js → xychartDiagram-5P7HB3ND-yht-JyXJ.js} +1 -1
  290. package/payload/server/public/data.html +5 -5
  291. package/payload/server/public/graph.html +5 -5
  292. package/payload/server/public/index.html +7 -7
  293. package/payload/server/public/public.html +4 -4
  294. package/payload/server/server.js +209 -178
  295. package/payload/server/public/assets/admin-BbcN8otj.js +0 -216
  296. package/payload/server/public/assets/channel-p5ZCfK_I.js +0 -1
  297. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DqDM1D4-.js +0 -1
  298. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DeYvPD8H.js +0 -1
  299. package/payload/server/public/assets/clone-60k82qeh.js +0 -1
  300. package/payload/server/public/assets/data-oXs84UW6.js +0 -1
  301. package/payload/server/public/assets/graph-DcpqWbSs.js +0 -1
  302. package/payload/server/public/assets/graph-labels-DJvuUODB.js +0 -1
  303. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-DHckg-aj.js +0 -1
  304. /package/payload/server/public/assets/{brand-B6pMzF6h.js → brand-BWCtpG7_.js} +0 -0
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: property-market-report
3
+ description: Use when a user asks for an A4 print-ready property market report for a specific area, branded with an estate agent's design tokens and logos, illustrated by up to 10 property listings that hyperlink back to their source URLs. Triggers include "create a market report for <area>", "market data sheet for <postcode>", "Bishop's Stortford / CM23 / Sawbridgeworth report", or any request that names market metrics for an area and a set of property URLs to feature.
4
+ ---
5
+
6
+ # Property Market Report
7
+
8
+ A 4-page A4 PDF: cover (full-bleed hero with light logo), market summary + headline KPIs, activity + property-type distribution + at-a-glance table, then a featured-listings grid where every tile hyperlinks to its source URL.
9
+
10
+ ## Typography is fixed; only colours and logos come from the agent
11
+
12
+ The report has its own typographic identity — a luxury serif stack pinned in the template:
13
+
14
+ - **Display** — Cormorant Garamond (italic for headlines, KPI values, prices, addresses)
15
+ - **Body** — Lora
16
+ - **Sans / labels** — Inter (eyebrows, KPI labels, chips, footer meta)
17
+
18
+ **Never substitute the agent's body or display fonts**, even if their `DESIGN.md` specifies different families. The market report intentionally reads as an editorial / luxury document across every brand it renders for; the agent's identity comes through the **palette and logo only**. If a brand insists on its own typography, that's a different deliverable — likely a brochure, not a market report.
19
+
20
+ ## Inputs (never invented)
21
+
22
+ - **`design_tokens_path`** — path to a `DESIGN.md` that tokenises the agent's brand (`muvin.co.uk` → `/estate-agents/muvin/DESIGN.md`). Read the canonical hex values directly; never substitute "close" colours. **Only colour tokens are consumed** — the report imports `primary` (e.g. teal-900), `primary_dark`, `accent` (e.g. gold-500), `paper`, `paper_banded`, and `rule`. Any typography, spacing, or radius tokens in the agent's DESIGN.md are *ignored by design*.
23
+ - **`logo_light_path`** / **`logo_dark_path`** — PNG/SVG. Light = white-on-dark version (for the teal-toned cover); dark = full-colour version (for body-page headers). When supplied, the renderer emits an `<img>` for the logo; when omitted, it falls back to a Cormorant-Garamond text wordmark using `brand.name` + `brand.tagline`. The text-wordmark fallback is acceptable for brands whose logo is essentially a serif-typeset name (e.g. *muvin*); for richer marks, supply the files.
24
+ - **`property_urls`** — list of up to 10 source URLs (Muvin, Rightmove, Zoopla, etc.). Each must resolve to a single property listing page. Reject more than 10; the layout breaks at >10 cards.
25
+ - **`market_data`** — flat dict of numbers (average asking, average sold, £/sqft, YoY growth, demand rating, days-on-market, property-type distribution, sample size, period). Source from a market intelligence API (PropertyData / Land Registry / ONS) or paste from an existing report. **The period is the *generated* date, not whatever appears in the URL slug** — the Market Trak `oct-2025` slug describes a snapshot from May 2026 (Oct 2025 was the trailing window). Use the report-page's stated `Generated` value.
26
+
27
+ ## How the skill renders
28
+
29
+ `references/template.html` is the single source of truth for the layout. It carries `<!-- REPLACE: <slot> -->` comments — never edit the structure directly; edit the template once, the slot names stay stable.
30
+
31
+ `references/render.py` consumes three plain-Python inputs (brand · market · properties), substitutes them into the template, then drives Chrome headless to produce the PDF and zips the HTML+images bundle. Run it with `python3 render.py inputs.json` where `inputs.json` carries:
32
+
33
+ ```json
34
+ { "out_dir": "...", "filename_stem": "...",
35
+ "brand": { "name", "tagline", "primary", "primary_dark", "accent",
36
+ "paper", "paper_banded", "rule" },
37
+ "market": { "location_name", "location_code", "period", "cover_title",
38
+ "cover_hero", "page2_photo",
39
+ "summary_headline", "summary_paragraphs",
40
+ "avg_asking", "avg_sold", "psf", "yoy_growth",
41
+ "demand_rating", "demand_gauge_position",
42
+ "price_growth_note", "demand_note",
43
+ "for_sale", "sales_per_month", "turnover_pct", "dom",
44
+ "n_props", "type_dist" },
45
+ "properties": [{ "url", "slug", "address", "price_kind", "price",
46
+ "beds", "baths", "type" }, ...] /* ≤ 10 */
47
+ }
48
+ ```
49
+
50
+ If any `<!-- REPLACE: ... -->` slot is left unfilled after substitution the renderer raises — a contract gap, not a silent default.
51
+
52
+ ## Outcome contract
53
+
54
+ Every report ships these artefacts in a single output directory:
55
+
56
+ - `index.html` — single self-contained file, no build step, opens directly. Webfonts loaded from Google Fonts.
57
+ - `<area>-<period>.pdf` — 4–5 page A4 PDF rendered via Chrome headless `--print-to-pdf`. 4 pages when there are ≤ 5 listings, 5 pages when 6–10 listings (the listings split across pages 4 and 5). Anything beyond 5 pages means content is overflowing — tighten KPI tile padding or listing card aspect ratio until it fits.
58
+ - `<area>-<period>-web.zip` — packaged bundle containing `index.html` + `img/`, ready to drop into a static-site host. Self-contained except for the Google Fonts CSS link, which is fetched at view time.
59
+ - `img/<NN>-<slug>.jpg` — one image per property, 0-padded index, slug from the source URL. Produced by the **property-socials** skill in landscape format (1200×628) where possible; each report-page card uses this branded tile, not the raw hero. If a property has no local brochure scaffold, run `real-estate-brochure:property-extract` → `real-estate-brochure:property-brochure` → `property-socials` first; the report consumes the resulting `og-landscape-main.jpg`.
60
+
61
+ ## Visual contract
62
+
63
+ The design tokens carry the contract. Two rules survive any token re-skin:
64
+
65
+ 1. **Cover** — full-bleed property hero behind a vertical gradient scrim (top ~55% opacity → bottom ~95% opacity in the surface-deep colour). Light logo top-left, area name in display-italic, tagline in accent colour, four-up meta strip across the foot (Report · Location · Period · Status).
66
+ 2. **Listing tiles** — every property card is a hyperlinked `<a>` wrapping a tile that pairs the property-socials landscape image with: address (display italic, 14–16pt), price (display italic, 14–16pt), spec chips (sans, pill-shaped). The `<a>` opens the source URL in a new tab. Tile borders use the rule colour from the design tokens, not arbitrary greys.
67
+
68
+ ## Pagination contract
69
+
70
+ A4 portrait, 210×297 mm, 16 mm horizontal padding, 18 mm vertical. Each `<section class="page">` is `height:297mm; overflow:hidden; page-break-after:always`. If Chrome's `mdls -name kMDItemNumberOfPages` returns >4, content is overflowing — diagnose with `pdftoppm -png -f <n> -l <n>` to see which page bleeds, then trim that page only (smaller KPI padding, tighter photo-strip aspect ratio, fewer listing cards per page). Never reach for `min-height` to "let it grow" — the printed deliverable is the deliverable.
71
+
72
+ ## Listings page layout
73
+
74
+ Up to 10 properties fit on **two body pages** of the report (page 4 and an optional page 5 if >5 listings):
75
+
76
+ - ≤ 5 listings → one page, cards stacked at 70mm photo column × 5/3 aspect (~42mm tall photo).
77
+ - 6–10 listings → two pages, 5 per page, same dimensions.
78
+ - Each card: `<a href="<source_url>" target="_blank" rel="noopener">` wraps the whole card. Tile photo on the left, meta on the right (address · locality · price · chip row). Hover state is irrelevant for print but should still feel clickable in the HTML preview (subtle teal-tint background-shift, no underline on the address).
79
+
80
+ ## Source attribution
81
+
82
+ The disclaimer block on the final page must name:
83
+
84
+ - All market-data sources (Land Registry, ONS, PropertyData, Rightmove, Zoopla, local agents) by name.
85
+ - Image attribution: `Property imagery: <agent>.co.uk via property-socials`.
86
+ - A short "Accuracy" line and a short "Investment" line — see the existing `bishops-stortford-*` reports for canonical wording.
87
+
88
+ ## Failure modes seen
89
+
90
+ - **Logo invisible in preview.** The IDE preview panel can't always resolve relative `img/` paths or large base64 data URIs. The deliverable PDF is the source of truth; viewing the HTML in a real browser (or via `python3 -m http.server`) is the second source of truth. Don't burn iterations chasing preview-panel artefacts.
91
+ - **Date drift from URL slug.** A Market Trak URL like `…-oct-2025?…` does not mean the report is October 2025. Always trust the `Generated:` field on the source page.
92
+ - **Page 3 / page 4 overflow.** The default Cormorant Garamond + Lora stack is slightly taller-set than a sans default. KPI tile padding and panel padding need to come down (~5 mm not 7 mm) for serif typography to fit alongside the photo strip on page 3 and two listing cards on page 4.
93
+ - **Property-socials skipped.** A report that drops raw `loop-app.b-cdn.net/propertyimages/...jpg` straight into a tile breaks the brand contract — the tiles have no overlay, no scrim, no address copy. Always run the OG pipeline; if it can't run, surface the gap, don't substitute.
94
+ - **Anchor links dead in PDF.** Chrome `--print-to-pdf` preserves `<a href>` as live PDF annotations. Verify by opening the PDF in Preview and clicking a listing — if the link is dead, the HTML wrapped a `<div>` instead of an `<a>`.
95
+
96
+ ## Out of scope
97
+
98
+ - The market-data fetch itself (owned by whichever PropertyData / Land Registry adapter the operator uses).
99
+ - The property-extract / property-brochure / property-socials pipeline (owned by those skills).
100
+ - HTML emails / web embeds of the report. The deliverable is A4 PDF + the self-contained HTML preview.
@@ -0,0 +1,267 @@
1
+ """property-market-report renderer.
2
+
3
+ Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
4
+ derived from three inputs: brand tokens, market data, and a list of properties.
5
+
6
+ Public entry: render(brand, market, properties, out_dir, template_path=None)
7
+ brand: dict — see BRAND_KEYS below
8
+ market: dict — see MARKET_KEYS below
9
+ properties: list[dict] — each property dict has the keys listed in
10
+ PROPERTY_KEYS below; up to 10 properties supported.
11
+ out_dir: pathlib.Path — directory to write index.html into (must already
12
+ contain an img/ subdirectory with the per-property hero JPGs
13
+ named <NN>-<slug>.jpg where NN is the 0-padded index).
14
+ template_path: pathlib.Path | None — override the bundled template.html.
15
+
16
+ Produces:
17
+ out_dir/index.html
18
+
19
+ PDF generation and web-zip packaging live in this module's main() — call:
20
+ python3 render.py inputs.json
21
+ where inputs.json is { "brand": {...}, "market": {...}, "properties": [...] }.
22
+ """
23
+ from __future__ import annotations
24
+ import json, html, re, sys, subprocess, shutil, zipfile
25
+ from pathlib import Path
26
+
27
+ BRAND_KEYS = (
28
+ "name", "tagline", "primary", "primary_dark", "accent",
29
+ "paper", "paper_banded", "rule",
30
+ )
31
+ # Optional brand keys (not required):
32
+ # "logo_light" — path to white-on-transparent logo (used on dark cover)
33
+ # "logo_dark" — path to full-colour logo (used on body-page headers)
34
+ # When omitted, the renderer falls back to a Cormorant-Garamond text wordmark
35
+ # using brand.name + brand.tagline.
36
+ MARKET_KEYS = (
37
+ "location_name", "location_code", "period",
38
+ "summary_headline", "summary_paragraphs",
39
+ "avg_asking", "avg_sold", "psf", "yoy_growth",
40
+ "demand_rating", "demand_gauge_position",
41
+ "price_growth_note", "demand_note",
42
+ "for_sale", "sales_per_month", "turnover_pct", "dom",
43
+ "n_props", "type_dist",
44
+ "cover_hero", "page2_photo",
45
+ )
46
+ PROPERTY_KEYS = (
47
+ "url", "slug", "address", "price_kind", "price",
48
+ "beds", "baths", "type",
49
+ )
50
+
51
+
52
+ def _listing_card(p: dict, index: int) -> str:
53
+ addr_parts = [x.strip() for x in p["address"].split(",")]
54
+ addr = html.escape(addr_parts[0])
55
+ locality = html.escape(", ".join(addr_parts[1:]))
56
+ chips = []
57
+ if p.get("beds"): chips.append(f"{p['beds']} bed")
58
+ if p.get("baths"): chips.append(f"{p['baths']} bath")
59
+ if p.get("type"): chips.append(html.escape(p["type"]))
60
+ chips_html = "".join(f"<span>{c}</span>" for c in chips)
61
+ img_path = f"img/{index:02d}-{p['slug']}.jpg"
62
+ return (
63
+ f'<a class="listing" href="{html.escape(p["url"])}" target="_blank" rel="noopener">'
64
+ f'<div class="photo"><img src="{img_path}" alt="{addr}"><div class="badge">For sale</div></div>'
65
+ f'<div class="meta">'
66
+ f'<div class="addr">{addr}</div>'
67
+ f'<div class="spec">{locality}</div>'
68
+ f'<div class="price"><small>{html.escape(p.get("price_kind") or "Guide price")}</small>'
69
+ f'{html.escape(p["price"])}</div>'
70
+ f'<div class="kfs">{chips_html}</div>'
71
+ f"</div></a>"
72
+ )
73
+
74
+
75
+ def _type_dist_rows(dist: list[tuple[str, float]]) -> str:
76
+ return "".join(
77
+ f'<div class="dist-row"><div class="name">{html.escape(name)}</div>'
78
+ f'<div class="bar"><span{" class=gold" if i == 0 else ""} '
79
+ f'style="width:{pct}%"></span></div>'
80
+ f'<div class="pct">{pct}%</div></div>'
81
+ for i, (name, pct) in enumerate(dist)
82
+ )
83
+
84
+
85
+ def _at_a_glance_rows(market: dict) -> str:
86
+ gap = market["avg_asking"] - market["avg_sold"]
87
+ rows = [
88
+ ("Average asking price", f"£{market['avg_asking']:,}"),
89
+ ("Average sold price", f"£{market['avg_sold']:,}"),
90
+ ("Asking-to-sold gap", f"−£{gap:,}"),
91
+ ("Price per sq ft", f"£{market['psf']}"),
92
+ ("YoY growth", f"{market['yoy_growth']*100:+.1f}%"),
93
+ ("Properties analysed", f"{market['n_props']}"),
94
+ ("Live listings", f"{market['for_sale']}"),
95
+ ("Sales per month", f"{market['sales_per_month']}"),
96
+ ("Days on market", f"{market['dom']}"),
97
+ ]
98
+ return "".join(
99
+ f'<tr><td>{html.escape(k)}</td><td class="num">{html.escape(v)}</td></tr>'
100
+ for k, v in rows
101
+ )
102
+
103
+
104
+ def _summary_paragraphs(paras: list[str]) -> str:
105
+ if not paras:
106
+ return ""
107
+ out = [f'<p class="dropcap">{paras[0]}</p>']
108
+ for p in paras[1:]:
109
+ out.append(f"<p>{p}</p>")
110
+ return "".join(out)
111
+
112
+
113
+ def _wordmark(brand: dict, variant: str, out_dir: Path) -> str:
114
+ """variant ∈ {'light', 'dark'}.
115
+
116
+ 'light' = cover (large, white over dark teal scrim).
117
+ 'dark' = body-page header (small, full-colour over light paper).
118
+
119
+ When brand['logo_light'] / brand['logo_dark'] is supplied as a filesystem
120
+ path, the file is copied into out_dir/img/ and referenced via a relative
121
+ path so the web zip stays self-contained. When omitted, falls back to a
122
+ Cormorant-Garamond text wordmark using brand.name + brand.tagline.
123
+ """
124
+ key = "logo_light" if variant == "light" else "logo_dark"
125
+ logo_path = brand.get(key)
126
+ if logo_path:
127
+ src = Path(logo_path)
128
+ dest_dir = out_dir / "img"
129
+ dest_dir.mkdir(parents=True, exist_ok=True)
130
+ dest = dest_dir / src.name
131
+ if src.resolve() != dest.resolve():
132
+ shutil.copy2(src, dest)
133
+ rel = f"img/{src.name}"
134
+ if variant == "light":
135
+ return f'<img src="{html.escape(rel)}" alt="{html.escape(brand["name"])}" style="height:14mm;width:auto;display:block">'
136
+ return f'<img src="{html.escape(rel)}" alt="{html.escape(brand["name"])}" style="height:9mm;width:auto;display:block">'
137
+ # Text-wordmark fallback
138
+ cls = "wm light" if variant == "light" else "wm small"
139
+ return (
140
+ f'<div class="{cls}">'
141
+ f'<span class="wm-name">{html.escape(brand["name"])}</span>'
142
+ f'<span class="wm-sub">{html.escape(brand["tagline"])}</span>'
143
+ f"</div>"
144
+ )
145
+
146
+
147
+ def render(brand: dict, market: dict, properties: list[dict], out_dir: Path,
148
+ template_path: Path | None = None) -> Path:
149
+ template_path = template_path or (Path(__file__).parent / "template.html")
150
+ tmpl = template_path.read_text()
151
+
152
+ if len(properties) > 10:
153
+ raise ValueError("property-market-report supports up to 10 properties.")
154
+
155
+ out_dir.mkdir(parents=True, exist_ok=True)
156
+
157
+ listings_a = "".join(_listing_card(p, i) for i, p in enumerate(properties[:5]))
158
+ listings_b = "".join(_listing_card(p, i + 5) for i, p in enumerate(properties[5:]))
159
+
160
+ slots = {
161
+ "brand_name": brand["name"],
162
+ "brand_tagline": brand["tagline"],
163
+ "wordmark_light": _wordmark(brand, "light", out_dir),
164
+ "wordmark_dark": _wordmark(brand, "dark", out_dir),
165
+ "brand_primary": brand["primary"],
166
+ "brand_primary_dark":brand["primary_dark"],
167
+ "brand_accent": brand["accent"],
168
+ "brand_paper": brand["paper"],
169
+ "brand_paper_banded":brand["paper_banded"],
170
+ "brand_rule": brand["rule"],
171
+
172
+ "location_name": market["location_name"],
173
+ "location_code": market["location_code"],
174
+ "period": market["period"],
175
+ "cover_title": market.get("cover_title", market["location_name"].split(",")[0] + "."),
176
+ "cover_hero_path": market["cover_hero"],
177
+ "page2_photo": market["page2_photo"],
178
+
179
+ "summary_headline": market["summary_headline"],
180
+ "summary_paragraphs":_summary_paragraphs(market["summary_paragraphs"]),
181
+
182
+ "kpi_avg_asking": f"£{market['avg_asking']:,}",
183
+ "kpi_avg_sold": f"£{market['avg_sold']:,}",
184
+ "kpi_psf": f"£{market['psf']}",
185
+ "kpi_yoy": f"{market['yoy_growth']*100:+.1f}%",
186
+ "kpi_for_sale": str(market["for_sale"]),
187
+ "kpi_sales_per_month":str(market["sales_per_month"]),
188
+ "kpi_turnover": f"{int(market['turnover_pct']*100)}%",
189
+ "kpi_dom": str(market["dom"]),
190
+
191
+ "demand_rating": market["demand_rating"],
192
+ "gauge_position": market.get("demand_gauge_position", "50%"),
193
+ "price_growth_note": market["price_growth_note"],
194
+ "demand_note": market["demand_note"],
195
+ "n_props": str(market["n_props"]),
196
+
197
+ "at_a_glance_rows": _at_a_glance_rows(market),
198
+ "type_dist_rows": _type_dist_rows(market["type_dist"]),
199
+ "listing_cards_page_4": listings_a,
200
+ "listing_cards_page_5": listings_b,
201
+ }
202
+
203
+ out = tmpl
204
+ for k, v in slots.items():
205
+ out = out.replace(f"<!-- REPLACE: {k} -->", v)
206
+
207
+ # Anything still unfilled is a contract gap — fail loud
208
+ leftovers = re.findall(r"<!-- REPLACE: (\w+) -->", out)
209
+ if leftovers:
210
+ raise RuntimeError(
211
+ "template has unfilled placeholders: " + ", ".join(sorted(set(leftovers)))
212
+ )
213
+
214
+ out_path = out_dir / "index.html"
215
+ out_path.write_text(out)
216
+ return out_path
217
+
218
+
219
+ def render_pdf(out_dir: Path, pdf_name: str) -> Path:
220
+ chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
221
+ if not Path(chrome).exists():
222
+ raise FileNotFoundError(
223
+ "Chrome not found at " + chrome + " — install Chrome or override the path."
224
+ )
225
+ pdf_path = out_dir / pdf_name
226
+ subprocess.run([
227
+ chrome, "--headless", "--disable-gpu", "--no-pdf-header-footer",
228
+ f"--print-to-pdf={pdf_path}", "--virtual-time-budget=30000",
229
+ f"file://{(out_dir / 'index.html').resolve()}",
230
+ ], check=True, capture_output=True)
231
+ return pdf_path
232
+
233
+
234
+ def pack_web_zip(out_dir: Path, zip_name: str) -> Path:
235
+ """Bundle index.html + img/ into a self-contained web zip."""
236
+ zip_path = out_dir / zip_name
237
+ if zip_path.exists():
238
+ zip_path.unlink()
239
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
240
+ z.write(out_dir / "index.html", "index.html")
241
+ img_dir = out_dir / "img"
242
+ if img_dir.exists():
243
+ for p in sorted(img_dir.iterdir()):
244
+ if p.is_file():
245
+ z.write(p, f"img/{p.name}")
246
+ return zip_path
247
+
248
+
249
+ def main():
250
+ if len(sys.argv) < 2:
251
+ print("Usage: render.py <inputs.json>", file=sys.stderr)
252
+ sys.exit(2)
253
+ inputs = json.loads(Path(sys.argv[1]).read_text())
254
+ out_dir = Path(inputs.get("out_dir", "."))
255
+ base = inputs.get("filename_stem",
256
+ f"{inputs['market']['location_name'].split(',')[0].lower().replace(' ', '-')}"
257
+ f"-{inputs['market']['period'].lower().replace(' ', '-')}")
258
+ render(inputs["brand"], inputs["market"], inputs["properties"], out_dir)
259
+ pdf = render_pdf(out_dir, f"{base}.pdf")
260
+ web = pack_web_zip(out_dir, f"{base}-web.zip")
261
+ print(f"HTML → {out_dir/'index.html'}")
262
+ print(f"PDF → {pdf}")
263
+ print(f"WEB → {web}")
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()
@@ -0,0 +1,266 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Market Data Sheet — <!-- REPLACE: location_name --> · <!-- REPLACE: period --> · <!-- REPLACE: brand_name --></title>
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&family=Lora:ital,wght@0,400;0,500;0,600;1,400&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root{
12
+ --teal-900:<!-- REPLACE: brand_primary -->;
13
+ --teal-950:<!-- REPLACE: brand_primary_dark -->;
14
+ --gold-500:<!-- REPLACE: brand_accent -->;
15
+ --paper-0:#FFFFFF;
16
+ --paper-25:<!-- REPLACE: brand_paper -->;
17
+ --paper-50:#FBF8F8;
18
+ --paper-75:<!-- REPLACE: brand_paper_banded -->;
19
+ --slate-500:#56605F; --slate-600:#6E6E6E;
20
+ --rule-300:<!-- REPLACE: brand_rule -->;
21
+
22
+ --text-default:var(--teal-900);
23
+ --text-muted:var(--slate-600);
24
+ --text-accent:var(--gold-500);
25
+ --text-on-dark:var(--paper-0);
26
+ --surface-page:var(--paper-25);
27
+ --surface-card:var(--paper-0);
28
+ --surface-banded:var(--paper-75);
29
+ --surface-deep:var(--teal-900);
30
+ --radius-md:4px; --radius-lg:8px; --radius-xl:16px;
31
+
32
+ --display:"Cormorant Garamond",Garamond,Georgia,serif;
33
+ --body:"Lora",Georgia,serif;
34
+ --sans:"Inter",system-ui,sans-serif;
35
+ }
36
+ *{box-sizing:border-box;margin:0;padding:0}
37
+ html,body{background:var(--rule-300);font-family:var(--body);color:var(--text-default);font-weight:400;-webkit-font-smoothing:antialiased}
38
+ a{color:inherit;text-decoration:none}
39
+ .page{width:210mm;min-height:297mm;height:297mm;margin:8mm auto;background:var(--surface-page);position:relative;overflow:hidden;padding:18mm 16mm;page-break-after:always}
40
+ .page.dark{background:var(--surface-deep);color:var(--text-on-dark)}
41
+ .page.dark .muted{color:#B7C8C8}
42
+ @page{size:A4 portrait;margin:0}
43
+ @media print{html,body{background:transparent} .page{margin:0;box-shadow:none;page-break-after:always}}
44
+
45
+ h1,h2,h3,h4{font-family:var(--display);font-weight:500;line-height:1.15;color:inherit}
46
+ h1{font-size:54pt;letter-spacing:-1pt;font-style:italic;font-weight:400}
47
+ h2{font-size:26pt;font-style:italic;font-weight:400;margin-bottom:6mm;letter-spacing:-.3pt}
48
+ h3{font-size:14pt;font-weight:500}
49
+ h4{font-family:var(--sans);font-size:8.5pt;font-weight:600;letter-spacing:2.2pt;text-transform:uppercase;color:var(--text-accent)}
50
+ p{font-size:11pt;line-height:1.65;color:inherit;font-family:var(--body)}
51
+ .muted{color:var(--text-muted);font-size:9.5pt;line-height:1.55;font-family:var(--body)}
52
+ .eyebrow{font-family:var(--sans);font-weight:600;font-size:9pt;letter-spacing:3pt;text-transform:uppercase;color:var(--text-accent)}
53
+ .dropcap::first-letter{font-family:var(--display);font-weight:400;font-style:italic;font-size:5em;line-height:.82;float:left;margin:.05em .12em -.08em 0;color:var(--teal-900);letter-spacing:-.04em}
54
+ .page.dark .dropcap::first-letter{color:var(--gold-500)}
55
+
56
+ .cover-bg{position:absolute;inset:0;background:url(<!-- REPLACE: cover_hero_path -->) center/cover no-repeat}
57
+ .cover-bg::after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,rgba(6,68,68,.55) 0%,rgba(6,68,68,.78) 55%,rgba(4,45,45,.94) 100%)}
58
+ .cover-frame{position:absolute;inset:0;padding:18mm 16mm;display:flex;flex-direction:column;justify-content:space-between;z-index:1}
59
+ .cover-hero h1{color:var(--text-on-dark);max-width:170mm}
60
+ .cover-hero .lede{color:var(--gold-500);font-family:var(--display);font-style:italic;font-weight:400;font-size:16pt;letter-spacing:.2pt;margin-top:6mm}
61
+ .cover-meta{display:grid;grid-template-columns:repeat(4,1fr);gap:8mm;border-top:1px solid rgba(255,255,255,.22);padding-top:8mm}
62
+ .cover-meta dt{font-family:var(--sans);font-weight:600;font-size:8pt;letter-spacing:2pt;text-transform:uppercase;color:var(--gold-500);margin-bottom:3mm}
63
+ .cover-meta dd{font-family:var(--display);font-style:italic;font-weight:500;font-size:15pt;color:var(--text-on-dark)}
64
+ .cover-rule{height:1px;background:var(--gold-500);width:30mm}
65
+
66
+ .doc-head{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:6mm;border-bottom:1px solid var(--rule-300);margin-bottom:8mm}
67
+ .doc-head .breadcrumb{font-family:var(--sans);font-weight:500;font-size:8pt;letter-spacing:2pt;text-transform:uppercase;color:var(--text-muted)}
68
+
69
+ .kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:5mm;margin-bottom:6mm}
70
+ .kpis.cube{grid-template-columns:repeat(2,1fr);grid-template-rows:repeat(2,1fr);gap:4mm;margin-bottom:0;height:100%}
71
+ .kpis.cube .kpi{padding:5mm;display:flex;flex-direction:column;justify-content:space-between}
72
+ .kpi{background:var(--surface-card);border-radius:var(--radius-lg);padding:5mm;border:1px solid var(--rule-300)}
73
+ .kpi .label{font-family:var(--sans);font-weight:600;font-size:7pt;letter-spacing:1.7pt;text-transform:uppercase;color:var(--text-muted);margin-bottom:2mm}
74
+ .kpi .value{font-family:var(--display);font-style:italic;font-weight:500;font-size:22pt;color:var(--text-default);line-height:1;letter-spacing:-.3pt}
75
+ .kpi .sub{font-size:8pt;color:var(--text-muted);margin-top:2mm;font-family:var(--body);font-style:italic}
76
+ .kpi.gold{background:var(--teal-900);color:var(--text-on-dark);border:none}
77
+ .kpi.gold .label{color:var(--gold-500)}
78
+ .kpi.gold .value{color:var(--text-on-dark)}
79
+ .kpi.gold .sub{color:#B7C8C8}
80
+
81
+ .two-col{display:grid;grid-template-columns:1.1fr .9fr;gap:8mm;align-items:start}
82
+ .panel{background:var(--surface-card);border-radius:var(--radius-lg);padding:5.5mm;border:1px solid var(--rule-300)}
83
+ .panel.banded{background:var(--surface-banded)}
84
+
85
+ .dist{display:flex;flex-direction:column;gap:4mm;margin-top:3mm}
86
+ .dist-row{display:grid;grid-template-columns:40mm 1fr 14mm;align-items:center;gap:3mm}
87
+ .dist-row .name{font-size:10pt;color:var(--text-default);font-family:var(--body)}
88
+ .dist-row .pct{font-family:var(--display);font-style:italic;font-weight:500;font-size:12pt;color:var(--text-default);text-align:right}
89
+ .bar{height:5mm;background:var(--paper-75);border-radius:var(--radius-md);overflow:hidden}
90
+ .bar > span{display:block;height:100%;background:var(--teal-900);border-radius:var(--radius-md)}
91
+ .bar > span.gold{background:var(--gold-500)}
92
+
93
+ .summary p{font-size:11pt;line-height:1.7}
94
+ .summary p + p{margin-top:3mm}
95
+
96
+ table.cmp{width:100%;border-collapse:collapse;font-size:10pt;font-family:var(--body)}
97
+ table.cmp th,table.cmp td{padding:2.8mm 2mm;text-align:left;border-bottom:1px solid var(--rule-300)}
98
+ table.cmp th{font-family:var(--sans);font-weight:600;font-size:7.5pt;letter-spacing:1.8pt;text-transform:uppercase;color:var(--text-muted)}
99
+ table.cmp td.num{font-family:var(--display);font-style:italic;font-weight:500;text-align:right;font-size:11.5pt}
100
+ table.cmp tr:last-child td{border-bottom:none}
101
+
102
+ .doc-foot{position:absolute;bottom:9mm;left:16mm;right:16mm;display:flex;justify-content:space-between;font-family:var(--sans);font-weight:500;font-size:8pt;letter-spacing:1.5pt;text-transform:uppercase;color:var(--text-muted)}
103
+ .page.dark .doc-foot{color:#7FA0A0}
104
+
105
+ .pill{display:inline-flex;align-items:center;gap:2.5mm;padding:2mm 5mm;border-radius:50px;background:var(--gold-500);color:var(--text-on-dark);font-family:var(--display);font-style:italic;font-weight:500;font-size:11pt;letter-spacing:.3pt}
106
+ .pill::before{content:"";width:2.2mm;height:2.2mm;border-radius:50%;background:#fff}
107
+ .gauge{display:flex;align-items:center;gap:6mm;margin-top:4mm}
108
+ .gauge-track{flex:1;height:5mm;background:var(--paper-75);border-radius:50px;position:relative;overflow:hidden}
109
+ .gauge-fill{position:absolute;inset:0 50% 0 0;background:linear-gradient(90deg,var(--teal-900),var(--gold-500))}
110
+ .gauge-marker{position:absolute;top:-2mm;width:1.2mm;height:9mm;background:var(--text-default);border-radius:2px}
111
+ .gauge-ends{display:flex;justify-content:space-between;font-family:var(--sans);font-weight:500;font-size:7.5pt;letter-spacing:1.5pt;text-transform:uppercase;color:var(--text-muted);margin-top:2mm}
112
+
113
+ .disclaimer{margin-top:5mm;padding-top:5mm;border-top:1px solid var(--rule-300)}
114
+ .disclaimer h4{margin-bottom:3mm}
115
+ .disclaimer p{font-size:8pt;color:var(--text-muted);line-height:1.55;font-family:var(--body);font-style:italic}
116
+ .disclaimer p + p{margin-top:2.5mm}
117
+
118
+ .wm{display:inline-flex;flex-direction:column;align-items:flex-start;line-height:1;color:var(--teal-900)}
119
+ .wm.light{color:var(--paper-0)}
120
+ .wm-name{font-family:var(--display);font-weight:600;font-style:normal;font-size:36pt;letter-spacing:-1pt;line-height:.9}
121
+ .wm-sub{font-family:var(--sans);font-weight:600;font-size:7pt;letter-spacing:2pt;text-transform:uppercase;margin-top:2mm}
122
+ .wm.small .wm-name{font-size:22pt}
123
+ .wm.small .wm-sub{margin-top:.8mm;font-size:5.5pt;letter-spacing:1.8pt}
124
+
125
+ .full-photo{position:relative;width:calc(100% + 32mm);margin:0 -16mm;height:78mm;overflow:hidden}
126
+ .full-photo img{width:100%;height:100%;object-fit:cover;display:block}
127
+
128
+ .listings{display:flex;flex-direction:column;gap:2.5mm;margin-top:2.5mm}
129
+ .listing{display:grid;grid-template-columns:58mm 1fr;gap:5mm;border:1px solid var(--rule-300);border-radius:var(--radius-xl);overflow:hidden;background:var(--surface-card);transition:background 200ms,border-color 200ms}
130
+ .listing:hover{background:var(--paper-50);border-color:var(--gold-500)}
131
+ .listing .photo{position:relative;aspect-ratio:16/9;background:var(--paper-75)}
132
+ .listing .photo img{width:100%;height:100%;object-fit:cover;display:block}
133
+ .listing .photo .badge{position:absolute;left:2.5mm;top:2.5mm;background:var(--gold-500);color:#fff;font-family:var(--sans);font-weight:600;font-size:6pt;letter-spacing:1.2pt;text-transform:uppercase;padding:.9mm 2.2mm;border-radius:50px}
134
+ .listing .meta{padding:2.5mm 5mm 2.5mm 0;display:flex;flex-direction:column;justify-content:center;gap:.5mm}
135
+ .listing .meta .addr{font-family:var(--display);font-style:italic;font-weight:500;font-size:15pt;color:var(--text-default);line-height:1.1;letter-spacing:-.2pt}
136
+ .listing .meta .spec{font-size:8.5pt;color:var(--text-muted);font-style:italic;line-height:1.3}
137
+ .listing .meta .price{font-family:var(--display);font-style:italic;font-weight:500;font-size:16pt;color:var(--text-default);margin-top:1mm;line-height:1;letter-spacing:-.3pt}
138
+ .listing .meta .price small{font-family:var(--sans);font-weight:600;font-style:normal;font-size:6pt;letter-spacing:1.5pt;text-transform:uppercase;color:var(--text-muted);display:block;margin-bottom:.6mm}
139
+ .listing .meta .kfs{display:flex;flex-wrap:wrap;gap:1.2mm;margin-top:1.5mm}
140
+ .listing .meta .kfs span{font-family:var(--sans);font-weight:500;font-size:6.5pt;letter-spacing:.4pt;color:var(--text-default);background:var(--paper-75);padding:.8mm 2mm;border-radius:50px}
141
+ </style>
142
+ </head>
143
+ <body>
144
+
145
+ <!-- PAGE 1 — COVER -->
146
+ <section class="page dark">
147
+ <div class="cover-bg"></div>
148
+ <div class="cover-frame">
149
+ <div>
150
+ <!-- REPLACE: wordmark_light -->
151
+ </div>
152
+ <div class="cover-hero">
153
+ <div class="cover-rule"></div>
154
+ <h4 style="color:var(--gold-500);margin:6mm 0 4mm">Market Data Sheet</h4>
155
+ <h1><!-- REPLACE: cover_title --></h1>
156
+ <p class="lede">A view of the <!-- REPLACE: location_code --> housing market — <!-- REPLACE: period -->.</p>
157
+ </div>
158
+ <dl class="cover-meta">
159
+ <div><dt>Report</dt><dd>Market Data</dd></div>
160
+ <div><dt>Location</dt><dd><!-- REPLACE: location_code --></dd></div>
161
+ <div><dt>Period</dt><dd><!-- REPLACE: period --></dd></div>
162
+ <div><dt>Status</dt><dd style="color:var(--gold-500)">Live data</dd></div>
163
+ </dl>
164
+ </div>
165
+ <div class="doc-foot" style="z-index:2"><span><!-- REPLACE: brand_name --> · Property Intelligence</span><span>01</span></div>
166
+ </section>
167
+
168
+ <!-- PAGE 2 — SUMMARY + HEADLINE KPIs -->
169
+ <section class="page">
170
+ <header class="doc-head">
171
+ <!-- REPLACE: wordmark_dark -->
172
+ <div class="breadcrumb"><!-- REPLACE: location_name --> · <!-- REPLACE: location_code --> · <!-- REPLACE: period --></div>
173
+ </header>
174
+ <div class="eyebrow">Market summary</div>
175
+ <h2 style="margin-top:3mm"><!-- REPLACE: summary_headline --></h2>
176
+ <div class="summary" style="max-width:170mm"><!-- REPLACE: summary_paragraphs --></div>
177
+ <div style="height:6mm"></div>
178
+ <div class="kpis">
179
+ <div class="kpi gold"><div class="label">Average Asking Price</div><div class="value"><!-- REPLACE: kpi_avg_asking --></div><div class="sub">Current listings — <!-- REPLACE: location_code --></div></div>
180
+ <div class="kpi"><div class="label">Average Sold Price</div><div class="value"><!-- REPLACE: kpi_avg_sold --></div><div class="sub">Completed transactions</div></div>
181
+ <div class="kpi"><div class="label">Price per sq ft</div><div class="value"><!-- REPLACE: kpi_psf --></div><div class="sub">Asking-price basis</div></div>
182
+ </div>
183
+ <div class="two-col" style="align-items:stretch">
184
+ <div class="panel banded" style="display:flex;flex-direction:column">
185
+ <h4>Price growth</h4>
186
+ <div style="display:flex;align-items:baseline;gap:4mm;margin-top:3mm">
187
+ <span style="font-family:var(--display);font-style:italic;font-weight:500;font-size:34pt;color:var(--teal-900);line-height:1;letter-spacing:-.5pt"><!-- REPLACE: kpi_yoy --></span>
188
+ <span class="muted" style="font-style:italic">year on year</span>
189
+ </div>
190
+ <p class="muted" style="margin-top:3mm"><!-- REPLACE: price_growth_note --></p>
191
+ </div>
192
+ <div class="panel" style="display:flex;flex-direction:column">
193
+ <h4>Demand rating</h4>
194
+ <div style="margin-top:3mm"><span class="pill"><!-- REPLACE: demand_rating --></span></div>
195
+ <div class="gauge"><div class="gauge-track"><div class="gauge-fill"></div><div class="gauge-marker" style="left:<!-- REPLACE: gauge_position -->"></div></div></div>
196
+ <div class="gauge-ends"><span>Buyer's market</span><span>Seller's market</span></div>
197
+ <p class="muted" style="margin-top:4mm"><!-- REPLACE: demand_note --></p>
198
+ </div>
199
+ </div>
200
+ <div class="full-photo" style="margin-top:8mm"><img src="<!-- REPLACE: page2_photo -->" alt=""></div>
201
+ <div class="doc-foot"><span><!-- REPLACE: brand_name --> · Property Intelligence</span><span>02</span></div>
202
+ </section>
203
+
204
+ <!-- PAGE 3 — ACTIVITY (2×2 KPI cube + at-a-glance + distribution full-width below) -->
205
+ <section class="page">
206
+ <header class="doc-head">
207
+ <!-- REPLACE: wordmark_dark -->
208
+ <div class="breadcrumb"><!-- REPLACE: location_name --> · <!-- REPLACE: location_code --> · <!-- REPLACE: period --></div>
209
+ </header>
210
+ <div class="eyebrow">Sales volume &amp; activity</div>
211
+ <h2 style="margin-top:3mm;margin-bottom:5mm">How the market is moving.</h2>
212
+ <div class="two-col" style="grid-template-columns:1fr 1fr;gap:6mm;margin-bottom:6mm">
213
+ <div class="kpis cube">
214
+ <div class="kpi"><div class="label">Total for sale</div><div class="value"><!-- REPLACE: kpi_for_sale --></div><div class="sub">Live listings</div></div>
215
+ <div class="kpi"><div class="label">Avg sales / month</div><div class="value"><!-- REPLACE: kpi_sales_per_month --></div><div class="sub">Completions</div></div>
216
+ <div class="kpi"><div class="label">Turnover / month</div><div class="value"><!-- REPLACE: kpi_turnover --></div><div class="sub">Stock churn</div></div>
217
+ <div class="kpi gold"><div class="label">Days on market</div><div class="value"><!-- REPLACE: kpi_dom --></div><div class="sub">Average list-to-sale</div></div>
218
+ </div>
219
+ <div class="panel banded">
220
+ <h4>At a glance</h4>
221
+ <table class="cmp" style="margin-top:3mm">
222
+ <tr><th>Metric</th><th style="text-align:right">Value</th></tr>
223
+ <!-- REPLACE: at_a_glance_rows -->
224
+ </table>
225
+ </div>
226
+ </div>
227
+ <div class="panel">
228
+ <h4>Property type — share of market</h4>
229
+ <p class="muted" style="margin-top:2mm">Distribution across the <!-- REPLACE: n_props -->-property sample.</p>
230
+ <div class="dist"><!-- REPLACE: type_dist_rows --></div>
231
+ </div>
232
+ <div class="doc-foot"><span><!-- REPLACE: brand_name --> · Property Intelligence</span><span>03</span></div>
233
+ </section>
234
+
235
+ <!-- PAGE 4 — LISTINGS (1–5) -->
236
+ <section class="page">
237
+ <header class="doc-head">
238
+ <!-- REPLACE: wordmark_dark -->
239
+ <div class="breadcrumb"><!-- REPLACE: location_name --> · <!-- REPLACE: location_code --> · <!-- REPLACE: period --></div>
240
+ </header>
241
+ <div class="eyebrow">Featured listings · <!-- REPLACE: location_code --></div>
242
+ <h2 style="margin-top:2mm;margin-bottom:4mm">On the market with <!-- REPLACE: brand_name -->.</h2>
243
+ <div class="listings"><!-- REPLACE: listing_cards_page_4 --></div>
244
+ <div class="doc-foot"><span><!-- REPLACE: brand_name --> · Property Intelligence</span><span>04</span></div>
245
+ </section>
246
+
247
+ <!-- PAGE 5 — LISTINGS (6–10) + DISCLAIMER -->
248
+ <section class="page">
249
+ <header class="doc-head">
250
+ <!-- REPLACE: wordmark_dark -->
251
+ <div class="breadcrumb"><!-- REPLACE: location_name --> · <!-- REPLACE: location_code --> · <!-- REPLACE: period --></div>
252
+ </header>
253
+ <div class="eyebrow">Featured listings · <!-- REPLACE: location_code --> — continued</div>
254
+ <h2 style="margin-top:2mm;margin-bottom:4mm">More homes for sale.</h2>
255
+ <div class="listings"><!-- REPLACE: listing_cards_page_5 --></div>
256
+ <div class="disclaimer">
257
+ <h4>Important disclaimer</h4>
258
+ <p><strong>Sources.</strong> ONS House Price Index, HM Land Registry, Rightmove, Zoopla, local estate agents and market research providers, with supporting analysis via the PropertyData API. Property imagery: <!-- REPLACE: brand_name --> via property-socials.</p>
259
+ <p><strong>Accuracy.</strong> Property data can vary and change rapidly. Figures are estimates — obtain independent professional advice for specific decisions.</p>
260
+ <p><strong>Investment.</strong> This report is not financial or investment advice. Past performance does not guarantee future results.</p>
261
+ </div>
262
+ <div class="doc-foot"><span><!-- REPLACE: brand_name --> · Generated <!-- REPLACE: period --> · <!-- REPLACE: location_name --> <!-- REPLACE: location_code --></span><span>05</span></div>
263
+ </section>
264
+
265
+ </body>
266
+ </html>