Frontend Setup¶
Prerequisites¶
- Node.js 24 LTS
- npm
- gcloud CLI (authenticated)
The root .nvmrc pins Node.js 24. On macOS, install fnm and enable
automatic version switching:
brew install fnm
echo 'eval "$(fnm env --use-on-cd --shell zsh)"' >> ~/.zshrc
eval "$(fnm env --use-on-cd --shell zsh)"
fnm install
Quick Start¶
From the repository root, run the bootstrap script:
This handles everything: downloads environment files, installs backend and frontend dependencies.
Manual Setup (Frontend Only)¶
If you prefer to set up just the frontend, or need to understand each step:
Step 1: Download Environment Files¶
This downloads environment files from Google Secret Manager. The .env.test file is required for development servers.
First time only: If you get permission errors, run cd backend && just setup-secrets first.
Step 2: Install Dependencies¶
Run dependency installation from frontend/, the npm workspace root:
Do not install inside a workspace member or create nested package lockfiles.
The legacy just install recipe still traverses members; use the root npm command
for dependency edits. .npmrc enforces min-release-age=7; check
npm view <pkg> time --json before selecting a newly released dependency.
Step 3: Start Development¶
For detailed information about what each command does, see Justfile Details.
Development Servers¶
Standard Development Flow¶
- Setup: Run
./bootstrap.pyor manual steps above (once per worktree) - Start dev server:
just dev - Make changes in
shared/or other packages - Build all projects with
just build
Development Commands¶
cd frontend
# Recommended: Build shared + start playground UI with hot reload
just dev
# Alternative dev servers
just dev-shared # Watch shared package only
just dev-playground # Start playground UI only (assumes shared is built)
just dev-client-backoffice # Build shared + start client backoffice
Widget Integration¶
Recommended: Using Rose Loader¶
The Rose Loader handles React loading, error handling, and initialization:
<script src="https://cdn.userose.ai/loader/rose-loader.js"></script>
<script>
window.InboundXLoader.init({
api_key: "YOUR_API_KEY",
api_host: "https://api.userose.ai/rose",
cdn_url: "https://cdn.userose.ai/widget/YOUR_DOMAIN"
});
</script>
For localhost/development:
<script src="https://cdn.userose.ai/loader/rose-loader.js"></script>
<script>
window.InboundXLoader.init({
api_key: "YOUR_API_KEY",
api_host: "https://api.userose.ai/rose",
cdn_url: "https://cdn.userose.ai/widget/YOUR_DOMAIN",
debug: true, // Enable console logging
forceDomain: "supported-domain.com" // Override domain for localhost
});
</script>
Direct Widget Initialization (Advanced)¶
For environments where the loader isn't suitable:
<script src="https://cdn.userose.ai/widget/YOUR_DOMAIN/inboundx-widget.js"
data-inboundx-auto-init="true"
data-api-key="YOUR_API_KEY"
data-api-url="https://api.userose.ai/rose"
data-site-name="example.com"></script>
Or via JavaScript:
InboundXWidget.init({
apiUrl: 'https://api.userose.ai/rose',
siteName: 'example.com',
domain: 'example.com'
});
Chrome Extension Development¶
The Chrome extension uses scoped shared entrypoints plus the public widget bootstrap:
import { getDefaultApiUrl } from '@inboundx/shared/config';
import { logger } from '@inboundx/shared/platform';
import { loadChromeExtensionWidgetApi } from '@inboundx/widget/chrome-extension-bootstrap';
const apiUrl = getDefaultApiUrl();
if (!apiUrl) {
logger.error('Rose Chrome Extension: No API URL configured');
return;
}
const api = await loadChromeExtensionWidgetApi();
await api?.init?.({
apiUrl,
deploymentType: 'chrome-extension',
forceDisplay: true,
forceInit: true,
});
Building¶
cd frontend/chrome-plugin
# Development mode with hot reload (uses local Supabase)
just dev
# Static build (uses test backend API)
just build test
The build output is in frontend/chrome-plugin/.output/chrome-mv3/ β load it as an unpacked extension in Chrome.
If a local Supabase is running, just build auto-detects it and uses local Supabase config regardless of the environment specified.
API Key Management¶
The build process requires IX_API_KEY from the environment files:
Stats Opt-Out Extension Development¶
frontend/stats-opt-out/ is a separate, tiny WXT extension that keeps your own
browsing out of Rose production stats on every site running the real widget. It
does not inject Rose (that's chrome-plugin/, which already reports
deployment_target = 'chrome-extension'). Instead, a content script writes the
rose_widget_test localStorage flag the widget reads, so the site's own widget
reports deployment_target = 'widget-test' and drops out of production numbers.
It has no env / API key / secret dependencies β it only flips a flag.
cd frontend/stats-opt-out
just install # first time only
just dev # dev browser with hot reload
just build # static build β .output/chrome-mv3/
just zip # build + zip for distribution
Load frontend/stats-opt-out/.output/chrome-mv3/ as an unpacked extension. The
popup is a π» ghost: solid = visible (counted), faded = invisible (excluded);
click to toggle. CI cuts a GitHub release on changes under
frontend/stats-opt-out/** via deploy-stats-opt-out.yml. See the package
README.md for details.
Building¶
Build All Packages¶
Individual Builds¶
just build-widget # Build widget only
just build-playground # Build playground UI only
just build-chrome-plugin # Build Chrome extension
just build-client-backoffice # Build client backoffice
Testing¶
Run Tests¶
Type Checking¶
Testing pitfalls¶
Run Vitest from frontend/. Before merge, just check runs lint, TypeScript, and
unit tests. For a targeted check, run just lint and just test-projects <name>
or npm run test:unit -- --project <name>. The edit hook only covers Biome lint.
Root npm Vitest scripts use scripts/run-vitest.mjs on macOS/Linux. Each run owns
a separate process group. Completion or SIGINT/SIGTERM/SIGHUP stops its remaining
workers, escalating to SIGKILL after one second; concurrent runs are unaffected.
Use these npm scripts or the just recipes to get cleanup; a direct npx vitest
bypasses it. Killing the supervisor with SIGKILL cannot trigger cleanup, and
children that explicitly detach into another session are outside its group.
- When testing form detection in jsdom, visible forms may still have
offsetParent === null;FormDetectionManagertreats those as hidden. If a test expectssetupFormTracking()to claim a form, defineoffsetParenton the form element. - Tests that call
setSiteConfigContext()must provide the real context shape, includingisLoaded: true; otherwiseSiteConfigManagerintentionally returns no resolver and config-dependent helpers behave as unconfigured. - Native
dispatchEventat a React component needsact(), else assertions pass vacuously against broken code. - Ensure React Testing Library cleanup runs between tests so stale renders cannot
satisfy queries. Backoffice registers it explicitly in its test setup; elsewhere,
check automatic cleanup registration before adding an
afterEach(cleanup)hook.
Simulating backoffice ingestion progress¶
On localhost or apptest.userose.ai, add simulateIngestionProgress=<percent> to
the Knowledge or Playground URL to preview an active website-ingestion state. The
value must be an integer from 0 through 99:
http://127.0.0.1:3002/config-studio/playground?simulateIngestionProgress=42
https://apptest.userose.ai/config-studio/knowledge?domain=example.com&simulateIngestionProgress=42
The parameter drives the same progress banner, Knowledge sidebar indicator, and onboarding Playground gate as a real crawl. It is a browser-only visual QA override: it does not start an ingestion job or write configuration or database data. Remove the parameter to return to the live ingestion state. Invalid values are ignored, and the override is disabled on the production backoffice host.
Deployment¶
Pre-production UI (Firebase)¶
cd frontend
# Deploy to production
just deploy-playground
# Deploy to test channel
just preview-playground
Client Backoffice (Cloudflare Pages)¶
Deployed via GitHub Actions:
mainbranch β production (app.userose.ai)stagingbranch β staging (appstaging.userose.ai)developbranch β test (apptest.userose.ai)
Logging Configuration¶
Log Levels¶
| Environment | Default Level |
|---|---|
| Development | All levels (debug) |
| Production | warn and error only |
| Test | Silent |
Override Log Level¶
LOG_LEVEL=silent # Completely silent
LOG_LEVEL=error # Only errors
LOG_LEVEL=warn # Warnings and errors
LOG_LEVEL=info # Info, warnings, errors
LOG_LEVEL=debug # All levels
Usage¶
import { logger } from '@inboundx/shared/platform';
logger.debug('Debug information');
logger.info('User action', data);
logger.warn('Deprecated feature used');
logger.error('API failed', error);
Important
Always use logger from @inboundx/shared/platform, never console.log.
Analytics Events¶
All PostHog events use rw_ prefix (Rose Widget):
| Event | Description |
|---|---|
rw_widget_impression |
Widget displayed to user |
rw_message_sent |
User sent a message |
rw_cta_clicked |
User clicked CTA button |
rw_client_form_submitted |
Form submission detected |
rw_demo_cta_rendered |
Demo CTA displayed |
rw_demo_cta_clicked |
Demo CTA clicked |
- Outbound-link click tracking: a navigating
<a>click loses its PostHog event (async event-bus torn down beforecapture). Emit synchronously viasendBeaconin the click handler βPostHogProvider.captureNavigationEventSync+isNavigationEvent. Debug with DevTools Preserve log; events are taggedrw_environment(chrome-plugin =staging) so check PostHog unfiltered. - You can't observe
$pageviewcapture offline: posthog-js defers its initial$pageviewuntil remote config resolves, so against an unreachableapi_hostnothing is emitted β including the baseline, which makes any A/B of capture flags vacuous. Assert oninst.config.capture_pageview/capture_pageleaveread off the instance afterinit, not on what hits the wire.
Event Deduplication¶
Best Practice
Handle deduplication at the component level, not in analytics functions.
// β
Correct: Component handles deduplication
useEffect(() => {
if (isVersionCompatible && currentSessionId && isInitialized) {
ChatAnalytics.trackWidgetImpression({ siteName });
}
}, [isVersionCompatible && currentSessionId && isInitialized]);
Supabase Type Generation¶
When the Supabase schema changes, follow generated types after migrations
to select the database containing that change. just gen-types defaults to
production, matching the usual workflow. Use just gen-types local for Docker-local
Supabase or just gen-types <project-ref> for a preview with unapplied migrations.
.env.local does not override this selection. Generation and formatting must both
succeed before the checked-in types are replaced.
Commit frontend/shared/src/types/database.generated.ts alongside the migration.
App packages import these types from @inboundx/shared/types.
Never Use Manual Type Assertions
Alias views are the exception. Postgres reports every view column as nullable, so
workspaces and workspace_roots (views over sites / site_roots, see
alias views)
generate string | null for columns that are NOT NULL in the table. Restore them at the
call site with .overrideTypes<Row[], { merge: false }>() and a comment saying why,
as in frontend/client-backoffice/src/services/sitesAdmin.ts (fetchAllSites). The
override also hides a malformed select() string, so exercise the query against a real
database once. Do not use it on tables.
select() needs a single string literal: supabase-js parses the selected columns
at the type level, so concatenation can yield GenericStringError. Keep the list
on one line with an explained biome-ignore format when needed; do not mask the
query error with as unknown as. Narrow CHECK-constrained columns only when the
generated type cannot express the constraint. See
frontend/client-backoffice/src/services/playgroundDatasets.ts (RUN_ITEM_COLUMNS).
Adding a supported language¶
Adding a language code to SUPPORTED_LANGUAGES is not enough β the widget's
UI strings live in several hand-maintained i18n maps. If you only add the code,
URL/locale detection will resolve the new language but every UI string falls
back to English (IX-3322: pt was a supported language for months while the
placeholder, loading progress, and labels stayed English).
When adding a language, add the new code to all of these (frontend paths are
relative to frontend/; the backend path is relative to the repository root):
shared/src/utils/content/languageUtils.tsSupportedLanguagetype unionSUPPORTED_LANGUAGESarrayLANGUAGE_NAMESrecordshared/src/config/constants.tsβ every i18n map. You don't need to enumerate them here: each one is pinned toLocalizedText = Record<SupportedLanguage, string>viasatisfies, sojust type-checknames every map still missing the new code (and rejects a misspelled one).shared/src/config/__tests__/constants.test.tsrepeats the check at runtime, including for the module-private maps.- Backend parity:
backend/packages/ixweb/ixweb/models/requests.pyβSupportedLanguageenum +full_namemap (the codeβname expansion the answer prompt uses). This one is not covered by the frontend type check β it is a separate enum in a separate language, so it still has to be updated by hand.
Troubleshooting¶
"Error: .env.test file is missing"¶
See Justfile Details - Troubleshooting for more solutions.
TypeScript Errors in Shared Package¶
Ensure shared package is built:
Chrome Extension Not Loading¶
- Check that environment files are downloaded:
ls frontend/.env.* - Rebuild:
cd frontend/chrome-plugin && just build test - Reload extension in Chrome (
chrome://extensionsβ refresh icon)
Widget Not Appearing¶
- Check browser console for errors
- Verify
data-site-namematches Supabase configuration - Check traffic control settings in PostHog