@supersuit/suggest-a-change 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 (2026-10-26)
4
+
5
+ - **An app can turn any built-in context item off.** `context: { items?, precise?, off: ContextKey[] }`, with keys `taps`, `pages`, `errors`, `time`, `language`, `device`, `screen`, `appearance`, `connection`, `age`, `account` (`CONTEXT_KEYS`, `contextOff`). An item off is never collected (the trail keeps nothing of it from the first mount and forgets what it had), never listed by the sheet, and never in the report or the issue; `device`, `screen` and `time` off drop the matching diagnostic too. `installContextListeners({ off })` and `collectContext({ off })` take the same list. Earned by a family app whose taps were a child's name, a visit or a medicine: it stripped taps on its server while the sheet still promised "your last taps".
6
+ - **`data-private`.** A tap on an element marked `data-private`, or inside one, is kept only as "something private", and a button's `data-private` parts are left out of its label.
7
+ - **The app's own note beside the list.** `words.text.destinationNote` renders under the what-goes list (or the one line), before Send, in `.sac-note`, never replacing the list, so an app that turns `destination()` on no longer loses its consent sentence.
8
+
3
9
  ## 0.2.2 (2026-10-26)
4
10
 
5
11
  - The pixel ratio in a report's context is rounded to two places (it read `1.600000023841858x` on a real Mac).
package/README.md CHANGED
@@ -90,14 +90,18 @@ The four diagnostics are the app's version, the time, the screen size and the ph
90
90
  | Your last taps | the last 20, by what the button or link is called; never what was typed | so the fixer can repeat what you did |
91
91
  | Errors the page hit | the last 10 errors and unhandled rejections, message and first stack frame, no query strings | the error the app hit is often the fastest way to the cause |
92
92
 
93
- The trail (pages, taps, errors) is kept from the moment any of the module's screens first mounts (the entry, `<KeepSending>`, `<SuggestNews>`), in memory only, in small ring buffers. Never captured: an input's value, the text of anything someone types into (an editable region, a text, search or combo box; it is "a text field"), cookies, storage, query strings. A host that bundles two copies of the module still gets one trail and one set of listeners (the state sits on the global object under a registered symbol), and a reset never undoes a wrapper the host put on `history` after ours. `collectContext()` and `installContextListeners()` are exported from `"./client"` for a host that wants them elsewhere.
93
+ The trail (pages, taps, errors) is kept from the moment any of the module's screens first mounts (the entry, `<KeepSending>`, `<SuggestNews>`), in memory only, in small ring buffers. Never captured: an input's value, the text of anything someone types into (an editable region, a text, search or combo box; it is "a text field"), the text of anything marked `data-private` or inside one (below), cookies, storage, query strings. A host that bundles two copies of the module still gets one trail and one set of listeners (the state sits on the global object under a registered symbol), and a reset never undoes a wrapper the host put on `history` after ours. `collectContext()` and `installContextListeners()` are exported from `"./client"` for a host that wants them elsewhere.
94
94
 
95
95
  - **The app's own context.** `context: async () => [{ label: "Plan", value: "Pro, yearly", why: "some features only exist on Pro" }]` adds anything app-specific; an item without a `why` is dropped. Or `context: { items, precise: true }` to also ask the browser for a precise location (its permission prompt); without that, location is only the time zone, plus roughly where from the receiver (below).
