@supersuit/suggest-a-change 0.2.3 → 0.2.4

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.2.4 (2026-10-26)
4
+
5
+ - **The list of what a suggestion carries is folded by default.** With a destination, the sheet above Send says one line, "Files an issue on <repo> (private)" or, in `--sac-danger` and never folded, "(public: anyone can read it)", then a toggle "What it carries (N)" counting what will go (the always rows plus each ticked one). Tapping it opens the full list with its tick boxes, reasons and account line; unticking inside still leaves an item out and lowers the count. Without a destination the tick boxes fold under the one line as "Also shared (N)". The toggle is a `<button aria-expanded aria-controls>` with a 44px target (`.sac-fold`); the app's `destinationNote` stays visible outside the fold. New replaceable words: `filesOn`, `privateShort`, `publicShort`, `carriesToggle`, `alsoSharedToggle`, `carriesLead`. `destinationWords` is unchanged. Earned by the operator's read of the open list on a phone: "it's a little bit disturbing with all the checkboxes shown."
6
+
3
7
  ## 0.2.3 (2026-10-26)
4
8
 
5
9
  - **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".
package/README.md CHANGED
@@ -101,6 +101,8 @@ The issue carries it all in a "Context (shared so this can be fixed)" table of w
101
101
 
102
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
103
 
104
+ **The list is folded by default.** Above Send the sheet shows one line saying where it files ("Files an issue on acme/notes (private)"; a public repo reads "(public: anyone can read it)" in `--sac-danger`, always shown, never folded) and a toggle with the count of what goes ("What it carries (14)"). Tapping it opens the itemized list above with its tick boxes and reasons, and the account line; the count follows what is ticked. Without a destination the tick boxes fold the same way under the one line ("Also shared (9)"). The toggle is a `<button aria-expanded>` (`.sac-fold`, a 44px target), and the app's `destinationNote` stays outside the fold. Every word of it is replaceable through `words.text` (`filesOn`, `privateShort`, `publicShort`, `carriesToggle`, `alsoSharedToggle`, `carriesLead`).
105
+
104
106
  **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.
105
107
 
106
108
  **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).
@@ -85,6 +85,9 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
85
85
  const [ctx, setCtx] = useState(null);
86
86
  const [contact, setContact] = useState(null);
87
87
  const [choice, setChoice] = useState({});
88
+ // The itemized list of what goes, with its tick boxes, is folded until the person opens it: a wall of checkboxes was
89
+ // "a little bit disturbing" (the operator, 2026-10-26). Where it files, and a public repo's warning, are never folded.
90
+ const [open, setOpen] = useState(false);
88
91
  // Where it files, asked once when the sheet opens; until it answers (or with none) the one-line note stays.
89
92
  useEffect(() => {
90
93
  if (!adapters.destination)
@@ -325,6 +328,10 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
325
328
  const goes = dest ? rows : null;
326
329
  const destWords = dest ? destinationWords(dest) : null;
327
330
  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));
331
+ // What the fold's toggle counts: everything that will go, the always rows plus each ticked one.
332
+ const goingCount = (list) => list.filter((g) => !g.optional || isOn(g.key)).length;
333
+ const foldId = `${titleId}-fold`;
334
+ const foldToggle = (label) => (_jsxs("button", { type: "button", className: "sac-button sac-fold", "aria-expanded": open, "aria-controls": foldId, "data-sac-fold": "", onClick: () => setOpen((o) => !o), children: [_jsx("span", { className: "sac-fold-mark", "aria-hidden": "true" }), label] }));
328
335
  const hasAnything = Boolean(words.trim() || voice.length || media.length);
329
336
  async function send() {
330
337
  if (!rt || !who || !key || !hasAnything)
@@ -399,5 +406,5 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
399
406
  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) => {
400
407
  const p = rt.part(sent.id, f.file);
401
408
  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));
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, {})] }) }));
409
+ }) })) : 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", "data-sac-where": "", children: [S.filesOn(dest.repo), ' (', _jsx("span", { className: destWords.danger ? 'sac-danger' : 'sac-muted', children: destWords.danger ? S.publicShort : S.privateShort }), ")"] }), foldToggle(S.carriesToggle(goingCount(goes))), open ? (_jsxs("div", { id: foldId, className: "sac-fold-body", children: [_jsx("p", { className: "sac-muted", children: S.carriesLead }), _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 })] })) : null] })) : (_jsxs("div", { className: "sac-fyi", children: [_jsx("p", { className: "sac-muted sac-fyi", "data-sac-summary": "", children: summary }), rows.some((g) => g.optional) ? foldToggle(S.alsoSharedToggle(rows.filter((g) => g.optional && isOn(g.key)).length)) : null, open && rows.some((g) => g.optional) ? (_jsx("div", { id: foldId, className: "sac-fold-body", children: _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, {})] }) }));
403
410
  }
