@rightkit/onboarding 0.5.0 → 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`, `signInStep` (Google/Apple/OAuth buttons that call the shared `rightkit-oauth` owner; copy says "Opens your browser"; never an in-app webview, Orthic-Labs/rightkit#279); 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
@@ -45,7 +45,7 @@ import "@rightkit/onboarding/onboarding.css";
45
45
 
46
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
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)`.
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.
49
49
 
50
50
  ## When the legal gate re-prompts
51
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, legalMode, appName, 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 | null;
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). */
@@ -84,9 +86,10 @@ function NoticeList({ documents }) {
84
86
  return _jsx("p", { className: "rk-onboarding__muted", children: "No notices to show." });
85
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)) });
86
88
  }
87
- 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 }) {
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 }) {
88
90
  if (legalMode === "gate" && !legal)
89
91
  throw new Error("Onboarding: `legal` is required when legalMode is \"gate\"");
92
+ const L = { ...DEFAULT_LABELS, ...labelOverrides };
90
93
  const gated = legalMode === "gate";
91
94
  /** Leading steps the user cannot navigate to: the Agreement stand-in in gate mode. */
92
95
  const lead = gated ? 1 : 0;
@@ -97,6 +100,9 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
97
100
  const [gateCleared, setGateCleared] = useState(false);
98
101
  /** One auto-start per mount, even when the models step unmounts (Back/Next) or the step list is rebuilt. */
99
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);
100
106
  /** Persists a patch; throws when the settings backend rejects it. */
101
107
  const persist = useCallback(async (patch) => {
102
108
  setSaving(true);
@@ -169,6 +175,9 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
169
175
  const index = !accepted ? 0 : builtins.length > lead ? Math.max(lead, Math.min(activeState.currentStep, builtins.length - 1)) : 0;
170
176
  const active = builtins[index];
171
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;
172
181
  const setReady = useCallback((ready) => setReadyMap((m) => m[activeId] === ready ? m : { ...m, [activeId]: ready }), [activeId]);
173
182
  useEffect(() => {
174
183
  if (!needsGate) {
@@ -188,7 +197,8 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
188
197
  setGateCleared(true); })();
189
198
  return () => { live = false; };
190
199
  }, [needsGate, gateCleared, beforeLegalGate]);
191
- 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 };
192
202
  const canContinue = active?.canContinue ? active.canContinue(context) : true;
193
203
  const runCallback = async (callback) => {
194
204
  try {
@@ -198,24 +208,55 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
198
208
  setError(e instanceof Error ? e.message : String(e));
199
209
  }
200
210
  };
201
- const markAndMove = async (nextIndex, completedId) => {
202
- 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;
203
220
  if (nextIndex >= builtins.length) {
204
- if (await save({ completed, currentStep: Math.max(0, builtins.length - 1), skipped: false }))
205
- 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)));
206
223
  return;
207
224
  }
208
- 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 });
209
250
  };
210
251
  if (error && !state)
211
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" })] });
212
253
  if (state && !active)
213
- 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), children: "Finish setup" })] });
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 })] });
214
255
  if (!state || !active)
215
- 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 }) });
216
257
  if (needsGate) {
217
258
  if (!gateCleared)
218
- 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 })] });
219
260
  if (!legal)
220
261
  return null;
221
262
  const reacceptance = activeState.legalAccepted || activeState.legalAcceptance !== undefined;
@@ -233,10 +274,10 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
233
274
  const progress = reacceptance ? {} : { completed: ["legal"], currentStep: builtins.length > 1 ? 1 : 0 };
234
275
  await persist({ legalAccepted: true, eligibilityBasis, legalAcceptance, ...progress });
235
276
  if (!reacceptance && builtins.length === 1)
236
- await onComplete();
277
+ await onComplete(resultOf(["legal"], []));
237
278
  } })] });
238
279
  }
239
280
  const body = active.render(context);
240
- 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, onClick: () => { if (i >= lead && (i <= index || canContinue))
241
- 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 <= lead || 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) })] })] })] });
242
283
  }
package/dist/steps.d.ts CHANGED
@@ -21,11 +21,26 @@ 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 {
25
38
  label?: string;
26
39
  title?: string;
27
40
  description?: string;
28
- }): OnboardingStep;
41
+ copy?: LicenseStepCopy;
42
+ }
43
+ export declare function licenseStep(backend: LicenseBackend, options?: LicenseStepOptions): OnboardingStep;
29
44
  /** Sign-in step. Every button hands off to the shared owner and opens the user's default browser; no in-app webview. */
30
45
  export declare function signInStep(backend: SignInBackend, options?: {
31
46
  label?: string;
package/dist/steps.js CHANGED
@@ -100,18 +100,22 @@ 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 }) };
115
119
  }
116
120
  function SignInBody({ backend }) {
117
121
  const [account, setAccount] = useState({ signedIn: false });
package/dist/types.d.ts CHANGED
@@ -108,6 +108,29 @@ export interface PersistedOnboardingState {
108
108
  /** Legacy mirror of `legalAcceptance.eligibilityBasis`. */
109
109
  eligibilityBasis?: EligibilityBasis;
110
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;
111
134
  }
112
135
  export interface OnboardingStepContext {
113
136
  state: PersistedOnboardingState;
@@ -121,6 +144,8 @@ export interface OnboardingStepContext {
121
144
  ready: Readonly<Record<string, boolean>>;
122
145
  /** Reports whether the active step is ready (used by `canContinue`). Stable per step. */
123
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;
124
149
  }
125
150
  export interface OnboardingStep {
126
151
  id: string;
@@ -132,6 +157,14 @@ export interface OnboardingStep {
132
157
  canContinue?: (context: OnboardingStepContext) => boolean;
133
158
  /** Shown beside the disabled Continue/Finish button. */
134
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). */
135
168
  render: (context: OnboardingStepContext) => ReactNode;
136
169
  }
137
170
  export interface LegalStepConfig extends Omit<LegalGateProps, "variant" | "onAgree" | "onQuit"> {
@@ -157,8 +190,11 @@ export interface OnboardingProps {
157
190
  permissions?: PermissionsBackend;
158
191
  models?: ModelsBackend;
159
192
  license?: LicenseBackend;
160
- 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>;
161
195
  onSkip?: () => void | Promise<void>;
196
+ /** Override any button or status text. Keys not given keep the English defaults. */
197
+ labels?: Partial<OnboardingLabels>;
162
198
  className?: string;
163
199
  /** Version the user must have accepted. A different (or missing) stored version re-prompts with `variant: "re-acceptance"`. */
164
200
  requiredVersion?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/onboarding",
3
- "version": "0.5.0",
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,8 +29,8 @@
29
29
  "react": ">=18 <21"
30
30
  },
31
31
  "devDependencies": {
32
- "@rightkit/react-shell": "0.6.8",
33
- "@rightkit/legal-ui": "0.3.0",
32
+ "@rightkit/react-shell": "0.7.0",
33
+ "@rightkit/legal-ui": "0.4.0",
34
34
  "@rightkit/qa-chrome": "0.1.8",
35
35
  "@testing-library/jest-dom": "^7.0.1",
36
36
  "@testing-library/dom": "^10.4.2",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "repository": {
53
53
  "type": "git",
54
- "url": "git+https://github.com/Orthic-Labs/rightkit.git",
54
+ "url": "git+https://github.com/bogusyogi/rightkit.git",
55
55
  "directory": "packages/onboarding"
56
56
  },
57
57
  "scripts": {