You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(analytics): load Segment only with Transcend consent - #5340
The CDP frontend loaded Segment with a custom snippet stub (frontend/src/utils/segment.ts) that injected https://cdn.segment.com/analytics.js/v1/<key>/analytics.min.js straight from src/main.ts, with no consent check. Transcend's airgap.js is already the first, synchronous script in index.html (#4969), but nothing passed the visitor's choice to Segment, and Segment loaded whatever the choice.
This PR switches the frontend to the LFX Segments library (https://lfx-segment.platform.linuxfoundation.org/latest/lfx-segment-analytics.min.js, built from linuxfoundation/segment-web-scripts). That library is the Transcend consent wrapper from the Segment Consent Management epic (linuxfoundation/lfx-self-serve-ops#1141), and every other LFX property already loads it. From its consent release (v0.0.14, due on latest/ on 2026-11-23; see Open questions):
no window.airgap (blocked, missing, or late by more than 10 s): Segment never loads;
no Analytics consent: Segment never loads; withdrawn later: sending stops and the stored Segment identity is cleared;
opt-in regimes (everything except US_DNSS): waits for a confirmed choice, then loads on a later grant and sends the page view;
every event carries context.consent.categoryPreferences, and each later change sends Segment Consent Preference Updated;
ad click IDs are stripped from events without Advertising consent.
Why the library instead of wrapping the stub with withTranscend
Every call site uses only track(event, properties), page(name) and identify(userId, traits), which the library exposes one-to-one. Wrapping the stub would have meant a second copy of the regime rules, the withdrawal clean-up and the click-ID stripping in this repo, kept in sync by hand. With the library, one implementation covers every LFX property, and a fix there reaches CDP through latest/.
The write key is unchanged: init(VUE_APP_SEGMENT_KEY) passes CDP's own key, so events keep going to the same Segment source.
Changes
frontend/src/utils/segment.ts: the stub is replaced by a loader that injects the LFX library from latest/ and calls init(writeKey), plus an analytics module (track, page, identify). Calls made while the library loads are forwarded once it has loaded. The library drops calls made while Segment is held for consent; they are not replayed after a later grant. A blocked library script makes every call a no-op, silently. A library that loads without LfxSegmentsAnalytics is logged (Segment library loaded without LfxSegmentsAnalytics). A call that throws or rejects is logged (Segment call failed) instead of becoming an unhandled rejection, because no call site awaits or catches it.
init() is called before any queued call reaches the instance, and its promise is not awaited. A failed first load is retried by the library on the next consent change, so later calls must still go through.
The 11 window.analytics call sites (identify.ts, saved views, member/organization/activity pages, the LinkedIn/Reddit/Stack Overflow drawers, the paywall page) now import analytics from @/utils/segment. The Window.analytics global and its any casts and eslint-disable comments are gone.
src/main.ts: drops installAnalyticsStub(). Segment still loads only when VUE_APP_SEGMENT_KEY is a real key.
Sign-out: Auth0Service.logout(), which every sign-out path reaches (the user's logout, a failed /me at boot, a 401), calls analytics.reset() before the Auth0 redirect. The library clears the stored Segment user ID and traits and its cross-domain link IDs. It isn't awaited, so sign-out never waits on analytics.
Unit tests: vitest 4.1.11 (the version the root already uses) is added to the frontend, with frontend/vitest.config.ts (resolves the @ alias, type-checked through tsconfig.json), a test script, and a "Unit tests" step in frontend-checks.yml. The lockfile change is just that entry. eslint.config.mjs allows devDependencies in src/**/*.test.ts and vitest.config.ts.
Docs: frontend/README.md (Segment needs Transcend Analytics consent; the test script; what CI runs), frontend/CLAUDE.md (pnpm run test in the checks), frontend/docs/environment.md (VUE_APP_SEGMENT_KEY is read in config.ts).
Behaviour changes to be aware of:
The library sends one unnamed page view when Segment starts (with domain), in addition to the named page('Members'), page('Organization') and page('Activities') calls.
It also runs the LFX cross-domain identity link (lfajs_aid on links to LFX domains, removed from the address bar), as on other LFX sites.
Sign-out now clears the Segment identity, and the next anonymous ID is new.
In opt-in regimes, the boot identify is dropped while Segment waits for a choice and, with the library as released, isn't replayed after the grant: events after a mid-session grant stay anonymous until the next full load. The fix is in the library: linuxfoundation/segment-web-scripts#73 (draft) holds the latest identify and sends it on grant, as it already does for identifyAuth0User. CDP needs no change when it ships through latest/.
Type of change
Bug fix
New feature
Refactor / cleanup
Performance improvement
Chore / dependency update
Documentation
Related issue
Closes linuxfoundation/lfx-self-serve-ops#1148
Part of linuxfoundation/lfx-self-serve-ops#1141.
Investigation
Segment call sites: window.analytics.identify in shared/modules/monitoring/identify.ts; .page in the member, organization and activity list pages; .track in SavedViewManagement.vue, SavedViewForm.vue, member-list-emails.vue, temporary-paywall-page.vue and the LinkedIn/Reddit/Stack Overflow settings drawers. No reset, group or alias calls.
airgap: frontend/index.html loads airgap.js as the first <script> in <head>, synchronously, since feat: replace osano with transcend cookie consent #4969. The LFX library is injected later, from src/main.ts, which is a module script at the end of <body>. So airgap always runs first. feat: replace osano with transcend cookie consent #4969 is live on both hosts: the frontend production deploy of 2026-11-11 (commit 8bd10f1) includes it, and on 2026-11-13 a headless Chrome check of https://cm.lfx.dev/ and https://lf-staging.crowd.dev/ found window.airgap running (regime US_DNSS from a US address). Both hosts still load Segment through the old stub.
Hosts: production cm.lfx.dev, staging lf-staging.crowd.dev (the backend OpenAPI servers and ADR-0016). Neither sends a Content-Security-Policy header (nginx.kube.conf sets none), so no CSP change is needed for transcend-cdn.com or lfx-segment.platform.linuxfoundation.org.
Write key: VUE_APP_SEGMENT_KEY. The build has the placeholder CROWD_VUE_APP_SEGMENT_KEY, which scripts/docker-entrypoint.sh replaces when the container starts. Production has a real key configured. Staging has none: today's staging stub requests analytics.js/v1//analytics.min.js, and with this PR isRealSegmentKey('') keeps Segment off there.
LFX Insights: no vendored copy. Its crowd.dev submodule is a sparse checkout of services/libs/*, services/archetypes and services/base.tsconfig.json (init-submodules.sh), so frontend/index.html is not in it. analytics.load appears nowhere in linuxfoundation/insights. Insights loads the LFX library through lfxSegmentCdnUrl (frontend/app/plugins/analytics.ts).
How it was verified
pnpm run lint (0 warnings), pnpm run lint:cycles (0 new), pnpm run typecheck (0 errors before and after), pnpm run test (9 passing), and pnpm run build:production all pass, run from frontend/.
Unit tests (src/utils/segment.test.ts) cover:
placeholder keys count as no key, and with no key nothing is injected or sent;
the loader injects the latest/ library, not the Segment CDN;
init(writeKey) runs before the calls made while the library loaded (page, track, identify, reset), which are then forwarded in order, with their properties and traits;
calls still go through after a failed first init();
a call that throws is contained and logged;
a library without LfxSegmentsAnalytics is logged and sends nothing;
a blocked library script sends nothing;
index.html loads airgap.js as the first script, without async, defer or type="module", matched case-insensitively.
CDP's own calls through the real library, in headless Chrome: segment.ts bundled into a test page with a fake airgap, the library served as latest/, Segment settings and API intercepted, storage cleared before each run. Calls made at boot: identify('user-1', traits), page('Members'), track('Custom view created', …).
Library
Regime
At boot
After Analytics is granted
v0.0.14
US_DNSS, Auto
library page view, then CDP's identify, page Members, track, all with the consent stamp
n/a
v0.0.14
GDPR, no choice
nothing
library page view (anonymous); CDP's boot calls are not replayed; new calls go out with the user ID
v0.0.13
US_DNSS
the same four events, no consent stamp
n/a
v0.0.13
GDPR, no choice
the same four events, including identify with traits
no change
Sign-out with the same setup, v0.0.13 and v0.0.14: after identify, calling analytics.reset() without awaiting it and then redirecting (what Auth0Service.logout() does) leaves no ajs_user_id or ajs_user_traits in localStorage or cookies on the next page. Without the call both survive the redirect.
Browser check of the production build against latest/ as it was on 2026-11-12 (v0.0.14, wrapper 1.0.7), in headless Chrome, with a test write key. airgap.js was replaced by a fake that implements ready, getConsent, getRegimes and addEventListener. Requests to cdn.segment.com and api.segment.io were intercepted:
In every case the script order was airgap.js, then the LFX library.
Not done here: the Segment Source Debugger check on cm.lfx.dev (acceptance item 3). It needs this change deployed to production (staging has no Segment key), and frontend deploys are manual (LF Oracle Production Deploy with frontend). The Transcend allow-list for cm.lfx.dev and lf-staging.crowd.dev is already merged (linuxfoundation/lfx-transcend-config#10).
Decisions
Deploy timing: deploy once approved; no need to wait for 2026-11-23. Since 2026-11-13, latest/ serves v0.0.13 again, which has no consent gating, so until the consent release (planned for 2026-11-23) cm.lfx.dev loads Segment for every visitor, in every regime, as the stub does today, plus the library's page view and lfajs_aid. Gating then starts when latest/ moves, without a CDP deploy.
Write key: keep CDP's own Segment source (VUE_APP_SEGMENT_KEY).
Extra page view and lfajs_aid: accepted. Reports that count page views can drop the library's one with name IS NULL.
Sign-out: analytics.reset() added in this PR.
Identify after a later grant: accepted here; fixed in the library by linuxfoundation/segment-web-scripts#73 (see Behaviour changes).
latest/ vs a pinned version: keep latest/, as every LFX property does. Promotions need prod-latest approval, rollback is the Promote Latest workflow, and a broken library is now logged.
Frontend checklist
npm run lint passes with 0 warnings
npm run typecheck error count: before 0, after 0 (must not rise)
npm run build:production passes
Screenshots for UI changes: n/a, no UI change
Bundle size delta for dependency bumps: n/a, vitest is a devDependency only
Replace the custom Segment snippet stub, which injected analytics.js from
cdn.segment.com with no consent check, with the LFX Segments library from
its latest/ CDN URL. The library waits for Transcend airgap.js, never loads
Segment without Analytics consent, holds opt-in regimes until a confirmed
choice, starts on a later grant, and stamps context.consent on every event.
Call sites now go through a typed analytics module instead of
window.analytics, and the frontend gets a vitest setup for the loader.
Closeslinuxfoundation/lfx-self-serve-ops#1148
Signed-off-by: Luis Mori Guerra <[email protected]>
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You have signed the CLA already but the status is still pending? Let us recheck it.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Consent enforcement relies on a mutable external library whose source was unavailable, while deployed consent/withdrawal behavior and Segment source routing still require human validation.
No open issues 🎉
1 file not reviewed
pnpm-lock.yaml: Generated file
What changed in this PR
Replaces CDP’s direct Segment loader with the shared LFX analytics library so tracking follows Transcend consent, building on the consent-script integration from #4969.
Changes:
Loads the consent-aware LFX library with CDP’s existing write key and forwards analytics calls through a typed module.
Migrates existing tracking, page-view, and identification calls away from window.analytics.
Adds loader unit tests, Vitest configuration, and frontend CI test execution.
File
Description
pnpm-lock.yaml
Records frontend Vitest dependency resolution.
frontend/vitest.config.ts
Configures frontend unit-test discovery.
frontend/src/utils/segment.ts
Replaces the Segment stub with the LFX loader and typed analytics methods.
frontend/src/utils/segment.test.ts
Tests loading, call forwarding, failure handling, and Transcend script order.
- withAnalytics logs a failed track/page/identify instead of leaving an
unhandled rejection: no call site awaits or catches these calls, and a
method renamed on latest/ would otherwise reach Datadog RUM as an error.
- The fake library records properties and traits, so a dropped payload
fails the forwarding test.
- index.html check matches <script> tags and type=module case-insensitively
(CodeQL js/bad-tag-filter).
- Frontend docs: Segment needs Transcend Analytics consent, the test script
and CI unit-test step, VUE_APP_SEGMENT_KEY is read in config.ts.
Signed-off-by: Luis Mori Guerra <[email protected]>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Granting or re-granting analytics consent does not restore the signed-in user’s identity, leaving subsequent permitted events anonymous until the user is fetched again.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Re-identify signed-in users when Analytics consent is granted
frontend/src/utils/segment.ts:61
If authentication finishes before Analytics consent is granted, the library drops the identification call, and auth.store.ts:73-76 does not identify again until the user is fetched again. Later permitted events therefore remain anonymous; withdrawing and re-granting consent also loses identification because withdrawal clears the stored identity. Re-identify from the current signed-in auth state when consent is granted and the SDK is ready, without replaying pre-consent tracking events.
- loadLfxAnalytics logs when latest/ loads but exposes no instance, so a
renamed global no longer turns Segment off silently. A blocked script
stays silent.
- vitest.config.ts resolves the @ alias, and tsconfig type-checks it.
- README: Segment loads when Transcend allows Analytics (US_DNSS is
opt-out), not only after an explicit choice.
- Tests drop comments that restate the code.
Signed-off-by: Luis Mori Guerra <[email protected]>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Consent enforcement relies on an inaccessible, mutable external library, and mocked tests leave deployed-host consent gating, withdrawal, and Segment Source Debugger checks requiring human validation.
Auth0Service.logout(), which every sign-out path reaches (the user's
logout, a failed /me at boot, and a 401), now calls analytics.reset()
before the Auth0 redirect. The library clears the stored Segment user
ID and traits and its cross-domain link IDs, so the next person on a
shared browser isn't attributed to the previous user.
reset() is not awaited, so sign-out never waits on analytics. Checked in
headless Chrome with v0.0.13 and v0.0.14: after identify, sign-out and
the redirect, ajs_user_id and ajs_user_traits are gone from localStorage
and cookies; without the call both survive.
Signed-off-by: Luis Mori Guerra <[email protected]>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed to evaluate the critical risk that the mutable analytics artifact sends events and identity traits without consent.
No open issues 🎉
1 file not reviewed
pnpm-lock.yaml: Generated file
Review effort: Balanced
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The CDP frontend loaded Segment with a custom snippet stub (
frontend/src/utils/segment.ts) that injectedhttps://cdn.segment.com/analytics.js/v1/<key>/analytics.min.jsstraight fromsrc/main.ts, with no consent check. Transcend'sairgap.jsis already the first, synchronous script inindex.html(#4969), but nothing passed the visitor's choice to Segment, and Segment loaded whatever the choice.This PR switches the frontend to the LFX Segments library (
https://lfx-segment.platform.linuxfoundation.org/latest/lfx-segment-analytics.min.js, built from linuxfoundation/segment-web-scripts). That library is the Transcend consent wrapper from the Segment Consent Management epic (linuxfoundation/lfx-self-serve-ops#1141), and every other LFX property already loads it. From its consent release (v0.0.14, due onlatest/on 2026-11-23; see Open questions):window.airgap(blocked, missing, or late by more than 10 s): Segment never loads;US_DNSS): waits for a confirmed choice, then loads on a later grant and sends the page view;context.consent.categoryPreferences, and each later change sendsSegment Consent Preference Updated;Why the library instead of wrapping the stub with
withTranscendEvery call site uses only
track(event, properties),page(name)andidentify(userId, traits), which the library exposes one-to-one. Wrapping the stub would have meant a second copy of the regime rules, the withdrawal clean-up and the click-ID stripping in this repo, kept in sync by hand. With the library, one implementation covers every LFX property, and a fix there reaches CDP throughlatest/.The write key is unchanged:
init(VUE_APP_SEGMENT_KEY)passes CDP's own key, so events keep going to the same Segment source.Changes
frontend/src/utils/segment.ts: the stub is replaced by a loader that injects the LFX library fromlatest/and callsinit(writeKey), plus ananalyticsmodule (track,page,identify). Calls made while the library loads are forwarded once it has loaded. The library drops calls made while Segment is held for consent; they are not replayed after a later grant. A blocked library script makes every call a no-op, silently. A library that loads withoutLfxSegmentsAnalyticsis logged (Segment library loaded without LfxSegmentsAnalytics). A call that throws or rejects is logged (Segment call failed) instead of becoming an unhandled rejection, because no call site awaits or catches it.init()is called before any queued call reaches the instance, and its promise is not awaited. A failed first load is retried by the library on the next consent change, so later calls must still go through.window.analyticscall sites (identify.ts, saved views, member/organization/activity pages, the LinkedIn/Reddit/Stack Overflow drawers, the paywall page) now importanalyticsfrom@/utils/segment. TheWindow.analyticsglobal and itsanycasts and eslint-disable comments are gone.src/main.ts: dropsinstallAnalyticsStub(). Segment still loads only whenVUE_APP_SEGMENT_KEYis a real key.Auth0Service.logout(), which every sign-out path reaches (the user's logout, a failed/meat boot, a 401), callsanalytics.reset()before the Auth0 redirect. The library clears the stored Segment user ID and traits and its cross-domain link IDs. It isn't awaited, so sign-out never waits on analytics.vitest4.1.11 (the version the root already uses) is added to the frontend, withfrontend/vitest.config.ts(resolves the@alias, type-checked throughtsconfig.json), atestscript, and a "Unit tests" step infrontend-checks.yml. The lockfile change is just that entry.eslint.config.mjsallows devDependencies insrc/**/*.test.tsandvitest.config.ts.frontend/README.md(Segment needs Transcend Analytics consent; thetestscript; what CI runs),frontend/CLAUDE.md(pnpm run testin the checks),frontend/docs/environment.md(VUE_APP_SEGMENT_KEYis read inconfig.ts).Behaviour changes to be aware of:
domain), in addition to the namedpage('Members'),page('Organization')andpage('Activities')calls.lfajs_aidon links to LFX domains, removed from the address bar), as on other LFX sites.identifyis dropped while Segment waits for a choice and, with the library as released, isn't replayed after the grant: events after a mid-session grant stay anonymous until the next full load. The fix is in the library: linuxfoundation/segment-web-scripts#73 (draft) holds the latestidentifyand sends it on grant, as it already does foridentifyAuth0User. CDP needs no change when it ships throughlatest/.Type of change
Related issue
Closes linuxfoundation/lfx-self-serve-ops#1148
Part of linuxfoundation/lfx-self-serve-ops#1141.
Investigation
window.analytics.identifyinshared/modules/monitoring/identify.ts;.pagein the member, organization and activity list pages;.trackinSavedViewManagement.vue,SavedViewForm.vue,member-list-emails.vue,temporary-paywall-page.vueand the LinkedIn/Reddit/Stack Overflow settings drawers. Noreset,grouporaliascalls.frontend/index.htmlloadsairgap.jsas the first<script>in<head>, synchronously, since feat: replace osano with transcend cookie consent #4969. The LFX library is injected later, fromsrc/main.ts, which is a module script at the end of<body>. So airgap always runs first. feat: replace osano with transcend cookie consent #4969 is live on both hosts: the frontend production deploy of 2026-11-11 (commit8bd10f1) includes it, and on 2026-11-13 a headless Chrome check ofhttps://cm.lfx.dev/andhttps://lf-staging.crowd.dev/foundwindow.airgaprunning (regimeUS_DNSSfrom a US address). Both hosts still load Segment through the old stub.cm.lfx.dev, staginglf-staging.crowd.dev(the backend OpenAPI servers and ADR-0016). Neither sends aContent-Security-Policyheader (nginx.kube.confsets none), so no CSP change is needed fortranscend-cdn.comorlfx-segment.platform.linuxfoundation.org.VUE_APP_SEGMENT_KEY. The build has the placeholderCROWD_VUE_APP_SEGMENT_KEY, whichscripts/docker-entrypoint.shreplaces when the container starts. Production has a real key configured. Staging has none: today's staging stub requestsanalytics.js/v1//analytics.min.js, and with this PRisRealSegmentKey('')keeps Segment off there.crowd.devsubmodule is a sparse checkout ofservices/libs/*,services/archetypesandservices/base.tsconfig.json(init-submodules.sh), sofrontend/index.htmlis not in it.analytics.loadappears nowhere in linuxfoundation/insights. Insights loads the LFX library throughlfxSegmentCdnUrl(frontend/app/plugins/analytics.ts).How it was verified
pnpm run lint(0 warnings),pnpm run lint:cycles(0 new),pnpm run typecheck(0 errors before and after),pnpm run test(9 passing), andpnpm run build:productionall pass, run fromfrontend/.Unit tests (
src/utils/segment.test.ts) cover:latest/library, not the Segment CDN;init(writeKey)runs before the calls made while the library loaded (page,track,identify,reset), which are then forwarded in order, with their properties and traits;init();LfxSegmentsAnalyticsis logged and sends nothing;index.htmlloadsairgap.jsas the first script, withoutasync,deferortype="module", matched case-insensitively.CDP's own calls through the real library, in headless Chrome:
segment.tsbundled into a test page with a fakeairgap, the library served aslatest/, Segment settings and API intercepted, storage cleared before each run. Calls made at boot:identify('user-1', traits),page('Members'),track('Custom view created', …).US_DNSS,Autoidentify,page Members,track, all with the consent stampGDPR, no choiceUS_DNSSGDPR, no choiceidentifywith traitsSign-out with the same setup, v0.0.13 and v0.0.14: after
identify, callinganalytics.reset()without awaiting it and then redirecting (whatAuth0Service.logout()does) leaves noajs_user_idorajs_user_traitsinlocalStorageor cookies on the next page. Without the call both survive the redirect.Browser check of the production build against
latest/as it was on 2026-11-12 (v0.0.14, wrapper 1.0.7), in headless Chrome, with a test write key.airgap.jswas replaced by a fake that implementsready,getConsent,getRegimesandaddEventListener. Requests tocdn.segment.comandapi.segment.iowere intercepted:airgap.jsblocked, 12 sGDPR, no choice yetcategoryPreferencesalltrueGDPR, Analytics refused (Advertising granted)US_DNSS,AutoUS_DNSS, then Advertising withdrawnSegment Consent Preference Updated,Advertising: falseUS_DNSS, then Analytics withdrawnIn every case the script order was
airgap.js, then the LFX library.Not done here: the Segment Source Debugger check on
cm.lfx.dev(acceptance item 3). It needs this change deployed to production (staging has no Segment key), and frontend deploys are manual (LF Oracle Production Deploywithfrontend). The Transcend allow-list forcm.lfx.devandlf-staging.crowd.devis already merged (linuxfoundation/lfx-transcend-config#10).Decisions
latest/serves v0.0.13 again, which has no consent gating, so until the consent release (planned for 2026-11-23)cm.lfx.devloads Segment for every visitor, in every regime, as the stub does today, plus the library's page view andlfajs_aid. Gating then starts whenlatest/moves, without a CDP deploy.VUE_APP_SEGMENT_KEY).lfajs_aid: accepted. Reports that count page views can drop the library's one withname IS NULL.analytics.reset()added in this PR.latest/vs a pinned version: keeplatest/, as every LFX property does. Promotions needprod-latestapproval, rollback is the Promote Latest workflow, and a broken library is now logged.Frontend checklist
npm run lintpasses with 0 warningsnpm run typecheckerror count: before0, after0(must not rise)npm run build:productionpassesvitestis a devDependency onlyfrontend/package.jsononly)