@rightkit/onboarding 0.6.3 → 0.6.4

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.
@@ -1,9 +1,23 @@
1
- import type { LegalAcceptanceRecord, OnboardingProps, PersistedOnboardingState } from "./types.js";
1
+ import type { LegalAcceptanceRecord, OnboardingProps, PersistedOnboardingState, SettingsBackend } from "./types.js";
2
2
  /** @internal Validates one stored or returned acceptance record; `undefined` when it is not a well-formed record. */
3
3
  export declare function readAcceptance(value: unknown): LegalAcceptanceRecord | undefined;
4
4
  /** @internal Normalises the persisted `onboarding.state` value. */
5
5
  export declare function readState(value: unknown): PersistedOnboardingState;
6
6
  /** True when the stored acceptance satisfies the host's requirement. */
7
7
  export declare function isLegalAccepted(state: PersistedOnboardingState, requiredVersion?: string, custom?: (state: PersistedOnboardingState) => boolean, requiredDocumentsHash?: string): boolean;
8
+ /**
9
+ * True when the setup step `stepId` is settled in a persisted onboarding state: the step is completed or skipped, or the
10
+ * whole setup was set aside ("Set up later", `skipped: true`). Reads the raw persisted value, so callers need no cast.
11
+ * Missing or malformed input is false; it never throws. Which steps gate an app stays with the app.
12
+ */
13
+ export declare function isStepSettled(state: unknown, stepId: string): boolean;
14
+ /**
15
+ * Restarts the setup at its first step ("Show onboarding again"). The setup progress (completed and skipped steps, the
16
+ * set-aside flag) is cleared, but the legal acceptance is kept: the agreement is not asked again, and `legal` stays
17
+ * completed. Reads the stored value first, so the rest of the settings are untouched.
18
+ */
19
+ export declare function restartOnboardingSetup({ settings }: {
20
+ settings: SettingsBackend;
21
+ }): Promise<void>;
8
22
  export declare function onboardingStateKey(): string;
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;
23
+ 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, frame, density, trafficLightInset }: OnboardingProps): import("react").JSX.Element | null;
@@ -50,12 +50,24 @@ export function readState(value) {
50
50
  ...(Array.isArray(candidate.skippedSteps) ? { skippedSteps: candidate.skippedSteps.filter((x) => typeof x === "string") } : {}),
51
51
  };
52
52
  }
