npm.io
2.30.9 • Published yesterday

@esmdev/lead-gen-ui

Licence
UNLICENSED
Version
2.30.9
Deps
0
Size
47.4 MB
Vulns
0
Weekly
0

@esmdev/lead-gen-ui

Reusable LeadOS UI. Consumed by /apps/web (the standalone SaaS) and by external enterprise suite hosts (Landmark Suite, Red Cloud OS, …).

Since v2.0 the package ships two layers:

  1. Presentational components — the headless visual library (tables, drawers, modals, primitives), one subpath per module.
  2. Batteries-included module views<LeadManagerView />, <RelationshipCRMView />: fully data-wired screens a host mounts as drop-in surfaces. These own their Convex queries/mutations, internal navigation, and chrome. This is what v2.0 added — before it, hosts had to reimplement all the data wiring themselves.

v2.1 adds embedded mode for hosts that already have their own application chrome and their own authentication (Landmark Suite, Red Cloud OS): a chrome prop that drops LeadOS's L1 nav rail + top bar, and a getToken prop on <LeadOSProvider> that bootstraps a real @convex-dev/auth session from a host-minted JWT. Both are opt-in and backward-compatible — see Embedded mode.


Setup guide for host integrators

How to embed LeadOS module views into an external host app.

Requirements

Peer dependency Version Notes
next >= 15 (App Router) Required. The wired views use next/navigation + next/link. The package is Next.js-coupled by design — every ESM host suite is a Next.js app.
react / react-dom >= 19 Host owns the singletons.
convex >= 1.30 The host's single Convex client.
@convex-dev/auth >= 0.0.92 Auth provider used by <LeadOSProvider>.

The host runs its own Next.js + Convex app and points at a LeadOS-operated Convex deployment (Option A — one shared multi-tenant backend). The host does not deploy the LeadOS backend itself. Host users never sign into LeadOS directly — they reach it through the HMAC host-SSO bridge, which turns a host-minted JWT into a real @convex-dev/auth session. See Embedded mode for the full wiring.

1. Install
npm install @esmdev/lead-gen-ui
2. Import the stylesheet — once

Every component styles itself with CSS custom properties. Import the token stylesheet once at your app root, or components render unstyled:

// app/layout.tsx
import '@esmdev/lead-gen-ui/styles.css';
3. Environment
NEXT_PUBLIC_CONVEX_URL = https://<leados-convex-deployment>.convex.cloud
4. Provider tree

The Next.js server-side auth provider wraps the root layout; everything else is one component — <LeadOSProvider> (Convex client + auth + theme + feature flags + active-org context):

// app/layout.tsx
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import { LeadOSProvider } from '@esmdev/lead-gen-ui';
import '@esmdev/lead-gen-ui/styles.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ConvexAuthNextjsServerProvider>
      <html lang="en" data-theme="dark">
        <body>
          <LeadOSProvider
            convexUrl={process.env.NEXT_PUBLIC_CONVEX_URL}
            theme={{ brand: 'Landmark Suite' }}
          >
            {children}
          </LeadOSProvider>
        </body>
      </html>
    </ConvexAuthNextjsServerProvider>
  );
}
5. Mount a module — one catch-all route each

Each module view owns its internal navigation, so the host adds a single optional-catch-all route per module:

// app/lead-manager/[[...slug]]/page.tsx
import { LeadManagerView } from '@esmdev/lead-gen-ui/lead-manager';
export default function Page() {
  return <LeadManagerView />;
}
// app/relationship-crm/[[...slug]]/page.tsx
import { RelationshipCRMView } from '@esmdev/lead-gen-ui/relationship-crm';
export default function Page() {
  return <RelationshipCRMView />;
}

That's it — /lead-manager/* and /relationship-crm/* are now live, data-wired LeadOS surfaces inside the host.

If your host already renders its own application chrome, pass chrome={false} and wire getToken — see Embedded mode.


Embedded mode

