@honua/app-platform 0.1.1-beta.0 → 0.1.4-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (383) hide show
  1. package/collaboration/client.d.ts.map +1 -1
  2. package/connect-url-safety.d.ts +53 -0
  3. package/connect-url-safety.js +189 -0
  4. package/contract/discovery.d.ts +34 -0
  5. package/contract/discovery.d.ts.map +1 -1
  6. package/contract/index.d.ts +7 -5
  7. package/contract/index.d.ts.map +1 -1
  8. package/contract/index.js +3 -2
  9. package/contract/pmtiles.d.ts +10 -1
  10. package/contract/pmtiles.d.ts.map +1 -1
  11. package/contract/pmtiles.js +10 -2
  12. package/contract/protocol-module.d.ts +84 -9
  13. package/contract/protocol-module.d.ts.map +1 -1
  14. package/contract/query-filter-fes.d.ts +18 -0
  15. package/contract/query-filter-fes.d.ts.map +1 -0
  16. package/contract/query-filter-fes.js +122 -0
  17. package/contract/query-filter.d.ts +300 -0
  18. package/contract/query-filter.d.ts.map +1 -0
  19. package/contract/query-filter.js +883 -0
  20. package/contract/schema.d.ts +12 -0
  21. package/contract/schema.d.ts.map +1 -1
  22. package/contract/schema.js +34 -0
  23. package/contract/source-capability-support.d.ts.map +1 -1
  24. package/contract/source-capability-support.js +15 -3
  25. package/contract/source.d.ts +45 -25
  26. package/contract/source.d.ts.map +1 -1
  27. package/contract/source.js +719 -479
  28. package/contract/types.d.ts +87 -18
  29. package/contract/types.d.ts.map +1 -1
  30. package/contract/widget-source.d.ts.map +1 -1
  31. package/controls/basemap-switcher.d.ts +19 -11
  32. package/controls/basemap-switcher.d.ts.map +1 -1
  33. package/controls/basemap-switcher.js +58 -25
  34. package/controls/catalog.d.ts +105 -0
  35. package/controls/catalog.d.ts.map +1 -0
  36. package/controls/catalog.js +341 -0
  37. package/controls/element-utils.d.ts +9 -0
  38. package/controls/element-utils.d.ts.map +1 -1
  39. package/controls/element-utils.js +17 -0
  40. package/controls/index.d.ts +29 -8
  41. package/controls/index.d.ts.map +1 -1
  42. package/controls/index.js +26 -8
  43. package/controls/layer-list.d.ts +13 -5
  44. package/controls/layer-list.d.ts.map +1 -1
  45. package/controls/layer-list.js +72 -15
  46. package/controls/legend.d.ts +32 -5
  47. package/controls/legend.d.ts.map +1 -1
  48. package/controls/legend.js +95 -25
  49. package/controls/qualification.d.ts +152 -0
  50. package/controls/qualification.d.ts.map +1 -0
  51. package/controls/qualification.js +1265 -0
  52. package/controls/registry.d.ts +104 -0
  53. package/controls/registry.d.ts.map +1 -0
  54. package/controls/registry.js +150 -0
  55. package/controls/swipe-control.d.ts +6 -1
  56. package/controls/swipe-control.d.ts.map +1 -1
  57. package/controls/swipe-control.js +82 -20
  58. package/core/client.d.ts +45 -21
  59. package/core/client.d.ts.map +1 -1
  60. package/core/client.js +224 -46
  61. package/core/error-classifications.d.ts +6 -0
  62. package/core/error-classifications.d.ts.map +1 -1
  63. package/core/error-classifications.js +6 -0
  64. package/core/error-code-registry.d.ts +6 -0
  65. package/core/error-code-registry.d.ts.map +1 -1
  66. package/core/error-code-registry.js +6 -0
  67. package/core/error-envelope.js +2 -0
  68. package/core/errors.d.ts +1 -1
  69. package/core/errors.js +1 -1
  70. package/core/geoparquet-sql.d.ts +68 -4
  71. package/core/geoparquet-sql.d.ts.map +1 -1
  72. package/core/geoparquet-sql.js +325 -8
  73. package/core/odata-write-codec.d.ts.map +1 -1
  74. package/core/odata-write-codec.js +0 -1
  75. package/core/odata.d.ts +6 -0
  76. package/core/odata.d.ts.map +1 -1
  77. package/core/odata.js +6 -1
  78. package/core/ogc-processes.d.ts +182 -25
  79. package/core/ogc-processes.d.ts.map +1 -1
  80. package/core/ogc-processes.js +602 -30
  81. package/core/process-runner.d.ts +8 -1
  82. package/core/process-runner.d.ts.map +1 -1
  83. package/core/process-runner.js +1 -0
  84. package/core/protocol-transport.d.ts +2 -0
  85. package/core/protocol-transport.d.ts.map +1 -1
  86. package/core/stac.d.ts +31 -0
  87. package/core/stac.d.ts.map +1 -1
  88. package/core/stac.js +136 -19
  89. package/core/surfaces.d.ts.map +1 -1
  90. package/core/surfaces.js +273 -68
  91. package/core/types.d.ts +123 -9
  92. package/core/types.d.ts.map +1 -1
  93. package/core/wfs-axis-order.d.ts +18 -0
  94. package/core/wfs-axis-order.d.ts.map +1 -0
  95. package/core/wfs-axis-order.js +108 -0
  96. package/core/wfs-capabilities.d.ts +65 -24
  97. package/core/wfs-capabilities.d.ts.map +1 -1
  98. package/core/wfs-capabilities.js +237 -27
  99. package/core/wfs-filter.d.ts +26 -3
  100. package/core/wfs-filter.d.ts.map +1 -1
  101. package/core/wfs-filter.js +48 -53
  102. package/core/wfs-protocol-error.d.ts +12 -0
  103. package/core/wfs-protocol-error.d.ts.map +1 -0
  104. package/core/wfs-protocol-error.js +13 -0
  105. package/core/wfs.d.ts +53 -1
  106. package/core/wfs.d.ts.map +1 -1
  107. package/core/wfs.js +251 -29
  108. package/core/wms-feature-info.d.ts +92 -0
  109. package/core/wms-feature-info.d.ts.map +1 -0
  110. package/core/wms-feature-info.js +285 -0
  111. package/core/wms.d.ts +9 -0
  112. package/core/wms.d.ts.map +1 -1
  113. package/core/wms.js +17 -5
  114. package/esri-compat/event-bus.d.ts +20 -0
  115. package/esri-compat/event-bus.d.ts.map +1 -1
  116. package/esri-compat/feature-filter.d.ts +46 -1
  117. package/esri-compat/feature-filter.d.ts.map +1 -1
  118. package/esri-compat/feature-filter.js +49 -0
  119. package/esri-compat/geojson-layer.d.ts +4 -2
  120. package/esri-compat/geojson-layer.d.ts.map +1 -1
  121. package/esri-compat/geojson-layer.js +4 -2
  122. package/esri-compat/identity-manager.js +2 -2
  123. package/esri-compat/imagery-layer.d.ts +2 -1
  124. package/esri-compat/imagery-layer.d.ts.map +1 -1
  125. package/esri-compat/imagery-layer.js +2 -1
  126. package/esri-compat/layer-list.d.ts +8 -1
  127. package/esri-compat/layer-list.d.ts.map +1 -1
  128. package/esri-compat/layer-list.js +11 -4
  129. package/esri-compat/legend.d.ts.map +1 -1
  130. package/esri-compat/legend.js +1 -1
  131. package/esri-compat/locator.d.ts +297 -0
  132. package/esri-compat/locator.d.ts.map +1 -0
  133. package/esri-compat/locator.js +579 -0
  134. package/esri-compat/map-image-layer.d.ts.map +1 -1
  135. package/esri-compat/map-view.d.ts.map +1 -1
  136. package/esri-compat/map-view.js +1 -1
  137. package/esri-compat/scene-view.d.ts.map +1 -1
  138. package/esri-compat/time-slider.d.ts +29 -0
  139. package/esri-compat/time-slider.d.ts.map +1 -1
  140. package/esri-compat/time-slider.js +117 -0
  141. package/esri-compat/wfs-layer.d.ts +2 -1
  142. package/esri-compat/wfs-layer.d.ts.map +1 -1
  143. package/esri-compat/wfs-layer.js +2 -1
  144. package/esri-compat/widget-host.d.ts +42 -6
  145. package/esri-compat/widget-host.d.ts.map +1 -1
  146. package/esri-compat/widget-host.js +99 -10
  147. package/generated-app/projection.d.ts.map +1 -1
  148. package/generated-app/runtime.d.ts.map +1 -1
  149. package/map/data-to-map-bridge.d.ts +1 -1
  150. package/map/data-to-map-bridge.d.ts.map +1 -1
  151. package/map/data-to-map-bridge.js +1 -2
  152. package/map/honua-map.d.ts.map +1 -1
  153. package/map/honua-map.js +1 -1
  154. package/map/index.d.ts +2 -0
  155. package/map/index.d.ts.map +1 -1
  156. package/map/index.js +2 -0
  157. package/map/realtime-reconciliation-adapter.d.ts +98 -0
  158. package/map/realtime-reconciliation-adapter.d.ts.map +1 -0
  159. package/map/realtime-reconciliation-adapter.js +84 -0
  160. package/map/temporal-playback.d.ts +25 -0
  161. package/map/temporal-playback.d.ts.map +1 -1
  162. package/map/temporal-playback.js +29 -1
  163. package/operator/workspace/workspace.d.ts.map +1 -1
  164. package/operator/workspace/workspace.js +0 -2
  165. package/package.json +4 -3
  166. package/query-planner/canonical-filter.d.ts +30 -0
  167. package/query-planner/canonical-filter.d.ts.map +1 -0
  168. package/query-planner/canonical-filter.js +30 -0
  169. package/query-planner/columnar-plan.d.ts +63 -0
  170. package/query-planner/columnar-plan.d.ts.map +1 -0
  171. package/query-planner/columnar-plan.js +94 -0
  172. package/query-planner/diagnostics.d.ts +3 -3
  173. package/query-planner/diagnostics.d.ts.map +1 -1
  174. package/query-planner/diagnostics.js +17 -4
  175. package/query-planner/duckdb.d.ts.map +1 -1
  176. package/query-planner/duckdb.js +16 -2
  177. package/query-planner/execution-context.d.ts.map +1 -1
  178. package/query-planner/execution-context.js +3 -1
  179. package/query-planner/executor.d.ts.map +1 -1
  180. package/query-planner/executor.js +7 -1
  181. package/query-planner/geoservices.d.ts.map +1 -1
  182. package/query-planner/geoservices.js +24 -5
  183. package/query-planner/grpc.d.ts.map +1 -1
  184. package/query-planner/grpc.js +29 -5
  185. package/query-planner/index.d.ts +20 -5
  186. package/query-planner/index.d.ts.map +1 -1
  187. package/query-planner/index.js +16 -3
  188. package/query-planner/ir.d.ts +6 -1
  189. package/query-planner/ir.d.ts.map +1 -1
  190. package/query-planner/ir.js +63 -1
  191. package/query-planner/odata-protocol-module.d.ts +54 -0
  192. package/query-planner/odata-protocol-module.d.ts.map +1 -0
  193. package/query-planner/odata-protocol-module.js +199 -0
  194. package/query-planner/odata-v1.d.ts +23 -0
  195. package/query-planner/odata-v1.d.ts.map +1 -0
  196. package/query-planner/odata-v1.js +202 -0
  197. package/query-planner/odata.d.ts +0 -3
  198. package/query-planner/odata.d.ts.map +1 -1
  199. package/query-planner/odata.js +0 -143
  200. package/query-planner/ogc-features.d.ts.map +1 -1
  201. package/query-planner/ogc-features.js +23 -1
  202. package/query-planner/planner.d.ts +2 -2
  203. package/query-planner/planner.d.ts.map +1 -1
  204. package/query-planner/planner.js +146 -27
  205. package/query-planner/representation.d.ts +59 -0
  206. package/query-planner/representation.d.ts.map +1 -0
  207. package/query-planner/representation.js +140 -0
  208. package/query-planner/types.d.ts +153 -3
  209. package/query-planner/types.d.ts.map +1 -1
  210. package/query-planner/wfs-protocol-module.d.ts +57 -0
  211. package/query-planner/wfs-protocol-module.d.ts.map +1 -0
  212. package/query-planner/wfs-protocol-module.js +731 -0
  213. package/query-planner/wfs-v1.d.ts +41 -0
  214. package/query-planner/wfs-v1.d.ts.map +1 -0
  215. package/query-planner/wfs-v1.js +366 -0
  216. package/query-planner/wfs.d.ts +1 -3
  217. package/query-planner/wfs.d.ts.map +1 -1
  218. package/query-planner/wfs.js +1 -70
  219. package/realtime/checkpoint-store.d.ts +206 -0
  220. package/realtime/checkpoint-store.d.ts.map +1 -0
  221. package/realtime/checkpoint-store.js +504 -0
  222. package/realtime/honua-server.d.ts +8 -0
  223. package/realtime/honua-server.d.ts.map +1 -1
  224. package/realtime/honua-server.js +123 -14
  225. package/realtime/index.d.ts +4 -0
  226. package/realtime/index.d.ts.map +1 -1
  227. package/realtime/index.js +2 -0
  228. package/realtime/reconciliation.d.ts +245 -0
  229. package/realtime/reconciliation.d.ts.map +1 -0
  230. package/realtime/reconciliation.js +377 -0
  231. package/realtime/resumable-transport.d.ts.map +1 -1
  232. package/realtime/sse.d.ts.map +1 -1
  233. package/realtime/websocket.d.ts.map +1 -1
  234. package/realtime/wire.d.ts +2 -1
  235. package/realtime/wire.d.ts.map +1 -1
  236. package/realtime/wire.js +24 -8
  237. package/replica-sync/errors.d.ts +19 -1
  238. package/replica-sync/errors.d.ts.map +1 -1
  239. package/replica-sync/errors.js +16 -0
  240. package/replica-sync/geoservices.d.ts +260 -0
  241. package/replica-sync/geoservices.d.ts.map +1 -0
  242. package/replica-sync/geoservices.js +1093 -0
  243. package/replica-sync/index.d.ts +5 -1
  244. package/replica-sync/index.d.ts.map +1 -1
  245. package/replica-sync/index.js +3 -1
  246. package/replica-sync/transport-conformance.d.ts +58 -0
  247. package/replica-sync/transport-conformance.d.ts.map +1 -0
  248. package/replica-sync/transport-conformance.js +265 -0
  249. package/replica-sync/types.d.ts +22 -0
  250. package/replica-sync/types.d.ts.map +1 -1
  251. package/runtime/index.d.ts +6 -2
  252. package/runtime/index.d.ts.map +1 -1
  253. package/runtime/index.js +3 -1
  254. package/runtime/load-package.d.ts +24 -0
  255. package/runtime/load-package.d.ts.map +1 -1
  256. package/runtime/load-package.js +45 -1
  257. package/runtime/maplibre-renderer.d.ts +11 -0
  258. package/runtime/maplibre-renderer.d.ts.map +1 -1
  259. package/runtime/maplibre-renderer.js +32 -7
  260. package/runtime/offline-region-protocol.d.ts +73 -0
  261. package/runtime/offline-region-protocol.d.ts.map +1 -0
  262. package/runtime/offline-region-protocol.js +63 -0
  263. package/runtime/offline-region-style.d.ts +78 -0
  264. package/runtime/offline-region-style.d.ts.map +1 -0
  265. package/runtime/offline-region-style.js +147 -0
  266. package/runtime/pmtiles-protocol.d.ts +7 -8
  267. package/runtime/pmtiles-protocol.d.ts.map +1 -1
  268. package/runtime/pmtiles-protocol.js +19 -66
  269. package/runtime/protocol-registry.d.ts +55 -0
  270. package/runtime/protocol-registry.d.ts.map +1 -0
  271. package/runtime/protocol-registry.js +103 -0
  272. package/runtime/runtime.d.ts +2 -0
  273. package/runtime/runtime.d.ts.map +1 -1
  274. package/runtime/runtime.js +6 -1
  275. package/scene-workspace/analysis-widgets.d.ts +4 -2
  276. package/scene-workspace/analysis-widgets.d.ts.map +1 -1
  277. package/scene-workspace/analysis-widgets.js +4 -2
  278. package/scene-workspace/camera-correspondence.d.ts +151 -0
  279. package/scene-workspace/camera-correspondence.d.ts.map +1 -0
  280. package/scene-workspace/camera-correspondence.js +220 -0
  281. package/scene-workspace/cesium-adapter.d.ts +230 -29
  282. package/scene-workspace/cesium-adapter.d.ts.map +1 -1
  283. package/scene-workspace/cesium-adapter.js +741 -56
  284. package/scene-workspace/cesium-mount.d.ts +205 -0
  285. package/scene-workspace/cesium-mount.d.ts.map +1 -0
  286. package/scene-workspace/cesium-mount.js +392 -0
  287. package/scene-workspace/cesium-scene-owner.d.ts +160 -0
  288. package/scene-workspace/cesium-scene-owner.d.ts.map +1 -0
  289. package/scene-workspace/cesium-scene-owner.js +205 -0
  290. package/scene-workspace/cesium-state-sync.d.ts +101 -0
  291. package/scene-workspace/cesium-state-sync.d.ts.map +1 -0
  292. package/scene-workspace/cesium-state-sync.js +432 -0
  293. package/scene-workspace/cesium-time.d.ts +346 -0
  294. package/scene-workspace/cesium-time.d.ts.map +1 -0
  295. package/scene-workspace/cesium-time.js +403 -0
  296. package/scene-workspace/index.d.ts +42 -10
  297. package/scene-workspace/index.d.ts.map +1 -1
  298. package/scene-workspace/index.js +32 -7
  299. package/scene-workspace/maplibre-state-sync.d.ts +112 -0
  300. package/scene-workspace/maplibre-state-sync.d.ts.map +1 -0
  301. package/scene-workspace/maplibre-state-sync.js +400 -0
  302. package/scene-workspace/primitives.d.ts +237 -3
  303. package/scene-workspace/primitives.d.ts.map +1 -1
  304. package/scene-workspace/primitives.js +1087 -17
  305. package/scene-workspace/scene-discovery.d.ts +5 -2
  306. package/scene-workspace/scene-discovery.d.ts.map +1 -1
  307. package/scene-workspace/scene-discovery.js +5 -2
  308. package/scene-workspace/scene-view.d.ts +5 -2
  309. package/scene-workspace/scene-view.d.ts.map +1 -1
  310. package/scene-workspace/scene-view.js +5 -2
  311. package/scene-workspace/source-to-cesium.d.ts +141 -8
  312. package/scene-workspace/source-to-cesium.d.ts.map +1 -1
  313. package/scene-workspace/source-to-cesium.js +497 -72
  314. package/scene-workspace/state-sync-port-kit.d.ts +156 -0
  315. package/scene-workspace/state-sync-port-kit.d.ts.map +1 -0
  316. package/scene-workspace/state-sync-port-kit.js +319 -0
  317. package/scene-workspace/state-sync.d.ts +34 -1
  318. package/scene-workspace/state-sync.d.ts.map +1 -1
  319. package/scene-workspace/state-sync.js +48 -0
  320. package/scene-workspace/types.d.ts +8 -0
  321. package/scene-workspace/types.d.ts.map +1 -1
  322. package/scene-workspace/types.js +2 -0
  323. package/scene-workspace/workspace.d.ts +12 -1
  324. package/scene-workspace/workspace.d.ts.map +1 -1
  325. package/scene-workspace/workspace.js +13 -0
  326. package/studio/index.d.ts +24 -7
  327. package/studio/index.d.ts.map +1 -1
  328. package/studio/index.js +21 -7
  329. package/studio/lifecycle-client.d.ts +164 -0
  330. package/studio/lifecycle-client.d.ts.map +1 -0
  331. package/studio/lifecycle-client.js +299 -0
  332. package/studio/lifecycle-errors.d.ts +91 -0
  333. package/studio/lifecycle-errors.d.ts.map +1 -0
  334. package/studio/lifecycle-errors.js +77 -0
  335. package/studio/lifecycle-types.d.ts +321 -0
  336. package/studio/lifecycle-types.d.ts.map +1 -0
  337. package/studio/lifecycle-types.js +22 -0
  338. package/web-components/controller.d.ts.map +1 -1
  339. package/web-components/csp-styles.d.ts +6 -0
  340. package/web-components/csp-styles.d.ts.map +1 -0
  341. package/web-components/csp-styles.js +53 -0
  342. package/web-components/elements.d.ts +158 -2
  343. package/web-components/elements.d.ts.map +1 -1
  344. package/web-components/elements.js +1302 -215
  345. package/web-components/export-redaction.d.ts +252 -0
  346. package/web-components/export-redaction.d.ts.map +1 -0
  347. package/web-components/export-redaction.js +588 -0
  348. package/web-components/export.d.ts +417 -0
  349. package/web-components/export.d.ts.map +1 -0
  350. package/web-components/export.js +719 -0
  351. package/web-components/feature-editor-model.d.ts +265 -0
  352. package/web-components/feature-editor-model.d.ts.map +1 -0
  353. package/web-components/feature-editor-model.js +438 -0
  354. package/web-components/feature-editor-sketch.d.ts +125 -0
  355. package/web-components/feature-editor-sketch.d.ts.map +1 -0
  356. package/web-components/feature-editor-sketch.js +194 -0
  357. package/web-components/feature-editor-workflow.d.ts +286 -0
  358. package/web-components/feature-editor-workflow.d.ts.map +1 -0
  359. package/web-components/feature-editor-workflow.js +889 -0
  360. package/web-components/feature-editor.d.ts +83 -0
  361. package/web-components/feature-editor.d.ts.map +1 -0
  362. package/web-components/feature-editor.js +829 -0
  363. package/web-components/feature-table-engine.d.ts +465 -0
  364. package/web-components/feature-table-engine.d.ts.map +1 -0
  365. package/web-components/feature-table-engine.js +1360 -0
  366. package/web-components/feature-table-view.d.ts +95 -0
  367. package/web-components/feature-table-view.d.ts.map +1 -0
  368. package/web-components/feature-table-view.js +343 -0
  369. package/web-components/index.d.ts +34 -3
  370. package/web-components/index.d.ts.map +1 -1
  371. package/web-components/index.js +35 -2
  372. package/web-components/maplibre-renderer.d.ts.map +1 -1
  373. package/web-components/maplibre-renderer.js +13 -4
  374. package/web-components/measurement.d.ts +3 -1
  375. package/web-components/measurement.d.ts.map +1 -1
  376. package/web-components/measurement.js +62 -18
  377. package/web-components/time-slider.d.ts +122 -0
  378. package/web-components/time-slider.d.ts.map +1 -0
  379. package/web-components/time-slider.js +740 -0
  380. package/web-components/types.d.ts +310 -0
  381. package/web-components/types.d.ts.map +1 -1
  382. package/webmap/convert-label.js +0 -1
  383. package/webmap/convert-symbol.d.ts.map +1 -1
