@rightkit/onboarding 0.2.0 → 0.2.2

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
@@ -24,10 +24,44 @@ import "@rightkit/onboarding/onboarding.css";
24
24
  | `beforeLegalGate()` | Awaited before the gate shows (e.g. size or reveal the window). The package does no window sizing itself. |
25
25
  | `requireModel` | Continue/Finish on the models step wait for an installed model. |
26
26
  | `autoStartDownload` | Start the first missing model download when the models step opens. |
27
+ | `shouldAutoStart(entry, models)` | Choose which missing model auto-starts when the models step first opens (first `missing` entry returning true). Replaces `autoStartDownload`'s rule, including its "a model is already installed" guard. Once per mount, across re-renders and Back/Next. |
28
+ | `onModelsStepEnter(ctx)` | Full control, called once when the step opens and the list has loaded: `ctx.models`, `ctx.start(id)`, `ctx.refresh()`. Replaces `shouldAutoStart` and `autoStartDownload`; a throw is shown in the step. |
27
29
  | `modelsIntro` | App copy shown at the top of the models step. |
28
30
  | `allowSkip` | Show "Set up later" (default `true`). |
29
31
  | `titlebar` | Node rendered in a strip along the top. |
30
- | `dragRegion` | Add `data-tauri-drag-region` to that strip (default `false`; height `--rk-onboarding-titlebar-h`, default 32px). |
32
+ | `dragRegion` | Add `data-tauri-drag-region` to that strip on the setup steps (default `false`; height `--rk-onboarding-titlebar-h`, default 32px). Never rendered on the legal stage. |
33
+ | `legalDragRegion` | Also render the drag region on the legal stage (default `false`; the legal gate has no strip, and `titlebar` content alone never adds the attribute). |
34
+ | `legalReceipt` | `{ read(), write(record) }`: the app's own acceptance receipt. See below. |
35
+
36
+ ## App-owned legal receipt
37
+
38
+ An app that already persists its own acceptance receipt passes `legalReceipt` and keeps that store as the source of truth. `read()` runs on load (sync or async) and returns a `LegalAcceptanceRecord` (`version`, `documentsHash`, `acceptedAt`, `eligibilityBasis`) or `null`. Its result replaces any acceptance in the settings backend: `null`, a throw, or an incomplete record all mean "not accepted", so the gate shows. `requiredVersion` / `isAccepted` then evaluate against the app's receipt, and a receipt for another version re-prompts with `variant: "re-acceptance"`. On agree, `write(record)` is awaited first; if it throws, the gate shows its save-failure alert and nothing else is persisted.
39
+
40
+ Mapping example for ScrapeRight's four-field `scraperight.legal-acceptance.v1` receipt (`legalAcceptanceVersion`, `legalAcceptedAt`, `legalEligibilityBasis`, `legalMaterialSha256`; pass `documentsHash={LEGAL_MATERIAL_SHA256}` so the hash round-trips):
41
+
42
+ ```ts
43
+ const legalReceipt: LegalReceiptPort = {
44
+ read() {
45
+ const r = loadLegalAcceptance(); // reads localStorage "scraperight.legal-acceptance.v1"
46
+ if (!r?.legalAcceptanceVersion || !r.legalAcceptedAt || !r.legalEligibilityBasis) return null;
47
+ return {
48
+ version: r.legalAcceptanceVersion,
49
+ acceptedAt: r.legalAcceptedAt,
50
+ eligibilityBasis: r.legalEligibilityBasis, // "individual" | "enterprise"
51
+ documentsHash: r.legalMaterialSha256 ?? "",
52
+ };
53
+ },
54
+ write(rec) {
55
+ saveLegalAcceptance({
56
+ legalAcceptanceVersion: rec.version,
57
+ legalAcceptedAt: rec.acceptedAt, // UTC ISO, ends in Z
58
+ legalEligibilityBasis: rec.eligibilityBasis,
59
+ legalMaterialSha256: rec.documentsHash,
60
+ });
61
+ },
62
+ };
63
+ <Onboarding legalReceipt={legalReceipt} requiredVersion={REQUIRED_LEGAL_ACCEPTANCE_VERSION} documentsHash={LEGAL_MATERIAL_SHA256} ... />
64
+ ```
31
65
 