53
- /** Replace the acceptance in `state` with the one from the app's receipt (`undefined`: not accepted). */
54
- function withReceipt(state, record) {
53
+ /**
54
+ * Replace the acceptance in `state` with the one from the app's receipt (`undefined`: not accepted). A `historical` record
55
+ * (`HistoricalLegalReceipt`) is kept as the acceptance for the re-acceptance variant but never counts as accepted: it is
56
+ * flagged reopened, so the gate shows again and the setup progress is left alone.
57
+ */
58
+ function withReceipt(state, record, historical = false) {
55
59
  const { legalAcceptance: _drop, eligibilityBasis: _basis, ...rest } = state;
56
60
  void _drop;
57
61
  void _basis;
58
- return record ? { ...rest, legalAccepted: true, eligibilityBasis: record.eligibilityBasis, legalAcceptance: record } : { ...rest, legalAccepted: false };
62
+ if (!record)
63
+ return { ...rest, legalAccepted: false };
64
+ if (historical)
65
+ return { ...rest, legalAccepted: false, legalReopened: true, eligibilityBasis: record.eligibilityBasis, legalAcceptance: record };
66
+ return { ...rest, legalAccepted: true, eligibilityBasis: record.eligibilityBasis, legalAcceptance: record };
67
+ }
68
+ /** True when a `LegalReceiptPort.read()` value is a historical receipt (`{ record, current: false }`). */
69
+ function isHistoricalReceipt(value) {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "record" in value && value.current === false;
59
71
  }
60
72
  /** True when the stored acceptance satisfies the host's requirement. */
61
73
  export function isLegalAccepted(state, requiredVersion, custom, requiredDocumentsHash) {
@@ -78,6 +90,39 @@ export function isLegalAccepted(state, requiredVersion, custom, requiredDocument
78
90
  // `legalAccepted` flag carries no version, time or basis, so it re-prompts once (as re-acceptance).
79
91
  return state.legalAcceptance !== undefined;
80
92
  }
93
+ /**
94
+ * True when the setup step `stepId` is settled in a persisted onboarding state: the step is completed or skipped, or the
95
+ * whole setup was set aside ("Set up later", `skipped: true`). Reads the raw persisted value, so callers need no cast.
96
+ * Missing or malformed input is false; it never throws. Which steps gate an app stays with the app.
97
+ */
98
+ export function isStepSettled(state, stepId) {
99
+ try {
100
+ if (typeof state !== "object" || state === null || typeof stepId !== "string")
101
+ return false;
102
+ const candidate = state;
103
+ if (candidate.skipped === true)
104
+ return true;
105
+ const completed = Array.isArray(candidate.completed) && candidate.completed.includes(stepId);
106
+ const skipped = Array.isArray(candidate.skippedSteps) && candidate.skippedSteps.includes(stepId);
107
+ return completed || skipped;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ /**
114
+ * Restarts the setup at its first step ("Show onboarding again"). The setup progress (completed and skipped steps, the
115
+ * set-aside flag) is cleared, but the legal acceptance is kept: the agreement is not asked again, and `legal` stays
116
+ * completed. Reads the stored value first, so the rest of the settings are untouched.
117
+ */
118
+ export async function restartOnboardingSetup({ settings }) {
119
+ const key = onboardingStateKey();
120
+ const snapshot = await settings.load();
121
+ const state = readState(snapshot.values[key]);
122
+ await settings.patch({
123
+ [key]: { ...state, currentStep: 0, skipped: false, skippedSteps: [], completed: state.completed.filter((id) => id === "legal") },
124
+ }, snapshot.revision);
125
+ }
81
126
  /** FNV-1a fallback when SubtleCrypto is unavailable. */
82
127
  function fnv(text) {
83
128
  let h = 0x811c9dc5;
@@ -110,7 +155,7 @@ function NoticeList({ documents }) {
110
155
  return _jsx("p", { className: "rk-onboarding__muted", children: "No notices to show." });
111
156
  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)) });
112
157
  }
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 }) {
158
+ 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, frame = "rail", density = "default", trafficLightInset = false }) {
114
159
  if (legalMode === "gate" && !legal)
115
160
  throw new Error("Onboarding: `legal` is required when legalMode is \"gate\"");
116
161
  const L = { ...DEFAULT_LABELS, ...labelOverrides };
@@ -169,14 +214,22 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
169
214
  }
170
215
  // The app's receipt is the source of truth for acceptance; an unreadable receipt counts as none.
171
216
  let record;
217
+ let historical = false;
172
218
  try {
173
- record = readAcceptance(await legalReceipt.read());
219
+ const value = await legalReceipt.read();
220
+ if (isHistoricalReceipt(value)) {
221
+ record = readAcceptance(value.record);
222
+ historical = record !== undefined;
223
+ }
224
+ else
225
+ record = readAcceptance(value);
174
226
  }
175
227
  catch {
176
228
  record = undefined;
229
+ historical = false;
177
230
  }
178
231
  if (live)
179
- setState(withReceipt(loaded, record));
232
+ setState(withReceipt(loaded, record, historical));
180
233
  }
181
234
  catch (e) {
182
235
  if (live)
@@ -275,7 +328,7 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
275
328
  }
276
329
  await markAndMove(index + 1, { completed: active.id });
277
330
  };
278
- const rootClass = cx("rk-onboarding", layout === "contained" && "rk-onboarding--contained", className);
331
+ const rootClass = cx("rk-onboarding", layout === "contained" && "rk-onboarding--contained", density === "compact" && "rk-onboarding--compact", trafficLightInset && "rk-onboarding--lights", frame === "card" && "rk-onboarding--card", className);
279
332
  if (error && !state)
280
333
  return _jsxs("div", { className: rootClass, children: [_jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }), _jsx(Button, { onClick: () => window.location.reload(), children: "Retry" })] });
281
334
  if (state && !active)
