@supersuit/suggest-a-change 0.1.11 → 0.1.13

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,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.1.13 (2026-10-21)
4
+
5
+ - **Old news is no longer toasted on every open.** The page load's one toast went to the newest move the person had not opened, however long ago it happened, so opening the app a day later replayed the same news each time. The phone now keeps what the last page load already had as news (`#view:noticed`, beside the seen record and cleared with it): only a move that is new since that load is toasted, and an older one keeps the dot and the New mark until it is opened. The first load on this release behaves as before. New in `seen.ts`: `fresh(store, id, moves)`.
6
+ - **A toast is a small pill at the bottom, not a bar over the top of the page.** `.sac-toasts` anchored to the top safe area and each `.sac-toast` spanned the width, so the suggestion news shown when an app opened sat over the host's own header and first row. Toasts now sit above the bottom safe area, as wide as their words (up to 24rem), in a smaller type, with a drawn × so they read as dismissable; a tap still dismisses. Styling only: no API or `Words` change. Test: package.test.mjs, red against the old stylesheet.
7
+
8
+ ## 0.1.12 (2026-10-19)
9
+
10
+ - **The kind is one small menu, and saying it is one big box for every kind.** The sheet drew three radio rows for the kind and a one-line field for the words, so the choice took the space and the place to speak looked like an afterthought. The kind is now a compact `<select>` labelled "What kind?", starting on "Change something", and the words go in one multi-line box (at least 9rem tall whatever field the host supplies, through `.sac-sayit textarea`) whose placeholder follows the kind. "Something is broken" no longer adds three question boxes: its placeholder asks what happened, what was expected and how to make it happen again, and the answer goes in `words`. A report no longer carries `answers` (the contract still accepts them). A draft left with answers from an earlier version folds them into the box once. New `Words` keys: `sayItPlaceholderBroken`, `sayItPlaceholderFeedback`; `brokenHappened`, `brokenExpected`, `brokenSteps` and `optional` stay so an override still typechecks. `ReportProblem`'s `happened` now starts the box.
4
11
 
5
12
  ## 0.1.11 (2026-10-18)
6
13
 
package/README.md CHANGED
@@ -75,7 +75,7 @@ The four diagnostics are the app's version, the time, the screen size and the ph
75
75
 
