messagefoundry-webconsole 0.2.15__py3-none-any.whl

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 (40) hide show
  1. messagefoundry_webconsole/__init__.py +128 -0
  2. messagefoundry_webconsole/_auth.py +841 -0
  3. messagefoundry_webconsole/_html.py +539 -0
  4. messagefoundry_webconsole/_security.py +253 -0
  5. messagefoundry_webconsole/_service.py +22 -0
  6. messagefoundry_webconsole/_static.py +78 -0
  7. messagefoundry_webconsole/mount.py +103 -0
  8. messagefoundry_webconsole/pages/__init__.py +25 -0
  9. messagefoundry_webconsole/pages/_common.py +21 -0
  10. messagefoundry_webconsole/pages/account.py +771 -0
  11. messagefoundry_webconsole/pages/admin.py +480 -0
  12. messagefoundry_webconsole/pages/audit.py +66 -0
  13. messagefoundry_webconsole/pages/config.py +113 -0
  14. messagefoundry_webconsole/pages/connections.py +519 -0
  15. messagefoundry_webconsole/pages/messages.py +689 -0
  16. messagefoundry_webconsole/pages/monitoring.py +853 -0
  17. messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
  18. messagefoundry_webconsole/routes/__init__.py +6 -0
  19. messagefoundry_webconsole/routes/_common.py +45 -0
  20. messagefoundry_webconsole/routes/account.py +451 -0
  21. messagefoundry_webconsole/routes/admin.py +505 -0
  22. messagefoundry_webconsole/routes/audit.py +39 -0
  23. messagefoundry_webconsole/routes/config.py +67 -0
  24. messagefoundry_webconsole/routes/connection_writes.py +248 -0
  25. messagefoundry_webconsole/routes/core.py +1077 -0
  26. messagefoundry_webconsole/routes/monitoring.py +94 -0
  27. messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
  28. messagefoundry_webconsole/routes/oidc.py +172 -0
  29. messagefoundry_webconsole/routes/search.py +204 -0
  30. messagefoundry_webconsole/routes/sso.py +88 -0
  31. messagefoundry_webconsole/routes/status.py +178 -0
  32. messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
  33. messagefoundry_webconsole/static/app.css +345 -0
  34. messagefoundry_webconsole/static/app.js +1506 -0
  35. messagefoundry_webconsole/static/csp-probe.js +9 -0
  36. messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
  37. messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
  38. messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
  39. messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
  40. messagefoundry_webconsole-0.2.15.dist-info/licenses/NOTICE +31 -0
