@quran-lab/tajweedformer 0.0.0-stage → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +148 -0
  3. package/README.md +121 -2
  4. package/dist/tajweedformer-opus.mjs +2 -0
  5. package/dist/tajweedformer-opus.wasm +0 -0
  6. package/dist/tajweedformer-scorer.mjs +2 -0
  7. package/dist/tajweedformer-scorer.wasm +0 -0
  8. package/dist/tajweedformer.mjs +2 -0
  9. package/dist/tajweedformer.wasm +0 -0
  10. package/docs/api.md +167 -0
  11. package/examples/browser.html +69 -0
  12. package/examples/node.mjs +37 -0
  13. package/package.json +81 -6
  14. package/web/consent/copy.js +51 -0
  15. package/web/consent/decks.js +470 -0
  16. package/web/consent/element.js +170 -0
  17. package/web/consent/index.d.ts +128 -0
  18. package/web/consent/index.js +239 -0
  19. package/web/consent/state.js +162 -0
  20. package/web/consent/theme.js +31 -0
  21. package/web/consent/wording.js +126 -0
  22. package/web/index.d.ts +4 -0
  23. package/web/index.js +10 -0
  24. package/web/platform/client.js +484 -0
  25. package/web/platform/contributor-key.js +112 -0
  26. package/web/platform/index.d.ts +148 -0
  27. package/web/platform/index.js +8 -0
  28. package/web/platform/storage.js +50 -0
  29. package/web/platform/telemetry.js +314 -0
  30. package/web/platform/update-notice.js +60 -0
  31. package/web/scorer.d.ts +4 -0
  32. package/web/scorer.js +10 -0
  33. package/web/sharing/bitrate.js +69 -0
  34. package/web/sharing/encoder.js +176 -0
  35. package/web/sharing/index.d.ts +262 -0
  36. package/web/sharing/index.js +287 -0
  37. package/web/sharing/oggopus.js +325 -0
  38. package/web/sharing/outbox.js +670 -0
  39. package/web/sharing/resampler.js +122 -0
  40. package/web/sharing/uploader.js +178 -0
  41. package/web/tajweed/decode.js +213 -0
  42. package/web/tajweed/delivery.js +317 -0
  43. package/web/tajweed/engine.js +600 -0
  44. package/web/tajweed/heads.js +73 -0
  45. package/web/tajweed/index.d.ts +737 -0
  46. package/web/tajweed/index.js +12 -0
  47. package/web/tajweed/manifest.js +452 -0
  48. package/web/tajweed/node.d.ts +3 -0
  49. package/web/tajweed/node.js +59 -0
  50. package/web/tajweed/settings.js +483 -0
  51. package/web/tajweed/tajweedformer.js +369 -0