32
66
  ## Custom steps and readiness
33
67
 
@@ -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): boolean;
4
4
  export declare function onboardingStateKey(): string;
5
- export declare function Onboarding({ settings, legal, steps, permissions, models, license, onComplete, onSkip, className, requiredVersion, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, modelsIntro, allowSkip, titlebar, dragRegion }: OnboardingProps): import("react").JSX.Element;
5
+ export declare function Onboarding({ settings, legal, steps, permissions, models, license, onComplete, onSkip, className, requiredVersion, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt }: OnboardingProps): import("react").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { LegalGate } from "@rightkit/legal-ui";
4
4
  import { Button } from "@rightkit/app-shell/react";
5
5
  import { cx } from "./ui.js";
@@ -31,6 +31,13 @@ function readState(value) {
31
31
  ...(legalAcceptance ? { legalAcceptance } : {}),
32
32
  };
33
33
  }
34
+ /** Replace the acceptance in `state` with the one from the app's receipt (`undefined`: not accepted). */
35
+ function withReceipt(state, record) {
36
+ const { legalAcceptance: _drop, eligibilityBasis: _basis, ...rest } = state;
37
+ void _drop;
38
+ void _basis;
39
+ return record ? { ...rest, legalAccepted: true, eligibilityBasis: record.eligibilityBasis, legalAcceptance: record } : { ...rest, legalAccepted: false };
40
+ }
34
41
  /** True when the stored acceptance satisfies the host's requirement. */
