@mythicalos/ui-core 0.2.1 → 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,303 @@
1
+ // @mythicalos/ui-core — PURE, DOM-free dropdown-popover logic (ds/components-popover, the
2
+ // component registry's `popover` v1 row). The design card is a chip trigger ("review lane:
3
+ // **codex cross-model** ▾") over an anchored panel — `--my-shadow-modal`, high z-index, ✓ marking
4
+ // the current item, and "ancestors must never clip it".
5
+ //
6
+ // EVERY decision the popover makes lives here so the Preact and React bindings can only ever
7
+ // render, never decide: class derivation, the viewport-aware flip/align geometry, the keyboard
8
+ // grammar (which key does what), roving-focus index arithmetic that skips disabled rows, the
9
+ // trigger's text composition, and the ARIA attribute maps. The bindings own exactly two things
10
+ // this module cannot: touching the DOM (measuring, focusing, listening) and rendering.
11
+ //
12
+ // Geometry provenance: `resolvePopoverPlacement` reproduces the established approach in
13
+ // `src/select/mythical-select.js`'s `data-flip` handling — flip up only when the panel would clip
14
+ // below AND genuinely fits above, measured as `panelHeight + gap + breathing` against the
15
+ // viewport. Keeping one rule means <mythical-select> and <Popover> never disagree about which way
16
+ // a list opens on the same screen.
17
+ /** Trigger caret (design card: `<span class="car">▾</span>`). */
18
+ export const POPOVER_CARET = "▾";
19
+ /** Selected-item marker (design card: "✓ marks current"). */
20
+ export const POPOVER_CHECK = "✓";
21
+ /** Anchor gap in px — the design card's `top: calc(100% + 6px)`. Mirrored in styles.css. */
22
+ export const POPOVER_GAP_PX = 6;
23
+ /** Extra breathing room the flip test demands beyond the gap (mythical-select uses `height + 8`
24
+ * for a 6px gap — i.e. gap + 2). */
25
+ export const POPOVER_BREATHING_PX = 2;
26
+ /** Trigger value shown when nothing is selected (the em-dash placeholder, as in the product's
27
+ * hand-rolled selector) — never a fabricated item label. */
28
+ export const POPOVER_EMPTY_VALUE = "—";
29
+ /** The pre-measurement position: the design card's own (anchored below, left-aligned). A binding
30
+ * renders this on the first open frame, then corrects it once the panel can be measured. */
31
+ export const POPOVER_DEFAULT_POSITION = { placement: "below", align: "start" };
32
+ /**
33
+ * Viewport-aware vertical placement. Flips UP only when the panel would clip off the bottom AND it
34
+ * genuinely fits above — a panel taller than both gaps stays `below` (scrolling down to it beats
35
+ * clipping it against the top edge, and it matches <mythical-select>'s behaviour exactly).
36
+ */
37
+ export function resolvePopoverPlacement(anchor, panelHeight, viewport, gap = POPOVER_GAP_PX) {
38
+ const need = panelHeight + gap + POPOVER_BREATHING_PX;
39
+ return anchor.bottom + need > viewport.height && anchor.top - need > 0 ? "above" : "below";
40
+ }
41
+ /**
42
+ * Viewport-aware horizontal alignment. The panel is start-aligned (design card: `left: 0`) and
43
+ * switches to end-aligned only when start does NOT fit and end does.
44
+ *
45
+ * "Fits" means BOTH resulting edges are inside the viewport, on both branches — an alignment pins
46
+ * one panel edge to the matching anchor edge, so an anchor that is itself partly off-screen drags
47
+ * the panel off with it. Testing only the far edge is wrong in each direction symmetrically:
48
+ * start-aligning an anchor hanging off the LEFT clips the panel's left edge even though its right
49
+ * edge is comfortable, and end-aligning an anchor hanging off the RIGHT clips its right edge even
50
+ * though `right - panelWidth >= 0`. A panel that fits neither way stays `start` — flipping would
51
+ * only trade one overflow for the other.
52
+ */
53
+ export function resolvePopoverAlign(anchor, panelWidth, viewport) {
54
+ const startFits = anchor.left >= 0 && anchor.left + panelWidth <= viewport.width;
55
+ const endFits = anchor.right <= viewport.width && anchor.right - panelWidth >= 0;
56
+ return !startFits && endFits ? "end" : "start";
57
+ }
58
+ /** Both axes at once — what a binding calls from its post-open measurement effect. */
59
+ export function resolvePopoverPosition(anchor, panel, viewport, gap = POPOVER_GAP_PX) {
60
+ return {
61
+ placement: resolvePopoverPlacement(anchor, panel.height, viewport, gap),
62
+ align: resolvePopoverAlign(anchor, panel.width, viewport),
63
+ };
64
+ }
65
+ /** True when two positions describe the same placement — lets a binding skip a redundant
66
+ * re-render after re-measuring (and, more importantly, not loop measure→setState→measure). */
67
+ export function samePopoverPosition(a, b) {
68
+ return a.placement === b.placement && a.align === b.align;
69
+ }
70
+ /* ── class derivation ─────────────────────────────────────────────────── */
71
+ /**
72
+ * EVERY class name this component family renders — the derived ones the functions below compose
73
+ * AND the purely structural ones a binding puts on a wrapper/span. One source, so a binding cannot
74
+ * invent, misspell, or quietly re-style a class, and the Preact and React siblings cannot drift on
75
+ * markup either. (The rest of this package's atoms inline their structural classes in both
76
+ * bindings; the popover has more of them than any other atom, so it earns the map.)
77
+ */
78
+ export const POPOVER_CLASS = {
79
+ anchor: "my-pop-anchor",
80
+ trigger: "my-pop-trigger",
81
+ triggerLabel: "my-pop-trigger__label",
82
+ triggerValue: "my-pop-trigger__value",
83
+ triggerCaret: "my-pop-trigger__caret",
84
+ panel: "my-pop",
85
+ panelAbove: "my-pop--above",
86
+ panelEnd: "my-pop--end",
87
+ head: "my-pop__head",
88
+ title: "my-pop__title",
89
+ caption: "my-pop__caption",
90
+ menu: "my-pop__menu",
91
+ item: "my-pop__item",
92
+ itemLabel: "my-pop__label",
93
+ itemCheck: "my-pop__check",
94
+ divider: "my-pop__div",
95
+ foot: "my-pop__foot",
96
+ isOpen: "is-open",
97
+ isSelected: "is-selected",
98
+ isDisabled: "is-disabled",
99
+ };
100
+ /** Trigger class list. Shape rule 10: the trigger is clickable, so styles.css gives it
101
+ * `--my-r-control` — never the pill radius. */
102
+ export function popoverTriggerClass(s = {}) {
103
+ const cls = [POPOVER_CLASS.trigger];
104
+ if (s.open)
105
+ cls.push(POPOVER_CLASS.isOpen);
106
+ if (s.disabled)
107
+ cls.push(POPOVER_CLASS.isDisabled);
108
+ return cls.join(" ");
109
+ }
110
+ /** Panel class list; the flip/align modifiers are the only thing position changes. */
111
+ export function popoverPanelClass(pos = POPOVER_DEFAULT_POSITION) {
112
+ const cls = [POPOVER_CLASS.panel];
113
+ if (pos.placement === "above")
114
+ cls.push(POPOVER_CLASS.panelAbove);
115
+ if (pos.align === "end")
116
+ cls.push(POPOVER_CLASS.panelEnd);
117
+ return cls.join(" ");
118
+ }
119
+ /** Menu-row class list. */
120
+ export function popoverItemClass(item = {}) {
121
+ const cls = [POPOVER_CLASS.item];
122
+ if (item.selected)
123
+ cls.push(POPOVER_CLASS.isSelected);
124
+ if (item.disabled)
125
+ cls.push(POPOVER_CLASS.isDisabled);
126
+ return cls.join(" ");
127
+ }
128
+ /** Stable, collision-free id set derived from one caller-supplied base id. */
129
+ export function popoverIds(baseId) {
130
+ return {
131
+ trigger: `${baseId}-trigger`,
132
+ panel: `${baseId}-panel`,
133
+ menu: `${baseId}-menu`,
134
+ title: `${baseId}-title`,
135
+ };
136
+ }
137
+ export function popoverTriggerAria(open, ids) {
138
+ return {
139
+ id: ids.trigger,
140
+ "aria-haspopup": "menu",
141
+ "aria-expanded": open ? "true" : "false",
142
+ "aria-controls": open ? ids.menu : undefined,
143
+ };
144
+ }
145
+ /** The visual panel: the surface, deliberately WITHOUT a role. It holds the optional heading and
146
+ * the caller's footer, which a `menu` may not own. */
147
+ export function popoverPanelAria(ids) {
148
+ return { id: ids.panel };
149
+ }
150
+ /** The nested menu, which owns ONLY the `menuitemradio` rows. Named by the heading when there is
151
+ * one (the more specific label), otherwise by the trigger. */
152
+ export function popoverMenuAria(ids, titled) {
153
+ return { id: ids.menu, role: "menu", "aria-labelledby": titled ? ids.title : ids.trigger };
154
+ }
155
+ /**
156
+ * The caret and the ✓ are pure decoration: the trigger's `aria-expanded` and the row's
157
+ * `aria-checked` already convey both states, so reading the glyphs would duplicate them. Lives here
158
+ * rather than inline in each binding so neither can quietly un-hide one.
159
+ */
160
+ export const POPOVER_DECORATIVE_ARIA = { "aria-hidden": "true" };
161
+ /** The rule between the menu and the caller's footer — a real separator, not a styled `<div>`. */
162
+ export const POPOVER_SEPARATOR_ARIA = { role: "separator" };
163
+ export function popoverItemAria(item) {
164
+ return {
165
+ role: "menuitemradio",
166
+ "aria-checked": item.selected ? "true" : "false",
167
+ "aria-disabled": item.disabled ? "true" : undefined,
168
+ };
169
+ }
170
+ export function popoverTriggerKeyAction(key) {
171
+ if (key === "ArrowDown")
172
+ return "open-first";
173
+ if (key === "ArrowUp")
174
+ return "open-last";
175
+ return null;
176
+ }
177
+ export function popoverPanelKeyAction(key) {
178
+ switch (key) {
179
+ case "Escape":
180
+ return "close";
181
+ case "Tab":
182
+ return "dismiss";
183
+ case "ArrowDown":
184
+ return "next";
185
+ case "ArrowUp":
186
+ return "prev";
187
+ case "Home":
188
+ return "first";
189
+ case "End":
190
+ return "last";
191
+ default:
192
+ return null;
193
+ }
194
+ }
195
+ /** Should the binding call `preventDefault()`? Everything we act on, except `dismiss` — Tab MUST
196
+ * keep its native behaviour or the popover becomes a focus trap. */
197
+ export function popoverKeyHandled(action) {
198
+ return action !== null && action !== "dismiss";
199
+ }
200
+ /**
201
+ * Does this action apply to the element that currently has focus?
202
+ *
203
+ * Dismissal (Escape / Tab) applies from ANYWHERE inside the popover — including the caller's
204
+ * footer content, where Escape must still close. Roving (arrows / Home / End) applies ONLY when
205
+ * focus is on a menu row or on the panel fallback. That distinction is load-bearing, not tidiness:
206
+ * a footer control is arbitrary caller markup, so swallowing Home/End there would steal a text
207
+ * input's caret keys, and swallowing an arrow would yank focus out of the footer and into the menu.
208
+ * The binding supplies the one thing this module cannot compute — whether the focused element is a
209
+ * roving surface.
210
+ */
211
+ export function popoverAppliesToFocus(action, focusOnRovingSurface) {
212
+ if (action === null)
213
+ return false;
214
+ if (action === "close" || action === "dismiss")
215
+ return true;
216
+ return focusOnRovingSurface;
217
+ }
218
+ /* ── roving-focus index arithmetic (disabled rows are skipped, ends wrap) ── */
219
+ /** Index of the first enabled item scanning forward (`dir` 1) or backward (`dir` -1); -1 if every
220
+ * item is disabled (or the list is empty). */
221
+ export function edgePopoverIndex(items, dir = 1) {
222
+ const n = items.length;
223
+ for (let step = 0; step < n; step++) {
224
+ const i = dir === 1 ? step : n - 1 - step;
225
+ if (!items[i]?.disabled)
226
+ return i;
227
+ }
228
+ return -1;
229
+ }
230
+ /** The next enabled index from `from` in `dir`, wrapping. Returns -1 when nothing is enabled and
231
+ * `from` itself when it is the ONLY enabled row (a wrap that lands back where it started). */
232
+ export function stepPopoverIndex(items, from, dir) {
233
+ const n = items.length;
234
+ if (n === 0)
235
+ return -1;
236
+ for (let step = 1; step <= n; step++) {
237
+ const i = (((from + dir * step) % n) + n) % n;
238
+ if (!items[i]?.disabled)
239
+ return i;
240
+ }
241
+ return -1;
242
+ }
243
+ /**
244
+ * Where focus lands when the panel opens: the selected row if it is focusable, else the first
245
+ * enabled one. A selected-but-disabled row does not swallow focus.
246
+ */
247
+ export function initialPopoverIndex(items) {
248
+ const sel = items.findIndex((it) => it.selected && !it.disabled);
249
+ return sel >= 0 ? sel : edgePopoverIndex(items, 1);
250
+ }
251
+ /**
252
+ * The single entry point a binding's keydown handler calls: maps a panel key action + the current
253
+ * focused index to the next focused index. Non-navigation actions (and a null action) leave the
254
+ * index untouched, so the caller never has to branch on the action twice.
255
+ *
256
+ * `current < 0` means "focus is in the panel but not on a row" (an all-disabled or empty list, or
257
+ * a click that landed on the panel's padding). From there Down means the FIRST enabled row and Up
258
+ * means the LAST — not a wrap arithmetic accident off index -1.
259
+ */
260
+ export function resolvePopoverIndex(action, items, current) {
261
+ switch (action) {
262
+ case "next":
263
+ return current < 0 ? edgePopoverIndex(items, 1) : stepPopoverIndex(items, current, 1);
264
+ case "prev":
265
+ return current < 0 ? edgePopoverIndex(items, -1) : stepPopoverIndex(items, current, -1);
266
+ case "first":
267
+ return edgePopoverIndex(items, 1);
268
+ case "last":
269
+ return edgePopoverIndex(items, -1);
270
+ default:
271
+ return current;
272
+ }
273
+ }
274
+ /* ── optional slots ───────────────────────────────────────────────────── */
275
+ /**
276
+ * Is an optional slot (heading, caption, footer) worth rendering its chrome?
277
+ *
278
+ * `undefined`, `null`, `false` and `""` are all values BOTH frameworks render as nothing, and the
279
+ * idiomatic conditional slot is `footer={enabled && <Action />}` — which passes `false`, not
280
+ * `undefined`, when disabled. Testing `!== undefined` would leave an orphan separator and an empty
281
+ * box behind it, and an empty heading would leave the menu's `aria-labelledby` pointing at an
282
+ * element with no text. Shared here so both bindings apply the same rule.
283
+ */
284
+ export function popoverHasSlotContent(slot) {
285
+ return slot !== undefined && slot !== null && slot !== false && slot !== "";
286
+ }
287
+ /**
288
+ * The trigger face: prefix + the CURRENTLY selected item's label. Honest by construction — with no
289
+ * selection (or a selection that is not in the list) it shows the placeholder rather than naming an
290
+ * item that is not actually chosen.
291
+ */
292
+ export function popoverTriggerText(items, opts = {}) {
293
+ const sel = items.find((it) => it.selected);
294
+ return {
295
+ label: opts.label ?? null,
296
+ value: sel ? sel.label : (opts.placeholder ?? POPOVER_EMPTY_VALUE),
297
+ };
298
+ }
299
+ // Deliberately NOT exported: an `aria-label` for the trigger. The trigger renders its label and
300
+ // value as real text (only the caret is aria-hidden), so its accessible name is already computed
301
+ // from exactly what the eye reads. Adding an aria-label would override that visible text — the
302
+ // classic "label in name" failure — so the panel points at the trigger with `aria-labelledby`
303
+ // instead of anyone re-deriving a string.
@@ -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,39 @@
1
+ /** The separator between the bold "N unsaved change(s)" count and the changed-field list. */
2
+ export declare const SAVE_BAR_SEP = " \u00B7 ";
3
+ /** Design-card copy for the two actions (ds/layouts-settings.html). Both are overridable per
4
+ * surface — the reference product's settings page says "Save locally" because its save writes to
5
+ * the local container, not the card's "Save & apply". */
6
+ export declare const SAVE_BAR_DISCARD_LABEL = "Discard";
7
+ export declare const SAVE_BAR_SAVE_LABEL = "Save & apply";
8
+ export interface SaveBarNote {
9
+ /** How many fields are dirty. `0` ⇒ the bar must not be shown at all (see `saveBarDirty`). */
10
+ count: number;
11
+ /** The bold lead-in: `1 unsaved change` / `3 unsaved changes`. */
12
+ countLabel: string;
13
+ /** The changed-field list that follows the separator: `Base URL, Harness`. */
14
+ detail: string;
15
+ /** The full visible sentence, `countLabel + SAVE_BAR_SEP + detail`. Empty `detail` ⇒ just the
16
+ * count (never a dangling separator). */
17
+ text: string;
18
+ }
19
+ /** Compose the save bar's note from the list of changed-field labels. Pluralization is decided
20
+ * here, once, for both bindings. Non-string / blank entries are dropped rather than rendered as
21
+ * `undefined` in the list — but they are dropped BEFORE the count, so the count always equals the
22
+ * number of fields actually named. */
23
+ export declare function saveBarNote(changed: readonly string[]): SaveBarNote;
24
+ /** The card's "the save bar slides up only when dirty" rule, as a predicate. The bindings return
25
+ * `null` for a not-dirty bar so no surface can ever render "0 unsaved changes" next to a live
26
+ * Save button. */
27
+ export declare function saveBarDirty(changed: readonly string[]): boolean;
28
+ /** Every class the bar renders, root and elements. The bindings import these rather than spelling
29
+ * the strings themselves, so a rename cannot land in one framework and not the other, and the
30
+ * stylesheet test can enumerate the parts instead of restating a hand-written list. */
31
+ export declare const SAVE_BAR_PARTS: {
32
+ readonly root: "my-savebar";
33
+ readonly note: "my-savebar__note";
34
+ readonly count: "my-savebar__count";
35
+ readonly actions: "my-savebar__actions";
36
+ };
37
+ /** Root class for the bar. No tone axis — the modifier space is reserved for the caller's own
38
+ * passthrough class, which the bindings append. */
39
+ export declare function saveBarClass(): string;