96
+ - **Turning items off.** `context: { items?, precise?, off: ["taps", "pages"] }` turns any built-in item off, for an app whose screens would carry what it must not share (a family app whose buttons are a child's name, a visit or a medicine). The keys are `taps`, `pages`, `errors`, `time`, `language`, `device`, `screen`, `appearance`, `connection`, `age` and `account` (`ContextKey`, all of them in `CONTEXT_KEYS`). An item off is never collected (from the first mount, the trail keeps nothing of it and forgets what it had), never listed on the sheet, and never in the report or the issue; `device`, `screen` and `time` off also drop the matching diagnostic, so the one line does not promise it either. It lives on the `context` adapter, beside `precise`, because it answers the same question (what context goes) and needs no new adapter. The parity test holds the sheet's list and the composed issue to each other with items off.
97
+ - **`data-private`, per element.** A tap on an element marked `data-private`, or anything inside one, is kept only as "something private"; a button with a `data-private` part (`Visit for <span data-private>Mia</span>`) is kept by its other text ("Visit for"). Mark wherever a person's details render as tappable text, when the rest of the trail is still worth keeping. `data-private="false"` unmarks.
96
98
  - **A way to reach them.** `contact: async () => ({ email, phone, handle })` gives what the app already knows, so whoever fixes it can say thanks or ask a question. It goes by default to a private repo and is off, with the reason ("this repo is public, so anyone could read it"), for a public one. It travels as `who.contact`, the one place a report may carry an email. The receiving side treats it as the person saying "share it" and takes the address itself from the host (the server half's `contactOf(member)`, a relay's `identity()`), so a phone can never put someone else's address on an issue.
97
99
 
98
100
  The issue carries it all in a "Context (shared so this can be fixed)" table of what, value and why (in place of the About line), the trail in collapsible blocks, and a "Reach them" section with `mailto:` and `tel:` links "To thank them or ask a follow-up question". When the body would pass GitHub's limit, the trail is cut first (oldest taps, then pages, then errors), then the transcript.
99
101
 
100
- **Where it files.** With the optional `destination()` adapter, answering `{ repo, public, account, attaches, name? }`, that line becomes the list of exactly what the issue will carry ("your words · your voice note and its transcript · 2 screenshots, saved to the suggestion-attachments branch of acme/notes · this screen (Photos, /photos) · notes 1.2.3 · iPhone, 390x844 · the time · your name, Alex Kim"), the repo it files on, who can read it there (a public repo says so in `--sac-danger`), and whose GitHub account files it. The list follows the draft as things are added or removed, and it is built by `whatGoes` from `./github`, the same pieces `composeIssue` writes the body from, so the sheet and the issue cannot drift. `attaches: false` says files wait until the repo can take them. With `via: "relay"` (and `relayApp`, the relay's app name as people see it) the account line says the issue is opened by that app and whose it is ("Opened by the Supersuit Suggestions app on GitHub, which says it came from Alex Kim."), since the filer's own account is not what opens it. Sending is the consent. Without the adapter (or when it answers null) the sheet keeps the one line. My suggestions and the detail view link the issue a report became ("Issue #7"); a member who cannot read the repo still sees the number and the state.
102
+ **Where it files.** With the optional `destination()` adapter, answering `{ repo, public, account, attaches, name? }`, that line becomes the list of exactly what the issue will carry ("your words · your voice note and its transcript · 2 screenshots, saved to the suggestion-attachments branch of acme/notes · this screen (Photos, /photos) · notes 1.2.3 · iPhone, 390x844 · the time · your name, Alex Kim"), the repo it files on, who can read it there (a public repo says so in `--sac-danger`), and whose GitHub account files it. The list follows the draft as things are added or removed, and it is built by `whatGoes` from `./github`, the same pieces `composeIssue` writes the body from, so the sheet and the issue cannot drift. `attaches: false` says files wait until the repo can take them. With `via: "relay"` (and `relayApp`, the relay's app name as people see it) the account line says the issue is opened by that app and whose it is ("Opened by the Supersuit Suggestions app on GitHub, which says it came from Alex Kim."), since the filer's own account is not what opens it. Sending is the consent. Without the adapter (or when it answers null) the sheet keeps the one line.
103
+
104
+ **The app's own note.** `words.text.destinationNote` is a sentence or two only the app can say about where a report goes and what it carries ("The people who fix this are outside your family. A photo or video of your child goes too, and a video keeps where it was filmed."). The sheet shows it under the list (or under the one line, without a destination), before Send, in the module's styling (`.sac-note`), and never instead of the list. It is a word rather than a field of `destination()` because every word a person reads lives in `words.text`, where an app already keeps its consent sentence (`goesTo`, `includes`) and translates it, and because it has to show whether or not `destination()` answers: an app that turns `destination()` on keeps its note. My suggestions and the detail view link the issue a report became ("Issue #7"); a member who cannot read the repo still sees the number and the state.
101
105
 
102
106
  **Identity.** Every screen asks `adapters.identity()` when it mounts, whenever it is handed a different `adapters` object, and whenever the host calls `refreshIdentity()` (exported from `"./client"`; call it after a sign-in, sign-out or account switch). When the account differs, the old account's rows leave the screen and its waiting reports stay on the phone, to be sent only while that account is signed in; every send re-confirms the account first. Keep `adapters` stable where you can; a parent that rebuilds it every render is safe, only slower (one `identity()` call per render).
103
107
 
@@ -5,7 +5,7 @@ import type { StoreAdapter } from './reports.ts';
5
5
  import type { startTake } from './recorder.ts';
6
6
  import type { Words } from './words.ts';
7
7
  import type { Destination } from '../github/compose.ts';
8
- import type { AppContextItem } from './context.ts';
8
+ import type { AppContextItem, ContextKey } from './context.ts';
9
9
  export type Role = 'operator' | 'member' | 'maintainer';
10
10
  export type Kind = 'change' | 'broken' | 'feedback';
11
11
  /** `name` (or `username`) is how the person is called in the app; it goes in the context, and the account never does. */
@@ -113,10 +113,12 @@ export interface Adapters {
113
113
  * (the same list the issue is written from); null or absent keeps the one-line note. */
114
114
  destination?(): Promise<SheetDestination | null>;
115
115
  /** Anything app-specific that would help fix it, each with why it is shared (an item without a why is dropped). Or
116
- * `{ items?, precise? }`, where `precise: true` also asks the browser for the person's location (its permission prompt). */
116
+ * `{ items?, precise?, off? }`, where `precise: true` also asks the browser for the person's location (its permission
117
+ * prompt) and `off` turns built-in items off: an item off is never collected, never listed and never in the issue. */
117
118
  context?: (() => Promise<AppContextItem[]>) | {
118
119
  items?(): Promise<AppContextItem[]>;
119
120
  precise?: boolean;
121
+ off?: readonly ContextKey[];
120
122
  };
121
123
  /** How to reach the sender, from what the app already knows. Shared by default to a private repo, off for a public one. */
122
124
  contact?(): Promise<Contact | null>;
@@ -29,10 +29,17 @@ export interface AppContextItem {
29
29
  value: string;
30
30
  why: string;
31
31
  }
32
+ /** Every built-in item an app can turn off. `pages`, `taps` and `errors` are the trail; the rest are the items' keys. */
33
+ export declare const CONTEXT_KEYS: readonly ['taps', 'pages', 'errors', 'time', 'language', 'device', 'screen', 'appearance', 'connection', 'age', 'account'];
34
+ export type ContextKey = typeof CONTEXT_KEYS[number];
32
35
  /** A URL or path with no query and no hash, wherever it appears in a piece of text. */
33
36
  export declare const withoutQueries: (s: string) => string;
34
- /** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. */
35
- export declare function installContextListeners(): void;
37
+ /** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. `off`
38
+ * turns trail items off for the page (`taps`, `pages`, `errors`): from then on nothing of them is kept, and what was
39
+ * kept is forgotten. */
40
+ export declare function installContextListeners(o?: {
41
+ off?: readonly string[];
42
+ }): void;
36
43
  /** Stops keeping the trail and forgets it (tests, and a host that signs someone out). */
37
44
  export declare function resetContext(): void;
38
45
  /** A copy of the trail as it stands. */
@@ -56,6 +63,10 @@ export interface CollectOptions {
56
63
  /** A user agent to read instead of the browser's (tests). */
57
64
  userAgent?: string;
58
65
  now?: () => number;
66
+ /** Built-in items the app turned off: never collected, so never listed and never sent. */
67
+ off?: readonly ContextKey[];
59
68
  }
69
+ /** The `off` list from the `context` adapter, whatever form it takes; unknown keys are ignored. */
70
+ export declare function contextOff(c: unknown): ContextKey[];
60
71
  /** Everything the module knows that would help fix it, each item with why it is shared, and the trail. */
61
72
  export declare function collectContext(o?: CollectOptions): Promise<ReportContext>;
@@ -1,8 +1,12 @@
1
+ /** Every built-in item an app can turn off. `pages`, `taps` and `errors` are the trail; the rest are the items' keys. */
2
+ export const CONTEXT_KEYS = ['taps', 'pages', 'errors', 'time', 'language', 'device', 'screen', 'appearance', 'connection', 'age', 'account'];
1
3
  const MAX = { routes: 10, taps: 20, errors: 10, items: 40 };
2
4
  const SHARED = Symbol.for('@supersuit/suggest-a-change/context');
3
5
  const shared = globalThis[SHARED]
4
6
  ?? (globalThis[SHARED] = { trail: { routes: [], taps: [], errors: [] }, installedAt: 0, undo: null });
5
7
  const trail = shared.trail;
8
+ // What the app turned off, for the page: once off, the listeners keep nothing of it until a reset.
9
+ const offSet = () => (shared.off ??= new Set());
6
10
  const clip = (s, max) => (s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`);
7
11
  const iso = () => new Date().toISOString();
8
12
  const push = (list, x, max) => { list.push(x); if (list.length > max)
@@ -35,17 +39,35 @@ const routeOf = (href) => {
35
39
  return path.split('/').map((seg) => (seg && idLike(seg) ? ':id' : seg)).join('/');
36
40
  };
37
41
  function recordRoute() {
38
- if (typeof location === 'undefined')
42
+ if (typeof location === 'undefined' || offSet().has('pages'))
39
43
  return;
40
44
  const route = routeOf(location.href);
41
45
  if (trail.routes.at(-1)?.route === route)
42
46
  return;
43
47
  push(trail.routes, { route: clip(route, 300), at: iso() }, MAX.routes);
44
48
  }
45
- /** What a tapped thing is called: its label or its text, never what was typed into it. */
49
+ const PRIVATE = '[data-private]:not([data-private="false"])';
50
+ /** An element's text with every `data-private` part left out. */
51
+ function publicText(el) {
52
+ let out = '';
53
+ const walk = (n) => {
54
+ if (n.nodeType === 3)
55
+ out += n.textContent ?? '';
56
+ else if (n instanceof Element && !n.matches(PRIVATE))
57
+ n.childNodes.forEach(walk);
58
+ else if (!(n instanceof Element))
59
+ n.childNodes.forEach(walk);
60
+ };
61
+ el.childNodes.forEach(walk);
62
+ return out;
63
+ }
64
+ /** What a tapped thing is called: its label or its text, never what was typed into it, and never the text of
65
+ * anything marked `data-private` (a child's name on a button): a tap there is only "something private". */
46
66
  function labelOf(target) {
47
67
  if (!(target instanceof Element))
48
68
  return null;
69
+ if (target.closest(PRIVATE))
70
+ return 'something private';
49
71
  // Anything someone types into is "a text field", by its own label at most and never its text: an editable region,
50
72
  // a text, search or combo box, wherever inside it the tap landed.
51
73
  const typing = target.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"], [role="combobox"]');
@@ -68,7 +90,9 @@ function labelOf(target) {
68
90
  }
69
91
  if (aria)
70
92
  return clip(aria, 60);
71
- const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
93
+ if (el.closest(PRIVATE))
94
+ return 'something private';
95
+ const text = publicText(el).replace(/\s+/g, ' ').trim();
72
96
  if (text)
73
97
  return clip(text, 60);
74
98
  const title = el.getAttribute('title')?.trim();
@@ -82,17 +106,35 @@ const firstFrame = (stack) => {
82
106
  };
83
107
  const messageOf = (x) => (x instanceof Error ? x.message : typeof x === 'string' ? x : x === undefined ? '' : typeof x === 'number' || typeof x === 'boolean' ? String(x) : '[object]');
84
108
  function recordError(message, stack) {
109
+ if (offSet().has('errors'))
110
+ return;
85
111
  push(trail.errors, { message: clip(withoutQueries(message || 'An error with no message'), 300), ...(firstFrame(stack) ? { frame: firstFrame(stack) } : {}), at: iso() }, MAX.errors);
86
112
  }
87
- /** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. */
88
- export function installContextListeners() {
113
+ /** Empties the parts of the trail an app turned off. */
114
+ function forgetOff() {
115
+ const off = offSet();
116
+ if (off.has('pages'))
117
+ trail.routes.length = 0;
118
+ if (off.has('taps'))
119
+ trail.taps.length = 0;
120
+ if (off.has('errors'))
121
+ trail.errors.length = 0;
122
+ }
123
+ /** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. `off`
124
+ * turns trail items off for the page (`taps`, `pages`, `errors`): from then on nothing of them is kept, and what was
125
+ * kept is forgotten. */
126
+ export function installContextListeners(o = {}) {
127
+ for (const k of o.off ?? [])
128
+ offSet().add(k);
129
+ forgetOff();
89
130
  if (shared.undo || typeof window === 'undefined' || typeof document === 'undefined')
90
131
  return;
91
132
  shared.installedAt = Date.now();
92
133
  // A wrapper a host put on top of ours may outlive a reset; once reset, ours only passes the call through.
93
134
  let live = true;
94
135
  recordRoute();
95
- const onClick = (e) => { const l = labelOf(e.target); if (l)
136
+ const onClick = (e) => { if (offSet().has('taps'))
137
+ return; const l = labelOf(e.target); if (l)
96
138
  push(trail.taps, { label: l, at: iso() }, MAX.taps); };
97
139
  const onError = (e) => { const ev = e; recordError(ev.message || messageOf(ev.error), ev.error?.stack); };
98
140
  const onRejection = (e) => { const reason = e.reason; recordError(`Unhandled rejection: ${messageOf(reason)}`, reason?.stack); };
@@ -139,6 +181,7 @@ export function installContextListeners() {
139
181
  export function resetContext() {
140
182
  shared.undo?.();
141
183
  shared.undo = null;
184
+ offSet().clear();
142
185
  trail.routes.length = 0;
143
186
  trail.taps.length = 0;
144
187
  trail.errors.length = 0;
@@ -225,18 +268,25 @@ const media = (q) => { try {
225
268
  catch {
226
269
  return false;
227
270
  } };
271
+ /** The `off` list from the `context` adapter, whatever form it takes; unknown keys are ignored. */
272
+ export function contextOff(c) {
273
+ const off = c && typeof c === 'object' ? c.off : undefined;
274
+ return Array.isArray(off) ? off.filter((k) => CONTEXT_KEYS.includes(k)) : [];
275
+ }
228
276
  /** Everything the module knows that would help fix it, each item with why it is shared, and the trail. */
229
277
  export async function collectContext(o = {}) {
230
278
  const now = new Date(o.now?.() ?? Date.now());
231
279
  const items = [];
232
- const add = (key, label, value, why) => { if (value)
280
+ const off = new Set(o.off ?? []);
281
+ const add = (key, label, value, why) => { if (off.has(key))
282
+ return; if (value)
233
283
  items.push({ key, label: clip(label, 80), value: clip(value, 500), why: clip(why, 200) }); };
234
284
  const who = o.identity;
235
285
  add('account', 'Your name in the app', who?.name || who?.username, 'so whoever fixes it knows who to thank, and can look at your account if the problem is in it');
236
286
  add('time', 'Your local time', timeWords(now), 'so the fixer can line it up with what the app logged at that moment');
237
287
  const langs = typeof navigator === 'undefined' ? [] : (navigator.languages?.length ? [...navigator.languages] : navigator.language ? [navigator.language] : []);
238
288
  add('language', 'Your languages', langs.join(', '), 'some problems only happen in one language or date format');
239
- add('device', 'Your device', await deviceWords(o.userAgent), 'so the fixer can try it on the same kind of device and browser');
289
+ add('device', 'Your device', off.has('device') ? '' : await deviceWords(o.userAgent), 'so the fixer can try it on the same kind of device and browser');
240
290
  if (typeof window !== 'undefined') {
241
291
  const s = typeof screen === 'undefined' ? null : screen;
242
292
  add('screen', 'Screen and window', `${s ? `${s.width}x${s.height} screen, ` : ''}${window.innerWidth}x${window.innerHeight} window, ${Math.round((window.devicePixelRatio || 1) * 100) / 100}x pixels`, 'how things are laid out depends on the size of the screen and window');
@@ -275,5 +325,6 @@ export async function collectContext(o = {}) {
275
325
  }
276
326
  catch { /* the app's own context failing never blocks a suggestion */ }
277
327
  }
278
- return { items: items.slice(0, MAX.items), ...contextTrail() };
328
+ const t = contextTrail();
329
+ return { items: items.slice(0, MAX.items), routes: off.has('pages') ? [] : t.routes, taps: off.has('taps') ? [] : t.taps, errors: off.has('errors') ? [] : t.errors };
279
330
  }
@@ -15,5 +15,5 @@ export * from './mine.tsx';
15
15
  export { seenStatuses, markSeen, moved, onSeen, seenKey, type Seen } from './seen.ts';
16
16
  export * from './detail.tsx';
17
17
  export { refreshIdentity } from './runtime.ts';
18
- export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries } from './context.ts';
19
- export type { ContextItem, AppContextItem, ReportContext, Trail, CollectOptions } from './context.ts';
18
+ export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries, contextOff, CONTEXT_KEYS } from './context.ts';
19
+ export type { ContextItem, AppContextItem, ReportContext, Trail, CollectOptions, ContextKey } from './context.ts';
@@ -15,4 +15,4 @@ export * from './mine.js';
15
15
  export { seenStatuses, markSeen, moved, onSeen, seenKey } from './seen.js';
16
16
  export * from './detail.js';
17
17
  export { refreshIdentity } from './runtime.js';
18
- export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries } from './context.js';
18
+ export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries, contextOff, CONTEXT_KEYS } from './context.js';
@@ -6,7 +6,7 @@
6
6
  // is sent only while that person is signed in. "Received" is derived from the outbox: a report has landed
7
7
  // when none of its writes is left there, because a write leaves only after the adapter said `done`.
8
8
  import { useEffect, useRef, useState } from 'react';
9
- import { installContextListeners } from './context.js';
9
+ import { contextOff, installContextListeners } from './context.js';
10
10
  import { draftKey } from './drafts.js';
11
11
  import { openStore } from './idb.js';
12
12
  import { makeOutbox } from './outbox.js';
@@ -172,7 +172,9 @@ export function useRuntime(adapters) {
172
172
  const current = useRef({ account: null, release: () => { } });
173
173
  const alive = useRef(true);
174
174
  // Every screen of the module mounts this, so the trail (pages, taps, errors) starts with the first one; it installs once.
175
- useEffect(() => { installContextListeners(); }, []);
175
+ // What the app turned off is never kept, from the first mount on.
176
+ const off = contextOff(adapters.context).join(',');
177
+ useEffect(() => { installContextListeners({ off: off ? off.split(',') : [] }); }, [off]);
176
178
  useEffect(() => { alive.current = true; return () => { alive.current = false; current.current.release(); current.current = { account: null, release: () => { } }; }; }, []);
177
179
  useEffect(() => { const l = () => setSignal((n) => n + 1); identityListeners.add(l); return () => { identityListeners.delete(l); }; }, []);
178
180
  useEffect(() => { if (state.rt)
@@ -9,7 +9,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
9
9
  import { isAcceptedMedia, sanitizeRoute, typeForFile, validateReport } from '../contract.mjs';
10
10
  import { destinationWords, whatGoes } from '../github/compose.js';
11
11
  import { validContact } from '../contract.mjs';
12
- import { collectContext } from './context.js';
12
+ import { collectContext, contextOff } from './context.js';
13
13
  import { useDialog } from './dialog.js';
14
14
  import { DefaultField } from './default-field.js';
15
15
  import { draftKey, loadDraft, saveDraft } from './drafts.js';
@@ -95,10 +95,13 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
95
95
  setDest(null); });
96
96
  return () => { live = false; };
97
97
  }, [adapters]);
98
+ // What the app turned off, as the list's keys: never collected, never listed, never sent.
99
+ const off = useMemo(() => contextOff(adapters.context), [adapters]);
100
+ const offPieces = useMemo(() => new Set(off.map((k) => (k === 'pages' ? 'routes' : k === 'taps' || k === 'errors' ? k : `ctx:${k}`))), [off]);
98
101
  const contextOptions = useCallback(() => {
99
102
  const c = adapters.context;
100
- return typeof c === 'function' ? { extra: c, precise: false } : { extra: c?.items ? () => c.items() : undefined, precise: Boolean(c?.precise) };
101
- }, [adapters]);
103
+ return typeof c === 'function' ? { extra: c, precise: false, off } : { extra: c?.items ? () => c.items() : undefined, precise: Boolean(c?.precise), off };
104
+ }, [adapters, off]);
102
105
  const gather = useCallback(() => collectContext({ identity: who ?? null, where, about: adapters.about, ...contextOptions(), ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) }), [who, where, adapters, contextOptions]);
103
106
  useEffect(() => {
104
107
  if (!who)
@@ -301,7 +304,9 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
301
304
  { name: 'at', label: S.diagAt, value: new Date(adapters.phone?.now?.() ?? Date.now()).toISOString() },
302
305
  { name: 'screen', label: S.diagScreen, value: screenSize() },
303
306
  { name: 'device', label: S.diagDevice, value: adapters.device ? adapters.device() : phoneType() },
304
- ].filter((d) => Boolean(d.value));
307
+ ].filter((d) => Boolean(d.value))
308
+ // A diagnostic whose context item the app turned off is not said, listed or sent either.
309
+ .filter((d) => !offPieces.has({ device: 'ctx:device', screen: 'ctx:screen', at: 'ctx:time' }[d.name] ?? ''));
305
310
  const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)];
306
311
  const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`;
307
312
  // With a destination, the one line becomes the list of exactly what the issue will carry, built by the same function
@@ -316,7 +321,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
316
321
  ...(ctx ? { context: ctx } : {}),
317
322
  ...(contact ? { who: { contact } } : {}),
318
323
  }, dest ?? { repo: '', public: false, account: '', attaches: true });
319
- const isOn = (key, fallback = true) => choice[key] ?? rows.find((r) => r.key === key)?.on ?? fallback;
324
+ const isOn = (key, fallback = true) => !offPieces.has(key) && (choice[key] ?? rows.find((r) => r.key === key)?.on ?? fallback);
320
325
  const goes = dest ? rows : null;
321
326
  const destWords = dest ? destinationWords(dest) : null;
322
327
  const optionalRow = (g) => (_jsx("li", { "data-goes": g.key, className: "sac-goes-opt", children: _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: isOn(g.key), onChange: (e) => setChoice((c) => ({ ...c, [g.key]: e.target.checked })) }), ' ', _jsx("span", { className: "sac-goes-label", children: g.text }), g.value ? _jsxs(_Fragment, { children: [": ", _jsx("span", { className: "sac-goes-value", children: g.value })] }) : null, ' ', _jsxs("span", { className: "sac-muted sac-goes-why", children: ["(", g.why, g.reason ? `; off by default because ${g.reason}` : '', ")"] })] }) }, g.key));
