@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:
- Presentational components — the headless visual library (tables, drawers, modals, primitives), one subpath per module.
- 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_KEYto 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.
getTokenfetches 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. runtimeis externalized from every other bundle soActiveOrgContextis 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:
prepublishOnly(automatic).npm publishruns the package'sprepublishOnlyscript first, which does a clean rebuild:rm -rf dist && npm run build. It is mechanically impossible to publish a staledist/— npm always rebuilds from current source before packing.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.1It 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.