@quran-lab/tajweedformer 2.0.0 → 2.0.1

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,13 +1,15 @@
1
1
  // <quranlab-consent> and <quranlab-sharing-settings>: the SDK's own sharing sheet and settings for
2
- // the browser (SHARING_EXPERIENCE.md, PLATFORM_CLIENT.md section 9). Themable with CSS custom
3
- // properties (--ql-bg, --ql-fg, --ql-muted, --ql-accent, --ql-accent-fg, --ql-radius, --ql-font) and
4
- // ::part(sheet|title|body|primary|secondary|guardian), RTL from the deck's direction, keyboard and
5
- // screen reader friendly (a modal <dialog>), two buttons of equal weight, nothing pre-ticked.
2
+ // the browser (SHARING_EXPERIENCE.md, PLATFORM_CLIENT.md section 9). Themable with a `theme` (theme.js) or
3
+ // the CSS custom properties it sets (--ql-bg, --ql-fg, --ql-muted, --ql-accent, --ql-accent-fg, --ql-radius,
4
+ // --ql-font, --ql-title-font) and ::part(sheet|title|body|primary|secondary|guardian), RTL from the deck's
5
+ // direction, keyboard and screen reader friendly (a modal <dialog>), two buttons of equal weight, nothing
6
+ // pre-ticked. An app's own wording (wording.js) is shown in place of the deck's strings it replaces.
6
7
  //
7
8
  // import '@quran-lab/tajweedformer/consent'; // defines the elements
8
- // const yes = await QuranLabConsent.ask({ lang: 'ar', variant: 'first', guardian: false });
9
+ // const yes = await QuranLabConsent.ask({ lang: 'ar', variant: 'first', guardian: false, theme: { accent: '#0b6e4f' } });
9
10
 
10
11
  import { copyFor } from './copy.js';
12
+ import { applyTheme } from './theme.js';
11
13
 
12
14
  const STYLE = `
13
15
  :host { all: initial; }
@@ -18,7 +20,7 @@ dialog { box-sizing: border-box; max-inline-size: min(34rem, calc(100vw - 2rem))
18
20
  box-shadow: 0 20px 60px rgb(0 0 0 / .25); }
19
21
  dialog::backdrop { background: rgb(0 0 0 / .45); }
20
22
 
21
- h2 { margin: 0 0 .75rem; font-size: 1.25rem; line-height: 1.3; font-weight: 650; }
23
+ h2 { margin: 0 0 .75rem; font-size: 1.25rem; line-height: 1.3; font-weight: 650; font-family: var(--ql-title-font, inherit); }
22
24
  p { margin: 0 0 .75rem; }
23
25
  .muted { color: var(--ql-muted, inherit); opacity: .85; font-size: .95rem; }
24
26
  ul { margin: 0 0 1rem; padding-inline-start: 1.1rem; }
@@ -51,7 +53,8 @@ const Base = globalThis.HTMLElement || class {};
51
53
  export class QuranLabConsent extends Base {
52
54
  /**
53
55
  * Show the sheet and resolve with the learner's answer (true = share). Dismissing (Escape) is "Not now".
54
- * @param {{ lang?: string, variant?: 'first'|'reask'|'version', guardian?: boolean, parent?: Element }} o
56
+ * @param {{ lang?: string, variant?: 'first'|'reask'|'version', guardian?: boolean, parent?: Element,
57
+ * wording?: Record<string, string>|null, theme?: object|null }} o
55
58
  * @returns {Promise<{ yes: boolean, guardianConfirmed: boolean }>}
56
59
  */
57
60
  static ask(o = {}) {
@@ -60,8 +63,9 @@ export class QuranLabConsent extends Base {
60
63
  return el.show(o).finally(() => el.remove());
61
64
  }
62
65
 
63
- show({ lang = (globalThis.navigator && navigator.language) || 'en', variant = 'first', guardian = false } = {}) {
64
- const c = copyFor(lang);
66
+ show({ lang = (globalThis.navigator && navigator.language) || 'en', variant = 'first', guardian = false, wording = null, theme = null } = {}) {
67
+ const c = copyFor(lang, wording);
68
+ applyTheme(this, theme);
65
69
  const t = c.t;
66
70
  const root = this.shadowRoot || this.attachShadow({ mode: 'open' });
67
71
  const lines = (guardian ? ['sheet.what', 'sheet.who', 'sheet.control'] : ['sheet.mistakes', 'sheet.what', 'sheet.who', 'sheet.control'])
@@ -101,13 +105,14 @@ export class QuranLabConsent extends Base {
101
105
  }
102
106
 
103
107
  /** The one-time thank-you, as a small dismissable note. */
104
- static thank({ lang, parent, ms = 6000 } = {}) {
105
- const c = copyFor(lang || (globalThis.navigator && navigator.language) || 'en');
108
+ static thank({ lang, parent, ms = 6000, wording = null, theme = null } = {}) {
109
+ const c = copyFor(lang || (globalThis.navigator && navigator.language) || 'en', wording);
106
110
  const el = document.createElement('div');
107
111
  el.setAttribute('role', 'status');
108
112
  el.lang = c.lang; el.dir = c.dir;
109
113
  el.textContent = c.t('thanks');
110
- el.style.cssText = 'position:fixed;inset-inline:1rem;inset-block-end:1rem;max-inline-size:32rem;margin-inline:auto;padding:1rem 1.25rem;border-radius:14px;background:var(--ql-accent,#2a3b8f);color:var(--ql-accent-fg,#fff);font:500 1rem/1.5 var(--ql-font,system-ui,sans-serif);z-index:2147483647;box-shadow:0 10px 30px rgb(0 0 0 / .25)';
114
+ el.style.cssText = 'position:fixed;inset-inline:1rem;inset-block-end:1rem;max-inline-size:32rem;margin-inline:auto;padding:1rem 1.25rem;border-radius:var(--ql-radius,14px);background:var(--ql-accent,#2a3b8f);color:var(--ql-accent-fg,#fff);font:500 1rem/1.5 var(--ql-font,system-ui,sans-serif);z-index:2147483647;box-shadow:0 10px 30px rgb(0 0 0 / .25)';
115
+ applyTheme(el, theme);
111
116
  (parent || document.body).appendChild(el);
112
117
  setTimeout(() => el.remove(), ms);
113
118
  return el;
@@ -124,7 +129,8 @@ export class QuranLabSharingSettings extends Base {
124
129
  connectedCallback() { this.render(); }
125
130
  render() {
126
131
  if (!this._c) return;
127
- const c = copyFor(this.getAttribute('lang') || this._c.lang);
132
+ const c = copyFor(this.getAttribute('lang') || this._c.lang, this._c.wording || null);
133
+ applyTheme(this, this._c.theme || null);
128
134
  const t = c.t;
129
135
  const root = this.shadowRoot || this.attachShadow({ mode: 'open' });
130
136
  const on = this._c.state.sharing;
@@ -135,7 +141,7 @@ export class QuranLabSharingSettings extends Base {
135
141
  <input type="checkbox" role="switch" id="s" ${on ? 'checked' : ''} aria-checked="${on}"></div>
136
142
  ${this._c.state.guardianVariant ? `<label class="guardian"><input type="checkbox" id="g"> <span>${esc(t('sheet.guardian.confirm'))}</span></label>` : ''}
137
143
  <details><summary>${esc(t('settings.what'))}</summary><p>${esc(t('what.detail'))}</p></details>
138
- ${code ? `<details id="rc"><summary>Recovery code</summary><p class="code" id="code"></p></details>` : ''}
144
+ ${code ? `<details id="rc"><summary>${esc(t('recovery.reveal'))}</summary><p class="code" id="code"></p><p class="muted">${esc(t('recovery.keep'))}</p><p class="muted">${esc(t('recovery.hint'))}</p></details>` : ''}
139
145
  <div class="row"><button class="secondary" id="del">${esc(t('settings.delete'))}</button></div>
140
146
  <p class="muted" id="msg" role="status"></p>
141
147
  </section>`;