@@ -394,5 +399,5 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
394
399
  return (_jsx("div", { className: "sac-backdrop", "data-suggest-sheet": "", children: _jsxs("div", { ref: box, className: "sac-sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, tabIndex: -1, children: [_jsxs("header", { className: "sac-header", children: [_jsx("h2", { id: titleId, className: "sac-title", children: S.sheetTitle }), onClose ? _jsx("button", { type: "button", className: "sac-button sac-close", onClick: onClose, children: S.close }) : null] }), _jsx("div", { className: "sac-sr", "data-sac-live": "", role: "status", "aria-live": "polite", "aria-atomic": "true", children: announce }), who === undefined ? _jsx("p", { children: S.loading }) : who === null ? _jsx("p", { role: "alert", children: S.signedOut }) : !rt ? _jsx("p", { children: S.loading }) : sent ? (_jsxs("div", { className: "sac-sent", children: [_jsx(Receipt, { adapters: adapters, reportId: sent.id, quiet: true, onLine: setAnnounce }), sent.files.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: sent.files.map((f) => {
395
400
  const p = rt.part(sent.id, f.file);
396
401
  return (_jsxs("li", { className: "sac-item", "data-attachment-state": p.state, children: [_jsxs("span", { className: "sac-item-name", children: [f.name, f.bytes ? `, ${sizeWords(f.bytes)}` : ''] }), _jsxs("span", { className: "sac-item-state", children: [p.state === 'sending' ? _jsx("progress", { "aria-label": `${S.sending} ${f.name}` }) : null, p.state === 'waiting' ? S.waitingToSend : p.state === 'sending' ? S.sending : p.state === 'sent' ? S.sentWord : S.didNotSend(p.reason ?? '')] }), p.state === 'failed' ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(sent.id), children: S.retry }) : null] }, f.file));
397
- }) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("div", { className: "sac-kind", children: [_jsx("label", { htmlFor: `${titleId}-kind`, children: S.kindLegend }), _jsx("select", { id: `${titleId}-kind`, className: "sac-select", value: kind, onChange: (e) => setKind(e.target.value), children: kinds.map((k) => _jsx("option", { value: k.id, children: k.label }, k.id)) })] }), _jsxs("fieldset", { className: "sac-group sac-sayit", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: kind === 'broken' ? S.sayItPlaceholderBroken : kind === 'feedback' ? S.sayItPlaceholderFeedback : S.sayItPlaceholder }), voice.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => (_jsxs("li", { className: "sac-item", children: [_jsx("span", { className: "sac-item-name", children: S.voiceNote(i + 1, lengthWords(v.ms)) }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${S.voiceNote(i + 1, lengthWords(v.ms))}`, onClick: () => void removeVoice(v), children: S.remove })] }, v.key))) })) : null] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.showItLegend }), _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => fileInput.current?.click(), children: S.addImages }), _jsx("button", { type: "button", className: "sac-button", onClick: () => videoInput.current?.click(), children: S.addVideo }), adapters.snapshot ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void takeScreenshot(), children: S.takeScreenshot }) : null] }), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", multiple: true, hidden: true, "aria-label": S.addImages, "data-sac-images": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'image'); } }), _jsx("input", { ref: videoInput, type: "file", accept: "video/*", hidden: true, "aria-label": S.addVideo, "data-sac-video": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'video'); } }), media.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => (_jsxs("li", { className: "sac-item", children: [_jsxs("span", { className: "sac-item-name", children: [m.name, ", ", sizeWords(m.bytes)] }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${m.name}`, onClick: () => void removeMedia(m), children: S.remove })] }, m.key))) })) : null] }), goes && destWords ? (_jsxs("div", { className: "sac-goes sac-fyi", "data-sac-goes": "", children: [_jsxs("p", { className: "sac-goes-head", children: [_jsx("strong", { children: destWords.headline }), ' (', _jsx("span", { className: destWords.danger ? 'sac-danger' : 'sac-muted', children: destWords.privacy }), ")"] }), _jsx("p", { className: "sac-muted", children: "It will carry:" }), _jsx("ul", { className: "sac-goes-list", children: goes.filter((g) => !g.optional).map((g) => _jsx("li", { "data-goes": g.key, children: g.text }, g.key)) }), goes.some((g) => g.optional) ? _jsx("ul", { className: "sac-goes-opts", "aria-label": S.contextLegend, children: goes.filter((g) => g.optional).map(optionalRow) }) : null, _jsx("p", { className: "sac-muted", children: destWords.account })] })) : (_jsxs("div", { className: "sac-fyi", children: [_jsx("p", { className: "sac-muted sac-fyi", "data-sac-summary": "", children: summary }), rows.some((g) => g.optional) ? _jsx("ul", { className: "sac-goes-opts", "aria-label": S.contextLegend, children: rows.filter((g) => g.optional).map(optionalRow) }) : null] })), error ? _jsx("p", { role: "alert", className: "sac-error", children: error }) : null, _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "submit", className: "sac-button sac-primary", disabled: !hasAnything, "aria-describedby": hasAnything ? undefined : `${titleId}-hint`, children: S.send }), hasAnything ? null : _jsx("span", { id: `${titleId}-hint`, className: "sac-muted", children: S.sendHint })] })] })), _jsx(Toasts, {})] }) }));
402
+ }) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("div", { className: "sac-kind", children: [_jsx("label", { htmlFor: `${titleId}-kind`, children: S.kindLegend }), _jsx("select", { id: `${titleId}-kind`, className: "sac-select", value: kind, onChange: (e) => setKind(e.target.value), children: kinds.map((k) => _jsx("option", { value: k.id, children: k.label }, k.id)) })] }), _jsxs("fieldset", { className: "sac-group sac-sayit", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: kind === 'broken' ? S.sayItPlaceholderBroken : kind === 'feedback' ? S.sayItPlaceholderFeedback : S.sayItPlaceholder }), voice.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => (_jsxs("li", { className: "sac-item", children: [_jsx("span", { className: "sac-item-name", children: S.voiceNote(i + 1, lengthWords(v.ms)) }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${S.voiceNote(i + 1, lengthWords(v.ms))}`, onClick: () => void removeVoice(v), children: S.remove })] }, v.key))) })) : null] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.showItLegend }), _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => fileInput.current?.click(), children: S.addImages }), _jsx("button", { type: "button", className: "sac-button", onClick: () => videoInput.current?.click(), children: S.addVideo }), adapters.snapshot ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void takeScreenshot(), children: S.takeScreenshot }) : null] }), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", multiple: true, hidden: true, "aria-label": S.addImages, "data-sac-images": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'image'); } }), _jsx("input", { ref: videoInput, type: "file", accept: "video/*", hidden: true, "aria-label": S.addVideo, "data-sac-video": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'video'); } }), media.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => (_jsxs("li", { className: "sac-item", children: [_jsxs("span", { className: "sac-item-name", children: [m.name, ", ", sizeWords(m.bytes)] }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${m.name}`, onClick: () => void removeMedia(m), children: S.remove })] }, m.key))) })) : null] }), goes && destWords ? (_jsxs("div", { className: "sac-goes sac-fyi", "data-sac-goes": "", children: [_jsxs("p", { className: "sac-goes-head", children: [_jsx("strong", { children: destWords.headline }), ' (', _jsx("span", { className: destWords.danger ? 'sac-danger' : 'sac-muted', children: destWords.privacy }), ")"] }), _jsx("p", { className: "sac-muted", children: "It will carry:" }), _jsx("ul", { className: "sac-goes-list", children: goes.filter((g) => !g.optional).map((g) => _jsx("li", { "data-goes": g.key, children: g.text }, g.key)) }), goes.some((g) => g.optional) ? _jsx("ul", { className: "sac-goes-opts", "aria-label": S.contextLegend, children: goes.filter((g) => g.optional).map(optionalRow) }) : null, _jsx("p", { className: "sac-muted", children: destWords.account })] })) : (_jsxs("div", { className: "sac-fyi", children: [_jsx("p", { className: "sac-muted sac-fyi", "data-sac-summary": "", children: summary }), rows.some((g) => g.optional) ? _jsx("ul", { className: "sac-goes-opts", "aria-label": S.contextLegend, children: rows.filter((g) => g.optional).map(optionalRow) }) : null] })), S.destinationNote ? _jsx("p", { className: "sac-note", "data-sac-note": "", children: S.destinationNote }) : null, error ? _jsx("p", { role: "alert", className: "sac-error", children: error }) : null, _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "submit", className: "sac-button sac-primary", disabled: !hasAnything, "aria-describedby": hasAnything ? undefined : `${titleId}-hint`, children: S.send }), hasAnything ? null : _jsx("span", { id: `${titleId}-hint`, className: "sac-muted", children: S.sendHint })] })] })), _jsx(Toasts, {})] }) }));
398
403
  }
