@supersuit/suggest-a-change 0.1.8 → 0.1.10

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.10 (2026-10-17)
4
+
5
+ - **What goes with it is one small line.** The sheet no longer draws a section with a legend, an explainer and four switches above Send. Every diagnostic (the app's version, the time, the screen size, the phone type) goes with every report, beside the screen's name and address, and one muted line says what goes and to whom. Whoever acts on a report needs all of them, so leaving one out only ever cost a round trip. The `Words` keys the switches used (`withLegend`, `alwaysSent`, `include`, `screenName`) stay, so an app that overrides them still typechecks.
6
+
7
+ ## 0.1.9 (2026-10-17)
8
+
9
+ - **The door helpers are exported from `./server`.** `json`, `signIn`, `notFound`, `cappedText` and `peek` are what an app's own route wrapper needs to answer as the handlers do and never read a body past a cap. Two apps (cofounders-template, first-years-template) carried byte-identical copies; they now import these. The handlers use the same functions, so there is one copy of the door. No behaviour change.
10
+
3
11
  ## 0.1.8 (2026-10-17)
4
12
 
5
13
  - **The news reaches a person whose entry sits in a menu.** In the Cofounders and Us apps the entry lives in a three-dot menu that stays closed and remounts it on every open, so the 0.1.7 dot was never seen and the toast was drawn inside the closed menu. New in `./client`: `<SuggestNews adapters>`, mounted once at the root, shows the toast with no entry on screen (and keeps the outbox moving); `useSuggestNews(adapters)` returns `{ moved }` for the menu's trigger; `<SuggestDot adapters moved>` draws the entry's dot there (nothing at 0), and the class `sac-dot-host` gives the trigger the `position: relative` it needs. The toast is once per page load and account however many times the entry mounts, whichever of these asks first. `<SuggestEntry>` keeps its own dot and, alone, behaves as in 0.1.7.
package/README.md CHANGED
@@ -69,7 +69,7 @@ Styling: structure only, class names prefixed `sac-`. `@supersuit/suggest-a-chan
69
69
  | `--sac-backdrop` | `rgba(0, 0, 0, 0.45)` | the dim behind the sheet |
70
70
  | `--sac-focus` | `#1a56db` | focus rings |
71
71
 
72
- The four diagnostics are the app's version, the time, the screen size and the phone type; the app's version and the phone type are on by default and the time and screen size are off (the report's id and the app's own received time already say when it was sent), and the screen's name and address are always sent and shown as such.
72
+ The four diagnostics are the app's version, the time, the screen size and the phone type. All four go with every report, with the screen's name and address, and the sheet says so in one small line above Send rather than a section of switches.
73
73
 
74
74
  **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).
75
75
 
@@ -212,6 +212,8 @@ A client adapter's `uploadPart` asks `upload-url`, PUTs the bytes straight to st
212
212
 
213
213
  The role stored on a report comes from the signed-in member and the `maintainers` list. A `who` sent by the phone is ignored; a report carrying `timeline`, `status`, `plan` or `decision` is refused. `sniffType(firstBytes)` names a file by its bytes (jpeg, png, webp, heic/heif, gif, mp4/mov, webm, m4a/aac, mp3, wav, ogg/opus; anything else is `null`) and `finalize` refuses a file whose bytes are not the type it was declared as.
214
214
 
215
+ **A wrapper of your own answers in the module's words.** When an app's route has to check a caller or read one field before the handler runs, it uses the same door the handlers use: `json(status, body)` (JSON, never cached), `signIn()` (401) and `notFound()` (404), `cappedText(req, cap)` (the body as text, or a 413 `Response` past `cap` bytes, by the declared length and again by counting what arrives; no body reads as `""`), and `peek(text)` (a JSON object, or `{}` for anything else, for reading one field while the module judges the body). Copying these into an app made a second door that could drift from the first.
216
+
215
217
  **Settle carries a revision.** `agentList` gives each report a `revision`; the Mac sends it back with `settle`, and the store settles only if the report still has exactly that content. A follow-up or file that arrived in between answers 409 `stale`, drops the claim, and the next list offers the report with the addition. Adapters compute the same value with `reportRevision`.
216
218
 
217
219
  **The collector MUST verify.** `agentFile` answers the verified `bytes` and `sha256`; a Mac that downloads the link must check both before it trusts the file.
@@ -69,8 +69,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
69
69
  const [answers, setAnswers] = useState({ ...NO_ANSWERS, happened: initial?.happened ?? '' });
