@rightkit/onboarding 0.6.1 → 0.6.3

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, bogusyogi/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); native receipt: `createNativeLegalReceiptPort`, `LEGAL_RECEIPT_COMMANDS`, `reopenLegalAcceptance`, `resetLegalAcceptance`; adapters: `licenseBackendFromView`, `licenseStateFromView`, `signInBackendFromOAuth`; types from `types.ts` (including `LegalReceiptPort`, `LegalReceiptCreateRequest`, `DeclaredLegalDocument`), `LicenseStepCopy`, `LicenseStepOptions`, `ModelsStepOptions`, `LicenseViewClient`, `LicenseViewLike`, `LicenseViewBackendOptions`, `OAuthSignInPort`, `LegalReceiptInvoke`, `NativeLegalReceiptPortOptions` |
17
17
  | `@rightkit/onboarding/onboarding.css` | Stylesheet |
18
18
 
19
19
  ## Peer dependencies
@@ -21,7 +21,7 @@ First-run onboarding framework for Right Suite apps. The legal agreement gate co
21
21
  | Peer | Range |
22
22
  |---|---|
23
23
  | `@rightkit/react-shell` | `>=0.2.0 <1` |
24
- | `@rightkit/legal-ui` | `>=0.1.2 <1` |
24
+ | `@rightkit/legal-ui` | `>=0.4.1 <1` |
25
25
  | `react` | `>=18 <21` |
26
26
 
27
27
  ## Example
@@ -45,7 +45,37 @@ 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)`, `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.
48
+ `layout` is `"window"` by default (the surface fills the viewport, as in a dedicated onboarding window). `layout="contained"` removes the viewport minimum height so the host sizes the surface; it is passed to the legal gate as well (`@rightkit/legal-ui` `>=0.4.1`).
49
+
50
+ Acceptance is stored as `legalAcceptance: { version, documentsHash, acceptedAt, eligibilityBasis }`. An app can pass `legalReceipt` and keep its own receipt as the source of truth (`read()` is consulted on load and overrides the settings copy). The receipt is recorded one of two ways:
51
+
52
+ - **Native (preferred): `create(request)`.** The renderer declares `{ version, documentsHash, documents, eligibilityBasis, surface: "first_run" }` and no time; the native service stamps its own UTC `acceptedAt`, compares the declaration with the manifest it embeds, stores the receipt and returns the record. `receiptDocuments` sets the declared document set (default: the gate's titles and URLs; document bodies are never sent). Build the declaration with `acceptanceDeclaration(manifest)` and the digest with `materialAcceptanceDigestAsync(manifest)` from `@rightkit/legal/browser`. A throw, an invalid record, or a record for another version, digest or eligibility keeps the gate closed with an alert; if only the settings save failed afterwards, a retry reuses the receipt already created instead of stamping a second one.
53
+ - **Legacy: `write(record)` (deprecated).** The renderer builds the record, including `acceptedAt` from the webview clock, and the app stores it. It is used only when the port has no `create`. A port with neither cannot accept.
54
+
55
+ `createNativeLegalReceiptPort({ invoke })` is the port over the host's legal-receipt service (`rightkit_legal_receipt_read`, `rightkit_legal_receipt_create`, `rightkit_legal_receipt_reset`; `LEGAL_RECEIPT_COMMANDS`, override with `commands`). The native side registers those commands once under the host caller policy (`rightkit-tauri-shell` host services, trusted for the window that shows the gate) and stores the receipt under the app root. Command names are provisional until that service is published. A persistence failure, in any path, keeps the gate closed, and a bare legacy `legalAccepted` flag with no record never passes.
56
+
57
+ `reopenLegalAcceptance({ settings })` makes the gate show again as a re-acceptance on the next mount (the old record stays until the user agrees; progress is kept). `resetLegalAcceptance({ settings, legalReceipt? })` clears the receipt first (`legalReceipt.reset`, required when a port is given) and then the settings copy, so the next mount shows the first-run gate.
58
+
59
+ ```tsx
60
+ import { createNativeLegalReceiptPort } from "@rightkit/onboarding";
61
+ import { acceptanceDeclaration, materialAcceptanceDigestAsync } from "@rightkit/legal/browser";
62
+ import { invoke } from "@tauri-apps/api/core";
63
+
64
+ const declaration = acceptanceDeclaration(manifest);
65
+ <Onboarding
66
+ legalReceipt={createNativeLegalReceiptPort({ invoke })}
67
+ requiredVersion={declaration.acceptanceVersion}
68
+ requiredDocumentsHash={await materialAcceptanceDigestAsync(manifest)}
69
+ receiptDocuments={declaration.documents}
70
+ /* ... */
71
+ />
72
+ ```
73
+
74
+ ### Licence and sign-in adapters
75
+
76
+ `licenseBackendFromView(client, options)` turns the native licence client (`createLicenseClient` from `@rightkit/license` 0.3.0, structurally: this package does not import it) into the `LicenseBackend` the licence step takes. The lifecycle decides the status (`grace`, `lifetime` and `verification_required` show as active; `migration_required`, `clock_repair_required`, `revoked` and `invalid` as invalid; anything unknown is invalid, never active). Pass `messageText: licenseMessage` for the detail text, `trialEmail` (where the trial email comes from; a trial is offered only with it and `client.requestTrial`), and optionally `purchaseUrl`, `openExternal`, `labels`. The step displays what the native service reports and decides nothing.
77
+
78
+ `signIn` adds the built-in sign-in step after the licence step and is handed to every step as `ctx.signIn`. `signInBackendFromOAuth(port, providers)` builds it over the app's `rightkit-oauth` commands (`port.status()` and `port.signIn(provider)`, which call `OAuthClient::sign_in_with_browser` and open the default browser); only `signedIn` and `account` are read from the answers, and a sign-in that resolves without `signedIn: true` rejects so the step shows its retry message. `signInStep(backend)` still works as an app step. 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
79
 
50
80
  ## When the legal gate re-prompts
51
81
 
@@ -1,5 +1,9 @@
1
- import type { OnboardingProps, PersistedOnboardingState } from "./types.js";
1
+ import type { LegalAcceptanceRecord, OnboardingProps, PersistedOnboardingState } from "./types.js";
2
+ /** @internal Validates one stored or returned acceptance record; `undefined` when it is not a well-formed record. */
3
+ export declare function readAcceptance(value: unknown): LegalAcceptanceRecord | undefined;
4
+ /** @internal Normalises the persisted `onboarding.state` value. */
5
+ export declare function readState(value: unknown): PersistedOnboardingState;
2
6
  /** True when the stored acceptance satisfies the host's requirement. */
3
7
  export declare function isLegalAccepted(state: PersistedOnboardingState, requiredVersion?: string, custom?: (state: PersistedOnboardingState) => boolean, requiredDocumentsHash?: string): boolean;
4
8
  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, labels: labelOverrides }: OnboardingProps): import("react").JSX.Element | null;