@@ -103,6 +103,9 @@ export interface Strings {
103
103
  issueLink: (n: number) => string;
104
104
  /** The accessible name of the list of context, each row a tick box. */
105
105
  contextLegend: string;
106
+ /** The app's own note, shown under the what-goes list (or the one line) and before Send, never instead of it: what
107
+ * only the app can say about where a report goes and what it carries. Empty shows nothing. */
108
+ destinationNote: string;
106
109
  }
107
110
  export declare const DEFAULT_STRINGS: Strings;
108
111
  export declare function stringsFor(words: Words): Strings;
@@ -91,6 +91,7 @@ export const DEFAULT_STRINGS = {
91
91
  movedToast: (words, note) => (note ? `Your suggestion: ${words}. ${note}` : `Your suggestion: ${words}`),
92
92
  issueLink: (n) => `Issue #${n}`,
93
93
  contextLegend: 'Also shared, so it can be fixed (untick anything to leave it out)',
94
+ destinationNote: '',
94
95
  };
95
96
  export function stringsFor(words) {
96
97
  return { ...DEFAULT_STRINGS, ...words.text };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "The report format and flow behind a Suggest a change button: what a report is, who acts at each step, the screens, and the check every app runs. The contract has zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@ import type { StoreAdapter } from './reports.ts'
8
8
  import type { startTake } from './recorder.ts'
9
9
  import type { Words } from './words.ts'
10
10
  import type { Destination } from '../github/compose.ts'
11
- import type { AppContextItem } from './context.ts'
11
+ import type { AppContextItem, ContextKey } from './context.ts'
12
12
 
13
13
  export type Role = 'operator' | 'member' | 'maintainer'
14
14
  export type Kind = 'change' | 'broken' | 'feedback'
@@ -84,8 +84,9 @@ export interface Adapters {
84
84
  * (the same list the issue is written from); null or absent keeps the one-line note. */
85
85
  destination?(): Promise<SheetDestination | null>
86
86
  /** Anything app-specific that would help fix it, each with why it is shared (an item without a why is dropped). Or
87
- * `{ items?, precise? }`, where `precise: true` also asks the browser for the person's location (its permission prompt). */
88
- context?: (() => Promise<AppContextItem[]>) | { items?(): Promise<AppContextItem[]>; precise?: boolean }
87
+ * `{ items?, precise?, off? }`, where `precise: true` also asks the browser for the person's location (its permission
88
+ * prompt) and `off` turns built-in items off: an item off is never collected, never listed and never in the issue. */
89
+ context?: (() => Promise<AppContextItem[]>) | { items?(): Promise<AppContextItem[]>; precise?: boolean; off?: readonly ContextKey[] }
89
90
  /** How to reach the sender, from what the app already knows. Shared by default to a private repo, off for a public one. */
90
91
  contact?(): Promise<Contact | null>
91
92
  }