70
70
  const [voice, setVoice] = useState([]);
71
71
  const [media, setMedia] = useState([]);
72
- // The design's example line is the default: version, screen name (always) and phone type. The time and the screen size are opt-in.
73
- const [on, setOn] = useState({ appVersion: true, at: false, screen: false, device: true });
74
72
  const [error, setError] = useState('');
75
73
  const [announce, setAnnounce] = useState('');
76
74
  const [sent, setSent] = useState(null);
@@ -253,17 +251,16 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
253
251
  await rt?.store.del('kv', metaKey(v.key));
254
252
  setAnnounce(S.voiceNoteRemoved);
255
253
  }
256
- // What goes with it: exactly the contract's four diagnostics, each with a switch that really removes it.
257
- // Where the person was (the screen's name and address) is what the report is about, so it is always sent and shown as such.
254
+ // What goes with it: the contract's four diagnostics, always sent, with no switches. Whoever reads the report
255
+ // needs the device, the time and the screen to act on it, so leaving one out only ever cost a round trip.
256
+ // The sheet says so in one small FYI line rather than a section to configure.
258
257
  const avail = [
259
258
  { name: 'appVersion', label: S.diagAppVersion, value: adapters.about?.version },
260
259
  { name: 'at', label: S.diagAt, value: new Date(adapters.phone?.now?.() ?? Date.now()).toISOString() },
261
260
  { name: 'screen', label: S.diagScreen, value: screenSize() },
262
261
  { name: 'device', label: S.diagDevice, value: adapters.device ? adapters.device() : phoneType() },
263
262
  ].filter((d) => Boolean(d.value));