@@ -334,6 +387,11 @@ export function Onboarding({ settings, legal, legalMode = "gate", appName, steps
334
387
  } })] });
335
388
  }
336
389
  const body = active.render(context);
390
+ if (frame === "card") {
391
+ // The HeardRight v5 frame: one centred card, the app name on top, a dot pager and no step rail.
392
+ return _jsxs("main", { className: rootClass, "data-testid": "onboarding", "data-step-id": active.id, "data-frame": "card", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: dragRegion }), _jsxs("section", { className: "rk-onboarding__main rk-onboarding__main--card", children: [_jsx("div", { className: "rk-onboarding__brand rk-onboarding__brand--card", children: appName ?? legal?.appName }), _jsxs("div", { className: "rk-onboarding__frame", children: [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: [_jsx(Button, { variant: "secondary", disabled: index <= lead || saving || continuing, onClick: () => void save({ currentStep: index - 1 }), children: L.back }), _jsx("nav", { className: "rk-onboarding__dots", "aria-label": "Setup progress", children: builtins.map((step, i) => _jsx("button", { type: "button", className: cx("rk-onboarding__dot", i === index && "is-active", activeState.completed.includes(step.id) && "is-done"), "aria-label": `Step ${i + 1}: ${step.label}`, "aria-current": i === index ? "step" : undefined, disabled: (gated && i === 0 && builtins.length > 1) || continuing, onClick: () => { if (!continuing && i >= lead && (i <= index || canContinue))
393
+ void save({ currentStep: i }); } }, step.id)) }), _jsx("span", { className: "rk-onboarding__hint", children: !canContinue && active.blockedHint ? active.blockedHint : null }), 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(Button, { variant: "primary", disabled: saving || continuing || !canContinue, onClick: () => void advance(), children: active.continueLabel ?? (index === builtins.length - 1 ? L.finish : L.continue) })] })] })] })] });
394
+ }
337
395
  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))
338
396
  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) })] })] })] });
339
397
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
1
+ export { Onboarding, isLegalAccepted, isStepSettled, onboardingStateKey, restartOnboardingSetup } from "./Onboarding.js";
2
2
  export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
3
3
  export type { LicenseStepCopy, LicenseStepOptions, ModelsStepOptions } from "./steps.js";
4
4
  export { licenseBackendFromView, licenseStateFromView, signInBackendFromOAuth } from "./adapters.js";
5
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";
6
+ export { LEGAL_RECEIPT_COMMANDS, createNativeLegalReceiptPort, importLegacyAcceptance, reopenLegalAcceptance, resetLegalAcceptance } from "./legal-receipt.js";
7
+ export type { LegacyAcceptanceImportOptions, LegalReceiptInvoke, NativeLegalReceiptPortOptions } from "./legal-receipt.js";
8
8
  export type * from "./types.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
1
+ export { Onboarding, isLegalAccepted, isStepSettled, onboardingStateKey, restartOnboardingSetup } from "./Onboarding.js";
2
2
  export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
3
3
  export { licenseBackendFromView, licenseStateFromView, signInBackendFromOAuth } from "./adapters.js";
4
- export { LEGAL_RECEIPT_COMMANDS, createNativeLegalReceiptPort, reopenLegalAcceptance, resetLegalAcceptance } from "./legal-receipt.js";
4
+ export { LEGAL_RECEIPT_COMMANDS, createNativeLegalReceiptPort, importLegacyAcceptance, reopenLegalAcceptance, resetLegalAcceptance } from "./legal-receipt.js";
@@ -1,4 +1,4 @@
1
- import type { LegalReceiptPort, SettingsBackend } from "./types.js";
1
+ import type { LegalAcceptanceRecord, LegalReceiptCreateRequest, LegalReceiptPort, SettingsBackend } from "./types.js";
2
2
  /**
3
3
  * The commands of the native legal-receipt host service. The native side registers them once under the host caller policy
4
4
  * (`rightkit-tauri-shell` host services); the trusted window is the one that shows the legal gate. Names are provisional
@@ -8,6 +8,8 @@ export declare const LEGAL_RECEIPT_COMMANDS: Readonly<{
8
8
  read: "rightkit_legal_receipt_read";
9
9
  create: "rightkit_legal_receipt_create";
10
10
  reset: "rightkit_legal_receipt_reset";
11
+ /** Imports a renderer-held legacy acceptance once (see `importLegacyAcceptance`). */
12
+ import: "rightkit_legal_receipt_import";
11
13
  }>;