@@ -2,7 +2,9 @@
2
2
  // for the person sending, why it is shared; the sheet lists them all, on by default, and any one can be left out.
3
3
  //
4
4
  // The trail (the last pages, taps and errors) is kept from the moment the module first mounts, in memory only, in
5
- // small ring buffers. NEVER captured: what anyone typed (an input's value), cookies, storage, or a query string.
5
+ // small ring buffers. NEVER captured: what anyone typed (an input's value), cookies, storage, or a query string, nor
6
+ // any text of an element marked `data-private` or inside one. An app turns any built-in item off (`ContextKey`): an item
7
+ // off is never kept, never listed and never sent.
6
8
  import type { Identity } from './adapters.ts'
7
9
 
8
10
  export interface ContextItem { key: string; label: string; value: string; why: string }
@@ -11,14 +13,20 @@ export interface ReportContext extends Trail { items: ContextItem[] }
11
13
  /** What an app adds through the `context` adapter: anything app-specific, each with why it helps. */
12
14
  export interface AppContextItem { label: string; value: string; why: string }
13
15
 
16
+ /** Every built-in item an app can turn off. `pages`, `taps` and `errors` are the trail; the rest are the items' keys. */
17
+ export const CONTEXT_KEYS = ['taps', 'pages', 'errors', 'time', 'language', 'device', 'screen', 'appearance', 'connection', 'age', 'account'] as const
18
+ export type ContextKey = typeof CONTEXT_KEYS[number]
19
+
14
20
  const MAX = { routes: 10, taps: 20, errors: 10, items: 40 }
