@rightkit/onboarding 0.3.3 → 0.6.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.
package/README.md CHANGED
@@ -13,7 +13,7 @@ First-run onboarding framework for Right Suite apps. The legal agreement gate co
13
13
 
14
14
  | Import | Contents |
15
15
  |---|---|
16
- | `@rightkit/onboarding` | `Onboarding`, `isLegalAccepted`, `onboardingStateKey`, `licenseStep`, `modelsStep`, `permissionsStep`; types from `types.ts`, `ModelsStepOptions` |
16
+ | `@rightkit/onboarding` | `Onboarding`, `isLegalAccepted`, `onboardingStateKey`, `licenseStep`, `modelsStep`, `permissionsStep`, `signInStep` (Google/Apple/OAuth buttons that call the shared `rightkit-oauth` owner; copy says "Opens your browser"; never an in-app webview, bogusyogi/rightkit#279); types from `types.ts`, `ModelsStepOptions` |
17
17
  | `@rightkit/onboarding/onboarding.css` | Stylesheet |
18
18
 
19
19
  ## Peer dependencies
@@ -43,7 +43,9 @@ import "@rightkit/onboarding/onboarding.css";
43
43
  />
44
44
  ```
45
45
 
46
- Acceptance is stored as `legalAcceptance: { version, documentsHash, acceptedAt, eligibilityBasis }`. An app that already keeps its own acceptance receipt can pass `legalReceipt` (`read()` and `write(record)`) and keep that store as the source of truth. Steps can set `canContinue(context)`.
46
+ Free or open-source apps without a legal gate pass `legalMode="none"` (no legal step, `legal` not needed, `appName` for the rail) or `legalMode="notice"` (no gate or acceptance; a first Notice step lists `legal.documents` as links). The default is `"gate"`.
47
+
48
+ Acceptance is stored as `legalAcceptance: { version, documentsHash, acceptedAt, eligibilityBasis }`. An app that already keeps its own acceptance receipt can pass `legalReceipt` (`read()` and `write(record)`) and keep that store as the source of truth. Steps can set `canContinue(context)`, `onContinue(context)` (a veto: `false` or a throw keeps the step), `continueLabel`, and `skippable` (a per-step Skip button). `ctx.setResult(value)` stores a value that `onComplete({ completed, skipped, steps })` receives. `labels` overrides the button and status text, and `licenseStep(backend, { copy })` overrides the licence copy.
47
49
 
48
50
  ## When the legal gate re-prompts
49
51
 
@@ -2,4 +2,4 @@ import type { OnboardingProps, PersistedOnboardingState } from "./types.js";
2
2
  /** True when the stored acceptance satisfies the host's requirement. */
3
3
  export declare function isLegalAccepted(state: PersistedOnboardingState, requiredVersion?: string, custom?: (state: PersistedOnboardingState) => boolean, requiredDocumentsHash?: string): boolean;
4
4
  export declare function onboardingStateKey(): string;
5
- export declare function Onboarding({ settings, legal, steps, permissions, models, license, onComplete, onSkip, className, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt }: OnboardingProps): import("react").JSX.Element;
5
+ export declare function Onboarding({ settings, legal, legalMode, appName, steps, permissions, models, license, onComplete, onSkip, className, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt, labels: labelOverrides }: OnboardingProps): import("react").JSX.Element | null;
@@ -6,6 +6,7 @@ import { cx } from "./ui.js";
6
6
  import { licenseStep, modelsStep, permissionsStep } from "./steps.js";
7
7
  const KEY = "onboarding.state";
8
8
  const INITIAL = { version: 1, currentStep: 0, completed: [], skipped: false, legalAccepted: false };
9
+ const DEFAULT_LABELS = { skip: "Set up later", skipStep: "Skip", back: "Back", continue: "Continue", finish: "Finish setup", loading: "Loading setup…" };
9
10
  function readAcceptance(value) {
10
11
  if (!value || typeof value !== "object")
11
12
  return undefined;
@@ -29,6 +30,7 @@ function readState(value) {
29
30
  legalAccepted: candidate.legalAccepted === true || legalAcceptance !== undefined,
30
31
  eligibilityBasis: legalAcceptance?.eligibilityBasis ?? (candidate.eligibilityBasis === "individual" || candidate.eligibilityBasis === "enterprise" ? candidate.eligibilityBasis : undefined),
31
32
  ...(legalAcceptance ? { legalAcceptance } : {}),
33
+ ...(Array.isArray(candidate.skippedSteps) ? { skippedSteps: candidate.skippedSteps.filter((x) => typeof x === "string") } : {}),
32
34
  };
33
35
  }
34
36
  /** Replace the acceptance in `state` with the one from the app's receipt (`undefined`: not accepted). */
@@ -79,7 +81,18 @@ function TitleStrip({ titlebar, dragRegion }) {
79
81
  return null;
80
82
  return _jsx("div", { className: "rk-onboarding__titlebar", "data-testid": "onboarding-titlebar", ...(dragRegion ? { "data-tauri-drag-region": "" } : {}), children: titlebar });
81
83
  }
82
- export function Onboarding({ settings, legal, steps = [], permissions, models, license, onComplete, onSkip, className, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt }) {
84
+ function NoticeList({ documents }) {
85
+ if (!documents.length)
86
+ return _jsx("p", { className: "rk-onboarding__muted", children: "No notices to show." });
87
+ return _jsx("ul", { className: "rk-onboarding__notices", children: documents.map((d) => _jsx("li", { children: d.onOpen ? _jsx("button", { type: "button", onClick: d.onOpen, children: d.title }) : d.url ? _jsx("a", { href: d.url, target: "_blank", rel: "noreferrer", children: d.title }) : d.title }, d.title)) });
88
+ }
89
+ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps = [], permissions, models, license, onComplete, onSkip, className, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt, labels: labelOverrides }) {
90
+ if (legalMode === "gate" && !legal)
91
+ throw new Error("Onboarding: `legal` is required when legalMode is \"gate\"");
92
+ const L = { ...DEFAULT_LABELS, ...labelOverrides };
93
+ const gated = legalMode === "gate";
94
+ /** Leading steps the user cannot navigate to: the Agreement stand-in in gate mode. */
95
+ const lead = gated ? 1 : 0;
83
96
  const [state, setState] = useState(null);
84
97
  const [saving, setSaving] = useState(false);
85
98
  const [error, setError] = useState(null);
@@ -87,6 +100,9 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
87
100
  const [gateCleared, setGateCleared] = useState(false);
88
101
  /** One auto-start per mount, even when the models step unmounts (Back/Next) or the step list is rebuilt. */
89
102
  const modelsStarted = useRef(false);
103
+ /** Values passed to `ctx.setResult`, keyed by step id; handed to `onComplete`. */
104
+ const stepResults = useRef({});
105
+ const [continuing, setContinuing] = useState(false);
90
106
  /** Persists a patch; throws when the settings backend rejects it. */
91
107
  const persist = useCallback(async (patch) => {
92
108
  setSaving(true);
@@ -144,20 +160,24 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
144
160
  return () => { live = false; };
145
161
  }, [settings, legalReceipt]);
146
162
  const builtins = useMemo(() => [
147
- { id: "legal", label: "Agreement", title: "Review agreement", skippable: false, render: () => _jsx("p", { className: "rk-onboarding__muted", children: "Agreement accepted." }) },
163
+ ...(gated ? [{ id: "legal", label: "Agreement", title: "Review agreement", skippable: false, render: () => _jsx("p", { className: "rk-onboarding__muted", children: "Agreement accepted." }) }] : []),
164
+ ...(legalMode === "notice" ? [{ id: "notice", label: "Notice", title: "Notices", skippable: false, render: () => _jsx(NoticeList, { documents: legal?.documents ?? [] }) }] : []),
148
165
  ...(permissions ? [permissionsStep(permissions)] : []),
149
166
  ...(models ? [modelsStep(models, { required: requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, intro: modelsIntro, startGuard: modelsStarted })] : []),
150
167
  ...(license ? [licenseStep(license)] : []),
151
168
  ...steps,
152
- ], [license, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
169
+ ], [gated, legalMode, legal?.documents, license, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
153
170
  const activeState = state ?? INITIAL;
154
- const accepted = isLegalAccepted(activeState, requiredVersion, isAccepted, requiredDocumentsHash);
171
+ const accepted = !gated || isLegalAccepted(activeState, requiredVersion, isAccepted, requiredDocumentsHash);
155
172
  const needsGate = state !== null && !accepted;
156
173
  // The agreement gate does not depend on the saved step: until it is accepted, the active step is the first.
157
174
  // Once accepted, the Agreement step (index 0) is never shown again when other steps exist.
158
- const index = !accepted ? 0 : builtins.length > 1 ? Math.max(1, Math.min(activeState.currentStep, builtins.length - 1)) : 0;
175
+ const index = !accepted ? 0 : builtins.length > lead ? Math.max(lead, Math.min(activeState.currentStep, builtins.length - 1)) : 0;
159
176
  const active = builtins[index];
160
177
  const activeId = active?.id ?? "";
178
+ /** The step on screen now; an `onContinue` that resolves after the step changed is stale. */
179
+ const activeIdRef = useRef(activeId);
180
+ activeIdRef.current = activeId;
161
181
  const setReady = useCallback((ready) => setReadyMap((m) => m[activeId] === ready ? m : { ...m, [activeId]: ready }), [activeId]);
162
182
  useEffect(() => {
163
183
  if (!needsGate) {
@@ -177,7 +197,8 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
177
197
  setGateCleared(true); })();
178
198
  return () => { live = false; };
179
199
  }, [needsGate, gateCleared, beforeLegalGate]);
180
- const context = { state: activeState, settings, setState: saveStep, permissions, models, license, ready: readyMap, setReady };
200
+ const setResult = useCallback((value) => { stepResults.current[activeId] = value; }, [activeId]);
201
+ const context = { state: activeState, settings, setState: saveStep, permissions, models, license, ready: readyMap, setReady, setResult };
181
202
  const canContinue = active?.canContinue ? active.canContinue(context) : true;
182
203
  const runCallback = async (callback) => {
183
204
  try {
@@ -187,22 +208,57 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
187
208
  setError(e instanceof Error ? e.message : String(e));
188
209
  }
189
210
  };
190
- const markAndMove = async (nextIndex, completedId) => {
191
- const completed = completedId && !activeState.completed.includes(completedId) ? [...activeState.completed, completedId] : activeState.completed;
211
+ const resultOf = (completed, skipped) => ({ completed, skipped, steps: { ...stepResults.current } });
212
+ /** Marks `mark.completed` done or `mark.skipped` skipped (never both), then moves to `nextIndex`; completing the last step calls `onComplete`. */
213
+ const markAndMove = async (nextIndex, mark = {}) => {
214
+ const previousSkipped = activeState.skippedSteps ?? [];
215
+ // A step is completed or skipped, never both: a skip removes an earlier completion of the same step.
216
+ const completedBase = activeState.completed.filter((id) => id !== mark.skipped);
217
+ const completed = mark.completed && !completedBase.includes(mark.completed) ? [...completedBase, mark.completed] : completedBase;
218
+ const skippedBase = previousSkipped.filter((id) => id !== mark.completed);
219
+ const skippedSteps = mark.skipped && !skippedBase.includes(mark.skipped) ? [...skippedBase, mark.skipped] : skippedBase;
192
220
  if (nextIndex >= builtins.length) {
193
- if (await save({ completed, currentStep: Math.max(0, builtins.length - 1), skipped: false }))
194
- await runCallback(onComplete);
221
+ if (await save({ completed, skippedSteps, currentStep: Math.max(0, builtins.length - 1), skipped: false }))
222
+ await runCallback(() => onComplete(resultOf(completed, skippedSteps)));
195
223
  return;
196
224
  }
197
- await save({ completed, currentStep: nextIndex, skipped: false });
225
+ await save({ completed, skippedSteps, currentStep: nextIndex, skipped: false });
226
+ };
227
+ /** Primary button: runs the step's `onContinue` veto first; `false` or a throw keeps the step. */
228
+ const advance = async () => {
229
+ if (active.onContinue) {
230
+ const stepId = active.id;
231
+ setContinuing(true);
232
+ setError(null);
233
+ let proceed = true;
234
+ try {
235
+ proceed = (await active.onContinue(context)) !== false;
236
+ }
237
+ catch (e) {
238
+ if (activeIdRef.current === stepId)
239
+ setError(e instanceof Error ? e.message : String(e));
240
+ return;
241
+ }
242
+ finally {
243
+ setContinuing(false);
244
+ }
245
+ // The step changed while onContinue was awaited: discard this completion rather than move or save from stale state.
246
+ if (!proceed || activeIdRef.current !== stepId)
247
+ return;
248
+ }
249
+ await markAndMove(index + 1, { completed: active.id });
198
250
  };
199
251
  if (error && !state)
200
252
  return _jsxs("div", { className: cx("rk-onboarding", className), children: [_jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }), _jsx(Button, { onClick: () => window.location.reload(), children: "Retry" })] });
253
+ if (state && !active)
254
+ return _jsxs("main", { className: cx("rk-onboarding", className), "data-testid": "onboarding", children: [_jsx("p", { className: "rk-onboarding__muted", children: "Nothing to set up." }), _jsx(Button, { variant: "primary", onClick: () => void runCallback(() => onComplete(resultOf(activeState.completed, activeState.skippedSteps ?? []))), children: L.finish })] });
201
255
  if (!state || !active)
202
- return _jsx("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: _jsx("p", { className: "rk-onboarding__muted", children: "Loading setup\u2026" }) });
256
+ return _jsx("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: _jsx("p", { className: "rk-onboarding__muted", children: L.loading }) });
203
257
  if (needsGate) {
204
258
  if (!gateCleared)
205
- return _jsxs("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx("p", { className: "rk-onboarding__muted", children: "Loading setup\u2026" })] });
259
+ return _jsxs("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx("p", { className: "rk-onboarding__muted", children: L.loading })] });
260
+ if (!legal)
261
+ return null;
206
262
  const reacceptance = activeState.legalAccepted || activeState.legalAcceptance !== undefined;
207
263
  return _jsxs("div", { className: cx("rk-onboarding rk-onboarding--legal", className), "data-testid": "onboarding-legal", "data-variant": reacceptance ? "re-acceptance" : "first-run", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx(LegalGate, { ...legal, variant: reacceptance ? "re-acceptance" : "first-run", onQuit: () => void legal.onQuit?.(), onAgree: async ({ eligibilityBasis }) => {
208
264
  const legalAcceptance = { version: requiredVersion ?? "1", documentsHash: documentsHash ?? requiredDocumentsHash ?? await hashDocuments(legal.documents), acceptedAt: new Date().toISOString(), eligibilityBasis };
@@ -218,10 +274,10 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
218
274
  const progress = reacceptance ? {} : { completed: ["legal"], currentStep: builtins.length > 1 ? 1 : 0 };
219
275
  await persist({ legalAccepted: true, eligibilityBasis, legalAcceptance, ...progress });
220
276
  if (!reacceptance && builtins.length === 1)
221
- await onComplete();
277
+ await onComplete(resultOf(["legal"], []));
222
278
  } })] });
223
279
  }
224
280
  const body = active.render(context);
225
- return _jsxs("main", { className: cx("rk-onboarding", className), "data-testid": "onboarding", "data-step-id": active.id, children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: dragRegion }), _jsxs("aside", { className: "rk-onboarding__rail", children: [_jsx("div", { className: "rk-onboarding__brand", children: legal.appName }), _jsx("nav", { "aria-label": "Setup progress", children: builtins.map((step, i) => _jsxs("button", { type: "button", className: cx("rk-onboarding__step", i === index && "is-active", activeState.completed.includes(step.id) && "is-done"), "aria-current": i === index ? "step" : undefined, disabled: i === 0 && builtins.length > 1, onClick: () => { if (i > 0 && (i <= index || canContinue))
226
- void save({ currentStep: i }); }, children: [_jsx("span", { children: activeState.completed.includes(step.id) ? "✓" : i + 1 }), step.label] }, step.id)) })] }), _jsxs("section", { className: "rk-onboarding__main", children: [_jsxs("header", { children: [_jsxs("p", { className: "rk-onboarding__eyebrow", children: ["Step ", index + 1, " of ", builtins.length] }), _jsx("h1", { children: active.title }), active.description ? _jsx("p", { className: "rk-onboarding__description", children: active.description }) : null] }), _jsx("div", { className: "rk-onboarding__content", children: body }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, _jsxs("footer", { children: [allowSkip ? _jsx(Button, { variant: "ghost", disabled: saving, onClick: () => { void save({ currentStep: index, skipped: true }).then((ok) => ok ? runCallback(onSkip) : undefined); }, children: "Set up later" }) : null, _jsx("span", { className: "rk-onboarding__hint", children: !canContinue && active.blockedHint ? active.blockedHint : null }), _jsx(Button, { variant: "secondary", disabled: index <= 1 || saving, onClick: () => void save({ currentStep: index - 1 }), children: "Back" }), _jsx(Button, { variant: "primary", disabled: saving || !canContinue, onClick: () => void markAndMove(index + 1, active.id), children: index === builtins.length - 1 ? "Finish setup" : "Continue" })] })] })] });
281
+ return _jsxs("main", { className: cx("rk-onboarding", className), "data-testid": "onboarding", "data-step-id": active.id, children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: dragRegion }), _jsxs("aside", { className: "rk-onboarding__rail", children: [_jsx("div", { className: "rk-onboarding__brand", children: appName ?? legal?.appName }), _jsx("nav", { "aria-label": "Setup progress", children: builtins.map((step, i) => _jsxs("button", { type: "button", className: cx("rk-onboarding__step", i === index && "is-active", activeState.completed.includes(step.id) && "is-done"), "aria-current": i === index ? "step" : undefined, disabled: (gated && i === 0 && builtins.length > 1) || continuing, onClick: () => { if (!continuing && i >= lead && (i <= index || canContinue))
282
+ void save({ currentStep: i }); }, children: [_jsx("span", { children: activeState.completed.includes(step.id) ? "✓" : i + 1 }), step.label] }, step.id)) })] }), _jsxs("section", { className: "rk-onboarding__main", children: [_jsxs("header", { children: [_jsxs("p", { className: "rk-onboarding__eyebrow", children: ["Step ", index + 1, " of ", builtins.length] }), active.title ? _jsx("h1", { children: active.title }) : null, active.description ? _jsx("p", { className: "rk-onboarding__description", children: active.description }) : null] }), _jsx("div", { className: "rk-onboarding__content", children: body }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, _jsxs("footer", { children: [allowSkip ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => { void save({ currentStep: index, skipped: true }).then((ok) => ok ? runCallback(onSkip) : undefined); }, children: L.skip }) : null, active.skippable ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => void markAndMove(index + 1, { skipped: active.id }), children: L.skipStep }) : null, _jsx("span", { className: "rk-onboarding__hint", children: !canContinue && active.blockedHint ? active.blockedHint : null }), _jsx(Button, { variant: "secondary", disabled: index <= lead || saving || continuing, onClick: () => void save({ currentStep: index - 1 }), children: L.back }), _jsx(Button, { variant: "primary", disabled: saving || continuing || !canContinue, onClick: () => void advance(), children: active.continueLabel ?? (index === builtins.length - 1 ? L.finish : L.continue) })] })] })] });
227
283
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
2
- export { licenseStep, modelsStep, permissionsStep } from "./steps.js";
2
+ export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
3
3
  export type { ModelsStepOptions } from "./steps.js";
4
4
  export type * from "./types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
2
- export { licenseStep, modelsStep, permissionsStep } from "./steps.js";
2
+ export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
package/dist/steps.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
- import type { LicenseBackend, ModelsAutoStart, ModelsBackend, OnboardingStep, PermissionsBackend } from "./types.js";
2
+ import type { SignInBackend, LicenseBackend, ModelsAutoStart, ModelsBackend, OnboardingStep, PermissionsBackend } from "./types.js";
3
3
  export declare function permissionsStep(backend: PermissionsBackend, options?: {
4
4
  label?: string;
5
5
  title?: string;
@@ -21,7 +21,28 @@ export interface ModelsStepOptions extends ModelsAutoStart {
21
21
  };
22
22
  }
23
23
  export declare function modelsStep(backend: ModelsBackend, options?: ModelsStepOptions): OnboardingStep;
24
- export declare function licenseStep(backend: LicenseBackend, options?: {
24
+ /** Copy overrides for the licence step. Each key defaults to the English text. */
25
+ export interface LicenseStepCopy {
26
+ /** Key field label and placeholder. Default "Licence key". */
27
+ keyLabel?: string;
28
+ /** Submit button. Default "Activate". */
29
+ activateLabel?: string;
30
+ /** Trial button. Default "Start trial". */
31
+ trialLabel?: string;
32
+ /** Plans link. Default "View plans". */
33
+ plansLabel?: string;
34
+ /** Status text when no licence is active. Default "You can continue without a licence.". */
35
+ noLicenceDetail?: string;
36
+ }
37
+ export interface LicenseStepOptions {
38
+ label?: string;
39
+ title?: string;
40
+ description?: string;
41
+ copy?: LicenseStepCopy;
42
+ }
43
+ export declare function licenseStep(backend: LicenseBackend, options?: LicenseStepOptions): OnboardingStep;
44
+ /** Sign-in step. Every button hands off to the shared owner and opens the user's default browser; no in-app webview. */
45
+ export declare function signInStep(backend: SignInBackend, options?: {
25
46
  label?: string;
26
47
  title?: string;
27
48
  description?: string;
package/dist/steps.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { Button, Input, Badge } from "@rightkit/react-shell/react";
4
4
  function ProgressBar({ value, label }) {
@@ -100,16 +100,45 @@ export function modelsStep(backend, options = {}) {
100
100
  render: (ctx) => _jsx(ModelsBody, { backend: backend, onReady: ctx.setReady, autoStartDownload: options.autoStartDownload, shouldAutoStart: options.shouldAutoStart, onModelsStepEnter: options.onModelsStepEnter, intro: options.intro, startGuard: options.startGuard }),
101
101
  };
102
102
  }
103
- function LicenseBody({ backend }) {
103
+ function LicenseBody({ backend, copy }) {
104
104
  const [state, setState] = useState({ status: "none" });
105
105
  const [key, setKey] = useState("");
106
106
  const [busy, setBusy] = useState(false);
107
107
  const [error, setError] = useState(null);
108
108
  useEffect(() => void backend.load().then(setState, (e) => setError(e instanceof Error ? e.message : String(e))), [backend]);
109
109
  const run = (fn) => { setBusy(true); setError(null); void fn().then(setState, (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(false)); };
110
- return _jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-license", children: [_jsxs("div", { className: "rk-onboarding__license-state", children: [_jsx(Badge, { tone: state.status === "active" || state.status === "trial" ? "ok" : state.status === "expired" || state.status === "invalid" ? "bad" : "neutral", children: state.label ?? state.status }), _jsx("span", { children: state.detail ?? "You can continue without a licence." })] }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, backend.activate ? _jsxs("form", { className: "rk-onboarding__form", onSubmit: (e) => { e.preventDefault(); if (key.trim())
111
- run(() => backend.activate(key.trim())); }, children: [_jsx(Input, { "aria-label": "Licence key", placeholder: "Licence key", value: key, onChange: (e) => setKey(e.target.value), disabled: busy }), _jsx(Button, { size: "sm", variant: "primary", type: "submit", disabled: busy || !key.trim(), children: "Activate" })] }) : null, _jsxs("div", { className: "rk-onboarding__row-actions", children: [backend.startTrial ? _jsx(Button, { size: "sm", variant: "secondary", disabled: busy, onClick: () => run(() => backend.startTrial()), children: "Start trial" }) : null, backend.purchaseUrl ? _jsx(Button, { size: "sm", variant: "ghost", onClick: () => void backend.openExternal?.(backend.purchaseUrl), children: "View plans" }) : null] })] });
110
+ return _jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-license", children: [_jsxs("div", { className: "rk-onboarding__license-state", children: [_jsx(Badge, { tone: state.status === "active" || state.status === "trial" ? "ok" : state.status === "expired" || state.status === "invalid" ? "bad" : "neutral", children: state.label ?? state.status }), _jsx("span", { children: state.detail ?? copy.noLicenceDetail })] }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, backend.activate ? _jsxs("form", { className: "rk-onboarding__form", onSubmit: (e) => { e.preventDefault(); if (key.trim())
111
+ run(() => backend.activate(key.trim())); }, children: [_jsx(Input, { "aria-label": copy.keyLabel, placeholder: copy.keyLabel, value: key, onChange: (e) => setKey(e.target.value), disabled: busy }), _jsx(Button, { size: "sm", variant: "primary", type: "submit", disabled: busy || !key.trim(), children: copy.activateLabel })] }) : null, _jsxs("div", { className: "rk-onboarding__row-actions", children: [backend.startTrial ? _jsx(Button, { size: "sm", variant: "secondary", disabled: busy, onClick: () => run(() => backend.startTrial()), children: copy.trialLabel }) : null, backend.purchaseUrl ? _jsx(Button, { size: "sm", variant: "ghost", onClick: () => void backend.openExternal?.(backend.purchaseUrl), children: copy.plansLabel }) : null] })] });
112
112
  }
113
113
  export function licenseStep(backend, options = {}) {
114
- return { id: "license", label: options.label ?? "Licence", title: options.title ?? "Choose how you’ll use this app", description: options.description ?? "Activate a licence, start a trial, or continue with the free plan.", skippable: true, render: () => _jsx(LicenseBody, { backend: backend }) };
114
+ const copy = {
115
+ keyLabel: "Licence key", activateLabel: "Activate", trialLabel: "Start trial", plansLabel: "View plans",
116
+ noLicenceDetail: "You can continue without a licence.", ...options.copy,
117
+ };
118
+ return { id: "license", label: options.label ?? "Licence", title: options.title ?? "Choose how you’ll use this app", description: options.description ?? "Activate a licence, start a trial, or continue with the free plan.", skippable: true, render: () => _jsx(LicenseBody, { backend: backend, copy: copy }) };
119
+ }
120
+ function SignInBody({ backend }) {
121
+ const [account, setAccount] = useState({ signedIn: false });
122
+ const [busy, setBusy] = useState(null);
123
+ const [error, setError] = useState(null);
124
+ useEffect(() => { let live = true; backend.load().then((a) => { if (live)
125
+ setAccount(a); }, () => undefined); return () => { live = false; }; }, [backend]);
126
+ const start = async (provider) => {
127
+ setBusy(provider);
128
+ setError(null);
129
+ try {
130
+ setAccount(await backend.start(provider));
131
+ }
132
+ catch (e) {
133
+ setError(e instanceof Error ? e.message : "Sign-in did not finish.");
134
+ }
135
+ finally {
136
+ setBusy(null);
137
+ }
138
+ };
139
+ return _jsxs("div", { className: "rk-onboarding__signin", children: [account.signedIn ? _jsxs("p", { children: ["Signed in", account.account ? _jsxs(_Fragment, { children: [" as ", _jsx("strong", { children: account.account })] }) : null, "."] }) : null, _jsx("div", { className: "rk-onboarding__row-actions", children: backend.providers.map((p) => _jsx(Button, { size: "sm", variant: "secondary", disabled: busy !== null, onClick: () => void start(p.id), children: busy === p.id ? "Waiting for your browser…" : p.label ?? `Sign in with ${p.id === "google" ? "Google" : p.id === "apple" ? "Apple" : "your account"}` }, p.id)) }), _jsx("p", { className: "rk-onboarding__hint", children: _jsx("small", { children: "Opens your browser. You stay signed in to the accounts you already use there." }) }), error ? _jsx("p", { role: "alert", children: error }) : null] });
140
+ }
141
+ /** Sign-in step. Every button hands off to the shared owner and opens the user's default browser; no in-app webview. */
142
+ export function signInStep(backend, options = {}) {
143
+ return { id: "sign-in", label: options.label ?? "Sign in", title: options.title ?? "Sign in", description: options.description ?? "Sign-in opens your default browser, so you stay signed in to the accounts you already use there.", skippable: true, render: () => _jsx(SignInBody, { backend: backend }) };
115
144
  }
package/dist/types.d.ts CHANGED
@@ -57,6 +57,28 @@ export interface LicenseBackend {
57
57
  purchaseUrl?: string;
58
58
  openExternal?: (url: string) => void | Promise<void>;
59
59
  }
60
+ export type SignInProvider = "google" | "apple" | "oauth";
61
+ /**
62
+ * Sign-in owned by `rightkit-oauth`. Every provider opens the user's DEFAULT browser (never an in-app webview, so existing
63
+ * browser sessions are kept); `start` must call the shared owner (`OAuthClient::sign_in_with_browser` through the app's
64
+ * command), not open a window of its own. Orthic-Labs/rightkit#279.
65
+ */
66
+ export interface SignInBackend {
67
+ providers: readonly {
68
+ id: SignInProvider;
69
+ label?: string;
70
+ }[];
71
+ /** Account already signed in, if any. */
72
+ load(): Promise<{
73
+ signedIn: boolean;
74
+ account?: string;
75
+ }>;
76
+ /** Open the default browser for `provider` and resolve when the sign-in finishes (or reject / throw on failure or cancel). */
77
+ start(provider: SignInProvider): Promise<{
78
+ signedIn: boolean;
79
+ account?: string;
80
+ }>;
81
+ }
60
82
  export type EligibilityBasis = "individual" | "enterprise";
61
83
  /** Full record of one legal acceptance, persisted so version changes can re-prompt. */
62
84
  export interface LegalAcceptanceRecord {
@@ -86,6 +108,29 @@ export interface PersistedOnboardingState {
86
108
  /** Legacy mirror of `legalAcceptance.eligibilityBasis`. */
87
109
  eligibilityBasis?: EligibilityBasis;
88
110
  legalAcceptance?: LegalAcceptanceRecord;
111
+ /** Step ids the user skipped with the per-step Skip button (not marked complete). */
112
+ skippedSteps?: string[];
113
+ }
114
+ /** What `onComplete` receives. `steps` holds values passed to `ctx.setResult` during this mount. */
115
+ export interface OnboardingResult {
116
+ completed: string[];
117
+ skipped: string[];
118
+ steps: Record<string, unknown>;
119
+ }
120
+ /** Button and status copy. Each key defaults to the English text shown in the table. */
121
+ export interface OnboardingLabels {
122
+ /** "Set up later". */
123
+ skip: string;
124
+ /** Per-step Skip button. */
125
+ skipStep: string;
126
+ /** "Back". */
127
+ back: string;
128
+ /** "Continue". */
129
+ continue: string;
130
+ /** "Finish setup" on the last step. */
131
+ finish: string;
132
+ /** "Loading setup…". */
133
+ loading: string;
89
134
  }
90
135
  export interface OnboardingStepContext {
91
136
  state: PersistedOnboardingState;
@@ -94,10 +139,13 @@ export interface OnboardingStepContext {
94
139
  permissions?: PermissionsBackend;
95
140
  models?: ModelsBackend;
96
141
  license?: LicenseBackend;
142
+ signIn?: SignInBackend;
97
143
  /** Readiness reported by steps via `setReady`, keyed by step id. */
98
144
  ready: Readonly<Record<string, boolean>>;
99
145
  /** Reports whether the active step is ready (used by `canContinue`). Stable per step. */
100
146
  setReady: (ready: boolean) => void;
147
+ /** Stores a value for this step; it is passed to `onComplete` as `result.steps[step.id]`. */
148
+ setResult: (value: unknown) => void;
101
149
  }
102
150
  export interface OnboardingStep {
103
151
  id: string;
@@ -109,21 +157,44 @@ export interface OnboardingStep {
109
157
  canContinue?: (context: OnboardingStepContext) => boolean;
110
158
  /** Shown beside the disabled Continue/Finish button. */
111
159
  blockedHint?: string;
160
+ /** Primary button text for this step (e.g. "Open CodeRight"). Overrides Continue and Finish setup. */
161
+ continueLabel?: string;
162
+ /**
163
+ * Runs before Continue/Finish advances. Return `false` (or throw) to keep the step; a throw shows its message.
164
+ * `void` or `true` advances. Not run for Back, the rail, or Skip.
165
+ */
166
+ onContinue?: (context: OnboardingStepContext) => void | boolean | Promise<void | boolean>;
167
+ /** Empty title hides the heading (e.g. a Welcome step). */
112
168
  render: (context: OnboardingStepContext) => ReactNode;
113
169
  }
114
170
  export interface LegalStepConfig extends Omit<LegalGateProps, "variant" | "onAgree" | "onQuit"> {
115
171
  onQuit?: () => void;
116
172
  }
173
+ /**
174
+ * How legal documents take part in onboarding.
175
+ * - `gate` (default): the blocking agreement gate comes first and is recorded (needs `legal`).
176
+ * - `notice`: no gate and no acceptance. A first "Notice" step lists the `legal` documents as links; Continue moves on (free or open-source apps that still hold their notice).
177
+ * - `none`: no legal step at all; `legal` is not needed.
178
+ */
179
+ export type LegalMode = "gate" | "notice" | "none";
117
180
  export interface OnboardingProps {
118
181
  settings: SettingsBackend;
119
- legal: LegalStepConfig;
182
+ /** Required when `legalMode` is `gate` (default). Optional for `notice` (the documents shown) and `none`. */
183
+ legal?: LegalStepConfig;
184
+ /** Default `gate`. See `LegalMode`. */
185
+ legalMode?: LegalMode;
186
+ /** Name shown in the step rail. Default: `legal.appName`. */
187
+ appName?: string;
120
188
  /** App-specific steps are appended after built-in steps, in supplied order. */
121
189
  steps?: readonly OnboardingStep[];
122
190
  permissions?: PermissionsBackend;
123
191
  models?: ModelsBackend;
124
192
  license?: LicenseBackend;
125
- onComplete: () => void | Promise<void>;
193
+ /** Called once when setup finishes, with the completed and skipped step ids and the step results. */
194
+ onComplete: (result: OnboardingResult) => void | Promise<void>;
126
195
  onSkip?: () => void | Promise<void>;
196
+ /** Override any button or status text. Keys not given keep the English defaults. */
197
+ labels?: Partial<OnboardingLabels>;
127
198
  className?: string;
128
199
  /** Version the user must have accepted. A different (or missing) stored version re-prompts with `variant: "re-acceptance"`. */
129
200
  requiredVersion?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/onboarding",
3
- "version": "0.3.3",
3
+ "version": "0.6.0",
4
4
  "description": "First-run onboarding framework: ordered steps, progress, skip/resume, legal gate first, permissions, model download and licence/trial steps.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -29,20 +29,21 @@
29
29
  "react": ">=18 <21"
30
30
  },
31
31
  "devDependencies": {
32
- "@rightkit/react-shell": "0.6.4",
33
- "@rightkit/legal-ui": "0.2.2",
34
- "@rightkit/qa-chrome": "0.1.6",
35
- "@testing-library/jest-dom": "^6.6.3",
36
- "@testing-library/react": "^16.1.0",
37
- "@testing-library/user-event": "^14.5.2",
38
- "@types/react": "^19.0.0",
39
- "@types/react-dom": "^19.0.0",
40
- "jsdom": "^25.0.1",
41
- "react": "^19.0.0",
42
- "react-dom": "^19.0.0",
43
- "typescript": "^5.7.0",
44
- "vite": "^6.0.0",
45
- "vitest": "^2.1.8"
32
+ "@rightkit/react-shell": "0.7.0",
33
+ "@rightkit/legal-ui": "0.4.0",
34
+ "@rightkit/qa-chrome": "0.1.8",
35
+ "@testing-library/jest-dom": "^7.0.1",
36
+ "@testing-library/dom": "^10.4.2",
37
+ "@testing-library/react": "^16.3.3",
38
+ "@testing-library/user-event": "^14.6.8",
39
+ "@types/react": "^19.3.0",
40
+ "@types/react-dom": "^19.3.0",
41
+ "jsdom": "^30.1.3",
42
+ "react": "^19.3.1",
43
+ "react-dom": "^19.3.1",
44
+ "typescript": "^7.0.2",
45
+ "vite": "^8.3.4",
46
+ "vitest": "^5.0.5"
46
47
  },
47
48
  "publishConfig": {
48
49
  "registry": "https://registry.npmjs.org",
@@ -50,7 +51,7 @@
50
51
  },
51
52
  "repository": {
52
53
  "type": "git",
53
- "url": "git+https://github.com/Orthic-Labs/rightkit.git",
54
+ "url": "git+https://github.com/bogusyogi/rightkit.git",
54
55
  "directory": "packages/onboarding"
55
56
  },
56
57
  "scripts": {