@exegia/corpora-ui 0.22.0 → 0.23.0

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.
Files changed (40) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-flow-block.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-block.d.ts +123 -0
  3. package/dist-lib/components/blocks/scaffold/__tests__/scaffold-atom.test.d.ts +1 -0
  4. package/dist-lib/components/blocks/scaffold/index.d.ts +2 -0
  5. package/dist-lib/components/blocks/scaffold/scaffold-atom.d.ts +97 -0
  6. package/dist-lib/components/blocks/scaffold/scaffold-context.d.ts +3 -1
  7. package/dist-lib/components/blocks/scaffold/scaffold-root.d.ts +12 -3
  8. package/dist-lib/components/blocks/scaffold/type.d.ts +58 -18
  9. package/dist-lib/components/blocks/scaffold/use-scaffold-state.d.ts +26 -0
  10. package/dist-lib/components/blocks/scaffold/use-scaffold.d.ts +7 -1
  11. package/dist-lib/components/blocks/scaffold/utils.d.ts +11 -1
  12. package/dist-lib/components/ui/switch.d.ts +4 -0
  13. package/dist-lib/index.d.ts +1 -0
  14. package/dist-lib/index.js +1383 -1196
  15. package/dist-lib/index.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/components/beste/piece/browser-frame.tsx +1 -1
  18. package/src/components/beste/piece/utils.ts +4 -4
  19. package/src/components/blocks/auth/__tests__/auth-flow-block.test.tsx +183 -0
  20. package/src/components/blocks/auth/auth-flow-block.tsx +296 -0
  21. package/src/components/blocks/scaffold/__tests__/scaffold-atom.test.tsx +210 -0
  22. package/src/components/blocks/scaffold/index.ts +15 -0
  23. package/src/components/blocks/scaffold/scaffold-actions.tsx +5 -1
  24. package/src/components/blocks/scaffold/scaffold-atom.ts +383 -0
  25. package/src/components/blocks/scaffold/scaffold-canvas.tsx +12 -4
  26. package/src/components/blocks/scaffold/scaffold-context.ts +3 -1
  27. package/src/components/blocks/scaffold/scaffold-inspector.tsx +9 -2
  28. package/src/components/blocks/scaffold/scaffold-panel.tsx +9 -5
  29. package/src/components/blocks/scaffold/scaffold-root.tsx +64 -32
  30. package/src/components/blocks/scaffold/scaffold-tab.tsx +13 -9
  31. package/src/components/blocks/scaffold/type.ts +64 -18
  32. package/src/components/blocks/scaffold/use-scaffold-state.ts +58 -0
  33. package/src/components/blocks/scaffold/use-scaffold.ts +30 -19
  34. package/src/components/blocks/scaffold/utils.ts +45 -1
  35. package/src/components/docs/component-preview.tsx +7 -2
  36. package/src/components/docs/demo-controls.tsx +32 -23
  37. package/src/components/ui/switch.tsx +30 -0
  38. package/src/index.ts +1 -0
  39. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +0 -19
  40. package/src/components/blocks/scaffold/use-panel-visibility.ts +0 -180
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,7 @@ export function BrowserFrame({
19
19
  className
20
20
  )}
21
21
  >
22
- <div className="relative flex max-h-[500px] min-h-32 w-full flex-col overflow-hidden rounded-lg border border-border bg-neutral-50 shadow-lg dark:bg-neutral-900">
22
+ <div className="relative flex min-h-32 w-full flex-col overflow-hidden rounded-lg border border-border bg-neutral-50 shadow-lg dark:bg-neutral-900">
23
23
  <div
24
24
  id="title-bar"