15
21
  // ONE trail per page, however many copies of the module a host bundles: the state lives on the global object under a
16
22
  // registered symbol, so a second copy finds the first one's listeners installed and shares its trail.
17
- type Shared = { trail: Trail; installedAt: number; undo: (() => void) | null }
23
+ type Shared = { trail: Trail; installedAt: number; undo: (() => void) | null; off?: Set<string> }
18
24
  const SHARED = Symbol.for('@supersuit/suggest-a-change/context')
19
25
  const shared: Shared = ((globalThis as Record<symbol, unknown>)[SHARED] as Shared | undefined)
20
26
  ?? ((globalThis as Record<symbol, unknown>)[SHARED] = { trail: { routes: [], taps: [], errors: [] }, installedAt: 0, undo: null }) as Shared
21
27
  const trail = shared.trail
28
+ // What the app turned off, for the page: once off, the listeners keep nothing of it until a reset.
29
+ const offSet = () => (shared.off ??= new Set())
22
30
 
23
31
  const clip = (s: string, max: number) => (s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`)
24
32
  const iso = () => new Date().toISOString()
@@ -44,15 +52,30 @@ const routeOf = (href: string) => {
44
52
  }
45
53
 
46
54
  function recordRoute() {
47
- if (typeof location === 'undefined') return
55
+ if (typeof location === 'undefined' || offSet().has('pages')) return
48
56
  const route = routeOf(location.href)
49
57
  if (trail.routes.at(-1)?.route === route) return
50
58
  push(trail.routes, { route: clip(route, 300), at: iso() }, MAX.routes)
51
59
  }
52
60
 
53
- /** What a tapped thing is called: its label or its text, never what was typed into it. */
61
+ const PRIVATE = '[data-private]:not([data-private="false"])'
62
+ /** An element's text with every `data-private` part left out. */
63
+ function publicText(el: Element): string {
64
+ let out = ''
65
+ const walk = (n: Node) => {
66
+ if (n.nodeType === 3) out += n.textContent ?? ''
67
+ else if (n instanceof Element && !n.matches(PRIVATE)) n.childNodes.forEach(walk)
68
+ else if (!(n instanceof Element)) n.childNodes.forEach(walk)
69
+ }
70
+ el.childNodes.forEach(walk)
71
+ return out
72
+ }
73
+
74
+ /** What a tapped thing is called: its label or its text, never what was typed into it, and never the text of
75
+ * anything marked `data-private` (a child's name on a button): a tap there is only "something private". */
54
76
  function labelOf(target: EventTarget | null): string | null {
55
77
  if (!(target instanceof Element)) return null
78
+ if (target.closest(PRIVATE)) return 'something private'
56
79
  // Anything someone types into is "a text field", by its own label at most and never its text: an editable region,
57
80
  // a text, search or combo box, wherever inside it the tap landed.
58
81
  const typing = target.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"], [role="combobox"]')
@@ -69,7 +92,8 @@ function labelOf(target: EventTarget | null): string | null {
69
92
  return type === 'checkbox' ? 'a checkbox' : type === 'radio' ? 'a choice' : type === 'button' || type === 'submit' ? clip(el.value ? 'a button' : 'a button', 60) : 'a text field'
70
93
  }
71
94
  if (aria) return clip(aria, 60)
72
- const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
95
+ if (el.closest(PRIVATE)) return 'something private'
96
+ const text = publicText(el).replace(/\s+/g, ' ').trim()
73
97
  if (text) return clip(text, 60)
74
98
  const title = el.getAttribute('title')?.trim()
75
99
  return title ? clip(title, 60) : el.tagName.toLowerCase()
@@ -82,17 +106,30 @@ const firstFrame = (stack: unknown): string | undefined => {
82
106
  }
83
107
  const messageOf = (x: unknown): string => (x instanceof Error ? x.message : typeof x === 'string' ? x : x === undefined ? '' : typeof x === 'number' || typeof x === 'boolean' ? String(x) : '[object]')
84
108
  function recordError(message: string, stack?: unknown) {
109
+ if (offSet().has('errors')) return
85
110
  push(trail.errors, { message: clip(withoutQueries(message || 'An error with no message'), 300), ...(firstFrame(stack) ? { frame: firstFrame(stack) } : {}), at: iso() }, MAX.errors)
86
111
  }
87
112
 
88
- /** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. */
89
- export function installContextListeners(): void {
113
+ /** Empties the parts of the trail an app turned off. */
114
+ function forgetOff() {
115
+ const off = offSet()
116
+ if (off.has('pages')) trail.routes.length = 0
117
+ if (off.has('taps')) trail.taps.length = 0
118
+ if (off.has('errors')) trail.errors.length = 0
119
+ }
120
+
121
+ /** Starts keeping the trail. Idempotent: every screen of the module calls it on mount, and it installs once. `off`
122
+ * turns trail items off for the page (`taps`, `pages`, `errors`): from then on nothing of them is kept, and what was
123
+ * kept is forgotten. */
124
+ export function installContextListeners(o: { off?: readonly string[] } = {}): void {
125
+ for (const k of o.off ?? []) offSet().add(k)
126
+ forgetOff()
90
127
  if (shared.undo || typeof window === 'undefined' || typeof document === 'undefined') return
91
128
  shared.installedAt = Date.now()
92
129
  // A wrapper a host put on top of ours may outlive a reset; once reset, ours only passes the call through.
93
130
  let live = true
94
131
  recordRoute()
95
- const onClick = (e: Event) => { const l = labelOf(e.target); if (l) push(trail.taps, { label: l, at: iso() }, MAX.taps) }
132
+ const onClick = (e: Event) => { if (offSet().has('taps')) return; const l = labelOf(e.target); if (l) push(trail.taps, { label: l, at: iso() }, MAX.taps) }
96
133
  const onError = (e: Event) => { const ev = e as ErrorEvent; recordError(ev.message || messageOf(ev.error), (ev.error as { stack?: unknown } | undefined)?.stack) }
97
134
  const onRejection = (e: Event) => { const reason = (e as PromiseRejectionEvent).reason; recordError(`Unhandled rejection: ${messageOf(reason)}`, (reason as { stack?: unknown } | undefined)?.stack) }
98
135
  const onNav = () => recordRoute()
@@ -129,6 +166,7 @@ export function installContextListeners(): void {
129
166
  /** Stops keeping the trail and forgets it (tests, and a host that signs someone out). */
130
167
  export function resetContext(): void {
131
168
  shared.undo?.(); shared.undo = null
169
+ offSet().clear()
132
170
  trail.routes.length = 0; trail.taps.length = 0; trail.errors.length = 0
133
171
  }
134
172
 
@@ -196,19 +234,28 @@ export interface CollectOptions {
196
234
  /** A user agent to read instead of the browser's (tests). */
197
235
  userAgent?: string
198
236
  now?: () => number
237
+ /** Built-in items the app turned off: never collected, so never listed and never sent. */
238
+ off?: readonly ContextKey[]
239
+ }
240
+
241
+ /** The `off` list from the `context` adapter, whatever form it takes; unknown keys are ignored. */
242
+ export function contextOff(c: unknown): ContextKey[] {
243
+ const off = c && typeof c === 'object' ? (c as { off?: unknown }).off : undefined
244
+ return Array.isArray(off) ? off.filter((k): k is ContextKey => (CONTEXT_KEYS as readonly string[]).includes(k)) : []
199
245
  }
200
246
 
201
247
  /** Everything the module knows that would help fix it, each item with why it is shared, and the trail. */
202
248
  export async function collectContext(o: CollectOptions = {}): Promise<ReportContext> {
203
249
  const now = new Date(o.now?.() ?? Date.now())
204
250
  const items: ContextItem[] = []
205
- const add = (key: string, label: string, value: string | undefined | null, why: string) => { if (value) items.push({ key, label: clip(label, 80), value: clip(value, 500), why: clip(why, 200) }) }
251
+ const off = new Set<string>(o.off ?? [])
252
+ const add = (key: string, label: string, value: string | undefined | null, why: string) => { if (off.has(key)) return; if (value) items.push({ key, label: clip(label, 80), value: clip(value, 500), why: clip(why, 200) }) }
206
253
  const who = o.identity as (Identity & { name?: string; username?: string }) | null | undefined
207
254
  add('account', 'Your name in the app', who?.name || who?.username, 'so whoever fixes it knows who to thank, and can look at your account if the problem is in it')
208
255
  add('time', 'Your local time', timeWords(now), 'so the fixer can line it up with what the app logged at that moment')
209
256
  const langs = typeof navigator === 'undefined' ? [] : (navigator.languages?.length ? [...navigator.languages] : navigator.language ? [navigator.language] : [])
210
257
  add('language', 'Your languages', langs.join(', '), 'some problems only happen in one language or date format')
211
- add('device', 'Your device', await deviceWords(o.userAgent), 'so the fixer can try it on the same kind of device and browser')
258
+ add('device', 'Your device', off.has('device') ? '' : await deviceWords(o.userAgent), 'so the fixer can try it on the same kind of device and browser')
212
259
  if (typeof window !== 'undefined') {
213
260
  const s = typeof screen === 'undefined' ? null : screen
214
261
  add('screen', 'Screen and window', `${s ? `${s.width}x${s.height} screen, ` : ''}${window.innerWidth}x${window.innerHeight} window, ${Math.round((window.devicePixelRatio || 1) * 100) / 100}x pixels`, 'how things are laid out depends on the size of the screen and window')
@@ -239,5 +286,6 @@ export async function collectContext(o: CollectOptions = {}): Promise<ReportCont
239
286
  })
240
287
  } catch { /* the app's own context failing never blocks a suggestion */ }
241
288
  }
242
- return { items: items.slice(0, MAX.items), ...contextTrail() }
289
+ const t = contextTrail()
290
+ return { items: items.slice(0, MAX.items), routes: off.has('pages') ? [] : t.routes, taps: off.has('taps') ? [] : t.taps, errors: off.has('errors') ? [] : t.errors }
243
291
  }
@@ -15,5 +15,5 @@ export * from './mine.tsx'
15
15
  export { seenStatuses, markSeen, moved, onSeen, seenKey, type Seen } from './seen.ts'
16
16
  export * from './detail.tsx'
17
17
  export { refreshIdentity } from './runtime.ts'
18
- export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries } from './context.ts'
19
- export type { ContextItem, AppContextItem, ReportContext, Trail, CollectOptions } from './context.ts'
18
+ export { collectContext, installContextListeners, contextTrail, resetContext, parseUserAgent, withoutQueries, contextOff, CONTEXT_KEYS } from './context.ts'
19
+ export type { ContextItem, AppContextItem, ReportContext, Trail, CollectOptions, ContextKey } from './context.ts'
@@ -7,7 +7,7 @@
7
7
  // when none of its writes is left there, because a write leaves only after the adapter said `done`.
8
8
  import { useEffect, useRef, useState } from 'react'
9
9
  import type { Adapters, Identity } from './adapters.ts'
10
- import { installContextListeners } from './context.ts'
10
+ import { contextOff, installContextListeners } from './context.ts'
11
11
  import { draftKey } from './drafts.ts'
12
12
  import { openStore, type Store } from './idb.ts'
13
13
  import { makeOutbox, type Op, type Sent } from './outbox.ts'
@@ -181,7 +181,9 @@ export function useRuntime(adapters: Adapters): { rt: Runtime | null; who: Ident
181
181
  const current = useRef<{ account: string | null; release: () => void }>({ account: null, release: () => {} })
182
182
  const alive = useRef(true)
183
183
  // Every screen of the module mounts this, so the trail (pages, taps, errors) starts with the first one; it installs once.
184
- useEffect(() => { installContextListeners() }, [])
184
+ // What the app turned off is never kept, from the first mount on.
185
+ const off = contextOff(adapters.context).join(',')
186
+ useEffect(() => { installContextListeners({ off: off ? off.split(',') : [] }) }, [off])
185
187
  useEffect(() => { alive.current = true; return () => { alive.current = false; current.current.release(); current.current = { account: null, release: () => {} } } }, [])
186
188
  useEffect(() => { const l = () => setSignal((n) => n + 1); identityListeners.add(l); return () => { identityListeners.delete(l) } }, [])
187
189
  useEffect(() => { if (state.rt) state.rt.adapters = adapters })
@@ -10,7 +10,7 @@ import type { Adapters, FieldProps, Kind, SheetDestination } from './adapters.ts
10
10
  import { destinationWords, whatGoes, type GoesItem } from '../github/compose.ts'
11
11
  import { validContact } from '../contract.mjs'
12
12
  import type { Contact } from './adapters.ts'
13
- import { collectContext, type ReportContext } from './context.ts'
13
+ import { collectContext, contextOff, type ReportContext } from './context.ts'
14
14
  import { useDialog } from './dialog.ts'
15
15
  import { DefaultField } from './default-field.tsx'
16
16
  import { draftKey, loadDraft, saveDraft } from './drafts.ts'
@@ -99,10 +99,13 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
99
99
  adapters.destination().then((d) => { if (live) setDest(d && typeof d.repo === 'string' ? d : null) }, () => { if (live) setDest(null) })
100
100
  return () => { live = false }
101
101
  }, [adapters])
102
+ // What the app turned off, as the list's keys: never collected, never listed, never sent.
103
+ const off = useMemo(() => contextOff(adapters.context), [adapters])
104
+ const offPieces = useMemo(() => new Set(off.map((k) => (k === 'pages' ? 'routes' : k === 'taps' || k === 'errors' ? k : `ctx:${k}`))), [off])
102
105
  const contextOptions = useCallback(() => {
103
106
  const c = adapters.context
104
- return typeof c === 'function' ? { extra: c, precise: false } : { extra: c?.items ? () => c.items!() : undefined, precise: Boolean(c?.precise) }
105
- }, [adapters])
107
+ return typeof c === 'function' ? { extra: c, precise: false, off } : { extra: c?.items ? () => c.items!() : undefined, precise: Boolean(c?.precise), off }
108
+ }, [adapters, off])
106
109
  const gather = useCallback(() => collectContext({ identity: who ?? null, where, about: adapters.about, ...contextOptions(), ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) }), [who, where, adapters, contextOptions])
107
110
  useEffect(() => {
108
111
  if (!who) return
@@ -293,6 +296,8 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
293
296
  { name: 'screen', label: S.diagScreen, value: screenSize() },
294
297
  { name: 'device', label: S.diagDevice, value: adapters.device ? adapters.device() : phoneType() },
295
298
  ] as { name: Diag; label: string; value: string | undefined }[]).filter((d): d is { name: Diag; label: string; value: string } => Boolean(d.value))
299
+ // A diagnostic whose context item the app turned off is not said, listed or sent either.
300
+ .filter((d) => !offPieces.has(({ device: 'ctx:device', screen: 'ctx:screen', at: 'ctx:time' } as Record<string, string>)[d.name] ?? ''))
296
301
  const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)]
297
302
  const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`
298
303
  // With a destination, the one line becomes the list of exactly what the issue will carry, built by the same function
@@ -307,7 +312,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
307
312
  ...(ctx ? { context: ctx } : {}),
308
313
  ...(contact ? { who: { contact } } : {}),
309
314
  }, dest ?? { repo: '', public: false, account: '', attaches: true })
