Appearance
Consumer UI
The Consumer UI (luke-consumer-ui) is Lukeflow's primary orchestrator web application — the single interface tenants use to build forms, manage email and signature templates, run workflows, place phone calls, and administer access. It is a React 19 single-page app built on the TailAdmin base, and it consumes the Lukeflow Headless Libraries as vendored, pre-built dist.
Partial · deployed (dev/qa)
Repository:
luke-consumer-ui· Type: Application · Stack: React 19 / Vite 6 / TypeScript / Tailwind v4
Looking for the click-paths?
For the end-to-end user journeys through this app — sign-in, building & publishing a form, embedding it, sending for signature, requesting access, etc., each with a diagram — see Consumer UI — User Action Flows.
Overview
Lukeflow describes itself as "an orchestrator for a better future." The Consumer UI is the tenant-facing surface of that orchestrator: a Vite-bundled SPA where an authenticated user, scoped to a tenant, works across the platform's capabilities. The shell is derived from TailAdmin (MIT), themed for Lukeflow, and organized into roughly a dozen product sections spanning ~55 page-level .tsx files (47 in the authenticated app, plus auth/onboarding/public views).
Rather than embedding source for the form, email, signature, and workflow engines, the app vendors their built dist — the engines are developed and tested in their own monorepos (e.g. luke-forms) and copied in here as pinned artifacts. That keeps the app thin and the engines independently versioned and testable.
Where the source lives
This app is a consumer of the headless engines. Feature and bugfix work on a form/email/sign/workflow package happens in that package's monorepo; this repo only re-vendors the built dist and themes it. See Forms library.
Architecture
The app follows a conventional layered structure under src/:
| Layer | Path | Responsibility |
|---|---|---|
| Routes | App.tsx | React Router v7 route table; auth/capability guards |
| Shell | layout/ | AppLayout, AppSidebar, AppHeader, tenant switcher |
| Pages | pages/ | Per-section screens (~55 files across ~12 sections) |
| Components | components/ | Shared UI, tables, charts, header widgets |
| Context | context/ | AuthContext, ThemeContext, SidebarContext |
| Hooks | hooks/ | useDialog, useMediaQuery, useMutationLock |
| API / lib | lib/ | Typed gateway clients, stores, schema + guards |
The vendored-dist model
The @lukeflow/* packages are declared in package.json as file: dependencies pointing at vendor/@lukeflow/<pkg>/, and reinforced with npm overrides so the vendored copy always wins. The currently vendored packages are:
| Package group | Packages |
|---|---|
| Forms | form-core, form-react, form-builder, form-embed |
email-core, email-react, email-builder | |
| Signatures | sign-core, sign-react |
| Workflow | workflow-core, workflow-builder |
| Analytics | analytics-core, analytics-react |
The ship flow for the Forms packages is scripted: npm run vendor:forms builds nothing itself but copies the whole dist/ of each package out of a sibling luke-forms checkout (override with LUKE_FORMS_DIR), derives each vendored package.json from source, refreshes public/embed.js from the embed SDK's IIFE build, and writes vendor/@lukeflow/.vendor-manifest.json with a per-file SHA-256. npm run vendor:check re-verifies that manifest and gates CI.
Why the whole dist, not a file list
Vendoring used to copy a hand-picked list of files with a hand-maintained package.json, and it lost things silently — twice. It dropped form-core's ./quickjs subpath, so the isolated-JS evaluator was built and tested upstream but never arrived (nothing failed; the capability was just absent). And it left form-react a commit stale, so an upstream stored-XSS fix sat unvendored for days. Neither is the kind of mistake more care prevents — a copy step with no verifier can't tell you what it failed to copy. Copying the whole dist, deriving exports from source, and asserting every advertised subpath exists means an entry point can't be lost in transit; the manifest check catches hand-edits and partial copies.
The other package groups still use the manual copy (index.{js,cjs,d.ts,d.cts} + styles.css). Either way, npm run build runs tsc -b first, so a passing build confirms the vendored public types still resolve against the app.
Renderer adapters must parse the schema once
@lukeflow/form-react's engine rebuilds whenever the schema object identity changes, and it reads initialValues only at build time. So an adapter that calls JSON.parse inline in its render body hands the engine a new object on every render — and any re-render of the host silently wipes the filler's in-progress answers while the inputs keep displaying them, so submitting then fails with "field is required". It bit the public embed hardest (finishing an attachment upload, or switching to the Attachments tab, re-renders the host) and the same pattern existed in the recipient portal. Both adapters now useMemo the parse on the schema string — exact, since strings compare by value, and a real schema edit still rebuilds for the builder's live preview. To show a different record under the same schema, remount with a key; changing initialValues on a mounted renderer does nothing.
App sections & capabilities
Product areas are gated by tenant subscription level. Routes for a capability are wrapped in a CapabilityRoute guard. The MVP surface is Forms + Email + Access; the PHONE, SIGNATURES and WORKFLOW capabilities are hidden by default (post-MVP — see status below).
| Section | Capability | Representative routes |
|---|---|---|
| Dashboard | — | /dashboard |
| Forms | FORMS | /forms, /forms/:id, /forms/inbox (the unified Inbox — form and email tasks), /forms/instances, /forms/:code/fill, /forms/:code/responses |
EMAIL | /email | |
| Email Templates | EMAIL | /email-templates, /email-templates/:id |
| Signatures | SIGNATURES | /signatures, /signatures/:id |
| Phone | PHONE | /phone, /phone/:id |
| Workflow | WORKFLOW | /workflow, /workflow/connections, /workflow/:id |
| Analytics | FORMS + flag | /analytics (hidden unless VITE_ANALYTICS_ENABLED) |
| Access | — | /access |
| Plans | — | /plans — tier comparison, current plan, and usage this month (submissions/emails vs limits) |
| Account | — | /account/profile, /account/settings |
| Support | — | /support |
| Auth | — | /signin, /signup, /sso-callback |
Public token routes
Three routes render outside the authenticated shell, keyed by an opaque token, for recipients who are not logged-in tenant users:
/embed/:token— embedded inbound form intake/respond/:token— outbound form response/sign/:token— signature request signing
These correspond to dedicated Vite build targets (embed, respond, render) alongside the main SPA build.
Authentication
Sessions and tenant scoping flow through AuthContext (backed by lib/authApi), part of the platform's headless WorkOS-based auth. See Authentication & Authorization.
The access token lives in memory and is renewed lazily: authed() refreshes once on a 401 and retries. refresh() is single-flight — WorkOS rotates the refresh token, so simultaneous refreshes (pages that load two resources in parallel, e.g. the forms list's live + trashed queries) would spend the cookie twice and the loser's 401 would clear it, signing the user out mid-load.
Key features
Form designer & runtime — visual builder (
FormBuilderPage), preview, fill, inbox, instances, responses, embed and send panels, plus an AI assist panel. Form settings open from a gear icon beside the form name into a tabbed dialog (FormSettingsModal: General · Submission · Legal · Appearance · Activity) — see Forms. Legal configures the consent statement fillers must accept (consent record); Appearance carries the font and the "Developed at Lukeflow" option (with a live preview of the badge): locked on for free tenants, switchable for paying ones. The badge (components/common/LukeflowBadge.tsx) and the consent gate (components/formBuilder/FormConsentGate.tsx) both render on the public embed and respond bundles.Email — template list + visual/AI builder (
EmailTemplateBuilderPage) and an email section, built on react-email components. Once a domain is verified, the Email page also registers email boxes — inbound (receive, workflow-triggering) or outbound (send, dedicated Postmark stream) addresses on the sender domain — and, beneath them, routing rules (components/email/EmailRoutingRules.tsx) deciding what arriving mail becomes: who reviews it, at what priority, or that it makes no task at all. The list states that rules run top-down and the first match wins, because a user who assumes actions accumulate will write two rules that quietly contradict each other.Inbox — one work queue for the tenant, at
/forms/inbox(the route is unchanged; the nav entry and heading are now "Inbox", since a reviewer would never look under a forms label for the email they were told to triage). Rows render by the task'skind: a form task shows its submission, an email task shows the received message (components/email/EmailMessageView.tsx). The split view lists inbound boxes as sources alongside forms, email first. See Email intake.The email body is rendered as text, never as HTML
It is markup written by an unauthenticated stranger. Injecting it — even sanitised — puts remote images (tracking pixels confirming a human read the mail), arbitrary CSS, and one sanitiser bug next to an authenticated session. HTML-only mail falls back to text recovered from the markup, so suppressing it costs nothing a reviewer needs.
Signatures — signature template builder, list, and the public signing page (post-MVP; hidden behind a flag).
Phone — call list and detail views for the Vapi-backed voice capability (post-MVP; hidden behind a flag).
Workflow — JSON→BPMN builder, runs modal, and Nango-backed connections (post-MVP; hidden behind a flag).
Access management (
/access) — a grouped console over the three access dimensions plus the people surfaces:- You — Manage My Access: what you hold, requesting more, your request history, and Sent back to you — requests a resource owner returned, with the reason and the two actions the approval process is waiting on (revise & resubmit, or withdraw).
- Requests — the approval queue for resource owners and org owners. "Deny" is presented as Send back, because that is what it now does: the request returns to the requester rather than ending. See the core-engine access-request approval workflow.
- Access model — Roles (core-engine
RoleCatalog, explained + a member×role matrix), Attributes (identity-provider profile fields, read-only) and Capabilities (per-capability grant levels, one capability at a time, plus who approves requests for it — the resource-owner group the approval task routes to; empty means the org owners). - Organization — members, candidate groups, invitations.
LukeExplains — plain-language access explanations shown wherever access is chosen or decided (request form, approval queue, grant editors).
lib/lukeExplainsderives them from the real backend contract — level semantics fromCapabilityLevel, and the blocked-action lists from the routes actually annotated@RequiresCapabilityAction(PUBLISH|DELETE)— so requester and approver see the same truthful statement of what a grant allows, what it still blocks, and what deserves a second look.Capability levels —
read | contributor | read-write, mirroring core-engineCapabilityLevel(#104). Contributor is grantable from the Capabilities section and enforced by the API; the feature screens still gate edit controls oncanWrite(read-write only), so a contributor currently sees a read-only product UI — the screens must move publish/purge controls ontocanPublish/canDeletefirst.Access provenance (forward-compatible) —
AccessProvenanceon members and grants renders a source badge and locks the control when access is owned by an external identity system. The backend does not stamp a source yet, so nothing is labelled today; the UI lights up when it starts arriving.Payments — Forms → Payments (
/forms/payments,PaymentsSettings) connects the workspace's own Stripe account (owner only; members see the status) and is where Stripe Connect returns the owner (?code=&state=is completed exactly once, even under StrictMode). The builder only offers the Payment field when the plan and platform allow it, warns when a payment form can't go live yet, and shows the server's reason when a publish is refused. The public embed and respond pages mount Stripe's card form throughlib/formPayments+@lukeflow/form-react/stripe, pay the server-priced charge (only without asking when it equals the total the field showed; otherwise the pay step asks first), fall back to a pay-only step after a decline, and let a returning recipient resume. The in-app fill page explains that a payment form must be filled by the payer. Submission details show the charge with a link into Stripe. See Forms → Payments.Plans, usage & upgrade — the
/planspage (lib/planApi,lib/usageApi,lib/billingApi) reads the tenant's tier fromGET /api/planand its month-to-date consumption fromGET /api/usage, rendering a tier comparison, the current plan, and a "Usage this month" section: per-metric bars (submissions, emails, and a live storage gauge rendered KB/MB/GB) of used-vs-limit. Fails soft — a usage hiccup never hides the plan; the bar turns red and prompts an upgrade at the cap, and unlimited tiers show the count only. WhenGET /api/billing/configreports Stripe is wired, each purchasable tier's Upgrade button opens Stripe Checkout (POST /api/billing/checkout→ redirect); otherwise it falls back to a sales-contact link. See Plans, Limits & Usage.Tenant switching — multi-tenant session switching in the sidebar.
AI assist panels — per-capability assistants (forms, email, workflow). They no longer call
luke-agentsdirectly: every turn goes through Core Engine at/api/ai(lib/agentTransport.ts), which attaches the workspace's own provider key. A402becomesAgentProviderRequiredError— never retried, since retrying cannot make a missing key appear — and the panel offers Connect your AI provider instead of an error.AI settings (
/ai) — bring your own AI provider. The workspace owner connects Groq, OpenAI, Anthropic and Gemini — as many as they like, together — each as its own collapsible section showing whether that key is working, the four characters it ends with and when it was last checked; a provider whose key has stopped working opens by default rather than hiding the one thing worth seeing. One connected provider is the workspace default; anyone may pick a model from any of them, and that choice is remembered per person, server-side. The model list is read live from their own account, so it shows what their key can actually run, and models are grouped by whether they can serve a build turn at all — a speech-to-text model stays reachable but stops sitting beside a chat model as an equal choice. The key is verified with the provider before it is stored, encrypted at rest by Core Engine, and never returned to the page — the most it shows is the last four characters. Same doctrine as Forms → Payments: Lukeflow enables the capability, the workspace brings the account that gets billed. No plan gate.Observability — Sentry (
@sentry/react) wiring vialib/observability.Autofill suppression — inputs suppress browser/password-manager autofill by default (
lib/autofill).
Technology
| Concern | Choice |
|---|---|
| Framework | React 19 (react, react-dom ^19.2) |
| Routing | React Router 7 (react-router ^7.18) |
| Build | Vite 6 (multiple config targets: main, embed, respond, render) |
| Language | TypeScript ~5.7 (tsc -b project references) |
| Styling | Tailwind CSS v4 (@tailwindcss/postcss), TailAdmin base |
| Tables / graph | @tanstack/react-table, @xyflow/react |
pdfjs-dist, react-pdf (signature rendering) | |
@react-email/components, @react-email/render | |
| Integrations | @nangohq/frontend (workflow connections) |
| Validation | zod v4; dompurify for sanitization |
| Errors | @sentry/react |
| Testing | Vitest 4 (unit) + Playwright (e2e) |
Local development
bash
npm install
npm run dev # Vite dev server on http://localhost:5173
npm run build # tsc -b + vite build + embed/respond/render targets
npm run preview # preview the production build
npm run lint # eslint . --max-warnings 0 (zero-tolerance)
npm test # vitest run — unit suite
npm run test:e2e # playwright test — end-to-end specsRe-vendoring a headless package's bundle (from its monorepo build):
bash
npm run vendor:forms # copy form-* dist + public/embed.js from ../luke-forms, write the manifest
npm run vendor:check # verify vendor/@lukeflow matches its manifest (this is what CI runs)
npm run vendor:respond # build:respond + copy bundle into vendor/
npm run vendor:embed # build:embed + copy bundle into vendor/The test suite comprises ~128 unit cases across 18 Vitest files and four Playwright specs (screens, flows, smoke, forms-builder-overflow). The screens spec is a render matrix that visits 31 routes across 4 viewports (phone 390px, tablet 768px, laptop 1280px, desktop 1536px), asserting each screen renders without crashing or horizontal overflow.
Visual regression: what the pixel suites will and won't catch
visual.spec.ts (every route) and visual-states.spec.ts (modals, drawers, error states) compare Linux screenshots against committed baselines. They run on the parity branches (develop/qa/prod) and via the manual workflow — not on feature PRs, so a legitimate redesign doesn't have to fight the gate before review.
Both share one comparison policy (e2e/support/visual.ts):
| Setting | Value | Why |
|---|---|---|
threshold | 0.2 | per-pixel colour distance — the actual antialiasing allowance |
maxDiffPixels | 20 | absolute, so a laptop shot is no more forgiving than a phone one |
Why the budget is absolute, not a ratio
It used to be maxDiffPixelRatio: 0.01, which was wrong twice over. A ratio is not an antialiasing control — AA noise is many pixels differing slightly, which is exactly what threshold handles; a ratio instead licenses some share of pixels to differ by any amount. And it scales with image size: 1% is ~3.3k pixels on a 390×844 phone but ~10.2k on a 1280×800 laptop, so the identical text edit tripped on phone and passed on laptop.
That was not theoretical. Renaming the inbox heading (2026-07-31) updated the phone baselines and left the laptop ones committed with the OLD wording, still green — passing by tolerance, not by matching. When the budget was later pinned to 0 to measure, 38 of 200 baselines turned out to have silently drifted by 25–2443 pixels each, accumulated across many PRs.
The floor was then measured rather than guessed: with every baseline freshly shot, 202/202 compared exactly equal. The runner is deterministic, so 20 is pure headroom. If this starts flaking, find the nondeterminism (an unhidden caret, a live timestamp) — don't raise the number.
Regenerating or checking baselines — Actions → E2E → Run workflow, with mode:
update— reshoot and upload avisual-baselinesartifact to download and commit. A human still approves every pixel; a job that pushed baselines itself would auto-approve regressions.verify— compare against the committed images and fail on drift, uploading the expected/actual/diff report. This is how a tolerance change (or a stale baseline) can be evaluated before it reaches a parity branch.
The sidebar is collapsed in almost every shot
SidebarContext defaults to collapsed, so the nav renders as icons and no baseline held a navigation label — renaming a nav item was pixel-free on desktop. The desktop-nav-expanded state exists to cover exactly that. Note its assertion uses toBeInViewport(), not toBeVisible(): sub-menus are an overflow-hidden container animated to height: 0, and toBeVisible() is not clipping-aware — a clipped link still reports a 24×40 box and passes.
CI is gating
CI (ci.yml) runs on Node 22: vendor:check, then type-check + build, then unit tests, then eslint . --max-warnings 0. Any error or warning fails the build. CodeQL and a security scan also run; the e2e suite runs hermetically (Vite dev server + stubbed gateway calls) in a separate workflow.
Deployment
The app deploys to Render, with deploy configuration owned by the luke-platform repository (this repo carries no render.yaml). Lukeflow services deploy from the develop and qa branches — not main — so changes must target develop to ship.
The single npm run build produces the main SPA plus three additional bundles (dist-embed, dist-respond, dist-render) for the public /embed, /respond, and PDF-render targets. Note that the standalone /embed bundle is also copied into the core engine's static assets, so an embed change only reaches production once that copy is rebuilt and shipped there too.
Status & gaps
The app is deployed on dev/qa and broadly functional, but a few capabilities are still partial:
- Send-side runtime — some outbound send paths (e.g. outbound-form send, email send-to-Camunda) are staged in the UI but not fully wired end-to-end.
- Post-MVP capabilities hidden — the MVP UI is Forms + Email + Access only.
PHONE,SIGNATURESandWORKFLOWare hidden from nav, routes, and the access/request/catalog lists, each behind its own flag (VITE_PHONE_ENABLED/VITE_SIGNATURES_ENABLED/VITE_WORKFLOW_ENABLED, all default off) until launch. The pages still exist and stay fully covered by the E2E suite (which enables all three). Documents has no UI capability, so nothing is gated there.
For an at-a-glance view of what is complete versus pending across the platform, see the Completeness Scorecard.