25
25
  className={cn(
@@ -6,15 +6,15 @@ export const titleBarStyle = (
6
6
  component: "title" | "frame"
7
7
  ): ClassNameValue => {
8
8
  const titleBarClassName: Record<TitleStyleType, ClassNameValue> = {
9
- titlebar: "",
9
+ titlebar: "relative",
10
10
  hidden: "top-0 left-0 absolute",
11
- expanded: "",
11
+ expanded: "relative",
12
12
  }
13
13
 
14
14
  const bodyClassName: Record<TitleStyleType, ClassNameValue> = {
15
- titlebar: "",
15
+ titlebar: "relative",
16
16
  hidden: "",
17
- expanded: "",
17
+ expanded: "relative",
18
18
  }
19
19
 
20
20
  return component == "title" ? titleBarClassName[style] : bodyClassName[style]
@@ -0,0 +1,183 @@
1
+ import { describe, expect, mock, test } from "bun:test"
2
+ import { render, screen, waitFor } from "@testing-library/react"
3
+ import userEvent from "@testing-library/user-event"
4
+ import { createStore } from "jotai"
5
+
6
+ import { ExegiaProvider } from "@/state"
7
+ import { AuthFlowBlock } from "../auth-flow-block"
8
+ import {
9
+ DEFAULT_AUTH_FLOW_ID,
10
+ authFlowStepAtom,
11
+ authUserAtom,
12
+ goToAuthStepAtom,
13
+ isAuthenticatedAtom,
14
+ } from "../auth-state"
15
+ import type { AuthUser } from "../auth-state"
16
+
17
+ const USER: AuthUser = {
18
+ id: "u-1",
19
+ name: "Yona Appletree",
20
+ email: "yo@example.com",
21
+ }
22
+
23
+ type Store = ReturnType<typeof createStore>
24
+
25
+ function mount(store: Store, ui: React.ReactElement) {
26
+ return render(<ExegiaProvider store={store}>{ui}</ExegiaProvider>)
27
+ }
28
+
29
+ /** MorphStep exits run under AnimatePresence; happy-dom serves stale
30
+ * selector-cache results to `waitFor` queries that start while an exit is
31
+ * still running — let the exit settle on real timers, then query once. */
32
+ const settleExit = () => new Promise((resolve) => setTimeout(resolve, 400))
33
+
34
+ async function submitLogin(email = "yo@example.com", password = "hunter22!") {
35
+ const user = userEvent.setup()
36
+ await user.type(screen.getByLabelText("Email"), email)
37
+ await user.type(screen.getByLabelText("Password"), password)
38
+ await user.click(screen.getByRole("button", { name: "Login" }))
39
+ return user
40
+ }
41
+
42
+ describe("AuthFlowBlock · step switching", () => {
43
+ test("renders the login step by default with pre-wired navigation", async () => {
44
+ const user = userEvent.setup()
45
+ const store = createStore()
46
+ mount(store, <AuthFlowBlock />)
47
+
48
+ expect(screen.getByText("Login to your account")).toBeDefined()
49
+
50
+ await user.click(screen.getByRole("button", { name: "Sign up" }))
51
+ expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("signup")
52
+ await settleExit()
53
+ expect(screen.getByText("Create your account")).toBeDefined()
54
+
55
+ // Signup's footer goes back to login.
56
+ await user.click(screen.getByRole("button", { name: "Login" }))
57
+ expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("login")
58
+ await settleExit()
59
+
60
+ // The forgot-password link sits in the password row, which the login
61
+ // block reveals once the email is valid.
62
+ await user.type(screen.getByLabelText("Email"), "yo@example.com")
63
+ await user.click(
64
+ await screen.findByRole("button", { name: "Forgot password?" })
65
+ )
66
+ expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe(
67
+ "forgot-password"
68
+ )
69
+ })
70
+
71
+ test("follows the store when something else drives the flow", async () => {
72
+ const store = createStore()
73
+ mount(store, <AuthFlowBlock />)
74
+
75
+ store.set(goToAuthStepAtom(DEFAULT_AUTH_FLOW_ID), "update-password")
76
+ expect(await screen.findByText("Update your password")).toBeDefined()
77
+ })
78
+ })
79
+
80
+ describe("AuthFlowBlock · directives", () => {
81
+ test("{ verify } moves to the code step with the masked destination", async () => {
82
+ const store = createStore()
83
+ const onLogin = mock(() => ({
84
+ verify: { identifier: "yo@example.com" } as const,
85
+ }))
86
+ mount(store, <AuthFlowBlock onLogin={onLogin} />)
87
+
88
+ await submitLogin()
89
+ expect(onLogin).toHaveBeenCalledTimes(1)
90
+ expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe(
91
+ "verify-code"
92
+ )
93
+ // The masked identifier feeds CodeAuthBlock's destination copy.
94
+ expect(await screen.findByText(/y•••@example\.com/)).toBeDefined()
95
+ })
96
+
97
+ test("{ user } completes the flow and signs the session in", async () => {
98
+ const store = createStore()
99
+ mount(store, <AuthFlowBlock onLogin={() => ({ user: USER })} />)
100
+
101
+ await submitLogin()
102
+ expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("success")
103
+ expect(store.get(isAuthenticatedAtom)).toBe(true)
104
+ expect(store.get(authUserAtom)?.id).toBe("u-1")
105
+ await settleExit()
106
+ expect(screen.getByText("Welcome")).toBeDefined()
107
+ expect(screen.getByText("You're signed in")).toBeDefined()
108
+ })
109
+
110
+ test("a resolved handler without a directive stays on the step", async () => {
111
+ const store = createStore()
112
+ mount(store, <AuthFlowBlock onLogin={() => undefined} />)
113
+
114
+ await submitLogin()
115
+ expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("login")
116
+ })
117
+
118
+ test("a rejected handler stays on the step and shows the block's error", async () => {
119
+ const store = createStore()
120
+ mount(
121
+ store,
122
+ <AuthFlowBlock
123
+ onLogin={() => {
124
+ throw new Error("Wrong password")
125
+ }}
126
+ />
127
+ )
128
+
129
+ await submitLogin()
130
+ expect(store.get(authFlowStepAtom(DEFAULT_AUTH_FLOW_ID))).toBe("login")
131
+ expect(await screen.findByText("Wrong password")).toBeDefined()
132
+ expect(store.get(isAuthenticatedAtom)).toBe(false)
133
+ })
134
+ })
135
+
136
+ describe("AuthFlowBlock · customization", () => {
137
+ test("steps overrides merge over the wiring", async () => {
138
+ const store = createStore()
139
+ mount(
140
+ store,
141
+ <AuthFlowBlock
142
+ steps={{ login: { title: "Sign in to Corpora", providers: [] } }}
143
+ />
144
+ )
145
+
146
+ expect(screen.getByText("Sign in to Corpora")).toBeDefined()
147
+ })
148
+
149
+ test("renderStep replaces a step; undefined keeps the default", async () => {
150
+ const store = createStore()
151
+ mount(
152
+ store,
153
+ <AuthFlowBlock
154
+ renderStep={(step) =>
155
+ step === "success" ? <p>Custom landing</p> : undefined
156
+ }
157
+ />
158
+ )
159
+
160
+ expect(screen.getByText("Login to your account")).toBeDefined()
161
+ store.set(goToAuthStepAtom(DEFAULT_AUTH_FLOW_ID), "success")
162
+ expect(await screen.findByText("Custom landing")).toBeDefined()
163
+ })
164
+
165
+ test("two flow ids orchestrate independently", async () => {
166
+ const store = createStore()
167
+ mount(
168
+ store,
169
+ <>
170
+ <AuthFlowBlock flowId="page" />
171
+ <AuthFlowBlock flowId="modal" />
172
+ </>
173
+ )
174
+
175
+ store.set(goToAuthStepAtom("modal"), "forgot-password")
176
+ expect(await screen.findByText("Reset your password")).toBeDefined()
177
+ // The page flow is still on login.
178
+ await waitFor(() =>
179
+ expect(screen.getByText("Login to your account")).toBeDefined()
180
+ )
181
+ expect(store.get(authFlowStepAtom("page"))).toBe("login")
182
+ })
183
+ })
@@ -0,0 +1,296 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { MotionConfig } from "motion/react"
5
+
6
+ import type { SocialProvider } from "@/components/composed/social-providers"
7
+ import type { AuthAccent } from "@/lib/auth-accent"
8
+ import { cn } from "@/lib/utils"
9
+ import { AuthCard, AuthSuccess, MorphStep } from "./auth-shell"
10
+ import { CodeAuthBlock, type CodeAuthBlockProps } from "./code-auth-block"
11
+ import {
12
+ ForgotPasswordBlock,
13
+ type ForgotPasswordBlockProps,
14
+ } from "./forgot-password-block"
15
+ import { LoginBlock, type LoginBlockProps } from "./login-block"
16
+ import {
17
+ OnboardingBlock,
18
+ type OnboardingBlockProps,
19
+ type OnboardingStepConfig,
20
+ type OnboardingValue,
21
+ } from "./onboarding-block"
22
+ import { SignupBlock, type SignupBlockProps } from "./signup-block"
23
+ import {
24
+ UpdatePasswordBlock,
25
+ type UpdatePasswordBlockProps,
26
+ } from "./update-password-block"
27
+ import type {
28
+ AuthFlowId,
29
+ AuthFlowState,
30
+ AuthFlowStep,
31
+ AuthUser,
32
+ BeginAuthVerificationOptions,
33
+ } from "./auth-state-type"
34
+ import { useAuthFlow, useAuthFlowActions } from "./use-auth-state"
35
+
36
+ /**
37
+ * Where the flow goes after a step callback resolves. Returned from every
38
+ * `AuthFlowBlock` handler:
39
+ *
40
+ * - `{ user }` — the attempt signed someone in: mark the flow complete AND
41
+ * sign the session in (one atomic write, `completeAuthFlowAtom`).
42
+ * - `{ verify }` — a code went out: record the identifier and move to the
43
+ * verification step (`beginAuthVerificationAtom`).
44
+ * - `{ step }` — plain navigation with a clean slate (`goToAuthStepAtom`).
45
+ * - `void` — stay put; the block renders its own success state.
46
+ *
47
+ * Rejections are NOT handled here — they propagate back into the block,
48
+ * which owns its transient error/shake state (see `react/CLAUDE.md`,
49
+ * "Third implementation: auth").
50
+ */
51
+ export type AuthFlowDirective =
52
+ | { user: AuthUser }
53
+ | { verify: BeginAuthVerificationOptions }
54
+ | { step: AuthFlowStep }
55
+ | void
56
+
57
+ export type AuthFlowHandler<Data = void> = (
58
+ data: Data
59
+ ) => Promise<AuthFlowDirective> | AuthFlowDirective
60
+
61
+ /** Per-step prop overrides, merged over the orchestrator's wiring — spread
62
+ * last, so an app can restyle a block or unhook a default navigation link
63
+ * (`{ login: { onSignup: undefined } }` removes the sign-up hand-off). */
64
+ export interface AuthFlowStepOverrides {
65
+ login?: Partial<LoginBlockProps>
66
+ signup?: Partial<SignupBlockProps>
67
+ "verify-code"?: Partial<CodeAuthBlockProps>
68
+ "forgot-password"?: Partial<ForgotPasswordBlockProps>
69
+ "update-password"?: Partial<UpdatePasswordBlockProps>
70
+ onboarding?: Partial<OnboardingBlockProps>
71
+ }
72
+
73
+ export interface AuthFlowBlockProps {
74
+ /** Which flow instance to orchestrate. The default flow unless a re-auth
75
+ * modal or a second surface needs its own. */
76
+ flowId?: AuthFlowId
77
+ /** Brand mark handed to every step's card. */
78
+ logo?: React.ReactNode
79
+ /** Brand accent handed to every step's card. */
80
+ accent?: AuthAccent
81
+ /** Social providers offered on the login and signup steps. */
82
+ providers?: SocialProvider[]
83
+ /** The login attempt. Resolve with a directive; reject to show the error
84
+ * in the block. */
85
+ onLogin?: AuthFlowHandler<{
86
+ email: string
87
+ password: string
88
+ remember: boolean
89
+ }>
90
+ /** The signup attempt. */
91
+ onSignup?: AuthFlowHandler<{ name: string; email: string; password: string }>
92
+ /** A social provider chosen on the login or signup step. */
93
+ onProviderSelect?: AuthFlowHandler<SocialProvider>
94
+ /** The forgot-password request. Resolving without a directive stays on the
95
+ * step (the block shows its own "link sent" state). */
96
+ onRequestReset?: AuthFlowHandler<{ email: string }>
97
+ /** The code entered on the verification step. */
98
+ onVerifyCode?: AuthFlowHandler<string>
99
+ /** "Resend code" on the verification step. */
100
+ onResendCode?: AuthFlowHandler
101
+ /** The update-password submit. */
102
+ onUpdatePassword?: AuthFlowHandler<{ password: string }>
103
+ /** Onboarding finished, with the merged profile. */
104
+ onOnboardingComplete?: AuthFlowHandler<Record<string, OnboardingValue>>
105
+ /** Declared onboarding steps, handed to `OnboardingBlock`. */
106
+ onboardingSteps?: OnboardingStepConfig[]
107
+ /** Per-step prop overrides, merged over the orchestrator's wiring. */
108
+ steps?: AuthFlowStepOverrides
109
+ /** Replace any step's UI entirely; return `undefined` to keep the default
110
+ * for that step. Receives the flow state for destination copy etc. */
111
+ renderStep?: (
112
+ step: AuthFlowStep,
113
+ flow: AuthFlowState
114
+ ) => React.ReactNode | undefined
115
+ /** Replaces the whole default success card. */
116
+ success?: React.ReactNode
117
+ /** Card title of the default success step. */
118
+ successTitle?: string
119
+ /** Body under the default success step's "You're signed in" check. */
120
+ successDescription?: string
121
+ className?: string
122
+ }
123
+
124
+ /**
125
+ * Renders the right auth block for the flow's current step with the store
126
+ * wiring built in — the switchboard every host app was hand-rolling around
127
+ * `useAuthFlow`. Navigation links between steps (login ↔ signup, forgot
128
+ * password, back from verification) are pre-wired to `goToStep`; each
129
+ * submit-shaped prop awaits your handler and applies the returned
130
+ * {@link AuthFlowDirective}.
131
+ *
132
+ * The blocks themselves stay untouched: passwords, codes and field drafts
133
+ * live in their local state, and a rejected handler renders as the block's
134
+ * own error — the orchestrator never mirrors transients into the store.
135
+ * Needs `ExegiaProvider` above it, like every stateful block.
136
+ *
137
+ * ```tsx
138
+ * <AuthFlowBlock
139
+ * onLogin={async ({ email, password }) => {
140
+ * const outcome = await api.signIn(email, password)
141
+ * return outcome.mfa ? { verify: { identifier: email } } : { user: outcome.user }
142
+ * }}
143
+ * onVerifyCode={async (code) => ({ user: await api.verify(code) })}
144
+ * />
145
+ * ```
146
+ */
147
+ export function AuthFlowBlock({
148
+ flowId,
149
+ logo,
150
+ accent,
151
+ providers,
152
+ onLogin,
153
+ onSignup,
154
+ onProviderSelect,
155
+ onRequestReset,
156
+ onVerifyCode,
157
+ onResendCode,
158
+ onUpdatePassword,
159
+ onOnboardingComplete,
160
+ onboardingSteps,
161
+ steps,
162
+ renderStep,
163
+ success,
164
+ successTitle = "Welcome",
165
+ successDescription,
166
+ className,
167
+ }: AuthFlowBlockProps): React.ReactElement {
168
+ const flow = useAuthFlow(flowId)
169
+ const { goToStep, beginVerification, complete } = useAuthFlowActions(flowId)
170
+
171
+ const apply = React.useCallback(
172
+ (directive: AuthFlowDirective) => {
173
+ if (!directive) return
174
+ if ("user" in directive) complete(directive.user)
175
+ else if ("verify" in directive) beginVerification(directive.verify)
176
+ else goToStep(directive.step)
177
+ },
178
+ [complete, beginVerification, goToStep]
179
+ )
180
+
181
+ /** Wrap a handler so its directive lands in the store. Errors propagate —
182
+ * the block owns the error rendering. Absent handlers stay absent, so a
183
+ * block keeps its "no handler" affordances (hidden buttons etc.). */
184
+ function run<Data>(handler: AuthFlowHandler<Data> | undefined) {
185
+ if (!handler) return undefined
186
+ return async (data: Data) => {
187
+ apply(await handler(data))
188
+ }
189
+ }
190
+
191
+ const content = ((): React.ReactNode => {
192
+ const custom = renderStep?.(flow.step, flow)
193
+ if (custom !== undefined) return custom
194
+
195
+ switch (flow.step) {
196
+ case "login":
197
+ return (
198
+ <LoginBlock
199
+ logo={logo}
200
+ accent={accent}
201
+ providers={providers}
202
+ onSubmit={run(onLogin)}
203
+ onProviderSelect={run(onProviderSelect)}
204
+ onForgotPassword={() => goToStep("forgot-password")}
205
+ onSignup={() => goToStep("signup")}
206
+ {...steps?.login}
207
+ />
208
+ )
209
+ case "signup":
210
+ return (
211
+ <SignupBlock
212
+ logo={logo}
213
+ accent={accent}
214
+ providers={providers}
215
+ onSubmit={run(onSignup)}
216
+ onProviderSelect={run(onProviderSelect)}
217
+ onLogin={() => goToStep("login")}
218
+ {...steps?.signup}
219
+ />
220
+ )
221
+ case "verify-code":
222
+ return (
223
+ <CodeAuthBlock
224
+ logo={logo}
225
+ accent={accent}
226
+ channel={flow.channel}
227
+ destination={flow.maskedIdentifier ?? undefined}
228
+ onVerify={run(onVerifyCode)}
229
+ onResend={run(onResendCode)}
230
+ onBack={() => goToStep("login")}
231
+ {...steps?.["verify-code"]}
232
+ />
233
+ )
234
+ case "forgot-password":
235
+ return (
236
+ <ForgotPasswordBlock
237
+ logo={logo}
238
+ accent={accent}
239
+ onSubmit={run(onRequestReset)}
240
+ onBackToLogin={() => goToStep("login")}
241
+ {...steps?.["forgot-password"]}
242
+ />
243
+ )
244
+ case "update-password":
245
+ return (
246
+ <UpdatePasswordBlock
247
+ logo={logo}
248
+ accent={accent}
249
+ onSubmit={run(onUpdatePassword)}
250
+ onDone={() => goToStep("login")}
251
+ {...steps?.["update-password"]}
252
+ />
253
+ )
254
+ case "onboarding":
255
+ return (
256
+ <OnboardingBlock
257
+ logo={logo}
258
+ accent={accent}
259
+ steps={onboardingSteps}
260
+ onComplete={run(onOnboardingComplete)}
261
+ // The flow's success step is the completion screen — showing the
262
+ // block's own too would stack two checkmarks.
263
+ showCompleteScreen={false}
264
+ {...steps?.onboarding}
265
+ />
266
+ )
267
+ case "success":
268
+ return (
269
+ success ?? (
270
+ // Header names the moment, the check screen names the outcome —
271
+ // the same split the onboarding block's completion screen uses.
272
+ <AuthCard title={successTitle} logo={logo} accent={accent}>
273
+ <AuthSuccess
274
+ title="You're signed in"
275
+ description={successDescription}
276
+ />
277
+ </AuthCard>
278
+ )
279
+ )
280
+ }
281
+ })()
282
+
283
+ return (
284
+ <MotionConfig reducedMotion="user">
285
+ <div
286
+ className={cn("flex w-full justify-center", className)}
287
+ data-slot="auth-flow"
288
+ data-step={flow.step}
289
+ >
290
+ <MorphStep step={flow.step} className="flex w-full justify-center">
291
+ {content}
292
+ </MorphStep>
293
+ </div>
294
+ </MotionConfig>
295
+ )
296
+ }