310
- const isOn = (key: string, fallback = true) => choice[key] ?? rows.find((r) => r.key === key)?.on ?? fallback
315
+ const isOn = (key: string, fallback = true) => !offPieces.has(key) && (choice[key] ?? rows.find((r) => r.key === key)?.on ?? fallback)
311
316
  const goes = dest ? rows : null
312
317
  const destWords = dest ? destinationWords(dest) : null
313
318
  const optionalRow = (g: GoesItem) => (
@@ -474,6 +479,9 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
474
479
  </div>
475
480
  )}
476
481
 
482
+ {/* The app's own note: under the list, before Send, never instead of it. */}
483
+ {S.destinationNote ? <p className="sac-note" data-sac-note="">{S.destinationNote}</p> : null}
484
+
477
485
  {error ? <p role="alert" className="sac-error">{error}</p> : null}
478
486
  <div className="sac-actions">
479
487
  <button type="submit" className="sac-button sac-primary" disabled={!hasAnything} aria-describedby={hasAnything ? undefined : `${titleId}-hint`}>{S.send}</button>
@@ -58,6 +58,7 @@
58
58
  .sac-error { color: var(--sac-danger); margin: 0; }
59
59
  .sac-danger { color: var(--sac-danger); font-weight: 600; }
60
60
  .sac-goes p { margin: 0; }
