@supersuit/suggest-a-change 0.1.4 → 0.1.6

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6 (2026-10-15)
4
+
5
+ - **Writes made in the same millisecond leave the phone in the order they were made.** `enqueue` stamps each op with `seq`, and the queue sorts by `at`, then `seq`, then id. Before, ties fell to the id, so a screen that saved a draft and then asked for it to be prepared in one tick sent the prepare first about half the time, and the receiver prepared the draft as it had been.
6
+
7
+ ## 0.1.5 (2026-10-14)
8
+
9
+ - **The agent is Pluma.** Every word a person reads now names Pluma, the agent formerly called Freeda: "With Pluma", "Goes to <owner> and Pluma", "Pluma picks it up". The status key `with-freeda` is unchanged, so stored reports and older clients still read. A copy of the contract vendored elsewhere must be taken again: these are new bytes.
10
+
3
11
  ## 0.1.4 (2026-10-14)
4
12
 
5
13
  - **The owner may decline a report Freeda has not planned.** `FLOW["with-freeda"].next` and `FLOW["planned"].next` now include `"not-now"`, so a report the reading worker cannot plan has an exit instead of showing "With Freeda" for ever. `agentStatus` accepts both moves (they take the approval, as any end does). A copy of the contract vendored elsewhere must be taken again: these are new bytes.
@@ -2,7 +2,7 @@ import type { Adapters } from './adapters.ts';
2
2
  export declare function statusOf(r: Record<string, unknown>): string;
3
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
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"),
5
+ /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
6
6
  * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
