@supersuit/suggest-a-change 0.1.9 → 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,9 @@
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
+
3
7
  ## 0.1.9 (2026-10-17)
4
8
 
5
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.
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
 
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.9",
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; }