@supersuit/suggest-a-change 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8 (2026-10-17)
4
+
5
+ - **The news reaches a person whose entry sits in a menu.** In the Cofounders and Us apps the entry lives in a three-dot menu that stays closed and remounts it on every open, so the 0.1.7 dot was never seen and the toast was drawn inside the closed menu. New in `./client`: `<SuggestNews adapters>`, mounted once at the root, shows the toast with no entry on screen (and keeps the outbox moving); `useSuggestNews(adapters)` returns `{ moved }` for the menu's trigger; `<SuggestDot adapters moved>` draws the entry's dot there (nothing at 0), and the class `sac-dot-host` gives the trigger the `position: relative` it needs. The toast is once per page load and account however many times the entry mounts, whichever of these asks first. `<SuggestEntry>` keeps its own dot and, alone, behaves as in 0.1.7.
6
+
3
7
  ## 0.1.7 (2026-10-17)
4
8
 
5
9
  - **The person who suggested a change is told when it is being made and when it ends.** `createReportHandlers` takes a required `tellSubmitter(e)`, called once per move to `being-made` or an end (`TOLD`, now in the contract) with `{ member, reportId, status, words, meaning, ended, note? }`. The words are the module's own (`wordsFor`/`meaningFor`), never the report's text; `note` is the one-line reason, on `not-now` only. The planning steps, a repeat, a refused move and a maintainer's own report starting tell nobody, and a failure never fails the move. **Breaking:** without `tellSubmitter` the handlers throw at startup; an app with nobody to tell passes `{ none: "<reason>" }`. The `status` tell event also carries `member` and `status`, and a `not-now` row in the member's list carries its `note`.
package/README.md CHANGED
@@ -73,7 +73,31 @@ The four diagnostics are the app's version, the time, the screen size and the ph
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
 
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 app open 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).
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
+
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.
79
+
80
+ ```tsx
81
+ import { SuggestDot, SuggestEntry, SuggestNews, useSuggestNews } from "@supersuit/suggest-a-change/client";
82
+
83
+ // once, at the root
84
+ <SuggestNews adapters={adapters} />
85
+
86
+ // the menu's trigger
87
+ function MoreButton({ onClick }: { onClick(): void }) {
88
+ const { moved } = useSuggestNews(adapters);
89
+ return (
90
+ <button type="button" className="sac-dot-host" aria-label="More" onClick={onClick}>
91
+ ...<SuggestDot adapters={adapters} moved={moved} />
92
+ </button>
93
+ );
94
+ }
95
+
96
+ // inside the menu, as before
97
+ <SuggestEntry adapters={adapters} role="menuitem" onOpen={() => setOpen(true)} />
98
+ ```
99
+
100
+ `<SuggestNews>` also takes `children`, a function given `{ moved }`, for a root that draws the trigger itself. A host using only the hook renders `<Toasts />` somewhere always mounted so the toast has a place to show.
77
101
 
78
102
  **Follow-ups.** Words and voice notes added to a report afterwards are queued in the outbox like the report (a `followUp` write, then one part per voice note), keep the app's refusal with a Try again, and delete their local copy only once they have landed. `StoreAdapter.append` must therefore dedupe by report id and `more.followId`.
79
103
 
@@ -1,10 +1,33 @@
1
- import { type AriaRole } from 'react';
1
+ import { type AriaRole, type ReactNode } from 'react';
2
2
  import type { Adapters } from './adapters.ts';
3
3
  /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
4
4
  * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
5
5
  export declare function KeepSending({ adapters }: {
6
6
  adapters: Adapters;
7
7
  }): null;