The batteries-included views default to a standalone experience: they render LeadOS's own L1 nav rail and top bar, and they expect users to sign in through LeadOS's own Convex Auth screens. That is exactly what the standalone SaaS (apps/web) wants.

A host that already has its own application chrome and its own authentication (Landmark Suite, Red Cloud OS) needs two things instead: suppress LeadOS's chrome so there is only one navigation, and hand LeadOS a session for the already-authenticated host user. v2.1 adds both as opt-in, backward-compatible props.

1. Drop the LeadOS chrome — chrome={false}

<LeadManagerView /> and <RelationshipCRMView /> accept a chrome prop. It defaults to true (the standalone experience). Pass false to suppress LeadOS's L1 nav rail and top bar — the host's own chrome is then the only app-level navigation:

// app/lead-manager/[[...slug]]/page.tsx — embedded host
import { LeadManagerView } from '@esmdev/lead-gen-ui/lead-manager';
export default function Page() {
  return <LeadManagerView chrome={false} />;
}
// Standalone SaaS — omit the prop for the full LeadOS chrome.
return <LeadManagerView />;

The module's L2 sidebar always renders — an embedded user still needs to navigate between the module's own sub-views (Lead Review, Contacts, Companies, ICP Manager …). chrome={false} removes only the redundant app-level layer, never the module's own navigation.

Container contract. Mount <LeadManagerView /> and <RelationshipCRMView /> inside a flush container — no padding, no margin, full height. In embedded mode the views render edge-to-edge: the module L2 sidebar sits flush against whatever is to its left (your host's own L1 navigation) and the content area extends to the slot's right edge. If your host app applies content padding around mounted route children, that padding pushes a visible gap between your chrome and the LeadOS sidebar — LeadOS does not try to absorb host padding with CSS, because that is fragile across hosts. Instead, wrap the LeadOS view in a zero-padding, full-height child container:

// In your host layout — give the LeadOS view a flush, full-height slot.
<div style={{ padding: 0, margin: 0, height: '100%' }}>
  <LeadManagerView chrome={false} />
</div>
2. Hand LeadOS a session — getToken

Host users have no LeadOS Convex Auth session of their own. The getToken prop on <LeadOSProvider> closes that gap. The host implements getToken by calling its own JWT issuer — a server route the host owns that mints a short-lived HS256 JWT signed with the shared LEADOS_HOST_SSO_SIGNING_KEY (the token contract lives in convex/lib/landmarkSsoBridge.ts):

// app/layout.tsx — embedded host
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import { LeadOSProvider } from '@esmdev/lead-gen-ui';
import '@esmdev/lead-gen-ui/styles.css';

// The host's OWN server route — not part of LeadOS. It authenticates the
// current host user and signs a JWT with LEADOS_HOST_SSO_SIGNING_KEY. The
// signing key stays server-side; the browser only ever sees the JWT.
async function getLeadOSToken(): Promise<string | null> {
  const res = await fetch('/api/leados-sso-token', { method: 'POST' });
  if (!res.ok) return null;          // host user not currently authenticated
  const { token } = await res.json();
  return token;
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ConvexAuthNextjsServerProvider>
      <html lang="en" data-theme="dark">
        <body>
          <LeadOSProvider
            convexUrl={process.env.NEXT_PUBLIC_CONVEX_URL}
            theme={{ brand: 'Landmark Suite' }}
            getToken={getLeadOSToken}
          >
            {children}
          </LeadOSProvider>
        </body>
      </html>
    </ConvexAuthNextjsServerProvider>
  );
}

getToken returns either a host-minted JWT, or null when the host user is not currently authenticated (LeadOS then renders an unauthenticated state rather than attempting a sign-in).

