@secmia/openui-flow 4.2.1 → 4.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,995 +1,338 @@
1
- # @secmia/openui-flow
2
-
3
- Production-ready adaptive workflow and onboarding flow engine for React.
4
-
5
- If you are evaluating quickly, you can get first success in under 3 minutes.
6
-
7
- ## For AI agents
8
-
9
- Use [AGENTS.md](openui-flow/AGENTS.md) for a complete package debrief, API map, customization levels, and recommended implementation workflow.
10
-
11
- ## 3-minute onboarding path
12
-
13
- 1. Install package.
14
- 2. Paste the starter component.
15
- 3. Confirm `onComplete` fires.
16
-
17
- ```bash
18
- npm install @secmia/openui-flow
19
- ```
20
-
21
- ```tsx
22
- 'use client';
23
-
24
- import {
25
- AdaptiveFlow,
26
- DefaultAppRequirements,
27
- type AdaptiveFlowAdapter,
28
- } from '@secmia/openui-flow';
29
-
30
- type AppRequirement =
31
- | (typeof DefaultAppRequirements)[keyof typeof DefaultAppRequirements]
32
- | 'selected_plan';
33
-
34
- const requirements: AppRequirement[] = [
35
- DefaultAppRequirements.HAS_EMAIL,
36
- DefaultAppRequirements.EMAIL_VERIFIED,
37
- DefaultAppRequirements.HAS_PASSWORD,
38
- DefaultAppRequirements.HAS_FIRST_NAME,
39
- DefaultAppRequirements.ACCEPTED_TOS,
40
- 'selected_plan',
41
- ];
42
-
43
- const adapter: AdaptiveFlowAdapter = {
44
- async lookupByEmail(email) {
45
- const exists = email.endsWith('@company.com');
46
- return {
47
- accountExists: exists,
48
- hasPassword: exists,
49
- isVerified: false,
50
- agreedToTos: false,
51
- profile: { firstName: null, lastName: null, jobTitle: null },
52
- };
53
- },
54
- async requestOtp(email) {
55
- console.log('request otp', email);
56
- },
57
- async verifyOtp(email, code) {
58
- console.log('verify otp', email, code);
59
- },
60
- };
61
-
62
- export default function FlowPage() {
63
- return (
64
- <AdaptiveFlow
65
- requirements={requirements}
66
- adapter={adapter}
67
- persistence={{ key: 'flow-state:v1', storage: 'session' }}
68
- onComplete={(context) => {
69
- console.log('flow complete', context);
70
- }}
71
- />
72
- );
73
- }
74
- ```
75
-
76
- Expected result:
77
- - User progresses through required steps.
78
- - Flow ends at complete state.
79
- - `onComplete` receives final context.
80
-
81
- Peer dependencies:
82
- - `react` `^18 || ^19`
83
- - `react-dom` `^18 || ^19`
84
-
85
- ---
86
-
87
- ## Quick answers
88
-
89
- Can users add their own flows?
90
- - Yes. This is first-class.
91
- - Define your own requirements, graph, step keys, resolver logic, and UI rendering.
92
-
93
- When should I use requirements vs requirementGraph?
94
- - Use `requirements` for the fastest start.
95
- - Use `requirementGraph` when you need priorities, conditions, or dependencies.
96
-
97
- How do I keep onboarding logic local to my app?
98
- - Create an app-specific requirement union near your flow module.
99
- - Include defaults and your custom requirements in one list.
100
-
101
- ---
102
-
103
- ## First customization: local requirement contract
104
-
105
- ```ts
106
- import { DefaultAppRequirements } from '@secmia/openui-flow';
107
-
108
- type DefaultRequirement =
109
- (typeof DefaultAppRequirements)[keyof typeof DefaultAppRequirements];
110
-
111
- type ProductRequirement =
112
- | DefaultRequirement
113
- | 'selected_plan'
114
- | 'connected_slack'
115
- | 'has_workspace_name';
116
-
117
- export const requirements: ProductRequirement[] = [
118
- DefaultAppRequirements.HAS_EMAIL,
119
- DefaultAppRequirements.EMAIL_VERIFIED,
120
- DefaultAppRequirements.HAS_PASSWORD,
121
- DefaultAppRequirements.HAS_FIRST_NAME,
122
- DefaultAppRequirements.ACCEPTED_TOS,
123
- 'selected_plan',
124
- 'connected_slack',
125
- ];
126
- ```
127
-
128
- ---
129
-
130
- ## Next level: graph mode
131
-
132
- Use this when you need priority routing, conditional activation, dependency gates, or async resolvers.
133
-
134
- ```tsx
135
- import {
136
- AdaptiveFlow,
137
- DefaultAdaptiveSteps,
138
- createRequirementGraph,
139
- type AdaptiveContextBase,
140
- type RequirementResolver,
141
- } from '@secmia/openui-flow';
142
-
143
- type AppContext = AdaptiveContextBase & {
144
- riskLevel: 'low' | 'high';
145
- useSso: boolean;
146
- selectedPlan: 'starter' | 'pro' | null;
147
- };
148
-
149
- type Req =
150
- | 'has_email'
151
- | 'email_verified'
152
- | 'has_password'
153
- | 'accepted_tos'
154
- | 'selected_plan';
155
-
156
- type Step =
157
- | 'COLLECT_EMAIL'
158
- | 'VERIFY_OTP'
159
- | 'COLLECT_PASSWORD'
160
- | 'COLLECT_TOS'
161
- | 'SELECT_PLAN'
162
- | 'COMPLETE';
163
-
164
- const requirements: Req[] = [
165
- 'has_email',
166
- 'email_verified',
167
- 'has_password',
168
- 'accepted_tos',
169
- 'selected_plan',
170
- ];
171
-
172
- const resolvers: Partial<Record<Req, RequirementResolver<AppContext, Step>>> = {
173
- has_email: { step: 'COLLECT_EMAIL', isMet: (ctx) => Boolean(ctx.email) },
174
- email_verified: { step: 'VERIFY_OTP', isMet: async (ctx) => ctx.isVerified },
175
- has_password: { step: 'COLLECT_PASSWORD', isMet: (ctx) => ctx.hasPassword },
176
- accepted_tos: { step: 'COLLECT_TOS', isMet: (ctx) => ctx.agreedToTos },
177
- selected_plan: { step: 'SELECT_PLAN', isMet: (ctx) => Boolean(ctx.selectedPlan) },
178
- };
179
-
180
- const graph = createRequirementGraph(requirements, resolvers, {
181
- priorities: {
182
- email_verified: 20,
183
- has_password: 10,
184
- selected_plan: 5,
185
- },
186
- conditions: {
187
- has_password: (ctx) => !ctx.useSso,
188
- email_verified: (ctx) => ctx.riskLevel === 'high',
189
- },
190
- dependencies: {
191
- selected_plan: ['accepted_tos'],
192
- },
193
- });
194
-
195
- <AdaptiveFlow<AppContext, Req, Step>
196
- requirementGraph={graph}
197
- completeStep={DefaultAdaptiveSteps.COMPLETE}
198
- />;
199
- ```
200
-
201
- ---
202
-
203
- ## Advanced: maximum configuration
204
-
205
- This demonstrates almost every major capability in one setup.
206
-
207
- ```tsx
208
- 'use client';
209
-
210
- import {
211
- AdaptiveFlow,
212
- DefaultAdaptiveSteps,
213
- createDefaultRequirementGraph,
214
- type AdaptiveContextBase,
215
- type AdaptiveFlowAdapter,
216
- type AdaptiveFlowValidators,
217
- type RequirementResolver,
218
- } from '@secmia/openui-flow';
219
-
220
- type ProductContext = AdaptiveContextBase & {
221
- selectedPlan: 'starter' | 'pro' | null;
222
- workspaceName: string | null;
223
- riskScore: number;
224
- useSso: boolean;
225
- };
226
-
227
- type ProductRequirement =
228
- | 'has_email'
229
- | 'email_verified'
230
- | 'has_password'
231
- | 'has_first_name'
232
- | 'accepted_tos'
233
- | 'selected_plan'
234
- | 'has_workspace_name';
235
-
236
- type ProductStep =
237
- | 'COLLECT_EMAIL'
238
- | 'VERIFY_OTP'
239
- | 'COLLECT_PASSWORD'
240
- | 'COLLECT_PROFILE'
241
- | 'COLLECT_TOS'
242
- | 'SELECT_PLAN'
243
- | 'COLLECT_WORKSPACE'
244
- | 'COMPLETE';
245
-
246
- const resolvers: Partial<Record<ProductRequirement, RequirementResolver<ProductContext, ProductStep>>> = {
247
- selected_plan: {
248
- step: 'SELECT_PLAN',
249
- isMet: (ctx) => Boolean(ctx.selectedPlan),
250
- },
251
- has_workspace_name: {
252
- step: 'COLLECT_WORKSPACE',
253
- isMet: (ctx) => Boolean(ctx.workspaceName),
254
- },
255
- };
256
-
257
- const graph = createDefaultRequirementGraph<ProductContext, ProductRequirement, ProductStep>({
258
- requirements: [
259
- 'has_email',
260
- 'email_verified',
261
- 'has_password',
262
- 'has_first_name',
263
- 'accepted_tos',
264
- 'selected_plan',
265
- 'has_workspace_name',
266
- ],
267
- resolvers,
268
- priorities: {
269
- email_verified: 50,
270
- has_password: 40,
271
- selected_plan: 30,
272
- },
273
- conditions: {
274
- has_password: (ctx) => !ctx.useSso,
275
- email_verified: (ctx) => ctx.riskScore >= 70,
276
- },
277
- dependencies: {
278
- has_workspace_name: ['selected_plan'],
279
- },
280
- });
281
-
282
- const adapter: AdaptiveFlowAdapter<ProductContext> = {
283
- async lookupByEmail(email) {
284
- const response = await fetch('/api/identity/lookup', {
285
- method: 'POST',
286
- headers: { 'content-type': 'application/json' },
287
- body: JSON.stringify({ email }),
288
- });
289
- if (!response.ok) {
290
- throw new Error('Lookup failed.');
291
- }
292
- return response.json();
293
- },
294
- async requestOtp(email) {
295
- await fetch('/api/identity/request-otp', {
296
- method: 'POST',
297
- headers: { 'content-type': 'application/json' },
298
- body: JSON.stringify({ email }),
299
- });
300
- },
301
- async verifyOtp(email, code) {
302
- await fetch('/api/identity/verify-otp', {
303
- method: 'POST',
304
- headers: { 'content-type': 'application/json' },
305
- body: JSON.stringify({ email, code }),
306
- });
307
- },
308
- async saveProfile(context) {
309
- await fetch('/api/profile', {
310
- method: 'PATCH',
311
- headers: { 'content-type': 'application/json' },
312
- body: JSON.stringify(context.profile),
313
- });
314
- },
315
- async acceptTos() {
316
- await fetch('/api/legal/accept', { method: 'POST' });
317
- },
318
- async startOAuth(provider) {
319
- window.location.href = `/api/identity/${provider}`;
320
- },
321
- async completeOAuth() {
322
- return {
323
- isVerified: true,
324
- hasPassword: true,
325
- };
326
- },
327
- };
328
-
329
- const validators: AdaptiveFlowValidators<ProductContext> = {
330
- email: (value) => {
331
- if (!value.endsWith('@company.com')) {
332
- return 'Company email is required.';
333
- }
334
- },
335
- password: (value, { hasPassword }) => {
336
- if (!hasPassword && value.length < 12) {
337
- return 'Password must be at least 12 characters.';
338
- }
339
- },
340
- };
341
-
342
- export default function EnterpriseFlow() {
343
- return (
344
- <AdaptiveFlow<ProductContext, ProductRequirement, ProductStep>
345
- requirementGraph={graph}
346
- completeStep={DefaultAdaptiveSteps.COMPLETE}
347
- adapter={adapter}
348
- validators={validators}
349
- persistence={{
350
- key: 'enterprise-flow-state:v1',
351
- storage: 'session',
352
- clearOnComplete: true,
353
- }}
354
- stepTitles={{
355
- SELECT_PLAN: 'Choose your plan',
356
- COLLECT_WORKSPACE: 'Name your workspace',
357
- }}
358
- stepRegistry={{
359
- SELECT_PLAN: ({ setContextPatch }) => (
360
- <button onClick={() => setContextPatch({ selectedPlan: 'pro' })}>Select Pro Plan</button>
361
- ),
362
- COLLECT_WORKSPACE: ({ setContextPatch }) => (
363
- <button onClick={() => setContextPatch({ workspaceName: 'Acme HQ' })}>Set Workspace</button>
364
- ),
365
- }}
366
- renderStep={({ defaultView, transitions }) => (
367
- <div>
368
- <p>Transitions so far: {transitions.length}</p>
369
- {defaultView}
370
- </div>
371
- )}
372
- onStepTransition={(transition) => {
373
- console.log('transition', transition);
374
- }}
375
- onError={(error) => {
376
- console.error('flow error', error);
377
- }}
378
- onComplete={(context) => {
379
- console.log('complete context', context);
380
- }}
381
- classNames={{
382
- shell: 'rounded-xl border p-6 bg-white',
383
- title: 'text-xl font-semibold',
384
- }}
385
- styles={{
386
- shell: { maxWidth: 720 },
387
- }}
388
- unstyled={false}
389
- initialValue={{
390
- selectedPlan: null,
391
- workspaceName: null,
392
- riskScore: 80,
393
- useSso: false,
394
- }}
395
- />
396
- );
397
- }
398
- ```
399
-
400
- ---
401
-
402
- ## Custom UI: full control options
403
-
404
- You have 4 UI customization levels, from quick theming to complete headless control.
405
-
406
- ### Level 1: style and class overrides
407
-
408
- Keep built-in UI structure, but override styles/classes for all slots.
409
-
410
- ```tsx
411
- <AdaptiveFlow
412
- classNames={{
413
- shell: 'rounded-2xl border border-zinc-200 p-8',
414
- title: 'text-2xl font-bold tracking-tight',
415
- oauthButton: 'h-11 rounded-lg border px-4',
416
- error: 'text-sm text-red-700 bg-red-50 rounded p-3',
417
- }}
418
- styles={{
419
- shell: { maxWidth: 680, margin: '0 auto' },
420
- title: { letterSpacing: '-0.01em' },
421
- }}
422
- />
423
- ```
424
-
425
- Configure OAuth button list in default footer:
426
-
427
- ```tsx
428
- <AdaptiveFlow
429
- oauthProviders={[
430
- { id: 'google', label: 'Continue with Google' },
431
- { id: 'github', label: 'Continue with GitHub' },
432
- { id: 'microsoft', label: 'Continue with Microsoft' },
433
- ]}
434
- />
435
- ```
436
-
437
- Use `unstyled` to disable built-in inline defaults.
438
-
439
- ```tsx
440
- <AdaptiveFlow unstyled classNames={{ shell: 'my-own-layout-root' }} />
441
- ```
442
-
443
- ### Level 2: custom UI for specific steps
444
-
445
- Use `stepRegistry` to replace selected steps with your own components while leaving other steps on defaults.
446
-
447
- ```tsx
448
- <AdaptiveFlow
449
- stepRegistry={{
450
- SELECT_PLAN: ({ setContextPatch, run }) => (
451
- <div>
452
- <h3>Choose a plan</h3>
453
- <button
454
- onClick={() => {
455
- void run(async () => {
456
- setContextPatch({ selectedPlan: 'starter' });
457
- });
458
- }}
459
- >
460
- Starter
461
- </button>
462
- <button
463
- onClick={() => {
464
- void run(async () => {
465
- setContextPatch({ selectedPlan: 'pro' });
466
- });
467
- }}
468
- >
469
- Pro
470
- </button>
471
- </div>
472
- ),
473
- }}
474
- />
475
- ```
476
-
477
- `run(job)` semantics in custom renderers:
478
- - sets `busy=true` during the async job
479
- - clears previous global/field errors before execution
480
- - catches and normalizes thrown errors into flow error state
481
- - reverts `busy=false` on completion
482
-
483
- Use it for custom step actions to keep consistent loading/error behavior.
484
-
485
- ### Level 3: custom renderer for all steps
486
-
487
- Use `renderStep` for full step-body ownership. You can still reuse `defaultView` when useful.
488
-
489
- ```tsx
490
- <AdaptiveFlow
491
- renderStep={({ step, defaultView, transitions, context, setContextPatch }) => (
492
- <section>
493
- <header>
494
- <h2>Current step: {step}</h2>
495
- <p>Transitions: {transitions.length}</p>
496
- </header>
497
-
498
- <aside>
499
- <button onClick={() => setContextPatch({ locale: 'en-US' } as Partial<typeof context>)}>
500
- Set locale
501
- </button>
502
- </aside>
503
-
504
- <main>{defaultView}</main>
505
- </section>
506
- )}
507
- />
508
- ```
509
-
510
- ### Level 4: fully custom UI for everything (headless mode)
511
-
512
- If you want total control over shell, header, footer, button layout, and every interaction, use `useAdaptiveFlow` (or engine APIs directly).
513
-
514
- ### Headless hook (`useAdaptiveFlow`)
515
-
516
- `useAdaptiveFlow` exposes the flow lifecycle without rendering any UI.
517
-
518
- ```tsx
519
- 'use client';
520
-
521
- import { useAdaptiveFlow, type AdaptiveContextBase } from '@secmia/openui-flow';
522
-
523
- type Ctx = AdaptiveContextBase & {
524
- selectedPlan: 'starter' | 'pro' | null;
525
- };
526
-
527
- export default function FullyCustomFlow() {
528
- const {
529
- step,
530
- context,
531
- missingRequirements,
532
- busy,
533
- fieldErrors,
534
- handleEmail,
535
- handleOtp,
536
- handlePassword,
537
- handleProfile,
538
- handleTos,
539
- setContextPatch,
540
- } = useAdaptiveFlow<Ctx>({
541
- requirements: ['has_email', 'email_verified', 'has_password', 'has_first_name', 'accepted_tos', 'selected_plan'],
542
- completeStep: 'COMPLETE',
543
- initialValue: { selectedPlan: null },
544
- });
545
-
546
- return (
547
- <div>
548
- <h2>Step: {step}</h2>
549
- <p>Pending: {missingRequirements.length}</p>
550
- {fieldErrors.email ? <p>{fieldErrors.email}</p> : null}
551
-
552
- <button disabled={busy} onClick={() => handleEmail('user@company.com')}>Submit Email</button>
553
- <button disabled={busy} onClick={() => handleOtp('123456')}>Submit OTP</button>
554
- <button disabled={busy} onClick={() => handlePassword('secure-password')}>Submit Password</button>
555
- <button disabled={busy} onClick={() => handleProfile({ firstName: 'A', lastName: 'B', jobTitle: null })}>Save Profile</button>
556
- <button disabled={busy} onClick={() => handleTos()}>Accept TOS</button>
557
- <button onClick={() => setContextPatch({ selectedPlan: 'pro' })}>Select Plan</button>
558
- <pre>{JSON.stringify(context, null, 2)}</pre>
559
- </div>
560
- );
561
- }
562
- ```
563
-
564
- You can still bypass the hook and use pure engine APIs if you need lower-level control.
565
-
566
- ### Optional schema validation (Zod-friendly)
567
-
568
- `AdaptiveFlow` and `useAdaptiveFlow` accept a `schemas` object. Any schema with `safeParse` or `parse` is supported (including Zod schemas).
569
-
570
- ```tsx
571
- import { z } from 'zod';
572
-
573
- const emailSchema = z.string().email();
574
- const profileSchema = z.object({
575
- firstName: z.string().min(1),
576
- lastName: z.string().min(1),
577
- jobTitle: z.string().nullable(),
578
- });
579
-
580
- <AdaptiveFlow
581
- schemas={{
582
- email: emailSchema,
583
- profile: profileSchema,
584
- }}
585
- />
586
- ```
587
-
588
- ### Field-level validation errors
589
-
590
- Validators can return either a string or `{ field, message }`.
591
-
592
- ```ts
593
- const validators = {
594
- password: (value: string) => {
595
- if (value.length < 12) {
596
- return { field: 'password', message: 'Password must be at least 12 characters.' };
597
- }
598
- },
599
- };
600
- ```
601
-
602
- Default UI now binds field errors to the matching input and sets `aria-invalid` automatically.
603
-
604
- ```tsx
605
- 'use client';
606
-
607
- import { useEffect, useMemo, useState } from 'react';
608
- import {
609
- createRequirementGraph,
610
- evaluateNextStep,
611
- getMissingRequirements,
612
- DefaultAdaptiveSteps,
613
- type AdaptiveContextBase,
614
- type RequirementResolver,
615
- } from '@secmia/openui-flow';
616
-
617
- type Ctx = AdaptiveContextBase & {
618
- selectedPlan: 'starter' | 'pro' | null;
619
- };
620
-
621
- type Req = 'has_email' | 'email_verified' | 'selected_plan';
622
- type Step = 'COLLECT_EMAIL' | 'VERIFY_OTP' | 'SELECT_PLAN' | 'COMPLETE';
623
-
624
- const resolvers: Partial<Record<Req, RequirementResolver<Ctx, Step>>> = {
625
- has_email: { step: 'COLLECT_EMAIL', isMet: (ctx) => Boolean(ctx.email) },
626
- email_verified: { step: 'VERIFY_OTP', isMet: (ctx) => ctx.isVerified },
627
- selected_plan: { step: 'SELECT_PLAN', isMet: (ctx) => Boolean(ctx.selectedPlan) },
628
- };
629
-
630
- export default function FullyCustomAuth() {
631
- const [context, setContext] = useState<Ctx>({
632
- email: null,
633
- isVerified: false,
634
- hasPassword: false,
635
- agreedToTos: false,
636
- profile: null,
637
- selectedPlan: null,
638
- });
639
- const [step, setStep] = useState<Step>('COLLECT_EMAIL');
640
- const [missing, setMissing] = useState<Req[]>([]);
641
-
642
- const graph = useMemo(
643
- () => createRequirementGraph<Ctx, Req, Step>(['has_email', 'email_verified', 'selected_plan'], resolvers),
644
- [],
645
- );
646
-
647
- useEffect(() => {
648
- void (async () => {
649
- const [nextStep, missingReqs] = await Promise.all([
650
- evaluateNextStep(context, graph, DefaultAdaptiveSteps.COMPLETE as Step),
651
- getMissingRequirements(context, graph),
652
- ]);
653
- setStep(nextStep);
654
- setMissing(missingReqs);
655
- })();
656
- }, [context, graph]);
657
-
658
- return (
659
- <div>
660
- <header>
661
- <h1>My Brand Auth</h1>
662
- <p>
663
- Progress: {3 - missing.length}/3
664
- </p>
665
- </header>
666
-
667
- {step === 'COLLECT_EMAIL' ? (
668
- <button onClick={() => setContext((prev) => ({ ...prev, email: 'user@company.com' }))}>
669
- Mock email submit
670
- </button>
671
- ) : null}
672
-
673
- {step === 'VERIFY_OTP' ? (
674
- <button onClick={() => setContext((prev) => ({ ...prev, isVerified: true }))}>
675
- Mock verify
676
- </button>
677
- ) : null}
678
-
679
- {step === 'SELECT_PLAN' ? (
680
- <button onClick={() => setContext((prev) => ({ ...prev, selectedPlan: 'pro' }))}>
681
- Choose Pro
682
- </button>
683
- ) : null}
684
-
685
- {step === 'COMPLETE' ? <p>All done.</p> : null}
686
- </div>
687
- );
688
- }
689
- ```
690
-
691
- Precedence rule inside `AdaptiveFlow`:
692
- - `renderStep` wins first.
693
- - If `renderStep` is not provided, `stepRegistry[step]` is used.
694
- - If neither is provided, built-in default step UI is rendered.
695
-
696
- This gives you a clean path from quick defaults to complete custom UI ownership.
697
-
698
- ---
699
-
700
- ## Integration recipes
701
-
702
- ### Next.js App Router
703
-
704
- ```tsx
705
- 'use client';
706
-
707
- import { useRouter } from 'next/navigation';
708
- import {
709
- AdaptiveFlow,
710
- DefaultAppRequirements,
711
- type AdaptiveFlowAdapter,
712
- } from '@secmia/openui-flow';
713
-
714
- type AppRequirement =
715
- | (typeof DefaultAppRequirements)[keyof typeof DefaultAppRequirements]
716
- | 'has_workspace_name';
717
-
718
- const requirements: AppRequirement[] = [
719
- DefaultAppRequirements.HAS_EMAIL,
720
- DefaultAppRequirements.EMAIL_VERIFIED,
721
- DefaultAppRequirements.HAS_PASSWORD,
722
- DefaultAppRequirements.HAS_FIRST_NAME,
723
- DefaultAppRequirements.ACCEPTED_TOS,
724
- ];
725
-
726
- const adapter: AdaptiveFlowAdapter = {
727
- async lookupByEmail(email) {
728
- const response = await fetch('/api/identity/lookup', {
729
- method: 'POST',
730
- headers: { 'content-type': 'application/json' },
731
- body: JSON.stringify({ email }),
732
- });
733
- if (!response.ok) {
734
- throw new Error('Lookup failed.');
735
- }
736
- return response.json();
737
- },
738
- };
739
-
740
- export default function SignInClient() {
741
- const router = useRouter();
742
-
743
- return (
744
- <AdaptiveFlow
745
- adapter={adapter}
746
- requirements={requirements}
747
- persistence={{ key: 'next-flow-state:v1', storage: 'session' }}
748
- onComplete={() => router.replace('/dashboard')}
749
- />
750
- );
751
- }
752
- ```
753
-
754
- ### Supabase
755
-
756
- ```tsx
757
- 'use client';
758
-
759
- import { createClient } from '@supabase/supabase-js';
760
- import { AdaptiveFlow, type AdaptiveFlowAdapter } from '@secmia/openui-flow';
761
-
762
- const supabase = createClient(
763
- process.env.NEXT_PUBLIC_SUPABASE_URL!,
764
- process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
765
- );
766
-
767
- const adapter: AdaptiveFlowAdapter = {
768
- async requestOtp(email) {
769
- const { error } = await supabase.auth.signInWithOtp({ email });
770
- if (error) {
771
- throw new Error(error.message);
772
- }
773
- },
774
- async verifyOtp(email, code) {
775
- const { error } = await supabase.auth.verifyOtp({ email, token: code, type: 'email' });
776
- if (error) {
777
- throw new Error(error.message);
778
- }
779
- },
780
- async startOAuth(provider) {
781
- const { error } = await supabase.auth.signInWithOAuth({
782
- provider,
783
- options: { redirectTo: `${window.location.origin}/auth/callback` },
784
- });
785
- if (error) {
786
- throw new Error(error.message);
787
- }
788
- },
789
- };
790
-
791
- export default function SupabaseAuth() {
792
- return <AdaptiveFlow adapter={adapter} persistence={{ key: 'supabase-auth:v1' }} />;
793
- }
794
- ```
795
-
796
- ### Firebase
797
-
798
- ```tsx
799
- 'use client';
800
-
801
- import { getAuth, GoogleAuthProvider, OAuthProvider, signInWithPopup } from 'firebase/auth';
802
- import { AdaptiveFlow, type AdaptiveFlowAdapter } from '@secmia/openui-flow';
803
- import { app } from './firebase';
804
-
805
- const auth = getAuth(app);
806
-
807
- const adapter: AdaptiveFlowAdapter = {
808
- async startOAuth(provider) {
809
- if (provider === 'google') {
810
- await signInWithPopup(auth, new GoogleAuthProvider());
811
- return;
812
- }
813
- await signInWithPopup(auth, new OAuthProvider('apple.com'));
814
- },
815
- };
816
-
817
- export default function FirebaseAuth() {
818
- return <AdaptiveFlow adapter={adapter} persistence={{ key: 'firebase-auth:v1' }} />;
819
- }
820
- ```
821
-
822
- ---
823
-
824
- ## Engine-only usage
825
-
826
- ```ts
827
- import {
828
- createRequirementGraph,
829
- evaluateNextStep,
830
- getMissingRequirements,
831
- DefaultAdaptiveSteps,
832
- } from '@secmia/openui-flow';
833
-
834
- const nextStep = await evaluateNextStep(context, graph, DefaultAdaptiveSteps.COMPLETE);
835
- const missing = await getMissingRequirements(context, graph);
836
- ```
837
-
838
- ---
839
-
840
- ## Core concepts
841
-
842
- - Context: current auth and profile state.
843
- - Requirement: business condition that must be satisfied.
844
- - Resolver: checker function for one requirement.
845
- - Step: current UI state for unmet requirement.
846
- - RequirementGraph: graph nodes with optional priority, condition, and dependency metadata.
847
-
848
- Graph behavior:
849
- - Dependency-aware ordering uses topological sorting.
850
- - Priority is used as tie-breaker among currently available nodes.
851
- - Circular dependencies and unknown dependencies are rejected during graph creation.
852
-
853
- Evaluation loop:
854
- 1. Build graph.
855
- 2. Evaluate active nodes.
856
- 3. Select highest-priority unmet node.
857
- 4. Render mapped step.
858
- 5. Update context.
859
- 6. Repeat until complete step.
860
-
861
- ---
862
-
863
- ## API reference
864
-
865
- Primary exports:
866
- - `AdaptiveFlow`
867
- - `useAdaptiveFlow`
868
- - `defaultRequirements`
869
- - `initialContext`
870
- - `defaultRequirementResolvers`
871
- - `createDefaultRequirementGraph`
872
- - `createRequirementGraph`
873
- - `evaluateNextStep`
874
- - `getMissingRequirements`
875
- - `DefaultAdaptiveSteps`
876
- - `DefaultAppRequirements`
877
-
878
- Important types:
879
- - `AdaptiveFlowProps`
880
- - `AdaptiveFlowAdapter`
881
- - `UseAdaptiveFlowOptions`
882
- - `UseAdaptiveFlowResult`
883
- - `AdaptiveFlowValidators`
884
- - `AdaptiveFlowValidationResult`
885
- - `AdaptiveFlowValidationIssue`
886
- - `AdaptiveFlowFieldErrors`
887
- - `AdaptiveFlowSchemas`
888
- - `AdaptiveFlowSchema`
889
- - `AdaptiveFlowPersistence`
890
- - `AdaptiveFlowOAuthProvider`
891
- - `AdaptiveStepRegistry`
892
- - `AdaptiveStepRendererArgs`
893
- - `AdaptiveStepRenderArgs`
894
- - `AdaptiveStepTransition`
895
- - `RequirementResolver`
896
- - `RequirementGraph`
897
- - `RequirementGraphNode`
898
- - `AdaptiveContext`
899
- - `AdaptiveContextBase`
900
-
901
- ---
902
-
903
- ## SSR and hydration notes
904
-
905
- - Storage persistence only runs in the browser (`window` guard).
906
- - On SSR, the flow starts from `initialValue`/defaults and then hydrates persisted browser state on mount.
907
- - For App Router and other SSR setups, pass server-known session hints in `initialValue` to minimize client-side step flash.
908
-
909
- Example:
910
-
911
- ```tsx
912
- <AdaptiveFlow
913
- initialValue={{
914
- email: user.email ?? null,
915
- isVerified: Boolean(user.emailVerifiedAt),
916
- hasPassword: Boolean(user.hasPassword),
917
- }}
918
- persistence={{ key: 'flow-state:v1', storage: 'session' }}
919
- />
920
- ```
921
-
922
- ### Retry policy
923
-
924
- Use `retryPolicy` to retry transient adapter failures with configurable backoff.
925
-
926
- ```tsx
927
- <AdaptiveFlow
928
- retryPolicy={{
929
- maxAttempts: 4,
930
- initialDelayMs: 200,
931
- factor: 2,
932
- maxDelayMs: 2000,
933
- jitter: true,
934
- shouldRetry: (error) => error.name !== 'FlowValidationError',
935
- }}
936
- />
937
- ```
938
-
939
- Default behavior:
940
- - adapter calls are retried with exponential backoff
941
- - non-adapter validation errors are not retried unless `shouldRetry` allows them
942
- - retries use the browser/Node timer API, so no extra dependency is required
943
-
944
- ---
945
-
946
- ## Release checklist
947
-
948
- - Ensure adapter methods return clear, user-safe errors.
949
- - Version persistence keys (example: `flow-state:v1`).
950
- - Track analytics with `onStepTransition`.
951
- - Keep requirements and graph definitions local to each product module.
952
- - Add integration tests for all flow branches.
953
- - Run `npm run typecheck` and `npm run build` before every publish.
954
-
955
- ---
956
-
957
- ## Troubleshooting
958
-
959
- Step not appearing:
960
- - Cause: priority, condition, or dependency suppresses node activation.
961
- - Fix: inspect graph `priorities`, `conditions`, and `dependencies`.
962
-
963
- State resets on refresh:
964
- - Cause: persistence missing or wrong key.
965
- - Fix: set `persistence.key` and chosen storage.
966
-
967
- OAuth return does not resume:
968
- - Cause: missing `completeOAuth` or persistence disabled.
969
- - Fix: implement `completeOAuth` and enable persistence.
970
-
971
- Persistence write/read failures:
972
- - Cause: storage quota, blocked storage, malformed persisted payload.
973
- - Fix: pass `persistence.onError` to capture and report storage failures.
974
-
975
- Custom nested context fields collapsing after patch:
976
- - Cause: patching with a shallow object update.
977
- - Fix: the flow now performs a recursive object merge for plain nested objects. Arrays are replaced, not merged.
978
-
979
- ```tsx
980
- <AdaptiveFlow
981
- persistence={{
982
- key: 'flow-state:v1',
983
- storage: 'local',
984
- onError: (error, phase) => {
985
- console.error('persistence failure', phase, error);
986
- },
987
- }}
988
- />
989
- ```
990
-
991
- ---
992
-
993
- ## License
994
-
995
- MIT
1
+ # @secmia/openui-flow
2
+
3
+ Adaptive, graph-driven multi-step flow orchestration for React.
4
+
5
+ Build authentication, onboarding, compliance, setup, and gating flows with one engine:
6
+ - requirement-based routing
7
+ - dependency-aware graph evaluation
8
+ - headless hook + default UI
9
+ - adapter-driven backend integration
10
+ - persistence, validation, retry/backoff
11
+
12
+ ## For AI agents
13
+
14
+ Use [AGENTS.md](AGENTS.md) for implementation debrief, architecture notes, and workflow guidance.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install @secmia/openui-flow
20
+ ```
21
+
22
+ Peer dependencies:
23
+ - `react` `^18 || ^19`
24
+ - `react-dom` `^18 || ^19`
25
+
26
+ ## 3-minute quickstart (Next.js App Router safe)
27
+
28
+ > Important: in Next.js App Router, functions cannot be passed from Server Components to Client Components. Keep `AdaptiveFlow`, adapter functions, and handlers inside a Client Component.
29
+
30
+ ```tsx
31
+ // app/flow-demo.tsx
32
+ 'use client';
33
+
34
+ import {
35
+ AdaptiveFlow,
36
+ DefaultAppRequirements,
37
+ type AdaptiveFlowAdapter,
38
+ } from '@secmia/openui-flow';
39
+
40
+ type AppRequirement =
41
+ | (typeof DefaultAppRequirements)[keyof typeof DefaultAppRequirements]
42
+ | 'selected_plan';
43
+
44
+ const requirements: AppRequirement[] = [
45
+ DefaultAppRequirements.HAS_EMAIL,
46
+ DefaultAppRequirements.EMAIL_VERIFIED,
47
+ DefaultAppRequirements.HAS_PASSWORD,
48
+ DefaultAppRequirements.HAS_FIRST_NAME,
49
+ DefaultAppRequirements.HAS_LAST_NAME,
50
+ DefaultAppRequirements.HAS_JOB_TITLE,
51
+ DefaultAppRequirements.ACCEPTED_TOS,
52
+ 'selected_plan',
53
+ ];
54
+
55
+ const adapter: AdaptiveFlowAdapter = {
56
+ async lookupByEmail(email) {
57
+ const exists = email.endsWith('@company.com');
58
+ return {
59
+ accountExists: exists,
60
+ hasPassword: exists,
61
+ isVerified: false,
62
+ agreedToTos: false,
63
+ profile: { firstName: null, lastName: null, jobTitle: null },
64
+ };
65
+ },
66
+ async requestOtp(email) {
67
+ console.log('request otp', email);
68
+ },
69
+ async verifyOtp(email, code) {
70
+ console.log('verify otp', email, code);
71
+ },
72
+ };
73
+
74
+ export default function FlowDemo() {
75
+ return (
76
+ <AdaptiveFlow
77
+ requirements={requirements}
78
+ adapter={adapter}
79
+ persistence={{ key: 'flow-state:v1', storage: 'session' }}
80
+ onComplete={(context) => {
81
+ console.log('flow complete', context);
82
+ }}
83
+ />
84
+ );
85
+ }
86
+ ```
87
+
88
+ ```tsx
89
+ // app/page.tsx (Server Component)
90
+ import FlowDemo from './flow-demo';
91
+
92
+ export default function Page() {
93
+ return <FlowDemo />;
94
+ }
95
+ ```
96
+
97
+ ## What this package is
98
+
99
+ `@secmia/openui-flow` is a generic flow engine with a React-first API.
100
+
101
+ You define:
102
+ - requirements (conditions that must be met)
103
+ - resolvers (how each requirement is evaluated)
104
+ - graph metadata (priority, dependency, conditional activation)
105
+
106
+ The engine decides the next step; UI can be default, partially custom, or fully headless.
107
+
108
+ ## Core model
109
+
110
+ - `Context`: current user/product/session state.
111
+ - `Requirement`: business condition (`has_email`, `accepted_tos`, etc.).
112
+ - `Resolver`: maps requirement to `isMet(context)` + fallback step.
113
+ - `RequirementGraph`: ordered nodes with `priority`, `dependsOn`, `when`.
114
+
115
+ Graph guarantees:
116
+ - cycle detection at graph creation
117
+ - dependency validation
118
+ - dependency-aware topological ordering
119
+ - deterministic tie-breaking
120
+
121
+ ## API overview
122
+
123
+ Primary exports:
124
+ - `AdaptiveFlow`
125
+ - `useAdaptiveFlow`
126
+ - `createRequirementGraph`
127
+ - `createDefaultRequirementGraph`
128
+ - `evaluateNextStep`
129
+ - `getMissingRequirements`
130
+ - `defaultRequirements`
131
+ - `defaultRequirementResolvers`
132
+ - `initialContext`
133
+ - `DefaultAppRequirements`
134
+ - `DefaultAdaptiveSteps`
135
+
136
+ High-value types:
137
+ - `AdaptiveFlowProps`
138
+ - `AdaptiveFlowAdapter`
139
+ - `UseAdaptiveFlowOptions`
140
+ - `UseAdaptiveFlowResult`
141
+ - `AdaptiveFlowValidators`
142
+ - `AdaptiveFlowSchemas`
143
+ - `AdaptiveFlowPersistence`
144
+ - `AdaptiveFlowRetryPolicy`
145
+ - `AdaptiveFlowOAuthProvider`
146
+ - `RequirementGraph`
147
+ - `RequirementResolver`
148
+
149
+ ## UI customization levels
150
+
151
+ ### Level 1: style/class overrides
152
+
153
+ ```tsx
154
+ <AdaptiveFlow
155
+ classNames={{
156
+ shell: 'rounded-xl border p-6',
157
+ title: 'text-xl font-semibold',
158
+ oauthButton: 'h-10 rounded border px-3',
159
+ }}
160
+ styles={{
161
+ shell: { maxWidth: 720 },
162
+ }}
163
+ />
164
+ ```
165
+
166
+ Use `unstyled` to disable default inline styles.
167
+
168
+ ### Level 2: per-step overrides (`stepRegistry`)
169
+
170
+ ```tsx
171
+ <AdaptiveFlow
172
+ stepRegistry={{
173
+ SELECT_PLAN: ({ setContextPatch }) => (
174
+ <button onClick={() => setContextPatch({ selectedPlan: 'pro' })}>Select Pro</button>
175
+ ),
176
+ }}
177
+ />
178
+ ```
179
+
180
+ ### Level 3: full step renderer (`renderStep`)
181
+
182
+ ```tsx
183
+ <AdaptiveFlow
184
+ renderStep={({ step, defaultView, transitions }) => (
185
+ <section>
186
+ <h2>{step}</h2>
187
+ <p>Transitions: {transitions.length}</p>
188
+ {defaultView}
189
+ </section>
190
+ )}
191
+ />
192
+ ```
193
+
194
+ ### Level 4: fully headless (`useAdaptiveFlow`)
195
+
196
+ ```tsx
197
+ 'use client';
198
+
199
+ import { useAdaptiveFlow } from '@secmia/openui-flow';
200
+
201
+ export default function HeadlessFlow() {
202
+ const {
203
+ step,
204
+ busy,
205
+ fieldErrors,
206
+ handleEmail,
207
+ handleOtp,
208
+ setContextPatch,
209
+ } = useAdaptiveFlow({
210
+ requirements: ['has_email', 'email_verified', 'accepted_tos'],
211
+ completeStep: 'COMPLETE',
212
+ });
213
+
214
+ return (
215
+ <div>
216
+ <p>Step: {step}</p>
217
+ {fieldErrors.email ? <p>{fieldErrors.email}</p> : null}
218
+ <button disabled={busy} onClick={() => handleEmail('user@company.com')}>Submit Email</button>
219
+ <button disabled={busy} onClick={() => handleOtp('123456')}>Submit OTP</button>
220
+ <button onClick={() => setContextPatch({ agreedToTos: true })}>Accept TOS</button>
221
+ </div>
222
+ );
223
+ }
224
+ ```
225
+
226
+ ## Validation and schemas
227
+
228
+ Validators can return:
229
+ - `string`
230
+ - `{ field, message }`
231
+ - `void`
232
+ - async variants of the same
233
+
234
+ ```ts
235
+ const validators = {
236
+ password: (value: string) => {
237
+ if (value.length < 12) {
238
+ return { field: 'password', message: 'Password must be at least 12 characters.' };
239
+ }
240
+ },
241
+ };
242
+ ```
243
+
244
+ Schema adapters are Zod-friendly (`safeParse` or `parse`):
245
+
246
+ ```tsx
247
+ import { z } from 'zod';
248
+
249
+ <AdaptiveFlow
250
+ schemas={{
251
+ email: z.string().email(),
252
+ }}
253
+ />
254
+ ```
255
+
256
+ ## Persistence
257
+
258
+ ```tsx
259
+ <AdaptiveFlow
260
+ persistence={{
261
+ key: 'flow-state:v1',
262
+ storage: 'local',
263
+ clearOnComplete: true,
264
+ onError: (error, phase) => {
265
+ console.error('persistence', phase, error);
266
+ },
267
+ }}
268
+ />
269
+ ```
270
+
271
+ ## Retry/backoff (adapter calls)
272
+
273
+ ```tsx
274
+ <AdaptiveFlow
275
+ retryPolicy={{
276
+ maxAttempts: 4,
277
+ initialDelayMs: 200,
278
+ factor: 2,
279
+ maxDelayMs: 2000,
280
+ jitter: true,
281
+ shouldRetry: (error) => error.name !== 'FlowValidationError',
282
+ }}
283
+ />
284
+ ```
285
+
286
+ Notes:
287
+ - retries apply to adapter calls (`lookupByEmail`, `requestOtp`, `verifyOtp`, etc.)
288
+ - validation errors are not retried unless your `shouldRetry` allows them
289
+
290
+ ## OAuth
291
+
292
+ - Default footer shows Google and Apple.
293
+ - Override provider buttons with `oauthProviders`.
294
+ - Redirect URL is owned by your adapter�s `startOAuth` implementation.
295
+
296
+ ```tsx
297
+ <AdaptiveFlow
298
+ oauthProviders={[
299
+ { id: 'google', label: 'Continue with Google' },
300
+ { id: 'github', label: 'Continue with GitHub' },
301
+ ]}
302
+ />
303
+ ```
304
+
305
+ ```ts
306
+ const adapter = {
307
+ async startOAuth(provider: string) {
308
+ window.location.href = `/api/identity/${provider}?redirectTo=${encodeURIComponent(`${window.location.origin}/auth/callback`)}`;
309
+ },
310
+ async completeOAuth() {
311
+ return { isVerified: true };
312
+ },
313
+ };
314
+ ```
315
+
316
+ ## SSR and hydration
317
+
318
+ - Storage reads/writes run only in the browser.
319
+ - On SSR, flow starts with defaults/`initialValue` and hydrates client state on mount.
320
+ - For minimal UI flash, seed `initialValue` from server-known session hints.
321
+
322
+ ## Troubleshooting
323
+
324
+ - Next.js "Functions cannot be passed to Client Components": keep adapter and handlers in a Client Component.
325
+ - Step not appearing: verify `priority`, `dependsOn`, and `when`.
326
+ - State reset: check `persistence.key` and storage mode.
327
+ - OAuth not resuming: implement `completeOAuth` and enable persistence.
328
+
329
+ ## Release checklist
330
+
331
+ - `npm run typecheck`
332
+ - `npm run build`
333
+ - Verify adapter errors are user-safe and actionable
334
+ - Use versioned persistence keys (for example `flow-state:v1`)
335
+
336
+ ## License
337
+
338
+ MIT