@@ -1,6 +1,12 @@
1
1
  import { createHonuaWebComponentController } from "./controller.js";
2
+ import { renderCspSafeShadowHtml } from "./csp-styles.js";
3
+ import { redactHonuaExportText } from "./export-redaction.js";
4
+ import { HONUA_EXPORT_KINDS, approximateHonuaScaleLabel, createBrowserPrintExportAdapter, runHonuaExport, } from "./export.js";
5
+ import { HonuaFeatureEditorElement, } from "./feature-editor.js";
6
+ import { featureTableFocusMoveForKey, featureTableGridHtml, featureTableGridStyles, featureTableViewModel, legacyFeatureTableViewModel, } from "./feature-table-view.js";
2
7
  import { HonuaMapLibreRenderer } from "./maplibre-renderer.js";
3
8
  import { HonuaMeasurementElement } from "./measurement.js";
9
+ import { HonuaTimeSliderElement } from "./time-slider.js";
4
10
  const globalDom = globalThis;
5
11
  const HTMLElementBase = globalDom.HTMLElement ?? class {
6
12
  };
@@ -8,6 +14,7 @@ class HonuaElementBase extends HTMLElementBase {
8
14
  #controller;
9
15
  #unsubscribe;
10
16
  #controllerReadyListener;
17
+ #controllerReadyTarget;
11
18
  state;
12
19
  get controller() {
13
20
  return this.#controller;
@@ -39,8 +46,9 @@ class HonuaElementBase extends HTMLElementBase {
39
46
  this.#unsubscribe?.remove();
40
47
  this.#unsubscribe = undefined;
41
48
  if (this.#controllerReadyListener) {
42
- this.getRootEventTarget()?.removeEventListener("honua-controller-ready", this.#controllerReadyListener);
49
+ this.#controllerReadyTarget?.removeEventListener("honua-controller-ready", this.#controllerReadyListener);
43
50
  this.#controllerReadyListener = undefined;
51
+ this.#controllerReadyTarget = undefined;
44
52
  }
45
53
  }
46
54
  controllerChanged(_controller) { }
@@ -54,7 +62,7 @@ class HonuaElementBase extends HTMLElementBase {
54
62
  this.ensureShadowRoot();
55
63
  const root = this.shadowRoot ?? this;
56
64
  const focus = captureFocus(root);
57
- root.innerHTML = html;
65
+ renderCspSafeShadowHtml(root, html);
58
66
  restoreFocus(root, focus);
59
67
  }
60
68
  resolveControllerFromContext() {
@@ -77,6 +85,7 @@ class HonuaElementBase extends HTMLElementBase {
77
85
  const target = this.getRootEventTarget();
78
86
  if (!target)
79
87
  return;
88
+ this.#controllerReadyTarget = target;
80
89
  this.#controllerReadyListener = (event) => {
81
90
  const detail = event.detail;
82
91
  if (!detail?.controller)
@@ -113,6 +122,14 @@ export class HonuaMapElement extends HonuaElementBase {
113
122
  #packageLoadToken = 0;
114
123
  #packageLoading = false;
115
124
  #packageLoadError;
125
+ #messages = {};
126
+ get messages() {
127
+ return this.#messages;
128
+ }
129
+ set messages(messages) {
130
+ this.#messages = messages ?? {};
131
+ this.render();
132
+ }
116
133
  get map() {
117
134
  return this.#renderer?.map;
118
135
  }
@@ -180,10 +197,11 @@ export class HonuaMapElement extends HonuaElementBase {
180
197
  }
181
198
  catch {
182
199
  this.#packageLoading = false;
183
- this.#packageLoadError = "Invalid map-package JSON.";
200
+ const message = this.#messages.invalidPackage ?? "Invalid map-package JSON.";
201
+ this.#packageLoadError = message;
184
202
  this.dispatchTypedEvent("honua-map-error", {
185
- error: new Error("Invalid map-package JSON."),
186
- message: "Invalid map-package JSON.",
203
+ error: new Error(message),
204
+ message,
187
205
  });
188
206
  }
189
207
  }
@@ -220,6 +238,7 @@ export class HonuaMapElement extends HonuaElementBase {
220
238
  const viewport = state?.viewport ?? {};
221
239
  const visibleLayers = layers.filter((layer) => layer.visible);
222
240
  const label = this.getAttribute("label") ?? state?.mapPackage?.mapPackageId ?? "Honua map";
241
+ const messages = this.#messages;
223
242
  const root = this.shadowRoot ?? this;
224
243
  if (!root.querySelector?.(".map")) {
225
244
  this.setShadowHtml(`
@@ -227,10 +246,10 @@ export class HonuaMapElement extends HonuaElementBase {
227
246
  <section part="map" class="map" role="region" aria-label="${escapeHtml(label)}" tabindex="0">
228
247
  <div class="map__chrome">
229
248
  <div class="map__title"></div>
230
- <div class="map__controls" aria-label="Map controls">
231
- <button type="button" class="icon-button" data-zoom="out" aria-label="Zoom out">-</button>
232
- <output class="zoom" aria-label="Zoom">0</output>
233
- <button type="button" class="icon-button" data-zoom="in" aria-label="Zoom in">+</button>
249
+ <div class="map__controls" aria-label="${escapeHtml(messages.controlsLabel ?? "Map controls")}">
250
+ <button type="button" class="icon-button" data-zoom="out" aria-label="${escapeHtml(messages.zoomOutLabel ?? "Zoom out")}">-</button>
251
+ <output class="zoom" aria-label="${escapeHtml(messages.zoomLabel ?? "Zoom")}">0</output>
252
+ <button type="button" class="icon-button" data-zoom="in" aria-label="${escapeHtml(messages.zoomInLabel ?? "Zoom in")}">+</button>
234
253
  </div>
235
254
  </div>
236
255
  <div class="map__canvas" part="canvas">
@@ -238,8 +257,8 @@ export class HonuaMapElement extends HonuaElementBase {
238
257
  <div class="map__status" aria-live="polite"></div>
239
258
  </div>
240
259
  <div class="map__footer">
241
- <span data-visible-layers>0 visible</span>
242
- <span data-center>No center</span>
260
+ <span data-visible-layers>${escapeHtml(formatMapVisibleLayers(messages.visibleLayers, 0))}</span>
261
+ <span data-center>${escapeHtml(messages.noCenter ?? "No center")}</span>
243
262
  </div>
244
263
  </section>
245
264
  `);
@@ -249,8 +268,8 @@ export class HonuaMapElement extends HonuaElementBase {
249
268
  nextRoot.querySelector(".map")?.setAttribute("aria-label", label);
250
269
  setText(nextRoot.querySelector(".map__title"), label);
251
270
  setText(nextRoot.querySelector(".zoom"), String(viewport.zoom?.toFixed?.(1) ?? viewport.zoom ?? 0));
252
- setText(nextRoot.querySelector("[data-visible-layers]"), `${String(visibleLayers.length)} visible`);
253
- setText(nextRoot.querySelector("[data-center]"), viewport.center ? viewport.center.join(", ") : "No center");
271
+ setText(nextRoot.querySelector("[data-visible-layers]"), formatMapVisibleLayers(messages.visibleLayers, visibleLayers.length));
272
+ setText(nextRoot.querySelector("[data-center]"), viewport.center ? viewport.center.join(", ") : (messages.noCenter ?? "No center"));
254
273
  setText(nextRoot.querySelector(".map__status"), this.mapStatusText(state));
255
274
  void this.syncRenderer();
256
275
  }
@@ -345,8 +364,8 @@ export class HonuaMapElement extends HonuaElementBase {
345
364
  if (state?.mapPackage)
346
365
  return "";
347
366
  if (this.#packageLoading || this.#packageUrl)
348
- return "Loading map package";
349
- return "No map package";
367
+ return this.#messages.loadingPackage ?? "Loading map package";
368
+ return this.#messages.noPackage ?? "No map package";
350
369
  }
351
370
  }
352
371
  /**
@@ -371,6 +390,14 @@ export class HonuaLayerListElement extends HonuaElementBase {
371
390
  }
372
391
  #dragLayerId;
373
392
  #layersOverride;
393
+ #messages = {};
394
+ get messages() {
395
+ return this.#messages;
396
+ }
397
+ set messages(messages) {
398
+ this.#messages = messages ?? {};
399
+ this.render();
400
+ }
374
401
  /**
375
402
  * The layer rows currently rendered. Headless hosts (for example the
376
403
  * esri-compat LayerList shim delegating through `HonuaWidgetHost`) can
@@ -394,7 +421,7 @@ export class HonuaLayerListElement extends HonuaElementBase {
394
421
  }
395
422
  render() {
396
423
  const layers = this.layers;
397
- const label = this.getAttribute("label") ?? "Layers";
424
+ const label = this.#messages.label ?? this.getAttribute("label") ?? "Layers";
398
425
  const supportsOpacity = typeof this.controller?.setLayerOpacity === "function";
399
426
  const supportsReorder = typeof this.controller?.moveLayer === "function";
400
427
  this.setShadowHtml(`
@@ -403,7 +430,7 @@ export class HonuaLayerListElement extends HonuaElementBase {
403
430
  <legend>${escapeHtml(label)}</legend>
404
431
  <div class="stack" role="list">
405
432
  ${layers.length === 0
406
- ? `<p class="empty">No layers</p>`
433
+ ? `<p class="empty">${escapeHtml(this.#messages.noLayers ?? "No layers")}</p>`
407
434
  : layers
408
435
  .map((layer, index) => this.renderRow(layer, index, layers.length, supportsOpacity, supportsReorder))
409
436
  .join("")}
@@ -428,15 +455,15 @@ export class HonuaLayerListElement extends HonuaElementBase {
428
455
  max="100"
429
456
  step="1"
430
457
  value="${opacityPercent}"
431
- aria-label="Opacity"
432
- aria-valuetext="${opacityPercent}%"
458
+ aria-label="${escapeAttribute(this.#messages.opacityLabel ?? "Opacity")}"
459
+ aria-valuetext="${escapeAttribute(formatLayerOpacityValue(this.#messages.opacityValue, opacityPercent))}"
433
460
  data-layer-opacity="${id}"
434
461
  />`
435
462
  : "";
436
463
  const reorder = supportsReorder
437
464
  ? `
438
- <button type="button" class="move" part="move-up" aria-label="Move up" data-move="up:${id}" ${index === 0 ? "disabled" : ""}>&#9650;</button>
439
- <button type="button" class="move" part="move-down" aria-label="Move down" data-move="down:${id}" ${index === count - 1 ? "disabled" : ""}>&#9660;</button>`
465
+ <button type="button" class="move" part="move-up" aria-label="${escapeAttribute(this.#messages.moveUpLabel ?? "Move up")}" data-move="up:${id}" ${index === 0 ? "disabled" : ""}>&#9650;</button>
466
+ <button type="button" class="move" part="move-down" aria-label="${escapeAttribute(this.#messages.moveDownLabel ?? "Move down")}" data-move="down:${id}" ${index === count - 1 ? "disabled" : ""}>&#9660;</button>`
440
467
  : "";
441
468
  return `
442
469
  <div class="layer-row" role="listitem" part="row" data-layer-row="${id}"${supportsReorder ? ` draggable="true"` : ""}>
@@ -563,6 +590,14 @@ export class HonuaLegendElement extends HonuaElementBase {
563
590
  return ["for", "label", "include-hidden"];
564
591
  }
565
592
  #itemsOverride;
593
+ #messages = {};
594
+ get messages() {
595
+ return this.#messages;
596
+ }
597
+ set messages(messages) {
598
+ this.#messages = messages ?? {};
599
+ this.render();
600
+ }
566
601
  /**
567
602
  * Explicit legend items. When set, these render instead of the controller
568
603
  * state's legend (visibility coupling still applies when the controller is
@@ -598,20 +633,23 @@ export class HonuaLegendElement extends HonuaElementBase {
598
633
  }
599
634
  render() {
600
635
  const legend = this.visibleItems;
601
- const label = this.getAttribute("label") ?? "Legend";
636
+ const label = this.#messages.label ?? this.getAttribute("label") ?? "Legend";
637
+ const swatchStyles = legend
638
+ .map((item, index) => `.swatch-${index} { --swatch: ${escapeAttribute(item.color ?? "#64748b")}; }`)
639
+ .join("\n");
602
640
  this.setShadowHtml(`
603
- <style>${baseStyles()}${listStyles()}</style>
641
+ <style>${baseStyles()}${listStyles()}${legendStyles()}${swatchStyles}</style>
604
642
  <section class="panel" part="panel" aria-label="${escapeHtml(label)}">
605
643
  <h2>${escapeHtml(label)}</h2>
606
644
  <ul class="legend" role="list">
607
645
  ${legend.length === 0
608
- ? `<li class="empty">No legend</li>`
646
+ ? `<li class="empty">${escapeHtml(this.#messages.noLegend ?? "No legend")}</li>`
609
647
  : legend
610
- .map((item) => `
648
+ .map((item, index) => `
611
649
  <li role="listitem" data-legend-item="${escapeAttribute(item.id)}">
612
650
  ${item.iconUrl
613
651
  ? `<img class="swatch" alt="" aria-hidden="true" src="${escapeAttribute(item.iconUrl)}" />`
614
- : `<span class="swatch" aria-hidden="true" style="--swatch:${escapeAttribute(item.color ?? "#64748b")}"></span>`}
652
+ : `<span class="swatch swatch-${index}" aria-hidden="true"></span>`}
615
653
  <span>${escapeHtml(item.label)}</span>
616
654
  </li>
617
655
  `)
@@ -621,12 +659,86 @@ export class HonuaLegendElement extends HonuaElementBase {
621
659
  `);
622
660
  }
623
661
  }
662
+ /**
663
+ * `<honua-feature-table>` — the production feature grid (issue #681).
664
+ *
665
+ * Two lanes, both rendering the same accessible WAI-ARIA `grid`:
666
+ *
667
+ * - **Bounded lane** (recommended for operational apps) — assign the `table`
668
+ * property a {@link HonuaFeatureTable} from
669
+ * {@link createHonuaFeatureTable}. The element then renders the engine's
670
+ * virtualized window, drives remote paging from real scroll geometry, pushes
671
+ * header clicks into multi-column sort, exposes total-known / estimated /
672
+ * partial / stale / loading / cancelled / unsupported / error truth, and
673
+ * announces realtime reconciliation conflicts in a polite live region.
674
+ * - **Controller lane** (unchanged, backwards compatible) — with no engine
675
+ * attached the element queries the shared controller for a single bounded
676
+ * page, exactly as before, and renders it through the same grid markup so the
677
+ * keyboard and screen-reader contract does not depend on which lane is in use.
678
+ *
679
+ * Keyboard: arrow keys move the focused cell, `Home`/`End` jump within the row
680
+ * (`Ctrl`/`Cmd` to the grid), `PageUp`/`PageDown` move a window at a time —
681
+ * loading the page the focus lands on in the bounded lane — and
682
+ * `Enter`/`Space` selects the focused row. Exactly one cell is tabbable
683
+ * (roving `tabindex`).
684
+ */
624
685
  export class HonuaFeatureTableElement extends HonuaElementBase {
625
686
  static get observedAttributes() {
626
- return ["for", "source", "fields", "page-size", "filter-text", "label"];
687
+ return ["for", "source", "fields", "page-size", "filter-text", "label", "row-height"];
627
688
  }
628
689
  #model;
629
690
  #refreshToken = 0;
691
+ #table;
692
+ #tableUnsubscribe;
693
+ #tableSnapshot;
694
+ #announcedConflicts = "";
695
+ #tableConnected = false;
696
+ #restoringScroll = false;
697
+ #messages = {};
698
+ get messages() {
699
+ return this.#messages;
700
+ }
701
+ set messages(messages) {
702
+ this.#messages = messages ?? {};
703
+ this.render();
704
+ }
705
+ /**
706
+ * The bounded query engine backing this grid. Assigning one switches the
707
+ * element to the bounded lane; assigning `undefined` restores the
708
+ * controller-driven single-page lane.
709
+ */
710
+ get table() {
711
+ return this.#table;
712
+ }
713
+ set table(table) {
714
+ if (this.#table === table) {
715
+ // Re-assigning the same engine must still be able to re-take a
716
+ // subscription that `disconnectedCallback()` dropped.
717
+ this.#subscribeTable();
718
+ return;
719
+ }
720
+ this.#tableUnsubscribe?.();
721
+ this.#tableUnsubscribe = undefined;
722
+ this.#table = table;
723
+ this.#tableSnapshot = table?.snapshot;
724
+ // Only hold a subscription while connected; `connectedCallback()` re-takes
725
+ // it so a detached-then-reinserted grid resumes updating.
726
+ this.#subscribeTable();
727
+ this.render();
728
+ }
729
+ /** Takes the engine subscription, unless one is already held or we are detached. */
730
+ #subscribeTable() {
731
+ const table = this.#table;
732
+ if (!table || this.#tableUnsubscribe || !this.#tableConnected)
733
+ return;
734
+ // The engine may have moved on while detached; re-read before listening.
735
+ this.#tableSnapshot = table.snapshot;
736
+ this.#tableUnsubscribe = table.subscribe((snapshot) => {
737
+ this.#tableSnapshot = snapshot;
738
+ this.render();
739
+ this.#announceConflicts(snapshot);
740
+ });
741
+ }
630
742
  attributeChangedCallback(name, _oldValue, newValue) {
631
743
  this.resolveControllerFromContext();
632
744
  if (name === "filter-text") {
@@ -640,13 +752,40 @@ export class HonuaFeatureTableElement extends HonuaElementBase {
640
752
  void this.refresh();
641
753
  }
642
754
  connectedCallback() {
755
+ this.#tableConnected = true;
643
756
  super.connectedCallback();
757
+ this.#subscribeTable();
758
+ if (this.#table) {
759
+ // Reconnect must not re-query: the engine already holds bounded pages, and
760
+ // re-taking the subscription plus a render is enough to resume.
761
+ this.render();
762
+ return;
763
+ }
644
764
  void this.refresh();
645
765
  }
766
+ disconnectedCallback() {
767
+ super.disconnectedCallback();
768
+ this.#tableConnected = false;
769
+ this.#tableUnsubscribe?.();
770
+ this.#tableUnsubscribe = undefined;
771
+ }
646
772
  stateChanged() {
773
+ if (this.#table) {
774
+ // Selection is owned by the engine (and, through it, shared exploration
775
+ // state) in the bounded lane — a controller state change must not trigger
776
+ // an unbounded re-query.
777
+ this.render();
778
+ return;
779
+ }
647
780
  void this.refresh();
648
781
  }
649
782
  async refresh() {
783
+ if (this.#table) {
784
+ const snapshot = await this.#table.refresh();
785
+ this.#tableSnapshot = snapshot;
786
+ this.render();
787
+ return featureTableModelFromSnapshot(snapshot);
788
+ }
650
789
  const controller = this.controller;
651
790
  if (!controller)
652
791
  return undefined;
@@ -662,53 +801,202 @@ export class HonuaFeatureTableElement extends HonuaElementBase {
662
801
  return model;
663
802
  }
664
803
  render() {
665
- const state = this.state;
666
- const model = this.#model ?? tableModelFromState(state, this.sourceId(), this.fields(), this.pageSize());
667
- const selectedId = state?.selection?.featureId;
668
- const label = this.getAttribute("label") ?? "Features";
669
- this.setShadowHtml(`
670
- <style>${baseStyles()}${tableStyles()}</style>
671
- <section class="table-panel" part="panel" aria-label="${escapeHtml(label)}">
672
- <div class="table-panel__bar">
673
- <h2>${escapeHtml(label)}</h2>
674
- <span>${escapeHtml(String(model.totalCount))}</span>
675
- </div>
676
- <div class="table-wrap">
677
- <table role="grid" aria-rowcount="${escapeHtml(String(model.rows.length))}">
678
- <thead>
679
- <tr>${model.fields.map((field) => `<th scope="col">${escapeHtml(field)}</th>`).join("")}</tr>
680
- </thead>
681
- <tbody>
682
- ${model.rows.length === 0
683
- ? `<tr><td colspan="${Math.max(1, model.fields.length)}" class="empty">No rows</td></tr>`
684
- : model.rows
685
- .map((row) => `
686
- <tr tabindex="0" data-source-id="${escapeHtml(row.sourceId)}" data-feature-id="${escapeHtml(String(row.id))}" aria-selected="${String(String(selectedId ?? "") === String(row.id))}">
687
- ${model.fields.map((field) => `<td>${escapeHtml(formatCell(row.attributes[field]))}</td>`).join("")}
688
- </tr>
689
- `)
690
- .join("")}
691
- </tbody>
692
- </table>
693
- </div>
694
- </section>
695
- `);
696
- this.shadowRoot?.querySelectorAll("tbody tr[data-feature-id]").forEach((row) => {
697
- const select = () => this.selectRow(row);
698
- row.addEventListener("click", select);
699
- row.addEventListener("keydown", (event) => {
700
- if (event.key !== "Enter" && event.key !== " ")
701
- return;
702
- event.preventDefault();
703
- select();
804
+ const label = this.#messages.label ?? this.getAttribute("label") ?? "Features";
805
+ const rowHeight = this.rowHeight();
806
+ const snapshot = this.#tableSnapshot;
807
+ const viewModel = snapshot
808
+ ? featureTableViewModel(snapshot, { label, rowHeight, messages: this.#messages })
809
+ : legacyFeatureTableViewModel(this.#model ?? tableModelFromState(this.state, this.sourceId(), this.fields(), this.pageSize()), {
810
+ label,
811
+ rowHeight,
812
+ messages: this.#messages,
813
+ ...(this.state?.selection?.featureId !== undefined
814
+ ? { selectedFeatureId: String(this.state.selection.featureId) }
815
+ : {}),
704
816
  });
817
+ // `setShadowHtml` replaces the whole shadow tree, including the scroll
818
+ // container. A fresh container starts at `scrollTop = 0`, which would snap a
819
+ // virtualized grid back to the top (and strand a full-height leading spacer)
820
+ // on every publish — including the synchronous loading snapshot that
821
+ // `setScroll` itself publishes. Carry the offset across the swap.
822
+ const previousScrollTop = this.#scroller()?.scrollTop ?? 0;
823
+ this.setShadowHtml(`<style>${baseStyles()}${tableStyles()}${featureTableGridStyles()}</style>${featureTableGridHtml(viewModel)}`);
824
+ this.#bindGrid();
825
+ this.#restoreScrollTop(previousScrollTop);
826
+ }
827
+ #scroller() {
828
+ return this.shadowRoot?.querySelector("[data-scroller]") ?? null;
829
+ }
830
+ #restoreScrollTop(scrollTop) {
831
+ if (scrollTop <= 0)
832
+ return;
833
+ const scroller = this.#scroller();
834
+ if (!scroller || scroller.scrollTop === scrollTop)
835
+ return;
836
+ // Restoring the offset fires `scroll`; that echo must not be mistaken for a
837
+ // user gesture and fed back into the engine as a new window.
838
+ this.#restoringScroll = true;
839
+ scroller.scrollTop = scrollTop;
840
+ queueMicrotask(() => {
841
+ this.#restoringScroll = false;
705
842
  });
706
843
  }
844
+ #bindGrid() {
845
+ const root = this.shadowRoot;
846
+ if (!root)
847
+ return;
848
+ for (const button of root.querySelectorAll("[data-sort-field]")) {
849
+ button.addEventListener("click", (event) => {
850
+ const field = button.dataset.sortField;
851
+ if (!field)
852
+ return;
853
+ void this.#table?.toggleSort(field, { additive: event.shiftKey });
854
+ });
855
+ }
856
+ for (const row of root.querySelectorAll("tbody tr[data-feature-id]")) {
857
+ row.addEventListener("click", () => this.selectRow(row));
858
+ row.addEventListener("keydown", (event) => this.#onRowKeydown(event, row));
859
+ }
860
+ for (const cell of root.querySelectorAll('tbody [role="gridcell"]')) {
861
+ cell.addEventListener("focus", () => this.#focusCell(cell));
862
+ cell.addEventListener("keydown", (event) => this.#onGridKeydown(event, cell));
863
+ }
864
+ const scroller = root.querySelector("[data-scroller]");
865
+ if (scroller && this.#table) {
866
+ scroller.addEventListener("scroll", () => {
867
+ if (this.#restoringScroll)
868
+ return;
869
+ void this.#table?.setScroll({
870
+ scrollTop: scroller.scrollTop,
871
+ rowHeight: this.rowHeight(),
872
+ viewportHeight: scroller.clientHeight || this.rowHeight() * 10,
873
+ });
874
+ });
875
+ }
876
+ }
877
+ /**
878
+ * Keys pressed while the **row** itself holds focus, rather than one of its
879
+ * cells. Cells handle their own keys and their events bubble through the row,
880
+ * so this bails unless the row is the actual target — no double handling.
881
+ *
882
+ * Enter/Space selects, matching the row-level activation the grid has always
883
+ * offered. Any navigation key hands focus to the row's first cell, which is
884
+ * where the roving-tabindex model takes over.
885
+ */
886
+ #onRowKeydown(event, row) {
887
+ if (event.target !== row)
888
+ return;
889
+ if (event.key === "Enter" || event.key === " ") {
890
+ event.preventDefault();
891
+ this.selectRow(row);
892
+ return;
893
+ }
894
+ if (!featureTableFocusMoveForKey(event.key, { ctrl: event.ctrlKey, meta: event.metaKey }))
895
+ return;
896
+ const cell = row.querySelector('[role="gridcell"]');
897
+ if (!cell)
898
+ return;
899
+ event.preventDefault();
900
+ cell.focus();
901
+ }
902
+ #focusCell(cell) {
903
+ const rowKey = cell.closest("tr")?.dataset.rowKey;
904
+ const field = cell.dataset.field;
905
+ if (!rowKey || !field || !this.#table)
906
+ return;
907
+ if (this.#tableSnapshot?.focus?.rowKey === rowKey && this.#tableSnapshot.focus.field === field)
908
+ return;
909
+ this.#table.setFocus({ rowKey, field });
910
+ }
911
+ #onGridKeydown(event, cell) {
912
+ const row = cell.closest("tr");
913
+ if (event.key === "Enter" || event.key === " ") {
914
+ event.preventDefault();
915
+ if (row)
916
+ this.selectRow(row);
917
+ return;
918
+ }
919
+ const move = featureTableFocusMoveForKey(event.key, { ctrl: event.ctrlKey, meta: event.metaKey });
920
+ if (!move)
921
+ return;
922
+ event.preventDefault();
923
+ const table = this.#table;
924
+ if (!table) {
925
+ this.#moveDomFocus(move, cell, row);
926
+ return;
927
+ }
928
+ if (!this.#tableSnapshot?.focus)
929
+ this.#focusCell(cell);
930
+ void table.moveFocus(move).then(() => this.#restoreFocusedCell());
931
+ }
932
+ /** Controller lane: move focus within the rendered rows only — no paging. */
933
+ #moveDomFocus(move, cell, row) {
934
+ const root = this.shadowRoot;
935
+ if (!root || !row)
936
+ return;
937
+ const rows = [...root.querySelectorAll("tbody tr")];
938
+ const cells = [...row.querySelectorAll('[role="gridcell"]')];
939
+ const rowIndex = rows.indexOf(row);
940
+ const cellIndex = cells.indexOf(cell);
941
+ const clamp = (value, max) => Math.max(0, Math.min(value, max));
942
+ let nextRow = rowIndex;
943
+ let nextCell = cellIndex;
944
+ if (move === "up")
945
+ nextRow -= 1;
946
+ else if (move === "down")
947
+ nextRow += 1;
948
+ else if (move === "left")
949
+ nextCell -= 1;
950
+ else if (move === "right")
951
+ nextCell += 1;
952
+ else if (move === "row-start")
953
+ nextCell = 0;
954
+ else if (move === "row-end")
955
+ nextCell = cells.length - 1;
956
+ else if (move === "page-up")
957
+ nextRow = 0;
958
+ else if (move === "page-down")
959
+ nextRow = rows.length - 1;
960
+ else if (move === "grid-start") {
961
+ nextRow = 0;
962
+ nextCell = 0;
963
+ }
964
+ else if (move === "grid-end") {
965
+ nextRow = rows.length - 1;
966
+ nextCell = cells.length - 1;
967
+ }
968
+ const target = rows[clamp(nextRow, rows.length - 1)]?.querySelectorAll('[role="gridcell"]')[clamp(nextCell, cells.length - 1)];
969
+ target?.focus();
970
+ }
971
+ #restoreFocusedCell() {
972
+ const focus = this.#tableSnapshot?.focus;
973
+ const root = this.shadowRoot;
974
+ if (!focus || !root)
975
+ return;
976
+ const selector = `tbody tr[data-row-key="${cssEscape(focus.rowKey)}"] [data-field="${cssEscape(focus.field)}"]`;
977
+ root.querySelector(selector)?.focus();
978
+ }
979
+ #announceConflicts(snapshot) {
980
+ const digest = snapshot.conflicts.map((conflict) => `${conflict.code}:${conflict.rowKeys.join(",")}`).join("|");
981
+ if (digest === this.#announcedConflicts)
982
+ return;
983
+ this.#announcedConflicts = digest;
984
+ for (const conflict of snapshot.conflicts) {
985
+ this.dispatchTypedEvent("honua-table-conflict", {
986
+ code: conflict.code,
987
+ message: conflict.message,
988
+ rowKeys: conflict.rowKeys,
989
+ });
990
+ }
991
+ }
707
992
  selectRow(row) {
708
993
  const sourceId = row.dataset.sourceId;
709
994
  const featureId = row.dataset.featureId;
710
995
  if (!sourceId || featureId === undefined)
711
996
  return;
997
+ const rowKey = row.dataset.rowKey;
998
+ if (this.#table && rowKey)
999
+ this.#table.select([rowKey]);
712
1000
  const feature = (this.#model?.rows ?? []).find((candidate) => String(candidate.id) === featureId);
713
1001
  this.controller?.selectFeature({ sourceId, featureId, ...(feature ? { feature } : {}) });
714
1002
  this.dispatchTypedEvent("honua-selection-change", {
@@ -731,6 +1019,53 @@ export class HonuaFeatureTableElement extends HonuaElementBase {
731
1019
  const parsed = Number(this.getAttribute("page-size"));
732
1020
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
733
1021
  }
1022
+ rowHeight() {
1023
+ const parsed = Number(this.getAttribute("row-height"));
1024
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 32;
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Project a bounded snapshot onto the legacy {@link HonuaFeatureTableModel} so
1029
+ * existing `refresh()` callers keep compiling. `totalCount` is only the
1030
+ * engine's count when the engine actually knows one; a partial or unknown count
1031
+ * reports the resident row count, which the `exceededTransferLimit` flag marks
1032
+ * as incomplete rather than presenting it as a total.
1033
+ */
1034
+ function featureTableModelFromSnapshot(snapshot) {
1035
+ const rows = snapshot.rows.filter((row) => row !== undefined);
1036
+ const known = snapshot.count.kind === "known" || snapshot.count.kind === "estimated";
1037
+ return {
1038
+ sourceId: snapshot.sourceId,
1039
+ status: legacyStatusForState(snapshot.state),
1040
+ fields: snapshot.visibleColumns.map((column) => column.field),
1041
+ rows: rows.map((row) => ({
1042
+ id: row.id,
1043
+ sourceId: row.sourceId,
1044
+ attributes: row.attributes,
1045
+ ...(row.geometry !== undefined ? { geometry: row.geometry } : {}),
1046
+ })),
1047
+ totalCount: known ? (snapshot.count.value ?? snapshot.count.loaded) : snapshot.count.loaded,
1048
+ ...(known ? {} : { exceededTransferLimit: true }),
1049
+ ...(snapshot.error !== undefined ? { error: snapshot.error } : {}),
1050
+ };
1051
+ }
1052
+ function legacyStatusForState(state) {
1053
+ switch (state) {
1054
+ case "idle":
1055
+ return "idle";
1056
+ case "loading":
1057
+ return "loading";
1058
+ case "error":
1059
+ return "error";
1060
+ case "unsupported":
1061
+ return "unsupported";
1062
+ default:
1063
+ return "ready";
1064
+ }
1065
+ }
1066
+ /** Minimal `CSS.escape` for the attribute selectors this element builds. */
1067
+ function cssEscape(value) {
1068
+ return value.split("\\").join("\\\\").split('"').join('\\"');
734
1069
  }
735
1070
  /**
736
1071
  * `<honua-search>` — the survival-tier search box (issue #493).
@@ -752,7 +1087,7 @@ export class HonuaFeatureTableElement extends HonuaElementBase {
752
1087
  */
753
1088
  export class HonuaSearchElement extends HonuaElementBase {
754
1089
  static get observedAttributes() {
755
- return ["for", "source", "placeholder", "label", "debounce", "zoom"];
1090
+ return ["for", "source", "placeholder", "label", "submit-label", "debounce", "zoom"];
756
1091
  }
757
1092
  #query = "";
758
1093
  #results = [];
@@ -763,6 +1098,14 @@ export class HonuaSearchElement extends HonuaElementBase {
763
1098
  #suggestTimer;
764
1099
  #suggestToken = 0;
765
1100
  #geocodeToken = 0;
1101
+ #messages = {};
1102
+ get messages() {
1103
+ return this.#messages;
1104
+ }
1105
+ set messages(messages) {
1106
+ this.#messages = messages ?? {};
1107
+ this.render();
1108
+ }
766
1109
  /** The geocoding provider powering typeahead + geocode-on-submit. */
767
1110
  get geocoder() {
768
1111
  return this.#geocoder;
@@ -775,6 +1118,20 @@ export class HonuaSearchElement extends HonuaElementBase {
775
1118
  this.#status = "";
776
1119
  this.render();
777
1120
  }
1121
+ /**
1122
+ * Text rendered in the form's submit button. Defaults to `"Search"` for
1123
+ * compatibility with the original markup. The property reflects the
1124
+ * `submit-label` attribute so either caller API can supply localized text.
1125
+ */
1126
+ get submitLabel() {
1127
+ return this.getAttribute("submit-label") ?? "Search";
1128
+ }
1129
+ set submitLabel(value) {
1130
+ if (value === undefined)
1131
+ this.removeAttribute("submit-label");
1132
+ else
1133
+ this.setAttribute("submit-label", value);
1134
+ }
778
1135
  attributeChangedCallback() {
779
1136
  this.resolveControllerFromContext();
780
1137
  this.render();
@@ -784,8 +1141,9 @@ export class HonuaSearchElement extends HonuaElementBase {
784
1141
  super.disconnectedCallback();
785
1142
  }
786
1143
  render() {
787
- const label = this.getAttribute("label") ?? "Search";
788
- const placeholder = this.getAttribute("placeholder") ?? "Search";
1144
+ const label = this.#messages.label ?? this.getAttribute("label") ?? "Search";
1145
+ const placeholder = this.#messages.placeholder ?? this.getAttribute("placeholder") ?? "Search";
1146
+ const submitLabel = this.#messages.submitLabel ?? this.submitLabel;
789
1147
  const geocoding = this.#geocoder !== undefined;
790
1148
  const expanded = geocoding && this.#suggestions.length > 0;
791
1149
  const comboboxAttributes = geocoding
@@ -797,10 +1155,12 @@ export class HonuaSearchElement extends HonuaElementBase {
797
1155
  <form>
798
1156
  <label class="sr-only" for="honua-search-input">${escapeHtml(label)}</label>
799
1157
  <input id="honua-search-input" name="q" value="${escapeAttribute(this.#query)}" placeholder="${escapeAttribute(placeholder)}" autocomplete="off"${comboboxAttributes} />
800
- <button type="submit">Search</button>
1158
+ <button type="submit">${escapeHtml(submitLabel)}</button>
801
1159
  </form>
802
1160
  ${geocoding
803
- ? `<ul class="suggestions" id="honua-search-listbox" role="listbox" aria-label="${escapeAttribute(`${label} suggestions`)}"${expanded ? "" : " hidden"}>
1161
+ ? `<ul class="suggestions" id="honua-search-listbox" role="listbox" aria-label="${escapeAttribute(typeof this.#messages.suggestionsLabel === "function"
1162
+ ? this.#messages.suggestionsLabel(label)
1163
+ : (this.#messages.suggestionsLabel ?? `${label} suggestions`))}"${expanded ? "" : " hidden"}>
804
1164
  ${this.#suggestions
805
1165
  .map((suggestion, index) => `<li id="honua-search-option-${index}" role="option" aria-selected="${String(index === this.#activeIndex)}" data-suggestion-index="${index}">${escapeHtml(suggestion.text)}</li>`)
806
1166
  .join("")}
@@ -821,11 +1181,18 @@ export class HonuaSearchElement extends HonuaElementBase {
821
1181
  else
822
1182
  void this.runSearch(query);
823
1183
  });
824
- if (this.#geocoder) {
825
- input?.addEventListener("input", () => {
826
- this.#query = input.value;
1184
+ // The typed query is tracked unconditionally, not only when a geocoder is
1185
+ // assigned. `render()` re-creates the input from `#query`, so without this
1186
+ // any controller-driven re-render (a viewport change, a layer toggle, a
1187
+ // realtime refresh) silently wiped whatever the user had typed — the
1188
+ // focus-restoration gate this is measured against (issue #683, REQ-004)
1189
+ // found exactly that. Suggestion scheduling stays geocoder-gated.
1190
+ input?.addEventListener("input", () => {
1191
+ this.#query = input.value;
1192
+ if (this.#geocoder)
827
1193
  this.scheduleSuggest();
828
- });
1194
+ });
1195
+ if (this.#geocoder) {
829
1196
  input?.addEventListener("keydown", (event) => this.onComboboxKeydown(event));
830
1197
  this.shadowRoot?.querySelectorAll("li[data-suggestion-index]").forEach((option) => {
831
1198
  option.addEventListener("click", () => {
@@ -912,7 +1279,8 @@ export class HonuaSearchElement extends HonuaElementBase {
912
1279
  return;
913
1280
  this.#suggestions = [];
914
1281
  this.#activeIndex = -1;
915
- this.#status = error instanceof Error ? error.message : "Suggestions unavailable.";
1282
+ this.#status =
1283
+ error instanceof Error ? error.message : (this.#messages.suggestionsUnavailable ?? "Suggestions unavailable.");
916
1284
  this.render();
917
1285
  }
918
1286
  }
@@ -949,7 +1317,7 @@ export class HonuaSearchElement extends HonuaElementBase {
949
1317
  return;
950
1318
  const candidate = candidates[0];
951
1319
  if (!candidate) {
952
- this.#status = `No results for "${trimmed}".`;
1320
+ this.#status = this.#messages.noResults?.(trimmed) ?? `No results for "${trimmed}".`;
953
1321
  this.render();
954
1322
  return;
955
1323
  }
@@ -969,7 +1337,12 @@ export class HonuaSearchElement extends HonuaElementBase {
969
1337
  catch (error) {
970
1338
  if (token !== this.#geocodeToken)
971
1339
  return;
972
- this.#status = error instanceof Error ? error.message : "Geocoding failed.";
1340
+ this.#status =
1341
+ error instanceof Error
1342
+ ? error.message
1343
+ : typeof this.#messages.geocodingFailed === "function"
1344
+ ? this.#messages.geocodingFailed(error)
1345
+ : (this.#messages.geocodingFailed ?? "Geocoding failed.");
973
1346
  this.render();
974
1347
  }
975
1348
  }
@@ -997,9 +1370,20 @@ export class HonuaSearchElement extends HonuaElementBase {
997
1370
  }
998
1371
  export class HonuaEditorElement extends HonuaElementBase {
999
1372
  static get observedAttributes() {
1000
- return ["for", "source", "label"];
1373
+ return [
1374
+ "for",
1375
+ "source",
1376
+ "label",
1377
+ "new-label",
1378
+ "save-label",
1379
+ "delete-label",
1380
+ "no-selection-label",
1381
+ "read-only-label",
1382
+ "editable-label",
1383
+ ];
1001
1384
  }
1002
1385
  #model;
1386
+ #messages = {};
1003
1387
  get editorModel() {
1004
1388
  return this.#model ?? this.state?.editor;
1005
1389
  }
@@ -1007,6 +1391,74 @@ export class HonuaEditorElement extends HonuaElementBase {
1007
1391
  this.#model = model;
1008
1392
  this.render();
1009
1393
  }
1394
+ /** Caller-supplied localized status and read-only messages. */
1395
+ get messages() {
1396
+ return this.#messages;
1397
+ }
1398
+ set messages(messages) {
1399
+ this.#messages = messages ?? {};
1400
+ this.render();
1401
+ }
1402
+ /** Text rendered by the create action. Defaults to `"New"`. */
1403
+ get newLabel() {
1404
+ return this.getAttribute("new-label") ?? "New";
1405
+ }
1406
+ set newLabel(value) {
1407
+ if (value === undefined)
1408
+ this.removeAttribute("new-label");
1409
+ else
1410
+ this.setAttribute("new-label", value);
1411
+ }
1412
+ /** Text rendered by the save action. Defaults to `"Save"`. */
1413
+ get saveLabel() {
1414
+ return this.getAttribute("save-label") ?? "Save";
1415
+ }
1416
+ set saveLabel(value) {
1417
+ if (value === undefined)
1418
+ this.removeAttribute("save-label");
1419
+ else
1420
+ this.setAttribute("save-label", value);
1421
+ }
1422
+ /** Text rendered by the delete action. Defaults to `"Delete"`. */
1423
+ get deleteLabel() {
1424
+ return this.getAttribute("delete-label") ?? "Delete";
1425
+ }
1426
+ set deleteLabel(value) {
1427
+ if (value === undefined)
1428
+ this.removeAttribute("delete-label");
1429
+ else
1430
+ this.setAttribute("delete-label", value);
1431
+ }
1432
+ /** Text rendered when no feature is selected. Defaults to `"No selection"`. */
1433
+ get noSelectionLabel() {
1434
+ return this.getAttribute("no-selection-label") ?? "No selection";
1435
+ }
1436
+ set noSelectionLabel(value) {
1437
+ if (value === undefined)
1438
+ this.removeAttribute("no-selection-label");
1439
+ else
1440
+ this.setAttribute("no-selection-label", value);
1441
+ }
1442
+ /** Text rendered for a read-only editor without a model-supplied reason. Defaults to `"Read-only"`. */
1443
+ get readOnlyLabel() {
1444
+ return this.getAttribute("read-only-label") ?? "Read-only";
1445
+ }
1446
+ set readOnlyLabel(value) {
1447
+ if (value === undefined)
1448
+ this.removeAttribute("read-only-label");
1449
+ else
1450
+ this.setAttribute("read-only-label", value);
1451
+ }
1452
+ /** Text rendered for an editable editor. Defaults to `"Editable"`. */
1453
+ get editableLabel() {
1454
+ return this.getAttribute("editable-label") ?? "Editable";
1455
+ }
1456
+ set editableLabel(value) {
1457
+ if (value === undefined)
1458
+ this.removeAttribute("editable-label");
1459
+ else
1460
+ this.setAttribute("editable-label", value);
1461
+ }
1010
1462
  attributeChangedCallback() {
1011
1463
  this.resolveControllerFromContext();
1012
1464
  this.render();
@@ -1017,19 +1469,30 @@ export class HonuaEditorElement extends HonuaElementBase {
1017
1469
  const selected = state?.selection?.feature;
1018
1470
  const canUpdate = model.capabilities.canUpdate && !model.capabilities.readOnly;
1019
1471
  const label = this.getAttribute("label") ?? "Editor";
1472
+ const newLabel = this.newLabel;
1473
+ const saveLabel = this.saveLabel;
1474
+ const deleteLabel = this.deleteLabel;
1475
+ const noSelectionLabel = this.noSelectionLabel;
1476
+ const readOnlyLabel = this.readOnlyLabel;
1477
+ const editableLabel = this.editableLabel;
1478
+ const statusLabel = this.#messages.status?.[model.status] ?? model.status;
1479
+ const readOnlyReason = this.#messages.readOnlyReason;
1480
+ const readOnlyMessage = typeof readOnlyReason === "function"
1481
+ ? readOnlyReason(model.capabilities.reason)
1482
+ : (readOnlyReason ?? model.capabilities.reason ?? readOnlyLabel);
1020
1483
  this.setShadowHtml(`
1021
1484
  <style>${baseStyles()}${editorStyles()}</style>
1022
1485
  <section class="editor" part="panel" aria-label="${escapeHtml(label)}">
1023
1486
  <div class="editor__bar">
1024
1487
  <h2>${escapeHtml(label)}</h2>
1025
- <span data-status>${escapeHtml(model.status)}</span>
1488
+ <span data-status>${escapeHtml(statusLabel)}</span>
1026
1489
  </div>
1027
- <p class="selection">${escapeHtml(selected?.title ?? "No selection")}</p>
1028
- <p class="muted">${escapeHtml(model.capabilities.readOnly ? (model.capabilities.reason ?? "Read-only") : "Editable")}</p>
1490
+ <p class="selection">${escapeHtml(selected?.title ?? noSelectionLabel)}</p>
1491
+ <p class="muted">${escapeHtml(model.capabilities.readOnly ? readOnlyMessage : editableLabel)}</p>
1029
1492
  <div class="editor__actions">
1030
- <button type="button" data-action="new" ${model.capabilities.canCreate && !model.capabilities.readOnly ? "" : "disabled"}>New</button>
1031
- <button type="button" data-action="save" ${canUpdate ? "" : "disabled"}>Save</button>
1032
- <button type="button" data-action="delete" ${model.capabilities.canDelete && !model.capabilities.readOnly ? "" : "disabled"}>Delete</button>
1493
+ <button type="button" data-action="new" ${model.capabilities.canCreate && !model.capabilities.readOnly ? "" : "disabled"}>${escapeHtml(newLabel)}</button>
1494
+ <button type="button" data-action="save" ${canUpdate ? "" : "disabled"}>${escapeHtml(saveLabel)}</button>
1495
+ <button type="button" data-action="delete" ${model.capabilities.canDelete && !model.capabilities.readOnly ? "" : "disabled"}>${escapeHtml(deleteLabel)}</button>
1033
1496
  </div>
1034
1497
  </section>
1035
1498
  `);
@@ -1062,6 +1525,14 @@ export class HonuaChartElement extends HonuaElementBase {
1062
1525
  return ["for", "label"];
1063
1526
  }
1064
1527
  #model;
1528
+ #messages = {};
1529
+ get messages() {
1530
+ return this.#messages;
1531
+ }
1532
+ set messages(messages) {
1533
+ this.#messages = messages ?? {};
1534
+ this.render();
1535
+ }
1065
1536
  get chartModel() {
1066
1537
  return this.#model ?? this.state?.chart;
1067
1538
  }
@@ -1074,24 +1545,28 @@ export class HonuaChartElement extends HonuaElementBase {
1074
1545
  this.render();
1075
1546
  }
1076
1547
  render() {
1077
- const model = this.chartModel ?? defaultChartModel(this.getAttribute("label") ?? "Chart");
1548
+ const model = this.chartModel ?? defaultChartModel(this.#messages.label ?? this.getAttribute("label") ?? "Chart");
1549
+ const title = this.#messages.label ?? model.title;
1078
1550
  const max = Math.max(1, ...(model.data ?? []).map((datum) => datum.value));
1551
+ const barStyles = (model.data ?? [])
1552
+ .map((datum, index) => `.bar-${index} { --bar: ${(datum.value / max) * 100}%; --bar-color: ${escapeAttribute(datum.color ?? "#2563eb")}; }`)
1553
+ .join("\n");
1079
1554
  this.setShadowHtml(`
1080
- <style>${baseStyles()}${chartStyles()}</style>
1081
- <section class="chart" part="panel" aria-label="${escapeHtml(model.title)}">
1082
- <h2>${escapeHtml(model.title)}</h2>
1555
+ <style>${baseStyles()}${chartStyles()}${barStyles}</style>
1556
+ <section class="chart" part="panel" aria-label="${escapeHtml(title)}">
1557
+ <h2>${escapeHtml(title)}</h2>
1083
1558
  <div class="bars">
1084
1559
  ${model.data?.length
1085
1560
  ? model.data
1086
- .map((datum) => `
1561
+ .map((datum, index) => `
1087
1562
  <div class="bar-row">
1088
1563
  <span>${escapeHtml(datum.label)}</span>
1089
- <span class="bar" style="--bar:${(datum.value / max) * 100}%;--bar-color:${escapeAttribute(datum.color ?? "#2563eb")}"></span>
1564
+ <span class="bar bar-${index}"></span>
1090
1565
  <strong>${escapeHtml(String(datum.value))}</strong>
1091
1566
  </div>
1092
1567
  `)
1093
1568
  .join("")
1094
- : `<p class="empty">${escapeHtml(model.message ?? "No chart data")}</p>`}
1569
+ : `<p class="empty">${escapeHtml(this.#messages.noData ?? model.message ?? "No chart data")}</p>`}
1095
1570
  </div>
1096
1571
  </section>
1097
1572
  `);
@@ -1102,24 +1577,32 @@ export class HonuaBasemapControlElement extends HonuaElementBase {
1102
1577
  return ["for", "label"];
1103
1578
  }
1104
1579
  #activeBasemapId;
1580
+ #messages = {};
1581
+ get messages() {
1582
+ return this.#messages;
1583
+ }
1584
+ set messages(messages) {
1585
+ this.#messages = messages ?? {};
1586
+ this.render();
1587
+ }
1105
1588
  attributeChangedCallback() {
1106
1589
  this.resolveControllerFromContext();
1107
1590
  this.render();
1108
1591
  }
1109
1592
  render() {
1110
- const label = this.getAttribute("label") ?? "Basemaps";
1593
+ const label = this.#messages.label ?? this.getAttribute("label") ?? "Basemaps";
1111
1594
  const basemaps = basemapsFromState(this.state);
1112
1595
  const active = this.#activeBasemapId ?? basemaps.find((basemap) => basemap.visible)?.id ?? basemaps[0]?.id;
1113
1596
  this.setShadowHtml(`
1114
- <style>${baseStyles()}${controlPanelStyles()}</style>
1597
+ <style>${baseStyles()}${controlPanelStyles()}${basemapControlStyles()}</style>
1115
1598
  <section class="control-panel" part="panel" aria-label="${escapeHtml(label)}">
1116
1599
  <div class="control-panel__bar">
1117
1600
  <h2>${escapeHtml(label)}</h2>
1118
- <span>${escapeHtml(basemaps.length >= 2 ? "ready" : "unsupported")}</span>
1601
+ <span>${escapeHtml(this.#messages.status?.[basemaps.length >= 2 ? "ready" : "unsupported"] ?? (basemaps.length >= 2 ? "ready" : "unsupported"))}</span>
1119
1602
  </div>
1120
1603
  ${basemaps.length < 2
1121
- ? `<p class="empty" role="status">At least two basemap layers are required.</p>`
1122
- : `<div class="segmented" role="group" aria-label="${escapeAttribute(label)}">
1604
+ ? `<p class="empty" role="status">${escapeHtml(this.#messages.unavailable ?? this.#messages.unsupported ?? "At least two basemap layers are required.")}</p>`
1605
+ : `<div class="segmented" role="group" aria-label="${escapeAttribute(this.#messages.groupLabel ?? label)}">
1123
1606
  ${basemaps
1124
1607
  .map((basemap) => `
1125
1608
  <button type="button" data-basemap-id="${escapeAttribute(basemap.id)}" aria-pressed="${String(basemap.id === active)}">${escapeHtml(basemap.title)}</button>
@@ -1152,6 +1635,14 @@ export class HonuaBookmarksElement extends HonuaElementBase {
1152
1635
  static get observedAttributes() {
1153
1636
  return ["for", "label", "bookmarks"];
1154
1637
  }
1638
+ #messages = {};
1639
+ get messages() {
1640
+ return this.#messages;
1641
+ }
1642
+ set messages(messages) {
1643
+ this.#messages = messages ?? {};
1644
+ this.render();
1645
+ }
1155
1646
  attributeChangedCallback() {
1156
1647
  this.resolveControllerFromContext();
1157
1648
  this.render();
@@ -1159,15 +1650,17 @@ export class HonuaBookmarksElement extends HonuaElementBase {
1159
1650
  render() {
1160
1651
  const label = this.getAttribute("label") ?? "Bookmarks";
1161
1652
  const bookmarks = this.bookmarks();
1653
+ const statusLabel = this.#messages.status?.[bookmarks.length > 0 ? "ready" : "unsupported"] ??
1654
+ (bookmarks.length > 0 ? "ready" : "unsupported");
1162
1655
  this.setShadowHtml(`
1163
1656
  <style>${baseStyles()}${controlPanelStyles()}</style>
1164
1657
  <section class="control-panel" part="panel" aria-label="${escapeHtml(label)}">
1165
1658
  <div class="control-panel__bar">
1166
1659
  <h2>${escapeHtml(label)}</h2>
1167
- <span>${escapeHtml(bookmarks.length > 0 ? "ready" : "unsupported")}</span>
1660
+ <span>${escapeHtml(statusLabel)}</span>
1168
1661
  </div>
1169
1662
  ${bookmarks.length === 0
1170
- ? `<p class="empty" role="status">No initial view or bookmarks are available.</p>`
1663
+ ? `<p class="empty" role="status">${escapeHtml(this.#messages.empty ?? "No initial view or bookmarks are available.")}</p>`
1171
1664
  : `<div class="button-stack">
1172
1665
  ${bookmarks
1173
1666
  .map((bookmark) => `
@@ -1188,7 +1681,12 @@ export class HonuaBookmarksElement extends HonuaElementBase {
1188
1681
  bookmarks() {
1189
1682
  const configured = parseBookmarks(this.getAttribute("bookmarks"));
1190
1683
  const home = this.state?.mapPackage?.initialView;
1191
- return [...(home ? [{ id: "home", label: "Home", viewport: home }] : []), ...configured];
1684
+ return [
1685
+ ...(home
1686
+ ? [{ id: "home", label: this.#messages.homeLabel ?? "Home", viewport: home }]
1687
+ : []),
1688
+ ...configured,
1689
+ ];
1192
1690
  }
1193
1691
  goToBookmark(bookmark) {
1194
1692
  this.controller?.setViewport(bookmark.viewport);
@@ -1205,6 +1703,15 @@ export class HonuaLocateControlElement extends HonuaElementBase {
1205
1703
  }
1206
1704
  #status = "idle";
1207
1705
  #message = "";
1706
+ #messages = {};
1707
+ /** Caller-supplied localized labels and state messages. */
1708
+ get messages() {
1709
+ return this.#messages;
1710
+ }
1711
+ set messages(messages) {
1712
+ this.#messages = messages ?? {};
1713
+ this.render();
1714
+ }
1208
1715
  attributeChangedCallback() {
1209
1716
  this.resolveControllerFromContext();
1210
1717
  this.render();
@@ -1213,15 +1720,20 @@ export class HonuaLocateControlElement extends HonuaElementBase {
1213
1720
  const label = this.getAttribute("label") ?? "Locate";
1214
1721
  const supported = this.hasConfiguredLocation() || Boolean(globalThis.navigator?.geolocation);
1215
1722
  const status = supported ? this.#status : "unsupported";
1723
+ const statusLabel = this.#messages.status?.[status] ?? status;
1724
+ const message = this.#message ||
1725
+ (supported
1726
+ ? (this.#messages.initial ?? "Centers the shared map controller.")
1727
+ : (this.#messages.unavailable ?? "Geolocation is unavailable."));
1216
1728
  this.setShadowHtml(`
1217
1729
  <style>${baseStyles()}${controlPanelStyles()}</style>
1218
1730
  <section class="control-panel" part="panel" aria-label="${escapeHtml(label)}">
1219
1731
  <div class="control-panel__bar">
1220
1732
  <h2>${escapeHtml(label)}</h2>
1221
- <span>${escapeHtml(status)}</span>
1733
+ <span>${escapeHtml(statusLabel)}</span>
1222
1734
  </div>
1223
- <button type="button" data-locate ${supported ? "" : "disabled"}>Use location</button>
1224
- <p class="empty" role="status">${escapeHtml(supported ? this.#message || "Centers the shared map controller." : "Geolocation is unavailable.")}</p>
1735
+ <button type="button" data-locate="action" ${supported ? "" : "disabled"}>${escapeHtml(this.#messages.actionLabel ?? "Use location")}</button>
1736
+ <p class="empty" role="status">${escapeHtml(message)}</p>
1225
1737
  </section>
1226
1738
  `);
1227
1739
  this.shadowRoot?.querySelector("button[data-locate]")?.addEventListener("click", () => {
@@ -1237,7 +1749,7 @@ export class HonuaLocateControlElement extends HonuaElementBase {
1237
1749
  const geolocation = globalThis.navigator?.geolocation;
1238
1750
  if (!geolocation) {
1239
1751
  this.#status = "unsupported";
1240
- this.#message = "Geolocation is unavailable.";
1752
+ this.#message = this.#messages.unavailable ?? "Geolocation is unavailable.";
1241
1753
  this.dispatchTypedEvent("honua-locate-change", {
1242
1754
  status: "unsupported",
1243
1755
  message: this.#message,
@@ -1246,7 +1758,7 @@ export class HonuaLocateControlElement extends HonuaElementBase {
1246
1758
  return;
1247
1759
  }
1248
1760
  this.#status = "loading";
1249
- this.#message = "Requesting location.";
1761
+ this.#message = this.#messages.requesting ?? "Requesting location.";
1250
1762
  this.render();
1251
1763
  geolocation.getCurrentPosition((position) => {
1252
1764
  this.applyLocation({
@@ -1255,7 +1767,7 @@ export class HonuaLocateControlElement extends HonuaElementBase {
1255
1767
  });
1256
1768
  }, (error) => {
1257
1769
  this.#status = "error";
1258
- this.#message = error.message;
1770
+ this.#message = this.#messages.error?.(error) ?? error.message;
1259
1771
  this.dispatchTypedEvent("honua-locate-change", {
1260
1772
  status: "error",
1261
1773
  error,
@@ -1266,7 +1778,7 @@ export class HonuaLocateControlElement extends HonuaElementBase {
1266
1778
  }
1267
1779
  applyLocation(viewport) {
1268
1780
  this.#status = "ready";
1269
- this.#message = "Location applied.";
1781
+ this.#message = this.#messages.applied ?? "Location applied.";
1270
1782
  this.controller?.setViewport(viewport);
1271
1783
  this.dispatchTypedEvent("honua-locate-change", { status: "ready", viewport });
1272
1784
  this.dispatchTypedEvent("honua-viewport-change", viewport);
@@ -1301,6 +1813,15 @@ export class HonuaMeasureControlElement extends HonuaElementBase {
1301
1813
  return ["for", "label"];
1302
1814
  }
1303
1815
  #mode = "off";
1816
+ #messages = {};
1817
+ /** Caller-supplied localized status, action, and empty-state messages. */
1818
+ get messages() {
1819
+ return this.#messages;
1820
+ }
1821
+ set messages(messages) {
1822
+ this.#messages = messages ?? {};
1823
+ this.render();
1824
+ }
1304
1825
  attributeChangedCallback() {
1305
1826
  this.resolveControllerFromContext();
1306
1827
  this.render();
@@ -1311,23 +1832,25 @@ export class HonuaMeasureControlElement extends HonuaElementBase {
1311
1832
  render() {
1312
1833
  const label = this.getAttribute("label") ?? "Measure";
1313
1834
  const enabled = this.controller?.canMeasure() ?? false;
1835
+ const status = enabled ? "ready" : "unsupported";
1314
1836
  this.setShadowHtml(`
1315
1837
  <style>${baseStyles()}${controlPanelStyles()}</style>
1316
1838
  <section class="control-panel" part="panel" aria-label="${escapeHtml(label)}">
1317
1839
  <div class="control-panel__bar">
1318
1840
  <h2>${escapeHtml(label)}</h2>
1319
- <span>${escapeHtml(enabled ? "ready" : "unsupported")}</span>
1841
+ <span>${escapeHtml(this.#messages.status?.[status] ?? status)}</span>
1320
1842
  </div>
1321
1843
  <div class="segmented" role="group" aria-label="${escapeAttribute(label)}">
1322
1844
  ${["off", "distance", "area"]
1323
1845
  .map((mode) => `
1324
- <button type="button" data-measure-mode="${mode}" aria-pressed="${String(this.#mode === mode)}"${enabled ? "" : ' aria-disabled="true" disabled'}>${escapeHtml(modeLabel(mode))}</button>
1846
+ <button type="button" data-measure-mode="${mode}" aria-pressed="${String(this.#mode === mode)}"${enabled ? "" : ' aria-disabled="true" disabled'}>${escapeHtml(this.#messages.actionLabels?.[mode] ?? modeLabel(mode))}</button>
1325
1847
  `)
1326
1848
  .join("")}
1327
1849
  </div>
1328
1850
  ${enabled
1329
1851
  ? ""
1330
- : `<p class="empty" role="status">Measurement is disabled because no geometry provider is configured. Pass a \`measurementGeometry\` provider to the controller to enable distance and area measuring.</p>`}
1852
+ : `<p class="empty" role="status">${escapeHtml(this.#messages.empty ??
1853
+ "Measurement is disabled because no geometry provider is configured. Pass a `measurementGeometry` provider to the controller to enable distance and area measuring.")}</p>`}
1331
1854
  </section>
1332
1855
  `);
1333
1856
  if (!enabled)
@@ -1345,7 +1868,8 @@ export class HonuaMeasureControlElement extends HonuaElementBase {
1345
1868
  : {
1346
1869
  mode,
1347
1870
  status: "unsupported",
1348
- message: "Measurement requires a geometry provider. Configure `measurementGeometry` on the controller to enable measuring.",
1871
+ message: this.#messages.unsupportedMessage ??
1872
+ "Measurement requires a geometry provider. Configure `measurementGeometry` on the controller to enable measuring.",
1349
1873
  };
1350
1874
  this.dispatchTypedEvent("honua-measure-change", detail);
1351
1875
  this.render();
@@ -1364,6 +1888,15 @@ export class HonuaSketchControlElement extends HonuaElementBase {
1364
1888
  return ["for", "label"];
1365
1889
  }
1366
1890
  #mode = "off";
1891
+ #messages = {};
1892
+ /** Caller-supplied localized status, action, and empty-state messages. */
1893
+ get messages() {
1894
+ return this.#messages;
1895
+ }
1896
+ set messages(messages) {
1897
+ this.#messages = messages ?? {};
1898
+ this.render();
1899
+ }
1367
1900
  attributeChangedCallback() {
1368
1901
  this.resolveControllerFromContext();
1369
1902
  this.render();
@@ -1374,23 +1907,25 @@ export class HonuaSketchControlElement extends HonuaElementBase {
1374
1907
  render() {
1375
1908
  const label = this.getAttribute("label") ?? "Sketch";
1376
1909
  const enabled = this.controller?.canSketch() ?? false;
1910
+ const status = enabled ? "ready" : "unsupported";
1377
1911
  this.setShadowHtml(`
1378
1912
  <style>${baseStyles()}${controlPanelStyles()}</style>
1379
1913
  <section class="control-panel" part="panel" aria-label="${escapeHtml(label)}">
1380
1914
  <div class="control-panel__bar">
1381
1915
  <h2>${escapeHtml(label)}</h2>
1382
- <span>${escapeHtml(enabled ? "ready" : "unsupported")}</span>
1916
+ <span>${escapeHtml(this.#messages.status?.[status] ?? status)}</span>
1383
1917
  </div>
1384
1918
  <div class="segmented" role="group" aria-label="${escapeAttribute(label)}">
1385
1919
  ${["off", "point", "line", "polygon"]
1386
1920
  .map((mode) => `
1387
- <button type="button" data-sketch-mode="${mode}" aria-pressed="${String(this.#mode === mode)}"${enabled ? "" : ' aria-disabled="true" disabled'}>${escapeHtml(modeLabel(mode))}</button>
1921
+ <button type="button" data-sketch-mode="${mode}" aria-pressed="${String(this.#mode === mode)}"${enabled ? "" : ' aria-disabled="true" disabled'}>${escapeHtml(this.#messages.actionLabels?.[mode] ?? modeLabel(mode))}</button>
1388
1922
  `)
1389
1923
  .join("")}
1390
1924
  </div>
1391
1925
  ${enabled
1392
1926
  ? ""
1393
- : `<p class="empty" role="status">Sketching is disabled because no geometry provider is configured. Pass a \`sketchGeometry\` provider to the controller to enable point, line, and polygon drawing.</p>`}
1927
+ : `<p class="empty" role="status">${escapeHtml(this.#messages.empty ??
1928
+ "Sketching is disabled because no geometry provider is configured. Pass a `sketchGeometry` provider to the controller to enable point, line, and polygon drawing.")}</p>`}
1394
1929
  </section>
1395
1930
  `);
1396
1931
  if (!enabled)
@@ -1408,79 +1943,283 @@ export class HonuaSketchControlElement extends HonuaElementBase {
1408
1943
  : {
1409
1944
  mode,
1410
1945
  status: "unsupported",
1411
- message: "Sketching requires a geometry provider. Configure `sketchGeometry` on the controller to enable drawing.",
1946
+ message: this.#messages.unsupportedMessage ??
1947
+ "Sketching requires a geometry provider. Configure `sketchGeometry` on the controller to enable drawing.",
1412
1948
  };
1413
1949
  this.dispatchTypedEvent("honua-sketch-change", detail);
1414
1950
  this.render();
1415
1951
  }
1416
1952
  }
1953
+ /** Legacy `honua-export` event `format` label for each export kind. */
1954
+ const EXPORT_FORMAT_BY_KIND = {
1955
+ print: "print",
1956
+ snapshot: "png",
1957
+ state: "json",
1958
+ };
1959
+ const EXPORT_KIND_BY_FORMAT = {
1960
+ print: "print",
1961
+ png: "snapshot",
1962
+ json: "state",
1963
+ };
1964
+ /** Maps an export outcome onto the shared component-status vocabulary. */
1965
+ const EXPORT_COMPONENT_STATUS = {
1966
+ ready: "ready",
1967
+ unsupported: "unsupported",
1968
+ cancelled: "idle",
1969
+ error: "error",
1970
+ };
1971
+ /**
1972
+ * `<honua-print-export>` — print, snapshot, and sanitized state export.
1973
+ *
1974
+ * Assign {@link HonuaPrintExportElement.exportAdapter} to enable snapshot and
1975
+ * state export. Without an adapter both **fail closed** (issue #683): the
1976
+ * buttons render disabled with an explanation, and a programmatic
1977
+ * `requestExport()` resolves to an `unsupported` result carrying a
1978
+ * `HonuaCapabilityNotSupportedError` — never a blank image or a
1979
+ * partially-credentialed JSON document. Browser print keeps working with no
1980
+ * adapter, because `window.print()` reads no pixels and serializes no state.
1981
+ */
1417
1982
  export class HonuaPrintExportElement extends HonuaElementBase {
1983
+ #exportAdapter;
1984
+ #lastResult;
1985
+ #inFlight;
1986
+ #messages = {};
1987
+ /** Monotonic request generation; only the newest export may publish. */
1988
+ #exportGeneration = 0;
1418
1989
  static get observedAttributes() {
1419
1990
  return ["for", "label", "title"];
1420
1991
  }
1992
+ /**
1993
+ * The application-supplied export adapter. Setting it re-renders so the
1994
+ * snapshot/state affordances become enabled; clearing it re-disables them.
1995
+ */
1996
+ get exportAdapter() {
1997
+ return this.#exportAdapter;
1998
+ }
1999
+ set exportAdapter(adapter) {
2000
+ if (this.#exportAdapter === adapter)
2001
+ return;
2002
+ this.#exportAdapter = adapter;
2003
+ this.render();
2004
+ }
2005
+ /** Caller-supplied localized status, action, and unsupported messages. */
2006
+ get messages() {
2007
+ return this.#messages;
2008
+ }
2009
+ set messages(messages) {
2010
+ this.#messages = messages ?? {};
2011
+ this.render();
2012
+ }
2013
+ /** The most recent export result, for callers that prefer polling to events. */
2014
+ get lastExportResult() {
2015
+ return this.#lastResult;
2016
+ }
1421
2017
  attributeChangedCallback() {
1422
2018
  this.resolveControllerFromContext();
1423
2019
  this.render();
1424
2020
  }
2021
+ disconnectedCallback() {
2022
+ // An in-flight export outlives nothing: disconnecting cancels it so a
2023
+ // detached element cannot keep an adapter (or a server-side render job)
2024
+ // alive (REQ-005 deterministic disposal).
2025
+ this.#inFlight?.abort();
2026
+ this.#inFlight = undefined;
2027
+ // Bump the generation too: an abort-ignoring adapter's continuation must
2028
+ // not publish onto a detached element either.
2029
+ this.#exportGeneration += 1;
2030
+ super.disconnectedCallback();
2031
+ }
2032
+ /** Kinds that are actually available right now, given the assigned adapter. */
2033
+ availableExportKinds() {
2034
+ const adapter = this.#resolveAdapter();
2035
+ if (!adapter)
2036
+ return [];
2037
+ try {
2038
+ return adapter.describeCapabilities().kinds.filter((kind) => HONUA_EXPORT_KINDS.includes(kind));
2039
+ }
2040
+ catch {
2041
+ return [];
2042
+ }
2043
+ }
2044
+ /**
2045
+ * Runs one export. Resolves with the full result envelope (never throws) and
2046
+ * dispatches `honua-export` with a redacted projection of it.
2047
+ */
2048
+ async requestExport(kind) {
2049
+ this.#inFlight?.abort();
2050
+ const controller = typeof AbortController === "function" ? new AbortController() : undefined;
2051
+ this.#inFlight = controller;
2052
+ // An AbortSignal is advisory: an adapter is free to ignore it and settle
2053
+ // later, so aborting the previous export does not guarantee it stays quiet.
2054
+ // Ownership of the element's visible state therefore belongs to a
2055
+ // generation, not to a controller — a completion that no longer owns the
2056
+ // element neither publishes a result nor dispatches an event, so a slow
2057
+ // first export cannot overwrite a newer one that already finished.
2058
+ this.#exportGeneration += 1;
2059
+ const generation = this.#exportGeneration;
2060
+ const title = this.getAttribute("title") ?? this.state?.packageId ?? undefined;
2061
+ const result = await runHonuaExport({
2062
+ kind,
2063
+ adapter: this.#resolveAdapter(kind),
2064
+ state: this.state,
2065
+ ...(title ? { title } : {}),
2066
+ ...(controller ? { signal: controller.signal } : {}),
2067
+ });
2068
+ if (this.#inFlight === controller)
2069
+ this.#inFlight = undefined;
2070
+ if (generation !== this.#exportGeneration) {
2071
+ // Superseded. The caller still gets its own result — it asked for this
2072
+ // export and is entitled to the outcome — but the element does not adopt
2073
+ // it, and any resource the adapter handed back is released here, since
2074
+ // nothing else now holds a reference to release it.
2075
+ void result.release();
2076
+ return result;
2077
+ }
2078
+ this.#lastResult = result;
2079
+ this.dispatchTypedEvent("honua-export", exportDetailFromResult(result, title));
2080
+ this.render();
2081
+ return result;
2082
+ }
2083
+ /**
2084
+ * Resolves the adapter for `kind`. The built-in browser-print adapter is used
2085
+ * for `print` only when the application supplied nothing — snapshot and state
2086
+ * have no built-in fallback by design.
2087
+ */
2088
+ #resolveAdapter(kind) {
2089
+ if (this.#exportAdapter)
2090
+ return this.#exportAdapter;
2091
+ if (kind !== undefined && kind !== "print")
2092
+ return undefined;
2093
+ const printAdapter = createBrowserPrintExportAdapter();
2094
+ return printAdapter.describeCapabilities().kinds.length > 0 ? printAdapter : undefined;
2095
+ }
1425
2096
  render() {
1426
2097
  const label = this.getAttribute("label") ?? "Print and export";
2098
+ const available = new Set(this.availableExportKinds());
2099
+ const status = available.size === 0 ? "unsupported" : "ready";
2100
+ const last = this.#lastResult;
2101
+ const buttons = ["print", "snapshot", "state"]
2102
+ .map((kind) => {
2103
+ const enabled = available.has(kind);
2104
+ const actionLabel = this.#messages.actionLabels?.[kind] ?? EXPORT_LABELS[kind];
2105
+ const title = enabled
2106
+ ? `${actionLabel} export`
2107
+ : (this.#messages.unavailable?.[kind] ??
2108
+ `${actionLabel} export requires an application-supplied export adapter.`);
2109
+ // Deliberately neither `disabled` nor `aria-disabled`, even when no
2110
+ // adapter is assigned. Both suppress activation — assistive technology
2111
+ // skips a disabled control, and Playwright treats either as
2112
+ // non-actionable — which would silence the `honua-export` event that is
2113
+ // how an application *discovers* it must supply an adapter. The
2114
+ // unavailability is conveyed where it can still be read and acted on:
2115
+ // an explanatory `title` on the control and the live-region readout
2116
+ // below. Fail-closed is unaffected — activating it runs the export,
2117
+ // which returns `unsupported` with no bytes.
2118
+ return `<button type="button" data-export-format="${EXPORT_FORMAT_BY_KIND[kind]}" data-export-kind="${kind}"${enabled ? "" : ' data-export-unavailable="true"'} title="${escapeHtml(title)}">${escapeHtml(actionLabel)}</button>`;
2119
+ })
2120
+ .join("");
1427
2121
  this.setShadowHtml(`
1428
2122
  <style>${baseStyles()}${controlPanelStyles()}</style>
1429
2123
  <section class="control-panel" part="panel" aria-label="${escapeHtml(label)}">
1430
2124
  <div class="control-panel__bar">
1431
2125
  <h2>${escapeHtml(label)}</h2>
1432
- <span>${escapeHtml(typeof window !== "undefined" && typeof window.print === "function" ? "ready" : "unsupported")}</span>
1433
- </div>
1434
- <div class="button-stack">
1435
- <button type="button" data-export-format="print">Print</button>
1436
- <button type="button" data-export-format="png">Snapshot</button>
1437
- <button type="button" data-export-format="json">State JSON</button>
2126
+ <span>${escapeHtml(this.#messages.status?.[status] ?? status)}</span>
1438
2127
  </div>
2128
+ <div class="button-stack">${buttons}</div>
2129
+ <p part="status" role="status" aria-live="polite">${escapeHtml(exportStatusText(last, available, this.#messages))}</p>
1439
2130
  </section>
1440
2131
  `);
1441
- this.shadowRoot?.querySelectorAll("button[data-export-format]").forEach((button) => {
1442
- button.addEventListener("click", () => this.export(button.dataset.exportFormat));
1443
- });
1444
- }
1445
- export(format) {
1446
- const title = this.getAttribute("title") ?? this.state?.packageId ?? undefined;
1447
- if (format === "print" && typeof window !== "undefined" && typeof window.print === "function") {
1448
- this.dispatchTypedEvent("honua-export", {
1449
- format,
1450
- status: "ready",
1451
- ...(title ? { title } : {}),
2132
+ this.shadowRoot?.querySelectorAll("button[data-export-kind]").forEach((button) => {
2133
+ button.addEventListener("click", () => {
2134
+ void this.requestExport(button.dataset.exportKind);
1452
2135
  });
1453
- window.print();
1454
- return;
1455
- }
1456
- const message = format === "print"
1457
- ? "Window printing is unavailable."
1458
- : "Snapshot export requires an application adapter to avoid leaking private map credentials.";
1459
- this.dispatchTypedEvent("honua-export", {
1460
- format,
1461
- status: "unsupported",
1462
- ...(title ? { title } : {}),
1463
- message,
1464
2136
  });
1465
2137
  }
1466
2138
  }
2139
+ const EXPORT_LABELS = {
2140
+ print: "Print",
2141
+ snapshot: "Snapshot",
2142
+ state: "State JSON",
2143
+ };
2144
+ /**
2145
+ * Redacted, displayable summary of the last export attempt — or, before any
2146
+ * attempt, an honest statement of what is actually available. The
2147
+ * adapter-required message is keyed on snapshot/state specifically, not on
2148
+ * "nothing is available": browser print is available with no adapter, and
2149
+ * reporting "ready to export" while two of the three buttons are inert would be
2150
+ * exactly the kind of quiet degradation issue #683 exists to remove.
2151
+ */
2152
+ function exportStatusText(result, available, messages = {}) {
2153
+ if (!result) {
2154
+ if (available.has("snapshot") && available.has("state"))
2155
+ return messages.ready ?? "Ready to export.";
2156
+ return available.size === 0
2157
+ ? (messages.adapterRequired ?? "Assign an export adapter to enable export.")
2158
+ : (messages.adapterRequiredForSnapshotState ?? "Assign an export adapter to enable snapshot and state export.");
2159
+ }
2160
+ if (result.status === "ready") {
2161
+ const redacted = result.redactions.length > 0 ? ` ${result.redactions.length} value(s) were withheld as non-exportable.` : "";
2162
+ return result.sideEffectOnly
2163
+ ? `Print layout sent to the browser.${redacted}`
2164
+ : `Exported ${result.filename ?? "artifact"}.${redacted}`;
2165
+ }
2166
+ return result.message ?? `Export ${result.status}.`;
2167
+ }
2168
+ /** Projects a result onto the event detail, carrying only redacted fields. */
2169
+ function exportDetailFromResult(result, title) {
2170
+ const detail = {
2171
+ format: EXPORT_FORMAT_BY_KIND[result.kind],
2172
+ kind: result.kind,
2173
+ status: EXPORT_COMPONENT_STATUS[result.status],
2174
+ exportStatus: result.status,
2175
+ redactionCount: result.redactions.length,
2176
+ };
2177
+ const mutable = detail;
2178
+ if (title)
2179
+ mutable.title = redactHonuaExportText(title);
2180
+ if (result.message)
2181
+ mutable.message = result.message;
2182
+ if (result.adapterId)
2183
+ mutable.adapterId = redactHonuaExportText(result.adapterId);
2184
+ if (result.filename)
2185
+ mutable.filename = result.filename;
2186
+ if (result.mediaType)
2187
+ mutable.mediaType = result.mediaType;
2188
+ if (result.bytes)
2189
+ mutable.byteLength = result.bytes.byteLength;
2190
+ if (result.error)
2191
+ mutable.errorCode = result.error.sdkCode;
2192
+ return detail;
2193
+ }
2194
+ /** The `format` → export-kind mapping, exported for callers on the legacy event shape. */
2195
+ export function honuaExportKindFromFormat(format) {
2196
+ return EXPORT_KIND_BY_FORMAT[format];
2197
+ }
1467
2198
  export class HonuaMapStatusElement extends HonuaElementBase {
1468
2199
  static get observedAttributes() {
1469
2200
  return ["for", "label", "attribution"];
1470
2201
  }
2202
+ #messages = {};
2203
+ get messages() {
2204
+ return this.#messages;
2205
+ }
2206
+ set messages(messages) {
2207
+ this.#messages = messages ?? {};
2208
+ this.render();
2209
+ }
1471
2210
  attributeChangedCallback() {
1472
2211
  this.resolveControllerFromContext();
1473
2212
  this.render();
1474
2213
  }
1475
2214
  render() {
1476
- const label = this.getAttribute("label") ?? "Map status";
2215
+ const label = this.#messages.label ?? this.getAttribute("label") ?? "Map status";
1477
2216
  const attribution = this.getAttribute("attribution") ?? this.state?.mapPackage?.mapPackageId ?? "Honua";
1478
2217
  this.setShadowHtml(`
1479
2218
  <style>${baseStyles()}${mapStatusStyles()}</style>
1480
2219
  <section class="map-status" part="panel" aria-label="${escapeHtml(label)}">
1481
- <span aria-label="Approximate scale">${escapeHtml(approximateScale(this.state?.viewport))}</span>
1482
- <span aria-label="Attribution">${escapeHtml(attribution)}</span>
1483
- <button type="button" data-fullscreen>Fullscreen</button>
2220
+ <span aria-label="${escapeAttribute(this.#messages.scaleLabel ?? "Approximate scale")}">${escapeHtml(approximateScale(this.state?.viewport))}</span>
2221
+ <span aria-label="${escapeAttribute(this.#messages.attributionLabel ?? "Attribution")}">${escapeHtml(attribution)}</span>
2222
+ <button type="button" data-fullscreen="action">${escapeHtml(this.#messages.fullscreenLabel ?? "Fullscreen")}</button>
1484
2223
  </section>
1485
2224
  `);
1486
2225
  this.shadowRoot?.querySelector("button[data-fullscreen]")?.addEventListener("click", () => {
@@ -1496,7 +2235,7 @@ export class HonuaMapStatusElement extends HonuaElementBase {
1496
2235
  this.dispatchTypedEvent("honua-fullscreen-change", {
1497
2236
  fullscreen: false,
1498
2237
  status: "unsupported",
1499
- message: "Fullscreen is unavailable.",
2238
+ message: this.#messages.fullscreenUnavailable ?? "Fullscreen is unavailable.",
1500
2239
  });
1501
2240
  return;
1502
2241
  }
@@ -1511,22 +2250,32 @@ export class HonuaActionPanelElement extends HonuaElementBase {
1511
2250
  static get observedAttributes() {
1512
2251
  return ["for", "label", "actions"];
1513
2252
  }
2253
+ #messages = {};
2254
+ /** Caller-supplied localized panel label, status, and empty-state text. */
2255
+ get messages() {
2256
+ return this.#messages;
2257
+ }
2258
+ set messages(messages) {
2259
+ this.#messages = messages ?? {};
2260
+ this.render();
2261
+ }
1514
2262
  attributeChangedCallback() {
1515
2263
  this.resolveControllerFromContext();
1516
2264
  this.render();
1517
2265
  }
1518
2266
  render() {
1519
- const label = this.getAttribute("label") ?? "Actions";
2267
+ const label = this.#messages.label ?? this.getAttribute("label") ?? "Actions";
1520
2268
  const actions = parseActions(this.getAttribute("actions"));
2269
+ const status = actions.length > 0 ? "ready" : "unsupported";
1521
2270
  this.setShadowHtml(`
1522
- <style>${baseStyles()}${controlPanelStyles()}</style>
2271
+ <style>${baseStyles()}${controlPanelStyles()}${actionPanelStyles()}</style>
1523
2272
  <section class="control-panel" part="panel" aria-label="${escapeHtml(label)}">
1524
2273
  <div class="control-panel__bar">
1525
2274
  <h2>${escapeHtml(label)}</h2>
1526
- <span>${escapeHtml(actions.length > 0 ? "ready" : "unsupported")}</span>
2275
+ <span>${escapeHtml(this.#messages.status?.[status] ?? status)}</span>
1527
2276
  </div>
1528
2277
  ${actions.length === 0
1529
- ? `<p class="empty" role="status">No actions are configured.</p>`
2278
+ ? `<p class="empty" role="status">${escapeHtml(this.#messages.empty ?? "No actions are configured.")}</p>`
1530
2279
  : `<div class="button-stack">
1531
2280
  ${actions
1532
2281
  .map((action) => `
@@ -1549,33 +2298,71 @@ export class HonuaActionPanelElement extends HonuaElementBase {
1549
2298
  });
1550
2299
  }
1551
2300
  }
2301
+ /**
2302
+ * Every tag this kit owns, keyed for both the blanket
2303
+ * {@link defineHonuaWebComponents} registration and per-tag
2304
+ * {@link defineHonuaWebComponent} lookups (and, transitively, the catalog id
2305
+ * → constructor resolution `../controls/registry.js` uses for cross-kit
2306
+ * registration). Order here is the kit's auto-registration order.
2307
+ */
2308
+ const WEB_COMPONENT_ELEMENTS = new Map([
2309
+ ["honua-map", HonuaMapElement],
2310
+ ["honua-layer-list", HonuaLayerListElement],
2311
+ ["honua-legend", HonuaLegendElement],
2312
+ ["honua-feature-table", HonuaFeatureTableElement],
2313
+ ["honua-search", HonuaSearchElement],
2314
+ ["honua-editor", HonuaEditorElement],
2315
+ ["honua-feature-editor", HonuaFeatureEditorElement],
2316
+ ["honua-chart", HonuaChartElement],
2317
+ ["honua-basemap-control", HonuaBasemapControlElement],
2318
+ ["honua-bookmarks", HonuaBookmarksElement],
2319
+ ["honua-locate-control", HonuaLocateControlElement],
2320
+ ["honua-measure-control", HonuaMeasureControlElement],
2321
+ ["honua-measurement", HonuaMeasurementElement],
2322
+ ["honua-time-slider", HonuaTimeSliderElement],
2323
+ ["honua-sketch-control", HonuaSketchControlElement],
2324
+ ["honua-print-export", HonuaPrintExportElement],
2325
+ ["honua-map-status", HonuaMapStatusElement],
2326
+ ["honua-action-panel", HonuaActionPanelElement],
2327
+ ]);
2328
+ /**
2329
+ * Registers every custom element this kit owns. Skips tags already defined.
2330
+ * Called automatically on import of `./index.js` (`@honua/sdk-js/web-components`)
2331
+ * — NOT on import of this module, which stays side-effect-free (issue #679).
2332
+ */
1552
2333
  export function defineHonuaWebComponents(registry = globalDom.customElements) {
1553
2334
  if (!registry)
1554
2335
  return;
1555
- defineIfMissing(registry, "honua-map", HonuaMapElement);
1556
- defineIfMissing(registry, "honua-layer-list", HonuaLayerListElement);
1557
- defineIfMissing(registry, "honua-legend", HonuaLegendElement);
1558
- defineIfMissing(registry, "honua-feature-table", HonuaFeatureTableElement);
1559
- defineIfMissing(registry, "honua-search", HonuaSearchElement);
1560
- defineIfMissing(registry, "honua-editor", HonuaEditorElement);
1561
- defineIfMissing(registry, "honua-chart", HonuaChartElement);
1562
- defineIfMissing(registry, "honua-basemap-control", HonuaBasemapControlElement);
1563
- defineIfMissing(registry, "honua-bookmarks", HonuaBookmarksElement);
1564
- defineIfMissing(registry, "honua-locate-control", HonuaLocateControlElement);
1565
- defineIfMissing(registry, "honua-measure-control", HonuaMeasureControlElement);
1566
- defineIfMissing(registry, "honua-measurement", HonuaMeasurementElement);
1567
- defineIfMissing(registry, "honua-sketch-control", HonuaSketchControlElement);
1568
- defineIfMissing(registry, "honua-print-export", HonuaPrintExportElement);
1569
- defineIfMissing(registry, "honua-map-status", HonuaMapStatusElement);
1570
- defineIfMissing(registry, "honua-action-panel", HonuaActionPanelElement);
2336
+ for (const [tagName, ctor] of WEB_COMPONENT_ELEMENTS) {
2337
+ defineIfMissing(registry, tagName, ctor);
2338
+ }
2339
+ }
2340
+ /**
2341
+ * Registers a single web-components tag by name (e.g. `"honua-feature-table"`).
2342
+ * Unknown tags are a no-op. Skips the registration when the tag is already
2343
+ * defined. This is the primitive the catalog-driven `registerComponent` /
2344
+ * `registerComponents` APIs in `../controls/registry.js` call for
2345
+ * `web-components`-sourced catalog entries, so a consumer can register one
2346
+ * tag from this kit without the blanket {@link defineHonuaWebComponents} call.
2347
+ */
2348
+ export function defineHonuaWebComponent(tagName, registry = globalDom.customElements) {
2349
+ if (!registry)
2350
+ return;
2351
+ const ctor = WEB_COMPONENT_ELEMENTS.get(tagName);
2352
+ if (ctor)
2353
+ defineIfMissing(registry, tagName, ctor);
1571
2354
  }
1572
2355
  function defineIfMissing(registry, tagName, ctor) {
1573
2356
  if (!registry.get(tagName))
1574
2357
  registry.define(tagName, ctor);
1575
2358
  }
1576
- if (globalDom.customElements) {
1577
- defineHonuaWebComponents(globalDom.customElements);
1578
- }
2359
+ // Deliberately no module-load auto-registration here (issue #679 PR review):
2360
+ // this module must stay side-effect-free on import so `../controls/registry.js`
2361
+ // can dynamically `import()` it for single-tag registration without that
2362
+ // import silently claiming every tag the kit owns. The blanket
2363
+ // auto-registration importing `@honua/sdk-js/web-components` triggers lives
2364
+ // in `./index.js`, which is every existing consumer's actual entry point —
2365
+ // see that module for the `defineHonuaWebComponents()` call.
1579
2366
  function tableModelFromState(state, sourceId, fields, pageSize) {
1580
2367
  const resolvedSourceId = sourceId ?? Object.keys(state?.featuresBySource ?? {})[0];
1581
2368
  const rows = resolvedSourceId ? [...(state?.featuresBySource[resolvedSourceId] ?? [])] : [];
@@ -1674,24 +2461,16 @@ function getElementById(root, id) {
1674
2461
  return root.getElementById(id);
1675
2462
  return typeof document !== "undefined" ? document.getElementById(id) : null;
1676
2463
  }
1677
- function formatCell(value) {
1678
- if (value === null || value === undefined)
1679
- return "";
1680
- if (typeof value === "object")
1681
- return JSON.stringify(value);
1682
- return String(value);
1683
- }
1684
2464
  function modeLabel(mode) {
1685
2465
  return mode.charAt(0).toUpperCase() + mode.slice(1);
1686
2466
  }
2467
+ /**
2468
+ * `<honua-map-status>`'s scale readout. Shares the single scale implementation
2469
+ * with the export pipeline's provenance block (issue #683) so a printed or
2470
+ * snapshotted map can never disagree with the scale the user saw on screen.
2471
+ */
1687
2472
  function approximateScale(viewport) {
1688
- const zoom = viewport?.zoom;
1689
- if (!Number.isFinite(zoom))
1690
- return "Scale unavailable";
1691
- const latitude = viewport?.center?.[1] ?? 0;
1692
- const metersPerPixel = (156543.03392 * Math.cos((latitude * Math.PI) / 180)) / 2 ** Number(zoom);
1693
- const scale = Math.max(1, Math.round(metersPerPixel * 96 * 39.37));
1694
- return `1:${scale.toLocaleString("en-US")}`;
2473
+ return approximateHonuaScaleLabel(viewport) ?? "Scale unavailable";
1695
2474
  }
1696
2475
  function setText(element, value) {
1697
2476
  if (element)
@@ -1765,6 +2544,13 @@ function escapeHtml(value) {
1765
2544
  function escapeAttribute(value) {
1766
2545
  return escapeHtml(value).replace(/'/g, "&#39;");
1767
2546
  }
2547
+ function formatLayerOpacityValue(format, percent) {
2548
+ if (typeof format === "function")
2549
+ return format(percent);
2550
+ if (format !== undefined)
2551
+ return format.replace("{percent}", String(percent));
2552
+ return `${percent}%`;
2553
+ }
1768
2554
  function baseStyles() {
1769
2555
  return `
1770
2556
  :host {
@@ -1806,8 +2592,17 @@ function baseStyles() {
1806
2592
  }
1807
2593
  `;
1808
2594
  }
2595
+ function formatMapVisibleLayers(format, count) {
2596
+ return typeof format === "function"
2597
+ ? format(count)
2598
+ : format
2599
+ ? format.replace("{count}", String(count))
2600
+ : `${count} visible`;
2601
+ }
1809
2602
  function mapStyles() {
1810
2603
  return `
2604
+ :host { direction: inherit; }
2605
+ :host([dir="rtl"]) { direction: rtl; }
1811
2606
  .map {
1812
2607
  background: #dbeafe;
1813
2608
  border: 1px solid var(--honua-ui-border);
@@ -1823,7 +2618,8 @@ function mapStyles() {
1823
2618
  display: flex;
1824
2619
  gap: 8px;
1825
2620
  justify-content: space-between;
1826
- padding: 8px 10px;
2621
+ padding-block: 8px;
2622
+ padding-inline: 10px;
1827
2623
  }
1828
2624
  .map__controls { align-items: center; display: flex; gap: 6px; }
1829
2625
  .icon-button { min-width: 32px; padding: 0; }
@@ -1857,18 +2653,19 @@ function mapStyles() {
1857
2653
  position: absolute;
1858
2654
  }
1859
2655
  .map__renderer .maplibregl-canvas {
1860
- left: 0;
2656
+ inset-block-start: 0;
2657
+ inset-inline-start: 0;
1861
2658
  position: absolute;
1862
- top: 0;
1863
2659
  }
1864
2660
  .map__status {
1865
2661
  background: rgba(255,255,255,0.86);
1866
2662
  border-radius: 6px;
1867
2663
  color: var(--honua-ui-muted);
1868
- left: 10px;
1869
- padding: 4px 7px;
2664
+ inset-block-start: 10px;
2665
+ inset-inline-start: 10px;
2666
+ padding-block: 4px;
2667
+ padding-inline: 7px;
1870
2668
  position: absolute;
1871
- top: 10px;
1872
2669
  z-index: 1;
1873
2670
  }
1874
2671
  .map__status:empty { display: none; }
@@ -1876,21 +2673,52 @@ function mapStyles() {
1876
2673
  .maplibregl-control-container {
1877
2674
  display: none;
1878
2675
  }
2676
+ @media (forced-colors: active), (prefers-contrast: more) {
2677
+ .map {
2678
+ background: Canvas;
2679
+ border-color: CanvasText;
2680
+ color: CanvasText;
2681
+ }
2682
+ .map__chrome, .map__footer, .map__status {
2683
+ background: Canvas;
2684
+ color: CanvasText;
2685
+ }
2686
+ .map__canvas { background: Canvas; }
2687
+ .map__controls button { border-color: ButtonText; color: ButtonText; }
2688
+ }
2689
+ @media (max-width: 320px) {
2690
+ .map { min-width: 0; }
2691
+ .map__chrome, .map__footer {
2692
+ align-items: stretch;
2693
+ flex-direction: column;
2694
+ }
2695
+ .map__controls { justify-content: space-between; width: 100%; }
2696
+ }
1879
2697
  `;
1880
2698
  }
1881
2699
  function listStyles() {
1882
2700
  return `
2701
+ :host { direction: inherit; }
2702
+ :host([dir="rtl"]) { direction: rtl; }
1883
2703
  .panel {
1884
2704
  background: var(--honua-ui-bg);
1885
2705
  border: 1px solid var(--honua-ui-border);
1886
2706
  border-radius: 8px;
1887
2707
  margin: 0;
1888
- padding: 10px;
2708
+ padding-block: 10px;
2709
+ padding-inline: 10px;
1889
2710
  }
1890
- legend { font-weight: 650; padding: 0 4px; }
2711
+ legend { font-weight: 650; padding-block: 0; padding-inline: 4px; }
1891
2712
  .stack { display: grid; gap: 8px; }
1892
2713
  .check-row { align-items: center; display: flex; gap: 8px; min-height: 28px; }
1893
- .legend { display: grid; gap: 8px; list-style: none; margin: 10px 0 0; padding: 0; }
2714
+ .legend {
2715
+ display: grid;
2716
+ gap: 8px;
2717
+ list-style: none;
2718
+ margin-block-start: 10px;
2719
+ margin-inline: 0;
2720
+ padding: 0;
2721
+ }
1894
2722
  .legend li { align-items: center; display: flex; gap: 8px; min-height: 24px; }
1895
2723
  .swatch {
1896
2724
  background: var(--swatch);
@@ -1903,6 +2731,23 @@ function listStyles() {
1903
2731
  img.swatch { object-fit: cover; }
1904
2732
  `;
1905
2733
  }
2734
+ function legendStyles() {
2735
+ return `
2736
+ @media (max-width: 320px) {
2737
+ .panel { min-width: 0; }
2738
+ .legend li { align-items: flex-start; min-width: 0; }
2739
+ .legend li > span:last-child { min-width: 0; overflow-wrap: anywhere; }
2740
+ }
2741
+ @media (forced-colors: active), (prefers-contrast: more) {
2742
+ .legend li { color: CanvasText; }
2743
+ .legend .swatch {
2744
+ background: Canvas;
2745
+ border: 2px solid CanvasText;
2746
+ forced-color-adjust: none;
2747
+ }
2748
+ }
2749
+ `;
2750
+ }
1906
2751
  function layerListStyles() {
1907
2752
  return `
1908
2753
  .layer-row {
@@ -1915,14 +2760,33 @@ function layerListStyles() {
1915
2760
  align-items: center;
1916
2761
  display: flex;
1917
2762
  gap: 6px;
1918
- padding-left: 24px;
2763
+ padding-inline-start: 24px;
1919
2764
  }
1920
2765
  .opacity { flex: 1; min-width: 60px; }
1921
2766
  .move { min-width: 32px; padding: 0; }
2767
+ @media (forced-colors: active), (prefers-contrast: more) {
2768
+ .layer-row {
2769
+ border-top-color: CanvasText;
2770
+ }
2771
+ .layer-row input[type="checkbox"]:checked + span {
2772
+ color: Highlight;
2773
+ }
2774
+ .layer-row__tools button {
2775
+ border-color: ButtonText;
2776
+ }
2777
+ }
2778
+ @media (max-width: 320px) {
2779
+ .panel { min-width: 0; }
2780
+ .layer-row__tools { flex-wrap: wrap; padding-left: 0; }
2781
+ .opacity { min-width: 0; width: 100%; }
2782
+ .move { flex: 1 1 32px; }
2783
+ }
1922
2784
  `;
1923
2785
  }
1924
2786
  function tableStyles() {
1925
2787
  return `
2788
+ :host { direction: inherit; }
2789
+ :host([dir="rtl"]) { direction: rtl; }
1926
2790
  .table-panel {
1927
2791
  border: 1px solid var(--honua-ui-border);
1928
2792
  border-radius: 8px;
@@ -1933,15 +2797,17 @@ function tableStyles() {
1933
2797
  background: var(--honua-ui-surface);
1934
2798
  display: flex;
1935
2799
  justify-content: space-between;
1936
- padding: 8px 10px;
2800
+ padding-block: 8px;
2801
+ padding-inline: 10px;
1937
2802
  }
1938
2803
  .table-wrap { max-height: 300px; overflow: auto; }
1939
2804
  table { border-collapse: collapse; min-width: 100%; table-layout: fixed; }
1940
2805
  th, td {
1941
2806
  border-top: 1px solid var(--honua-ui-border);
1942
2807
  overflow: hidden;
1943
- padding: 7px 9px;
1944
- text-align: left;
2808
+ padding-block: 7px;
2809
+ padding-inline: 9px;
2810
+ text-align: start;
1945
2811
  text-overflow: ellipsis;
1946
2812
  white-space: nowrap;
1947
2813
  }
@@ -1949,63 +2815,133 @@ function tableStyles() {
1949
2815
  tr[aria-selected="true"] td { background: #dbeafe; }
1950
2816
  tbody tr[data-feature-id] { cursor: pointer; }
1951
2817
  tbody tr[data-feature-id]:focus { outline: 2px solid var(--honua-ui-accent); outline-offset: -2px; }
2818
+ @media (forced-colors: active), (prefers-contrast: more) {
2819
+ .table-panel {
2820
+ background: Canvas;
2821
+ border-color: CanvasText;
2822
+ color: CanvasText;
2823
+ }
2824
+ .table-panel__bar, th { background: Canvas; color: CanvasText; }
2825
+ th, td { border-top-color: CanvasText; }
2826
+ tr[aria-selected="true"] td { background: Highlight; color: HighlightText; }
2827
+ tbody tr[data-feature-id]:focus { outline-color: Highlight; }
2828
+ }
1952
2829
  `;
1953
2830
  }
1954
2831
  function searchStyles() {
1955
2832
  return `
2833
+ :host { direction: inherit; }
2834
+ :host([dir="rtl"]) { direction: rtl; }
1956
2835
  .search {
1957
2836
  border: 1px solid var(--honua-ui-border);
1958
2837
  border-radius: 8px;
1959
- padding: 10px;
2838
+ min-width: 0;
2839
+ padding-block: 10px;
2840
+ padding-inline: 10px;
2841
+ width: 100%;
1960
2842
  }
1961
- form { display: grid; gap: 8px; grid-template-columns: minmax(120px, 1fr) auto; }
2843
+ form button { padding-block: 0; padding-inline: 10px; }
2844
+ form { display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr) auto; min-width: 0; }
1962
2845
  input {
1963
2846
  border: 1px solid var(--honua-ui-border);
1964
2847
  border-radius: 6px;
1965
2848
  min-height: 32px;
1966
2849
  min-width: 0;
1967
- padding: 0 9px;
2850
+ padding-block: 0;
2851
+ padding-inline: 9px;
2852
+ }
2853
+ .results {
2854
+ display: grid;
2855
+ gap: 6px;
2856
+ list-style: none;
2857
+ margin-block-start: 10px;
2858
+ margin-inline: 0;
2859
+ padding: 0;
1968
2860
  }
1969
- .results { display: grid; gap: 6px; list-style: none; margin: 10px 0 0; padding: 0; }
1970
- .results button { text-align: left; width: 100%; }
2861
+ .results button { text-align: start; width: 100%; }
1971
2862
  .suggestions {
1972
2863
  border: 1px solid var(--honua-ui-border);
1973
2864
  border-radius: 6px;
1974
2865
  display: grid;
1975
2866
  list-style: none;
1976
- margin: 6px 0 0;
2867
+ margin-block-start: 6px;
2868
+ margin-inline: 0;
1977
2869
  overflow: hidden;
1978
2870
  padding: 0;
1979
2871
  }
1980
2872
  .suggestions[hidden] { display: none; }
1981
- .suggestions [role="option"] { cursor: pointer; padding: 6px 9px; }
2873
+ .suggestions [role="option"] {
2874
+ cursor: pointer;
2875
+ padding-block: 6px;
2876
+ padding-inline: 9px;
2877
+ }
1982
2878
  .suggestions [role="option"][aria-selected="true"] {
1983
2879
  background: var(--honua-ui-accent);
1984
2880
  color: var(--honua-ui-accent-fg);
1985
2881
  }
1986
- .status { color: var(--honua-ui-muted); margin: 6px 0 0; }
2882
+ .status { color: var(--honua-ui-muted); margin-block-start: 6px; margin-inline: 0; }
1987
2883
  .status:empty { display: none; }
2884
+ @media (max-width: 320px) {
2885
+ form { grid-template-columns: minmax(0, 1fr); }
2886
+ form button { width: 100%; }
2887
+ }
2888
+ @media (forced-colors: active), (prefers-contrast: more) {
2889
+ .search, input, button, .suggestions { border-color: CanvasText; }
2890
+ .suggestions [role="option"][aria-selected="true"] {
2891
+ background: Highlight;
2892
+ color: HighlightText;
2893
+ }
2894
+ }
1988
2895
  `;
1989
2896
  }
1990
2897
  function editorStyles() {
1991
2898
  return `
2899
+ :host { direction: inherit; }
2900
+ :host([dir="rtl"]) { direction: rtl; }
1992
2901
  .editor {
1993
2902
  border: 1px solid var(--honua-ui-border);
1994
2903
  border-radius: 8px;
1995
- padding: 10px;
2904
+ padding-block: 10px;
2905
+ padding-inline: 10px;
1996
2906
  }
2907
+ button { padding-block: 0; padding-inline: 10px; }
1997
2908
  .editor__bar, .editor__actions { align-items: center; display: flex; gap: 8px; justify-content: space-between; }
1998
2909
  .editor__actions { justify-content: flex-start; }
1999
- .selection { margin: 10px 0 4px; }
2000
- .muted { margin: 0 0 10px; }
2910
+ .selection { margin-block: 10px 4px; margin-inline: 0; }
2911
+ .muted { margin-block: 0 10px; margin-inline: 0; }
2912
+ @media (forced-colors: active), (prefers-contrast: more) {
2913
+ .editor {
2914
+ background: Canvas;
2915
+ border-color: CanvasText;
2916
+ color: CanvasText;
2917
+ }
2918
+ .editor__actions button {
2919
+ border-color: ButtonText;
2920
+ color: ButtonText;
2921
+ }
2922
+ }
2923
+ @media (max-width: 320px) {
2924
+ .editor { min-width: 0; }
2925
+ .editor__bar { align-items: flex-start; flex-direction: column; }
2926
+ .editor__actions { flex-wrap: wrap; }
2927
+ .editor__actions button {
2928
+ flex: 1 1 120px;
2929
+ min-width: 0;
2930
+ overflow-wrap: anywhere;
2931
+ white-space: normal;
2932
+ }
2933
+ }
2001
2934
  `;
2002
2935
  }
2003
2936
  function chartStyles() {
2004
2937
  return `
2938
+ :host { direction: inherit; }
2939
+ :host([dir="rtl"]) { direction: rtl; }
2005
2940
  .chart {
2006
2941
  border: 1px solid var(--honua-ui-border);
2007
2942
  border-radius: 8px;
2008
- padding: 10px;
2943
+ padding-block: 10px;
2944
+ padding-inline: 10px;
2009
2945
  }
2010
2946
  .bars { display: grid; gap: 8px; margin-top: 10px; }
2011
2947
  .bar-row { align-items: center; display: grid; gap: 8px; grid-template-columns: minmax(70px, 1fr) 3fr auto; }
@@ -2014,14 +2950,32 @@ function chartStyles() {
2014
2950
  background: var(--bar-color);
2015
2951
  border-radius: inherit;
2016
2952
  content: "";
2017
- inset: 0 auto 0 0;
2953
+ inset-block: 0;
2954
+ inset-inline-end: auto;
2955
+ inset-inline-start: 0;
2018
2956
  position: absolute;
2019
2957
  width: var(--bar);
2020
2958
  }
2959
+ @media (forced-colors: active), (prefers-contrast: more) {
2960
+ .chart {
2961
+ background: Canvas;
2962
+ border-color: CanvasText;
2963
+ color: CanvasText;
2964
+ }
2965
+ .bar { background: ButtonFace; outline: 1px solid ButtonText; }
2966
+ .bar::before { background: Highlight; }
2967
+ }
2968
+ @media (max-width: 320px) {
2969
+ .chart { min-width: 0; }
2970
+ .bar-row { grid-template-columns: minmax(0, 1fr); }
2971
+ .bar { min-width: 0; width: 100%; }
2972
+ }
2021
2973
  `;
2022
2974
  }
2023
2975
  function controlPanelStyles() {
2024
2976
  return `
2977
+ :host { direction: inherit; }
2978
+ :host([dir="rtl"]) { direction: rtl; }
2025
2979
  .control-panel {
2026
2980
  background: var(--honua-ui-bg);
2027
2981
  border: 1px solid var(--honua-ui-border);
@@ -2029,13 +2983,24 @@ function controlPanelStyles() {
2029
2983
  display: grid;
2030
2984
  gap: 10px;
2031
2985
  min-width: 180px;
2032
- padding: 10px;
2986
+ max-width: 100%;
2987
+ min-inline-size: 0;
2988
+ padding-block: 10px;
2989
+ padding-inline: 10px;
2033
2990
  }
2991
+ .control-panel > * { min-width: 0; max-width: 100%; }
2992
+ button { padding-block: 0; padding-inline: 10px; }
2034
2993
  .control-panel__bar {
2035
2994
  align-items: center;
2036
2995
  display: flex;
2037
2996
  gap: 8px;
2038
2997
  justify-content: space-between;
2998
+ min-width: 0;
2999
+ }
3000
+ .control-panel__bar h2,
3001
+ .control-panel__bar span {
3002
+ min-width: 0;
3003
+ overflow-wrap: anywhere;
2039
3004
  }
2040
3005
  .control-panel__bar span {
2041
3006
  color: var(--honua-ui-muted);
@@ -2047,7 +3012,7 @@ function controlPanelStyles() {
2047
3012
  }
2048
3013
  .button-stack button {
2049
3014
  justify-content: flex-start;
2050
- text-align: left;
3015
+ text-align: start;
2051
3016
  }
2052
3017
  .segmented {
2053
3018
  display: grid;
@@ -2056,7 +3021,10 @@ function controlPanelStyles() {
2056
3021
  }
2057
3022
  .segmented button {
2058
3023
  min-width: 0;
2059
- padding: 0 8px;
3024
+ overflow-wrap: anywhere;
3025
+ padding-block: 0;
3026
+ padding-inline: 8px;
3027
+ white-space: normal;
2060
3028
  }
2061
3029
  button[aria-pressed="true"] {
2062
3030
  background: var(--honua-ui-accent);
@@ -2065,6 +3033,93 @@ function controlPanelStyles() {
2065
3033
  }
2066
3034
  p {
2067
3035
  margin: 0;
3036
+ overflow-wrap: anywhere;
3037
+ }
3038
+ @media (max-width: 240px) {
3039
+ .control-panel {
3040
+ min-width: 0;
3041
+ }
3042
+ .control-panel__bar {
3043
+ align-items: flex-start;
3044
+ flex-direction: column;
3045
+ }
3046
+ .control-panel__bar h2 {
3047
+ min-width: 0;
3048
+ overflow-wrap: anywhere;
3049
+ }
3050
+ .segmented {
3051
+ grid-template-columns: 1fr;
3052
+ }
3053
+ .button-stack button,
3054
+ .segmented button,
3055
+ .control-panel > button {
3056
+ width: 100%;
3057
+ }
3058
+ }
3059
+ @media (min-width: 241px) and (max-width: 320px) {
3060
+ .segmented { grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); }
3061
+ }
3062
+ @media (forced-colors: active), (prefers-contrast: more) {
3063
+ .control-panel {
3064
+ background: Canvas;
3065
+ border-color: CanvasText;
3066
+ color: CanvasText;
3067
+ }
3068
+ .control-panel__bar span { color: CanvasText; }
3069
+ .button-stack button {
3070
+ background: ButtonFace;
3071
+ border-color: ButtonText;
3072
+ color: ButtonText;
3073
+ }
3074
+ }
3075
+ `;
3076
+ }
3077
+ function basemapControlStyles() {
3078
+ return `
3079
+ @media (forced-colors: active), (prefers-contrast: more) {
3080
+ .control-panel {
3081
+ background: Canvas;
3082
+ border-color: CanvasText;
3083
+ color: CanvasText;
3084
+ }
3085
+ .control-panel__bar span, .empty { color: CanvasText; }
3086
+ .segmented button {
3087
+ background: ButtonFace;
3088
+ border: 2px solid ButtonText;
3089
+ color: ButtonText;
3090
+ forced-color-adjust: none;
3091
+ }
3092
+ .segmented button[aria-pressed="true"] {
3093
+ background: Highlight;
3094
+ border-color: Highlight;
3095
+ color: HighlightText;
3096
+ }
3097
+ .segmented button:focus-visible {
3098
+ outline: 2px solid Highlight;
3099
+ outline-offset: 2px;
3100
+ }
3101
+ }
3102
+ `;
3103
+ }
3104
+ function actionPanelStyles() {
3105
+ return `
3106
+ @media (forced-colors: active), (prefers-contrast: more) {
3107
+ .control-panel {
3108
+ background: Canvas;
3109
+ border-color: CanvasText;
3110
+ color: CanvasText;
3111
+ }
3112
+ .control-panel__bar span, .empty { color: CanvasText; }
3113
+ .button-stack button {
3114
+ background: ButtonFace;
3115
+ border: 2px solid ButtonText;
3116
+ color: ButtonText;
3117
+ forced-color-adjust: none;
3118
+ }
3119
+ .button-stack button:disabled {
3120
+ border-color: GrayText;
3121
+ color: GrayText;
3122
+ }
2068
3123
  }
2069
3124
  `;
2070
3125
  }
@@ -2076,15 +3131,47 @@ function mapStatusStyles() {
2076
3131
  border: 1px solid var(--honua-ui-border);
2077
3132
  border-radius: 8px;
2078
3133
  display: flex;
3134
+ direction: inherit;
2079
3135
  flex-wrap: wrap;
2080
3136
  gap: 8px;
2081
3137
  justify-content: space-between;
2082
3138
  min-height: 42px;
2083
- padding: 6px 8px;
3139
+ padding-block: 6px;
3140
+ padding-inline: 8px;
2084
3141
  }
2085
3142
  span {
2086
3143
  color: var(--honua-ui-muted);
2087
3144
  min-width: 0;
3145
+ text-align: start;
3146
+ }
3147
+ .map-status button { text-align: start; }
3148
+ @media (forced-colors: active), (prefers-contrast: more) {
3149
+ .map-status {
3150
+ background: Canvas;
3151
+ border-color: CanvasText;
3152
+ color: CanvasText;
3153
+ }
3154
+ .map-status span { color: CanvasText; }
3155
+ .map-status button {
3156
+ background: ButtonFace;
3157
+ border: 2px solid ButtonText;
3158
+ color: ButtonText;
3159
+ forced-color-adjust: none;
3160
+ }
3161
+ :host([data-status="unsupported"]) .map-status {
3162
+ border-color: GrayText;
3163
+ color: GrayText;
3164
+ }
3165
+ :host([data-status="unsupported"]) .map-status span { color: GrayText; }
3166
+ :host([data-status="error"]) .map-status {
3167
+ border-color: Mark;
3168
+ color: MarkText;
3169
+ }
3170
+ :host([data-status="error"]) .map-status span { color: MarkText; }
3171
+ }
3172
+ @media (max-width: 240px) {
3173
+ .map-status { align-items: flex-start; flex-direction: column; }
3174
+ .map-status button { inline-size: 100%; }
2088
3175
  }
2089
3176
  `;
2090
3177
  }