@@ -151,7 +157,7 @@ export class QuranLabSharingSettings extends Base {
151
157
  root.querySelector('#del').addEventListener('click', async () => {
152
158
  if (!globalThis.confirm || globalThis.confirm(t('delete.confirm'))) {
153
159
  const ok = await this._c.deleteShared().then(() => true, () => false);
154
- msg.textContent = ok ? t('delete.done') : t('delete.confirm');
160
+ msg.textContent = ok ? t('delete.done') : t('delete.failed.network');
155
161
  if (ok) this.render();
156
162
  }
157
163
  });
@@ -1,45 +1,107 @@
1
1
  // Types for @quran-lab/tajweedformer/consent: the recitation sharing experience (SHARING_EXPERIENCE.md).
2
2
  import type { QuranLab, StateStore } from '../platform/index.js';
3
3
 
4
+ /** 'first', or 'reask' after "Not now". 'version' is deprecated and never produced since 2.0.1: a grant is kept
5
+ * across decks and wordings and is never asked about again. */
4
6
  export type AskVariant = 'first' | 'reask' | 'version';
5
7
  export interface Ask { variant: AskVariant; guardian: boolean }
6
8
  export interface Answer { yes: boolean; guardianConfirmed?: boolean }
7
9
 
10
+ /** sheets shown in all per install, ever (the first ask and at most two re-asks after "Not now") */
8
11
  export declare const MAX_ASKS: 3;
12
+ /** sessions between asks */
9
13
  export declare const REASK_GAP: 7;
14
+ /** the consent version a 2.0.0 grant stored without a version was given to (the deck 2.0.0 bundled) */
15
+ export declare const LEGACY_CONSENT_VERSION: 'share-2026-11-07';
16
+ /** the StateStore key of the sharing state (the same since 2.0.0) */
17
+ export declare const SHARING_STATE_KEY: 'sharing.v1';
10
18
 
11
19
  export declare class SharingState {
12
- constructor(state: StateStore, o?: { enabled?: () => boolean; consentVersion?: string; audience?: string; clock?: () => number });
13
- /** uploads may run: allowed by the app and the signed config, and the learner said yes */
20
+ constructor(state: StateStore, o?: { enabled?: () => boolean; consentVersion?: string; consentTextSha256?: string | null; audience?: string; clock?: () => number });
21
+ /** uploads may run: allowed by the signed config, and the learner said yes (to whichever version) */
14
22
  readonly sharing: boolean;
15
23
  readonly guardianVariant: boolean;
16
- /** a session ended; the ask due now, or null */
24
+ /** asking has not ended: no yes, not turned off, fewer than MAX_ASKS sheets shown */
25
+ readonly asking: boolean;
26
+ /** the version a new grant records */
27
+ readonly consentVersion: string | null;
28
+ readonly consentTextSha256: string | null;
29
+ /** the version the current grant was given to, as recorded then (null when not granted) */
30
+ readonly grantVersion: string | null;
31
+ /** the hash of the own wording the current grant was given to (null for a deck's wording or when not sharing) */
32
+ readonly grantTextSha256: string | null;
33
+ /** the version a new grant records; an existing grant keeps its own and is not asked again */
34
+ setConsent(version: string, textSha256?: string | null): void;
35
+ /** a session ended; the ask due now (counted as shown), or null */
17
36
  sessionCompleted(o?: { feedback?: boolean }): Ask | null;
18
- askNow(): Ask;
37
+ /** the app's own timing: an ask counted as shown, or null once asking has ended */
38
+ askNow(): Ask | null;
19
39
  answer(yes: boolean, o?: { guardianConfirmed?: boolean }): 'granted' | 'declined' | 'final';
20
40
  setSharing(on: boolean, o?: { guardianConfirmed?: boolean }): void;
21
41
  takeThanks(): boolean;
22
42
  onChange(f: (s: object) => void): () => void;
23
43
  }
24
44
 
25
- /** A copy deck (quranlab.sharing-copy/1) for one language. */
45
+ /** A copy deck (quranlab.sharing-copy/1) for one language, with any own wording applied. */
26
46
  export interface Deck {
27
47
  lang: string; dir: 'ltr' | 'rtl'; version: string; consentVersion: string; deleteDays: number;
48
+ /** 'immediate' since deck 2026-11-09.1 */
49
+ deleteTiming: string;
50
+ /** the keys the app's own wording replaces */
51
+ overridden: string[];
28
52
  hadithSource: { collection: string; number: number; url: string; translation_source: string | null; translated: boolean } | null;
29
- /** a string by key ({days} filled); English when this deck lacks it */
53
+ /** a string by key: the app's own wording, else this deck's, else English */
30
54
  t(key: string): string;
31
55
  /** the plural impact line for real shared minutes */
32
56
  minutes(n: number): string;
33
57
  }
34
58
  export declare const EN: Readonly<{ format: string; version: string; lang: string; dir: string; consent_version: string; strings: Record<string, unknown> }>;
35
- export declare function copyFor(lang?: string): Deck;
59
+ export declare function copyFor(lang?: string, overrides?: Record<string, string> | null): Deck;
60
+
61
+ /** An app's own wording ({deck key: text}); plural lines as `impact.minutes.<category>`. */
62
+ export type Wording = Record<string, string>;
63
+ /** The consent a grant is to: the deck's version, or custom-<24 hex> with the text's SHA-256 and canonical text. */
64
+ export interface Consent { version: string; textSha256: string | null; canonicalText: string | null }
65
+ export declare const CONSENT_TEXT_FORMAT: 'quranlab.consent-text/1';
66
+ export declare const PLURAL_CATEGORIES: readonly string[];
67
+ /** checks overrides; throws a TypeError for an unknown key or an empty or blank text */
68
+ export declare function validateWording(overrides: Wording | null | undefined): Readonly<Wording>;
69
+ export declare function canonicalConsentText(base: string, overrides: Wording | null): string | null;
70
+ export declare function deriveConsent(base: string, overrides: Wording | null): Consent;
71
+ export declare function wordingKeys(): Set<string>;
72
+ export declare function sha256HexSync(bytes: Uint8Array): string;
73
+
74
+ /** The look of the SDK's sheets (consent, guardian, thanks, settings): CSS values; a number cornerRadius is px. */
75
+ export interface SharingTheme {
76
+ accent?: string; accentText?: string; background?: string; text?: string; secondaryText?: string;
77
+ cornerRadius?: string | number; titleFont?: string; bodyFont?: string;
78
+ }
79
+ export declare const THEME_PROPERTIES: Readonly<Record<keyof SharingTheme, string>>;
80
+ export declare function themeProperties(theme: SharingTheme | null | undefined): Record<string, string>;
81
+ export declare function applyTheme<E extends { style: CSSStyleDeclaration }>(el: E, theme: SharingTheme | null | undefined): E;
82
+
83
+ /** What an app's own UI receives: show its own sheet with `copy`, then answer from the learner's tap. */
84
+ export interface OwnAsk extends Ask { lang: string; copy: Deck; consentVersion: string }
36
85
  export declare function registerDeck(lang: string, deck: { format: 'quranlab.sharing-copy/1'; [k: string]: unknown }): void;
37
86
  export declare function languages(): string[];
38
87
 
39
88
  export interface SharingController {
40
89
  lang: string;
41
90
  state: SharingState;
42
- deck: Deck;
91
+ /** the copy with the app's own wording applied (the same as copy) */
92
+ readonly deck: Deck;
93
+ readonly copy: Deck;
94
+ /** the consent a new grant records; send textSha256 or canonicalText to Quran Lab for a review of your wording */
95
+ readonly consent: Consent;
96
+ readonly wording: Readonly<Wording>;
97
+ readonly theme: SharingTheme | null;
98
+ /** replace deck strings (null or {} restores the deck); a TypeError for a bad key or text, nothing changes then */
99
+ setWording(wording: Wording | null): Consent;
100
+ setTheme(theme: SharingTheme | null): void;
101
+ /** the ask an app's own UI has not answered yet */
102
+ readonly pendingAsk: OwnAsk | null;
103
+ /** the learner's answer, from their own tap (default sheet and own UI); after a yes or a switch turned off it changes nothing */
104
+ answer(yes: boolean, o?: { guardianConfirmed?: boolean }): 'granted' | 'declined' | 'final';
43
105
  sessionCompleted(o?: { feedback?: boolean }): Promise<Answer | null>;
44
106
  setSharing(on: boolean, o?: { guardianConfirmed?: boolean }): void;
45
107
  /** "Delete what I shared": signed with this install's device key (no app credential can delete) */
@@ -50,15 +112,16 @@ export interface SharingController {
50
112
  impactLine(): Promise<string | null>;
51
113
  }
52
114
 
53
- /** Turn on the sharing experience for a configured client (contributions: true). */
54
- export declare function enableSharing(ql: QuranLab, o?: { lang?: string; ask?: 'auto' | ((a: Ask) => Promise<Answer>); idle?: () => boolean }): SharingController;
115
+ /** Wire the sharing experience for a configured client (the SDK also does it when a model attaches in a page). */
116
+ export declare function enableSharing(ql: QuranLab, o?: { lang?: string; ask?: 'auto' | ((a: OwnAsk) => Promise<Answer | null | void>); idle?: () => boolean;
117
+ wording?: Wording | null; theme?: SharingTheme | null }): SharingController;
55
118
  export declare function deleteWithKey(ql: QuranLab, key: import('../platform/index.js').ContributorKey): Promise<{ accepted: boolean; sessionsLeft: number | null }>;
56
119
 
57
120
  /** The web components (import '@quran-lab/tajweedformer/consent/element' or let enableSharing load them). */
58
121
  export declare class QuranLabConsent extends HTMLElement {
59
- static ask(o?: { lang?: string; variant?: AskVariant; guardian?: boolean; parent?: Element }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
60
- static thank(o?: { lang?: string; parent?: Element; ms?: number }): HTMLElement;
61
- show(o?: { lang?: string; variant?: AskVariant; guardian?: boolean }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
122
+ static ask(o?: { lang?: string; variant?: AskVariant; guardian?: boolean; parent?: Element; wording?: Wording | null; theme?: SharingTheme | null }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
123
+ static thank(o?: { lang?: string; parent?: Element; ms?: number; wording?: Wording | null; theme?: SharingTheme | null }): HTMLElement;
124
+ show(o?: { lang?: string; variant?: AskVariant; guardian?: boolean; wording?: Wording | null; theme?: SharingTheme | null }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
62
125
  }
63
126
  export declare class QuranLabSharingSettings extends HTMLElement {
64
127
  controller: SharingController;
@@ -5,45 +5,114 @@
5
5
  //
6
6
  // import { QuranLab } from '@quran-lab/tajweedformer/platform';
7
7
  // import { enableSharing } from '@quran-lab/tajweedformer/consent';
8
- // const ql = await QuranLab.configure({ key, contributions: true });
9
- // const sharing = enableSharing(ql, { lang: 'ar' }); // asks by itself after a session with feedback
8
+ // const ql = await QuranLab.configure({ key });
9
+ // const sharing = enableSharing(ql, { lang: 'ar' }); // asks by itself after a session with feedback, where
10
+ // // the signed config offers sharing (flags.contributions)
10
11
  // document.querySelector('quranlab-sharing-settings').controller = sharing;
12
+ //
13
+ // Since 2.0.1 the sheets take a theme (`theme`, or CSS custom properties), an app can show its own UI
14
+ // (`ask: fn`, then the learner's answer through the return value or `sharing.answer(yes)`), and an app can
15
+ // replace strings with its own wording (`wording`, `sharing.setWording`): a learner who agrees then agrees to that
16
+ // text, under the version `custom-<24 hex>` derived from it, and each session sends its hash.
17
+ //
18
+ // The sheet is shown at most three times per install; a yes is kept, with the version the learner agreed to, across
19
+ // copy decks, wordings and SDK upgrades (also from 2.0.0), and is never asked about again.
11
20
 
12
21
  import { SharingState } from './state.js';
13
22
  import { copyFor, registerDeck, languages, EN } from './copy.js';
23
+ import { validateWording, deriveConsent } from './wording.js';
24
+ import { themeProperties } from './theme.js';
14
25
  import { contributorKey, forgetContributorKey, deletionMessage, b64url, newRecoveryCode, recoveryHash } from '../platform/contributor-key.js';
26
+ import { SDK_VERSION } from '../platform/client.js';
15
27
 
16
- export { SharingState, MAX_ASKS, REASK_GAP } from './state.js';
28
+ export { SharingState, MAX_ASKS, REASK_GAP, LEGACY_CONSENT_VERSION, SHARING_STATE_KEY } from './state.js';
17
29
  export { copyFor, registerDeck, languages, EN } from './copy.js';
30
+ export { validateWording, deriveConsent, canonicalConsentText, wordingKeys, sha256HexSync, CONSENT_TEXT_FORMAT, PLURAL_CATEGORIES } from './wording.js';
31
+ export { THEME_PROPERTIES, themeProperties, applyTheme } from './theme.js';
18
32
 
19
33
  /**
20
34
  * Turn on the sharing experience for a configured platform client.
21
- * @param {import('../platform/client.js').QuranLab} ql configured with contributions: true
35
+ * @param {import('../platform/client.js').QuranLab} ql a configured client (the signed config decides whether sharing is offered)
22
36
  * @param {object} [o]
23
37
  * @param {string} [o.lang] the deck's language (default: the browser's)
24
- * @param {'auto'|((ask: object) => Promise<{yes: boolean, guardianConfirmed?: boolean}>)} [o.ask='auto']
38
+ * @param {'auto'|((ask: object) => Promise<{yes: boolean, guardianConfirmed?: boolean}|null|undefined>)} [o.ask='auto']
25
39
  * auto: the SDK shows <quranlab-consent> when the app is idle after a session with feedback; a
26
- * function: the app shows it itself (timing only; the lines are copyFor(lang))
40
+ * function: the app shows its own UI with the SDK's strings (`ask.copy`, the copy with any own wording)
41
+ * and resolves with the learner's answer, or resolves with nothing and calls `sharing.answer(yes)` from
42
+ * the learner's tap later. Counted and re-asked exactly like the SDK's own sheet (three asks at most).
27
43
  * @param {(() => boolean)} [o.idle] whether the app is idle now (default: always)
44
+ * @param {Record<string, string>|null} [o.wording] the app's own wording, {deck key: text} (see setWording)
45
+ * @param {object|null} [o.theme] colours, corner radius and fonts of the SDK's sheets (THEME_PROPERTIES)
28
46
  */
29
47
  export function enableSharing(ql, o = {}) {
30
48
  const lang = o.lang || (globalThis.navigator && navigator.language) || 'en';
31
- const deck = copyFor(lang);
32
- const state = new SharingState(ql.state, { enabled: () => ql.sharingAllowed(), consentVersion: deck.consentVersion, audience: ql.audience });
49
+ let wording = validateWording(o.wording);
50
+ let theme = o.theme || null;
51
+ themeProperties(theme); // refuses a bad theme now, not when a sheet opens
52
+ let deck = copyFor(lang, wording);
53
+ let consent = deriveConsent(deck.consentVersion, wording);
54
+ const state = new SharingState(ql.state, { enabled: () => ql.sharingAllowed(), consentVersion: consent.version,
55
+ consentTextSha256: consent.textSha256, audience: ql.audience });
56
+ const count = (field) => { try { if (ql.telemetry && typeof ql.telemetry.count === 'function') ql.telemetry.count(field); } catch (_) { /* never in the way */ } };
57
+ let pending = null; // the ask an app's own UI has not answered yet
33
58
  const ctl = {
34
- lang, state, deck,
59
+ lang, state,
60
+ /** The copy the sheets show and an app's own UI should show: the deck with the app's own wording applied. */
61
+ get deck() { return deck; },
62
+ get copy() { return deck; },
63
+ /** The consent a new grant records: { version, textSha256, canonicalText } (the last two null for the deck's own
64
+ * wording). Send textSha256 or canonicalText to Quran Lab for a review of your wording. */
65
+ get consent() { return consent; },
66
+ get wording() { return wording; },
67
+ get theme() { return theme; },
68
+ /**
69
+ * Replace deck strings with the app's own wording ({key: text}; plural lines as `impact.minutes.one`, ...;
70
+ * null or {} goes back to the deck). An unknown key or an empty or blank text throws a TypeError and nothing
71
+ * changes. A learner who agrees from now on agrees to this wording (its custom version); a learner who already
72
+ * agreed keeps the version recorded then, keeps sharing and is not asked again.
73
+ */
74
+ setWording(next) {
75
+ const w = validateWording(next);
76
+ wording = w;
77
+ deck = copyFor(lang, wording);
78
+ consent = deriveConsent(deck.consentVersion, wording);
79
+ state.setConsent(consent.version, consent.textSha256);
80
+ return consent;
81
+ },
82
+ /** The look of the SDK's sheets: { accent, accentText, background, text, secondaryText, cornerRadius, titleFont,
83
+ * bodyFont } (CSS values; null for the default). The words and the consent version do not change. */
84
+ setTheme(next) { themeProperties(next); theme = next || null; },
85
+ /** The ask an app's own UI is showing and has not answered (null when none). */
86
+ get pendingAsk() { return pending; },
87
+ /**
88
+ * The learner's answer to a consent ask, from the learner's own tap (the SDK's sheet calls it, and so does an
89
+ * app's own UI). yes: share under the wording shown; no: not now. Counted as share_yes or share_no. After a yes
90
+ * or the switch turned off it changes nothing (and counts nothing): the settings switch decides then.
91
+ */
92
+ answer(yes, { guardianConfirmed = false } = {}) {
93
+ const was = state.s.consent;
94
+ const r = state.answer(!!yes, { guardianConfirmed: !!guardianConfirmed });
95
+ pending = null;
96
+ if (was !== 'granted' && was !== 'final') count(yes ? 'share_yes' : 'share_no');
97
+ return r;
98
+ },
35
99
  /** The ask to show after a session (null when none is due); auto mode shows it. */
36
100
  async sessionCompleted({ feedback = true } = {}) {
37
101
  const ask = state.sessionCompleted({ feedback });
38
102
  if (!ask) return null;
39
- const show = typeof o.ask === 'function' ? o.ask : async (a) => {
103
+ const own = typeof o.ask === 'function';
104
+ const show = own ? o.ask : async (a) => {
40
105
  const { QuranLabConsent } = await import('./element.js');
41
- return QuranLabConsent.ask({ lang, variant: a.variant, guardian: a.guardian });
106
+ return QuranLabConsent.ask({ lang, variant: a.variant, guardian: a.guardian, wording, theme });
42
107
  };
43
108
  const wait = () => new Promise((r) => { const tick = () => (!o.idle || o.idle() ? r() : setTimeout(tick, 1000)); tick(); });
44
109
  await wait();
45
- const a = await show(ask);
46
- state.answer(!!a.yes, { guardianConfirmed: !!a.guardianConfirmed });
110
+ const full = { ...ask, lang, copy: deck, consentVersion: consent.version };
111
+ pending = full;
112
+ count('share_asked');
113
+ const a = await show(full);
114
+ if (!a || typeof a.yes !== 'boolean') return null; // the app's own UI answers later with ctl.answer()
115
+ if (pending === full) ctl.answer(a.yes, { guardianConfirmed: !!a.guardianConfirmed });
47
116
  return a;
48
117
  },
49
118
  /** The settings switch: off stops uploads at once and deletes the unsent queue. */
@@ -75,7 +144,7 @@ export function enableSharing(ql, o = {}) {
75
144
  async impactLine() {
76
145
  const s = await ql.contributionSummary();
77
146
  if (!s || !s.you || !s.you.minutes) return null;
78
- return deck.minutes(Math.floor(s.you.minutes));
147
+ return ctl.copy.minutes(Math.floor(s.you.minutes));
79
148
  },
80
149
  };
81
150
  ql.sharing = ctl;
@@ -87,11 +156,27 @@ export function enableSharing(ql, o = {}) {
87
156
  const key = await contributorKey(ql.contributor());
88
157
  let code = ql.state.get('sharing.recovery');
89
158
  if (!code) { code = newRecoveryCode(); ql.state.set('sharing.recovery', code); }
159
+ // 2.0.0 passed ql.token() (a string; the outbox wants {token, attested}, so every pass stopped at
160
+ // "no token"), the telemetry device ({class, os_major}; the session JSON wants {deviceClass,
161
+ // osMajor}, so every start threw "os_major undefined" and was reported as sharing_start:error),
162
+ // snake_case decode fields (dropped), a flags snapshot and no consent check in the outbox
163
+ const tokenProvider = async () => {
164
+ const token = await ql.token();
165
+ return { token, attested: !!(ql._token && ql._token.attested) };
166
+ };
167
+ tokenProvider.invalidate = async () => { ql._token = null; ql.state.set('token', null); };
168
+ const dev = ql.telemetry.device || {};
90
169
  session = await mod.createSharingSession({
91
- api: ql.apiUrl, fetch: ql.fetch, tokenProvider: () => ql.token(), headers: ql.origin ? { origin: ql.origin } : {},
170
+ api: ql.apiUrl, fetch: ql.fetch, tokenProvider, headers: ql.origin ? { origin: ql.origin } : {},
92
171
  contributor: ql.contributor(), contributorKeyRaw: key.publicKeyRaw, recoveryHash: await recoveryHash(code), inputSampleRate: tf.sampleRate, inputRoute: 'unknown',
93
- decodeInfo: { model_revision: ql.modelRevision || null, backend: 'exact', weights_sha256: null, sdk_version: '2.0.0' },
94
- consentVersion: deck.consentVersion, device: ql.telemetry.device, flags: ql.flags,
172
+ decodeInfo: { modelRevision: ql.modelRevision || null, backend: 'exact', weightsSha256: null, sdkVersion: SDK_VERSION },
173
+ // the version the learner agreed to, as recorded then (not the deck or wording in force now) and, for an
174
+ // app's own wording, the hash of that text
175
+ consentVersion: state.grantVersion, consentTextSha256: state.grantTextSha256,
176
+ device: { deviceClass: dev.class || 'browser', osMajor: dev.os_major || 1 },
177
+ // read at every pass: the signed config in force now, and the learner's answer now (a withdrawal
178
+ // or a config that turned sharing off stops the next PUT, not only the next session)
179
+ flags: () => ql.flags, enabled: () => state.sharing,
95
180
  ...(ql.sharingBitrate ? { bitrate: ql.sharingBitrate } : {}),
96
181
  });
97
182
  ctl.outbox = mod.outbox || ctl.outbox;
@@ -105,7 +190,7 @@ export function enableSharing(ql, o = {}) {
105
190
  };
106
191
  s.finish = () => {
107
192
  const k = finish();
108
- if (starting) begin().then((x) => x && x.finish()).then(() => { if (state.takeThanks() && typeof o.ask !== 'function') import('./element.js').then((m) => m.QuranLabConsent.thank({ lang })); });
193
+ if (starting) begin().then((x) => x && x.finish()).then(() => { if (state.takeThanks() && typeof o.ask !== 'function') import('./element.js').then((m) => m.QuranLabConsent.thank({ lang, wording, theme })); });
109
194
  starting = null;
110
195
  return k;
111
196
  };
@@ -1,88 +1,150 @@
1
1
  // The sharing state machine: when to ask,
2
2
  // how often to re-ask, the guardian variant, settings, the thank-you. Plain JS, no DOM.
3
+ //
4
+ // Asking, since 2.0.1:
5
+ // - the sheet is shown at most MAX_ASKS times per install, ever (the first ask and, after "Not now", re-asks
6
+ // REASK_GAP sessions apart); the count is of asks handed out (the SDK's sheet or an app's own UI), stored, and
7
+ // carried over from 2.0.0's `asks`;
8
+ // - a yes ends asking for good: the grant keeps the consent version (and, for an app's own wording, the text hash)
9
+ // recorded when the learner agreed, across copy decks, wordings and SDK upgrades. There is no version re-ask
10
+ // and nothing pauses sharing when the wording in force changes;
11
+ // - turning sharing off ends asking for good; only the settings switch (setSharing) turns it on or off then.
12
+ //
13
+ // Stored under SHARING_STATE_KEY, the same object 2.0.0 stored: { v: 1, consent: 'unknown' | 'granted' |
14
+ // 'declined' | 'final', asks, lastAskSession, sessions, thanked, decidedAt, consentVersion, guardian } plus
15
+ // consentTextSha256 (2.0.1). 2.0.0's `versionAsked` is left as it is and no longer read.
3
16
 
4
17
  export const SHARING_STATE_KEY = 'sharing.v1';
5
18
  const FRESH = { v: 1, consent: 'unknown', asks: 0, lastAskSession: 0, sessions: 0, thanked: false, decidedAt: null,
6
- consentVersion: null, guardian: false };
7
- /** Asks in all: the first and at most two re-asks. */
19
+ consentVersion: null, consentTextSha256: null, guardian: false };
20
+ /** Sheets shown in all, ever: the first ask and at most two re-asks after "Not now". */
8
21
  export const MAX_ASKS = 3;
9
22
  /** Sessions between asks. */
10
23
  export const REASK_GAP = 7;
24
+ /** The consent version of the copy deck the web SDK 2.0.0 bundled: a 2.0.0 grant stored without a version was
25
+ * given to it. */
26
+ export const LEGACY_CONSENT_VERSION = 'share-2026-11-07';
11
27
 
12
28
  export class SharingState {
13
29
  /**
14
30
  * @param {{ get(k): any, set(k, v): void }} state persistence (per origin in browsers)
15
- * @param {{ enabled: () => boolean, consentVersion?: string, audience?: string, clock?: () => number }} o
31
+ * @param {{ enabled: () => boolean, consentVersion?: string, consentTextSha256?: string|null, audience?: string,
32
+ * clock?: () => number }} o consentVersion: the version a new grant records (the deck's, or custom-...
33
+ * for an app's own wording, with its text hash)
16
34
  */
17
35
  constructor(state, o = {}) {
18
36
  this.state = state;
19
37
  this.enabledFn = o.enabled || (() => false);
20
38
  this.consentVersion = o.consentVersion || null;
39
+ this.consentTextSha256 = o.consentTextSha256 || null;
21
40
  this.audience = o.audience || 'general';
22
41
  this.clock = o.clock || Date.now;
23
42
  this.listeners = new Set();
24
43
  }
25
- get s() { return { ...FRESH, ...(this.state.get(SHARING_STATE_KEY) || {}) }; }
44
+ get s() {
45
+ const s = { ...FRESH, ...(this.state.get(SHARING_STATE_KEY) || {}) };
46
+ if (!Number.isFinite(s.asks) || s.asks < 0) s.asks = 0;
47
+ // a 2.0.0 grant without a version was given to the deck 2.0.0 bundled
48
+ if (s.consent === 'granted' && !s.consentVersion) { s.consentVersion = LEGACY_CONSENT_VERSION; s.consentTextSha256 = null; }
49
+ return s;
50
+ }
26
51
  _put(s) { this.state.set(SHARING_STATE_KEY, s); for (const f of this.listeners) f(s); }
27
52
  onChange(f) { this.listeners.add(f); return () => this.listeners.delete(f); }
53
+ _today() { return new Date(this.clock()).toISOString().slice(0, 10); }
28
54
 
29
- /** Uploads may run: enabled by the app and the signed config, and the learner said yes (for the
30
- * current consent version). */
55
+ /** Uploads may run: enabled by the app and the signed config, and the learner said yes (to whichever
56
+ * version: a grant is never paused by a newer deck or wording). */
31
57
  get sharing() {
32
- const s = this.s;
33
- return this.enabledFn() && s.consent === 'granted';
58
+ return this.enabledFn() && this.s.consent === 'granted';
34
59
  }
35
60
  get guardianVariant() { return this.audience === 'children'; }
36
61
 
62
+ /** Asking has not ended: no yes, not turned off, and fewer than MAX_ASKS sheets shown. */
63
+ get asking() {
64
+ const s = this.s;
65
+ return (s.consent === 'unknown' || s.consent === 'declined') && s.asks < MAX_ASKS;
66
+ }
67
+
68
+ /** The version a NEW grant records (an app changed its wording, or a new deck). A learner who already agreed
69
+ * keeps the version recorded then, keeps sharing and is not asked again. */
70
+ setConsent(version, textSha256 = null) {
71
+ this.consentVersion = version || null;
72
+ this.consentTextSha256 = textSha256 || null;
73
+ for (const f of this.listeners) f(this.s);
74
+ }
75
+
76
+ /** The consent version the current grant was given to (null when not granted): what each session of this
77
+ * grant sends as consent_version. */
78
+ get grantVersion() { const s = this.s; return s.consent === 'granted' ? s.consentVersion : null; }
79
+
80
+ /** The hash of the custom wording the current grant was given to (null for a deck's own wording, or when
81
+ * not sharing): what each session of this grant sends as consent_text_sha256. */
82
+ get grantTextSha256() {
83
+ if (!this.sharing) return null;
84
+ const s = this.s;
85
+ return s.consentVersion && s.consentVersion.startsWith('custom-') ? s.consentTextSha256 || null : null;
86
+ }
87
+
88
+ _ask(s) {
89
+ if (!(s.consent === 'unknown' || s.consent === 'declined') || s.asks >= MAX_ASKS) return null;
90
+ if (s.asks > 0 && s.sessions - s.lastAskSession < REASK_GAP) return null;
91
+ return s.consent === 'declined' ? 'reask' : 'first';
92
+ }
93
+
37
94
  /**
38
- * A session ended. Returns null, or the ask to show: { variant: 'first' | 'reask' | 'version',
39
- * guardian: boolean }. The caller shows the sheet and calls answer().
95
+ * A session ended. Returns null, or the ask to show: { variant: 'first' | 'reask', guardian: boolean }
96
+ * (counted as shown). The caller shows the sheet and calls answer().
40
97
  * @param {{ feedback: boolean }} o feedback: the learner was shown the model's verdicts
41
98
  */
42
99
  sessionCompleted({ feedback = false } = {}) {
43
100
  const s = this.s;
44
101
  s.sessions += 1;
45
- let ask = null;
46
- if (this.enabledFn() && feedback) {
47
- if (s.consent === 'unknown') ask = 'first';
48
- else if (s.consent === 'declined' && s.asks < MAX_ASKS && s.sessions - s.lastAskSession >= REASK_GAP) ask = 'reask';
49
- else if (s.consent === 'granted' && this.consentVersion && s.consentVersion && s.consentVersion !== this.consentVersion && !s.versionAsked) {
50
- ask = 'version'; s.versionAsked = this.consentVersion;
51
- }
52
- }
53
- if (ask) { s.asks += ask === 'version' ? 0 : 1; s.lastAskSession = s.sessions; }
102
+ const ask = this.enabledFn() && feedback ? this._ask(s) : null;
103
+ if (ask) { s.asks += 1; s.lastAskSession = s.sessions; }
54
104
  this._put(s);
55
105
  return ask ? { variant: ask, guardian: this.guardianVariant } : null;
56
106
  }
57
107
 
58
- /** The app asks now (its own timing); the disclosure lines stay the SDK's. */
59
- askNow() { return { variant: this.s.consent === 'unknown' ? 'first' : 'reask', guardian: this.guardianVariant }; }
108
+ /** The app asks now (its own timing; the disclosure lines stay the SDK's). Counted as shown; null once asking
109
+ * has ended (a yes, sharing turned off, or MAX_ASKS sheets shown). The REASK_GAP spacing is the app's to keep. */
110
+ askNow() {
111
+ const s = this.s;
112
+ if (!(s.consent === 'unknown' || s.consent === 'declined') || s.asks >= MAX_ASKS) return null;
113
+ const variant = s.consent === 'declined' ? 'reask' : 'first';
114
+ s.asks += 1; s.lastAskSession = s.sessions;
115
+ this._put(s);
116
+ return { variant, guardian: this.guardianVariant };
117
+ }
60
118
 
61
119
  /**
62
- * The learner answered the sheet. yes: share; no (or dismissed): not now (final after the last ask).
63
- * guardianConfirmed must be true for a yes in the guardian variant.
120
+ * The learner answered the sheet. yes: share, recording the version in force now; no (or dismissed): not now
121
+ * (final after the last ask). guardianConfirmed must be true for a yes in the guardian variant. After a yes or
122
+ * a switch turned off this changes nothing (the recorded grant stays as it is): use setSharing.
64
123
  */
65
124
  answer(yes, { guardianConfirmed = false } = {}) {
66
125
  const s = this.s;
126
+ if (s.consent === 'granted' || s.consent === 'final') return s.consent;
67
127
  if (yes) {
68
128
  if (this.guardianVariant && !guardianConfirmed) throw new Error('the guardian variant needs the parent or guardian confirmation');
69
- Object.assign(s, { consent: 'granted', decidedAt: new Date(this.clock()).toISOString().slice(0, 10), consentVersion: this.consentVersion,
70
- guardian: !!guardianConfirmed });
129
+ Object.assign(s, { consent: 'granted', decidedAt: this._today(), consentVersion: this.consentVersion,
130
+ consentTextSha256: this.consentTextSha256, guardian: !!guardianConfirmed });
71
131
  } else {
72
132
  s.consent = s.asks >= MAX_ASKS ? 'final' : 'declined';
73
- s.decidedAt = new Date(this.clock()).toISOString().slice(0, 10);
133
+ s.decidedAt = this._today();
74
134
  }
75
135
  this._put(s);
76
136
  return s.consent;
77
137
  }
78
138
 
79
- /** The settings switch. Off is final until the learner turns it on again here. */
139
+ /** The settings switch. On records the version in force now (an existing grant keeps its own). Off is final
140
+ * until the learner turns it on again here, and is never asked about again. */
80
141
  setSharing(on, { guardianConfirmed = false } = {}) {
81
142
  const s = this.s;
82
143
  if (on) {
144
+ if (s.consent === 'granted') return;
83
145
  if (this.guardianVariant && !guardianConfirmed) throw new Error('the guardian variant needs the parent or guardian confirmation');
84
- Object.assign(s, { consent: 'granted', consentVersion: this.consentVersion, guardian: !!guardianConfirmed,
85
- decidedAt: new Date(this.clock()).toISOString().slice(0, 10) });
146
+ Object.assign(s, { consent: 'granted', consentVersion: this.consentVersion, consentTextSha256: this.consentTextSha256,
147
+ guardian: !!guardianConfirmed, decidedAt: this._today() });
86
148
  } else {
87
149
  s.consent = 'final';
88
150
  }
@@ -0,0 +1,31 @@
1
+ // The look of the SDK's sharing sheets (consent, guardian, thanks, settings): the same theme keys as the Swift
2
+ // SDK's SharingTheme, set as CSS custom properties on each element (an app can also set the properties in its
3
+ // own CSS). A theme changes colours, corner radius and fonts, never the words or the consent version.
4
+
5
+ /** Theme key -> the CSS custom property it sets. */
6
+ export const THEME_PROPERTIES = Object.freeze({
7
+ accent: '--ql-accent', accentText: '--ql-accent-fg', background: '--ql-bg', text: '--ql-fg', secondaryText: '--ql-muted',
8
+ cornerRadius: '--ql-radius', titleFont: '--ql-title-font', bodyFont: '--ql-font',
9
+ });
10
+
11
+ /** A theme as CSS custom properties ({ '--ql-accent': '#2a3b8f', ... }); a number cornerRadius is in px. An
12
+ * unknown key or a value that is not a plain CSS value throws a TypeError; null or {} is the default look. */
13
+ export function themeProperties(theme) {
14
+ if (theme === null || theme === undefined) return {};
15
+ if (typeof theme !== 'object' || Array.isArray(theme)) throw new TypeError('theme: an object');
16
+ const out = {};
17
+ for (const [k, v] of Object.entries(theme)) {
18
+ if (!(k in THEME_PROPERTIES)) throw new TypeError(`theme: unknown key ${k} (${Object.keys(THEME_PROPERTIES).join(', ')})`);
19
+ if (v === null || v === undefined || v === '') continue;
20
+ if (k === 'cornerRadius' && typeof v === 'number') { out[THEME_PROPERTIES[k]] = `${v}px`; continue; }
21
+ if (typeof v !== 'string' || /[;{}<>]/.test(v)) throw new TypeError(`theme: ${k} must be a CSS value`);
22
+ out[THEME_PROPERTIES[k]] = v;
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /** Sets a theme's properties on an element (its shadow tree inherits them). */
28
+ export function applyTheme(el, theme) {
29
+ for (const [k, v] of Object.entries(themeProperties(theme))) el.style.setProperty(k, v);
30
+ return el;
31
+ }