e11y 0.2.0 → 1.1.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 (288) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +130 -10
  3. data/CHANGELOG.md +80 -1
  4. data/CLAUDE.md +168 -0
  5. data/CONTRIBUTING.md +640 -0
  6. data/README.md +165 -701
  7. data/RELEASE.md +41 -12
  8. data/Rakefile +249 -57
  9. data/config/README.md +1 -1
  10. data/config/loki-local-config.yaml +12 -0
  11. data/config/otel-collector-config.yaml +44 -0
  12. data/cucumber.yml +1 -0
  13. data/docker-compose.yml +18 -2
  14. data/docs/ADAPTERS.md +76 -0
  15. data/docs/ADAPTIVE_SAMPLING.md +59 -0
  16. data/docs/COMPARISON.md +104 -0
  17. data/docs/CONFIGURATION.md +52 -0
  18. data/docs/DISTRIBUTED_TRACING.md +44 -0
  19. data/docs/LIMITATIONS.md +13 -0
  20. data/docs/METRICS_DSL.md +84 -0
  21. data/docs/PERFORMANCE.md +60 -0
  22. data/docs/PII_FILTERING.md +40 -0
  23. data/docs/PRESETS.md +65 -0
  24. data/docs/QUICK-START.md +546 -587
  25. data/docs/RAILS_INTEGRATION.md +79 -0
  26. data/docs/SCHEMA_VALIDATION.md +63 -0
  27. data/docs/SLO-PROMQL-ALERTS.md +161 -0
  28. data/docs/TESTING.md +69 -0
  29. data/docs/{ADR-001-architecture.md → architecture/ADR-001-architecture.md} +36 -65
  30. data/docs/{ADR-002-metrics-yabeda.md → architecture/ADR-002-metrics-yabeda.md} +62 -236
  31. data/docs/architecture/ADR-003-slo-observability.md +1402 -0
  32. data/docs/{ADR-004-adapter-architecture.md → architecture/ADR-004-adapter-architecture.md} +163 -146
  33. data/docs/{ADR-005-tracing-context.md → architecture/ADR-005-tracing-context.md} +10 -9
  34. data/docs/{ADR-006-security-compliance.md → architecture/ADR-006-security-compliance.md} +184 -191
  35. data/docs/{ADR-007-opentelemetry-integration.md → architecture/ADR-007-opentelemetry-integration.md} +3 -21
  36. data/docs/{ADR-008-rails-integration.md → architecture/ADR-008-rails-integration.md} +182 -743
  37. data/docs/{ADR-009-cost-optimization.md → architecture/ADR-009-cost-optimization.md} +45 -54
  38. data/docs/architecture/ADR-010-developer-experience.md +522 -0
  39. data/docs/{ADR-011-testing-strategy.md → architecture/ADR-011-testing-strategy.md} +44 -86
  40. data/docs/{ADR-012-event-evolution.md → architecture/ADR-012-event-evolution.md} +11 -11
  41. data/docs/{ADR-013-reliability-error-handling.md → architecture/ADR-013-reliability-error-handling.md} +37 -12
  42. data/docs/{ADR-014-event-driven-slo.md → architecture/ADR-014-event-driven-slo.md} +12 -24
  43. data/docs/{ADR-015-middleware-order.md → architecture/ADR-015-middleware-order.md} +43 -59
  44. data/docs/{ADR-016-self-monitoring-slo.md → architecture/ADR-016-self-monitoring-slo.md} +58 -355
  45. data/docs/{ADR-017-multi-rails-compatibility.md → architecture/ADR-017-multi-rails-compatibility.md} +4 -11
  46. data/docs/architecture/ADR-018-memory-optimization.md +366 -0
  47. data/docs/{ADR-INDEX.md → architecture/ADR-INDEX.md} +11 -6
  48. data/docs/plans/2026-03-20-browser-overlay-svelte.md +281 -0
  49. data/docs/{00-ICP-AND-TIMELINE.md → prd/00-ICP-AND-TIMELINE.md} +6 -6
  50. data/docs/{01-SCALE-REQUIREMENTS.md → prd/01-SCALE-REQUIREMENTS.md} +6 -6
  51. data/docs/prd/01-overview-vision.md +19 -14
  52. data/docs/use_cases/README.md +22 -23
  53. data/docs/use_cases/UC-001-request-scoped-debug-buffering.md +50 -44
  54. data/docs/use_cases/UC-002-business-event-tracking.md +26 -95
  55. data/docs/use_cases/UC-003-event-metrics.md +66 -0
  56. data/docs/use_cases/UC-004-zero-config-slo-tracking.md +33 -684
  57. data/docs/use_cases/UC-005-sentry-integration.md +13 -15
  58. data/docs/use_cases/UC-006-trace-context-management.md +30 -28
  59. data/docs/use_cases/UC-007-pii-filtering.md +35 -87
  60. data/docs/use_cases/UC-008-opentelemetry-integration.md +51 -89
  61. data/docs/use_cases/UC-009-multi-service-tracing.md +30 -178
  62. data/docs/use_cases/UC-010-background-job-tracking.md +24 -91
  63. data/docs/use_cases/UC-011-rate-limiting.md +95 -168
  64. data/docs/use_cases/UC-012-audit-trail.md +21 -46
  65. data/docs/use_cases/UC-013-high-cardinality-protection.md +29 -167
  66. data/docs/use_cases/UC-014-adaptive-sampling.md +2 -2
  67. data/docs/use_cases/UC-015-cost-optimization.md +46 -99
  68. data/docs/use_cases/UC-016-rails-logger-migration.md +39 -213
  69. data/docs/use_cases/UC-017-local-development.md +203 -777
  70. data/docs/use_cases/UC-018-testing-events.md +3 -3
  71. data/docs/use_cases/UC-019-retention-based-routing.md +53 -106
  72. data/docs/use_cases/UC-020-event-versioning.md +8 -9
  73. data/docs/use_cases/UC-021-error-handling-retry-dlq.md +18 -22
  74. data/docs/use_cases/UC-022-event-registry.md +15 -21
  75. data/docs/use_cases/backlog.md +119 -87
  76. data/e11y.gemspec +2 -2
  77. data/gems/e11y-devtools/README.md +158 -0
  78. data/gems/e11y-devtools/config/routes.rb +15 -0
  79. data/gems/e11y-devtools/e11y-devtools.gemspec +25 -0
  80. data/gems/e11y-devtools/exe/e11y +34 -0
  81. data/gems/e11y-devtools/frontend/.gitignore +24 -0
  82. data/gems/e11y-devtools/frontend/README.md +51 -0
  83. data/gems/e11y-devtools/frontend/index.html +14 -0
  84. data/gems/e11y-devtools/frontend/package-lock.json +3707 -0
  85. data/gems/e11y-devtools/frontend/package.json +28 -0
  86. data/gems/e11y-devtools/frontend/public/mocks/v1/events/recent.json +4205 -0
  87. data/gems/e11y-devtools/frontend/public/mocks/v1/interactions.json +194 -0
  88. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/0a2e04027cfa22d014bc22e8b27cd913/events.json +86 -0
  89. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/0e1543af6a630fb3af6b52283154b3e0/events.json +169 -0
  90. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/1838b691faa49564f97db8592ff3978d/events.json +78 -0
  91. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/29f198f6588dacffb687777eb5f8f118/events.json +197 -0
  92. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/34bc3c9c0097de28a7a6f99b90a8e7bc/events.json +194 -0
  93. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/3ba6c20d068ab9cee00e51b180e66444/events.json +184 -0
  94. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/435bfd8f17b9009146a79812d7c3726d/events.json +144 -0
  95. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/4c7676e3fe668e99edb2b94d7d5678a9/events.json +222 -0
  96. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/6daf0d47974bedfc55d5de7004a3ea9f/events.json +194 -0
  97. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/8a81ada42834d15f287bb40010043605/events.json +194 -0
  98. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/8c0a98900edaae105469df8daedccf02/events.json +198 -0
  99. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/8e4f645180f8a7d1dce426b07380466b/events.json +222 -0
  100. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/93db346fa5d44a032605a13b627f4b80/events.json +128 -0
  101. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/98ff6146faf7bd9be8bd03a8275817ba/events.json +223 -0
  102. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/9997ddd0247bc7e25f2ca7a5c415c93d/events.json +197 -0
  103. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/99e35f8ef3baedd798cc4fd085980ad9/events.json +194 -0
  104. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/b4f3095c1909924cbc98889a86c83d6d/events.json +131 -0
  105. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/b54b7fc32b7575a7110de809d11ccda0/events.json +128 -0
  106. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/c0b48033fa06746bcc5886745e053cff/events.json +169 -0
  107. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/c44649ac76701b4558927cd2305ab535/events.json +169 -0
  108. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/d601ae3320057580a39dbdac2edfdf4a/events.json +248 -0
  109. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/e67e724bab422d2b52eeb49635e512e1/events.json +194 -0
  110. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/e6c72765a28f158a8485b35fa63f73da/events.json +194 -0
  111. data/gems/e11y-devtools/frontend/public/mocks/v1/traces/f541b87405c9a54819b18ebe529f6419/events.json +194 -0
  112. data/gems/e11y-devtools/frontend/scripts/generate_mocks.rb +397 -0
  113. data/gems/e11y-devtools/frontend/src/App.svelte +827 -0
  114. data/gems/e11y-devtools/frontend/src/components/Fab.svelte +19 -0
  115. data/gems/e11y-devtools/frontend/src/components/FilterBar.svelte +38 -0
  116. data/gems/e11y-devtools/frontend/src/components/FullscreenPanel.svelte +82 -0
  117. data/gems/e11y-devtools/frontend/src/components/InteractionsTimeline.svelte +264 -0
  118. data/gems/e11y-devtools/frontend/src/components/RecentHistogram.svelte +354 -0
  119. data/gems/e11y-devtools/frontend/src/lib/api.ts +37 -0
  120. data/gems/e11y-devtools/frontend/src/lib/eventIdentity.ts +12 -0
  121. data/gems/e11y-devtools/frontend/src/lib/format.ts +37 -0
  122. data/gems/e11y-devtools/frontend/src/lib/listFilter.ts +43 -0
  123. data/gems/e11y-devtools/frontend/src/lib/recentVolume.ts +80 -0
  124. data/gems/e11y-devtools/frontend/src/lib/router.ts +12 -0
  125. data/gems/e11y-devtools/frontend/src/lib/transitions.ts +34 -0
  126. data/gems/e11y-devtools/frontend/src/lib/viewportOrigin.ts +25 -0
  127. data/gems/e11y-devtools/frontend/src/main.ts +8 -0
  128. data/gems/e11y-devtools/frontend/src/overlay-entry.ts +24 -0
  129. data/gems/e11y-devtools/frontend/src/overlay.css +1080 -0
  130. data/gems/e11y-devtools/frontend/svelte.config.js +2 -0
  131. data/gems/e11y-devtools/frontend/test_puppeteer.js +41 -0
  132. data/gems/e11y-devtools/frontend/test_scale.js +3 -0
  133. data/gems/e11y-devtools/frontend/tsconfig.app.json +21 -0
  134. data/gems/e11y-devtools/frontend/tsconfig.json +7 -0
  135. data/gems/e11y-devtools/frontend/tsconfig.node.json +26 -0
  136. data/gems/e11y-devtools/frontend/vite.config.ts +36 -0
  137. data/gems/e11y-devtools/lib/e11y/devtools/mcp/server.rb +96 -0
  138. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tool_base.rb +25 -0
  139. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/clear.rb +31 -0
  140. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/errors.rb +35 -0
  141. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/event_detail.rb +33 -0
  142. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/events_by_trace.rb +33 -0
  143. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/interactions.rb +40 -0
  144. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/recent_events.rb +34 -0
  145. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/search.rb +34 -0
  146. data/gems/e11y-devtools/lib/e11y/devtools/mcp/tools/stats.rb +30 -0
  147. data/gems/e11y-devtools/lib/e11y/devtools/overlay/assets/overlay.js +20 -0
  148. data/gems/e11y-devtools/lib/e11y/devtools/overlay/controller.rb +94 -0
  149. data/gems/e11y-devtools/lib/e11y/devtools/overlay/engine.rb +26 -0
  150. data/gems/e11y-devtools/lib/e11y/devtools/overlay/middleware.rb +80 -0
  151. data/gems/e11y-devtools/lib/e11y/devtools/overlay/rails_controller.rb +67 -0
  152. data/gems/e11y-devtools/lib/e11y/devtools/tui/app.rb +262 -0
  153. data/gems/e11y-devtools/lib/e11y/devtools/tui/grouping.rb +66 -0
  154. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_detail.rb +62 -0
  155. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/event_list.rb +70 -0
  156. data/gems/e11y-devtools/lib/e11y/devtools/tui/widgets/interaction_list.rb +47 -0
  157. data/gems/e11y-devtools/lib/e11y/devtools/version.rb +8 -0
  158. data/gems/e11y-devtools/lib/e11y/devtools.rb +13 -0
  159. data/gems/e11y-devtools/spec/e11y/devtools/mcp/tools_spec.rb +107 -0
  160. data/gems/e11y-devtools/spec/e11y/devtools/overlay/controller_spec.rb +91 -0
  161. data/gems/e11y-devtools/spec/e11y/devtools/overlay/middleware_spec.rb +46 -0
  162. data/gems/e11y-devtools/spec/e11y/devtools/tui/app_spec.rb +85 -0
  163. data/gems/e11y-devtools/spec/e11y/devtools/tui/grouping_spec.rb +64 -0
  164. data/gems/e11y-devtools/spec/spec_helper.rb +5 -0
  165. data/gems/e11y-devtools/spec/tui/widgets/event_list_spec.rb +44 -0
  166. data/gems/e11y-devtools/spec/tui/widgets/interaction_list_spec.rb +62 -0
  167. data/lib/e11y/adapters/audit_encrypted.rb +53 -11
  168. data/lib/e11y/adapters/base.rb +33 -34
  169. data/lib/e11y/adapters/dev_log/file_store.rb +143 -0
  170. data/lib/e11y/adapters/dev_log/query.rb +219 -0
  171. data/lib/e11y/adapters/dev_log.rb +118 -0
  172. data/lib/e11y/adapters/file.rb +3 -6
  173. data/lib/e11y/adapters/in_memory.rb +52 -5
  174. data/lib/e11y/adapters/in_memory_test.rb +29 -0
  175. data/lib/e11y/adapters/loki.rb +58 -23
  176. data/lib/e11y/adapters/null.rb +82 -0
  177. data/lib/e11y/adapters/opentelemetry_collector.rb +183 -0
  178. data/lib/e11y/adapters/otel_logs.rb +136 -23
  179. data/lib/e11y/adapters/sentry.rb +4 -7
  180. data/lib/e11y/adapters/stdout.rb +73 -7
  181. data/lib/e11y/adapters/yabeda.rb +153 -29
  182. data/lib/e11y/buffers/adaptive_buffer.rb +3 -17
  183. data/lib/e11y/buffers/{request_scoped_buffer.rb → ephemeral_buffer.rb} +72 -58
  184. data/lib/e11y/buffers/ring_buffer.rb +3 -16
  185. data/lib/e11y/configuration.rb +272 -0
  186. data/lib/e11y/console.rb +10 -17
  187. data/lib/e11y/current.rb +53 -1
  188. data/lib/e11y/debug/pipeline_inspector.rb +96 -0
  189. data/lib/e11y/documentation/generator.rb +48 -0
  190. data/lib/e11y/event/base.rb +176 -82
  191. data/lib/e11y/event/value_sampling_config.rb +1 -5
  192. data/lib/e11y/events/rails/database/query.rb +1 -4
  193. data/lib/e11y/events/rails/job/failed.rb +2 -0
  194. data/lib/e11y/instruments/active_job.rb +44 -12
  195. data/lib/e11y/instruments/rails_instrumentation.rb +49 -24
  196. data/lib/e11y/instruments/sidekiq.rb +135 -31
  197. data/lib/e11y/linters/base.rb +11 -0
  198. data/lib/e11y/linters/pii/pii_declaration_linter.rb +120 -0
  199. data/lib/e11y/linters/slo/config_consistency_linter.rb +76 -0
  200. data/lib/e11y/linters/slo/explicit_declaration_linter.rb +36 -0
  201. data/lib/e11y/linters/slo/slo_status_from_linter.rb +41 -0
  202. data/lib/e11y/logger/bridge.rb +26 -7
  203. data/lib/e11y/metrics/cardinality_protection.rb +10 -15
  204. data/lib/e11y/metrics/cardinality_tracker.rb +16 -6
  205. data/lib/e11y/metrics/registry.rb +3 -5
  206. data/lib/e11y/metrics/test_backend.rb +62 -0
  207. data/lib/e11y/metrics.rb +56 -10
  208. data/lib/e11y/middleware/adapter_resolver.rb +40 -0
  209. data/lib/e11y/middleware/audit_signing.rb +43 -6
  210. data/lib/e11y/middleware/baggage_protection.rb +75 -0
  211. data/lib/e11y/middleware/dev_log_source.rb +24 -0
  212. data/lib/e11y/middleware/event_slo.rb +23 -9
  213. data/lib/e11y/middleware/otel_span.rb +23 -0
  214. data/lib/e11y/middleware/pii_filter.rb +104 -75
  215. data/lib/e11y/middleware/rate_limiting.rb +54 -27
  216. data/lib/e11y/middleware/request.rb +70 -23
  217. data/lib/e11y/middleware/routing.rb +78 -21
  218. data/lib/e11y/middleware/sampling.rb +66 -17
  219. data/lib/e11y/middleware/self_monitoring_emit.rb +39 -0
  220. data/lib/e11y/middleware/trace_context.rb +45 -10
  221. data/lib/e11y/middleware/track_latency.rb +34 -0
  222. data/lib/e11y/middleware/validation.rb +7 -16
  223. data/lib/e11y/middleware/versioning.rb +26 -22
  224. data/lib/e11y/opentelemetry/semantic_conventions.rb +109 -0
  225. data/lib/e11y/opentelemetry/span_creator.rb +142 -0
  226. data/lib/e11y/pii/patterns.rb +12 -1
  227. data/lib/e11y/pipeline/builder.rb +4 -4
  228. data/lib/e11y/presets/audit_event.rb +13 -2
  229. data/lib/e11y/railtie.rb +52 -14
  230. data/lib/e11y/registry.rb +306 -0
  231. data/lib/e11y/reliability/circuit_breaker.rb +19 -21
  232. data/lib/e11y/reliability/dlq/base.rb +71 -0
  233. data/lib/e11y/reliability/dlq/file_adapter.rb +301 -0
  234. data/lib/e11y/reliability/dlq/file_storage.rb +63 -34
  235. data/lib/e11y/reliability/dlq/filter.rb +37 -54
  236. data/lib/e11y/reliability/retry_handler.rb +26 -29
  237. data/lib/e11y/reliability/retry_rate_limiter.rb +3 -11
  238. data/lib/e11y/sampling/error_spike_detector.rb +0 -2
  239. data/lib/e11y/sampling/load_monitor.rb +5 -9
  240. data/lib/e11y/sampling/stratified_tracker.rb +18 -0
  241. data/lib/e11y/self_monitoring/buffer_monitor.rb +2 -0
  242. data/lib/e11y/self_monitoring/performance_monitor.rb +19 -61
  243. data/lib/e11y/self_monitoring/reliability_monitor.rb +4 -74
  244. data/lib/e11y/slo/config_loader.rb +40 -0
  245. data/lib/e11y/slo/config_validator.rb +58 -0
  246. data/lib/e11y/slo/dashboard_generator.rb +122 -0
  247. data/lib/e11y/slo/event_driven.rb +8 -0
  248. data/lib/e11y/slo/tracker.rb +31 -4
  249. data/lib/e11y/testing/have_tracked_event_matcher.rb +190 -0
  250. data/lib/e11y/testing/rspec_matchers.rb +21 -0
  251. data/lib/e11y/testing/snapshot_matcher.rb +86 -0
  252. data/lib/e11y/trace_context/sampler.rb +35 -0
  253. data/lib/e11y/tracing/faraday_middleware.rb +31 -0
  254. data/lib/e11y/tracing/net_http_patch.rb +33 -0
  255. data/lib/e11y/tracing/propagator.rb +144 -0
  256. data/lib/e11y/tracing.rb +47 -0
  257. data/lib/e11y/version.rb +1 -1
  258. data/lib/e11y/versioning/version_extractor.rb +32 -0
  259. data/lib/e11y.rb +123 -266
  260. data/lib/generators/e11y/event/event_generator.rb +22 -0
  261. data/lib/generators/e11y/event/templates/event.rb.tt +16 -0
  262. data/lib/generators/e11y/grafana_dashboard/grafana_dashboard_generator.rb +30 -0
  263. data/lib/generators/e11y/grafana_dashboard/templates/e11y_dashboard.json +81 -0
  264. data/lib/generators/e11y/install/install_generator.rb +34 -0
  265. data/lib/generators/e11y/install/templates/e11y.rb +239 -0
  266. data/lib/generators/e11y/prometheus_alerts/prometheus_alerts_generator.rb +29 -0
  267. data/lib/generators/e11y/prometheus_alerts/templates/e11y_alerts.yml +28 -0
  268. data/lib/tasks/e11y_docs.rake +30 -0
  269. data/lib/tasks/e11y_events.rake +71 -0
  270. data/lib/tasks/e11y_lint.rake +91 -0
  271. data/lib/tasks/e11y_slo.rake +29 -0
  272. metadata +186 -39
  273. data/docs/ADR-003-slo-observability.md +0 -3337
  274. data/docs/ADR-010-developer-experience.md +0 -2166
  275. data/docs/API-REFERENCE-L28.md +0 -914
  276. data/docs/COMPREHENSIVE-CONFIGURATION.md +0 -2366
  277. data/docs/CONTRIBUTING.md +0 -312
  278. data/docs/IMPLEMENTATION_NOTES.md +0 -2804
  279. data/docs/IMPLEMENTATION_PLAN.md +0 -1971
  280. data/docs/IMPLEMENTATION_PLAN_ARCHITECTURE.md +0 -586
  281. data/docs/PLAN.md +0 -148
  282. data/docs/README.md +0 -296
  283. data/docs/design/00-memory-optimization.md +0 -593
  284. data/docs/guides/MIGRATION-L27-L28.md +0 -692
  285. data/docs/guides/PERFORMANCE-BENCHMARKS.md +0 -434
  286. data/docs/guides/README.md +0 -44
  287. data/docs/use_cases/UC-003-pattern-based-metrics.md +0 -1627
  288. data/lib/e11y/adapters/registry.rb +0 -141