76
76
  **Telling the person in the app, with no setup.** The entry asks `mine()` once when it mounts and compares each report's status with the one this account last opened (kept on the phone beside the cached views, per app and account, and cleared on sign-out). A report that has since been started or ended (`TOLD`) shows a dot on the entry, says New on its row in My suggestions, and the newest such move gets one toast per page load in the module's words (`words.text.movedToast`, given the status's own word and, on `not-now`, the reason: "Your suggestion: Done", "Your suggestion: Not now. too big for now"). Opening the report clears it. A report this phone sees for the first time is recorded silently, so an app taking this release does not announce every old report. The list's `not-now` rows carry their one-line `note` for this. Out of the app, the server's `tellSubmitter` does the telling (below).
77
77
 
78
- **When the entry sits in a menu.** A host that shows the entry inside a menu that stays closed (a three-dot menu, often remounting its rows on every open) would never show the dot, and the toast would be drawn inside the closed menu. Mount `<SuggestNews>` once at the app's root, always mounted: it asks `mine()` when it mounts, shows the page load's one toast with no entry on screen, and keeps the outbox moving. Put the dot on the menu's own trigger with `useSuggestNews(adapters)`, which returns `{ moved }`, and `<SuggestDot>`, which draws the entry's dot (nothing when `moved` is 0). The trigger needs `position: relative`; the class `sac-dot-host` does it. The toast is once per page load and account however many times the entry mounts, whichever of these asks first. The entry in the menu keeps its own dot.
78
+ **When the entry sits in a menu.** A host that shows the entry inside a menu that stays closed (a three-dot menu, often remounting its rows on every open) would never show the dot, and the toast would be drawn inside the closed menu. Mount `<SuggestNews>` once at the app's root, always mounted: it asks `mine()` when it mounts, shows the page load's one toast with no entry on screen, and keeps the outbox moving. Put the dot on the menu's own trigger with `useSuggestNews(adapters)`, which returns `{ moved }`, and `<SuggestDot>`, which draws the entry's dot (nothing when `moved` is 0). The trigger needs `position: relative`; the class `sac-dot-host` does it. The toast is once per page load and account however many times the entry mounts, whichever of these asks first, and only for a move the previous page load had not already seen: older news keeps the dot and the New mark without a toast. The entry in the menu keeps its own dot.
79
79
 
80
80
  ```tsx
81
81
  import { SuggestDot, SuggestEntry, SuggestNews, useSuggestNews } from "@supersuit/suggest-a-change/client";
@@ -5,7 +5,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
5
5
  // shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
6
6
  import { useEffect, useState } from 'react';
7
7
  import { useRuntime } from './runtime.js';
8
- import { firstToastThisOpen, moved, onSeen } from './seen.js';
8
+ import { firstToastThisOpen, fresh, moved, onSeen } from './seen.js';
9
9
  import { SuggestSheet } from './sheet.js';
10
10
  import { toast, Toasts } from './toast.js';
11
11
  import { useClearOnSignOut } from './view-cache.js';
@@ -17,7 +17,7 @@ export function KeepSending({ adapters }) {
17
17
  return null;
18
18
  }
19
19
  // THE NEWS: once per mount, asks the app for this person's reports and compares each with what they last opened. A move
20
- // since then is counted (the dot), and the newest one gets the page load's one toast, whichever of the entry or a
20
+ // since then is counted (the dot), and the newest one the last page load had not already seen gets the page load's one toast, whichever of the entry or a
21
21
  // root-mounted SuggestNews asks first (the guard is per page load and account, in seen.ts). Opening a report anywhere
22
22
  // recounts. `toasted` says this mount showed the toast, so a standalone entry knows to draw a toast host for it.
23
23
  function useNews(adapters, rt) {
@@ -56,10 +56,14 @@ function useNews(adapters, rt) {
56
56
  if (!live || !rows || !now || now.account !== rt.who.account)
57
57
  return;
58
58
  const m = await count();
59
- if (!live || !m?.length || !firstToastThisOpen(id))
59
+ if (!live || !m)
60
+ return;
61
+ // Only a move the last page load had not already seen is toasted; an older one keeps just the dot.
62
+ const f = await fresh(rt.store, id, m);
63
+ if (!f.length || !firstToastThisOpen(id))
60
64
  return;
61
65
  const at = (x) => rows.find((r) => r.id === x)?.at ?? 0;
62
- const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0];
66
+ const top = [...f].sort((a, b) => at(b.id) - at(a.id))[0];
63
67
  const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined;
64
68
  setToasted(true);
65
69
  toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 });
@@ -21,4 +21,16 @@ export declare function moved(store: Store, id: Id, rows: {
21
21
  status: string;
22
22
  }[]>;
23
23
  export declare function firstToastThisOpen(id: Id): boolean;
24
+ /** WHAT THE LAST PAGE LOAD ALREADY SAW. The moves (report and status) that were already news the last time the app was
25
+ * loaded, so a move the person was told about, or could have been, is not toasted again on the next load; it keeps
26
+ * only the dot and the New mark until they open it. Returns the moves in `m` that are new since then, and records `m`
27
+ * as what this load saw. With no record yet (the first load on this release) every move is fresh, as before. */
28
+ export declare const noticedKey: (id: Id) => string;
29
+ export declare function fresh(store: Store, id: Id, m: {
30
+ id: string;
31
+ status: string;
32
+ }[]): Promise<{
33
+ id: string;
34
+ status: string;
35
+ }[]>;
24
36
  export {};
@@ -70,3 +70,24 @@ export function firstToastThisOpen(id) {
70
70
  toasted.add(k);
71
71
  return true;
72
72
  }
73
+ /** WHAT THE LAST PAGE LOAD ALREADY SAW. The moves (report and status) that were already news the last time the app was
74
+ * loaded, so a move the person was told about, or could have been, is not toasted again on the next load; it keeps
75
+ * only the dot and the New mark until they open it. Returns the moves in `m` that are new since then, and records `m`
76
+ * as what this load saw. With no record yet (the first load on this release) every move is fresh, as before. */
77
+ export const noticedKey = (id) => `${draftKey(id)}#view:noticed`;
78
+ export function fresh(store, id, m) {
79
+ return serial(noticedKey(id), async () => {
80
+ const had = await readCache(store, noticedKey(id));
81
+ const before = had && typeof had === 'object' ? had : {};
82
+ const out = m.filter((r) => before[r.id] !== r.status);
83
+ const now = { ...before };
84
+ for (const r of m)
85
+ now[r.id] = r.status;
86
+ // A report no longer moved (opened, or gone) drops out, so the record stays the size of the dot.
87
+ for (const k of Object.keys(now))
88
+ if (!m.some((r) => r.id === k))
89
+ delete now[k];
90
+ await writeCache(store, noticedKey(id), now);
91
+ return out;
92
+ });
93
+ }
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- // THE SHEET (design section 2): kind, say it, show it, what goes with it, send. Phone first. Everything
3
+ // THE SHEET (design section 2): kind, say it, show it, what goes with it, send. Phone first. The kind is one
4
+ // small menu and saying it is one big box for every kind, broken included: the box is what takes the space,
5
+ // so a person sees first that they will be heard out (the operator, 2026-10-19). Everything
4
6
  // typed is saved as it is typed, every recording as it is made, and nothing leaves the phone except
5
7
  // through the outbox, so a closed app loses nothing. A voice note alone is a whole report.
6
8
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
@@ -15,8 +17,10 @@ import { useRuntime, useTick } from './runtime.js';
15
17
  import { toast, Toasts } from './toast.js';
16
18
  import { effectiveCeilings, lengthWords, phoneType, screenSize, sizeWords, stringsFor } from './words.js';
17
19
  const NO_ANSWERS = { happened: '', expected: '', steps: '' };
20
+ const joinWords = (...parts) => parts.map((p) => p.trim()).filter(Boolean).join('\n\n');
18
21
  // The fields of this sheet. A take cut off by a closed app is routed back by its question id, which names
19
- // the draft (app and account), so only the same account's interrupted take rejoins its draft.
22
+ // the draft (app and account), so only the same account's interrupted take rejoins its draft. The three
23
+ // broken questions are gone from the sheet; their ids stay so a take cut off before the change still rejoins.
20
24
  const OUR_FIELDS = ['words', 'happened', 'expected', 'steps'];
21
25
  const metaKey = (key) => `sac-meta:${key}`;
22
26
  const answersKey = (draft) => `${draft}#answers`;
@@ -65,8 +69,8 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
65
69
  const box = useRef(null);
66
70
  useDialog(box, onClose);
67
71
  const [kind, setKind] = useState(initial?.kind ?? 'change');
68
- const [words, setWords] = useState('');
69
- const [answers, setAnswers] = useState({ ...NO_ANSWERS, happened: initial?.happened ?? '' });
72
+ // "Report a problem" can open with what went wrong already said: it starts the box.
73
+ const [words, setWords] = useState(initial?.happened ?? '');
70
74
  const [voice, setVoice] = useState([]);
71
75
  const [media, setMedia] = useState([]);
72
76
  const [error, setError] = useState('');
@@ -107,9 +111,12 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
107
111
  // "Report a problem" opens as broken whatever kind an earlier draft was.
108
112
  if (!initial?.kind && (d.kind === 'change' || d.kind === 'broken' || d.kind === 'feedback'))
109
113
  setKind(d.kind);
110
- setWords(d.words);
111
- setAnswers((cur) => ({ ...a, happened: cur.happened || a.happened }));
112
- if (d.words.trim() || takes.length || metas.length)
114
+ // A draft from when broken asked three questions keeps its answers: they join the one box, once.
115
+ const restored = joinWords(d.words, a.happened, a.expected, a.steps);
116
+ setWords((cur) => (restored.includes(cur.trim()) ? restored : joinWords(restored, cur)));
117
+ if (a !== NO_ANSWERS)
118
+ await rt.store.del('kv', answersKey(key));
119
+ if (restored.trim() || takes.length || metas.length)
113
120
  toast({ id: 'sac-restored', text: S.restored });
114
121
  }