264
- // The design's order: the app's version, this screen's name (always), then whatever else is on, phone type last.
265
- const isOn = (n) => avail.find((d) => d.name === n && on[d.name])?.label;
266
- const lineParts = [isOn('appVersion'), S.screenName, isOn('at'), isOn('screen'), isOn('device')].filter((x) => Boolean(x));
263
+ const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)];
267
264
  const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`;
268
265
  // Answers to the broken questions only count while the kind is broken: they are not sent otherwise.
269
266
  const hasAnything = Boolean(words.trim() || voice.length || media.length || (kind === 'broken' && (answers.happened.trim() || answers.expected.trim() || answers.steps.trim())));
@@ -275,8 +272,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
275
272
  const d = await saveDraft(rt.store, key, { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] });
276
273
  const diagnostics = {};
277
274
  for (const x of avail)
278
- if (on[x.name])
279
- diagnostics[x.name] = x.value;
275
+ diagnostics[x.name] = x.value;
280
276
  const report = {
281
277
  id: d.reportId,
282
278
  kind,
@@ -335,5 +331,5 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
335
331
  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) => {
336
332
  const p = rt.part(sent.id, f.file);
337
333
  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));
338
- }) })) : 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("fieldset", { role: "radiogroup", className: "sac-group sac-kinds", children: [_jsx("legend", { children: S.kindLegend }), kinds.map((k) => (_jsxs("label", { className: "sac-choice", children: [_jsx("input", { type: "radio", name: "sac-kind", value: k.id, checked: kind === k.id, onChange: () => setKind(k.id) }), _jsx("span", { children: k.label })] }, k.id)))] }), kind === 'broken' ? (_jsxs("fieldset", { className: "sac-group", children: [_jsxs("legend", { children: [S.kindBroken, " (", S.optional, ")"] }), fieldFor('happened', S.brokenHappened, answers.happened, (v) => setAnswers((a) => ({ ...a, happened: v }))), fieldFor('expected', S.brokenExpected, answers.expected, (v) => setAnswers((a) => ({ ...a, expected: v }))), fieldFor('steps', S.brokenSteps, answers.steps, (v) => setAnswers((a) => ({ ...a, steps: v })))] })) : null, _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: 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] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.withLegend }), _jsx("p", { className: "sac-summary", "data-sac-summary": "", "aria-live": "polite", children: summary }), _jsx("p", { className: "sac-muted", "data-sac-where": "", children: S.alwaysSent(where.screen, sanitizeRoute(where.route)) }), avail.map((d) => (_jsxs("label", { className: "sac-switch", children: [_jsx("input", { type: "checkbox", role: "switch", checked: on[d.name], "aria-label": S.include(d.label), onChange: (e) => setOn((cur) => ({ ...cur, [d.name]: e.target.checked })) }), _jsxs("span", { children: [d.label, ": ", _jsx("span", { className: "sac-muted", children: d.value })] })] }, d.name)))] }), 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, {})] }) }));
334
+ }) })) : 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("fieldset", { role: "radiogroup", className: "sac-group sac-kinds", children: [_jsx("legend", { children: S.kindLegend }), kinds.map((k) => (_jsxs("label", { className: "sac-choice", children: [_jsx("input", { type: "radio", name: "sac-kind", value: k.id, checked: kind === k.id, onChange: () => setKind(k.id) }), _jsx("span", { children: k.label })] }, k.id)))] }), kind === 'broken' ? (_jsxs("fieldset", { className: "sac-group", children: [_jsxs("legend", { children: [S.kindBroken, " (", S.optional, ")"] }), fieldFor('happened', S.brokenHappened, answers.happened, (v) => setAnswers((a) => ({ ...a, happened: v }))), fieldFor('expected', S.brokenExpected, answers.expected, (v) => setAnswers((a) => ({ ...a, expected: v }))), fieldFor('steps', S.brokenSteps, answers.steps, (v) => setAnswers((a) => ({ ...a, steps: v })))] })) : null, _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: 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] }), _jsx("p", { className: "sac-muted sac-fyi", "data-sac-summary": "", children: summary }), 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, {})] }) }));
339
335
  }
@@ -10,6 +10,7 @@
10
10
  import { END, FLOW, STATUSES, TOLD, isAcceptedMedia, isReportId, mayMove, meaningFor, validateReport, wordsFor } from '../contract.mjs';
11
11
  import { effectiveLimits } from './rate.js';
12
12
  import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.js';
13
+ import { json, notFound, signIn, cappedText } from './http.js';
13
14
  /** What a collector saw of a report: its verified files and its follow-ups. A store computes this from its own copy,
14
15
  * inside `settle`, and settles only when it equals what the collector says it collected. */
15
16
  export function reportRevision(r) {
@@ -35,40 +36,17 @@ export const INCOMPLETE_GRACE_MS = 60 * 60_000;
35
36
  const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
36
37
  const FOLLOW = /^[A-Za-z0-9_-]{1,64}$/;
37
38
  const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy'];
38
- const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
39
- const notFound = () => json(404, { error: 'not found' });
40
- const signIn = () => json(401, { error: 'sign in first' });
41
39
  const bad = (error) => json(400, { error });
42
40
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
43
41
  /** Reads a JSON object body, refusing past `BODY_CAP` by the header and again by counting what arrives. */
44
42
  async function readBody(req, cap = BODY_CAP, optional = false) {
45
- const declared = Number(req.headers.get('content-length'));
46
- if (Number.isFinite(declared) && declared > cap)
47
- return { res: json(413, { error: 'too large' }) };
48
- if (!req.body)
43
+ if (!req.body && !(Number(req.headers.get('content-length')) > cap))
49
44
  return optional ? { body: {} } : { res: bad('a JSON body is needed') };
50
- const reader = req.body.getReader();
51
- const chunks = [];
52
- let n = 0;
53
- for (;;) {
54
- const { done, value } = await reader.read();
55
- if (done)
56
- break;
57
- n += value.byteLength;
58
- if (n > cap) {
59
- await reader.cancel().catch(() => { });
60
- return { res: json(413, { error: 'too large' }) };
61
- }
62
- chunks.push(value);
63
- }
64
- const all = new Uint8Array(n);
65
- let o = 0;
66
- for (const c of chunks) {
67
- all.set(c, o);
68
- o += c.byteLength;
69
- }
45
+ const text = await cappedText(req, cap);
46
+ if (typeof text !== 'string')
47
+ return { res: text };
70
48
  try {
71
- const v = JSON.parse(new TextDecoder().decode(all));
49
+ const v = JSON.parse(text);
72
50
  return isObj(v) ? { body: v } : optional ? { body: {} } : { res: bad('a JSON object is needed') };
73
51
  }
74
52
  catch {
@@ -0,0 +1,10 @@
1
+ /** A JSON response that is never cached. */
2
+ export declare const json: (status: number, body: unknown) => Response;
3
+ /** 401, the module's answer to a caller it does not know. */
4
+ export declare const signIn: () => Response;
5
+ /** 404, the module's answer to a report that is gone or not the caller's (the two are never told apart). */
6
+ export declare const notFound: () => Response;
7
+ /** The body as text, or a 413 once it passes `cap` bytes: by its declared length, and again by counting what arrives. A request with no body reads as ''. */
8
+ export declare function cappedText(req: Request, cap: number): Promise<string | Response>;
9
+ /** A JSON object out of text, or an empty one: the module judges the body itself; a wrapper only peeks at a field. */
10
+ export declare function peek(text: string): Record<string, unknown>;
@@ -0,0 +1,49 @@
1
+ // The door, in the module's own words: the answers the handlers give for "who are you" and "no such report", a body
2
+ // read that stops at a cap, and a peek at one field. Exported so an app's own route wrapper (one that has to check a
3
+ // caller, or read a field, before the module sees the request) answers exactly as the module does and never reads a
4
+ // body past the cap. Web-standard APIs only.
5
+ /** A JSON response that is never cached. */
6
+ export const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
7
+ /** 401, the module's answer to a caller it does not know. */
8
+ export const signIn = () => json(401, { error: 'sign in first' });
9
+ /** 404, the module's answer to a report that is gone or not the caller's (the two are never told apart). */
10
+ export const notFound = () => json(404, { error: 'not found' });
11
+ /** The body as text, or a 413 once it passes `cap` bytes: by its declared length, and again by counting what arrives. A request with no body reads as ''. */
12
+ export async function cappedText(req, cap) {
13
+ const declared = Number(req.headers.get('content-length'));
14
+ if (Number.isFinite(declared) && declared > cap)
15
+ return json(413, { error: 'too large' });
16
+ if (!req.body)
17
+ return '';
18
+ const reader = req.body.getReader();
19
+ const chunks = [];
20
+ let n = 0;
21
+ for (;;) {
22
+ const { done, value } = await reader.read();
23
+ if (done)
24
+ break;
25
+ n += value.byteLength;
26
+ if (n > cap) {
27
+ await reader.cancel().catch(() => { });
28
+ return json(413, { error: 'too large' });
29
+ }
30
+ chunks.push(value);
31
+ }
32
+ const all = new Uint8Array(n);
33
+ let o = 0;
34
+ for (const c of chunks) {
35
+ all.set(c, o);
36
+ o += c.byteLength;
37
+ }
38
+ return new TextDecoder().decode(all);
39
+ }
40
+ /** A JSON object out of text, or an empty one: the module judges the body itself; a wrapper only peeks at a field. */
41
+ export function peek(text) {
42
+ try {
43
+ const v = JSON.parse(text);
44
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
45
+ }
46
+ catch {
47
+ return {};
48
+ }
49
+ }
@@ -1,5 +1,6 @@
1
1
  export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts';
2
2
  export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts';
3
+ export { json, signIn, notFound, cappedText, peek } from './http.ts';
3
4
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts';
4
5
  export { effectiveLimits } from './rate.ts';
5
6
  export type { Limits } from './rate.ts';
@@ -1,5 +1,6 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
2
  export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.js';
3
+ export { json, signIn, notFound, cappedText, peek } from './http.js';
3
4
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.js';
4
5
  export { effectiveLimits } from './rate.js';
5
6
  export { runStoreConformance, checks as conformanceChecks } from './conformance.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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": {
@@ -75,8 +75,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
75
75
  const [answers, setAnswers] = useState<Answers>({ ...NO_ANSWERS, happened: initial?.happened ?? '' })
76
76
  const [voice, setVoice] = useState<Voice[]>([])
77
77
  const [media, setMedia] = useState<Media[]>([])
78
- // The design's example line is the default: version, screen name (always) and phone type. The time and the screen size are opt-in.
79
- const [on, setOn] = useState<Record<Diag, boolean>>({ appVersion: true, at: false, screen: false, device: true })
80
78
  const [error, setError] = useState('')
81
79
  const [announce, setAnnounce] = useState('')
82
80
  const [sent, setSent] = useState<{ id: string; files: { file: string; name: string; bytes: number }[] } | null>(null)
@@ -251,17 +249,16 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
251
249
  setAnnounce(S.voiceNoteRemoved)
252
250
  }
253
251
 
254
- // What goes with it: exactly the contract's four diagnostics, each with a switch that really removes it.
255
- // Where the person was (the screen's name and address) is what the report is about, so it is always sent and shown as such.
252
+ // What goes with it: the contract's four diagnostics, always sent, with no switches. Whoever reads the report
253
+ // needs the device, the time and the screen to act on it, so leaving one out only ever cost a round trip.
254
+ // The sheet says so in one small FYI line rather than a section to configure.
256
255
  const avail = ([
257
256
  { name: 'appVersion', label: S.diagAppVersion, value: adapters.about?.version },
258
257
  { name: 'at', label: S.diagAt, value: new Date(adapters.phone?.now?.() ?? Date.now()).toISOString() },
259
258
  { name: 'screen', label: S.diagScreen, value: screenSize() },
260
259
  { name: 'device', label: S.diagDevice, value: adapters.device ? adapters.device() : phoneType() },
261
260
  ] as { name: Diag; label: string; value: string | undefined }[]).filter((d): d is { name: Diag; label: string; value: string } => Boolean(d.value))
262
- // The design's order: the app's version, this screen's name (always), then whatever else is on, phone type last.
263
- const isOn = (n: Diag) => avail.find((d) => d.name === n && on[d.name])?.label
264
- const lineParts = [isOn('appVersion'), S.screenName, isOn('at'), isOn('screen'), isOn('device')].filter((x): x is string => Boolean(x))
261
+ const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)]
265
262
  const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`