How <LeadOSProvider> uses it. On mount, if the LeadOS client is not already authenticated, the provider calls getToken() and signs in via the backend host-sso Convex Auth provider — which verifies the JWT through the HMAC bridge and provisions (or reuses) the LeadOS user, org, and membership. getToken is a one-time bootstrap credential, not a per-request token. Once sign-in succeeds, Convex Auth owns the session and refreshes its own access token via its own refresh token. The provider calls getToken again only on session-loss recovery — if the Convex Auth session is fully lost — never per query. The host's JWT can therefore be short-lived (minutes); it only has to outlive the initial handshake.

Omit getToken for the standalone SaaS — users sign in through LeadOS's own screens and the bootstrap is skipped entirely (the gate is a pure passthrough).

Security — the shared signing key

The host-SSO bridge is symmetric (HS256): one secret both signs and verifies. The shared LEADOS_HOST_SSO_SIGNING_KEY must be set on the LeadOS Convex deployment AND on every host's signing infrastructure. Treat it like any other server secret:

  • Never expose LEADOS_HOST_SSO_SIGNING_KEY to the browser. It belongs only on the host's server (its JWT issuer) and on the LeadOS Convex deployment. The browser sees only the minted JWT — never the key.
  • The JWT must be minted server-side by the host. getToken fetches it from a host-owned server route; it never signs anything client-side.
  • Anyone holding the key can mint a valid LeadOS session for any user. Rotate it as you would a database credential.

Theming — host accent overrides

By default the embedded LeadOS views render LeadOS's own purple accent (--accent #8b5cf6). A host application can override the accent color family so embedded views adopt the host's brand — pass theme.tokens (a LeadOSPalette) to <LeadOSProvider>.

The overrides are applied as CSS custom properties on a layout-transparent wrapper scoped to the embedded subtree: the host app's own styling is never touched, and nothing is written to :root.

What's themeable

v2.2.0 makes the 7-token accent family overridable. Each LeadOSPalette field maps to one CSS custom property:

LeadOSPalette field CSS custom property Purpose
accent --accent primary brand accent — active states, indicators, focus ring
accentHover --accent-hover accent hover state
accentPressed --accent-pressed accent pressed / active state
accentSubtle --accent-subtle translucent accent backgrounds — selected chips, row tints
accentOn --accent-on text/icon color on top of an accent fill (usually white)
iconTintAccent --icon-tint-accent SectionIcon accent-tone background tint
iconStrokeAccent --icon-stroke-accent SectionIcon accent-tone stroke color

Every field is optional — override the whole family or just accent. Any field left unset falls through to the LeadOS default in styles.css.

LeadOSPalette is exported from the package root, ./theme, and ./runtime.

Example — Landmark (blue brand)

Landmark's brand accent is blue (#2B7FFF — its --theme-primary-500), despite its gold logo asset. Mapping the Landmark palette onto the theme prop from the provider tree above:

import { LeadOSProvider } from '@esmdev/lead-gen-ui';
import '@esmdev/lead-gen-ui/styles.css';

<LeadOSProvider
  convexUrl={process.env.NEXT_PUBLIC_CONVEX_URL}
  theme={{
    brand: 'Landmark Suite',
    tokens: {
      accent:           '#2B7FFF',                 // --theme-primary-500
      accentHover:      '#5599FF',                 // --theme-primary-400
      accentPressed:    '#1A6BE0',                 // --theme-primary-600
      accentSubtle:     'rgba(43, 127, 255, 0.16)',
      accentOn:         '#ffffff',
      iconTintAccent:   'rgba(43, 127, 255, 0.16)',
      iconStrokeAccent: '#5599FF',
    },
  }}
>
  {children}
</LeadOSProvider>
Example — a different host (Red Cloud OS, red brand)

Any host maps its own brand the same way — only the values change:

<LeadOSProvider
  convexUrl={process.env.NEXT_PUBLIC_CONVEX_URL}
  theme={{
    brand: 'Red Cloud OS',
    tokens: {
      accent:           '#E5484D',
      accentHover:      '#EC6B6F',
      accentPressed:    '#C93A3F',
      accentSubtle:     'rgba(229, 72, 77, 0.16)',
      accentOn:         '#ffffff',
      iconTintAccent:   'rgba(229, 72, 77, 0.16)',
      iconStrokeAccent: '#EC6B6F',
    },
  }}
