Skip to content

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:

./bootstrap.py

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

cd frontend
just download-env all

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:

just install

just install runs npm ci: it installs every workspace exactly as locked and never rewrites package-lock.json. To add or change a dependency, run npm install <pkg> from frontend/ and commit the lockfile change. Do not install inside a workspace member or create nested package lockfiles. .npmrc enforces min-release-age=7; check npm view <pkg> time --json before selecting a newly released dependency.

Step 3: Start Development

just dev

For detailed information about what each command does, see Justfile Details.

Development Servers

Standard Development Flow

  1. Setup: Run ./bootstrap.py or manual steps above (once per worktree)
  2. Start dev server: just dev
  3. Make changes in shared/ or other packages
  4. 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

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:

# Download env files first (from frontend/)
just download-env all

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

cd frontend
just build

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

cd frontend
npm run test:unit

Type Checking

cd frontend
just type-check

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.

Vitest detects agent sessions and switches to a quiet reporter that drops console output from passing tests, so React and Radix warnings never appear. To audit warnings as CI sees them, run node scripts/run-vitest.mjs run --reporter=default.

  • When testing form detection in jsdom, visible forms may still have offsetParent === null; FormDetectionManager treats those as hidden. If a test expects setupFormTracking() to claim a form, define offsetParent on the form element.
  • Tests that call setSiteConfigContext() must provide the real context shape, including isLoaded: true; otherwise SiteConfigManager intentionally returns no resolver and config-dependent helpers behave as unconfigured.
  • Native dispatchEvent at a React component needs act(), else assertions pass vacuously against broken code.
  • A mocked query hook (useResolvedConfig, …) must return the same object on every render. A factory like () => ({ data: {...} }) gives a new data each render, so a component that re-seeds form state in useEffect([data]) re-renders forever. The worker then runs out of memory after minutes, with no failing assertion. Build the return value once per test (see WebhookConfigSheet.test.tsx).
  • Keep extends: true on every project in vitest.config.ts. Inline projects do not inherit root test options otherwise, and without globals React Testing Library skips its automatic cleanup and never enables React's act environment. Tests then show "environment not configured to support act" and other act warnings stay hidden.

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:

  • main branch β†’ production (app.userose.ai)
  • staging branch β†’ staging (appstaging.userose.ai)
  • develop branch β†’ 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 rendered and the page visible for 1 continuous second
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 before capture). Emit synchronously via sendBeacon in the click handler β€” PostHogProvider.captureNavigationEventSync + isNavigationEvent. Debug with DevTools Preserve log; events are tagged rw_environment (chrome-plugin = staging) so check PostHog unfiltered.
  • You can't observe $pageview capture offline: posthog-js defers its initial $pageview until remote config resolves, so against an unreachable api_host nothing is emitted β€” including the baseline, which makes any A/B of capture flags vacuous. Assert on inst.config.capture_pageview / capture_pageleave read off the instance after init, not on what hits the wire.
  • A new event costs money and does not reach Supabase by default. PostHog bills per event: fire at most once per visitor session (a rw_impression_… sessionStorage flag, already in the privacy policy) unless per-occurrence counts are needed. The PostHog β†’ Supabase batch export copies only the events on its hand-managed include_events list, so an event read from session_events must be added there in the PostHog UI. The PostHog MCP batch-export-update tool has no include_events field; do not use it on this export.

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. local reads the shared inboundx-2 stack (--workdir ..). For an isolated copy under .context/<name>/, run the recipe's supabase gen types typescript --local --workdir .context/<name> command with the same --schema list, then Biome-format the output yourself.

If just gen-types also rewrites objects your migration did not touch, the checked-in file has drifted from production (objects created outside migrations, older CLI output). Keep only your change's lines, say so in the PR, and refresh the whole file in its own PR.

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

// ❌ Bad
return data as unknown as MyType;

// βœ… Good: Use generated types
type MyTableRow = Database['public']['Tables']['my_table']['Row'];

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):

  1. shared/src/utils/content/languageUtils.ts
  2. SupportedLanguage type union
  3. SUPPORTED_LANGUAGES array
  4. LANGUAGE_NAMES record
  5. shared/src/config/constants.ts β€” every i18n map. You don't need to enumerate them here: each one is pinned to LocalizedText = Record<SupportedLanguage, string> via satisfies, so just type-check names every map still missing the new code (and rejects a misspelled one). shared/src/config/__tests__/constants.test.ts repeats the check at runtime, including for the module-private maps.
  6. Backend parity: backend/packages/ixweb/ixweb/models/requests.py — SupportedLanguage enum + full_name map (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"

cd frontend
just download-env test

See Justfile Details - Troubleshooting for more solutions.

TypeScript Errors in Shared Package

Ensure shared package is built:

cd frontend
just build-widget  # or just dev to build and watch

Chrome Extension Not Loading

  1. Check that environment files are downloaded: ls frontend/.env.*
  2. Rebuild: cd frontend/chrome-plugin && just build test
  3. Reload extension in Chrome (chrome://extensions β†’ refresh icon)

Widget Not Appearing

  1. Check browser console for errors
  2. Verify data-site-name matches Supabase configuration
  3. Check traffic control settings in PostHog