115
122
  voiceN.current = highest(takes.map((t) => t.key));
@@ -148,9 +155,8 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
148
155
  const fields = { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] };
149
156
  chain.current = chain.current.then(async () => {
150
157
  await saveDraft(rt.store, key, fields);
151
- await rt.store.put('kv', answersKey(key), answers);
152
158
  }).catch(() => { });
153
- }, [rt, key, loaded, sent, kind, words, answers, voice, media]);
159
+ }, [rt, key, loaded, sent, kind, words, voice, media]);
154
160
  const ensureId = useCallback(async () => {
155
161
  await chain.current;
156
162
  return (await saveDraft(rt.store, key, {})).reportId;
@@ -262,8 +268,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
262
268
  ].filter((d) => Boolean(d.value));
263
269
  const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)];
264
270
  const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`;
265
- // Answers to the broken questions only count while the kind is broken: they are not sent otherwise.
266
- const hasAnything = Boolean(words.trim() || voice.length || media.length || (kind === 'broken' && (answers.happened.trim() || answers.expected.trim() || answers.steps.trim())));
271
+ const hasAnything = Boolean(words.trim() || voice.length || media.length);
267
272
  async function send() {
268
273
  if (!rt || !who || !key || !hasAnything)
269
274
  return;
@@ -279,7 +284,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
279
284
  ...(adapters.about ? { app: Object.fromEntries(Object.entries(adapters.about).filter(([k]) => k !== 'version')) } : {}),
280
285
  who: { member: who.account, role: who.role },
281
286
  words: words.trim(),
282
- ...(kind === 'broken' ? { answers: { happened: answers.happened.trim(), expected: answers.expected.trim(), steps: answers.steps.trim() } } : {}),
283
287
  voice: voice.map((v) => ({ file: v.file, ms: v.ms, field: v.field })),
284
288
  media: await Promise.all(media.map(async (m) => {
285
289
  const blob = await rt.store.get('blobs', m.key);
@@ -299,7 +303,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
299
303
  ...media.map((m) => ({ file: m.file, part: 0, blob: m.key })),
300
304
  ];
301
305
  await queueReport({ store: rt.store, outbox: rt.outbox, report, parts: partRefs, draft: key, ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) });
302
- await rt.store.del('kv', answersKey(key));
303
306
  for (const v of voice)
304
307
  await rt.store.del('kv', metaKey(v.key));
305
308
  for (const m of media)
@@ -331,5 +334,5 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
331
334
  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) => {
332
335
  const p = rt.part(sent.id, f.file);
333
336
  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));
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, {})] }) }));
337
+ }) })) : 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] }), _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, {})] }) }));
335
338
  }
@@ -25,6 +25,9 @@ export interface Strings {
25
25
  sayItLegend: string;
26
26
  sayItLabel: string;
27
27
  sayItPlaceholder: string;
28
+ /** The one box's placeholder when the kind is "Something is broken": it asks what the three questions used to. */
29
+ sayItPlaceholderBroken: string;
30
+ sayItPlaceholderFeedback: string;
28
31
  showItLegend: string;
29
32
  addImages: string;
30
33
  addVideo: string;
@@ -20,7 +20,9 @@ export const DEFAULT_STRINGS = {
20
20
  optional: 'optional',
21
21
  sayItLegend: 'Say it',
22
22
  sayItLabel: 'Your suggestion',
23
- sayItPlaceholder: 'Type it, or hold the microphone and say it',
23
+ sayItPlaceholder: 'What would you change? Type it, or hold the microphone and say it',
24
+ sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or hold the microphone and say it',
25
+ sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or hold the microphone and say it',
24
26
  showItLegend: 'Show it',
25
27
  addImages: 'Add a screenshot or photo',
26
28
  addVideo: 'Add a short video',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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": {
@@ -5,7 +5,7 @@
5
5
  import { useEffect, useState, type AriaRole, type ReactNode } from 'react'
6
6
  import type { Adapters } from './adapters.ts'
7
7
  import { useRuntime, type Runtime } from './runtime.ts'
8
- import { firstToastThisOpen, moved, onSeen } from './seen.ts'
8
+ import { firstToastThisOpen, fresh, moved, onSeen } from './seen.ts'
9
9
  import { SuggestSheet } from './sheet.tsx'
10
10
  import { toast, Toasts } from './toast.tsx'
11
11
  import { useClearOnSignOut } from './view-cache.ts'
@@ -19,7 +19,7 @@ export function KeepSending({ adapters }: { adapters: Adapters }) {
19
19
  }
20
20
 
21
21
  // THE NEWS: once per mount, asks the app for this person's reports and compares each with what they last opened. A move
22
- // since then is counted (the dot), and the newest one gets the page load's one toast, whichever of the entry or a
22
+ // since then is counted (the dot), and the newest one the last page load had not already seen gets the page load's one toast, whichever of the entry or a
23
23
  // root-mounted SuggestNews asks first (the guard is per page load and account, in seen.ts). Opening a report anywhere
24
24
  // recounts. `toasted` says this mount showed the toast, so a standalone entry knows to draw a toast host for it.
25
25
  function useNews(adapters: Adapters, rt: Runtime | null): { moved: number; toasted: boolean } {
@@ -44,9 +44,12 @@ function useNews(adapters: Adapters, rt: Runtime | null): { moved: number; toast
44
44
  try { now = await adapters.identity() } catch { now = null }
45
45
  if (!live || !rows || !now || now.account !== rt.who.account) return
46
46
  const m = await count()
47
- if (!live || !m?.length || !firstToastThisOpen(id)) return
47
+ if (!live || !m) return
48
+ // Only a move the last page load had not already seen is toasted; an older one keeps just the dot.
49
+ const f = await fresh(rt.store, id, m)
50
+ if (!f.length || !firstToastThisOpen(id)) return
48
51
  const at = (x: string) => rows!.find((r) => r.id === x)?.at ?? 0
49
- const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0]
52
+ const top = [...f].sort((a, b) => at(b.id) - at(a.id))[0]
50
53
  const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined
51
54
  setToasted(true)
52
55
  toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 })
@@ -70,3 +70,22 @@ export function firstToastThisOpen(id: Id): boolean {
70
70
  toasted.add(k)
71
71
  return true
72
72
  }
73
+
74
+ /** WHAT THE LAST PAGE LOAD ALREADY SAW. The moves (report and status) that were already news the last time the app was
75
+ * loaded, so a move the person was told about, or could have been, is not toasted again on the next load; it keeps
76
+ * only the dot and the New mark until they open it. Returns the moves in `m` that are new since then, and records `m`
77
+ * as what this load saw. With no record yet (the first load on this release) every move is fresh, as before. */
78
+ export const noticedKey = (id: Id) => `${draftKey(id)}#view:noticed`
79
+ export function fresh(store: Store, id: Id, m: { id: string; status: string }[]): Promise<{ id: string; status: string }[]> {
80
+ return serial(noticedKey(id), async () => {
81
+ const had = await readCache<Seen>(store, noticedKey(id))
82
+ const before = had && typeof had === 'object' ? had : {}
83
+ const out = m.filter((r) => before[r.id] !== r.status)
84
+ const now: Seen = { ...before }
85
+ for (const r of m) now[r.id] = r.status
86
+ // A report no longer moved (opened, or gone) drops out, so the record stays the size of the dot.
87
+ for (const k of Object.keys(now)) if (!m.some((r) => r.id === k)) delete now[k]
88
+ await writeCache(store, noticedKey(id), now)
89
+ return out
90
+ })
91
+ }
@@ -1,5 +1,7 @@
1
1
  'use client'
