also looking at this
Harden AnalyticsHQ release and dashboard quality
#52
34 files
+1236
-340
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -2299,13 +2299,17 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2299 | 2299 | .ga-dot-v { background: var(--accent); } |
| 2300 | 2300 | .ga-dot-u { background: var(--text-3); } |
| 2301 | 2301 | .ga-col:hover .ga-dot { opacity: 1; } |
| 2302 | .ga-tip { position: absolute; left: 50%; transform: translateX(-50%) translateY(calc(-100% - 12px)); min-width: 124px; padding: 8px 10px; border-radius: 9px; background: var(--panel); border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,0.14); opacity: 0; visibility: hidden; transition: opacity 0.12s ease; pointer-events: none; z-index: 20; } | |
| 2303 | .ga-col:hover .ga-tip { opacity: 1; visibility: visible; } | |
| 2302 | .ga-tip { display: none; position: absolute; left: 50%; transform: translateX(-50%) translateY(calc(-100% - 12px)); min-width: 124px; padding: 8px 10px; border-radius: 9px; background: var(--panel); border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,0.14); pointer-events: none; z-index: 20; } | |
| 2303 | .ga-col:hover .ga-tip { display: block; } | |
| 2304 | 2304 | .ga-tip-day { font-size: 11px; color: var(--text-2); margin-bottom: 5px; } |
| 2305 | 2305 | .ga-tip-row { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); white-space: nowrap; line-height: 1.7; } |
| 2306 | 2306 | .ga-tip-row b { margin-left: auto; color: var(--text); font-weight: 600; } |
| 2307 | 2307 | .ga-sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; } |
| 2308 | @media (prefers-reduced-motion: reduce) { .ga-dot, .ga-tip { transition: none; } } | |
| 2308 | @media (max-width: 1023px) { | |
| 2309 | .ga-col:hover::before, | |
| 2310 | .ga-dot, | |
| 2311 | .ga-tip { display: none; } | |
| 2312 | } | |
| 2309 | 2313 | |
| 2310 | 2314 | /* Export CSV pill: hover tightens the border to the accent like the range pills. */ |
| 2311 | 2315 | #export-csv:hover { color: var(--text); border-color: var(--accent); } |
| @@ -2339,6 +2343,25 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2339 | 2343 | .chrome-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--text-2); font: 500 13px/1 var(--sans, ui-sans-serif); text-decoration: none; cursor: pointer; transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; } |
| 2340 | 2344 | .chrome-btn:hover { color: var(--text); border-color: var(--accent); } |
| 2341 | 2345 | .chrome-icon { padding: 7px 10px; font-size: 14px; line-height: 1; } |
| 2346 | ||
| 2347 | @media (max-width: 767px) { | |
| 2348 | .site-switcher, | |
| 2349 | .range-tool, | |
| 2350 | .live-status, | |
| 2351 | .chrome-btn, | |
| 2352 | .range-link, | |
| 2353 | .ga-row, | |
| 2354 | .goal-form input, | |
| 2355 | .goal-form select, | |
| 2356 | .goal-form button { min-height: 2.75rem; } | |
| 2357 | ||
| 2358 | .site-switcher { height: 2.75rem; } | |
| 2359 | .range-link { display: inline-flex; align-items: center; justify-content: center; } | |
| 2360 | .range-tool { min-width: 2.75rem; } | |
| 2361 | .ga-row { align-items: center; } | |
| 2362 | .kpi-label { min-height: 2rem; } | |
| 2363 | .kpi-value { font-size: clamp(1.375rem, 7.25vw, 1.875rem); line-height: 1.2; white-space: nowrap; } | |
| 2364 | } | |
| 2342 | 2365 | /* One glyph at a time. `theme.isDark()` writes data-t, and these pick the matching |
| 2343 | 2366 | icon -- the same contract bughq's header uses, so the two toggles behave alike. */ |
| 2344 | 2367 | .theme-btn .ic-sun, .theme-btn .ic-moon { display: none; } |
| @@ -2474,8 +2497,8 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2474 | 2497 | <section class="grid grid-cols-2 lg:grid-cols-7 mb-6 border-border lg:divide-x panel reveal" aria-label="Key metrics"> |
| 2475 | 2498 | @foreach (kpiCards as kpi) |
| 2476 | 2499 | <div class="px-5 py-5 border-border"> |
| 2477 | <div class="mb-2 font-medium text-text-3 text-xs tracking-wider uppercase">{{ kpi.label }}</div> | |
| 2478 | <div class="font-semibold text-3xl tracking-tight mono">{{ kpi.value }}</div> | |
| 2500 | <div class="mb-2 font-medium text-text-3 text-xs tracking-wider uppercase kpi-label">{{ kpi.label }}</div> | |
| 2501 | <div class="font-semibold text-3xl tracking-tight kpi-value mono">{{ kpi.value }}</div> | |
| 2479 | 2502 | @if (kpi.delta !== null) |
| 2480 | 2503 | <div class="mt-1.5 font-medium text-xs mono" style="color: "> |
| 2481 | 2504 | {{ kpi.delta >= 0 ? '▲' : '▼' }} {{ Math.abs(kpi.delta) }}% <span class="text-text-3">vs prev</span> |
| @@ -3303,7 +3326,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3303 | 3326 | /api/sites/{site}/goals via @submit=addGoal. Pattern is the event NAME for |
| 3304 | 3327 | event goals or the URL PATH for pageview goals. --}} |
| 3305 | 3328 | @if (ownsActive) |
| 3306 | <form @submit="addGoal" class="flex flex-wrap gap-2 items-center mt-4 pt-4" style="border-top:1px solid var(--border)"> | |
| 3329 | <form @submit="addGoal" class="flex flex-wrap gap-2 items-center mt-4 pt-4 goal-form" style="border-top:1px solid var(--border)"> | |
| 3307 | 3330 | <input name="name" required maxlength="255" placeholder="Goal name" aria-label="Goal name" style="flex:1;min-width:110px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> |
| 3308 | 3331 | <select name="type" aria-label="Goal type" style="padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> |
| 3309 | 3332 | <option value="event">Event</option> |
| @@ -3550,7 +3573,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3550 | 3573 | dragging: false, |
| 3551 | 3574 | scrollWheelZoom: false, |
| 3552 | 3575 | doubleClickZoom: false, |
| 3553 | touchZoom: false, | |
| 3576 | pinchZoom: false, | |
| 3554 | 3577 | keyboard: false, |
| 3555 | 3578 | }) |
| 3556 | 3579 | layer = new GeoJSON(world, { style: styleFor(tokens()) }) |
| @@ -14,6 +14,8 @@ interface PageAudit { | ||
| 14 | 14 | focusable: number |
| 15 | 15 | imagesWithoutAlt: number |
| 16 | 16 | overflow: number |
| 17 | undersizedDashboardControls: string[] | |
| 18 | wrappedDashboardValues: string[] | |
| 17 | 19 | unresolved: boolean |
| 18 | 20 | unnamedControls: string[] |
| 19 | 21 | } |
| @@ -182,6 +184,20 @@ const auditExpression = `(() => { | ||
| 182 | 184 | return { r: 255, g: 255, b: 255, a: 1 } |
| 183 | 185 | } |
| 184 | 186 | const controls = [...document.querySelectorAll('a[href], button, input:not([type="hidden"]), select, textarea')].filter(visible) |
| 187 | const dashboardControls = [...document.querySelectorAll('.site-switcher, .range-link, .range-tool, .chrome-btn, .ga-row, .goal-form input, .goal-form select, .goal-form button')].filter(visible) | |
| 188 | const undersizedDashboardControls = dashboardControls.flatMap(element => { | |
| 189 | const rect = element.getBoundingClientRect() | |
| 190 | if (rect.width >= 44 && rect.height >= 44) return [] | |
| 191 | const name = element.getAttribute('aria-label') || element.textContent?.trim().replace(/\\s+/g, ' ').slice(0, 32) || element.tagName.toLowerCase() | |
| 192 | return [name + ' (' + Math.round(rect.width) + 'x' + Math.round(rect.height) + ')'] | |
| 193 | }) | |
| 194 | const wrappedDashboardValues = [...document.querySelectorAll('.kpi-value')].filter(visible).flatMap(element => { | |
| 195 | const rect = element.getBoundingClientRect() | |
| 196 | const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight) | |
| 197 | return rect.height > lineHeight * 1.5 || element.scrollWidth > element.clientWidth + 1 | |
| 198 | ? [element.textContent?.trim() || 'metric value'] | |
| 199 | : [] | |
| 200 | }) | |
| 185 | 201 | const unnamedControls = controls.filter(element => !label(element)).map(element => { |
| 186 | 202 | const identity = element.id ? '#' + element.id : element.getAttribute('name') ? '[name="' + element.getAttribute('name') + '"]' : '' |
| 187 | 203 | return element.tagName.toLowerCase() + identity |
| @@ -210,11 +226,23 @@ const auditExpression = `(() => { | ||
| 210 | 226 | focusable: controls.length, |
| 211 | 227 | imagesWithoutAlt: [...document.images].filter(image => !image.hasAttribute('alt')).length, |
| 212 | 228 | overflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), |
| 229 | undersizedDashboardControls, | |
| 230 | wrappedDashboardValues, | |
| 213 | 231 | unresolved: document.body.innerText.includes('{{') || document.body.innerText.includes('@if'), |
| 214 | 232 | unnamedControls, |
| 215 | 233 | } |
| 216 | 234 | })()` |
| 217 | 235 | |
| 236 | const dashboardFixtureExpression = `(() => { | |
| 237 | const fixture = document.createElement('section') | |
| 238 | fixture.id = 'visual-dashboard-fixture' | |
| 239 | fixture.setAttribute('aria-hidden', 'true') | |
| 240 | fixture.style.cssText = 'position:absolute;left:1rem;top:0;width:calc(100vw - 2rem);opacity:0.001;pointer-events:none;z-index:-1' | |
| 241 | const columns = Array.from({ length: 90 }, (_, index) => '<div class="relative flex-1 ga-col"><div class="ga-tip">Day ' + index + '</div></div>').join('') | |
| 242 | fixture.innerHTML = '<select class="panel site-switcher" aria-label="Switch site"><option>example.com</option></select><a class="range-link" href="#">24h</a><button class="range-tool">Export</button><a class="chrome-btn" href="#">Account</a><a class="ga-row" href="#">Page row</a><form class="goal-form"><input aria-label="Goal name"></form><section class="grid grid-cols-2"><div class="px-5"><div class="kpi-label">Average visit time</div><div class="text-3xl kpi-value mono">27m 47s</div></div></section><div class="p-5 panel"><div class="relative h-44 w-full ga-chart"><div class="flex absolute inset-0">' + columns + '</div></div></div>' | |
| 243 | document.body.appendChild(fixture) | |
| 244 | })()` | |
| 245 | ||
| 218 | 246 | async function main(): Promise<void> { |
| 219 | 247 | if (!existsSync(DIST) && !existsSync(SSR_PAGES)) |
| 220 | 248 | throw new Error(`Built site not found at ${DIST} or ${SSR_OUTPUT}. Run bun run build first.`) |
| @@ -290,9 +318,15 @@ async function main(): Promise<void> { | ||
| 290 | 318 | document.documentElement.setAttribute('color-mode', theme) |
| 291 | 319 | })()`) |
| 292 | 320 | await Bun.sleep(500) |
| 321 | if (route === '/dashboard') await cdp.evaluate(dashboardFixtureExpression) | |
| 293 | 322 | const audit = await cdp.evaluate<PageAudit>(auditExpression) |
| 323 | if (route === '/dashboard') await cdp.evaluate("document.getElementById('visual-dashboard-fixture')?.remove()") | |
| 294 | 324 | const key = `${route} at ${viewport.name}/${theme}` |
| 295 | 325 | if (audit.overflow > 1) failures.push(`${key}: ${audit.overflow}px horizontal overflow`) |
| 326 | if (viewport.mobile && audit.undersizedDashboardControls.length) | |
| 327 | failures.push(`${key}: undersized dashboard controls ${audit.undersizedDashboardControls.join(', ')}`) | |
| 328 | if (viewport.mobile && audit.wrappedDashboardValues.length) | |
| 329 | failures.push(`${key}: wrapped dashboard metric values ${audit.wrappedDashboardValues.join(', ')}`) | |
| 296 | 330 | if (audit.unresolved) failures.push(`${key}: unresolved template expression is visible`) |
| 297 | 331 | if (audit.imagesWithoutAlt) failures.push(`${key}: ${audit.imagesWithoutAlt} image(s) have no alt attribute`) |
| 298 | 332 | if (audit.unnamedControls.length) failures.push(`${key}: unnamed controls ${audit.unnamedControls.join(', ')}`) |
| @@ -357,7 +357,7 @@ describe('the map talks to nothing but this origin', () => { | ||
| 357 | 357 | |
| 358 | 358 | test('interaction is disabled — this is a report, not an explorer', () => { |
| 359 | 359 | const s = mapScript() |
| 360 | for (const opt of ['dragging', 'scrollWheelZoom', 'doubleClickZoom', 'touchZoom']) | |
| 360 | for (const opt of ['dragging', 'scrollWheelZoom', 'doubleClickZoom', 'pinchZoom']) | |
| 361 | 361 | expect({ opt, disabled: new RegExp(`${opt}:\\s*false`).test(s) }).toEqual({ opt, disabled: true }) |
| 362 | 362 | }) |
| 363 | 363 | |