@supersuit/suggest-a-change 0.1.0 → 0.1.1

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 (2026-10-13)
4
+
5
+ - **A report sent with the Mac away is sent when it is back, even with the sheet closed.** The outbox used to move only while the sheet, My suggestions or a detail screen was mounted, so a report waited until the person opened one again. `<SuggestEntry>` now keeps the outbox moving while it is mounted, and `KeepSending` is exported from `./client` for a host with no always-mounted entry (it draws nothing).
6
+ - A voice file's upload link now allows 200,000 bytes per second of declared length, so an uncompressed WAV is not refused.
7
+ - When finalize refuses a file, the pending entry is removed first and the stored object is deleted only if that removal happened.
8
+ - The client test "after Send, focus moves to the receipt" waits for both of its conditions, for longer.
9
+
3
10
  ## 0.1.0 (2026-10-12)
4
11
 
5
12
  First release. The report contract and flow map: kinds, roles, ceilings, statuses, who acts at each step, and report validation.
package/README.md CHANGED
@@ -47,7 +47,7 @@ const adapters: Adapters = {
47
47
  // then: {open && <SuggestSheet adapters={adapters} where={{ screen: "Photos", route: location.pathname }} onClose={() => setOpen(false)} />}
48
48
  ```
49
49
 
50
- The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and the receiving Mac writes the transcript.
50
+ The entry also keeps the phone's outbox moving while it is mounted (a report sent with the Mac away goes when it is back, with the sheet closed); a host with no always-mounted entry renders `<KeepSending adapters={adapters} />` once, high in the app. The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and the receiving Mac writes the transcript.
51
51
 
52
52
  Styling: structure only, class names prefixed `sac-`. `@supersuit/suggest-a-change/styles.css` is optional defaults (16px inputs so iOS does not zoom, 44px touch targets, safe-area padding on the sheet). Re-skin it with these custom properties:
53
53
 
@@ -1,4 +1,9 @@
1
1
  import type { Adapters } from './adapters.ts';
2
+ /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
3
+ * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
4
+ export declare function KeepSending({ adapters }: {
5
+ adapters: Adapters;
6
+ }): null;
2
7
  export declare function SuggestEntry({ adapters, onOpen, className }: {
3
8
  adapters: Adapters;
4
9
  onOpen(): void;
@@ -4,8 +4,15 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
4
4
  // Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
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
+ import { useRuntime } from './runtime.js';
7
8
  import { SuggestSheet } from './sheet.js';
8
9
  import { stringsFor } from './words.js';
10
+ /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
11
+ * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
12
+ export function KeepSending({ adapters }) {
13
+ useRuntime(adapters);
14
+ return null;
15
+ }
9
16
  export function SuggestEntry({ adapters, onOpen, className }) {
10
17
  const S = stringsFor(adapters.words);
11
18
  const [why, setWhy] = useState('');
@@ -28,7 +35,7 @@ export function SuggestEntry({ adapters, onOpen, className }) {
28
35
  setWhy('');
29
36
  onOpen();
30
37
  }
31
- return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsx("button", { type: "button", "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: S.entry }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null] }));
38
+ return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsx(KeepSending, { adapters: adapters }), _jsx("button", { type: "button", "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: S.entry }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null] }));
32
39
  }
33
40
  /** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
34
41
  export function ReportProblem({ adapters, screen, error }) {
@@ -158,7 +158,7 @@ export declare const APPROVAL_LEASE_MS: number;
158
158
  export declare const READ_TTL_MS: number;
159
159
  export declare const VOICE_FILE_BYTES: number;
160
160
  /** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
161
- export declare const VOICE_BYTES_PER_SECOND = 96000;
161
+ export declare const VOICE_BYTES_PER_SECOND = 200000;
162
162
  /** How long a report with missing attachments waits before it is offered to a Mac anyway. */
163
163
  export declare const INCOMPLETE_GRACE_MS: number;
164
164
  export declare function createReportHandlers(opts: ReportHandlerOptions): {
@@ -26,7 +26,7 @@ export const APPROVAL_LEASE_MS = 7 * 24 * 3600_000;
26
26
  export const READ_TTL_MS = 5 * 60_000;
27
27
  export const VOICE_FILE_BYTES = 25 * 2 ** 20;
28
28
  /** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
29
- export const VOICE_BYTES_PER_SECOND = 96_000;
29
+ export const VOICE_BYTES_PER_SECOND = 200_000;
30
30
  /** How long a report with missing attachments waits before it is offered to a Mac anyway. */
31
31
  export const INCOMPLETE_GRACE_MS = 60 * 60_000;
32
32
  const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
@@ -374,8 +374,9 @@ export function createReportHandlers(opts) {
374
374
  return json(409, { error: 'that file has not arrived yet' });
375
375
  const declared = declaredFile(r, file);
376
376
  const refuse = async (error) => {
377
- await store.discard({ reportId: id, file }); // nothing unverified stays in storage
378
- await store.setFile(id, file, null);
377
+ // Drop the pending entry first and delete the object only if that really removed it: a verified entry that won a race is never orphaned.
378
+ if (await store.setFile(id, file, null))
379
+ await store.discard({ reportId: id, file }); // nothing unverified stays in storage
379
380
  return json(422, { error });
380
381
  };
381
382
  if (!sameType(state.pending.type, real.type) || !sameType(declared?.type ?? state.pending.type, real.type))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {
@@ -4,9 +4,17 @@
4
4
  // shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
5
5
  import { useEffect, useState } from 'react'
6
6
  import type { Adapters } from './adapters.ts'
7
+ import { useRuntime } from './runtime.ts'
7
8
  import { SuggestSheet } from './sheet.tsx'
8
9
  import { stringsFor } from './words.ts'
9
10
 
11
+ /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
12
+ * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
13
+ export function KeepSending({ adapters }: { adapters: Adapters }) {
14
+ useRuntime(adapters)
15
+ return null
16
+ }
17
+
10
18
  export function SuggestEntry({ adapters, onOpen, className }: { adapters: Adapters; onOpen(): void; className?: string }) {
11
19
  const S = stringsFor(adapters.words)
12
20
  const [why, setWhy] = useState('')
@@ -23,6 +31,7 @@ export function SuggestEntry({ adapters, onOpen, className }: { adapters: Adapte
23
31
  }
24
32
  return (
25
33
  <span className="sac-entry-wrap">
34
+ <KeepSending adapters={adapters} />
26
35
  <button type="button" data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>{S.entry}</button>
27
36
  {why ? <span role="status" className="sac-entry-why">{why}</span> : null}
28
37
  </span>
@@ -108,7 +108,7 @@ export const APPROVAL_LEASE_MS = 7 * 24 * 3600_000
108
108
  export const READ_TTL_MS = 5 * 60_000
109
109
  export const VOICE_FILE_BYTES = 25 * 2 ** 20
110
110
  /** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
111
- export const VOICE_BYTES_PER_SECOND = 96_000
111
+ export const VOICE_BYTES_PER_SECOND = 200_000
112
112
  /** How long a report with missing attachments waits before it is offered to a Mac anyway. */
113
113
  export const INCOMPLETE_GRACE_MS = 60 * 60_000
114
114
  const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
@@ -362,8 +362,8 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
362
362
  if (!real) return json(409, { error: 'that file has not arrived yet' })
363
363
  const declared = declaredFile(r, file)
364
364
  const refuse = async (error: string) => {
365
- await store.discard({ reportId: id, file }) // nothing unverified stays in storage
366
- await store.setFile(id, file, null)
365
+ // Drop the pending entry first and delete the object only if that really removed it: a verified entry that won a race is never orphaned.
366
+ if (await store.setFile(id, file, null)) await store.discard({ reportId: id, file }) // nothing unverified stays in storage
367
367
  return json(422, { error })
368
368
  }
369
369
  if (!sameType(state.pending.type, real.type) || !sameType(declared?.type ?? state.pending.type, real.type)) return refuse('the file\'s contents are not the type it was declared as')