266
263
 
267
264
  // Answers to the broken questions only count while the kind is broken: they are not sent otherwise.
@@ -273,7 +270,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
273
270
  await chain.current
274
271
  const d = await saveDraft(rt.store, key, { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] })
275
272
  const diagnostics: Record<string, string> = {}
276
- for (const x of avail) if (on[x.name]) diagnostics[x.name] = x.value
273
+ for (const x of avail) diagnostics[x.name] = x.value
277
274
  const report: Record<string, unknown> & { id: string } = {
278
275
  id: d.reportId,
279
276
  kind,
@@ -411,17 +408,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
411
408
  ) : null}
412
409
  </fieldset>
413
410
 
414
- <fieldset className="sac-group">
415
- <legend>{S.withLegend}</legend>
416
- <p className="sac-summary" data-sac-summary="" aria-live="polite">{summary}</p>
417
- <p className="sac-muted" data-sac-where="">{S.alwaysSent(where.screen, sanitizeRoute(where.route))}</p>
418
- {avail.map((d) => (
419
- <label key={d.name} className="sac-switch">
420
- <input type="checkbox" role="switch" checked={on[d.name]} aria-label={S.include(d.label)} onChange={(e) => setOn((cur) => ({ ...cur, [d.name]: e.target.checked }))} />
421
- <span>{d.label}: <span className="sac-muted">{d.value}</span></span>
422
- </label>
423
- ))}
424
- </fieldset>
411
+ <p className="sac-muted sac-fyi" data-sac-summary="">{summary}</p>
425
412
 