9
+ export declare function Onboarding({ settings, legal, legalMode, appName, steps, permissions, models, license, signIn, receiptDocuments, onComplete, onSkip, className, layout, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt, labels: labelOverrides }: OnboardingProps): import("react").JSX.Element | null;
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { LegalGate } from "@rightkit/legal-ui";
4
4
  import { Button } from "@rightkit/react-shell/react";
5
5
  import { cx } from "./ui.js";
6
- import { licenseStep, modelsStep, permissionsStep } from "./steps.js";
6
+ import { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
7
7
  const KEY = "onboarding.state";
8
8
  const INITIAL = { version: 1, currentStep: 0, completed: [], skipped: false, legalAccepted: false };
9
9
  const DEFAULT_LABELS = { skip: "Set up later", skipStep: "Skip", back: "Back", continue: "Continue", finish: "Finish setup", loading: "Loading setup…" };
@@ -14,7 +14,8 @@ function isCanonicalUtc(value) {
14
14
  const parsed = Date.parse(value);
15
15
  return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
16
16
  }
17
- function readAcceptance(value) {
17
+ /** @internal Validates one stored or returned acceptance record; `undefined` when it is not a well-formed record. */
18
+ export function readAcceptance(value) {
18
19
  if (!value || typeof value !== "object" || Array.isArray(value))
19
20
  return undefined;
20
21
  const r = value;
@@ -28,7 +29,8 @@ function readAcceptance(value) {
28
29
  return undefined;
29
30
  return { version: r.version, documentsHash: r.documentsHash, acceptedAt: r.acceptedAt, eligibilityBasis: r.eligibilityBasis };
30
31
  }
31
- function readState(value) {
32
+ /** @internal Normalises the persisted `onboarding.state` value. */
33
+ export function readState(value) {
32
34
  if (!value || typeof value !== "object")
33
35
  return INITIAL;
34
36
  const candidate = value;
@@ -44,6 +46,7 @@ function readState(value) {
44
46
  legalAccepted: legalAcceptance !== undefined || (!hasStoredAcceptance && candidate.legalAccepted === true),
45
47
  eligibilityBasis: legalAcceptance?.eligibilityBasis ?? (candidate.eligibilityBasis === "individual" || candidate.eligibilityBasis === "enterprise" ? candidate.eligibilityBasis : undefined),
46
48
  ...(legalAcceptance ? { legalAcceptance } : {}),
49
+ ...(candidate.legalReopened === true ? { legalReopened: true } : {}),
47
50
  ...(Array.isArray(candidate.skippedSteps) ? { skippedSteps: candidate.skippedSteps.filter((x) => typeof x === "string") } : {}),
48
51
  };
49
52
  }
@@ -56,6 +59,9 @@ function withReceipt(state, record) {
56
59
  }
57
60
  /** True when the stored acceptance satisfies the host's requirement. */
58
61
  export function isLegalAccepted(state, requiredVersion, custom, requiredDocumentsHash) {
62
+ // A reopened agreement (`reopenLegalAcceptance`) is not accepted until the user agrees again, whatever the host's check says.
63
+ if (state.legalReopened === true)
64
+ return false;
59
65
  if (custom)
60
66
  return custom(state);
61
67
  // A stored record whose timestamp is not canonical UTC is not an acceptance,
@@ -104,7 +110,7 @@ function NoticeList({ documents }) {
104
110
  return _jsx("p", { className: "rk-onboarding__muted", children: "No notices to show." });
105
111
  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)) });
106
112
  }
107
- 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 }) {
113
+ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps = [], permissions, models, license, signIn, receiptDocuments, onComplete, onSkip, className, layout = "window", requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt, labels: labelOverrides }) {
108
114
  if (legalMode === "gate" && !legal)
109
115
  throw new Error("Onboarding: `legal` is required when legalMode is \"gate\"");
110
116
  const L = { ...DEFAULT_LABELS, ...labelOverrides };
@@ -121,6 +127,8 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
121
127
  /** Values passed to `ctx.setResult`, keyed by step id; handed to `onComplete`. */
122
128
  const stepResults = useRef({});
123
129
  const [continuing, setContinuing] = useState(false);
130
+ /** A receipt the native service already created for this agreement when saving the settings failed; a retry reuses it instead of stamping a second one. */
131
+ const createdReceipt = useRef(null);
124
132
  /** Persists a patch; throws when the settings backend rejects it. */
125
133
  const persist = useCallback(async (patch) => {
126
134
  setSaving(true);
@@ -183,8 +191,9 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
183
191
  ...(permissions ? [permissionsStep(permissions)] : []),
184
192
  ...(models ? [modelsStep(models, { required: requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, intro: modelsIntro, startGuard: modelsStarted })] : []),
185
193
  ...(license ? [licenseStep(license)] : []),
194
+ ...(signIn ? [signInStep(signIn)] : []),
186
195
  ...steps,
187
- ], [gated, legalMode, legal?.documents, license, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
196
+ ], [gated, legalMode, legal?.documents, license, signIn, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
188
197
  const activeState = state ?? INITIAL;
189
198
  const accepted = !gated || isLegalAccepted(activeState, requiredVersion, isAccepted, requiredDocumentsHash);
190
199
  const needsGate = state !== null && !accepted;
@@ -216,7 +225,7 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
216
225
  return () => { live = false; };
217
226
  }, [needsGate, gateCleared, beforeLegalGate]);
218
227
  const setResult = useCallback((value) => { stepResults.current[activeId] = value; }, [activeId]);
219
- const context = { state: activeState, settings, setState: saveStep, permissions, models, license, ready: readyMap, setReady, setResult };
228
+ const context = { state: activeState, settings, setState: saveStep, permissions, models, license, signIn, ready: readyMap, setReady, setResult };
220
229
  const canContinue = active?.canContinue ? active.canContinue(context) : true;
221
230
  const runCallback = async (callback) => {
222
231
  try {
@@ -266,36 +275,65 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
266
275
  }
267
276
  await markAndMove(index + 1, { completed: active.id });
268
277
  };
278
+ const rootClass = cx("rk-onboarding", layout === "contained" && "rk-onboarding--contained", className);
269
279
  if (error && !state)
270
- 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" })] });
280
+ return _jsxs("div", { className: rootClass, children: [_jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }), _jsx(Button, { onClick: () => window.location.reload(), children: "Retry" })] });
271
281
  if (state && !active)
