bobframes 0.1.0__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.
- bobframes/__init__.py +3 -0
- bobframes/_version.py +1 -0
- bobframes/catalog.py +154 -0
- bobframes/cli.py +266 -0
- bobframes/derive_post_merge.py +365 -0
- bobframes/derives/__init__.py +0 -0
- bobframes/derives/pass_class_breakdown.py +102 -0
- bobframes/derives/texture_usage.py +121 -0
- bobframes/discovery.py +132 -0
- bobframes/global_entities.py +99 -0
- bobframes/html/__init__.py +0 -0
- bobframes/html/template.py +1056 -0
- bobframes/lint.py +114 -0
- bobframes/manifest.py +127 -0
- bobframes/parquetize.py +282 -0
- bobframes/parsers/__init__.py +0 -0
- bobframes/parsers/derive_program_transitions.py +73 -0
- bobframes/parsers/parse_init_state.py +675 -0
- bobframes/paths.py +111 -0
- bobframes/probes/__init__.py +0 -0
- bobframes/probes/whatif.py +165 -0
- bobframes/qrd_harness.py +119 -0
- bobframes/query_examples.py +222 -0
- bobframes/rdcmd.py +72 -0
- bobframes/replay/__init__.py +26 -0
- bobframes/replay/replay_main.py +2305 -0
- bobframes/reports/__init__.py +0 -0
- bobframes/reports/_dashboard.py +425 -0
- bobframes/reports/ab.py +88 -0
- bobframes/reports/base.py +114 -0
- bobframes/reports/cache.py +147 -0
- bobframes/reports/chrome.py +1306 -0
- bobframes/reports/cli.py +99 -0
- bobframes/reports/delta.py +167 -0
- bobframes/reports/discovery.py +118 -0
- bobframes/reports/draws_by_class.py +165 -0
- bobframes/reports/formatters.py +122 -0
- bobframes/reports/instancing_opportunities.py +276 -0
- bobframes/reports/orchestrator.py +59 -0
- bobframes/reports/overdraw.py +293 -0
- bobframes/reports/pass_gpu.py +190 -0
- bobframes/reports/shader_hotlist.py +240 -0
- bobframes/reports/trend_table.py +444 -0
- bobframes/resource_labels.py +162 -0
- bobframes/run.py +480 -0
- bobframes/schemas.py +426 -0
- bobframes/stable_keys.py +83 -0
- bobframes/tests/__init__.py +0 -0
- bobframes/tests/_render_util.py +84 -0
- bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
- bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
- bobframes/tests/data/golden/_reports/index.html +264 -0
- bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
- bobframes/tests/data/golden/_reports/overdraw.html +275 -0
- bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
- bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
- bobframes/tests/data/golden/_reports/trend_table.html +390 -0
- bobframes/tests/data/golden/index.html +1175 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
- bobframes/tests/make_synthetic.py +171 -0
- bobframes/tests/smoke.py +199 -0
- bobframes/tests/test_determinism.py +19 -0
- bobframes/tests/test_discovery.py +97 -0
- bobframes/tests/test_hardening.py +142 -0
- bobframes/tests/test_parity.py +22 -0
- bobframes/tests/test_perf.py +18 -0
- bobframes/tests/test_replay_drift.py +115 -0
- bobframes/tests/test_schemas.py +26 -0
- bobframes/tests/test_schemas_unit.py +55 -0
- bobframes/tests/test_stable_keys.py +61 -0
- bobframes-0.1.0.dist-info/METADATA +144 -0
- bobframes-0.1.0.dist-info/RECORD +130 -0
- bobframes-0.1.0.dist-info/WHEEL +4 -0
- bobframes-0.1.0.dist-info/entry_points.txt +2 -0
- bobframes-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1306 @@
|
|
|
1
|
+
"""Page chrome: CSS tokens, page open/close, header, KPI strip, section card, legend, footer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as _html
|
|
6
|
+
|
|
7
|
+
from . import formatters as _f
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_DESIGN_TOKENS = """
|
|
11
|
+
:root {
|
|
12
|
+
color-scheme: light dark;
|
|
13
|
+
|
|
14
|
+
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
|
|
15
|
+
--sp-6: 24px; --sp-8: 32px; --sp-12: 48px;
|
|
16
|
+
|
|
17
|
+
--fs-display: 2.25rem;
|
|
18
|
+
--fs-h1: 1.5rem; --fs-h2: 1.1rem; --fs-h3: 0.9rem;
|
|
19
|
+
--fs-body: 14px; --fs-mono: 13px; --fs-small: 11px;
|
|
20
|
+
|
|
21
|
+
--motion-hover: 150ms ease-out;
|
|
22
|
+
--motion-focus: 100ms ease-out;
|
|
23
|
+
--motion-vt: 200ms ease-in-out;
|
|
24
|
+
--motion-disclosure: 180ms ease-out;
|
|
25
|
+
|
|
26
|
+
--bg: light-dark(oklch(97.2% 0.012 80), oklch(16.4% 0.012 260));
|
|
27
|
+
--surface-0: var(--bg);
|
|
28
|
+
--surface-1: light-dark(oklch(94.6% 0.018 80), oklch(19.5% 0.013 260));
|
|
29
|
+
--surface-2: light-dark(oklch(94.6% 0.020 80), oklch(20.7% 0.014 260));
|
|
30
|
+
--code-bg: var(--surface-2);
|
|
31
|
+
|
|
32
|
+
--fg: light-dark(oklch(22.0% 0.005 80), oklch(93.6% 0.005 260));
|
|
33
|
+
--text-1: var(--fg);
|
|
34
|
+
--muted: light-dark(oklch(49.4% 0.005 80), oklch(67.5% 0.005 260));
|
|
35
|
+
--text-2: var(--muted);
|
|
36
|
+
--text-3: light-dark(oklch(60.0% 0.012 80), oklch(56.4% 0.020 280));
|
|
37
|
+
|
|
38
|
+
--border: light-dark(oklch(84.0% 0.024 82), oklch(28.0% 0.014 260));
|
|
39
|
+
--border-1: var(--border);
|
|
40
|
+
--border-strong: light-dark(oklch(75.0% 0.030 82), oklch(35.0% 0.018 260));
|
|
41
|
+
--border-2: var(--border-strong);
|
|
42
|
+
|
|
43
|
+
--row-alt: light-dark(oklch(93.2% 0.022 82), oklch(20.5% 0.015 260));
|
|
44
|
+
--row-hover: light-dark(oklch(90.0% 0.038 82), oklch(25.0% 0.022 260));
|
|
45
|
+
|
|
46
|
+
--accent-primary: light-dark(oklch(38.0% 0.020 260), oklch(78.0% 0.015 260));
|
|
47
|
+
--accent-data: light-dark(oklch(55.0% 0.155 230), oklch(75.0% 0.115 230));
|
|
48
|
+
--accent: var(--accent-primary);
|
|
49
|
+
|
|
50
|
+
--status-alarm: light-dark(oklch(52.0% 0.180 28), oklch(70.0% 0.155 28));
|
|
51
|
+
--status-warn: light-dark(oklch(70.0% 0.155 85), oklch(80.0% 0.130 85));
|
|
52
|
+
--status-ok: light-dark(oklch(55.0% 0.135 145), oklch(72.0% 0.115 145));
|
|
53
|
+
--status-info: light-dark(oklch(55.0% 0.115 260), oklch(75.0% 0.090 260));
|
|
54
|
+
|
|
55
|
+
--c-opaque: light-dark(oklch(60.5% 0.110 135), oklch(73.5% 0.115 135));
|
|
56
|
+
--c-prepass: light-dark(oklch(64.0% 0.135 50), oklch(73.0% 0.130 50));
|
|
57
|
+
--c-translucent: light-dark(oklch(56.0% 0.085 240), oklch(71.0% 0.080 240));
|
|
58
|
+
--c-additive: light-dark(oklch(56.0% 0.115 305), oklch(72.0% 0.105 305));
|
|
59
|
+
--c-decal: light-dark(oklch(60.0% 0.115 65), oklch(71.0% 0.115 65));
|
|
60
|
+
--c-shadow: light-dark(oklch(42.0% 0.025 285), oklch(55.0% 0.020 285));
|
|
61
|
+
--c-ui: light-dark(oklch(71.0% 0.115 90), oklch(80.0% 0.115 90));
|
|
62
|
+
--c-postprocess: light-dark(oklch(66.0% 0.055 240), oklch(78.0% 0.055 240));
|
|
63
|
+
--c-other: light-dark(oklch(64.0% 0.000 0), oklch(75.0% 0.000 0));
|
|
64
|
+
|
|
65
|
+
--pos: var(--status-ok);
|
|
66
|
+
--neg: var(--status-alarm);
|
|
67
|
+
--neutral: var(--text-2);
|
|
68
|
+
}
|
|
69
|
+
@media (prefers-reduced-motion: reduce) {
|
|
70
|
+
:root {
|
|
71
|
+
--motion-hover: 0s;
|
|
72
|
+
--motion-focus: 0s;
|
|
73
|
+
--motion-vt: 0s;
|
|
74
|
+
--motion-disclosure: 0s;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_CHROME_CSS = """
|
|
81
|
+
* { box-sizing: border-box; }
|
|
82
|
+
html, body { margin: 0; padding: 0; background: var(--surface-0); color: var(--text-1); }
|
|
83
|
+
body {
|
|
84
|
+
padding: var(--sp-6) var(--sp-6) var(--sp-12);
|
|
85
|
+
font: var(--fs-body)/1.5 'Inter', 'Segoe UI', system-ui, sans-serif;
|
|
86
|
+
max-width: 1600px;
|
|
87
|
+
margin: 0 auto;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
h1 {
|
|
91
|
+
font-size: var(--fs-h1); font-weight: 600; color: var(--accent);
|
|
92
|
+
margin: 0 0 var(--sp-2); padding-bottom: var(--sp-2);
|
|
93
|
+
border-bottom: 1px solid var(--border-2);
|
|
94
|
+
letter-spacing: -0.01em;
|
|
95
|
+
}
|
|
96
|
+
h2 {
|
|
97
|
+
font-size: var(--fs-h2); font-weight: 600; color: var(--text-1);
|
|
98
|
+
margin: var(--sp-6) 0 var(--sp-3);
|
|
99
|
+
padding: 0 0 0 var(--sp-3);
|
|
100
|
+
border-left: 3px solid var(--accent);
|
|
101
|
+
line-height: 1.4;
|
|
102
|
+
scroll-margin-top: 64px;
|
|
103
|
+
}
|
|
104
|
+
h2:first-of-type { margin-top: 0; }
|
|
105
|
+
section[id], h2[id] { scroll-margin-top: 64px; }
|
|
106
|
+
h2[id]:target { color: var(--accent); }
|
|
107
|
+
h3 {
|
|
108
|
+
font-size: var(--fs-h3); font-weight: 500; color: var(--text-2);
|
|
109
|
+
margin: var(--sp-3) 0 var(--sp-2);
|
|
110
|
+
}
|
|
111
|
+
a { color: var(--accent); text-decoration: none; }
|
|
112
|
+
a:hover { text-decoration: underline; text-decoration-thickness: 2px; }
|
|
113
|
+
a:visited { color: var(--text-2); }
|
|
114
|
+
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
115
|
+
table.report a { text-decoration: underline; text-decoration-thickness: 1px;
|
|
116
|
+
text-underline-offset: 2px; }
|
|
117
|
+
table.report a:visited { color: var(--text-3); }
|
|
118
|
+
|
|
119
|
+
header.strip {
|
|
120
|
+
display: flex; flex-wrap: wrap; gap: var(--sp-3) var(--sp-6);
|
|
121
|
+
align-items: baseline;
|
|
122
|
+
padding-bottom: var(--sp-3);
|
|
123
|
+
border-bottom: 1px solid var(--border-2);
|
|
124
|
+
margin: 0 0 var(--sp-4);
|
|
125
|
+
}
|
|
126
|
+
header.strip span { color: var(--text-2); font-size: var(--fs-small); }
|
|
127
|
+
header.strip span strong { color: var(--text-1); font-weight: 600; }
|
|
128
|
+
|
|
129
|
+
nav.crumb {
|
|
130
|
+
font-size: var(--fs-small);
|
|
131
|
+
color: var(--text-2);
|
|
132
|
+
margin: 0 0 var(--sp-3);
|
|
133
|
+
display: flex; flex-wrap: wrap; gap: var(--sp-2);
|
|
134
|
+
align-items: center;
|
|
135
|
+
}
|
|
136
|
+
nav.crumb a, nav.crumb a[data-link-kind] {
|
|
137
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
138
|
+
color: var(--accent-primary);
|
|
139
|
+
text-decoration: none;
|
|
140
|
+
padding: 2px 8px;
|
|
141
|
+
border: 1px solid var(--border-1);
|
|
142
|
+
border-radius: 2px;
|
|
143
|
+
background: var(--surface-1);
|
|
144
|
+
transition: border-color var(--motion-hover), background var(--motion-hover);
|
|
145
|
+
}
|
|
146
|
+
nav.crumb a:hover, nav.crumb a[data-link-kind]:hover {
|
|
147
|
+
border-color: var(--accent-primary);
|
|
148
|
+
background: var(--row-hover);
|
|
149
|
+
text-decoration: none;
|
|
150
|
+
}
|
|
151
|
+
nav.crumb a + a::before { content: ''; }
|
|
152
|
+
|
|
153
|
+
.kpi-strip {
|
|
154
|
+
display: grid;
|
|
155
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
156
|
+
gap: var(--sp-3);
|
|
157
|
+
margin: 0 0 var(--sp-8);
|
|
158
|
+
}
|
|
159
|
+
.kpi-chip {
|
|
160
|
+
background: var(--surface-1);
|
|
161
|
+
border: 1px solid var(--border-1);
|
|
162
|
+
padding: var(--sp-3) var(--sp-4);
|
|
163
|
+
display: flex; flex-direction: column; gap: var(--sp-1);
|
|
164
|
+
min-height: 88px;
|
|
165
|
+
}
|
|
166
|
+
.kpi-chip .kpi-label {
|
|
167
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
168
|
+
color: var(--text-3);
|
|
169
|
+
text-transform: lowercase;
|
|
170
|
+
letter-spacing: 0.04em;
|
|
171
|
+
}
|
|
172
|
+
.kpi-chip .kpi-value {
|
|
173
|
+
font: 600 var(--fs-display)/1.05 ui-monospace, monospace;
|
|
174
|
+
color: var(--text-1);
|
|
175
|
+
font-variant-numeric: tabular-nums;
|
|
176
|
+
letter-spacing: -0.02em;
|
|
177
|
+
}
|
|
178
|
+
.kpi-chip .kpi-delta {
|
|
179
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
180
|
+
font-variant-numeric: tabular-nums;
|
|
181
|
+
color: var(--text-3);
|
|
182
|
+
}
|
|
183
|
+
.kpi-chip.tone-pos .kpi-value, .kpi-chip.tone-pos .kpi-delta { color: var(--pos); }
|
|
184
|
+
.kpi-chip.tone-neg .kpi-value, .kpi-chip.tone-neg .kpi-delta { color: var(--neg); }
|
|
185
|
+
|
|
186
|
+
.table-wrap {
|
|
187
|
+
overflow-x: auto;
|
|
188
|
+
border: 1px solid var(--border-1);
|
|
189
|
+
border-radius: 4px;
|
|
190
|
+
margin: 0 0 var(--sp-6);
|
|
191
|
+
}
|
|
192
|
+
.table-wrap > table.report { border: 0; margin: 0; }
|
|
193
|
+
|
|
194
|
+
nav.toc {
|
|
195
|
+
display: grid;
|
|
196
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
197
|
+
gap: var(--sp-1) var(--sp-3);
|
|
198
|
+
font: var(--fs-mono) ui-monospace, monospace;
|
|
199
|
+
margin: 0 0 var(--sp-6);
|
|
200
|
+
padding: var(--sp-2) var(--sp-3);
|
|
201
|
+
background: var(--surface-2);
|
|
202
|
+
border: 1px solid var(--border-1);
|
|
203
|
+
}
|
|
204
|
+
nav.toc a { display: inline-block; padding: 2px 0; }
|
|
205
|
+
|
|
206
|
+
table.report {
|
|
207
|
+
width: 100%;
|
|
208
|
+
border-collapse: collapse;
|
|
209
|
+
font: var(--fs-mono) ui-monospace, 'Cascadia Code', Consolas, monospace;
|
|
210
|
+
margin-top: var(--sp-1);
|
|
211
|
+
}
|
|
212
|
+
table.report thead th {
|
|
213
|
+
position: sticky;
|
|
214
|
+
top: var(--hdr-offset);
|
|
215
|
+
background: var(--surface-2); color: var(--accent);
|
|
216
|
+
text-align: left; font-weight: 600;
|
|
217
|
+
padding: var(--sp-2) var(--sp-3);
|
|
218
|
+
border-bottom: 1px solid var(--border-2);
|
|
219
|
+
white-space: nowrap;
|
|
220
|
+
z-index: 1;
|
|
221
|
+
}
|
|
222
|
+
table.report thead th.num { text-align: right; }
|
|
223
|
+
table.report tbody td {
|
|
224
|
+
padding: var(--sp-2) var(--sp-3);
|
|
225
|
+
border-bottom: 1px solid var(--border-1);
|
|
226
|
+
vertical-align: top;
|
|
227
|
+
}
|
|
228
|
+
table.report tbody td:first-child {
|
|
229
|
+
font-weight: 600; color: var(--text-1);
|
|
230
|
+
}
|
|
231
|
+
table.report td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
232
|
+
table.report tbody tr:nth-child(even) td { background: var(--surface-2); }
|
|
233
|
+
table.report tbody tr:hover td { background: var(--row-hover); }
|
|
234
|
+
table.report tbody td .lbl {
|
|
235
|
+
color: var(--text-2);
|
|
236
|
+
margin-left: 6px;
|
|
237
|
+
font-style: italic;
|
|
238
|
+
opacity: .85;
|
|
239
|
+
}
|
|
240
|
+
table.report tr.area-break td { border-top: 2px solid var(--border-2); }
|
|
241
|
+
table.report td.area-cell { color: var(--text-2); }
|
|
242
|
+
|
|
243
|
+
.bar-row {
|
|
244
|
+
display: grid;
|
|
245
|
+
grid-template-columns: minmax(240px, 1fr) 2fr 90px;
|
|
246
|
+
gap: var(--sp-3);
|
|
247
|
+
align-items: center;
|
|
248
|
+
padding: 4px 0;
|
|
249
|
+
font: var(--fs-mono) ui-monospace, 'Cascadia Code', Consolas, monospace;
|
|
250
|
+
}
|
|
251
|
+
.bar-row .key { white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
252
|
+
font-weight: 600; color: var(--text-1); }
|
|
253
|
+
.bar-row .total { text-align: right; font-variant-numeric: tabular-nums;
|
|
254
|
+
color: var(--text-2); }
|
|
255
|
+
.bar { display: flex; height: 18px; background: var(--surface-2);
|
|
256
|
+
border: 1px solid var(--border-1); overflow: hidden; }
|
|
257
|
+
.bar .seg { flex: 0 0 auto; color: #fff; font-size: 10px; line-height: 18px;
|
|
258
|
+
text-align: center; overflow: hidden; white-space: nowrap; }
|
|
259
|
+
|
|
260
|
+
.ibar {
|
|
261
|
+
display: inline-block; width: 80px; height: 6px;
|
|
262
|
+
background: var(--surface-2); border: 1px solid var(--border-1);
|
|
263
|
+
vertical-align: middle; margin-left: 6px;
|
|
264
|
+
}
|
|
265
|
+
.ibar > div { height: 100%; background: var(--accent); }
|
|
266
|
+
|
|
267
|
+
.delta { font-variant-numeric: tabular-nums; padding: 2px var(--sp-2);
|
|
268
|
+
text-align: right; }
|
|
269
|
+
.delta-pill {
|
|
270
|
+
display: inline-block; padding: 1px 6px; border-radius: 2px;
|
|
271
|
+
background: var(--surface-2);
|
|
272
|
+
font: 600 var(--fs-small) ui-monospace, monospace;
|
|
273
|
+
}
|
|
274
|
+
.delta.pos, .delta-pill.pos { color: var(--pos); font-weight: 600; }
|
|
275
|
+
.delta.neg, .delta-pill.neg { color: var(--neg); font-weight: 600; }
|
|
276
|
+
.delta.flat, .delta-pill.flat { color: var(--text-3); }
|
|
277
|
+
.delta.new, .delta-pill.new { color: var(--text-3); font-style: italic; }
|
|
278
|
+
.delta.alarm { border-left: 3px solid var(--status-alarm); padding-left: 6px; }
|
|
279
|
+
.delta-latest { border-left: 2px solid var(--border-2); }
|
|
280
|
+
|
|
281
|
+
.legend { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-4);
|
|
282
|
+
margin: 0 0 var(--sp-3);
|
|
283
|
+
font-size: var(--fs-small); color: var(--text-2); }
|
|
284
|
+
.legend .chip { display: inline-flex; align-items: center; gap: 6px; }
|
|
285
|
+
.legend .swatch { display: inline-block; width: 12px; height: 12px;
|
|
286
|
+
border-radius: 2px; }
|
|
287
|
+
|
|
288
|
+
.device-strip {
|
|
289
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
290
|
+
color: var(--text-2);
|
|
291
|
+
background: var(--surface-2);
|
|
292
|
+
padding: var(--sp-2) var(--sp-3);
|
|
293
|
+
border: 1px solid var(--border-1);
|
|
294
|
+
margin: 0 0 var(--sp-4);
|
|
295
|
+
}
|
|
296
|
+
.ab-strip {
|
|
297
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
298
|
+
color: var(--text-2);
|
|
299
|
+
background: var(--surface-2);
|
|
300
|
+
padding: var(--sp-2) var(--sp-3);
|
|
301
|
+
border: 1px solid var(--border-2);
|
|
302
|
+
margin: 0 0 var(--sp-4);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.rank {
|
|
306
|
+
display: inline-block; width: 1.4em; text-align: center;
|
|
307
|
+
font: 600 var(--fs-small) ui-monospace, monospace;
|
|
308
|
+
margin-right: var(--sp-1);
|
|
309
|
+
color: var(--text-3);
|
|
310
|
+
}
|
|
311
|
+
.rank.rank-1 { color: var(--accent); }
|
|
312
|
+
.rank.rank-2 { color: var(--text-1); }
|
|
313
|
+
.rank.rank-3 { color: var(--text-2); }
|
|
314
|
+
|
|
315
|
+
details.matrix, details.category {
|
|
316
|
+
margin: 0 0 var(--sp-4);
|
|
317
|
+
background: var(--surface-1);
|
|
318
|
+
border: 1px solid var(--border-1);
|
|
319
|
+
}
|
|
320
|
+
details.matrix > summary, details.category > summary {
|
|
321
|
+
cursor: pointer; list-style: none; user-select: none;
|
|
322
|
+
padding: var(--sp-3) var(--sp-4);
|
|
323
|
+
font: 600 var(--fs-body) 'Inter', system-ui, sans-serif;
|
|
324
|
+
color: var(--accent);
|
|
325
|
+
border-bottom: 1px solid transparent;
|
|
326
|
+
display: flex; justify-content: space-between; align-items: baseline;
|
|
327
|
+
gap: var(--sp-3);
|
|
328
|
+
}
|
|
329
|
+
details.matrix[open] > summary, details.category[open] > summary {
|
|
330
|
+
border-bottom-color: var(--border-1);
|
|
331
|
+
}
|
|
332
|
+
details > summary::-webkit-details-marker { display: none; }
|
|
333
|
+
details.matrix > summary::before, details.category > summary::before {
|
|
334
|
+
content: '+'; margin-right: var(--sp-2);
|
|
335
|
+
font-family: ui-monospace, monospace; color: var(--text-3);
|
|
336
|
+
display: inline-block; width: 0.8em;
|
|
337
|
+
}
|
|
338
|
+
details[open] > summary::before { content: '-'; }
|
|
339
|
+
details.matrix > .matrix-body, details.category > .cat-body {
|
|
340
|
+
padding: var(--sp-4);
|
|
341
|
+
}
|
|
342
|
+
.cat-meta { font: var(--fs-small) ui-monospace, monospace;
|
|
343
|
+
color: var(--text-3); font-variant-numeric: tabular-nums; }
|
|
344
|
+
.spark { display: inline-block; vertical-align: middle; color: var(--text-2); }
|
|
345
|
+
|
|
346
|
+
footer.legend {
|
|
347
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
348
|
+
color: var(--text-2);
|
|
349
|
+
margin-top: var(--sp-12);
|
|
350
|
+
padding-top: var(--sp-3);
|
|
351
|
+
border-top: 1px solid var(--border-1);
|
|
352
|
+
display: block;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.note { font-size: var(--fs-small); color: var(--text-2);
|
|
356
|
+
margin-top: var(--sp-2); }
|
|
357
|
+
|
|
358
|
+
.dash-grid {
|
|
359
|
+
display: grid;
|
|
360
|
+
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
361
|
+
gap: var(--sp-6);
|
|
362
|
+
margin: 0 0 var(--sp-6);
|
|
363
|
+
}
|
|
364
|
+
a.dash-card { border: 1px solid var(--border-1);
|
|
365
|
+
padding: var(--sp-4); display: flex; flex-direction: column;
|
|
366
|
+
gap: var(--sp-3); text-decoration: none; color: inherit;
|
|
367
|
+
transition: border-color 0.1s, background 0.1s; }
|
|
368
|
+
a.dash-card:hover { background: var(--surface-1); border-color: var(--border-2);
|
|
369
|
+
text-decoration: none; }
|
|
370
|
+
a.dash-card:visited { color: inherit; }
|
|
371
|
+
a.dash-card h3 { margin: 0; color: var(--accent); font-size: var(--fs-h2);
|
|
372
|
+
border-left: 3px solid var(--accent); padding-left: var(--sp-3); }
|
|
373
|
+
a.dash-card table.report { font-size: var(--fs-small); }
|
|
374
|
+
a.dash-card table.report a { pointer-events: none; }
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
_STICKY_CSS = """
|
|
379
|
+
/* Sticky stack: crumb (top:0) -> summary-bar (top:crumb-h) -> thead (top:hdr-offset).
|
|
380
|
+
--hdr-offset is the combined height of crumb + summary-bar (pages set per page).
|
|
381
|
+
--crumb-h is the crumb height alone. Defaults work for most pages.
|
|
382
|
+
h2 is NOT sticky: the cascading sticky h2 + thead + summary-bar over-stacks
|
|
383
|
+
on long multi-section pages. Sticky thead is sufficient for table reading. */
|
|
384
|
+
body { --hdr-offset: 120px; --crumb-h: 36px; }
|
|
385
|
+
nav.crumb {
|
|
386
|
+
position: sticky; top: 0; z-index: 3;
|
|
387
|
+
background: var(--bg);
|
|
388
|
+
padding-top: var(--sp-1); padding-bottom: var(--sp-1);
|
|
389
|
+
}
|
|
390
|
+
.summary-bar {
|
|
391
|
+
position: sticky; top: var(--crumb-h); z-index: 3;
|
|
392
|
+
display: grid;
|
|
393
|
+
grid-template-columns: minmax(140px, max-content) 1fr auto;
|
|
394
|
+
gap: var(--sp-2) var(--sp-6);
|
|
395
|
+
align-items: center;
|
|
396
|
+
background: var(--surface-1);
|
|
397
|
+
border: 1px solid var(--border-1);
|
|
398
|
+
border-top: 2px solid var(--accent-data);
|
|
399
|
+
padding: var(--sp-3) var(--sp-4);
|
|
400
|
+
margin: 0 0 var(--sp-4);
|
|
401
|
+
}
|
|
402
|
+
.summary-bar .sb-label {
|
|
403
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
404
|
+
color: var(--text-2);
|
|
405
|
+
text-transform: lowercase;
|
|
406
|
+
letter-spacing: 0.04em;
|
|
407
|
+
}
|
|
408
|
+
.summary-bar .sb-headline {
|
|
409
|
+
font: 600 var(--fs-h1)/1.15 ui-monospace, monospace;
|
|
410
|
+
color: var(--text-1);
|
|
411
|
+
font-variant-numeric: tabular-nums;
|
|
412
|
+
letter-spacing: -0.01em;
|
|
413
|
+
}
|
|
414
|
+
.summary-bar .sb-sub {
|
|
415
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
416
|
+
color: var(--text-2);
|
|
417
|
+
grid-column: 2;
|
|
418
|
+
font-variant-numeric: tabular-nums;
|
|
419
|
+
}
|
|
420
|
+
.summary-bar .sb-link { align-self: center; grid-row: 1 / span 2; grid-column: 3; }
|
|
421
|
+
.summary-bar.tone-alarm { border-top-color: var(--status-alarm); }
|
|
422
|
+
.summary-bar.tone-ok { border-top-color: var(--status-ok); }
|
|
423
|
+
.summary-bar.tone-warn { border-top-color: var(--status-warn); }
|
|
424
|
+
.summary-bar.tone-info { border-top-color: var(--status-info); }
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
_LINK_KIND_CSS = """
|
|
429
|
+
a[data-link-kind="primary"] {
|
|
430
|
+
display: inline-flex;
|
|
431
|
+
align-items: center;
|
|
432
|
+
gap: var(--sp-2);
|
|
433
|
+
padding: 6px 12px;
|
|
434
|
+
background: var(--surface-1);
|
|
435
|
+
border: 1px solid var(--border-2);
|
|
436
|
+
color: var(--accent-primary);
|
|
437
|
+
text-decoration: none;
|
|
438
|
+
transition: background var(--motion-hover), border-color var(--motion-hover);
|
|
439
|
+
}
|
|
440
|
+
a[data-link-kind="primary"]:hover {
|
|
441
|
+
background: var(--row-hover);
|
|
442
|
+
border-color: var(--accent-primary);
|
|
443
|
+
text-decoration: none;
|
|
444
|
+
}
|
|
445
|
+
a[data-link-kind="primary"]:visited { color: var(--accent-primary); }
|
|
446
|
+
|
|
447
|
+
a[data-link-kind="inline"] {
|
|
448
|
+
color: var(--accent-primary);
|
|
449
|
+
text-decoration: underline;
|
|
450
|
+
text-underline-offset: 2px;
|
|
451
|
+
text-decoration-thickness: 1px;
|
|
452
|
+
}
|
|
453
|
+
a[data-link-kind="inline"]:hover { text-decoration-thickness: 2px; }
|
|
454
|
+
a[data-link-kind="inline"] .icon {
|
|
455
|
+
margin-left: 3px;
|
|
456
|
+
color: var(--text-2);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
a[data-link-kind="drill"] {
|
|
460
|
+
color: var(--text-1);
|
|
461
|
+
text-decoration: underline;
|
|
462
|
+
text-decoration-color: var(--border-2);
|
|
463
|
+
text-decoration-thickness: 1px;
|
|
464
|
+
text-underline-offset: 2px;
|
|
465
|
+
}
|
|
466
|
+
a[data-link-kind="drill"]:hover {
|
|
467
|
+
color: var(--accent-primary);
|
|
468
|
+
text-decoration-color: var(--accent-primary);
|
|
469
|
+
text-decoration-thickness: 2px;
|
|
470
|
+
}
|
|
471
|
+
tr:has(a[data-link-kind="drill"]):hover td { background: var(--row-hover); }
|
|
472
|
+
|
|
473
|
+
a[data-link-kind="copy"] {
|
|
474
|
+
display: inline-flex;
|
|
475
|
+
align-items: center;
|
|
476
|
+
justify-content: center;
|
|
477
|
+
width: 24px; height: 24px;
|
|
478
|
+
color: var(--text-2);
|
|
479
|
+
text-decoration: none;
|
|
480
|
+
border-radius: 2px;
|
|
481
|
+
transition: color var(--motion-hover), background var(--motion-hover);
|
|
482
|
+
}
|
|
483
|
+
a[data-link-kind="copy"]:hover {
|
|
484
|
+
color: var(--accent-primary);
|
|
485
|
+
background: var(--row-hover);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
a[data-link-kind="crumb"] {
|
|
489
|
+
color: var(--text-2);
|
|
490
|
+
text-decoration: none;
|
|
491
|
+
}
|
|
492
|
+
a[data-link-kind="crumb"]:hover {
|
|
493
|
+
color: var(--accent-primary);
|
|
494
|
+
text-decoration: underline;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.icon {
|
|
498
|
+
width: 11px; height: 11px;
|
|
499
|
+
display: inline-block;
|
|
500
|
+
vertical-align: -1px;
|
|
501
|
+
fill: currentColor;
|
|
502
|
+
}
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
_ICON_SPRITE = """
|
|
507
|
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
|
508
|
+
<defs>
|
|
509
|
+
<symbol id="icon-link-out" viewBox="0 0 16 16">
|
|
510
|
+
<path d="M4 4h5v1H5v6h6V8h1v3.5a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5z"/>
|
|
511
|
+
<path d="M11 5L7.5 8.5M11 5h-3M11 5v3" stroke="currentColor" fill="none" stroke-width="1"/>
|
|
512
|
+
</symbol>
|
|
513
|
+
<symbol id="icon-file" viewBox="0 0 16 16">
|
|
514
|
+
<path d="M4 2h5l3 3v9H4z" stroke="currentColor" fill="none" stroke-width="1"/>
|
|
515
|
+
<path d="M9 2v3h3" stroke="currentColor" fill="none" stroke-width="1"/>
|
|
516
|
+
</symbol>
|
|
517
|
+
<symbol id="icon-arrow-right" viewBox="0 0 16 16">
|
|
518
|
+
<path d="M4 8h8M9 5l3 3-3 3" stroke="currentColor" fill="none" stroke-width="1.5"/>
|
|
519
|
+
</symbol>
|
|
520
|
+
<symbol id="icon-copy" viewBox="0 0 16 16">
|
|
521
|
+
<path d="M5 5h7v8H5z" stroke="currentColor" fill="none" stroke-width="1"/>
|
|
522
|
+
<path d="M3 3h7v2" stroke="currentColor" fill="none" stroke-width="1"/>
|
|
523
|
+
</symbol>
|
|
524
|
+
<symbol id="icon-search" viewBox="0 0 16 16">
|
|
525
|
+
<circle cx="7" cy="7" r="4" stroke="currentColor" fill="none" stroke-width="1.5"/>
|
|
526
|
+
<path d="M10 10l3 3" stroke="currentColor" stroke-width="1.5"/>
|
|
527
|
+
</symbol>
|
|
528
|
+
<symbol id="icon-warn" viewBox="0 0 16 16">
|
|
529
|
+
<path d="M8 2l6 11H2L8 2zM8 7v3M8 11.5v.5" stroke="currentColor" fill="none" stroke-width="1"/>
|
|
530
|
+
</symbol>
|
|
531
|
+
</defs>
|
|
532
|
+
</svg>
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
_CONTAINER_CSS = """
|
|
537
|
+
body { container-type: inline-size; container-name: page; }
|
|
538
|
+
|
|
539
|
+
@container page (max-width: 1100px) {
|
|
540
|
+
nav.toc { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
@container page (max-width: 860px) {
|
|
544
|
+
.dash-grid { grid-template-columns: 1fr; }
|
|
545
|
+
.summary-bar { grid-template-columns: 1fr auto; }
|
|
546
|
+
.summary-bar .sb-sub { grid-column: 1; }
|
|
547
|
+
.summary-bar .sb-link { grid-row: auto; grid-column: 2; }
|
|
548
|
+
.summary-bar .sb-headline { font-size: var(--fs-h1); }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
@container page (max-width: 768px) {
|
|
552
|
+
.bar-row {
|
|
553
|
+
grid-template-columns: 1fr;
|
|
554
|
+
grid-template-rows: auto auto auto;
|
|
555
|
+
gap: var(--sp-1);
|
|
556
|
+
}
|
|
557
|
+
.bar-row .total { text-align: left; }
|
|
558
|
+
.kpi-strip { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
|
|
559
|
+
.kpi-chip .kpi-value { font-size: var(--fs-h1); }
|
|
560
|
+
body { padding: var(--sp-3); }
|
|
561
|
+
.summary-bar .sb-headline { font-size: var(--fs-h2); }
|
|
562
|
+
table.report thead th,
|
|
563
|
+
table.report tbody td { padding: var(--sp-1) var(--sp-2); }
|
|
564
|
+
}
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
_PRINT_CSS = """
|
|
569
|
+
@media print {
|
|
570
|
+
@page { size: A4; margin: 12mm; }
|
|
571
|
+
:root { color-scheme: light; }
|
|
572
|
+
html, body { background: #fff; color: #000; }
|
|
573
|
+
body { max-width: none; padding: 0; }
|
|
574
|
+
|
|
575
|
+
nav.crumb, .device-strip, .ab-strip,
|
|
576
|
+
rdc-copy-button, rdc-search-cards, rdc-ab-picker { display: none; }
|
|
577
|
+
|
|
578
|
+
.summary-bar {
|
|
579
|
+
position: static;
|
|
580
|
+
background: #fff;
|
|
581
|
+
border-color: #888;
|
|
582
|
+
border-top-width: 3px;
|
|
583
|
+
break-inside: avoid;
|
|
584
|
+
break-after: avoid;
|
|
585
|
+
print-color-adjust: exact;
|
|
586
|
+
}
|
|
587
|
+
.summary-bar .sb-headline { color: #000; }
|
|
588
|
+
.summary-bar .sb-link { display: none; }
|
|
589
|
+
|
|
590
|
+
h1, h2 { color: #000; }
|
|
591
|
+
h2[id] { position: static; background: transparent; break-after: avoid; }
|
|
592
|
+
table.report thead th {
|
|
593
|
+
position: static;
|
|
594
|
+
background: #f0f0f0;
|
|
595
|
+
color: #000;
|
|
596
|
+
print-color-adjust: exact;
|
|
597
|
+
}
|
|
598
|
+
table.report thead { display: table-header-group; }
|
|
599
|
+
table.report tbody tr { break-inside: avoid; }
|
|
600
|
+
|
|
601
|
+
.kpi-strip { break-inside: avoid; grid-template-columns: repeat(4, 1fr); }
|
|
602
|
+
.kpi-chip { background: #fff; border-color: #888; }
|
|
603
|
+
.kpi-chip .kpi-value { color: #000; }
|
|
604
|
+
|
|
605
|
+
.dash-grid { grid-template-columns: repeat(2, 1fr); }
|
|
606
|
+
a.dash-card { break-inside: avoid; }
|
|
607
|
+
|
|
608
|
+
.bar, .bar .seg, .ibar, .ibar > div,
|
|
609
|
+
.legend .swatch, .delta.pos, .delta.neg, .delta-pill {
|
|
610
|
+
print-color-adjust: exact;
|
|
611
|
+
-webkit-print-color-adjust: exact;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
details, details > summary { display: block; }
|
|
615
|
+
details > .matrix-body, details > .cat-body { display: block; }
|
|
616
|
+
|
|
617
|
+
a[data-link-kind="primary"] {
|
|
618
|
+
background: transparent;
|
|
619
|
+
border: 1px solid #888;
|
|
620
|
+
color: #000;
|
|
621
|
+
}
|
|
622
|
+
a[data-link-kind="inline"] { color: #000; }
|
|
623
|
+
a[data-link-kind="inline"] .icon { display: none; }
|
|
624
|
+
|
|
625
|
+
/* Multi-section trend_table: page break before each h2 section after the first */
|
|
626
|
+
body[data-multi-section="true"] h2[id] ~ h2[id] { break-before: page; }
|
|
627
|
+
|
|
628
|
+
/* Per-drop browser: too large for print; show fallback message */
|
|
629
|
+
body[data-page-kind="drop-browser"] > * { display: none; }
|
|
630
|
+
body[data-page-kind="drop-browser"]::before {
|
|
631
|
+
content: "Per-drop browser is not designed for print. See the cumulative reports.";
|
|
632
|
+
display: block;
|
|
633
|
+
padding: 20mm;
|
|
634
|
+
font: bold 1.2rem system-ui, sans-serif;
|
|
635
|
+
color: #000;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
_COMPONENTS_CSS_BASE = """
|
|
642
|
+
rdc-sortable-table { display: block; }
|
|
643
|
+
rdc-sortable-table table.report thead th { cursor: pointer; user-select: none; }
|
|
644
|
+
rdc-sortable-table table.report thead th[aria-sort="ascending"]::after {
|
|
645
|
+
content: ' \\25B4'; color: var(--text-3);
|
|
646
|
+
}
|
|
647
|
+
rdc-sortable-table table.report thead th[aria-sort="descending"]::after {
|
|
648
|
+
content: ' \\25BE'; color: var(--text-3);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
rdc-copy-button {
|
|
652
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
653
|
+
min-width: 28px; padding: 0 6px; height: 22px;
|
|
654
|
+
color: var(--text-2);
|
|
655
|
+
cursor: pointer;
|
|
656
|
+
border: 1px solid transparent;
|
|
657
|
+
border-radius: 2px;
|
|
658
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
659
|
+
margin-left: 4px;
|
|
660
|
+
transition: color var(--motion-hover), background var(--motion-hover), border-color var(--motion-hover);
|
|
661
|
+
}
|
|
662
|
+
rdc-copy-button:hover {
|
|
663
|
+
color: var(--accent-primary);
|
|
664
|
+
background: var(--row-hover);
|
|
665
|
+
border-color: var(--border-1);
|
|
666
|
+
}
|
|
667
|
+
rdc-copy-button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 1px; }
|
|
668
|
+
rdc-copy-button::before { content: 'copy'; }
|
|
669
|
+
rdc-copy-button.copied { color: var(--status-ok); border-color: var(--status-ok); }
|
|
670
|
+
rdc-copy-button.copied::before { content: 'ok'; }
|
|
671
|
+
|
|
672
|
+
rdc-heatmap-cell {
|
|
673
|
+
display: inline-block;
|
|
674
|
+
padding: 1px 4px;
|
|
675
|
+
border-radius: 2px;
|
|
676
|
+
font-variant-numeric: tabular-nums;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
rdc-sticky-h2 { display: contents; }
|
|
680
|
+
rdc-sticky-h2 h2[aria-current="section"] {
|
|
681
|
+
border-left-color: var(--accent-data);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
rdc-row-drill { display: contents; }
|
|
685
|
+
rdc-row-drill > tr { cursor: pointer; }
|
|
686
|
+
rdc-row-drill > tr:hover td { background: var(--row-hover); }
|
|
687
|
+
|
|
688
|
+
rdc-search-cards {
|
|
689
|
+
display: flex; align-items: center; gap: var(--sp-3);
|
|
690
|
+
margin: 0 0 var(--sp-4);
|
|
691
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
692
|
+
}
|
|
693
|
+
rdc-search-cards input[type="search"] {
|
|
694
|
+
font: inherit;
|
|
695
|
+
padding: 6px 10px;
|
|
696
|
+
border: 1px solid var(--border-1);
|
|
697
|
+
background: var(--surface-0);
|
|
698
|
+
color: var(--text-1);
|
|
699
|
+
border-radius: 2px;
|
|
700
|
+
min-width: 280px;
|
|
701
|
+
}
|
|
702
|
+
rdc-search-cards input[type="search"]:focus {
|
|
703
|
+
outline: 2px solid var(--accent-primary);
|
|
704
|
+
outline-offset: 1px;
|
|
705
|
+
}
|
|
706
|
+
rdc-search-cards .rdc-count {
|
|
707
|
+
color: var(--text-2);
|
|
708
|
+
font-variant-numeric: tabular-nums;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
rdc-ab-picker {
|
|
712
|
+
display: inline-flex; align-items: center; gap: var(--sp-2);
|
|
713
|
+
margin: 0 0 var(--sp-4);
|
|
714
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
715
|
+
}
|
|
716
|
+
rdc-ab-picker label { color: var(--text-2); }
|
|
717
|
+
rdc-ab-picker select {
|
|
718
|
+
font: inherit;
|
|
719
|
+
padding: 4px 8px;
|
|
720
|
+
border: 1px solid var(--border-1);
|
|
721
|
+
background: var(--surface-0);
|
|
722
|
+
color: var(--text-1);
|
|
723
|
+
border-radius: 2px;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
rdc-alarm-banner { display: contents; }
|
|
727
|
+
|
|
728
|
+
/* Chip cluster: wrap N primary chips in flex row, no table abuse */
|
|
729
|
+
.chip-cluster {
|
|
730
|
+
display: flex; flex-wrap: wrap; gap: var(--sp-2);
|
|
731
|
+
margin: 0 0 var(--sp-4);
|
|
732
|
+
}
|
|
733
|
+
.chip-cluster a[data-link-kind="primary"] { padding: 4px 10px; font-size: var(--fs-small); }
|
|
734
|
+
|
|
735
|
+
/* Pair list: grouped variant chips per A/B pair */
|
|
736
|
+
.pair-list { display: flex; flex-direction: column; gap: var(--sp-4); margin: 0 0 var(--sp-6); }
|
|
737
|
+
.pair-group { border: 1px solid var(--border-1); padding: var(--sp-3) var(--sp-4); background: var(--surface-1); }
|
|
738
|
+
.pair-group > h3 {
|
|
739
|
+
margin: 0 0 var(--sp-3); padding: 0;
|
|
740
|
+
font: var(--fs-mono) ui-monospace, monospace;
|
|
741
|
+
color: var(--text-2);
|
|
742
|
+
font-weight: 500;
|
|
743
|
+
text-transform: none;
|
|
744
|
+
letter-spacing: 0;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/* Catalog grid: flex-wrap chip area for report shortcuts */
|
|
748
|
+
.catalog-grid {
|
|
749
|
+
display: grid;
|
|
750
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
751
|
+
gap: var(--sp-2);
|
|
752
|
+
margin: 0 0 var(--sp-6);
|
|
753
|
+
}
|
|
754
|
+
.catalog-grid a[data-link-kind="primary"] { justify-content: flex-start; }
|
|
755
|
+
"""
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
_COMPONENTS_JS_ALL = """
|
|
759
|
+
(function(){
|
|
760
|
+
if (typeof customElements === 'undefined') return;
|
|
761
|
+
|
|
762
|
+
class RdcBase extends HTMLElement {
|
|
763
|
+
connectedCallback(){
|
|
764
|
+
if (this._rdcUp) return;
|
|
765
|
+
this._rdcUp = true;
|
|
766
|
+
try { this.init(); } catch(e) { console.error('rdc init error', this.tagName, e); }
|
|
767
|
+
}
|
|
768
|
+
init(){}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
class RdcSortableTable extends RdcBase {
|
|
772
|
+
init(){
|
|
773
|
+
const table = this.querySelector('table');
|
|
774
|
+
if (!table) return;
|
|
775
|
+
this._table = table;
|
|
776
|
+
const ths = table.querySelectorAll('thead th');
|
|
777
|
+
this._ths = ths;
|
|
778
|
+
ths.forEach((th, ci) => {
|
|
779
|
+
const isNum = th.classList.contains('num');
|
|
780
|
+
th.setAttribute('aria-sort', 'none');
|
|
781
|
+
th.addEventListener('click', () => this.sort(ci, isNum));
|
|
782
|
+
});
|
|
783
|
+
const liveId = 'rdc-live-' + Math.random().toString(36).slice(2, 8);
|
|
784
|
+
const live = document.createElement('div');
|
|
785
|
+
live.id = liveId;
|
|
786
|
+
live.setAttribute('aria-live', 'polite');
|
|
787
|
+
live.setAttribute('aria-atomic', 'true');
|
|
788
|
+
live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)';
|
|
789
|
+
this.appendChild(live);
|
|
790
|
+
this._live = live;
|
|
791
|
+
const def = this.dataset.defaultSort;
|
|
792
|
+
const dir = this.dataset.defaultDir || 'desc';
|
|
793
|
+
if (def){
|
|
794
|
+
const cols = Array.from(ths).map(th => th.textContent.trim().toLowerCase());
|
|
795
|
+
const idx = cols.indexOf(def.toLowerCase());
|
|
796
|
+
if (idx >= 0){
|
|
797
|
+
const isNum = ths[idx].classList.contains('num');
|
|
798
|
+
this._applySort(idx, isNum, dir === 'asc' ? 'ascending' : 'descending');
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
sort(ci, isNum){
|
|
803
|
+
const cur = this._ths[ci].getAttribute('aria-sort') || 'none';
|
|
804
|
+
const dir = cur === 'ascending' ? 'descending' : 'ascending';
|
|
805
|
+
this._applySort(ci, isNum, dir);
|
|
806
|
+
}
|
|
807
|
+
_applySort(ci, isNum, dir){
|
|
808
|
+
const tbody = this._table.tBodies[0];
|
|
809
|
+
if (!tbody) return;
|
|
810
|
+
const rows = Array.from(tbody.rows);
|
|
811
|
+
rows.sort((a, b) => {
|
|
812
|
+
const av = (a.cells[ci] ? a.cells[ci].textContent : '').trim();
|
|
813
|
+
const bv = (b.cells[ci] ? b.cells[ci].textContent : '').trim();
|
|
814
|
+
if (isNum){
|
|
815
|
+
const an = parseFloat(av.replace(/,/g, ''));
|
|
816
|
+
const bn = parseFloat(bv.replace(/,/g, ''));
|
|
817
|
+
const aok = !isNaN(an), bok = !isNaN(bn);
|
|
818
|
+
if (!aok && !bok) return 0;
|
|
819
|
+
if (!aok) return 1;
|
|
820
|
+
if (!bok) return -1;
|
|
821
|
+
return dir === 'ascending' ? an - bn : bn - an;
|
|
822
|
+
}
|
|
823
|
+
return dir === 'ascending' ? av.localeCompare(bv) : bv.localeCompare(av);
|
|
824
|
+
});
|
|
825
|
+
rows.forEach(r => tbody.appendChild(r));
|
|
826
|
+
this._ths.forEach(th => th.setAttribute('aria-sort', 'none'));
|
|
827
|
+
this._ths[ci].setAttribute('aria-sort', dir);
|
|
828
|
+
if (this._live) {
|
|
829
|
+
const colName = (this._ths[ci].textContent || '').trim();
|
|
830
|
+
this._live.textContent = 'sorted by ' + colName + ' ' + dir;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
customElements.define('rdc-sortable-table', RdcSortableTable);
|
|
835
|
+
|
|
836
|
+
class RdcCopyButton extends RdcBase {
|
|
837
|
+
init(){
|
|
838
|
+
const value = this.dataset.value || '';
|
|
839
|
+
const label = this.dataset.label || ('copy ' + value);
|
|
840
|
+
this.setAttribute('role', 'button');
|
|
841
|
+
this.setAttribute('tabindex', '0');
|
|
842
|
+
this.setAttribute('aria-label', label);
|
|
843
|
+
const handler = async () => {
|
|
844
|
+
try {
|
|
845
|
+
await navigator.clipboard.writeText(value);
|
|
846
|
+
} catch (e) {
|
|
847
|
+
const ta = document.createElement('textarea');
|
|
848
|
+
ta.value = value;
|
|
849
|
+
document.body.appendChild(ta);
|
|
850
|
+
ta.select();
|
|
851
|
+
try { document.execCommand('copy'); } catch(e2){}
|
|
852
|
+
ta.remove();
|
|
853
|
+
}
|
|
854
|
+
this.classList.add('copied');
|
|
855
|
+
setTimeout(() => this.classList.remove('copied'), 1000);
|
|
856
|
+
};
|
|
857
|
+
this.addEventListener('click', handler);
|
|
858
|
+
this.addEventListener('keydown', (e) => {
|
|
859
|
+
if (e.key === 'Enter' || e.key === ' '){
|
|
860
|
+
e.preventDefault();
|
|
861
|
+
handler();
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
customElements.define('rdc-copy-button', RdcCopyButton);
|
|
867
|
+
|
|
868
|
+
class RdcStickyH2 extends RdcBase {
|
|
869
|
+
init(){
|
|
870
|
+
const h2 = this.querySelector('h2[id]');
|
|
871
|
+
if (!h2) return;
|
|
872
|
+
const io = new IntersectionObserver((entries) => {
|
|
873
|
+
entries.forEach(e => {
|
|
874
|
+
if (e.isIntersecting){
|
|
875
|
+
h2.setAttribute('aria-current', 'section');
|
|
876
|
+
} else {
|
|
877
|
+
h2.removeAttribute('aria-current');
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
}, { rootMargin: '-50% 0px -50% 0px', threshold: 0 });
|
|
881
|
+
io.observe(h2);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
customElements.define('rdc-sticky-h2', RdcStickyH2);
|
|
885
|
+
|
|
886
|
+
class RdcHeatmapCell extends RdcBase {
|
|
887
|
+
init(){
|
|
888
|
+
const v = parseFloat(this.dataset.value);
|
|
889
|
+
const lo = parseFloat(this.dataset.min);
|
|
890
|
+
const hi = parseFloat(this.dataset.max);
|
|
891
|
+
const dir = this.dataset.direction || 'hot';
|
|
892
|
+
if (isNaN(v) || isNaN(lo) || isNaN(hi) || hi <= lo) return;
|
|
893
|
+
let t = (v - lo) / (hi - lo);
|
|
894
|
+
if (dir === 'cold') t = 1 - t;
|
|
895
|
+
t = Math.max(0, Math.min(1, t));
|
|
896
|
+
const pct = Math.round(t * 25);
|
|
897
|
+
this.style.background = 'color-mix(in oklch, var(--accent-data) ' + pct + '%, transparent)';
|
|
898
|
+
if (t >= 0.72){
|
|
899
|
+
this.style.color = 'light-dark(black, white)';
|
|
900
|
+
}
|
|
901
|
+
this.setAttribute('aria-label', v + ' (relative ' + Math.round(t * 100) + '%)');
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
customElements.define('rdc-heatmap-cell', RdcHeatmapCell);
|
|
905
|
+
|
|
906
|
+
class RdcRowDrill extends RdcBase {
|
|
907
|
+
init(){
|
|
908
|
+
const href = this.dataset.href;
|
|
909
|
+
if (!href) return;
|
|
910
|
+
this.setAttribute('role', 'link');
|
|
911
|
+
this.setAttribute('tabindex', '0');
|
|
912
|
+
this.style.cursor = 'pointer';
|
|
913
|
+
const go = (ev) => {
|
|
914
|
+
if (ev.target && ev.target.closest('a')) return;
|
|
915
|
+
if (ev.target && ev.target.closest('rdc-copy-button')) return;
|
|
916
|
+
window.location.href = href;
|
|
917
|
+
};
|
|
918
|
+
this.addEventListener('click', go);
|
|
919
|
+
this.addEventListener('keydown', (ev) => {
|
|
920
|
+
if (ev.key === 'Enter'){
|
|
921
|
+
ev.preventDefault();
|
|
922
|
+
window.location.href = href;
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
customElements.define('rdc-row-drill', RdcRowDrill);
|
|
928
|
+
|
|
929
|
+
class RdcSearchCards extends RdcBase {
|
|
930
|
+
init(){
|
|
931
|
+
const target = this.dataset.target || '.dash-grid';
|
|
932
|
+
const cards = document.querySelectorAll(target + ' > *');
|
|
933
|
+
const input = this.querySelector('input');
|
|
934
|
+
if (!input || !cards.length) return;
|
|
935
|
+
const counter = this.querySelector('.rdc-count');
|
|
936
|
+
const update = () => {
|
|
937
|
+
const q = input.value.trim().toLowerCase();
|
|
938
|
+
let shown = 0;
|
|
939
|
+
cards.forEach(c => {
|
|
940
|
+
const text = (c.textContent || '').toLowerCase();
|
|
941
|
+
const match = !q || text.indexOf(q) >= 0;
|
|
942
|
+
c.style.display = match ? '' : 'none';
|
|
943
|
+
if (match) shown++;
|
|
944
|
+
});
|
|
945
|
+
if (counter) counter.textContent = shown + ' / ' + cards.length;
|
|
946
|
+
};
|
|
947
|
+
input.addEventListener('input', update);
|
|
948
|
+
update();
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
customElements.define('rdc-search-cards', RdcSearchCards);
|
|
952
|
+
|
|
953
|
+
class RdcAlarmBanner extends RdcBase {
|
|
954
|
+
init(){
|
|
955
|
+
const sev = this.dataset.severity || 'high';
|
|
956
|
+
const role = sev === 'high' ? 'alert' : 'status';
|
|
957
|
+
this.setAttribute('role', role);
|
|
958
|
+
this.setAttribute('aria-live', sev === 'high' ? 'assertive' : 'polite');
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
customElements.define('rdc-alarm-banner', RdcAlarmBanner);
|
|
962
|
+
|
|
963
|
+
class RdcAbPicker extends RdcBase {
|
|
964
|
+
init(){
|
|
965
|
+
const select = this.querySelector('select');
|
|
966
|
+
if (!select) return;
|
|
967
|
+
this.setAttribute('role', 'combobox');
|
|
968
|
+
select.addEventListener('change', () => {
|
|
969
|
+
const url = select.value;
|
|
970
|
+
if (url) window.location.href = url;
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
customElements.define('rdc-ab-picker', RdcAbPicker);
|
|
975
|
+
|
|
976
|
+
// Whole-row drill: any <tr> containing <a data-link-kind="drill"> becomes clickable.
|
|
977
|
+
function _wireRowDrill(){
|
|
978
|
+
const seen = new WeakSet();
|
|
979
|
+
document.querySelectorAll('a[data-link-kind="drill"]').forEach(a => {
|
|
980
|
+
const tr = a.closest('tr');
|
|
981
|
+
if (!tr || seen.has(tr)) return;
|
|
982
|
+
seen.add(tr);
|
|
983
|
+
tr.addEventListener('click', (ev) => {
|
|
984
|
+
if (ev.target.closest('a')) return;
|
|
985
|
+
if (ev.target.closest('rdc-copy-button')) return;
|
|
986
|
+
a.click();
|
|
987
|
+
});
|
|
988
|
+
tr.style.cursor = 'pointer';
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
if (document.readyState === 'loading'){
|
|
992
|
+
document.addEventListener('DOMContentLoaded', _wireRowDrill);
|
|
993
|
+
} else {
|
|
994
|
+
_wireRowDrill();
|
|
995
|
+
}
|
|
996
|
+
})();
|
|
997
|
+
"""
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
_TOKENS_CSS = _DESIGN_TOKENS
|
|
1001
|
+
_PRIMITIVES_CSS = _CHROME_CSS + _LINK_KIND_CSS + _STICKY_CSS + _CONTAINER_CSS + _PRINT_CSS
|
|
1002
|
+
_COMPONENTS_CSS = _COMPONENTS_CSS_BASE
|
|
1003
|
+
_COMPONENTS_JS = _COMPONENTS_JS_ALL
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
# Inline 16x16 SVG favicon (3-bar stack pattern) as data URL.
|
|
1007
|
+
# Uses single quotes inside SVG so the data URL can be enclosed in
|
|
1008
|
+
# href="..." without breaking the HTML attribute.
|
|
1009
|
+
_FAVICON_HREF = (
|
|
1010
|
+
"data:image/svg+xml;utf8,"
|
|
1011
|
+
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'>"
|
|
1012
|
+
"<rect x='2' y='3' width='12' height='2' fill='%23888'/>"
|
|
1013
|
+
"<rect x='2' y='7' width='12' height='2' fill='%234a8'/>"
|
|
1014
|
+
"<rect x='2' y='11' width='12' height='2' fill='%23d54'/>"
|
|
1015
|
+
"</svg>"
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _minify_css(s: str) -> str:
|
|
1020
|
+
"""Strip CSS comments + collapse whitespace + drop blank lines.
|
|
1021
|
+
Conservative: preserves selectors and rule structure."""
|
|
1022
|
+
import re as _re
|
|
1023
|
+
s = _re.sub(r'/\*.*?\*/', '', s, flags=_re.DOTALL)
|
|
1024
|
+
s = _re.sub(r'\n\s*\n', '\n', s)
|
|
1025
|
+
s = _re.sub(r'^\s+', '', s, flags=_re.MULTILINE)
|
|
1026
|
+
s = _re.sub(r'\s*([{};:,])\s*', r'\1', s)
|
|
1027
|
+
s = _re.sub(r';}', '}', s)
|
|
1028
|
+
return s.strip()
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _minify_js(s: str) -> str:
|
|
1032
|
+
"""Strip JS line comments + block comments + collapse leading whitespace.
|
|
1033
|
+
Conservative: preserves string literals (does not strip inside them naively)."""
|
|
1034
|
+
import re as _re
|
|
1035
|
+
# Strip block comments (greedy minimal)
|
|
1036
|
+
s = _re.sub(r'/\*.*?\*/', '', s, flags=_re.DOTALL)
|
|
1037
|
+
# Strip line comments (// to end of line) - careful with URLs (// in strings)
|
|
1038
|
+
# Use a heuristic: only strip lines whose // is preceded by whitespace at start of statement
|
|
1039
|
+
lines = []
|
|
1040
|
+
for line in s.split('\n'):
|
|
1041
|
+
# Drop line comments that begin a line (after whitespace)
|
|
1042
|
+
stripped = line.lstrip()
|
|
1043
|
+
if stripped.startswith('//'):
|
|
1044
|
+
continue
|
|
1045
|
+
lines.append(line.rstrip())
|
|
1046
|
+
s = '\n'.join(ln for ln in lines if ln)
|
|
1047
|
+
return s
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _compose_css() -> str:
|
|
1051
|
+
return _minify_css(_TOKENS_CSS + _PRIMITIVES_CSS + _COMPONENTS_CSS)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _compose_js() -> str:
|
|
1055
|
+
return _minify_js(_COMPONENTS_JS)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
_CSS = _compose_css()
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def design_tokens_css() -> str:
|
|
1062
|
+
"""Return :root tokens CSS for reuse in template.py."""
|
|
1063
|
+
return _TOKENS_CSS
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def chrome_css() -> str:
|
|
1067
|
+
"""Return primitives + components CSS (without tokens). Used by template.py."""
|
|
1068
|
+
return _PRIMITIVES_CSS + _COMPONENTS_CSS
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def components_js() -> str:
|
|
1072
|
+
"""Return Web Components JS blob. Used by template.py."""
|
|
1073
|
+
return _COMPONENTS_JS
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def h(s) -> str:
|
|
1077
|
+
return _html.escape(str(s if s is not None else ''))
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
# Canonical class order + colors. Reports use this order in stacks.
|
|
1081
|
+
DRAW_CLASSES = [
|
|
1082
|
+
'opaque', 'prepass', 'shadow', 'translucent', 'additive',
|
|
1083
|
+
'decal', 'ui', 'postprocess', 'other',
|
|
1084
|
+
]
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def class_color_var(cls: str) -> str:
|
|
1088
|
+
return f'var(--c-{cls})' if cls in DRAW_CLASSES else 'var(--c-other)'
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def page_open(title: str, *, hdr_offset_px: int | None = None,
|
|
1092
|
+
body_attrs: dict | None = None) -> str:
|
|
1093
|
+
"""Open a self-contained HTML page. hdr_offset_px sets --hdr-offset on <body>.
|
|
1094
|
+
|
|
1095
|
+
Use 48 for dashboard / single-section reports, 84 for multi-section reports
|
|
1096
|
+
that carry ab_strip / device_strip / toc above the first sticky h2.
|
|
1097
|
+
body_attrs: extra attributes on <body> (e.g. {'data-multi-section': 'true'}).
|
|
1098
|
+
"""
|
|
1099
|
+
js = _compose_js()
|
|
1100
|
+
script = f'<script>{js}</script>' if js else ''
|
|
1101
|
+
attrs: list[str] = []
|
|
1102
|
+
if hdr_offset_px is not None:
|
|
1103
|
+
attrs.append(f'style="--hdr-offset: {int(hdr_offset_px)}px"')
|
|
1104
|
+
for k, v in (body_attrs or {}).items():
|
|
1105
|
+
attrs.append(f'{_html.escape(k)}="{_html.escape(str(v))}"')
|
|
1106
|
+
body_attr_str = (' ' + ' '.join(attrs)) if attrs else ''
|
|
1107
|
+
return (f'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
1108
|
+
f'<title>{_html.escape(title)}</title>'
|
|
1109
|
+
f'<link rel="icon" href="{_FAVICON_HREF}">'
|
|
1110
|
+
f'<style>{_compose_css()}</style>'
|
|
1111
|
+
f'{script}</head><body{body_attr_str}>'
|
|
1112
|
+
f'{_ICON_SPRITE}')
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def icon(name: str) -> str:
|
|
1116
|
+
"""Return inline SVG referencing the icon sprite."""
|
|
1117
|
+
return f'<svg class="icon" aria-hidden="true"><use href="#icon-{_html.escape(name)}"/></svg>'
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def summary_bar(label: str, headline: str, *,
|
|
1121
|
+
sub: str | None = None,
|
|
1122
|
+
link_href: str | None = None,
|
|
1123
|
+
link_text: str = 'view',
|
|
1124
|
+
tone: str = 'neutral') -> str:
|
|
1125
|
+
"""Render the per-page summary-bar primitive.
|
|
1126
|
+
|
|
1127
|
+
label: short caption (e.g. "worst gpu area")
|
|
1128
|
+
headline: 5-second number / phrase (rendered at --fs-display)
|
|
1129
|
+
sub: optional sub-line (e.g. "rank 1 of 7 areas")
|
|
1130
|
+
link_href: optional primary-action link rendered as button chip
|
|
1131
|
+
tone: neutral | alarm | warn | ok | info (controls top border color)
|
|
1132
|
+
"""
|
|
1133
|
+
wrap_open = ''
|
|
1134
|
+
wrap_close = ''
|
|
1135
|
+
if tone == 'alarm':
|
|
1136
|
+
wrap_open = '<rdc-alarm-banner data-severity="high">'
|
|
1137
|
+
wrap_close = '</rdc-alarm-banner>'
|
|
1138
|
+
parts = [wrap_open]
|
|
1139
|
+
parts.append(f'<aside class="summary-bar tone-{_html.escape(tone)}" aria-label="page summary">')
|
|
1140
|
+
parts.append(f'<div class="sb-label">{_html.escape(label)}</div>')
|
|
1141
|
+
parts.append(f'<div class="sb-headline">{_html.escape(headline)}</div>')
|
|
1142
|
+
if sub:
|
|
1143
|
+
parts.append(f'<div class="sb-sub">{_html.escape(sub)}</div>')
|
|
1144
|
+
if link_href:
|
|
1145
|
+
parts.append(f'<a class="sb-link" href="{_html.escape(link_href)}" '
|
|
1146
|
+
f'data-link-kind="primary">{_html.escape(link_text)}</a>')
|
|
1147
|
+
parts.append('</aside>')
|
|
1148
|
+
parts.append(wrap_close)
|
|
1149
|
+
return ''.join(parts)
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def ab_picker(options: list, current_href: str | None = None) -> str:
|
|
1153
|
+
"""Render A/B picker dropdown.
|
|
1154
|
+
|
|
1155
|
+
options: list of (label, href) tuples.
|
|
1156
|
+
current_href: optional href to mark selected.
|
|
1157
|
+
"""
|
|
1158
|
+
if not options:
|
|
1159
|
+
return ''
|
|
1160
|
+
parts = ['<rdc-ab-picker><label for="rdc-ab-select">compare</label>',
|
|
1161
|
+
'<select id="rdc-ab-select">',
|
|
1162
|
+
'<option value="">none</option>']
|
|
1163
|
+
for label, href in options:
|
|
1164
|
+
sel = ' selected' if current_href == href else ''
|
|
1165
|
+
parts.append(f'<option value="{_html.escape(href)}"{sel}>'
|
|
1166
|
+
f'{_html.escape(label)}</option>')
|
|
1167
|
+
parts.append('</select></rdc-ab-picker>')
|
|
1168
|
+
return ''.join(parts)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def ab_picker_for(root: str, report_name: str, *, ab=None) -> str:
|
|
1172
|
+
"""Discover A/B pairs under <root>/_reports/ab and emit picker for given report.
|
|
1173
|
+
|
|
1174
|
+
Suppresses output when ab is non-None (caller is already on an A/B page).
|
|
1175
|
+
Emits relative URLs from <root>/_reports/<report>.html.
|
|
1176
|
+
"""
|
|
1177
|
+
import os as _os
|
|
1178
|
+
if ab is not None:
|
|
1179
|
+
return ''
|
|
1180
|
+
ab_dir = _os.path.join(root, '_reports', 'ab')
|
|
1181
|
+
if not _os.path.isdir(ab_dir):
|
|
1182
|
+
return ''
|
|
1183
|
+
pairs = sorted(d for d in _os.listdir(ab_dir)
|
|
1184
|
+
if _os.path.isdir(_os.path.join(ab_dir, d)))
|
|
1185
|
+
if not pairs:
|
|
1186
|
+
return ''
|
|
1187
|
+
options = [(p, f'ab/{p}/{report_name}.html') for p in pairs]
|
|
1188
|
+
return ab_picker(options)
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def link(href: str, text: str, *, kind: str = 'inline',
|
|
1192
|
+
icon_name: str | None = None, target_blank: bool = False) -> str:
|
|
1193
|
+
"""Render <a data-link-kind=...> with optional trailing icon.
|
|
1194
|
+
|
|
1195
|
+
kind: primary | inline | drill | copy | crumb
|
|
1196
|
+
icon_name: matches a symbol id in _ICON_SPRITE (link-out, file, arrow-right, copy, search, warn)
|
|
1197
|
+
"""
|
|
1198
|
+
target_attr = ' target="_blank" rel="noopener"' if target_blank else ''
|
|
1199
|
+
icon_html = icon(icon_name) if icon_name else ''
|
|
1200
|
+
return (f'<a href="{_html.escape(href)}" data-link-kind="{_html.escape(kind)}"'
|
|
1201
|
+
f'{target_attr}>{_html.escape(text)}{icon_html}</a>')
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def page_close() -> str:
|
|
1205
|
+
return '</body></html>'
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def header(title: str, *, drops: int = 0, captures: int = 0,
|
|
1209
|
+
build_ts: str = '', kpis: list | None = None,
|
|
1210
|
+
crumb_depth: int = 1, current_page: str | None = None) -> str:
|
|
1211
|
+
"""Render top page header: h1 + data strip + crumb + optional kpi strip.
|
|
1212
|
+
|
|
1213
|
+
crumb_depth = number of '../' segments to root index.html.
|
|
1214
|
+
Chronological reports under _reports/ use 1. A/B under _reports/ab/<pair>/ use 3.
|
|
1215
|
+
|
|
1216
|
+
current_page: if 'dashboard', drops the dashboard self-link from crumb.
|
|
1217
|
+
if 'root', drops the root-catalog self-link.
|
|
1218
|
+
"""
|
|
1219
|
+
up = '../' * crumb_depth
|
|
1220
|
+
crumb_links = []
|
|
1221
|
+
if current_page != 'root':
|
|
1222
|
+
crumb_links.append(f'<a href="{up}index.html" data-link-kind="crumb">root catalog</a>')
|
|
1223
|
+
if current_page != 'dashboard':
|
|
1224
|
+
crumb_links.append(f'<a href="{up}_reports/index.html" data-link-kind="crumb">dashboard</a>')
|
|
1225
|
+
fact_spans = [f'<span>built <strong>{_html.escape(build_ts)}</strong></span>']
|
|
1226
|
+
if drops > 1:
|
|
1227
|
+
fact_spans.append(f'<span>drops <strong>{drops}</strong></span>')
|
|
1228
|
+
parts = [
|
|
1229
|
+
f'<h1>{_html.escape(title)}</h1>',
|
|
1230
|
+
'<header class="strip">',
|
|
1231
|
+
*fact_spans,
|
|
1232
|
+
'</header>',
|
|
1233
|
+
f'<nav class="crumb">{"".join(crumb_links)}</nav>',
|
|
1234
|
+
]
|
|
1235
|
+
if kpis:
|
|
1236
|
+
parts.append(kpi_strip(kpis))
|
|
1237
|
+
return '\n'.join(parts)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def legend(classes: list[str] | None = None) -> str:
|
|
1241
|
+
out = ['<div class="legend">']
|
|
1242
|
+
for c in (classes or DRAW_CLASSES):
|
|
1243
|
+
out.append(f'<span class="chip"><span class="swatch" '
|
|
1244
|
+
f'style="background: {class_color_var(c)}"></span>{_html.escape(c)}</span>')
|
|
1245
|
+
out.append('</div>')
|
|
1246
|
+
return '\n'.join(out)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def kpi_chip(label: str, value, *, delta: str | None = None,
|
|
1250
|
+
tone: str = 'neutral') -> str:
|
|
1251
|
+
"""Render one KPI chip. tone in {pos, neg, neutral}."""
|
|
1252
|
+
parts = [f'<div class="kpi-chip tone-{h(tone)}">']
|
|
1253
|
+
parts.append(f'<div class="kpi-label">{h(label)}</div>')
|
|
1254
|
+
parts.append(f'<div class="kpi-value">{h(value)}</div>')
|
|
1255
|
+
if delta:
|
|
1256
|
+
parts.append(f'<div class="kpi-delta">{h(delta)}</div>')
|
|
1257
|
+
parts.append('</div>')
|
|
1258
|
+
return ''.join(parts)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def kpi_strip(kpis: list) -> str:
|
|
1262
|
+
"""Render hero KPI strip below page header."""
|
|
1263
|
+
if not kpis:
|
|
1264
|
+
return ''
|
|
1265
|
+
parts = ['<div class="kpi-strip">']
|
|
1266
|
+
for k in kpis:
|
|
1267
|
+
parts.append(kpi_chip(
|
|
1268
|
+
k.get('label', ''), k.get('value', ''),
|
|
1269
|
+
delta=k.get('delta'), tone=k.get('tone', 'neutral'),
|
|
1270
|
+
))
|
|
1271
|
+
parts.append('</div>')
|
|
1272
|
+
return ''.join(parts)
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def section_card(section_id: str, title: str, body: str, *,
|
|
1276
|
+
count: str | int | None = None,
|
|
1277
|
+
subtitle: str | None = None,
|
|
1278
|
+
level: str = 'h2') -> str:
|
|
1279
|
+
head_parts = [f'<{level}>{h(title)}</{level}>']
|
|
1280
|
+
if count is not None:
|
|
1281
|
+
if isinstance(count, int):
|
|
1282
|
+
count_str = _f.fmt_int(count)
|
|
1283
|
+
else:
|
|
1284
|
+
count_str = str(count)
|
|
1285
|
+
head_parts.append(f'<span class="card-count">{h(count_str)}</span>')
|
|
1286
|
+
sub = (f'<p class="card-subtitle">{h(subtitle)}</p>'
|
|
1287
|
+
if subtitle else '')
|
|
1288
|
+
return (f'<section class="card" id="{h(section_id)}">'
|
|
1289
|
+
f'<header>{"".join(head_parts)}</header>'
|
|
1290
|
+
f'{sub}'
|
|
1291
|
+
f'{body}'
|
|
1292
|
+
f'</section>')
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def footer_legend(extra: str = '') -> str:
|
|
1296
|
+
"""Deprecated stub. Returns empty string; callers should be removed."""
|
|
1297
|
+
return ''
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def ab_strip(ab, *, baseline_suffix: str = '', compare_suffix: str = '') -> str:
|
|
1301
|
+
"""Render the A/B header strip. Empty string when ab is None."""
|
|
1302
|
+
if ab is None:
|
|
1303
|
+
return ''
|
|
1304
|
+
baseline, compare = ab
|
|
1305
|
+
return (f'<div class="ab-strip">baseline: {h(baseline.key)}{baseline_suffix} '
|
|
1306
|
+
f'| compare: {h(compare.key)}{compare_suffix}</div>')
|