@mythicalos/ui-core 0.2.2 → 0.3.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.
@@ -0,0 +1,142 @@
1
+ import type { DeliveryClass } from "./sendbar.js";
2
+ /** Queue record lifecycle. `queued` is the ONLY cancellable state (see `canCancelRow`). */
3
+ export type QueueItemStatus = "queued" | "leased" | "delivered" | "canceled";
4
+ /** One queue record, generic over the product: an opaque id, its class, its body, its status. */
5
+ export interface QueueItem {
6
+ id: string;
7
+ cls: DeliveryClass;
8
+ body: string;
9
+ status: QueueItemStatus;
10
+ }
11
+ /**
12
+ * Why the queue cannot be shown. These stay DISTINCT on purpose (see `QUEUE_UNAVAILABLE_COPY`):
13
+ * collapsing them into one message would make the UI claim something it does not know.
14
+ * - `unsupported` — this deployment does not do queued delivery at all.
15
+ * - `error` — the read failed. Says only that; never that the queue is empty.
16
+ * - `unaddressable` — there is no queue address to read for this target.
17
+ */
18
+ export type QueueUnavailableReason = "unsupported" | "error" | "unaddressable";
19
+ /**
20
+ * HONESTY INVARIANT (binding, do not reword): CAPABILITY-NEUTRAL empty copy. It reports what the
21
+ * READ returned and nothing else. It must never claim operational emptiness ("queue empty", "no
22
+ * pending deliveries"), never imply a store exists, and never restate the design card's
23
+ * "deliveries land here by class (ASAP interrupts · ON-DONE waits)" — that line is doubly false
24
+ * (it asserts a store AND the interrupt semantics `DELIVERY_HINT` corrects).
25
+ */
26
+ export declare const QUEUE_EMPTY_COPY = "The queue read returned no records.";
27
+ /** Stale = last-known data. NO retry/reconnect claim — nothing here is recovering on its own. */
28
+ export declare const QUEUE_STALE_COPY = "Queue disconnected \u2014 showing the last received list.";
29
+ export declare const QUEUE_LOADING_COPY = "Loading queue\u2026";
30
+ /** The three unavailable reasons, each with its own copy. Asserted pairwise-distinct by test. */
31
+ export declare const QUEUE_UNAVAILABLE_COPY: Record<QueueUnavailableReason, string>;
32
+ export declare const QUEUE_CANCEL_ASK = "Cancel this delivery?";
33
+ export declare const QUEUE_CANCEL_YES = "Cancel it";
34
+ export declare const QUEUE_CANCEL_NO = "Keep";
35
+ export declare const QUEUE_CANCEL_LABEL = "\u2715 cancel";
36
+ /**
37
+ * Every structural class the queue renders. Named here — not typed literally in a binding — so the
38
+ * Preact and React bindings cannot drift.
39
+ */
40
+ export declare const QUEUE_CLASSES: {
41
+ readonly panel: "my-queue";
42
+ readonly list: "my-queue__list";
43
+ readonly state: "my-queue__state";
44
+ readonly stale: "my-queue__stale";
45
+ readonly empty: "my-queue__empty";
46
+ readonly rowBody: "my-qrow__body";
47
+ readonly rowStatus: "my-qrow__status";
48
+ readonly cancel: "my-qrow__cancel";
49
+ readonly ask: "my-qrow__ask";
50
+ readonly actions: "my-qrow__acts";
51
+ readonly confirmYes: "my-qmini my-qmini--yes";
52
+ readonly confirmNo: "my-qmini my-qmini--no";
53
+ };
54
+ /**
55
+ * The queue's input. `stale` is its OWN state, never folded into `ok` — a failing poll that still
56
+ * holds the last received list is a materially different claim from a fresh successful read.
57
+ */
58
+ export type QueueSource = {
59
+ kind: "loading";
60
+ } | {
61
+ kind: "unavailable";
62
+ reason: QueueUnavailableReason;
63
+ } | {
64
+ kind: "ok";
65
+ items: readonly QueueItem[];
66
+ } | {
67
+ kind: "stale";
68
+ items: readonly QueueItem[];
69
+ };
70
+ /** What the panel body renders. `empty` is reachable from exactly one source arm — see below. */
71
+ export type QueueView = {
72
+ kind: "state";
73
+ copy: string;
74
+ } | {
75
+ kind: "empty";
76
+ copy: string;
77
+ } | {
78
+ kind: "list";
79
+ items: readonly QueueItem[];
80
+ staleCopy: string | null;
81
+ /** See `isFreshSource` — false suppresses every cancel affordance in the list. */
82
+ fresh: boolean;
83
+ };
84
+ /**
85
+ * HONESTY INVARIANT (binding): only a completed SUCCESSFUL read is fresh. A `stale` list is
86
+ * last-known data — the poll is failing, so each row's status is what it WAS, not what it is. That
87
+ * makes a cancel control on a stale row a claim the component cannot support: it would assert the
88
+ * record is still `queued` right beside a banner saying the list is no longer current. Stale rows
89
+ * therefore render with no cancel affordance, and an armed confirm force-disarms when its source
90
+ * goes stale (`shouldDisarmCancel`).
91
+ */
92
+ export declare function isFreshSource(source: QueueSource): boolean;
93
+ /**
94
+ * HONESTY INVARIANT (binding): the empty presentation renders ONLY on `ok` + zero items — i.e. only
95
+ * after a fresh successful read actually returned nothing.
96
+ * - `loading` renders the loading state, NEVER "empty" (an unfinished read knows nothing yet).
97
+ * - `unavailable` renders its own distinct reason copy, NEVER "empty".
98
+ * - `stale` with zero items renders a flagged (but empty) LIST, never the empty copy — the last
99
+ * received list happening to be empty is not a fresh "there is nothing queued" claim.
100
+ *
101
+ * `unavailableDetail` lets a product ADD what it knows about a reason (e.g. which mode the daemon
102
+ * is in). It is APPENDED to this package's own sentence, never substituted for it — a caller
103
+ * therefore cannot collapse two reasons into one message, deliberately or by accident: the distinct
104
+ * base sentences always survive, so two reasons can never render identical copy.
105
+ */
106
+ export declare function unavailableText(reason: QueueUnavailableReason, detail?: string): string;
107
+ export declare function queueView(source: QueueSource, unavailableDetail?: Partial<Record<QueueUnavailableReason, string>>): QueueView;
108
+ /** ASAP / ON-DONE badge label (verbatim). */
109
+ export declare function queueBadgeLabel(cls: DeliveryClass): "ASAP" | "ON-DONE";
110
+ /** Badge class: ASAP → accent-soft, ON-DONE → disabled-muted (design card). */
111
+ export declare function queueBadgeClass(cls: DeliveryClass): string;
112
+ /** The row class. An armed row swaps to the confirm presentation instead of a status modifier. */
113
+ export declare function queueRowClass(status: QueueItemStatus, armed: boolean): string;
114
+ /** Human status label — the status verbatim, never a friendlier synonym that loses precision. */
115
+ export declare function queueStatusLabel(status: QueueItemStatus): string;
116
+ /**
117
+ * HONESTY INVARIANT (binding): the cancel affordance exists ONLY on `queued` records AND only when
118
+ * the caller says the record is cancellable at all (`canCancel` — e.g. the viewer owns the queue).
119
+ * Every other status renders WITHOUT an active cancel control: no greyed-out button that implies a
120
+ * cancel could have happened, and never a control whose click would be rejected downstream.
121
+ */
122
+ export declare function canCancelRow(status: QueueItemStatus, canCancel: boolean): boolean;
123
+ export type CancelState = {
124
+ armedId: string | null;
125
+ };
126
+ export type CancelEvent = {
127
+ type: "arm";
128
+ id: string;
129
+ } | {
130
+ type: "confirm";
131
+ } | {
132
+ type: "disarm";
133
+ };
134
+ export declare function cancelReducer(state: CancelState, event: CancelEvent): CancelState;
135
+ /**
136
+ * An armed two-step cancel must NEVER outlive its cancelability. Force-disarm when cancelability is
137
+ * lost (`canCancel` flips false), when the source stops being FRESH (it went stale, unavailable, or
138
+ * back to loading — the armed row is then last-known data, not a live record), or when the armed row
139
+ * is no longer a live `queued` record in the current source (it left the queue as
140
+ * leased/delivered/canceled, or vanished).
141
+ */
142
+ export declare function shouldDisarmCancel(armedId: string | null, canCancel: boolean, source: QueueSource): boolean;
@@ -0,0 +1,143 @@
1
+ // @mythicalos/ui-core — the queue-row half of the terminal set (ds/components-terminal, spec v2):
2
+ // the delivery queue's source→view resolution, row classes, and the two-step inline-cancel state
3
+ // machine. Pure — no framework, no DOM. The honesty rules below live HERE, not in the product, so
4
+ // every consumer inherits them.
5
+ import { deliveryClassLabel } from "./sendbar.js";
6
+ /**
7
+ * HONESTY INVARIANT (binding, do not reword): CAPABILITY-NEUTRAL empty copy. It reports what the
8
+ * READ returned and nothing else. It must never claim operational emptiness ("queue empty", "no
9
+ * pending deliveries"), never imply a store exists, and never restate the design card's
10
+ * "deliveries land here by class (ASAP interrupts · ON-DONE waits)" — that line is doubly false
11
+ * (it asserts a store AND the interrupt semantics `DELIVERY_HINT` corrects).
12
+ */
13
+ export const QUEUE_EMPTY_COPY = "The queue read returned no records.";
14
+ /** Stale = last-known data. NO retry/reconnect claim — nothing here is recovering on its own. */
15
+ export const QUEUE_STALE_COPY = "Queue disconnected — showing the last received list.";
16
+ export const QUEUE_LOADING_COPY = "Loading queue…";
17
+ /** The three unavailable reasons, each with its own copy. Asserted pairwise-distinct by test. */
18
+ export const QUEUE_UNAVAILABLE_COPY = {
19
+ unsupported: "Queued delivery is unavailable in this mode.",
20
+ error: "The queue could not be read.",
21
+ unaddressable: "This session has no queue address.",
22
+ };
23
+ export const QUEUE_CANCEL_ASK = "Cancel this delivery?";
24
+ export const QUEUE_CANCEL_YES = "Cancel it";
25
+ export const QUEUE_CANCEL_NO = "Keep";
26
+ export const QUEUE_CANCEL_LABEL = "✕ cancel";
27
+ /**
28
+ * Every structural class the queue renders. Named here — not typed literally in a binding — so the
29
+ * Preact and React bindings cannot drift.
30
+ */
31
+ export const QUEUE_CLASSES = {
32
+ panel: "my-queue",
33
+ list: "my-queue__list",
34
+ state: "my-queue__state",
35
+ stale: "my-queue__stale",
36
+ empty: "my-queue__empty",
37
+ rowBody: "my-qrow__body",
38
+ rowStatus: "my-qrow__status",
39
+ cancel: "my-qrow__cancel",
40
+ ask: "my-qrow__ask",
41
+ actions: "my-qrow__acts",
42
+ confirmYes: "my-qmini my-qmini--yes",
43
+ confirmNo: "my-qmini my-qmini--no",
44
+ };
45
+ /**
46
+ * HONESTY INVARIANT (binding): only a completed SUCCESSFUL read is fresh. A `stale` list is
47
+ * last-known data — the poll is failing, so each row's status is what it WAS, not what it is. That
48
+ * makes a cancel control on a stale row a claim the component cannot support: it would assert the
49
+ * record is still `queued` right beside a banner saying the list is no longer current. Stale rows
50
+ * therefore render with no cancel affordance, and an armed confirm force-disarms when its source
51
+ * goes stale (`shouldDisarmCancel`).
52
+ */
53
+ export function isFreshSource(source) {
54
+ return source.kind === "ok";
55
+ }
56
+ /**
57
+ * HONESTY INVARIANT (binding): the empty presentation renders ONLY on `ok` + zero items — i.e. only
58
+ * after a fresh successful read actually returned nothing.
59
+ * - `loading` renders the loading state, NEVER "empty" (an unfinished read knows nothing yet).
60
+ * - `unavailable` renders its own distinct reason copy, NEVER "empty".
61
+ * - `stale` with zero items renders a flagged (but empty) LIST, never the empty copy — the last
62
+ * received list happening to be empty is not a fresh "there is nothing queued" claim.
63
+ *
64
+ * `unavailableDetail` lets a product ADD what it knows about a reason (e.g. which mode the daemon
65
+ * is in). It is APPENDED to this package's own sentence, never substituted for it — a caller
66
+ * therefore cannot collapse two reasons into one message, deliberately or by accident: the distinct
67
+ * base sentences always survive, so two reasons can never render identical copy.
68
+ */
69
+ export function unavailableText(reason, detail) {
70
+ const base = QUEUE_UNAVAILABLE_COPY[reason];
71
+ const extra = detail?.trim();
72
+ return extra ? `${base} ${extra}` : base;
73
+ }
74
+ export function queueView(source, unavailableDetail) {
75
+ switch (source.kind) {
76
+ case "loading":
77
+ return { kind: "state", copy: QUEUE_LOADING_COPY };
78
+ case "unavailable":
79
+ return { kind: "state", copy: unavailableText(source.reason, unavailableDetail?.[source.reason]) };
80
+ case "ok":
81
+ return source.items.length === 0
82
+ ? { kind: "empty", copy: QUEUE_EMPTY_COPY }
83
+ : { kind: "list", items: source.items, staleCopy: null, fresh: true };
84
+ case "stale":
85
+ return { kind: "list", items: source.items, staleCopy: QUEUE_STALE_COPY, fresh: false };
86
+ }
87
+ }
88
+ // ── class + label derivation ──
89
+ /** ASAP / ON-DONE badge label (verbatim). */
90
+ export function queueBadgeLabel(cls) {
91
+ return deliveryClassLabel(cls);
92
+ }
93
+ /** Badge class: ASAP → accent-soft, ON-DONE → disabled-muted (design card). */
94
+ export function queueBadgeClass(cls) {
95
+ return cls === "asap" ? "my-qbadge my-qbadge--asap" : "my-qbadge my-qbadge--done";
96
+ }
97
+ /** The row class. An armed row swaps to the confirm presentation instead of a status modifier. */
98
+ export function queueRowClass(status, armed) {
99
+ return armed ? "my-qrow is-confirm" : `my-qrow my-qrow--${status}`;
100
+ }
101
+ /** Human status label — the status verbatim, never a friendlier synonym that loses precision. */
102
+ export function queueStatusLabel(status) {
103
+ return status;
104
+ }
105
+ /**
106
+ * HONESTY INVARIANT (binding): the cancel affordance exists ONLY on `queued` records AND only when
107
+ * the caller says the record is cancellable at all (`canCancel` — e.g. the viewer owns the queue).
108
+ * Every other status renders WITHOUT an active cancel control: no greyed-out button that implies a
109
+ * cancel could have happened, and never a control whose click would be rejected downstream.
110
+ */
111
+ export function canCancelRow(status, canCancel) {
112
+ return canCancel && status === "queued";
113
+ }
114
+ export function cancelReducer(state, event) {
115
+ switch (event.type) {
116
+ case "arm":
117
+ return { armedId: event.id };
118
+ case "confirm":
119
+ return { armedId: null }; // the onCancel(id) side-effect belongs to the component, not here
120
+ case "disarm":
121
+ return { armedId: null };
122
+ }
123
+ }
124
+ /**
125
+ * An armed two-step cancel must NEVER outlive its cancelability. Force-disarm when cancelability is
126
+ * lost (`canCancel` flips false), when the source stops being FRESH (it went stale, unavailable, or
127
+ * back to loading — the armed row is then last-known data, not a live record), or when the armed row
128
+ * is no longer a live `queued` record in the current source (it left the queue as
129
+ * leased/delivered/canceled, or vanished).
130
+ */
131
+ export function shouldDisarmCancel(armedId, canCancel, source) {
132
+ if (armedId === null)
133
+ return false;
134
+ if (!canCancel)
135
+ return true;
136
+ // deliberately `ok` only: a `stale` list still carries items, but they are the LAST RECEIVED
137
+ // statuses, so an arm held against them would be a cancel aimed at data we know is out of date
138
+ if (!isFreshSource(source))
139
+ return true;
140
+ const items = source.kind === "ok" ? source.items : [];
141
+ const row = items.find((it) => it.id === armedId);
142
+ return row === undefined || !canCancelRow(row.status, canCancel);
143
+ }
@@ -0,0 +1,105 @@
1
+ /** Delivery class. `asap` takes the first turn gap; `on-done` waits for full idle. */
2
+ export type DeliveryClass = "asap" | "on-done";
3
+ export declare const DELIVERY_CLASSES: readonly DeliveryClass[];
4
+ /** The class a bar starts on when the caller expresses no preference. One definition, so the two
5
+ * bindings cannot ship different defaults. */
6
+ export declare const DEFAULT_DELIVERY_CLASS: DeliveryClass;
7
+ /** Verbatim badge/button label for a delivery class. */
8
+ export declare function deliveryClassLabel(cls: DeliveryClass): "ASAP" | "ON-DONE";
9
+ /**
10
+ * HONESTY INVARIANT (binding, do not reword): delivery ALWAYS waits for a turn boundary. The design
11
+ * card's older per-class wording — "ASAP interrupts · ON-DONE waits" — is FALSE: nothing about ASAP
12
+ * interrupts a turn in flight, it merely takes the FIRST gap between turns. This one combined line
13
+ * replaces both of the card's per-class hints. Never reintroduce "interrupts" here.
14
+ */
15
+ export declare const DELIVERY_HINT = "ASAP takes the first turn gap \u00B7 ON-DONE waits for full idle.";
16
+ /** Enabled placeholder when the caller supplies no target name. */
17
+ export declare const SEND_PLACEHOLDER = "Message\u2026 (\u23CE send \u00B7 \u21E7\u23CE newline)";
18
+ /** Last-resort disabled placeholder when the caller supplies no reason. Says only that sending is
19
+ * unavailable — it never speculates about why. */
20
+ export declare const SEND_DISABLED_FALLBACK = "Sending is unavailable.";
21
+ /**
22
+ * Placeholder copy. Disabled ⇒ the caller's honest reason (or the neutral fallback); enabled ⇒ the
23
+ * design's named prompt `Message {name}… (⏎ send · ⇧⏎ newline)` when a target name is supplied,
24
+ * otherwise the nameless form. A disabled bar NEVER shows the enabled prompt.
25
+ */
26
+ export declare function sendPlaceholder(disabled: boolean, disabledReason?: string, targetName?: string): string;
27
+ /** Send is possible only with non-blank text, when enabled and not mid-send. */
28
+ export declare function canSend(text: string, disabled: boolean, busy: boolean): boolean;
29
+ /**
30
+ * The ASAP/ON-DONE semantics line renders ONLY where the delivery controls are actually usable — a
31
+ * disabled bar surfaces just the truthful reason (through the placeholder), never per-class
32
+ * semantics for controls the user cannot operate.
33
+ */
34
+ export declare function showDeliveryHint(disabled: boolean): boolean;
35
+ /**
36
+ * Single-flight gate for the send action. `canSend`'s `busy` argument covers the case where the
37
+ * CALLER tracks the in-flight state, but `onSend` may be async and a caller that flips `busy` in a
38
+ * later tick (or not at all) leaves a window in which two clicks — or a click racing an Enter —
39
+ * both call `onSend` and deliver the same message twice. The gate closes that window inside the
40
+ * component: it is synchronous, so the second attempt in the same tick is refused before any state
41
+ * update can land.
42
+ *
43
+ * Lives here rather than in a binding so both bindings get identical semantics, and so the race is
44
+ * testable without a DOM.
45
+ */
46
+ export interface SendGate {
47
+ /** Takes the lock and returns true; returns false when a send is already in flight. */
48
+ tryAcquire(): boolean;
49
+ /** Releases the lock. Safe to call when not held. */
50
+ release(): void;
51
+ inFlight(): boolean;
52
+ }
53
+ export declare function makeSendGate(): SendGate;
54
+ /**
55
+ * Resolve one send attempt to a plain success/failure.
56
+ *
57
+ * `onSend` is contracted to resolve a boolean, but it is caller code and may REJECT. Letting that
58
+ * rejection escape an async event handler surfaces it as an unhandled rejection — an application
59
+ * error, in many apps a full error overlay — for what is really just a send that did not land. So a
60
+ * rejection is treated as a FAILED send: nothing was delivered, the draft is kept, and the failure
61
+ * is handed to `onError` so the caller can still see it. It is never swallowed silently AND never
62
+ * reported as a success: anything other than a resolved `true` is a failure.
63
+ *
64
+ * Lives here so both bindings behave identically and so the rejection path is testable without a
65
+ * DOM.
66
+ */
67
+ export declare function resolveSend(attempt: () => boolean | Promise<boolean>, onError?: (error: unknown) => void): Promise<boolean>;
68
+ /**
69
+ * The draft clears ONLY on a delivered/queued success AND only when the field still holds exactly
70
+ * the submitted body — the field stays editable while the request is in flight, so text typed
71
+ * meanwhile is a NEWER draft a completing send must not erase. A failed send keeps the text either
72
+ * way, so the user retries without retyping.
73
+ */
74
+ export declare function clearDraftOnSend(sent: boolean, submitted: string, current: string): boolean;
75
+ /** Textarea key semantics: Enter=send, Shift/Alt-Enter=newline, IME composition suppressed. */
76
+ export type KeyAction = "send" | "newline" | "none";
77
+ /**
78
+ * HONESTY INVARIANT: an in-progress IME composition NEVER sends — `isComposing` short-circuits to
79
+ * `none` so a CJK/dead-key commit press can't fire a delivery the user did not ask for.
80
+ */
81
+ export declare function keyAction(ev: unknown): KeyAction;
82
+ /** The send button's label. */
83
+ export declare const SEND_BUTTON_LABEL = "Send";
84
+ /**
85
+ * Every structural class the send bar renders. Named here — not typed literally in a binding — so
86
+ * the Preact and React bindings cannot drift.
87
+ *
88
+ * DEVIATION FROM THE DESIGN CARD (deliberate, carried over from the shipped implementation this was
89
+ * extracted from): the card draws the delivery class as a `<select>`. The shipped bar uses a
90
+ * two-button segmented control with `aria-pressed`, because with exactly two mutually exclusive
91
+ * options both are visible at once — the current class is readable without opening a menu, which is
92
+ * what a delivery decision needs. Everything else in the bar follows the card.
93
+ */
94
+ export declare const SENDBAR_CLASSES: {
95
+ readonly wrap: "my-sendbar-wrap";
96
+ readonly segment: "my-sendbar__seg";
97
+ readonly input: "my-sendbar__input";
98
+ readonly send: "my-sendbar__send";
99
+ readonly hint: "my-sendbar__hint";
100
+ readonly notice: "my-sendbar__notice";
101
+ };
102
+ /** The bar wrapper: `my-sendbar`, plus `is-disabled` when the whole bar is unusable. */
103
+ export declare function sendBarClass(disabled: boolean): string;
104
+ /** One delivery-class segment button; `is-on` marks the current selection. */
105
+ export declare function deliveryClassButtonClass(selected: boolean): string;
@@ -0,0 +1,136 @@
1
+ // @mythicalos/ui-core — the send bar half of the terminal set (ds/components-terminal, spec v2):
2
+ // delivery-class selection, draft/keyboard semantics, and the honest per-class hint. Pure — both
3
+ // the Preact and React bindings derive every class string and every user-visible string from here,
4
+ // so the two bindings (and any future one) cannot drift on the copy that carries the honesty.
5
+ export const DELIVERY_CLASSES = ["asap", "on-done"];
6
+ /** The class a bar starts on when the caller expresses no preference. One definition, so the two
7
+ * bindings cannot ship different defaults. */
8
+ export const DEFAULT_DELIVERY_CLASS = "asap";
9
+ /** Verbatim badge/button label for a delivery class. */
10
+ export function deliveryClassLabel(cls) {
11
+ return cls === "asap" ? "ASAP" : "ON-DONE";
12
+ }
13
+ /**
14
+ * HONESTY INVARIANT (binding, do not reword): delivery ALWAYS waits for a turn boundary. The design
15
+ * card's older per-class wording — "ASAP interrupts · ON-DONE waits" — is FALSE: nothing about ASAP
16
+ * interrupts a turn in flight, it merely takes the FIRST gap between turns. This one combined line
17
+ * replaces both of the card's per-class hints. Never reintroduce "interrupts" here.
18
+ */
19
+ export const DELIVERY_HINT = "ASAP takes the first turn gap · ON-DONE waits for full idle.";
20
+ /** Enabled placeholder when the caller supplies no target name. */
21
+ export const SEND_PLACEHOLDER = "Message… (⏎ send · ⇧⏎ newline)";
22
+ /** Last-resort disabled placeholder when the caller supplies no reason. Says only that sending is
23
+ * unavailable — it never speculates about why. */
24
+ export const SEND_DISABLED_FALLBACK = "Sending is unavailable.";
25
+ /**
26
+ * Placeholder copy. Disabled ⇒ the caller's honest reason (or the neutral fallback); enabled ⇒ the
27
+ * design's named prompt `Message {name}… (⏎ send · ⇧⏎ newline)` when a target name is supplied,
28
+ * otherwise the nameless form. A disabled bar NEVER shows the enabled prompt.
29
+ */
30
+ export function sendPlaceholder(disabled, disabledReason, targetName) {
31
+ if (disabled)
32
+ return disabledReason ?? SEND_DISABLED_FALLBACK;
33
+ const name = targetName?.trim();
34
+ return name ? `Message ${name}… (⏎ send · ⇧⏎ newline)` : SEND_PLACEHOLDER;
35
+ }
36
+ /** Send is possible only with non-blank text, when enabled and not mid-send. */
37
+ export function canSend(text, disabled, busy) {
38
+ return !disabled && !busy && text.trim().length > 0;
39
+ }
40
+ /**
41
+ * The ASAP/ON-DONE semantics line renders ONLY where the delivery controls are actually usable — a
42
+ * disabled bar surfaces just the truthful reason (through the placeholder), never per-class
43
+ * semantics for controls the user cannot operate.
44
+ */
45
+ export function showDeliveryHint(disabled) {
46
+ return !disabled;
47
+ }
48
+ export function makeSendGate() {
49
+ let held = false;
50
+ return {
51
+ tryAcquire() {
52
+ if (held)
53
+ return false;
54
+ held = true;
55
+ return true;
56
+ },
57
+ release() {
58
+ held = false;
59
+ },
60
+ inFlight() {
61
+ return held;
62
+ },
63
+ };
64
+ }
65
+ /**
66
+ * Resolve one send attempt to a plain success/failure.
67
+ *
68
+ * `onSend` is contracted to resolve a boolean, but it is caller code and may REJECT. Letting that
69
+ * rejection escape an async event handler surfaces it as an unhandled rejection — an application
70
+ * error, in many apps a full error overlay — for what is really just a send that did not land. So a
71
+ * rejection is treated as a FAILED send: nothing was delivered, the draft is kept, and the failure
72
+ * is handed to `onError` so the caller can still see it. It is never swallowed silently AND never
73
+ * reported as a success: anything other than a resolved `true` is a failure.
74
+ *
75
+ * Lives here so both bindings behave identically and so the rejection path is testable without a
76
+ * DOM.
77
+ */
78
+ export async function resolveSend(attempt, onError) {
79
+ try {
80
+ return (await attempt()) === true;
81
+ }
82
+ catch (error) {
83
+ onError?.(error);
84
+ return false;
85
+ }
86
+ }
87
+ /**
88
+ * The draft clears ONLY on a delivered/queued success AND only when the field still holds exactly
89
+ * the submitted body — the field stays editable while the request is in flight, so text typed
90
+ * meanwhile is a NEWER draft a completing send must not erase. A failed send keeps the text either
91
+ * way, so the user retries without retyping.
92
+ */
93
+ export function clearDraftOnSend(sent, submitted, current) {
94
+ return sent === true && submitted === current;
95
+ }
96
+ /**
97
+ * HONESTY INVARIANT: an in-progress IME composition NEVER sends — `isComposing` short-circuits to
98
+ * `none` so a CJK/dead-key commit press can't fire a delivery the user did not ask for.
99
+ */
100
+ export function keyAction(ev) {
101
+ const e = ev;
102
+ if (!e || e.key !== "Enter" || e.isComposing)
103
+ return "none";
104
+ if (e.shiftKey || e.altKey)
105
+ return "newline";
106
+ return "send";
107
+ }
108
+ // ── class derivation ──
109
+ /** The send button's label. */
110
+ export const SEND_BUTTON_LABEL = "Send";
111
+ /**
112
+ * Every structural class the send bar renders. Named here — not typed literally in a binding — so
113
+ * the Preact and React bindings cannot drift.
114
+ *
115
+ * DEVIATION FROM THE DESIGN CARD (deliberate, carried over from the shipped implementation this was
116
+ * extracted from): the card draws the delivery class as a `<select>`. The shipped bar uses a
117
+ * two-button segmented control with `aria-pressed`, because with exactly two mutually exclusive
118
+ * options both are visible at once — the current class is readable without opening a menu, which is
119
+ * what a delivery decision needs. Everything else in the bar follows the card.
120
+ */
121
+ export const SENDBAR_CLASSES = {
122
+ wrap: "my-sendbar-wrap",
123
+ segment: "my-sendbar__seg",
124
+ input: "my-sendbar__input",
125
+ send: "my-sendbar__send",
126
+ hint: "my-sendbar__hint",
127
+ notice: "my-sendbar__notice",
128
+ };
129
+ /** The bar wrapper: `my-sendbar`, plus `is-disabled` when the whole bar is unusable. */
130
+ export function sendBarClass(disabled) {
131
+ return disabled ? "my-sendbar is-disabled" : "my-sendbar";
132
+ }
133
+ /** One delivery-class segment button; `is-on` marks the current selection. */
134
+ export function deliveryClassButtonClass(selected) {
135
+ return selected ? "my-sendbar__cls is-on" : "my-sendbar__cls";
136
+ }