2
- // THE SHEET (design section 2): kind, say it, show it, what goes with it, send. Phone first. Everything
2
+ // THE SHEET (design section 2): kind, say it, show it, what goes with it, send. Phone first. The kind is one
3
+ // small menu and saying it is one big box for every kind, broken included: the box is what takes the space,
4
+ // so a person sees first that they will be heard out (the operator, 2026-10-19). Everything
3
5
  // typed is saved as it is typed, every recording as it is made, and nothing leaves the phone except
4
6
  // through the outbox, so a closed app loses nothing. A voice note alone is a whole report.
5
7
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
@@ -20,9 +22,11 @@ export interface Voice { key: string; file: string; ms: number; field: string }
20
22
  export interface Media { key: string; file: string; name: string; type: string; bytes: number; ms?: number }
21
23
  type Answers = { happened: string; expected: string; steps: string }
22
24
  const NO_ANSWERS: Answers = { happened: '', expected: '', steps: '' }
25
+ const joinWords = (...parts: string[]) => parts.map((p) => p.trim()).filter(Boolean).join('\n\n')
23
26
  type Diag = 'appVersion' | 'at' | 'screen' | 'device'
24
27
  // The fields of this sheet. A take cut off by a closed app is routed back by its question id, which names
25
- // the draft (app and account), so only the same account's interrupted take rejoins its draft.
28
+ // the draft (app and account), so only the same account's interrupted take rejoins its draft. The three
29
+ // broken questions are gone from the sheet; their ids stay so a take cut off before the change still rejoins.
26
30
  const OUR_FIELDS = ['words', 'happened', 'expected', 'steps']
