@unofficialbox/box-open-elements 0.5.0 → 0.7.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 (227) hide show
  1. package/README.md +27 -73
  2. package/dist/components/collections/table.d.ts +113 -3
  3. package/dist/components/collections/table.js +540 -37
  4. package/dist/components/feedback/due-badge.d.ts +38 -0
  5. package/dist/components/feedback/due-badge.js +168 -0
  6. package/dist/components/feedback/due-types.d.ts +32 -0
  7. package/dist/components/feedback/due-types.js +91 -0
  8. package/dist/components/feedback/progress-steps.d.ts +51 -5
  9. package/dist/components/feedback/progress-steps.js +200 -22
  10. package/dist/components/feedback/stage-path.d.ts +44 -0
  11. package/dist/components/feedback/stage-path.js +235 -0
  12. package/dist/components/forms/select.d.ts +21 -0
  13. package/dist/components/forms/select.js +100 -3
  14. package/dist/components/forms/text-field.d.ts +21 -0
  15. package/dist/components/forms/text-field.js +100 -1
  16. package/dist/components/layout/nav-sidebar.d.ts +14 -0
  17. package/dist/components/layout/nav-sidebar.js +49 -0
  18. package/dist/components/layout/split-view.d.ts +14 -0
  19. package/dist/components/layout/split-view.js +80 -1
  20. package/dist/components/overlays/command-palette.d.ts +62 -0
  21. package/dist/components/overlays/command-palette.js +537 -0
  22. package/dist/components/overlays/command-types.d.ts +90 -0
  23. package/dist/components/overlays/command-types.js +259 -0
  24. package/dist/components/overlays/drawer.d.ts +18 -0
  25. package/dist/components/overlays/drawer.js +135 -9
  26. package/dist/components/overlays/shortcuts-overlay.d.ts +48 -0
  27. package/dist/components/overlays/shortcuts-overlay.js +385 -0
  28. package/dist/components/overlays/tooltip.js +1 -1
  29. package/dist/core/index.d.ts +4 -0
  30. package/dist/core/index.js +2 -0
  31. package/dist/core/offset-window.d.ts +70 -0
  32. package/dist/core/offset-window.js +134 -0
  33. package/dist/core/virtualize.d.ts +71 -0
  34. package/dist/core/virtualize.js +67 -0
  35. package/dist/element-maps.d.ts +292 -0
  36. package/dist/element-maps.js +1 -0
  37. package/dist/entries/activity-density.d.ts +1 -0
  38. package/dist/entries/activity-density.js +1 -0
  39. package/dist/entries/agent-chat.d.ts +1 -0
  40. package/dist/entries/agent-chat.js +1 -0
  41. package/dist/entries/audit-log.d.ts +1 -0
  42. package/dist/entries/audit-log.js +1 -0
  43. package/dist/entries/command-palette.d.ts +1 -0
  44. package/dist/entries/command-palette.js +1 -0
  45. package/dist/entries/compare-view.d.ts +1 -0
  46. package/dist/entries/compare-view.js +1 -0
  47. package/dist/entries/diff-viewer.d.ts +1 -0
  48. package/dist/entries/diff-viewer.js +1 -0
  49. package/dist/entries/due-badge.d.ts +1 -0
  50. package/dist/entries/due-badge.js +1 -0
  51. package/dist/entries/form-wizard.d.ts +1 -0
  52. package/dist/entries/form-wizard.js +1 -0
  53. package/dist/entries/lineage-graph.d.ts +1 -0
  54. package/dist/entries/lineage-graph.js +1 -0
  55. package/dist/entries/notification-bell.d.ts +1 -0
  56. package/dist/entries/notification-bell.js +1 -0
  57. package/dist/entries/notification-inbox.d.ts +1 -0
  58. package/dist/entries/notification-inbox.js +1 -0
  59. package/dist/entries/provenance-strip.d.ts +1 -0
  60. package/dist/entries/provenance-strip.js +1 -0
  61. package/dist/entries/run-trace.d.ts +1 -0
  62. package/dist/entries/run-trace.js +1 -0
  63. package/dist/entries/shortcuts-overlay.d.ts +1 -0
  64. package/dist/entries/shortcuts-overlay.js +1 -0
  65. package/dist/entries/signature-ceremony.d.ts +1 -0
  66. package/dist/entries/signature-ceremony.js +1 -0
  67. package/dist/entries/stage-path.d.ts +1 -0
  68. package/dist/entries/stage-path.js +1 -0
  69. package/dist/entries/timeline.d.ts +1 -0
  70. package/dist/entries/timeline.js +1 -0
  71. package/dist/entries/version-graph.d.ts +1 -0
  72. package/dist/entries/version-graph.js +1 -0
  73. package/dist/entries/version-list.d.ts +1 -0
  74. package/dist/entries/version-list.js +1 -0
  75. package/dist/entries/wizard-summary.d.ts +1 -0
  76. package/dist/entries/wizard-summary.js +1 -0
  77. package/dist/entries/work-queue.d.ts +1 -0
  78. package/dist/entries/work-queue.js +1 -0
  79. package/dist/entries/workload-board.d.ts +1 -0
  80. package/dist/entries/workload-board.js +1 -0
  81. package/dist/index.d.ts +21 -0
  82. package/dist/index.js +21 -0
  83. package/dist/patterns/agent-chat/agent-chat.d.ts +75 -0
  84. package/dist/patterns/agent-chat/agent-chat.js +820 -0
  85. package/dist/patterns/agent-chat/controller.d.ts +27 -0
  86. package/dist/patterns/agent-chat/controller.js +182 -0
  87. package/dist/patterns/agent-chat/index.d.ts +3 -0
  88. package/dist/patterns/agent-chat/index.js +6 -0
  89. package/dist/patterns/agent-chat/types.d.ts +109 -0
  90. package/dist/patterns/agent-chat/types.js +1 -0
  91. package/dist/patterns/audit/activity-density.d.ts +68 -0
  92. package/dist/patterns/audit/activity-density.js +428 -0
  93. package/dist/patterns/audit/audit-log.d.ts +182 -0
  94. package/dist/patterns/audit/audit-log.js +1082 -0
  95. package/dist/patterns/audit/index.d.ts +3 -0
  96. package/dist/patterns/audit/index.js +7 -0
  97. package/dist/patterns/audit/shared.d.ts +10 -0
  98. package/dist/patterns/audit/shared.js +50 -0
  99. package/dist/patterns/audit/types.d.ts +143 -0
  100. package/dist/patterns/audit/types.js +327 -0
  101. package/dist/patterns/audit/window.d.ts +116 -0
  102. package/dist/patterns/audit/window.js +132 -0
  103. package/dist/patterns/content-explorer/actions/controller.js +12 -2
  104. package/dist/patterns/content-explorer/box-transport.js +117 -25
  105. package/dist/patterns/content-explorer/content-explorer.d.ts +13 -1
  106. package/dist/patterns/content-explorer/content-explorer.js +36 -3
  107. package/dist/patterns/content-explorer/contracts.d.ts +24 -1
  108. package/dist/patterns/content-explorer/contracts.js +68 -11
  109. package/dist/patterns/content-explorer/controller.d.ts +16 -1
  110. package/dist/patterns/content-explorer/controller.js +130 -4
  111. package/dist/patterns/content-explorer/navigation/controller.js +11 -9
  112. package/dist/patterns/content-explorer/types.d.ts +58 -1
  113. package/dist/patterns/content-picker/content-picker.d.ts +71 -0
  114. package/dist/patterns/content-picker/content-picker.js +747 -0
  115. package/dist/patterns/content-picker/controller.d.ts +38 -0
  116. package/dist/patterns/content-picker/controller.js +141 -0
  117. package/dist/patterns/content-picker/index.d.ts +3 -0
  118. package/dist/patterns/content-picker/index.js +6 -0
  119. package/dist/patterns/content-picker/types.d.ts +52 -0
  120. package/dist/patterns/content-picker/types.js +26 -0
  121. package/dist/patterns/content-sidebar/content-sidebar.d.ts +43 -0
  122. package/dist/patterns/content-sidebar/content-sidebar.js +293 -0
  123. package/dist/patterns/content-sidebar/index.d.ts +2 -0
  124. package/dist/patterns/content-sidebar/index.js +4 -0
  125. package/dist/patterns/content-sidebar/types.d.ts +18 -0
  126. package/dist/patterns/content-sidebar/types.js +20 -0
  127. package/dist/patterns/content-uploader/box-transport.d.ts +14 -0
  128. package/dist/patterns/content-uploader/box-transport.js +54 -0
  129. package/dist/patterns/content-uploader/content-uploader.d.ts +62 -0
  130. package/dist/patterns/content-uploader/content-uploader.js +559 -0
  131. package/dist/patterns/content-uploader/controller.d.ts +39 -0
  132. package/dist/patterns/content-uploader/controller.js +227 -0
  133. package/dist/patterns/content-uploader/index.d.ts +4 -0
  134. package/dist/patterns/content-uploader/index.js +8 -0
  135. package/dist/patterns/content-uploader/types.d.ts +120 -0
  136. package/dist/patterns/content-uploader/types.js +38 -0
  137. package/dist/patterns/diff/compare-view.d.ts +47 -0
  138. package/dist/patterns/diff/compare-view.js +276 -0
  139. package/dist/patterns/diff/diff-viewer.d.ts +42 -0
  140. package/dist/patterns/diff/diff-viewer.js +434 -0
  141. package/dist/patterns/diff/engine.d.ts +22 -0
  142. package/dist/patterns/diff/engine.js +276 -0
  143. package/dist/patterns/diff/index.d.ts +4 -0
  144. package/dist/patterns/diff/index.js +8 -0
  145. package/dist/patterns/diff/types.d.ts +69 -0
  146. package/dist/patterns/diff/types.js +24 -0
  147. package/dist/patterns/form-wizard/controller.d.ts +39 -0
  148. package/dist/patterns/form-wizard/controller.js +157 -0
  149. package/dist/patterns/form-wizard/form-wizard.d.ts +63 -0
  150. package/dist/patterns/form-wizard/form-wizard.js +468 -0
  151. package/dist/patterns/form-wizard/index.d.ts +4 -0
  152. package/dist/patterns/form-wizard/index.js +8 -0
  153. package/dist/patterns/form-wizard/types.d.ts +109 -0
  154. package/dist/patterns/form-wizard/types.js +83 -0
  155. package/dist/patterns/form-wizard/wizard-summary.d.ts +62 -0
  156. package/dist/patterns/form-wizard/wizard-summary.js +324 -0
  157. package/dist/patterns/internal/safe-href.d.ts +17 -0
  158. package/dist/patterns/internal/safe-href.js +17 -0
  159. package/dist/patterns/lineage/index.d.ts +3 -0
  160. package/dist/patterns/lineage/index.js +6 -0
  161. package/dist/patterns/lineage/lineage-graph.d.ts +30 -0
  162. package/dist/patterns/lineage/lineage-graph.js +434 -0
  163. package/dist/patterns/lineage/provenance-strip.d.ts +22 -0
  164. package/dist/patterns/lineage/provenance-strip.js +194 -0
  165. package/dist/patterns/lineage/types.d.ts +53 -0
  166. package/dist/patterns/lineage/types.js +49 -0
  167. package/dist/patterns/notifications/index.d.ts +3 -0
  168. package/dist/patterns/notifications/index.js +6 -0
  169. package/dist/patterns/notifications/notification-bell.d.ts +38 -0
  170. package/dist/patterns/notifications/notification-bell.js +198 -0
  171. package/dist/patterns/notifications/notification-inbox.d.ts +46 -0
  172. package/dist/patterns/notifications/notification-inbox.js +537 -0
  173. package/dist/patterns/notifications/types.d.ts +61 -0
  174. package/dist/patterns/notifications/types.js +108 -0
  175. package/dist/patterns/preview/annotation-inspector.d.ts +25 -6
  176. package/dist/patterns/preview/annotation-inspector.js +239 -94
  177. package/dist/patterns/preview/annotation-thread.d.ts +25 -5
  178. package/dist/patterns/preview/annotation-thread.js +233 -82
  179. package/dist/patterns/preview/annotation-toolbar.d.ts +20 -6
  180. package/dist/patterns/preview/annotation-toolbar.js +162 -96
  181. package/dist/patterns/preview/content-preview-adapter.d.ts +10 -1
  182. package/dist/patterns/preview/content-preview-adapter.js +26 -1
  183. package/dist/patterns/preview/preview-element.d.ts +15 -2
  184. package/dist/patterns/preview/preview-element.js +223 -52
  185. package/dist/patterns/preview/provider-adapter.d.ts +47 -0
  186. package/dist/patterns/preview/provider-adapter.js +17 -0
  187. package/dist/patterns/run/index.d.ts +2 -0
  188. package/dist/patterns/run/index.js +4 -0
  189. package/dist/patterns/run/run-trace.d.ts +40 -0
  190. package/dist/patterns/run/run-trace.js +441 -0
  191. package/dist/patterns/run/types.d.ts +66 -0
  192. package/dist/patterns/run/types.js +152 -0
  193. package/dist/patterns/signature/index.d.ts +2 -0
  194. package/dist/patterns/signature/index.js +4 -0
  195. package/dist/patterns/signature/signature-ceremony.d.ts +33 -0
  196. package/dist/patterns/signature/signature-ceremony.js +317 -0
  197. package/dist/patterns/signature/types.d.ts +68 -0
  198. package/dist/patterns/signature/types.js +89 -0
  199. package/dist/patterns/timeline/index.d.ts +2 -0
  200. package/dist/patterns/timeline/index.js +4 -0
  201. package/dist/patterns/timeline/timeline.d.ts +27 -0
  202. package/dist/patterns/timeline/timeline.js +430 -0
  203. package/dist/patterns/timeline/types.d.ts +36 -0
  204. package/dist/patterns/timeline/types.js +27 -0
  205. package/dist/patterns/versions/graph-layout.d.ts +43 -0
  206. package/dist/patterns/versions/graph-layout.js +159 -0
  207. package/dist/patterns/versions/index.d.ts +4 -0
  208. package/dist/patterns/versions/index.js +8 -0
  209. package/dist/patterns/versions/shared.d.ts +4 -0
  210. package/dist/patterns/versions/shared.js +13 -0
  211. package/dist/patterns/versions/types.d.ts +37 -0
  212. package/dist/patterns/versions/types.js +39 -0
  213. package/dist/patterns/versions/version-graph.d.ts +36 -0
  214. package/dist/patterns/versions/version-graph.js +438 -0
  215. package/dist/patterns/versions/version-list.d.ts +39 -0
  216. package/dist/patterns/versions/version-list.js +407 -0
  217. package/dist/patterns/work-queue/controller.d.ts +26 -0
  218. package/dist/patterns/work-queue/controller.js +150 -0
  219. package/dist/patterns/work-queue/index.d.ts +4 -0
  220. package/dist/patterns/work-queue/index.js +7 -0
  221. package/dist/patterns/work-queue/types.d.ts +128 -0
  222. package/dist/patterns/work-queue/types.js +45 -0
  223. package/dist/patterns/work-queue/work-queue.d.ts +48 -0
  224. package/dist/patterns/work-queue/work-queue.js +534 -0
  225. package/dist/patterns/work-queue/workload-board.d.ts +53 -0
  226. package/dist/patterns/work-queue/workload-board.js +608 -0
  227. package/package.json +89 -5