8
+ /** For a host that shows the entry where it is not always visible (inside a menu): call this where the menu's trigger is
9
+ * drawn. It asks the app once per mount and returns how many of this person's suggestions moved since they last opened
10
+ * them (draw `<SuggestDot>` with it). The page load's one toast is shown by whichever of this, `<SuggestNews>` or the
11
+ * entry asks first; a host using only this hook must also render `<Toasts />` somewhere always mounted. */
12
+ export declare function useSuggestNews(adapters: Adapters): {
13
+ moved: number;
14
+ };
15
+ /** Mount once at the app's root, always mounted: shows the page load's one toast when a suggestion moved, with no entry
16
+ * on screen, and keeps the outbox moving (as `<KeepSending>` does). `children`, if given, receives `{ moved }`. */
17
+ export declare function SuggestNews({ adapters, children }: {
18
+ adapters: Adapters;
19
+ children?: (news: {
20
+ moved: number;
21
+ }) => ReactNode;
22
+ }): import("react").JSX.Element;
23
+ /** The entry's dot, for a host's own trigger (the three-dot button of a menu the entry sits in). Draws nothing when
24
+ * `moved` is 0. It is positioned like the entry's dot, so the trigger needs `position: relative` (the class
25
+ * `sac-dot-host` in the module's styles does it). */
26
+ export declare function SuggestDot({ adapters, moved: count, className }: {
27
+ adapters: Adapters;
28
+ moved: number;
29
+ className?: string;
30
+ }): import("react").JSX.Element | null;
8
31
  export declare function SuggestEntry({ adapters, onOpen, className, role }: {
9
32
  adapters: Adapters;
10
33
  onOpen(): void;
@@ -16,18 +16,14 @@ export function KeepSending({ adapters }) {
16
16
  useRuntime(adapters);
17
17
  return null;
18
18
  }
19
- export function SuggestEntry({ adapters, onOpen, className, role }) {
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
21
+ // root-mounted SuggestNews asks first (the guard is per page load and account, in seen.ts). Opening a report anywhere
22
+ // recounts. `toasted` says this mount showed the toast, so a standalone entry knows to draw a toast host for it.
23
+ function useNews(adapters, rt) {
20
24
  const S = stringsFor(adapters.words);
21
- const [why, setWhy] = useState('');
22
- const [busy, setBusy] = useState(false);
23
- // The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
24
- const { rt, who } = useRuntime(adapters);
25
25
  const [news, setNews] = useState(0);
26
- const [toasting, setToasting] = useState(false);
27
- useEffect(() => { setWhy(''); }, [adapters]);
28
- useClearOnSignOut(rt, who, adapters.words.app);
29
- // Once per mount, asks the app for this person's reports and compares each with what they last opened: a move since
30
- // then shows the dot, and the newest one gets the open's one toast. Opening a report (anywhere) clears it.
26
+ const [toasted, setToasted] = useState(false);
31
27
  useEffect(() => {
32
28
  setNews(0);
33
29
  if (!rt)
@@ -65,13 +61,47 @@ export function SuggestEntry({ adapters, onOpen, className, role }) {
65
61
  const at = (x) => rows.find((r) => r.id === x)?.at ?? 0;
66
62
  const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0];
67
63
  const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined;
68
- setToasting(true);
64
+ setToasted(true);
69
65
  toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 });
70
66
  })();
71
67
  const off = onSeen(() => { void count(); });
72
68
  return () => { live = false; off(); };
73
69
  // eslint-disable-next-line react-hooks/exhaustive-deps
74
70
  }, [rt]);
