@tineira/apc-web 0.7.1 → 0.9.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.
package/README.md CHANGED
@@ -27,4 +27,6 @@ const stop = enablePinning({ onPin: (grantId, target) => savePin(grantId, target
27
27
 
28
28
  `onPin` gets the agent's grant and a `FocusTarget` (`{ kind, id, part, params }`); your server keeps it as that grant's pin and gives it to the agent ([`@tineira/apc-server`](https://www.npmjs.com/package/@tineira/apc-server)).
29
29
 
30
+ Every string it draws can be passed by your app (`strings` on each component; see "Localize the page" in the docs), with the English as default.
31
+
30
32
  Source, the worked examples (Pulse and Signals) and the changelog: https://github.com/tineira/agent-pointed-context. MIT.
@@ -30,6 +30,7 @@
30
30
  * per browser). On by default; an app turns it off with `dockHint = null` or `dock-hint="off"`.
31
31
  */
32
32
  import { type ApcProfile } from "@tineira/apc-protocol";
33
+ import { type BarStrings } from "./strings.js";
33
34
  export type AgentState = "off" | "idle" | "waiting" | "busy" | "seen";
34
35
  export interface BarAgent {
35
36
  /** The app's id for the agent (its grant): what pins name. */
@@ -65,14 +66,14 @@ export declare function mergePins<P extends {
65
66
  label: string;
66
67
  seen: boolean;
67
68
  here: boolean;
68
- }>(pins: ReadonlyArray<P | null | undefined>): P | null;
69
+ }>(pins: ReadonlyArray<P | null | undefined>, strings?: Partial<BarStrings> | null): P | null;
69
70
  /** The hint that offers the dock to a Direct user, and where it leads. */
70
71
  export declare const DOCK_HINT: {
71
- readonly text: "Use this agent across apps with the dock";
72
+ readonly text: string;
72
73
  readonly url: "https://apc.tineira.com/dock";
73
74
  };
74
75
  /** The words for the path an agent came by, for its tag. Null when the app does not say. */
75
- export declare function pathLabel(paths: readonly ApcProfile[] | undefined): {
76
+ export declare function pathLabel(paths: readonly ApcProfile[] | undefined, given?: Partial<BarStrings> | null): {
76
77
  text: string;
77
78
  title: string;
78
79
  } | null;
@@ -84,14 +85,14 @@ export declare function isMobileViewport(): boolean;
84
85
  */
85
86
  export declare function barCanCollapse(agents: readonly BarAgent[], mobile?: boolean): boolean;
86
87
  /** Who is reading, for the collapsed indicator: the agents that connected. */
87
- export declare function readingSummary(agents: readonly BarAgent[]): string;
88
+ export declare function readingSummary(agents: readonly BarAgent[], given?: Partial<BarStrings> | null): string;
88
89
  /** The dock hint shows while an agent that came only directly is connected, unless the app turned it off or the person dismissed it. */
89
90
  export declare function showDockHint(agents: readonly BarAgent[], options: {
90
91
  enabled: boolean;
91
92
  dismissed: boolean;
92
93
  }): boolean;
93
94
  /** The words a mark, its dot and its revoke button say, for tooltips and screen readers. Pure, so it is tested. */
94
- export declare function agentLabels(a: BarAgent): {
95
+ export declare function agentLabels(a: BarAgent, given?: Partial<BarStrings> | null): {
95
96
  mark: string;
96
97
  dot: string | null;
97
98
  revoke: string;
@@ -107,4 +108,6 @@ export interface ApcAgentsElement extends HTMLElement {
107
108
  text: string;
108
109
  url: string;
109
110
  } | null;
111
+ /** The bar's strings (TIN-668): set the keys you translate; the rest stay English. */
112
+ strings: Partial<BarStrings> | null;
110
113
  }
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import { APC_MOBILE_MEDIA } from "@tineira/apc-protocol";
33
33
  import { APC_ARMED_EVENT } from "./pinning.js";
34
+ import { DEFAULT_BAR_STRINGS, resolveStrings } from "./strings.js";
34
35
  /** How long the first click on ⊘ waits for the second before it lets go. */
35
36
  export const REVOKE_CONFIRM_MS = 4000;
36
37
  /**
@@ -39,23 +40,24 @@ export const REVOKE_CONFIRM_MS = 4000;
39
40
  * target once), unread until both are read, and "here" only when both targets are on screen (so the
40
41
  * dot still opens the one that is not). No pin at all is null.
41
42
  */
42
- export function mergePins(pins) {
43
+ export function mergePins(pins, strings) {
43
44
  const some = pins.filter((p) => !!p);
44
45
  if (some.length < 2)
45
46
  return some[0] ?? null;
46
- return { ...some[0], label: [...new Set(some.map((p) => p.label))].join(" and "), seen: some.every((p) => p.seen), here: some.every((p) => p.here) };
47
+ return { ...some[0], label: resolveStrings(DEFAULT_BAR_STRINGS, strings).joinPins([...new Set(some.map((p) => p.label))]), seen: some.every((p) => p.seen), here: some.every((p) => p.here) };
47
48
  }
48
49
  /** The hint that offers the dock to a Direct user, and where it leads. */
49
- export const DOCK_HINT = { text: "Use this agent across apps with the dock", url: "https://apc.tineira.com/dock" };
50
+ export const DOCK_HINT = { text: DEFAULT_BAR_STRINGS.dockHint, url: "https://apc.tineira.com/dock" };
50
51
  const COLLAPSED_KEY = "apc-agents.collapsed";
51
52
  const HINT_KEY = "apc-agents.dock-hint";
52
53
  /** The words for the path an agent came by, for its tag. Null when the app does not say. */
53
- export function pathLabel(paths) {
54
+ export function pathLabel(paths, given) {
54
55
  if (!paths?.length)
55
56
  return null;
57
+ const strings = resolveStrings(DEFAULT_BAR_STRINGS, given);
56
58
  if (paths.includes("direct") && paths.includes("dock"))
57
- return { text: "direct + dock", title: "The same agent, connected directly and through a dock. Each way keeps its own access; Revoke cuts both." };
58
- return paths[0] === "direct" ? { text: "direct", title: "Connected directly, on this app's own MCP URL" } : { text: "via dock", title: "Came through an agent dock" };
59
+ return { text: strings.pathBoth, title: strings.pathBothTitle };
60
+ return paths[0] === "direct" ? { text: strings.pathDirect, title: strings.pathDirectTitle } : { text: strings.pathDock, title: strings.pathDockTitle };
59
61
  }
60
62
  /** Whether this window is a phone or any touch viewport (APC 0.6): a coarse pointer or a narrow width. False where there is no window. */
61
63
  export function isMobileViewport() {
@@ -69,13 +71,10 @@ export function barCanCollapse(agents, mobile = false) {
69
71
  return !mobile && agents.length > 0 && agents.every((a) => !!a.paths?.length && !a.paths.includes("direct"));
70
72
  }
71
73
  /** Who is reading, for the collapsed indicator: the agents that connected. */
72
- export function readingSummary(agents) {
74
+ export function readingSummary(agents, given) {
75
+ const strings = resolveStrings(DEFAULT_BAR_STRINGS, given);
73
76
  const names = agents.filter((a) => a.state !== "off").map((a) => a.name);
74
- if (!names.length)
75
- return "No agent is reading";
76
- if (names.length === 1)
77
- return `${names[0]} is reading`;
78
- return `${names.slice(0, -1).join(", ")} and ${names.at(-1)} are reading`;
77
+ return names.length ? strings.reading(names) : strings.nobodyReading;
79
78
  }
80
79
  /** The dock hint shows while an agent that came only directly is connected, unless the app turned it off or the person dismissed it. */
81
80
  export function showDockHint(agents, options) {
@@ -163,15 +162,15 @@ html.apc-bar-fixed body { padding-bottom: var(--apc-bar-height, 0px); }
163
162
  /** A small robot, for an agent with no icon. */
164
163
  const BOT = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 2h2v2.07A7 7 0 0 1 19 11v7a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3v-7a7 7 0 0 1 6-6.93V2Zm-2.5 10a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm7 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM2 12h2v5H2v-5Zm18 0h2v5h-2v-5Z"/></svg>';
165
164
  /** The words a mark, its dot and its revoke button say, for tooltips and screen readers. Pure, so it is tested. */
166
- export function agentLabels(a) {
167
- const mark = `${a.name}${a.state === "off" ? " (not connected yet)" : ""}. Drag onto anything to point this agent.`;
165
+ export function agentLabels(a, given) {
166
+ const strings = resolveStrings(DEFAULT_BAR_STRINGS, given);
167
+ const mark = strings.markTitle(a.name, a.state !== "off");
168
168
  const both = !!a.paths?.includes("direct") && !!a.paths.includes("dock");
169
- const revoke = `Revoke ${a.name}: it can no longer read this app${both ? ", directly or through the dock" : ""}, at once. To let it back, connect or drop it here again.`;
170
- const revokeArmed = `Click again to revoke ${a.name}.`;
169
+ const revoke = strings.revokeTitle(a.name, both);
170
+ const revokeArmed = strings.revokeConfirmTitle(a.name);
171
171
  if (!a.pin)
172
172
  return { mark, dot: null, revoke, revokeArmed };
173
- const read = a.pin.seen ? "your agent read it" : "waiting for your agent's next call";
174
- return { mark, dot: `Pinned to ${a.pin.label}: ${read}${a.pin.here ? "" : ". Click to open it."}`, revoke, revokeArmed };
173
+ return { mark, dot: strings.pinTitle(a.pin.label, a.pin.seen, a.pin.here), revoke, revokeArmed };
175
174
  }
176
175
  /** The room each fixed bar takes at the bottom of the page: the page keeps the largest, as --apc-bar-height and padding on <body>. */
177
176
  const reserved = new Map();
@@ -202,7 +201,12 @@ export function defineAgentBar(tag = "apc-agents") {
202
201
  armed = new Map();
203
202
  /** The app draws an agent's icon into this element itself (for example with its own logos). Called on every update. */
204
203
  renderIcon = null;
205
- hint = { ...DOCK_HINT };
204
+ /** undefined: the default hint, in the bar's strings; null: off. */
205
+ hint = undefined;
206
+ words = DEFAULT_BAR_STRINGS;
207
+ given = null;
208
+ /** Bumped when the strings change, so every entry is drawn again. */
209
+ stringsVersion = 0;
206
210
  /** The collapsed indicator's icons, kept per agent like the marks' (an app's renderIcon may keep state in them). */
207
211
  miniIcons = new Map();
208
212
  get agents() {
@@ -214,12 +218,26 @@ export function defineAgentBar(tag = "apc-agents") {
214
218
  }
215
219
  /** The hint that offers the dock to a Direct user; null turns it off. */
216
220
  get dockHint() {
217
- return this.getAttribute("dock-hint") === "off" ? null : this.hint;
221
+ if (this.getAttribute("dock-hint") === "off" || this.hint === null)
222
+ return null;
223
+ return this.hint ?? { text: this.words.dockHint, url: DOCK_HINT.url };
218
224
  }
219
225
  set dockHint(next) {
220
226
  this.hint = next;
221
227
  this.render();
222
228
  }
229
+ /** Every string the bar shows (TIN-668): the keys you set, over the English defaults. */
230
+ get strings() {
231
+ return this.given;
232
+ }
233
+ set strings(next) {
234
+ this.given = next;
235
+ this.words = resolveStrings(DEFAULT_BAR_STRINGS, next);
236
+ this.stringsVersion++;
237
+ if (this.getAttribute("data-apc-label") === "default")
238
+ this.setAttribute("aria-label", this.words.toolbar);
239
+ this.render();
240
+ }
223
241
  /** Mobile (APC 0.6): the viewport is a phone's or a touch one's; the bar then never collapses. */
224
242
  media = null;
225
243
  resizer = null;
@@ -268,8 +286,10 @@ export function defineAgentBar(tag = "apc-agents") {
268
286
  }
269
287
  if (!this.hasAttribute("role"))
270
288
  this.setAttribute("role", "toolbar");
271
- if (!this.hasAttribute("aria-label"))
272
- this.setAttribute("aria-label", "Agents");
289
+ if (!this.hasAttribute("aria-label")) {
290
+ this.setAttribute("aria-label", this.words.toolbar);
291
+ this.setAttribute("data-apc-label", "default");
292
+ }
273
293
  this.render();
274
294
  }
275
295
  emit(type, id) {
@@ -334,8 +354,8 @@ export function defineAgentBar(tag = "apc-agents") {
334
354
  fold.type = "button";
335
355
  fold.className = "apc-agents__collapse apc-agents__extra";
336
356
  fold.textContent = "‹";
337
- fold.title = "Collapse to who is reading. Your agents came through your dock: drag them from there.";
338
- fold.setAttribute("aria-label", "Collapse the agent bar");
357
+ fold.title = this.words.collapseTitle;
358
+ fold.setAttribute("aria-label", this.words.collapseAria);
339
359
  fold.addEventListener("click", () => {
340
360
  store(COLLAPSED_KEY, "1");
341
361
  this.render();
@@ -352,9 +372,9 @@ export function defineAgentBar(tag = "apc-agents") {
352
372
  const button = document.createElement("button");
353
373
  button.type = "button";
354
374
  button.className = "apc-agents__mini apc-agents__extra";
355
- const summary = readingSummary(this.list);
356
- button.title = `${summary}. Show the agents.`;
357
- button.setAttribute("aria-label", `${summary}. Show the agents.`);
375
+ const summary = readingSummary(this.list, this.words);
376
+ button.title = this.words.showAgents(summary);
377
+ button.setAttribute("aria-label", this.words.showAgents(summary));
358
378
  for (const a of reading.slice(0, 4)) {
359
379
  let icon = this.miniIcons.get(a.id);
360
380
  if (!icon) {
@@ -374,7 +394,7 @@ export function defineAgentBar(tag = "apc-agents") {
374
394
  icon.innerHTML = BOT;
375
395
  button.append(icon);
376
396
  }
377
- button.append(document.createTextNode(reading.length ? `${reading.length} reading` : "no agent reading"));
397
+ button.append(document.createTextNode(reading.length ? this.words.readingCount(reading.length) : this.words.nobodyReadingShort));
378
398
  button.addEventListener("click", () => {
379
399
  store(COLLAPSED_KEY, null);
380
400
  this.render();
@@ -397,15 +417,15 @@ export function defineAgentBar(tag = "apc-agents") {
397
417
  const close = document.createElement("button");
398
418
  close.type = "button";
399
419
  close.textContent = "×";
400
- close.setAttribute("aria-label", "Dismiss this hint");
420
+ close.setAttribute("aria-label", this.words.dockHintDismiss);
401
421
  close.addEventListener("click", dismiss);
402
422
  box.append(link, close);
403
423
  return box;
404
424
  }
405
425
  fill(node, a) {
406
- const labels = agentLabels(a);
426
+ const labels = agentLabels(a, this.words);
407
427
  const armed = this.armed.has(a.id);
408
- const key = JSON.stringify([a.name, a.state, a.pin, a.warning, a.restricted, typeof a.icon === "string" ? a.icon.length : null, armed, a.paths ?? null]);
428
+ const key = JSON.stringify([a.name, a.state, a.pin, a.warning, a.restricted, typeof a.icon === "string" ? a.icon.length : null, armed, a.paths ?? null, this.stringsVersion]);
409
429
  if (this.renderIcon)
410
430
  this.renderIcon(a, node.icon);
411
431
  else if (node.key === "" || JSON.stringify(a.icon ?? null) !== node.icon.dataset.src) {
@@ -451,19 +471,19 @@ export function defineAgentBar(tag = "apc-agents") {
451
471
  dot.type = "button";
452
472
  dot.className = `apc-agents__dot${a.pin.seen ? " apc-agents__dot--seen" : ""}${a.pin.here ? "" : " apc-agents__dot--away"}`;
453
473
  dot.title = labels.dot;
454
- dot.setAttribute("aria-label", `${a.name} pinned to ${a.pin.label}${a.pin.seen ? ", read" : ", not read yet"}`);
474
+ dot.setAttribute("aria-label", this.words.pinAria(a.name, a.pin.label, a.pin.seen));
455
475
  dot.addEventListener("click", () => !a.pin?.here && this.emit("apc-open", a.id));
456
476
  const unpin = document.createElement("button");
457
477
  unpin.type = "button";
458
478
  unpin.className = "apc-agents__unpin";
459
479
  unpin.textContent = "×";
460
- unpin.setAttribute("aria-label", `Remove ${a.name}'s pin`);
480
+ unpin.setAttribute("aria-label", this.words.unpinAria(a.name));
461
481
  unpin.addEventListener("click", (e) => e.isTrusted && this.emit("apc-unpin", a.id));
462
482
  pin.append(dot, unpin);
463
483
  parts.push(pin);
464
484
  }
465
485
  // Which path it came by; a linked agent can be shown as two again.
466
- const path = pathLabel(a.paths);
486
+ const path = pathLabel(a.paths, this.words);
467
487
  if (path) {
468
488
  const tag = document.createElement("span");
469
489
  tag.className = "apc-agents__path";
@@ -474,9 +494,9 @@ export function defineAgentBar(tag = "apc-agents") {
474
494
  const unlink = document.createElement("button");
475
495
  unlink.type = "button";
476
496
  unlink.className = "apc-agents__unlink";
477
- unlink.textContent = "unlink";
478
- unlink.title = `Show ${a.name}'s two connections as two agents again. Nothing else changes.`;
479
- unlink.setAttribute("aria-label", `Unlink ${a.name}`);
497
+ unlink.textContent = this.words.unlink;
498
+ unlink.title = this.words.unlinkTitle(a.name);
499
+ unlink.setAttribute("aria-label", this.words.unlinkAria(a.name));
480
500
  unlink.addEventListener("click", (e) => e.isTrusted && this.emit("apc-unlink", a.id));
481
501
  parts.push(unlink);
482
502
  }
@@ -485,9 +505,9 @@ export function defineAgentBar(tag = "apc-agents") {
485
505
  const revoke = document.createElement("button");
486
506
  revoke.type = "button";
487
507
  revoke.className = `apc-agents__revoke${armed ? " apc-agents__revoke--armed" : ""}`;
488
- revoke.textContent = armed ? "Revoke?" : "⊘";
508
+ revoke.textContent = armed ? this.words.revokeConfirm : "⊘";
489
509
  revoke.title = armed ? labels.revokeArmed : labels.revoke;
490
- revoke.setAttribute("aria-label", armed ? labels.revokeArmed : `Revoke ${a.name}`);
510
+ revoke.setAttribute("aria-label", armed ? labels.revokeArmed : this.words.revokeAria(a.name));
491
511
  revoke.addEventListener("click", (e) => e.isTrusted && this.revokeClicked(a.id));
492
512
  revoke.addEventListener("keydown", (e) => e.key === "Escape" && this.disarm(a.id));
493
513
  parts.push(revoke);
@@ -8,6 +8,7 @@
8
8
  * scoped by class prefix and themed through --apc-* custom properties. Set `agents` and `appName`;
9
9
  * listen for `apc-add` (make an agent) and `apc-rename` (detail { id, name }).
10
10
  */
11
+ import { type DirectStrings } from "./strings.js";
11
12
  export interface DirectAgent {
12
13
  /** The app's id for the agent's grant. */
13
14
  id: string;
@@ -27,13 +28,15 @@ export type DirectAgentsEvent = "apc-add" | "apc-rename";
27
28
  * asked in the chat, so it asks for that first, under a name the person will recognise, and for the
28
29
  * steps only if the agent cannot.
29
30
  */
30
- export declare function connectPrompt(url: string, appName: string): string;
31
+ export declare function connectPrompt(url: string, appName: string, strings?: Partial<DirectStrings> | null): string;
31
32
  /** The words of one agent's line. Pure, so it is tested. */
32
- export declare function directAgentStatus(a: DirectAgent): string;
33
+ export declare function directAgentStatus(a: DirectAgent, strings?: Partial<DirectStrings> | null): string;
33
34
  /** Defines `<apc-direct-agents>` where custom elements exist (a browser), so importing this module in Node is safe. */
34
35
  export declare function defineDirectAgents(tag?: string): void;
35
36
  /** The element's type, for apps that hold a reference to it. */
36
37
  export interface ApcDirectAgentsElement extends HTMLElement {
37
38
  agents: DirectAgent[];
38
39
  appName: string;
40
+ /** The list's strings (TIN-668): set the keys you translate; the rest stay English. */
41
+ strings: Partial<DirectStrings> | null;
39
42
  }
@@ -8,20 +8,18 @@
8
8
  * scoped by class prefix and themed through --apc-* custom properties. Set `agents` and `appName`;
9
9
  * listen for `apc-add` (make an agent) and `apc-rename` (detail { id, name }).
10
10
  */
11
+ import { DEFAULT_DIRECT_STRINGS, resolveStrings } from "./strings.js";
11
12
  /**
12
13
  * What a person pastes to their own agent to connect it. Many agents can add a server themselves when
13
14
  * asked in the chat, so it asks for that first, under a name the person will recognise, and for the
14
15
  * steps only if the agent cannot.
15
16
  */
16
- export function connectPrompt(url, appName) {
17
- return `Please connect this remote MCP server, named "${appName}". URL: ${url}. It uses Streamable HTTP and needs no OAuth or API key: the access is in the URL. If you can add it yourself, do it now and tell me when it's connected. If you can't, tell me the exact steps to add it in the app I'm using you in right now. Do not open the URL in a browser.`;
17
+ export function connectPrompt(url, appName, strings) {
18
+ return resolveStrings(DEFAULT_DIRECT_STRINGS, strings).prompt(url, appName);
18
19
  }
19
20
  /** The words of one agent's line. Pure, so it is tested. */
20
- export function directAgentStatus(a) {
21
- if (!a.connected)
22
- return "not connected yet";
23
- const as = a.client ? `connected as ${a.client}` : "connected";
24
- return a.linked ? `${as}, also through your dock` : as;
21
+ export function directAgentStatus(a, strings) {
22
+ return resolveStrings(DEFAULT_DIRECT_STRINGS, strings).status(a);
25
23
  }
26
24
  const STYLE_ID = "apc-direct-style";
27
25
  const CSS = `
@@ -43,7 +41,7 @@ apc-direct-agents { display: flex; flex-direction: column; gap: 10px; color: var
43
41
  .apc-direct__add { align-self: flex-start; }
44
42
  .apc-direct__add:hover, .apc-direct__rename button:hover { border-color: var(--apc-accent, #b85209); }
45
43
  `;
46
- function copyField(text, label) {
44
+ function copyField(text, label, copied) {
47
45
  const box = document.createElement("div");
48
46
  box.className = "apc-direct__copy";
49
47
  const code = document.createElement("code");
@@ -53,7 +51,7 @@ function copyField(text, label) {
53
51
  button.textContent = label;
54
52
  button.addEventListener("click", () => {
55
53
  void navigator.clipboard?.writeText(text).then(() => {
56
- button.textContent = "Copied";
54
+ button.textContent = copied;
57
55
  setTimeout(() => (button.textContent = label), 1500);
58
56
  }, () => undefined);
59
57
  });
@@ -66,7 +64,9 @@ export function defineDirectAgents(tag = "apc-direct-agents") {
66
64
  return;
67
65
  class ApcDirectAgents extends HTMLElement {
68
66
  list = [];
69
- name = "this app";
67
+ name = null;
68
+ words = DEFAULT_DIRECT_STRINGS;
69
+ given = null;
70
70
  /** Agents whose connection is unfolded; an agent not connected yet is unfolded until it connects. */
71
71
  open = new Set();
72
72
  shut = new Set();
@@ -80,12 +80,21 @@ export function defineDirectAgents(tag = "apc-direct-agents") {
80
80
  this.render();
81
81
  }
82
82
  get appName() {
83
- return this.name;
83
+ return this.name ?? this.words.thisApp;
84
84
  }
85
85
  set appName(next) {
86
86
  this.name = next;
87
87
  this.render();
88
88
  }
89
+ /** Every string the list shows (TIN-668): the keys you set, over the English defaults. */
90
+ get strings() {
91
+ return this.given;
92
+ }
93
+ set strings(next) {
94
+ this.given = next;
95
+ this.words = resolveStrings(DEFAULT_DIRECT_STRINGS, next);
96
+ this.render();
97
+ }
89
98
  connectedCallback() {
90
99
  if (!document.getElementById(STYLE_ID)) {
91
100
  const style = document.createElement("style");
@@ -104,6 +113,7 @@ export function defineDirectAgents(tag = "apc-direct-agents") {
104
113
  render() {
105
114
  if (!this.isConnected)
106
115
  return;
116
+ const w = this.words;
107
117
  const rows = this.list.map((a) => {
108
118
  const row = document.createElement("div");
109
119
  row.className = "apc-direct__agent";
@@ -117,14 +127,14 @@ export function defineDirectAgents(tag = "apc-direct-agents") {
117
127
  input.value = this.draft;
118
128
  input.addEventListener("input", () => (this.draft = input.value));
119
129
  input.maxLength = 40;
120
- input.setAttribute("aria-label", "Agent name");
121
- input.placeholder = "Empty: back to its own name";
130
+ input.setAttribute("aria-label", w.nameLabel);
131
+ input.placeholder = w.namePlaceholder;
122
132
  const save = document.createElement("button");
123
133
  save.type = "submit";
124
- save.textContent = "Save";
134
+ save.textContent = w.save;
125
135
  const cancel = document.createElement("button");
126
136
  cancel.type = "button";
127
- cancel.textContent = "Cancel";
137
+ cancel.textContent = w.cancel;
128
138
  cancel.addEventListener("click", () => {
129
139
  this.editing = null;
130
140
  this.render();
@@ -149,11 +159,11 @@ export function defineDirectAgents(tag = "apc-direct-agents") {
149
159
  name.textContent = a.name;
150
160
  const status = document.createElement("span");
151
161
  status.className = "apc-direct__status";
152
- status.textContent = directAgentStatus(a);
162
+ status.textContent = directAgentStatus(a, w);
153
163
  const rename = document.createElement("button");
154
164
  rename.type = "button";
155
165
  rename.className = "apc-direct__link";
156
- rename.textContent = "Rename";
166
+ rename.textContent = w.rename;
157
167
  rename.addEventListener("click", () => {
158
168
  this.editing = a.id;
159
169
  this.draft = a.name;
@@ -163,7 +173,7 @@ export function defineDirectAgents(tag = "apc-direct-agents") {
163
173
  toggle.type = "button";
164
174
  toggle.className = "apc-direct__link";
165
175
  const open = this.isOpen(a);
166
- toggle.textContent = open ? "Hide connection" : "Show connection";
176
+ toggle.textContent = open ? w.hideConnection : w.showConnection;
167
177
  toggle.setAttribute("aria-expanded", String(open));
168
178
  toggle.addEventListener("click", () => {
169
179
  if (open) {
@@ -184,11 +194,11 @@ export function defineDirectAgents(tag = "apc-direct-agents") {
184
194
  body.className = "apc-direct__body";
185
195
  const urlHint = document.createElement("p");
186
196
  urlHint.className = "apc-direct__hint";
187
- urlHint.textContent = "Add this URL to your agent as a remote MCP server (Streamable HTTP). No login: the secret in the URL is its access, so give each agent its own.";
197
+ urlHint.textContent = w.urlHint;
188
198
  const askHint = document.createElement("p");
189
199
  askHint.className = "apc-direct__hint";
190
- askHint.textContent = "Don't know how? Paste this to your agent:";
191
- body.append(urlHint, copyField(a.url, "Copy"), askHint, copyField(connectPrompt(a.url, this.name), "Copy prompt"));
200
+ askHint.textContent = w.askHint;
201
+ body.append(urlHint, copyField(a.url, w.copy, w.copied), askHint, copyField(connectPrompt(a.url, this.appName, w), w.copyPrompt, w.copied));
192
202
  row.append(body);
193
203
  }
194
204
  return row;
@@ -196,7 +206,7 @@ export function defineDirectAgents(tag = "apc-direct-agents") {
196
206
  const add = document.createElement("button");
197
207
  add.type = "button";
198
208
  add.className = "apc-direct__add";
199
- add.textContent = this.list.length ? "Add another agent" : "Connect an agent";
209
+ add.textContent = this.list.length ? w.addAnother : w.connectAgent;
200
210
  add.addEventListener("click", () => this.emit("apc-add"));
201
211
  this.replaceChildren(...rows, add);
202
212
  }
package/dist/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export * from "./view.js";
4
4
  export * from "./agents-bar.js";
5
5
  export * from "./pin-badge.js";
6
6
  export * from "./direct-agents.js";
7
+ export * from "./strings.js";
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export * from "./view.js";
4
4
  export * from "./agents-bar.js";
5
5
  export * from "./pin-badge.js";
6
6
  export * from "./direct-agents.js";
7
+ export * from "./strings.js";
@@ -18,6 +18,7 @@
18
18
  * script has no customElements, and it moves the badge with its own drag, since a moved reading is
19
19
  * sent again from the page. Keep the two in step.
20
20
  */
21
+ import { type PinStrings } from "./strings.js";
21
22
  export interface PinAgent {
22
23
  /** The app's id for the agent (its grant): what the mark drags as. */
23
24
  id: string;
@@ -30,7 +31,7 @@ export interface PinAgent {
30
31
  /** Event, with detail { id }: the person removed the pin. */
31
32
  export type PinBadgeEvent = "apc-unpin";
32
33
  /** The words a badge says, for its tooltip and screen readers. Pure, so it is tested. */
33
- export declare function pinBadgeLabels(a: Pick<PinAgent, "name" | "seen">): {
34
+ export declare function pinBadgeLabels(a: Pick<PinAgent, "name" | "seen">, given?: Partial<PinStrings> | null): {
34
35
  title: string;
35
36
  unpin: string;
36
37
  };
@@ -42,4 +43,6 @@ export interface ApcPinElement extends HTMLElement {
42
43
  readonly mark: HTMLElement | null;
43
44
  readonly iconHost: HTMLElement | null;
44
45
  renderIcon: ((agent: PinAgent, into: HTMLElement) => void) | null;
46
+ /** The badge's strings (TIN-668): set the keys you translate; the rest stay English. */
47
+ strings: Partial<PinStrings> | null;
45
48
  }
package/dist/pin-badge.js CHANGED
@@ -18,6 +18,7 @@
18
18
  * script has no customElements, and it moves the badge with its own drag, since a moved reading is
19
19
  * sent again from the page. Keep the two in step.
20
20
  */
21
+ import { DEFAULT_PIN_STRINGS, resolveStrings } from "./strings.js";
21
22
  const STYLE_ID = "apc-pin-style";
22
23
  const CSS = `
23
24
  apc-pin { position: relative; display: inline-block; width: var(--apc-pin-size, 28px); height: var(--apc-pin-size, 28px); vertical-align: middle; }
@@ -40,11 +41,9 @@ apc-pin.apc-pin--dragging { opacity: 0.35; }
40
41
  /** A small robot, for an agent with no icon. */
41
42
  const BOT = '<svg class="apc-pin__img" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M11 2h2v2.07A7 7 0 0 1 19 11v7a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3v-7a7 7 0 0 1 6-6.93V2Zm-2.5 10a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm7 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM2 12h2v5H2v-5Zm18 0h2v5h-2v-5Z"/></svg>';
42
43
  /** The words a badge says, for its tooltip and screen readers. Pure, so it is tested. */
43
- export function pinBadgeLabels(a) {
44
- return {
45
- title: `${a.name}: ${a.seen ? "read this pin" : "has not read this pin yet"}. Drag to point it at something else.`,
46
- unpin: `Remove ${a.name}'s pin`,
47
- };
44
+ export function pinBadgeLabels(a, given) {
45
+ const strings = resolveStrings(DEFAULT_PIN_STRINGS, given);
46
+ return { title: strings.title(a.name, a.seen), unpin: strings.unpinAria(a.name) };
48
47
  }
49
48
  /** The element's constructor, defined only where custom elements exist (a browser), so importing this module in Node is safe. */
50
49
  export function definePinBadge(tag = "apc-pin") {
@@ -57,6 +56,17 @@ export function definePinBadge(tag = "apc-pin") {
57
56
  iconKey = "";
58
57
  stopDim = null;
59
58
  draw = null;
59
+ words = DEFAULT_PIN_STRINGS;
60
+ given = null;
61
+ /** Every string the badge shows (TIN-668): the keys you set, over the English defaults. */
62
+ get strings() {
63
+ return this.given;
64
+ }
65
+ set strings(next) {
66
+ this.given = next;
67
+ this.words = resolveStrings(DEFAULT_PIN_STRINGS, next);
68
+ this.render();
69
+ }
60
70
  /**
61
71
  * The app draws the agent's icon into this element itself (for example with its own logos). Called on every update.
62
72
  * A function that draws nothing says the app draws it elsewhere, into `iconHost` (React: a portal).
@@ -86,7 +96,7 @@ export function definePinBadge(tag = "apc-pin") {
86
96
  connectedCallback() {
87
97
  // A property set before the element was defined (a framework, a script) sits on the instance: take it in through the setter.
88
98
  const own = this;
89
- for (const key of ["agent", "renderIcon"]) {
99
+ for (const key of ["agent", "renderIcon", "strings"]) {
90
100
  if (!Object.hasOwn(own, key))
91
101
  continue;
92
102
  const value = own[key];
@@ -146,12 +156,12 @@ export function definePinBadge(tag = "apc-pin") {
146
156
  if (!this.parts || !a)
147
157
  return;
148
158
  const { mark, icon, check, unpin } = this.parts;
149
- const labels = pinBadgeLabels(a);
159
+ const labels = pinBadgeLabels(a, this.words);
150
160
  this.setAttribute("title", labels.title);
151
161
  this.setAttribute("data-apc-pin-state", a.seen ? "seen" : "waiting");
152
162
  mark.setAttribute("data-grant", a.id);
153
163
  mark.setAttribute("role", "img");
154
- mark.setAttribute("aria-label", `${a.name}, ${a.seen ? "has read its pin" : "has not read its pin yet"}`);
164
+ mark.setAttribute("aria-label", this.words.markAria(a.name, a.seen));
155
165
  check.hidden = !a.seen;
156
166
  unpin.setAttribute("aria-label", labels.unpin);
157
167
  if (this.draw)
package/dist/pinning.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type ApcRole, type FocusTarget } from "@tineira/apc-protocol";
2
+ import { type PinningStrings } from "./strings.js";
2
3
  /** How long the page waits for its own apc.json: a held drop never waits longer. */
3
4
  export declare const APC_JSON_LOAD_MS = 3000;
4
5
  /**
@@ -57,6 +58,12 @@ export interface PinningOptions {
57
58
  * A test that sends events of its own passes `() => true`; nothing in a page can reach this.
58
59
  */
59
60
  trusted?: (event: Event) => boolean;
61
+ /**
62
+ * The words enablePinning shows the person (TIN-668): the tap-to-pin note, the answers to a refused
63
+ * drop, and the note for a dock agent dropped on a Direct-only app (`directOnlyNote`, `couldNotCheckNote`
64
+ * replace the protocol's English ones). The keys you leave out stay English.
65
+ */
66
+ strings?: Partial<PinningStrings>;
60
67
  }
61
68
  /** The answer to a dock agent dropped on a Direct-only app: who it was, what to tell the person, and where they connect instead. */
62
69
  export interface DockUnsupported {
@@ -67,13 +74,30 @@ export interface DockUnsupported {
67
74
  connect: string | null;
68
75
  /**
69
76
  * `direct_only`: the app's apc.json lists no "dock". `unchecked`: the page could not read its own
70
- * apc.json, so it cannot tell, and it pairs nothing it cannot check.
77
+ * apc.json, so it cannot tell, and it pairs nothing it cannot check. `refused` (TIN-756): the app offers
78
+ * Dock and answered the drag, then cannot place the drop (its `canTarget` said no, or it does not know the
79
+ * agent); `connect` is null and the app's handler need not send the person to its connect page.
71
80
  */
72
- reason: "direct_only" | "unchecked";
81
+ reason: "direct_only" | "unchecked" | "refused";
73
82
  /** `directOnlyNote(name)`, or `couldNotCheckNote(name)`: the app's own words, on its own page. */
74
83
  message: string;
75
84
  }
76
- export declare function showDockUnsupported(answer: DockUnsupported): HTMLElement;
85
+ export declare function showDockUnsupported(answer: DockUnsupported, given?: Partial<PinningStrings> | null): HTMLElement;
86
+ /** What the person is told when an app answered a dock agent's drop and then cannot place it (TIN-756). */
87
+ export declare const REFUSED_NOTE: string;
88
+ export declare const UNKNOWN_AGENT_NOTE: string;
89
+ /** How a refused drop is told: through the app's `onDockUnsupported` when it has one, else the page note (TIN-756). */
90
+ export declare function refusalTeller(options: Pick<PinningOptions, "onDockUnsupported" | "direct" | "strings">): (message: string, drag: AgentDrag, target: FocusTarget) => void;
91
+ /**
92
+ * What an app does with a native drop of a dock agent it answered on dragover (`preventDefault`): pins it, hands an
93
+ * agent it does not know to `onUnknownAgent`, or says why it did nothing with `tell`. A drop the browser delivered
94
+ * is never left without a word (TIN-756): the extension stands back from an app that answered the dragover, so a
95
+ * silent refusal here was a drop with no answer at all.
96
+ */
97
+ export declare function placeNativeDrop(options: Pick<PinningOptions, "canTarget" | "onPin" | "resolveAgent" | "onUnknownAgent" | "strings">, drag: AgentDrag | null, hit: {
98
+ element: Element;
99
+ target: FocusTarget;
100
+ } | null, tell: (message: string, drag: AgentDrag, target: FocusTarget) => void): "pinned" | "unknown_agent" | "refused" | "none";
77
101
  /** What an agent's drag carries. hub and ticket come with pairing (TIN-382): the hub's origin and a single-use, short-lived ticket. */
78
102
  export interface AgentDrag {
79
103
  v: 1;
package/dist/pinning.js CHANGED
@@ -1,4 +1,5 @@
1
- import { APC_JSON_MAX_BYTES, APC_JSON_PATH, couldNotCheckNote, directOnlyNote, parseApcManifest, parseFocusAttrs, urlOnOrigin } from "@tineira/apc-protocol";
1
+ import { APC_JSON_MAX_BYTES, APC_JSON_PATH, parseApcManifest, parseFocusAttrs, urlOnOrigin } from "@tineira/apc-protocol";
2
+ import { DEFAULT_PINNING_STRINGS, resolveStrings } from "./strings.js";
2
3
  const profileLoads = new Map();
3
4
  /** How long the page waits for its own apc.json: a held drop never waits longer. */
4
5
  export const APC_JSON_LOAD_MS = 3000;
@@ -61,7 +62,8 @@ export function loadApcProfiles(origin = location.origin, fetcher = (...a) => fe
61
62
  * replaces it: never a stack of notes.
62
63
  */
63
64
  let shownNote = null;
64
- export function showDockUnsupported(answer) {
65
+ export function showDockUnsupported(answer, given) {
66
+ const strings = resolveStrings(DEFAULT_PINNING_STRINGS, given);
65
67
  if (shownNote) {
66
68
  clearTimeout(shownNote.timer);
67
69
  shownNote.box.remove();
@@ -73,20 +75,21 @@ export function showDockUnsupported(answer) {
73
75
  Object.assign(box.style, { position: "fixed", zIndex: "2147483647", right: "16px", bottom: "calc(16px + max(var(--apc-bar-height, 0px), env(safe-area-inset-bottom, 0px)))", maxWidth: "340px", padding: "12px 14px", borderRadius: "10px", background: "#1f1f1f", color: "#fff", font: "13px/1.45 system-ui, sans-serif", boxShadow: "0 4px 16px rgba(0,0,0,.25)" });
74
76
  const text = document.createElement("p");
75
77
  text.textContent = answer.message;
76
- Object.assign(text.style, { margin: "0 0 8px" });
77
- box.appendChild(text);
78
78
  // Only an http(s) page of this app: the same check apc.json's direct.connect passes (no blob:, javascript: or another site).
79
79
  const connect = typeof location === "undefined" ? null : urlOnOrigin(answer.connect, location.origin);
80
+ // Room under the words only when a link follows them.
81
+ Object.assign(text.style, { margin: connect ? "0 0 8px" : "0" });
82
+ box.appendChild(text);
80
83
  if (connect) {
81
84
  const link = document.createElement("a");
82
85
  link.setAttribute("href", connect);
83
- link.textContent = `Connect an agent to ${answer.name}`;
86
+ link.textContent = strings.connectLink(answer.name);
84
87
  Object.assign(link.style, { color: "#f2a46b", fontWeight: "600" });
85
88
  box.appendChild(link);
86
89
  }
87
90
  const close = document.createElement("button");
88
91
  close.setAttribute("type", "button");
89
- close.setAttribute("aria-label", "Close");
92
+ close.setAttribute("aria-label", strings.close);
90
93
  close.textContent = "×";
91
94
  Object.assign(close.style, { position: "absolute", top: "4px", right: "6px", background: "none", border: "0", color: "#ccc", font: "16px system-ui, sans-serif", cursor: "pointer" });
92
95
  const remove = () => {
@@ -102,6 +105,39 @@ export function showDockUnsupported(answer) {
102
105
  shownNote = { box, timer: setTimeout(remove, 20_000) };
103
106
  return box;
104
107
  }
108
+ /** What the person is told when an app answered a dock agent's drop and then cannot place it (TIN-756). */
109
+ export const REFUSED_NOTE = DEFAULT_PINNING_STRINGS.refused;
110
+ export const UNKNOWN_AGENT_NOTE = DEFAULT_PINNING_STRINGS.unknownAgent;
111
+ /** How a refused drop is told: through the app's `onDockUnsupported` when it has one, else the page note (TIN-756). */
112
+ export function refusalTeller(options) {
113
+ const words = resolveStrings(DEFAULT_PINNING_STRINGS, options.strings);
114
+ return (message, drag, target) => (options.onDockUnsupported ?? ((answer) => showDockUnsupported(answer, words)))({ drag, target, name: options.direct?.name ?? words.thisApp, connect: null, reason: "refused", message });
115
+ }
116
+ /**
117
+ * What an app does with a native drop of a dock agent it answered on dragover (`preventDefault`): pins it, hands an
118
+ * agent it does not know to `onUnknownAgent`, or says why it did nothing with `tell`. A drop the browser delivered
119
+ * is never left without a word (TIN-756): the extension stands back from an app that answered the dragover, so a
120
+ * silent refusal here was a drop with no answer at all.
121
+ */
122
+ export function placeNativeDrop(options, drag, hit, tell) {
123
+ if (!drag || !hit)
124
+ return "none";
125
+ const grantId = options.resolveAgent ? options.resolveAgent(drag.agent) : drag.agent;
126
+ if (!grantId) {
127
+ if (drag.hub && drag.ticket && options.onUnknownAgent) {
128
+ options.onUnknownAgent({ ...drag, hub: drag.hub, ticket: drag.ticket }, hit.target, hit.element);
129
+ return "unknown_agent";
130
+ }
131
+ tell(resolveStrings(DEFAULT_PINNING_STRINGS, options.strings).unknownAgent, drag, hit.target);
132
+ return "refused";
133
+ }
134
+ if (options.canTarget && !options.canTarget(grantId, hit.target)) {
135
+ tell(resolveStrings(DEFAULT_PINNING_STRINGS, options.strings).refused, drag, hit.target);
136
+ return "refused";
137
+ }
138
+ options.onPin(grantId, hit.target, hit.element);
139
+ return "pinned";
140
+ }
105
141
  /** The drag type a dock puts on an agent: its payload is agentDragPayload(handle), never a secret. */
106
142
  export const APC_AGENT_TYPE = "application/x-apc-agent";
107
143
  /**
@@ -202,7 +238,7 @@ body.apc-armed [data-focus] { outline: 2px dashed var(--apc-accent, #b85209); ou
202
238
  [data-apc-mark][aria-pressed="true"] { outline: 3px solid var(--apc-accent, #b85209); outline-offset: 2px; }
203
239
  `;
204
240
  /** The note shown while an agent is armed: what to do, and a way out. Fixed above the agent bar (--apc-bar-height). */
205
- function armedNote(name, onCancel) {
241
+ function armedNote(name, onCancel, words) {
206
242
  const box = document.createElement("div");
207
243
  box.setAttribute("role", "status");
208
244
  box.setAttribute("data-apc-tap-pin", "");
@@ -212,11 +248,11 @@ function armedNote(name, onCancel) {
212
248
  const say = (t) => {
213
249
  text.textContent = t;
214
250
  };
215
- say(`Tap what to point ${name} at.`);
251
+ say(words.tapPrompt(name));
216
252
  const cancel = document.createElement("button");
217
253
  cancel.setAttribute("type", "button");
218
254
  cancel.setAttribute("data-apc-tap-cancel", "");
219
- cancel.textContent = "Cancel";
255
+ cancel.textContent = words.cancel;
220
256
  Object.assign(cancel.style, { minHeight: "44px", minWidth: "64px", padding: "0 14px", borderRadius: "10px", border: "1px solid #666", background: "transparent", color: "#fff", font: "600 14px system-ui, sans-serif", cursor: "pointer" });
221
257
  cancel.addEventListener("click", onCancel);
222
258
  box.append(text, cancel);
@@ -239,6 +275,7 @@ function armedNote(name, onCancel) {
239
275
  */
240
276
  export function enablePinning(options) {
241
277
  const root = options.root ?? document;
278
+ const words = resolveStrings(DEFAULT_PINNING_STRINGS, options.strings);
242
279
  const markSelector = options.markSelector ?? "[data-apc-mark]";
243
280
  let drag = null;
244
281
  // Tap to pin: the agent armed by a tap on its mark, and the note that says so.
@@ -276,8 +313,8 @@ export function enablePinning(options) {
276
313
  document.head.append(style);
277
314
  }
278
315
  const mark = [...document.querySelectorAll(markSelector)].find((m) => m.getAttribute("data-grant") === armed && !m.closest("apc-pin"));
279
- const name = mark?.getAttribute("aria-label") || mark?.getAttribute("title") || "your agent";
280
- note = armedNote(name, () => tapPin.cancel());
316
+ const name = mark?.getAttribute("aria-label") || mark?.getAttribute("title") || words.yourAgent;
317
+ note = armedNote(name, () => tapPin.cancel(), words);
281
318
  document.body.append(note.box);
282
319
  document.body.classList.add("apc-armed", "apc-dragging");
283
320
  document.documentElement.setAttribute("data-apc-armed", armed);
@@ -310,7 +347,7 @@ export function enablePinning(options) {
310
347
  restore.push([el, null]);
311
348
  el.setAttribute("tabindex", "0");
312
349
  }
313
- note?.say("Pointing your agent: Tab to an outlined item, then press Enter or Space to pin it. Escape cancels.");
350
+ note?.say(words.keyboardArmed);
314
351
  targets[0]?.focus();
315
352
  };
316
353
  const setCurrent = (next) => {
@@ -399,7 +436,7 @@ export function enablePinning(options) {
399
436
  swallowClickUntil = Date.now() + 500;
400
437
  swallowOn = hit?.element ?? null;
401
438
  if (result === "refused")
402
- note?.say("This agent cannot point at that. Tap another outlined target, or Cancel.");
439
+ note?.say(words.refusedTap);
403
440
  };
404
441
  // Only the click that follows a pinning (or refused) tap is swallowed; every other click goes through.
405
442
  const onClickCapture = (event) => {
@@ -498,7 +535,7 @@ export function enablePinning(options) {
498
535
  if (fromDock && unread() && typeof given === "function")
499
536
  load(given());
500
537
  // A dock agent dropped before the app's profiles are known: held until they are, within the
501
- // loader's own time limit (loadApcProfiles: 3 s; the ticket lives 60 s).
538
+ // loader's own time limit (loadApcProfiles: 3 s; the ticket lives 240 s).
502
539
  if (fromDock && loading) {
503
540
  void ready.then(() => deliver(drag, hit));
504
541
  return;
@@ -509,26 +546,17 @@ export function enablePinning(options) {
509
546
  // A dock agent on an app that does not offer Dock, or whose profiles could not be read: answered,
510
547
  // never sent to pairing (an app that cannot check its own file pairs nothing on a guess).
511
548
  if (drag?.hub && drag.ticket && (dockless() || unread())) {
512
- const name = options.direct?.name ?? "This app";
549
+ const name = options.direct?.name ?? words.thisApp;
513
550
  const reason = dockless() ? "direct_only" : "unchecked";
514
- const answer = { drag, target: hit?.target ?? null, name, connect: options.direct?.connect ?? null, reason, message: reason === "direct_only" ? directOnlyNote(name) : couldNotCheckNote(name) };
515
- (options.onDockUnsupported ?? showDockUnsupported)(answer);
551
+ const answer = { drag, target: hit?.target ?? null, name, connect: options.direct?.connect ?? null, reason, message: reason === "direct_only" ? words.directOnlyNote(name) : words.couldNotCheckNote(name) };
552
+ (options.onDockUnsupported ?? ((a) => showDockUnsupported(a, words)))(answer);
516
553
  return;
517
554
  }
518
555
  // A drop held while the profiles loaded, on an app that turns out to offer Dock but takes no native drops.
519
556
  if (!options.acceptNativeDrops)
520
557
  return;
521
- if (!drag || !hit)
522
- return;
523
- const grantId = options.resolveAgent ? options.resolveAgent(drag.agent) : drag.agent;
524
- if (!grantId) {
525
- if (drag.hub && drag.ticket)
526
- options.onUnknownAgent?.({ ...drag, hub: drag.hub, ticket: drag.ticket }, hit.target, hit.element);
527
- return;
528
- }
529
- if (options.canTarget && !options.canTarget(grantId, hit.target))
530
- return;
531
- options.onPin(grantId, hit.target, hit.element);
558
+ // Through the app's own handler when it has one (Pulse and Signals show it in their own banners), else the page note.
559
+ placeNativeDrop(options, drag, hit, refusalTeller(options));
532
560
  };
533
561
  const onKey = (event) => {
534
562
  swallowClickUntil = 0; // a new key press: nothing from before is swallowed
@@ -547,7 +575,7 @@ export function enablePinning(options) {
547
575
  swallowClickUntil = Date.now() + 500;
548
576
  swallowOn = hit.element;
549
577
  if (tapPin.tap(hit) === "refused")
550
- note?.say("This agent cannot point at that. Tab to another outlined item, or press Escape.");
578
+ note?.say(words.refusedKeys);
551
579
  }
552
580
  };
553
581
  root.addEventListener("pointerdown", onDown);
@@ -0,0 +1,133 @@
1
+ /** What `<apc-agents>` says. Tooltips and `aria-label`s included. */
2
+ export interface BarStrings {
3
+ /** The bar's own `aria-label` (unless the element has one). */
4
+ toolbar: string;
5
+ /** A mark's tooltip: the agent and what to do with it. `connected` is false until it first connects. */
6
+ markTitle(name: string, connected: boolean): string;
7
+ /** A pin dot's tooltip. `read`: the agent read the pin; `here`: its target is on this screen. */
8
+ pinTitle(label: string, read: boolean, here: boolean): string;
9
+ /** A pin dot's `aria-label`. */
10
+ pinAria(name: string, label: string, read: boolean): string;
11
+ /** The × that removes a pin: its `aria-label`. */
12
+ unpinAria(name: string): string;
13
+ /** The ⊘ button's tooltip. `both`: the agent is linked, so revoking cuts both its paths. */
14
+ revokeTitle(name: string, both: boolean): string;
15
+ /** The ⊘ button's `aria-label`. */
16
+ revokeAria(name: string): string;
17
+ /** The ⊘ button's text after the first click. */
18
+ revokeConfirm: string;
19
+ /** The armed button's tooltip and `aria-label`. */
20
+ revokeConfirmTitle(name: string): string;
21
+ /** The tag of an agent that came directly: text, then tooltip. */
22
+ pathDirect: string;
23
+ pathDirectTitle: string;
24
+ pathDock: string;
25
+ pathDockTitle: string;
26
+ /** The tag of an agent linked across both. */
27
+ pathBoth: string;
28
+ pathBothTitle: string;
29
+ /** The Unlink button: text, tooltip and `aria-label`. */
30
+ unlink: string;
31
+ unlinkTitle(name: string): string;
32
+ unlinkAria(name: string): string;
33
+ /** The ‹ button that collapses the bar: tooltip and `aria-label`. */
34
+ collapseTitle: string;
35
+ collapseAria: string;
36
+ /** The collapsed indicator, long form (tooltip and `aria-label`): who is reading. Names in the order given. */
37
+ reading(names: readonly string[]): string;
38
+ /** The same when nobody is. */
39
+ nobodyReading: string;
40
+ /** The collapsed indicator's tooltip and `aria-label`: the summary above, then how to open the bar. */
41
+ showAgents(summary: string): string;
42
+ /** The collapsed indicator's visible text: "2 reading". Plurals are yours. */
43
+ readingCount(count: number): string;
44
+ /** …and when no agent is reading. */
45
+ nobodyReadingShort: string;
46
+ /** The dock hint's text (the link). An app can also set `dockHint = { text, url }`, or turn it off with `dockHint = null`. */
47
+ dockHint: string;
48
+ /** The hint's × button: `aria-label`. */
49
+ dockHintDismiss: string;
50
+ /** Two or more pinned targets of one linked agent as one label: "A and B". Called with the distinct labels. */
51
+ joinPins(labels: readonly string[]): string;
52
+ }
53
+ export declare const DEFAULT_BAR_STRINGS: BarStrings;
54
+ /** What `<apc-pin>` says. */
55
+ export interface PinStrings {
56
+ /** The badge's tooltip. */
57
+ title(name: string, seen: boolean): string;
58
+ /** The × button's `aria-label`. */
59
+ unpinAria(name: string): string;
60
+ /** The mark's `aria-label`. */
61
+ markAria(name: string, seen: boolean): string;
62
+ }
63
+ export declare const DEFAULT_PIN_STRINGS: PinStrings;
64
+ /** What `<apc-direct-agents>` says, and the prompt it hands the person to paste to their agent. */
65
+ export interface DirectStrings {
66
+ /** The agent name field: `aria-label` and placeholder. */
67
+ nameLabel: string;
68
+ namePlaceholder: string;
69
+ save: string;
70
+ cancel: string;
71
+ rename: string;
72
+ hideConnection: string;
73
+ showConnection: string;
74
+ /** Above the URL, and above the prompt. */
75
+ urlHint: string;
76
+ askHint: string;
77
+ /** The copy buttons, and what the first says for a moment after it copied. */
78
+ copy: string;
79
+ copyPrompt: string;
80
+ copied: string;
81
+ /** The add button once there is an agent, and before. */
82
+ addAnother: string;
83
+ connectAgent: string;
84
+ /** The app's name in the prompt until `appName` is set. */
85
+ thisApp: string;
86
+ /** One agent's status line. */
87
+ status(agent: {
88
+ connected: boolean;
89
+ client?: string | null;
90
+ linked?: boolean;
91
+ }): string;
92
+ /** What the person pastes to their own agent to connect it. */
93
+ prompt(url: string, appName: string): string;
94
+ }
95
+ export declare const DEFAULT_DIRECT_STRINGS: DirectStrings;
96
+ /** What `enablePinning` shows the person: the tap-to-pin note and the answers to a dock agent's drop. */
97
+ export interface PinningStrings {
98
+ /** The note while an agent is armed, with the agent's name (from its mark). */
99
+ tapPrompt(name: string): string;
100
+ /** The agent's name when its mark has none. */
101
+ yourAgent: string;
102
+ /** The note's Cancel button. */
103
+ cancel: string;
104
+ /** Armed from the keyboard. */
105
+ keyboardArmed: string;
106
+ /** The armed agent may not point there: by tap, and by keyboard. */
107
+ refusedTap: string;
108
+ refusedKeys: string;
109
+ /** A native drop the app cannot place: the agent may not point there, or is not known. */
110
+ refused: string;
111
+ unknownAgent: string;
112
+ /** The app's name in those answers until `direct.name` says it. */
113
+ thisApp: string;
114
+ /** The page note for a dock agent dropped on a Direct-only app: the link to the app's connect page, and its × (`aria-label`). */
115
+ connectLink(appName: string): string;
116
+ close: string;
117
+ /** A dock agent dropped on a Direct-only app, and when the page could not check whether it is one. Default: the protocol's notes. */
118
+ directOnlyNote(appName: string): string;
119
+ couldNotCheckNote(appName: string): string;
120
+ }
121
+ export declare const DEFAULT_PINNING_STRINGS: PinningStrings;
122
+ /** The defaults with the app's overrides on top. A key set to `undefined` keeps its default. */
123
+ export declare function resolveStrings<T extends object>(defaults: T, given?: Partial<T> | null): T;
124
+ /**
125
+ * The locale a page's dates and times are shown in: the one the app names, else the page's own
126
+ * (`<html lang>`), else English. Never the operating system's: a Spanish page on an English machine
127
+ * reads Spanish dates.
128
+ */
129
+ export declare function pageLocale(explicit?: string | null): string;
130
+ /** A time or date for the person, in the page's locale (see `pageLocale`). For an app's own labels, such as a pin's "read 12:04". */
131
+ export declare function formatPageTime(value: Date | number | string, options?: Intl.DateTimeFormatOptions & {
132
+ locale?: string | null;
133
+ }): string;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Every string the package puts in the page (TIN-668), so an app in another language ships it without
3
+ * a fork. Each component takes one `strings` object (a `Partial` of its type below): the keys you leave
4
+ * out keep their English default, which is exactly the text the package always showed. A string that
5
+ * holds a name, a count or a label is a function, so the app decides the word order and the plural
6
+ * forms: nothing here is joined from English fragments.
7
+ *
8
+ * - `<apc-agents>`: `agentBar.strings = { ... }` (type `BarStrings`)
9
+ * - `<apc-pin>`: `pin.strings = { ... }` (type `PinStrings`)
10
+ * - `<apc-direct-agents>`: `list.strings = { ... }` (type `DirectStrings`)
11
+ * - `enablePinning({ strings: { ... } })` (type `PinningStrings`)
12
+ *
13
+ * Left as they are, on purpose: the symbols (×, ⊘, ‹, !, ✓), which carry no language; the agents' own
14
+ * names, labels and URLs, which the app passes in; and the page text an agent reads, which is not this
15
+ * package's. The note for a dock agent dropped on a Direct-only app is the protocol's `directOnlyNote`;
16
+ * `PinningStrings.directOnlyNote` replaces it with the app's own words.
17
+ */
18
+ import { couldNotCheckNote, directOnlyNote } from "@tineira/apc-protocol";
19
+ export const DEFAULT_BAR_STRINGS = {
20
+ toolbar: "Agents",
21
+ markTitle: (name, connected) => `${name}${connected ? "" : " (not connected yet)"}. Drag onto anything to point this agent.`,
22
+ pinTitle: (label, read, here) => `Pinned to ${label}: ${read ? "your agent read it" : "waiting for your agent's next call"}${here ? "" : ". Click to open it."}`,
23
+ pinAria: (name, label, read) => `${name} pinned to ${label}${read ? ", read" : ", not read yet"}`,
24
+ unpinAria: (name) => `Remove ${name}'s pin`,
25
+ revokeTitle: (name, both) => `Revoke ${name}: it can no longer read this app${both ? ", directly or through the dock" : ""}, at once. To let it back, connect or drop it here again.`,
26
+ revokeAria: (name) => `Revoke ${name}`,
27
+ revokeConfirm: "Revoke?",
28
+ revokeConfirmTitle: (name) => `Click again to revoke ${name}.`,
29
+ pathDirect: "direct",
30
+ pathDirectTitle: "Connected directly, on this app's own MCP URL",
31
+ pathDock: "via dock",
32
+ pathDockTitle: "Came through an agent dock",
33
+ pathBoth: "direct + dock",
34
+ pathBothTitle: "The same agent, connected directly and through a dock. Each way keeps its own access; Revoke cuts both.",
35
+ unlink: "unlink",
36
+ unlinkTitle: (name) => `Show ${name}'s two connections as two agents again. Nothing else changes.`,
37
+ unlinkAria: (name) => `Unlink ${name}`,
38
+ collapseTitle: "Collapse to who is reading. Your agents came through your dock: drag them from there.",
39
+ collapseAria: "Collapse the agent bar",
40
+ reading: (names) => (names.length === 1 ? `${names[0]} is reading` : `${names.slice(0, -1).join(", ")} and ${names.at(-1)} are reading`),
41
+ nobodyReading: "No agent is reading",
42
+ showAgents: (summary) => `${summary}. Show the agents.`,
43
+ readingCount: (count) => `${count} reading`,
44
+ nobodyReadingShort: "no agent reading",
45
+ dockHint: "Use this agent across apps with the dock",
46
+ dockHintDismiss: "Dismiss this hint",
47
+ joinPins: (labels) => labels.join(" and "),
48
+ };
49
+ export const DEFAULT_PIN_STRINGS = {
50
+ title: (name, seen) => `${name}: ${seen ? "read this pin" : "has not read this pin yet"}. Drag to point it at something else.`,
51
+ unpinAria: (name) => `Remove ${name}'s pin`,
52
+ markAria: (name, seen) => `${name}, ${seen ? "has read its pin" : "has not read its pin yet"}`,
53
+ };
54
+ export const DEFAULT_DIRECT_STRINGS = {
55
+ nameLabel: "Agent name",
56
+ namePlaceholder: "Empty: back to its own name",
57
+ save: "Save",
58
+ cancel: "Cancel",
59
+ rename: "Rename",
60
+ hideConnection: "Hide connection",
61
+ showConnection: "Show connection",
62
+ urlHint: "Add this URL to your agent as a remote MCP server (Streamable HTTP). No login: the secret in the URL is its access, so give each agent its own.",
63
+ askHint: "Don't know how? Paste this to your agent:",
64
+ copy: "Copy",
65
+ copyPrompt: "Copy prompt",
66
+ copied: "Copied",
67
+ addAnother: "Add another agent",
68
+ connectAgent: "Connect an agent",
69
+ thisApp: "this app",
70
+ status: (a) => {
71
+ if (!a.connected)
72
+ return "not connected yet";
73
+ const as = a.client ? `connected as ${a.client}` : "connected";
74
+ return a.linked ? `${as}, also through your dock` : as;
75
+ },
76
+ prompt: (url, appName) => `Please connect this remote MCP server, named "${appName}". URL: ${url}. It uses Streamable HTTP and needs no OAuth or API key: the access is in the URL. If you can add it yourself, do it now and tell me when it's connected. If you can't, tell me the exact steps to add it in the app I'm using you in right now. Do not open the URL in a browser.`,
77
+ };
78
+ export const DEFAULT_PINNING_STRINGS = {
79
+ tapPrompt: (name) => `Tap what to point ${name} at.`,
80
+ yourAgent: "your agent",
81
+ cancel: "Cancel",
82
+ keyboardArmed: "Pointing your agent: Tab to an outlined item, then press Enter or Space to pin it. Escape cancels.",
83
+ refusedTap: "This agent cannot point at that. Tap another outlined target, or Cancel.",
84
+ refusedKeys: "This agent cannot point at that. Tab to another outlined item, or press Escape.",
85
+ refused: "This agent cannot point at that. Drop it on another highlighted item.",
86
+ unknownAgent: "This app does not know that agent.",
87
+ thisApp: "This app",
88
+ connectLink: (appName) => `Connect an agent to ${appName}`,
89
+ close: "Close",
90
+ directOnlyNote,
91
+ couldNotCheckNote,
92
+ };
93
+ /** The defaults with the app's overrides on top. A key set to `undefined` keeps its default. */
94
+ export function resolveStrings(defaults, given) {
95
+ const out = { ...defaults };
96
+ if (given)
97
+ for (const key of Object.keys(given))
98
+ if (given[key] !== undefined)
99
+ out[key] = given[key];
100
+ return out;
101
+ }
102
+ /**
103
+ * The locale a page's dates and times are shown in: the one the app names, else the page's own
104
+ * (`<html lang>`), else English. Never the operating system's: a Spanish page on an English machine
105
+ * reads Spanish dates.
106
+ */
107
+ export function pageLocale(explicit) {
108
+ const lang = explicit || (typeof document === "undefined" ? "" : document.documentElement?.lang);
109
+ const tag = (lang ?? "").trim();
110
+ if (!tag)
111
+ return "en";
112
+ try {
113
+ return Intl.getCanonicalLocales(tag)[0] ?? "en";
114
+ }
115
+ catch {
116
+ return "en";
117
+ }
118
+ }
119
+ /** A time or date for the person, in the page's locale (see `pageLocale`). For an app's own labels, such as a pin's "read 12:04". */
120
+ export function formatPageTime(value, options) {
121
+ const { locale, ...given } = options ?? {};
122
+ // No format option at all: a date and a time, in the medium and short styles.
123
+ const format = Object.keys(given).length ? given : { dateStyle: "medium", timeStyle: "short" };
124
+ const date = value instanceof Date ? value : new Date(value);
125
+ return new Intl.DateTimeFormat(pageLocale(locale), format).format(date);
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tineira/apc-web",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Agent Pointed Context (APC) for an app's page, in the browser: pinning agents to what the person sees, the agent bar (<apc-agents>), the pin badge, presence and the Direct agents list.",
5
5
  "keywords": [
6
6
  "apc",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@tineira/apc-protocol": "^0.7.1"
43
+ "@tineira/apc-protocol": "^0.9.0"
44
44
  },
45
45
  "scripts": {},
46
46
  "types": "./dist/index.d.ts"