@supersuit/suggest-a-change 0.0.0-stage → 0.1.0

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +218 -2
  4. package/bin/check.mjs +4 -0
  5. package/bin/init.mjs +4 -0
  6. package/bin/suggest-a-change.mjs +23 -0
  7. package/dist/client/adapters.d.ts +101 -0
  8. package/dist/client/adapters.js +1 -0
  9. package/dist/client/default-field.d.ts +2 -0
  10. package/dist/client/default-field.js +99 -0
  11. package/dist/client/detail.d.ts +17 -0
  12. package/dist/client/detail.js +215 -0
  13. package/dist/client/dialog.d.ts +2 -0
  14. package/dist/client/dialog.js +53 -0
  15. package/dist/client/drafts.d.ts +28 -0
  16. package/dist/client/drafts.js +39 -0
  17. package/dist/client/entry.d.ts +12 -0
  18. package/dist/client/entry.js +55 -0
  19. package/dist/client/idb.d.ts +12 -0
  20. package/dist/client/idb.js +22 -0
  21. package/dist/client/index.d.ts +16 -0
  22. package/dist/client/index.js +16 -0
  23. package/dist/client/mine.d.ts +5 -0
  24. package/dist/client/mine.js +55 -0
  25. package/dist/client/op-id.d.ts +5 -0
  26. package/dist/client/op-id.js +27 -0
  27. package/dist/client/outbox.d.ts +35 -0
  28. package/dist/client/outbox.js +70 -0
  29. package/dist/client/receipt.d.ts +15 -0
  30. package/dist/client/receipt.js +44 -0
  31. package/dist/client/recorder.d.ts +21 -0
  32. package/dist/client/recorder.js +124 -0
  33. package/dist/client/reports.d.ts +76 -0
  34. package/dist/client/reports.js +91 -0
  35. package/dist/client/runtime.d.ts +63 -0
  36. package/dist/client/runtime.js +225 -0
  37. package/dist/client/sheet.d.ts +28 -0
  38. package/dist/client/sheet.js +339 -0
  39. package/dist/client/toast.d.ts +20 -0
  40. package/dist/client/toast.js +73 -0
  41. package/dist/client/words.d.ts +106 -0
  42. package/dist/client/words.js +150 -0
  43. package/dist/contract.d.mts +83 -0
  44. package/dist/contract.mjs +155 -0
  45. package/dist/server/conformance.d.ts +27 -0
  46. package/dist/server/conformance.js +303 -0
  47. package/dist/server/handlers.d.ts +177 -0
  48. package/dist/server/handlers.js +525 -0
  49. package/dist/server/index.d.ts +7 -0
  50. package/dist/server/index.js +5 -0
  51. package/dist/server/rate.d.ts +16 -0
  52. package/dist/server/rate.js +27 -0
  53. package/dist/server/sniff.d.ts +5 -0
  54. package/dist/server/sniff.js +90 -0
  55. package/package.json +81 -4
  56. package/src/check/args.mjs +16 -0
  57. package/src/check/cli.mjs +45 -0
  58. package/src/check/index.mjs +437 -0
  59. package/src/check/init.mjs +183 -0
  60. package/src/client/adapters.ts +78 -0
  61. package/src/client/default-field.tsx +101 -0
  62. package/src/client/detail.tsx +240 -0
  63. package/src/client/dialog.ts +34 -0
  64. package/src/client/drafts.ts +59 -0
  65. package/src/client/entry.tsx +54 -0
  66. package/src/client/idb.ts +35 -0
  67. package/src/client/index.ts +16 -0
  68. package/src/client/mine.tsx +84 -0
  69. package/src/client/op-id.ts +26 -0
  70. package/src/client/outbox.ts +80 -0
  71. package/src/client/receipt.tsx +47 -0
  72. package/src/client/recorder.ts +109 -0
  73. package/src/client/reports.ts +131 -0
  74. package/src/client/runtime.ts +222 -0
  75. package/src/client/sheet.tsx +437 -0
  76. package/src/client/styles.css +63 -0
  77. package/src/client/toast.tsx +72 -0
  78. package/src/client/words.ts +237 -0
  79. package/src/contract.mjs +146 -0
  80. package/src/server/conformance.ts +282 -0
  81. package/src/server/handlers.ts +469 -0
  82. package/src/server/index.ts +8 -0
  83. package/src/server/rate.ts +29 -0
  84. package/src/server/sniff.ts +68 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-12)