@@ -106,6 +106,18 @@ export interface Strings {
106
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
107
  * only the app can say about where a report goes and what it carries. Empty shows nothing. */
108
108
  destinationNote: string;
109
+ /** The one line above Send that says where a suggestion files, before its privacy word ("Files an issue on acme/notes"). */
110
+ filesOn: (repo: string) => string;
111
+ /** The privacy word after it for a private repo. */
112
+ privateShort: string;
113
+ /** The same for a public repo, in the danger colour and never folded: the one fact that must not be missed. */
114
+ publicShort: string;
115
+ /** The toggle that opens the itemized list of what the issue will carry, with how many things go. */
116
+ carriesToggle: (n: number) => string;
117
+ /** The toggle without a destination, opening the context tick boxes, with how many are ticked. */
118
+ alsoSharedToggle: (n: number) => string;
119
+ /** The lead-in of the list once it is open. */
120
+ carriesLead: string;
109
121
  }
110
122
  export declare const DEFAULT_STRINGS: Strings;
111
123
  export declare function stringsFor(words: Words): Strings;
@@ -92,6 +92,12 @@ export const DEFAULT_STRINGS = {
92
92
  issueLink: (n) => `Issue #${n}`,
93
93
  contextLegend: 'Also shared, so it can be fixed (untick anything to leave it out)',
94
94
  destinationNote: '',
95
+ filesOn: (repo) => `Files an issue on ${repo}`,
96
+ privateShort: 'private',
97
+ publicShort: 'public: anyone can read it',
98
+ carriesToggle: (n) => `What it carries (${n})`,
99
+ alsoSharedToggle: (n) => `Also shared (${n})`,
100
+ carriesLead: 'It will carry:',
95
101
  };