7
7
  export declare function StatusStrip({ status, owner, step, timeline, label }: {
8
8
  status: string;
@@ -34,7 +34,7 @@ export function stepOf(r) {
34
34
  return typeof r.step === 'string' ? r.step : undefined;
35
35
  }
36
36
  const timelineOf = (r) => Array.isArray(r?.timeline) ? r.timeline.map((t) => String(t?.status)) : undefined;
37
- /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Freeda"),
37
+ /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
38
38
  * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
39
39
  export function StatusStrip({ status, owner, step, timeline, label }) {
40
40
  const nodes = useMemo(() => {
@@ -9,6 +9,8 @@ export interface Op {
9
9
  /** The id of the write this one belongs to (a part's report or follow-up). While that write is refused, this one is held, not sent. */
10
10
  parent?: string;
11
11
  at: number;
12
+ /** The order this phone made it in, among writes with the same `at`. Written by enqueue. */
13
+ seq?: number;
12
14
  error?: string;
13
15
  }
14
16
  export type Sent = 'done' | {
@@ -5,8 +5,11 @@ export function makeOutbox({ store, send, reach }) {
5
5
  // the new write existed. Measured on the phone: without this, a second answer sat on the phone
6
6
  // until the 15 s tick.
7
7
  let again = false;
8
+ // Two writes in one millisecond (a save, then the step that reads it) keep the order they were made
9
+ // in. Ordering them by id instead sent the second first about half the time.
10
+ let made = 0;
8
11
  async function pending() {
9
- return (await store.all('outbox')).map((r) => r.value).sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
12
+ return (await store.all('outbox')).map((r) => r.value).sort((a, b) => a.at - b.at || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id));
10
13
  }
11
14
  async function tell(landed) {
12
15
  const all = await pending();
@@ -56,7 +59,7 @@ export function makeOutbox({ store, send, reach }) {
56
59
  }
57
60
  return {
58
61
  pending,
59
- async enqueue(op) { await store.put('outbox', op.id, op); await tell(); },
62
+ async enqueue(op) { await store.put('outbox', op.id, { ...op, seq: op.seq ?? ++made }); await tell(); },
60
63
  drain() {
61
64
  if (running) {
62
65
  again = true;
@@ -27,7 +27,7 @@ export const DEFAULT_STRINGS = {
27
27
  takeScreenshot: 'Take a screenshot of this screen',
28
28
  remove: 'Remove',
29
29
  withLegend: 'What goes with it',
30
- goesTo: (owner) => `Goes to ${owner} and Freeda.`,
30
+ goesTo: (owner) => `Goes to ${owner} and Pluma.`,
31
31
  includes: (parts) => `Includes ${list(parts)}.`,
32
32
  screenName: "this screen's name",
33
33
  alwaysSent: (screen, route) => `Always sent: the screen you are on (${screen}) and its address (${route}).`,
@@ -113,7 +113,7 @@ export function statusMeaning(status, owner) {
113
113
  /** The receipt's one line (design section 2). `received` is only ever asked for after the adapter said `done`. */
114
114
  export function receiptWords(state, words) {
115
115
  if (state === 'received')
116
- return `Got it. Freeda picks it up next time ${words.owner}'s Mac is on.`;
116
+ return `Got it. Pluma picks it up next time ${words.owner}'s Mac is on.`;
117
117
  if (state === 'on-mac')
118
118
  return 'On your Mac.';
119
119
  return 'On this phone, waiting to send.';
package/dist/contract.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/contract.mjs (@supersuit/suggest-a-change)
2
2
  // THE REPORT CONTRACT AND THE FLOW MAP for Suggest a change (design sections 4, 5 and 7). ZERO
3
3
  // imports on purpose, so it can be vendored whole where an install step does not exist: the phone, the
4
- // hosted apps and Freeda's inbox all validate against this one copy. The flow lives here, with who acts
4
+ // hosted apps and Pluma's inbox all validate against this one copy. The flow lives here, with who acts
5
5
  // at each step, so what a person sees cannot drift from what happened.
6
6
  export const KINDS = ["change", "broken", "feedback"];
7
7
  export const ROLES = ["operator", "member", "maintainer"];
@@ -13,9 +13,9 @@ export const CEILINGS = {
13
13
  };
14
14
  export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
15
15
  export const END = ["live", "sent-to-template", "filed", "not-now"];
16
- // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Freeda by the Mac. A Helm on the owner's Mac posts straight into
17
- // the inbox, so a report may go from the phone to Freeda without the hosted app's "received". The owner may decline
18
- // ("not-now") before Freeda has a plan or before she asks: a report she cannot plan would otherwise have no exit.
16
+ // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Pluma by the Mac. A Helm on the owner's Mac posts straight into
17
+ // the inbox, so a report may go from the phone to Pluma without the hosted app's "received". The owner may decline
18
+ // ("not-now") before Pluma has a plan or before she asks: a report she cannot plan would otherwise have no exit.
19
19
  export const FLOW = {
20
20
  "on-phone": { actor: "person", next: ["received", "with-freeda"] },
21
21
  "received": { actor: "app", next: ["with-freeda"] },
@@ -29,14 +29,14 @@ export const FLOW = {
29
29
  "not-now": { actor: "owner", next: [] },
30
30
  };
31
31
  export const mayMove = (from, to) => Boolean(FLOW[from]?.next.includes(to));
32
- // The design's status table (section 2), word for word. "planned" is a step inside Freeda's work
33
- // that the table does not name for a person, so it reads as "With Freeda".
32
+ // The design's status table (section 2), word for word. "planned" is a step inside Pluma's work
33
+ // that the table does not name for a person, so it reads as "With Pluma".
34
34
  const TABLE = {
35
35
  "on-phone": ["On this phone", () => "Not sent yet; it will send when there is a connection"],
36
36
  "received": ["Received", () => "The app has it, safely stored"],
37
- "with-freeda": ["With Freeda", (o) => `${o}'s Mac has picked it up`],
38
- "planned": ["With Freeda", (o) => `${o}'s Mac has picked it up`],
39
- "waiting-on-owner": [null, (o) => `Freeda has a plan and is asking ${o}`],
37
+ "with-freeda": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
38
+ "planned": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
39
+ "waiting-on-owner": [null, (o) => `Pluma has a plan and is asking ${o}`],
40
40
  "being-made": ["Being made", () => "A session is working on it; the current step shows underneath"],
41
41
  "live": ["Done", () => "Shipped in the app, with the date it went live"],
42
42
  "sent-to-template": ["Sent to the template", () => "It became a change to the template every copy of this app uses; it reaches this app once merged"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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": {
@@ -30,7 +30,7 @@ export function stepOf(r: Record<string, unknown>): string | undefined {
30
30
  const timelineOf = (r?: Record<string, unknown>): string[] | undefined =>
31
31
  Array.isArray(r?.timeline) ? (r!.timeline as { status?: unknown }[]).map((t) => String(t?.status)) : undefined
32
32
 
33
- /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Freeda"),
33
+ /** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Pluma"),
34
34
  * and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
35
35
  export function StatusStrip({ status, owner, step, timeline, label }: { status: string; owner: string; step?: string; timeline?: string[]; /** The list's accessible name; the screens pass the app's own (words.text.stepLabel). */ label?: string }) {
36
36
  const nodes = useMemo(() => {
@@ -18,6 +18,8 @@ export interface Op {
18
18
  /** The id of the write this one belongs to (a part's report or follow-up). While that write is refused, this one is held, not sent. */
19
19
  parent?: string
20
20
  at: number
21
+ /** The order this phone made it in, among writes with the same `at`. Written by enqueue. */
22
+ seq?: number
21
23
  error?: string
22
24
  }
23
25
  export type Sent = 'done' | { refused: string }
@@ -30,9 +32,12 @@ export function makeOutbox({ store, send, reach }: { store: Store; send(op: Op):
30
32
  // the new write existed. Measured on the phone: without this, a second answer sat on the phone
31
33
  // until the 15 s tick.
32
34
  let again = false
35
+ // Two writes in one millisecond (a save, then the step that reads it) keep the order they were made
36
+ // in. Ordering them by id instead sent the second first about half the time.
37
+ let made = 0
33
38
 
34
39
  async function pending(): Promise<Op[]> {
35
- return (await store.all<Op>('outbox')).map((r) => r.value).sort((a, b) => a.at - b.at || a.id.localeCompare(b.id))
40
+ return (await store.all<Op>('outbox')).map((r) => r.value).sort((a, b) => a.at - b.at || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id))
36
41
  }
37
42
  async function tell(landed?: string) {
38
43
  const all = await pending()
@@ -69,7 +74,7 @@ export function makeOutbox({ store, send, reach }: { store: Store; send(op: Op):
69
74
  }
70
75
  return {
71
76
  pending,
72
- async enqueue(op: Op) { await store.put('outbox', op.id, op); await tell() },
77
+ async enqueue(op: Op) { await store.put('outbox', op.id, { ...op, seq: op.seq ?? ++made }); await tell() },
73
78
  drain(): Promise<void> {
74
79
  if (running) { again = true; return running }
75
80
  return (running = run().finally(() => { running = null }))
@@ -122,7 +122,7 @@ export const DEFAULT_STRINGS: Strings = {
122
122
  takeScreenshot: 'Take a screenshot of this screen',
123
123
  remove: 'Remove',
124
124
  withLegend: 'What goes with it',
125
- goesTo: (owner) => `Goes to ${owner} and Freeda.`,
125
+ goesTo: (owner) => `Goes to ${owner} and Pluma.`,
126
126
  includes: (parts) => `Includes ${list(parts)}.`,
127
127
  screenName: "this screen's name",
128
128
  alwaysSent: (screen, route) => `Always sent: the screen you are on (${screen}) and its address (${route}).`,
@@ -202,7 +202,7 @@ export type ReceiptState = 'on-phone' | 'received' | 'on-mac'
202
202
 
203
203
  /** The receipt's one line (design section 2). `received` is only ever asked for after the adapter said `done`. */
204
204
  export function receiptWords(state: ReceiptState, words: Pick<Words, 'owner'>): string {
205
- if (state === 'received') return `Got it. Freeda picks it up next time ${words.owner}'s Mac is on.`
205
+ if (state === 'received') return `Got it. Pluma picks it up next time ${words.owner}'s Mac is on.`
206
206
  if (state === 'on-mac') return 'On your Mac.'
207
207
  return 'On this phone, waiting to send.'
208
208
  }
package/src/contract.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/contract.mjs (@supersuit/suggest-a-change)
2
2
  // THE REPORT CONTRACT AND THE FLOW MAP for Suggest a change (design sections 4, 5 and 7). ZERO
3
3
  // imports on purpose, so it can be vendored whole where an install step does not exist: the phone, the
4
- // hosted apps and Freeda's inbox all validate against this one copy. The flow lives here, with who acts
4
+ // hosted apps and Pluma's inbox all validate against this one copy. The flow lives here, with who acts
5
5
  // at each step, so what a person sees cannot drift from what happened.
6
6
 
7
7
  export const KINDS = ["change", "broken", "feedback"];
@@ -17,9 +17,9 @@ export const CEILINGS = {
17
17
  export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
18
18
  export const END = ["live", "sent-to-template", "filed", "not-now"];
19
19
 
20
- // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Freeda by the Mac. A Helm on the owner's Mac posts straight into
21
- // the inbox, so a report may go from the phone to Freeda without the hosted app's "received". The owner may decline
22
- // ("not-now") before Freeda has a plan or before she asks: a report she cannot plan would otherwise have no exit.
20
+ // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Pluma by the Mac. A Helm on the owner's Mac posts straight into
21
+ // the inbox, so a report may go from the phone to Pluma without the hosted app's "received". The owner may decline
22
+ // ("not-now") before Pluma has a plan or before she asks: a report she cannot plan would otherwise have no exit.
23
23
  export const FLOW = {
24
24
  "on-phone": { actor: "person", next: ["received", "with-freeda"] },
25
25
  "received": { actor: "app", next: ["with-freeda"] },
@@ -35,14 +35,14 @@ export const FLOW = {
35
35
 
36
36
  export const mayMove = (from, to) => Boolean(FLOW[from]?.next.includes(to));
37
37
 
38
- // The design's status table (section 2), word for word. "planned" is a step inside Freeda's work
39
- // that the table does not name for a person, so it reads as "With Freeda".
38
+ // The design's status table (section 2), word for word. "planned" is a step inside Pluma's work
39
+ // that the table does not name for a person, so it reads as "With Pluma".
40
40
  const TABLE = {
41
41
  "on-phone": ["On this phone", () => "Not sent yet; it will send when there is a connection"],
42
42
  "received": ["Received", () => "The app has it, safely stored"],
43
- "with-freeda": ["With Freeda", (o) => `${o}'s Mac has picked it up`],
44
- "planned": ["With Freeda", (o) => `${o}'s Mac has picked it up`],
45
- "waiting-on-owner": [null, (o) => `Freeda has a plan and is asking ${o}`],
43
+ "with-freeda": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
44
+ "planned": ["With Pluma", (o) => `${o}'s Mac has picked it up`],
45
+ "waiting-on-owner": [null, (o) => `Pluma has a plan and is asking ${o}`],
46
46
  "being-made": ["Being made", () => "A session is working on it; the current step shows underneath"],
47
47
  "live": ["Done", () => "Shipped in the app, with the date it went live"],
48
48
  "sent-to-template": ["Sent to the template", () => "It became a change to the template every copy of this app uses; it reaches this app once merged"],