12
14
  /** Tauri's `invoke`, structurally (`@tauri-apps/api/core`). No import, so the package stays free of Tauri. */
13
15
  export type LegalReceiptInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
@@ -18,7 +20,9 @@ export interface NativeLegalReceiptPortOptions {
18
20
  /**
19
21
  * `LegalReceiptPort` over the native legal-receipt service.
20
22
  *
21
- * - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record.
23
+ * - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record. A
24
+ * historical receipt (a version or digest that no longer matches) comes back as `{ record, current: false }`, which keeps
25
+ * the re-acceptance variant and the setup progress; the native service offers it that way.
22
26
  * - `create` sends the declaration (`version`, `documentsHash`, `documents`, `eligibilityBasis`, `surface`) and NO time. The
23
27
  * service compares it with the manifest it embeds, stamps native UTC time, stores the receipt and returns the record; a
24
28
  * refusal or a malformed answer throws, which keeps the gate closed.
@@ -26,6 +30,27 @@ export interface NativeLegalReceiptPortOptions {
26
30
  * There is no `write`: the renderer never supplies a stored record.
27
31
  */
28
32
  export declare function createNativeLegalReceiptPort({ invoke, commands }: NativeLegalReceiptPortOptions): LegalReceiptPort;
33
+ export interface LegacyAcceptanceImportOptions {
34
+ invoke: LegalReceiptInvoke;
35
+ /** Key of the renderer-held legacy receipt, for example `scraperight.legal-acceptance.v1`. */
36
+ storageKey: string;
37
+ /**
38
+ * Maps the stored text to the declaration the native service verifies against its manifest (hashes, versions, documents).
39
+ * `null`: not an importable record; nothing is sent and the legacy key stays in place.
40
+ */
41
+ parse: (raw: string) => LegalReceiptCreateRequest | null;
42
+ /** Storage that holds the legacy key. Default `localStorage` when present. `null`: no legacy storage to read. */
43
+ storage?: Pick<Storage, "getItem" | "removeItem"> | null;
44
+ commands?: Partial<typeof LEGAL_RECEIPT_COMMANDS>;
45
+ }
46
+ /**
47
+ * One-time import of a renderer-held (legacy) acceptance into the native receipt. The legacy record is submitted once to the
48
+ * native `import` command, which verifies it against the manifest and stamps the import with its own clock. Only after the
49
+ * native service confirms the record is the legacy key removed. A refusal or a failed call throws and leaves the key in place,
50
+ * so the next launch retries; no acceptance is invented here. Returns the imported record, or `null` when there is nothing to
51
+ * import.
52
+ */
53
+ export declare function importLegacyAcceptance({ invoke, storageKey, parse, storage, commands }: LegacyAcceptanceImportOptions): Promise<LegalAcceptanceRecord | null>;
29
54
  /**
30
55
  * Re-opens the agreement: the stored acceptance stops counting, so the gate shows as a re-acceptance on the next mount of
31
56
  * `Onboarding`. The old record (and the app's receipt) stays until the user agrees again; agreeing creates the new receipt and
@@ -8,6 +8,8 @@ export const LEGAL_RECEIPT_COMMANDS = Object.freeze({
8
8
  read: "rightkit_legal_receipt_read",
9
9
  create: "rightkit_legal_receipt_create",
10
10
  reset: "rightkit_legal_receipt_reset",
11
+ /** Imports a renderer-held legacy acceptance once (see `importLegacyAcceptance`). */
12
+ import: "rightkit_legal_receipt_import",
11
13
  });
12
14
  /** Host commands reject with `{ code, message }`; the gate and steps show `Error#message`. */
13
15
  function hostError(error) {
@@ -24,10 +26,26 @@ async function call(invoke, command, args) {
24
26
  throw hostError(error);
25
27
  }
26
28
  }