35
42
  export function isLegalAccepted(state, requiredVersion, custom) {
36
43
  if (custom)
@@ -66,12 +73,14 @@ function TitleStrip({ titlebar, dragRegion }) {
66
73
  return null;
67
74
  return _jsx("div", { className: "rk-onboarding__titlebar", "data-testid": "onboarding-titlebar", ...(dragRegion ? { "data-tauri-drag-region": "" } : {}), children: titlebar });
68
75
  }
69
- export function Onboarding({ settings, legal, steps = [], permissions, models, license, onComplete, onSkip, className, requiredVersion, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, modelsIntro, allowSkip = true, titlebar, dragRegion }) {
76
+ export function Onboarding({ settings, legal, steps = [], permissions, models, license, onComplete, onSkip, className, requiredVersion, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt }) {
70
77
  const [state, setState] = useState(null);
71
78
  const [saving, setSaving] = useState(false);
72
79
  const [error, setError] = useState(null);
73
80
  const [readyMap, setReadyMap] = useState({});
74
81
  const [gateCleared, setGateCleared] = useState(false);
82
+ /** One auto-start per mount, even when the models step unmounts (Back/Next) or the step list is rebuilt. */
83
+ const modelsStarted = useRef(false);
75
84
  /** Persists a patch; throws when the settings backend rejects it. */
76
85
  const persist = useCallback(async (patch) => {
77
86
  setSaving(true);
@@ -100,16 +109,41 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
100
109
  }
101
110
  }, [persist]);
102
111
  const saveStep = useCallback(async (patch) => { await save(patch); }, [save]);
103
- useEffect(() => { let live = true; void settings.load().then((snapshot) => { if (live)
104
- setState(readState(snapshot.values[KEY])); }, (e) => { if (live)
105
- setError(e instanceof Error ? e.message : String(e)); }); return () => { live = false; }; }, [settings]);
112
+ useEffect(() => {
113
+ let live = true;
114
+ void (async () => {
115
+ try {
116
+ const loaded = readState((await settings.load()).values[KEY]);
117
+ if (!legalReceipt) {
118
+ if (live)
119
+ setState(loaded);
120
+ return;
121
+ }
122
+ // The app's receipt is the source of truth for acceptance; an unreadable receipt counts as none.
123
+ let record;
124
+ try {
125
+ record = readAcceptance(await legalReceipt.read());
126
+ }
127
+ catch {
128
+ record = undefined;
129
+ }
130
+ if (live)
131
+ setState(withReceipt(loaded, record));
132
+ }
133
+ catch (e) {
134
+ if (live)
135
+ setError(e instanceof Error ? e.message : String(e));
136
+ }
137
+ })();
138
+ return () => { live = false; };
139
+ }, [settings, legalReceipt]);
106
140
  const builtins = useMemo(() => [
107
141
  { id: "legal", label: "Agreement", title: "Review agreement", skippable: false, render: () => _jsx("p", { className: "rk-onboarding__muted", children: "Agreement accepted." }) },
108
142
  ...(permissions ? [permissionsStep(permissions)] : []),
109
- ...(models ? [modelsStep(models, { required: requireModel, autoStartDownload, intro: modelsIntro })] : []),
143
+ ...(models ? [modelsStep(models, { required: requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, intro: modelsIntro, startGuard: modelsStarted })] : []),
110
144
  ...(license ? [licenseStep(license)] : []),
111
145
  ...steps,
112
- ], [license, models, permissions, steps, requireModel, autoStartDownload, modelsIntro]);
146
+ ], [license, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
113
147
  const activeState = state ?? INITIAL;
114
148
  const accepted = isLegalAccepted(activeState, requiredVersion, isAccepted);
115
149
  const needsGate = state !== null && !accepted;
@@ -162,10 +196,19 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
162
196
  return _jsx("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: _jsx("p", { className: "rk-onboarding__muted", children: "Loading setup\u2026" }) });
163
197
  if (needsGate) {
164
198
  if (!gateCleared)
165
- return _jsxs("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: dragRegion }), _jsx("p", { className: "rk-onboarding__muted", children: "Loading setup\u2026" })] });
199
+ 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" })] });
166
200
  const reacceptance = activeState.legalAccepted || activeState.legalAcceptance !== undefined;
167
- 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: dragRegion }), _jsx(LegalGate, { ...legal, variant: reacceptance ? "re-acceptance" : "first-run", onQuit: () => void legal.onQuit?.(), onAgree: async ({ eligibilityBasis }) => {
201
+ 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 }) => {
168
202
  const legalAcceptance = { version: requiredVersion ?? "1", documentsHash: documentsHash ?? await hashDocuments(legal.documents), acceptedAt: new Date().toISOString(), eligibilityBasis };
203
+ if (legalReceipt) {
204
+ try {
205
+ await legalReceipt.write(legalAcceptance);
206
+ }
207
+ catch (e) {
208
+ setError(e instanceof Error ? e.message : String(e));
209
+ throw e;
210
+ }
211
+ }
169
212
  const progress = reacceptance ? {} : { completed: ["legal"], currentStep: builtins.length > 1 ? 1 : 0 };
170
213
  await persist({ legalAccepted: true, eligibilityBasis, legalAcceptance, ...progress });
171
214
  if (!reacceptance && builtins.length === 1)
package/dist/steps.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { type ReactNode } from "react";
2
- import type { LicenseBackend, ModelsBackend, OnboardingStep, PermissionsBackend } from "./types.js";
2
+ import type { 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;
6
6
  description?: string;
7
7
  }): OnboardingStep;
8
- export interface ModelsStepOptions {
8
+ export interface ModelsStepOptions extends ModelsAutoStart {
9
9
  label?: string;
10
10
  title?: string;
11
11
  description?: string;
@@ -15,6 +15,10 @@ export interface ModelsStepOptions {
15
15
  autoStartDownload?: boolean;
16
16
  /** App copy rendered at the top of the step. */
17
17
  intro?: ReactNode;
18
+ /** Shared "already auto-started" flag. `Onboarding` supplies one that survives Back/Next and re-renders; omit it and the guard lives for the step's mount only. */
19
+ startGuard?: {
20
+ current: boolean;
21
+ };
18
22
  }
19
23
  export declare function modelsStep(backend: ModelsBackend, options?: ModelsStepOptions): OnboardingStep;
20
24
  export declare function licenseStep(backend: LicenseBackend, options?: {
package/dist/steps.js CHANGED
@@ -29,12 +29,13 @@ function PermissionsBody({ backend }) {
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 }) };
31
31
  }
32
- function ModelsBody({ backend, onReady, autoStartDownload, intro }) {
32
+ function ModelsBody({ backend, onReady, autoStartDownload, shouldAutoStart, onModelsStepEnter, intro, startGuard }) {
33
33
  const [models, setModels] = useState([]);
34
34
  const [loaded, setLoaded] = useState(false);
35
35
  const [busy, setBusy] = useState(null);
36
36
  const [error, setError] = useState(null);
37
- const autoStarted = useRef(false);
37
+ const ownGuard = useRef(false);
38
+ const autoStarted = startGuard ?? ownGuard;
38
39
  const reload = useCallback(() => backend.list().then((list) => { setModels(list); setLoaded(true); }, (e) => setError(e instanceof Error ? e.message : String(e))), [backend]);
39
40
  useEffect(() => {
40
41
  void reload();
@@ -56,9 +57,23 @@ function ModelsBody({ backend, onReady, autoStartDownload, intro }) {
56
57
  void request.then(reload, (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(null));
57
58
  };
58
59
  useEffect(() => {
59
- if (!autoStartDownload || !loaded || autoStarted.current)
60
+ if (!loaded || autoStarted.current)
61
+ return;
62
+ if (!onModelsStepEnter && !shouldAutoStart && !autoStartDownload)
60
63
  return;
61
64
  autoStarted.current = true;
65
+ if (onModelsStepEnter) {
66
+ // The app owns which model starts and when; the hook runs once per Onboarding mount.
67
+ const ctx = { models, start: (id) => backend.install(id).then(reload), refresh: () => backend.list().then((list) => { setModels(list); return list; }) };
68
+ void Promise.resolve().then(() => onModelsStepEnter(ctx)).catch((e) => setError(e instanceof Error ? e.message : String(e)));
69
+ return;
70
+ }
71
+ if (shouldAutoStart) {
72
+ const pick = models.find((m) => m.state === "missing" && shouldAutoStart(m, models));
73
+ if (pick)
74
+ run(pick.id, backend.install(pick.id));
75
+ return;
76
+ }
62
77
  if (models.some((m) => m.state === "installed" || m.state === "downloading" || m.state === "verifying"))
63
78
  return;
64
79
  const first = models.find((m) => m.state === "missing");
@@ -66,7 +81,7 @@ function ModelsBody({ backend, onReady, autoStartDownload, intro }) {
66
81
  run(first.id, backend.install(first.id));
67
82
  // run is intentionally omitted: it only closes over setters.
68
83
  // eslint-disable-next-line react-hooks/exhaustive-deps
69
- }, [autoStartDownload, loaded, models, backend]);
84
+ }, [autoStartDownload, shouldAutoStart, onModelsStepEnter, loaded, models, backend]);
70
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) => {
71
86
  const progress = model.progress ?? (model.state === "installed" ? 1 : 0);
72
87
  const active = model.state === "downloading" || model.state === "verifying";
@@ -82,7 +97,7 @@ export function modelsStep(backend, options = {}) {
82
97
  id: "models", label: options.label ?? "Models", title: options.title ?? "Choose local models", description: options.description ?? "Downloads are verified before a model is activated.", skippable: true,
83
98
  canContinue: options.required ? (ctx) => ctx.ready.models === true : undefined,
84
99
  blockedHint: options.required ? "Download a model to continue." : undefined,
85
- render: (ctx) => _jsx(ModelsBody, { backend: backend, onReady: ctx.setReady, autoStartDownload: options.autoStartDownload, intro: options.intro }),
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 }),
86
101
  };
87
102
  }
88
103
  function LicenseBody({ backend }) {
package/dist/types.d.ts CHANGED
@@ -66,6 +66,16 @@ export interface LegalAcceptanceRecord {
66
66
  acceptedAt: string;
67
67
  eligibilityBasis: EligibilityBasis;
68
68
  }
69
+ /**
70
+ * App-owned persistence for the legal acceptance. Lets an app keep its existing receipt (any shape, any store) and map it
71
+ * to and from the package's record. When supplied it is the source of truth: `read()` is consulted on load and overrides
72
+ * whatever acceptance the settings backend holds (`null` means "not accepted", so the gate shows), and `write()` is
73
+ * awaited when the user agrees, before the settings backend is patched.
74
+ */
75
+ export interface LegalReceiptPort {
76
+ read(): LegalAcceptanceRecord | null | Promise<LegalAcceptanceRecord | null>;
77
+ write(record: LegalAcceptanceRecord): void | Promise<void>;
78
+ }
69
79
  export interface PersistedOnboardingState {
70
80
  version: 1;
71
81
  currentStep: number;
@@ -125,15 +135,38 @@ export interface OnboardingProps {
125
135
  beforeLegalGate?: () => void | Promise<void>;
126
136
  /** Require at least one downloaded model before Continue/Finish on the models step. Default false. */
127
137
  requireModel?: boolean;
128
- /** Start downloading the first missing model when the models step opens. Default false. */
138
+ /** Start downloading the first missing model when the models step opens (skipped when any model is installed or downloading). Default false. */
129
139
  autoStartDownload?: boolean;
140
+ /** Choose which missing model starts when the models step first opens: the first `state === "missing"` entry for which this returns true. Replaces the `autoStartDownload` rule (no "something is installed" guard; the app decides). Fires at most once per `Onboarding` mount, across re-renders and Back/Next. */
141
+ shouldAutoStart?: (entry: ModelEntry, models: readonly ModelEntry[]) => boolean;
142
+ /** Full control: called once, when the models step first opens and the list has loaded. Replaces `shouldAutoStart` and `autoStartDownload`. Start models through `ctx.start`. Fires at most once per `Onboarding` mount; a throw is shown in the step. */
143
+ onModelsStepEnter?: (ctx: ModelsStepEnterContext) => void | Promise<void>;
130
144
  /** App copy shown at the top of the models step. */
131
145
  modelsIntro?: ReactNode;
132
146
  /** Show "Set up later". Default true. */
133
147
  allowSkip?: boolean;
134
148
  /** Optional titlebar content rendered in a strip along the top. */
135
149
  titlebar?: ReactNode;
136
- /** Render a `data-tauri-drag-region` strip at the top. Default false. */
150
+ /** Render a `data-tauri-drag-region` strip at the top of the setup steps. Default false. Never applied to the legal stage; see `legalDragRegion`. */
137
151
  dragRegion?: boolean;
152
+ /** Also render the drag region on the legal stage (loading and gate). Default false: the legal gate has no strip. */
153
+ legalDragRegion?: boolean;
154
+ /** Read and write the legal acceptance in the app's own receipt instead of (overriding) the settings backend's copy. */
155
+ legalReceipt?: LegalReceiptPort;
138
156
  }
139
157
  export type { LegalGateDocument, LegalGateTheme };
158
+ /** Context handed to `onModelsStepEnter`. */
159
+ export interface ModelsStepEnterContext {
160
+ /** The model list as loaded when the step opened. */
161
+ models: readonly ModelEntry[];
162
+ /** Start installing one model, then refresh the list. */
163
+ start(id: string): Promise<void>;
164
+ /** Re-read the list from the backend (and update the step). */
165
+ refresh(): Promise<ModelEntry[]>;
166
+ }
167
+ /** The auto-start options shared by `Onboarding` and `modelsStep`. */
168
+ export interface ModelsAutoStart {
169
+ autoStartDownload?: boolean;
170
+ shouldAutoStart?: (entry: ModelEntry, models: readonly ModelEntry[]) => boolean;
171
+ onModelsStepEnter?: (ctx: ModelsStepEnterContext) => void | Promise<void>;
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/onboarding",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@rightkit/app-shell": "0.2.0",
33
- "@rightkit/legal-ui": "0.1.2",
33
+ "@rightkit/legal-ui": "0.1.3",
34
34
  "@testing-library/jest-dom": "^6.6.3",
35
35
  "@testing-library/react": "^16.1.0",
36
36
  "@testing-library/user-event": "^14.5.2",