@dreamdata_io/verify-tracking 0.1.0 → 0.1.1
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.
- package/README.md +52 -25
- package/dist/cli.js +54 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/env.js.map +1 -1
- package/dist/review/dashboard.js +37 -0
- package/dist/review/dashboard.js.map +1 -0
- package/dist/review/data.js +185 -0
- package/dist/review/data.js.map +1 -0
- package/dist/review/emit.js +66 -0
- package/dist/review/emit.js.map +1 -0
- package/dist/review/open.js +53 -0
- package/dist/review/open.js.map +1 -0
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/scripts/install.sh +43 -3
- package/verification/review/index.html +1810 -0
|
@@ -0,0 +1,1810 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Verification Console</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<style>
|
|
8
|
+
/*
|
|
9
|
+
* Palette and metrics come from the Verification Console design. It is a
|
|
10
|
+
* deliberately single-theme page: an internal console pinned to one look,
|
|
11
|
+
* so every colour is painted explicitly rather than inherited, and there is
|
|
12
|
+
* no dark variant to keep in step.
|
|
13
|
+
*/
|
|
14
|
+
:root {
|
|
15
|
+
--navy: #24466c;
|
|
16
|
+
--navy-mid: #8fb0c4;
|
|
17
|
+
--ground: #f4f5f7;
|
|
18
|
+
--surface: #ffffff;
|
|
19
|
+
--line: #e2e8f0;
|
|
20
|
+
--hair: #eef1f5;
|
|
21
|
+
--muted: #63717e;
|
|
22
|
+
--faint: #a3aed0;
|
|
23
|
+
--body: #444;
|
|
24
|
+
--link: #0069c0;
|
|
25
|
+
--rail: #105bfb;
|
|
26
|
+
--pass: #238551;
|
|
27
|
+
--pass-bg: #ebf7ed;
|
|
28
|
+
--pass-line: #c2ebca;
|
|
29
|
+
--pass-ink: #02675c;
|
|
30
|
+
--fail: #cc301a;
|
|
31
|
+
--fail-bg: #fef2f2;
|
|
32
|
+
--fail-line: #edd7d5;
|
|
33
|
+
--fail-ink: #b4240f;
|
|
34
|
+
--warn: #66520c;
|
|
35
|
+
--warn-bg: #fff5d2;
|
|
36
|
+
--warn-line: #f6c77e;
|
|
37
|
+
--warn-callout: #fcf1db;
|
|
38
|
+
--neutral-bg: #f1f5f8;
|
|
39
|
+
--info-bg: #eff5fe;
|
|
40
|
+
--info-line: #d6e4f8;
|
|
41
|
+
--tint: #f6fafb;
|
|
42
|
+
--tint-line: #e7eff3;
|
|
43
|
+
--panel: #f5f9fa;
|
|
44
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
* { box-sizing: border-box; }
|
|
48
|
+
|
|
49
|
+
html, body { margin: 0; height: 100%; }
|
|
50
|
+
|
|
51
|
+
body {
|
|
52
|
+
background: var(--ground);
|
|
53
|
+
color: #000;
|
|
54
|
+
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
55
|
+
font-size: 13px;
|
|
56
|
+
line-height: 1.5;
|
|
57
|
+
-webkit-font-smoothing: antialiased;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.app { display: flex; height: 100vh; position: relative; min-width: 1040px; overflow: hidden; }
|
|
61
|
+
|
|
62
|
+
/* ── Sidebar ───────────────────────────────────────────────────── */
|
|
63
|
+
|
|
64
|
+
.side {
|
|
65
|
+
width: 308px; flex: 0 0 308px; display: flex; flex-direction: column;
|
|
66
|
+
background: var(--surface); border-right: 1px solid var(--line); overflow: hidden;
|
|
67
|
+
}
|
|
68
|
+
.side-head {
|
|
69
|
+
display: flex; align-items: center; gap: 8px;
|
|
70
|
+
padding: 14px 16px; border-bottom: 1px solid var(--hair);
|
|
71
|
+
}
|
|
72
|
+
.side-title { font-size: 13px; font-weight: 600; letter-spacing: -.01em; flex: 1; }
|
|
73
|
+
.icon-btn {
|
|
74
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: 6px;
|
|
75
|
+
width: 26px; height: 26px; cursor: pointer; color: var(--muted);
|
|
76
|
+
font-size: 13px; line-height: 1; flex: 0 0 auto;
|
|
77
|
+
}
|
|
78
|
+
.icon-btn:hover { background: var(--tint); }
|
|
79
|
+
|
|
80
|
+
.side-block {
|
|
81
|
+
padding: 14px 16px; border-bottom: 1px solid var(--hair);
|
|
82
|
+
display: flex; flex-direction: column; gap: 10px;
|
|
83
|
+
}
|
|
84
|
+
.side-search { padding: 12px 16px 10px; display: flex; flex-direction: column; gap: 8px; }
|
|
85
|
+
|
|
86
|
+
.eyebrow {
|
|
87
|
+
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
88
|
+
letter-spacing: .06em; color: var(--muted);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.field { display: flex; flex-direction: column; gap: 4px; }
|
|
92
|
+
.field label { font-size: 11px; font-weight: 600; color: var(--muted); }
|
|
93
|
+
.field input {
|
|
94
|
+
font: inherit; font-size: 12.5px; padding: 6px 9px; width: 100%;
|
|
95
|
+
border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: #000;
|
|
96
|
+
}
|
|
97
|
+
.field input:focus-visible { outline: 2px solid var(--rail); outline-offset: -1px; border-color: var(--rail); }
|
|
98
|
+
|
|
99
|
+
.btn {
|
|
100
|
+
font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer;
|
|
101
|
+
border: 1px solid var(--line); background: var(--surface); color: #000;
|
|
102
|
+
border-radius: 6px; padding: 6px 12px; height: 30px; white-space: nowrap;
|
|
103
|
+
}
|
|
104
|
+
.btn:hover { background: var(--tint); }
|
|
105
|
+
.btn.primary { background: var(--navy); border-color: var(--navy); color: #fff; }
|
|
106
|
+
.btn.primary:hover { background: #1d3a5b; }
|
|
107
|
+
.btn.full { width: 100%; }
|
|
108
|
+
|
|
109
|
+
.notice {
|
|
110
|
+
font-size: 11.5px; color: var(--muted); background: var(--neutral-bg);
|
|
111
|
+
border: 1px solid var(--hair); border-radius: 6px; padding: 7px 9px; line-height: 1.45;
|
|
112
|
+
}
|
|
113
|
+
.notice code {
|
|
114
|
+
font-family: var(--mono); font-size: 11px; display: block; margin-top: 4px;
|
|
115
|
+
color: var(--navy); word-break: break-all; user-select: all;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.site-list { flex: 1; overflow-y: auto; padding: 0 10px 16px; }
|
|
119
|
+
.site {
|
|
120
|
+
position: relative; padding: 7px 10px; border-radius: 8px;
|
|
121
|
+
cursor: pointer; margin-bottom: 1px; border: 1px solid transparent;
|
|
122
|
+
}
|
|
123
|
+
.site:hover { background: var(--tint); }
|
|
124
|
+
.site.on { border-color: var(--info-line); background: var(--info-bg); }
|
|
125
|
+
.site-top { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
|
|
126
|
+
.site-slug {
|
|
127
|
+
font-size: 12.5px; font-weight: 600; font-family: var(--mono);
|
|
128
|
+
white-space: nowrap; flex: 0 0 auto;
|
|
129
|
+
}
|
|
130
|
+
.site-host {
|
|
131
|
+
font-size: 11px; color: var(--muted); white-space: nowrap;
|
|
132
|
+
overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0;
|
|
133
|
+
}
|
|
134
|
+
.site-when { font-size: 10.5px; color: var(--faint); white-space: nowrap; flex: 0 0 auto; }
|
|
135
|
+
.site-tags { display: flex; align-items: center; gap: 5px; margin-top: 3px; min-width: 0; }
|
|
136
|
+
.side-rail {
|
|
137
|
+
width: 48px; flex: 0 0 48px; display: flex; flex-direction: column; align-items: center;
|
|
138
|
+
gap: 12px; padding: 14px 0; background: var(--surface); border-right: 1px solid var(--line);
|
|
139
|
+
}
|
|
140
|
+
.side-rail .vert {
|
|
141
|
+
writing-mode: vertical-rl; font-size: 11px; letter-spacing: .08em;
|
|
142
|
+
text-transform: uppercase; color: var(--muted);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Pills, shared by the sidebar, header and cause cards. */
|
|
146
|
+
.pill {
|
|
147
|
+
font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
148
|
+
border-radius: 4px; padding: 1px 6px; font-size: 10.5px;
|
|
149
|
+
}
|
|
150
|
+
.pill.lg { font-size: 12px; border-radius: 99px; padding: 2px 10px; border-style: solid; border-width: 1px; }
|
|
151
|
+
.pill.good { color: var(--pass); background: var(--pass-bg); border-color: var(--pass-line); }
|
|
152
|
+
.pill.bad { color: var(--fail); background: var(--fail-bg); border-color: var(--fail-line); }
|
|
153
|
+
.pill.warn { color: var(--warn); background: var(--warn-bg); border-color: var(--warn-line); }
|
|
154
|
+
.pill.neutral { color: var(--muted); background: var(--neutral-bg); border-color: var(--line); }
|
|
155
|
+
.more { font-size: 10.5px; color: var(--muted); white-space: nowrap; }
|
|
156
|
+
|
|
157
|
+
/* ── Main column ───────────────────────────────────────────────── */
|
|
158
|
+
|
|
159
|
+
main { flex: 1; min-width: 520px; display: flex; flex-direction: column; overflow: hidden; }
|
|
160
|
+
|
|
161
|
+
.top {
|
|
162
|
+
padding: 16px 24px 14px; background: var(--surface);
|
|
163
|
+
border-bottom: 1px solid var(--line); display: flex; align-items: flex-start; gap: 16px;
|
|
164
|
+
}
|
|
165
|
+
.top h1 { font-size: 22px; font-weight: 700; letter-spacing: -.025em; margin: 0; }
|
|
166
|
+
.top-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
167
|
+
.top-meta {
|
|
168
|
+
font-size: 12px; color: #666; margin-top: 5px;
|
|
169
|
+
display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
|
|
170
|
+
}
|
|
171
|
+
.top-meta a {
|
|
172
|
+
font-size: 13px; font-weight: 600; color: var(--link);
|
|
173
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
174
|
+
}
|
|
175
|
+
.top-meta a:hover { color: #08548a; text-decoration: underline; }
|
|
176
|
+
.top-meta .sep { color: #cbd5e1; }
|
|
177
|
+
.top-meta .mono { font-family: var(--mono); }
|
|
178
|
+
/* The header's right column: the action, then where the files it produced live. */
|
|
179
|
+
.top-side { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }
|
|
180
|
+
.top-note {
|
|
181
|
+
font-size: 11px; color: var(--muted); text-align: right; max-width: 260px;
|
|
182
|
+
}
|
|
183
|
+
.top-note .mono { font-family: var(--mono); word-break: break-all; user-select: all; }
|
|
184
|
+
|
|
185
|
+
.scroll { flex: 1; overflow-y: auto; padding: 20px 24px 60px; }
|
|
186
|
+
.col { max-width: 1040px; margin: 0 auto; display: flex; flex-direction: column; gap: 10px; }
|
|
187
|
+
|
|
188
|
+
/* ── Summary card ──────────────────────────────────────────────────
|
|
189
|
+
* The verdict sits on white with a status-coloured rail rather than on a
|
|
190
|
+
* navy slab: the tone is carried by the rail and the headline colour, so
|
|
191
|
+
* the card reads as part of the same stack as the question cards below,
|
|
192
|
+
* and the diagnosis rides in it as a disclosure instead of a second block.
|
|
193
|
+
*/
|
|
194
|
+
.summary {
|
|
195
|
+
border-radius: 12px; background: var(--surface);
|
|
196
|
+
border: 1px solid var(--line); overflow: hidden;
|
|
197
|
+
}
|
|
198
|
+
.summary-top { display: flex; align-items: stretch; gap: 14px; padding: 16px 18px; }
|
|
199
|
+
.summary-rail { width: 5px; flex: 0 0 5px; border-radius: 99px; }
|
|
200
|
+
.summary-rail.good { background: var(--pass); }
|
|
201
|
+
.summary-rail.warn { background: #f59f19; }
|
|
202
|
+
.summary-rail.bad { background: var(--fail); }
|
|
203
|
+
.summary-main { flex: 1; min-width: 0; }
|
|
204
|
+
.summary-head { font-size: 24px; font-weight: 700; letter-spacing: -.025em; line-height: 1.2; }
|
|
205
|
+
.summary-head.good { color: var(--pass-ink); }
|
|
206
|
+
.summary-head.warn { color: var(--warn); }
|
|
207
|
+
.summary-head.bad { color: var(--fail-ink); }
|
|
208
|
+
.summary-sub {
|
|
209
|
+
font-size: 13px; color: var(--body); margin-top: 5px;
|
|
210
|
+
line-height: 1.5; text-wrap: pretty;
|
|
211
|
+
}
|
|
212
|
+
.summary-stats {
|
|
213
|
+
flex: 0 0 auto; display: flex; gap: 20px;
|
|
214
|
+
align-items: flex-start; padding-left: 8px;
|
|
215
|
+
}
|
|
216
|
+
.stat { text-align: right; }
|
|
217
|
+
.stat-value { font-size: 21px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
|
218
|
+
.stat-label {
|
|
219
|
+
font-size: 10.5px; color: var(--muted); text-transform: uppercase;
|
|
220
|
+
letter-spacing: .05em; margin-top: 2px; white-space: nowrap;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* Diagnosis, as a disclosure inside the summary card */
|
|
224
|
+
.dx { border-top: 1px solid var(--hair); background: var(--tint); }
|
|
225
|
+
.dx > summary {
|
|
226
|
+
cursor: pointer; list-style: none; padding: 11px 18px;
|
|
227
|
+
font-size: 12.5px; font-weight: 600; color: var(--link);
|
|
228
|
+
}
|
|
229
|
+
.dx > summary::-webkit-details-marker { display: none; }
|
|
230
|
+
.dx > summary:hover { color: #08548a; }
|
|
231
|
+
.dx-body { padding: 0 18px 16px; }
|
|
232
|
+
.dx-clean {
|
|
233
|
+
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
|
|
234
|
+
padding: 11px 13px; font-size: 12.5px; line-height: 1.55;
|
|
235
|
+
color: var(--body); text-wrap: pretty;
|
|
236
|
+
}
|
|
237
|
+
.dx-causes { display: flex; flex-direction: column; gap: 9px; }
|
|
238
|
+
.cause {
|
|
239
|
+
background: var(--surface); border: 1px solid var(--line);
|
|
240
|
+
border-radius: 8px; padding: 12px 13px;
|
|
241
|
+
}
|
|
242
|
+
.cause-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
243
|
+
.cause-rank { font-size: 11px; font-weight: 700; color: var(--muted); }
|
|
244
|
+
.cause-tag { font-size: 11.5px; font-weight: 700; border-radius: 5px; padding: 2px 8px; border: 1px solid transparent; }
|
|
245
|
+
.cause-tag.bad { color: var(--fail); background: var(--fail-bg); border-color: var(--fail-line); }
|
|
246
|
+
.cause-tag.warn { color: var(--warn); background: var(--warn-bg); border-color: var(--warn-line); }
|
|
247
|
+
.cause-tag.neutral { color: var(--muted); background: var(--neutral-bg); border-color: var(--line); }
|
|
248
|
+
.cause-grid {
|
|
249
|
+
display: grid; grid-template-columns: 74px 1fr; gap: 4px 10px;
|
|
250
|
+
margin-top: 10px; align-items: baseline;
|
|
251
|
+
}
|
|
252
|
+
.cause-key {
|
|
253
|
+
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
|
|
254
|
+
}
|
|
255
|
+
.cause-key.problem { color: var(--fail-ink); }
|
|
256
|
+
.cause-key.fix { color: var(--link); }
|
|
257
|
+
.cause-val { font-size: 13px; line-height: 1.5; color: #000; text-wrap: pretty; }
|
|
258
|
+
.cause-doc { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--hair); }
|
|
259
|
+
.cause-doc a { font-size: 12px; font-weight: 600; color: var(--link); }
|
|
260
|
+
.dx-also { font-size: 12px; color: var(--muted); margin-top: 10px; line-height: 1.5; }
|
|
261
|
+
.dx-body details { margin-top: 9px; }
|
|
262
|
+
.dx-body details > summary {
|
|
263
|
+
cursor: pointer; font-size: 12px; font-weight: 600;
|
|
264
|
+
color: var(--link); list-style: none;
|
|
265
|
+
}
|
|
266
|
+
.dx-body details > summary::-webkit-details-marker { display: none; }
|
|
267
|
+
.dx-body details > div {
|
|
268
|
+
font-size: 12px; color: var(--body); margin-top: 6px; line-height: 1.55; text-wrap: pretty;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.section-label {
|
|
272
|
+
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
273
|
+
letter-spacing: .07em; color: var(--muted); margin-top: 8px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* Question cards
|
|
277
|
+
*
|
|
278
|
+
* Two to a row, and reduced to the eyebrow plus the answer: the reasoning
|
|
279
|
+
* sentence and the evidence chips that used to sit under each answer now
|
|
280
|
+
* open in the detail panel, so the grid stays scannable at a glance. The
|
|
281
|
+
* selection ring is an inset overlay rather than a left rail, so picking a
|
|
282
|
+
* card never shifts its text.
|
|
283
|
+
*/
|
|
284
|
+
.q-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
285
|
+
.q {
|
|
286
|
+
position: relative; background: var(--surface); border: 1px solid var(--line);
|
|
287
|
+
border-radius: 12px; padding: 13px 15px; cursor: pointer;
|
|
288
|
+
display: flex; flex-direction: column; gap: 5px;
|
|
289
|
+
}
|
|
290
|
+
.q:hover { border-color: var(--navy-mid); }
|
|
291
|
+
.q.on::after {
|
|
292
|
+
content: ""; position: absolute; inset: 0; pointer-events: none;
|
|
293
|
+
border: 2px solid var(--rail); border-radius: 12px;
|
|
294
|
+
}
|
|
295
|
+
.q-row { display: flex; align-items: center; gap: 8px; }
|
|
296
|
+
.q-label {
|
|
297
|
+
flex: 1; min-width: 0; font-size: 10.5px; font-weight: 700;
|
|
298
|
+
text-transform: uppercase; letter-spacing: .07em; color: var(--muted);
|
|
299
|
+
}
|
|
300
|
+
.q-answer {
|
|
301
|
+
font-size: 17px; font-weight: 650; letter-spacing: -.015em;
|
|
302
|
+
line-height: 1.3; text-wrap: pretty;
|
|
303
|
+
}
|
|
304
|
+
.q-more { flex: 0 0 auto; font-size: 11px; color: var(--link); font-weight: 600; white-space: nowrap; }
|
|
305
|
+
.flag-icon {
|
|
306
|
+
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
|
|
307
|
+
width: 15px; height: 15px; border-radius: 50%; font-size: 10.5px; font-weight: 800;
|
|
308
|
+
line-height: 1;
|
|
309
|
+
}
|
|
310
|
+
.flag-icon.bad { color: var(--fail); background: var(--fail-bg); border: 1px solid var(--fail-line); }
|
|
311
|
+
.flag-icon.warn { color: var(--warn); background: var(--warn-bg); border: 1px solid var(--warn-line); }
|
|
312
|
+
|
|
313
|
+
/* The pageview card keeps a full row to itself: it carries the table. */
|
|
314
|
+
.q.wide { padding: 14px 16px 16px; gap: 0; }
|
|
315
|
+
.q.wide .q-row { align-items: baseline; }
|
|
316
|
+
.q.wide .q-answer { flex: 1; min-width: 0; }
|
|
317
|
+
|
|
318
|
+
.chip {
|
|
319
|
+
font-size: 11px; font-family: var(--mono); color: var(--navy);
|
|
320
|
+
background: var(--neutral-bg); border: 1px solid var(--tint-line);
|
|
321
|
+
border-radius: 5px; padding: 2px 7px;
|
|
322
|
+
}
|
|
323
|
+
.callout {
|
|
324
|
+
background: var(--warn-callout); border: 1px solid var(--warn-line);
|
|
325
|
+
border-radius: 8px; padding: 10px 12px; font-size: 12.5px; line-height: 1.55;
|
|
326
|
+
color: var(--warn); text-wrap: pretty;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* Pageview table */
|
|
330
|
+
table.pv { width: 100%; border-collapse: collapse; margin-top: 12px; table-layout: fixed; }
|
|
331
|
+
table.pv th {
|
|
332
|
+
text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
333
|
+
letter-spacing: .05em; color: var(--muted); padding: 0 8px 6px 0;
|
|
334
|
+
}
|
|
335
|
+
table.pv td { border-top: 1px solid var(--hair); padding: 9px 8px 9px 0; vertical-align: top; }
|
|
336
|
+
table.pv td.cell { padding: 7px 8px 7px 0; }
|
|
337
|
+
.pv-label { font-size: 12.5px; font-weight: 600; }
|
|
338
|
+
.obs.exp { background: var(--neutral-bg); border-color: var(--tint-line); }
|
|
339
|
+
.obs.exp .obs-text { color: var(--muted); font-weight: 600; }
|
|
340
|
+
.obs { border-radius: 6px; padding: 5px 8px; border: 1px solid transparent; }
|
|
341
|
+
.obs-top { display: flex; align-items: baseline; gap: 5px; }
|
|
342
|
+
.obs-mark { font-weight: 700; font-size: 12px; }
|
|
343
|
+
.obs-text { font-size: 12.5px; font-weight: 600; }
|
|
344
|
+
.obs-note { font-size: 11px; line-height: 1.4; margin-top: 2px; }
|
|
345
|
+
.obs.ok { background: var(--pass-bg); border-color: var(--pass-line); }
|
|
346
|
+
.obs.ok .obs-mark { color: var(--pass); }
|
|
347
|
+
.obs.ok .obs-text { color: var(--pass-ink); }
|
|
348
|
+
.obs.ok .obs-note { color: var(--muted); }
|
|
349
|
+
.obs.off { background: var(--fail-bg); border-color: var(--fail-line); }
|
|
350
|
+
.obs.off .obs-mark { color: var(--fail); }
|
|
351
|
+
.obs.off .obs-text { color: var(--fail-ink); }
|
|
352
|
+
.obs.off .obs-note { color: var(--fail-ink); }
|
|
353
|
+
.obs.blank { background: #f6f6f6; border-color: var(--hair); }
|
|
354
|
+
.obs.blank .obs-mark { color: var(--faint); }
|
|
355
|
+
.obs.blank .obs-text { color: var(--muted); font-weight: 400; }
|
|
356
|
+
.obs.blank .obs-note { color: var(--muted); }
|
|
357
|
+
|
|
358
|
+
/* ── Detail panel ──────────────────────────────────────────────── */
|
|
359
|
+
|
|
360
|
+
.detail {
|
|
361
|
+
position: absolute; top: 0; right: 0; bottom: 0; width: 52%; min-width: 560px; z-index: 5;
|
|
362
|
+
display: flex; flex-direction: column; background: var(--panel);
|
|
363
|
+
border-left: 1px solid #cbd5e1; box-shadow: -12px 0 32px rgba(36, 70, 108, .12); overflow: hidden;
|
|
364
|
+
}
|
|
365
|
+
.detail-head {
|
|
366
|
+
padding: 14px 16px; border-bottom: 1px solid var(--line); background: var(--surface);
|
|
367
|
+
display: flex; align-items: flex-start; gap: 10px;
|
|
368
|
+
}
|
|
369
|
+
.detail-title {
|
|
370
|
+
font-size: 14px; font-weight: 600; letter-spacing: -.01em;
|
|
371
|
+
margin-top: 3px; line-height: 1.35; text-wrap: pretty;
|
|
372
|
+
}
|
|
373
|
+
.detail-body {
|
|
374
|
+
flex: 1; overflow-y: auto; padding: 14px 16px 40px;
|
|
375
|
+
display: flex; flex-direction: column; gap: 14px;
|
|
376
|
+
}
|
|
377
|
+
.detail-lead {
|
|
378
|
+
font-size: 12.5px; line-height: 1.55; color: var(--navy); background: var(--info-bg);
|
|
379
|
+
border: 1px solid var(--info-line); border-radius: 8px; padding: 10px 12px; text-wrap: pretty;
|
|
380
|
+
}
|
|
381
|
+
.d-label {
|
|
382
|
+
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
383
|
+
letter-spacing: .06em; color: var(--muted); margin-bottom: 7px;
|
|
384
|
+
}
|
|
385
|
+
.d-note { font-size: 11.5px; color: var(--muted); line-height: 1.5; margin-bottom: 7px; }
|
|
386
|
+
.d-prose {
|
|
387
|
+
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
|
|
388
|
+
padding: 10px 12px; font-size: 12.5px; line-height: 1.55;
|
|
389
|
+
color: var(--body); text-wrap: pretty;
|
|
390
|
+
}
|
|
391
|
+
.d-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
392
|
+
.close-btn {
|
|
393
|
+
border: 1px solid var(--line); background: var(--surface); border-radius: 6px;
|
|
394
|
+
padding: 0 9px; height: 26px; cursor: pointer; color: var(--muted);
|
|
395
|
+
font: inherit; font-size: 11.5px; font-weight: 600; line-height: 1;
|
|
396
|
+
white-space: nowrap; display: flex; align-items: center; gap: 5px; flex: 0 0 auto;
|
|
397
|
+
}
|
|
398
|
+
.close-btn:hover { border-color: var(--navy-mid); color: var(--navy); }
|
|
399
|
+
.close-btn span { font-size: 10px; color: var(--faint); }
|
|
400
|
+
.d-card { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 9px 11px; }
|
|
401
|
+
.d-stack { display: flex; flex-direction: column; gap: 7px; }
|
|
402
|
+
.d-card-top { display: flex; align-items: center; gap: 7px; }
|
|
403
|
+
.status {
|
|
404
|
+
font-size: 10px; font-weight: 700; border-radius: 4px;
|
|
405
|
+
padding: 1px 6px; letter-spacing: .04em; white-space: nowrap;
|
|
406
|
+
}
|
|
407
|
+
.status.pass { color: var(--pass); background: var(--pass-bg); }
|
|
408
|
+
.status.fail { color: var(--fail); background: var(--fail-bg); }
|
|
409
|
+
.status.other { color: var(--warn); background: var(--warn-bg); }
|
|
410
|
+
.d-card-title { font-size: 12.5px; font-weight: 600; line-height: 1.3; }
|
|
411
|
+
.d-card-detail { font-size: 12px; color: var(--body); line-height: 1.5; margin-top: 5px; text-wrap: pretty; }
|
|
412
|
+
.d-evidence {
|
|
413
|
+
font-size: 11px; font-family: var(--mono); color: var(--muted); background: #f6f6f6;
|
|
414
|
+
border-radius: 5px; padding: 6px 7px; margin-top: 6px;
|
|
415
|
+
white-space: pre-wrap; word-break: break-word;
|
|
416
|
+
}
|
|
417
|
+
.d-id { font-size: 10.5px; font-family: var(--mono); color: var(--faint); margin-top: 5px; }
|
|
418
|
+
.d-advice { background: var(--surface); border: 1px solid var(--warn-line); border-radius: 8px; padding: 10px 12px; }
|
|
419
|
+
.d-advice .d-label { color: var(--warn); margin-bottom: 5px; }
|
|
420
|
+
.d-advice-text { font-size: 12px; line-height: 1.55; color: var(--body); text-wrap: pretty; }
|
|
421
|
+
.d-raw summary { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--link); }
|
|
422
|
+
.d-raw pre {
|
|
423
|
+
font-size: 11px; font-family: var(--mono); color: var(--body);
|
|
424
|
+
white-space: pre-wrap; word-break: break-word; margin: 8px 0 0; line-height: 1.5;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* ── GTM summary (top of the detail panel) ─────────────────────── */
|
|
428
|
+
.gtm {
|
|
429
|
+
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
|
|
430
|
+
padding: 10px 12px 11px; display: flex; flex-direction: column; gap: 9px;
|
|
431
|
+
}
|
|
432
|
+
.gtm-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
|
433
|
+
.gtm-hint { font-size: 11px; color: var(--faint); line-height: 1.4; }
|
|
434
|
+
.gtm-c { display: flex; flex-direction: column; gap: 7px; }
|
|
435
|
+
.gtm-c + .gtm-c { border-top: 1px solid var(--hair); padding-top: 9px; }
|
|
436
|
+
.gtm-c-top { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; }
|
|
437
|
+
.gtm-id { font-size: 12px; font-weight: 700; font-family: var(--mono); }
|
|
438
|
+
.gtm-meta, .gtm-ships { font-size: 11px; color: var(--muted); }
|
|
439
|
+
.gtm-ships { color: var(--body); }
|
|
440
|
+
.gtm-empty { font-size: 11.5px; color: var(--muted); line-height: 1.5; }
|
|
441
|
+
.gtm-tag-title { font-size: 11.5px; font-weight: 600; color: var(--body); }
|
|
442
|
+
.gtm-note { font-size: 11px; color: var(--warn); line-height: 1.45; margin-top: 3px; }
|
|
443
|
+
.gtm-rows { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
|
|
444
|
+
.gtm-row {
|
|
445
|
+
display: grid; grid-template-columns: 96px 1fr; gap: 8px; align-items: baseline;
|
|
446
|
+
border-left: 3px solid var(--hair); padding: 4px 0 4px 8px; border-radius: 2px;
|
|
447
|
+
}
|
|
448
|
+
.gtm-row.gate { border-left-color: var(--warn); background: var(--warn-bg); }
|
|
449
|
+
.gtm-k {
|
|
450
|
+
font-size: 10.5px; font-weight: 600; text-transform: uppercase;
|
|
451
|
+
letter-spacing: .05em; color: var(--muted);
|
|
452
|
+
}
|
|
453
|
+
.gtm-row.gate .gtm-k { color: var(--warn); }
|
|
454
|
+
.gtm-v { font-size: 12px; line-height: 1.45; color: var(--body); text-wrap: pretty; }
|
|
455
|
+
|
|
456
|
+
.grid-wrap {
|
|
457
|
+
background: var(--surface); border: 1px solid var(--line);
|
|
458
|
+
border-radius: 8px; padding: 10px 12px; overflow-x: auto;
|
|
459
|
+
}
|
|
460
|
+
table.mx { width: 100%; border-collapse: collapse; }
|
|
461
|
+
table.mx th {
|
|
462
|
+
text-align: left; font-size: 10.5px; font-weight: 600; text-transform: uppercase;
|
|
463
|
+
letter-spacing: .04em; color: var(--muted); padding: 0 8px 6px 0;
|
|
464
|
+
}
|
|
465
|
+
table.mx td { border-top: 1px solid var(--hair); padding: 7px 8px 7px 0; }
|
|
466
|
+
table.mx td.phase { font-size: 11.5px; font-weight: 600; white-space: nowrap; }
|
|
467
|
+
.mx-cell { display: flex; align-items: center; gap: 5px; }
|
|
468
|
+
.mx-cell i { width: 9px; height: 9px; border-radius: 2px; flex: 0 0 9px; }
|
|
469
|
+
.mx-cell span { font-size: 11.5px; color: var(--body); font-family: var(--mono); }
|
|
470
|
+
.mx-status { font-size: 10px; color: var(--faint); margin-top: 2px; padding-left: 14px; }
|
|
471
|
+
|
|
472
|
+
.empty { padding: 14px 12px; font-size: 12px; color: var(--muted); }
|
|
473
|
+
.foot {
|
|
474
|
+
font-size: 11px; color: var(--faint); font-family: var(--mono);
|
|
475
|
+
padding: 14px 24px; border-top: 1px solid var(--line); background: var(--surface);
|
|
476
|
+
line-height: 1.6; word-break: break-word;
|
|
477
|
+
}
|
|
478
|
+
:focus-visible { outline: 2px solid var(--rail); outline-offset: 2px; }
|
|
479
|
+
</style>
|
|
480
|
+
</head>
|
|
481
|
+
<body>
|
|
482
|
+
|
|
483
|
+
<script>
|
|
484
|
+
/**
|
|
485
|
+
* Which dataset to show: data.js by default, or ?data=local-data.js for a
|
|
486
|
+
* set built from another reports directory (a replayed local-model run).
|
|
487
|
+
*
|
|
488
|
+
* document.write is the price of keeping this to one page: the script below
|
|
489
|
+
* reads window.__REVIEW_DATA__ at parse time, and a parser-inserted write is
|
|
490
|
+
* the only way to load a chosen file synchronously from a file:// page with
|
|
491
|
+
* no build step. The name is matched against a plain filename beside this
|
|
492
|
+
* page, so a hand-edited query string cannot point it at an arbitrary URL.
|
|
493
|
+
*/
|
|
494
|
+
(function () {
|
|
495
|
+
// A self-contained page (the one the CLI writes) has already assigned its
|
|
496
|
+
// data above, and there is no sibling file to load — nor any to be misled
|
|
497
|
+
// by, if it were ever saved next to someone else's data.js.
|
|
498
|
+
if (window.__REVIEW_DATA__) return;
|
|
499
|
+
var requested = new URLSearchParams(location.search).get('data') || 'data.js';
|
|
500
|
+
var file = /^[A-Za-z0-9._-]+\.js$/.test(requested) ? requested : 'data.js';
|
|
501
|
+
window.__REVIEW_DATA_FILE__ = file;
|
|
502
|
+
document.write('<scr' + 'ipt src="' + file + '"><\/scr' + 'ipt>');
|
|
503
|
+
})();
|
|
504
|
+
</script>
|
|
505
|
+
|
|
506
|
+
<div class="app" id="app"></div>
|
|
507
|
+
|
|
508
|
+
<script>
|
|
509
|
+
"use strict";
|
|
510
|
+
|
|
511
|
+
const DATA = window.__REVIEW_DATA__ || { slugs: [] };
|
|
512
|
+
const DATA_FILE = window.__REVIEW_DATA_FILE__ || 'data.js';
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* build.ts nests each run under `slug.report` and splits the phases into
|
|
516
|
+
* `variants`. Everything below wants one flat report per account with a
|
|
517
|
+
* `phases` array, which is the shape the report JSON itself has — so the
|
|
518
|
+
* adaptation happens once, here, rather than in every model function.
|
|
519
|
+
*/
|
|
520
|
+
const REPORTS = (DATA.slugs || [])
|
|
521
|
+
.filter((s) => s && s.report)
|
|
522
|
+
.map((s) => ({
|
|
523
|
+
slug: s.slug,
|
|
524
|
+
slugRecordedAs: s.slugRecordedAs || null,
|
|
525
|
+
url: s.report.url,
|
|
526
|
+
timestamp: s.report.timestamp,
|
|
527
|
+
deployment: s.report.deployment || {},
|
|
528
|
+
result: s.report.result || {},
|
|
529
|
+
httpProbe: s.report.httpProbe || null,
|
|
530
|
+
phases: (s.report.variants || []).map((v) => v.evidence).filter(Boolean),
|
|
531
|
+
}));
|
|
532
|
+
|
|
533
|
+
// ── Static tables ────────────────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Which automated checks belong behind each question. A check may appear
|
|
537
|
+
* under more than one: `second-page-keeps-tracking` is evidence both for what
|
|
538
|
+
* the site is built on and for whether the page view fires everywhere.
|
|
539
|
+
*/
|
|
540
|
+
const CHECK_MAP = {
|
|
541
|
+
script: ['script-installed', 'global-handoff', 'first-party-delivery-host'],
|
|
542
|
+
install: ['script-installed'],
|
|
543
|
+
tech: ['second-page-keeps-tracking', 'soft-nav-keeps-tracking'],
|
|
544
|
+
cmp: ['clean-consent-mode-default', 'consent-mode-signal'],
|
|
545
|
+
// The v2 check alone: `cmp` above answers "does the banner's choice get
|
|
546
|
+
// through", this one answers "is the mechanism installed at all", and the
|
|
547
|
+
// two are separate questions on purpose — see consentModeModel.
|
|
548
|
+
consentmode: ['consent-mode-v2-enabled'],
|
|
549
|
+
pageview: ['clean-before-consent', 'grant-enables-normal', 'reject-blocks-normal', 'cookieless-covers-unconsented',
|
|
550
|
+
'page-view-counted-once', 'second-page-keeps-tracking', 'second-page-honours-consent', 'anonymous-id-stable',
|
|
551
|
+
'grant-sets-identity-cookies', 'reject-writes-no-identity', 'request-payload-complete',
|
|
552
|
+
'request-delivery-accepted', 'console-clean', 'collection-complete'],
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const VERDICT_MAP = {
|
|
556
|
+
script: ['script-presence', 'global-object'],
|
|
557
|
+
install: ['script-presence'],
|
|
558
|
+
tech: ['event-behavior'],
|
|
559
|
+
cmp: ['consent-mode'],
|
|
560
|
+
consentmode: ['consent-mode'],
|
|
561
|
+
pageview: ['core-tracking', 'event-behavior', 'cookies', 'debugging-signals'],
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const VENDOR_LABEL = {
|
|
565
|
+
onetrust: 'OneTrust',
|
|
566
|
+
cookiebot: 'Cookiebot',
|
|
567
|
+
usercentrics: 'Usercentrics',
|
|
568
|
+
cookieyes: 'CookieYes',
|
|
569
|
+
cookiescript: 'CookieScript',
|
|
570
|
+
complianz: 'Complianz',
|
|
571
|
+
osano: 'Osano',
|
|
572
|
+
trustarc: 'TrustArc',
|
|
573
|
+
termly: 'Termly',
|
|
574
|
+
ketch: 'Ketch',
|
|
575
|
+
hubspot: 'HubSpot',
|
|
576
|
+
pardot: 'Pardot',
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const vendorLabel = (v) => VENDOR_LABEL[String(v).toLowerCase()] || String(v).charAt(0).toUpperCase() + String(v).slice(1);
|
|
580
|
+
|
|
581
|
+
const FRAMEWORK_LABEL = {
|
|
582
|
+
next: 'Next.js',
|
|
583
|
+
nuxt: 'Nuxt',
|
|
584
|
+
remix: 'Remix',
|
|
585
|
+
sveltekit: 'SvelteKit',
|
|
586
|
+
gatsby: 'Gatsby',
|
|
587
|
+
angular: 'Angular',
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const PHASES = [
|
|
591
|
+
{ key: 'clean', label: 'Before any choice' },
|
|
592
|
+
{ key: 'grant', label: 'Accept' },
|
|
593
|
+
{ key: 'reject', label: 'Reject' },
|
|
594
|
+
];
|
|
595
|
+
|
|
596
|
+
const METHOD_LABEL = {
|
|
597
|
+
'google-tag-manager': 'Google Tag Manager',
|
|
598
|
+
'hardcoded-and-tag-manager': 'GTM + page snippet',
|
|
599
|
+
'hardcoded-snippet': 'Page snippet',
|
|
600
|
+
'server-side': 'Server-side',
|
|
601
|
+
'bundled-no-global': 'npm package',
|
|
602
|
+
'not-installed': 'Not installed',
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const METHOD_SUB = {
|
|
606
|
+
'google-tag-manager': 'The loader ships from the site’s own container, so changes are made in GTM rather than in the site code.',
|
|
607
|
+
'hardcoded-and-tag-manager': 'One loader comes from the container and another sits in the page source, so a change may need both a GTM edit and a release.',
|
|
608
|
+
'hardcoded-snippet': 'The snippet sits in the page source, so any change needs a website release.',
|
|
609
|
+
'bundled-no-global': 'The SDK is compiled into the app’s own JavaScript, so any change needs a developer release.',
|
|
610
|
+
'server-side': 'Events are sent from their servers rather than the visitor’s browser.',
|
|
611
|
+
'not-installed': 'Nothing was found in the page, on the wire, or in the tag manager container.',
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Reader-facing names for the cause catalog. Kept in step with
|
|
616
|
+
* src/analysis/diagnosis/copy.en.ts — a missing id falls back to the id
|
|
617
|
+
* itself, so a newly added cause renders readably before it is listed here.
|
|
618
|
+
*/
|
|
619
|
+
const CAUSE_LABELS = {
|
|
620
|
+
'page-unreadable': 'Site blocked our browser',
|
|
621
|
+
'third-party-segment': 'Runs Segment, not Dreamdata',
|
|
622
|
+
'banner-undrivable': 'Banner couldn’t be answered',
|
|
623
|
+
'not-installed': 'No tracking script installed',
|
|
624
|
+
'requests-never-reach-dreamdata': 'Nothing reaches Dreamdata',
|
|
625
|
+
'cookieless-not-installed': 'No cookieless tracking',
|
|
626
|
+
'cookieless-shares-normal-gate': 'Cookieless blocked by consent',
|
|
627
|
+
'consent-not-persisted-across-reload': 'Consent lost on reload',
|
|
628
|
+
'consent-mode-not-wired': 'Consent Mode not wired up',
|
|
629
|
+
'consent-mode-never-called': 'Consent Mode not enabled',
|
|
630
|
+
'consent-mode-set-late': 'Consent platform loads too late',
|
|
631
|
+
'consent-gate-wrong-signal': 'Tag gated on a signal nothing grants',
|
|
632
|
+
'normal-bundle-never-fires': 'Consented visitors not tracked',
|
|
633
|
+
'router-no-page-call': 'SPA: only the first page tracked',
|
|
634
|
+
'entry-page-only': 'Only the landing page tracked',
|
|
635
|
+
'consent-default-not-denied': 'Tracks before the visitor answers',
|
|
636
|
+
'cmp-signal-not-emitted': 'Banner choice never passed on',
|
|
637
|
+
'combined-tag-gate-failed': 'Script’s consent gate did not hold',
|
|
638
|
+
'no-consent-gate-on-bundle': 'Script ignores the consent choice',
|
|
639
|
+
'navigation-treated-as-consent': 'Navigating counted as consent',
|
|
640
|
+
'reject-writes-identity': 'Identity cookies set after refusal',
|
|
641
|
+
'duplicate-two-builds': 'Two script builds, double counting',
|
|
642
|
+
'duplicate-replayed-buffer': 'Events delivered twice',
|
|
643
|
+
'duplicate-same-build': 'Snippet installed twice',
|
|
644
|
+
'snippet-order': 'Bundles load in the wrong order',
|
|
645
|
+
'refusal-not-passed-to-consent-mode': 'Refusal never reaches Consent Mode',
|
|
646
|
+
'consent-mode-v1-only': 'Consent Mode v1, not v2',
|
|
647
|
+
'consent-mode-region-shadowed': 'Permissive consent default per region',
|
|
648
|
+
'consent-gate-broad-signal': 'Tag gated on the wrong consent signal',
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const GRID_COLUMNS = [
|
|
652
|
+
{ key: 'bundle', label: 'Script in page' },
|
|
653
|
+
{ key: 'load', label: 'Event on load' },
|
|
654
|
+
{ key: 'nav', label: 'Event after nav' },
|
|
655
|
+
{ key: 'identity', label: 'Visitor ID' },
|
|
656
|
+
{ key: 'consent', label: 'Consent Mode' },
|
|
657
|
+
];
|
|
658
|
+
|
|
659
|
+
const STATUS_RANK = { fail: 0, warn: 1, inconclusive: 2, pass: 3, not_applicable: 4 };
|
|
660
|
+
const STATUS_COLOR = {
|
|
661
|
+
pass: '#238551', fail: '#cc301a', warn: '#f59f19', inconclusive: '#f59f19',
|
|
662
|
+
not_applicable: '#cbd5e1', missing: '#eef1f5', info: '#a3aed0',
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
function esc(s) {
|
|
668
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
|
|
669
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function host(url) {
|
|
673
|
+
try { return new URL(url).hostname.replace(/^www\./, ''); } catch (e) { return url || '—'; }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function when(ts) {
|
|
677
|
+
if (!ts) return '—';
|
|
678
|
+
const d = new Date(ts);
|
|
679
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
|
|
680
|
+
d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function libraries(report) {
|
|
684
|
+
const out = [];
|
|
685
|
+
for (const p of report.phases || []) {
|
|
686
|
+
for (const l of (p.network && p.network.observedLibraries) || []) if (!out.includes(l)) out.push(l);
|
|
687
|
+
}
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function bundleNames(report) {
|
|
692
|
+
const dep = report.deployment || {};
|
|
693
|
+
const inst = dep.install || {};
|
|
694
|
+
const set = new Set([].concat(inst.domBundles || [], inst.tagManagerBundles || []));
|
|
695
|
+
if (dep.normalBundleConfigured) set.add('normal');
|
|
696
|
+
if (dep.cookielessBundleConfigured) set.add('cookieless');
|
|
697
|
+
const libs = libraries(report);
|
|
698
|
+
if (libs.some((l) => /-cl|cookieless/i.test(l))) set.add('cookieless');
|
|
699
|
+
if (libs.some((l) => /^analytics v[23]/i.test(l))) set.add('normal');
|
|
700
|
+
const v3 = libs.some((l) => /v3/.test(l));
|
|
701
|
+
const names = [];
|
|
702
|
+
if (set.has('normal')) names.push(v3 ? 'Future analytics v3.0' : 'Analytics v2.0@next');
|
|
703
|
+
if (set.has('cookieless')) names.push('Cookieless analytics v2.0@next');
|
|
704
|
+
return names;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function pill(tag, big) {
|
|
708
|
+
const kind = tag.good ? 'good' : tag.bad ? 'bad' : tag.warn ? 'warn' : 'neutral';
|
|
709
|
+
return `<span class="pill ${big ? 'lg ' : ''}${kind}" title="${esc(tag.title || '')}">${esc(tag.label)}</span>`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Models ───────────────────────────────────────────────────────────
|
|
713
|
+
|
|
714
|
+
function tagsFor(report) {
|
|
715
|
+
const dep = report.deployment || {};
|
|
716
|
+
const causes = (dep.diagnosis || {}).causes || [];
|
|
717
|
+
if (causes.length) {
|
|
718
|
+
return causes.slice(0, 2).map((c) => ({
|
|
719
|
+
label: CAUSE_LABELS[c.id] || c.id,
|
|
720
|
+
title: c.problem || c.id,
|
|
721
|
+
bad: c.kind !== 'hardening' && c.kind !== 'not-a-verdict',
|
|
722
|
+
warn: c.kind === 'hardening',
|
|
723
|
+
neutral: c.kind === 'not-a-verdict',
|
|
724
|
+
good: false,
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
if ((dep.checks || []).some((c) => c.status === 'fail')) {
|
|
728
|
+
return [{ label: 'Failures with no known cause', title: 'Checks failed but no catalogued cause explains them', bad: true }];
|
|
729
|
+
}
|
|
730
|
+
return [{ label: 'Tracking is healthy', title: 'Every check passed for the way this site is set up', good: true }];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The verdict line at the top of the page: a tone, a headline, one sentence,
|
|
735
|
+
* and the three counts worth seeing without opening anything. Every value is
|
|
736
|
+
* read off the report — no wording is chosen here that the checks and the
|
|
737
|
+
* cause catalog have not already decided.
|
|
738
|
+
*/
|
|
739
|
+
function healthModel(report, dx) {
|
|
740
|
+
const dep = report.deployment || {};
|
|
741
|
+
const checks = dep.checks || [];
|
|
742
|
+
const passed = checks.filter((c) => c.status === 'pass').length;
|
|
743
|
+
const failed = checks.filter((c) => c.status === 'fail').length;
|
|
744
|
+
const driven = (dep.matrix || []).length;
|
|
745
|
+
const bad = dx.causes.filter((c) => c.bad);
|
|
746
|
+
const warn = dx.causes.filter((c) => c.warn);
|
|
747
|
+
|
|
748
|
+
const tone = bad.length || (failed && !dx.causes.length) ? 'bad' : warn.length ? 'warn' : 'good';
|
|
749
|
+
const headline = bad.length
|
|
750
|
+
? (bad.length === 1 ? 'One thing is breaking tracking' : bad.length + ' things are breaking tracking')
|
|
751
|
+
: failed && !dx.causes.length
|
|
752
|
+
? 'Checks failed with no known cause'
|
|
753
|
+
: warn.length
|
|
754
|
+
? (warn.length === 1 ? 'Nothing is broken — one thing to harden' : 'Nothing is broken — ' + warn.length + ' things to harden')
|
|
755
|
+
: dx.causes.length
|
|
756
|
+
? 'Not a verdict about this site'
|
|
757
|
+
: 'Tracking is healthy';
|
|
758
|
+
const sub = bad.length
|
|
759
|
+
? bad[0].problem
|
|
760
|
+
: failed && !dx.causes.length
|
|
761
|
+
? failed + ' check' + (failed === 1 ? '' : 's') + ' failed, but no catalogued cause explains them — worth a look by hand.'
|
|
762
|
+
: warn.length
|
|
763
|
+
? warn[0].problem
|
|
764
|
+
: dx.causes.length
|
|
765
|
+
? dx.causes[0].problem
|
|
766
|
+
: 'Every check passed for the way this site is set up, so there is nothing for the team to change right now.';
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
tone,
|
|
770
|
+
headline,
|
|
771
|
+
sub,
|
|
772
|
+
stats: [
|
|
773
|
+
{ value: passed + '/' + checks.length, label: 'Checks passed' },
|
|
774
|
+
{ value: String(dx.causes.length), label: dx.causes.length === 1 ? 'Cause' : 'Causes' },
|
|
775
|
+
{ value: String(driven), label: 'States tested' },
|
|
776
|
+
],
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function diagnosisModel(report) {
|
|
781
|
+
const dep = report.deployment || {};
|
|
782
|
+
const dx = dep.diagnosis || {};
|
|
783
|
+
const list = dx.causes || [];
|
|
784
|
+
const causes = list.map((c, i) => {
|
|
785
|
+
const url = typeof c.docUrl === 'string' && c.docUrl.startsWith('https://developer.dreamdata.io/') ? c.docUrl : null;
|
|
786
|
+
return {
|
|
787
|
+
rank: list.length > 1 ? String(i + 1) : '',
|
|
788
|
+
label: CAUSE_LABELS[c.id] || c.id,
|
|
789
|
+
bad: c.kind !== 'hardening' && c.kind !== 'not-a-verdict',
|
|
790
|
+
warn: c.kind === 'hardening',
|
|
791
|
+
neutral: c.kind === 'not-a-verdict',
|
|
792
|
+
problem: c.problem || '',
|
|
793
|
+
fix: c.fix || '',
|
|
794
|
+
docUrl: url,
|
|
795
|
+
docTitle: c.docTitle || 'Dreamdata docs',
|
|
796
|
+
};
|
|
797
|
+
});
|
|
798
|
+
const everyHardening = causes.length > 0 && causes.every((c) => c.warn);
|
|
799
|
+
const onlyNotAVerdict = causes.length > 0 && causes.every((c) => c.neutral);
|
|
800
|
+
// With no causes the disclosure still has to say something true, and "nothing
|
|
801
|
+
// is wrong" is only true when nothing failed either — a run with failures the
|
|
802
|
+
// catalog cannot name must not read as a clean bill of health.
|
|
803
|
+
const failed = (dep.checks || []).filter((c) => c.status === 'fail').length;
|
|
804
|
+
return {
|
|
805
|
+
head: onlyNotAVerdict ? 'Not a verdict about this site'
|
|
806
|
+
: everyHardening ? 'Nothing is broken — one thing to harden'
|
|
807
|
+
: causes.length ? 'What is wrong, and what to do about it'
|
|
808
|
+
: failed ? 'Which checks failed without a named cause'
|
|
809
|
+
: 'Nothing is wrong with this install',
|
|
810
|
+
causes,
|
|
811
|
+
cleanNote: causes.length === 0
|
|
812
|
+
? (failed
|
|
813
|
+
? failed + ' check' + (failed === 1 ? '' : 's') + ' failed, but no catalogued cause explains ' +
|
|
814
|
+
(failed === 1 ? 'it' : 'them') + '. Open the question cards below to read the failing checks and their evidence.'
|
|
815
|
+
: 'Every check passed for the way this site is set up, so there is nothing for the team to change right now.')
|
|
816
|
+
: '',
|
|
817
|
+
also: (dx.alsoFired || []).length
|
|
818
|
+
? 'Also found, outside the top causes: ' + dx.alsoFired.map((id) => CAUSE_LABELS[id] || id).join('; ') + '.'
|
|
819
|
+
: '',
|
|
820
|
+
advice: (report.result || {}).remediationAdvice || '',
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Which question cards (and the pageview card) have a diagnosis cause
|
|
826
|
+
* pointing into their evidence, so the card can carry a small warning/error
|
|
827
|
+
* mark instead of making the reader open every card to find the one the
|
|
828
|
+
* diagnosis panel is actually talking about. Driven off the same
|
|
829
|
+
* `explains` list the diagnosis panel already carries — CHECK_MAP is the
|
|
830
|
+
* one place that already says which checks belong to which card.
|
|
831
|
+
*/
|
|
832
|
+
function causeFlagsForQuestions(report) {
|
|
833
|
+
const dep = report.deployment || {};
|
|
834
|
+
const causes = (dep.diagnosis || {}).causes || [];
|
|
835
|
+
const flags = {};
|
|
836
|
+
for (const c of causes) {
|
|
837
|
+
const bad = c.kind !== 'hardening' && c.kind !== 'not-a-verdict';
|
|
838
|
+
const warn = c.kind === 'hardening';
|
|
839
|
+
if (!bad && !warn) continue;
|
|
840
|
+
const explains = c.explains || [];
|
|
841
|
+
if (!explains.length) continue;
|
|
842
|
+
const label = CAUSE_LABELS[c.id] || c.id;
|
|
843
|
+
for (const qId of Object.keys(CHECK_MAP)) {
|
|
844
|
+
if (!CHECK_MAP[qId].some((id) => explains.includes(id))) continue;
|
|
845
|
+
const cur = flags[qId] || { bad: false, warn: false, labels: [] };
|
|
846
|
+
cur.bad = cur.bad || bad;
|
|
847
|
+
cur.warn = cur.warn || warn;
|
|
848
|
+
cur.labels.push(label);
|
|
849
|
+
flags[qId] = cur;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return flags;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function flagIcon(flag) {
|
|
856
|
+
if (!flag) return '';
|
|
857
|
+
const cls = flag.bad ? 'bad' : 'warn';
|
|
858
|
+
const mark = flag.bad ? '✕' : '!';
|
|
859
|
+
return `<span class="flag-icon ${cls}" title="${esc(flag.labels.join('; '))}">${mark}</span>`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function consentShape(report) {
|
|
863
|
+
const dep = report.deployment || {};
|
|
864
|
+
const cmp = dep.cmp || {};
|
|
865
|
+
const driven = (cmp.drivenPhases || []).length > 0;
|
|
866
|
+
return {
|
|
867
|
+
collects: !!cmp.detected,
|
|
868
|
+
usable: driven || !cmp.detected,
|
|
869
|
+
neverDriven: !!cmp.detected && !driven,
|
|
870
|
+
installed: bundleNames(report).length > 0 || !!dep.normalBundleConfigured || !!dep.cookielessBundleConfigured,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Whether an unconsented state (clean/reject) is on the hook for a
|
|
876
|
+
* cookieless page view, per the backend's own cookieless-covers-unconsented
|
|
877
|
+
* check — not_applicable means the account deliberately ships no cookieless
|
|
878
|
+
* bundle (its container says so), which is a design choice, not a gap. Only
|
|
879
|
+
* "fail" or "pass" means this phase is actually held to a cookieless event.
|
|
880
|
+
*/
|
|
881
|
+
function cookielessVerdict(phaseKey, dep) {
|
|
882
|
+
const check = (dep.checks || []).find((c) => c.id === 'cookieless-covers-unconsented');
|
|
883
|
+
if (!check || !(check.phases || []).includes(phaseKey)) return null;
|
|
884
|
+
if (check.status === 'not_applicable') return 'not-required';
|
|
885
|
+
if (check.status === 'inconclusive') return 'not-testable';
|
|
886
|
+
return 'required';
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function expectedFor(phaseKey, shape, dep) {
|
|
890
|
+
if (!shape.installed) return 'No event';
|
|
891
|
+
if (!shape.collects) return '1 normal';
|
|
892
|
+
if (phaseKey === 'grant') return '1 normal';
|
|
893
|
+
if (!shape.usable) return 'Not testable';
|
|
894
|
+
const verdict = cookielessVerdict(phaseKey, dep);
|
|
895
|
+
// A banner exists and this state has not granted consent, so a cookieless
|
|
896
|
+
// page view is what compliant tracking looks like here by default — no
|
|
897
|
+
// state should ever have bare "nothing" as its expected outcome. The one
|
|
898
|
+
// exception is an account explicitly built without a cookieless bundle
|
|
899
|
+
// (verdict 'not-required'), which is a coverage decision the check itself
|
|
900
|
+
// already names, not a silent expectation of nothing.
|
|
901
|
+
if (verdict === 'not-required') return 'Not required';
|
|
902
|
+
if (verdict === 'not-testable') return 'Not testable';
|
|
903
|
+
return '1 cookieless';
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function observedCell(counts, expectNormal, expectCookieless, blank) {
|
|
907
|
+
if (blank) return { kind: 'blank', text: 'Not tested', note: 'banner could not be driven' };
|
|
908
|
+
const n = counts.normal || 0;
|
|
909
|
+
const c = counts.cookieless || 0;
|
|
910
|
+
const parts = [];
|
|
911
|
+
if (n) parts.push(n + ' normal');
|
|
912
|
+
if (c) parts.push(c + ' cookieless');
|
|
913
|
+
const text = parts.length ? parts.join(' + ') : 'No event';
|
|
914
|
+
let ok;
|
|
915
|
+
if (expectCookieless) ok = c > 0;
|
|
916
|
+
else if (expectNormal) ok = n > 0;
|
|
917
|
+
else ok = true;
|
|
918
|
+
return { kind: ok ? 'ok' : 'off', text };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function pageviewModel(report) {
|
|
922
|
+
const dep = report.deployment || {};
|
|
923
|
+
const shape = consentShape(report);
|
|
924
|
+
const byPhase = {};
|
|
925
|
+
for (const m of dep.matrix || []) byPhase[m.phase] = m;
|
|
926
|
+
|
|
927
|
+
const rule = !shape.installed
|
|
928
|
+
? 'No Dreamdata script is installed on this site, so no page view can fire in any state. Nothing here is a tracking fault — the install has to happen first.'
|
|
929
|
+
: !shape.usable
|
|
930
|
+
? 'The consent banner was found but this run never managed to open it, so only the untouched first load could be judged. The Accept and Reject columns are left blank on purpose — they were never observed.'
|
|
931
|
+
: !shape.collects
|
|
932
|
+
? 'No consent banner was found on this site, so nothing gates the tracking — we expect a normal page view in every state, before and after any choice.'
|
|
933
|
+
: 'This site collects consent, so before a choice and after a refusal we expect a cookieless page view, and a normal one once consent is granted — unless the account is deliberately built without a cookieless bundle, in which case those states are marked not required rather than expected to record nothing.';
|
|
934
|
+
|
|
935
|
+
const rows = PHASES.map((p) => {
|
|
936
|
+
const m = byPhase[p.key];
|
|
937
|
+
const blank = !m || (p.key !== 'clean' && shape.neverDriven);
|
|
938
|
+
const verdict = p.key !== 'grant' ? cookielessVerdict(p.key, dep) : null;
|
|
939
|
+
const expectCookieless = shape.installed && shape.collects && p.key !== 'grant' && verdict !== 'not-required' && verdict !== 'not-testable';
|
|
940
|
+
const expectNormal = shape.installed && (p.key === 'grant' || !shape.collects);
|
|
941
|
+
const sp = (m && m.secondPage) || null;
|
|
942
|
+
const cells = [
|
|
943
|
+
observedCell({ normal: m ? m.normalRequests : 0, cookieless: m ? m.cookielessRequests : 0 },
|
|
944
|
+
expectNormal, expectCookieless, blank),
|
|
945
|
+
sp && sp.observed
|
|
946
|
+
? observedCell({ normal: sp.normalRequests, cookieless: sp.cookielessRequests },
|
|
947
|
+
expectNormal, expectCookieless, blank)
|
|
948
|
+
: { kind: 'blank', text: blank ? 'Phase not tested' : 'No reachable link' },
|
|
949
|
+
(function () {
|
|
950
|
+
const ids = (m && m.identityCookiesSet) || [];
|
|
951
|
+
if (blank) return { kind: 'blank', text: 'Not tested' };
|
|
952
|
+
if (p.key === 'grant') return ids.length ? { kind: 'ok', text: ids.join(', ') } : { kind: 'off', text: 'Nothing stored' };
|
|
953
|
+
return ids.length ? { kind: 'off', text: ids.join(', '), note: 'written without consent' } : { kind: 'ok', text: 'Nothing stored' };
|
|
954
|
+
})(),
|
|
955
|
+
];
|
|
956
|
+
return { label: p.label, expected: expectedFor(p.key, shape, dep), cells };
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
const dupe = (dep.checks || []).find((c) => c.id === 'page-view-counted-once');
|
|
960
|
+
return {
|
|
961
|
+
answer: (function () {
|
|
962
|
+
const off = rows.some((r) => r.cells.some((c) => c.kind === 'off'));
|
|
963
|
+
if (!shape.installed) return 'No — no script installed';
|
|
964
|
+
if (!shape.usable) return 'Partly tested — the banner never opened';
|
|
965
|
+
return off ? 'No' : 'Yes';
|
|
966
|
+
})(),
|
|
967
|
+
rule,
|
|
968
|
+
rows,
|
|
969
|
+
callout: dupe && (dupe.status === 'fail' || dupe.status === 'warn') ? dupe.detail : '',
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* "Is Google Consent Mode v2 enabled?"
|
|
975
|
+
*
|
|
976
|
+
* Rendered only when the page carries a Google tag, which is the same gate
|
|
977
|
+
* consentModeV2Check applies (sawGoogleTag) and deliberately NOT "was
|
|
978
|
+
* Dreamdata installed through GTM". The two are different sets and the
|
|
979
|
+
* difference decides real reports: fama.io installs the snippet directly in
|
|
980
|
+
* its HTML and Consent Mode never being called is its entire diagnosis, while
|
|
981
|
+
* sovos.com is a tag-manager install with no gtag.js on the page at all, so
|
|
982
|
+
* there is nothing to ask about. Gating on install method would hide the card
|
|
983
|
+
* on the first and show an unanswerable one on the second.
|
|
984
|
+
*/
|
|
985
|
+
function consentModeModel(report) {
|
|
986
|
+
const dep = report.deployment || {};
|
|
987
|
+
const v = dep.consentModeV2;
|
|
988
|
+
const phases = report.phases || [];
|
|
989
|
+
const sawGoogleTag = phases.some((p) => p.consent && p.consent.googleTagDataExists);
|
|
990
|
+
if (!v || !sawGoogleTag) return null;
|
|
991
|
+
|
|
992
|
+
const ANSWERS = {
|
|
993
|
+
'enabled': 'Enabled',
|
|
994
|
+
'enabled-not-yet-answered': 'Enabled, not answered yet',
|
|
995
|
+
'misconfigured': 'Misconfigured',
|
|
996
|
+
'not-installed': 'Not installed',
|
|
997
|
+
'unreadable': 'Unreadable',
|
|
998
|
+
};
|
|
999
|
+
const CAUSE_SENTENCE = {
|
|
1000
|
+
'no-consent-mode': 'A Google tag created the consent signal entries itself, which is why analytics_storage looks readable — but no consent platform ever set one, so a tag waiting on the visitor’s choice waits forever.',
|
|
1001
|
+
'v1-only': 'Running v1: the two advertising signals v2 added (ad_user_data, ad_personalization) are never declared. Analytics consent still works.',
|
|
1002
|
+
'not-yet-answered': 'No consent update was applied during this run, which is the correct and normal state for a visitor who has not answered the banner.',
|
|
1003
|
+
'region-shadowed-default': 'The consent platform allows analytics storage globally and blocks it per region. Google applies the right one, but anything reading the dataLayer commands directly sees the permissive value and takes it for a decision.',
|
|
1004
|
+
'wait-period-timed-out': 'A wait_for_update window closed with no answer in it, so tags fell back to the declared defaults rather than the visitor’s choice.',
|
|
1005
|
+
'set-late': 'Consent was set after tags had already run, so the tags that fire first see no consent state at all.',
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
// The settled read of whichever phase has one; these flags do not differ
|
|
1009
|
+
// between phases except for wasSetLate, which the verdict already folds in.
|
|
1010
|
+
const ics = (phases.map((p) => p.consent && p.consent.ics).filter(Boolean))[0] || null;
|
|
1011
|
+
const chips = [];
|
|
1012
|
+
if (v.version === 'v2' || v.version === 'v1') chips.push('Consent Mode ' + v.version);
|
|
1013
|
+
if (ics) {
|
|
1014
|
+
chips.push('ics.active: ' + ics.active);
|
|
1015
|
+
chips.push('usedUpdate: ' + ics.usedUpdate);
|
|
1016
|
+
if (ics.wasSetLate === true) chips.push('wasSetLate: true');
|
|
1017
|
+
if (ics.waitPeriodTimedOut === true) chips.push('waitPeriodTimedOut: true');
|
|
1018
|
+
chips.push(ics.entryKeys.length + ' signal' + (ics.entryKeys.length === 1 ? '' : 's') + ' declared');
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const sentences = (v.causes || []).map((id) => CAUSE_SENTENCE[id]).filter(Boolean);
|
|
1022
|
+
return {
|
|
1023
|
+
id: 'consentmode',
|
|
1024
|
+
label: 'Consent Mode v2',
|
|
1025
|
+
question: 'Is Google Consent Mode v2 enabled?',
|
|
1026
|
+
answer: ANSWERS[v.outcome] || v.outcome,
|
|
1027
|
+
sub: sentences.length
|
|
1028
|
+
? sentences.join(' ')
|
|
1029
|
+
: 'Both signals v2 added are declared and gtag(‘consent’, …) is in use.',
|
|
1030
|
+
chips,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function questionModels(report) {
|
|
1035
|
+
const dep = report.deployment || {};
|
|
1036
|
+
const inst = dep.install || {};
|
|
1037
|
+
const cmp = dep.cmp || {};
|
|
1038
|
+
const routing = dep.routing || {};
|
|
1039
|
+
const libs = libraries(report);
|
|
1040
|
+
const bundles = bundleNames(report);
|
|
1041
|
+
const probe = report.httpProbe || {};
|
|
1042
|
+
const markers = [...new Set([].concat(routing.frameworkMarkers || [], probe.frameworkMarkers || []))];
|
|
1043
|
+
const unobserved = !routing.mode || routing.mode === 'unobserved' || routing.observed === false;
|
|
1044
|
+
const spa = !unobserved && routing.mode !== 'document-loads';
|
|
1045
|
+
const framework = markers.length ? (FRAMEWORK_LABEL[markers[0]] || markers[0]) : '';
|
|
1046
|
+
|
|
1047
|
+
const cmpMarkers = probe.cmpMarkers || [];
|
|
1048
|
+
const driven = (cmp.drivenPhases || []).length > 0;
|
|
1049
|
+
const cmpAnswer = cmp.detected
|
|
1050
|
+
? (cmp.vendorConfirmed && cmp.vendor ? vendorLabel(cmp.vendor) : 'Banner exists, unknown vendor')
|
|
1051
|
+
: 'No banner';
|
|
1052
|
+
const cmpSub = cmp.detected
|
|
1053
|
+
? (!driven
|
|
1054
|
+
? 'The banner was found but this run never clicked through it, so its Accept and Reject behaviour is untested.'
|
|
1055
|
+
: cmp.vendorConfirmed && cmp.vendor
|
|
1056
|
+
? 'The banner was found and driven in both directions.'
|
|
1057
|
+
: 'The banner was matched by its Accept / Reject button text only, so the vendor is unnamed.' +
|
|
1058
|
+
(cmpMarkers.length ? ' Page markers hint at ' + cmpMarkers.join(', ') + '.' : ''))
|
|
1059
|
+
: 'Nothing on the paths we walked asked the visitor for a choice.';
|
|
1060
|
+
|
|
1061
|
+
const cards = [
|
|
1062
|
+
{
|
|
1063
|
+
id: 'script',
|
|
1064
|
+
label: 'Script installed',
|
|
1065
|
+
question: 'What script does this website install?',
|
|
1066
|
+
answer: bundles.length ? bundles.join(' + ') : 'Not installed',
|
|
1067
|
+
sub: bundles.length
|
|
1068
|
+
? (libs.length ? 'Live events reported the build ' + libs.join(', ') + '.' : 'The script is configured, but no event named a build during this check.')
|
|
1069
|
+
: (dep.shape === 'undetermined'
|
|
1070
|
+
? 'Nothing was found in the page, on the wire, or in the tag manager — but the banner never opened, so a script that only loads after consent cannot be ruled out.'
|
|
1071
|
+
: 'Nothing was found in the page, on the wire, or in the tag manager container.'),
|
|
1072
|
+
chips: [],
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
id: 'install',
|
|
1076
|
+
label: 'Install method',
|
|
1077
|
+
question: 'How is that script installed?',
|
|
1078
|
+
answer: METHOD_LABEL[inst.method] || inst.label || 'Install method unclear',
|
|
1079
|
+
sub: METHOD_SUB[inst.method] || '',
|
|
1080
|
+
chips: (inst.tagManagerContainerIds || []).slice(0, 4),
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
id: 'tech',
|
|
1084
|
+
label: 'Site type',
|
|
1085
|
+
question: 'What is the website built on?',
|
|
1086
|
+
answer: unobserved
|
|
1087
|
+
? 'Not observed'
|
|
1088
|
+
: spa
|
|
1089
|
+
? 'Single-page app' + (framework ? ' – ' + framework : '')
|
|
1090
|
+
: 'Classic',
|
|
1091
|
+
sub: (routing.reason || '') + (markers.length ? ' Framework fingerprints: ' + markers.join(', ') + '.' : ' No framework fingerprint was found in the page source.'),
|
|
1092
|
+
chips: [],
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
id: 'cmp',
|
|
1096
|
+
label: 'Cookie banner',
|
|
1097
|
+
question: 'Do they have a cookie banner? Which vendor?',
|
|
1098
|
+
answer: cmpAnswer,
|
|
1099
|
+
sub: cmpSub,
|
|
1100
|
+
chips: [],
|
|
1101
|
+
},
|
|
1102
|
+
];
|
|
1103
|
+
|
|
1104
|
+
const consentmode = consentModeModel(report);
|
|
1105
|
+
if (consentmode) cards.push(consentmode);
|
|
1106
|
+
return cards;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function gridCheckIds(phase, col) {
|
|
1110
|
+
if (col === 'bundle') return [];
|
|
1111
|
+
if (col === 'load') {
|
|
1112
|
+
if (phase === 'clean') return ['clean-before-consent', 'cookieless-covers-unconsented'];
|
|
1113
|
+
if (phase === 'grant') return ['grant-enables-normal', 'page-view-counted-once'];
|
|
1114
|
+
return ['reject-blocks-normal', 'cookieless-covers-unconsented'];
|
|
1115
|
+
}
|
|
1116
|
+
if (col === 'nav') {
|
|
1117
|
+
const ids = ['second-page-keeps-tracking', 'soft-nav-keeps-tracking'];
|
|
1118
|
+
if (phase !== 'grant') ids.push('second-page-honours-consent');
|
|
1119
|
+
return ids;
|
|
1120
|
+
}
|
|
1121
|
+
if (col === 'identity') {
|
|
1122
|
+
if (phase === 'clean') return ['clean-before-consent'];
|
|
1123
|
+
if (phase === 'grant') return ['grant-sets-identity-cookies', 'anonymous-id-stable'];
|
|
1124
|
+
return ['reject-writes-no-identity'];
|
|
1125
|
+
}
|
|
1126
|
+
// The consent column carries the v2 check too: a phase whose
|
|
1127
|
+
// analytics_storage reads correctly while nothing ever set it is exactly
|
|
1128
|
+
// the case the other two checks cannot see.
|
|
1129
|
+
return phase === 'clean'
|
|
1130
|
+
? ['clean-consent-mode-default', 'consent-mode-v2-enabled']
|
|
1131
|
+
: ['consent-mode-signal', 'consent-mode-v2-enabled'];
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function gridModel(report) {
|
|
1135
|
+
const dep = report.deployment || {};
|
|
1136
|
+
const byId = {};
|
|
1137
|
+
for (const c of dep.checks || []) byId[c.id] = c;
|
|
1138
|
+
const byPhase = {};
|
|
1139
|
+
for (const m of dep.matrix || []) byPhase[m.phase] = m;
|
|
1140
|
+
const worst = (list) => {
|
|
1141
|
+
const known = list.filter((s) => s in STATUS_RANK);
|
|
1142
|
+
if (!known.length) return 'info';
|
|
1143
|
+
return known.sort((a, b) => STATUS_RANK[a] - STATUS_RANK[b])[0];
|
|
1144
|
+
};
|
|
1145
|
+
const reqText = (n, c) => {
|
|
1146
|
+
const parts = [];
|
|
1147
|
+
if (n) parts.push(n + ' normal');
|
|
1148
|
+
if (c) parts.push(c + ' cookieless');
|
|
1149
|
+
return parts.length ? parts.join(' + ') : 'none';
|
|
1150
|
+
};
|
|
1151
|
+
const rows = PHASES.map((p) => {
|
|
1152
|
+
const m = byPhase[p.key];
|
|
1153
|
+
const cells = GRID_COLUMNS.map((col) => {
|
|
1154
|
+
let status = 'missing';
|
|
1155
|
+
let text = 'not observed';
|
|
1156
|
+
if (m) {
|
|
1157
|
+
const relevant = gridCheckIds(p.key, col.key)
|
|
1158
|
+
.map((id) => byId[id])
|
|
1159
|
+
.filter(Boolean)
|
|
1160
|
+
.filter((c) => !Array.isArray(c.phases) || c.phases.includes(p.key));
|
|
1161
|
+
status = col.key === 'bundle' ? 'info' : worst(relevant.map((c) => c.status));
|
|
1162
|
+
if (col.key === 'bundle') {
|
|
1163
|
+
const names = [];
|
|
1164
|
+
if (m.normalBundleInDom) names.push('normal');
|
|
1165
|
+
if (m.cookielessBundleInDom) names.push('cookieless');
|
|
1166
|
+
text = names.length ? names.join(' + ') : 'none';
|
|
1167
|
+
} else if (col.key === 'load') {
|
|
1168
|
+
text = reqText(m.normalRequests, m.cookielessRequests);
|
|
1169
|
+
} else if (col.key === 'nav') {
|
|
1170
|
+
const sp = m.secondPage || {};
|
|
1171
|
+
text = sp.observed ? reqText(sp.normalRequests, sp.cookielessRequests) : 'no second page';
|
|
1172
|
+
} else if (col.key === 'identity') {
|
|
1173
|
+
text = (m.identityCookiesSet || []).join(', ') || 'none';
|
|
1174
|
+
} else {
|
|
1175
|
+
text = (m.analyticsStorage || 'not set') + (m.analyticsStorageSource ? ' (' + m.analyticsStorageSource + ')' : '');
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
status,
|
|
1180
|
+
statusLabel: String(status).replace('_', ' '),
|
|
1181
|
+
text,
|
|
1182
|
+
color: STATUS_COLOR[status] || STATUS_COLOR.info,
|
|
1183
|
+
};
|
|
1184
|
+
});
|
|
1185
|
+
return { label: p.label, cells };
|
|
1186
|
+
});
|
|
1187
|
+
return { columns: GRID_COLUMNS, rows };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* The published GTM container's own account of the install, as a scannable
|
|
1192
|
+
* summary — three gates, per tag, per container.
|
|
1193
|
+
*
|
|
1194
|
+
* This exists because the container is the only place the install's real
|
|
1195
|
+
* shape is written down, and the panel's prose could only fit it as one long
|
|
1196
|
+
* sentence in `install.detail`. verda.com's reads "ships ... from one
|
|
1197
|
+
* combined tag, gated on analytics_storage and blocked whenever CookieConsent
|
|
1198
|
+
* does not contain statistics:true and fired only on the virtualPageView or
|
|
1199
|
+
* cookie_consent_update dataLayer events" — every fact present, none of it
|
|
1200
|
+
* findable at a glance, and the reader still has to know that those are three
|
|
1201
|
+
* independent gates any one of which stops the tag.
|
|
1202
|
+
*
|
|
1203
|
+
* `tone: 'gate'` marks a row that can hold the tag back, so the eye lands on
|
|
1204
|
+
* the gates rather than on the inventory rows next to them. Returns null when
|
|
1205
|
+
* no container was scanned: a hardcoded or npm install has nothing to say
|
|
1206
|
+
* here and an empty card would read as a failed read.
|
|
1207
|
+
*/
|
|
1208
|
+
function gtmModel(report) {
|
|
1209
|
+
const install = (report.deployment || {}).install || {};
|
|
1210
|
+
if (!install.tagManagerScanned) return null;
|
|
1211
|
+
const ids = install.tagManagerContainerIds || [];
|
|
1212
|
+
if (!ids.length) return null;
|
|
1213
|
+
|
|
1214
|
+
const BUNDLE_LABEL = { normal: 'standard (cookie-based)', cookieless: 'cookieless' };
|
|
1215
|
+
const bundles = (list) => (list || []).map((b) => BUNDLE_LABEL[b] || b).join(' + ') || 'none';
|
|
1216
|
+
|
|
1217
|
+
// Evidence archives predate `containers`, and a container that 404s or will
|
|
1218
|
+
// not parse has no tags to describe — both are reported as themselves
|
|
1219
|
+
// rather than silently rendering as "no Dreamdata tag".
|
|
1220
|
+
const scans = install.tagManagerContainers && install.tagManagerContainers.length
|
|
1221
|
+
? install.tagManagerContainers
|
|
1222
|
+
: ids.map((id) => ({ containerId: id, read: null, error: null, version: null, bundles: install.tagManagerBundles, tags: null }));
|
|
1223
|
+
|
|
1224
|
+
const containers = scans.map((c) => {
|
|
1225
|
+
const tags = (c.tags || []).map((t) => {
|
|
1226
|
+
// Absent and empty mean different things: an archive written before
|
|
1227
|
+
// this field existed did not look, while [] means every firing path is
|
|
1228
|
+
// reachable without a custom event. Reporting the first as the second
|
|
1229
|
+
// would state "fires on every page" about verda.com, whose tag fires on
|
|
1230
|
+
// nothing but virtualPageView.
|
|
1231
|
+
const events = t.requiresEvents;
|
|
1232
|
+
const consent = t.consent || [];
|
|
1233
|
+
const blocked = t.blockedWhen || [];
|
|
1234
|
+
return {
|
|
1235
|
+
title: 'Tag ' + t.tagIndex + ' — loads the ' + bundles(t.bundles) + ' bundle' + ((t.bundles || []).length > 1 ? 's' : ''),
|
|
1236
|
+
// One tag has one consent field and one trigger set, so a combined
|
|
1237
|
+
// tag cannot hold the two bundles to opposite consent states however
|
|
1238
|
+
// GTM is configured. Worth saying next to the gates, because it is
|
|
1239
|
+
// why reading them per bundle would be meaningless here.
|
|
1240
|
+
note: (t.bundles || []).length > 1
|
|
1241
|
+
? 'One tag loads both bundles, so no GTM setting can gate them apart — the split falls to the snippet’s own JavaScript.'
|
|
1242
|
+
: '',
|
|
1243
|
+
rows: [
|
|
1244
|
+
{
|
|
1245
|
+
label: 'Fires on',
|
|
1246
|
+
value: !events
|
|
1247
|
+
? 'not recorded — this run’s evidence predates the trigger read; re-run the site to see it'
|
|
1248
|
+
: events.length
|
|
1249
|
+
? 'the ' + events.join(' or ') + ' dataLayer event' + (events.length > 1 ? 's' : '') + ' — and nothing else'
|
|
1250
|
+
: 'a GTM built-in event (page load / DOM ready), which fires on every page by itself',
|
|
1251
|
+
// A custom-event trigger is the gate that breaks silently: the
|
|
1252
|
+
// container keeps reading as healthy after a release stops
|
|
1253
|
+
// pushing the event.
|
|
1254
|
+
tone: events && events.length ? 'gate' : 'plain',
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
label: 'Consent Mode',
|
|
1258
|
+
value: consent.length ? 'requires ' + consent.join(' + ') : 'no consent requirement on the tag',
|
|
1259
|
+
tone: consent.length ? 'gate' : 'plain',
|
|
1260
|
+
},
|
|
1261
|
+
{
|
|
1262
|
+
label: 'Exceptions',
|
|
1263
|
+
value: blocked.length ? 'blocked when ' + blocked.join('; or ') : 'none',
|
|
1264
|
+
tone: blocked.length ? 'gate' : 'plain',
|
|
1265
|
+
},
|
|
1266
|
+
],
|
|
1267
|
+
};
|
|
1268
|
+
});
|
|
1269
|
+
return {
|
|
1270
|
+
containerId: c.containerId,
|
|
1271
|
+
meta: [c.version ? 'version ' + c.version : '', c.read === false ? 'could not be read' : '', c.error || '']
|
|
1272
|
+
.filter(Boolean).join(' · '),
|
|
1273
|
+
ships: bundles(c.bundles),
|
|
1274
|
+
tags,
|
|
1275
|
+
// Distinguishes "read it, no Dreamdata tag in it" from "never got to
|
|
1276
|
+
// look" — checkproof.com loads two containers and only one carries a
|
|
1277
|
+
// tag, credo.ai's second one 404s.
|
|
1278
|
+
empty: c.tags === null
|
|
1279
|
+
? 'This container’s tags were not recorded in this run’s evidence.'
|
|
1280
|
+
: tags.length === 0
|
|
1281
|
+
? (c.read === false ? 'The container could not be read, so nothing is known about its tags.' : 'Read, but it carries no Dreamdata tag.')
|
|
1282
|
+
: '',
|
|
1283
|
+
};
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
return { containers, errors: install.tagManagerErrors || [] };
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function detailModel(report, question) {
|
|
1290
|
+
const dep = report.deployment || {};
|
|
1291
|
+
const res = report.result || {};
|
|
1292
|
+
const titles = {
|
|
1293
|
+
script: 'What script does this website install?',
|
|
1294
|
+
install: 'How is that script installed?',
|
|
1295
|
+
tech: 'What is the website built on?',
|
|
1296
|
+
cmp: 'Do they have a cookie banner?',
|
|
1297
|
+
consentmode: 'Is Google Consent Mode v2 enabled?',
|
|
1298
|
+
pageview: 'Does the page view event fire as expected?',
|
|
1299
|
+
};
|
|
1300
|
+
const leads = {
|
|
1301
|
+
script: 'The answer is read from the tag-manager container scan and the DOM script tags, then corroborated by the library string each live event reported.',
|
|
1302
|
+
install: 'Install method comes from where the loading tag was found: a published tag-manager container, a hardcoded tag in the HTML, a server-side endpoint, or a bundled package with no loader.',
|
|
1303
|
+
tech: 'Routing mode is observed, not guessed: the run follows a same-origin link in each phase and records whether the browser loaded a new document or changed route in place.',
|
|
1304
|
+
cmp: 'A banner is detected by vendor globals and element ids first; when none match, it is matched by Accept / Reject button text — in which case the vendor cannot be named.',
|
|
1305
|
+
consentmode: 'Read from google_tag_data.ics’s own diagnostic flags. ics.active is the only proof gtag(‘consent’, …) was ever called — the signal entries exist as soon as any Google tag loads, so analytics_storage is readable on a page with no Consent Mode at all.',
|
|
1306
|
+
pageview: 'Every phase runs in a fresh browser context. Requests are attributed to the bundle that sent them, so a normal and a cookieless page view are never conflated.',
|
|
1307
|
+
};
|
|
1308
|
+
const byId = {};
|
|
1309
|
+
for (const c of dep.checks || []) byId[c.id] = c;
|
|
1310
|
+
const checks = (CHECK_MAP[question] || []).map((id) => byId[id]).filter(Boolean).map((c) => ({
|
|
1311
|
+
id: c.id, title: c.title, detail: c.detail, evidence: c.evidence,
|
|
1312
|
+
cls: c.status === 'pass' ? 'pass' : c.status === 'fail' ? 'fail' : 'other',
|
|
1313
|
+
statusLabel: String(c.status || '').replace('_', ' ').toUpperCase(),
|
|
1314
|
+
}));
|
|
1315
|
+
const vIds = VERDICT_MAP[question] || [];
|
|
1316
|
+
const verdicts = (res.verdicts || []).filter((v) => vIds.includes(v.id)).map((v) => ({
|
|
1317
|
+
title: v.title, feedback: v.feedback, evidenceQuote: v.evidenceQuote,
|
|
1318
|
+
cls: v.passed === true ? 'pass' : v.passed === false ? 'fail' : 'other',
|
|
1319
|
+
statusLabel: v.passed === true ? 'PASS' : v.passed === false ? 'FAIL' : 'INCONCLUSIVE',
|
|
1320
|
+
}));
|
|
1321
|
+
const rawSlices = {
|
|
1322
|
+
script: { shape: dep.shape, shapeReason: dep.shapeReason, install: dep.install, normalBundleSignals: dep.normalBundleSignals, httpProbeScript: (report.httpProbe || {}).script },
|
|
1323
|
+
install: { install: dep.install, normalBundleSignals: dep.normalBundleSignals },
|
|
1324
|
+
tech: { routing: dep.routing, secondPages: (dep.matrix || []).map((m) => ({ phase: m.phase, secondPage: m.secondPage })) },
|
|
1325
|
+
cmp: { cmp: dep.cmp, cmpMarkers: (report.httpProbe || {}).cmpMarkers, consentPerPhase: (report.phases || []).map((p) => ({ phase: p.phase, consent: p.consent, consentAcceptance: p.consentAcceptance })) },
|
|
1326
|
+
consentmode: {
|
|
1327
|
+
consentModeV2: dep.consentModeV2,
|
|
1328
|
+
perPhase: (report.phases || []).map((p) => ({
|
|
1329
|
+
phase: p.phase,
|
|
1330
|
+
ics: p.consent && p.consent.ics,
|
|
1331
|
+
icsBeforeDecision: p.consent && p.consent.icsBeforeDecision,
|
|
1332
|
+
signals: p.consent && p.consent.signals,
|
|
1333
|
+
dataLayerConsentPushes: p.consent && p.consent.dataLayerConsentPushes,
|
|
1334
|
+
})),
|
|
1335
|
+
},
|
|
1336
|
+
pageview: { matrix: dep.matrix, requests: (report.phases || []).map((p) => ({ phase: p.phase, requests: (p.network && p.network.requests) || [], cookies: p.cookies })) },
|
|
1337
|
+
};
|
|
1338
|
+
// The question cards no longer carry their reasoning sentence or their
|
|
1339
|
+
// evidence chips, so the panel is where that copy lands: "what we expect for
|
|
1340
|
+
// this site" is the card's old sub-line for a question, and the pageview
|
|
1341
|
+
// card's expectation rule for the table.
|
|
1342
|
+
const pv = question === 'pageview' ? pageviewModel(report) : null;
|
|
1343
|
+
const card = pv ? null : questionModels(report).find((q) => q.id === question);
|
|
1344
|
+
return {
|
|
1345
|
+
title: titles[question] || question,
|
|
1346
|
+
lead: leads[question] || '',
|
|
1347
|
+
expectation: pv ? pv.rule : (card && card.sub) || '',
|
|
1348
|
+
chips: (card && card.chips) || [],
|
|
1349
|
+
callout: pv ? pv.callout : '',
|
|
1350
|
+
checks,
|
|
1351
|
+
verdicts,
|
|
1352
|
+
advice: question === 'pageview' || question === 'cmp' || question === 'consentmode' ? res.remediationAdvice || '' : '',
|
|
1353
|
+
rawLabel: 'report.json — the fields behind this answer',
|
|
1354
|
+
raw: JSON.stringify(rawSlices[question], null, 2),
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// ── Rendering ────────────────────────────────────────────────────────
|
|
1359
|
+
|
|
1360
|
+
const state = {
|
|
1361
|
+
slug: REPORTS[0] ? REPORTS[0].slug : null,
|
|
1362
|
+
question: 'pageview',
|
|
1363
|
+
sidebarOpen: true,
|
|
1364
|
+
search: '',
|
|
1365
|
+
newSlug: '',
|
|
1366
|
+
newUrl: '',
|
|
1367
|
+
runNotice: '',
|
|
1368
|
+
runCommand: '',
|
|
1369
|
+
commandCopied: false,
|
|
1370
|
+
commandCopyFailed: false,
|
|
1371
|
+
detailOpen: false,
|
|
1372
|
+
copied: false,
|
|
1373
|
+
copyFailed: false,
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
function currentReport() {
|
|
1377
|
+
return REPORTS.find((r) => r.slug === state.slug) || null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function set(patch) { Object.assign(state, patch); render(); }
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* This page is opened over file://, which is not a secure context in every
|
|
1384
|
+
* browser, so navigator.clipboard can be missing outright and writeText can
|
|
1385
|
+
* reject. Saying "Copied" when nothing was copied is the one failure a reader
|
|
1386
|
+
* cannot notice, so the label only changes once the write actually landed.
|
|
1387
|
+
*/
|
|
1388
|
+
function copyText(text, done) {
|
|
1389
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1390
|
+
navigator.clipboard.writeText(text).then(() => done(true), () => done(legacyCopy(text)));
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
done(legacyCopy(text));
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// The pre-Clipboard-API path, which still works on file://.
|
|
1397
|
+
function legacyCopy(text) {
|
|
1398
|
+
try {
|
|
1399
|
+
const ta = document.createElement('textarea');
|
|
1400
|
+
ta.value = text;
|
|
1401
|
+
ta.style.position = 'fixed';
|
|
1402
|
+
ta.style.opacity = '0';
|
|
1403
|
+
document.body.appendChild(ta);
|
|
1404
|
+
ta.select();
|
|
1405
|
+
const ok = document.execCommand('copy');
|
|
1406
|
+
document.body.removeChild(ta);
|
|
1407
|
+
return ok;
|
|
1408
|
+
} catch {
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function matchingReports() {
|
|
1414
|
+
const search = state.search.trim().toLowerCase();
|
|
1415
|
+
return REPORTS.filter((r) =>
|
|
1416
|
+
r.slug.toLowerCase().includes(search) || String(r.url || '').toLowerCase().includes(search));
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function listHeading() {
|
|
1420
|
+
const n = matchingReports().length;
|
|
1421
|
+
return n + ' account' + (n === 1 ? '' : 's') + ' checked';
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function siteListHtml() {
|
|
1425
|
+
const rows = matchingReports();
|
|
1426
|
+
if (rows.length === 0) return `<div class="empty">No account matches that search.</div>`;
|
|
1427
|
+
return rows.map((r) => {
|
|
1428
|
+
const tags = tagsFor(r);
|
|
1429
|
+
return `<div class="site ${r.slug === state.slug ? 'on' : ''}" data-act="select-site" data-slug="${esc(r.slug)}">
|
|
1430
|
+
<div class="site-top">
|
|
1431
|
+
<div class="site-slug">${esc(r.slug)}</div>
|
|
1432
|
+
<div class="site-host">${esc(host(r.url))}</div>
|
|
1433
|
+
<span class="site-when">${esc(when(r.timestamp))}</span>
|
|
1434
|
+
</div>
|
|
1435
|
+
<div class="site-tags">${pill(tags[0])}${tags.length > 1 ? `<span class="more">+${tags.length - 1}</span>` : ''}</div>
|
|
1436
|
+
</div>`;
|
|
1437
|
+
}).join('');
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function renderSidebar() {
|
|
1441
|
+
if (!state.sidebarOpen) {
|
|
1442
|
+
return `<aside class="side-rail">
|
|
1443
|
+
<button class="icon-btn" data-act="toggle-sidebar" title="Expand panel">»</button>
|
|
1444
|
+
<div class="vert">${esc(listHeading())}</div>
|
|
1445
|
+
</aside>`;
|
|
1446
|
+
}
|
|
1447
|
+
return `<aside class="side">
|
|
1448
|
+
<div class="side-head">
|
|
1449
|
+
<div class="side-title">Tracking verification</div>
|
|
1450
|
+
<button class="icon-btn" data-act="toggle-sidebar" title="Collapse panel">«</button>
|
|
1451
|
+
</div>
|
|
1452
|
+
<div class="side-block">
|
|
1453
|
+
<div class="eyebrow">Run a new check</div>
|
|
1454
|
+
<div class="field"><label for="ns">Account slug</label>
|
|
1455
|
+
<input id="ns" placeholder="dreamdata_io" value="${esc(state.newSlug)}" data-act="new-slug" /></div>
|
|
1456
|
+
<div class="field"><label for="nu">Website URL</label>
|
|
1457
|
+
<input id="nu" placeholder="https://dreamdata.io/" value="${esc(state.newUrl)}" data-act="new-url" /></div>
|
|
1458
|
+
<button class="btn primary full" data-act="run">Build the command</button>
|
|
1459
|
+
${state.runNotice ? `<div class="notice">${state.runNotice}
|
|
1460
|
+
${state.runCommand ? `<code>${esc(state.runCommand)}</code>
|
|
1461
|
+
<button class="btn full" style="margin-top:6px" data-act="copy-command">${state.commandCopied ? 'Copied' : state.commandCopyFailed ? 'Copy it by hand — the browser blocked this' : 'Copy'}</button>` : ''}
|
|
1462
|
+
</div>` : ''}
|
|
1463
|
+
</div>
|
|
1464
|
+
<div class="side-search">
|
|
1465
|
+
<div class="field"><input placeholder="Search a slug or domain" value="${esc(state.search)}" data-act="search" /></div>
|
|
1466
|
+
<div class="eyebrow">${esc(listHeading())}</div>
|
|
1467
|
+
</div>
|
|
1468
|
+
<div class="site-list">${siteListHtml()}</div>
|
|
1469
|
+
</aside>`;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function renderDiagnosis(dx) {
|
|
1473
|
+
const causes = dx.causes.map((c) => `<div class="cause">
|
|
1474
|
+
<div class="cause-top">
|
|
1475
|
+
${c.rank ? `<span class="cause-rank">${esc(c.rank)}</span>` : ''}
|
|
1476
|
+
<span class="cause-tag ${c.bad ? 'bad' : c.warn ? 'warn' : 'neutral'}">${esc(c.label)}</span>
|
|
1477
|
+
</div>
|
|
1478
|
+
<div class="cause-grid">
|
|
1479
|
+
<div class="cause-key problem">Cause</div><div class="cause-val">${esc(c.problem)}</div>
|
|
1480
|
+
<div class="cause-key fix">Fix</div><div class="cause-val">${esc(c.fix)}</div>
|
|
1481
|
+
</div>
|
|
1482
|
+
${c.docUrl ? `<div class="cause-doc"><a href="${esc(c.docUrl)}" target="_blank" rel="noreferrer">${esc(c.docTitle)} ↗</a></div>` : ''}
|
|
1483
|
+
</div>`).join('');
|
|
1484
|
+
|
|
1485
|
+
return `<details class="dx">
|
|
1486
|
+
<summary>${esc(dx.head)} ›</summary>
|
|
1487
|
+
<div class="dx-body">
|
|
1488
|
+
${dx.cleanNote ? `<div class="dx-clean">${esc(dx.cleanNote)}</div>` : ''}
|
|
1489
|
+
${dx.causes.length ? `<div class="dx-causes">${causes}</div>` : ''}
|
|
1490
|
+
${dx.also ? `<div class="dx-also">${esc(dx.also)}</div>` : ''}
|
|
1491
|
+
${dx.advice ? `<details><summary>Verify next ›</summary><div>${esc(dx.advice)}</div></details>` : ''}
|
|
1492
|
+
</div>
|
|
1493
|
+
</details>`;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function renderSummary(health, dx) {
|
|
1497
|
+
return `<div class="summary">
|
|
1498
|
+
<div class="summary-top">
|
|
1499
|
+
<div class="summary-rail ${health.tone}"></div>
|
|
1500
|
+
<div class="summary-main">
|
|
1501
|
+
<div class="summary-head ${health.tone}">${esc(health.headline)}</div>
|
|
1502
|
+
<div class="summary-sub">${esc(health.sub)}</div>
|
|
1503
|
+
</div>
|
|
1504
|
+
<div class="summary-stats">${health.stats.map((s) => `<div class="stat">
|
|
1505
|
+
<div class="stat-value">${esc(s.value)}</div>
|
|
1506
|
+
<div class="stat-label">${esc(s.label)}</div>
|
|
1507
|
+
</div>`).join('')}</div>
|
|
1508
|
+
</div>
|
|
1509
|
+
${renderDiagnosis(dx)}
|
|
1510
|
+
</div>`;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function renderQuestionCard(q, flag) {
|
|
1514
|
+
return `<div class="q ${state.question === q.id ? 'on' : ''}" data-act="select-question" data-q="${esc(q.id)}">
|
|
1515
|
+
<div class="q-row">
|
|
1516
|
+
<div class="q-label">${esc(q.label || q.question)}</div>
|
|
1517
|
+
${flagIcon(flag)}
|
|
1518
|
+
<div class="q-more">Details ›</div>
|
|
1519
|
+
</div>
|
|
1520
|
+
<div class="q-answer">${esc(q.answer)}</div>
|
|
1521
|
+
</div>`;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function renderPageviewCard(pv, flag) {
|
|
1525
|
+
const cell = (c) => `<td class="cell"><div class="obs ${c.kind}">
|
|
1526
|
+
<div class="obs-top">
|
|
1527
|
+
<span class="obs-mark">${c.kind === 'ok' ? '✓' : c.kind === 'off' ? '✕' : '—'}</span>
|
|
1528
|
+
<span class="obs-text">${esc(c.text)}</span>
|
|
1529
|
+
</div>
|
|
1530
|
+
${c.note ? `<div class="obs-note">${esc(c.note)}</div>` : ''}
|
|
1531
|
+
</div></td>`;
|
|
1532
|
+
|
|
1533
|
+
const rows = pv.rows.map((r) => `<tr>
|
|
1534
|
+
<td><div class="pv-label">${esc(r.label)}</div></td>
|
|
1535
|
+
<td><div class="obs exp"><span class="obs-text">${esc(r.expected)}</span></div></td>
|
|
1536
|
+
${r.cells.map(cell).join('')}
|
|
1537
|
+
</tr>`).join('');
|
|
1538
|
+
|
|
1539
|
+
return `<div class="q wide ${state.question === 'pageview' ? 'on' : ''}" data-act="select-question" data-q="pageview">
|
|
1540
|
+
<div class="q-row">
|
|
1541
|
+
<div class="q-answer">${esc(pv.answer)}</div>
|
|
1542
|
+
${flagIcon(flag)}
|
|
1543
|
+
<div class="q-more">Details ›</div>
|
|
1544
|
+
</div>
|
|
1545
|
+
<table class="pv">
|
|
1546
|
+
<thead><tr>
|
|
1547
|
+
<th style="width:18%">Visitor state</th>
|
|
1548
|
+
<th style="width:18%">Expected</th>
|
|
1549
|
+
<th>Landing page</th>
|
|
1550
|
+
<th>Second page</th>
|
|
1551
|
+
<th>Visitor ID</th>
|
|
1552
|
+
</tr></thead>
|
|
1553
|
+
<tbody>${rows}</tbody>
|
|
1554
|
+
</table>
|
|
1555
|
+
</div>`;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function renderDetail(report) {
|
|
1559
|
+
const d = detailModel(report, state.question);
|
|
1560
|
+
const grid = state.question === 'pageview' ? gridModel(report) : null;
|
|
1561
|
+
|
|
1562
|
+
const gridHtml = !grid ? '' : `<div>
|
|
1563
|
+
<div class="d-label">Phase × signal matrix</div>
|
|
1564
|
+
<div class="d-note">The same three visitor states as the card, judged against every signal the checks cover — the square is the worst check status naming that state.</div>
|
|
1565
|
+
<div class="grid-wrap"><table class="mx">
|
|
1566
|
+
<thead><tr><th>Phase</th>${grid.columns.map((c) => `<th>${esc(c.label)}</th>`).join('')}</tr></thead>
|
|
1567
|
+
<tbody>${grid.rows.map((r) => `<tr>
|
|
1568
|
+
<td class="phase">${esc(r.label)}</td>
|
|
1569
|
+
${r.cells.map((c) => `<td>
|
|
1570
|
+
<div class="mx-cell"><i style="background:${esc(c.color)}"></i><span>${esc(c.text)}</span></div>
|
|
1571
|
+
<div class="mx-status">${esc(c.statusLabel)}</div>
|
|
1572
|
+
</td>`).join('')}
|
|
1573
|
+
</tr>`).join('')}</tbody>
|
|
1574
|
+
</table></div>
|
|
1575
|
+
</div>`;
|
|
1576
|
+
|
|
1577
|
+
// Above the lead and above every question's own evidence, because it is the
|
|
1578
|
+
// one block that answers "what did they actually build" for all of them.
|
|
1579
|
+
const gtm = gtmModel(report);
|
|
1580
|
+
const gtmHtml = !gtm ? '' : `<div class="gtm">
|
|
1581
|
+
<div class="gtm-head">
|
|
1582
|
+
<div class="d-label" style="margin:0">GTM setup at a glance</div>
|
|
1583
|
+
<div class="gtm-hint">Read from the published container itself — any one gate below stops the tag</div>
|
|
1584
|
+
</div>
|
|
1585
|
+
${gtm.containers.map((c) => `<div class="gtm-c">
|
|
1586
|
+
<div class="gtm-c-top">
|
|
1587
|
+
<span class="gtm-id">${esc(c.containerId)}</span>
|
|
1588
|
+
${c.meta ? `<span class="gtm-meta">${esc(c.meta)}</span>` : ''}
|
|
1589
|
+
<span class="gtm-ships">ships ${esc(c.ships)}</span>
|
|
1590
|
+
</div>
|
|
1591
|
+
${c.empty ? `<div class="gtm-empty">${esc(c.empty)}</div>` : ''}
|
|
1592
|
+
${c.tags.map((t) => `<div class="gtm-tag">
|
|
1593
|
+
<div class="gtm-tag-title">${esc(t.title)}</div>
|
|
1594
|
+
${t.note ? `<div class="gtm-note">${esc(t.note)}</div>` : ''}
|
|
1595
|
+
<div class="gtm-rows">${t.rows.map((r) => `<div class="gtm-row ${r.tone}">
|
|
1596
|
+
<span class="gtm-k">${esc(r.label)}</span>
|
|
1597
|
+
<span class="gtm-v">${esc(r.value)}</span>
|
|
1598
|
+
</div>`).join('')}</div>
|
|
1599
|
+
</div>`).join('')}
|
|
1600
|
+
</div>`).join('')}
|
|
1601
|
+
${gtm.errors.length ? `<div class="gtm-empty">${esc(gtm.errors.join('; '))}</div>` : ''}
|
|
1602
|
+
</div>`;
|
|
1603
|
+
|
|
1604
|
+
const checks = d.checks.length ? `<div>
|
|
1605
|
+
<div class="d-label">Automated checks</div>
|
|
1606
|
+
<div class="d-stack">${d.checks.map((c) => `<div class="d-card">
|
|
1607
|
+
<div class="d-card-top">
|
|
1608
|
+
<span class="status ${c.cls}">${esc(c.statusLabel)}</span>
|
|
1609
|
+
<div class="d-card-title">${esc(c.title)}</div>
|
|
1610
|
+
</div>
|
|
1611
|
+
<div class="d-card-detail">${esc(c.detail)}</div>
|
|
1612
|
+
<div class="d-evidence">${esc(c.evidence)}</div>
|
|
1613
|
+
<div class="d-id">${esc(c.id)}</div>
|
|
1614
|
+
</div>`).join('')}</div>
|
|
1615
|
+
</div>` : '';
|
|
1616
|
+
|
|
1617
|
+
const verdicts = d.verdicts.length ? `<div>
|
|
1618
|
+
<div class="d-label">AI reviewer verdict</div>
|
|
1619
|
+
<div class="d-stack">${d.verdicts.map((v) => `<div class="d-card">
|
|
1620
|
+
<div class="d-card-top">
|
|
1621
|
+
<span class="status ${v.cls}">${esc(v.statusLabel)}</span>
|
|
1622
|
+
<div class="d-card-title">${esc(v.title)}</div>
|
|
1623
|
+
</div>
|
|
1624
|
+
<div class="d-card-detail">${esc(v.feedback)}</div>
|
|
1625
|
+
<div class="d-evidence">${esc(v.evidenceQuote)}</div>
|
|
1626
|
+
</div>`).join('')}</div>
|
|
1627
|
+
</div>` : '';
|
|
1628
|
+
|
|
1629
|
+
return `<aside class="detail">
|
|
1630
|
+
<div class="detail-head">
|
|
1631
|
+
<div style="flex:1;min-width:0">
|
|
1632
|
+
<div class="eyebrow">Technical detail</div>
|
|
1633
|
+
<div class="detail-title">${esc(d.title)}</div>
|
|
1634
|
+
</div>
|
|
1635
|
+
<button class="close-btn" data-act="close-detail" title="Close (Esc)">Close <span>esc</span></button>
|
|
1636
|
+
</div>
|
|
1637
|
+
<div class="detail-body">
|
|
1638
|
+
${gtmHtml}
|
|
1639
|
+
<div class="detail-lead">${esc(d.lead)}</div>
|
|
1640
|
+
${d.expectation ? `<div>
|
|
1641
|
+
<div class="d-label">What we expect for this site</div>
|
|
1642
|
+
<div class="d-prose">${esc(d.expectation)}</div>
|
|
1643
|
+
${d.chips.length ? `<div class="d-chips">${d.chips.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}</div>` : ''}
|
|
1644
|
+
</div>` : ''}
|
|
1645
|
+
${d.callout ? `<div class="callout">${esc(d.callout)}</div>` : ''}
|
|
1646
|
+
${gridHtml}
|
|
1647
|
+
${checks}
|
|
1648
|
+
${verdicts}
|
|
1649
|
+
${d.advice ? `<div class="d-advice"><div class="d-label">Suggested next step</div><div class="d-advice-text">${esc(d.advice)}</div></div>` : ''}
|
|
1650
|
+
<div>
|
|
1651
|
+
<div class="d-label">Raw evidence</div>
|
|
1652
|
+
<details class="d-card d-raw">
|
|
1653
|
+
<summary>${esc(d.rawLabel)}</summary>
|
|
1654
|
+
<pre>${esc(d.raw)}</pre>
|
|
1655
|
+
</details>
|
|
1656
|
+
</div>
|
|
1657
|
+
<button class="btn full" data-act="copy-detail">${state.copied ? 'Copied' : state.copyFailed ? 'Copy it by hand — the browser blocked this' : 'Copy this detail for the team'}</button>
|
|
1658
|
+
</div>
|
|
1659
|
+
</aside>`;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* The one command a run needs. `verify-tracking` is the installed name; a
|
|
1664
|
+
* checkout runs the same thing as `bun run src/cli.ts`, which is a developer's
|
|
1665
|
+
* detail and not what this page's reader has.
|
|
1666
|
+
*/
|
|
1667
|
+
function runCommandFor(slug, url) {
|
|
1668
|
+
return 'verify-tracking --slug ' + shq(slug) + ' --url ' + shq(url);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* A URL carries `?` and `&` far more often than not, and pasted unquoted the
|
|
1673
|
+
* `&` backgrounds the command. Single quotes are the one POSIX form with no
|
|
1674
|
+
* expansion inside them; a literal quote has to be closed and re-opened.
|
|
1675
|
+
*/
|
|
1676
|
+
function shq(value) {
|
|
1677
|
+
return "'" + String(value).split("'").join(`'\\''`) + "'";
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function render() {
|
|
1681
|
+
const report = currentReport();
|
|
1682
|
+
const app = document.getElementById('app');
|
|
1683
|
+
|
|
1684
|
+
if (!report) {
|
|
1685
|
+
app.innerHTML = renderSidebar() + `<main><div class="scroll"><div class="col">
|
|
1686
|
+
<div class="q" style="cursor:default">
|
|
1687
|
+
<div class="q-label">No checks yet</div>
|
|
1688
|
+
<div class="q-answer">Nothing was found in ${esc(DATA_FILE)}</div>
|
|
1689
|
+
<div class="summary-sub">Fill in <strong>Run a new check</strong> on the left to build the
|
|
1690
|
+
command, then paste it into your terminal:
|
|
1691
|
+
<code style="display:block;margin-top:6px;font-family:var(--mono)">verify-tracking --slug dreamdata_io --url https://dreamdata.io/</code>
|
|
1692
|
+
The check rebuilds this page when it finishes — reload the tab to see it.</div>
|
|
1693
|
+
</div>
|
|
1694
|
+
</div></div></main>`;
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const questions = questionModels(report);
|
|
1699
|
+
const tags = tagsFor(report);
|
|
1700
|
+
const pv = pageviewModel(report);
|
|
1701
|
+
const dx = diagnosisModel(report);
|
|
1702
|
+
const causeFlags = causeFlagsForQuestions(report);
|
|
1703
|
+
|
|
1704
|
+
app.innerHTML = renderSidebar() + `<main>
|
|
1705
|
+
<header class="top">
|
|
1706
|
+
<div style="flex:1;min-width:0">
|
|
1707
|
+
<div class="top-line">
|
|
1708
|
+
<h1>${esc(report.slug)}</h1>
|
|
1709
|
+
${tags.map((t) => pill(t, true)).join('')}
|
|
1710
|
+
</div>
|
|
1711
|
+
<div class="top-meta">
|
|
1712
|
+
<a href="${esc(report.url)}" target="_blank" rel="noreferrer">${esc(report.url)} ↗</a>
|
|
1713
|
+
<span class="sep">·</span>
|
|
1714
|
+
<span class="mono">${esc(host(report.url))}</span><span class="sep">·</span>
|
|
1715
|
+
<span>Checked ${esc(when(report.timestamp))}</span>
|
|
1716
|
+
</div>
|
|
1717
|
+
</div>
|
|
1718
|
+
<div class="top-side">
|
|
1719
|
+
<button class="btn" data-act="rerun">Re-run check</button>
|
|
1720
|
+
${DATA.outputRoot ? `<div class="top-note">Raw report + evidence:
|
|
1721
|
+
<span class="mono">${esc(DATA.outputRoot)}</span></div>` : ''}
|
|
1722
|
+
</div>
|
|
1723
|
+
</header>
|
|
1724
|
+
<div class="scroll"><div class="col">
|
|
1725
|
+
${renderSummary(healthModel(report, dx), dx)}
|
|
1726
|
+
<div class="section-label">How the site is set up</div>
|
|
1727
|
+
<div class="q-grid">${questions.map((q) => renderQuestionCard(q, causeFlags[q.id])).join('')}</div>
|
|
1728
|
+
<div class="section-label">Do the events fire as expected?</div>
|
|
1729
|
+
${renderPageviewCard(pv, causeFlags.pageview)}
|
|
1730
|
+
</div></div>
|
|
1731
|
+
</main>` + (state.detailOpen ? renderDetail(report) : '');
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// ── Events ───────────────────────────────────────────────────────────
|
|
1735
|
+
|
|
1736
|
+
document.getElementById('app').addEventListener('click', (e) => {
|
|
1737
|
+
const el = e.target.closest('[data-act]');
|
|
1738
|
+
if (!el) return;
|
|
1739
|
+
const act = el.dataset.act;
|
|
1740
|
+
if (act === 'toggle-sidebar') return set({ sidebarOpen: !state.sidebarOpen });
|
|
1741
|
+
if (act === 'close-detail') return set({ detailOpen: false });
|
|
1742
|
+
if (act === 'select-site') return set({ slug: el.dataset.slug, copied: false, copyFailed: false });
|
|
1743
|
+
if (act === 'select-question') {
|
|
1744
|
+
const q = el.dataset.q;
|
|
1745
|
+
return set({ question: q, detailOpen: !(state.detailOpen && state.question === q), copied: false, copyFailed: false });
|
|
1746
|
+
}
|
|
1747
|
+
if (act === 'run' || act === 'rerun') {
|
|
1748
|
+
const report = act === 'rerun' ? currentReport() : null;
|
|
1749
|
+
const slug = report ? report.slug : state.newSlug.trim();
|
|
1750
|
+
const url = report ? report.url : state.newUrl.trim();
|
|
1751
|
+
if (!slug || !url) return set({ runNotice: 'Add a slug and a URL first.' });
|
|
1752
|
+
// This page is opened over file:// and cannot start a run. Handing over
|
|
1753
|
+
// the exact command is the honest version of a "Run" button. One command:
|
|
1754
|
+
// the run rebuilds this dashboard itself when it finishes.
|
|
1755
|
+
return set({
|
|
1756
|
+
runCommand: runCommandFor(slug, url),
|
|
1757
|
+
runNotice: 'Run this in your terminal:',
|
|
1758
|
+
commandCopied: false,
|
|
1759
|
+
commandCopyFailed: false,
|
|
1760
|
+
sidebarOpen: true,
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
if (act === 'copy-command') {
|
|
1764
|
+
return copyText(state.runCommand, (ok) => set({ commandCopied: ok, commandCopyFailed: !ok }));
|
|
1765
|
+
}
|
|
1766
|
+
if (act === 'copy-detail') {
|
|
1767
|
+
const d = detailModel(currentReport(), state.question);
|
|
1768
|
+
// The GTM block is the first thing on screen, so it is the first thing in
|
|
1769
|
+
// the paste too — a pasted detail that omits it reads as if the container
|
|
1770
|
+
// was never looked at.
|
|
1771
|
+
const gtm = gtmModel(currentReport());
|
|
1772
|
+
const gtmText = !gtm ? '' : 'GTM setup at a glance\n' + gtm.containers.map((c) =>
|
|
1773
|
+
c.containerId + (c.meta ? ' (' + c.meta + ')' : '') + ' ships ' + c.ships + '\n' +
|
|
1774
|
+
(c.empty ? ' ' + c.empty + '\n' : '') +
|
|
1775
|
+
c.tags.map((t) => ' ' + t.title + '\n' + (t.note ? ' ' + t.note + '\n' : '') +
|
|
1776
|
+
t.rows.map((r) => ' ' + r.label + ': ' + r.value).join('\n')).join('\n')
|
|
1777
|
+
).join('\n') + '\n\n';
|
|
1778
|
+
const text = d.title + '\n\n' + gtmText +
|
|
1779
|
+
d.checks.map((c) => '[' + c.statusLabel + '] ' + c.title + ' — ' + c.detail + '\n ' + c.evidence).join('\n') +
|
|
1780
|
+
'\n\n' + d.raw;
|
|
1781
|
+
return copyText(text, (ok) => set({ copied: ok, copyFailed: !ok }));
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
// The panel's close button advertises Esc, so Esc has to work.
|
|
1786
|
+
document.addEventListener('keydown', (e) => {
|
|
1787
|
+
if (e.key === 'Escape' && state.detailOpen) set({ detailOpen: false });
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
document.getElementById('app').addEventListener('input', (e) => {
|
|
1791
|
+
const el = e.target.closest('[data-act]');
|
|
1792
|
+
if (!el) return;
|
|
1793
|
+
// Written straight to state without a full re-render: re-rendering on every
|
|
1794
|
+
// keystroke would replace the input the caret is in. The list and its
|
|
1795
|
+
// heading are the only things a search changes, so they are patched here.
|
|
1796
|
+
if (el.dataset.act === 'new-slug') { state.newSlug = el.value; return; }
|
|
1797
|
+
if (el.dataset.act === 'new-url') { state.newUrl = el.value; return; }
|
|
1798
|
+
if (el.dataset.act === 'search') {
|
|
1799
|
+
state.search = el.value;
|
|
1800
|
+
const list = document.querySelector('.site-list');
|
|
1801
|
+
if (list) list.innerHTML = siteListHtml();
|
|
1802
|
+
const heading = document.querySelector('.side-search .eyebrow');
|
|
1803
|
+
if (heading) heading.textContent = listHeading();
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
render();
|
|
1808
|
+
</script>
|
|
1809
|
+
</body>
|
|
1810
|
+
</html>
|