@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 +4 -0
- package/README.md +1 -1
- package/dist/client/sheet.js +6 -10
- package/package.json +1 -1
- package/src/client/sheet.tsx +6 -19
- package/src/client/styles.css +1 -0
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
|
|
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
|
|
package/dist/client/sheet.js
CHANGED
|
@@ -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:
|
|
257
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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] }),
|
|
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.
|
|
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": {
|
package/src/client/sheet.tsx
CHANGED
|
@@ -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:
|
|
255
|
-
//
|
|
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
|
-
|
|
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)
|
|
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
|
-
<
|
|
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">
|
package/src/client/styles.css
CHANGED
|
@@ -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; }
|