@@ -0,0 +1,354 @@
1
+ <script lang="ts">
2
+ import { scaleBand } from "d3-scale"
3
+ import { Axis, Bars, Chart, Svg } from "layerchart"
4
+ import { buildRecentVolumeBuckets, type HistogramTimeRange } from "../lib/recentVolume"
5
+
6
+ type HistoRow = {
7
+ idx: number
8
+ t0: number
9
+ t1: number
10
+ e0: number
11
+ e1: number
12
+ w0: number
13
+ w1: number
14
+ r0: number
15
+ r1: number
16
+ total: number
17
+ }
18
+
19
+ let {
20
+ recent = [] as Record<string, unknown>[],
21
+ timeRange = $bindable(null as HistogramTimeRange | null),
22
+ } = $props()
23
+
24
+ const COL_ERR = "var(--e11y-histo-err)"
25
+ const COL_WARN = "var(--e11y-histo-warn)"
26
+ const COL_REST = "var(--e11y-histo-ok)"
27
+ /** HTML shade above chart, under brush overlay (SVG rect was hidden below overlay). */
28
+ const COL_SEL_SHADE = "var(--e11y-sel-bg)"
29
+
30
+ const BAR_RADIUS = 3
31
+ const TWEEN = { duration: 420, easing: (t: number) => 1 - (1 - t) * (1 - t) }
32
+
33
+ /** Host height scales with peak bucket total (√ for large spikes). */
34
+ const HISTO_H_MIN = 120
35
+ const HISTO_H_MAX = 260
36
+ const HISTO_H_BASE = 96
37
+ const HISTO_H_PER_SQRT = 16
38
+
39
+ /** Must match Chart `padding` (Svg inner `<g>` is translated by this). */
40
+ const CHART_PAD = { top: 6, right: 6, bottom: 26, left: 4 } as const
41
+
42
+ let buckets = $derived(buildRecentVolumeBuckets(recent, 32))
43
+
44
+ let chartRows = $derived.by((): HistoRow[] => {
45
+ return buckets.map((b, idx) => {
46
+ const err = b.counts.err
47
+ const warn = b.counts.warn
48
+ const rest = b.counts.rest
49
+ const total = err + warn + rest
50
+ return {
51
+ idx,
52
+ t0: b.t0,
53
+ t1: b.t1,
54
+ e0: 0,
55
+ e1: err,
56
+ w0: err,
57
+ w1: err + warn,
58
+ r0: err + warn,
59
+ r1: total,
60
+ total,
61
+ }
62
+ })
63
+ })
64
+
65
+ let maxY = $derived(Math.max(1, ...chartRows.map((r) => r.total)))
66
+
67
+ let chartHostHeightPx = $derived(
68
+ Math.round(
69
+ Math.min(HISTO_H_MAX, Math.max(HISTO_H_MIN, HISTO_H_BASE + HISTO_H_PER_SQRT * Math.sqrt(maxY)))
70
+ )
71
+ )
72
+
73
+ let chartWidth = $state(0)
74
+
75
+ let xBand = $derived(
76
+ scaleBand<number>()
77
+ .domain(chartRows.map((r) => r.idx))
78
+ .range([0, Math.max(0, chartWidth - CHART_PAD.left - CHART_PAD.right)])
79
+ .paddingInner(0.18)
80
+ .paddingOuter(0.06)
81
+ )
82
+
83
+ let tickIdxs = $derived.by(() => {
84
+ const n = chartRows.length
85
+ if (n === 0) return []
86
+ const want = [0, Math.floor(n / 4), Math.floor(n / 2), Math.floor((3 * n) / 4), n - 1]
87
+ return [...new Set(want.filter((i) => i >= 0 && i < n))]
88
+ })
89
+
90
+ let chartHost: HTMLDivElement | null = $state(null)
91
+ let brushOverlay: HTMLDivElement | null = $state(null)
92
+ let dragA = $state<number | null>(null)
93
+ let dragB = $state<number | null>(null)
94
+ let dragging = $state(false)
95
+
96
+ function formatTick(ms: number): string {
97
+ return new Date(ms).toISOString().slice(11, 23) + "Z"
98
+ }
99
+
100
+ /** X in LayerChart inner `<g>` space (same as band scale output), robust to SVG/CSS transforms. */
101
+ function clientToInnerPlotX(clientX: number, clientY: number): number | null {
102
+ const root = chartHost
103
+ if (!root) return null
104
+ const svg = root.querySelector<SVGSVGElement>("svg.layercake-layout-svg")
105
+ const g = svg?.querySelector<SVGGElement>(".layercake-layout-svg_g")
106
+ if (!svg || !g) return null
107
+ const m = g.getScreenCTM()
108
+ if (!m) return null
109
+ return new DOMPoint(clientX, clientY).matrixTransform(m.inverse()).x
110
+ }
111
+
112
+ function indexFromClientX(clientX: number, clientY: number): number {
113
+ const n = chartRows.length
114
+ if (n === 0) return 0
115
+ const root = chartHost
116
+ if (!root) return 0
117
+ const xInner = clientToInnerPlotX(clientX, clientY)
118
+ const bw = xBand.bandwidth()
119
+ if (xInner == null || !Number.isFinite(xInner) || bw <= 0) {
120
+ const svg = root.querySelector<SVGSVGElement>("svg.layercake-layout-svg")
121
+ const rect = svg?.getBoundingClientRect() ?? root.getBoundingClientRect()
122
+ const fallback = clientX - rect.left - CHART_PAD.left
123
+ return indexFromInnerX(fallback, n)
124
+ }
125
+ return indexFromInnerX(xInner, n)
126
+ }
127
+
128
+ function indexFromInnerX(xInner: number, n: number): number {
129
+ const bw = xBand.bandwidth()
130
+ for (let i = 0; i < n; i++) {
131
+ const x0 = xBand(i)
132
+ if (x0 === undefined) continue
133
+ if (xInner >= x0 && xInner < x0 + bw) return i
134
+ }
135
+ const first = xBand(0)
136
+ const last = xBand(n - 1)
137
+ if (first !== undefined && xInner < first) return 0
138
+ if (last !== undefined && xInner >= last + bw) return n - 1
139
+ let nearest = 0
140
+ let best = Infinity
141
+ for (let i = 0; i < n; i++) {
142
+ const x0 = xBand(i)
143
+ if (x0 === undefined) continue
144
+ const mid = x0 + bw / 2
145
+ const d = Math.abs(xInner - mid)
146
+ if (d < best) {
147
+ best = d
148
+ nearest = i
149
+ }
150
+ }
151
+ return nearest
152
+ }
153
+
154
+ function commitRange(i0: number, i1: number): void {
155
+ const b = buckets
156
+ const lo = Math.min(i0, i1)
157
+ const hi = Math.max(i0, i1)
158
+ timeRange = { startMs: b[lo].t0, endMs: b[hi].t1, lo, hi }
159
+ }
160
+
161
+ function onPointerDown(e: PointerEvent): void {
162
+ if (e.button !== 0) return
163
+ if (chartRows.length === 0) return
164
+ const cap = brushOverlay ?? chartHost
165
+ if (!cap) return
166
+ try {
167
+ cap.setPointerCapture(e.pointerId)
168
+ } catch {
169
+ /* ignore */
170
+ }
171
+ dragging = true
172
+ const i = indexFromClientX(e.clientX, e.clientY)
173
+ dragA = i
174
+ dragB = i
175
+ }
176
+
177
+ function onPointerMove(e: PointerEvent): void {
178
+ if (!dragging || dragA === null) return
179
+ dragB = indexFromClientX(e.clientX, e.clientY)
180
+ }
181
+
182
+ function onPointerUp(e: PointerEvent): void {
183
+ const cap = brushOverlay ?? chartHost
184
+ if (!cap) return
185
+ try {
186
+ cap.releasePointerCapture(e.pointerId)
187
+ } catch {
188
+ /* ignore */
189
+ }
190
+ if (dragging && dragA !== null && dragB !== null) {
191
+ commitRange(dragA, dragB)
192
+ }
193
+ dragging = false
194
+ dragA = null
195
+ dragB = null
196
+ }
197
+
198
+ function onDoubleClick(): void {
199
+ timeRange = null
200
+ }
201
+
202
+ function clearRange(): void {
203
+ timeRange = null
204
+ }
205
+
206
+ function selIndexRange(): { lo: number; hi: number } | null {
207
+ if (dragging && dragA !== null && dragB !== null) {
208
+ return { lo: Math.min(dragA, dragB), hi: Math.max(dragA, dragB) }
209
+ }
210
+ if (!timeRange || buckets.length === 0) return null
211
+ const n = buckets.length
212
+ if (timeRange.lo == null || timeRange.hi == null) return null
213
+ const rawLo = Math.max(0, Math.min(timeRange.lo, n - 1))
214
+ const rawHi = Math.max(0, Math.min(timeRange.hi, n - 1))
215
+ return { lo: Math.min(rawLo, rawHi), hi: Math.max(rawLo, rawHi) }
216
+ }
217
+
218
+ let spanLabel = $derived.by((): string | null => {
219
+ if (buckets.length === 0) return null
220
+ const first = buckets[0]
221
+ const last = buckets[buckets.length - 1]
222
+ return `${formatTick(first.t0)} → ${formatTick(last.t1)}`
223
+ })
224
+
225
+ let selectionLayout = $derived.by((): { left: number; top: number; width: number; height: number } | null => {
226
+ const sel = selIndexRange()
227
+ if (!sel) return null
228
+ const bw = xBand.bandwidth()
229
+ const x0 = xBand(sel.lo)
230
+ const x1 = (xBand(sel.hi) ?? 0) + bw
231
+ if (x0 === undefined) return null
232
+ const plotH = chartHostHeightPx - CHART_PAD.top - CHART_PAD.bottom
233
+ return {
234
+ left: CHART_PAD.left + x0,
235
+ top: CHART_PAD.top,
236
+ width: Math.max(0, x1 - x0),
237
+ height: Math.max(0, plotH),
238
+ }
239
+ })
240
+ </script>
241
+
242
+ {#if chartRows.length > 0}
243
+ <div class="e11y-histo-wrap">
244
+ <div
245
+ bind:this={chartHost}
246
+ bind:clientWidth={chartWidth}
247
+ class="e11y-histo-chart-host"
248
+ style:height="{chartHostHeightPx}px"
249
+ style:min-height="{chartHostHeightPx}px"
250
+ role="application"
251
+ aria-label="Log volume by time (LayerChart). Drag to filter. Double-click resets."
252
+ >
253
+ <Chart
254
+ data={chartRows}
255
+ x="idx"
256
+ xDomain={chartRows.map((r) => r.idx)}
257
+ xScale={xBand}
258
+ y="total"
259
+ yDomain={[0, maxY]}
260
+ yNice={false}
261
+ padding={{ top: CHART_PAD.top, right: CHART_PAD.right, bottom: CHART_PAD.bottom, left: CHART_PAD.left }}
262
+ brush={{ disabled: true }}
263
+ >
264
+ <Svg class="e11y-histo-svg" label="Recent log volume">
265
+ <!-- LayerChart rect helper only reads range from `y` when it is [low, high]; plain y/y1 leaves y1 ignored. -->
266
+ <Bars
267
+ data={chartRows}
268
+ y={(d: HistoRow) => [d.e0, d.e1]}
269
+ fill={COL_ERR}
270
+ stroke="none"
271
+ strokeWidth={0}
272
+ radius={0}
273
+ rounded="none"
274
+ tweened={TWEEN}
275
+ />
276
+ <Bars
277
+ data={chartRows}
278
+ y={(d: HistoRow) => [d.w0, d.w1]}
279
+ fill={COL_WARN}
280
+ stroke="none"
281
+ strokeWidth={0}
282
+ radius={0}
283
+ rounded="none"
284
+ tweened={TWEEN}
285
+ />
286
+ <Bars
287
+ data={chartRows}
288
+ y={(d: HistoRow) => [d.r0, d.r1]}
289
+ fill={COL_REST}
290
+ stroke="none"
291
+ strokeWidth={0}
292
+ radius={BAR_RADIUS}
293
+ rounded="top"
294
+ tweened={TWEEN}
295
+ />
296
+
297
+ <Axis
298
+ placement="bottom"
299
+ rule={false}
300
+ grid={false}
301
+ ticks={tickIdxs.map((i) => chartRows[i]!.idx)}
302
+ format={(v) => {
303
+ const row = chartRows.find((r) => r.idx === v)
304
+ return row ? formatTick(row.t0) : ""
305
+ }}
306
+ tickLength={3}
307
+ tickLabelProps={{ class: "e11y-histo-axis-tick" }}
308
+ />
309
+ </Svg>
310
+ </Chart>
311
+ {#if selectionLayout}
312
+ <div
313
+ class="e11y-histo-sel-shade"
314
+ style:left="{selectionLayout.left}px"
315
+ style:top="{selectionLayout.top}px"
316
+ style:width="{selectionLayout.width}px"
317
+ style:height="{selectionLayout.height}px"
318
+ aria-hidden="true"
319
+ ></div>
320
+ {/if}
321
+ <div
322
+ bind:this={brushOverlay}
323
+ class="e11y-histo-brush-overlay"
324
+ aria-hidden="true"
325
+ onpointerdown={onPointerDown}
326
+ onpointermove={onPointerMove}
327
+ onpointerup={onPointerUp}
328
+ onpointercancel={onPointerUp}
329
+ ondblclick={onDoubleClick}
330
+ ></div>
331
+ </div>
332
+
333
+ <div class="e11y-histo-footer">
334
+ <div class="e11y-histo-legend">
335
+ <span><i class="e11y-histo-dot e11y-histo-dot--err"></i> error</span>
336
+ <span><i class="e11y-histo-dot e11y-histo-dot--warn"></i> warn</span>
337
+ <span><i class="e11y-histo-dot e11y-histo-dot--rest"></i> other</span>
338
+ </div>
339
+ <div class="e11y-histo-meta">
340
+ {#if spanLabel}
341
+ <span class="e11y-histo-span" title="Full sample span (UTC)">{spanLabel}</span>
342
+ {/if}
343
+ {#if timeRange}
344
+ <span class="e11y-histo-filter" title="Active time filter (UTC)">
345
+ Filter: {formatTick(timeRange.startMs)}–{formatTick(timeRange.endMs)}
346
+ </span>
347
+ <button type="button" class="e11y-histo-clear" onclick={clearRange}>Clear range</button>
348
+ {:else}
349
+ <span class="e11y-histo-hint">Drag to narrow · double-click to reset</span>
350
+ {/if}
351
+ </div>
352
+ </div>
353
+ </div>
354
+ {/if}
@@ -0,0 +1,37 @@
1
+ import type { SourceFilter } from "./router"
2
+
3
+ function useMocks(): boolean {
4
+ return import.meta.env.DEV
5
+ }
6
+
7
+ export async function fetchRecent(limit = 200): Promise<Record<string, unknown>[]> {
8
+ const url = useMocks()
9
+ ? "/mocks/v1/events/recent.json"
10
+ : `/_e11y/v1/events/recent?limit=${limit}`
11
+ const r = await fetch(url)
12
+ if (!r.ok) throw new Error(`recent: ${r.status}`)
13
+ return r.json() as Promise<Record<string, unknown>[]>
14
+ }
15
+
16
+ export async function fetchInteractions(source: SourceFilter): Promise<Record<string, unknown>[]> {
17
+ const url = useMocks()
18
+ ? "/mocks/v1/interactions.json"
19
+ : `/_e11y/v1/interactions${source === "all" ? "" : `?source=${source}`}`
20
+ const r = await fetch(url)
21
+ if (!r.ok) throw new Error(`interactions: ${r.status}`)
22
+ const rows = (await r.json()) as Record<string, unknown>[]
23
+ if (useMocks()) {
24
+ if (source === "all") return rows
25
+ return rows.filter((i) => i.source === source)
26
+ }
27
+ return rows
28
+ }
29
+
30
+ export async function fetchTraceEvents(traceId: string): Promise<Record<string, unknown>[]> {
31
+ const url = useMocks()
32
+ ? `/mocks/v1/traces/${encodeURIComponent(traceId)}/events.json`
33
+ : `/_e11y/v1/traces/${encodeURIComponent(traceId)}/events`
34
+ const r = await fetch(url)
35
+ if (!r.ok) throw new Error(`trace events: ${r.status}`)
36
+ return r.json() as Promise<Record<string, unknown>[]>
37
+ }
@@ -0,0 +1,12 @@
1
+ /** Stable key for deduping events across polls (pulse / badges). */
2
+ export function eventKey(e: Record<string, unknown>, index: number): string {
3
+ const id = e.id
4
+ if (typeof id === "string" && id.length > 0) return id
5
+
6
+ return [
7
+ String(e.trace_id ?? ""),
8
+ String(e.timestamp ?? ""),
9
+ String(e.event_name ?? ""),
10
+ index,
11
+ ].join("|")
12
+ }
@@ -0,0 +1,37 @@
1
+ /** Format ISO timestamp for list rows + short relative hint. */
2
+ export function formatInteractionStarted(iso: string): { absolute: string; relative: string } {
3
+ const d = new Date(iso)
4
+ if (Number.isNaN(d.getTime())) {
5
+ return { absolute: iso || "—", relative: "" }
6
+ }
7
+ const absolute = d.toLocaleString(undefined, {
8
+ month: "short",
9
+ day: "numeric",
10
+ hour: "2-digit",
11
+ minute: "2-digit",
12
+ second: "2-digit",
13
+ })
14
+ const sec = Math.round((Date.now() - d.getTime()) / 1000)
15
+ let relative = ""
16
+ if (sec < 60) relative = `${sec}s ago`
17
+ else if (sec < 3600) relative = `${Math.floor(sec / 60)}m ago`
18
+ else if (sec < 86400) relative = `${Math.floor(sec / 3600)}h ago`
19
+ else relative = `${Math.floor(sec / 86400)}d ago`
20
+ return { absolute, relative }
21
+ }
22
+
23
+ export function summarizeTraceIds(ids: string[] | undefined): {
24
+ primary: string
25
+ extra: number
26
+ preview: string
27
+ } {
28
+ const list = ids ?? []
29
+ if (list.length === 0) {
30
+ return { primary: "—", extra: 0, preview: "" }
31
+ }
32
+ const primary = list[0] ?? "—"
33
+ const extra = Math.max(0, list.length - 1)
34
+ const preview =
35
+ list.length <= 2 ? list.join(", ") : `${list[0]}, ${list[1]} +${list.length - 2}`
36
+ return { primary, extra, preview }
37
+ }
@@ -0,0 +1,43 @@
1
+ export type ListSeverityFilter = "all" | "error" | "warn" | "rest"
2
+
3
+ /** error filter matches error + fatal */
4
+ export function eventMatchesSeverity(ev: Record<string, unknown>, filter: ListSeverityFilter): boolean {
5
+ if (filter === "all") return true
6
+ const s = String(ev.severity ?? "")
7
+ if (filter === "error") return s === "error" || s === "fatal"
8
+ if (filter === "warn") return s === "warn"
9
+ /* rest: debug, info, success, … */
10
+ return s !== "error" && s !== "fatal" && s !== "warn"
11
+ }
12
+
13
+ export function eventMatchesSearch(ev: Record<string, unknown>, query: string): boolean {
14
+ const q = query.trim().toLowerCase()
15
+ if (!q) return true
16
+ if (String(ev.event_name ?? "").toLowerCase().includes(q)) return true
17
+ if (String(ev.trace_id ?? "").toLowerCase().includes(q)) return true
18
+ const meta = ev.metadata
19
+ if (meta && typeof meta === "object") {
20
+ try {
21
+ if (JSON.stringify(meta).toLowerCase().includes(q)) return true
22
+ } catch {
23
+ /* ignore */
24
+ }
25
+ }
26
+ const payload = ev.payload
27
+ if (payload !== undefined && payload !== null) {
28
+ try {
29
+ if (JSON.stringify(payload).toLowerCase().includes(q)) return true
30
+ } catch {
31
+ /* ignore */
32
+ }
33
+ }
34
+ return false
35
+ }
36
+
37
+ export function filterEventList(
38
+ rows: Record<string, unknown>[],
39
+ severity: ListSeverityFilter,
40
+ search: string
41
+ ): Record<string, unknown>[] {
42
+ return rows.filter((ev) => eventMatchesSeverity(ev, severity) && eventMatchesSearch(ev, search))
43
+ }
@@ -0,0 +1,80 @@
1
+ /** Stacked severity counts per time slice (for recent-events volume bar). */
2
+
3
+ /** Histogram brush: bucket indices + bounds (indices stay aligned with columns when `recent` changes). */
4
+ export type HistogramTimeRange = {
5
+ startMs: number
6
+ endMs: number
7
+ lo: number
8
+ hi: number
9
+ }
10
+
11
+ export type VolumeSeverity = "err" | "warn" | "rest"
12
+
13
+ export interface VolumeBucket {
14
+ /** Bucket start (ms since epoch). */
15
+ t0: number
16
+ t1: number
17
+ counts: Record<VolumeSeverity, number>
18
+ }
19
+
20
+ function severityVolumeGroup(sev: unknown): VolumeSeverity {
21
+ const s = String(sev ?? "")
22
+ if (s === "error" || s === "fatal") return "err"
23
+ if (s === "warn") return "warn"
24
+ return "rest"
25
+ }
26
+
27
+ /** Parse event `timestamp` (ISO string) to epoch ms. */
28
+ export function eventTimestampMs(ev: Record<string, unknown>): number | null {
29
+ const raw = ev.timestamp
30
+ if (typeof raw !== "string") return null
31
+ const ms = Date.parse(raw)
32
+ return Number.isFinite(ms) ? ms : null
33
+ }
34
+
35
+ /** Newest-first input (as from API); builds ~`bucketCount` buckets from oldest→newest span. */
36
+ export function buildRecentVolumeBuckets(
37
+ rows: Record<string, unknown>[],
38
+ bucketCount = 28
39
+ ): VolumeBucket[] {
40
+ if (rows.length === 0) return []
41
+
42
+ const times: number[] = []
43
+ for (const ev of rows) {
44
+ const t = eventTimestampMs(ev)
45
+ if (t != null) times.push(t)
46
+ }
47
+ if (times.length === 0) return []
48
+
49
+ let tMin = Math.min(...times)
50
+ let tMax = Math.max(...times)
51
+ if (tMax <= tMin) {
52
+ tMin -= 1
53
+ tMax += 1
54
+ }
55
+
56
+ const n = Math.max(1, Math.min(bucketCount, Math.ceil(rows.length / 2) || bucketCount))
57
+ const width = (tMax - tMin) / n
58
+ const buckets: VolumeBucket[] = []
59
+ for (let i = 0; i < n; i++) {
60
+ buckets.push({
61
+ t0: tMin + i * width,
62
+ t1: tMin + (i + 1) * width,
63
+ counts: { err: 0, warn: 0, rest: 0 },
64
+ })
65
+ }
66
+
67
+ for (const ev of rows) {
68
+ const t = eventTimestampMs(ev)
69
+ if (t == null) continue
70
+ const idx = Math.min(n - 1, Math.max(0, Math.floor((t - tMin) / width)))
71
+ const g = severityVolumeGroup(ev.severity)
72
+ buckets[idx].counts[g] += 1
73
+ }
74
+
75
+ return buckets
76
+ }
77
+
78
+ export function bucketTotal(b: VolumeBucket): number {
79
+ return b.counts.err + b.counts.warn + b.counts.rest
80
+ }
@@ -0,0 +1,12 @@
1
+ export type SourceFilter = "web" | "job" | "all"
2
+
3
+ export type OverlayRoute =
4
+ | { screen: "problems" }
5
+ | { screen: "interactions" }
6
+ | { screen: "events"; traceId: string }
7
+ | {
8
+ screen: "detail"
9
+ traceId: string
10
+ event: Record<string, unknown>
11
+ detailFrom: "problems" | "events"
12
+ }
@@ -0,0 +1,34 @@
1
+ import { cubicInOut } from "svelte/easing"
2
+ import type { TransitionConfig } from "svelte/transition"
3
+
4
+ export type CircleOrigin = { x: number; y: number; r: number }
5
+
6
+ /** Circular reveal from (x,y) — same idea as Magic UI theme toggler (clip-path expand). */
7
+ export function circleExpand(
8
+ _node: Element,
9
+ { x, y, r, duration }: CircleOrigin & { duration: number }
10
+ ): TransitionConfig {
11
+ return {
12
+ duration,
13
+ easing: cubicInOut,
14
+ css: (t) => {
15
+ const radius = Math.max(0, t * r)
16
+ return `clip-path: circle(${radius}px at ${x}px ${y}px);`
17
+ },
18
+ }
19
+ }
20
+
21
+ /** Circular collapse back toward (x,y). */
22
+ export function circleCollapse(
23
+ _node: Element,
24
+ { x, y, r, duration }: CircleOrigin & { duration: number }
25
+ ): TransitionConfig {
26
+ return {
27
+ duration,
28
+ easing: cubicInOut,
29
+ css: (t) => {
30
+ const radius = Math.max(0, t * r)
31
+ return `clip-path: circle(${radius}px at ${x}px ${y}px);`
32
+ },
33
+ }
34
+ }
@@ -0,0 +1,25 @@
1
+ import type { CircleOrigin } from "./transitions"
2
+
3
+ /** Circle origin from FAB button (center + radius to farthest viewport corner). */
4
+ export function originFromFabButton(el: HTMLButtonElement): CircleOrigin {
5
+ const { top, left, width, height } = el.getBoundingClientRect()
6
+ const x = left + width / 2
7
+ const y = top + height / 2
8
+ const vw = typeof window !== "undefined" ? (window.visualViewport?.width ?? window.innerWidth) : 800
9
+ const vh = typeof window !== "undefined" ? (window.visualViewport?.height ?? window.innerHeight) : 600
10
+ const r = Math.hypot(Math.max(x, vw - x), Math.max(y, vh - y))
11
+ return { x, y, r }
12
+ }
13
+
14
+ /** Fallback when opening without a click target (e.g. programmatic). ~bottom-right FAB. */
15
+ export function originFallbackFabCorner(): CircleOrigin {
16
+ const vw = typeof window !== "undefined" ? (window.visualViewport?.width ?? window.innerWidth) : 800
17
+ const vh = typeof window !== "undefined" ? (window.visualViewport?.height ?? window.innerHeight) : 600
18
+ const margin = 16
19
+ const halfW = 70
20
+ const halfH = 22
21
+ const x = vw - margin - halfW
22
+ const y = vh - margin - halfH
23
+ const r = Math.hypot(Math.max(x, vw - x), Math.max(y, vh - y))
24
+ return { x, y, r }
25
+ }
@@ -0,0 +1,8 @@
1
+ import { mount } from "svelte"
2
+ import "./overlay.css"
3
+ import App from "./App.svelte"
4
+
5
+ const el = document.getElementById("app")
6
+ if (el) {
7
+ mount(App, { target: el })
8
+ }
@@ -0,0 +1,24 @@
1
+ import { mount } from "svelte"
2
+ import App from "./App.svelte"
3
+ import "./overlay.css"
4
+
5
+ const ROOT_ID = "e11y-devtools-root"
6
+
7
+ function boot(): void {
8
+ if (typeof document === "undefined") return
9
+ if (document.getElementById(ROOT_ID)) return
10
+
11
+ const target = document.createElement("div")
12
+ target.id = ROOT_ID
13
+ document.body.appendChild(target)
14
+
15
+ mount(App, { target })
16
+ }
17
+
18
+ if (typeof document !== "undefined") {
19
+ if (document.readyState === "loading") {
20
+ document.addEventListener("DOMContentLoaded", () => boot())
21
+ } else {
22
+ boot()
23
+ }
24
+ }