@@ -0,0 +1,345 @@
1
+ /* SPDX-License-Identifier: AGPL-3.0-or-later */
2
+ /* First-party styles for the /ui ops dashboard (ADR 0065). Same-origin; CSP style-src 'self'. */
3
+
4
+ :root {
5
+ --bg: #0f1115;
6
+ --panel: #171a21;
7
+ --ink: #e6e9ef;
8
+ --muted: #9aa4b2;
9
+ --line: #262b34;
10
+ --accent: #5b9dff;
11
+ --foundry: #f59e0b; /* molten amber — the "Foundry" wordmark accent (brand guidelines) */
12
+ --ok: #3fb950;
13
+ --warn: #d29922;
14
+ --bad: #f85149;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+
19
+ body {
20
+ margin: 0;
21
+ font: 14px/1.5 -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
22
+ color: var(--ink);
23
+ background: var(--bg);
24
+ }
25
+
26
+ nav {
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ padding: 0.6rem 1rem;
31
+ background: var(--panel);
32
+ border-bottom: 1px solid var(--line);
33
+ }
34
+
35
+ nav .brand { margin-right: 1rem; text-decoration: none; color: inherit; }
36
+
37
+ /* MessageFoundry wordmark (brand guidelines, June 2026): one camelCase word — "Message" in the base
38
+ ink, "Foundry" in molten amber — set in extra-bold system sans with tightened tracking. The amber
39
+ is confined to the wordmark and headings here, never body text. */
40
+ .wordmark { font-weight: 800; letter-spacing: -0.02em; white-space: nowrap; }
41
+ .wordmark .wm-foundry { color: var(--foundry); }
42
+ .wordmark .wm-tm {
43
+ font-size: 0.65em; font-weight: 500; color: var(--muted); margin-left: 0.06em;
44
+ }
45
+ nav .navlinks { display: flex; align-items: center; flex-wrap: wrap; gap: 0.15rem; }
46
+
47
+ /* Grouped nav as dropdowns: each area is a menu opening on :hover AND :focus-within (keyboard-reachable,
48
+ no JS — inside the CSP 'self' policy). The toggle is a <button>; the items are links inside the panel. */
49
+ .navgroup { position: relative; }
50
+ .navtop {
51
+ background: none; border: 0; font: inherit; cursor: pointer; color: var(--muted);
52
+ padding: 0.35rem 0.55rem; border-radius: 6px; white-space: nowrap;
53
+ }
54
+ .navtop:hover, .navgroup:focus-within .navtop, .navtop.active { color: var(--accent); }
55
+ .navmenu {
56
+ display: none; position: absolute; top: 100%; left: 0; margin-top: 0.3rem; z-index: 30;
57
+ min-width: 12rem; flex-direction: column; padding: 0.3rem;
58
+ background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
59
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
60
+ }
61
+ /* Invisible bridge across the margin gap so a hover moving button→menu doesn't fall through and close it. */
62
+ .navmenu::before { content: ""; position: absolute; left: 0; right: 0; top: -0.3rem; height: 0.3rem; }
63
+ .navgroup:hover .navmenu, .navgroup:focus-within .navmenu { display: flex; }
64
+ .navlinks .navgroup:last-child .navmenu { left: auto; right: 0; } /* keep the rightmost menu on-screen */
65
+ .navmenu a {
66
+ color: var(--muted); text-decoration: none; white-space: nowrap;
67
+ padding: 0.35rem 0.6rem; border-radius: 5px;
68
+ }
69
+ .navmenu a:hover, .navmenu a.active { color: var(--accent); background: var(--bg); }
70
+ /* Right cluster: the live status glyphs + Sign out, held together so nav's space-between keeps the links
71
+ left and this block flush right. */
72
+ .navright { display: flex; align-items: center; gap: 0.9rem; }
73
+
74
+ /* Live nav status glyphs (app.js polls /ui/nav-status, every page): the alerts bell + engine-health heart.
75
+ Neutral gray until the first poll; then health = green / orange / blinking-red and alerts = severity-
76
+ colored or gray. `color` drives the monochrome SVG tint (fill=currentColor). */
77
+ .navstatus { display: inline-flex; align-items: center; gap: 0.55rem; }
78
+ /* Each glyph is a link to its detail page (bell → /ui/alerts, heart → /ui/status): no underline, tinted by
79
+ `color`, with a hover/focus affordance so it reads as clickable. */
80
+ .navstat {
81
+ display: inline-flex; align-items: center; color: var(--muted); line-height: 0;
82
+ text-decoration: none; cursor: pointer; border-radius: 4px;
83
+ }
84
+ .navstat:hover { opacity: 0.75; }
85
+ .navstat:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
86
+ .navstat .statglyph { display: block; }
87
+ .health-ok { color: var(--ok); }
88
+ .health-warn { color: var(--warn); }
89
+ .health-down { color: var(--bad); animation: mf-health-blink 1.1s ease-in-out infinite; }
90
+ .alerts-none { color: var(--muted); }
91
+ .alerts-info { color: var(--accent); }
92
+ .alerts-warning { color: var(--warn); }
93
+ .alerts-critical { color: var(--bad); }
94
+ @keyframes mf-health-blink {
95
+ 0%, 100% { opacity: 1; }
96
+ 50% { opacity: 0.2; }
97
+ }
98
+ /* Honor reduced-motion: a critical engine stays solid red instead of blinking. */
99
+ @media (prefers-reduced-motion: reduce) {
100
+ .health-down { animation: none; }
101
+ }
102
+
103
+ nav .logout button, .filters button, .login button {
104
+ background: var(--accent); color: #04122b; border: 0; border-radius: 6px;
105
+ padding: 0.35rem 0.8rem; cursor: pointer; font-weight: 600;
106
+ }
107
+ nav .logout button { background: transparent; color: var(--muted); border: 1px solid var(--line); }
108
+ button[disabled] { opacity: 0.5; cursor: not-allowed; }
109
+
110
+ main { padding: 1rem; }
111
+ /* Content-area links (page bodies, cards — e.g. "View messages →", "← Connections"): the UA default
112
+ blue/purple-visited is unreadable on the dark background, so tint them with the accent. Author origin
113
+ beats the UA :visited rule, so both states read the same. Nav/table links are styled elsewhere. */
114
+ main a, main a:visited { color: var(--accent); }
115
+ h1 { font-size: 1.25rem; }
116
+ h2 { font-size: 1rem; color: var(--muted); margin-top: 1.5rem; }
117
+
118
+ table.grid { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
119
+ table.grid th, table.grid td {
120
+ text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--line);
121
+ white-space: nowrap; font-variant-numeric: tabular-nums;
122
+ }
123
+ table.grid th { color: var(--muted); font-weight: 600; }
124
+ table.grid a { color: var(--accent); text-decoration: none; }
125
+ /* Key/value readout tables (rows_table adjustable=False): plain + wrapping, NOT the adjustable grid.
126
+ Long values (paths, ids) wrap instead of tripping a horizontal scrollbar — app.js leaves these alone
127
+ (no data-mf-table), so they stay a simple width:100% table with no fixed layout / scroll wrapper. */
128
+ table.grid.info th, table.grid.info td { white-space: normal; overflow-wrap: anywhere; }
129
+
130
+ /* Adjustable grid tables (app.js, data-mf-table): keyboard-operable click-to-sort + drag-to-resize
131
+ columns, remembered per table. Best-practice layout (TanStack / AG Grid / W3C-APG): widths live on
132
+ injected <col> elements and the table gets an explicit px width = their SUM inside an overflow-x:auto
133
+ wrapper (NOT width:100%, which redistributes leftover space and snaps a resized column back wider than
134
+ set); sort is a native <button> cycling asc/desc/none with aria-sort on exactly one header. */
135
+ .mf-table-scroll { overflow-x: auto; }
136
+ table.grid.mf-adjustable { table-layout: fixed; width: auto; } /* app.js sets the explicit px width */
137
+ table.grid.mf-adjustable th { position: relative; }
138
+ table.grid.mf-adjustable td { overflow: hidden; text-overflow: ellipsis; }
139
+ button.mf-sort {
140
+ display: block; width: 100%; margin: 0; padding: 0; cursor: pointer;
141
+ background: none; border: 0; color: inherit; font: inherit; text-align: left;
142
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
143
+ }
144
+ button.mf-sort:hover { color: var(--ink); }
145
+ button.mf-sort:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
146
+ .mf-sort-arrow { color: var(--accent); font-size: 0.8em; }
147
+ th .mf-resize {
148
+ position: absolute; top: 0; right: 0; width: 9px; height: 100%; cursor: col-resize; z-index: 1;
149
+ }
150
+ th .mf-resize:hover { box-shadow: inset -2px 0 0 var(--accent); }
151
+ /* Drag-to-reorder: drop-target indicator (a left edge marking where the dragged column will land). */
152
+ table.grid.mf-adjustable th.mf-drop { box-shadow: inset 3px 0 0 var(--accent); }
153
+ body.mf-col-resizing, body.mf-col-resizing * { cursor: col-resize !important; user-select: none !important; }
154
+
155
+ /* Connections filter-as-you-type box (app.js, data-mf-conns-filter). */
156
+ .filterbox {
157
+ margin: 0; padding: 0.4rem 0.6rem; width: 18rem; max-width: 100%; box-sizing: border-box;
158
+ background: var(--panel); color: var(--ink); border: 1px solid var(--line); border-radius: 6px;
159
+ }
160
+ .filterbox::placeholder { color: var(--muted); }
161
+ .filterbox:focus { outline: none; border-color: var(--accent); }
162
+ /* Connection detail link (right of the name) + the message-log From/To date fields. */
163
+ /* The ⓘ connection-details link sits after the name→messages link in the same cell; give it a clear gap so
164
+ it isn't a mis-click hazard next to the primary name link. */
165
+ .detail-link { font-size: 1em; color: var(--muted); margin-left: 0.9rem; padding: 0 0.15rem; text-decoration: none; vertical-align: middle; }
166
+ .detail-link:hover { color: var(--accent); }
167
+ .filters .dtfield { display: inline-flex; gap: 0.25rem; align-items: center; color: var(--muted); }
168
+
169
+ .status { padding: 0.05rem 0.45rem; border-radius: 999px; font-size: 0.8rem; }
170
+ .status-running { color: var(--ok); }
171
+ .status-stopped, .status-draining { color: var(--muted); }
172
+ .status-failed, .status-error { color: var(--bad); }
173
+ .status-filtered, .status-stopping { color: var(--warn); }
174
+ /* #233 (ADR 0111): present in the config but deployed=false — an intentional, benign state, so it reads
175
+ in the informational accent (never the muted grey of "stopped", which means it SHOULD be running). */
176
+ .status-not_deployed { color: var(--accent); }
177
+
178
+ pre.raw {
179
+ background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
180
+ padding: 0.8rem; overflow: auto; max-height: 60vh; white-space: pre-wrap; word-break: break-word;
181
+ }
182
+
183
+ .filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.5rem 0; }
184
+ .filters input { background: var(--bg); color: var(--ink); border: 1px solid var(--line);
185
+ border-radius: 6px; padding: 0.3rem 0.5rem; }
186
+ .pager { color: var(--muted); }
187
+
188
+ .card { max-width: 360px; margin: 4rem auto; background: var(--panel);
189
+ border: 1px solid var(--line); border-radius: 10px; padding: 1.5rem; }
190
+ .card .login { display: flex; flex-direction: column; gap: 0.75rem; }
191
+ .card label { display: flex; flex-direction: column; gap: 0.25rem; color: var(--muted); }
192
+ .card input { background: var(--bg); color: var(--ink); border: 1px solid var(--line);
193
+ border-radius: 6px; padding: 0.4rem 0.5rem; }
194
+ /* The connection-detail view reuses the card frame but needs room for its tables — the 360px login-card
195
+ width is too cramped — so widen it and drop the tall login top-margin. */
196
+ .card.detail-card { max-width: 760px; margin: 1.5rem auto; }
197
+ .banner { color: var(--warn); }
198
+
199
+ .ctls { display: flex; gap: 0.3rem; }
200
+ .ctl { display: inline; margin: 0; }
201
+ .ctl button {
202
+ background: transparent; color: var(--muted); border: 1px solid var(--line);
203
+ border-radius: 5px; padding: 0.15rem 0.5rem; font-size: 0.8rem; cursor: pointer;
204
+ }
205
+ .ctl button:hover { color: var(--accent); border-color: var(--accent); }
206
+
207
+ /* Connections filter + bulk-action toolbar, sitting on the page-header row (ADR 0065). */
208
+ .page-head { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin: 0.5rem 0; }
209
+ .page-head h1 { margin: 0; }
210
+ .page-head .ctlbar { margin-left: auto; } /* push the filter + dropdown/Go to the right, header stays left */
211
+ .ctlbar { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin: 0; }
212
+ .ctlbar .filterbox { margin: 0; }
213
+ .ctlbar select {
214
+ background: var(--bg); color: var(--ink); border: 1px solid var(--line);
215
+ border-radius: 6px; padding: 0.3rem 0.5rem;
216
+ }
217
+ .ctlbar button {
218
+ background: var(--accent); color: #04122b; border: 0; border-radius: 6px;
219
+ padding: 0.35rem 0.8rem; cursor: pointer; font-weight: 600;
220
+ }
221
+ .ctlbar span { color: var(--muted); }
222
+
223
+ /* Selection checkbox column — keep it tight (one checkbox per cell). */
224
+ table.grid th:has(> [data-mf-conns-all]), table.grid td:has(> .rowcb) {
225
+ width: 1%; text-align: center;
226
+ }
227
+ .rowcb { margin: 0; cursor: pointer; }
228
+
229
+ /* Object-of-interest flag (#131): a per-row toggle button (TOML-managed) or a read-only glyph
230
+ (code-first). Amber when flagged (the brand accent), muted outline otherwise. */
231
+ .flagbtn {
232
+ background: transparent; border: 0; cursor: pointer; padding: 0 0.2rem; line-height: 1;
233
+ color: var(--muted); font-size: 1rem; border-radius: 4px;
234
+ }
235
+ .flagbtn:hover { color: var(--foundry); }
236
+ .flagbtn.flagged { color: var(--foundry); }
237
+ .flagbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
238
+ .flagform { margin: 0; }
239
+ .flag { color: var(--muted); }
240
+ .flag.flagged { color: var(--foundry); }
241
+ /* Flagged-only view toggle in the toolbar. */
242
+ .flagfilter { display: inline-flex; align-items: center; gap: 0.3rem; color: var(--muted);
243
+ white-space: nowrap; cursor: pointer; }
244
+ .flagfilter input { cursor: pointer; }
245
+
246
+ /* "Waiting for Reply" per-message outbound state (#136): a small accent badge beside the status pill,
247
+ shown while an outbound awaits an MLLP ACK past its display delay (transient — clears on the next
248
+ poll/ws swap). */
249
+ .waiting { color: var(--accent); font-size: 0.75rem; margin-left: 0.4rem; white-space: nowrap; }
250
+
251
+ .muted { color: var(--muted); }
252
+ .detail-head { display: flex; align-items: center; gap: 1rem; }
253
+ .livestats { color: var(--muted); font-variant-numeric: tabular-nums; min-height: 1.2rem;
254
+ margin: 0.25rem 0 0.5rem; }
255
+
256
+ ul.tree { list-style: none; margin: 0; padding-left: 1rem; border-left: 1px solid var(--line); }
257
+ ul.tree .tlabel { color: var(--muted); }
258
+ ul.tree .tval { color: var(--ink); white-space: pre-wrap; word-break: break-word; }
259
+
260
+ /* Edit-and-resubmit editor (ADR 0090 §9, BACKLOG #153). The editable COPY, the "Modified" badge that
261
+ app.js reveals on the first change, and the mode/outbound controls. */
262
+ .btn-link { color: var(--accent); text-decoration: none; font-size: 0.85rem; }
263
+ .btn-link:hover { text-decoration: underline; }
264
+ .edit-form { max-width: 900px; }
265
+ .edit-raw {
266
+ width: 100%; box-sizing: border-box; background: var(--bg); color: var(--ink);
267
+ border: 1px solid var(--line); border-radius: 6px; padding: 0.5rem;
268
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85rem;
269
+ white-space: pre; overflow-wrap: normal; overflow-x: auto; tab-size: 2;
270
+ }
271
+ .edit-mode-row { display: flex; flex-direction: column; gap: 0.3rem; margin: 0.5rem 0; }
272
+ .edit-mode-row label { color: var(--ink); }
273
+ .edit-to-row { flex-direction: row; align-items: center; gap: 0.5rem; }
274
+ .edit-to-row input { background: var(--bg); color: var(--ink); border: 1px solid var(--line);
275
+ border-radius: 6px; padding: 0.35rem 0.5rem; }
276
+ .edit-ctls { align-items: center; margin-top: 0.6rem; }
277
+ .edit-ctls button.primary {
278
+ background: var(--accent); color: #04122b; border: 0; border-radius: 6px;
279
+ padding: 0.35rem 0.9rem; cursor: pointer; font-weight: 600;
280
+ }
281
+ .edit-ctls button[disabled] { opacity: 0.5; cursor: default; }
282
+ .badge-modified {
283
+ color: var(--warn); border: 1px solid var(--warn); border-radius: 999px;
284
+ padding: 0.05rem 0.55rem; font-size: 0.75rem; font-weight: 600;
285
+ }
286
+
287
+ /* Flow & trends (BACKLOG #76): status-colored inline-SVG data-flow graph + queue-trend chart. All
288
+ colours come from the shared theme tokens; the node fill is driven by the live status CLASS (never an
289
+ operator-assigned colour). Wide graphs/charts scroll inside their own wrapper (the page body never
290
+ scrolls horizontally). */
291
+ .flowgraph-wrap, .trendchart-wrap { overflow-x: auto; max-width: 100%; margin-top: 0.5rem; }
292
+ .flowgraph, .trendchart { display: block; }
293
+ .gcolhead { fill: var(--muted); font-size: 12px; font-weight: 600; }
294
+ .gnodelabel { fill: var(--ink); font-size: 12px; }
295
+ /* Node box: a translucent status tint + a solid status-coloured stroke (so a colour-blind operator still
296
+ reads the box outline + the label). "logic" = router/handler nodes (no live status). */
297
+ .gnode { fill: var(--panel); stroke: var(--line); stroke-width: 1.5px; }
298
+ .gnode-logic { stroke: var(--muted); }
299
+ .gnode-running { stroke: var(--ok); }
300
+ .gnode-stopped, .gnode-draining { stroke: var(--muted); }
301
+ .gnode-failed, .gnode-error { stroke: var(--bad); }
302
+ .gnode-filtered, .gnode-stopping { stroke: var(--warn); }
303
+ .gnode-not_deployed { stroke: var(--accent); }
304
+ .gedge { stroke: var(--line); stroke-width: 1.5px; }
305
+ .gedge-declared, .gedge-literal { stroke: var(--muted); }
306
+ .gedge-heuristic { stroke: var(--muted); stroke-dasharray: 4 3; opacity: 0.7; }
307
+ /* Trend chart: axes + up-to-6 overlaid series (colour classes reused by the legend swatches). */
308
+ .trendchart .axis { stroke: var(--line); stroke-width: 1px; }
309
+ .trendchart .axislabel { fill: var(--muted); font-size: 11px; }
310
+ .trendchart .series { stroke-width: 1.5px; }
311
+ .series-0 { stroke: var(--accent); }
312
+ .series-1 { stroke: var(--ok); }
313
+ .series-2 { stroke: var(--warn); }
314
+ .series-3 { stroke: var(--bad); }
315
+ .series-4 { stroke: var(--foundry); }
316
+ .series-5 { stroke: var(--muted); }
317
+ .chart-legend { display: flex; flex-wrap: wrap; gap: 0.9rem; margin: 0.4rem 0; color: var(--muted);
318
+ font-size: 0.8rem; }
319
+ .legend-item { display: inline-flex; align-items: center; gap: 0.35rem; }
320
+ .legend-item .swatch { display: inline-block; width: 0.8rem; height: 0.2rem; border-radius: 2px; }
321
+ .swatch.series-0 { background: var(--accent); }
322
+ .swatch.series-1 { background: var(--ok); }
323
+ .swatch.series-2 { background: var(--warn); }
324
+ .swatch.series-3 { background: var(--bad); }
325
+ .swatch.series-4 { background: var(--foundry); }
326
+ .swatch.series-5 { background: var(--muted); }
327
+
328
+ /* ASVS 3.7.5 — the fail-VISIBLE hardening detects. `.mf-insecure-banner` styles both the two
329
+ script-raised banners (insecure context, CSP not enforced) and the server-rendered
330
+ scripts-blocked banner below, so all three read the same. */
331
+ .mf-insecure-banner {
332
+ margin: 0;
333
+ padding: 0.6rem 1rem;
334
+ background: var(--bad);
335
+ color: #fff;
336
+ font-weight: 600;
337
+ }
338
+
339
+ /* The scripts-blocked banner is rendered by the SERVER and is visible by default; the nonce'd mark
340
+ script in the page shell stamps `mf-scripts-ok` on <html>, which hides it before first paint. A
341
+ browser that enforces CSP without understanding nonce sources — or one with JavaScript disabled —
342
+ never runs that script, so the banner STANDS and the degradation is visible instead of silent.
343
+ Hiding it from CSS (not from JS) is deliberate: `style-src 'self'` keeps working on exactly the
344
+ clients whose `script-src` blocks everything. */
345
+ .mf-scripts-ok #mf-scripts-blocked-banner { display: none; }