71
+ return { moved: news, toasted };
72
+ }
73
+ /** For a host that shows the entry where it is not always visible (inside a menu): call this where the menu's trigger is
74
+ * drawn. It asks the app once per mount and returns how many of this person's suggestions moved since they last opened
75
+ * them (draw `<SuggestDot>` with it). The page load's one toast is shown by whichever of this, `<SuggestNews>` or the
76
+ * entry asks first; a host using only this hook must also render `<Toasts />` somewhere always mounted. */
77
+ export function useSuggestNews(adapters) {
78
+ const { rt } = useRuntime(adapters);
79
+ return { moved: useNews(adapters, rt).moved };
80
+ }
81
+ /** Mount once at the app's root, always mounted: shows the page load's one toast when a suggestion moved, with no entry
82
+ * on screen, and keeps the outbox moving (as `<KeepSending>` does). `children`, if given, receives `{ moved }`. */
83
+ export function SuggestNews({ adapters, children }) {
84
+ const news = useSuggestNews(adapters);
85
+ return _jsxs(_Fragment, { children: [_jsx(Toasts, {}), children ? children(news) : null] });
86
+ }
87
+ /** The entry's dot, for a host's own trigger (the three-dot button of a menu the entry sits in). Draws nothing when
88
+ * `moved` is 0. It is positioned like the entry's dot, so the trigger needs `position: relative` (the class
89
+ * `sac-dot-host` in the module's styles does it). */
90
+ export function SuggestDot({ adapters, moved: count, className }) {
91
+ const S = stringsFor(adapters.words);
92
+ if (!count)
93
+ return null;
94
+ return _jsxs(_Fragment, { children: [_jsx("span", { className: `sac-entry-dot${className ? ` ${className}` : ''}`, "data-sac-moved": "", "aria-hidden": "true" }), _jsx("span", { className: "sac-sr", children: S.entryMoved })] });
95
+ }
96
+ export function SuggestEntry({ adapters, onOpen, className, role }) {
97
+ const S = stringsFor(adapters.words);
98
+ const [why, setWhy] = useState('');
99
+ const [busy, setBusy] = useState(false);
100
+ // The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
101
+ const { rt, who } = useRuntime(adapters);
102
+ const { moved: news, toasted: toasting } = useNews(adapters, rt);
103
+ useEffect(() => { setWhy(''); }, [adapters]);
104
+ useClearOnSignOut(rt, who, adapters.words.app);
75
105
  async function press() {
76
106
  setBusy(true);
77
107
  let who = null;
@@ -89,7 +119,7 @@ export function SuggestEntry({ adapters, onOpen, className, role }) {
89
119
  setWhy('');
90
120
  onOpen();
91
121
  }
92
- return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsxs("button", { type: "button", role: role, "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: [S.entry, news ? _jsxs(_Fragment, { children: [_jsx("span", { className: "sac-entry-dot", "data-sac-moved": "", "aria-hidden": "true" }), _jsx("span", { className: "sac-sr", children: S.entryMoved })] }) : null] }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null, toasting ? _jsx(Toasts, {}) : null] }));
122
+ return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsxs("button", { type: "button", role: role, "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: [S.entry, _jsx(SuggestDot, { adapters: adapters, moved: news })] }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null, toasting ? _jsx(Toasts, {}) : null] }));
93
123
  }
94
124
  /** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
95
125
  export function ReportProblem({ adapters, screen, error }) {
@@ -60,7 +60,8 @@ export function moved(store, id, rows) {
60
60
  return out;
61
61
  });
62
62
  }
63
- /** One toast per app open, per account: a remounted entry in the same open does not say it again. */
63
+ /** One toast per page load, per account, whoever asks: a remounted entry (a menu that remounts it on every open) or a
64
+ * root-mounted SuggestNews beside it never says it again until the page is loaded again. */
64
65
  const toasted = new Set();
65
66
  export function firstToastThisOpen(id) {
66
67
  const k = seenKey(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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": {
@@ -2,9 +2,9 @@
2
2
  // THE ENTRY: one control, `data-suggest-entry`, which is the contract the screen checker looks for.
3
3
  // Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
4
4
  // shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
5
- import { useEffect, useState, type AriaRole } from 'react'
5
+ import { useEffect, useState, type AriaRole, type ReactNode } from 'react'
6
6
  import type { Adapters } from './adapters.ts'
7
- import { useRuntime } from './runtime.ts'
7
+ import { useRuntime, type Runtime } from './runtime.ts'
8
8
  import { firstToastThisOpen, moved, onSeen } from './seen.ts'
9
9
  import { SuggestSheet } from './sheet.tsx'
10
10
  import { toast, Toasts } from './toast.tsx'
@@ -18,18 +18,14 @@ export function KeepSending({ adapters }: { adapters: Adapters }) {
18
18
  return null
19
19
  }
20
20
 
21
- export function SuggestEntry({ adapters, onOpen, className, role }: { adapters: Adapters; onOpen(): void; className?: string; /** An ARIA role for the button (for example `menuitem` when the host shows it as a menu row). Unset by default. */ role?: AriaRole }) {
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
23
+ // root-mounted SuggestNews asks first (the guard is per page load and account, in seen.ts). Opening a report anywhere
24
+ // recounts. `toasted` says this mount showed the toast, so a standalone entry knows to draw a toast host for it.
25
+ function useNews(adapters: Adapters, rt: Runtime | null): { moved: number; toasted: boolean } {
22
26
  const S = stringsFor(adapters.words)
23
- const [why, setWhy] = useState('')
24
- const [busy, setBusy] = useState(false)
25
- // The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
26
- const { rt, who } = useRuntime(adapters)
27
27
  const [news, setNews] = useState(0)
28
- const [toasting, setToasting] = useState(false)
29
- useEffect(() => { setWhy('') }, [adapters])
30
- useClearOnSignOut(rt, who, adapters.words.app)
31
- // Once per mount, asks the app for this person's reports and compares each with what they last opened: a move since
32
- // then shows the dot, and the newest one gets the open's one toast. Opening a report (anywhere) clears it.
28
+ const [toasted, setToasted] = useState(false)
33
29
  useEffect(() => {
34
30
  setNews(0)
35
31
  if (!rt) return
@@ -52,13 +48,50 @@ export function SuggestEntry({ adapters, onOpen, className, role }: { adapters:
52
48
  const at = (x: string) => rows!.find((r) => r.id === x)?.at ?? 0
53
49
  const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0]
54
50
  const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined
55
- setToasting(true)
51
+ setToasted(true)
56
52
  toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 })
57
53
  })()
58
54
  const off = onSeen(() => { void count() })
59
55
  return () => { live = false; off() }
60
56
  // eslint-disable-next-line react-hooks/exhaustive-deps
61
57
  }, [rt])
58
+ return { moved: news, toasted }
59
+ }
60
+
61
+ /** For a host that shows the entry where it is not always visible (inside a menu): call this where the menu's trigger is
62
+ * drawn. It asks the app once per mount and returns how many of this person's suggestions moved since they last opened
63
+ * them (draw `<SuggestDot>` with it). The page load's one toast is shown by whichever of this, `<SuggestNews>` or the
64
+ * entry asks first; a host using only this hook must also render `<Toasts />` somewhere always mounted. */
65
+ export function useSuggestNews(adapters: Adapters): { moved: number } {
66
+ const { rt } = useRuntime(adapters)
67
+ return { moved: useNews(adapters, rt).moved }
68
+ }
69
+
70
+ /** Mount once at the app's root, always mounted: shows the page load's one toast when a suggestion moved, with no entry
71
+ * on screen, and keeps the outbox moving (as `<KeepSending>` does). `children`, if given, receives `{ moved }`. */
72
+ export function SuggestNews({ adapters, children }: { adapters: Adapters; children?: (news: { moved: number }) => ReactNode }) {
73
+ const news = useSuggestNews(adapters)
74
+ return <><Toasts />{children ? children(news) : null}</>
75
+ }
76
+
77
+ /** The entry's dot, for a host's own trigger (the three-dot button of a menu the entry sits in). Draws nothing when
78
+ * `moved` is 0. It is positioned like the entry's dot, so the trigger needs `position: relative` (the class
79
+ * `sac-dot-host` in the module's styles does it). */
80
+ export function SuggestDot({ adapters, moved: count, className }: { adapters: Adapters; moved: number; className?: string }) {
81
+ const S = stringsFor(adapters.words)
82
+ if (!count) return null
83
+ return <><span className={`sac-entry-dot${className ? ` ${className}` : ''}`} data-sac-moved="" aria-hidden="true" /><span className="sac-sr">{S.entryMoved}</span></>
84
+ }
85
+
86
+ export function SuggestEntry({ adapters, onOpen, className, role }: { adapters: Adapters; onOpen(): void; className?: string; /** An ARIA role for the button (for example `menuitem` when the host shows it as a menu row). Unset by default. */ role?: AriaRole }) {
87
+ const S = stringsFor(adapters.words)
88
+ const [why, setWhy] = useState('')
89
+ const [busy, setBusy] = useState(false)
90
+ // The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
91
+ const { rt, who } = useRuntime(adapters)
92
+ const { moved: news, toasted: toasting } = useNews(adapters, rt)
93
+ useEffect(() => { setWhy('') }, [adapters])
94
+ useClearOnSignOut(rt, who, adapters.words.app)
62
95
  async function press() {
63
96
  setBusy(true)
64
97
  let who = null
@@ -72,7 +105,7 @@ export function SuggestEntry({ adapters, onOpen, className, role }: { adapters:
72
105
  <span className="sac-entry-wrap">
73
106
  <button type="button" role={role} data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>
74
107
  {S.entry}
75
- {news ? <><span className="sac-entry-dot" data-sac-moved="" aria-hidden="true" /><span className="sac-sr">{S.entryMoved}</span></> : null}
108
+ <SuggestDot adapters={adapters} moved={news} />
76
109
  </button>
77
110
  {why ? <span role="status" className="sac-entry-why">{why}</span> : null}
78
111
  {toasting ? <Toasts /> : null}
@@ -61,7 +61,8 @@ export function moved(store: Store, id: Id, rows: { id: string; status: string }
61
61
  })
62
62
  }
63
63
 
64
- /** One toast per app open, per account: a remounted entry in the same open does not say it again. */
64
+ /** One toast per page load, per account, whoever asks: a remounted entry (a menu that remounts it on every open) or a
65
+ * root-mounted SuggestNews beside it never says it again until the page is loaded again. */
65
66
  const toasted = new Set<string>()
66
67
  export function firstToastThisOpen(id: Id): boolean {
67
68
  const k = seenKey(id)
@@ -45,7 +45,7 @@
45
45
  .sac-item { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
46
46
  .sac-row-button { width: 100%; text-align: left; display: flex; flex-direction: column; gap: 2px; }
47
47
  .sac-row-status { font-weight: 600; }
48
- .sac-entry { position: relative; }
48
+ .sac-entry, .sac-dot-host { position: relative; }
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; }