@tineira/apc-web 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/agents-bar.d.ts +110 -0
- package/dist/agents-bar.js +498 -0
- package/dist/direct-agents.d.ts +39 -0
- package/dist/direct-agents.js +205 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/pin-badge.d.ts +45 -0
- package/dist/pin-badge.js +174 -0
- package/dist/pinning.d.ts +160 -0
- package/dist/pinning.js +598 -0
- package/dist/presence.d.ts +18 -0
- package/dist/presence.js +50 -0
- package/dist/view.d.ts +15 -0
- package/dist/view.js +32 -0
- package/package.json +44 -3
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Direct profile's page part (APC 0.4, TIN-578): the list where the person makes and names the
|
|
3
|
+
* agents that connect to this app directly, each with its own MCP URL, and the connect instructions
|
|
4
|
+
* (the URL, and a prompt to paste to the agent). Revoke is the agent bar's (core). The server side is
|
|
5
|
+
* in @tineira/apc-server (direct.ts).
|
|
6
|
+
*
|
|
7
|
+
* A custom element in light DOM, like `<apc-agents>`: the app's fonts apply, styles are injected once,
|
|
8
|
+
* scoped by class prefix and themed through --apc-* custom properties. Set `agents` and `appName`;
|
|
9
|
+
* listen for `apc-add` (make an agent) and `apc-rename` (detail { id, name }).
|
|
10
|
+
*/
|
|
11
|
+
export interface DirectAgent {
|
|
12
|
+
/** The app's id for the agent's grant. */
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
/** Its MCP URL: the secret in it is the grant. */
|
|
16
|
+
url: string;
|
|
17
|
+
/** It has connected (sent initialize). */
|
|
18
|
+
connected: boolean;
|
|
19
|
+
/** The MCP client it connected with, e.g. "claude-code". */
|
|
20
|
+
client?: string | null;
|
|
21
|
+
/** It is also reached through a dock, shown as one agent (apc_link). */
|
|
22
|
+
linked?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export type DirectAgentsEvent = "apc-add" | "apc-rename";
|
|
25
|
+
/**
|
|
26
|
+
* What a person pastes to their own agent to connect it. Many agents can add a server themselves when
|
|
27
|
+
* asked in the chat, so it asks for that first, under a name the person will recognise, and for the
|
|
28
|
+
* steps only if the agent cannot.
|
|
29
|
+
*/
|
|
30
|
+
export declare function connectPrompt(url: string, appName: string): string;
|
|
31
|
+
/** The words of one agent's line. Pure, so it is tested. */
|
|
32
|
+
export declare function directAgentStatus(a: DirectAgent): string;
|
|
33
|
+
/** Defines `<apc-direct-agents>` where custom elements exist (a browser), so importing this module in Node is safe. */
|
|
34
|
+
export declare function defineDirectAgents(tag?: string): void;
|
|
35
|
+
/** The element's type, for apps that hold a reference to it. */
|
|
36
|
+
export interface ApcDirectAgentsElement extends HTMLElement {
|
|
37
|
+
agents: DirectAgent[];
|
|
38
|
+
appName: string;
|
|
39
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Direct profile's page part (APC 0.4, TIN-578): the list where the person makes and names the
|
|
3
|
+
* agents that connect to this app directly, each with its own MCP URL, and the connect instructions
|
|
4
|
+
* (the URL, and a prompt to paste to the agent). Revoke is the agent bar's (core). The server side is
|
|
5
|
+
* in @tineira/apc-server (direct.ts).
|
|
6
|
+
*
|
|
7
|
+
* A custom element in light DOM, like `<apc-agents>`: the app's fonts apply, styles are injected once,
|
|
8
|
+
* scoped by class prefix and themed through --apc-* custom properties. Set `agents` and `appName`;
|
|
9
|
+
* listen for `apc-add` (make an agent) and `apc-rename` (detail { id, name }).
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* What a person pastes to their own agent to connect it. Many agents can add a server themselves when
|
|
13
|
+
* asked in the chat, so it asks for that first, under a name the person will recognise, and for the
|
|
14
|
+
* steps only if the agent cannot.
|
|
15
|
+
*/
|
|
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.`;
|
|
18
|
+
}
|
|
19
|
+
/** 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;
|
|
25
|
+
}
|
|
26
|
+
const STYLE_ID = "apc-direct-style";
|
|
27
|
+
const CSS = `
|
|
28
|
+
apc-direct-agents { display: flex; flex-direction: column; gap: 10px; color: var(--apc-ink, inherit); }
|
|
29
|
+
.apc-direct__agent { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border: 1px solid var(--apc-line, #d6dce5); border-radius: 10px; }
|
|
30
|
+
.apc-direct__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }
|
|
31
|
+
.apc-direct__name { font-weight: 600; }
|
|
32
|
+
.apc-direct__status { color: var(--apc-muted, #5b6575); font-size: 0.9em; }
|
|
33
|
+
.apc-direct__link { border: 0; background: none; padding: 0; font: inherit; font-size: 0.9em; color: var(--apc-accent, #b85209); cursor: pointer; }
|
|
34
|
+
.apc-direct__link:hover { text-decoration: underline; }
|
|
35
|
+
.apc-direct__body { display: flex; flex-direction: column; gap: 6px; }
|
|
36
|
+
.apc-direct__hint { margin: 0; color: var(--apc-muted, #5b6575); font-size: 0.85em; }
|
|
37
|
+
.apc-direct__copy { display: flex; min-width: 0; align-items: stretch; border: 1px solid var(--apc-line, #d6dce5); border-radius: 8px; overflow: hidden; }
|
|
38
|
+
.apc-direct__copy code { flex: 1; min-width: 0; padding: 6px 8px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
39
|
+
.apc-direct__copy button { flex: none; border: 0; border-left: 1px solid var(--apc-line, #d6dce5); background: none; padding: 0 10px; font: inherit; font-size: 0.85em; font-weight: 600; color: var(--apc-accent, #b85209); cursor: pointer; }
|
|
40
|
+
.apc-direct__rename { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
41
|
+
.apc-direct__rename input { flex: 1; min-width: 8em; padding: 4px 8px; border: 1px solid var(--apc-line, #d6dce5); border-radius: 6px; background: transparent; color: inherit; font: inherit; }
|
|
42
|
+
.apc-direct__rename button, .apc-direct__add { padding: 5px 12px; border: 1px solid var(--apc-line, #d6dce5); border-radius: 8px; background: var(--apc-chip, transparent); color: inherit; font: inherit; font-weight: 600; cursor: pointer; }
|
|
43
|
+
.apc-direct__add { align-self: flex-start; }
|
|
44
|
+
.apc-direct__add:hover, .apc-direct__rename button:hover { border-color: var(--apc-accent, #b85209); }
|
|
45
|
+
`;
|
|
46
|
+
function copyField(text, label) {
|
|
47
|
+
const box = document.createElement("div");
|
|
48
|
+
box.className = "apc-direct__copy";
|
|
49
|
+
const code = document.createElement("code");
|
|
50
|
+
code.textContent = text;
|
|
51
|
+
const button = document.createElement("button");
|
|
52
|
+
button.type = "button";
|
|
53
|
+
button.textContent = label;
|
|
54
|
+
button.addEventListener("click", () => {
|
|
55
|
+
void navigator.clipboard?.writeText(text).then(() => {
|
|
56
|
+
button.textContent = "Copied";
|
|
57
|
+
setTimeout(() => (button.textContent = label), 1500);
|
|
58
|
+
}, () => undefined);
|
|
59
|
+
});
|
|
60
|
+
box.append(code, button);
|
|
61
|
+
return box;
|
|
62
|
+
}
|
|
63
|
+
/** Defines `<apc-direct-agents>` where custom elements exist (a browser), so importing this module in Node is safe. */
|
|
64
|
+
export function defineDirectAgents(tag = "apc-direct-agents") {
|
|
65
|
+
if (typeof customElements === "undefined" || customElements.get(tag))
|
|
66
|
+
return;
|
|
67
|
+
class ApcDirectAgents extends HTMLElement {
|
|
68
|
+
list = [];
|
|
69
|
+
name = "this app";
|
|
70
|
+
/** Agents whose connection is unfolded; an agent not connected yet is unfolded until it connects. */
|
|
71
|
+
open = new Set();
|
|
72
|
+
shut = new Set();
|
|
73
|
+
editing = null;
|
|
74
|
+
draft = "";
|
|
75
|
+
get agents() {
|
|
76
|
+
return this.list;
|
|
77
|
+
}
|
|
78
|
+
set agents(next) {
|
|
79
|
+
this.list = next;
|
|
80
|
+
this.render();
|
|
81
|
+
}
|
|
82
|
+
get appName() {
|
|
83
|
+
return this.name;
|
|
84
|
+
}
|
|
85
|
+
set appName(next) {
|
|
86
|
+
this.name = next;
|
|
87
|
+
this.render();
|
|
88
|
+
}
|
|
89
|
+
connectedCallback() {
|
|
90
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
91
|
+
const style = document.createElement("style");
|
|
92
|
+
style.id = STYLE_ID;
|
|
93
|
+
style.textContent = CSS;
|
|
94
|
+
document.head.append(style);
|
|
95
|
+
}
|
|
96
|
+
this.render();
|
|
97
|
+
}
|
|
98
|
+
emit(type, detail = {}) {
|
|
99
|
+
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
|
|
100
|
+
}
|
|
101
|
+
isOpen(a) {
|
|
102
|
+
return this.open.has(a.id) || (!a.connected && !this.shut.has(a.id));
|
|
103
|
+
}
|
|
104
|
+
render() {
|
|
105
|
+
if (!this.isConnected)
|
|
106
|
+
return;
|
|
107
|
+
const rows = this.list.map((a) => {
|
|
108
|
+
const row = document.createElement("div");
|
|
109
|
+
row.className = "apc-direct__agent";
|
|
110
|
+
const head = document.createElement("div");
|
|
111
|
+
head.className = "apc-direct__head";
|
|
112
|
+
if (this.editing === a.id) {
|
|
113
|
+
const form = document.createElement("form");
|
|
114
|
+
form.className = "apc-direct__rename";
|
|
115
|
+
// The app re-renders on every update: what the person typed is kept in draft.
|
|
116
|
+
const input = document.createElement("input");
|
|
117
|
+
input.value = this.draft;
|
|
118
|
+
input.addEventListener("input", () => (this.draft = input.value));
|
|
119
|
+
input.maxLength = 40;
|
|
120
|
+
input.setAttribute("aria-label", "Agent name");
|
|
121
|
+
input.placeholder = "Empty: back to its own name";
|
|
122
|
+
const save = document.createElement("button");
|
|
123
|
+
save.type = "submit";
|
|
124
|
+
save.textContent = "Save";
|
|
125
|
+
const cancel = document.createElement("button");
|
|
126
|
+
cancel.type = "button";
|
|
127
|
+
cancel.textContent = "Cancel";
|
|
128
|
+
cancel.addEventListener("click", () => {
|
|
129
|
+
this.editing = null;
|
|
130
|
+
this.render();
|
|
131
|
+
});
|
|
132
|
+
input.addEventListener("keydown", (e) => e.key === "Escape" && cancel.click());
|
|
133
|
+
form.addEventListener("submit", (e) => {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
this.editing = null;
|
|
136
|
+
this.emit("apc-rename", { id: a.id, name: this.draft });
|
|
137
|
+
this.render();
|
|
138
|
+
});
|
|
139
|
+
form.append(input, save, cancel);
|
|
140
|
+
head.append(form);
|
|
141
|
+
queueMicrotask(() => {
|
|
142
|
+
input.focus();
|
|
143
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const name = document.createElement("span");
|
|
148
|
+
name.className = "apc-direct__name";
|
|
149
|
+
name.textContent = a.name;
|
|
150
|
+
const status = document.createElement("span");
|
|
151
|
+
status.className = "apc-direct__status";
|
|
152
|
+
status.textContent = directAgentStatus(a);
|
|
153
|
+
const rename = document.createElement("button");
|
|
154
|
+
rename.type = "button";
|
|
155
|
+
rename.className = "apc-direct__link";
|
|
156
|
+
rename.textContent = "Rename";
|
|
157
|
+
rename.addEventListener("click", () => {
|
|
158
|
+
this.editing = a.id;
|
|
159
|
+
this.draft = a.name;
|
|
160
|
+
this.render();
|
|
161
|
+
});
|
|
162
|
+
const toggle = document.createElement("button");
|
|
163
|
+
toggle.type = "button";
|
|
164
|
+
toggle.className = "apc-direct__link";
|
|
165
|
+
const open = this.isOpen(a);
|
|
166
|
+
toggle.textContent = open ? "Hide connection" : "Show connection";
|
|
167
|
+
toggle.setAttribute("aria-expanded", String(open));
|
|
168
|
+
toggle.addEventListener("click", () => {
|
|
169
|
+
if (open) {
|
|
170
|
+
this.open.delete(a.id);
|
|
171
|
+
this.shut.add(a.id);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
this.open.add(a.id);
|
|
175
|
+
this.shut.delete(a.id);
|
|
176
|
+
}
|
|
177
|
+
this.render();
|
|
178
|
+
});
|
|
179
|
+
head.append(name, status, rename, toggle);
|
|
180
|
+
}
|
|
181
|
+
row.append(head);
|
|
182
|
+
if (this.isOpen(a)) {
|
|
183
|
+
const body = document.createElement("div");
|
|
184
|
+
body.className = "apc-direct__body";
|
|
185
|
+
const urlHint = document.createElement("p");
|
|
186
|
+
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.";
|
|
188
|
+
const askHint = document.createElement("p");
|
|
189
|
+
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"));
|
|
192
|
+
row.append(body);
|
|
193
|
+
}
|
|
194
|
+
return row;
|
|
195
|
+
});
|
|
196
|
+
const add = document.createElement("button");
|
|
197
|
+
add.type = "button";
|
|
198
|
+
add.className = "apc-direct__add";
|
|
199
|
+
add.textContent = this.list.length ? "Add another agent" : "Connect an agent";
|
|
200
|
+
add.addEventListener("click", () => this.emit("apc-add"));
|
|
201
|
+
this.replaceChildren(...rows, add);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
customElements.define(tag, ApcDirectAgents);
|
|
205
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pin badge (TIN-482): the standard mark an APC app shows where an agent is pinned. It is the
|
|
3
|
+
* agent's logo in a square, a dashed breathing ring while the agent has not read the pin, a check
|
|
4
|
+
* once it has, × to remove the pin, and the agent's name as tooltip. The agent bar (agents-bar.ts)
|
|
5
|
+
* is where the agents are; this is where one points, and it works the same in every APC app.
|
|
6
|
+
*
|
|
7
|
+
* The badge is a mark: it carries data-apc-mark and data-grant, the hooks enablePinning drags by, so
|
|
8
|
+
* dragging it onto another target moves the pin with no code in the app. While it is dragged it
|
|
9
|
+
* dims, until the drop or Esc. Where the badge goes is the app's: Pulse floats badges over their
|
|
10
|
+
* targets, Signals puts them inline next to a post part.
|
|
11
|
+
*
|
|
12
|
+
* A custom element in light DOM, like <apc-agents>: enablePinning finds marks by event target, and
|
|
13
|
+
* the app's fonts and theme apply. Its styles are injected once and scoped by class prefix; the app
|
|
14
|
+
* themes it through --apc-* custom properties (--apc-chip, --apc-line, --apc-ink, --apc-muted, --apc-accent, --apc-ok, and --apc-pin-font for
|
|
15
|
+
* the check). Framework-agnostic: set `agent`, listen for apc-unpin.
|
|
16
|
+
*
|
|
17
|
+
* The browser extension keeps a copy of this badge's look (apps/extension/src/content.ts): a content
|
|
18
|
+
* script has no customElements, and it moves the badge with its own drag, since a moved reading is
|
|
19
|
+
* sent again from the page. Keep the two in step.
|
|
20
|
+
*/
|
|
21
|
+
export interface PinAgent {
|
|
22
|
+
/** The app's id for the agent (its grant): what the mark drags as. */
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
/** An image source for the agent's mark (a data URI or URL), unless the app draws it (renderIcon). */
|
|
26
|
+
icon?: string | null;
|
|
27
|
+
/** Whether the agent has read the pin: a check instead of the dashed ring. */
|
|
28
|
+
seen: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Event, with detail { id }: the person removed the pin. */
|
|
31
|
+
export type PinBadgeEvent = "apc-unpin";
|
|
32
|
+
/** 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
|
+
title: string;
|
|
35
|
+
unpin: string;
|
|
36
|
+
};
|
|
37
|
+
/** The element's constructor, defined only where custom elements exist (a browser), so importing this module in Node is safe. */
|
|
38
|
+
export declare function definePinBadge(tag?: string): void;
|
|
39
|
+
/** The element's type, for apps that hold a reference to it. */
|
|
40
|
+
export interface ApcPinElement extends HTMLElement {
|
|
41
|
+
agent: PinAgent | null;
|
|
42
|
+
readonly mark: HTMLElement | null;
|
|
43
|
+
readonly iconHost: HTMLElement | null;
|
|
44
|
+
renderIcon: ((agent: PinAgent, into: HTMLElement) => void) | null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pin badge (TIN-482): the standard mark an APC app shows where an agent is pinned. It is the
|
|
3
|
+
* agent's logo in a square, a dashed breathing ring while the agent has not read the pin, a check
|
|
4
|
+
* once it has, × to remove the pin, and the agent's name as tooltip. The agent bar (agents-bar.ts)
|
|
5
|
+
* is where the agents are; this is where one points, and it works the same in every APC app.
|
|
6
|
+
*
|
|
7
|
+
* The badge is a mark: it carries data-apc-mark and data-grant, the hooks enablePinning drags by, so
|
|
8
|
+
* dragging it onto another target moves the pin with no code in the app. While it is dragged it
|
|
9
|
+
* dims, until the drop or Esc. Where the badge goes is the app's: Pulse floats badges over their
|
|
10
|
+
* targets, Signals puts them inline next to a post part.
|
|
11
|
+
*
|
|
12
|
+
* A custom element in light DOM, like <apc-agents>: enablePinning finds marks by event target, and
|
|
13
|
+
* the app's fonts and theme apply. Its styles are injected once and scoped by class prefix; the app
|
|
14
|
+
* themes it through --apc-* custom properties (--apc-chip, --apc-line, --apc-ink, --apc-muted, --apc-accent, --apc-ok, and --apc-pin-font for
|
|
15
|
+
* the check). Framework-agnostic: set `agent`, listen for apc-unpin.
|
|
16
|
+
*
|
|
17
|
+
* The browser extension keeps a copy of this badge's look (apps/extension/src/content.ts): a content
|
|
18
|
+
* script has no customElements, and it moves the badge with its own drag, since a moved reading is
|
|
19
|
+
* sent again from the page. Keep the two in step.
|
|
20
|
+
*/
|
|
21
|
+
const STYLE_ID = "apc-pin-style";
|
|
22
|
+
const CSS = `
|
|
23
|
+
apc-pin { position: relative; display: inline-block; width: var(--apc-pin-size, 28px); height: var(--apc-pin-size, 28px); vertical-align: middle; }
|
|
24
|
+
.apc-pin__mark { display: grid; place-items: center; width: var(--apc-pin-size, 28px); height: var(--apc-pin-size, 28px); border-radius: 6px; cursor: grab; touch-action: none; outline-offset: 2px;
|
|
25
|
+
background: var(--apc-chip, #ffffff); color: var(--apc-ink, #141a24); box-shadow: 0 2px 6px rgb(0 0 0 / 0.18); }
|
|
26
|
+
.apc-pin__icon { display: grid; place-items: center; }
|
|
27
|
+
.apc-pin__mark .apc-pin__img { width: 18px; height: 18px; display: block; object-fit: contain; pointer-events: none; }
|
|
28
|
+
apc-pin[data-apc-pin-state="waiting"] .apc-pin__mark { outline: 2px dashed var(--apc-accent, #b85209); animation: apc-pin-breathe 1.8s ease-in-out infinite; }
|
|
29
|
+
apc-pin.apc-pin--dragging { opacity: 0.35; }
|
|
30
|
+
.apc-pin__check { position: absolute; right: -7px; bottom: -7px; width: 15px; height: 15px; border-radius: 50%; background: var(--apc-ok, #1f7a4d); color: #fff;
|
|
31
|
+
font: 700 10px/15px var(--apc-pin-font, ui-monospace, monospace); text-align: center; }
|
|
32
|
+
.apc-pin__check[hidden] { display: none; }
|
|
33
|
+
.apc-pin__unpin { position: absolute; right: -8px; top: -8px; width: 16px; height: 16px; padding: 0; border-radius: 50%; border: 1px solid var(--apc-line, #d6dce5);
|
|
34
|
+
background: var(--apc-pin-unpin-bg, var(--apc-chip, #ffffff)); color: var(--apc-muted, #56627a); font-size: 12px; line-height: 13px; cursor: pointer; opacity: 0.85; }
|
|
35
|
+
.apc-pin__unpin:hover { color: var(--apc-ink, #141a24); opacity: 1; }
|
|
36
|
+
@media (pointer: coarse), (max-width: 640px) { .apc-pin__unpin::after { content: ""; position: absolute; inset: -14px; } }
|
|
37
|
+
@keyframes apc-pin-breathe { 0%, 100% { outline-offset: 2px; } 50% { outline-offset: 4px; } }
|
|
38
|
+
@media (prefers-reduced-motion: reduce) { apc-pin .apc-pin__mark { animation: none !important; } }
|
|
39
|
+
`;
|
|
40
|
+
/** A small robot, for an agent with no icon. */
|
|
41
|
+
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
|
+
/** 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
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** The element's constructor, defined only where custom elements exist (a browser), so importing this module in Node is safe. */
|
|
50
|
+
export function definePinBadge(tag = "apc-pin") {
|
|
51
|
+
// Null in a content script, where there are no custom elements.
|
|
52
|
+
if (!globalThis.customElements || customElements.get(tag))
|
|
53
|
+
return;
|
|
54
|
+
class ApcPin extends HTMLElement {
|
|
55
|
+
value = null;
|
|
56
|
+
parts = null;
|
|
57
|
+
iconKey = "";
|
|
58
|
+
stopDim = null;
|
|
59
|
+
draw = null;
|
|
60
|
+
/**
|
|
61
|
+
* The app draws the agent's icon into this element itself (for example with its own logos). Called on every update.
|
|
62
|
+
* A function that draws nothing says the app draws it elsewhere, into `iconHost` (React: a portal).
|
|
63
|
+
*/
|
|
64
|
+
get renderIcon() {
|
|
65
|
+
return this.draw;
|
|
66
|
+
}
|
|
67
|
+
set renderIcon(next) {
|
|
68
|
+
this.draw = next;
|
|
69
|
+
this.render();
|
|
70
|
+
}
|
|
71
|
+
/** Where the agent's icon goes: the app may draw into it once the badge is connected. */
|
|
72
|
+
get iconHost() {
|
|
73
|
+
return this.parts?.icon ?? null;
|
|
74
|
+
}
|
|
75
|
+
get agent() {
|
|
76
|
+
return this.value;
|
|
77
|
+
}
|
|
78
|
+
set agent(next) {
|
|
79
|
+
this.value = next;
|
|
80
|
+
this.render();
|
|
81
|
+
}
|
|
82
|
+
/** The mark inside: what a drag starts from. */
|
|
83
|
+
get mark() {
|
|
84
|
+
return this.parts?.mark ?? null;
|
|
85
|
+
}
|
|
86
|
+
connectedCallback() {
|
|
87
|
+
// A property set before the element was defined (a framework, a script) sits on the instance: take it in through the setter.
|
|
88
|
+
const own = this;
|
|
89
|
+
for (const key of ["agent", "renderIcon"]) {
|
|
90
|
+
if (!Object.hasOwn(own, key))
|
|
91
|
+
continue;
|
|
92
|
+
const value = own[key];
|
|
93
|
+
delete own[key];
|
|
94
|
+
own[key] = value;
|
|
95
|
+
}
|
|
96
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
97
|
+
const style = document.createElement("style");
|
|
98
|
+
style.id = STYLE_ID;
|
|
99
|
+
style.textContent = CSS;
|
|
100
|
+
document.head.append(style);
|
|
101
|
+
}
|
|
102
|
+
if (!this.parts) {
|
|
103
|
+
const mark = document.createElement("span");
|
|
104
|
+
mark.className = "apc-pin__mark";
|
|
105
|
+
mark.setAttribute("data-apc-mark", "");
|
|
106
|
+
const icon = document.createElement("span");
|
|
107
|
+
icon.className = "apc-pin__icon";
|
|
108
|
+
mark.append(icon);
|
|
109
|
+
const check = document.createElement("span");
|
|
110
|
+
check.className = "apc-pin__check";
|
|
111
|
+
check.textContent = "✓";
|
|
112
|
+
check.setAttribute("aria-hidden", "true");
|
|
113
|
+
const unpin = document.createElement("button");
|
|
114
|
+
unpin.type = "button";
|
|
115
|
+
unpin.className = "apc-pin__unpin";
|
|
116
|
+
unpin.textContent = "×";
|
|
117
|
+
unpin.addEventListener("click", (e) => e.isTrusted && this.value && this.dispatchEvent(new CustomEvent("apc-unpin", { detail: { id: this.value.id }, bubbles: true })));
|
|
118
|
+
// The badge dims from the press until the drop or Esc (the drag itself is enablePinning's).
|
|
119
|
+
mark.addEventListener("pointerdown", (e) => e.button === 0 && this.dim());
|
|
120
|
+
this.parts = { mark, icon, check, unpin };
|
|
121
|
+
this.append(mark, check, unpin);
|
|
122
|
+
}
|
|
123
|
+
this.render();
|
|
124
|
+
}
|
|
125
|
+
disconnectedCallback() {
|
|
126
|
+
this.stopDim?.();
|
|
127
|
+
}
|
|
128
|
+
dim() {
|
|
129
|
+
this.stopDim?.();
|
|
130
|
+
this.classList.add("apc-pin--dragging");
|
|
131
|
+
const stop = () => this.stopDim?.();
|
|
132
|
+
const onKey = (e) => e.key === "Escape" && stop();
|
|
133
|
+
window.addEventListener("pointerup", stop);
|
|
134
|
+
window.addEventListener("pointercancel", stop);
|
|
135
|
+
window.addEventListener("keydown", onKey);
|
|
136
|
+
this.stopDim = () => {
|
|
137
|
+
window.removeEventListener("pointerup", stop);
|
|
138
|
+
window.removeEventListener("pointercancel", stop);
|
|
139
|
+
window.removeEventListener("keydown", onKey);
|
|
140
|
+
this.classList.remove("apc-pin--dragging");
|
|
141
|
+
this.stopDim = null;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
render() {
|
|
145
|
+
const a = this.value;
|
|
146
|
+
if (!this.parts || !a)
|
|
147
|
+
return;
|
|
148
|
+
const { mark, icon, check, unpin } = this.parts;
|
|
149
|
+
const labels = pinBadgeLabels(a);
|
|
150
|
+
this.setAttribute("title", labels.title);
|
|
151
|
+
this.setAttribute("data-apc-pin-state", a.seen ? "seen" : "waiting");
|
|
152
|
+
mark.setAttribute("data-grant", a.id);
|
|
153
|
+
mark.setAttribute("role", "img");
|
|
154
|
+
mark.setAttribute("aria-label", `${a.name}, ${a.seen ? "has read its pin" : "has not read its pin yet"}`);
|
|
155
|
+
check.hidden = !a.seen;
|
|
156
|
+
unpin.setAttribute("aria-label", labels.unpin);
|
|
157
|
+
if (this.draw)
|
|
158
|
+
this.draw(a, icon);
|
|
159
|
+
else if (this.iconKey !== JSON.stringify(a.icon ?? null)) {
|
|
160
|
+
this.iconKey = JSON.stringify(a.icon ?? null);
|
|
161
|
+
if (a.icon) {
|
|
162
|
+
const img = document.createElement("img");
|
|
163
|
+
img.className = "apc-pin__img";
|
|
164
|
+
img.src = a.icon;
|
|
165
|
+
img.alt = a.name;
|
|
166
|
+
icon.replaceChildren(img);
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
icon.innerHTML = BOT;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
customElements.define(tag, ApcPin);
|
|
174
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { type ApcRole, type FocusTarget } from "@tineira/apc-protocol";
|
|
2
|
+
/** How long the page waits for its own apc.json: a held drop never waits longer. */
|
|
3
|
+
export declare const APC_JSON_LOAD_MS = 3000;
|
|
4
|
+
/**
|
|
5
|
+
* The profiles the page's own app offers, from its apc.json (APC 0.5), for `enablePinning({ profiles })`:
|
|
6
|
+
* read without cookies, with no redirect followed, within APC_JSON_LOAD_MS for headers and body, and at
|
|
7
|
+
* most APC_JSON_MAX_BYTES, as the hub and the extension read it. Kept per origin once read; null when
|
|
8
|
+
* there is no valid file in time, and then read again on the next call.
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadApcProfiles(origin?: string, fetcher?: typeof fetch): Promise<ApcRole[] | null>;
|
|
11
|
+
export interface PinningOptions {
|
|
12
|
+
/** Where marks and targets live; defaults to document. */
|
|
13
|
+
root?: Document | HTMLElement;
|
|
14
|
+
/** Selector of the draggable agent marks; each carries data-grant. */
|
|
15
|
+
markSelector?: string;
|
|
16
|
+
/** Refuse a target for one agent (for example outside a restricted grant). */
|
|
17
|
+
canTarget?: (grantId: string, target: FocusTarget) => boolean;
|
|
18
|
+
onPin: (grantId: string, target: FocusTarget, element: Element) => void;
|
|
19
|
+
/**
|
|
20
|
+
* APC v2, opt-in (TIN-369): also accept a native HTML5 drop of an agent dragged from another
|
|
21
|
+
* window (a dock), whose dataTransfer carries APC_AGENT_TYPE. Off by default: v1 apps do not turn it on.
|
|
22
|
+
*/
|
|
23
|
+
acceptNativeDrops?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The grant a dropped agent's public handle names in this app, or null when the app does not know
|
|
26
|
+
* it (the drop is then ignored). Defaults to the handle itself, taken as a grant id.
|
|
27
|
+
*/
|
|
28
|
+
resolveAgent?: (handle: string) => string | null;
|
|
29
|
+
/**
|
|
30
|
+
* APC v2, opt-in (TIN-382): a dropped agent this app does not know yet, whose drag names its hub
|
|
31
|
+
* and carries a pairing ticket. The app asks the person, then pairs with the hub. Without it the drop is ignored.
|
|
32
|
+
*/
|
|
33
|
+
onUnknownAgent?: (drag: AgentDrag & {
|
|
34
|
+
hub: string;
|
|
35
|
+
ticket: string;
|
|
36
|
+
}, target: FocusTarget, element: Element) => void;
|
|
37
|
+
/**
|
|
38
|
+
* APC 0.5: the profiles this app offers, as its apc.json says: a list; a promise of one; or a loader,
|
|
39
|
+
* such as `loadApcProfiles` itself, called now and again on a later dock drop if it gave nothing. An app
|
|
40
|
+
* without "dock" still takes the drop of an agent dragged from a dock, anywhere on the page and with or
|
|
41
|
+
* without acceptNativeDrops, and answers it with `onDockUnsupported` instead of `onUnknownAgent`: never
|
|
42
|
+
* a drop that silently does nothing. While the profiles load, a dock agent's drop is taken and held,
|
|
43
|
+
* then answered or paired as they say. When they cannot be read (null, an error, the time limit), the
|
|
44
|
+
* drop is never sent to pairing: it is answered as `unchecked` ("could not check this app").
|
|
45
|
+
*/
|
|
46
|
+
profiles?: readonly ApcRole[] | PromiseLike<readonly ApcRole[] | null> | (() => PromiseLike<readonly ApcRole[] | null>);
|
|
47
|
+
/** For that answer: the app's name and the page where the person connects an agent directly (apc.json's `direct.connect`). */
|
|
48
|
+
direct?: {
|
|
49
|
+
name: string;
|
|
50
|
+
connect: string | null;
|
|
51
|
+
};
|
|
52
|
+
/** A dock agent dropped on an app that does not offer Dock. Without it, `showDockUnsupported` tells the person in the page. */
|
|
53
|
+
onDockUnsupported?: (answer: DockUnsupported) => void;
|
|
54
|
+
/**
|
|
55
|
+
* Which events may arm, tap, pin or drag (APC 0.6). By default only the person's own: `event.isTrusted`,
|
|
56
|
+
* so a script in the page (`element.click()`, a dispatched PointerEvent) cannot point an agent at data.
|
|
57
|
+
* A test that sends events of its own passes `() => true`; nothing in a page can reach this.
|
|
58
|
+
*/
|
|
59
|
+
trusted?: (event: Event) => boolean;
|
|
60
|
+
}
|
|
61
|
+
/** 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
|
+
export interface DockUnsupported {
|
|
63
|
+
drag: AgentDrag;
|
|
64
|
+
/** The target under the drop, if any. */
|
|
65
|
+
target: FocusTarget | null;
|
|
66
|
+
name: string;
|
|
67
|
+
connect: string | null;
|
|
68
|
+
/**
|
|
69
|
+
* `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.
|
|
71
|
+
*/
|
|
72
|
+
reason: "direct_only" | "unchecked";
|
|
73
|
+
/** `directOnlyNote(name)`, or `couldNotCheckNote(name)`: the app's own words, on its own page. */
|
|
74
|
+
message: string;
|
|
75
|
+
}
|
|
76
|
+
export declare function showDockUnsupported(answer: DockUnsupported): HTMLElement;
|
|
77
|
+
/** 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
|
+
export interface AgentDrag {
|
|
79
|
+
v: 1;
|
|
80
|
+
agent: string;
|
|
81
|
+
hub?: string;
|
|
82
|
+
ticket?: string;
|
|
83
|
+
}
|
|
84
|
+
/** The drag type a dock puts on an agent: its payload is agentDragPayload(handle), never a secret. */
|
|
85
|
+
export declare const APC_AGENT_TYPE = "application/x-apc-agent";
|
|
86
|
+
/**
|
|
87
|
+
* Set on the page's root element while enablePinning takes native agent drops (APC 0.5): the page
|
|
88
|
+
* answers every drop of a dock agent itself, so the browser extension stands back even on a
|
|
89
|
+
* Direct-only app. A Direct-only app without it gets the extension's answer instead.
|
|
90
|
+
*/
|
|
91
|
+
export declare const APC_DROPS_ATTR = "data-apc-native-drops";
|
|
92
|
+
/**
|
|
93
|
+
* What an agent's drag carries: a public handle and nothing else. Any page the agent is dropped on
|
|
94
|
+
* can read it, so it must never hold a grant secret or an MCP URL.
|
|
95
|
+
*/
|
|
96
|
+
export declare function agentDragPayload(handle: string, pairing?: {
|
|
97
|
+
hub: string;
|
|
98
|
+
ticket: string;
|
|
99
|
+
}): string;
|
|
100
|
+
/**
|
|
101
|
+
* A drag payload, or null when it is not one (another app's data, a wrong version, junk). A hub
|
|
102
|
+
* that is not a bare http(s) origin, or a ticket that is not a short token, is dropped, not the drag.
|
|
103
|
+
*/
|
|
104
|
+
export declare function parseAgentDrag(text: string): AgentDrag | null;
|
|
105
|
+
/** The handle in a drag payload, or null when it is not one. */
|
|
106
|
+
export declare function parseAgentDragPayload(text: string): string | null;
|
|
107
|
+
/** The innermost pointable element at a point, with its parsed target. */
|
|
108
|
+
export declare function targetAt(x: number, y: number): {
|
|
109
|
+
element: Element;
|
|
110
|
+
target: FocusTarget;
|
|
111
|
+
} | null;
|
|
112
|
+
/** The innermost pointable element at or above a node, with its parsed target. */
|
|
113
|
+
export declare function targetOf(start: Element | null): {
|
|
114
|
+
element: Element;
|
|
115
|
+
target: FocusTarget;
|
|
116
|
+
} | null;
|
|
117
|
+
/**
|
|
118
|
+
* Tap to pin (APC 0.6, the touch way): the person taps an agent's mark (the agent is then "armed"),
|
|
119
|
+
* then taps a target. Pure, so it is tested: it holds which agent is armed and what a tap on a target
|
|
120
|
+
* does. A second tap on the armed mark, or cancel, lets go; a tap that is no target, or a target the
|
|
121
|
+
* agent may not point at, keeps the agent armed.
|
|
122
|
+
*/
|
|
123
|
+
export type TapPinResult = "pinned" | "no_target" | "refused" | "idle";
|
|
124
|
+
export interface TapPin {
|
|
125
|
+
/** The armed agent's grant id, or null. */
|
|
126
|
+
readonly armed: string | null;
|
|
127
|
+
/** Arm this agent; the armed one again lets go. Returns the armed agent after. */
|
|
128
|
+
toggle(grantId: string): string | null;
|
|
129
|
+
cancel(): void;
|
|
130
|
+
/** A tap, with the target under it (or null). Pins the armed agent there. */
|
|
131
|
+
tap(hit: {
|
|
132
|
+
element: Element;
|
|
133
|
+
target: FocusTarget;
|
|
134
|
+
} | null): TapPinResult;
|
|
135
|
+
}
|
|
136
|
+
export declare function createTapPin(options: {
|
|
137
|
+
canTarget?: PinningOptions["canTarget"];
|
|
138
|
+
onPin: PinningOptions["onPin"];
|
|
139
|
+
onChange?: (armed: string | null) => void;
|
|
140
|
+
}): TapPin;
|
|
141
|
+
/** The window event fired when an agent is armed or let go (detail.id: the grant id or null): the agent bar marks the armed mark. */
|
|
142
|
+
export declare const APC_ARMED_EVENT = "apc-armed";
|
|
143
|
+
/** Less than this many pixels between a touch's down and up is a tap, not a drag. */
|
|
144
|
+
export declare const TAP_SLOP_PX = 10;
|
|
145
|
+
/**
|
|
146
|
+
* Drag an agent's mark onto anything that carries data-focus. While dragging,
|
|
147
|
+
* the body has class apc-dragging (valid targets can outline themselves) and
|
|
148
|
+
* the target under the pointer has class apc-target. Escape cancels.
|
|
149
|
+
*
|
|
150
|
+
* Touch (APC 0.6): a finger can drag a mark the same way (pointer events), and a plain tap on a mark
|
|
151
|
+
* (a touch or pen press that moves under TAP_SLOP_PX, or a keyboard activation) arms the agent
|
|
152
|
+
* instead: "tap to pin". The body gets class apc-armed (and apc-dragging), every target is outlined,
|
|
153
|
+
* a note at the bottom of the page says what to do and has Cancel, and the next tap on a target pins
|
|
154
|
+
* the agent there (the click that follows is swallowed, so the target's own action does not run).
|
|
155
|
+
* Tapping the mark again, Cancel or Escape lets go. A mouse click on a mark does nothing, as before.
|
|
156
|
+
* A pin badge (<apc-pin>) can be dragged by touch but does not arm: it is where an agent points,
|
|
157
|
+
* not where agents are.
|
|
158
|
+
* Returns a function that removes the listeners.
|
|
159
|
+
*/
|
|
160
|
+
export declare function enablePinning(options: PinningOptions): () => void;
|