96
102
  export function stringsFor(words) {
97
103
  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",
3
+ "version": "0.2.4",
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": {
@@ -92,6 +92,9 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
92
92
  const [ctx, setCtx] = useState<ReportContext | null>(null)
93
93
  const [contact, setContact] = useState<Contact | null>(null)
94
94
  const [choice, setChoice] = useState<Record<string, boolean>>({})
95
+ // The itemized list of what goes, with its tick boxes, is folded until the person opens it: a wall of checkboxes was
96
+ // "a little bit disturbing" (the operator, 2026-10-26). Where it files, and a public repo's warning, are never folded.
97
+ const [open, setOpen] = useState(false)
95
98
  // Where it files, asked once when the sheet opens; until it answers (or with none) the one-line note stays.
96
99
  useEffect(() => {
97
100
  if (!adapters.destination) return
@@ -325,6 +328,15 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
325
328
  </li>
326
329
  )
327
330
 
331
+ // What the fold's toggle counts: everything that will go, the always rows plus each ticked one.
332
+ const goingCount = (list: GoesItem[]) => list.filter((g) => !g.optional || isOn(g.key)).length
333
+ const foldId = `${titleId}-fold`
334
+ const foldToggle = (label: string) => (
335
+ <button type="button" className="sac-button sac-fold" aria-expanded={open} aria-controls={foldId} data-sac-fold="" onClick={() => setOpen((o) => !o)}>
336
+ <span className="sac-fold-mark" aria-hidden="true" />{label}
337
+ </button>
338
+ )
339
+
328
340
  const hasAnything = Boolean(words.trim() || voice.length || media.length)
329
341
 
330
342
  async function send() {
@@ -466,16 +478,26 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
466
478
 
467
479
  {goes && destWords ? (
468
480
  <div className="sac-goes sac-fyi" data-sac-goes="">
469
- <p className="sac-goes-head"><strong>{destWords.headline}</strong>{' ('}<span className={destWords.danger ? 'sac-danger' : 'sac-muted'}>{destWords.privacy}</span>)</p>
470
- <p className="sac-muted">It will carry:</p>
471
- <ul className="sac-goes-list">{goes.filter((g) => !g.optional).map((g) => <li key={g.key} data-goes={g.key}>{g.text}</li>)}</ul>
472
- {goes.some((g) => g.optional) ? <ul className="sac-goes-opts" aria-label={S.contextLegend}>{goes.filter((g) => g.optional).map(optionalRow)}</ul> : null}
473
- <p className="sac-muted">{destWords.account}</p>
481
+ <p className="sac-goes-head" data-sac-where="">{S.filesOn(dest!.repo)}{' ('}<span className={destWords.danger ? 'sac-danger' : 'sac-muted'}>{destWords.danger ? S.publicShort : S.privateShort}</span>)</p>
482
+ {foldToggle(S.carriesToggle(goingCount(goes)))}
483
+ {open ? (
484
+ <div id={foldId} className="sac-fold-body">
485
+ <p className="sac-muted">{S.carriesLead}</p>
486
+ <ul className="sac-goes-list">{goes.filter((g) => !g.optional).map((g) => <li key={g.key} data-goes={g.key}>{g.text}</li>)}</ul>
487
+ {goes.some((g) => g.optional) ? <ul className="sac-goes-opts" aria-label={S.contextLegend}>{goes.filter((g) => g.optional).map(optionalRow)}</ul> : null}
488
+ <p className="sac-muted">{destWords.account}</p>
489
+ </div>
490
+ ) : null}
474
491
  </div>
475
492
  ) : (
476
493
  <div className="sac-fyi">
477
494
  <p className="sac-muted sac-fyi" data-sac-summary="">{summary}</p>
478
- {rows.some((g) => g.optional) ? <ul className="sac-goes-opts" aria-label={S.contextLegend}>{rows.filter((g) => g.optional).map(optionalRow)}</ul> : null}
495
+ {rows.some((g) => g.optional) ? foldToggle(S.alsoSharedToggle(rows.filter((g) => g.optional && isOn(g.key)).length)) : null}
496
+ {open && rows.some((g) => g.optional) ? (
497
+ <div id={foldId} className="sac-fold-body">
498
+ <ul className="sac-goes-opts" aria-label={S.contextLegend}>{rows.filter((g) => g.optional).map(optionalRow)}</ul>
499
+ </div>
500
+ ) : null}
479
501
  </div>
480
502
  )}
481
503
 
@@ -61,6 +61,13 @@
61
61
  .sac-note { font-size: 0.8rem; margin: 0; color: var(--sac-fg); }
62
62
  .sac-goes-list { margin: 2px 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 2px 0; }
63
63
  .sac-goes-list li:not(:last-child)::after { content: " \00b7 "; white-space: pre; color: var(--sac-muted); }
64
+ /* The fold over the itemized list of what goes: a quiet text-weight toggle, still a 44px target. */
65
+ .sac-fold { align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; border-color: transparent; background: transparent; padding-left: 0; color: var(--sac-muted); font-size: 0.9rem; margin: 2px 0 0; }
66
+ .sac-fold-mark { width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent; border-left: 6px solid currentColor; }
67
+ .sac-fold[aria-expanded="true"] .sac-fold-mark { transform: rotate(90deg); }
68
+ .sac-fold-body { display: flex; flex-direction: column; gap: 4px; }
69
+ .sac-goes-opts { margin: 0; padding: 0; list-style: none; }
70
+ .sac-goes-opt label { display: block; min-height: 44px; padding: 4px 0; box-sizing: border-box; }
64
71
  .sac-issue-link { color: var(--sac-accent); font-size: 0.9rem; }
65
72
  .sac-receipt-line { font-weight: 600; margin: 0; }
66
73
  .sac-strip { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
@@ -79,4 +86,4 @@
79
86
  .sac-entry-why { margin-left: 8px; color: var(--sac-muted); font-size: 0.9rem; }
80
87
  .sac-sheet :focus-visible, .sac-entry:focus-visible, .sac-button:focus-visible, .sac-record:focus-visible, .sac-row-button:focus-visible { outline: 3px solid var(--sac-focus); outline-offset: 2px; }
81
88
  @media (min-width: 640px) { .sac-backdrop { align-items: center; } .sac-sheet { border-radius: var(--sac-radius); } }
82
- @media (prefers-reduced-motion: no-preference) { .sac-voice[data-recording] .sac-record { transition: box-shadow 0.15s; } }
89
+ @media (prefers-reduced-motion: no-preference) { .sac-voice[data-recording] .sac-record { transition: box-shadow 0.15s; } .sac-fold-mark { transition: transform 0.15s; } }
@@ -112,6 +112,18 @@ export interface Strings {
112
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
113
  * only the app can say about where a report goes and what it carries. Empty shows nothing. */
114
114
  destinationNote: string
115
+ /** The one line above Send that says where a suggestion files, before its privacy word ("Files an issue on acme/notes"). */
116
+ filesOn: (repo: string) => string
117
+ /** The privacy word after it for a private repo. */
118
+ privateShort: string
119
+ /** The same for a public repo, in the danger colour and never folded: the one fact that must not be missed. */
120
+ publicShort: string
121
+ /** The toggle that opens the itemized list of what the issue will carry, with how many things go. */
122
+ carriesToggle: (n: number) => string
123
+ /** The toggle without a destination, opening the context tick boxes, with how many are ticked. */
124
+ alsoSharedToggle: (n: number) => string
125
+ /** The lead-in of the list once it is open. */
126
+ carriesLead: string
115
127
  }
116
128
 
117
129
  export const DEFAULT_STRINGS: Strings = {
@@ -203,6 +215,12 @@ export const DEFAULT_STRINGS: Strings = {
203
215
  issueLink: (n) => `Issue #${n}`,
204
216
  contextLegend: 'Also shared, so it can be fixed (untick anything to leave it out)',
205
217
  destinationNote: '',
218
+ filesOn: (repo) => `Files an issue on ${repo}`,
219
+ privateShort: 'private',
220
+ publicShort: 'public: anyone can read it',
221
+ carriesToggle: (n) => `What it carries (${n})`,
222
+ alsoSharedToggle: (n) => `Also shared (${n})`,
223
+ carriesLead: 'It will carry:',
206
224
  }
207
225
 
208
226
  export function stringsFor(words: Words): Strings {