@@ -0,0 +1,170 @@
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 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.
7
+ //
8
+ // import '@quran-lab/tajweedformer/consent'; // defines the elements
9
+ // const yes = await QuranLabConsent.ask({ lang: 'ar', variant: 'first', guardian: false, theme: { accent: '#0b6e4f' } });
10
+
11
+ import { copyFor } from './copy.js';
12
+ import { applyTheme } from './theme.js';
13
+
14
+ const STYLE = `
15
+ :host { all: initial; }
16
+ dialog { box-sizing: border-box; max-inline-size: min(34rem, calc(100vw - 2rem)); inline-size: 100%; border: none;
17
+ max-block-size: calc(100dvh - 2rem); overflow: auto; overscroll-behavior: contain;
18
+ padding: 1.5rem; border-radius: var(--ql-radius, 16px); background: var(--ql-bg, #fdfcf8); color: var(--ql-fg, #1c1b19);
19
+ font: 400 1rem/1.55 var(--ql-font, system-ui, -apple-system, "Segoe UI", "Noto Sans", "Noto Naskh Arabic", sans-serif);
20
+ box-shadow: 0 20px 60px rgb(0 0 0 / .25); }
21
+ dialog::backdrop { background: rgb(0 0 0 / .45); }
22
+
23
+ h2 { margin: 0 0 .75rem; font-size: 1.25rem; line-height: 1.3; font-weight: 650; font-family: var(--ql-title-font, inherit); }
24
+ p { margin: 0 0 .75rem; }
25
+ .muted { color: var(--ql-muted, inherit); opacity: .85; font-size: .95rem; }
26
+ ul { margin: 0 0 1rem; padding-inline-start: 1.1rem; }
27
+ li { margin-block-end: .35rem; }
28
+ label.guardian { display: flex; gap: .6rem; align-items: flex-start; margin: .5rem 0 1rem; font-weight: 600; }
29
+ label.guardian input { inline-size: 1.2rem; block-size: 1.2rem; margin: .15rem 0 0; accent-color: var(--ql-accent, #2a3b8f); }
30
+ .buttons { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
31
+ @media (max-width: 420px) { .buttons { grid-template-columns: 1fr; } }
32
+ button { font: inherit; font-weight: 600; min-block-size: 2.9rem; border-radius: calc(var(--ql-radius, 16px) * .6); cursor: pointer;
33
+ padding: .6rem 1rem; border: 2px solid var(--ql-accent, #2a3b8f); }
34
+ button.primary { background: var(--ql-accent, #2a3b8f); color: var(--ql-accent-fg, #fff); }
35
+ button.secondary { background: transparent; color: inherit; }
36
+ button:disabled { opacity: .5; cursor: not-allowed; }
37
+ button:focus-visible, input:focus-visible { outline: 3px solid var(--ql-accent, #2a3b8f); outline-offset: 2px; }
38
+ .row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: .6rem 0; }
39
+ .code { font-family: ui-monospace, monospace; font-size: .85rem; word-break: break-all; }
40
+ @media (prefers-color-scheme: dark) {
41
+ dialog { background: var(--ql-bg, #1c1b19); color: var(--ql-fg, #f3f1ea); }
42
+ button { border-color: var(--ql-accent, #a9b6ff); }
43
+ button.primary { background: var(--ql-accent, #a9b6ff); color: var(--ql-accent-fg, #10142e); }
44
+ label.guardian input, button:focus-visible { accent-color: var(--ql-accent, #a9b6ff); outline-color: var(--ql-accent, #a9b6ff); }
45
+ }
46
+ h2:focus { outline: none; }
47
+ `;
48
+
49
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
50
+
51
+ const Base = globalThis.HTMLElement || class {};
52
+
53
+ export class QuranLabConsent extends Base {
54
+ /**
55
+ * Show the sheet and resolve with the learner's answer (true = share). Dismissing (Escape) is "Not now".
56
+ * @param {{ lang?: string, variant?: 'first'|'reask'|'version', guardian?: boolean, parent?: Element,
57
+ * wording?: Record<string, string>|null, theme?: object|null }} o
58
+ * @returns {Promise<{ yes: boolean, guardianConfirmed: boolean }>}
59
+ */
60
+ static ask(o = {}) {
61
+ const el = document.createElement('quranlab-consent');
62
+ (o.parent || document.body).appendChild(el);
63
+ return el.show(o).finally(() => el.remove());
64
+ }
65
+
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);
69
+ const t = c.t;
70
+ const root = this.shadowRoot || this.attachShadow({ mode: 'open' });
71
+ const lines = (guardian ? ['sheet.what', 'sheet.who', 'sheet.control'] : ['sheet.mistakes', 'sheet.what', 'sheet.who', 'sheet.control'])
72
+ .map((k) => `<li>${esc(t(k))}</li>`).join('');
73
+ const hadith = t('sheet.hadith') ? `<p class="muted">${esc(t('sheet.hadith'))}<br><small>${esc(t('sheet.hadith_ref'))}</small></p>` : '';
74
+ root.innerHTML = `<style>${STYLE}</style>
75
+ <dialog part="sheet" lang="${esc(c.lang)}" dir="${esc(c.dir)}" aria-labelledby="t" aria-describedby="b">
76
+ <h2 id="t" part="title" tabindex="-1">${esc(t(guardian ? 'sheet.guardian.title' : 'sheet.title'))}</h2>
77
+ <div id="b" part="body">
78
+ ${guardian ? `<p part="guardian">${esc(t('sheet.guardian.body'))}</p>` : ''}
79
+ ${variant === 'reask' ? `<p>${esc(t('reask'))}</p>` : ''}
80
+ ${guardian ? '' : `<p>${esc(t('sheet.body'))}</p>`}
81
+ ${hadith}
82
+ <ul>${lines}</ul>
83
+ </div>
84
+ ${guardian ? `<label class="guardian"><input type="checkbox" id="g"> <span>${esc(t('sheet.guardian.confirm'))}</span></label>` : ''}
85
+ <div class="buttons">
86
+ <button class="primary" part="primary" id="yes" ${guardian ? 'disabled' : ''}>${esc(t(guardian ? 'sheet.guardian.share' : 'sheet.share'))}</button>
87
+ <button class="secondary" part="secondary" id="no">${esc(t('sheet.not_now'))}</button>
88
+ </div>
89
+ </dialog>`;
90
+ const d = root.querySelector('dialog');
91
+ const g = root.querySelector('#g');
92
+ const yes = root.querySelector('#yes');
93
+ if (g) g.addEventListener('change', () => { yes.disabled = !g.checked; });
94
+ return new Promise((resolve) => {
95
+ let answered = false;
96
+ const done = (v) => { if (answered) return; answered = true; try { d.close(); } catch (_) { /* closed */ } resolve({ yes: v, guardianConfirmed: !!(g && g.checked) }); };
97
+ yes.addEventListener('click', () => done(true));
98
+ root.querySelector('#no').addEventListener('click', () => done(false));
99
+ d.addEventListener('close', () => done(false));
100
+ d.addEventListener('cancel', () => done(false));
101
+ if (typeof d.showModal === 'function') d.showModal(); else d.setAttribute('open', '');
102
+ // focus the title, not a button: neither choice is pre-selected
103
+ root.querySelector('#t').focus();
104
+ });
105
+ }
106
+
107
+ /** The one-time thank-you, as a small dismissable note. */
108
+ static thank({ lang, parent, ms = 6000, wording = null, theme = null } = {}) {
109
+ const c = copyFor(lang || (globalThis.navigator && navigator.language) || 'en', wording);
110
+ const el = document.createElement('div');
111
+ el.setAttribute('role', 'status');
112
+ el.lang = c.lang; el.dir = c.dir;
113
+ el.textContent = c.t('thanks');
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);
116
+ (parent || document.body).appendChild(el);
117
+ setTimeout(() => el.remove(), ms);
118
+ return el;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * <quranlab-sharing-settings>: the switch, "What is shared", "Delete what I shared" and the
124
+ * contribution code. Set `.controller` to the sharing controller (web/consent/index.js).
125
+ */
126
+ export class QuranLabSharingSettings extends Base {
127
+ set controller(c) { this._c = c; this.render(); }
128
+ get controller() { return this._c; }
129
+ connectedCallback() { this.render(); }
130
+ render() {
131
+ if (!this._c) return;
132
+ const c = copyFor(this.getAttribute('lang') || this._c.lang, this._c.wording || null);
133
+ applyTheme(this, this._c.theme || null);
134
+ const t = c.t;
135
+ const root = this.shadowRoot || this.attachShadow({ mode: 'open' });
136
+ const on = this._c.state.sharing;
137
+ const code = this._c.recoveryCode();
138
+ root.innerHTML = `<style>${STYLE} :host{display:block} section{font:400 1rem/1.5 var(--ql-font,system-ui,sans-serif);color:inherit}</style>
139
+ <section lang="${esc(c.lang)}" dir="${esc(c.dir)}">
140
+ <div class="row"><label for="s">${esc(t('settings.toggle'))}</label>
141
+ <input type="checkbox" role="switch" id="s" ${on ? 'checked' : ''} aria-checked="${on}"></div>
142
+ ${this._c.state.guardianVariant ? `<label class="guardian"><input type="checkbox" id="g"> <span>${esc(t('sheet.guardian.confirm'))}</span></label>` : ''}
143
+ <details><summary>${esc(t('settings.what'))}</summary><p>${esc(t('what.detail'))}</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>` : ''}
145
+ <div class="row"><button class="secondary" id="del">${esc(t('settings.delete'))}</button></div>
146
+ <p class="muted" id="msg" role="status"></p>
147
+ </section>`;
148
+ const rc = root.querySelector('#rc');
149
+ // the code is put in the page only when the learner opens it, never in the initial markup
150
+ if (rc) rc.addEventListener('toggle', () => { root.querySelector('#code').textContent = rc.open ? this._c.recoveryCode() : ''; });
151
+ const msg = root.querySelector('#msg');
152
+ root.querySelector('#s').addEventListener('change', (e) => {
153
+ const g = root.querySelector('#g');
154
+ try { this._c.setSharing(e.target.checked, { guardianConfirmed: !!(g && g.checked) }); } catch (_) { e.target.checked = false; }
155
+ this.render();
156
+ });
157
+ root.querySelector('#del').addEventListener('click', async () => {
158
+ if (!globalThis.confirm || globalThis.confirm(t('delete.confirm'))) {
159
+ const ok = await this._c.deleteShared().then(() => true, () => false);
160
+ msg.textContent = ok ? t('delete.done') : t('delete.failed.network');
161
+ if (ok) this.render();
162
+ }
163
+ });
164
+ }
165
+ }
166
+
167
+ if (globalThis.customElements && !globalThis.customElements.get('quranlab-consent')) {
168
+ globalThis.customElements.define('quranlab-consent', QuranLabConsent);
169
+ globalThis.customElements.define('quranlab-sharing-settings', QuranLabSharingSettings);
170
+ }
@@ -0,0 +1,128 @@
1
+ // Types for @quran-lab/tajweedformer/consent: the recitation sharing experience (SHARING_EXPERIENCE.md).
2
+ import type { QuranLab, StateStore } from '../platform/index.js';
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. */
6
+ export type AskVariant = 'first' | 'reask' | 'version';
7
+ export interface Ask { variant: AskVariant; guardian: boolean }
8
+ export interface Answer { yes: boolean; guardianConfirmed?: boolean }
9
+
10
+ /** sheets shown in all per install, ever (the first ask and at most two re-asks after "Not now") */
11
+ export declare const MAX_ASKS: 3;
12
+ /** sessions between asks */
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';
18
+
19
+ export declare class SharingState {
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) */
22
+ readonly sharing: boolean;
23
+ readonly guardianVariant: boolean;
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 */
36
+ sessionCompleted(o?: { feedback?: boolean }): Ask | null;
37
+ /** the app's own timing: an ask counted as shown, or null once asking has ended */
38
+ askNow(): Ask | null;
39
+ answer(yes: boolean, o?: { guardianConfirmed?: boolean }): 'granted' | 'declined' | 'final';
40
+ setSharing(on: boolean, o?: { guardianConfirmed?: boolean }): void;
41
+ takeThanks(): boolean;
42
+ onChange(f: (s: object) => void): () => void;
43
+ }
44
+
45
+ /** A copy deck (quranlab.sharing-copy/1) for one language, with any own wording applied. */
46
+ export interface Deck {
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[];
52
+ hadithSource: { collection: string; number: number; url: string; translation_source: string | null; translated: boolean } | null;
53
+ /** a string by key: the app's own wording, else this deck's, else English */
54
+ t(key: string): string;
55
+ /** the plural impact line for real shared minutes */
56
+ minutes(n: number): string;
57
+ }
58
+ export declare const EN: Readonly<{ format: string; version: string; lang: string; dir: string; consent_version: string; strings: Record<string, unknown> }>;
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 }
85
+ export declare function registerDeck(lang: string, deck: { format: 'quranlab.sharing-copy/1'; [k: string]: unknown }): void;
86
+ export declare function languages(): string[];
87
+
88
+ export interface SharingController {
89
+ lang: string;
90
+ state: SharingState;
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';
105
+ sessionCompleted(o?: { feedback?: boolean }): Promise<Answer | null>;
106
+ setSharing(on: boolean, o?: { guardianConfirmed?: boolean }): void;
107
+ /** "Delete what I shared": signed with this install's device key (no app credential can delete) */
108
+ deleteShared(): Promise<{ accepted: boolean; sessionsLeft: number | null }>;
109
+ /** "Show my recovery code": for the learner only */
110
+ recoveryCode(): string | null;
111
+ rotateContributor(): Promise<void>;
112
+ impactLine(): Promise<string | null>;
113
+ }
114
+
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;
118
+ export declare function deleteWithKey(ql: QuranLab, key: import('../platform/index.js').ContributorKey): Promise<{ accepted: boolean; sessionsLeft: number | null }>;
119
+
120
+ /** The web components (import '@quran-lab/tajweedformer/consent/element' or let enableSharing load them). */
121
+ export declare class QuranLabConsent extends HTMLElement {
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 }>;
125
+ }
126
+ export declare class QuranLabSharingSettings extends HTMLElement {
127
+ controller: SharingController;
128
+ }
@@ -0,0 +1,239 @@
1
+ // Recitation sharing for the web product: the SDK's own consent sheet and settings (web components),
2
+ // the state machine (when to ask, re-ask limits, guardian variant), and the wiring that shares the
3
+ // WHOLE session while the SDK listens once the learner said yes (the 2.0.0 design; the upload itself is
4
+ // web/sharing/, loaded lazily so it costs nothing until sharing is on).
5
+ //
6
+ // import { QuranLab } from '@quran-lab/tajweedformer/platform';
7
+ // import { enableSharing } from '@quran-lab/tajweedformer/consent';
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)
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.
20
+
21
+ import { SharingState } from './state.js';
22
+ import { copyFor, registerDeck, languages, EN } from './copy.js';
23
+ import { validateWording, deriveConsent } from './wording.js';
24
+ import { themeProperties } from './theme.js';
25
+ import { contributorKey, forgetContributorKey, deletionMessage, b64url, newRecoveryCode, recoveryHash } from '../platform/contributor-key.js';
26
+ import { SDK_VERSION } from '../platform/client.js';
27
+
28
+ export { SharingState, MAX_ASKS, REASK_GAP, LEGACY_CONSENT_VERSION, SHARING_STATE_KEY } from './state.js';
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';
32
+
33
+ /**
34
+ * Turn on the sharing experience for a configured platform client.
35
+ * @param {import('../platform/client.js').QuranLab} ql a configured client (the signed config decides whether sharing is offered)
36
+ * @param {object} [o]
37
+ * @param {string} [o.lang] the deck's language (default: the browser's)
38
+ * @param {'auto'|((ask: object) => Promise<{yes: boolean, guardianConfirmed?: boolean}|null|undefined>)} [o.ask='auto']
39
+ * auto: the SDK shows <quranlab-consent> when the app is idle after a session with feedback; a
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).
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)
46
+ */
47
+ export function enableSharing(ql, o = {}) {
48
+ const lang = o.lang || (globalThis.navigator && navigator.language) || 'en';
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
58
+ const ctl = {
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
+ },
99
+ /** The ask to show after a session (null when none is due); auto mode shows it. */
100
+ async sessionCompleted({ feedback = true } = {}) {
101
+ const ask = state.sessionCompleted({ feedback });
102
+ if (!ask) return null;
103
+ const own = typeof o.ask === 'function';
104
+ const show = own ? o.ask : async (a) => {
105
+ const { QuranLabConsent } = await import('./element.js');
106
+ return QuranLabConsent.ask({ lang, variant: a.variant, guardian: a.guardian, wording, theme });
107
+ };
108
+ const wait = () => new Promise((r) => { const tick = () => (!o.idle || o.idle() ? r() : setTimeout(tick, 1000)); tick(); });
109
+ await wait();
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 });
116
+ return a;
117
+ },
118
+ /** The settings switch: off stops uploads at once and deletes the unsent queue. */
119
+ setSharing(on, opts) {
120
+ state.setSharing(on, opts);
121
+ if (!on && ctl.outbox) ctl.outbox.stop();
122
+ },
123
+ /** "Delete what I shared": challenge-response signed with this install's contributor key (P2.11). */
124
+ async deleteShared() {
125
+ const contributor = ql.contributor();
126
+ const key = await contributorKey(contributor, { create: false });
127
+ if (!key) { ql.resetContributor(); return { deleted: 0, objects: 0 }; } // never shared from here
128
+ const r = await deleteWithKey(ql, key);
129
+ await forgetContributorKey(contributor);
130
+ ql.resetContributor();
131
+ ql.state.set('sharing.recovery', null);
132
+ if (ctl.outbox) ctl.outbox.stop();
133
+ return r;
134
+ },
135
+ /** "Show my recovery code": for the learner's eyes only (call it from a confirm step in your UI). */
136
+ recoveryCode() { return ql.state.get('sharing.recovery'); },
137
+ /** A rejected contributor (403 contributor_revoked, 409 contributor_key_mismatch): new id, key, code. */
138
+ async rotateContributor() {
139
+ await forgetContributorKey(ql.contributor());
140
+ ql.resetContributor();
141
+ ql.state.set('sharing.recovery', null);
142
+ },
143
+ /** A quiet impact line from the backend's real numbers, or null. */
144
+ async impactLine() {
145
+ const s = await ql.contributionSummary();
146
+ if (!s || !s.you || !s.you.minutes) return null;
147
+ return ctl.copy.minutes(Math.floor(s.you.minutes));
148
+ },
149
+ };
150
+ ql.sharing = ctl;
151
+ // every session the SDK listens to, shared in full while consent is on (P2.9)
152
+ ctl.streamStarted = (tf, s) => {
153
+ if (!state.sharing) return;
154
+ let session = null, starting = null;
155
+ const begin = () => starting || (starting = import('../sharing/index.js').then(async (mod) => {
156
+ const key = await contributorKey(ql.contributor());
157
+ let code = ql.state.get('sharing.recovery');
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 || {};
169
+ session = await mod.createSharingSession({
170
+ api: ql.apiUrl, fetch: ql.fetch, tokenProvider, headers: ql.origin ? { origin: ql.origin } : {},
171
+ contributor: ql.contributor(), contributorKeyRaw: key.publicKeyRaw, recoveryHash: await recoveryHash(code), inputSampleRate: tf.sampleRate, inputRoute: 'unknown',
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,
180
+ ...(ql.sharingBitrate ? { bitrate: ql.sharingBitrate } : {}),
181
+ });
182
+ ctl.outbox = mod.outbox || ctl.outbox;
183
+ return session;
184
+ }).catch((e) => { ql.reportIssue('other', 'sharing_start:' + (e.code || 'error')); return null; }));
185
+ const push = s.pushAudio, finish = s.finish, free = s.free;
186
+ s.pushAudio = (pcm) => {
187
+ const k = push(pcm);
188
+ if (state.sharing) { const copy = pcm.slice(); begin().then((x) => x && x.push(copy)); }
189
+ return k;
190
+ };
191
+ s.finish = () => {
192
+ const k = finish();
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 })); });
194
+ starting = null;
195
+ return k;
196
+ };
197
+ s.free = () => { if (starting) begin().then((x) => x && x.finish()); starting = null; return free(); };
198
+ };
199
+ // the moment to ask: after a scored session (the learner saw feedback)
200
+ const scored = ql.scored.bind(ql);
201
+ ql.scored = (tf, r) => { scored(tf, r); ctl.sessionCompleted({ feedback: true }).catch(() => {}); };
202
+ return ctl;
203
+ }
204
+
205
+ /**
206
+ * "Delete what I shared" (PLATFORM_CLIENT.md section 9.1): a challenge for this app and contributor, the
207
+ * exact message signed with the device key (ECDSA P-256, SHA-256, r||s), then the deletion request. No
208
+ * app key or token is sent to these routes (the server refuses them). 202 is the answer whatever the
209
+ * signature (no enumeration); the summary then confirms. Returns { accepted, sessionsLeft }.
210
+ */
211
+ export async function deleteWithKey(ql, key) {
212
+ const app = `${ql.platform}:${ql.bundleId}`;
213
+ const contributor = key.contributor;
214
+ const post = async (path, body) => {
215
+ const h = { 'content-type': 'application/json', ...(ql.origin ? { origin: ql.origin } : {}) };
216
+ const r = await ql.fetch(ql.apiUrl + path, { method: 'POST', headers: h, body: JSON.stringify(body) });
217
+ let j = {};
218
+ try { j = await r.json(); } catch (_) { /* none */ }
219
+ if (r.status >= 400) { const e = new Error(`${r.status} ${j.error || ''}`); e.status = r.status; e.code = j.error; throw e; }
220
+ return j;
221
+ };
222
+ let lastErr = null;
223
+ for (let attempt = 0; attempt < 2; attempt++) {
224
+ const { nonce } = await post('/v1/contributions/challenge', { app, contributor });
225
+ const timestamp = Math.floor(ql.clock() / 1000);
226
+ const sig = await key.sign(deletionMessage({ app, contributor, nonce, timestamp }));
227
+ try {
228
+ await post('/v1/contributions/delete', { app, contributor, nonce, timestamp, action: 'delete-all', signature: b64url(sig) });
229
+ const s = await ql.contributionSummary();
230
+ return { accepted: true, sessionsLeft: s && s.you ? s.you.sessions : null };
231
+ } catch (e) {
232
+ lastErr = e;
233
+ if (!(e.code === 'nonce_invalid' || e.code === 'timestamp_invalid')) throw e;
234
+ }
235
+ }
236
+ throw lastErr;
237
+ }
238
+
239
+ export { QuranLabConsent, QuranLabSharingSettings } from './element.js';