27
31
 
28
32
  const metaKey = (key: string) => `sac-meta:${key}`
@@ -71,8 +75,8 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
71
75
  useDialog(box, onClose)
72
76
 
73
77
  const [kind, setKind] = useState<Kind>(initial?.kind ?? 'change')
74
- const [words, setWords] = useState('')
75
- const [answers, setAnswers] = useState<Answers>({ ...NO_ANSWERS, happened: initial?.happened ?? '' })
78
+ // "Report a problem" can open with what went wrong already said: it starts the box.
79
+ const [words, setWords] = useState(initial?.happened ?? '')
76
80
  const [voice, setVoice] = useState<Voice[]>([])
77
81
  const [media, setMedia] = useState<Media[]>([])
78
82
  const [error, setError] = useState('')
@@ -110,9 +114,11 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
110
114
  if (!live) return
111
115
  // "Report a problem" opens as broken whatever kind an earlier draft was.
112
116
  if (!initial?.kind && (d.kind === 'change' || d.kind === 'broken' || d.kind === 'feedback')) setKind(d.kind)
113
- setWords(d.words)
114
- setAnswers((cur) => ({ ...a, happened: cur.happened || a.happened }))
115
- if (d.words.trim() || takes.length || metas.length) toast({ id: 'sac-restored', text: S.restored })
117
+ // A draft from when broken asked three questions keeps its answers: they join the one box, once.
118
+ const restored = joinWords(d.words, a.happened, a.expected, a.steps)
119
+ setWords((cur) => (restored.includes(cur.trim()) ? restored : joinWords(restored, cur)))
120
+ if (a !== NO_ANSWERS) await rt.store.del('kv', answersKey(key))
121
+ if (restored.trim() || takes.length || metas.length) toast({ id: 'sac-restored', text: S.restored })
116
122
  }
