ReviewOS

also looking at this

stacks/analyticshq

Harden AnalyticsHQ release and dashboard quality

#52
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
34 files +1236 -340
.github/labeler.ymlmodified+120-224
Changes to .github/labeler.yml
@@ -1,250 +1,146 @@
1api:
2 - 'storage/framework/api/**'
1# actions/labeler@v6 format (structured match rules).
2# Scoped to paths owned by these applications.
33
4deps:
5 - ./package.json
6
7core:
8 - 'storage/framework/core/**'
9
10ide:
11 - 'storage/framework/defaults/ide/**'
12
13jetbrains:
14 - 'storage/framework/defaults/ide/jetbrains/**'
15
16vscode:
17 - 'storage/framework/defaults/ide/vscode/**'
18
19libs:
20 - 'storage/framework/libs/**'
21
22components:
23 - 'storage/framework/libs/components/**'
24 - resources/components/**
25
26entries:
27 - storage/framework/libs/entries/**
28
29examples:
30 - storage/framework/libs/examples/**
31
32functions:
33 - 'storage/framework/libs/functions/**'
34 - resources/functions/**
35
36scripts:
37 - ./storage/framework/scripts/**
38
39stack:
40 - 'storage/framework/stacks/**'
41
42dashboard:
43 - 'storage/framework/stacks/dashboard/**'
44
45vcs:
46 - ./storage/framework/core/vcs/**
47
48github:
49 - ./storage/framework/core/vcs/github/**
50
51views:
52 - 'storage/framework/core/views/**'
53 - resources/views/**
54
55web:
56 - ./storage/framework/core/views/web/**
4app:
5 - changed-files:
6 - any-glob-to-any-file:
7 - 'app/**'
578
589actions:
59 - 'storage/framework/core/actions/**'
60
61ai:
62 - 'storage/framework/core/ai/**'
63
64alias:
65 - 'storage/framework/core/alias/**'
66
67analytics:
68 - 'storage/framework/core/analytics/**'
69
70arrays:
71 - 'storage/framework/core/utils/arrays/**'
72
73auth:
74 - 'storage/framework/core/auth/**'
75
76buddy:
77 - 'storage/framework/core/buddy/**'
78
79build:
80 - 'storage/framework/core/build/**'
81
82cache:
83 - 'storage/framework/core/cache/**'
84
85chat:
86 - 'storage/framework/core/notifications/chat/**'
87
88cli:
89 - 'storage/framework/core/cli/**'
90 - app/Commands/**
91
92cloud:
93 - 'storage/framework/core/cloud/**'
94
95collections:
96 - 'storage/framework/core/utils/collections/**'
97
98config:
99 - 'storage/framework/core/config/**'
100 - config/**
101
102database:
103 - 'storage/framework/core/database/**'
104
105datetime:
106 - 'storage/framework/core/utils/datetime/**'
107
108desktop:
109 - 'storage/framework/core/desktop/**'
110 - ./storage/framework/core/views/desktop/**
111
112development:
113 - 'storage/framework/core/development/**'
114
115dns:
116 - 'storage/framework/core/dns/**'
117
118docs:
119 - 'storage/framework/core/docs/**'
120 - 'storage/framework/docs/**'
121 - docs/**
122 - config/docs.ts
10 - changed-files:
11 - any-glob-to-any-file:
12 - 'app/Actions/**'
12313
124email:
125 - 'storage/framework/core/notifications/email/**'
126
127error-handling:
128 - 'storage/framework/core/error-handling/**'
14commands:
15 - changed-files:
16 - any-glob-to-any-file:
17 - 'app/Commands/**'
12918
13019events:
131 - 'storage/framework/core/events/**'
132 - app/Events/**
133
134faker:
135 - 'storage/framework/core/faker/**'
136
137git:
138 - 'storage/framework/core/utils/git/**'
20 - changed-files:
21 - any-glob-to-any-file:
22 - 'app/Events/**'
23 - 'app/Listeners/**'
24 - 'app/Events.ts'
25 - 'app/Listener.ts'
13926
140health:
141 - 'storage/framework/core/health/**'
27jobs:
28 - changed-files:
29 - any-glob-to-any-file:
30 - 'app/Jobs/**'
14231
143lint:
144 - 'storage/framework/core/lint/**'
32mail:
33 - changed-files:
34 - any-glob-to-any-file:
35 - 'app/Mail/**'
14536
146logging:
147 - 'storage/framework/core/logging/**'
37middleware:
38 - changed-files:
39 - any-glob-to-any-file:
40 - 'app/Middleware/**'
41 - 'app/Middleware.ts'
14842
149modules:
150 - 'storage/framework/core/modules/**'
43models:
44 - changed-files:
45 - any-glob-to-any-file:
46 - 'app/Models/**'
15147
15248notifications:
153 - 'storage/framework/core/notifications/**'
154 - app/Notifications/**
155
156objects:
157 - 'storage/framework/core/utils/objects/**'
158
159orm:
160 - 'storage/framework/core/orm/**'
161
162path:
163 - 'storage/framework/core/path/**'
164
165payments:
166 - 'storage/framework/core/payments/**'
167
168push:
169 - 'storage/framework/core/notifications/push/**'
170
171query-builder:
172 - 'storage/framework/core/query-builder/**'
173
174queue:
175 - 'storage/framework/core/queue/**'
176
177real-time:
178 - 'storage/framework/core/real-time/**'
179
180repl:
181 - 'storage/framework/core/repl/**'
182
183router:
184 - 'storage/framework/core/router/**'
185
186scheduler:
187 - 'storage/framework/core/scheduler/**'
188
189search-engine:
190 - 'storage/framework/core/search-engine/**'
191
192security:
193 - 'storage/framework/core/security/**'
194
195signals:
196 - 'storage/framework/core/signals/**'
197
198slug:
199 - 'storage/framework/core/slug/**'
200
201sms:
202 - 'storage/framework/core/notifications/sms/**'
203
204storage:
205 - storage/**
49 - changed-files:
50 - any-glob-to-any-file:
51 - 'app/Notifications/**'
20652
207strings:
208 - 'storage/framework/core/utils/strings/**'
209
210testing:
211 - 'storage/framework/core/testing/**'
212
213tinker:
214 - 'storage/framework/core/tinker/**'
215
216types:
217 - 'storage/framework/core/types/**'
53config:
54 - changed-files:
55 - any-glob-to-any-file:
56 - 'config/**'
21857
219ui:
220 - 'storage/framework/core/ui/**'
58database:
59 - changed-files:
60 - any-glob-to-any-file:
61 - 'database/**'
22162
222utils:
223 - 'storage/framework/core/utils/**'
63migrations:
64 - changed-files:
65 - any-glob-to-any-file:
66 - 'database/migrations/**'
22467
225validation:
226 - 'storage/framework/core/validation/**'
68resources:
69 - changed-files:
70 - any-glob-to-any-file:
71 - 'resources/**'
22772
228app:
229 - app/**
73components:
74 - changed-files:
75 - any-glob-to-any-file:
76 - 'resources/components/**'
23077
231jobs:
232 - app/Jobs/**
78views:
79 - changed-files:
80 - any-glob-to-any-file:
81 - 'resources/views/**'
82 - 'resources/layouts/**'
83 - 'resources/partials/**'
23384
234models:
235 - app/Models/**
85functions:
86 - changed-files:
87 - any-glob-to-any-file:
88 - 'resources/functions/**'
23689
237resources:
238 - resources/**
90stores:
91 - changed-files:
92 - any-glob-to-any-file:
93 - 'resources/stores/**'
23994
24095lang:
241 - resources/lang/**
242
243stores:
244 - resources/stores/**
96 - changed-files:
97 - any-glob-to-any-file:
98 - 'resources/lang/**'
99 - 'locales/**'
245100
246101routes:
247 - routes/**
102 - changed-files:
103 - any-glob-to-any-file:
104 - 'routes/**'
105 - 'app/Routes.ts'
248106
249107tests:
250 - tests/**
108 - changed-files:
109 - any-glob-to-any-file:
110 - 'tests/**'
111
112docs:
113 - changed-files:
114 - any-glob-to-any-file:
115 - 'docs/**'
116 - 'config/docs.ts'
117 - '**/*.md'
118
119content:
120 - changed-files:
121 - any-glob-to-any-file:
122 - 'content/**'
123
124public:
125 - changed-files:
126 - any-glob-to-any-file:
127 - 'public/**'
128
129cloud:
130 - changed-files:
131 - any-glob-to-any-file:
132 - 'cloud/**'
133 - 'config/cloud.ts'
134
135deps:
136 - changed-files:
137 - any-glob-to-any-file:
138 - 'package.json'
139 - 'bun.lock'
140 - 'deps.yml'
141 - 'pantry.lock'
142
143ci:
144 - changed-files:
145 - any-glob-to-any-file:
146 - '.github/**'
.github/workflows/ci.ymlmodified+22-8
Changes to .github/workflows/ci.yml
@@ -201,7 +201,7 @@ jobs:
201201
202202 deploy-prod:
203203 if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.release-check.outputs.contains_release == '0'
204 needs: [secrets, lint, typecheck, test, release-check]
204 needs: [secrets, lint, typecheck, test, release-check, build]
205205 runs-on: ubuntu-latest
206206 environment: production
207207
@@ -250,7 +250,7 @@ jobs:
250250
251251 deploy-stage:
252252 if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-beta.'))
253 needs: [secrets, lint, typecheck, test]
253 needs: [secrets, lint, typecheck, test, build]
254254 runs-on: ubuntu-latest
255255 environment: staging
256256
@@ -297,7 +297,7 @@ jobs:
297297
298298 deploy-dev:
299299 if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.base_ref == 'main')
300 needs: [secrets, lint, typecheck, test]
300 needs: [secrets, lint, typecheck, test, build]
301301 runs-on: ubuntu-latest
302302 environment: development
303303
@@ -342,8 +342,7 @@ jobs:
342342 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
343343 run: bun buddy deploy development --yes
344344
345 publish-commit:
346 if: github.event_name == 'pull_request'
345 build:
347346 runs-on: ubuntu-latest
348347
349348 steps:
@@ -363,8 +362,23 @@ jobs:
363362 - name: Install Dependencies
364363 run: bun install
365364
366 - name: Build
365 - name: Build application
367366 run: bun run build
368367
369 - name: Publish Commit
370 run: ./storage/framework/scripts/publish-commit
368 - name: Build documentation
369 run: bun buddy build docs
370
371 - name: Validate documentation
372 run: bun run docs:validate
373
374 - name: Validate UI accessibility
375 run: bun run ui:validate
376
377 - name: Validate responsive visual contracts
378 run: bun run visual:validate
379
380 - name: Validate production release artifact
381 run: bun run release:validate
382
383 - name: Verify generated files are current
384 run: git diff --exit-code
.github/workflows/deploy.ymlmodified+10-0
Changes to .github/workflows/deploy.yml
@@ -64,6 +64,12 @@ jobs:
6464 - name: Install dependencies
6565 run: bun install --frozen-lockfile
6666
67 - name: Build the production artifact
68 run: bun run build
69
70 - name: Validate the production artifact
71 run: bun run release:validate
72
6773 # IP geolocation database (DB-IP Lite, CC BY 4.0). Fetched here rather than
6874 # committed: an ~8MB binary on a monthly cadence does not belong in git
6975 # history. It ships to the box with the rest of the working tree.
@@ -152,3 +158,7 @@ jobs:
152158 PORKBUN_API_KEY: ${{ secrets.PORKBUN_API_KEY }}
153159 PORKBUN_SECRET_KEY: ${{ secrets.PORKBUN_SECRET_KEY }}
154160 COPYFILE_DISABLE: '1'
161
162 - name: Smoke test production
163 if: needs.determine-environment.outputs.environment == 'production'
164 run: bun run deployment:smoke
app/Analytics/event-properties.tsadded+46-0
Changes to app/Analytics/event-properties.ts
@@ -0,0 +1,46 @@
1/**
2 * Browser keepalive requests have a small total-body budget. Keeping event
3 * properties below 48 KiB leaves room for the event envelope while allowing
4 * long URLs and useful structured metadata.
5 */
6export const MAX_EVENT_PROPERTIES_BYTES = 48 * 1024
7
8const URL_EVENTS = new Set(['Outbound Link', 'File Download'])
9
10function utf8Bytes(value: string): number {
11 return new TextEncoder().encode(value).byteLength
12}
13
14/**
15 * Serialize public event properties without allowing an unbounded database
16 * value. Reserved URL events keep one canonical key so grouping is stable.
17 *
18 * Invalid or oversized properties are omitted, but the event itself can still
19 * be recorded. The public collector must not become a 500 because optional
20 * metadata was unusable.
21 */
22export function serializeEventProperties(event: unknown, properties: unknown): string | null {
23 if (properties === null || properties === undefined)
24 return null
25
26 let normalized = properties
27 if (
28 URL_EVENTS.has(String(event))
29 && typeof properties === 'object'
30 && 'url' in properties
31 && properties.url
32 ) {
33 normalized = { url: String(properties.url) }
34 }
35
36 try {
37 const serialized = JSON.stringify(normalized)
38 if (!serialized || utf8Bytes(serialized) > MAX_EVENT_PROPERTIES_BYTES)
39 return null
40
41 return serialized
42 }
43 catch {
44 return null
45 }
46}
database/migrations/0000000052-widen-custom-event-properties.sqladded+5-0
Changes to database/migrations/0000000052-widen-custom-event-properties.sql
@@ -0,0 +1,5 @@
1-- Production analytics storage is PostgreSQL. SQLite already gives varchar
2-- columns unconstrained text affinity, so it needs no table rebuild.
3ALTER TABLE "custom_events"
4 ALTER COLUMN "properties" TYPE text
5 USING "properties"::text;
package.jsonmodified+5-0
Changes to package.json
@@ -31,6 +31,11 @@
3131 "dev:api": "./buddy dev:api",
3232 "dev:full": "./buddy dev",
3333 "build": "./buddy build",
34 "docs:validate": "bun scripts/validate-docs.ts",
35 "ui:validate": "bun scripts/validate-ui.ts",
36 "visual:validate": "bun scripts/validate-visual.ts",
37 "release:validate": "bun scripts/validate-release.ts",
38 "deployment:smoke": "bun scripts/smoke-deployment.ts",
3439 "prod": "./buddy build",
3540 "deploy": "./buddy deploy",
3641 "export:site": "bun scripts/analytics/export-site.ts",
public/marketing.cssmodified+1-1
Changes to public/marketing.css
@@ -8,7 +8,7 @@
88 --surface-2: #15191e;
99 --ink: #eef2f3;
1010 --muted: #9aa4a9;
11 --faint: #6d777c;
11 --faint: #7b878c;
1212 --line: rgba(233, 238, 240, 0.09);
1313 --line-strong: rgba(233, 238, 240, 0.16);
1414 --accent: #46d3c0;
resources/layouts/app.stxmodified+4-4
Changes to resources/layouts/app.stx
@@ -34,18 +34,18 @@
3434 One accent (emerald), off-black/off-white, auto light+dark, radius lock 12px. */
3535:root {
3636 --bg: #fbfbfc; --panel: #ffffff; --border: rgba(15,23,42,0.08);
37 --text: #0b1220; --text-2: #4b5563; --text-3: #98a2b3; --accent: #059669; --bar: rgba(5,150,105,0.16);
37 --text: #0b1220; --text-2: #4b5563; --text-3: #667085; --accent: #059669; --accent-ink: #04120d; --bar: rgba(5,150,105,0.16);
3838 --sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
3939 --mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
4040}
4141@media (prefers-color-scheme: dark) {
4242 :root { --bg: #080b12; --panel: #0f131c; --border: rgba(148,163,184,0.12);
43 --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #667085; --accent: #34d399; --bar: rgba(52,211,153,0.18); }
43 --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #7b879a; --accent: #34d399; --accent-ink: #04120d; --bar: rgba(52,211,153,0.18); }
4444}
4545:root[data-theme="dark"] { --bg: #080b12; --panel: #0f131c; --border: rgba(148,163,184,0.12);
46 --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #667085; --accent: #34d399; --bar: rgba(52,211,153,0.18); }
46 --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #7b879a; --accent: #34d399; --accent-ink: #04120d; --bar: rgba(52,211,153,0.18); }
4747:root[data-theme="light"] { --bg: #fbfbfc; --panel: #ffffff; --border: rgba(15,23,42,0.08);
48 --text: #0b1220; --text-2: #4b5563; --text-3: #98a2b3; --accent: #059669; --bar: rgba(5,150,105,0.16); }
48 --text: #0b1220; --text-2: #4b5563; --text-3: #667085; --accent: #059669; --accent-ink: #04120d; --bar: rgba(5,150,105,0.16); }
4949
5050body { background: var(--bg); color: var(--text); font-family: var(--sans); -webkit-font-smoothing: antialiased; }
5151.wordmark { font-weight: 700; letter-spacing: -0.03em; }
resources/layouts/auth.stxmodified+1-1
Changes to resources/layouts/auth.stx
@@ -26,7 +26,7 @@
2626:root {
2727 color-scheme: dark;
2828 --bg: #0a0c0e; --panel: #101317; --border: rgba(233,238,240,0.12);
29 --text: #eef2f3; --text-2: #9aa4a9; --text-3: #6d777c;
29 --text: #eef2f3; --text-2: #9aa4a9; --text-3: #7b878c;
3030 --accent: #46d3c0; --accent-ink: #04201c;
3131 --sans: 'Geist', ui-sans-serif, system-ui, sans-serif; --mono: 'Geist Mono', ui-monospace, monospace;
3232}
resources/views/account.stxmodified+3-3
Changes to resources/views/account.stx
@@ -87,14 +87,14 @@ onMount(() => {
8787
8888{{-- Tokens, body and .wordmark now come from resources/layouts/app.stx, which carries
8989 the superset of what this page and the dashboard each had. What stays is what only
90 this page uses. It is pushed rather than left loose because a loose <style> under a
90 this page uses. It is pushed rather than left loose because a loose style block under a
9191 layout gets swept out of the page and appended after the content, in the body. --}}
9292@push('styles')
9393<style>
9494.card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; }
9595.btn { border-radius: 10px; transition: transform 0.12s ease, opacity 0.15s ease, background-color 0.15s ease; font-weight: 600; }
9696.btn:active { transform: translateY(1px); }
97.btn-primary { background: var(--accent); color: #fff; }
97.btn-primary { background: var(--accent); color: var(--accent-ink); }
9898.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); }
9999.btn-ghost { border: 1px solid var(--border); color: var(--text); }
100100.btn-ghost:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
@@ -118,7 +118,7 @@ onMount(() => {
118118 </div>
119119 </header>
120120
121 {{-- A <div>, not <main>: the layout owns the single router container. Same classes. --}}
121 {{-- A div rather than another main: the layout owns the single router container. Same classes. --}}
122122 <div class="mx-auto px-5 py-8 md:px-6 max-w-3xl">
123123 <h1 class="mb-6 text-2xl wordmark">Account</h1>
124124
resources/views/compare/cloudflare.stxmodified+1-1
Changes to resources/views/compare/cloudflare.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.cloudflare
55const crumbs = crumbsFor(c)
resources/views/compare/fathom.stxmodified+1-1
Changes to resources/views/compare/fathom.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.fathom
55const crumbs = crumbsFor(c)
resources/views/compare/google-analytics.stxmodified+1-1
Changes to resources/views/compare/google-analytics.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors['google-analytics']
55const crumbs = crumbsFor(c)
resources/views/compare/matomo.stxmodified+1-1
Changes to resources/views/compare/matomo.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.matomo
55const crumbs = crumbsFor(c)
resources/views/compare/mixpanel.stxmodified+1-1
Changes to resources/views/compare/mixpanel.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.mixpanel
55const crumbs = crumbsFor(c)
resources/views/compare/plausible.stxmodified+1-1
Changes to resources/views/compare/plausible.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.plausible
55const crumbs = crumbsFor(c)
resources/views/compare/simple-analytics.stxmodified+1-1
Changes to resources/views/compare/simple-analytics.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors['simple-analytics']
55const crumbs = crumbsFor(c)
resources/views/compare/umami.stxmodified+1-1
Changes to resources/views/compare/umami.stx
@@ -1,5 +1,5 @@
11<script server>
2import { competitors, crumbsFor } from '../../data/competitors'
2import { competitors, crumbsFor } from '../data/competitors'
33
44const c = competitors.umami
55const crumbs = crumbsFor(c)
resources/views/dashboard.stxmodified+75-47
Changes to resources/views/dashboard.stx
@@ -280,7 +280,7 @@ const regionGeoAllowed = privacy.geo.granularity === 'region'
280280let shareMode = false
281281// The owner's existing share token for the active site ('' if none), so the owner
282282// UI can show/copy/revoke the link. Bridged into the client block.
283let shareToken = ''
283let __shareToken = ''
284284
285285// Race all DB work against a short timeout: if PostgreSQL isn't reachable, a
286286// TCP connect can hang far longer than a render should wait, so we bail to the
@@ -383,7 +383,7 @@ async function loadData() {
383383 || null
384384 // Owner (not a share viewer): surface any existing share token for the link UI.
385385 if (!shareMode)
386 shareToken = (await readSettings(siteId)).share_token || ''
386 __shareToken = (await readSettings(siteId)).share_token || ''
387387
388388 // Team: who can already reach this site, and who has been invited but has not
389389 // accepted yet. Resolved server-side and rendered with @foreach so the panel
@@ -1125,7 +1125,11 @@ const exportPayload = {
11251125 * because the snippets are built from server config but can only be *finished*
11261126 * on the client, which is the one thing that knows the real origin.
11271127 */
1128defineClientPayload({ activeSiteId, countryTotals, exportPayload, installOptions, liveNow, range, shareToken })
1128// This capability is intentionally available only to the authenticated owner
1129// UI so it can display the existing share link. The underlying server binding
1130// stays private and only this exact scalar crosses the bridge.
1131const shareCapability = __shareToken
1132defineClientPayload({ activeSiteId, countryTotals, exportPayload, installOptions, liveNow, range, shareCapability })
11291133
11301134useSeoMeta({
11311135 title: "Dashboard - analyticshq",
@@ -1144,12 +1148,12 @@ useSeoMeta({
11441148const goalError = state('')
11451149const goalBusy = state(false)
11461150const isPro = state(false)
1147// Share link: seeded from the server-bridged existing token (shareToken/activeSiteId
1151// Share link: seeded from the server-bridged existing capability and activeSiteId
11481152// are server consts inlined here by the data bridge), then create/revoke live.
11491153// Built by shareLinkFor rather than by a second copy of the same template -- it is
11501154// declared further down and reached by hoisting, which is the trade for having one
11511155// place that decides what a share URL looks like.
1152const shareUrl = state(shareToken ? shareLinkFor(shareToken) : '')
1156const shareUrl = state(shareCapability ? shareLinkFor(shareCapability) : '')
11531157const shareBusy = state(false)
11541158const shareCopied = state(false)
11551159// Live "current visitors": seeded from the server render, then polled so the
@@ -2051,7 +2055,7 @@ onMount(() => {
20512055{{-- The token block, body and .wordmark that stood here were byte-identical to
20522056 account.stx's and now live once in resources/layouts/app.stx. What remains is what
20532057 only the dashboard uses. It is pushed rather than left loose: under a layout stx
2054 sweeps every orphan <style> out of the page and appends it AFTER the content, in the
2058 sweeps every orphan style block out of the page and appends it AFTER the content, in the
20552059 body, so a loose block would still apply but from the wrong place. --}}
20562060@push('styles')
20572061{{-- ts-maps' own stylesheet. Same-origin from /vendor like the engine beside it
@@ -2122,7 +2126,7 @@ onMount(() => {
21222126.range-link:hover { color: var(--text); }
21232127.range-link[aria-current="page"] {
21242128 background: var(--accent);
2125 color: #fff;
2129 color: var(--accent-ink);
21262130}
21272131.range-tool {
21282132 display: inline-flex;
@@ -2153,7 +2157,7 @@ summary:focus-visible {
21532157 outline: 2px solid var(--accent);
21542158 outline-offset: 2px;
21552159}
2156.range-tool.is-active { color: #fff; background: var(--accent); border-color: var(--accent); }
2160.range-tool.is-active { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
21572161.range-tool i { width: 1rem; height: 1rem; }
21582162.date-menu { position: relative; }
21592163.date-menu > summary { list-style: none; }
@@ -2295,13 +2299,17 @@ select.panel:hover { border-color: var(--accent); }
22952299.ga-dot-v { background: var(--accent); }
22962300.ga-dot-u { background: var(--text-3); }
22972301.ga-col:hover .ga-dot { opacity: 1; }
2298.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; }
2299.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; }
23002304.ga-tip-day { font-size: 11px; color: var(--text-2); margin-bottom: 5px; }
23012305.ga-tip-row { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); white-space: nowrap; line-height: 1.7; }
23022306.ga-tip-row b { margin-left: auto; color: var(--text); font-weight: 600; }
23032307.ga-sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
2304@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}
23052313
23062314/* Export CSV pill: hover tightens the border to the accent like the range pills. */
23072315#export-csv:hover { color: var(--text); border-color: var(--accent); }
@@ -2335,6 +2343,25 @@ select.panel:hover { border-color: var(--accent); }
23352343.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; }
23362344.chrome-btn:hover { color: var(--text); border-color: var(--accent); }
23372345.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}
23382365/* One glyph at a time. `theme.isDark()` writes data-t, and these pick the matching
23392366 icon -- the same contract bughq's header uses, so the two toggles behave alike. */
23402367.theme-btn .ic-sun, .theme-btn .ic-moon { display: none; }
@@ -2343,9 +2370,9 @@ select.panel:hover { border-color: var(--accent); }
23432370</style>
23442371@endpush
23452372
2346{{-- A <div>, not <main>. The router container is the single <main> in
2347 resources/layouts/app.stx: fragment extraction slices from the first <main> to the
2348 LAST </main>, so a page that declares its own makes the router swap the wrong span.
2373{{-- A div rather than another main. The router container is owned by
2374 resources/layouts/app.stx: fragment extraction uses that single container,
2375 so a page that declares its own makes the router swap the wrong span.
23492376 Same classes as before, one tag name different. --}}
23502377@section('content')
23512378<div class="px-4 py-7 md:px-8 lg:px-10 min-h-[100dvh]">
@@ -2470,8 +2497,8 @@ select.panel:hover { border-color: var(--accent); }
24702497 <section class="grid grid-cols-2 lg:grid-cols-7 mb-6 border-border lg:divide-x panel reveal" aria-label="Key metrics">
24712498 @foreach (kpiCards as kpi)
24722499 <div class="px-5 py-5 border-border">
2473 <div class="mb-2 font-medium text-text-3 text-xs tracking-wider uppercase">{{ kpi.label }}</div>
2474 <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>
24752502 @if (kpi.delta !== null)
24762503 <div class="mt-1.5 font-medium text-xs mono" style="color: ">
24772504 {{ kpi.delta >= 0 ? '▲' : '▼' }} {{ Math.abs(kpi.delta) }}% <span class="text-text-3">vs prev</span>
@@ -2503,7 +2530,7 @@ select.panel:hover { border-color: var(--accent); }
25032530
25042531 The tab strip is x-for over the server-built catalogue, and the body is
25052532 reactive because the origin can only be resolved in the browser — see the
2506 installCode() note in the client block. {{ }} HTML-escapes the snippet so
2533 installCode() note in the client block. Server interpolation HTML-escapes the snippet so
25072534 the tag shows as text rather than executing. --}}
25082535 @if (ownsActive)
25092536 <section class="border-border panel site-settings-panel">
@@ -2514,7 +2541,7 @@ select.panel:hover { border-color: var(--accent); }
25142541 <div class="pb-5 px-5">
25152542 {{-- Tabs and prose are SERVER-rendered (@foreach, static labels, server
25162543 mustaches) so this panel still says something useful before the
2517 bundle runs. An x-for with a reactive {{ }} label cloaked the whole
2544 bundle runs. An x-for with a reactive text label cloaked the whole
25182545 body — x-cloak is display:none, so a new user with nothing to look
25192546 at but install instructions got a blank box. Only the code block
25202547 below is reactive, and only because the origin lives in the
@@ -2527,9 +2554,9 @@ select.panel:hover { border-color: var(--accent); }
25272554 role="tab"
25282555 @click="installTab.set('')"
25292556 :style="installTab() === ''
2530 ? 'padding:5px 11px;border-radius:999px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer'
2557 ? 'padding:5px 11px;border-radius:999px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer'
25312558 : 'padding:5px 11px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer'"
2532 style="padding:5px 11px;border-radius:999px;border:1px solid ;background:;color:;font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer"
2559 style="padding:5px 11px;border-radius:999px;border:1px solid ;background:;color:;font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer"
25332560 >{{ opt.label }}</button>
25342561 @endforeach
25352562 </div>
@@ -2553,7 +2580,7 @@ select.panel:hover { border-color: var(--accent); }
25532580
25542581 Lists are SERVER-rendered with @foreach and server mustaches, for the
25552582 reason spelled out on the install panel above: an x-for whose rows carry
2556 reactive {{ }} makes stx cloak the container, and x-cloak is display:none.
2583 reactive text makes stx cloak the container, and x-cloak is display:none.
25572584 A mutation re-reads from the server rather than patching a reactive list
25582585 in place — invites are rare and correctness is worth more than avoiding a
25592586 reload here.
@@ -2611,21 +2638,22 @@ select.panel:hover { border-color: var(--accent); }
26112638 <input
26122639 type="email"
26132640 x-model="inviteEmail"
2641 aria-label="Teammate email"
26142642 placeholder="teammate@example.com"
26152643 autocomplete="off"
26162644 style="flex:1 1 15rem;min-height:34px;padding:0 0.65rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:400 13px/1 var(--sans,ui-sans-serif)"
26172645 >
2618 <select x-model="inviteRole" style="min-height:34px;padding:0 0.5rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:400 13px/1 var(--sans,ui-sans-serif)">
2646 <select x-model="inviteRole" aria-label="Teammate role" style="min-height:34px;padding:0 0.5rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:400 13px/1 var(--sans,ui-sans-serif)">
26192647 <option value="viewer">can view</option>
26202648 <option value="admin">can manage</option>
26212649 </select>
2622 {{-- Static label. It was ``, and stx cloaks any element whose own text is
2650 {{-- Static label. It used to choose between Sending and Send
2651 invitation reactively, and stx cloaks any element whose own text is
26242652 a reactive mustache — so the primary control on this panel was
26252653 display:none until the bundle ran. Busy state is carried by
26262654 :disabled, an ATTRIBUTE binding, which does not cloak, and by
26272655 the sibling below, which is supposed to start hidden. --}}
2628 <button type="button" @click="sendInvite()" :disabled="inviteBusy()" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Send invitation</button>
2656 <button type="button" @click="sendInvite()" :disabled="inviteBusy()" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Send invitation</button>
26292657 <span :show="inviteBusy()" style="display:none;color:var(--text-3);font:400 12px/1 var(--sans,ui-sans-serif)">Sending…</span>
26302658 </div>
26312659 <p class="mt-2 text-text-3 text-xs">They get an emailed link that works once, for that address only, and expires in 14 days.</p>
@@ -2636,7 +2664,7 @@ select.panel:hover { border-color: var(--accent); }
26362664 <div class="mt-4 px-4 py-3" style="border: 1px solid var(--border); border-radius: 10px; background: var(--bg)">
26372665 <p class="mb-1 font-semibold text-sm">Invite your teammates with Pro</p>
26382666 <p class="mb-3 text-text-3 text-xs">Share this site with as many people as you like, each with their own login and their own level of access. Included in Pro at no extra cost per person.</p>
2639 <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink>
2667 <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink>
26402668 </div>
26412669 @endif
26422670 @endif
@@ -2694,7 +2722,7 @@ select.panel:hover { border-color: var(--accent); }
26942722 in separate elements for the same reason: a reactive child cloaks
26952723 its parent. --}}
26962724 <div class="flex flex-wrap gap-3 items-center mt-4">
2697 <button type="button" @click="importFromGa" :disabled="gaBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button>
2725 <button type="button" @click="importFromGa" :disabled="gaBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button>
26982726 </div>
26992727 <p class="mt-2 text-text-3 text-xs" :show="gaProgress()" style="display:none">{{ gaProgress() }}</p>
27002728 <p class="mt-3 text-sm text-text-2" :show="gaResult()" style="display:none">{{ gaResult() }}</p>
@@ -2778,7 +2806,7 @@ select.panel:hover { border-color: var(--accent); }
27782806 button's own text would hide the control until hydration. --}}
27792807 <div class="flex flex-wrap gap-3 items-center mt-4">
27802808 <button type="button" @click="checkFathomExport" :disabled="fathomBusy()" style="padding:8px 13px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--text-2);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Check this export</button>
2781 <button type="button" @click="importFromFathom" :disabled="fathomBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button>
2809 <button type="button" @click="importFromFathom" :disabled="fathomBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button>
27822810 </div>
27832811
27842812 <p class="mt-2 text-text-3 text-xs" :show="fathomProgress()" style="display:none">{{ fathomProgress() }}</p>
@@ -2807,7 +2835,7 @@ select.panel:hover { border-color: var(--accent); }
28072835 <div class="mt-1 px-4 py-3" style="border: 1px solid var(--border); border-radius: 10px; background: var(--bg)">
28082836 <p class="mb-1 font-semibold text-sm">Import your Fathom history with Pro</p>
28092837 <p class="mb-3 text-text-3 text-xs">Upload your Fathom export and keep every number you already have, sitting alongside the traffic we start tracking today. Included in Pro at no extra cost.</p>
2810 <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink>
2838 <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink>
28112839 </div>
28122840 @endif
28132841 </div>
@@ -2865,7 +2893,7 @@ select.panel:hover { border-color: var(--accent); }
28652893 @endif
28662894
28672895 {{-- Share control (owner-only): reactive create/copy/revoke of a read-only link.
2868 Signals live in the <script client> block; shareUrl is seeded from the
2896 Signals live in the client script block; shareUrl is seeded from the
28692897 server-bridged existing token so the current link shows on load. --}}
28702898 @if (ownsActive)
28712899 <div class="p-5 panel site-settings-panel">
@@ -2883,7 +2911,7 @@ select.panel:hover { border-color: var(--accent); }
28832911 <button type="button" @click="copyShare" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ shareCopied() ? 'Copied' : 'Copy' }}</button>
28842912 <button type="button" @click="revokeShare" :disabled="shareBusy()" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-3);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">Revoke</button>
28852913 </div>
2886 <button type="button" @click="createShare" :disabled="shareBusy()" :show="!shareUrl()" style="display:none;padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ shareBusy() ? 'Creating…' : 'Create share link' }}</button>
2914 <button type="button" @click="createShare" :disabled="shareBusy()" :show="!shareUrl()" style="display:none;padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ shareBusy() ? 'Creating…' : 'Create share link' }}</button>
28872915 </div>
28882916 </div>
28892917 @endif
@@ -3170,9 +3198,9 @@ select.panel:hover { border-color: var(--accent); }
31703198 that way. The map is drawn into an empty div by a plain module script, so
31713199 nothing in this subtree is reactive and nothing can be hidden.
31723200
3173 A <div>, not <main>: the router's fragment extraction slices from the
3174 first <main> to the last </main>, so a second one makes the SPA swap the
3175 wrong span.
3201 This remains a div because the router's fragment extraction expects the
3202 one main owned by the layout. A second one makes the SPA swap the wrong
3203 span.
31763204
31773205 The bar list below is NOT redundant. A browser-side map is blank until
31783206 the bundle runs, and it is the only thing a reader gets with JS off, on a
@@ -3256,7 +3284,7 @@ select.panel:hover { border-color: var(--accent); }
32563284
32573285 {{-- Top referrers: the actual referring URLs (full referrer), a drill-down beneath the
32583286 normalized Top sources / channels. Clickable to filter; excludes empty/direct traffic.
3259 {{ }} escapes the (beacon-supplied) url. --}}
3287 Server interpolation escapes the beacon-supplied URL. --}}
32603288 <div class="mt-6 p-5 panel reveal">
32613289 <h2 class="mb-4 font-semibold text-text-3 text-xs tracking-wider uppercase">Referrers</h2>
32623290 @forelse (referrerUrls as r)
@@ -3298,20 +3326,20 @@ select.panel:hover { border-color: var(--accent); }
32983326 /api/sites/{site}/goals via @submit=addGoal. Pattern is the event NAME for
32993327 event goals or the URL PATH for pageview goals. --}}
33003328 @if (ownsActive)
3301 <form @submit="addGoal" class="flex flex-wrap gap-2 items-center mt-4 pt-4" style="border-top:1px solid var(--border)">
3302 <input name="name" required maxlength="255" placeholder="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)">
3303 <select name="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)">
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)">
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)">
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)">
33043332 <option value="event">Event</option>
33053333 <option value="pageview">Pageview</option>
33063334 </select>
3307 <input name="pattern" required maxlength="255" placeholder="Event name or /path" 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 <select name="match" 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)">
3335 <input name="pattern" required maxlength="255" placeholder="Event name or /path" aria-label="Goal event or path" 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)">
3336 <select name="match" aria-label="Goal matching rule" 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)">
33093337 <option value="exact">exact</option>
33103338 <option value="contains">contains</option>
33113339 <option value="starts_with">starts with</option>
33123340 </select>
3313 <input name="value" type="number" step="any" placeholder="Value" style="width:80px;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)">
3314 <button type="submit" :disabled="goalBusy()" style="padding:7px 13px;border-radius:8px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ goalBusy() ? 'Adding…' : 'Add goal' }}</button>
3341 <input name="value" type="number" step="any" placeholder="Value" aria-label="Goal value" style="width:80px;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)">
3342 <button type="submit" :disabled="goalBusy()" style="padding:7px 13px;border-radius:8px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ goalBusy() ? 'Adding…' : 'Add goal' }}</button>
33153343 </form>
33163344 <div class="mt-2 text-xs" :show="goalError()" style="display:none;color:#ef4444">{{ goalError() }}</div>
33173345 @endif
@@ -3340,7 +3368,7 @@ select.panel:hover { border-color: var(--accent); }
33403368
33413369 {{-- Top outbound links / file downloads: the URLs behind the auto-tracked Outbound
33423370 Link / File Download events (properties.url). GROUP BY properties (dialect-portable,
3343 no JSON_EXTRACT); site+range scoped like Events. Display-only; {{ }} escapes the url. --}}
3371 no JSON_EXTRACT); site+range scoped like Events. Server interpolation escapes the URL. --}}
33443372 <div class="grid gap-6 md:grid-cols-2 mt-6">
33453373 <div class="p-5 panel reveal">
33463374 <h2 class="mb-4 font-semibold text-text-3 text-xs tracking-wider uppercase">Outbound links</h2>
@@ -3412,11 +3440,11 @@ select.panel:hover { border-color: var(--accent); }
34123440 @if (user)
34133441 <h2 class="mb-2 font-semibold text-lg">No site to show yet</h2>
34143442 <p class="mb-5 text-sm text-text-2">Add a site to get a tracking snippet and start seeing analytics here.</p>
3415 <StxLink to="/sites/new" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:#fff;text-decoration:none">Add a site</StxLink>
3443 <StxLink to="/sites/new" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:var(--accent-ink);text-decoration:none">Add a site</StxLink>
34163444 @else
34173445 <h2 class="mb-2 font-semibold text-lg">Sign in to view analytics</h2>
34183446 <p class="mb-5 text-sm text-text-2">This dashboard is private to the site owner.</p>
3419 <StxLink to="/login" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:#fff;text-decoration:none">Sign in</StxLink>
3447 <StxLink to="/login" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:var(--accent-ink);text-decoration:none">Sign in</StxLink>
34203448 @endif
34213449 </div>
34223450 @endif
@@ -3432,7 +3460,7 @@ select.panel:hover { border-color: var(--accent); }
34323460{{-- Pre-render token guard: send a signed-out visitor to /login before paint.
34333461
34343462 This USED to also mirror the localStorage token into the analyticshq_token cookie
3435 and reload once, so the <script server> block could owner-scope the switcher and
3463 and reload once, so the server script block could owner-scope the switcher and
34363464 setup panel. Both halves are gone (#33): stacks 0.70.369 has LoginAction set the
34373465 cookie in its own response, so it is present on the very first authenticated
34383466 document request — and the cookie is HttpOnly, so this block could not have written
@@ -3545,7 +3573,7 @@ select.panel:hover { border-color: var(--accent); }
35453573 dragging: false,
35463574 scrollWheelZoom: false,
35473575 doubleClickZoom: false,
3548 touchZoom: false,
3576 pinchZoom: false,
35493577 keyboard: false,
35503578 })
35513579 layer = new GeoJSON(world, { style: styleFor(tokens()) })
resources/views/forgot-password.stxmodified+2-2
Changes to resources/views/forgot-password.stx
@@ -93,8 +93,8 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
9393 @endif
9494
9595 <div>
96 <label class="block mb-1.5 font-medium text-text-2 text-xs">Email</label>
97 <input name="email" type="email" required autofocus autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field">
96 <label for="forgot-email" class="block mb-1.5 font-medium text-text-2 text-xs">Email</label>
97 <input id="forgot-email" name="email" type="email" required autofocus autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field">
9898 </div>
9999
100100 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
resources/views/invite.stxmodified+4-4
Changes to resources/views/invite.stx
@@ -89,11 +89,11 @@ const roleLabel = invite?.role === 'admin' ? 'view the reports and change settin
8989// Round-trips the reader back here after they sign in or register.
9090const returnTo = `/invite?token=${encodeURIComponent(token)}`
9191
92// The token is the only thing that crosses. Everything else this block resolved
92// The capability is the only thing that crosses. Everything else this block resolved
9393// — the invite row, the viewer, the refusal — stays server-side: the client has
9494// no decision to make, it only posts the token back for the server to re-check.
95const inviteToken = token
96defineClientPayload({ inviteToken })
95const inviteCapability = token
96defineClientPayload({ inviteCapability })
9797</script>
9898
9999<script client>
@@ -110,7 +110,7 @@ async function accept(): Promise<void> {
110110 const res = await session.authFetch('/api/invites/accept', {
111111 method: 'POST',
112112 headers: { 'content-type': 'application/json' },
113 body: JSON.stringify({ token: inviteToken }),
113 body: JSON.stringify({ token: inviteCapability }),
114114 })
115115 const d = await res.json().catch(() => ({}))
116116 if (res.ok && d.siteId) {
resources/views/login.stxmodified+4-4
Changes to resources/views/login.stx
@@ -79,15 +79,15 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
7979 @endif
8080
8181 <div>
82 <label class="block mb-1.5 font-medium text-text-2 text-xs">Email</label>
83 <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field">
82 <label for="login-email" class="block mb-1.5 font-medium text-text-2 text-xs">Email</label>
83 <input id="login-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field">
8484 </div>
8585 <div>
8686 <div class="flex items-baseline justify-between mb-1.5">
87 <label class="font-medium text-text-2 text-xs">Password</label>
87 <label for="login-password" class="font-medium text-text-2 text-xs">Password</label>
8888 <StxLink to="/forgot-password" class="text-text-3 text-xs hover:text-accent">Forgot?</StxLink>
8989 </div>
90 <input name="password" type="password" required autocomplete="current-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field">
90 <input id="login-password" name="password" type="password" required autocomplete="current-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field">
9191 </div>
9292
9393 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
resources/views/register.stxmodified+6-6
Changes to resources/views/register.stx
@@ -102,16 +102,16 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
102102 @endif
103103
104104 <div>
105 <label class="block mb-1.5 font-medium text-text-2 text-xs">Name</label>
106 <input name="name" type="text" required autocomplete="name" placeholder="Ada Lovelace" class="px-3.5 py-2.5 w-full text-sm field">
105 <label for="register-name" class="block mb-1.5 font-medium text-text-2 text-xs">Name</label>
106 <input id="register-name" name="name" type="text" required autocomplete="name" placeholder="Ada Lovelace" class="px-3.5 py-2.5 w-full text-sm field">
107107 </div>
108108 <div>
109 <label class="block mb-1.5 font-medium text-text-2 text-xs">Email</label>
110 <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field">
109 <label for="register-email" class="block mb-1.5 font-medium text-text-2 text-xs">Email</label>
110 <input id="register-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field">
111111 </div>
112112 <div>
113 <label class="block mb-1.5 font-medium text-text-2 text-xs">Password</label>
114 <input name="password" type="password" required autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field">
113 <label for="register-password" class="block mb-1.5 font-medium text-text-2 text-xs">Password</label>
114 <input id="register-password" name="password" type="password" required autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field">
115115 </div>
116116
117117 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
resources/views/reset-password.stxmodified+4-4
Changes to resources/views/reset-password.stx
@@ -128,12 +128,12 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
128128 @endif
129129
130130 <div>
131 <label class="block mb-1.5 font-medium text-text-2 text-xs">New password</label>
132 <input name="password" type="password" required autofocus autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field">
131 <label for="reset-password" class="block mb-1.5 font-medium text-text-2 text-xs">New password</label>
132 <input id="reset-password" name="password" type="password" required autofocus autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field">
133133 </div>
134134 <div>
135 <label class="block mb-1.5 font-medium text-text-2 text-xs">Confirm password</label>
136 <input name="password_confirmation" type="password" required autocomplete="new-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field">
135 <label for="reset-password-confirmation" class="block mb-1.5 font-medium text-text-2 text-xs">Confirm password</label>
136 <input id="reset-password-confirmation" name="password_confirmation" type="password" required autocomplete="new-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field">
137137 </div>
138138
139139 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
resources/views/sites/new.stxmodified+4-4
Changes to resources/views/sites/new.stx
@@ -93,12 +93,12 @@ async function handleSubmit(e: SubmitEvent): Promise<void> {
9393 @endif
9494
9595 <div>
96 <label class="block mb-1.5 font-medium text-text-2 text-xs">Site name</label>
97 <input name="name" type="text" required autofocus placeholder="My Blog" class="px-3.5 py-2.5 w-full text-sm field">
96 <label for="site-name" class="block mb-1.5 font-medium text-text-2 text-xs">Site name</label>
97 <input id="site-name" name="name" type="text" required autofocus placeholder="My Blog" class="px-3.5 py-2.5 w-full text-sm field">
9898 </div>
9999 <div>
100 <label class="block mb-1.5 font-medium text-text-2 text-xs">Domain <span class="text-text-3">(optional)</span></label>
101 <input name="domain" type="text" placeholder="example.com" class="px-3.5 py-2.5 w-full text-sm field">
100 <label for="site-domain" class="block mb-1.5 font-medium text-text-2 text-xs">Domain <span class="text-text-3">(optional)</span></label>
101 <input id="site-domain" name="domain" type="text" placeholder="example.com" class="px-3.5 py-2.5 w-full text-sm field">
102102 </div>
103103
104104 <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn">
routes/analytics.tsmodified+7-19
Changes to routes/analytics.ts
@@ -20,6 +20,7 @@ import { formatCount, renderBadge, renderSparkline, sanitizeLabel } from '../app
2020import { ASSIGNABLE_ROLES, isAssignableRole, listSiteMembers, resolveSiteRole, satisfies, siteExists, type SiteRole } from '../app/Analytics/access'
2121import { ALERT_CONDITIONS, ALERT_METRICS, isAlertCondition, isAlertMetric, isRelative } from '../app/Analytics/alerts'
2222import { planForSite } from '../app/Analytics/entitlements'
23import { serializeEventProperties } from '../app/Analytics/event-properties'
2324import { expiryFrom, hashToken, inviteRefusal, looksLikeEmail, mintToken, normalizeEmail } from '../app/Analytics/invites'
2425import { sendSiteInvite } from '../app/Mail/SiteInvite'
2526import { featureUnavailableMessage, isUnlimited, limitReachedMessage, type PlanFeatures, type PlanLimits } from '../config/plans'
@@ -551,25 +552,12 @@ route.post('/collect', async (request: any) => {
551552 }).execute()
552553 }
553554 else {
554 // Reserved auto-tracked events (Outbound Link / File Download) carry only a url. Store
555 // it canonically as {"url":...} regardless of any extra keys / key-order a caller sends,
556 // so the dashboard's GROUP BY properties aggregates exactly one row per URL — a client
557 // can't split or pollute a URL's row by appending junk keys.
558 let props = body.p ? JSON.stringify(body.p) : null
559 if ((String(event) === 'Outbound Link' || String(event) === 'File Download') && body.p && body.p.url) {
560 let url = String(body.p.url)
561 props = JSON.stringify({ url })
562 // properties is varchar(255): trim the url until the wrapped JSON fits, so it stays
563 // valid JSON (the dashboard JSON.parses it) and never overflows the column — on Postgres
564 // an over-length varchar insert errors (22001) and would 500 the beacon. Only pathologically
565 // long hrefs hit the loop. (TODO: widen custom_events.properties for full-length urls.)
566 while (props.length > 255 && url.length) {
567 url = url.slice(0, -8)
568 props = JSON.stringify({ url })
569 }
570 }
571 // .catch like the sites/sessions inserts above: a storage failure (e.g. an over-length
572 // non-reserved props blob under strict sql_mode) must never 500 the public beacon.
555 // Reserved auto-tracked events carry only their canonical URL, so grouping
556 // stays stable even when a caller sends extra keys in a different order.
557 // Optional metadata is bounded before it reaches the database.
558 const props = serializeEventProperties(event, body.p)
559 // .catch like the sites/sessions inserts above: a storage failure must
560 // never turn the public beacon into a 500 response.
573561 await db.insertInto('custom_events').values({
574562 id: randomId(),
575563 site_id: String(siteId),
scripts/smoke-deployment.tsadded+63-0
Changes to scripts/smoke-deployment.ts
@@ -0,0 +1,63 @@
1import { readFileSync } from 'node:fs'
2import { resolve } from 'node:path'
3
4interface SmokeContract {
5 domain: string
6 homeText: string
7}
8
9const CONTRACTS: Record<string, SmokeContract> = {
10 analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq' },
11 bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.' },
12 commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.' },
13 loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.' },
14 reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves' },
15 status: { domain: 'statushq.org', homeText: 'Know the moment' },
16}
17
18const projectRoot = resolve(import.meta.dir, '..')
19const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string }
20const packageName = String(packageJson.name || '')
21const directoryName = projectRoot.split('/').pop() || ''
22const appName = CONTRACTS[packageName] ? packageName : directoryName
23const contract = CONTRACTS[appName]
24
25if (!contract) throw new Error(`No deployment smoke contract exists for ${appName || '(unnamed)'}.`)
26
27const baseUrl = String(process.env.SMOKE_BASE_URL || `https://${contract.domain}`).replace(/\/$/, '')
28const attempts = Math.max(1, Number(process.env.SMOKE_ATTEMPTS || 10))
29const pauseMs = Math.max(0, Number(process.env.SMOKE_RETRY_MS || 3000))
30
31async function readWithRetry(path: string): Promise<string> {
32 let lastFailure = 'no response'
33 for (let attempt = 1; attempt <= attempts; attempt += 1) {
34 try {
35 const separator = path.includes('?') ? '&' : '?'
36 const response = await fetch(`${baseUrl}${path}${separator}smoke=${Date.now()}`, {
37 headers: { accept: path === '/robots.txt' ? 'text/plain' : 'text/html' },
38 redirect: 'follow',
39 signal: AbortSignal.timeout(10_000),
40 })
41 const body = await response.text()
42 if (response.ok) return body
43 lastFailure = `HTTP ${response.status}`
44 }
45 catch (error) {
46 lastFailure = error instanceof Error ? error.message : String(error)
47 }
48 if (attempt < attempts) await Bun.sleep(pauseMs)
49 }
50 throw new Error(`${baseUrl}${path} failed after ${attempts} attempts: ${lastFailure}`)
51}
52
53const home = await readWithRetry('/')
54if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`)
55
56const login = await readWithRetry('/login')
57if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.')
58
59const robots = await readWithRetry('/robots.txt')
60if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.')
61if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`)
62
63console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, and robots metadata are live.`)
scripts/validate-docs.tsadded+102-0
Changes to scripts/validate-docs.ts
@@ -0,0 +1,102 @@
1import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2import { dirname, join, relative, resolve } from 'node:path'
3
4const root = process.cwd()
5const docsDir = resolve(root, 'docs')
6const configPath = resolve(root, 'config/docs.ts')
7const issues = new Set<string>()
8const incomingRoutes = new Set<string>()
9const appRoutePrefixes = ['/api', '/account', '/dashboard', '/login', '/pricing', '/register', '/reports', '/status']
10
11function markdownFiles(dir: string): string[] {
12 return readdirSync(dir).flatMap((name) => {
13 const path = join(dir, name)
14 return statSync(path).isDirectory() ? markdownFiles(path) : path.endsWith('.md') ? [path] : []
15 })
16}
17
18function proseOnly(text: string): string {
19 let inFence = false
20 return text.split('\n').filter((line) => {
21 if (/^(?:```|~~~)/.test(line)) {
22 inFence = !inFence
23 return false
24 }
25 return !inFence
26 }).join('\n')
27}
28
29function routeForFile(file: string): string {
30 const path = relative(docsDir, file).replace(/\\/g, '/')
31 if (path === 'index.md') return '/'
32 return `/${path.replace(/(?:\/index)?\.md$/, '')}`
33}
34
35function resolveDocTarget(source: string, rawTarget: string): string | null {
36 let target = rawTarget.replace(/^<|>$/g, '').split(/[?#]/, 1)[0]
37 if (!target || /^(?:[a-z]+:|#|mailto:|tel:)/i.test(rawTarget)) return null
38
39 if (target === '/docs') target = '/'
40 else if (target.startsWith('/docs/')) target = target.slice('/docs'.length)
41
42 const base = target.startsWith('/') ? resolve(docsDir, `.${target}`) : resolve(dirname(source), target)
43 const candidates = [base, `${base}.md`, join(base, 'index.md')]
44 const found = candidates.find(candidate => existsSync(candidate) && statSync(candidate).isFile())
45 if (found) return found
46 if (target.startsWith('/') && appRoutePrefixes.some(prefix => target === prefix || target.startsWith(`${prefix}/`))) return null
47 return ''
48}
49
50function checkTarget(source: string, rawTarget: string): void {
51 const target = resolveDocTarget(source, rawTarget)
52 if (target === null) return
53 if (target === '') {
54 issues.add(`${relative(root, source)}: missing local target ${rawTarget}`)
55 return
56 }
57 if (target.startsWith(docsDir)) incomingRoutes.add(routeForFile(target))
58}
59
60if (!existsSync(docsDir)) {
61 console.error('Documentation directory does not exist: docs/')
62 process.exit(1)
63}
64
65const files = markdownFiles(docsDir)
66for (const file of files) {
67 const text = readFileSync(file, 'utf8')
68 const prose = proseOnly(text)
69 const name = relative(root, file)
70 const headings = prose.match(/^#\s+\S.*$/gm) ?? []
71 const homeLayout = /^layout:\s*home\s*$/m.test(text)
72
73 if (headings.length !== 1 && !(homeLayout && headings.length === 0)) {
74 issues.add(`${name}: expected one H1, found ${headings.length}`)
75 }
76
77 const fences = text.match(/^(?:```|~~~)/gm) ?? []
78 if (fences.length % 2 !== 0) issues.add(`${name}: unbalanced fenced code blocks`)
79 if (/[—–]/.test(prose)) issues.add(`${name}: contains a prohibited long dash`)
80 if (/\b(?:TODO|TBD|FIXME|XXX)\b/.test(prose)) issues.add(`${name}: contains an unfinished marker`)
81
82 for (const match of prose.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) checkTarget(file, match[1])
83 for (const match of prose.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) checkTarget(file, match[1])
84}
85
86if (existsSync(configPath)) {
87 const config = readFileSync(configPath, 'utf8')
88 for (const match of config.matchAll(/\blink:\s*['"]([^'"]+)['"]/g)) checkTarget(configPath, match[1])
89}
90
91for (const file of files) {
92 const route = routeForFile(file)
93 if (route !== '/' && !incomingRoutes.has(route)) {
94 issues.add(`${relative(root, file)}: not linked from navigation or another documentation page`)
95 }
96}
97
98console.log(`Validated ${files.length} documentation pages.`)
99if (issues.size > 0) {
100 for (const issue of [...issues].sort()) console.error(`- ${issue}`)
101 process.exit(1)
102}
scripts/validate-release.tsadded+199-0
Changes to scripts/validate-release.ts
@@ -0,0 +1,199 @@
1import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2import { extname, join, resolve } from 'node:path'
3
4interface AppContract {
5 domain: string
6 minPages: number
7 routes: Record<string, string[]>
8}
9
10const CONTRACTS: Record<string, AppContract> = {
11 analyticshq: {
12 domain: 'analyticshq.org',
13 minPages: 30,
14 routes: {
15 '/': ['analyticshq'],
16 '/compare/matomo': ['analyticshq vs Matomo', 'Point for point.'],
17 '/login': ['analyticshq'],
18 },
19 },
20 bughq: {
21 domain: 'bughq.org',
22 minPages: 25,
23 routes: {
24 '/': ['Error tracking for people who ship.', 'From throw to triaged'],
25 '/login': ['bughq'],
26 '/dashboard': ['bughq'],
27 },
28 },
29 commshq: {
30 domain: 'commshq.org',
31 minPages: 45,
32 routes: {
33 '/': ['Grow an audience worth knowing.', 'From first hello to favorite customer.'],
34 '/login': ['CommsHQ'],
35 '/dashboard/commshq': ['CommsHQ'],
36 },
37 },
38 loghq: {
39 domain: 'loghq.org',
40 minPages: 30,
41 routes: {
42 '/': ['Your logs, finally worth reading.', 'From scattered log files to one live stream.'],
43 '/login': ['loghq'],
44 '/compare/sentry': ['loghq', 'Sentry'],
45 },
46 },
47 reportshq: {
48 domain: 'reportshq.org',
49 minPages: 20,
50 routes: {
51 '/': ['Reports that build themselves', 'Three steps, and you write none of the reporting'],
52 '/login': ['ReportsHQ'],
53 '/pricing': ['ReportsHQ'],
54 },
55 },
56 status: {
57 domain: 'statushq.org',
58 minPages: 35,
59 routes: {
60 '/': ['Know the moment', 'One dashboard for uptime, SSL, DNS, and status.'],
61 '/login': ['Welcome back'],
62 '/register': ['Create your account'],
63 },
64 },
65}
66
67const projectRoot = resolve(import.meta.dir, '..')
68const distRoot = resolve(projectRoot, 'dist')
69const ssrRoot = resolve(projectRoot, '.output')
70const ssrPages = resolve(ssrRoot, 'server/pages')
71const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string }
72const packageName = String(packageJson.name || '')
73const directoryName = projectRoot.split('/').pop() || ''
74const appName = CONTRACTS[packageName] ? packageName : directoryName
75const contract = CONTRACTS[appName]
76
77if (!contract)
78 throw new Error(`No release contract is configured for package ${appName || '(unnamed)'}.`)
79
80const isSsr = existsSync(ssrPages)
81if (!existsSync(distRoot) && !isSsr)
82 throw new Error('No production artifact found. Run bun run build first.')
83
84function walkFiles(directory: string): string[] {
85 if (!existsSync(directory)) return []
86 const files: string[] = []
87 for (const entry of readdirSync(directory)) {
88 const path = join(directory, entry)
89 if (statSync(path).isDirectory()) files.push(...walkFiles(path))
90 else files.push(path)
91 }
92 return files
93}
94
95function routeArtifact(route: string): { html: string, source: string } | undefined {
96 if (!isSsr) {
97 const direct = route === '/' ? join(distRoot, 'index.html') : join(distRoot, `${route.slice(1)}.html`)
98 const nested = join(distRoot, route.slice(1), 'index.html')
99 const source = existsSync(direct) ? direct : existsSync(nested) ? nested : undefined
100 return source ? { html: readFileSync(source, 'utf8'), source } : undefined
101 }
102
103 const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-')
104 const source = join(ssrPages, `${name}.compiled.json`)
105 if (!existsSync(source)) return undefined
106 const compiled = JSON.parse(readFileSync(source, 'utf8')) as { html?: string }
107 return { html: compiled.html || '', source }
108}
109
110const pageFiles = isSsr
111 ? walkFiles(ssrPages).filter(path => path.endsWith('.compiled.json'))
112 : walkFiles(distRoot).filter(path => path.endsWith('.html'))
113
114const failures: string[] = []
115if (pageFiles.length < contract.minPages)
116 failures.push(`artifact contains ${pageFiles.length} pages; expected at least ${contract.minPages}`)
117
118const publicRoot = isSsr ? join(ssrRoot, 'public') : distRoot
119for (const [route, expectedText] of Object.entries(contract.routes)) {
120 const artifact = routeArtifact(route)
121 if (!artifact) {
122 failures.push(`${route}: production artifact is missing`)
123 continue
124 }
125
126 const withoutCode = artifact.html
127 .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
128 .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
129 .replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, ' ')
130 const visibleText = withoutCode.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
131
132 for (const fragment of expectedText) {
133 if (!visibleText.includes(fragment))
134 failures.push(`${route}: expected text is missing: ${fragment}`)
135 }
136 if (/@(if|foreach|forelse)\b/.test(visibleText))
137 failures.push(`${route}: unresolved template syntax is visible`)
138 if (/<h[12]\b[^>]*>\s*<\/h[12]>/i.test(withoutCode))
139 failures.push(`${route}: an empty primary heading was emitted`)
140 if (/\b(?:AWS_SECRET_ACCESS_KEY|DB_PASSWORD|STRIPE_SECRET_KEY)\s*[:=]\s*["']?[^\s"'<]{4,}/i.test(artifact.html))
141 failures.push(`${route}: a secret-shaped assignment was emitted`)
142
143 const assetPattern = /(?:src|href)=["'](\/[^"'?#]+)["']/gi
144 let match: RegExpExecArray | null
145 while ((match = assetPattern.exec(artifact.html))) {
146 const assetPath = match[1]
147 const looksLikeAsset = extname(assetPath) !== '' || assetPath.startsWith('/_stx/') || assetPath.startsWith('/__stx/')
148 if (!looksLikeAsset) continue
149 const local = resolve(publicRoot, `.${assetPath}`)
150 if (!local.startsWith(`${publicRoot}/`) || !existsSync(local))
151 failures.push(`${route}: referenced asset is missing: ${assetPath}`)
152 }
153}
154
155for (const metadataFile of ['sitemap.xml', 'robots.txt']) {
156 const path = join(publicRoot, metadataFile)
157 if (!existsSync(path)) {
158 failures.push(`${metadataFile}: missing from production artifact`)
159 continue
160 }
161 const contents = readFileSync(path, 'utf8')
162 if (contents.includes('http://localhost')) failures.push(`${metadataFile}: contains a localhost production URL`)
163 if (!contents.includes(contract.domain)) failures.push(`${metadataFile}: does not name ${contract.domain}`)
164}
165
166const migrationsDir = resolve(projectRoot, 'database/migrations')
167if (existsSync(migrationsDir)) {
168 for (const file of readdirSync(migrationsDir).sort()) {
169 const path = join(migrationsDir, file)
170 if (!statSync(path).isFile()) continue
171 if (readFileSync(path, 'utf8').trim().length === 0) failures.push(`migration is empty: ${file}`)
172 const sql = readFileSync(path, 'utf8')
173 if (/^(<{7}|={7}|>{7})/m.test(sql)) failures.push(`migration contains a merge marker: ${file}`)
174 }
175}
176
177if (isSsr && failures.length === 0) {
178 const { startProductionServer } = await import('@stacksjs/stx')
179 const port = 42_000 + Math.floor(Math.random() * 8_000)
180 const server = await startProductionServer({ outputDir: ssrRoot, port })
181 try {
182 for (const route of Object.keys(contract.routes)) {
183 const response = await fetch(`http://127.0.0.1:${server.port}${route}`)
184 const body = await response.text()
185 if (!response.ok) failures.push(`${route}: production server returned ${response.status}`)
186 if (!body.toLowerCase().includes(appName.toLowerCase())) failures.push(`${route}: production server response is not the expected application`)
187 }
188 }
189 finally {
190 server.stop()
191 }
192}
193
194if (failures.length) {
195 for (const failure of failures) console.error(`- ${failure}`)
196 process.exit(1)
197}
198
199console.log(`Validated ${pageFiles.length} production pages, ${Object.keys(contract.routes).length} smoke routes, metadata, assets, migrations, and ${isSsr ? 'the SSR server' : 'the SSG artifact'}.`)
scripts/validate-ui.tsadded+90-0
Changes to scripts/validate-ui.ts
@@ -0,0 +1,90 @@
1import { readdirSync, readFileSync, statSync } from 'node:fs'
2import { join, relative, resolve } from 'node:path'
3
4const root = process.cwd()
5const resources = resolve(root, 'resources')
6const issues = new Set<string>()
7
8function filesIn(directory: string): string[] {
9 return readdirSync(directory).flatMap((name) => {
10 const path = join(directory, name)
11 return statSync(path).isDirectory() ? filesIn(path) : /\.(?:stx|html|css)$/.test(path) ? [path] : []
12 })
13}
14
15function withoutComments(value: string): string {
16 const preserveLines = (comment: string): string => comment.replace(/[^\n]/g, ' ')
17 return value
18 .replace(/<!--[^]*?-->/g, preserveLines)
19 .replace(/\{\{--[^]*?--\}\}/g, preserveLines)
20 .replace(/\/\*[^]*?\*\//g, preserveLines)
21 .replace(/^\s*\/\/.*$/gm, preserveLines)
22}
23
24function lineAt(source: string, index: number): number {
25 return source.slice(0, index).split('\n').length
26}
27
28function named(attributes: string): boolean {
29 return /\b(?:aria-label|aria-labelledby|title|:aria-label)=/.test(attributes)
30}
31
32function report(file: string, source: string, index: number, message: string): void {
33 issues.add(`${relative(root, file)}:${lineAt(source, index)}: ${message}`)
34}
35
36const files = filesIn(resources)
37const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file))
38const combined = files.map(file => readFileSync(file, 'utf8')).join('\n')
39
40for (const file of markupFiles) {
41 const original = readFileSync(file, 'utf8')
42 const source = withoutComments(original)
43
44 for (const match of source.matchAll(/<img\b[^>]*>/gi)) {
45 if (!/\b(?::?alt)=/.test(match[0]))
46 report(file, source, match.index, 'image has no alt text')
47 }
48
49 for (const match of source.matchAll(/<button\b([^>]*)>([^]*?)<\/button>/gi)) {
50 const attributes = match[1]
51 const content = match[2]
52 .replace(/<[^>]+>/g, '')
53 .replace(/\{\{[^}]+\}\}/g, 'dynamic')
54 .replace(/@[a-z]+\([^)]*\)/gi, '')
55 .trim()
56 if (!content && !named(attributes))
57 report(file, source, match.index, 'icon-only button has no accessible name')
58 }
59
60 for (const match of source.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) {
61 const attributes = match[2]
62 if (/\btype=["']hidden["']/i.test(attributes) || named(attributes))
63 continue
64 const id = attributes.match(/\bid=["']([^"']+)["']/i)?.[1]
65 const before = source.slice(0, match.index)
66 const wrapped = before.lastIndexOf('<label') > before.lastIndexOf('</label>')
67 const referenced = id ? new RegExp(`<label\\b[^>]*\\bfor=["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(source) : false
68 if (!wrapped && !referenced)
69 report(file, source, match.index, `${match[1].toLowerCase()} has no programmatic label`)
70 }
71
72 for (const match of source.matchAll(/\btabindex=["']([1-9]\d*)["']/gi))
73 report(file, source, match.index, `positive tabindex ${match[1]} overrides natural keyboard order`)
74
75 for (const match of source.matchAll(/<(?:div|span)\b([^>]*\bonclick=[^>]*)>/gi)) {
76 if (!/\brole=["'](?:button|link)["']/i.test(match[1]) || !/\btabindex=["']0["']/i.test(match[1]))
77 report(file, source, match.index, 'clickable non-interactive element is not keyboard accessible')
78 }
79}
80
81if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined))
82 issues.add('resources: no visible keyboard focus treatment found')
83if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined))
84 issues.add('resources: no responsive breakpoint treatment found')
85
86console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`)
87if (issues.size) {
88 for (const issue of [...issues].sort()) console.error(`- ${issue}`)
89 process.exit(1)
90}
scripts/validate-visual.tsadded+364-0
Changes to scripts/validate-visual.ts
@@ -0,0 +1,364 @@
1import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2import { tmpdir } from 'node:os'
3import { join, resolve } from 'node:path'
4
5interface Viewport {
6 name: string
7 width: number
8 height: number
9 mobile: boolean
10}
11
12interface PageAudit {
13 contrast: string[]
14 focusable: number
15 imagesWithoutAlt: number
16 overflow: number
17 undersizedDashboardControls: string[]
18 wrappedDashboardValues: string[]
19 unresolved: boolean
20 unnamedControls: string[]
21}
22
23interface Pending {
24 reject: (error: Error) => void
25 resolve: (value: any) => void
26 timer: ReturnType<typeof setTimeout>
27}
28
29const DIST = resolve('dist')
30const SSR_OUTPUT = resolve('.output')
31const SSR_PAGES = resolve(SSR_OUTPUT, 'server/pages')
32const SSR_PUBLIC = resolve(SSR_OUTPUT, 'public')
33const OUTPUT = resolve('storage/logs/visual-contract')
34const VIEWPORTS: Viewport[] = [
35 { name: 'desktop', width: 1440, height: 1000, mobile: false },
36 { name: 'tablet', width: 768, height: 1024, mobile: false },
37 { name: 'mobile', width: 390, height: 844, mobile: true },
38]
39const THEMES = ['light', 'dark'] as const
40const ROUTE_CANDIDATES = [
41 '/',
42 '/login',
43 '/register',
44 '/dashboard',
45 '/dashboard/commshq',
46 '/reports',
47 '/projects',
48]
49
50function chromePath(): string {
51 const candidates = [
52 process.env.CHROME_PATH,
53 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
54 '/usr/bin/google-chrome',
55 '/usr/bin/google-chrome-stable',
56 '/usr/bin/chromium',
57 '/usr/bin/chromium-browser',
58 ]
59 const found = candidates.find(candidate => candidate && existsSync(candidate))
60 if (!found) throw new Error('Chrome was not found. Set CHROME_PATH to a Chrome or Chromium executable.')
61 return found
62}
63
64async function waitFor<T>(read: () => Promise<T> | T, timeout = 12_000): Promise<T> {
65 const started = Date.now()
66 let value = await read()
67 while (!value && Date.now() - started < timeout) {
68 await Bun.sleep(100)
69 value = await read()
70 }
71 if (!value) throw new Error(`Timed out after ${timeout}ms`)
72 return value
73}
74
75class Cdp {
76 private id = 0
77 private pending = new Map<number, Pending>()
78
79 constructor(private socket: WebSocket) {
80 socket.addEventListener('message', (event) => {
81 const message = JSON.parse(String(event.data))
82 if (!message.id) return
83 const pending = this.pending.get(message.id)
84 if (!pending) return
85 this.pending.delete(message.id)
86 clearTimeout(pending.timer)
87 if (message.error) pending.reject(new Error(message.error.message))
88 else pending.resolve(message.result)
89 })
90 }
91
92 send(method: string, params: Record<string, any> = {}): Promise<any> {
93 const id = ++this.id
94 return new Promise((resolve, reject) => {
95 const timer = setTimeout(() => {
96 this.pending.delete(id)
97 reject(new Error(`Chrome command timed out: ${method}`))
98 }, 15_000)
99 this.pending.set(id, { reject, resolve, timer })
100 this.socket.send(JSON.stringify({ id, method, params }))
101 })
102 }
103
104 async evaluate<T>(expression: string): Promise<T> {
105 const result = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
106 if (result.exceptionDetails)
107 throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text)
108 return result.result?.value as T
109 }
110}
111
112async function connect(url: string): Promise<Cdp> {
113 const socket = new WebSocket(url)
114 await new Promise<void>((resolve, reject) => {
115 socket.addEventListener('open', () => resolve(), { once: true })
116 socket.addEventListener('error', () => reject(new Error('Could not connect to Chrome')), { once: true })
117 })
118 return new Cdp(socket)
119}
120
121function routeName(route: string): string {
122 return route === '/' ? 'home' : route.slice(1).replaceAll('/', '-')
123}
124
125function routeFile(route: string): { kind: 'html' | 'ssr', path: string } | undefined {
126 if (existsSync(DIST)) {
127 const base = route === '/' ? join(DIST, 'index.html') : join(DIST, `${route.slice(1)}.html`)
128 if (existsSync(base)) return { kind: 'html', path: base }
129 const index = join(DIST, route.slice(1), 'index.html')
130 if (existsSync(index)) return { kind: 'html', path: index }
131 }
132
133 const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-')
134 const compiled = join(SSR_PAGES, `${name}.compiled.json`)
135 return existsSync(compiled) ? { kind: 'ssr', path: compiled } : undefined
136}
137
138function pageResponse(page: { kind: 'html' | 'ssr', path: string }): Response {
139 if (page.kind === 'html') return new Response(Bun.file(page.path))
140 const compiled = JSON.parse(readFileSync(page.path, 'utf8')) as { html?: string }
141 return new Response(compiled.html || '', { headers: { 'content-type': 'text/html; charset=utf-8' } })
142}
143
144const auditExpression = `(() => {
145 const visible = element => {
146 const style = getComputedStyle(element)
147 const rect = element.getBoundingClientRect()
148 if (element.closest('details:not([open])') && element.tagName !== 'SUMMARY') return false
149 return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0
150 && rect.width > 0 && rect.height > 0
151 }
152 const label = element => {
153 if (element.getAttribute('aria-label')) return element.getAttribute('aria-label')
154 const labelledBy = element.getAttribute('aria-labelledby')
155 if (labelledBy && labelledBy.split(/\\s+/).some(id => document.getElementById(id)?.textContent?.trim())) return labelledBy
156 if (element.id && document.querySelector('label[for="' + CSS.escape(element.id) + '"]')?.textContent?.trim()) return element.id
157 if (element.closest('label')?.textContent?.trim()) return 'wrapped'
158 if (element.tagName === 'BUTTON' && element.textContent?.trim()) return 'text'
159 if (element.tagName === 'A' && element.textContent?.trim()) return 'text'
160 if (element.getAttribute('title')) return element.getAttribute('title')
161 return ''
162 }
163 const parseRgb = value => {
164 const match = value.match(/rgba?\\(([^)]+)\\)/)
165 if (!match) return null
166 const parts = match[1].split(/[ ,/]+/).filter(Boolean).map(Number)
167 if (parts.length < 3 || parts.some(Number.isNaN)) return null
168 return { r: parts[0], g: parts[1], b: parts[2], a: parts[3] ?? 1 }
169 }
170 const luminance = color => {
171 const channel = value => {
172 const normalized = value / 255
173 return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
174 }
175 return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b)
176 }
177 const background = element => {
178 let current = element
179 while (current) {
180 const color = parseRgb(getComputedStyle(current).backgroundColor)
181 if (color && color.a >= 0.98) return color
182 current = current.parentElement
183 }
184 return { r: 255, g: 255, b: 255, a: 1 }
185 }
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 })
201 const unnamedControls = controls.filter(element => !label(element)).map(element => {
202 const identity = element.id ? '#' + element.id : element.getAttribute('name') ? '[name="' + element.getAttribute('name') + '"]' : ''
203 return element.tagName.toLowerCase() + identity
204 })
205 const contrast = []
206 const textElements = [...document.querySelectorAll('h1, h2, h3, h4, p, label, button, a, summary, span')]
207 for (const element of textElements) {
208 if (!visible(element) || !element.textContent?.trim()) continue
209 if (element.children.length && !['A', 'BUTTON', 'SUMMARY'].includes(element.tagName)) continue
210 const style = getComputedStyle(element)
211 const foreground = parseRgb(style.color)
212 const behind = background(element)
213 if (!foreground || foreground.a < 0.98) continue
214 const lighter = Math.max(luminance(foreground), luminance(behind))
215 const darker = Math.min(luminance(foreground), luminance(behind))
216 const ratio = (lighter + 0.05) / (darker + 0.05)
217 const large = Number.parseFloat(style.fontSize) >= 24 || (Number.parseFloat(style.fontSize) >= 18.66 && Number(style.fontWeight) >= 700)
218 if (ratio + 0.01 < (large ? 3 : 4.5)) {
219 const sample = element.textContent.trim().replace(/\\s+/g, ' ').slice(0, 42)
220 contrast.push(sample + ' (' + ratio.toFixed(2) + ':1)')
221 }
222 if (contrast.length >= 10) break
223 }
224 return {
225 contrast,
226 focusable: controls.length,
227 imagesWithoutAlt: [...document.images].filter(image => !image.hasAttribute('alt')).length,
228 overflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
229 undersizedDashboardControls,
230 wrappedDashboardValues,
231 unresolved: document.body.innerText.includes('{{') || document.body.innerText.includes('@if'),
232 unnamedControls,
233 }
234})()`
235
236const 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
246async function main(): Promise<void> {
247 if (!existsSync(DIST) && !existsSync(SSR_PAGES))
248 throw new Error(`Built site not found at ${DIST} or ${SSR_OUTPUT}. Run bun run build first.`)
249 const routes = ROUTE_CANDIDATES.filter(route => routeFile(route))
250 if (routes.length < 3) throw new Error(`Expected at least three visual routes, found ${routes.join(', ') || 'none'}.`)
251
252 mkdirSync(OUTPUT, { recursive: true })
253 const server = Bun.serve({
254 hostname: '127.0.0.1',
255 port: 0,
256 fetch(request) {
257 const url = new URL(request.url)
258 const route = decodeURIComponent(url.pathname)
259 const page = routeFile(route)
260 if (page) return pageResponse(page)
261 const publicRoot = existsSync(DIST) ? DIST : SSR_PUBLIC
262 const asset = resolve(publicRoot, `.${route}`)
263 const file = asset.startsWith(`${publicRoot}/`) && existsSync(asset) ? asset : undefined
264 return file ? new Response(Bun.file(file)) : new Response('Not found', { status: 404 })
265 },
266 })
267 const profile = mkdtempSync(join(tmpdir(), 'hq-visual-'))
268 const chrome = Bun.spawn([
269 chromePath(),
270 '--headless=new',
271 '--remote-debugging-port=0',
272 `--user-data-dir=${profile}`,
273 '--no-first-run',
274 '--no-default-browser-check',
275 '--disable-background-networking',
276 '--disable-component-update',
277 '--disable-extensions',
278 '--hide-scrollbars',
279 '--no-sandbox',
280 'about:blank',
281 ], { stdout: 'ignore', stderr: 'ignore' })
282
283 const failures: string[] = []
284 let screenshots = 0
285 try {
286 const portFile = join(profile, 'DevToolsActivePort')
287 await waitFor(() => existsSync(portFile), 10_000)
288 const port = Number(readFileSync(portFile, 'utf8').split('\n')[0])
289 const targets = await waitFor(async () => {
290 const items = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json()).catch(() => [])
291 return Array.isArray(items) && items.length ? items : null
292 }) as Array<{ type: string, url: string, webSocketDebuggerUrl: string }>
293 const target = targets.find(item => item.type === 'page' && item.url === 'about:blank') || targets.find(item => item.type === 'page')
294 if (!target) throw new Error('Chrome did not expose a page target.')
295
296 const cdp = await connect(target.webSocketDebuggerUrl)
297 await cdp.send('Page.enable')
298 await cdp.send('Runtime.enable')
299 await cdp.send('Emulation.setScriptExecutionDisabled', { value: true })
300
301 for (const route of routes) {
302 for (const viewport of VIEWPORTS) {
303 await cdp.send('Emulation.setDeviceMetricsOverride', {
304 width: viewport.width,
305 height: viewport.height,
306 deviceScaleFactor: 1,
307 mobile: viewport.mobile,
308 })
309 await cdp.send('Page.navigate', { url: `http://127.0.0.1:${server.port}${route}` })
310 await waitFor(() => cdp.evaluate("document.readyState === 'complete'"))
311 await Bun.sleep(100)
312 for (const theme of THEMES) {
313 await cdp.evaluate(`(() => {
314 const theme = ${JSON.stringify(theme)}
315 document.documentElement.classList.toggle('dark', theme === 'dark')
316 document.documentElement.setAttribute('data-theme', theme)
317 document.documentElement.setAttribute('data-color-mode', theme)
318 document.documentElement.setAttribute('color-mode', theme)
319 })()`)
320 await Bun.sleep(500)
321 if (route === '/dashboard') await cdp.evaluate(dashboardFixtureExpression)
322 const audit = await cdp.evaluate<PageAudit>(auditExpression)
323 if (route === '/dashboard') await cdp.evaluate("document.getElementById('visual-dashboard-fixture')?.remove()")
324 const key = `${route} at ${viewport.name}/${theme}`
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(', ')}`)
330 if (audit.unresolved) failures.push(`${key}: unresolved template expression is visible`)
331 if (audit.imagesWithoutAlt) failures.push(`${key}: ${audit.imagesWithoutAlt} image(s) have no alt attribute`)
332 if (audit.unnamedControls.length) failures.push(`${key}: unnamed controls ${audit.unnamedControls.join(', ')}`)
333 for (const issue of audit.contrast) failures.push(`${key}: low text contrast ${issue}`)
334
335 if (audit.focusable) {
336 await cdp.evaluate("document.body.setAttribute('tabindex', '-1'); document.body.focus()")
337 await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 })
338 await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 })
339 const focused = await cdp.evaluate("document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement")
340 if (!focused) failures.push(`${key}: Tab did not move focus to a control`)
341 }
342
343 const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, fromSurface: true })
344 writeFileSync(join(OUTPUT, `${routeName(route)}-${theme}-${viewport.name}.png`), Buffer.from(shot.data, 'base64'))
345 screenshots += 1
346 }
347 }
348 }
349 }
350 finally {
351 chrome.kill()
352 await chrome.exited
353 server.stop(true)
354 rmSync(profile, { force: true, recursive: true })
355 }
356
357 console.log(`Rendered ${routes.length} routes in ${THEMES.length} themes at ${VIEWPORTS.length} viewports and wrote ${screenshots} screenshots.`)
358 if (failures.length) {
359 for (const failure of failures) console.error(`- ${failure}`)
360 process.exit(1)
361 }
362}
363
364await main()
tests/fixtures/product-states.test.tsadded+19-0
Changes to tests/fixtures/product-states.test.ts
@@ -0,0 +1,19 @@
1import { describe, expect, test } from 'bun:test'
2
3const NOW = '2026-09-12T12:00:00.000Z'
4
5export const productStates = {
6 empty: { now: NOW, status: 'ready', sites: [], visitors: 0, views: 0 },
7 normal: { now: NOW, status: 'ready', sites: [{ id: 'site-demo', name: 'Northwind', visitors: 1842, views: 5291 }], visitors: 1842, views: 5291 },
8 loading: { now: NOW, status: 'loading', sites: [], visitors: null, views: null },
9 failure: { now: NOW, status: 'error', sites: [], error: 'Analytics query timed out' },
10 highVolume: { now: NOW, status: 'ready', sites: Array.from({ length: 250 }, (_, index) => ({ id: `site-${index + 1}`, name: `Site ${index + 1}`, visitors: 100_000 + index, views: 300_000 + index * 3 })) },
11} as const
12
13describe('deterministic analytics product states', () => {
14 test('covers every UI state', () => expect(Object.keys(productStates)).toEqual(['empty', 'normal', 'loading', 'failure', 'highVolume']))
15 test('keeps volume fixtures large and reproducible', () => {
16 expect(productStates.highVolume.sites).toHaveLength(250)
17 expect(JSON.stringify(productStates)).toBe(JSON.stringify(productStates))
18 })
19})
tests/unit/country-map.test.tsmodified+1-1
Changes to tests/unit/country-map.test.ts
@@ -357,7 +357,7 @@ describe('the map talks to nothing but this origin', () => {
357357
358358 test('interaction is disabled — this is a report, not an explorer', () => {
359359 const s = mapScript()
360 for (const opt of ['dragging', 'scrollWheelZoom', 'doubleClickZoom', 'touchZoom'])
360 for (const opt of ['dragging', 'scrollWheelZoom', 'doubleClickZoom', 'pinchZoom'])
361361 expect({ opt, disabled: new RegExp(`${opt}:\\s*false`).test(s) }).toEqual({ opt, disabled: true })
362362 })
363363
tests/unit/event-properties.test.tsadded+67-0
Changes to tests/unit/event-properties.test.ts
@@ -0,0 +1,67 @@
1import { describe, expect, test } from 'bun:test'
2import { readFileSync } from 'node:fs'
3import { join } from 'node:path'
4import { MAX_EVENT_PROPERTIES_BYTES, serializeEventProperties } from '../../app/Analytics/event-properties'
5
6describe('event property serialization', () => {
7 test('preserves structured metadata well beyond the old 255-character column', () => {
8 const properties = {
9 campaign: 'spring-launch',
10 notes: 'measured-context-'.repeat(700),
11 }
12 const serialized = serializeEventProperties('Signup', properties)
13
14 expect(serialized).toBe(JSON.stringify(properties))
15 expect(serialized!.length).toBeGreaterThan(10_000)
16 })
17
18 test('keeps the complete URL for reserved automatic events', () => {
19 const url = `https://example.com/${'deep/path/'.repeat(900)}?source=campaign`
20 const serialized = serializeEventProperties('Outbound Link', {
21 ignored: 'does not split the aggregate',
22 url,
23 })
24
25 expect(serialized).toBe(JSON.stringify({ url }))
26 expect(JSON.parse(serialized!).url).toBe(url)
27 })
28
29 test('canonicalizes file downloads the same way', () => {
30 expect(serializeEventProperties('File Download', {
31 url: 'https://example.com/report.csv',
32 arbitrary: true,
33 })).toBe('{"url":"https://example.com/report.csv"}')
34 })
35
36 test('omits optional properties that exceed the UTF-8 byte budget', () => {
37 const properties = { notes: 'é'.repeat(MAX_EVENT_PROPERTIES_BYTES) }
38
39 expect(serializeEventProperties('Oversized', properties)).toBeNull()
40 })
41
42 test('omits values JSON cannot serialize', () => {
43 expect(serializeEventProperties('Invalid', 1n)).toBeNull()
44 })
45})
46
47describe('event property storage', () => {
48 const root = join(import.meta.dir, '..', '..')
49
50 test('upgrades existing PostgreSQL columns to text', () => {
51 const migration = readFileSync(
52 join(root, 'database/migrations/0000000052-widen-custom-event-properties.sql'),
53 'utf8',
54 )
55
56 expect(migration).toContain('ALTER COLUMN "properties" TYPE text')
57 expect(migration).toContain('USING "properties"::text')
58 })
59
60 test('the collector uses the bounded serializer without URL clipping', () => {
61 const source = readFileSync(join(root, 'routes/analytics.ts'), 'utf8')
62
63 expect(source).toContain('serializeEventProperties(event, body.p)')
64 expect(source).not.toContain('while (props.length > 255')
65 expect(source).not.toContain('widen custom_events.properties')
66 })
67})