426
413
  {error ? <p role="alert" className="sac-error">{error}</p> : null}
427
414
  <div className="sac-actions">
@@ -49,6 +49,7 @@
49
49
  .sac-entry-dot { position: absolute; top: 6px; right: 6px; width: 10px; height: 10px; border-radius: 50%; background: var(--sac-accent); box-shadow: 0 0 0 2px var(--sac-bg); }
50
50
  .sac-new { align-self: flex-start; font-size: 0.8rem; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: var(--sac-accent); color: var(--sac-accent-fg); }
51
51
  .sac-muted { color: var(--sac-muted); font-size: 0.9rem; }
52
+ .sac-fyi { font-size: 0.8rem; margin: 0; }
52
53
  .sac-error { color: var(--sac-danger); margin: 0; }
53
54
  .sac-receipt-line { font-weight: 600; margin: 0; }
54
55
  .sac-strip { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
@@ -10,6 +10,7 @@
10
10
  import { END, FLOW, STATUSES, TOLD, isAcceptedMedia, isReportId, mayMove, meaningFor, validateReport, wordsFor } from '../contract.mjs'
11
11
  import { effectiveLimits, type Limits } from './rate.ts'
12
12
  import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.ts'
13
+ import { json, notFound, signIn, cappedText } from './http.ts'
13
14
 
14
15
  /** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
15
16
  * or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
@@ -129,34 +130,17 @@ const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
129
130
  const FOLLOW = /^[A-Za-z0-9_-]{1,64}$/
130
131
  const FORBIDDEN = ['timeline', 'status', 'plan', 'decision', 'files', 'receivedAt', 'followUps', 'claimedBy', 'settledBy']
131
132
 
132
- const json = (status: number, body: unknown): Response =>
133
- new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
134
- const notFound = () => json(404, { error: 'not found' })
135
- const signIn = () => json(401, { error: 'sign in first' })
136
133
  const bad = (error: string) => json(400, { error })
137
134
  const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
138
135
 
139
136
  type Body = { body: Record<string, unknown> } | { res: Response }
140
137
  /** Reads a JSON object body, refusing past `BODY_CAP` by the header and again by counting what arrives. */
141
138
  async function readBody(req: Request, cap = BODY_CAP, optional = false): Promise<Body> {
142
- const declared = Number(req.headers.get('content-length'))
143
- if (Number.isFinite(declared) && declared > cap) return { res: json(413, { error: 'too large' }) }
144
- if (!req.body) return optional ? { body: {} } : { res: bad('a JSON body is needed') }
145
- const reader = req.body.getReader()
146
- const chunks: Uint8Array[] = []
147
- let n = 0
148
- for (;;) {
149
- const { done, value } = await reader.read()
150
- if (done) break
151
- n += value.byteLength
152
- if (n > cap) { await reader.cancel().catch(() => {}); return { res: json(413, { error: 'too large' }) } }
153
- chunks.push(value)
154
- }
155
- const all = new Uint8Array(n)
156
- let o = 0
157
- for (const c of chunks) { all.set(c, o); o += c.byteLength }
139
+ if (!req.body && !(Number(req.headers.get('content-length')) > cap)) return optional ? { body: {} } : { res: bad('a JSON body is needed') }
140
+ const text = await cappedText(req, cap)
141
+ if (typeof text !== 'string') return { res: text }
158
142
  try {
159
- const v = JSON.parse(new TextDecoder().decode(all))
143
+ const v = JSON.parse(text)
160
144
  return isObj(v) ? { body: v } : optional ? { body: {} } : { res: bad('a JSON object is needed') }
161
145
  } catch { return optional ? { body: {} } : { res: bad('the body is not JSON') } }
162
146
  }
@@ -0,0 +1,38 @@
1
+ // The door, in the module's own words: the answers the handlers give for "who are you" and "no such report", a body
2
+ // read that stops at a cap, and a peek at one field. Exported so an app's own route wrapper (one that has to check a
3
+ // caller, or read a field, before the module sees the request) answers exactly as the module does and never reads a
4
+ // body past the cap. Web-standard APIs only.
5
+
6
+ /** A JSON response that is never cached. */
7
+ export const json = (status: number, body: unknown): Response =>
8
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
9
+ /** 401, the module's answer to a caller it does not know. */
10
+ export const signIn = (): Response => json(401, { error: 'sign in first' })
11
+ /** 404, the module's answer to a report that is gone or not the caller's (the two are never told apart). */
12
+ export const notFound = (): Response => json(404, { error: 'not found' })
13
+
14
+ /** The body as text, or a 413 once it passes `cap` bytes: by its declared length, and again by counting what arrives. A request with no body reads as ''. */
15
+ export async function cappedText(req: Request, cap: number): Promise<string | Response> {
16
+ const declared = Number(req.headers.get('content-length'))
17
+ if (Number.isFinite(declared) && declared > cap) return json(413, { error: 'too large' })
18
+ if (!req.body) return ''
19
+ const reader = req.body.getReader()
20
+ const chunks: Uint8Array[] = []
21
+ let n = 0
22
+ for (;;) {
23
+ const { done, value } = await reader.read()
24
+ if (done) break
25
+ n += value.byteLength
26
+ if (n > cap) { await reader.cancel().catch(() => {}); return json(413, { error: 'too large' }) }
27
+ chunks.push(value)
28
+ }
29
+ const all = new Uint8Array(n)
30
+ let o = 0
31
+ for (const c of chunks) { all.set(c, o); o += c.byteLength }
32
+ return new TextDecoder().decode(all)
33
+ }
34
+
35
+ /** A JSON object out of text, or an empty one: the module judges the body itself; a wrapper only peeks at a field. */
36
+ export function peek(text: string): Record<string, unknown> {
37
+ try { const v = JSON.parse(text) as unknown; return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {} } catch { return {} }
38
+ }
@@ -1,6 +1,7 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
2
  export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
3
3
  export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts'
4
+ export { json, signIn, notFound, cappedText, peek } from './http.ts'
4
5
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts'
5
6
  export { effectiveLimits } from './rate.ts'
6
7
  export type { Limits } from './rate.ts'