117
123
  voiceN.current = highest(takes.map((t) => t.key))
118
124
  mediaN.current = highest(metas.map((m) => m.key))
@@ -148,9 +154,8 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
148
154
  const fields = { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] }
149
155
  chain.current = chain.current.then(async () => {
150
156
  await saveDraft(rt.store, key, fields)
151
- await rt.store.put('kv', answersKey(key), answers)
152
157
  }).catch(() => {})
153
- }, [rt, key, loaded, sent, kind, words, answers, voice, media])
158
+ }, [rt, key, loaded, sent, kind, words, voice, media])
154
159
 
155
160
  const ensureId = useCallback(async () => {
156
161
  await chain.current
@@ -261,8 +266,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
261
266
  const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)]
262
267
  const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`
263
268
 
264
- // Answers to the broken questions only count while the kind is broken: they are not sent otherwise.
265
- const hasAnything = Boolean(words.trim() || voice.length || media.length || (kind === 'broken' && (answers.happened.trim() || answers.expected.trim() || answers.steps.trim())))
269
+ const hasAnything = Boolean(words.trim() || voice.length || media.length)
266
270
 
267
271
  async function send() {
268
272
  if (!rt || !who || !key || !hasAnything) return
@@ -277,7 +281,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
277
281
  ...(adapters.about ? { app: Object.fromEntries(Object.entries(adapters.about).filter(([k]) => k !== 'version')) } : {}),
278
282
  who: { member: who.account, role: who.role },
279
283
  words: words.trim(),
280
- ...(kind === 'broken' ? { answers: { happened: answers.happened.trim(), expected: answers.expected.trim(), steps: answers.steps.trim() } } : {}),
281
284
  voice: voice.map((v) => ({ file: v.file, ms: v.ms, field: v.field })),
282
285
  media: await Promise.all(media.map(async (m) => {
283
286
  const blob = await rt.store.get<Blob>('blobs', m.key)
@@ -294,7 +297,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
294
297
  ...media.map((m) => ({ file: m.file, part: 0, blob: m.key })),
295
298
  ]
296
299
  await queueReport({ store: rt.store, outbox: rt.outbox, report, parts: partRefs, draft: key, ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) })
297
- await rt.store.del('kv', answersKey(key))
298
300
  for (const v of voice) await rt.store.del('kv', metaKey(v.key))
299
301
  for (const m of media) await rt.store.del('kv', metaKey(m.key))
300
302
  setSent({ id: report.id, files: [...voice.map((v, i) => ({ file: v.file, name: S.voiceNote(i + 1, lengthWords(v.ms)), bytes: 0 })), ...media.map((m) => ({ file: m.file, name: m.name, bytes: m.bytes }))] })
@@ -353,28 +355,16 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
353
355
  </div>
354
356
  ) : (
355
357
  <form className="sac-form" onSubmit={(e) => { e.preventDefault(); void send() }}>
356
- <fieldset role="radiogroup" className="sac-group sac-kinds">
357
- <legend>{S.kindLegend}</legend>
358
- {kinds.map((k) => (
359
- <label key={k.id} className="sac-choice">
360
- <input type="radio" name="sac-kind" value={k.id} checked={kind === k.id} onChange={() => setKind(k.id)} />
361
- <span>{k.label}</span>
362
- </label>
363
- ))}
364
- </fieldset>
365
-
366
- {kind === 'broken' ? (
367
- <fieldset className="sac-group">
368
- <legend>{S.kindBroken} ({S.optional})</legend>
369
- {fieldFor('happened', S.brokenHappened, answers.happened, (v) => setAnswers((a) => ({ ...a, happened: v })))}
370
- {fieldFor('expected', S.brokenExpected, answers.expected, (v) => setAnswers((a) => ({ ...a, expected: v })))}
371
- {fieldFor('steps', S.brokenSteps, answers.steps, (v) => setAnswers((a) => ({ ...a, steps: v })))}
372
- </fieldset>
373
- ) : null}
358
+ <div className="sac-kind">
359
+ <label htmlFor={`${titleId}-kind`}>{S.kindLegend}</label>
360
+ <select id={`${titleId}-kind`} className="sac-select" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
361
+ {kinds.map((k) => <option key={k.id} value={k.id}>{k.label}</option>)}
362
+ </select>
363
+ </div>
374
364
 
375
- <fieldset className="sac-group">
365
+ <fieldset className="sac-group sac-sayit">
376
366
  <legend>{S.sayItLegend}</legend>
377
- {fieldFor('words', S.sayItLabel, words, setWords, { placeholder: S.sayItPlaceholder })}
367
+ {fieldFor('words', S.sayItLabel, words, setWords, { placeholder: kind === 'broken' ? S.sayItPlaceholderBroken : kind === 'feedback' ? S.sayItPlaceholderFeedback : S.sayItPlaceholder })}
378
368
  {voice.length ? (
379
369
  <ul className="sac-list" aria-label={S.voiceNotesLabel}>
380
370
  {voice.map((v, i) => (
@@ -32,6 +32,11 @@
32
32
  .sac-group > legend { font-weight: 600; padding: 0 4px; }
33
33
  .sac-choice, .sac-switch { display: flex; align-items: center; gap: 10px; min-height: 44px; }
34
34
  .sac-choice input, .sac-switch input { width: 24px; height: 24px; flex: none; }
35
+ .sac-kind { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
36
+ .sac-kind label { color: var(--sac-muted); font-size: 0.9rem; }
37
+ .sac-select { font: inherit; font-size: 16px; min-height: 36px; padding: 4px 10px; color: inherit; background: var(--sac-bg); border: 1px solid var(--sac-border); border-radius: var(--sac-radius); }
38
+ /* Saying it is the sheet's biggest thing, whatever field the host puts in it. */
39
+ .sac-sayit textarea { min-height: 9rem; }
35
40
  .sac-voice { display: flex; align-items: flex-end; gap: 8px; min-width: 0; }
36
41
  .sac-input, .sac-voice textarea, .sac-voice input { flex: 1; min-width: 0; min-height: 44px; font: inherit; font-size: 16px; color: inherit; background: var(--sac-bg); border: 1px solid var(--sac-border); border-radius: var(--sac-radius); padding: 10px 12px; box-sizing: border-box; }
37
42
  .sac-record { flex: none; width: 48px; height: 48px; min-width: 44px; min-height: 44px; border-radius: 50%; border: 1px solid var(--sac-border); background: var(--sac-bg); display: grid; place-items: center; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; cursor: pointer; }
@@ -58,8 +63,12 @@
58
63
  .sac-step[data-state="now"] { border-color: var(--sac-accent); color: var(--sac-fg); font-weight: 600; }
59
64
  .sac-step-detail { display: block; font-weight: 400; font-size: 0.85rem; }
60
65
  .sac-words { white-space: pre-wrap; overflow-wrap: anywhere; }
61
- .sac-toasts { position: fixed; left: 0; right: 0; top: calc(env(safe-area-inset-top) + 8px); z-index: calc(var(--sac-z) + 1); display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 0 16px; pointer-events: none; }
62
- .sac-toast { pointer-events: auto; font: inherit; width: 100%; max-width: 28rem; text-align: left; padding: 12px 16px; border-radius: var(--sac-radius); border: 1px solid var(--sac-border); background: var(--sac-bg); color: var(--sac-fg); }
66
+ /* Toasts sit small at the bottom edge, never over the top of the host's page: the top is where an app opens onto
67
+ its own content, and a full-width bar there reads as the app's header. Each is a pill as wide as its words, with a
68
+ drawn x so it reads as dismissable (the whole pill is the button). */
69
+ .sac-toasts { position: fixed; left: 0; right: 0; bottom: calc(env(safe-area-inset-bottom) + 12px); z-index: calc(var(--sac-z) + 1); display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 0 16px; pointer-events: none; }
70
+ .sac-toast { pointer-events: auto; font: inherit; font-size: 0.875rem; line-height: 1.3; width: auto; max-width: min(100%, 24rem); text-align: left; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--sac-border); background: var(--sac-bg); color: var(--sac-fg); display: inline-flex; align-items: center; gap: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); }
71
+ .sac-toast::after { content: "\00d7"; color: var(--sac-muted); font-size: 1rem; line-height: 1; }
63
72
  .sac-toast[data-tone="warn"] { background: var(--sac-fg); color: var(--sac-bg); }
64
73
  .sac-entry-why { margin-left: 8px; color: var(--sac-muted); font-size: 0.9rem; }
65
74
  .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; }
@@ -31,6 +31,9 @@ export interface Strings {
31
31
  sayItLegend: string
32
32
  sayItLabel: string
33
33
  sayItPlaceholder: string
34
+ /** The one box's placeholder when the kind is "Something is broken": it asks what the three questions used to. */
35
+ sayItPlaceholderBroken: string
36
+ sayItPlaceholderFeedback: string
34
37
  showItLegend: string
35
38
  addImages: string
36
39
  addVideo: string
@@ -121,7 +124,9 @@ export const DEFAULT_STRINGS: Strings = {
121
124
  optional: 'optional',
122
125
  sayItLegend: 'Say it',
123
126
  sayItLabel: 'Your suggestion',
124
- sayItPlaceholder: 'Type it, or hold the microphone and say it',
127
+ sayItPlaceholder: 'What would you change? Type it, or hold the microphone and say it',
128
+ sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or hold the microphone and say it',
129
+ sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or hold the microphone and say it',
125
130
  showItLegend: 'Show it',
126
131
  addImages: 'Add a screenshot or photo',
127
132
  addVideo: 'Add a short video',