@@ -0,0 +1,1082 @@
1
+ import { AUDIT_GROUP_BY_LABELS, AUDIT_GROUP_BY_VALUES, filterAuditEvents, groupAuditEvents, hasAuditFacets, resolveAuditGroupBy, summarizeAuditFacets, toAuditCsv, } from "./types.js";
2
+ import { auditToneColor, escapeHtml, formatAuditTimestamp, resolveReferenceTime, } from "./shared.js";
3
+ import { isSafeHref } from "../internal/safe-href.js";
4
+ import { isTimelineEventRecord, resolveTimelineTone } from "../timeline/types.js";
5
+ import { BaseElement, sameRowWindow } from "../../core/index.js";
6
+ import { buildAuditIndex, flattenAuditRows, planAuditWindow } from "./window.js";
7
+ import { boeInputControlStyles, boePanel, boeRadius } from "../../foundations/geometry/index.js";
8
+ import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
9
+ /**
10
+ * How many times the element will adopt a fresh height measurement for one row
11
+ * set. A hard stop, not a tuning knob: without it a non-uniform list can adopt,
12
+ * re-window, re-measure and adopt again on every animation frame forever.
13
+ */
14
+ const MAX_HEIGHT_ADOPTIONS = 3;
15
+ const DEFAULT_TAG_NAME = "box-audit-log";
16
+ /** Facet attribute ↔ `AuditFacets` key. One row per facet, no other mapping. */
17
+ const FACET_ATTRIBUTES = [
18
+ ["actor", "facet-actor"],
19
+ ["action", "facet-action"],
20
+ ["correlationId", "facet-correlation-id"],
21
+ ["from", "facet-from"],
22
+ ["to", "facet-to"],
23
+ ];
24
+ const elementStyles = `
25
+ [hidden] {
26
+ display: none !important;
27
+ }
28
+
29
+ :host {
30
+ display: block;
31
+ color: inherit;
32
+ font: inherit;
33
+ }
34
+
35
+ [part="panel"] {
36
+ display: grid;
37
+ gap: ${boePanel.gap};
38
+ padding: ${boePanel.padding};
39
+ border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 82%, transparent);
40
+ border-radius: ${boePanel.radius};
41
+ background: ${boePanel.background};
42
+ }
43
+
44
+ [part="title"] {
45
+ margin: 0;
46
+ font: inherit;
47
+ font-size: 1.1rem;
48
+ font-weight: 700;
49
+ color: var(--boe-token-text-text, #1f1e1b);
50
+ }
51
+
52
+ [part="toolbar"] {
53
+ display: flex;
54
+ flex-wrap: wrap;
55
+ align-items: end;
56
+ gap: 0.6rem;
57
+ }
58
+
59
+ [part="group-by"] {
60
+ display: inline-flex;
61
+ border: 1px solid var(--boe-token-stroke-stroke, #e8e8e8);
62
+ border-radius: 999px;
63
+ overflow: hidden;
64
+ }
65
+
66
+ [part="group-by-option"] {
67
+ appearance: none;
68
+ font: inherit;
69
+ font-size: 0.8rem;
70
+ font-weight: 600;
71
+ padding: 0.3rem 0.7rem;
72
+ border: none;
73
+ background: transparent;
74
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
75
+ cursor: pointer;
76
+ transition: background ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
77
+ }
78
+
79
+ [part="group-by-option"][aria-pressed="true"] {
80
+ background: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 12%, transparent);
81
+ color: var(--boe-token-surface-surface-brand, #0061d5);
82
+ }
83
+
84
+ [part="facet"] {
85
+ display: grid;
86
+ gap: 0.2rem;
87
+ font-size: 0.72rem;
88
+ font-weight: 700;
89
+ letter-spacing: 0.06em;
90
+ text-transform: uppercase;
91
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
92
+ }
93
+
94
+ ${boeInputControlStyles('[part="facet"] select, [part="facet"] input')}
95
+
96
+ [part="facet"] select,
97
+ [part="facet"] input {
98
+ font: inherit;
99
+ font-size: 0.85rem;
100
+ text-transform: none;
101
+ letter-spacing: normal;
102
+ font-weight: 400;
103
+ }
104
+
105
+ [part="clear-filters"],
106
+ [part="export"],
107
+ [part="drilldown-clear"] {
108
+ appearance: none;
109
+ font: inherit;
110
+ font-size: 0.8rem;
111
+ font-weight: 600;
112
+ padding: 0.35rem 0.7rem;
113
+ border-radius: ${boeRadius.control};
114
+ border: 1px solid var(--boe-token-stroke-stroke, #e8e8e8);
115
+ background: var(--boe-token-surface-surface, #ffffff);
116
+ color: var(--boe-token-text-text, #222222);
117
+ cursor: pointer;
118
+ }
119
+
120
+ [part="clear-filters"]:hover,
121
+ [part="export"]:hover,
122
+ [part="drilldown-clear"]:hover {
123
+ background: var(--boe-token-surface-surface-hover, #f4f4f4);
124
+ border-color: var(--boe-token-stroke-stroke-hover, #bcbcbc);
125
+ }
126
+
127
+ [part="drilldown"] {
128
+ display: flex;
129
+ flex-wrap: wrap;
130
+ align-items: center;
131
+ gap: 0.5rem;
132
+ padding: 0.4rem 0.6rem;
133
+ border-radius: ${boeRadius.med};
134
+ background: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 8%, transparent);
135
+ font-size: 0.82rem;
136
+ }
137
+
138
+ [part="drilldown-id"] {
139
+ font-family: ui-monospace, monospace;
140
+ font-weight: 700;
141
+ }
142
+
143
+ [part="result-summary"] {
144
+ margin: 0;
145
+ font-size: 0.8rem;
146
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
147
+ }
148
+
149
+ [part="groups"] {
150
+ display: grid;
151
+ gap: 0.5rem;
152
+ }
153
+
154
+ /* Windowing needs a bounded viewport to scroll within:
155
+ --boe-audit-height is the host's knob, defaulting to a page-sized
156
+ band. */
157
+ :host([virtualize]) [part="groups"] {
158
+ max-block-size: var(--boe-audit-height, 32rem);
159
+ overflow-y: auto;
160
+ }
161
+
162
+ [part="spacer"] {
163
+ /* The grid gap must not apply to the spacers, or the scroll range
164
+ gains a gap's height at each end and the content drifts. */
165
+ margin-block: calc(-0.25rem);
166
+ }
167
+
168
+ [part="group"] {
169
+ border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 70%, transparent);
170
+ border-radius: ${boeRadius.med};
171
+ overflow: hidden;
172
+ }
173
+
174
+ [part="group-heading"] {
175
+ margin: 0;
176
+ font: inherit;
177
+ }
178
+
179
+ [part="group-toggle"] {
180
+ appearance: none;
181
+ display: flex;
182
+ inline-size: 100%;
183
+ align-items: baseline;
184
+ gap: 0.5rem;
185
+ padding: 0.5rem 0.65rem;
186
+ border: none;
187
+ background: var(--boe-token-surface-surface-hover, #f4f4f4);
188
+ color: var(--boe-token-text-text, #1f1e1b);
189
+ font: inherit;
190
+ font-weight: 700;
191
+ text-align: start;
192
+ cursor: pointer;
193
+ }
194
+
195
+ [part="group-toggle"]::before {
196
+ content: "▾";
197
+ font-size: 0.7rem;
198
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
199
+ }
200
+
201
+ [part="group-toggle"][aria-expanded="false"]::before {
202
+ content: "▸";
203
+ }
204
+
205
+ [part="group-count"] {
206
+ margin-left: auto;
207
+ font-size: 0.76rem;
208
+ font-weight: 600;
209
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
210
+ }
211
+
212
+ [part="events"] {
213
+ list-style: none;
214
+ margin: 0;
215
+ padding: 0;
216
+ display: grid;
217
+ }
218
+
219
+ [part="event"] {
220
+ display: grid;
221
+ gap: 0.25rem;
222
+ padding: 0.5rem 0.65rem;
223
+ border-top: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 55%, transparent);
224
+ border-left: 3px solid var(--audit-tone, transparent);
225
+ }
226
+
227
+ [part="event-topline"] {
228
+ display: flex;
229
+ flex-wrap: wrap;
230
+ align-items: baseline;
231
+ gap: 0.4rem;
232
+ }
233
+
234
+ [part="event-action"] {
235
+ appearance: none;
236
+ padding: 0;
237
+ border: none;
238
+ background: none;
239
+ font: inherit;
240
+ font-weight: 600;
241
+ color: var(--boe-token-text-text, #1f1e1b);
242
+ text-align: start;
243
+ cursor: pointer;
244
+ }
245
+
246
+ [part="event-action"]:hover {
247
+ text-decoration: underline;
248
+ }
249
+
250
+ [part="actor"] {
251
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
252
+ font-size: 0.82rem;
253
+ }
254
+
255
+ [part="badge"] {
256
+ display: inline-flex;
257
+ padding: 0.12rem 0.42rem;
258
+ border-radius: 999px;
259
+ background: color-mix(in srgb, var(--audit-tone, var(--boe-token-surface-surface-brand, #0061d5)) 12%, transparent);
260
+ color: color-mix(in srgb, var(--audit-tone, var(--boe-token-surface-surface-brand, #0061d5)) 74%, black 26%);
261
+ font-size: 0.72rem;
262
+ font-weight: 600;
263
+ }
264
+
265
+ [part="timestamp"] {
266
+ margin-left: auto;
267
+ white-space: nowrap;
268
+ font-size: 0.75rem;
269
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
270
+ }
271
+
272
+ [part="event-summary"] {
273
+ margin: 0;
274
+ font-size: 0.85rem;
275
+ line-height: 1.5;
276
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
277
+ }
278
+
279
+ [part="evidence"] {
280
+ display: flex;
281
+ flex-wrap: wrap;
282
+ gap: 0.35rem;
283
+ }
284
+
285
+ [part="evidence-link"] {
286
+ appearance: none;
287
+ display: inline-flex;
288
+ align-items: center;
289
+ padding: 0.18rem 0.5rem;
290
+ border: 1px solid color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 30%, transparent);
291
+ border-radius: 999px;
292
+ background: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 6%, transparent);
293
+ color: var(--boe-token-surface-surface-brand, #0061d5);
294
+ font: inherit;
295
+ font-size: 0.74rem;
296
+ font-weight: 600;
297
+ text-decoration: none;
298
+ cursor: pointer;
299
+ }
300
+
301
+ [part="correlation"] {
302
+ appearance: none;
303
+ justify-self: start;
304
+ padding: 0.1rem 0.35rem;
305
+ border: 1px dashed color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 90%, transparent);
306
+ border-radius: ${boeRadius.size};
307
+ background: none;
308
+ font-family: ui-monospace, monospace;
309
+ font-size: 0.7rem;
310
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
311
+ cursor: pointer;
312
+ }
313
+
314
+ [part="correlation"]:hover {
315
+ color: var(--boe-token-surface-surface-brand, #0061d5);
316
+ border-color: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 45%, transparent);
317
+ }
318
+
319
+ [part="empty"] {
320
+ padding: ${boePanel.padding};
321
+ border-radius: ${boeRadius.large};
322
+ border: 1px dashed color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 70%, transparent);
323
+ color: var(--boe-token-text-text-secondary, #6f6f6f);
324
+ }
325
+
326
+ [part="group-by-option"]:focus-visible,
327
+ [part="group-toggle"]:focus-visible,
328
+ [part="event-action"]:focus-visible,
329
+ [part="evidence-link"]:focus-visible,
330
+ [part="correlation"]:focus-visible,
331
+ [part="clear-filters"]:focus-visible,
332
+ [part="export"]:focus-visible,
333
+ [part="drilldown-clear"]:focus-visible {
334
+ outline: none;
335
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 18%, transparent);
336
+ }
337
+ `;
338
+ /**
339
+ * The aggregation, faceting, and export layer over the `box-timeline` event
340
+ * contract (opportunity 3 of `plans/component-opportunities.md`): the same
341
+ * records the flat feed renders, grouped into collapsible day/actor/action
342
+ * sections with counts, narrowed by facets and a date range, drillable to one
343
+ * workflow run by correlation id, and exportable as CSV.
344
+ *
345
+ * Aggregation is client-side and pure. `virtualize` windows the log for
346
+ * production-scale volumes; server-side paging remains a tracked depth
347
+ * limitation.
348
+ */
349
+ export class AuditLog extends BaseElement {
350
+ static tagName = DEFAULT_TAG_NAME;
351
+ static get observedAttributes() {
352
+ return [
353
+ "events",
354
+ "exportable",
355
+ "facet-action",
356
+ "facet-actor",
357
+ "facet-correlation-id",
358
+ "facet-from",
359
+ "facet-to",
360
+ "group-by",
361
+ "heading",
362
+ "reference-time",
363
+ "virtualize",
364
+ "row-height",
365
+ "heading-height",
366
+ ];
367
+ }
368
+ toolbarEl;
369
+ groupByEl;
370
+ actorSelectEl;
371
+ actionSelectEl;
372
+ fromInputEl;
373
+ toInputEl;
374
+ clearFiltersEl;
375
+ exportEl;
376
+ drilldownEl;
377
+ drilldownIdEl;
378
+ summaryEl;
379
+ groupsEl;
380
+ emptyEl;
381
+ titleEl;
382
+ /** Section keys the reader has collapsed. Absent means expanded. */
383
+ collapsedGroups = new Set();
384
+ /** The row window the last render used; null when it rendered everything. */
385
+ lastWindow = null;
386
+ scrollFrame = 0;
387
+ measuredHeights = null;
388
+ measureFrame = 0;
389
+ /** Row set the adoption budget below is counted against. */
390
+ adoptedFor = null;
391
+ adoptionCount = 0;
392
+ viewportObserver = null;
393
+ contentSignature = "";
394
+ optionsSignature = "";
395
+ /** Raw `events` attribute the parse cache was built from. */
396
+ eventsRaw = null;
397
+ eventsCache = [];
398
+ get heading() {
399
+ return this.getAttribute("heading") ?? "Audit log";
400
+ }
401
+ set heading(value) {
402
+ this.setAttribute("heading", value);
403
+ }
404
+ /**
405
+ * Audit records; JSON payloads are validated per record before rendering.
406
+ *
407
+ * Memoized on the raw attribute so a single keystroke in the date field
408
+ * does not re-parse and re-validate the whole payload once per reader
409
+ * (`update`, `visibleEvents`, and each click-handler branch all read it).
410
+ * The cache invalidates itself when the attribute changes; the returned
411
+ * array is a copy, so a caller cannot mutate the log out from under it.
412
+ */
413
+ get events() {
414
+ const raw = this.getAttribute("events");
415
+ if (!raw) {
416
+ return [];
417
+ }
418
+ if (raw !== this.eventsRaw) {
419
+ this.eventsRaw = raw;
420
+ try {
421
+ const parsed = JSON.parse(raw);
422
+ this.eventsCache =
423
+ Array.isArray(parsed) && parsed.every(isTimelineEventRecord)
424
+ ? parsed
425
+ : [];
426
+ }
427
+ catch {
428
+ this.eventsCache = [];
429
+ }
430
+ }
431
+ return [...this.eventsCache];
432
+ }
433
+ set events(value) {
434
+ if (value.length) {
435
+ this.setAttribute("events", JSON.stringify(value));
436
+ return;
437
+ }
438
+ this.removeAttribute("events");
439
+ }
440
+ get groupBy() {
441
+ return resolveAuditGroupBy(this.getAttribute("group-by"));
442
+ }
443
+ set groupBy(value) {
444
+ this.setAttribute("group-by", resolveAuditGroupBy(value));
445
+ }
446
+ get facets() {
447
+ const facets = {};
448
+ for (const [key, attribute] of FACET_ATTRIBUTES) {
449
+ const value = this.getAttribute(attribute);
450
+ if (value) {
451
+ facets[key] = value;
452
+ }
453
+ }
454
+ return facets;
455
+ }
456
+ /** Replaces the whole selection — an omitted or empty facet is cleared. */
457
+ set facets(value) {
458
+ for (const [key, attribute] of FACET_ATTRIBUTES) {
459
+ const next = value[key];
460
+ if (next) {
461
+ this.setAttribute(attribute, next);
462
+ }
463
+ else {
464
+ this.removeAttribute(attribute);
465
+ }
466
+ }
467
+ }
468
+ get exportable() {
469
+ return this.hasAttribute("exportable");
470
+ }
471
+ set exportable(value) {
472
+ this.toggleAttribute("exportable", value);
473
+ }
474
+ get referenceTime() {
475
+ return this.getAttribute("reference-time") ?? "";
476
+ }
477
+ set referenceTime(value) {
478
+ this.setAttribute("reference-time", value);
479
+ }
480
+ /** The events currently on screen: the full set narrowed by the facets. */
481
+ get visibleEvents() {
482
+ return filterAuditEvents(this.events, this.facets);
483
+ }
484
+ /**
485
+ * The sections the current facets and grouping produce.
486
+ *
487
+ * Public because windowing takes away the alternative: a host used to be
488
+ * able to read the sections off the DOM, and once only a slice is rendered,
489
+ * that stops being the whole picture.
490
+ */
491
+ get groups() {
492
+ return this.currentGroups();
493
+ }
494
+ toggleGroup(key, expanded) {
495
+ const next = expanded ?? this.collapsedGroups.has(key);
496
+ if (next) {
497
+ this.collapsedGroups.delete(key);
498
+ }
499
+ else {
500
+ this.collapsedGroups.add(key);
501
+ }
502
+ this.dispatchEvent(new CustomEvent("group-toggled", {
503
+ bubbles: true,
504
+ composed: true,
505
+ detail: { key, expanded: next },
506
+ }));
507
+ if (this.isRendered) {
508
+ this.update();
509
+ }
510
+ }
511
+ expandAll() {
512
+ this.collapsedGroups.clear();
513
+ if (this.isRendered) {
514
+ this.update();
515
+ }
516
+ }
517
+ collapseAll() {
518
+ for (const group of this.currentGroups()) {
519
+ this.collapsedGroups.add(group.key);
520
+ }
521
+ if (this.isRendered) {
522
+ this.update();
523
+ }
524
+ }
525
+ /**
526
+ * Export what is on screen, not the whole set — an export that silently
527
+ * widens past the reader's filters is a compliance hazard. Returns the CSV
528
+ * and emits `export-requested` so the host owns delivery (download, mail,
529
+ * archive API).
530
+ */
531
+ exportCsv() {
532
+ const events = this.visibleEvents;
533
+ const csv = toAuditCsv(events);
534
+ this.dispatchEvent(new CustomEvent("export-requested", {
535
+ bubbles: true,
536
+ composed: true,
537
+ detail: { format: "csv", csv, events },
538
+ }));
539
+ return csv;
540
+ }
541
+ /**
542
+ * Watch the scroller so a measurable — or resized — viewport re-plans.
543
+ *
544
+ * Separate from `setupListeners` because that runs *once*, on the element's
545
+ * first connection, while `disconnectedCallback` tears the observer down
546
+ * every time. A log removed and re-inserted would otherwise come back with
547
+ * its listeners intact and its observer gone, so resizing its container
548
+ * would silently stop re-planning. Idempotent, so both callers are safe.
549
+ */
550
+ observeViewport() {
551
+ if (this.viewportObserver || typeof ResizeObserver === "undefined")
552
+ return;
553
+ this.viewportObserver = new ResizeObserver(() => {
554
+ if (!this.virtualize)
555
+ return;
556
+ const next = this.currentPlan().window;
557
+ if (this.lastWindow && sameRowWindow(this.lastWindow, next))
558
+ return;
559
+ this.update();
560
+ });
561
+ this.viewportObserver.observe(this.groupsEl);
562
+ }
563
+ connectedCallback() {
564
+ super.connectedCallback();
565
+ // Re-arm after a disconnect; no-op on the first connection, where
566
+ // setupListeners has already done it.
567
+ this.observeViewport();
568
+ }
569
+ disconnectedCallback() {
570
+ // The observer holds a reference to the scroller and the pending frames
571
+ // hold one to the element; a log removed mid-scroll should not keep either
572
+ // alive, or re-render into a shadow root nobody is looking at.
573
+ this.viewportObserver?.disconnect();
574
+ this.viewportObserver = null;
575
+ if (this.scrollFrame)
576
+ cancelAnimationFrame(this.scrollFrame);
577
+ if (this.measureFrame)
578
+ cancelAnimationFrame(this.measureFrame);
579
+ this.scrollFrame = 0;
580
+ this.measureFrame = 0;
581
+ super.disconnectedCallback?.();
582
+ }
583
+ attributeChangedCallback(name, oldValue, newValue) {
584
+ // A different grouping dimension mints different section keys, so the
585
+ // collapse state from the old dimension cannot be carried over.
586
+ if (name === "group-by" && oldValue !== newValue) {
587
+ this.collapsedGroups.clear();
588
+ }
589
+ super.attributeChangedCallback(name, oldValue, newValue);
590
+ }
591
+ /**
592
+ * Render only the sections near the viewport. Opt-in for the same reason
593
+ * `box-table`'s is: a windowed surface needs a bounded height to scroll
594
+ * within, and silently windowing an unbounded one would render nothing.
595
+ *
596
+ * Unlike the table, this *can* window a grouped, variable-height surface —
597
+ * it plans over a cumulative offset index rather than a row count times a
598
+ * row height. Grouping is the whole point of an audit log, so refusing to
599
+ * window it would mean refusing the only shape it comes in.
600
+ */
601
+ get virtualize() {
602
+ return this.hasAttribute("virtualize");
603
+ }
604
+ set virtualize(value) {
605
+ this.toggleAttribute("virtualize", Boolean(value));
606
+ }
607
+ /**
608
+ * Starting height estimates, in pixels — not contracts. The real heights
609
+ * depend on density, font, and how much an event's summary wraps; after the
610
+ * first paint the element measures one of each and uses that instead.
611
+ */
612
+ get rowHeight() {
613
+ return this.measuredHeights?.event ?? this.declaredHeight("row-height", 76);
614
+ }
615
+ get headingHeight() {
616
+ return this.measuredHeights?.heading ?? this.declaredHeight("heading-height", 40);
617
+ }
618
+ set rowHeight(value) {
619
+ this.measuredHeights = null;
620
+ this.setAttribute("row-height", String(value));
621
+ }
622
+ set headingHeight(value) {
623
+ this.measuredHeights = null;
624
+ this.setAttribute("heading-height", String(value));
625
+ }
626
+ declaredHeight(attribute, fallback) {
627
+ const raw = Number(this.getAttribute(attribute));
628
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
629
+ }
630
+ /** The window the last render used, or null when it rendered every section. */
631
+ get renderedWindow() {
632
+ return this.virtualize ? this.lastWindow : null;
633
+ }
634
+ /**
635
+ * Adopt the real rendered heights, once, when they differ from the ones the
636
+ * last plan used. Re-planning with the measured heights settles immediately —
637
+ * the next measurement matches — so this cannot loop.
638
+ */
639
+ /** Mean rendered height of the elements matching a part, or 0 if none. */
640
+ meanHeight(part) {
641
+ const nodes = Array.from(this.groupsEl.querySelectorAll(`[part="${part}"]`));
642
+ if (nodes.length === 0)
643
+ return 0;
644
+ const total = nodes.reduce((sum, node) => sum + node.getBoundingClientRect().height, 0);
645
+ return total / nodes.length;
646
+ }
647
+ /**
648
+ * Adopt the real rendered heights when they differ from the ones in use.
649
+ *
650
+ * Two things here exist because the obvious version ran away. Sampling *one*
651
+ * event row and adopting its height looks fine and is not: event rows are not
652
+ * uniform — `summary`, `evidence` and `correlationId` are each optional — so
653
+ * adopting the first rendered row's height moves the window, which renders a
654
+ * different first row, which measures differently, which moves the window.
655
+ * Measured in Chromium with every third event several lines taller: **55 full
656
+ * rebuilds in 0.9 seconds while nothing was scrolling**, the sampled height
657
+ * flipping between 33px and 166.5px.
658
+ *
659
+ * So: average across every rendered row rather than sampling one, which makes
660
+ * the estimate both more representative and far less sensitive to which rows
661
+ * happen to be on screen. And bound the adoptions per row set anyway, because
662
+ * "the average is stable" is an argument about typical data, and this is the
663
+ * code path where being wrong costs a frame-rate collapse rather than a
664
+ * slightly wrong scrollbar.
665
+ */
666
+ measureRowHeights() {
667
+ if (!this.virtualize || this.measureFrame)
668
+ return;
669
+ const signature = this.heightSignature();
670
+ if (signature !== this.adoptedFor) {
671
+ this.adoptedFor = signature;
672
+ this.adoptionCount = 0;
673
+ }
674
+ if (this.adoptionCount >= MAX_HEIGHT_ADOPTIONS)
675
+ return;
676
+ const headingHeight = this.meanHeight("group-heading");
677
+ const eventHeight = this.meanHeight("event");
678
+ if (headingHeight <= 0 || eventHeight <= 0)
679
+ return;
680
+ if (Math.abs(headingHeight - this.headingHeight) < 1 &&
681
+ Math.abs(eventHeight - this.rowHeight) < 1) {
682
+ return;
683
+ }
684
+ this.adoptionCount++;
685
+ this.measuredHeights = { heading: headingHeight, event: eventHeight };
686
+ this.measureFrame = requestAnimationFrame(() => {
687
+ this.measureFrame = 0;
688
+ this.update();
689
+ });
690
+ }
691
+ /**
692
+ * What counts as "the same row set" for the adoption budget: change the data,
693
+ * the grouping, the filters or the collapse state and the rows genuinely
694
+ * differ, so measuring again is warranted.
695
+ */
696
+ heightSignature() {
697
+ return JSON.stringify([
698
+ this.getAttribute("events") ?? "",
699
+ this.groupBy,
700
+ this.facets,
701
+ [...this.collapsedGroups].sort(),
702
+ ]);
703
+ }
704
+ currentPlan() {
705
+ const { rows, index } = this.cachedRows();
706
+ return planAuditWindow(rows, { heading: this.headingHeight, event: this.rowHeight }, {
707
+ viewportHeight: this.groupsEl.clientHeight,
708
+ scrollTop: this.groupsEl.scrollTop,
709
+ contentHeight: this.groupsEl.scrollHeight,
710
+ }, index);
711
+ }
712
+ now() {
713
+ return resolveReferenceTime(this.getAttribute("reference-time"));
714
+ }
715
+ /**
716
+ * Grouping, flattening and indexing for the current data, computed once and
717
+ * reused until something that affects them changes.
718
+ *
719
+ * The scroll path asks "did the window move?" on every frame that is not
720
+ * skipped, and answering it used to re-parse the whole `events` attribute,
721
+ * re-filter, re-group, re-flatten and rebuild the offset array — then
722
+ * `update()` did all of it again. That is O(n) twice per frame on a surface
723
+ * whose entire purpose is not being O(n) at scale.
724
+ */
725
+ rowCache = null;
726
+ cachedRows() {
727
+ const heights = { heading: this.headingHeight, event: this.rowHeight };
728
+ const signature = JSON.stringify([
729
+ this.getAttribute("events") ?? "",
730
+ this.groupBy,
731
+ this.facets,
732
+ this.referenceTime,
733
+ [...this.collapsedGroups].sort(),
734
+ heights,
735
+ ]);
736
+ if (this.rowCache?.signature === signature)
737
+ return this.rowCache;
738
+ const groups = groupAuditEvents(this.visibleEvents, this.groupBy, this.now());
739
+ const rows = flattenAuditRows(groups, this.collapsedGroups);
740
+ this.rowCache = { signature, groups, rows, index: buildAuditIndex(rows, heights) };
741
+ return this.rowCache;
742
+ }
743
+ currentGroups() {
744
+ return this.cachedRows().groups;
745
+ }
746
+ patchFacets(patch) {
747
+ this.facets = { ...this.facets, ...patch };
748
+ this.dispatchEvent(new CustomEvent("facets-changed", {
749
+ bubbles: true,
750
+ composed: true,
751
+ detail: { facets: this.facets },
752
+ }));
753
+ }
754
+ eventHtml(event) {
755
+ const tone = resolveTimelineTone(event.tone);
756
+ const timestamp = formatAuditTimestamp(event.timestamp);
757
+ const evidence = (event.evidence ?? [])
758
+ .map(entry => {
759
+ const attrs = `part="evidence-link" data-event-id="${escapeHtml(event.id)}" data-evidence-id="${escapeHtml(entry.id)}"`;
760
+ // An unsafe scheme renders as a button: the chip still works as an
761
+ // intent, it just never becomes a navigable link.
762
+ return entry.href && isSafeHref(entry.href)
763
+ ? `<a ${attrs} href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</a>`
764
+ : `<button type="button" ${attrs}>${escapeHtml(entry.label)}</button>`;
765
+ })
766
+ .join("");
767
+ return `
768
+ <li part="event" data-event-id="${escapeHtml(event.id)}" data-tone="${escapeHtml(tone)}" style="--audit-tone:${auditToneColor(tone)};">
769
+ <div part="event-topline">
770
+ <button type="button" part="event-action" data-event-id="${escapeHtml(event.id)}">${escapeHtml(event.action)}</button>
771
+ ${event.actor ? `<span part="actor">${escapeHtml(event.actor.name)}</span>` : ""}
772
+ ${event.badge ? `<span part="badge">${escapeHtml(event.badge)}</span>` : ""}
773
+ ${timestamp ? `<time part="timestamp" datetime="${escapeHtml(event.timestamp ?? "")}">${escapeHtml(timestamp)}</time>` : ""}
774
+ </div>
775
+ ${event.summary ? `<p part="event-summary">${escapeHtml(event.summary)}</p>` : ""}
776
+ ${evidence ? `<div part="evidence">${evidence}</div>` : ""}
777
+ ${event.correlationId
778
+ ? `<button type="button" part="correlation" data-correlation-id="${escapeHtml(event.correlationId)}" aria-label="Show only workflow run ${escapeHtml(event.correlationId)}">${escapeHtml(event.correlationId)}</button>`
779
+ : ""}
780
+ </li>
781
+ `;
782
+ }
783
+ groupHtml(group, index, slice) {
784
+ const toggleId = `audit-group-toggle-${String(index)}`;
785
+ const bodyId = `audit-group-body-${String(index)}`;
786
+ // Windowed: only the events inside the window. The heading always renders
787
+ // for an intersecting section — [part="group-body"] is aria-labelledby the
788
+ // toggle inside it, so a section without its heading is both unlabelled and
789
+ // impossible to collapse.
790
+ const events = slice ? group.events.slice(slice.start, slice.end) : group.events;
791
+ const actors = group.actorCount
792
+ ? ` · ${String(group.actorCount)} ${group.actorCount === 1 ? "actor" : "actors"}`
793
+ : "";
794
+ return `
795
+ <section part="group" data-group-key="${escapeHtml(group.key)}">
796
+ <h3 part="group-heading">
797
+ <button type="button" part="group-toggle" id="${toggleId}" aria-controls="${bodyId}" aria-expanded="true" data-group-key="${escapeHtml(group.key)}">
798
+ <span part="group-label">${escapeHtml(group.label)}</span>
799
+ <span part="group-count">${String(group.count)} ${group.count === 1 ? "event" : "events"}${actors}</span>
800
+ </button>
801
+ </h3>
802
+ <div part="group-body" id="${bodyId}" role="region" aria-labelledby="${toggleId}">
803
+ <ol part="events" role="list"${slice && slice.start > 0 ? ` start="${String(slice.start + 1)}"` : ""}>${events.map(event => this.eventHtml(event)).join("")}</ol>
804
+ </div>
805
+ </section>
806
+ `;
807
+ }
808
+ renderOptions(select, values, allLabel) {
809
+ select.innerHTML = [
810
+ `<option value="">${escapeHtml(allLabel)}</option>`,
811
+ // The empty value is the all-values option, so unattributed events are
812
+ // reachable by grouping rather than by faceting.
813
+ ...values
814
+ .filter(entry => entry.value !== "")
815
+ .map(entry => `<option value="${escapeHtml(entry.value)}">${escapeHtml(entry.label)} (${String(entry.count)})</option>`),
816
+ ].join("");
817
+ }
818
+ renderTemplate() {
819
+ if (!this.shadowRoot) {
820
+ return;
821
+ }
822
+ this.shadowRoot.innerHTML = `
823
+ <style>${elementStyles}</style>
824
+ <section part="panel" aria-labelledby="audit-title">
825
+ <h2 part="title" id="audit-title"></h2>
826
+ <div part="toolbar" role="group" aria-label="Audit filters">
827
+ <div part="group-by" role="group" aria-label="Group by">
828
+ ${AUDIT_GROUP_BY_VALUES.map(value => `<button type="button" part="group-by-option" data-group-by="${value}" aria-pressed="false">${AUDIT_GROUP_BY_LABELS[value]}</button>`).join("")}
829
+ </div>
830
+ <label part="facet">Actor<select part="facet-actor"></select></label>
831
+ <label part="facet">Action<select part="facet-action"></select></label>
832
+ <label part="facet">From<input type="date" part="facet-from"></label>
833
+ <label part="facet">To<input type="date" part="facet-to"></label>
834
+ <button type="button" part="clear-filters" hidden>Clear filters</button>
835
+ <button type="button" part="export" hidden>Export CSV</button>
836
+ </div>
837
+ <div part="drilldown" hidden>
838
+ <span>Workflow run</span>
839
+ <span part="drilldown-id"></span>
840
+ <button type="button" part="drilldown-clear">Clear drill-down</button>
841
+ </div>
842
+ <p part="result-summary" role="status"></p>
843
+ <div part="groups"></div>
844
+ <div part="empty" hidden>No audit events match these filters.</div>
845
+ </section>
846
+ `;
847
+ const root = this.shadowRoot;
848
+ this.titleEl = root.querySelector('[part="title"]');
849
+ this.toolbarEl = root.querySelector('[part="toolbar"]');
850
+ this.groupByEl = root.querySelector('[part="group-by"]');
851
+ this.actorSelectEl = root.querySelector('[part="facet-actor"]');
852
+ this.actionSelectEl = root.querySelector('[part="facet-action"]');
853
+ this.fromInputEl = root.querySelector('[part="facet-from"]');
854
+ this.toInputEl = root.querySelector('[part="facet-to"]');
855
+ this.clearFiltersEl = root.querySelector('[part="clear-filters"]');
856
+ this.exportEl = root.querySelector('[part="export"]');
857
+ this.drilldownEl = root.querySelector('[part="drilldown"]');
858
+ this.drilldownIdEl = root.querySelector('[part="drilldown-id"]');
859
+ this.summaryEl = root.querySelector('[part="result-summary"]');
860
+ this.groupsEl = root.querySelector('[part="groups"]');
861
+ this.emptyEl = root.querySelector('[part="empty"]');
862
+ }
863
+ setupListeners() {
864
+ // A windowed surface cannot bootstrap itself from an unmeasured viewport:
865
+ // an empty scroller is zero pixels tall, a zero-height viewport plans an
866
+ // empty window, and an empty window renders nothing to measure. Left alone
867
+ // the log stays blank until something else happens to trigger a render.
868
+ // The observer breaks that circle on the first layout, and keeps earning
869
+ // its place afterwards — a resized container changes how much fits.
870
+ this.observeViewport();
871
+ // Scroll fires continuously; coalesce to one re-render per frame, and skip
872
+ // entirely while the resolved slice is unchanged — most scroll events move
873
+ // the viewport within the sections already rendered.
874
+ this.groupsEl.addEventListener("scroll", () => {
875
+ if (!this.virtualize || this.scrollFrame)
876
+ return;
877
+ this.scrollFrame = requestAnimationFrame(() => {
878
+ this.scrollFrame = 0;
879
+ const next = this.currentPlan().window;
880
+ if (this.lastWindow && sameRowWindow(this.lastWindow, next))
881
+ return;
882
+ this.update();
883
+ });
884
+ }, { passive: true });
885
+ // The toolbar is built once and patched in place, so a select stays open
886
+ // and a half-typed date survives every re-render of the sections below.
887
+ this.toolbarEl.addEventListener("click", event => {
888
+ const target = event.target;
889
+ const option = target.closest('[part="group-by-option"]');
890
+ if (option && this.toolbarEl.contains(option)) {
891
+ const next = resolveAuditGroupBy(option.getAttribute("data-group-by"));
892
+ if (next !== this.groupBy) {
893
+ this.groupBy = next;
894
+ this.dispatchEvent(new CustomEvent("group-by-changed", {
895
+ bubbles: true,
896
+ composed: true,
897
+ detail: { groupBy: next },
898
+ }));
899
+ }
900
+ return;
901
+ }
902
+ if (target.closest('[part="clear-filters"]')) {
903
+ this.patchFacets({
904
+ actor: undefined,
905
+ action: undefined,
906
+ correlationId: undefined,
907
+ from: undefined,
908
+ to: undefined,
909
+ });
910
+ return;
911
+ }
912
+ if (target.closest('[part="export"]')) {
913
+ this.exportCsv();
914
+ }
915
+ });
916
+ this.toolbarEl.addEventListener("change", event => {
917
+ const target = event.target;
918
+ if (target === this.actorSelectEl) {
919
+ this.patchFacets({ actor: this.actorSelectEl.value || undefined });
920
+ }
921
+ else if (target === this.actionSelectEl) {
922
+ this.patchFacets({ action: this.actionSelectEl.value || undefined });
923
+ }
924
+ else if (target === this.fromInputEl) {
925
+ this.patchFacets({ from: this.fromInputEl.value || undefined });
926
+ }
927
+ else if (target === this.toInputEl) {
928
+ this.patchFacets({ to: this.toInputEl.value || undefined });
929
+ }
930
+ });
931
+ this.drilldownEl.addEventListener("click", event => {
932
+ if (event.target.closest('[part="drilldown-clear"]')) {
933
+ this.patchFacets({ correlationId: undefined });
934
+ }
935
+ });
936
+ this.groupsEl.addEventListener("click", event => {
937
+ const target = event.target;
938
+ const toggle = target.closest('[part="group-toggle"]');
939
+ if (toggle && this.groupsEl.contains(toggle)) {
940
+ this.toggleGroup(toggle.getAttribute("data-group-key") ?? "");
941
+ return;
942
+ }
943
+ const correlation = target.closest('[part="correlation"]');
944
+ if (correlation && this.groupsEl.contains(correlation)) {
945
+ const correlationId = correlation.getAttribute("data-correlation-id") ?? "";
946
+ this.patchFacets({ correlationId });
947
+ this.dispatchEvent(new CustomEvent("correlation-selected", {
948
+ bubbles: true,
949
+ composed: true,
950
+ detail: {
951
+ correlationId,
952
+ events: filterAuditEvents(this.events, { correlationId }),
953
+ },
954
+ }));
955
+ return;
956
+ }
957
+ const evidenceLink = target.closest('[part="evidence-link"]');
958
+ if (evidenceLink && this.groupsEl.contains(evidenceLink)) {
959
+ const source = this.events.find(entry => entry.id === (evidenceLink.getAttribute("data-event-id") ?? ""));
960
+ const evidence = source?.evidence?.find(entry => entry.id === (evidenceLink.getAttribute("data-evidence-id") ?? ""));
961
+ if (evidence) {
962
+ this.dispatchEvent(new CustomEvent("evidence-selected", {
963
+ bubbles: true,
964
+ composed: true,
965
+ detail: { event: source, evidence },
966
+ }));
967
+ }
968
+ return;
969
+ }
970
+ const action = target.closest('[part="event-action"]');
971
+ if (action && this.groupsEl.contains(action)) {
972
+ const source = this.events.find(entry => entry.id === (action.getAttribute("data-event-id") ?? ""));
973
+ if (source) {
974
+ this.dispatchEvent(new CustomEvent("event-selected", {
975
+ bubbles: true,
976
+ composed: true,
977
+ detail: { event: source },
978
+ }));
979
+ }
980
+ }
981
+ });
982
+ }
983
+ update() {
984
+ if (!this.groupsEl) {
985
+ return;
986
+ }
987
+ this.titleEl.textContent = this.heading;
988
+ const all = this.events;
989
+ const facets = this.facets;
990
+ const visible = filterAuditEvents(all, facets);
991
+ const groupBy = this.groupBy;
992
+ const groups = groupAuditEvents(visible, groupBy, this.now());
993
+ for (const option of Array.from(this.groupByEl.querySelectorAll('[part="group-by-option"]'))) {
994
+ option.setAttribute("aria-pressed", option.getAttribute("data-group-by") === groupBy ? "true" : "false");
995
+ }
996
+ // Facet options come from the unfiltered set, so choosing one facet never
997
+ // empties another's list.
998
+ const summary = summarizeAuditFacets(all);
999
+ const optionsSignature = JSON.stringify(summary);
1000
+ if (optionsSignature !== this.optionsSignature) {
1001
+ this.optionsSignature = optionsSignature;
1002
+ this.renderOptions(this.actorSelectEl, summary.actors, "All actors");
1003
+ this.renderOptions(this.actionSelectEl, summary.actions, "All actions");
1004
+ }
1005
+ this.actorSelectEl.value = facets.actor ?? "";
1006
+ this.actionSelectEl.value = facets.action ?? "";
1007
+ this.fromInputEl.value = facets.from ?? "";
1008
+ this.toInputEl.value = facets.to ?? "";
1009
+ this.clearFiltersEl.hidden = !hasAuditFacets(facets);
1010
+ this.exportEl.hidden = !this.exportable;
1011
+ this.drilldownEl.hidden = !facets.correlationId;
1012
+ this.drilldownIdEl.textContent = facets.correlationId ?? "";
1013
+ this.summaryEl.textContent =
1014
+ visible.length === all.length
1015
+ ? `${String(all.length)} ${all.length === 1 ? "event" : "events"} in ${String(groups.length)} ${groups.length === 1 ? "group" : "groups"}`
1016
+ : `Showing ${String(visible.length)} of ${String(all.length)} events in ${String(groups.length)} ${groups.length === 1 ? "group" : "groups"}`;
1017
+ // The section markup depends on content only. Collapse state is applied
1018
+ // separately below, so expanding a section never rebuilds the log.
1019
+ // Windowed renders depend on the scroll position too, so the resolved
1020
+ // slice joins the signature — otherwise scrolling would keep the sections
1021
+ // that were already on screen and never draw the ones scrolled into view.
1022
+ const plan = this.virtualize ? this.currentPlan() : null;
1023
+ this.lastWindow = plan?.window ?? null;
1024
+ const contentSignature = JSON.stringify([
1025
+ this.getAttribute("events") ?? "",
1026
+ groupBy,
1027
+ facets,
1028
+ this.referenceTime,
1029
+ // The whole rendered geometry, not just the slice: collapsing a section
1030
+ // *below* the window leaves the slice and the top spacer untouched while
1031
+ // making the log shorter, and a stale bottom spacer outlives the content
1032
+ // it described.
1033
+ plan
1034
+ ? [plan.window.startIndex, plan.window.endIndex, plan.paddingTop, plan.paddingBottom]
1035
+ : null,
1036
+ ]);
1037
+ if (contentSignature !== this.contentSignature) {
1038
+ this.contentSignature = contentSignature;
1039
+ const active = this.shadowRoot?.activeElement;
1040
+ const focusKey = active && this.groupsEl.contains(active)
1041
+ ? {
1042
+ part: active.getAttribute("part") ?? "",
1043
+ groupKey: active.getAttribute("data-group-key"),
1044
+ eventId: active.getAttribute("data-event-id"),
1045
+ }
1046
+ : null;
1047
+ const spacer = (height, position) => height > 0
1048
+ ? `<div part="spacer" data-position="${position}" aria-hidden="true" style="block-size:${String(height)}px;"></div>`
1049
+ : "";
1050
+ this.groupsEl.innerHTML = plan
1051
+ ? spacer(plan.paddingTop, "top") +
1052
+ plan.groups
1053
+ .map(planned => this.groupHtml(groups[planned.groupIndex], planned.groupIndex, {
1054
+ start: planned.eventStart,
1055
+ end: planned.eventEnd,
1056
+ }))
1057
+ .join("") +
1058
+ spacer(plan.paddingBottom, "bottom")
1059
+ : groups.map((group, index) => this.groupHtml(group, index)).join("");
1060
+ if (focusKey?.part) {
1061
+ const restored = Array.from(this.groupsEl.querySelectorAll(`[part="${focusKey.part}"]`)).find(node => node.getAttribute("data-group-key") === focusKey.groupKey &&
1062
+ node.getAttribute("data-event-id") === focusKey.eventId);
1063
+ restored?.focus();
1064
+ }
1065
+ }
1066
+ for (const section of Array.from(this.groupsEl.querySelectorAll('[part="group"]'))) {
1067
+ const key = section.getAttribute("data-group-key") ?? "";
1068
+ const expanded = !this.collapsedGroups.has(key);
1069
+ section.querySelector('[part="group-toggle"]')?.setAttribute("aria-expanded", String(expanded));
1070
+ const body = section.querySelector('[part="group-body"]');
1071
+ if (body) {
1072
+ body.hidden = !expanded;
1073
+ }
1074
+ }
1075
+ this.groupsEl.hidden = groups.length === 0;
1076
+ this.emptyEl.hidden = groups.length > 0;
1077
+ if (this.virtualize) {
1078
+ this.measureRowHeights();
1079
+ }
1080
+ }
1081
+ }
1082
+ AuditLog.register();