>
  {children}
</LeadOSProvider>
Standalone is unchanged

When no tokens are passed — as in the standalone LeadOS SaaS (apps/web) — nothing changes: no wrapper element is rendered, and the views inherit LeadOS's purple --accent defaults from styles.css exactly as before v2.2.0.

Coming in future versions

v2.2.0 themes the accent family only. Backgrounds, text, borders, status colors (--success / --warning / --error / --info) and shadows stay LeadOS's design and are not yet host-overridable. The violet-named --icon-tint-violet / --icon-stroke-violet tokens are retained as deprecated aliases of the *-accent tokens and are slated for removal in v3.0.


Frozen Convex codegen — and how to refresh it

The wired views call a fixed LeadOS Convex API surface. The package bundles a frozen snapshot of the Convex codegen (src/_generated/) so it is self-contained — a host never wires convex/_generated themselves.

Refreshing the snapshot is a deliberate, manual step — never automatic:

npm run sync:codegen   # from packages/ui/, before a version bump

It is intentionally NOT a build prebuild step: a half-finished local schema change must never leak into a published package version. Run it on purpose, review the diff, then bump the package version. A host adopts a new backend schema by upgrading the package version — that is the intended path.


Subpath exports

import { LeadOSProvider, ThemeProvider, FeatureFlagProvider } from '@esmdev/lead-gen-ui';
import { LeadManagerView }      from '@esmdev/lead-gen-ui/lead-manager';
import { RelationshipCRMView }  from '@esmdev/lead-gen-ui/relationship-crm';
import { /* AppShell, useActiveOrg, … */ } from '@esmdev/lead-gen-ui/runtime';
import '@esmdev/lead-gen-ui/styles.css';

Each module gets its own subpath (authentication-tenancy, configuration, super-admin-console, billing-and-credits, operational-observability, pipeline-orchestrator, primitives, theme, feature-flags, plus lead-manager / relationship-crm / runtime).

Conventions

  • Theme-aware: components read branding from ThemeProvider. No hardcoded ESM branding.
  • Feature-flag-aware: capability gating reads from FeatureFlagProvider.
  • runtime is externalized from every other bundle so ActiveOrgContext is a single instance across the whole package + host.

Build

npm run build --workspace=@esmdev/lead-gen-ui

Produces ESM + CJS outputs with TypeScript declarations + styles.css under dist/.

Publish

The package is published to the npm registry under the @esmdev scope. Publishing is a manual step — there is no CI publish workflow. (An earlier version of this section claimed "Publishing happens from CI"; that was never true. v2.1.0 shipped a stale dist/ as a direct result — see docs/v2-1-1-publish-fix.md.)

dist/ is a build artifact: it is gitignored and never committed. The publish workflow guarantees the published dist/ matches source via two safeguards:

  1. prepublishOnly (automatic). npm publish runs the package's prepublishOnly script first, which does a clean rebuild: rm -rf dist && npm run build. It is mechanically impossible to publish a stale dist/ — npm always rebuilds from current source before packing.

  2. verify:published (post-publish, mandatory). After every publish, run the post-publish check. It pulls the actual published tarball back from npm and asserts the compiled output carries the expected exports:

    npm run verify:published <version>     # e.g. 2.1.1

    It exits non-zero (naming the missing export) if the published artifact is stale or incomplete. This is the backstop that catches the v2.1.0 failure class. It is pure Node and can also run from CI.

Publish checklist
# from packages/ui/
npm run sync:codegen          # only if the Convex schema changed; review the diff
# bump "version" in package.json
npm publish                   # prepublishOnly clean-rebuilds dist/ automatically
npm run verify:published <version>   # MANDATORY — confirms the published artifact

If verify:published fails, the published version is bad — do not advertise it; fix and republish under a new patch version.