272
- 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 })] });
282
+ return _jsxs("main", { className: rootClass, "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 })] });
273
283
  if (!state || !active)
274
- return _jsx("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: _jsx("p", { className: "rk-onboarding__muted", children: L.loading }) });
284
+ return _jsx("div", { className: rootClass, "aria-busy": "true", children: _jsx("p", { className: "rk-onboarding__muted", children: L.loading }) });
275
285
  if (needsGate) {
276
286
  if (!gateCleared)
277
- 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 })] });
287
+ return _jsxs("div", { className: rootClass, "aria-busy": "true", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx("p", { className: "rk-onboarding__muted", children: L.loading })] });
278
288
  if (!legal)
279
289
  return null;
280
290
  const reacceptance = activeState.legalAccepted || activeState.legalAcceptance !== undefined;
281
- 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 }) => {
282
- const legalAcceptance = { version: requiredVersion ?? "1", documentsHash: documentsHash ?? requiredDocumentsHash ?? await hashDocuments(legal.documents), acceptedAt: new Date().toISOString(), eligibilityBasis };
283
- if (legalReceipt) {
284
- try {
285
- await legalReceipt.write(legalAcceptance);
286
- }
287
- catch (e) {
288
- setError(e instanceof Error ? e.message : String(e));
289
- throw e;
290
- }
291
+ /** Records the acceptance and returns the record to mirror into the settings state. A throw keeps the gate closed. */
292
+ const recordAcceptance = async (gate, basis) => {
293
+ const version = requiredVersion ?? "1";
294
+ const hash = documentsHash ?? requiredDocumentsHash ?? await hashDocuments(gate.documents);
295
+ if (legalReceipt?.create) {
296
+ // Native path: the service stamps the time and stores the receipt; nothing is built in the renderer.
297
+ const key = [version, hash, basis].join("\n");
298
+ if (createdReceipt.current?.key === key)
299
+ return createdReceipt.current.record;
300
+ const declared = receiptDocuments ?? gate.documents.map((d) => ({ title: d.title, ...(d.url ? { url: d.url } : {}) }));
301
+ const created = readAcceptance(await legalReceipt.create({ version, documentsHash: hash, documents: declared, eligibilityBasis: basis, surface: "first_run" }));
302
+ if (!created)
303
+ throw new Error("The legal receipt service returned an invalid record.");
304
+ // Bind the response to the declaration sent and any independent required hash.
305
+ if (created.eligibilityBasis !== basis || created.version !== version || created.documentsHash !== hash || (requiredDocumentsHash !== undefined && created.documentsHash !== requiredDocumentsHash)) {
306
+ throw new Error("The legal receipt does not match the agreement that was shown.");
307
+ }
308
+ createdReceipt.current = { key, record: created };
309
+ return created;
310
+ }
311
+ // LEGACY: the renderer builds the record with the webview clock. Deprecated; implement `legalReceipt.create`.
312
+ const record = { version, documentsHash: hash, acceptedAt: new Date().toISOString(), eligibilityBasis: basis };
313
+ if (legalReceipt) {
314
+ if (!legalReceipt.write)
315
+ throw new Error("The legal receipt can neither create nor write an acceptance.");
316
+ await legalReceipt.write(record);
317
+ }
318
+ return record;
319
+ };
320
+ return _jsxs("div", { className: cx("rk-onboarding rk-onboarding--legal", layout === "contained" && "rk-onboarding--contained", className), "data-testid": "onboarding-legal", "data-variant": reacceptance ? "re-acceptance" : "first-run", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx(LegalGate, { ...legal, layout: layout, variant: reacceptance ? "re-acceptance" : "first-run", onQuit: () => void legal.onQuit?.(), onAgree: async ({ eligibilityBasis }) => {
321
+ let legalAcceptance;
322
+ try {
323
+ legalAcceptance = await recordAcceptance(legal, eligibilityBasis);
324
+ }
325
+ catch (e) {
326
+ setError(e instanceof Error ? e.message : String(e));
327
+ throw e;
291
328
  }
292
329
  const progress = reacceptance ? {} : { completed: ["legal"], currentStep: builtins.length > 1 ? 1 : 0 };
293
- await persist({ legalAccepted: true, eligibilityBasis, legalAcceptance, ...progress });
330
+ await persist({ legalAccepted: true, legalReopened: false, eligibilityBasis, legalAcceptance, ...progress });
331
+ createdReceipt.current = null;
294
332
  if (!reacceptance && builtins.length === 1)
295
333
  await onComplete(resultOf(["legal"], []));
296
334
  } })] });
297
335
  }