29
+ /** The declaration the native service compares with its manifest: never a time. */
30
+ function declaration(request) {
31
+ return {
32
+ version: request.version,
33
+ documentsHash: request.documentsHash,
34
+ documents: request.documents,
35
+ eligibilityBasis: request.eligibilityBasis,
36
+ surface: request.surface,
37
+ };
38
+ }
39
+ /** True for a historical receipt value (`{ record, current: false }`) as the native read may return it. */
40
+ function isHistoricalValue(value) {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "record" in value && value.current === false;
42
+ }
27
43
  /**
28
44
  * `LegalReceiptPort` over the native legal-receipt service.
29
45
  *
30
- * - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record.
46
+ * - `read` returns the stored record, or `null` when there is none or the stored value is not a well-formed record. A
47
+ * historical receipt (a version or digest that no longer matches) comes back as `{ record, current: false }`, which keeps
48
+ * the re-acceptance variant and the setup progress; the native service offers it that way.
31
49
  * - `create` sends the declaration (`version`, `documentsHash`, `documents`, `eligibilityBasis`, `surface`) and NO time. The
32
50
  * service compares it with the manifest it embeds, stamps native UTC time, stores the receipt and returns the record; a
33
51
  * refusal or a malformed answer throws, which keeps the gate closed.
@@ -39,18 +57,16 @@ export function createNativeLegalReceiptPort({ invoke, commands }) {
39
57
  return {
40
58
  async read() {
41
59
  const value = await call(invoke, names.read);
42
- return value === null || value === undefined ? null : readAcceptance(value) ?? null;
60
+ if (value === null || value === undefined)
61
+ return null;
62
+ if (isHistoricalValue(value)) {
63
+ const record = readAcceptance(value.record);
64
+ return record ? { record, current: false } : null;
65
+ }
66
+ return readAcceptance(value) ?? null;
43
67
  },
44
68
  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
- }));
69
+ const created = readAcceptance(await call(invoke, names.create, { request: declaration(request) }));
54
70
  if (!created)
55
71
  throw new Error("The legal receipt service returned an invalid record.");
56
72
  return created;
@@ -58,6 +74,47 @@ export function createNativeLegalReceiptPort({ invoke, commands }) {
58
74
  async reset() { await call(invoke, names.reset); },
59
75
  };
60
76
  }
77
+ function browserStorage() {
78
+ try {
79
+ return typeof localStorage === "undefined" ? null : localStorage;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ /**
86
+ * One-time import of a renderer-held (legacy) acceptance into the native receipt. The legacy record is submitted once to the
87
+ * native `import` command, which verifies it against the manifest and stamps the import with its own clock. Only after the
88
+ * native service confirms the record is the legacy key removed. A refusal or a failed call throws and leaves the key in place,
89
+ * so the next launch retries; no acceptance is invented here. Returns the imported record, or `null` when there is nothing to
90
+ * import.
91
+ */
92
+ export async function importLegacyAcceptance({ invoke, storageKey, parse, storage, commands }) {
93
+ const names = { ...LEGAL_RECEIPT_COMMANDS, ...commands };
94
+ const backing = storage === undefined ? browserStorage() : storage;
95
+ let raw;
96
+ try {
97
+ raw = backing?.getItem(storageKey) ?? null;
98
+ }
99
+ catch {
100
+ raw = null;
101
+ }
102
+ if (raw === null)
103
+ return null;
104
+ const request = parse(raw);
105
+ if (!request)
106
+ return null;
107
+ const imported = readAcceptance(await call(invoke, names.import, { request: declaration(request) }));
108
+ if (!imported)
109
+ throw new Error("The legal receipt service returned an invalid record.");
110
+ try {
111
+ backing?.removeItem(storageKey);
112
+ }
113
+ catch {
114
+ /* the native receipt is stored; the native import is idempotent, so a later run only repeats the confirmation */
115
+ }
116
+ return imported;
117
+ }
61
118
  /**
62
119
  * Re-opens the agreement: the stored acceptance stops counting, so the gate shows as a re-acceptance on the next mount of
63
120
  * `Onboarding`. The old record (and the app's receipt) stays until the user agrees again; agreeing creates the new receipt and
package/dist/types.d.ts CHANGED
@@ -130,8 +130,17 @@ export interface LegalReceiptCreateRequest {
130
130
  * `reset()` clears the stored receipt (used by `resetLegalAcceptance`). A port needs `create` or `write`; with neither,
131
131
  * agreeing fails and the gate stays closed.
132
132
  */