61
+ .sac-note { font-size: 0.8rem; margin: 0; color: var(--sac-fg); }
61
62
  .sac-goes-list { margin: 2px 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 2px 0; }
62
63
  .sac-goes-list li:not(:last-child)::after { content: " \00b7 "; white-space: pre; color: var(--sac-muted); }
63
64
  .sac-issue-link { color: var(--sac-accent); font-size: 0.9rem; }
@@ -109,6 +109,9 @@ export interface Strings {
109
109
  issueLink: (n: number) => string
110
110
  /** The accessible name of the list of context, each row a tick box. */
111
111
  contextLegend: string
112
+ /** The app's own note, shown under the what-goes list (or the one line) and before Send, never instead of it: what
113
+ * only the app can say about where a report goes and what it carries. Empty shows nothing. */
114
+ destinationNote: string
112
115
  }
113
116
 
114
117
  export const DEFAULT_STRINGS: Strings = {
@@ -199,6 +202,7 @@ export const DEFAULT_STRINGS: Strings = {
199
202
  movedToast: (words, note) => (note ? `Your suggestion: ${words}. ${note}` : `Your suggestion: ${words}`),
200
203
  issueLink: (n) => `Issue #${n}`,
201
204
  contextLegend: 'Also shared, so it can be fixed (untick anything to leave it out)',
205
+ destinationNote: '',
202
206
  }
203
207
 
204
208
  export function stringsFor(words: Words): Strings {