298
336
  const body = active.render(context);
299
- 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))
337
+ return _jsxs("main", { className: rootClass, "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))
300
338
  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) })] })] })] });
301
339
  }
@@ -0,0 +1,59 @@
1
+ import type { LicenseBackend, LicenseState, SignInBackend, SignInProvider } from "./types.js";
2
+ /**
3
+ * The fields of `@rightkit/license` 0.3.0 `LicenseView` that the adapter reads. Structural on purpose: this package does not
4
+ * import the licence package, so `createLicenseClient(...)` output is accepted as it is and an app that has no licence step
5
+ * never loads it.
6
+ */
7
+ export interface LicenseViewLike {
8
+ status: string;
9
+ lifecycle?: string;
10
+ tier?: string | null;
11
+ /** Unix milliseconds. */
12
+ validUntil?: number | null;
13
+ /** A closed `MessageCode`; text comes from `messageText`. */
14
+ message?: string;
15
+ }
16
+ /** The slice of `createLicenseClient(...)` the licence step needs. */
17
+ export interface LicenseViewClient {
18
+ status(): Promise<LicenseViewLike>;
19
+ activate(key: string): Promise<LicenseViewLike>;
20
+ /** Present on the real client. A trial needs `trialEmail` as well. */
21
+ requestTrial?(email: string): Promise<{
22
+ accepted: boolean;
23
+ message?: string;
24
+ }>;
25
+ }
26
+ export interface LicenseViewBackendOptions {
27
+ /** Text for a `MessageCode`: pass `licenseMessage` from `@rightkit/license`. Without it the step shows no detail text. */
28
+ messageText?: (code: string) => string;
29
+ /**
30
+ * Where the trial email comes from (an input the app renders, or the signed-in account). A trial is offered only when this
31
+ * and the client's `requestTrial` both exist. Returning nothing refuses the trial with a message.
32
+ */
33
+ trialEmail?: () => string | undefined | Promise<string | undefined>;
34
+ purchaseUrl?: string;
35
+ openExternal?: (url: string) => void | Promise<void>;
36
+ /** Replaces the English status labels. */
37
+ labels?: Partial<Record<LicenseState["status"], string>>;
38
+ }
39
+ /** Maps a native `LicenseView` to the step's `LicenseState`. The lifecycle decides; the coarse status is the fallback. An unknown value is `invalid`, never `active`. */
40
+ export declare function licenseStateFromView(view: LicenseViewLike, options?: Pick<LicenseViewBackendOptions, "messageText" | "labels">): LicenseState;
41
+ /**
42
+ * `LicenseBackend` over a native licence client (`@rightkit/license` 0.3.0 `createLicenseClient`). The step shows the state
43
+ * the native service reports and never decides entitlement itself; errors from the client reach the step unchanged (they are
44
+ * closed, fixed-text `LicenseClientError`s).
45
+ */
46
+ export declare function licenseBackendFromView(client: LicenseViewClient, options?: LicenseViewBackendOptions): LicenseBackend;
47
+ /**
48
+ * The app's `rightkit-oauth` commands as a typed port. Both calls go to the shared owner
49
+ * (`OAuthClient::sign_in_with_browser` through the app's command): the default browser opens, no in-app webview, and no token
50
+ * reaches the renderer. Results are untrusted `unknown`; only `signedIn` and `account` are read.
51
+ */
52
+ export interface OAuthSignInPort {
53
+ /** Account already signed in, if any. */
54
+ status(): Promise<unknown>;
55
+ /** Open the default browser for `provider`; resolves when the sign-in finishes. */
56
+ signIn(provider: SignInProvider): Promise<unknown>;
57
+ }
58
+ /** `SignInBackend` over `port`. A sign-in that resolves without `signedIn: true` (closed browser tab, cancel) rejects, so the step shows its retry message. */
59
+ export declare function signInBackendFromOAuth(port: OAuthSignInPort, providers: SignInBackend["providers"]): SignInBackend;
@@ -0,0 +1,93 @@
1
+ const BY_LIFECYCLE = {
2
+ unlicensed: "none",
3
+ migration_required: "invalid",
4
+ trial: "trial",
5
+ expired_trial: "expired",
6
+ active: "active",
7
+ grace: "active",
8
+ lifetime: "active",
9
+ lifetime_provisional: "active",
10
+ verification_required: "active",
11
+ expired: "expired",
12
+ clock_repair_required: "invalid",
13
+ revoked: "invalid",
14
+ invalid: "invalid",
15
+ };
16
+ const BY_STATUS = {
17
+ none: "none",
18
+ active: "active",
19
+ grace: "active",
20
+ expired: "expired",
21
+ revoked: "invalid",
22
+ invalid: "invalid",
23
+ unverifiable: "invalid",
24
+ };
25
+ const DEFAULT_LABELS = {
26
+ none: "No licence",
27
+ trial: "Trial",
28
+ active: "Active",
29
+ expired: "Expired",
30
+ invalid: "Needs attention",
31
+ };
32
+ /** Maps a native `LicenseView` to the step's `LicenseState`. The lifecycle decides; the coarse status is the fallback. An unknown value is `invalid`, never `active`. */
33
+ export function licenseStateFromView(view, options = {}) {
34
+ const status = (view.lifecycle !== undefined ? BY_LIFECYCLE[view.lifecycle] : undefined) ?? BY_STATUS[view.status] ?? "invalid";
35
+ const text = view.message !== undefined && view.message !== "none" ? options.messageText?.(view.message) : undefined;
36
+ const validUntil = typeof view.validUntil === "number" && Number.isFinite(view.validUntil) ? new Date(view.validUntil).toISOString() : null;
37
+ return {
38
+ status,
39
+ label: options.labels?.[status] ?? DEFAULT_LABELS[status],
40
+ ...(text ? { detail: text } : {}),
41
+ ...(typeof view.tier === "string" && view.tier ? { tier: view.tier } : {}),
42
+ validUntil,
43
+ };
44
+ }
45
+ /**
46
+ * `LicenseBackend` over a native licence client (`@rightkit/license` 0.3.0 `createLicenseClient`). The step shows the state
47
+ * the native service reports and never decides entitlement itself; errors from the client reach the step unchanged (they are
48
+ * closed, fixed-text `LicenseClientError`s).
49
+ */
50
+ export function licenseBackendFromView(client, options = {}) {
51
+ const state = (view) => licenseStateFromView(view, options);
52
+ const { requestTrial } = client;
53
+ const { trialEmail } = options;
54
+ return {
55
+ load: async () => state(await client.status()),
56
+ activate: async (key) => state(await client.activate(key)),
57
+ ...(requestTrial && trialEmail ? {
58
+ startTrial: async () => {
59
+ const email = (await trialEmail())?.trim();
60
+ if (!email)
61
+ throw new Error("An email address is needed to start a trial.");
62
+ const outcome = await requestTrial.call(client, email);
63
+ const current = state(await client.status());
64
+ const text = outcome.message !== undefined && outcome.message !== "none" ? options.messageText?.(outcome.message) : undefined;
65
+ if (!outcome.accepted)
66
+ throw new Error(text ?? "A trial could not be started.");
67
+ return text ? { ...current, detail: text } : current;
68
+ },
69
+ } : {}),
70
+ ...(options.purchaseUrl ? { purchaseUrl: options.purchaseUrl } : {}),
71
+ ...(options.openExternal ? { openExternal: options.openExternal } : {}),
72
+ };
73
+ }
74
+ function accountOf(value) {
75
+ if (!value || typeof value !== "object" || Array.isArray(value))
76
+ return { signedIn: false };
77
+ const v = value;
78
+ const account = typeof v.account === "string" && v.account.trim() ? v.account.trim().slice(0, 320) : undefined;
79
+ return v.signedIn === true ? { signedIn: true, ...(account ? { account } : {}) } : { signedIn: false };
80
+ }
81
+ /** `SignInBackend` over `port`. A sign-in that resolves without `signedIn: true` (closed browser tab, cancel) rejects, so the step shows its retry message. */
82
+ export function signInBackendFromOAuth(port, providers) {
83
+ return {
84
+ providers,
85
+ load: async () => accountOf(await port.status()),
86
+ start: async (provider) => {
87
+ const result = accountOf(await port.signIn(provider));
88
+ if (!result.signedIn)
89
+ throw new Error("Sign-in did not finish. Try again.");
90
+ return result;
91
+ },
92
+ };
93
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
2
2
  export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
3
- export type { ModelsStepOptions } from "./steps.js";
3
+ export type { LicenseStepCopy, LicenseStepOptions, ModelsStepOptions } from "./steps.js";
4
+ export { licenseBackendFromView, licenseStateFromView, signInBackendFromOAuth } from "./adapters.js";
5
+ export type { LicenseViewBackendOptions, LicenseViewClient, LicenseViewLike, OAuthSignInPort } from "./adapters.js";
6
+ export { LEGAL_RECEIPT_COMMANDS, createNativeLegalReceiptPort, reopenLegalAcceptance, resetLegalAcceptance } from "./legal-receipt.js";
7
+ export type { LegalReceiptInvoke, NativeLegalReceiptPortOptions } from "./legal-receipt.js";
4
8
  export type * from "./types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
2
2
  export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
3
+ export { licenseBackendFromView, licenseStateFromView, signInBackendFromOAuth } from "./adapters.js";
4
+ export { LEGAL_RECEIPT_COMMANDS, createNativeLegalReceiptPort, reopenLegalAcceptance, resetLegalAcceptance } from "./legal-receipt.js";
@@ -0,0 +1,45 @@
1
+ import type { LegalReceiptPort, SettingsBackend } from "./types.js";
2
+ /**
3
+ * The commands of the native legal-receipt host service. The native side registers them once under the host caller policy
4
+ * (`rightkit-tauri-shell` host services); the trusted window is the one that shows the legal gate. Names are provisional
5
+ * until the host service is published; an app whose host registers other names passes `commands`.
6
+ */
7
+ export declare const LEGAL_RECEIPT_COMMANDS: Readonly<{
8
+ read: "rightkit_legal_receipt_read";
9
+ create: "rightkit_legal_receipt_create";
10
+ reset: "rightkit_legal_receipt_reset";
11
+ }>;
12
+ /** Tauri's `invoke`, structurally (`@tauri-apps/api/core`). No import, so the package stays free of Tauri. */
13
+ export type LegalReceiptInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
14
+ export interface NativeLegalReceiptPortOptions {
15
+ invoke: LegalReceiptInvoke;
16
+ commands?: Partial<typeof LEGAL_RECEIPT_COMMANDS>;
17
+ }
18
+ /**
19
+ * `LegalReceiptPort` over the native legal-receipt service.
20
+ *
21
+ * - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record.
22
+ * - `create` sends the declaration (`version`, `documentsHash`, `documents`, `eligibilityBasis`, `surface`) and NO time. The
23
+ * service compares it with the manifest it embeds, stamps native UTC time, stores the receipt and returns the record; a
24
+ * refusal or a malformed answer throws, which keeps the gate closed.
25
+ * - `reset` clears the receipt.
26
+ * There is no `write`: the renderer never supplies a stored record.
27
+ */
28
+ export declare function createNativeLegalReceiptPort({ invoke, commands }: NativeLegalReceiptPortOptions): LegalReceiptPort;
29
+ /**
30
+ * Re-opens the agreement: the stored acceptance stops counting, so the gate shows as a re-acceptance on the next mount of
31
+ * `Onboarding`. The old record (and the app's receipt) stays until the user agrees again; agreeing creates the new receipt and
32
+ * clears the flag. Progress through the other steps is kept.
33
+ */
34
+ export declare function reopenLegalAcceptance({ settings }: {
35
+ settings: SettingsBackend;
36
+ }): Promise<void>;
37
+ /**
38
+ * Forgets the acceptance entirely: the app's receipt is cleared first (`legalReceipt.reset`, required when a receipt port is
39
+ * given, so a failed reset cannot leave the receipt accepted), then the settings copy, so the next mount shows the first-run
40
+ * gate. Other steps' progress is kept.
41
+ */
42
+ export declare function resetLegalAcceptance({ settings, legalReceipt }: {
43
+ settings: SettingsBackend;
44
+ legalReceipt?: LegalReceiptPort;
45
+ }): Promise<void>;
@@ -0,0 +1,91 @@
1
+ import { onboardingStateKey, readAcceptance, readState } from "./Onboarding.js";
2
+ /**
3
+ * The commands of the native legal-receipt host service. The native side registers them once under the host caller policy
4
+ * (`rightkit-tauri-shell` host services); the trusted window is the one that shows the legal gate. Names are provisional
5
+ * until the host service is published; an app whose host registers other names passes `commands`.
6
+ */
7
+ export const LEGAL_RECEIPT_COMMANDS = Object.freeze({
8
+ read: "rightkit_legal_receipt_read",
9
+ create: "rightkit_legal_receipt_create",
10
+ reset: "rightkit_legal_receipt_reset",
11
+ });
12
+ /** Host commands reject with `{ code, message }`; the gate and steps show `Error#message`. */
13
+ function hostError(error) {
14
+ if (error instanceof Error)
15
+ return error;
16
+ const message = error && typeof error === "object" ? error.message : error;
17
+ return new Error(typeof message === "string" && message.trim() ? message : "The legal receipt service failed.");
18
+ }
19
+ async function call(invoke, command, args) {
20
+ try {
21
+ return await invoke(command, args);
22
+ }
23
+ catch (error) {
24
+ throw hostError(error);
25
+ }
26
+ }
27
+ /**
28
+ * `LegalReceiptPort` over the native legal-receipt service.
29
+ *
30
+ * - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record.
31
+ * - `create` sends the declaration (`version`, `documentsHash`, `documents`, `eligibilityBasis`, `surface`) and NO time. The
32
+ * service compares it with the manifest it embeds, stamps native UTC time, stores the receipt and returns the record; a
33
+ * refusal or a malformed answer throws, which keeps the gate closed.
34
+ * - `reset` clears the receipt.
35
+ * There is no `write`: the renderer never supplies a stored record.
36
+ */
37
+ export function createNativeLegalReceiptPort({ invoke, commands }) {
38
+ const names = { ...LEGAL_RECEIPT_COMMANDS, ...commands };
39
+ return {
40
+ async read() {
41
+ const value = await call(invoke, names.read);
42
+ return value === null || value === undefined ? null : readAcceptance(value) ?? null;
43
+ },
44
+ async create(request) {
45
+ const created = readAcceptance(await call(invoke, names.create, {
46
+ request: {
47
+ version: request.version,
48
+ documentsHash: request.documentsHash,
49
+ documents: request.documents,
50
+ eligibilityBasis: request.eligibilityBasis,
51
+ surface: request.surface,
52
+ },
53
+ }));
54
+ if (!created)
55
+ throw new Error("The legal receipt service returned an invalid record.");
56
+ return created;
57
+ },
58
+ async reset() { await call(invoke, names.reset); },
59
+ };
60
+ }
61
+ /**
62
+ * Re-opens the agreement: the stored acceptance stops counting, so the gate shows as a re-acceptance on the next mount of
63
+ * `Onboarding`. The old record (and the app's receipt) stays until the user agrees again; agreeing creates the new receipt and
64
+ * clears the flag. Progress through the other steps is kept.
65
+ */
66
+ export async function reopenLegalAcceptance({ settings }) {
67
+ const key = onboardingStateKey();
68
+ const snapshot = await settings.load();
69
+ const state = readState(snapshot.values[key]);
70
+ await settings.patch({ [key]: { ...state, legalReopened: true } }, snapshot.revision);
71
+ }
72
+ /**
73
+ * Forgets the acceptance entirely: the app's receipt is cleared first (`legalReceipt.reset`, required when a receipt port is
74
+ * given, so a failed reset cannot leave the receipt accepted), then the settings copy, so the next mount shows the first-run
75
+ * gate. Other steps' progress is kept.
76
+ */
77
+ export async function resetLegalAcceptance({ settings, legalReceipt }) {
78
+ if (legalReceipt) {
79
+ if (!legalReceipt.reset)
80
+ throw new Error("The legal receipt cannot be reset.");
81
+ await legalReceipt.reset();
82
+ }
83
+ const key = onboardingStateKey();
84
+ const snapshot = await settings.load();
85
+ const next = { ...readState(snapshot.values[key]), legalAccepted: false };
86
+ delete next.legalAcceptance;
87
+ delete next.eligibilityBasis;
88
+ delete next.legalReopened;
89
+ next.completed = next.completed.filter((id) => id !== "legal");
90
+ await settings.patch({ [key]: next }, snapshot.revision);
91
+ }
package/dist/steps.js CHANGED
@@ -24,7 +24,7 @@ function PermissionsBody({ backend }) {
24
24
  const [error, setError] = useState(null);
25
25
  const reload = useCallback(() => backend.list().then(setItems, (e) => setError(e instanceof Error ? e.message : String(e))), [backend]);
26
26
  useEffect(() => void reload(), [reload]);
27
- return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-permissions", children: [error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, items.length === 0 && !error ? _jsx("p", { className: "rk-onboarding__muted", children: "Checking permissions\u2026" }) : null, items.map((item) => (_jsxs("div", { className: "rk-onboarding__row", "data-permission-id": item.id, children: [_jsxs("div", { children: [_jsx("strong", { children: item.label }), _jsx("p", { children: item.description ?? item.detail })] }), _jsxs("div", { className: "rk-onboarding__row-actions", children: [_jsx(Badge, { tone: permissionTone(item.state), children: item.state }), item.state === "needed" || item.state === "denied" ? (_jsx(Button, { size: "sm", variant: "secondary", disabled: busy === item.id, onClick: () => { setBusy(item.id); setError(null); void backend.request(item.id).then((next) => setItems((current) => current.map((x) => x.id === next.id ? next : x)), (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(null)); }, children: item.state === "denied" ? "Try again" : "Allow" })) : null] })] }, item.id)))] }));
27
+ return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-permissions", children: [error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, items.length === 0 && error ? _jsx(Button, { size: "sm", variant: "secondary", onClick: () => { setError(null); void reload(); }, children: "Try again" }) : null, items.length === 0 && !error ? _jsx("p", { className: "rk-onboarding__muted", children: "Checking permissions\u2026" }) : null, items.map((item) => (_jsxs("div", { className: "rk-onboarding__row", "data-permission-id": item.id, children: [_jsxs("div", { children: [_jsx("strong", { children: item.label }), _jsx("p", { children: item.description ?? item.detail })] }), _jsxs("div", { className: "rk-onboarding__row-actions", children: [_jsx(Badge, { tone: permissionTone(item.state), children: item.state }), item.state === "needed" || item.state === "denied" ? (_jsx(Button, { size: "sm", variant: "secondary", disabled: busy === item.id, onClick: () => { setBusy(item.id); setError(null); void backend.request(item.id).then((next) => setItems((current) => current.map((x) => x.id === next.id ? next : x)), (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(null)); }, children: item.state === "denied" ? "Try again" : "Allow" })) : null] })] }, item.id)))] }));
28
28
  }
29
29
  export function permissionsStep(backend, options = {}) {
30
30
  return { id: "permissions", label: options.label ?? "Permissions", title: options.title ?? "Allow what this app needs", description: options.description ?? "You can change these permissions later in System Settings.", skippable: true, render: () => _jsx(PermissionsBody, { backend: backend }) };
@@ -82,7 +82,7 @@ function ModelsBody({ backend, onReady, autoStartDownload, shouldAutoStart, onMo
82
82
  // run is intentionally omitted: it only closes over setters.
83
83
  // eslint-disable-next-line react-hooks/exhaustive-deps
84
84
  }, [autoStartDownload, shouldAutoStart, onModelsStepEnter, loaded, models, backend]);
85
- return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-models", children: [intro ? _jsx("div", { className: "rk-onboarding__intro", "data-testid": "onboarding-models-intro", children: intro }) : null, error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, models.map((model) => {
85
+ return (_jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-models", children: [intro ? _jsx("div", { className: "rk-onboarding__intro", "data-testid": "onboarding-models-intro", children: intro }) : null, error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, error && !loaded ? _jsx(Button, { size: "sm", variant: "secondary", onClick: () => { setError(null); void reload(); }, children: "Try again" }) : null, models.map((model) => {
86
86
  const progress = model.progress ?? (model.state === "installed" ? 1 : 0);
87
87
  const active = model.state === "downloading" || model.state === "verifying";
88
88
  const cancel = backend.cancel;
package/dist/types.d.ts CHANGED
@@ -88,15 +88,56 @@ export interface LegalAcceptanceRecord {
88
88
  acceptedAt: string;
89
89
  eligibilityBasis: EligibilityBasis;
90
90
  }
91
+ /** One document of the set the user was shown. Shaped so `@rightkit/legal`'s `acceptanceDeclaration(manifest).documents` fits as is. */
92
+ export interface DeclaredLegalDocument {
93
+ id?: string;
94
+ role?: string;
95
+ title?: string;
96
+ url?: string;
97
+ version?: string;
98
+ sha256?: string;
99
+ treatment?: string;
100
+ material?: boolean;
101
+ }
102
+ /**
103
+ * What the renderer declares to a native receipt service when the user agrees. It carries no time: the service stamps its
104
+ * own UTC `acceptedAt`, compares `version`, `documentsHash` and `documents` with the manifest it embeds, and refuses a
105
+ * declaration that does not match.
106
+ */
107
+ export interface LegalReceiptCreateRequest {
108
+ /** Acceptance version the app requires (`requiredVersion`, else "1"). */
109
+ version: string;
110
+ /** Digest of the document set shown (`documentsHash`, else `requiredDocumentsHash`, else a hash of titles and URLs). */
111
+ documentsHash: string;
112
+ /** The declared set (`receiptDocuments`, else the gate's documents as `{ title, url }`). */
113
+ documents: readonly DeclaredLegalDocument[];
114
+ eligibilityBasis: EligibilityBasis;
115
+ /** Always "first_run": onboarding is the first-run and re-acceptance surface. */
116
+ surface: "first_run";
117
+ }
91
118
  /**
92
119
  * App-owned persistence for the legal acceptance. Lets an app keep its existing receipt (any shape, any store) and map it
93
120
  * to and from the package's record. When supplied it is the source of truth: `read()` is consulted on load and overrides
94
- * whatever acceptance the settings backend holds (`null` means "not accepted", so the gate shows), and `write()` is
95
- * awaited when the user agrees, before the settings backend is patched.
121
+ * whatever acceptance the settings backend holds (`null` means "not accepted", so the gate shows).
122
+ *
123
+ * Recording the acceptance, in order of preference:
124
+ * - `create(request)`: the NATIVE path. The service owns the time and the stored receipt and returns the record it stored;
125
+ * the renderer builds nothing. A throw, or a record that does not match the required version or hash, keeps the gate
126
+ * closed. When `create` exists, `write` is not called.
127
+ * - `write(record)`: LEGACY. The renderer builds the record, including its `acceptedAt` from the webview clock, and the app
128
+ * stores it. Kept for apps that have not moved to a native receipt service.
129
+ *
130
+ * `reset()` clears the stored receipt (used by `resetLegalAcceptance`). A port needs `create` or `write`; with neither,
131
+ * agreeing fails and the gate stays closed.
96
132
  */
97
133
  export interface LegalReceiptPort {
98
134
  read(): LegalAcceptanceRecord | null | Promise<LegalAcceptanceRecord | null>;
99
- write(record: LegalAcceptanceRecord): void | Promise<void>;
135
+ /** Native receipt creation: returns the stored record, stamped with native time. Preferred over `write`. */
136
+ create?(request: LegalReceiptCreateRequest): LegalAcceptanceRecord | Promise<LegalAcceptanceRecord>;
137
+ /** @deprecated Renderer-built record with webview time. Implement `create` on a native receipt service instead. */
138
+ write?(record: LegalAcceptanceRecord): void | Promise<void>;
139
+ /** Clear the stored receipt so the gate shows again as a first run. */
140
+ reset?(): void | Promise<void>;
100
141
  }
101
142
  export interface PersistedOnboardingState {
102
143
  version: 1;
@@ -108,6 +149,11 @@ export interface PersistedOnboardingState {
108
149
  /** Legacy mirror of `legalAcceptance.eligibilityBasis`. */
109
150
  eligibilityBasis?: EligibilityBasis;
110
151
  legalAcceptance?: LegalAcceptanceRecord;
152
+ /**
153
+ * Set by `reopenLegalAcceptance`: the stored acceptance no longer counts, so the gate shows as a re-acceptance. The old
154
+ * record stays until a new acceptance replaces it, which clears the flag.
155
+ */
156
+ legalReopened?: boolean;
111
157
  /** Step ids the user skipped with the per-step Skip button (not marked complete). */
112
158
  skippedSteps?: string[];
113
159
  }
@@ -178,6 +224,8 @@ export interface LegalStepConfig extends Omit<LegalGateProps, "variant" | "onAgr
178
224
  */
179
225
  export type LegalMode = "gate" | "notice" | "none";
180
226
  export interface OnboardingProps {
227
+ /** Window-sized by default; use `contained` when the host sizes the setup surface. */
228
+ layout?: "window" | "contained";
181
229
  settings: SettingsBackend;
182
230
  /** Required when `legalMode` is `gate` (default). Optional for `notice` (the documents shown) and `none`. */
183
231
  legal?: LegalStepConfig;
@@ -190,6 +238,13 @@ export interface OnboardingProps {
190
238
  permissions?: PermissionsBackend;
191
239
  models?: ModelsBackend;
192
240
  license?: LicenseBackend;
241
+ /**
242
+ * Adds the built-in sign-in step after the licence step and hands the backend to every step as `ctx.signIn`. Build it
243
+ * with `signInBackendFromOAuth` over the app's `rightkit-oauth` commands.
244
+ */
245
+ signIn?: SignInBackend;
246
+ /** The document set declared to `legalReceipt.create`. Default: the gate's documents as `{ title, url }`. */
247
+ receiptDocuments?: readonly DeclaredLegalDocument[];
193
248
  /** Called once when setup finishes, with the completed and skipped step ids and the step results. */
194
249
  onComplete: (result: OnboardingResult) => void | Promise<void>;
195
250
  onSkip?: () => void | Promise<void>;
@@ -228,7 +283,10 @@ export interface OnboardingProps {
228
283
  dragRegion?: boolean;
229
284
  /** Also render the drag region on the legal stage (loading and gate). Default false: the legal gate has no strip. */
230
285
  legalDragRegion?: boolean;
231
- /** Read and write the legal acceptance in the app's own receipt instead of (overriding) the settings backend's copy. */
286
+ /**
287
+ * Read and record the legal acceptance in the app's own (preferably native) receipt instead of (overriding) the settings
288
+ * backend's copy. See `LegalReceiptPort`; `createNativeLegalReceiptPort` builds one over the host's receipt commands.
289
+ */
232
290
  legalReceipt?: LegalReceiptPort;
233
291
  }
234
292
  export type { LegalGateDocument, LegalGateTheme };
package/onboarding.css CHANGED
@@ -1,5 +1,6 @@
1
1
  /* @rightkit/onboarding — uses app-shell tokens and legal-ui's locked light gate. */
2
2
  .rk-onboarding { position: relative; min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); background: var(--rk-plane, #fffcf5); color: var(--rk-ink, #231f1a); font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
3
+ .rk-onboarding--contained { min-height: 0; }
3
4
  .rk-onboarding *, .rk-onboarding *::before, .rk-onboarding *::after { box-sizing: border-box; }
4
5
  .rk-onboarding__rail { display: flex; flex-direction: column; gap: 22px; padding: 28px 14px; background: var(--rk-chrome, #eee9df); border-right: .5px solid var(--rk-hairline, rgba(35,31,26,.11)); }
5
6
  .rk-onboarding__brand { padding: 0 10px; font-size: 19px; font-weight: 650; letter-spacing: -.02em; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/onboarding",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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",
@@ -25,13 +25,13 @@
25
25
  ],
26
26
  "peerDependencies": {
27
27
  "@rightkit/react-shell": ">=0.2.0 <1",
28
- "@rightkit/legal-ui": ">=0.1.2 <1",
28
+ "@rightkit/legal-ui": ">=0.4.1 <1",
29
29
  "react": ">=18 <21"
30
30
  },
31
31
  "devDependencies": {
32
- "@rightkit/react-shell": "0.7.1",
33
- "@rightkit/legal-ui": "0.4.0",
34
- "@rightkit/qa-chrome": "0.1.8",
32
+ "@rightkit/react-shell": "0.7.2",
33
+ "@rightkit/legal-ui": "0.4.1",
34
+ "@rightkit/qa-chrome": "0.1.9",
35
35
  "@testing-library/jest-dom": "^7.0.1",
36
36
  "@testing-library/dom": "^10.4.2",
37
37
  "@testing-library/react": "^16.3.3",