@simon-says-build/ssb-overlay 0.1.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.js CHANGED
@@ -42,33 +42,88 @@ export function inAppApi(config, getToken, fetchImpl = globalThis.fetch) {
42
42
  chat: (id) => call(`/chats/${encodeURIComponent(id)}`),
43
43
  decide: (chatId, proposalId, action, body) => post(`/chats/${encodeURIComponent(chatId)}/proposals/${encodeURIComponent(proposalId)}/${action}`, body),
44
44
  pushToken: (token, platform) => post('/push-token', { token, platform }),
45
+ voice: (body) => post('/voice', body),
46
+ work: () => call('/work'),
45
47
  };
46
48
  }
47
49
 
48
- /** A finished gesture on the screen, as a mark in fractions of the screen: a tap is a pin, a drag a box. */
49
- export function markOf({ x0, y0, x1, y1 }, screen) {
50
- const fx = (v) => Math.min(1, Math.max(0, v / screen.width));
51
- const fy = (v) => Math.min(1, Math.max(0, v / screen.height));
52
- if (Math.hypot(x1 - x0, y1 - y0) < PIN_SLOP) return { kind: 'pin', x: fx(x1), y: fy(y1) };
53
- const left = Math.min(x0, x1), top = Math.min(y0, y1);
54
- return { kind: 'box', x: fx(left), y: fy(top), w: fx(Math.abs(x1 - x0)), h: fy(Math.abs(y1 - y0)) };
50
+ // ---- the pencil: scribbles on the live screen --------------------------------
51
+ const MIN_STEP = 3; // a point closer than this to the last one adds nothing
52
+
53
+ /** A stroke with `p` added, unless it is too close to the last point to matter. */
54
+ export function addPoint(stroke, p) {
55
+ const last = stroke[stroke.length - 1];
56
+ if (last && Math.hypot(p.x - last.x, p.y - last.y) < MIN_STEP) return stroke;
57
+ return [...stroke, { x: p.x, y: p.y }];
58
+ }
59
+
60
+ /**
61
+ * A stroke as straight segments a View can draw: each is a bar `width` long,
62
+ * centred on its midpoint and turned by `angle` (radians). A single point is a dot.
63
+ */
64
+ export function segmentsOf(stroke, thickness = 4) {
65
+ if (stroke.length === 1) return [{ left: stroke[0].x - thickness / 2, top: stroke[0].y - thickness / 2, width: thickness, angle: 0 }];
66
+ const out = [];
67
+ for (let i = 1; i < stroke.length; i++) {
68
+ const a = stroke[i - 1], b = stroke[i];
69
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
70
+ const width = len + thickness; // overlap the joints so a curve has no gaps
71
+ out.push({ left: (a.x + b.x) / 2 - width / 2, top: (a.y + b.y) / 2 - thickness / 2, width, angle: Math.atan2(b.y - a.y, b.x - a.x) });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ // A drawing is a list of items, each a pencil stroke or a box:
77
+ // { kind: 'stroke', points: [{x, y}] } | { kind: 'box', x0, y0, x1, y1 } (screen points)
78
+ const MIN_BOX = 8; // a box smaller than this was a slip, not a box
79
+
80
+ /** A box item from its drag, or null when it is too small to mean anything. */
81
+ export function boxItem(d) {
82
+ if (Math.abs(d.x1 - d.x0) < MIN_BOX || Math.abs(d.y1 - d.y0) < MIN_BOX) return null;
83
+ return { kind: 'box', x0: d.x0, y0: d.y0, x1: d.x1, y1: d.y1 };
55
84
  }
56
85
 
57
- /** The mark's box on the screen, in points (a pin is a small square round its point). */
58
- export function markRect(mark, screen) {
59
- const x = mark.x * screen.width, y = mark.y * screen.height;
60
- if (mark.kind === 'box') return { left: x, top: y, width: mark.w * screen.width, height: mark.h * screen.height };
61
- return { left: x - 14, top: y - 14, width: 28, height: 28 };
86
+ /** One item's box, in points. */
87
+ export function itemRect(it) {
88
+ if (it.kind === 'box') {
89
+ const left = Math.min(it.x0, it.x1), top = Math.min(it.y0, it.y1);
90
+ return { left, top, width: Math.abs(it.x1 - it.x0), height: Math.abs(it.y1 - it.y0) };
91
+ }
92
+ const xs = it.points.map((p) => p.x), ys = it.points.map((p) => p.y);
93
+ const left = Math.min(...xs), top = Math.min(...ys);
94
+ return { left, top, width: Math.max(...xs) - left, height: Math.max(...ys) - top };
62
95
  }
63
96
 
64
- /** The point a mark is about: a pin's point, a box's middle. */
65
- export const markPoint = (mark, screen) => mark.kind === 'box'
66
- ? { x: (mark.x + mark.w / 2) * screen.width, y: (mark.y + mark.h / 2) * screen.height }
67
- : { x: mark.x * screen.width, y: mark.y * screen.height };
97
+ /** The box round everything drawn, or null with nothing drawn. */
98
+ export function drawingBox(items) {
99
+ const rs = items.filter((it) => it.kind === 'box' || it.points?.length).map(itemRect);
100
+ if (!rs.length) return null;
101
+ const left = Math.min(...rs.map((r) => r.left)), top = Math.min(...rs.map((r) => r.top));
102
+ return { left, top, width: Math.max(...rs.map((r) => r.left + r.width)) - left, height: Math.max(...rs.map((r) => r.top + r.height)) - top };
103
+ }
68
104
 
69
105
  /**
70
- * Where the field beside the mark goes: just above the keyboard when that is
71
- * clear of the mark, else under the status bar. `keyboardTop` is the
106
+ * What was drawn, as the ONE mark a send carries (annotate.mjs cleanMarks):
107
+ * a `box` when only boxes were drawn, else a `pen`, over the drawing's box,
108
+ * in fractions of the screen.
109
+ */
110
+ export function drawingMark(items, screen) {
111
+ const r = drawingBox(items);
112
+ if (!r) return null;
113
+ const f = (v, d) => Math.min(1, Math.max(0, v / d));
114
+ return { kind: items.every((it) => it.kind === 'box') ? 'box' : 'pen',
115
+ x: f(r.left, screen.width), y: f(r.top, screen.height), w: f(r.width, screen.width), h: f(r.height, screen.height) };
116
+ }
117
+
118
+ /** The point the drawing is about (what the app has under it is named): the middle of its box. */
119
+ export const drawingPoint = (items) => {
120
+ const r = drawingBox(items);
121
+ return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null;
122
+ };
123
+
124
+ /**
125
+ * Where the note field goes: just above the keyboard when that is clear of
126
+ * the drawing, else under the status bar. `keyboardTop` is the
72
127
  * keyboard's top edge in screen points (the screen height when it is down).
73
128
  */
74
129
  export function fieldTop({ rect, screen, keyboardTop, insets, fieldH = 56 }) {
@@ -202,3 +257,54 @@ export function demoBanner(chat) {
202
257
  if (d.status === 'failed') return { text: `Done: ${what}. The demo couldn't be prepared${d.error ? `: ${d.error}` : '.'}` };
203
258
  return null;
204
259
  }
260
+
261
+ // ---- the button: wherever it is put --------------------------------------------
262
+ export const FAB = 52;
263
+ const TAP_SLOP = 8; // a press that moved less than this is a tap, not a drag
264
+
265
+ /** Where the button sits by default: bottom-right, clear of the insets. */
266
+ export const fabHome = (screen, insets) => ({ x: screen.width - FAB - 16, y: screen.height - FAB - 16 - insets.bottom });
267
+
268
+ /** A position kept on screen: the whole button visible, off the status bar. */
269
+ export function clampFab(pos, screen, insets) {
270
+ return {
271
+ x: Math.min(screen.width - FAB - 4, Math.max(4, pos.x)),
272
+ y: Math.min(screen.height - FAB - 4, Math.max(insets.top, pos.y)),
273
+ };
274
+ }
275
+
276
+ /** Did the finger travel far enough to be a drag? */
277
+ export const isDrag = (dx, dy) => Math.hypot(dx, dy) >= TAP_SLOP;
278
+
279
+ // ---- 🎙 Talk (functions/voice-chat.mjs, voice-agent/src/chat.mjs) --------------
280
+ export const SNAPSHOT_TOPIC = 'ssb.guide.snapshot'; // the agent's (voice-agent/src/guide.mjs TOPIC.snapshot)
281
+
282
+ /** What the agent is sent about the screen: the route, the picture when there is one, and whether this is the opening look. */
283
+ export const snapshotOf = ({ route, screenshot, first = false }) => JSON.stringify({ route: route || '/', ...(screenshot ? { screenshot } : {}), ...(first ? { first: true } : {}) });
284
+
285
+ /** The talk bar's words. */
286
+ export function talkLine(talk) {
287
+ if (!talk) return '';
288
+ if (talk.status === 'connecting') return 'Connecting to SSB…';
289
+ if (talk.status === 'waiting') return 'Waiting for SSB to join…';
290
+ return talk.muted ? 'Talking with SSB · muted' : 'Talking with SSB';
291
+ }
292
+
293
+ // ---- the panel's top: the work (2026-10-20, the product owner: "the web
294
+ // extension has jobs and in-progress things at the top, with chat down the
295
+ // bottom … maybe we can move to that pattern") ------------------------------
296
+ /** The extension's words for a job in flight (extension/overlay.js inFlightHtml). */
297
+ export const STATE_WORD = Object.freeze({ ready: 'Ready', queued: 'Queued', building: 'Building', checking: 'Checking', blocked: 'Blocked' });
298
+
299
+ /**
300
+ * What the top of the panel shows: the app's work in flight (GET
301
+ * /api/in-app/work, functions/sessions.mjs inFlightOf — whatever chat it came
302
+ * from), this chat's drafts waiting to start, and what this chat started that
303
+ * is not in flight any more (the row says so, with its job).
304
+ */
305
+ export function workView({ inFlight = [], chat = null } = {}) {
306
+ const flying = Array.isArray(inFlight) ? inFlight : [];
307
+ const flyingIds = new Set(flying.map((x) => x.id));
308
+ const { waiting, sent } = draftsOf(chat);
309
+ return { flying, waiting, sent: sent.filter((p) => !p.jobId || !flyingIds.has(p.jobId)) };
310
+ }