133
+ /**
134
+ * A receipt that exists but no longer counts: its version or documents differ from the current agreement. `read()` may
135
+ * return it so the gate shows as a re-acceptance and the setup progress is kept (`current: false`). Any other return
136
+ * value is a current record or `null`.
137
+ */
138
+ export interface HistoricalLegalReceipt {
139
+ record: LegalAcceptanceRecord;
140
+ current: false;
141
+ }
133
142
  export interface LegalReceiptPort {
134
- read(): LegalAcceptanceRecord | null | Promise<LegalAcceptanceRecord | null>;
143
+ read(): LegalAcceptanceRecord | HistoricalLegalReceipt | null | Promise<LegalAcceptanceRecord | HistoricalLegalReceipt | null>;
135
144
  /** Native receipt creation: returns the stored record, stamped with native time. Preferred over `write`. */
136
145
  create?(request: LegalReceiptCreateRequest): LegalAcceptanceRecord | Promise<LegalAcceptanceRecord>;
137
146
  /** @deprecated Renderer-built record with webview time. Implement `create` on a native receipt service instead. */
@@ -226,6 +235,19 @@ export type LegalMode = "gate" | "notice" | "none";
226
235
  export interface OnboardingProps {
227
236
  /** Window-sized by default; use `contained` when the host sizes the setup surface. */
228
237
  layout?: "window" | "contained";
238
+ /**
239
+ * Setup chrome. `rail` (default): a left step rail with a "Step n of N" eyebrow. `card`: one centred card with the app
240
+ * name on top and a dot pager, no rail (the HeardRight v5 onboarding mock). Applies to the setup steps only; the legal
241
+ * gate keeps its own layout.
242
+ */
243
+ frame?: "rail" | "card";
244
+ /** Vertical density of the setup steps. `compact` (rail frame): main padding 18px top, content 8px, rail 188px wide. Default `default`. */
245
+ density?: "default" | "compact";
246
+ /**
247
+ * Clears the macOS traffic lights: the rail (or card brand row) and the titlebar strip start past them. Use with a
248
+ * `titlebar` or `dragRegion` on macOS windows with overlay title bars. Default false.
249
+ */
250
+ trafficLightInset?: boolean;
229
251
  settings: SettingsBackend;
230
252
  /** Required when `legalMode` is `gate` (default). Optional for `notice` (the documents shown) and `none`. */
231
253
  legal?: LegalStepConfig;
@@ -277,7 +299,7 @@ export interface OnboardingProps {
277
299
  modelsIntro?: ReactNode;
278
300
  /** Show "Set up later". Default true. */
279
301
  allowSkip?: boolean;
280
- /** Optional titlebar content rendered in a strip along the top. */
302
+ /** Optional titlebar content rendered in a strip along the top. On macOS with `trafficLightInset`, the strip starts past the lights. */
281
303
  titlebar?: ReactNode;
282
304
  /** Render a `data-tauri-drag-region` strip at the top of the setup steps. Default false. Never applied to the legal stage; see `legalDragRegion`. */
283
305
  dragRegion?: boolean;
package/onboarding.css CHANGED
@@ -40,3 +40,23 @@
40
40
  .rk-onboarding__titlebar { position: absolute; inset: 0 0 auto 0; z-index: 2; display: flex; align-items: center; height: var(--rk-onboarding-titlebar-h, 32px); padding: 0 12px; }
41
41
  .rk-onboarding__hint { color: var(--rk-ink-2, #6b655c); font-size: 12px; text-align: right; }
42
42
  .rk-onboarding__intro { margin-bottom: 12px; color: var(--rk-ink-2, #6b655c); }
43
+ /* Density (HeardRight: onboarding too tall). compact: rail 188px, main 18px top, content 8px. */
44
+ .rk-onboarding--compact { grid-template-columns: 188px minmax(0, 1fr); }
45
+ .rk-onboarding--compact .rk-onboarding__main { padding-top: 18px; }
46
+ .rk-onboarding--compact .rk-onboarding__content { padding: 8px 0; }
47
+ /* macOS traffic lights: the rail and the titlebar strip start past them (lights-top: rail top; lights width from shell.css). */
48
+ .rk-onboarding--lights .rk-onboarding__rail { padding-top: var(--rk-onboarding-lights-top, 40px); }
49
+ .rk-onboarding--lights .rk-onboarding__titlebar { padding-left: calc(var(--rk-lights-width, 72px) + 6px); }
50
+ .rk-onboarding--lights .rk-onboarding__brand--card { padding-left: calc(var(--rk-lights-width, 72px) + 6px); }
51
+ /* card frame (HeardRight v5 mock): one centred card, the app name on top, a dot pager and no rail. */
52
+ .rk-onboarding--card { display: flex; flex-direction: column; align-items: stretch; }
53
+ .rk-onboarding--card .rk-onboarding__main--card { flex: 1; display: flex; flex-direction: column; align-items: center; max-width: none; width: 100%; padding: 0 clamp(24px, 5vw, 56px) 24px; }
54
+ .rk-onboarding__brand--card { align-self: stretch; padding: 18px 20px; font-size: 18px; font-weight: 900; letter-spacing: -.03em; }
55
+ .rk-onboarding__frame { display: flex; flex-direction: column; flex: 1; width: min(860px, 100%); min-height: 0; padding-top: 24px; }
56
+ .rk-onboarding--card .rk-onboarding__content { flex: 1; }
57
+ .rk-onboarding--card footer .rk-onboarding__hint { flex: none; }
58
+ .rk-onboarding__dots { display: flex; align-items: center; justify-content: center; gap: 10px; flex: 1; }
59
+ .rk-onboarding__dot { width: 10px; height: 10px; padding: 0; border: 0; border-radius: 999px; background: var(--rk-line-strong, rgba(35,31,26,.18)); cursor: pointer; transition: width .2s ease, background .2s ease; }
60
+ .rk-onboarding__dot.is-done { background: var(--rk-ok, #1e7f3c); }
61
+ .rk-onboarding__dot.is-active { width: 42px; background: var(--rk-accent, #7040c4); }
62
+ .rk-onboarding__dot:disabled { cursor: not-allowed; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/onboarding",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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,21 +29,11 @@
29
29
  "react": ">=18 <21"
30
30
  },
31
31
  "devDependencies": {
32
- "@rightkit/react-shell": "0.7.2",
33
- "@rightkit/legal-ui": "0.4.1",
34
- "@rightkit/qa-chrome": "0.1.9",
35
- "@testing-library/jest-dom": "^7.0.1",
36
- "@testing-library/dom": "^10.4.2",
37
- "@testing-library/react": "^16.3.3",
38
- "@testing-library/user-event": "^14.6.8",
32
+ "@rightkit/react-shell": "0.7.3",
33
+ "@rightkit/legal-ui": "0.4.2",
39
34
  "@types/react": "^19.3.0",
40
- "@types/react-dom": "^19.3.0",
41
- "jsdom": "^30.1.3",
42
35
  "react": "^19.3.1",
43
- "react-dom": "^19.3.1",
44
- "typescript": "^7.0.2",
45
- "vite": "^8.3.4",
46
- "vitest": "^5.0.5"
36
+ "typescript": "^7.0.2"
47
37
  },
48
38
  "publishConfig": {
49
39
  "registry": "https://registry.npmjs.org",
@@ -56,8 +46,6 @@
56
46
  },
57
47
  "scripts": {
58
48
  "build": "tsc -p tsconfig.json",
59
- "typecheck": "tsc -p tsconfig.json --noEmit",
60
- "pretest": "pnpm --filter \"@rightkit/onboarding^...\" run build",
61
- "test": "vitest run"
49
+ "typecheck": "tsc -p tsconfig.json --noEmit"
62
50
  }
63
51
  }