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:

npm install

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

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.

  • 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.
  • 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:

  • 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 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 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.

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

// ❌ 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