@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 +6 -0
- package/README.md +6 -2
- package/dist/client/adapters.d.ts +4 -2
- package/dist/client/context.d.ts +13 -2
- package/dist/client/context.js +60 -9
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +1 -1
- package/dist/client/runtime.js +4 -2
- package/dist/client/sheet.js +11 -6
- package/dist/client/words.d.ts +3 -0
- package/dist/client/words.js +1 -0
- package/package.json +1 -1
- package/src/client/adapters.ts +4 -3
- package/src/client/context.ts +59 -11
- package/src/client/index.ts +2 -2
- package/src/client/runtime.ts +4 -2
- package/src/client/sheet.tsx +12 -4
- package/src/client/styles.css +1 -0
- package/src/client/words.ts +4 -0
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.
|
|
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
|
|
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>;
|
package/dist/client/context.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|
package/dist/client/context.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
88
|
-
|
|
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) => {
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/client/index.js
CHANGED
|
@@ -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';
|
package/dist/client/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/client/sheet.js
CHANGED
|
@@ -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
|
}
|
package/dist/client/words.d.ts
CHANGED
|
@@ -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;
|
package/dist/client/words.js
CHANGED
|
@@ -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.
|
|
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": {
|
package/src/client/adapters.ts
CHANGED
|
@@ -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
|
|
88
|
-
|
|
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
|
}
|
package/src/client/context.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -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'
|
package/src/client/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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 })
|
package/src/client/sheet.tsx
CHANGED
|
@@ -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>
|
package/src/client/styles.css
CHANGED
|
@@ -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; }
|
package/src/client/words.ts
CHANGED
|
@@ -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 {
|