4
+
5
+ First release. The report contract and flow map: kinds, roles, ceilings, statuses, who acts at each step, and report validation.
6
+
7
+ The client core, exported as `./client`: IndexedDB store, op ids, the outbox (`Op.kind` is a string the host names; the module adds `report` and `reportPart`), the take recorder with crash recovery, drafts keyed `sac:<app>:<account>`, and the report sender over a `StoreAdapter`. Built to `dist/` with type declarations.
8
+
9
+ The screens, exported from `./client` (React 19 as an optional peer): the entry, the sheet, the receipt, My suggestions and the detail view, a default voice field, and optional styles in `./styles.css`.
10
+
11
+ The screen check, exported as `./check` and installed as the `suggest-a-change-check` command: it fails an app whose screens below home lack the Suggest a change entry (nested layouts and imported shell components are followed; an exempt screen needs a reason) or whose adapters file does not export all four of `identity`, `store`, `words` and `limits`. `checkEntryOnRoutes` does the same for an app with one shell. `suggest-a-change init` wires a new Next.js app: the dependency, adapter stubs under `tailor/`, the entry in the root layout and the check in the app's check script. The stubs carry a `suggest-a-change: STUB` line: the check warns about it, and `suggest-a-change-check --strict` fails on it, so an app cannot ship the stubs as done.
12
+
13
+ The server half, exported as `./server`: `createReportHandlers` (submit, upload-url, finalize, mine, one, append, and the agent routes list, file, settle, claim-approval, status), the `HostedStore` an app's storage implements, `runStoreConformance` (the suite every adapter must pass), and `sniffType`. Web-standard APIs only, no runtime dependency. Reports are isolated by member (another member's report answers exactly like a missing one), the stored role is decided by the server, attachments go straight to storage on links with size and type fixed and are verified by their bytes at finalize, the day's limit is twenty at most, a claim lasts ten minutes, and the approval is first-wins and held by the Mac that took it (a week, releasable). The store contract is create-only and narrow (`put`, `setFile`, `addFollowUp`, `setStatus` with a precondition, `reopen`), so a stale write cannot erase a status or a claim, and a follow-up or file that arrives after a Mac collected a report offers it again. Settle carries the revision the Mac was shown, so something added between list and settle is never lost; follow-up caps are enforced inside the atomic write; a verified file entry cannot be replaced; only the owner adds to a report, and not once it has ended; a voice file's link size follows its declared length. The phone refuses a picture or video format the app cannot take, in words, and takes a HEIC with no type by its extension. The conformance suite is proved against deliberately unsafe adapters.
14
+
15
+ The check is strict about what counts as rendering the entry: an imported component only covers a layout that uses it (as an element, a prop value or a rendered alias), and the imported name (for a namespace import, the member rendered) is carried through barrel files, strings and comments never count, JSX text is not read as code, and an app directory with no pages is a usage error rather than a pass.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SupersuitUp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,219 @@
1
- # Temporary Holding Version
1
+ # @supersuit/suggest-a-change
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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 (the package root) has zero dependencies.
4
+
5
+ ## 30 seconds
6
+
7
+ ```bash
8
+ npm install @supersuit/suggest-a-change
9
+ ```
10
+
11
+ ```js
12
+ import { KINDS, STATUSES, FLOW, CEILINGS } from "@supersuit/suggest-a-change";
13
+
14
+ console.log(KINDS); // ["change", "broken", "feedback"]
15
+ console.log(FLOW["with-freeda"]); // { actor: "mac", next: ["planned"] }
16
+ ```
17
+
18
+ The package root is the report contract: the kinds, roles, ceilings, the status list and the flow map (who performs each step and which moves are allowed), plus the functions that validate a report against them. It has no imports, so it can also be copied whole into a place that has no install step. If you copy it, keep a hash of the copy and fail a test when the bytes change; fix the contract here and copy it again.
19
+
20
+ The client core is TypeScript, built to `dist/` with declarations. `"./client"` is one entry point that includes the screens, so it needs React 19 installed in the app (the contract at the package root and `"./check"` do not):
21
+
22
+ ```ts
23
+ import { openStore, makeOutbox, makeReportSender, queueReport, draftKey, saveDraft, startTake, recoverTakes } from "@supersuit/suggest-a-change/client";
24
+ ```
25
+
26
+ A draft is keyed `sac:<app>:<account>`, so switching people never shows another person's draft, and it carries the report id made when it started. A report goes into the outbox as one `report` write and one `reportPart` per attachment, and the draft is cleared only when the last of them has landed. `StoreAdapter` is the interface an app implements for the receiving side.
27
+
28
+ ### The screens (React 19, optional peer)
29
+
30
+ `"./client"` also carries the screens: `SuggestEntry`, `SuggestSheet`, `Receipt`, `MySuggestions`, `SuggestionDetail`, `ReportProblem`, `DefaultField`, `Toasts`, and `receiptWords`. An app supplies `Adapters`:
31
+
32
+ ```tsx
33
+ import { SuggestEntry, SuggestSheet, type Adapters } from "@supersuit/suggest-a-change/client";
34
+ import "@supersuit/suggest-a-change/styles.css"; // optional
35
+
36
+ const adapters: Adapters = {
37
+ identity: async () => (user ? { account: user.memberKey, role: "member" } : null),
38
+ store, // a StoreAdapter: submit, uploadPart, mine, one, append (dedupe by id)
39
+ words: { app: "notes", owner: "Alex" }, // the owner's name is passed in, never written in the module
40
+ limits: { images: 3 }, // may lower the ceilings, never raise them
41
+ Field: GoldenVoiceField, // optional: a host's own voice field; the module ships DefaultField
42
+ snapshot: async () => screenshotBlob(), // optional: one tap to attach a picture of this screen
43
+ about: { app: "notes", version: "1.2.3" }, // stamped into every report
44
+ lands: "app", // "mac" on a Helm that posts straight to the owner's Mac
45
+ };
46
+ // On every screen below home: <SuggestEntry adapters={adapters} onOpen={() => setOpen(true)} />
47
+ // then: {open && <SuggestSheet adapters={adapters} where={{ screen: "Photos", route: location.pathname }} onClose={() => setOpen(false)} />}
48
+ ```
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.
51
+
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
+
54
+ | Property | Default | Used for |
55
+ | --- | --- | --- |
56
+ | `--sac-font` | `inherit` | all text |
57
+ | `--sac-fg` | `#1a1a1a` | text |
58
+ | `--sac-bg` | `#ffffff` | sheet and control backgrounds |
59
+ | `--sac-muted` | `#5f6368` | secondary text |
60
+ | `--sac-border` | `#cfd2d6` | outlines |
61
+ | `--sac-accent` | `#1a56db` | the primary button, the current step |
62
+ | `--sac-accent-fg` | `#ffffff` | text on the accent |
63
+ | `--sac-danger` | `#b3261e` | errors and the record dot |
64
+ | `--sac-radius` | `12px` | corners |
65
+ | `--sac-gap` | `12px` | spacing |
66
+ | `--sac-z` | `1000` | stacking of the sheet (toasts sit one above) |
67
+ | `--sac-backdrop` | `rgba(0, 0, 0, 0.45)` | the dim behind the sheet |
68
+ | `--sac-focus` | `#1a56db` | focus rings |
69
+
70
+ The four diagnostics are the app's version, the time, the screen size and the phone type; the app's version and the phone type are on by default and the time and screen size are off (the report's id and the app's own received time already say when it was sent), and the screen's name and address are always sent and shown as such.
71
+
72
+ **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).
73
+
74
+ **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`.
75
+
76
+ ## The check: every screen carries the button
77
+
78
+ `suggest-a-change-check` fails an app whose screens lack the Suggest a change entry, or whose adapters are not all supplied. Each app calls it from its own check script. Run it from the app's folder:
79
+
80
+ ```bash
81
+ npx suggest-a-change-check
82
+ npx suggest-a-change-check --help
83
+ ```
84
+
85
+ Exit 0 is clean, 1 is problems (each printed with the file and the fix), 2 is a usage error or nothing to check. `--json` prints `{ ok, problems, checked, exempted }`. The flags are `--repo`, `--config`, `--app-dir`, `--adapters` and `--strict`. An app directory with no pages is a usage error (exit 2: nothing was checked), as is an app directory outside the repo.
86
+
87
+ **Put `suggest-a-change-check --strict` in the app's check script** once the adapters are real. `init` writes stubs that sign nobody in and refuse every report, and marks the file with a `suggest-a-change: STUB` line. Without `--strict` that line is a `warning:` (also in the `--json` `warnings`) and the exit stays 0; with `--strict` it is a problem and the exit is 1. Delete the line when the four adapters are real.
88
+
89
+ For a Next.js app (app router) it reads source, not a running app:
90
+
91
+ - It takes the same screen list the home-screen check takes (see "The screen list" below): every page under the app directory (`appDir` in `home-screen.json`, else `src/app`, else `app`), minus the routes listed under `home` (default `/`).
92
+ - A screen passes when its page, or any layout or template from the app directory down to the page's own folder (nested layouts and route groups included), renders `<SuggestEntry` or carries `data-suggest-entry` as a JSX element or attribute. The entry may come through a component the file imports, but only if the file actually uses that import: it appears as an element (`<Shell>`), is passed as a value inside JSX (`<Providers Layout={Shell}>`), or is assigned to a const that is rendered (`const W = Shell; <W />`). Importing it is not enough, and calling a function with it (`String(Shell)`) is not using it. The imported NAME is carried through barrel files: a barrel that re-exports both Shell and Button covers a layout that renders Shell, never one that renders only Button. `export { A as B } from`, `export { default as X } from`, `export * from`, `export * as ns from`, and `import { X } from ...; export { X }` / `export default X` all pass the name along. For a namespace import (`import * as ui`) only the members rendered count: `<ui.Button />` is covered only if Button's export reaches the entry, while `<ui.Shell />` is covered if Shell's does; a namespace handed on whole (`parts={ui}`) counts every member. Relative, `@/` and `~/` imports are followed three levels deep, each re-export hop counting as one. A match inside a string literal, a comment or a template string does not count; JSX text is text, so an apostrophe in it starts nothing.
93
+ - A screen that should not have the button is listed in the same config file, with a reason:
94
+
95
+ ```json
96
+ { "home": ["/"], "suggest-a-change": { "exempt": { "/share/[token]": "opened by someone who is not a member" } } }
97
+ ```
98
+
99
+ An exemption with an empty reason, or for a route that no longer exists, is a problem.
100
+ - The adapters file (`tailor/suggest-a-change.ts`, or the path under `"suggest-a-change": { "adapters": "..." }`) must export all four of `identity`, `store`, `words` and `limits`, directly or through `export *` and `export { x as y }` from a local file.
101
+
102
+ What it cannot see: an entry rendered only under a condition, which component inside an imported file renders the entry (it checks the file: when one file defines both the entry-bearing component and other components, rendering any of that file's exports counts as covered), a component reached through more than three levels of imports, an import through a path alias other than `@/` and `~/`, an entry that code builds from a string, and a regular expression literal containing a quote, which can confuse its reading of the rest of that file. It reads text, so it will say a screen is covered when the entry exists in the source and never shows at runtime. Keep the entry unconditional (a signed-out visitor is told why by the entry itself).
103
+
104
+ ### The screen list
105
+
106
+ The check copies the route rules of a template's `living/home-screen-contract.mjs`, so both sides name the same screens. These are the rules, pinned by the `routes` fixture in `test/fixtures/` and the test `the screen list follows the rules the home-screen check follows`; change one side and the other must change with it:
107
+
108
+ - A screen is a file named `page.tsx`, `page.ts`, `page.jsx`, `page.js` or `page.mdx`. `page.test.tsx`, `page.dev.tsx` and `route.ts` are not screens.
109
+ - Any file under a folder whose name starts with `_` is skipped.
110
+ - Route groups, folders named `(name)`, are dropped from the route: `(marketing)/about/page.tsx` is `/about`.
111
+ - Parallel-route slots, folders named `@name`, are dropped: `@sidebar/stats/page.tsx` is `/stats`.
112
+ - Dynamic segments stay as written: `[id]`, `[...slug]`, `[[...path]]`.
113
+ - Intercepting folders `(.)x`, `(..)x` and `(...)x` are NOT dropped (only a folder name that both starts with `(` and ends with `)` is a group), so `photos/(.)view/[id]/page.tsx` is `/photos/(.)view/[id]`.
114
+ - A route is exempt from the check if it is a `home` route, or listed under `suggest-a-change.exempt` with a reason.
115
+
116
+ An app with one shell and no per-screen pages (a single-page app) checks its route list instead:
117
+
118
+ ```js
119
+ import { checkEntryOnRoutes } from "@supersuit/suggest-a-change/check";
120
+
121
+ const problems = checkEntryOnRoutes({
122
+ routes: ["/", "/inbox", { route: "/print", exempt: "a printed page" }],
123
+ render: (route) => renderToString(<App route={route} />), // returns the route's HTML
124
+ });
125
+ // [] when every route's HTML has an element carrying data-suggest-entry. checkEntryOnRoutesAsync takes an async render.
126
+ ```
127
+
128
+ `checkSuggestEverywhere({ repo, appDir, config, adaptersFile })` is the same check as the command, as a function that returns the problems (it throws `UsageError` when there is no app directory to check).
129
+
130
+ ### Wiring a new app
131
+
132
+ ```bash
133
+ npx suggest-a-change init
134
+ ```
135
+
136
+ Run it in the app's folder. It adds the dependency to `package.json`, writes `tailor/suggest-a-change.ts` with the four adapters stubbed (kept as it is if it exists), adds `suggest-a-change-check` to the app's check script (`living:check` if the app has one, else `check`), writes `src/components/suggest-a-change.tsx`, and renders it in the root layout (before the last JSX `{children}`, never a destructured parameter). It keeps `package.json`'s indent and the order of the other dependencies, and refuses an app directory outside the repo. The adapters it writes are stubs and `init` says so. Running it again changes nothing. Then install your dependencies and fill in the four adapters. `--repo`, `--app` (the id in `words.app`) and `--owner` (the first name the receipt says) are optional; `suggest-a-change check` is the same command as `suggest-a-change-check`.
137
+
138
+ Check this package (typecheck, client tests, build, node tests, the commands):
139
+
140
+ ```bash
141
+ npm ci
142
+ npm run check
143
+ ```
144
+
145
+ Development needs Node 22 or newer (vitest and jsdom); the built package runs on Node 20 and up.
146
+
147
+ ## The server half (`./server`)
148
+
149
+ Web-standard only (`Request`, `Response`, `crypto.subtle`), no runtime dependency, nothing from react or the client. A hosted app mounts the handlers and supplies a storage adapter.
150
+
151
+ ```ts
152
+ import { createReportHandlers } from "@supersuit/suggest-a-change/server";
153
+
154
+ const h = createReportHandlers({
155
+ store, // your HostedStore (see runStoreConformance)
156
+ identity: async (req) => member, // { account } or null
157
+ maintainers: ["mo"], // member keys with maintainer permission; ordinary membership never is
158
+ agent: async (req) => maintainerKey, // which maintainer's Mac is calling, or null
159
+ limits: { perPersonPerDay: 10 }, // may lower a limit, never raise it (also voiceNotes, followUpVoiceNotes, followUps, agentPage)
160
+ app: { name: "Notes" }, // stamped into every report from here; an app in the body is ignored
161
+ maintainersSince: { mo: "2026-10-01" }, // optional: reports from before this are not offered to that maintainer's Mac
162
+ tell: (e) => notify(e), // ids only, never words; a failure never fails the request
163
+ });
164
+ ```
165
+
166
+ | Route | Handler | Answers |
167
+ | --- | --- | --- |
168
+ | `POST /api/reports` | `submit` | 200 (a repeat of the same id is 200 too), 400, 401, 413, 422, 429 |
169
+ | `POST /api/reports/upload-url` | `uploadUrl` | 200 `{ url, headers }` (or `{ done }`), 400, 401, 404, 413 |
170
+ | `POST /api/reports/:id/finalize` | `finalize` | 200, 401, 404, 409 (not arrived yet), 422 (contents are not the declared type) |
171
+ | `GET /api/reports/mine` | `mine` | 200, 401 |
172
+ | `GET /api/reports/:id` | `one(req, id)` | 200, 401, 404 (missing and not yours look the same) |
173
+ | `POST /api/reports/:id/append` | `append(req, id)` | 200, 400, 401, 404 (not the owner's: same as missing), 409 (the suggestion has ended) |
174
+ | `GET /api/agent/reports` | `agentList` | 200 `{ reports }`: one page (20, oldest first) of what is open for this Mac, each claimed for ten minutes; a report whose files are still arriving waits up to an hour |
175
+ | `GET /api/agent/reports/:id/files/:file` | `agentFile(req, id, file)` | 200 `{ url, expiresAt, type, bytes, sha256 }` (the link lives five minutes), 404 |
176
+ | `POST /api/agent/reports/:id/settle` | `agentSettle` | body `{ revision }` (the one the list gave this report): 200, 400 (no revision), 409 (not claimed, or `stale`: something was added since the list, so the report is offered again) |
177
+ | `POST /api/agent/reports/:id/claim-approval` | `agentClaimApproval` | 200, or 409 `{ claimedBy }` |
178
+ | `POST /api/agent/reports/:id/status` | `agentStatus` | 200, 400, 409 |
179
+
180
+ A client adapter's `uploadPart` asks `upload-url`, PUTs the bytes straight to storage, then calls `finalize`. Nothing is uploaded through the app's server and no handler reads a body over about 400 KB (sized from the field limits at four bytes a character; far under 4 MB).
181
+
182
+ The role stored on a report comes from the signed-in member and the `maintainers` list. A `who` sent by the phone is ignored; a report carrying `timeline`, `status`, `plan` or `decision` is refused. `sniffType(firstBytes)` names a file by its bytes (jpeg, png, webp, heic/heif, gif, mp4/mov, webm, m4a/aac, mp3, wav, ogg/opus; anything else is `null`) and `finalize` refuses a file whose bytes are not the type it was declared as.
183
+
184
+ **Settle carries a revision.** `agentList` gives each report a `revision`; the Mac sends it back with `settle`, and the store settles only if the report still has exactly that content. A follow-up or file that arrived in between answers 409 `stale`, drops the claim, and the next list offers the report with the addition. Adapters compute the same value with `reportRevision`.
185
+
186
+ **The collector MUST verify.** `agentFile` answers the verified `bytes` and `sha256`; a Mac that downloads the link must check both before it trusts the file.
187
+
188
+ **The approval.** Moves before the owner is asked (`with-freeda`, `planned`, `waiting-on-owner`) need no claim. Leaving `waiting-on-owner`, starting `being-made` and any end status need the Mac that holds the approval (taken atomically if nobody holds it, otherwise 409 with who does). The holder can release it, and it lapses after seven days.
189
+
190
+ **What a storage adapter implements** (`HostedStore`). Every method that changes a report is narrow and atomic: `put` is create-only (`created`, `exists`, or `limit` when the member's day is full, counted in the same transaction), and `setFile` (never replaces a verified entry; an older pending never replaces a newer one), `addFollowUp` (the caps, a reused file name and an ended report are checked inside the write), `setStatus(expectFrom)`, `claimApproval`, `claim`, `settle` and `reopen` each change one thing. A real adapter implements these with a transaction or a precondition, never "read the report, change a copy, write it back": that rolls back a status or a claim written in between. Upload links must be create-only and one-time with the size and type fixed in their conditions (a Firebase or S3 signed PUT with `if-generation-match: 0` or `If-None-Match: *`), `discard` must delete a refused object, and `readUrl` links must expire after `ttlMs`.
191
+
192
+ **Every adapter must pass the conformance suite**, against the adapter and its real storage:
193
+
194
+ ```ts
195
+ import { runStoreConformance } from "@supersuit/suggest-a-change/server";
196
+ await runStoreConformance(() => ({ store, now, advance, putViaLink, getViaLink, objectKeys }), { test }); // the runner's own `test`
197
+ // putViaLink / getViaLink do what your storage service does with a PUT on an upload link and a GET on a read link.
198
+ ```
199
+
200
+ ## What is here, and what is coming
201
+
202
+ | Export | State |
203
+ | --- | --- |
204
+ | `"."` the report contract | here |
205
+ | `"./client"` ids, drafts, the report outbox and the recorder | here |
206
+ | `"./client"` the toast, entry, sheet, receipt, My suggestions and detail (React 19, optional peer) | here |
207
+ | `"./styles.css"` optional defaults | here |
208
+ | `"./check"` and the `suggest-a-change-check` and `suggest-a-change init` commands | here |
209
+ | `"./server"` the receiving side: handlers, store conformance suite, byte sniffing | here |
210
+
211
+ Each export is added to `package.json` in the same change as its code, so nothing here points at a file that does not exist.
212
+
213
+ ## Status
214
+
215
+ Version 0.1.0 is the first release and is published once by its owner (the first publish of a new package name cannot be a tag push). Every release after that is a pushed `v<version>` tag (GitHub Actions, npm trusted publishing). Below 1.0, additions are patch versions, so apps on `^0.1.0` take them without a change.
216
+
217
+ ## License
218
+
219
+ MIT
package/bin/check.mjs ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // suggest-a-change-check: exit 0 clean, 1 problems, 2 usage.
3
+ import { runCheck } from '../src/check/cli.mjs'
4
+ process.exit(runCheck(process.argv.slice(2)))
package/bin/init.mjs ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // suggest-a-change init: exit 0 done, 2 usage.
3
+ import { runInit } from '../src/check/init.mjs'
4
+ process.exit(runInit(process.argv.slice(2)))
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ // suggest-a-change: the module's command line. Exit codes: 0 ok, 1 problems, 2 usage.
3
+ // suggest-a-change init [...] wire an app to Suggest a change
4
+ // suggest-a-change check [...] the screen check (also installed as suggest-a-change-check)
5
+ import { runCheck } from '../src/check/cli.mjs'
6
+ import { runInit } from '../src/check/init.mjs'
7
+
8
+ const USAGE = `suggest-a-change: the report format and flow behind a Suggest a change button.
9
+
10
+ Usage:
11
+ suggest-a-change init [--repo <path>] [--app <id>] [--owner <name>] [--json]
12
+ suggest-a-change check [--repo <path>] [--config <file>] [--app-dir <dir>] [--adapters <file>] [--json]
13
+ suggest-a-change --help
14
+
15
+ "check" is the same command as suggest-a-change-check. Run either with --help for its flags.
16
+ The report contract is a library: import it from "@supersuit/suggest-a-change".`
17
+
18
+ const [cmd, ...rest] = process.argv.slice(2)
19
+ if (cmd === '--help' || cmd === '-h') { console.log(USAGE); process.exit(0) }
20
+ if (cmd === 'init') process.exit(runInit(rest))
21
+ if (cmd === 'check') process.exit(runCheck(rest))
22
+ console.error(cmd ? `unknown command ${cmd}\n\n${USAGE}` : USAGE)
23
+ process.exit(2)
@@ -0,0 +1,101 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { CEILINGS } from '../contract.mjs';
3
+ import type { Store } from './idb.ts';
4
+ import type { StoreAdapter } from './reports.ts';
5
+ import type { startTake } from './recorder.ts';
6
+ import type { Words } from './words.ts';
7
+ export type Role = 'operator' | 'member' | 'maintainer';
8
+ export type Kind = 'change' | 'broken' | 'feedback';
9
+ export type Identity = {
10
+ account: string;
11
+ role: Role;
12
+ };
13
+ export type HeardUpload = (key: string, blob: Blob, ms: number) => Promise<{
14
+ audio: string;
15
+ text: string;
16
+ } | {
17
+ refused: string;
18
+ }>;
19
+ /** What a field needs of the host's runtime: the phone's database. A host's richer runtime fits. */
20
+ export interface FieldRuntime {
21
+ store: Store;
22
+ }
23
+ /** The props every text field the module renders receives. Named and shaped after the Helm's golden
24
+ * voice field (VoiceFieldProps), so the Helm passes that component into the `Field` slot as it is. */
25
+ export interface FieldProps {
26
+ rt: FieldRuntime;
27
+ id: string;
28
+ label: string;
29
+ value: string;
30
+ onChange(v: string): void;
31
+ placeholder?: string;
32
+ multiline?: boolean;
33
+ type?: 'text' | 'search';
34
+ /** Gets each finished take. The sheet keeps the recording; transcription is the receiving Mac's job. */
35
+ upload?: HeardUpload;
36
+ onHeard?(h: {
37
+ audio: string;
38
+ text: string;
39
+ }): void;
40
+ onKept?(t: {
41
+ key: string;
42
+ ms: number;
43
+ }): void;
44
+ onHearing?(hearing: boolean): void;
45
+ disabled?: boolean;
46
+ persist?: boolean;
47
+ startTake?: typeof startTake;
48
+ /** A take stops by itself here and is kept at this length (the module's default field only). */
49
+ maxMs?: number;
50
+ /** Scopes this field's recorded takes (the sheet passes the draft key: app and account), so an interrupted take can only rejoin its own account. */
51
+ scope?: string;
52
+ /** The default field's own words, from the app's `words.text`. */
53
+ labels?: {
54
+ holdToTalk: string;
55
+ stopTalking: string;
56
+ micDenied: string;
57
+ voiceUnavailable: string;
58
+ voiceTotalLimit: string;
59
+ };
60
+ }
61
+ export interface Adapters {
62
+ /** Who is signed in; null when no one is. */
63
+ identity(): Promise<Identity | null>;
64
+ /** Where reports are kept. Must dedupe by report id (see StoreAdapter). */
65
+ store: StoreAdapter;
66
+ words: Words;
67
+ /** Lowers the ceilings; never raises them. */
68
+ limits?: Partial<typeof CEILINGS>;
69
+ Field?: ComponentType<FieldProps>;
70
+ /** One tap to attach a picture of the current screen. Without it the control is not shown. */
71
+ snapshot?(): Promise<Blob>;
72
+ /** Which app and version this is, stamped into every report. */
73
+ about?: {
74
+ app?: string;
75
+ template?: string;
76
+ templateVersion?: string;
77
+ copy?: string;
78
+ version?: string;
79
+ };
80
+ /** Where a received report lands: `'mac'` on a Helm that posts straight to the owner's Mac. */
81
+ lands?: 'app' | 'mac';
82
+ /** The phone's own database and reachability, for hosts that already hold them (and for tests). */
83
+ phone?: {
84
+ store?: Store;
85
+ reach?: {
86
+ check(force?: boolean): Promise<boolean>;
87
+ mark(up: boolean): void;
88
+ };
89
+ now?: () => number;
90
+ /** Reads a video's length in ms; undefined when it cannot be read. Defaults to the browser's own player. */
91
+ videoLength?(file: Blob): Promise<number | undefined>;
92
+ /** How long the default reader waits for the video's length before giving up. Default 3000. */
93
+ probeMs?: number;
94
+ };
95
+ /** Phone type for the device diagnostic. Defaults to a short name ("iPhone", "Android phone"). */
96
+ device?(): string;
97
+ /** The recorder the default field uses. Defaults to the module's own. */
98
+ startTake?: typeof startTake;
99
+ /** False when the receiving side cannot take a voice note added to a report afterwards (the detail view then says so). */
100
+ appendVoice?: boolean;
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { FieldProps } from './adapters.ts';
2
+ export declare function DefaultField(props: FieldProps): import("react").JSX.Element;
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // THE MODULE'S OWN VOICE FIELD, for hosted apps that have no golden one. A text area with a hold-to-talk
4
+ // button beside it, in one [data-voice-field] container with nothing else typeable in it. A take is
5
+ // recorded with the module's recorder (written to the phone's database as it is made). Transcription is
6
+ // not done here: the take goes to `upload`, and the receiving Mac writes the transcript. A voice note
7
+ // alone is a whole report, so a field with no words and one take is a complete answer.
8
+ //
9
+ // Holding the button records; letting go (or leaving it) stops. For anyone who cannot hold, Space or
10
+ // Enter on the focused button starts a take and again stops it.
11
+ import { useEffect, useRef, useState } from 'react';
12
+ import { startTake as defaultStartTake } from './recorder.js';
13
+ import { toast } from './toast.js';
14
+ import { DEFAULT_STRINGS } from './words.js';
15
+ export function DefaultField(props) {
16
+ const { rt, id, label, value, onChange, placeholder, multiline = false, type = 'text', upload, onHeard, onKept, onHearing, disabled, startTake = defaultStartTake, maxMs, labels, scope } = props;
17
+ const L = labels ?? DEFAULT_STRINGS;
18
+ // With nowhere to send a take, recording would lose it silently: the control stays, switched off, and says why.
19
+ const canRecord = Boolean(upload || onKept);
20
+ const [recording, setRecording] = useState(false);
21
+ const take = useRef(null);
22
+ const wanted = useRef(false);
23
+ const timer = useRef(null);
24
+ const latest = useRef(value);
25
+ latest.current = value;
26
+ const cb = useRef({ onChange, upload, onHeard, onKept, onHearing });
27
+ cb.current = { onChange, upload, onHeard, onKept, onHearing };
28
+ async function begin() {
29
+ if (take.current || wanted.current || disabled)
30
+ return;
31
+ // No time left: say so before recording instead of recording a note that will be refused.
32
+ if (maxMs !== undefined && maxMs <= 0) {
33
+ toast({ id: 'sac-limit', text: L.voiceTotalLimit, tone: 'warn' });
34
+ return;
35
+ }
36
+ wanted.current = true;
37
+ try {
38
+ const t = await startTake(rt.store, `take:${id}:${Date.now()}`, scope ? `${scope}|field:${id}` : `field:${id}`);
39
+ if (!wanted.current) {
40
+ await t.cancel();
41
+ return;
42
+ }
43
+ take.current = t;
44
+ setRecording(true);
45
+ if (maxMs)
46
+ timer.current = setTimeout(() => void end(), maxMs);
47
+ }
48
+ catch {
49
+ wanted.current = false;
50
+ toast({ id: 'sac-mic', text: L.micDenied, tone: 'warn' });
51
+ }
52
+ }
53
+ async function end() {
54
+ wanted.current = false;
55
+ if (timer.current) {
56
+ clearTimeout(timer.current);
57
+ timer.current = null;
58
+ }
59
+ const t = take.current;
60
+ if (!t)
61
+ return;
62
+ take.current = null;
63
+ setRecording(false);
64
+ const r0 = await t.stop();
65
+ // A take the timer ended is kept at the limit: the recorder reports a little longer than the timer ran.
66
+ const r = maxMs ? { ...r0, ms: Math.min(r0.ms, maxMs) } : r0;
67
+ cb.current.onHearing?.(true);
68
+ try {
69
+ if (!cb.current.upload) {
70
+ cb.current.onKept?.({ key: r.key, ms: r.ms });
71
+ return;
72
+ }
73
+ const h = await cb.current.upload(r.key, r.blob, r.ms);
74
+ if ('refused' in h) {
75
+ toast({ id: 'sac-refused', text: h.refused, tone: 'warn' });
76
+ return;
77
+ }
78
+ cb.current.onHeard?.(h);
79
+ if (h.text)
80
+ cb.current.onChange([latest.current.trim(), h.text].filter(Boolean).join(' '));
81
+ }
82
+ finally {
83
+ cb.current.onHearing?.(false);
84
+ }
85
+ }
86
+ useEffect(() => () => { wanted.current = false; if (timer.current)
87
+ clearTimeout(timer.current); void take.current?.cancel(); }, []);
88
+ const control = (_jsx("button", { type: "button", "data-voice-control": "", className: "sac-record", "aria-pressed": recording, "aria-label": canRecord ? `${recording ? L.stopTalking : L.holdToTalk}: ${label}` : `${L.voiceUnavailable}: ${label}`, disabled: disabled || !canRecord, onPointerDown: (e) => { e.preventDefault(); void begin(); }, onPointerUp: () => void end(), onPointerLeave: () => { if (wanted.current)
89
+ void end(); }, onPointerCancel: () => void end(), onContextMenu: (e) => e.preventDefault(), onKeyDown: (e) => {
90
+ if (e.key !== ' ' && e.key !== 'Enter')
91
+ return;
92
+ e.preventDefault();
93
+ if (e.repeat)
94
+ return;
95
+ void (take.current || wanted.current ? end() : begin());
96
+ }, children: _jsx("span", { "aria-hidden": "true", className: "sac-record-dot" }) }));
97
+ const shared = { id: `sac-field-${id}`, value, placeholder, disabled, className: 'sac-input', onChange: (e) => onChange(e.target.value) };
98
+ return (_jsxs("div", { "data-voice-field": "", className: "sac-voice", "data-recording": recording ? '' : undefined, children: [control, _jsx("label", { htmlFor: `sac-field-${id}`, className: "sac-sr", children: label }), multiline ? _jsx("textarea", { rows: 3, ...shared }) : _jsx("input", { type: type, ...shared })] }));
99
+ }
@@ -0,0 +1,17 @@
1
+ import type { Adapters } from './adapters.ts';
2
+ export declare function statusOf(r: Record<string, unknown>): string;
3
+ /** The step shown under "Being made". The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
4
+ export declare function stepOf(r: Record<string, unknown>): string | undefined;
5
+ /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Freeda"),
6
+ * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
7
+ export declare function StatusStrip({ status, owner, step, timeline, label }: {
8
+ status: string;
9
+ owner: string;
10
+ step?: string;
11
+ timeline?: string[]; /** The list's accessible name; the screens pass the app's own (words.text.stepLabel). */
12
+ label?: string;
13
+ }): import("react").JSX.Element;
14
+ export declare function SuggestionDetail({ adapters, id }: {
15
+ adapters: Adapters;
16
+ id: string;
17
+ }): import("react").JSX.Element;